diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml index 7bd48e8270a..49e7cfd65eb 100644 --- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml +++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml @@ -144,6 +144,7 @@ on: permissions: contents: read + pull-requests: read env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" @@ -151,7 +152,63 @@ env: PNPM_VERSION: "10.32.1" jobs: + validate_selected_ref: + runs-on: blacksmith-8vcpu-ubuntu-2404 + outputs: + selected_sha: ${{ steps.validate.outputs.selected_sha }} + trusted_reason: ${{ steps.validate.outputs.trusted_reason }} + steps: + - name: Checkout selected ref + uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref }} + fetch-depth: 0 + + - name: Validate selected ref + id: validate + env: + GH_TOKEN: ${{ github.token }} + INPUT_REF: ${{ inputs.ref }} + shell: bash + run: | + set -euo pipefail + selected_sha="$(git rev-parse HEAD)" + trusted_reason="" + + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + + if git merge-base --is-ancestor "$selected_sha" refs/remotes/origin/main; then + trusted_reason="main-ancestor" + elif git tag --points-at "$selected_sha" | grep -Eq '^v'; then + trusted_reason="release-tag" + else + pr_head_count="$( + gh api \ + -H "Accept: application/vnd.github+json" \ + "repos/${GITHUB_REPOSITORY}/commits/${selected_sha}/pulls" \ + --jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${selected_sha}"'")] | length' + )" + if [[ "$pr_head_count" != "0" ]]; then + trusted_reason="open-pr-head" + fi + fi + + if [[ -z "$trusted_reason" ]]; then + echo "Ref '${INPUT_REF}' resolved to $selected_sha, which is not trusted for secret-bearing live/E2E checks." >&2 + echo "Allowed refs must be on main, point to a release tag, or match an open PR head in ${GITHUB_REPOSITORY}." >&2 + exit 1 + fi + + echo "selected_sha=$selected_sha" >> "$GITHUB_OUTPUT" + echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT" + { + echo "Validated ref: \`${INPUT_REF}\`" + echo "Resolved SHA: \`$selected_sha\`" + echo "Trust reason: \`$trusted_reason\`" + } >> "$GITHUB_STEP_SUMMARY" + validate_release_live_cache: + needs: validate_selected_ref if: inputs.include_live_suites runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 60 @@ -164,7 +221,7 @@ jobs: - name: Checkout selected ref uses: actions/checkout@v6 with: - ref: ${{ inputs.ref }} + ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} fetch-depth: 0 - name: Setup Node environment @@ -191,6 +248,7 @@ jobs: run: pnpm test:live:cache validate_repo_e2e: + needs: validate_selected_ref if: inputs.include_repo_e2e runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: 90 @@ -200,7 +258,7 @@ jobs: - name: Checkout selected ref uses: actions/checkout@v6 with: - ref: ${{ inputs.ref }} + ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} fetch-depth: 0 - name: Setup Node environment @@ -218,6 +276,7 @@ jobs: run: pnpm test:e2e validate_special_e2e: + needs: validate_selected_ref if: inputs.include_repo_e2e || inputs.include_live_suites runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: ${{ matrix.timeout_minutes }} @@ -245,7 +304,7 @@ jobs: - name: Checkout selected ref uses: actions/checkout@v6 with: - ref: ${{ inputs.ref }} + ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} fetch-depth: 0 - name: Setup Node environment @@ -293,6 +352,7 @@ jobs: run: ${{ matrix.command }} validate_docker_e2e: + needs: validate_selected_ref if: inputs.include_release_path_suites || inputs.include_openwebui runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: ${{ matrix.timeout_minutes }} @@ -396,7 +456,7 @@ jobs: - name: Checkout selected ref uses: actions/checkout@v6 with: - ref: ${{ inputs.ref }} + ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} fetch-depth: 0 - name: Setup Node environment @@ -450,6 +510,7 @@ jobs: run: ${{ matrix.command }} validate_live_provider_suites: + needs: validate_selected_ref if: inputs.include_live_suites runs-on: blacksmith-32vcpu-ubuntu-2404 timeout-minutes: ${{ matrix.timeout_minutes }} @@ -538,7 +599,7 @@ jobs: - name: Checkout selected ref uses: actions/checkout@v6 with: - ref: ${{ inputs.ref }} + ref: ${{ needs.validate_selected_ref.outputs.selected_sha }} fetch-depth: 0 - name: Setup Node environment @@ -562,9 +623,39 @@ jobs: case "${{ matrix.suite_id }}" in live-cli-backend-docker) echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.4" >> "$GITHUB_ENV" + # The CLI backend Docker lane should exercise the same staged + # Codex auth path Peter uses locally so MCP cron creation and + # multimodal probes stay covered in CI. Replace the staged + # config.toml with a minimal CI-safe config so the repo stays + # trusted for MCP/tool use without inheriting maintainer-local + # provider/profile overrides that do not exist inside CI. + # Codex's workspace-write sandbox relies on user namespaces that + # this Docker lane does not provide, so run Codex unsandboxed + # inside the already-isolated container to keep MCP cron/tool + # execution representative instead of failing on nested sandbox + # setup. + echo 'OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV=["OPENAI_API_KEY","OPENAI_BASE_URL"]' >> "$GITHUB_ENV" + echo 'OPENCLAW_LIVE_CLI_BACKEND_ARGS=["exec","--json","--color","never","--sandbox","danger-full-access","--skip-git-repo-check"]' >> "$GITHUB_ENV" + echo 'OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS=["exec","resume","{sessionId}","-c","sandbox_mode=\"danger-full-access\"","--skip-git-repo-check"]' >> "$GITHUB_ENV" + echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV" + echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV" + echo "OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG=1" >> "$GITHUB_ENV" + ;; + live-codex-harness-docker) + # Keep CI on the API-key path for now. The staged Codex auth secret + # is currently stale, but the wrapper still supports codex-auth for + # local maintainer reruns without changing Peter's flow. + echo "OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key" >> "$GITHUB_ENV" ;; live-acp-bind-docker) - echo "OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini" >> "$GITHUB_ENV" + if [[ -n "${GEMINI_API_KEY:-}" || -n "${GOOGLE_API_KEY:-}" ]]; then + echo "OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex,gemini" >> "$GITHUB_ENV" + else + # The hydrated Gemini settings file only selects Gemini CLI auth + # mode. CI still needs a usable Gemini or Google API key before + # ACP bind can initialize a Gemini session. + echo "OPENCLAW_LIVE_ACP_BIND_AGENTS=claude,codex" >> "$GITHUB_ENV" + fi ;; esac diff --git a/.github/workflows/openclaw-release-checks.yml b/.github/workflows/openclaw-release-checks.yml index 8293313af28..1b86cda366d 100644 --- a/.github/workflows/openclaw-release-checks.yml +++ b/.github/workflows/openclaw-release-checks.yml @@ -130,12 +130,19 @@ jobs: ref: ${{ needs.resolve_target.outputs.ref }} provider: ${{ needs.resolve_target.outputs.provider }} mode: ${{ needs.resolve_target.outputs.mode }} - secrets: inherit + secrets: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }} + OPENCLAW_DISCORD_SMOKE_BOT_TOKEN: ${{ secrets.OPENCLAW_DISCORD_SMOKE_BOT_TOKEN }} + OPENCLAW_DISCORD_SMOKE_GUILD_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_GUILD_ID }} + OPENCLAW_DISCORD_SMOKE_CHANNEL_ID: ${{ secrets.OPENCLAW_DISCORD_SMOKE_CHANNEL_ID }} live_and_e2e_release_checks: needs: [resolve_target] permissions: contents: read + pull-requests: read uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml with: ref: ${{ needs.resolve_target.outputs.ref }} @@ -143,4 +150,47 @@ jobs: include_release_path_suites: true include_openwebui: true include_live_suites: true - secrets: inherit + secrets: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }} + ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }} + BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }} + CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} + KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }} + MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }} + MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} + MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} + MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }} + OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }} + OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }} + OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }} + OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }} + OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }} + FAL_KEY: ${{ secrets.FAL_KEY }} + RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }} + DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }} + TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }} + VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }} + Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }} + BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }} + BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }} + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }} + OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }} + OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }} + OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }} + OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }} + OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }} + OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }} diff --git a/.github/workflows/openclaw-scheduled-live-checks.yml b/.github/workflows/openclaw-scheduled-live-checks.yml index 8c0e331e901..027a67b1929 100644 --- a/.github/workflows/openclaw-scheduled-live-checks.yml +++ b/.github/workflows/openclaw-scheduled-live-checks.yml @@ -7,6 +7,7 @@ on: permissions: contents: read + pull-requests: read concurrency: group: openclaw-scheduled-live-checks-${{ github.ref }} @@ -19,6 +20,7 @@ jobs: live_and_openwebui_checks: permissions: contents: read + pull-requests: read uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml with: ref: ${{ github.sha }} @@ -26,4 +28,47 @@ jobs: include_release_path_suites: false include_openwebui: true include_live_suites: true - secrets: inherit + secrets: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }} + ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }} + BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }} + CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }} + GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} + KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }} + MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }} + MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }} + MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }} + MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }} + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }} + OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }} + OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }} + OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }} + OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }} + OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }} + FAL_KEY: ${{ secrets.FAL_KEY }} + RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }} + DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }} + TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }} + VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }} + Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }} + BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }} + BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }} + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }} + OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }} + OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }} + OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }} + OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }} + OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }} + OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 684d19f11c3..7094e0a0e4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,21 @@ Docs: https://docs.openclaw.ai - OpenAI Codex/OAuth: keep OpenClaw as the canonical owner for imported Codex CLI OAuth sessions, stop writing refreshed credentials back into `.codex`, and prefer fresher OpenClaw credentials over stale imported CLI state so refresh recovery stays stable. Thanks @vincentkoc. - OpenAI Codex/OAuth: treat the OpenAI TLS prerequisites probe as advisory instead of a hard blocker, so Codex sign-in can still proceed when the speculative Node/OpenSSL precheck fails but the real OAuth flow still works. Thanks @vincentkoc. - Models status/OAuth health: align OAuth health reporting with the same effective credential view runtime uses, so expired refreshable sessions stop showing healthy by default and fresher imported Codex CLI credentials surface correctly in `models status`, doctor, and gateway auth status. Thanks @vincentkoc. +- OpenAI Codex/OAuth: keep external CLI OAuth imports runtime-only by overlaying fresher Codex CLI credentials without mutating `auth-profiles.json`, so `.codex` stays a bootstrap/runtime input instead of becoming durable OpenClaw state. Thanks @vincentkoc. +- OpenAI Codex/OAuth: drop legacy CLI-manager routing from the remaining bootstrap path so Codex and MiniMax CLI imports are matched by their canonical OpenClaw profile ids instead of stale `managedBy` metadata. Thanks @vincentkoc. +- OpenAI Codex/OAuth: only bootstrap from external CLI OAuth when the local OpenClaw profile is missing or unusable, so healthy local sessions are no longer overridden by fresher `.codex` tokens. Thanks @vincentkoc. +- OpenAI Codex/OAuth: rename the external CLI bootstrap helper, reuse the same usable-oauth check across runtime fallback paths, and add debug logs plus health coverage so bootstrap decisions stay legible. Thanks @vincentkoc. - Twitch/setup: load Twitch through the bundled setup-entry discovery path and keep setup/status account detection aligned with runtime config. (#68008) Thanks @gumadeiras. +- Feishu/card actions: resolve card-action chat type from the Feishu chat API when stored context is missing, preferring `chat_mode` over `chat_type`, so DM-originated card actions no longer bypass `dmPolicy` by falling through to the group handling path. (#68201) +- Cron/isolated-agent: preserve `trusted: false` on isolated cron awareness events mirrored into the main session, and forward the optional `trusted` flag through the gateway cron wrapper so explicit trust downgrades survive session-key scoping. (#68210) +- Agents/fallback: recognize bare leading ZenMux `402 ...` quota-refresh errors without misclassifying plain numeric `402 ...` text, and keep the embedded fallback regression coverage stable. (#47579) Thanks @bwjoke. +- Failover/google: only treat `INTERNAL` status payloads as retryable timeouts when they also carry a `500` code, so malformed non-500 payloads do not enter the retry path. (#68238) Thanks @altaywtf and @Openbling. +- Agents/tools: filter bundled MCP/LSP tools through the final owner-only and tool-policy pipeline after merging them into the effective tool list, so existing allowlists, deny rules, sandbox policy, subagent policy, and owner-only restrictions apply to bundled tools the same way they apply to core tools. (#68195) +- Gateway/assistant media: require `operator.read` scope for assistant-media file and metadata requests on identity-bearing HTTP auth paths so callers without a read scope can no longer access assistant media. (#68175) Thanks @eleqtrizit. +- Exec approvals/display: escape raw control characters (including newline and carriage return) in the shared and macOS approval-prompt command sanitizers, so trailing command payloads no longer render on hidden extra lines in the approval UI. (#68198) +- OpenAI Codex/OAuth + Pi: keep imported Codex CLI OAuth bootstrap, Pi auth export, and runtime overlay handling aligned so Codex sessions survive refresh and health checks without leaking transient CLI state into saved auth files. Thanks @vincentkoc. +- Agents/TTS: report failed speech synthesis as a real tool error so unconfigured providers no longer feed successful TTS failure output back into agent loops. (#67980) Thanks @lawrence3699. +- Gateway/wake: allow unknown properties on wake payloads so external senders like Paperclip can attach opaque metadata without failing schema validation. (#68355) Thanks @kagura-agent. ## 2026.4.15 diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalCommandDisplaySanitizer.swift b/apps/macos/Sources/OpenClaw/ExecApprovalCommandDisplaySanitizer.swift index 4de5c699ad5..2899b2b3850 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalCommandDisplaySanitizer.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalCommandDisplaySanitizer.swift @@ -22,7 +22,21 @@ enum ExecApprovalCommandDisplaySanitizer { } private static func shouldEscape(_ scalar: UnicodeScalar) -> Bool { - scalar.properties.generalCategory == .format || self.invisibleCodePoints.contains(scalar.value) + let category = scalar.properties.generalCategory + if category == .control + || category == .format + || category == .lineSeparator + || category == .paragraphSeparator + { + return true + } + // Escape non-ASCII space separators (NBSP, narrow NBSP, ideographic space, etc.) so + // attackers cannot spoof token boundaries in the approval UI with spaces that render + // like a plain space but are handled differently by shells/parsers. + if category == .spaceSeparator, scalar.value != 0x20 { + return true + } + return self.invisibleCodePoints.contains(scalar.value) } private static func escape(_ scalar: UnicodeScalar) -> String { diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalCommandDisplaySanitizerTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalCommandDisplaySanitizerTests.swift index 34a4dc21534..4c5431eba93 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalCommandDisplaySanitizerTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalCommandDisplaySanitizerTests.swift @@ -9,4 +9,37 @@ struct ExecApprovalCommandDisplaySanitizerTests { ExecApprovalCommandDisplaySanitizer.sanitize(input) == "date\\u{200B}\\u{3164}\\u{FFA0}\\u{115F}\\u{1160}가") } + + @Test func `escapes control characters used to spoof line breaks`() { + let input = "echo safe\n\rcurl https://example.test" + #expect( + ExecApprovalCommandDisplaySanitizer.sanitize(input) == + "echo safe\\u{A}\\u{D}curl https://example.test") + } + + @Test func `escapes Unicode line and paragraph separators`() { + let lineInput = "echo ok\u{2028}curl https://example.test" + #expect( + ExecApprovalCommandDisplaySanitizer.sanitize(lineInput) == + "echo ok\\u{2028}curl https://example.test") + let paragraphInput = "echo ok\u{2029}curl https://example.test" + #expect( + ExecApprovalCommandDisplaySanitizer.sanitize(paragraphInput) == + "echo ok\\u{2029}curl https://example.test") + } + + @Test func `escapes non-ASCII Unicode space separators while preserving ASCII space`() { + let nbspInput = "echo ok\u{00A0}curl" + #expect( + ExecApprovalCommandDisplaySanitizer.sanitize(nbspInput) == "echo ok\\u{A0}curl") + let narrowNbspInput = "echo ok\u{202F}curl" + #expect( + ExecApprovalCommandDisplaySanitizer.sanitize(narrowNbspInput) == "echo ok\\u{202F}curl") + let ideographicSpaceInput = "echo ok\u{3000}curl" + #expect( + ExecApprovalCommandDisplaySanitizer.sanitize(ideographicSpaceInput) == + "echo ok\\u{3000}curl") + let asciiSpaceInput = "echo ok curl" + #expect(ExecApprovalCommandDisplaySanitizer.sanitize(asciiSpaceInput) == "echo ok curl") + } } diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 43a3a24bedf..091e7e164df 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -3c87ac2fc4c234348eb88812d1904724d7492890498f101d953bc761da8fdead config-baseline.json -eeed6fe659078632d9f95b3350b27103b4aba282d050ff38d3b0953a456d242d config-baseline.core.json +17d43e3c5dd6ac09fa6c7954e6923d2e0fba767483bac0a2b257fb7ec736f8a4 config-baseline.json +fdb7867bbc18792d3645ea36c31b64425d6f19c0b19a7460564f67eb97c0a71e config-baseline.core.json 99bb34fcf83ba6bb50a3fc11f170bd379bee5728b0938707fc39ebd7638e12eb config-baseline.channel.json -5f5d4e850df6e9854a85b5d008236854ce185c707fdbb566efcf00f8c08b36e3 config-baseline.plugin.json +b695cb31b4c0cf1d31f842f2892e99cc3ff8d84263ae72b72977cae844b81d6e config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index ba9e97784f0..753f3ebaff2 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -e3df4c13b4dcdc07809775c56eed15c3ab924db191a08fb5a7b48d6f73001966 plugin-sdk-api-baseline.json -2bb30ad45d5b382e92fd6b8a240a47f7679c59f9b524e54420879fadc28264b8 plugin-sdk-api-baseline.jsonl +e5c3d6eb56304164434a8b29670a6f02490f359eb7a5f3f4034e61a1b8421c54 plugin-sdk-api-baseline.json +14d07997a35902bbd3c94d528aafa53185bc66a0ddd9b518f87c85a352feb476 plugin-sdk-api-baseline.jsonl diff --git a/docs/concepts/agent-workspace.md b/docs/concepts/agent-workspace.md index 82c18626fa9..8b744631c7a 100644 --- a/docs/concepts/agent-workspace.md +++ b/docs/concepts/agent-workspace.md @@ -120,8 +120,8 @@ See [Memory](/concepts/memory) for the workflow and automatic memory flush. If any bootstrap file is missing, OpenClaw injects a "missing file" marker into the session and continues. Large bootstrap files are truncated when injected; -adjust limits with `agents.defaults.bootstrapMaxChars` (default: 20000) and -`agents.defaults.bootstrapTotalMaxChars` (default: 150000). +adjust limits with `agents.defaults.bootstrapMaxChars` (default: 12000) and +`agents.defaults.bootstrapTotalMaxChars` (default: 60000). `openclaw setup` can recreate missing defaults without overwriting existing files. diff --git a/docs/concepts/context.md b/docs/concepts/context.md index 348bb9d5366..29a9635b74d 100644 --- a/docs/concepts/context.md +++ b/docs/concepts/context.md @@ -38,7 +38,7 @@ Values vary by model, provider, tool policy, and what’s in your workspace. ``` 🧠 Context breakdown Workspace: -Bootstrap max/file: 20,000 chars +Bootstrap max/file: 12,000 chars Sandbox: mode=non-main sandboxed=false System prompt (run): 38,412 chars (~9,603 tok) (Project Context 23,901 chars (~5,976 tok)) @@ -112,7 +112,7 @@ By default, OpenClaw injects a fixed set of workspace files (if present): - `HEARTBEAT.md` - `BOOTSTRAP.md` (first-run only) -Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `20000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `150000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened. +Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `12000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `60000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened. When truncation occurs, the runtime can inject an in-prompt warning block under Project Context. Configure this with `agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`; default `once`). diff --git a/docs/concepts/system-prompt.md b/docs/concepts/system-prompt.md index 37feea5403b..73cf5511d07 100644 --- a/docs/concepts/system-prompt.md +++ b/docs/concepts/system-prompt.md @@ -118,9 +118,9 @@ unexpectedly high context usage and more frequent compaction. > as a one-shot startup-context block for that first turn. Large files are truncated with a marker. The max per-file size is controlled by -`agents.defaults.bootstrapMaxChars` (default: 20000). Total injected bootstrap +`agents.defaults.bootstrapMaxChars` (default: 12000). Total injected bootstrap content across files is capped by `agents.defaults.bootstrapTotalMaxChars` -(default: 150000). Missing files inject a short missing-file marker. When truncation +(default: 60000). Missing files inject a short missing-file marker. When truncation occurs, OpenClaw can inject a warning block in Project Context; control this with `agents.defaults.bootstrapPromptTruncationWarning` (`off`, `once`, `always`; default: `once`). diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 235ca17a7e2..6a0355888d3 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -955,21 +955,21 @@ Controls when workspace bootstrap files are injected into the system prompt. Def ### `agents.defaults.bootstrapMaxChars` -Max characters per workspace bootstrap file before truncation. Default: `20000`. +Max characters per workspace bootstrap file before truncation. Default: `12000`. ```json5 { - agents: { defaults: { bootstrapMaxChars: 20000 } }, + agents: { defaults: { bootstrapMaxChars: 12000 } }, } ``` ### `agents.defaults.bootstrapTotalMaxChars` -Max total characters injected across all workspace bootstrap files. Default: `150000`. +Max total characters injected across all workspace bootstrap files. Default: `60000`. ```json5 { - agents: { defaults: { bootstrapTotalMaxChars: 150000 } }, + agents: { defaults: { bootstrapTotalMaxChars: 60000 } }, } ``` diff --git a/extensions/anthropic/api.ts b/extensions/anthropic/api.ts index 6fcd8f8e147..c7733cbd3f4 100644 --- a/extensions/anthropic/api.ts +++ b/extensions/anthropic/api.ts @@ -1,4 +1,5 @@ export { CLAUDE_CLI_BACKEND_ID, isClaudeCliProvider } from "./cli-shared.js"; +export { buildAnthropicProvider } from "./register.runtime.js"; export { createAnthropicBetaHeadersWrapper, createAnthropicFastModeWrapper, diff --git a/extensions/anthropic/provider-contract-api.ts b/extensions/anthropic/provider-contract-api.ts new file mode 100644 index 00000000000..34acbcc9d7f --- /dev/null +++ b/extensions/anthropic/provider-contract-api.ts @@ -0,0 +1,59 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +const noopAuth = async () => ({ profiles: [] }); + +export function createAnthropicProvider(): ProviderPlugin { + return { + id: "anthropic", + label: "Anthropic", + docsPath: "/providers/models", + hookAliases: ["claude-cli"], + envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], + auth: [ + { + id: "cli", + kind: "custom", + label: "Claude CLI", + hint: "Reuse a local Claude CLI login and switch model selection to claude-cli/*", + run: noopAuth, + wizard: { + choiceId: "anthropic-cli", + choiceLabel: "Anthropic Claude CLI", + choiceHint: "Reuse a local Claude CLI login on this host", + groupId: "anthropic", + groupLabel: "Anthropic", + groupHint: "Claude CLI + API key", + }, + }, + { + id: "setup-token", + kind: "token", + label: "Anthropic setup-token", + hint: "Manual bearer token path", + run: noopAuth, + wizard: { + choiceId: "setup-token", + choiceLabel: "Anthropic setup-token", + choiceHint: "Manual token path", + groupId: "anthropic", + groupLabel: "Anthropic", + groupHint: "Claude CLI + API key + token", + }, + }, + { + id: "api-key", + kind: "api_key", + label: "Anthropic API key", + hint: "Direct Anthropic API key", + run: noopAuth, + wizard: { + choiceId: "apiKey", + choiceLabel: "Anthropic API key", + groupId: "anthropic", + groupLabel: "Anthropic", + groupHint: "Claude CLI + API key", + }, + }, + ], + }; +} diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index 510ffaf2883..ae8125528bd 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -18,7 +18,10 @@ import { upsertAuthProfile, validateAnthropicSetupToken, } from "openclaw/plugin-sdk/provider-auth"; -import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared"; +import { + cloneFirstTemplateModel, + type ProviderPlugin, +} from "openclaw/plugin-sdk/provider-model-shared"; import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import * as claudeCliAuth from "./cli-auth-seam.js"; @@ -395,11 +398,10 @@ async function runAnthropicCliMigrationNonInteractive(ctx: { }; } -export function registerAnthropicPlugin(api: OpenClawPluginApi): void { +export function buildAnthropicProvider(): ProviderPlugin { const providerId = "anthropic"; const defaultAnthropicModel = DEFAULT_ANTHROPIC_MODEL; - api.registerCliBackend(buildAnthropicCliBackend()); - api.registerProvider({ + return { id: providerId, label: "Anthropic", docsPath: "/providers/models", @@ -505,6 +507,11 @@ export function registerAnthropicPlugin(api: OpenClawPluginApi): void { store: ctx.store, profileId: ctx.profileId, }), - }); + }; +} + +export function registerAnthropicPlugin(api: OpenClawPluginApi): void { + api.registerCliBackend(buildAnthropicCliBackend()); + api.registerProvider(buildAnthropicProvider()); api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider); } diff --git a/extensions/brave/src/brave-web-search-provider.test.ts b/extensions/brave/src/brave-web-search-provider.test.ts index 996c4599249..245feb07ab8 100644 --- a/extensions/brave/src/brave-web-search-provider.test.ts +++ b/extensions/brave/src/brave-web-search-provider.test.ts @@ -1,7 +1,8 @@ import fs from "node:fs"; import { afterEach, describe, expect, it, vi } from "vitest"; import { validateJsonSchemaValue } from "../../../src/plugins/schema-validator.js"; -import { __testing, createBraveWebSearchProvider } from "./brave-web-search-provider.js"; +import { __testing } from "../test-api.js"; +import { createBraveWebSearchProvider } from "./brave-web-search-provider.js"; const braveManifest = JSON.parse( fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"), diff --git a/extensions/brave/src/brave-web-search-provider.ts b/extensions/brave/src/brave-web-search-provider.ts index fba71e8b282..9fd8140a868 100644 --- a/extensions/brave/src/brave-web-search-provider.ts +++ b/extensions/brave/src/brave-web-search-provider.ts @@ -3,25 +3,59 @@ import type { WebSearchProviderPlugin, WebSearchProviderToolDefinition, } from "openclaw/plugin-sdk/provider-web-search"; -import { isRecord } from "openclaw/plugin-sdk/text-runtime"; -import { - createBraveSchema, - mapBraveLlmContextResults, - normalizeBraveCountry, - normalizeBraveLanguageParams, - resolveBraveConfig, - resolveBraveMode, -} from "./brave-web-search-provider.shared.js"; +import { createWebSearchProviderContractFields } from "openclaw/plugin-sdk/provider-web-search-config-contract"; -type ConfigInput = Parameters< - NonNullable ->[0]; -type ConfigTarget = Parameters< - NonNullable ->[0]; +const BRAVE_CREDENTIAL_PATH = "plugins.entries.brave.config.webSearch.apiKey"; +const BraveSearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, + }, + country: { + type: "string", + description: + "2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.", + }, + language: { + type: "string", + description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').", + }, + freshness: { + type: "string", + description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.", + }, + date_after: { + type: "string", + description: "Only results published after this date (YYYY-MM-DD).", + }, + date_before: { + type: "string", + description: "Only results published before this date (YYYY-MM-DD).", + }, + search_lang: { + type: "string", + description: + "Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').", + }, + ui_lang: { + type: "string", + description: + "Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.", + }, + }, +} satisfies Record; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} function resolveProviderWebSearchPluginConfig( - config: ConfigInput, + config: unknown, pluginId: string, ): Record | undefined { if (!isRecord(config)) { @@ -34,40 +68,6 @@ function resolveProviderWebSearchPluginConfig( return isRecord(pluginConfig?.webSearch) ? pluginConfig.webSearch : undefined; } -function ensureObject(target: Record, key: string): Record { - const current = target[key]; - if (isRecord(current)) { - return current; - } - const next: Record = {}; - target[key] = next; - return next; -} - -function setProviderWebSearchPluginConfigValue( - configTarget: ConfigTarget, - pluginId: string, - key: string, - value: unknown, -): void { - const plugins = ensureObject(configTarget as Record, "plugins"); - const entries = ensureObject(plugins, "entries"); - const entry = ensureObject(entries, pluginId); - if (entry.enabled === undefined) { - entry.enabled = true; - } - const config = ensureObject(entry, "config"); - const webSearch = ensureObject(config, "webSearch"); - webSearch[key] = value; -} - -function setTopLevelCredentialValue( - searchConfigTarget: Record, - value: unknown, -): void { - searchConfigTarget.apiKey = value; -} - function mergeScopedSearchConfig( searchConfig: Record | undefined, key: string, @@ -94,17 +94,22 @@ function mergeScopedSearchConfig( return next; } +function resolveBraveMode(searchConfig?: Record): "web" | "llm-context" { + const brave = isRecord(searchConfig?.brave) ? searchConfig.brave : undefined; + return brave?.mode === "llm-context" ? "llm-context" : "web"; +} + function createBraveToolDefinition( searchConfig?: SearchConfigRecord, ): WebSearchProviderToolDefinition { - const braveMode = resolveBraveMode(resolveBraveConfig(searchConfig)); + const braveMode = resolveBraveMode(searchConfig); return { description: braveMode === "llm-context" ? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding." : "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.", - parameters: createBraveSchema(), + parameters: BraveSearchSchema, execute: async (args) => { const { executeBraveSearch } = await import("./brave-web-search-provider.runtime.js"); return await executeBraveSearch(args, searchConfig); @@ -124,15 +129,12 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://brave.com/search/api/", docsUrl: "https://docs.openclaw.ai/brave-search", autoDetectOrder: 10, - credentialPath: "plugins.entries.brave.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.brave.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => searchConfig?.apiKey, - setCredentialValue: setTopLevelCredentialValue, - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "brave")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "brave", "apiKey", value); - }, + credentialPath: BRAVE_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: BRAVE_CREDENTIAL_PATH, + searchCredential: { type: "top-level" }, + configuredCredential: { pluginId: "brave" }, + }), createTool: (ctx) => createBraveToolDefinition( mergeScopedSearchConfig( @@ -144,10 +146,3 @@ export function createBraveWebSearchProvider(): WebSearchProviderPlugin { ), }; } - -export const __testing = { - normalizeBraveCountry, - normalizeBraveLanguageParams, - resolveBraveMode, - mapBraveLlmContextResults, -} as const; diff --git a/extensions/brave/test-api.ts b/extensions/brave/test-api.ts index b523a2c51b1..c1c12b7dc13 100644 --- a/extensions/brave/test-api.ts +++ b/extensions/brave/test-api.ts @@ -1 +1,13 @@ -export { __testing } from "./src/brave-web-search-provider.js"; +import { + mapBraveLlmContextResults, + normalizeBraveCountry, + normalizeBraveLanguageParams, + resolveBraveMode, +} from "./src/brave-web-search-provider.shared.js"; + +export const __testing = { + normalizeBraveCountry, + normalizeBraveLanguageParams, + resolveBraveMode, + mapBraveLlmContextResults, +} as const; diff --git a/extensions/brave/web-search-provider.ts b/extensions/brave/web-search-provider.ts index 634c7931c97..01041edf46b 100644 --- a/extensions/brave/web-search-provider.ts +++ b/extensions/brave/web-search-provider.ts @@ -1 +1 @@ -export { __testing, createBraveWebSearchProvider } from "./src/brave-web-search-provider.js"; +export { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js"; diff --git a/extensions/discord/directory-contract-api.ts b/extensions/discord/directory-contract-api.ts new file mode 100644 index 00000000000..027b29f0459 --- /dev/null +++ b/extensions/discord/directory-contract-api.ts @@ -0,0 +1,4 @@ +export { + listDiscordDirectoryGroupsFromConfig, + listDiscordDirectoryPeersFromConfig, +} from "./src/directory-config.js"; diff --git a/extensions/discord/src/directory-config.ts b/extensions/discord/src/directory-config.ts index 369e55b263e..1158b3bc1e4 100644 --- a/extensions/discord/src/directory-config.ts +++ b/extensions/discord/src/directory-config.ts @@ -2,7 +2,7 @@ import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; import { createResolvedDirectoryEntriesLister, type DirectoryConfigParams, -} from "openclaw/plugin-sdk/directory-runtime"; +} from "openclaw/plugin-sdk/directory-config-runtime"; import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId } from "./accounts.js"; function resolveDiscordDirectoryConfigAccount( diff --git a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts index 7cd6e435924..5f233fa1784 100644 --- a/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts +++ b/extensions/discord/src/monitor/native-command.plugin-dispatch.test.ts @@ -1,17 +1,25 @@ import { ChannelType } from "discord-api-types/v10"; import type { NativeCommandSpec } from "openclaw/plugin-sdk/command-auth"; +import { resolveDirectStatusReplyForSession } from "openclaw/plugin-sdk/command-status-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { clearPluginCommands, registerPluginCommand } from "openclaw/plugin-sdk/plugin-runtime"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearPluginCommands, + executePluginCommand, + matchPluginCommand, + registerPluginCommand, +} from "openclaw/plugin-sdk/plugin-runtime"; +import { dispatchReplyWithDispatcher } from "openclaw/plugin-sdk/reply-dispatch-runtime"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createTestRegistry, setActivePluginRegistry, } from "../../../../test/helpers/plugins/plugin-registry.js"; +import { resolveDiscordNativeInteractionRouteState } from "./native-command-route.js"; import { createMockCommandInteraction, type MockCommandInteraction, } from "./native-command.test-helpers.js"; -import { createNoopThreadBindingManager } from "./thread-bindings.js"; +import { createNoopThreadBindingManager } from "./thread-bindings.manager.js"; let createDiscordNativeCommand: typeof import("./native-command.js").createDiscordNativeCommand; let discordNativeCommandTesting: typeof import("./native-command.js").__testing; @@ -22,33 +30,6 @@ const runtimeModuleMocks = vi.hoisted(() => ({ resolveDirectStatusReplyForSession: vi.fn(), })); -vi.mock("openclaw/plugin-sdk/plugin-runtime", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/plugin-runtime", - ); - return { - ...actual, - matchPluginCommand: (...args: unknown[]) => runtimeModuleMocks.matchPluginCommand(...args), - executePluginCommand: (...args: unknown[]) => runtimeModuleMocks.executePluginCommand(...args), - }; -}); - -vi.mock("openclaw/plugin-sdk/reply-runtime", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/reply-runtime", - ); - return { - ...actual, - dispatchReplyWithDispatcher: (...args: unknown[]) => - runtimeModuleMocks.dispatchReplyWithDispatcher(...args), - }; -}); - -vi.mock("openclaw/plugin-sdk/command-status-runtime", () => ({ - resolveDirectStatusReplyForSession: (...args: unknown[]) => - runtimeModuleMocks.resolveDirectStatusReplyForSession(...args), -})); - function createInteraction(params?: { channelType?: ChannelType; channelId?: string; @@ -270,13 +251,16 @@ async function expectPairCommandReply(params: { cfg: OpenClawConfig; commandName: string; interaction: MockCommandInteraction; + expectedRegisteredName?: string; }) { const command = await createPluginCommand({ cfg: params.cfg, name: params.commandName, }); const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher; - + const executeSpy = runtimeModuleMocks.executePluginCommand.mockResolvedValue({ + text: "paired:now", + }); await (command as { run: (interaction: unknown) => Promise }).run( Object.assign(params.interaction, { options: { @@ -288,6 +272,12 @@ async function expectPairCommandReply(params: { ); expect(dispatchSpy).not.toHaveBeenCalled(); + expect(executeSpy).toHaveBeenCalledWith( + expect.objectContaining({ + command: expect.objectContaining({ name: params.expectedRegisteredName ?? "pair" }), + args: "now", + }), + ); expect(params.interaction.followUp).toHaveBeenCalledWith( expect.objectContaining({ content: "paired:now" }), ); @@ -338,21 +328,28 @@ describe("Discord native plugin command dispatch", () => { await import("./native-command.js")); }); - beforeEach(async () => { + afterAll(() => { + clearPluginCommands(); + setActivePluginRegistry(createTestRegistry()); + discordNativeCommandTesting.setMatchPluginCommand(matchPluginCommand); + discordNativeCommandTesting.setExecutePluginCommand(executePluginCommand); + discordNativeCommandTesting.setDispatchReplyWithDispatcher(dispatchReplyWithDispatcher); + discordNativeCommandTesting.setResolveDirectStatusReplyForSession( + resolveDirectStatusReplyForSession, + ); + discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState( + resolveDiscordNativeInteractionRouteState, + ); + }); + + beforeEach(() => { vi.clearAllMocks(); clearPluginCommands(); setActivePluginRegistry(createTestRegistry()); - const actualPluginRuntime = await vi.importActual< - typeof import("openclaw/plugin-sdk/plugin-runtime") - >("openclaw/plugin-sdk/plugin-runtime"); runtimeModuleMocks.matchPluginCommand.mockReset(); - runtimeModuleMocks.matchPluginCommand.mockImplementation( - actualPluginRuntime.matchPluginCommand, - ); + runtimeModuleMocks.matchPluginCommand.mockImplementation(matchPluginCommand); runtimeModuleMocks.executePluginCommand.mockReset(); - runtimeModuleMocks.executePluginCommand.mockImplementation( - actualPluginRuntime.executePluginCommand, - ); + runtimeModuleMocks.executePluginCommand.mockImplementation(executePluginCommand); runtimeModuleMocks.dispatchReplyWithDispatcher.mockReset(); runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue({ counts: { @@ -372,7 +369,10 @@ describe("Discord native plugin command dispatch", () => { runtimeModuleMocks.executePluginCommand as typeof import("openclaw/plugin-sdk/plugin-runtime").executePluginCommand, ); discordNativeCommandTesting.setDispatchReplyWithDispatcher( - runtimeModuleMocks.dispatchReplyWithDispatcher as typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithDispatcher, + runtimeModuleMocks.dispatchReplyWithDispatcher as typeof dispatchReplyWithDispatcher, + ); + discordNativeCommandTesting.setResolveDirectStatusReplyForSession( + runtimeModuleMocks.resolveDirectStatusReplyForSession as typeof resolveDirectStatusReplyForSession, ); discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(async (params) => createUnboundRouteState({ @@ -436,7 +436,6 @@ describe("Discord native plugin command dispatch", () => { description: "Pair", acceptsArgs: true, }; - const command = await createNativeCommand(cfg, commandSpec); const interaction = createInteraction({ channelType: ChannelType.GuildText, channelId: "234567890123456789", @@ -455,6 +454,7 @@ describe("Discord native plugin command dispatch", () => { handler: async ({ args }) => ({ text: `open:${args ?? ""}` }), }), ).toEqual({ ok: true }); + const command = await createNativeCommand(cfg, commandSpec); const executeSpy = runtimeModuleMocks.executePluginCommand; const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue( diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts index 22fb5e9c464..69ed16921f7 100644 --- a/extensions/discord/src/monitor/native-command.ts +++ b/extensions/discord/src/monitor/native-command.ts @@ -94,6 +94,7 @@ const DISCORD_COMMAND_DESCRIPTION_MAX = 100; let matchPluginCommandImpl = pluginRuntime.matchPluginCommand; let executePluginCommandImpl = pluginRuntime.executePluginCommand; let dispatchReplyWithDispatcherImpl = dispatchReplyWithDispatcher; +let resolveDirectStatusReplyForSessionImpl = resolveDirectStatusReplyForSession; let resolveDiscordNativeInteractionRouteStateImpl = resolveDiscordNativeInteractionRouteState; export const __testing = { @@ -118,6 +119,13 @@ export const __testing = { dispatchReplyWithDispatcherImpl = next; return previous; }, + setResolveDirectStatusReplyForSession( + next: typeof resolveDirectStatusReplyForSession, + ): typeof resolveDirectStatusReplyForSession { + const previous = resolveDirectStatusReplyForSessionImpl; + resolveDirectStatusReplyForSessionImpl = next; + return previous; + }, setResolveDiscordNativeInteractionRouteState( next: typeof resolveDiscordNativeInteractionRouteState, ): typeof resolveDiscordNativeInteractionRouteState { @@ -621,6 +629,19 @@ async function safeDiscordInteractionCall( } } +function createNativeCommandDefinition(command: NativeCommandSpec): ChatCommandDefinition { + return { + key: command.name, + nativeName: command.name, + description: command.description, + textAliases: [], + acceptsArgs: command.acceptsArgs, + args: command.args, + argsParsing: "none", + scope: "native", + }; +} + export function createDiscordNativeCommand(params: { command: NativeCommandSpec; cfg: ReturnType; @@ -639,18 +660,13 @@ export function createDiscordNativeCommand(params: { ephemeralDefault, threadBindings, } = params; + const fallbackCommandDefinition = createNativeCommandDefinition(command); const commandDefinition = - findCommandByNativeName(command.name, "discord") ?? - ({ - key: command.name, - nativeName: command.name, - description: command.description, - textAliases: [], - acceptsArgs: command.acceptsArgs, - args: command.args, - argsParsing: "none", - scope: "native", - } satisfies ChatCommandDefinition); + matchPluginCommandImpl(`/${command.name}`) !== null + ? fallbackCommandDefinition + : (findCommandByNativeName(command.name, "discord", { + includeBundledChannelFallback: false, + }) ?? fallbackCommandDefinition); const argDefinitions = commandDefinition.args ?? command.args; const commandOptions = buildDiscordCommandOptions({ command: commandDefinition, @@ -1130,7 +1146,7 @@ async function dispatchDiscordCommandInteraction(params: { }); const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId); if (!suppressReplies && commandName === "status") { - const statusReply = await resolveDirectStatusReplyForSession({ + const statusReply = await resolveDirectStatusReplyForSessionImpl({ cfg, sessionKey: commandTargetSessionKey?.trim() || sessionKey, channel: "discord", diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts index dd51d481c37..08991f424b1 100644 --- a/extensions/discord/src/outbound-adapter.ts +++ b/extensions/discord/src/outbound-adapter.ts @@ -18,14 +18,34 @@ import { normalizeOptionalStringifiedId, } from "openclaw/plugin-sdk/text-runtime"; import type { DiscordComponentMessageSpec } from "./components.js"; -import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js"; +import type { ThreadBindingRecord } from "./monitor/thread-bindings.js"; import { normalizeDiscordOutboundTarget } from "./normalize.js"; -import { sendDiscordComponentMessage } from "./send.components.js"; -import { sendMessageDiscord, sendPollDiscord, sendWebhookMessageDiscord } from "./send.js"; -import { buildDiscordInteractiveComponents } from "./shared-interactive.js"; export const DISCORD_TEXT_CHUNK_LIMIT = 2000; +type DiscordSendRuntime = typeof import("./send.js"); +type DiscordSendFn = DiscordSendRuntime["sendMessageDiscord"]; +type DiscordComponentSendFn = typeof import("./send.components.js").sendDiscordComponentMessage; + +let discordSendRuntimePromise: Promise | undefined; +let discordComponentSendPromise: Promise | undefined; + +async function loadDiscordSendRuntime(): Promise { + discordSendRuntimePromise ??= import("./send.js"); + return await discordSendRuntimePromise; +} + +async function sendDiscordComponentMessageLazy( + ...args: Parameters +): ReturnType { + discordComponentSendPromise ??= import("./send.components.js").then( + (module) => module.sendDiscordComponentMessage, + ); + return await ( + await discordComponentSendPromise + )(...args); +} + function hasApprovalChannelData(payload: { channelData?: unknown }): boolean { const channelData = payload.channelData; if (!channelData || typeof channelData !== "object" || Array.isArray(channelData)) { @@ -93,6 +113,7 @@ async function maybeSendDiscordWebhookText(params: { if (!threadId) { return null; } + const { getThreadBindingManager } = await import("./monitor/thread-bindings.js"); const manager = getThreadBindingManager(params.accountId ?? undefined); if (!manager) { return null; @@ -105,6 +126,7 @@ async function maybeSendDiscordWebhookText(params: { identity: params.identity, binding, }); + const { sendWebhookMessageDiscord } = await loadDiscordSendRuntime(); const result = await sendWebhookMessageDiscord(params.text, { webhookId: binding.webhookId, webhookToken: binding.webhookToken, @@ -134,7 +156,12 @@ export const discordOutbound: ChannelOutboundAdapter = { | { components?: DiscordComponentMessageSpec } | undefined; const rawComponentSpec = - discordData?.components ?? buildDiscordInteractiveComponents(payload.interactive); + discordData?.components ?? + (payload.interactive + ? (await import("./shared-interactive.js")).buildDiscordInteractiveComponents( + payload.interactive, + ) + : undefined); const componentSpec = rawComponentSpec ? rawComponentSpec.text ? rawComponentSpec @@ -154,7 +181,8 @@ export const discordOutbound: ChannelOutboundAdapter = { }); } const send = - resolveOutboundSendDep(ctx.deps, "discord") ?? sendMessageDiscord; + resolveOutboundSendDep(ctx.deps, "discord") ?? + (await loadDiscordSendRuntime()).sendMessageDiscord; const target = resolveDiscordOutboundTarget({ to: ctx.to, threadId: ctx.threadId }); const mediaUrls = resolvePayloadMediaUrls(payload); const result = await sendPayloadMediaSequenceOrFallback({ @@ -162,7 +190,7 @@ export const discordOutbound: ChannelOutboundAdapter = { mediaUrls, fallbackResult: { messageId: "", channelId: target }, sendNoMedia: async () => - await sendDiscordComponentMessage(target, componentSpec, { + await sendDiscordComponentMessageLazy(target, componentSpec, { replyTo: ctx.replyToId ?? undefined, accountId: ctx.accountId ?? undefined, silent: ctx.silent ?? undefined, @@ -170,7 +198,7 @@ export const discordOutbound: ChannelOutboundAdapter = { }), send: async ({ text, mediaUrl, isFirst }) => { if (isFirst) { - return await sendDiscordComponentMessage(target, componentSpec, { + return await sendDiscordComponentMessageLazy(target, componentSpec, { mediaUrl, mediaAccess: ctx.mediaAccess, mediaLocalRoots: ctx.mediaLocalRoots, @@ -213,7 +241,8 @@ export const discordOutbound: ChannelOutboundAdapter = { } } const send = - resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; + resolveOutboundSendDep(deps, "discord") ?? + (await loadDiscordSendRuntime()).sendMessageDiscord; return await send(resolveDiscordOutboundTarget({ to, threadId }), text, { verbose: false, replyTo: replyToId ?? undefined, @@ -236,7 +265,8 @@ export const discordOutbound: ChannelOutboundAdapter = { silent, }) => { const send = - resolveOutboundSendDep(deps, "discord") ?? sendMessageDiscord; + resolveOutboundSendDep(deps, "discord") ?? + (await loadDiscordSendRuntime()).sendMessageDiscord; return await send(resolveDiscordOutboundTarget({ to, threadId }), text, { verbose: false, mediaUrl, @@ -249,7 +279,9 @@ export const discordOutbound: ChannelOutboundAdapter = { }); }, sendPoll: async ({ cfg, to, poll, accountId, threadId, silent }) => - await sendPollDiscord(resolveDiscordOutboundTarget({ to, threadId }), poll, { + await ( + await loadDiscordSendRuntime() + ).sendPollDiscord(resolveDiscordOutboundTarget({ to, threadId }), poll, { accountId: accountId ?? undefined, silent: silent ?? undefined, cfg, diff --git a/extensions/discord/src/shared.test.ts b/extensions/discord/src/shared.test.ts new file mode 100644 index 00000000000..3d6af28d4fa --- /dev/null +++ b/extensions/discord/src/shared.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { createDiscordPluginBase } from "./shared.js"; + +describe("createDiscordPluginBase", () => { + it("owns Discord native command name overrides", () => { + const plugin = createDiscordPluginBase({ setup: {} as never }); + + expect( + plugin.commands?.resolveNativeCommandName?.({ + commandKey: "tts", + defaultName: "tts", + }), + ).toBe("voice"); + expect( + plugin.commands?.resolveNativeCommandName?.({ + commandKey: "status", + defaultName: "status", + }), + ).toBe("status"); + }); +}); diff --git a/extensions/duckduckgo/src/ddg-search-provider.ts b/extensions/duckduckgo/src/ddg-search-provider.ts index dbedb681296..f6ebd025c51 100644 --- a/extensions/duckduckgo/src/ddg-search-provider.ts +++ b/extensions/duckduckgo/src/ddg-search-provider.ts @@ -1,37 +1,29 @@ -import { Type } from "@sinclair/typebox"; import { - enablePluginInConfig, - getScopedCredentialValue, - readNumberParam, - readStringParam, - setScopedCredentialValue, + createWebSearchProviderContractFields, type WebSearchProviderPlugin, -} from "openclaw/plugin-sdk/provider-web-search"; -import { runDuckDuckGoSearch } from "./ddg-client.js"; +} from "openclaw/plugin-sdk/provider-web-search-contract"; -const DuckDuckGoSearchSchema = Type.Object( - { - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-10).", - minimum: 1, - maximum: 10, - }), - ), - region: Type.Optional( - Type.String({ - description: "Optional DuckDuckGo region code such as us-en, uk-en, or de-de.", - }), - ), - safeSearch: Type.Optional( - Type.String({ - description: "SafeSearch level: strict, moderate, or off.", - }), - ), +const DuckDuckGoSearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, + }, + region: { + type: "string", + description: "Optional DuckDuckGo region code such as us-en, uk-en, or de-de.", + }, + safeSearch: { + type: "string", + description: "SafeSearch level: strict, moderate, or off.", + }, }, - { additionalProperties: false }, -); + additionalProperties: false, +} satisfies Record; export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin { return { @@ -45,17 +37,21 @@ export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin { docsUrl: "https://docs.openclaw.ai/tools/web", autoDetectOrder: 100, credentialPath: "", - inactiveSecretPaths: [], - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "duckduckgo"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "duckduckgo", value), - applySelectionConfig: (config) => enablePluginInConfig(config, "duckduckgo").config, + ...createWebSearchProviderContractFields({ + credentialPath: "", + searchCredential: { type: "scoped", scopeId: "duckduckgo" }, + selectionPluginId: "duckduckgo", + }), createTool: (ctx) => ({ description: "Search the web using DuckDuckGo. Returns titles, URLs, and snippets with no API key required.", parameters: DuckDuckGoSearchSchema, - execute: async (args) => - await runDuckDuckGoSearch({ + execute: async (args) => { + const [{ runDuckDuckGoSearch }, { readNumberParam, readStringParam }] = await Promise.all([ + import("./ddg-client.js"), + import("openclaw/plugin-sdk/provider-web-search"), + ]); + return await runDuckDuckGoSearch({ config: ctx.config, query: readStringParam(args, "query", { required: true }), count: readNumberParam(args, "count", { integer: true }), @@ -65,7 +61,8 @@ export function createDuckDuckGoWebSearchProvider(): WebSearchProviderPlugin { | "moderate" | "off" | undefined, - }), + }); + }, }), }; } diff --git a/extensions/exa/src/exa-web-search-provider.runtime.ts b/extensions/exa/src/exa-web-search-provider.runtime.ts new file mode 100644 index 00000000000..84b1f2c0e7a --- /dev/null +++ b/extensions/exa/src/exa-web-search-provider.runtime.ts @@ -0,0 +1,525 @@ +import { + buildSearchCacheKey, + DEFAULT_SEARCH_COUNT, + mergeScopedSearchConfig, + parseIsoDateRange, + readCachedSearchPayload, + readConfiguredSecretString, + readNumberParam, + readProviderEnvValue, + readStringParam, + resolveProviderWebSearchPluginConfig, + resolveSearchCacheTtlMs, + resolveSearchTimeoutSeconds, + resolveSiteName, + type SearchConfigRecord, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCachedSearchPayload, +} from "openclaw/plugin-sdk/provider-web-search"; +import { + normalizeOptionalLowercaseString, + normalizeOptionalString, +} from "openclaw/plugin-sdk/text-runtime"; + +const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search"; +const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "instant"] as const; +const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const; +const EXA_MAX_SEARCH_COUNT = 100; + +type ExaConfig = { + apiKey?: string; +}; + +type ExaSearchType = (typeof EXA_SEARCH_TYPES)[number]; +type ExaFreshness = (typeof EXA_FRESHNESS_VALUES)[number]; + +type ExaTextContentsOption = boolean | { maxCharacters?: number }; +type ExaHighlightsContentsOption = + | boolean + | { + maxCharacters?: number; + query?: string; + numSentences?: number; + highlightsPerUrl?: number; + }; +type ExaSummaryContentsOption = boolean | { query?: string }; + +type ExaContentsArgs = { + highlights?: ExaHighlightsContentsOption; + text?: ExaTextContentsOption; + summary?: ExaSummaryContentsOption; +}; + +type ExaSearchResult = { + title?: unknown; + url?: unknown; + publishedDate?: unknown; + highlights?: unknown; + highlightScores?: unknown; + summary?: unknown; + text?: unknown; +}; + +type ExaSearchResponse = { + results?: unknown; +}; + +function normalizeExaFreshness(value: string | undefined): ExaFreshness | undefined { + const trimmed = normalizeOptionalLowercaseString(value); + if (!trimmed) { + return undefined; + } + return EXA_FRESHNESS_VALUES.includes(trimmed as ExaFreshness) + ? (trimmed as ExaFreshness) + : undefined; +} + +function resolveExaConfig(searchConfig?: SearchConfigRecord): ExaConfig { + const exa = searchConfig?.exa; + return exa && typeof exa === "object" && !Array.isArray(exa) ? (exa as ExaConfig) : {}; +} + +function resolveExaApiKey(exa?: ExaConfig): string | undefined { + return ( + readConfiguredSecretString(exa?.apiKey, "tools.web.search.exa.apiKey") ?? + readProviderEnvValue(["EXA_API_KEY"]) + ); +} + +function resolveExaDescription(result: ExaSearchResult): string { + const highlights = result.highlights; + if (Array.isArray(highlights)) { + const highlightText = highlights + .map((entry) => normalizeOptionalString(entry)) + .filter((entry): entry is string => Boolean(entry)) + .join("\n"); + if (highlightText) { + return highlightText; + } + } + const summary = normalizeOptionalString(result.summary); + if (summary) { + return summary; + } + return normalizeOptionalString(result.text) ?? ""; +} + +function parsePositiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; +} + +function invalidContentsPayload(message: string) { + return { + error: "invalid_contents", + message, + docs: "https://docs.openclaw.ai/tools/web", + }; +} + +function isErrorPayload(value: unknown): value is { error: string; message: string; docs: string } { + return Boolean( + value && typeof value === "object" && "error" in value && "message" in value && "docs" in value, + ); +} + +function resolveExaSearchCount(value: unknown, fallback: number): number { + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(parsed)) { + return fallback; + } + return Math.max(1, Math.min(EXA_MAX_SEARCH_COUNT, Math.floor(parsed))); +} + +function parseExaContents( + rawContents: unknown, +): { value?: ExaContentsArgs } | { error: string; message: string; docs: string } { + if (rawContents === undefined) { + return { value: undefined }; + } + if (!rawContents || typeof rawContents !== "object" || Array.isArray(rawContents)) { + return invalidContentsPayload( + "contents must be an object with optional text, highlights, and summary fields.", + ); + } + + const raw = rawContents as Record; + const allowedKeys = new Set(["text", "highlights", "summary"]); + for (const key of Object.keys(raw)) { + if (!allowedKeys.has(key)) { + return invalidContentsPayload( + `contents has unknown field "${key}". Only "text", "highlights", and "summary" are allowed.`, + ); + } + } + + const parsed: ExaContentsArgs = {}; + + const parseText = ( + value: unknown, + ): ExaTextContentsOption | { error: string; message: string; docs: string } => { + if (typeof value === "boolean") { + return value; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return invalidContentsPayload("contents.text must be a boolean or an object."); + } + const obj = value as Record; + for (const key of Object.keys(obj)) { + if (key !== "maxCharacters") { + return invalidContentsPayload( + `contents.text has unknown field "${key}". Only "maxCharacters" is allowed.`, + ); + } + } + if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) { + return invalidContentsPayload("contents.text.maxCharacters must be a positive integer."); + } + return parsePositiveInteger(obj.maxCharacters) + ? { maxCharacters: parsePositiveInteger(obj.maxCharacters) } + : {}; + }; + + const parseHighlights = ( + value: unknown, + ): ExaHighlightsContentsOption | { error: string; message: string; docs: string } => { + if (typeof value === "boolean") { + return value; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return invalidContentsPayload("contents.highlights must be a boolean or an object."); + } + const obj = value as Record; + const allowed = new Set(["maxCharacters", "query", "numSentences", "highlightsPerUrl"]); + for (const key of Object.keys(obj)) { + if (!allowed.has(key)) { + return invalidContentsPayload( + `contents.highlights has unknown field "${key}". Allowed fields are "maxCharacters", "query", "numSentences", and "highlightsPerUrl".`, + ); + } + } + if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) { + return invalidContentsPayload( + "contents.highlights.maxCharacters must be a positive integer.", + ); + } + if ("numSentences" in obj && parsePositiveInteger(obj.numSentences) === undefined) { + return invalidContentsPayload("contents.highlights.numSentences must be a positive integer."); + } + if ("highlightsPerUrl" in obj && parsePositiveInteger(obj.highlightsPerUrl) === undefined) { + return invalidContentsPayload( + "contents.highlights.highlightsPerUrl must be a positive integer.", + ); + } + if ("query" in obj && typeof obj.query !== "string") { + return invalidContentsPayload("contents.highlights.query must be a string."); + } + return { + ...(parsePositiveInteger(obj.maxCharacters) + ? { maxCharacters: parsePositiveInteger(obj.maxCharacters) } + : {}), + ...(typeof obj.query === "string" ? { query: obj.query } : {}), + ...(parsePositiveInteger(obj.numSentences) + ? { numSentences: parsePositiveInteger(obj.numSentences) } + : {}), + ...(parsePositiveInteger(obj.highlightsPerUrl) + ? { highlightsPerUrl: parsePositiveInteger(obj.highlightsPerUrl) } + : {}), + }; + }; + + const parseSummary = ( + value: unknown, + ): ExaSummaryContentsOption | { error: string; message: string; docs: string } => { + if (typeof value === "boolean") { + return value; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + return invalidContentsPayload("contents.summary must be a boolean or an object."); + } + const obj = value as Record; + for (const key of Object.keys(obj)) { + if (key !== "query") { + return invalidContentsPayload( + `contents.summary has unknown field "${key}". Only "query" is allowed.`, + ); + } + } + if ("query" in obj && typeof obj.query !== "string") { + return invalidContentsPayload("contents.summary.query must be a string."); + } + return typeof obj.query === "string" ? { query: obj.query } : {}; + }; + + if ("text" in raw) { + const parsedText = parseText(raw.text); + if (isErrorPayload(parsedText)) { + return parsedText; + } + parsed.text = parsedText; + } + if ("highlights" in raw) { + const parsedHighlights = parseHighlights(raw.highlights); + if (isErrorPayload(parsedHighlights)) { + return parsedHighlights; + } + parsed.highlights = parsedHighlights; + } + if ("summary" in raw) { + const parsedSummary = parseSummary(raw.summary); + if (isErrorPayload(parsedSummary)) { + return parsedSummary; + } + parsed.summary = parsedSummary; + } + + return { value: parsed }; +} + +function normalizeExaResults(payload: unknown): ExaSearchResult[] { + if (!payload || typeof payload !== "object") { + return []; + } + const results = (payload as ExaSearchResponse).results; + if (!Array.isArray(results)) { + return []; + } + return results.filter((entry): entry is ExaSearchResult => + Boolean(entry && typeof entry === "object" && !Array.isArray(entry)), + ); +} + +function resolveFreshnessStartDate(freshness: ExaFreshness): string { + const now = new Date(); + if (freshness === "day") { + now.setUTCDate(now.getUTCDate() - 1); + return now.toISOString(); + } + if (freshness === "week") { + now.setUTCDate(now.getUTCDate() - 7); + return now.toISOString(); + } + if (freshness === "month") { + const currentDay = now.getUTCDate(); + now.setUTCDate(1); + now.setUTCMonth(now.getUTCMonth() - 1); + const lastDayOfTargetMonth = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0), + ).getUTCDate(); + now.setUTCDate(Math.min(currentDay, lastDayOfTargetMonth)); + return now.toISOString(); + } + now.setUTCFullYear(now.getUTCFullYear() - 1); + return now.toISOString(); +} + +async function runExaSearch(params: { + apiKey: string; + query: string; + count: number; + freshness?: ExaFreshness; + dateAfter?: string; + dateBefore?: string; + type: ExaSearchType; + contents?: ExaContentsArgs; + timeoutSeconds: number; +}): Promise { + const body: Record = { + query: params.query, + numResults: params.count, + type: params.type, + contents: params.contents ?? { highlights: true }, + }; + + if (params.dateAfter) { + body.startPublishedDate = params.dateAfter; + } else if (params.freshness) { + body.startPublishedDate = resolveFreshnessStartDate(params.freshness); + } + if (params.dateBefore) { + body.endPublishedDate = params.dateBefore; + } + + return withTrustedWebSearchEndpoint( + { + url: EXA_SEARCH_ENDPOINT, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "x-api-key": params.apiKey, + "x-exa-integration": "openclaw", + }, + body: JSON.stringify(body), + }, + }, + async (res) => { + if (!res.ok) { + const detail = await res.text(); + throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`); + } + try { + return normalizeExaResults(await res.json()); + } catch (error) { + throw new Error(`Exa API returned invalid JSON: ${String(error)}`, { cause: error }); + } + }, + ); +} + +function missingExaKeyPayload() { + return { + error: "missing_exa_api_key", + message: + "web_search (exa) needs an Exa API key. Set EXA_API_KEY in the Gateway environment, or configure tools.web.search.exa.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; +} + +export async function executeExaWebSearchProviderTool( + ctx: { config?: Record; searchConfig?: SearchConfigRecord }, + args: Record, +): Promise> { + const searchConfig = mergeScopedSearchConfig( + ctx.searchConfig, + "exa", + resolveProviderWebSearchPluginConfig(ctx.config, "exa"), + ) as SearchConfigRecord | undefined; + const params = args; + const exaConfig = resolveExaConfig(searchConfig); + const apiKey = resolveExaApiKey(exaConfig); + if (!apiKey) { + return missingExaKeyPayload(); + } + + const query = readStringParam(params, "query", { required: true }); + const rawType = readStringParam(params, "type"); + const type: ExaSearchType = EXA_SEARCH_TYPES.includes(rawType as ExaSearchType) + ? (rawType as ExaSearchType) + : "auto"; + const count = + readNumberParam(params, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined; + const rawFreshness = readStringParam(params, "freshness"); + const freshness = normalizeExaFreshness(rawFreshness); + if (rawFreshness && !freshness) { + return { + error: "invalid_freshness", + message: 'freshness must be one of "day", "week", "month", or "year".', + docs: "https://docs.openclaw.ai/tools/web", + }; + } + + const rawDateAfter = readStringParam(params, "date_after"); + const rawDateBefore = readStringParam(params, "date_before"); + if (freshness && (rawDateAfter || rawDateBefore)) { + return { + error: "conflicting_time_filters", + message: + "freshness cannot be combined with date_after or date_before. Use one time-filter mode.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + const parsedDateRange = parseIsoDateRange({ + rawDateAfter, + rawDateBefore, + invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.", + invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.", + invalidDateRangeMessage: "date_after must be earlier than or equal to date_before.", + }); + if ("error" in parsedDateRange) { + return parsedDateRange; + } + const { dateAfter, dateBefore } = parsedDateRange; + + const parsedContents = parseExaContents(params.contents); + if (isErrorPayload(parsedContents)) { + return parsedContents; + } + const contents = + parsedContents.value && Object.keys(parsedContents.value).length > 0 + ? parsedContents.value + : undefined; + + const cacheKey = buildSearchCacheKey([ + "exa", + type, + query, + resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT), + freshness, + dateAfter, + dateBefore, + contents?.highlights ? JSON.stringify(contents.highlights) : undefined, + contents?.text ? JSON.stringify(contents.text) : undefined, + contents?.summary ? JSON.stringify(contents.summary) : undefined, + ]); + const cached = readCachedSearchPayload(cacheKey); + if (cached) { + return cached; + } + + const start = Date.now(); + const results = await runExaSearch({ + apiKey, + query, + count: resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT), + freshness, + dateAfter, + dateBefore, + type, + contents, + timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), + }); + + const payload = { + query, + provider: "exa", + count: results.length, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: "exa", + wrapped: true, + }, + results: results.map((entry) => { + const title = typeof entry.title === "string" ? entry.title : ""; + const url = typeof entry.url === "string" ? entry.url : ""; + const description = resolveExaDescription(entry); + const summary = normalizeOptionalString(entry.summary) ?? ""; + const highlightScores = Array.isArray(entry.highlightScores) + ? entry.highlightScores.filter( + (score): score is number => typeof score === "number" && Number.isFinite(score), + ) + : []; + const published = + typeof entry.publishedDate === "string" && entry.publishedDate + ? entry.publishedDate + : undefined; + return { + title: title ? wrapWebContent(title, "web_search") : "", + url, + description: description ? wrapWebContent(description, "web_search") : "", + published, + siteName: resolveSiteName(url) || undefined, + ...(summary ? { summary: wrapWebContent(summary, "web_search") } : {}), + ...(highlightScores.length > 0 ? { highlightScores } : {}), + }; + }), + }; + + writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); + return payload; +} + +export const __testing = { + normalizeExaResults, + normalizeExaFreshness, + parseExaContents, + resolveExaApiKey, + resolveExaConfig, + resolveExaDescription, + resolveExaSearchCount, + resolveFreshnessStartDate, +} as const; diff --git a/extensions/exa/src/exa-web-search-provider.test.ts b/extensions/exa/src/exa-web-search-provider.test.ts index 9de72bc89f2..66511180d00 100644 --- a/extensions/exa/src/exa-web-search-provider.test.ts +++ b/extensions/exa/src/exa-web-search-provider.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; +import { __testing } from "../test-api.js"; import { createExaWebSearchProvider as createContractExaWebSearchProvider } from "../web-search-contract-api.js"; -import { __testing, createExaWebSearchProvider } from "./exa-web-search-provider.js"; +import { createExaWebSearchProvider } from "./exa-web-search-provider.js"; describe("exa web search provider", () => { it("exposes the expected metadata and selection wiring", () => { diff --git a/extensions/exa/src/exa-web-search-provider.ts b/extensions/exa/src/exa-web-search-provider.ts index a4a908bb625..df32eb605f9 100644 --- a/extensions/exa/src/exa-web-search-provider.ts +++ b/extensions/exa/src/exa-web-search-provider.ts @@ -1,594 +1,61 @@ -import { Type } from "@sinclair/typebox"; import { - buildSearchCacheKey, - DEFAULT_SEARCH_COUNT, - enablePluginInConfig, - getScopedCredentialValue, - mergeScopedSearchConfig, - parseIsoDateRange, - readCachedSearchPayload, - readConfiguredSecretString, - readNumberParam, - readProviderEnvValue, - readStringParam, - resolveProviderWebSearchPluginConfig, - resolveSearchCacheTtlMs, - resolveSearchTimeoutSeconds, - resolveSiteName, - setProviderWebSearchPluginConfigValue, - setScopedCredentialValue, - type SearchConfigRecord, + createWebSearchProviderContractFields, type WebSearchProviderPlugin, - type WebSearchProviderToolDefinition, - withTrustedWebSearchEndpoint, - wrapWebContent, - writeCachedSearchPayload, -} from "openclaw/plugin-sdk/provider-web-search"; -import { - normalizeOptionalLowercaseString, - normalizeOptionalString, -} from "openclaw/plugin-sdk/text-runtime"; +} from "openclaw/plugin-sdk/provider-web-search-contract"; -const EXA_SEARCH_ENDPOINT = "https://api.exa.ai/search"; +const EXA_CREDENTIAL_PATH = "plugins.entries.exa.config.webSearch.apiKey"; const EXA_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "instant"] as const; const EXA_FRESHNESS_VALUES = ["day", "week", "month", "year"] as const; const EXA_MAX_SEARCH_COUNT = 100; -type ExaConfig = { - apiKey?: string; -}; - -type ExaSearchType = (typeof EXA_SEARCH_TYPES)[number]; -type ExaFreshness = (typeof EXA_FRESHNESS_VALUES)[number]; - -type ExaTextContentsOption = boolean | { maxCharacters?: number }; -type ExaHighlightsContentsOption = - | boolean - | { - maxCharacters?: number; - query?: string; - numSentences?: number; - highlightsPerUrl?: number; - }; -type ExaSummaryContentsOption = boolean | { query?: string }; - -type ExaContentsArgs = { - highlights?: ExaHighlightsContentsOption; - text?: ExaTextContentsOption; - summary?: ExaSummaryContentsOption; -}; - -type ExaSearchResult = { - title?: unknown; - url?: unknown; - publishedDate?: unknown; - highlights?: unknown; - highlightScores?: unknown; - summary?: unknown; - text?: unknown; -}; - -type ExaSearchResponse = { - results?: unknown; -}; - -function normalizeExaFreshness(value: string | undefined): ExaFreshness | undefined { - const trimmed = normalizeOptionalLowercaseString(value); - if (!trimmed) { - return undefined; - } - return EXA_FRESHNESS_VALUES.includes(trimmed as ExaFreshness) - ? (trimmed as ExaFreshness) - : undefined; -} - -function optionalStringEnum(values: T, description: string) { - return Type.Optional( - Type.Unsafe({ +const ExaSearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-100, subject to Exa search-type limits).", + minimum: 1, + maximum: EXA_MAX_SEARCH_COUNT, + }, + freshness: { type: "string", - enum: [...values], - description, - }), - ); -} - -function resolveExaConfig(searchConfig?: SearchConfigRecord): ExaConfig { - const exa = searchConfig?.exa; - return exa && typeof exa === "object" && !Array.isArray(exa) ? (exa as ExaConfig) : {}; -} - -function resolveExaApiKey(exa?: ExaConfig): string | undefined { - return ( - readConfiguredSecretString(exa?.apiKey, "tools.web.search.exa.apiKey") ?? - readProviderEnvValue(["EXA_API_KEY"]) - ); -} - -function resolveExaDescription(result: ExaSearchResult): string { - const highlights = result.highlights; - if (Array.isArray(highlights)) { - const highlightText = highlights - .map((entry) => normalizeOptionalString(entry)) - .filter((entry): entry is string => Boolean(entry)) - .join("\n"); - if (highlightText) { - return highlightText; - } - } - const summary = normalizeOptionalString(result.summary); - if (summary) { - return summary; - } - return normalizeOptionalString(result.text) ?? ""; -} - -function parsePositiveInteger(value: unknown): number | undefined { - return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined; -} - -function invalidContentsPayload(message: string) { - return { - error: "invalid_contents", - message, - docs: "https://docs.openclaw.ai/tools/web", - }; -} - -function isErrorPayload(value: unknown): value is { error: string; message: string; docs: string } { - return Boolean( - value && typeof value === "object" && "error" in value && "message" in value && "docs" in value, - ); -} - -function resolveExaSearchCount(value: unknown, fallback: number): number { - const parsed = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(parsed)) { - return fallback; - } - return Math.max(1, Math.min(EXA_MAX_SEARCH_COUNT, Math.floor(parsed))); -} - -function parseExaContents( - rawContents: unknown, -): { value?: ExaContentsArgs } | { error: string; message: string; docs: string } { - if (rawContents === undefined) { - return { value: undefined }; - } - if (!rawContents || typeof rawContents !== "object" || Array.isArray(rawContents)) { - return invalidContentsPayload( - "contents must be an object with optional text, highlights, and summary fields.", - ); - } - - const raw = rawContents as Record; - const allowedKeys = new Set(["text", "highlights", "summary"]); - for (const key of Object.keys(raw)) { - if (!allowedKeys.has(key)) { - return invalidContentsPayload( - `contents has unknown field "${key}". Only "text", "highlights", and "summary" are allowed.`, - ); - } - } - - const parsed: ExaContentsArgs = {}; - - const parseText = ( - value: unknown, - ): ExaTextContentsOption | { error: string; message: string; docs: string } => { - if (typeof value === "boolean") { - return value; - } - if (!value || typeof value !== "object" || Array.isArray(value)) { - return invalidContentsPayload("contents.text must be a boolean or an object."); - } - const obj = value as Record; - for (const key of Object.keys(obj)) { - if (key !== "maxCharacters") { - return invalidContentsPayload( - `contents.text has unknown field "${key}". Only "maxCharacters" is allowed.`, - ); - } - } - if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) { - return invalidContentsPayload("contents.text.maxCharacters must be a positive integer."); - } - return parsePositiveInteger(obj.maxCharacters) - ? { maxCharacters: parsePositiveInteger(obj.maxCharacters) } - : {}; - }; - - const parseHighlights = ( - value: unknown, - ): ExaHighlightsContentsOption | { error: string; message: string; docs: string } => { - if (typeof value === "boolean") { - return value; - } - if (!value || typeof value !== "object" || Array.isArray(value)) { - return invalidContentsPayload("contents.highlights must be a boolean or an object."); - } - const obj = value as Record; - const allowed = new Set(["maxCharacters", "query", "numSentences", "highlightsPerUrl"]); - for (const key of Object.keys(obj)) { - if (!allowed.has(key)) { - return invalidContentsPayload( - `contents.highlights has unknown field "${key}". Allowed fields are "maxCharacters", "query", "numSentences", and "highlightsPerUrl".`, - ); - } - } - if ("maxCharacters" in obj && parsePositiveInteger(obj.maxCharacters) === undefined) { - return invalidContentsPayload( - "contents.highlights.maxCharacters must be a positive integer.", - ); - } - if ("numSentences" in obj && parsePositiveInteger(obj.numSentences) === undefined) { - return invalidContentsPayload("contents.highlights.numSentences must be a positive integer."); - } - if ("highlightsPerUrl" in obj && parsePositiveInteger(obj.highlightsPerUrl) === undefined) { - return invalidContentsPayload( - "contents.highlights.highlightsPerUrl must be a positive integer.", - ); - } - if ("query" in obj && typeof obj.query !== "string") { - return invalidContentsPayload("contents.highlights.query must be a string."); - } - return { - ...(parsePositiveInteger(obj.maxCharacters) - ? { maxCharacters: parsePositiveInteger(obj.maxCharacters) } - : {}), - ...(typeof obj.query === "string" ? { query: obj.query } : {}), - ...(parsePositiveInteger(obj.numSentences) - ? { numSentences: parsePositiveInteger(obj.numSentences) } - : {}), - ...(parsePositiveInteger(obj.highlightsPerUrl) - ? { highlightsPerUrl: parsePositiveInteger(obj.highlightsPerUrl) } - : {}), - }; - }; - - const parseSummary = ( - value: unknown, - ): ExaSummaryContentsOption | { error: string; message: string; docs: string } => { - if (typeof value === "boolean") { - return value; - } - if (!value || typeof value !== "object" || Array.isArray(value)) { - return invalidContentsPayload("contents.summary must be a boolean or an object."); - } - const obj = value as Record; - for (const key of Object.keys(obj)) { - if (key !== "query") { - return invalidContentsPayload( - `contents.summary has unknown field "${key}". Only "query" is allowed.`, - ); - } - } - if ("query" in obj && typeof obj.query !== "string") { - return invalidContentsPayload("contents.summary.query must be a string."); - } - return typeof obj.query === "string" ? { query: obj.query } : {}; - }; - - if ("text" in raw) { - const parsedText = parseText(raw.text); - if (isErrorPayload(parsedText)) { - return parsedText; - } - parsed.text = parsedText; - } - if ("highlights" in raw) { - const parsedHighlights = parseHighlights(raw.highlights); - if (isErrorPayload(parsedHighlights)) { - return parsedHighlights; - } - parsed.highlights = parsedHighlights; - } - if ("summary" in raw) { - const parsedSummary = parseSummary(raw.summary); - if (isErrorPayload(parsedSummary)) { - return parsedSummary; - } - parsed.summary = parsedSummary; - } - - return { value: parsed }; -} - -function normalizeExaResults(payload: unknown): ExaSearchResult[] { - if (!payload || typeof payload !== "object") { - return []; - } - const results = (payload as ExaSearchResponse).results; - if (!Array.isArray(results)) { - return []; - } - return results.filter((entry): entry is ExaSearchResult => - Boolean(entry && typeof entry === "object" && !Array.isArray(entry)), - ); -} - -function resolveFreshnessStartDate(freshness: ExaFreshness): string { - const now = new Date(); - if (freshness === "day") { - now.setUTCDate(now.getUTCDate() - 1); - return now.toISOString(); - } - if (freshness === "week") { - now.setUTCDate(now.getUTCDate() - 7); - return now.toISOString(); - } - if (freshness === "month") { - const currentDay = now.getUTCDate(); - now.setUTCDate(1); - now.setUTCMonth(now.getUTCMonth() - 1); - const lastDayOfTargetMonth = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 0), - ).getUTCDate(); - now.setUTCDate(Math.min(currentDay, lastDayOfTargetMonth)); - return now.toISOString(); - } - now.setUTCFullYear(now.getUTCFullYear() - 1); - return now.toISOString(); -} - -async function runExaSearch(params: { - apiKey: string; - query: string; - count: number; - freshness?: ExaFreshness; - dateAfter?: string; - dateBefore?: string; - type: ExaSearchType; - contents?: ExaContentsArgs; - timeoutSeconds: number; -}): Promise { - const body: Record = { - query: params.query, - numResults: params.count, - type: params.type, - contents: params.contents ?? { highlights: true }, - }; - - if (params.dateAfter) { - body.startPublishedDate = params.dateAfter; - } else if (params.freshness) { - body.startPublishedDate = resolveFreshnessStartDate(params.freshness); - } - if (params.dateBefore) { - body.endPublishedDate = params.dateBefore; - } - - return withTrustedWebSearchEndpoint( - { - url: EXA_SEARCH_ENDPOINT, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - "x-api-key": params.apiKey, - "x-exa-integration": "openclaw", - }, - body: JSON.stringify(body), - }, + enum: [...EXA_FRESHNESS_VALUES], + description: 'Filter by time: "day", "week", "month", or "year".', }, - async (res) => { - if (!res.ok) { - const detail = await res.text(); - throw new Error(`Exa API error (${res.status}): ${detail || res.statusText}`); - } - try { - return normalizeExaResults(await res.json()); - } catch (error) { - throw new Error(`Exa API returned invalid JSON: ${String(error)}`, { cause: error }); - } + date_after: { + type: "string", + description: "Only results published after this date (YYYY-MM-DD).", }, - ); -} - -function createExaSchema() { - return Type.Object( - { - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-100, subject to Exa search-type limits).", - minimum: 1, - maximum: EXA_MAX_SEARCH_COUNT, - }), - ), - freshness: optionalStringEnum( - EXA_FRESHNESS_VALUES, - 'Filter by time: "day", "week", "month", or "year".', - ), - date_after: Type.Optional( - Type.String({ - description: "Only results published after this date (YYYY-MM-DD).", - }), - ), - date_before: Type.Optional( - Type.String({ - description: "Only results published before this date (YYYY-MM-DD).", - }), - ), - type: optionalStringEnum( - EXA_SEARCH_TYPES, + date_before: { + type: "string", + description: "Only results published before this date (YYYY-MM-DD).", + }, + type: { + type: "string", + enum: [...EXA_SEARCH_TYPES], + description: 'Exa search mode: "auto", "neural", "fast", "deep", "deep-reasoning", or "instant".', - ), - contents: Type.Optional( - Type.Object( - { - highlights: Type.Optional( - Type.Unsafe({ - description: - "Highlights config: true, or an object with maxCharacters, query, numSentences, or highlightsPerUrl.", - }), - ), - text: Type.Optional( - Type.Unsafe({ - description: "Text config: true, or an object with maxCharacters.", - }), - ), - summary: Type.Optional( - Type.Unsafe({ - description: "Summary config: true, or an object with query.", - }), - ), - }, - { additionalProperties: false }, - ), - ), }, - { additionalProperties: false }, - ); -} - -function missingExaKeyPayload() { - return { - error: "missing_exa_api_key", - message: - "web_search (exa) needs an Exa API key. Set EXA_API_KEY in the Gateway environment, or configure tools.web.search.exa.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; -} - -function createExaToolDefinition( - searchConfig?: SearchConfigRecord, -): WebSearchProviderToolDefinition { - return { - description: - "Search the web using Exa AI. Supports neural or keyword search, publication date filters, and optional highlights or text extraction.", - parameters: createExaSchema(), - execute: async (args) => { - const params = args; - const exaConfig = resolveExaConfig(searchConfig); - const apiKey = resolveExaApiKey(exaConfig); - if (!apiKey) { - return missingExaKeyPayload(); - } - - const query = readStringParam(params, "query", { required: true }); - const rawType = readStringParam(params, "type"); - const type: ExaSearchType = EXA_SEARCH_TYPES.includes(rawType as ExaSearchType) - ? (rawType as ExaSearchType) - : "auto"; - const count = - readNumberParam(params, "count", { integer: true }) ?? - searchConfig?.maxResults ?? - undefined; - const rawFreshness = readStringParam(params, "freshness"); - const freshness = normalizeExaFreshness(rawFreshness); - if (rawFreshness && !freshness) { - return { - error: "invalid_freshness", - message: 'freshness must be one of "day", "week", "month", or "year".', - docs: "https://docs.openclaw.ai/tools/web", - }; - } - - const rawDateAfter = readStringParam(params, "date_after"); - const rawDateBefore = readStringParam(params, "date_before"); - if (freshness && (rawDateAfter || rawDateBefore)) { - return { - error: "conflicting_time_filters", - message: - "freshness cannot be combined with date_after or date_before. Use one time-filter mode.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - const parsedDateRange = parseIsoDateRange({ - rawDateAfter, - rawDateBefore, - invalidDateAfterMessage: "date_after must be YYYY-MM-DD format.", - invalidDateBeforeMessage: "date_before must be YYYY-MM-DD format.", - invalidDateRangeMessage: "date_after must be earlier than or equal to date_before.", - }); - if ("error" in parsedDateRange) { - return parsedDateRange; - } - const { dateAfter, dateBefore } = parsedDateRange; - - const parsedContents = parseExaContents(params.contents); - if (isErrorPayload(parsedContents)) { - return parsedContents; - } - const contents = - parsedContents.value && Object.keys(parsedContents.value).length > 0 - ? parsedContents.value - : undefined; - - const cacheKey = buildSearchCacheKey([ - "exa", - type, - query, - resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT), - freshness, - dateAfter, - dateBefore, - contents?.highlights ? JSON.stringify(contents.highlights) : undefined, - contents?.text ? JSON.stringify(contents.text) : undefined, - contents?.summary ? JSON.stringify(contents.summary) : undefined, - ]); - const cached = readCachedSearchPayload(cacheKey); - if (cached) { - return cached; - } - - const start = Date.now(); - const results = await runExaSearch({ - apiKey, - query, - count: resolveExaSearchCount(count, DEFAULT_SEARCH_COUNT), - freshness, - dateAfter, - dateBefore, - type, - contents, - timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), - }); - - const payload = { - query, - provider: "exa", - count: results.length, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: "exa", - wrapped: true, + contents: { + type: "object", + properties: { + highlights: { + description: + "Highlights config: true, or an object with maxCharacters, query, numSentences, or highlightsPerUrl.", }, - results: results.map((entry) => { - const title = typeof entry.title === "string" ? entry.title : ""; - const url = typeof entry.url === "string" ? entry.url : ""; - const description = resolveExaDescription(entry); - const summary = normalizeOptionalString(entry.summary) ?? ""; - const highlightScores = Array.isArray(entry.highlightScores) - ? entry.highlightScores.filter( - (score): score is number => typeof score === "number" && Number.isFinite(score), - ) - : []; - const published = - typeof entry.publishedDate === "string" && entry.publishedDate - ? entry.publishedDate - : undefined; - return { - title: title ? wrapWebContent(title, "web_search") : "", - url, - description: description ? wrapWebContent(description, "web_search") : "", - published, - siteName: resolveSiteName(url) || undefined, - ...(summary ? { summary: wrapWebContent(summary, "web_search") } : {}), - ...(highlightScores.length > 0 ? { highlightScores } : {}), - }; - }), - }; - - writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); - return payload; + text: { + description: "Text config: true, or an object with maxCharacters.", + }, + summary: { + description: "Summary config: true, or an object with query.", + }, + }, + additionalProperties: false, }, - }; -} + }, + additionalProperties: false, +} satisfies Record; export function createExaWebSearchProvider(): WebSearchProviderPlugin { return { @@ -602,35 +69,22 @@ export function createExaWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://exa.ai/", docsUrl: "https://docs.openclaw.ai/tools/web", autoDetectOrder: 65, - credentialPath: "plugins.entries.exa.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.exa.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "exa"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "exa", value), - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "exa")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "exa", "apiKey", value); - }, - applySelectionConfig: (config) => enablePluginInConfig(config, "exa").config, - createTool: (ctx) => - createExaToolDefinition( - mergeScopedSearchConfig( - ctx.searchConfig, - "exa", - resolveProviderWebSearchPluginConfig(ctx.config, "exa"), - ), - ), + credentialPath: EXA_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: EXA_CREDENTIAL_PATH, + searchCredential: { type: "scoped", scopeId: "exa" }, + configuredCredential: { pluginId: "exa" }, + selectionPluginId: "exa", + }), + createTool: (ctx) => ({ + description: + "Search the web using Exa AI. Supports neural or keyword search, publication date filters, and optional highlights or text extraction.", + parameters: ExaSearchSchema, + execute: async (args) => { + const { executeExaWebSearchProviderTool } = + await import("./exa-web-search-provider.runtime.js"); + return await executeExaWebSearchProviderTool(ctx, args); + }, + }), }; } - -export const __testing = { - normalizeExaResults, - normalizeExaFreshness, - parseExaContents, - resolveExaApiKey, - resolveExaConfig, - resolveExaDescription, - resolveExaSearchCount, - resolveFreshnessStartDate, -} as const; diff --git a/extensions/exa/test-api.ts b/extensions/exa/test-api.ts new file mode 100644 index 00000000000..8ce2f5e0e80 --- /dev/null +++ b/extensions/exa/test-api.ts @@ -0,0 +1 @@ +export { __testing } from "./src/exa-web-search-provider.runtime.js"; diff --git a/extensions/exa/web-search-provider.ts b/extensions/exa/web-search-provider.ts index 88802359652..55302ff6ebe 100644 --- a/extensions/exa/web-search-provider.ts +++ b/extensions/exa/web-search-provider.ts @@ -1 +1 @@ -export { __testing, createExaWebSearchProvider } from "./src/exa-web-search-provider.js"; +export { createExaWebSearchProvider } from "./src/exa-web-search-provider.js"; diff --git a/extensions/fal/index.ts b/extensions/fal/index.ts index 87cedfadfc2..3d38d821272 100644 --- a/extensions/fal/index.ts +++ b/extensions/fal/index.ts @@ -1,7 +1,6 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import { buildFalImageGenerationProvider } from "./image-generation-provider.js"; -import { applyFalConfig, FAL_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js"; +import { createFalProvider } from "./provider-registration.js"; import { buildFalVideoGenerationProvider } from "./video-generation-provider.js"; const PROVIDER_ID = "fal"; @@ -11,36 +10,7 @@ export default definePluginEntry({ name: "fal Provider", description: "Bundled fal image and video generation provider", register(api) { - api.registerProvider({ - id: PROVIDER_ID, - label: "fal", - docsPath: "/providers/models", - envVars: ["FAL_KEY"], - auth: [ - createProviderApiKeyAuthMethod({ - providerId: PROVIDER_ID, - methodId: "api-key", - label: "fal API key", - hint: "Image and video generation API key", - optionKey: "falApiKey", - flagName: "--fal-api-key", - envVar: "FAL_KEY", - promptMessage: "Enter fal API key", - defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF, - expectedProviders: ["fal"], - applyConfig: (cfg) => applyFalConfig(cfg), - wizard: { - choiceId: "fal-api-key", - choiceLabel: "fal API key", - choiceHint: "Image and video generation API key", - groupId: "fal", - groupLabel: "fal", - groupHint: "Image and video generation", - onboardingScopes: ["image-generation"], - }, - }), - ], - }); + api.registerProvider(createFalProvider()); api.registerImageGenerationProvider(buildFalImageGenerationProvider()); api.registerVideoGenerationProvider(buildFalVideoGenerationProvider()); }, diff --git a/extensions/fal/provider-contract-api.ts b/extensions/fal/provider-contract-api.ts new file mode 100644 index 00000000000..66fbf2fd532 --- /dev/null +++ b/extensions/fal/provider-contract-api.ts @@ -0,0 +1,31 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +const PROVIDER_ID = "fal"; +const FAL_DEFAULT_IMAGE_MODEL_REF = "fal/fal-ai/flux/dev"; + +export function createFalProvider(): ProviderPlugin { + return { + id: PROVIDER_ID, + label: "fal", + docsPath: "/providers/models", + envVars: ["FAL_KEY"], + auth: [ + { + id: "api-key", + kind: "api_key", + label: "fal API key", + hint: "Image and video generation API key", + run: async () => ({ profiles: [], defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF }), + wizard: { + choiceId: "fal-api-key", + choiceLabel: "fal API key", + choiceHint: "Image and video generation API key", + groupId: "fal", + groupLabel: "fal", + groupHint: "Image and video generation", + onboardingScopes: ["image-generation"], + }, + }, + ], + }; +} diff --git a/extensions/fal/provider-registration.ts b/extensions/fal/provider-registration.ts new file mode 100644 index 00000000000..d62c879f444 --- /dev/null +++ b/extensions/fal/provider-registration.ts @@ -0,0 +1,38 @@ +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; +import { applyFalConfig, FAL_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js"; + +const PROVIDER_ID = "fal"; + +export function createFalProvider(): ProviderPlugin { + return { + id: PROVIDER_ID, + label: "fal", + docsPath: "/providers/models", + envVars: ["FAL_KEY"], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "fal API key", + hint: "Image and video generation API key", + optionKey: "falApiKey", + flagName: "--fal-api-key", + envVar: "FAL_KEY", + promptMessage: "Enter fal API key", + defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF, + expectedProviders: ["fal"], + applyConfig: (cfg) => applyFalConfig(cfg), + wizard: { + choiceId: "fal-api-key", + choiceLabel: "fal API key", + choiceHint: "Image and video generation API key", + groupId: "fal", + groupLabel: "fal", + groupHint: "Image and video generation", + onboardingScopes: ["image-generation"], + }, + }), + ], + }; +} diff --git a/extensions/feishu/src/bot.card-action.test.ts b/extensions/feishu/src/bot.card-action.test.ts index 783342ff979..83bd583fb8f 100644 --- a/extensions/feishu/src/bot.card-action.test.ts +++ b/extensions/feishu/src/bot.card-action.test.ts @@ -25,9 +25,14 @@ vi.mock("./bot.js", () => ({ handleFeishuMessage: vi.fn(), })); +const createFeishuClientMock = vi.hoisted(() => vi.fn()); const sendCardFeishuMock = vi.hoisted(() => vi.fn()); const sendMessageFeishuMock = vi.hoisted(() => vi.fn()); +vi.mock("./client.js", () => ({ + createFeishuClient: createFeishuClientMock, +})); + vi.mock("./send.js", () => ({ sendCardFeishu: sendCardFeishuMock, sendMessageFeishu: sendMessageFeishuMock, @@ -89,6 +94,13 @@ describe("Feishu Card Action Handler", () => { beforeEach(() => { vi.clearAllMocks(); + createFeishuClientMock.mockReset().mockReturnValue({ + im: { + chat: { + get: vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "group" } }), + }, + }, + }); vi.mocked(handleFeishuMessage) .mockReset() .mockResolvedValue(undefined as never); @@ -354,6 +366,142 @@ describe("Feishu Card Action Handler", () => { ); }); + it("resolves DM chat type from the Feishu chat API when card context omits it", async () => { + createFeishuClientMock.mockReturnValueOnce({ + im: { + chat: { + get: vi.fn().mockResolvedValue({ code: 0, data: { chat_type: "p2p" } }), + }, + }, + }); + const event = createCardActionEvent({ + token: "tok9b", + chatId: "oc_dm_chat_123", + actionValue: { text: "/help" }, + }); + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + chat_id: "oc_dm_chat_123", + chat_type: "p2p", + }), + }), + }), + ); + expect(createFeishuClientMock).toHaveBeenCalledTimes(1); + }); + + it("uses resolved DM chat type when building approval cards without stored context", async () => { + createFeishuClientMock.mockReturnValueOnce({ + im: { + chat: { + get: vi.fn().mockResolvedValue({ code: 0, data: { chat_mode: "p2p" } }), + }, + }, + }); + const event = createCardActionEvent({ + token: "tok9c", + chatId: "oc_dm_chat_234", + actionValue: createFeishuCardInteractionEnvelope({ + k: "meta", + a: FEISHU_APPROVAL_REQUEST_ACTION, + m: { + command: "/new", + prompt: "Start a fresh session?", + }, + c: { + u: "u123", + h: "oc_dm_chat_234", + e: Date.now() + 60_000, + }, + }), + }); + + await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" }); + + expect(sendCardFeishuMock).toHaveBeenCalledWith( + expect.objectContaining({ + card: expect.objectContaining({ + body: expect.objectContaining({ + elements: expect.arrayContaining([ + expect.objectContaining({ + tag: "action", + actions: expect.arrayContaining([ + expect.objectContaining({ + value: expect.objectContaining({ + c: expect.objectContaining({ + t: "p2p", + }), + }), + }), + ]), + }), + ]), + }), + }), + }), + ); + expect(createFeishuClientMock).toHaveBeenCalledTimes(1); + }); + + it("falls back to p2p when Feishu chat API returns an error", async () => { + createFeishuClientMock.mockReturnValueOnce({ + im: { + chat: { + get: vi.fn().mockResolvedValue({ code: 99, msg: "not found" }), + }, + }, + }); + const event = createCardActionEvent({ + token: "tok9d", + chatId: "oc_unknown_chat_456", + actionValue: { text: "/help" }, + }); + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + chat_type: "p2p", + }), + }), + }), + ); + }); + + it("falls back to p2p when Feishu chat API throws", async () => { + createFeishuClientMock.mockReturnValueOnce({ + im: { + chat: { + get: vi.fn().mockRejectedValue(new Error("network failure")), + }, + }, + }); + const event = createCardActionEvent({ + token: "tok9e", + chatId: "oc_broken_chat_789", + actionValue: { text: "/help" }, + }); + + await handleFeishuCardAction({ cfg, event, runtime }); + + expect(handleFeishuMessage).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + message: expect.objectContaining({ + chat_type: "p2p", + }), + }), + }), + ); + }); + it("drops duplicate structured callback tokens", async () => { const event = createStructuredQuickActionEvent({ token: "tok10", diff --git a/extensions/feishu/src/card-action.ts b/extensions/feishu/src/card-action.ts index dc88d51cadb..a47bbb150a6 100644 --- a/extensions/feishu/src/card-action.ts +++ b/extensions/feishu/src/card-action.ts @@ -8,6 +8,7 @@ import { FEISHU_APPROVAL_CONFIRM_ACTION, FEISHU_APPROVAL_REQUEST_ACTION, } from "./card-ux-approval.js"; +import { createFeishuClient } from "./client.js"; import { sendCardFeishu, sendMessageFeishu } from "./send.js"; export type FeishuCardActionEvent = { @@ -104,7 +105,7 @@ function releaseFeishuCardActionToken(params: { token: string; accountId: string function buildSyntheticMessageEvent( event: FeishuCardActionEvent, content: string, - chatType?: "p2p" | "group", + chatType: "p2p" | "group", ): FeishuMessageEvent { return { sender: { @@ -117,7 +118,7 @@ function buildSyntheticMessageEvent( message: { message_id: `card-action-${event.token}`, chat_id: event.context.chat_id || event.operator.open_id, - chat_type: chatType ?? (event.context.chat_id ? "group" : "p2p"), + chat_type: chatType, message_type: "text", content: JSON.stringify({ text: content }), }, @@ -136,20 +137,127 @@ async function dispatchSyntheticCommand(params: { cfg: ClawdbotConfig; event: FeishuCardActionEvent; command: string; + account: ReturnType; botOpenId?: string; runtime?: RuntimeEnv; accountId?: string; chatType?: "p2p" | "group"; }): Promise { + const resolvedChatType = await resolveCardActionChatType({ + event: params.event, + account: params.account, + chatType: params.chatType, + log: params.runtime?.log ?? console.log, + }); await handleFeishuMessage({ cfg: params.cfg, - event: buildSyntheticMessageEvent(params.event, params.command, params.chatType), + event: buildSyntheticMessageEvent(params.event, params.command, resolvedChatType), botOpenId: params.botOpenId, runtime: params.runtime, accountId: params.accountId, }); } +// Feishu's im.chat.get returns two fields: +// chat_mode: conversation type — "p2p" | "group" | "topic" +// chat_type: privacy classification — "private" | "public" +// We check chat_mode first because it directly indicates conversation type. +// "private" maps to "p2p" as the safe-failure direction (restrictive DM +// policy) — a private group chat misclassified as p2p is safer than the +// reverse. "topic" and "public" are treated as group semantics. +function normalizeResolvedCardActionChatType(value: unknown): "p2p" | "group" | undefined { + if (value === "group" || value === "topic" || value === "public") { + return "group"; + } + if (value === "p2p" || value === "private") { + return "p2p"; + } + return undefined; +} + +const resolvedChatTypeCache = new Map(); +const CHAT_TYPE_CACHE_TTL_MS = 30 * 60_000; +const CHAT_TYPE_CACHE_MAX_SIZE = 5_000; + +function pruneChatTypeCache(now: number): void { + for (const [key, entry] of resolvedChatTypeCache.entries()) { + if (entry.expiresAt <= now) { + resolvedChatTypeCache.delete(key); + } + } + if (resolvedChatTypeCache.size > CHAT_TYPE_CACHE_MAX_SIZE) { + const excess = resolvedChatTypeCache.size - CHAT_TYPE_CACHE_MAX_SIZE; + const iter = resolvedChatTypeCache.keys(); + for (let i = 0; i < excess; i++) { + const key = iter.next().value; + if (key !== undefined) { + resolvedChatTypeCache.delete(key); + } + } + } +} + +function sanitizeLogValue(v: string): string { + return v.replace(/[\r\n]/g, " ").slice(0, 500); +} + +async function resolveCardActionChatType(params: { + event: FeishuCardActionEvent; + account: ReturnType; + chatType?: "p2p" | "group"; + log: (message: string) => void; +}): Promise<"p2p" | "group"> { + const explicitChatType = normalizeResolvedCardActionChatType(params.chatType); + if (explicitChatType) { + return explicitChatType; + } + + const chatId = params.event.context.chat_id?.trim(); + if (!chatId) { + return "p2p"; + } + + const cacheKey = `${params.account.accountId}:${chatId}`; + const now = Date.now(); + pruneChatTypeCache(now); + const cached = resolvedChatTypeCache.get(cacheKey); + if (cached) { + return cached.value; + } + + try { + const response = (await createFeishuClient(params.account).im.chat.get({ + path: { chat_id: chatId }, + })) as { code?: number; msg?: string; data?: { chat_type?: unknown; chat_mode?: unknown } }; + if (response.code === 0) { + const resolvedChatType = + normalizeResolvedCardActionChatType(response.data?.chat_mode) ?? + normalizeResolvedCardActionChatType(response.data?.chat_type); + if (resolvedChatType) { + resolvedChatTypeCache.set(cacheKey, { + value: resolvedChatType, + expiresAt: now + CHAT_TYPE_CACHE_TTL_MS, + }); + return resolvedChatType; + } + params.log( + `feishu[${params.account.accountId}]: card action missing chat type for chat; defaulting to p2p`, + ); + } else { + params.log( + `feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(response.msg ?? "unknown error")}; defaulting to p2p`, + ); + } + } catch (err) { + const message = err instanceof Error ? err.message : "unknown"; + params.log( + `feishu[${params.account.accountId}]: failed to resolve chat type: ${sanitizeLogValue(message)}; defaulting to p2p`, + ); + } + + return "p2p"; +} + async function sendInvalidInteractionNotice(params: { cfg: ClawdbotConfig; event: FeishuCardActionEvent; @@ -246,7 +354,12 @@ export async function handleFeishuCardAction(params: { prompt, sessionKey: envelope.c?.s, expiresAt: Date.now() + FEISHU_APPROVAL_CARD_TTL_MS, - chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"), + chatType: await resolveCardActionChatType({ + event, + account, + chatType: envelope.c?.t, + log, + }), confirmLabel: command === "/reset" ? "Reset" : "Confirm", }), accountId, @@ -282,10 +395,11 @@ export async function handleFeishuCardAction(params: { cfg, event, command, + account, botOpenId: params.botOpenId, runtime, accountId, - chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"), + chatType: envelope.c?.t, }); completeFeishuCardActionToken({ token: event.token, accountId: account.accountId }); return; @@ -311,6 +425,7 @@ export async function handleFeishuCardAction(params: { cfg, event, command: content, + account, botOpenId: params.botOpenId, runtime, accountId, diff --git a/extensions/firecrawl/src/firecrawl-search-provider.ts b/extensions/firecrawl/src/firecrawl-search-provider.ts index 6ba5cefa4ed..3f93fa2c738 100644 --- a/extensions/firecrawl/src/firecrawl-search-provider.ts +++ b/extensions/firecrawl/src/firecrawl-search-provider.ts @@ -1,27 +1,22 @@ -import { Type } from "@sinclair/typebox"; import { - enablePluginInConfig, - getScopedCredentialValue, - resolveProviderWebSearchPluginConfig, - setScopedCredentialValue, - setProviderWebSearchPluginConfigValue, + createWebSearchProviderContractFields, type WebSearchProviderPlugin, -} from "openclaw/plugin-sdk/provider-web-search"; -import { runFirecrawlSearch } from "./firecrawl-client.js"; +} from "openclaw/plugin-sdk/provider-web-search-contract"; -const GenericFirecrawlSearchSchema = Type.Object( - { - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-10).", - minimum: 1, - maximum: 10, - }), - ), +const FIRECRAWL_CREDENTIAL_PATH = "plugins.entries.firecrawl.config.webSearch.apiKey"; +const GenericFirecrawlSearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, + }, }, - { additionalProperties: false }, -); + additionalProperties: false, +} satisfies Record; export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin { return { @@ -35,27 +30,25 @@ export function createFirecrawlWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://www.firecrawl.dev/", docsUrl: "https://docs.openclaw.ai/tools/firecrawl", autoDetectOrder: 60, - credentialPath: "plugins.entries.firecrawl.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.firecrawl.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "firecrawl"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "firecrawl", value), - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "firecrawl")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "firecrawl", "apiKey", value); - }, - applySelectionConfig: (config) => enablePluginInConfig(config, "firecrawl").config, + credentialPath: FIRECRAWL_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: FIRECRAWL_CREDENTIAL_PATH, + searchCredential: { type: "scoped", scopeId: "firecrawl" }, + configuredCredential: { pluginId: "firecrawl" }, + selectionPluginId: "firecrawl", + }), createTool: (ctx) => ({ description: "Search the web using Firecrawl. Returns structured results with snippets from Firecrawl Search. Use firecrawl_search for Firecrawl-specific knobs like sources or categories.", parameters: GenericFirecrawlSearchSchema, - execute: async (args) => - await runFirecrawlSearch({ + execute: async (args) => { + const { runFirecrawlSearch } = await import("./firecrawl-client.js"); + return await runFirecrawlSearch({ cfg: ctx.config, query: typeof args.query === "string" ? args.query : "", count: typeof args.count === "number" ? args.count : undefined, - }), + }); + }, }), }; } diff --git a/extensions/google/api.ts b/extensions/google/api.ts index bb4939bc637..86987cb070a 100644 --- a/extensions/google/api.ts +++ b/extensions/google/api.ts @@ -2,11 +2,8 @@ import { resolveProviderHttpRequestConfig, type ProviderRequestTransportOverrides, } from "openclaw/plugin-sdk/provider-http"; -import { - applyAgentDefaultModelPrimary, - type OpenClawConfig, -} from "openclaw/plugin-sdk/provider-onboard"; import { parseGoogleOauthApiKey } from "./oauth-token-shared.js"; +export { applyGoogleGeminiModelDefault, GOOGLE_GEMINI_DEFAULT_MODEL } from "./onboard.js"; import { DEFAULT_GOOGLE_API_BASE_URL, normalizeGoogleApiBaseUrl, @@ -24,6 +21,8 @@ export { shouldNormalizeGoogleGenerativeAiProviderConfig, shouldNormalizeGoogleProviderConfig, } from "./provider-policy.js"; +export { buildGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; +export { buildGoogleProvider } from "./provider-registration.js"; export function parseGeminiAuth(apiKey: string): { headers: Record } { const parsed = apiKey.startsWith("{") ? parseGoogleOauthApiKey(apiKey) : null; @@ -88,27 +87,3 @@ export function resolveGoogleGenerativeAiHttpRequestConfig(params: { transport: params.transport, }); } - -export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview"; - -export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): { - next: OpenClawConfig; - changed: boolean; -} { - const current = cfg.agents?.defaults?.model as unknown; - const currentPrimary = - typeof current === "string" - ? current.trim() || undefined - : current && - typeof current === "object" && - typeof (current as { primary?: unknown }).primary === "string" - ? ((current as { primary: string }).primary || "").trim() || undefined - : undefined; - if (currentPrimary === GOOGLE_GEMINI_DEFAULT_MODEL) { - return { next: cfg, changed: false }; - } - return { - next: applyAgentDefaultModelPrimary(cfg, GOOGLE_GEMINI_DEFAULT_MODEL), - changed: true, - }; -} diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts index 3da20eed293..b564c716a40 100644 --- a/extensions/google/gemini-cli-provider.ts +++ b/extensions/google/gemini-cli-provider.ts @@ -4,6 +4,7 @@ import type { ProviderFetchUsageSnapshotContext, } from "openclaw/plugin-sdk/plugin-entry"; import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth-result"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools"; import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage"; import { formatGoogleOauthApiKey, parseGoogleUsageToken } from "./oauth-token-shared.js"; @@ -29,8 +30,8 @@ async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) { return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID); } -export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) { - api.registerProvider({ +export function buildGoogleGeminiCliProvider(): ProviderPlugin { + return { id: PROVIDER_ID, label: PROVIDER_LABEL, docsPath: "/providers/models", @@ -128,5 +129,9 @@ export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) { }; }, fetchUsageSnapshot: async (ctx) => await fetchGeminiCliUsage(ctx), - }); + }; +} + +export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) { + api.registerProvider(buildGoogleGeminiCliProvider()); } diff --git a/extensions/google/onboard.ts b/extensions/google/onboard.ts new file mode 100644 index 00000000000..5c6dd39d73f --- /dev/null +++ b/extensions/google/onboard.ts @@ -0,0 +1,28 @@ +import { + applyAgentDefaultModelPrimary, + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-onboard"; + +export const GOOGLE_GEMINI_DEFAULT_MODEL = "google/gemini-3.1-pro-preview"; + +export function applyGoogleGeminiModelDefault(cfg: OpenClawConfig): { + next: OpenClawConfig; + changed: boolean; +} { + const current = cfg.agents?.defaults?.model as unknown; + const currentPrimary = + typeof current === "string" + ? current.trim() || undefined + : current && + typeof current === "object" && + typeof (current as { primary?: unknown }).primary === "string" + ? ((current as { primary: string }).primary || "").trim() || undefined + : undefined; + if (currentPrimary === GOOGLE_GEMINI_DEFAULT_MODEL) { + return { next: cfg, changed: false }; + } + return { + next: applyAgentDefaultModelPrimary(cfg, GOOGLE_GEMINI_DEFAULT_MODEL), + changed: true, + }; +} diff --git a/extensions/google/provider-contract-api.ts b/extensions/google/provider-contract-api.ts new file mode 100644 index 00000000000..50150a7d1c8 --- /dev/null +++ b/extensions/google/provider-contract-api.ts @@ -0,0 +1,61 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +const noopAuth = async () => ({ profiles: [] }); + +export function createGoogleProvider(): ProviderPlugin { + return { + id: "google", + label: "Google AI Studio", + docsPath: "/providers/models", + hookAliases: ["google-antigravity", "google-vertex"], + envVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], + auth: [ + { + id: "api-key", + kind: "api_key", + label: "Google Gemini API key", + hint: "AI Studio / Gemini API key", + run: noopAuth, + wizard: { + choiceId: "gemini-api-key", + choiceLabel: "Google Gemini API key", + groupId: "google", + groupLabel: "Google", + groupHint: "Gemini API key + OAuth", + }, + }, + ], + }; +} + +export function createGoogleGeminiCliProvider(): ProviderPlugin { + return { + id: "google-gemini-cli", + label: "Gemini CLI OAuth", + docsPath: "/providers/models", + aliases: ["gemini-cli"], + envVars: [ + "OPENCLAW_GEMINI_OAUTH_CLIENT_ID", + "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", + "GEMINI_CLI_OAUTH_CLIENT_ID", + "GEMINI_CLI_OAUTH_CLIENT_SECRET", + ], + auth: [ + { + id: "oauth", + kind: "oauth", + label: "Google OAuth", + hint: "PKCE + localhost callback", + run: noopAuth, + }, + ], + wizard: { + setup: { + choiceId: "google-gemini-cli", + choiceLabel: "Gemini CLI OAuth", + choiceHint: "Google OAuth with project-aware token payload", + methodId: "oauth", + }, + }, + }; +} diff --git a/extensions/google/provider-registration.ts b/extensions/google/provider-registration.ts index 1883a961cf1..f5b480854ec 100644 --- a/extensions/google/provider-registration.ts +++ b/extensions/google/provider-registration.ts @@ -1,17 +1,17 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; -import { - GOOGLE_GEMINI_DEFAULT_MODEL, - applyGoogleGeminiModelDefault, - normalizeGoogleProviderConfig, - normalizeGoogleModelId, - resolveGoogleGenerativeAiTransport, -} from "./api.js"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; +import { normalizeGoogleModelId } from "./model-id.js"; +import { GOOGLE_GEMINI_DEFAULT_MODEL, applyGoogleGeminiModelDefault } from "./onboard.js"; import { GOOGLE_GEMINI_PROVIDER_HOOKS } from "./provider-hooks.js"; import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js"; +import { + normalizeGoogleProviderConfig, + resolveGoogleGenerativeAiTransport, +} from "./provider-policy.js"; -export function registerGoogleProvider(api: OpenClawPluginApi) { - api.registerProvider({ +export function buildGoogleProvider(): ProviderPlugin { + return { id: "google", label: "Google AI Studio", docsPath: "/providers/models", @@ -50,5 +50,9 @@ export function registerGoogleProvider(api: OpenClawPluginApi) { }), ...GOOGLE_GEMINI_PROVIDER_HOOKS, isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), - }); + }; +} + +export function registerGoogleProvider(api: OpenClawPluginApi) { + api.registerProvider(buildGoogleProvider()); } diff --git a/extensions/google/src/gemini-web-search-provider.runtime.ts b/extensions/google/src/gemini-web-search-provider.runtime.ts new file mode 100644 index 00000000000..2f3252930ec --- /dev/null +++ b/extensions/google/src/gemini-web-search-provider.runtime.ts @@ -0,0 +1,194 @@ +import { + buildSearchCacheKey, + buildUnsupportedSearchFilterResponse, + DEFAULT_SEARCH_COUNT, + readCachedSearchPayload, + readConfiguredSecretString, + readNumberParam, + readProviderEnvValue, + readStringParam, + resolveCitationRedirectUrl, + resolveSearchCacheTtlMs, + resolveSearchCount, + resolveSearchTimeoutSeconds, + type SearchConfigRecord, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCachedSearchPayload, +} from "openclaw/plugin-sdk/provider-web-search"; +import { DEFAULT_GOOGLE_API_BASE_URL } from "../api.js"; +import { + resolveGeminiConfig, + resolveGeminiModel, + type GeminiConfig, +} from "./gemini-web-search-provider.shared.js"; + +const GEMINI_API_BASE = DEFAULT_GOOGLE_API_BASE_URL; + +type GeminiGroundingResponse = { + candidates?: Array<{ + content?: { + parts?: Array<{ + text?: string; + }>; + }; + groundingMetadata?: { + groundingChunks?: Array<{ + web?: { + uri?: string; + title?: string; + }; + }>; + }; + }>; + error?: { + code?: number; + message?: string; + status?: string; + }; +}; + +export function resolveGeminiRuntimeApiKey(gemini?: GeminiConfig): string | undefined { + return ( + readConfiguredSecretString(gemini?.apiKey, "tools.web.search.gemini.apiKey") ?? + readProviderEnvValue(["GEMINI_API_KEY"]) + ); +} + +async function runGeminiSearch(params: { + query: string; + apiKey: string; + model: string; + timeoutSeconds: number; +}): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> { + const endpoint = `${GEMINI_API_BASE}/models/${params.model}:generateContent`; + + return withTrustedWebSearchEndpoint( + { + url: endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-goog-api-key": params.apiKey, + }, + body: JSON.stringify({ + contents: [{ parts: [{ text: params.query }] }], + tools: [{ google_search: {} }], + }), + }, + }, + async (res) => { + if (!res.ok) { + const safeDetail = ((await res.text()) || res.statusText).replace( + /key=[^&\s]+/giu, + "key=***", + ); + throw new Error(`Gemini API error (${res.status}): ${safeDetail}`); + } + + let data: GeminiGroundingResponse; + try { + data = (await res.json()) as GeminiGroundingResponse; + } catch (error) { + const safeError = String(error).replace(/key=[^&\s]+/giu, "key=***"); + throw new Error(`Gemini API returned invalid JSON: ${safeError}`, { cause: error }); + } + + if (data.error) { + const rawMessage = data.error.message || data.error.status || "unknown"; + throw new Error( + `Gemini API error (${data.error.code}): ${rawMessage.replace(/key=[^&\s]+/giu, "key=***")}`, + ); + } + + const candidate = data.candidates?.[0]; + const content = + candidate?.content?.parts + ?.map((part) => part.text) + .filter(Boolean) + .join("\n") ?? "No response"; + const rawCitations = (candidate?.groundingMetadata?.groundingChunks ?? []) + .filter((chunk) => chunk.web?.uri) + .map((chunk) => ({ + url: chunk.web!.uri!, + title: chunk.web?.title || undefined, + })); + + const citations: Array<{ url: string; title?: string }> = []; + for (let index = 0; index < rawCitations.length; index += 10) { + const batch = rawCitations.slice(index, index + 10); + const resolved = await Promise.all( + batch.map(async (citation) => ({ + ...citation, + url: await resolveCitationRedirectUrl(citation.url), + })), + ); + citations.push(...resolved); + } + + return { content, citations }; + }, + ); +} + +export async function executeGeminiSearch( + args: Record, + searchConfig?: SearchConfigRecord, +): Promise> { + const unsupportedResponse = buildUnsupportedSearchFilterResponse(args, "gemini"); + if (unsupportedResponse) { + return unsupportedResponse; + } + + const geminiConfig = resolveGeminiConfig(searchConfig); + const apiKey = resolveGeminiRuntimeApiKey(geminiConfig); + if (!apiKey) { + return { + error: "missing_gemini_api_key", + message: + "web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + + const query = readStringParam(args, "query", { required: true }); + const count = + readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined; + const model = resolveGeminiModel(geminiConfig); + const cacheKey = buildSearchCacheKey([ + "gemini", + query, + resolveSearchCount(count, DEFAULT_SEARCH_COUNT), + model, + ]); + const cached = readCachedSearchPayload(cacheKey); + if (cached) { + return cached; + } + + const start = Date.now(); + const result = await runGeminiSearch({ + query, + apiKey, + model, + timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), + }); + const payload = { + query, + provider: "gemini", + model, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: "gemini", + wrapped: true, + }, + content: wrapWebContent(result.content), + citations: result.citations, + }; + writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); + return payload; +} diff --git a/extensions/google/src/gemini-web-search-provider.shared.ts b/extensions/google/src/gemini-web-search-provider.shared.ts new file mode 100644 index 00000000000..dd754ca7479 --- /dev/null +++ b/extensions/google/src/gemini-web-search-provider.shared.ts @@ -0,0 +1,30 @@ +export const DEFAULT_GEMINI_WEB_SEARCH_MODEL = "gemini-2.5-flash"; + +export type GeminiConfig = { + apiKey?: unknown; + model?: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function trimToUndefined(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +export function resolveGeminiConfig(searchConfig?: Record): GeminiConfig { + const gemini = searchConfig?.gemini; + return isRecord(gemini) ? gemini : {}; +} + +export function resolveGeminiApiKey( + gemini?: GeminiConfig, + env: Record = process.env, +): string | undefined { + return trimToUndefined(gemini?.apiKey) ?? trimToUndefined(env.GEMINI_API_KEY); +} + +export function resolveGeminiModel(gemini?: GeminiConfig): string { + return trimToUndefined(gemini?.model) ?? DEFAULT_GEMINI_WEB_SEARCH_MODEL; +} diff --git a/extensions/google/src/gemini-web-search-provider.ts b/extensions/google/src/gemini-web-search-provider.ts index c10e6ba437a..37ca0966d8e 100644 --- a/extensions/google/src/gemini-web-search-provider.ts +++ b/extensions/google/src/gemini-web-search-provider.ts @@ -1,244 +1,42 @@ -import { Type } from "@sinclair/typebox"; import { - buildSearchCacheKey, - buildUnsupportedSearchFilterResponse, - DEFAULT_SEARCH_COUNT, - getScopedCredentialValue, - MAX_SEARCH_COUNT, + createWebSearchProviderContractFields, mergeScopedSearchConfig, - readCachedSearchPayload, - readConfiguredSecretString, - readNumberParam, - readProviderEnvValue, - readStringParam, - resolveCitationRedirectUrl, resolveProviderWebSearchPluginConfig, - resolveSearchCacheTtlMs, - resolveSearchCount, - resolveSearchTimeoutSeconds, - setScopedCredentialValue, - setProviderWebSearchPluginConfigValue, - type SearchConfigRecord, type WebSearchProviderPlugin, type WebSearchProviderToolDefinition, - withTrustedWebSearchEndpoint, - wrapWebContent, - writeCachedSearchPayload, -} from "openclaw/plugin-sdk/provider-web-search"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { DEFAULT_GOOGLE_API_BASE_URL } from "../api.js"; +} from "openclaw/plugin-sdk/provider-web-search-config-contract"; +import { resolveGeminiApiKey, resolveGeminiModel } from "./gemini-web-search-provider.shared.js"; -const DEFAULT_GEMINI_MODEL = "gemini-2.5-flash"; -const GEMINI_API_BASE = DEFAULT_GOOGLE_API_BASE_URL; - -type GeminiConfig = { - apiKey?: string; - model?: string; -}; - -type GeminiGroundingResponse = { - candidates?: Array<{ - content?: { - parts?: Array<{ - text?: string; - }>; - }; - groundingMetadata?: { - groundingChunks?: Array<{ - web?: { - uri?: string; - title?: string; - }; - }>; - }; - }>; - error?: { - code?: number; - message?: string; - status?: string; - }; -}; - -function resolveGeminiConfig(searchConfig?: SearchConfigRecord): GeminiConfig { - const gemini = searchConfig?.gemini; - return gemini && typeof gemini === "object" && !Array.isArray(gemini) - ? (gemini as GeminiConfig) - : {}; -} - -function resolveGeminiApiKey(gemini?: GeminiConfig): string | undefined { - return ( - readConfiguredSecretString(gemini?.apiKey, "tools.web.search.gemini.apiKey") ?? - readProviderEnvValue(["GEMINI_API_KEY"]) - ); -} - -function resolveGeminiModel(gemini?: GeminiConfig): string { - const model = normalizeOptionalString(gemini?.model) ?? ""; - return model || DEFAULT_GEMINI_MODEL; -} - -async function runGeminiSearch(params: { - query: string; - apiKey: string; - model: string; - timeoutSeconds: number; -}): Promise<{ content: string; citations: Array<{ url: string; title?: string }> }> { - const endpoint = `${GEMINI_API_BASE}/models/${params.model}:generateContent`; - - return withTrustedWebSearchEndpoint( - { - url: endpoint, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-goog-api-key": params.apiKey, - }, - body: JSON.stringify({ - contents: [{ parts: [{ text: params.query }] }], - tools: [{ google_search: {} }], - }), - }, +const GEMINI_CREDENTIAL_PATH = "plugins.entries.google.config.webSearch.apiKey"; +const GEMINI_TOOL_PARAMETERS = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, }, - async (res) => { - if (!res.ok) { - const safeDetail = ((await res.text()) || res.statusText).replace( - /key=[^&\s]+/gi, - "key=***", - ); - throw new Error(`Gemini API error (${res.status}): ${safeDetail}`); - } - - let data: GeminiGroundingResponse; - try { - data = (await res.json()) as GeminiGroundingResponse; - } catch (error) { - const safeError = String(error).replace(/key=[^&\s]+/gi, "key=***"); - throw new Error(`Gemini API returned invalid JSON: ${safeError}`, { cause: error }); - } - - if (data.error) { - const rawMessage = data.error.message || data.error.status || "unknown"; - throw new Error( - `Gemini API error (${data.error.code}): ${rawMessage.replace(/key=[^&\s]+/gi, "key=***")}`, - ); - } - - const candidate = data.candidates?.[0]; - const content = - candidate?.content?.parts - ?.map((part) => part.text) - .filter(Boolean) - .join("\n") ?? "No response"; - const rawCitations = (candidate?.groundingMetadata?.groundingChunks ?? []) - .filter((chunk) => chunk.web?.uri) - .map((chunk) => ({ - url: chunk.web!.uri!, - title: chunk.web?.title || undefined, - })); - - const citations: Array<{ url: string; title?: string }> = []; - for (let index = 0; index < rawCitations.length; index += 10) { - const batch = rawCitations.slice(index, index + 10); - const resolved = await Promise.all( - batch.map(async (citation) => ({ - ...citation, - url: await resolveCitationRedirectUrl(citation.url), - })), - ); - citations.push(...resolved); - } - - return { content, citations }; - }, - ); -} - -function createGeminiSchema() { - return Type.Object({ - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-10).", - minimum: 1, - maximum: MAX_SEARCH_COUNT, - }), - ), - country: Type.Optional(Type.String({ description: "Not supported by Gemini." })), - language: Type.Optional(Type.String({ description: "Not supported by Gemini." })), - freshness: Type.Optional(Type.String({ description: "Not supported by Gemini." })), - date_after: Type.Optional(Type.String({ description: "Not supported by Gemini." })), - date_before: Type.Optional(Type.String({ description: "Not supported by Gemini." })), - }); -} + country: { type: "string", description: "Not supported by Gemini." }, + language: { type: "string", description: "Not supported by Gemini." }, + freshness: { type: "string", description: "Not supported by Gemini." }, + date_after: { type: "string", description: "Not supported by Gemini." }, + date_before: { type: "string", description: "Not supported by Gemini." }, + }, + required: ["query"], +} satisfies Record; function createGeminiToolDefinition( - searchConfig?: SearchConfigRecord, + searchConfig?: Record, ): WebSearchProviderToolDefinition { return { description: "Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search.", - parameters: createGeminiSchema(), + parameters: GEMINI_TOOL_PARAMETERS, execute: async (args) => { - const params = args; - const unsupportedResponse = buildUnsupportedSearchFilterResponse(params, "gemini"); - if (unsupportedResponse) { - return unsupportedResponse; - } - - const geminiConfig = resolveGeminiConfig(searchConfig); - const apiKey = resolveGeminiApiKey(geminiConfig); - if (!apiKey) { - return { - error: "missing_gemini_api_key", - message: - "web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - - const query = readStringParam(params, "query", { required: true }); - const count = - readNumberParam(params, "count", { integer: true }) ?? - searchConfig?.maxResults ?? - undefined; - const model = resolveGeminiModel(geminiConfig); - const cacheKey = buildSearchCacheKey([ - "gemini", - query, - resolveSearchCount(count, DEFAULT_SEARCH_COUNT), - model, - ]); - const cached = readCachedSearchPayload(cacheKey); - if (cached) { - return cached; - } - - const start = Date.now(); - const result = await runGeminiSearch({ - query, - apiKey, - model, - timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), - }); - const payload = { - query, - provider: "gemini", - model, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: "gemini", - wrapped: true, - }, - content: wrapWebContent(result.content), - citations: result.citations, - }; - writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); - return payload; + const { executeGeminiSearch } = await import("./gemini-web-search-provider.runtime.js"); + return await executeGeminiSearch(args, searchConfig); }, }; } @@ -255,23 +53,19 @@ export function createGeminiWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://aistudio.google.com/apikey", docsUrl: "https://docs.openclaw.ai/tools/web", autoDetectOrder: 20, - credentialPath: "plugins.entries.google.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.google.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "gemini"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "gemini", value), - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "google")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "google", "apiKey", value); - }, + credentialPath: GEMINI_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: GEMINI_CREDENTIAL_PATH, + searchCredential: { type: "scoped", scopeId: "gemini" }, + configuredCredential: { pluginId: "google" }, + }), createTool: (ctx) => createGeminiToolDefinition( mergeScopedSearchConfig( - ctx.searchConfig as SearchConfigRecord | undefined, + ctx.searchConfig, "gemini", resolveProviderWebSearchPluginConfig(ctx.config, "google"), - ) as SearchConfigRecord | undefined, + ), ), }; } diff --git a/extensions/google/web-search-contract-api.ts b/extensions/google/web-search-contract-api.ts index dbb5cd099fd..c2fdfbf9eb8 100644 --- a/extensions/google/web-search-contract-api.ts +++ b/extensions/google/web-search-contract-api.ts @@ -1,28 +1 @@ -import { - createWebSearchProviderContractFields, - type WebSearchProviderPlugin, -} from "openclaw/plugin-sdk/provider-web-search-config-contract"; - -export function createGeminiWebSearchProvider(): WebSearchProviderPlugin { - const credentialPath = "plugins.entries.google.config.webSearch.apiKey"; - - return { - id: "gemini", - label: "Gemini (Google Search)", - hint: "Requires Google Gemini API key · Google Search grounding", - onboardingScopes: ["text-inference"], - credentialLabel: "Google Gemini API key", - envVars: ["GEMINI_API_KEY"], - placeholder: "AIza...", - signupUrl: "https://aistudio.google.com/apikey", - docsUrl: "https://docs.openclaw.ai/tools/web", - autoDetectOrder: 20, - credentialPath, - ...createWebSearchProviderContractFields({ - credentialPath, - searchCredential: { type: "scoped", scopeId: "gemini" }, - configuredCredential: { pluginId: "google" }, - }), - createTool: () => null, - }; -} +export { createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js"; diff --git a/extensions/imessage/contract-api.ts b/extensions/imessage/contract-api.ts index 25a36a96735..8347289b0c2 100644 --- a/extensions/imessage/contract-api.ts +++ b/extensions/imessage/contract-api.ts @@ -1,12 +1,10 @@ -export { - resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots, - resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots, -} from "./src/media-contract.js"; export { DEFAULT_IMESSAGE_ATTACHMENT_ROOTS, + resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots, resolveIMessageAttachmentRoots, + resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots, resolveIMessageRemoteAttachmentRoots, -} from "./src/media-contract.js"; +} from "./media-contract-api.js"; export { __testing as imessageConversationBindingTesting, createIMessageConversationBindingManager, diff --git a/extensions/imessage/media-contract-api.ts b/extensions/imessage/media-contract-api.ts new file mode 100644 index 00000000000..f6ae1fd7279 --- /dev/null +++ b/extensions/imessage/media-contract-api.ts @@ -0,0 +1,7 @@ +export { + DEFAULT_IMESSAGE_ATTACHMENT_ROOTS, + resolveIMessageAttachmentRoots, + resolveIMessageAttachmentRoots as resolveInboundAttachmentRoots, + resolveIMessageRemoteAttachmentRoots, + resolveIMessageRemoteAttachmentRoots as resolveRemoteInboundAttachmentRoots, +} from "./src/media-contract.js"; diff --git a/extensions/minimax/provider-contract-api.ts b/extensions/minimax/provider-contract-api.ts new file mode 100644 index 00000000000..dd7bead4f83 --- /dev/null +++ b/extensions/minimax/provider-contract-api.ts @@ -0,0 +1,84 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +const noopAuth = async () => ({ profiles: [] }); +const wizardGroup = { + groupId: "minimax", + groupLabel: "MiniMax", + groupHint: "M2.7 (recommended)", +} as const; + +export function createMinimaxProvider(): ProviderPlugin { + return { + id: "minimax", + label: "MiniMax", + hookAliases: ["minimax-cn"], + docsPath: "/providers/minimax", + envVars: ["MINIMAX_API_KEY"], + auth: [ + { + id: "api-global", + kind: "api_key", + label: "MiniMax API key (Global)", + hint: "Global endpoint - api.minimax.io", + run: noopAuth, + wizard: { + choiceId: "minimax-global-api", + choiceLabel: "MiniMax API key (Global)", + choiceHint: "Global endpoint - api.minimax.io", + ...wizardGroup, + }, + }, + { + id: "api-cn", + kind: "api_key", + label: "MiniMax API key (CN)", + hint: "CN endpoint - api.minimaxi.com", + run: noopAuth, + wizard: { + choiceId: "minimax-cn-api", + choiceLabel: "MiniMax API key (CN)", + choiceHint: "CN endpoint - api.minimaxi.com", + ...wizardGroup, + }, + }, + ], + }; +} + +export function createMinimaxPortalProvider(): ProviderPlugin { + return { + id: "minimax-portal", + label: "MiniMax", + hookAliases: ["minimax-portal-cn"], + docsPath: "/providers/minimax", + envVars: ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"], + auth: [ + { + id: "oauth", + kind: "device_code", + label: "MiniMax OAuth (Global)", + hint: "Global endpoint - api.minimax.io", + run: noopAuth, + wizard: { + choiceId: "minimax-global-oauth", + choiceLabel: "MiniMax OAuth (Global)", + choiceHint: "Global endpoint - api.minimax.io", + ...wizardGroup, + }, + }, + { + id: "oauth-cn", + kind: "device_code", + label: "MiniMax OAuth (CN)", + hint: "CN endpoint - api.minimaxi.com", + run: noopAuth, + wizard: { + choiceId: "minimax-cn-oauth", + choiceLabel: "MiniMax OAuth (CN)", + choiceHint: "CN endpoint - api.minimaxi.com", + ...wizardGroup, + }, + }, + ], + }; +} diff --git a/extensions/minimax/src/minimax-web-search-provider.runtime.ts b/extensions/minimax/src/minimax-web-search-provider.runtime.ts new file mode 100644 index 00000000000..10e3a0bd742 --- /dev/null +++ b/extensions/minimax/src/minimax-web-search-provider.runtime.ts @@ -0,0 +1,253 @@ +import { + DEFAULT_SEARCH_COUNT, + buildSearchCacheKey, + formatCliCommand, + mergeScopedSearchConfig, + readCachedSearchPayload, + readConfiguredSecretString, + readNumberParam, + readProviderEnvValue, + readStringParam, + resolveProviderWebSearchPluginConfig, + resolveSearchCacheTtlMs, + resolveSearchCount, + resolveSearchTimeoutSeconds, + resolveSiteName, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCachedSearchPayload, + type SearchConfigRecord, +} from "openclaw/plugin-sdk/provider-web-search"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; + +const MINIMAX_SEARCH_ENDPOINT_GLOBAL = "https://api.minimax.io/v1/coding_plan/search"; +const MINIMAX_SEARCH_ENDPOINT_CN = "https://api.minimaxi.com/v1/coding_plan/search"; +const MINIMAX_CODING_PLAN_ENV_VARS = ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY"] as const; + +type MiniMaxSearchResult = { + title?: string; + link?: string; + snippet?: string; + date?: string; +}; + +type MiniMaxRelatedSearch = { + query?: string; +}; + +type MiniMaxSearchResponse = { + organic?: MiniMaxSearchResult[]; + related_searches?: MiniMaxRelatedSearch[]; + base_resp?: { + status_code?: number; + status_msg?: string; + }; +}; + +function resolveMiniMaxApiKey(searchConfig?: SearchConfigRecord): string | undefined { + return ( + readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ?? + readProviderEnvValue([...MINIMAX_CODING_PLAN_ENV_VARS, "MINIMAX_API_KEY"]) + ); +} + +function isMiniMaxCnHost(value: string | undefined): boolean { + const trimmed = normalizeOptionalString(value); + if (!trimmed) { + return false; + } + try { + return new URL(trimmed).hostname.endsWith("minimaxi.com"); + } catch { + return trimmed.includes("minimaxi.com"); + } +} + +function resolveMiniMaxRegion( + searchConfig?: SearchConfigRecord, + config?: Record, +): "cn" | "global" { + // 1. Explicit region in search config takes priority + const minimax = + typeof searchConfig?.minimax === "object" && + searchConfig.minimax !== null && + !Array.isArray(searchConfig.minimax) + ? (searchConfig.minimax as Record) + : undefined; + const configuredRegion = + typeof minimax?.region === "string" ? normalizeOptionalString(minimax.region) : undefined; + if (configuredRegion) { + return configuredRegion === "cn" ? "cn" : "global"; + } + + // 2. Infer from the shared MiniMax host override. + if (isMiniMaxCnHost(process.env.MINIMAX_API_HOST)) { + return "cn"; + } + + // 3. Infer from model provider base URL (set by CN onboarding) + const models = config?.models as Record | undefined; + const providers = models?.providers as Record | undefined; + const minimaxProvider = providers?.minimax as Record | undefined; + const portalProvider = providers?.["minimax-portal"] as Record | undefined; + const baseUrl = typeof minimaxProvider?.baseUrl === "string" ? minimaxProvider.baseUrl : ""; + const portalBaseUrl = typeof portalProvider?.baseUrl === "string" ? portalProvider.baseUrl : ""; + if (isMiniMaxCnHost(baseUrl) || isMiniMaxCnHost(portalBaseUrl)) { + return "cn"; + } + + return "global"; +} + +function resolveMiniMaxEndpoint( + searchConfig?: SearchConfigRecord, + config?: Record, +): string { + return resolveMiniMaxRegion(searchConfig, config) === "cn" + ? MINIMAX_SEARCH_ENDPOINT_CN + : MINIMAX_SEARCH_ENDPOINT_GLOBAL; +} + +async function runMiniMaxSearch(params: { + query: string; + count: number; + apiKey: string; + endpoint: string; + timeoutSeconds: number; +}): Promise<{ + results: Array>; + relatedSearches?: string[]; +}> { + return withTrustedWebSearchEndpoint( + { + url: params.endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + Authorization: `Bearer ${params.apiKey}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ q: params.query }), + }, + }, + async (res) => { + if (!res.ok) { + const detail = await res.text(); + throw new Error(`MiniMax Search API error (${res.status}): ${detail || res.statusText}`); + } + + const data = (await res.json()) as MiniMaxSearchResponse; + + if (data.base_resp?.status_code && data.base_resp.status_code !== 0) { + throw new Error( + `MiniMax Search API error (${data.base_resp.status_code}): ${data.base_resp.status_msg || "unknown error"}`, + ); + } + + const organic = Array.isArray(data.organic) ? data.organic : []; + const results = organic.slice(0, params.count).map((entry) => { + const title = entry.title ?? ""; + const url = entry.link ?? ""; + const snippet = entry.snippet ?? ""; + return { + title: title ? wrapWebContent(title, "web_search") : "", + url, + description: snippet ? wrapWebContent(snippet, "web_search") : "", + published: entry.date || undefined, + siteName: resolveSiteName(url) || undefined, + }; + }); + + const relatedSearches = Array.isArray(data.related_searches) + ? data.related_searches + .map((r) => r.query) + .filter((q): q is string => typeof q === "string" && q.length > 0) + .map((q) => wrapWebContent(q, "web_search")) + : undefined; + + return { results, relatedSearches }; + }, + ); +} + +function missingMiniMaxKeyPayload() { + return { + error: "missing_minimax_api_key", + message: `web_search (minimax) needs a MiniMax Coding Plan key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set MINIMAX_CODE_PLAN_KEY, MINIMAX_CODING_API_KEY, or MINIMAX_API_KEY in the Gateway environment.`, + docs: "https://docs.openclaw.ai/tools/web", + }; +} + +export async function executeMiniMaxWebSearchProviderTool( + ctx: { config?: Record; searchConfig?: SearchConfigRecord }, + args: Record, +): Promise> { + const searchConfig = mergeScopedSearchConfig( + ctx.searchConfig, + "minimax", + resolveProviderWebSearchPluginConfig(ctx.config, "minimax"), + { mirrorApiKeyToTopLevel: true }, + ) as SearchConfigRecord | undefined; + const config = ctx.config; + const apiKey = resolveMiniMaxApiKey(searchConfig); + if (!apiKey) { + return missingMiniMaxKeyPayload(); + } + + const params = args; + const query = readStringParam(params, "query", { required: true }); + const count = + readNumberParam(params, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined; + + const resolvedCount = resolveSearchCount(count, DEFAULT_SEARCH_COUNT); + const endpoint = resolveMiniMaxEndpoint(searchConfig, config); + + const cacheKey = buildSearchCacheKey(["minimax", endpoint, query, resolvedCount]); + const cached = readCachedSearchPayload(cacheKey); + if (cached) { + return cached; + } + + const start = Date.now(); + const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig); + const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig); + + const { results, relatedSearches } = await runMiniMaxSearch({ + query, + count: resolvedCount, + apiKey, + endpoint, + timeoutSeconds, + }); + + const payload: Record = { + query, + provider: "minimax", + count: results.length, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: "minimax", + wrapped: true, + }, + results, + }; + + if (relatedSearches && relatedSearches.length > 0) { + payload.relatedSearches = relatedSearches; + } + + writeCachedSearchPayload(cacheKey, payload, cacheTtlMs); + return payload; +} + +export const __testing = { + MINIMAX_SEARCH_ENDPOINT_GLOBAL, + MINIMAX_SEARCH_ENDPOINT_CN, + resolveMiniMaxApiKey, + resolveMiniMaxEndpoint, + resolveMiniMaxRegion, +} as const; diff --git a/extensions/minimax/src/minimax-web-search-provider.test.ts b/extensions/minimax/src/minimax-web-search-provider.test.ts index 3e45303822c..03d7e0fa48a 100644 --- a/extensions/minimax/src/minimax-web-search-provider.test.ts +++ b/extensions/minimax/src/minimax-web-search-provider.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { __testing } from "./minimax-web-search-provider.js"; +import { minimaxWebSearchTesting } from "../test-api.js"; const { MINIMAX_SEARCH_ENDPOINT_GLOBAL, @@ -7,7 +7,7 @@ const { resolveMiniMaxApiKey, resolveMiniMaxEndpoint, resolveMiniMaxRegion, -} = __testing; +} = minimaxWebSearchTesting; describe("minimax web search provider", () => { const originalApiHost = process.env.MINIMAX_API_HOST; diff --git a/extensions/minimax/src/minimax-web-search-provider.ts b/extensions/minimax/src/minimax-web-search-provider.ts index 5ff2c9d43bc..367ef7f1948 100644 --- a/extensions/minimax/src/minimax-web-search-provider.ts +++ b/extensions/minimax/src/minimax-web-search-provider.ts @@ -1,275 +1,23 @@ -import { Type } from "@sinclair/typebox"; import { - DEFAULT_SEARCH_COUNT, - MAX_SEARCH_COUNT, - buildSearchCacheKey, - formatCliCommand, - mergeScopedSearchConfig, - readCachedSearchPayload, - readConfiguredSecretString, - readNumberParam, - readProviderEnvValue, - readStringParam, - resolveProviderWebSearchPluginConfig, - resolveSearchCacheTtlMs, - resolveSearchCount, - resolveSearchTimeoutSeconds, - resolveSiteName, - setProviderWebSearchPluginConfigValue, - setTopLevelCredentialValue, - withTrustedWebSearchEndpoint, - wrapWebContent, - writeCachedSearchPayload, - type SearchConfigRecord, + createWebSearchProviderContractFields, type WebSearchProviderPlugin, - type WebSearchProviderToolDefinition, -} from "openclaw/plugin-sdk/provider-web-search"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +} from "openclaw/plugin-sdk/provider-web-search-config-contract"; -const MINIMAX_SEARCH_ENDPOINT_GLOBAL = "https://api.minimax.io/v1/coding_plan/search"; -const MINIMAX_SEARCH_ENDPOINT_CN = "https://api.minimaxi.com/v1/coding_plan/search"; +const MINIMAX_CREDENTIAL_PATH = "plugins.entries.minimax.config.webSearch.apiKey"; const MINIMAX_CODING_PLAN_ENV_VARS = ["MINIMAX_CODE_PLAN_KEY", "MINIMAX_CODING_API_KEY"] as const; -type MiniMaxSearchResult = { - title?: string; - link?: string; - snippet?: string; - date?: string; -}; - -type MiniMaxRelatedSearch = { - query?: string; -}; - -type MiniMaxSearchResponse = { - organic?: MiniMaxSearchResult[]; - related_searches?: MiniMaxRelatedSearch[]; - base_resp?: { - status_code?: number; - status_msg?: string; - }; -}; - -function resolveMiniMaxApiKey(searchConfig?: SearchConfigRecord): string | undefined { - return ( - readConfiguredSecretString(searchConfig?.apiKey, "tools.web.search.apiKey") ?? - readProviderEnvValue([...MINIMAX_CODING_PLAN_ENV_VARS, "MINIMAX_API_KEY"]) - ); -} - -function isMiniMaxCnHost(value: string | undefined): boolean { - const trimmed = normalizeOptionalString(value); - if (!trimmed) { - return false; - } - try { - return new URL(trimmed).hostname.endsWith("minimaxi.com"); - } catch { - return trimmed.includes("minimaxi.com"); - } -} - -function resolveMiniMaxRegion( - searchConfig?: SearchConfigRecord, - config?: Record, -): "cn" | "global" { - // 1. Explicit region in search config takes priority - const minimax = - typeof searchConfig?.minimax === "object" && - searchConfig.minimax !== null && - !Array.isArray(searchConfig.minimax) - ? (searchConfig.minimax as Record) - : undefined; - const configuredRegion = - typeof minimax?.region === "string" ? normalizeOptionalString(minimax.region) : undefined; - if (configuredRegion) { - return configuredRegion === "cn" ? "cn" : "global"; - } - - // 2. Infer from the shared MiniMax host override. - if (isMiniMaxCnHost(process.env.MINIMAX_API_HOST)) { - return "cn"; - } - - // 3. Infer from model provider base URL (set by CN onboarding) - const models = config?.models as Record | undefined; - const providers = models?.providers as Record | undefined; - const minimaxProvider = providers?.minimax as Record | undefined; - const portalProvider = providers?.["minimax-portal"] as Record | undefined; - const baseUrl = typeof minimaxProvider?.baseUrl === "string" ? minimaxProvider.baseUrl : ""; - const portalBaseUrl = typeof portalProvider?.baseUrl === "string" ? portalProvider.baseUrl : ""; - if (isMiniMaxCnHost(baseUrl) || isMiniMaxCnHost(portalBaseUrl)) { - return "cn"; - } - - return "global"; -} - -function resolveMiniMaxEndpoint( - searchConfig?: SearchConfigRecord, - config?: Record, -): string { - return resolveMiniMaxRegion(searchConfig, config) === "cn" - ? MINIMAX_SEARCH_ENDPOINT_CN - : MINIMAX_SEARCH_ENDPOINT_GLOBAL; -} - -async function runMiniMaxSearch(params: { - query: string; - count: number; - apiKey: string; - endpoint: string; - timeoutSeconds: number; -}): Promise<{ - results: Array>; - relatedSearches?: string[]; -}> { - return withTrustedWebSearchEndpoint( - { - url: params.endpoint, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - Authorization: `Bearer ${params.apiKey}`, - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ q: params.query }), - }, - }, - async (res) => { - if (!res.ok) { - const detail = await res.text(); - throw new Error(`MiniMax Search API error (${res.status}): ${detail || res.statusText}`); - } - - const data = (await res.json()) as MiniMaxSearchResponse; - - if (data.base_resp?.status_code && data.base_resp.status_code !== 0) { - throw new Error( - `MiniMax Search API error (${data.base_resp.status_code}): ${data.base_resp.status_msg || "unknown error"}`, - ); - } - - const organic = Array.isArray(data.organic) ? data.organic : []; - const results = organic.slice(0, params.count).map((entry) => { - const title = entry.title ?? ""; - const url = entry.link ?? ""; - const snippet = entry.snippet ?? ""; - return { - title: title ? wrapWebContent(title, "web_search") : "", - url, - description: snippet ? wrapWebContent(snippet, "web_search") : "", - published: entry.date || undefined, - siteName: resolveSiteName(url) || undefined, - }; - }); - - const relatedSearches = Array.isArray(data.related_searches) - ? data.related_searches - .map((r) => r.query) - .filter((q): q is string => typeof q === "string" && q.length > 0) - .map((q) => wrapWebContent(q, "web_search")) - : undefined; - - return { results, relatedSearches }; - }, - ); -} - -const MiniMaxSearchSchema = Type.Object({ - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ +const MiniMaxSearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", description: "Number of results to return (1-10).", minimum: 1, - maximum: MAX_SEARCH_COUNT, - }), - ), -}); - -function missingMiniMaxKeyPayload() { - return { - error: "missing_minimax_api_key", - message: `web_search (minimax) needs a MiniMax Coding Plan key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set MINIMAX_CODE_PLAN_KEY, MINIMAX_CODING_API_KEY, or MINIMAX_API_KEY in the Gateway environment.`, - docs: "https://docs.openclaw.ai/tools/web", - }; -} - -function createMiniMaxToolDefinition( - searchConfig?: SearchConfigRecord, - config?: Record, -): WebSearchProviderToolDefinition { - return { - description: - "Search the web using MiniMax Search API. Returns titles, URLs, snippets, and related search suggestions.", - parameters: MiniMaxSearchSchema, - execute: async (args) => { - const apiKey = resolveMiniMaxApiKey(searchConfig); - if (!apiKey) { - return missingMiniMaxKeyPayload(); - } - - const params = args; - const query = readStringParam(params, "query", { required: true }); - const count = - readNumberParam(params, "count", { integer: true }) ?? - searchConfig?.maxResults ?? - undefined; - - const resolvedCount = resolveSearchCount(count, DEFAULT_SEARCH_COUNT); - const endpoint = resolveMiniMaxEndpoint(searchConfig, config); - - const cacheKey = buildSearchCacheKey(["minimax", endpoint, query, resolvedCount]); - const cached = readCachedSearchPayload(cacheKey); - if (cached) { - return cached; - } - - const start = Date.now(); - const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig); - const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig); - - const { results, relatedSearches } = await runMiniMaxSearch({ - query, - count: resolvedCount, - apiKey, - endpoint, - timeoutSeconds, - }); - - const payload: Record = { - query, - provider: "minimax", - count: results.length, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: "minimax", - wrapped: true, - }, - results, - }; - - if (relatedSearches && relatedSearches.length > 0) { - payload.relatedSearches = relatedSearches; - } - - writeCachedSearchPayload(cacheKey, payload, cacheTtlMs); - return payload; + maximum: 10, }, - }; -} - -export const __testing = { - MINIMAX_SEARCH_ENDPOINT_GLOBAL, - MINIMAX_SEARCH_ENDPOINT_CN, - resolveMiniMaxApiKey, - resolveMiniMaxEndpoint, - resolveMiniMaxRegion, -} as const; + }, +} satisfies Record; export function createMiniMaxWebSearchProvider(): WebSearchProviderPlugin { return { @@ -282,24 +30,21 @@ export function createMiniMaxWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://platform.minimax.io/user-center/basic-information/interface-key", docsUrl: "https://docs.openclaw.ai/tools/minimax-search", autoDetectOrder: 15, - credentialPath: "plugins.entries.minimax.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.minimax.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => searchConfig?.apiKey, - setCredentialValue: setTopLevelCredentialValue, - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "minimax")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "minimax", "apiKey", value); - }, - createTool: (ctx) => - createMiniMaxToolDefinition( - mergeScopedSearchConfig( - ctx.searchConfig as SearchConfigRecord | undefined, - "minimax", - resolveProviderWebSearchPluginConfig(ctx.config, "minimax"), - { mirrorApiKeyToTopLevel: true }, - ) as SearchConfigRecord | undefined, - ctx.config as Record | undefined, - ), + credentialPath: MINIMAX_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: MINIMAX_CREDENTIAL_PATH, + searchCredential: { type: "top-level" }, + configuredCredential: { pluginId: "minimax" }, + }), + createTool: (ctx) => ({ + description: + "Search the web using MiniMax Search API. Returns titles, URLs, snippets, and related search suggestions.", + parameters: MiniMaxSearchSchema, + execute: async (args) => { + const { executeMiniMaxWebSearchProviderTool } = + await import("./minimax-web-search-provider.runtime.js"); + return await executeMiniMaxWebSearchProviderTool(ctx, args); + }, + }), }; } diff --git a/extensions/minimax/test-api.ts b/extensions/minimax/test-api.ts index 79181773d9d..1a47d4092b3 100644 --- a/extensions/minimax/test-api.ts +++ b/extensions/minimax/test-api.ts @@ -7,4 +7,5 @@ export { minimaxMediaUnderstandingProvider, minimaxPortalMediaUnderstandingProvider, } from "./media-understanding-provider.js"; +export { __testing as minimaxWebSearchTesting } from "./src/minimax-web-search-provider.runtime.js"; export { buildMinimaxVideoGenerationProvider } from "./video-generation-provider.js"; diff --git a/extensions/moonshot/provider-contract-api.ts b/extensions/moonshot/provider-contract-api.ts new file mode 100644 index 00000000000..cf45ad17727 --- /dev/null +++ b/extensions/moonshot/provider-contract-api.ts @@ -0,0 +1,33 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +const noopAuth = async () => ({ profiles: [] }); + +export function createMoonshotProvider(): ProviderPlugin { + return { + id: "moonshot", + label: "Moonshot", + docsPath: "/providers/moonshot", + auth: [ + { + id: "api-key", + kind: "api_key", + label: "Kimi API key (.ai)", + hint: "Kimi K2.5 + Kimi", + run: noopAuth, + wizard: { + groupLabel: "Moonshot AI (Kimi K2.5)", + }, + }, + { + id: "api-key-cn", + kind: "api_key", + label: "Kimi API key (.cn)", + hint: "Kimi K2.5 + Kimi", + run: noopAuth, + wizard: { + groupLabel: "Moonshot AI (Kimi K2.5)", + }, + }, + ], + }; +} diff --git a/extensions/moonshot/src/kimi-web-search-provider.runtime.ts b/extensions/moonshot/src/kimi-web-search-provider.runtime.ts new file mode 100644 index 00000000000..6f8ea5fd1c1 --- /dev/null +++ b/extensions/moonshot/src/kimi-web-search-provider.runtime.ts @@ -0,0 +1,414 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard"; +import { + buildSearchCacheKey, + buildUnsupportedSearchFilterResponse, + DEFAULT_SEARCH_COUNT, + mergeScopedSearchConfig, + readCachedSearchPayload, + readConfiguredSecretString, + readNumberParam, + readProviderEnvValue, + readStringParam, + resolveProviderWebSearchPluginConfig, + resolveSearchCacheTtlMs, + resolveSearchCount, + resolveSearchTimeoutSeconds, + setProviderWebSearchPluginConfigValue, + type SearchConfigRecord, + type WebSearchProviderSetupContext, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCachedSearchPayload, +} from "openclaw/plugin-sdk/provider-web-search"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import { + isNativeMoonshotBaseUrl, + MOONSHOT_BASE_URL, + MOONSHOT_CN_BASE_URL, + MOONSHOT_DEFAULT_MODEL_ID, +} from "../provider-catalog.js"; + +const DEFAULT_KIMI_BASE_URL = MOONSHOT_BASE_URL; +const DEFAULT_KIMI_SEARCH_MODEL = MOONSHOT_DEFAULT_MODEL_ID; +/** Models that require explicit thinking disablement for web search. + * Reasoning variants (kimi-k2-thinking, kimi-k2-thinking-turbo) are excluded + * because they default to thinking-enabled and disabling it would defeat their + * purpose; they are also unlikely to be used for web search. */ +const KIMI_THINKING_MODELS = new Set(["kimi-k2.5"]); +const KIMI_WEB_SEARCH_TOOL = { + type: "builtin_function", + function: { name: "$web_search" }, +} as const; + +type KimiConfig = { + apiKey?: string; + baseUrl?: string; + model?: string; +}; + +type KimiToolCall = { + id?: string; + type?: string; + function?: { + name?: string; + arguments?: string; + }; +}; + +type KimiMessage = { + role?: string; + content?: string; + reasoning_content?: string; + tool_calls?: KimiToolCall[]; +}; + +type KimiSearchResponse = { + choices?: Array<{ + finish_reason?: string; + message?: KimiMessage; + }>; + search_results?: Array<{ + title?: string; + url?: string; + content?: string; + }>; +}; + +function resolveKimiConfig(searchConfig?: SearchConfigRecord): KimiConfig { + const kimi = searchConfig?.kimi; + return kimi && typeof kimi === "object" && !Array.isArray(kimi) ? (kimi as KimiConfig) : {}; +} + +function resolveKimiApiKey(kimi?: KimiConfig): string | undefined { + return ( + readConfiguredSecretString(kimi?.apiKey, "tools.web.search.kimi.apiKey") ?? + readProviderEnvValue(["KIMI_API_KEY", "MOONSHOT_API_KEY"]) + ); +} + +function resolveKimiModel(kimi?: KimiConfig): string { + const model = normalizeOptionalString(kimi?.model) ?? ""; + return model || DEFAULT_KIMI_SEARCH_MODEL; +} + +function trimTrailingSlashes(url: string): string { + return url.replace(/\/+$/, ""); +} + +function resolveKimiBaseUrl(kimi?: KimiConfig, openClawConfig?: OpenClawConfig): string { + const explicitBaseUrl = normalizeOptionalString(kimi?.baseUrl) ?? ""; + if (explicitBaseUrl) { + return trimTrailingSlashes(explicitBaseUrl) || DEFAULT_KIMI_BASE_URL; + } + + const moonshotBaseUrl = openClawConfig?.models?.providers?.moonshot?.baseUrl; + if (typeof moonshotBaseUrl === "string") { + const normalizedMoonshotBaseUrl = trimTrailingSlashes(moonshotBaseUrl.trim()); + if (normalizedMoonshotBaseUrl && isNativeMoonshotBaseUrl(normalizedMoonshotBaseUrl)) { + return normalizedMoonshotBaseUrl; + } + } + + return DEFAULT_KIMI_BASE_URL; +} + +function extractKimiMessageText(message: KimiMessage | undefined): string | undefined { + const content = message?.content?.trim(); + if (content) { + return content; + } + const reasoning = message?.reasoning_content?.trim(); + return reasoning || undefined; +} + +function extractKimiCitations(data: KimiSearchResponse): string[] { + const citations = (data.search_results ?? []) + .map((entry) => entry.url?.trim()) + .filter((url): url is string => Boolean(url)); + + for (const toolCall of data.choices?.[0]?.message?.tool_calls ?? []) { + const rawArguments = toolCall.function?.arguments; + if (!rawArguments) { + continue; + } + try { + const parsed = JSON.parse(rawArguments) as { + search_results?: Array<{ url?: string }>; + url?: string; + }; + const parsedUrl = normalizeOptionalString(parsed.url); + if (parsedUrl) { + citations.push(parsedUrl); + } + for (const result of parsed.search_results ?? []) { + const resultUrl = normalizeOptionalString(result.url); + if (resultUrl) { + citations.push(resultUrl); + } + } + } catch { + // ignore malformed tool arguments + } + } + + return [...new Set(citations)]; +} + +function extractKimiToolResultContent(toolCall: KimiToolCall): string | undefined { + const rawArguments = toolCall.function?.arguments; + if (typeof rawArguments !== "string" || rawArguments.trim().length === 0) { + return undefined; + } + return rawArguments; +} + +async function runKimiSearch(params: { + query: string; + apiKey: string; + baseUrl: string; + model: string; + timeoutSeconds: number; +}): Promise<{ content: string; citations: string[] }> { + const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`; + const messages: Array> = [{ role: "user", content: params.query }]; + const collectedCitations = new Set(); + + for (let round = 0; round < 3; round += 1) { + const next = await withTrustedWebSearchEndpoint( + { + url: endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + }, + body: JSON.stringify({ + model: params.model, + ...(KIMI_THINKING_MODELS.has(params.model) ? { thinking: { type: "disabled" } } : {}), + messages, + tools: [KIMI_WEB_SEARCH_TOOL], + }), + }, + }, + async ( + res, + ): Promise<{ done: true; content: string; citations: string[] } | { done: false }> => { + if (!res.ok) { + const detail = await res.text(); + throw new Error(`Kimi API error (${res.status}): ${detail || res.statusText}`); + } + + const data = (await res.json()) as KimiSearchResponse; + for (const citation of extractKimiCitations(data)) { + collectedCitations.add(citation); + } + const choice = data.choices?.[0]; + const message = choice?.message; + const text = extractKimiMessageText(message); + const toolCalls = message?.tool_calls ?? []; + + if (choice?.finish_reason !== "tool_calls" || toolCalls.length === 0) { + return { done: true, content: text ?? "No response", citations: [...collectedCitations] }; + } + + messages.push({ + role: "assistant", + content: message?.content ?? "", + ...(message?.reasoning_content ? { reasoning_content: message.reasoning_content } : {}), + tool_calls: toolCalls, + }); + + let pushed = false; + for (const toolCall of toolCalls) { + const toolCallId = toolCall.id?.trim(); + const toolCallName = toolCall.function?.name?.trim(); + const toolContent = extractKimiToolResultContent(toolCall); + if (!toolCallId || !toolCallName || !toolContent) { + continue; + } + pushed = true; + messages.push({ + role: "tool", + tool_call_id: toolCallId, + name: toolCallName, + content: toolContent, + }); + } + if (!pushed) { + return { done: true, content: text ?? "No response", citations: [...collectedCitations] }; + } + return { done: false }; + }, + ); + + if (next.done) { + return { content: next.content, citations: next.citations }; + } + } + + return { + content: "Search completed but no final answer was produced.", + citations: [...collectedCitations], + }; +} + +export async function executeKimiWebSearchProviderTool( + ctx: { config?: OpenClawConfig; searchConfig?: SearchConfigRecord }, + args: Record, +): Promise> { + const searchConfig = mergeScopedSearchConfig( + ctx.searchConfig, + "kimi", + resolveProviderWebSearchPluginConfig(ctx.config, "moonshot"), + ) as SearchConfigRecord | undefined; + const unsupportedResponse = buildUnsupportedSearchFilterResponse(args, "kimi"); + if (unsupportedResponse) { + return unsupportedResponse; + } + + const kimiConfig = resolveKimiConfig(searchConfig); + const apiKey = resolveKimiApiKey(kimiConfig); + if (!apiKey) { + return { + error: "missing_kimi_api_key", + message: + "web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure tools.web.search.kimi.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + + const query = readStringParam(args, "query", { required: true }); + const count = + readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined; + const model = resolveKimiModel(kimiConfig); + const baseUrl = resolveKimiBaseUrl(kimiConfig, ctx.config); + const cacheKey = buildSearchCacheKey([ + "kimi", + query, + resolveSearchCount(count, DEFAULT_SEARCH_COUNT), + baseUrl, + model, + ]); + const cached = readCachedSearchPayload(cacheKey); + if (cached) { + return cached; + } + + const start = Date.now(); + const result = await runKimiSearch({ + query, + apiKey, + baseUrl, + model, + timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), + }); + const payload = { + query, + provider: "kimi", + model, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: "kimi", + wrapped: true, + }, + content: wrapWebContent(result.content), + citations: result.citations, + }; + writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); + return payload; +} + +export async function runKimiSearchProviderSetup( + ctx: WebSearchProviderSetupContext, +): Promise { + const existingPluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "moonshot"); + const existingBaseUrl = normalizeOptionalString(existingPluginConfig?.baseUrl) ?? ""; + // Normalize trailing slashes so initialValue matches canonical option values. + const normalizedBaseUrl = existingBaseUrl.replace(/\/+$/, ""); + const existingModel = normalizeOptionalString(existingPluginConfig?.model) ?? ""; + + // Region selection (baseUrl) + const isCustomBaseUrl = normalizedBaseUrl && !isNativeMoonshotBaseUrl(normalizedBaseUrl); + const regionOptions: Array<{ value: string; label: string; hint?: string }> = []; + if (isCustomBaseUrl) { + regionOptions.push({ + value: normalizedBaseUrl, + label: `Keep current (${normalizedBaseUrl})`, + hint: "custom endpoint", + }); + } + regionOptions.push( + { + value: MOONSHOT_BASE_URL, + label: "Moonshot API key (.ai)", + hint: "api.moonshot.ai", + }, + { + value: MOONSHOT_CN_BASE_URL, + label: "Moonshot API key (.cn)", + hint: "api.moonshot.cn", + }, + ); + + const regionChoice = await ctx.prompter.select({ + message: "Kimi API region", + options: regionOptions, + initialValue: normalizedBaseUrl || MOONSHOT_BASE_URL, + }); + const baseUrl = regionChoice; + + // Model selection + const currentModelLabel = existingModel + ? `Keep current (moonshot/${existingModel})` + : `Use default (moonshot/${DEFAULT_KIMI_SEARCH_MODEL})`; + const modelChoice = await ctx.prompter.select({ + message: "Kimi web search model", + options: [ + { + value: "__keep__", + label: currentModelLabel, + }, + { + value: "__custom__", + label: "Enter model manually", + }, + { + value: DEFAULT_KIMI_SEARCH_MODEL, + label: `moonshot/${DEFAULT_KIMI_SEARCH_MODEL}`, + }, + ], + initialValue: "__keep__", + }); + + let model: string; + if (modelChoice === "__keep__") { + model = existingModel || DEFAULT_KIMI_SEARCH_MODEL; + } else if (modelChoice === "__custom__") { + const customModel = await ctx.prompter.text({ + message: "Kimi model name", + initialValue: existingModel || DEFAULT_KIMI_SEARCH_MODEL, + placeholder: DEFAULT_KIMI_SEARCH_MODEL, + }); + model = customModel?.trim() || DEFAULT_KIMI_SEARCH_MODEL; + } else { + model = modelChoice; + } + + // Write baseUrl and model into plugins.entries.moonshot.config.webSearch + const next = { ...ctx.config }; + setProviderWebSearchPluginConfigValue(next, "moonshot", "baseUrl", baseUrl); + setProviderWebSearchPluginConfigValue(next, "moonshot", "model", model); + return next; +} + +export const __testing = { + resolveKimiApiKey, + resolveKimiModel, + resolveKimiBaseUrl, + extractKimiCitations, + extractKimiToolResultContent, +} as const; diff --git a/extensions/moonshot/src/kimi-web-search-provider.test.ts b/extensions/moonshot/src/kimi-web-search-provider.test.ts index 8bb0b582339..c6bf5a21d38 100644 --- a/extensions/moonshot/src/kimi-web-search-provider.test.ts +++ b/extensions/moonshot/src/kimi-web-search-provider.test.ts @@ -1,7 +1,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard"; import { withEnv } from "openclaw/plugin-sdk/testing"; import { describe, expect, it } from "vitest"; -import { __testing } from "./kimi-web-search-provider.js"; +import { __testing } from "../test-api.js"; const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_"); diff --git a/extensions/moonshot/src/kimi-web-search-provider.ts b/extensions/moonshot/src/kimi-web-search-provider.ts index 2b48ef31fd1..754ce022e71 100644 --- a/extensions/moonshot/src/kimi-web-search-provider.ts +++ b/extensions/moonshot/src/kimi-web-search-provider.ts @@ -1,437 +1,33 @@ -import { Type } from "@sinclair/typebox"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard"; import { - buildSearchCacheKey, - buildUnsupportedSearchFilterResponse, - DEFAULT_SEARCH_COUNT, - getScopedCredentialValue, - MAX_SEARCH_COUNT, - mergeScopedSearchConfig, - readCachedSearchPayload, - readConfiguredSecretString, - readNumberParam, - readProviderEnvValue, - readStringParam, - resolveProviderWebSearchPluginConfig, - resolveSearchCacheTtlMs, - resolveSearchCount, - resolveSearchTimeoutSeconds, - setScopedCredentialValue, - setProviderWebSearchPluginConfigValue, - type SearchConfigRecord, + createWebSearchProviderContractFields, type WebSearchProviderPlugin, type WebSearchProviderSetupContext, - type WebSearchProviderToolDefinition, - withTrustedWebSearchEndpoint, - wrapWebContent, - writeCachedSearchPayload, -} from "openclaw/plugin-sdk/provider-web-search"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { - isNativeMoonshotBaseUrl, - MOONSHOT_BASE_URL, - MOONSHOT_CN_BASE_URL, - MOONSHOT_DEFAULT_MODEL_ID, -} from "../provider-catalog.js"; +} from "openclaw/plugin-sdk/provider-web-search-config-contract"; -const DEFAULT_KIMI_BASE_URL = MOONSHOT_BASE_URL; -const DEFAULT_KIMI_SEARCH_MODEL = MOONSHOT_DEFAULT_MODEL_ID; -/** Models that require explicit thinking disablement for web search. - * Reasoning variants (kimi-k2-thinking, kimi-k2-thinking-turbo) are excluded - * because they default to thinking-enabled and disabling it would defeat their - * purpose; they are also unlikely to be used for web search. */ -const KIMI_THINKING_MODELS = new Set(["kimi-k2.5"]); -const KIMI_WEB_SEARCH_TOOL = { - type: "builtin_function", - function: { name: "$web_search" }, -} as const; - -type KimiConfig = { - apiKey?: string; - baseUrl?: string; - model?: string; -}; - -type KimiToolCall = { - id?: string; - type?: string; - function?: { - name?: string; - arguments?: string; - }; -}; - -type KimiMessage = { - role?: string; - content?: string; - reasoning_content?: string; - tool_calls?: KimiToolCall[]; -}; - -type KimiSearchResponse = { - choices?: Array<{ - finish_reason?: string; - message?: KimiMessage; - }>; - search_results?: Array<{ - title?: string; - url?: string; - content?: string; - }>; -}; - -function resolveKimiConfig(searchConfig?: SearchConfigRecord): KimiConfig { - const kimi = searchConfig?.kimi; - return kimi && typeof kimi === "object" && !Array.isArray(kimi) ? (kimi as KimiConfig) : {}; -} - -function resolveKimiApiKey(kimi?: KimiConfig): string | undefined { - return ( - readConfiguredSecretString(kimi?.apiKey, "tools.web.search.kimi.apiKey") ?? - readProviderEnvValue(["KIMI_API_KEY", "MOONSHOT_API_KEY"]) - ); -} - -function resolveKimiModel(kimi?: KimiConfig): string { - const model = normalizeOptionalString(kimi?.model) ?? ""; - return model || DEFAULT_KIMI_SEARCH_MODEL; -} - -function trimTrailingSlashes(url: string): string { - return url.replace(/\/+$/, ""); -} - -function resolveKimiBaseUrl(kimi?: KimiConfig, openClawConfig?: OpenClawConfig): string { - const explicitBaseUrl = normalizeOptionalString(kimi?.baseUrl) ?? ""; - if (explicitBaseUrl) { - return trimTrailingSlashes(explicitBaseUrl) || DEFAULT_KIMI_BASE_URL; - } - - const moonshotBaseUrl = openClawConfig?.models?.providers?.moonshot?.baseUrl; - if (typeof moonshotBaseUrl === "string") { - const normalizedMoonshotBaseUrl = trimTrailingSlashes(moonshotBaseUrl.trim()); - if (normalizedMoonshotBaseUrl && isNativeMoonshotBaseUrl(normalizedMoonshotBaseUrl)) { - return normalizedMoonshotBaseUrl; - } - } - - return DEFAULT_KIMI_BASE_URL; -} - -function extractKimiMessageText(message: KimiMessage | undefined): string | undefined { - const content = message?.content?.trim(); - if (content) { - return content; - } - const reasoning = message?.reasoning_content?.trim(); - return reasoning || undefined; -} - -function extractKimiCitations(data: KimiSearchResponse): string[] { - const citations = (data.search_results ?? []) - .map((entry) => entry.url?.trim()) - .filter((url): url is string => Boolean(url)); - - for (const toolCall of data.choices?.[0]?.message?.tool_calls ?? []) { - const rawArguments = toolCall.function?.arguments; - if (!rawArguments) { - continue; - } - try { - const parsed = JSON.parse(rawArguments) as { - search_results?: Array<{ url?: string }>; - url?: string; - }; - const parsedUrl = normalizeOptionalString(parsed.url); - if (parsedUrl) { - citations.push(parsedUrl); - } - for (const result of parsed.search_results ?? []) { - const resultUrl = normalizeOptionalString(result.url); - if (resultUrl) { - citations.push(resultUrl); - } - } - } catch { - // ignore malformed tool arguments - } - } - - return [...new Set(citations)]; -} - -function extractKimiToolResultContent(toolCall: KimiToolCall): string | undefined { - const rawArguments = toolCall.function?.arguments; - if (typeof rawArguments !== "string" || rawArguments.trim().length === 0) { - return undefined; - } - return rawArguments; -} - -async function runKimiSearch(params: { - query: string; - apiKey: string; - baseUrl: string; - model: string; - timeoutSeconds: number; -}): Promise<{ content: string; citations: string[] }> { - const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`; - const messages: Array> = [{ role: "user", content: params.query }]; - const collectedCitations = new Set(); - - for (let round = 0; round < 3; round += 1) { - const next = await withTrustedWebSearchEndpoint( - { - url: endpoint, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${params.apiKey}`, - }, - body: JSON.stringify({ - model: params.model, - ...(KIMI_THINKING_MODELS.has(params.model) ? { thinking: { type: "disabled" } } : {}), - messages, - tools: [KIMI_WEB_SEARCH_TOOL], - }), - }, - }, - async ( - res, - ): Promise<{ done: true; content: string; citations: string[] } | { done: false }> => { - if (!res.ok) { - const detail = await res.text(); - throw new Error(`Kimi API error (${res.status}): ${detail || res.statusText}`); - } - - const data = (await res.json()) as KimiSearchResponse; - for (const citation of extractKimiCitations(data)) { - collectedCitations.add(citation); - } - const choice = data.choices?.[0]; - const message = choice?.message; - const text = extractKimiMessageText(message); - const toolCalls = message?.tool_calls ?? []; - - if (choice?.finish_reason !== "tool_calls" || toolCalls.length === 0) { - return { done: true, content: text ?? "No response", citations: [...collectedCitations] }; - } - - messages.push({ - role: "assistant", - content: message?.content ?? "", - ...(message?.reasoning_content ? { reasoning_content: message.reasoning_content } : {}), - tool_calls: toolCalls, - }); - - let pushed = false; - for (const toolCall of toolCalls) { - const toolCallId = toolCall.id?.trim(); - const toolCallName = toolCall.function?.name?.trim(); - const toolContent = extractKimiToolResultContent(toolCall); - if (!toolCallId || !toolCallName || !toolContent) { - continue; - } - pushed = true; - messages.push({ - role: "tool", - tool_call_id: toolCallId, - name: toolCallName, - content: toolContent, - }); - } - if (!pushed) { - return { done: true, content: text ?? "No response", citations: [...collectedCitations] }; - } - return { done: false }; - }, - ); - - if (next.done) { - return { content: next.content, citations: next.citations }; - } - } - - return { - content: "Search completed but no final answer was produced.", - citations: [...collectedCitations], - }; -} - -function createKimiSchema() { - return Type.Object({ - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-10).", - minimum: 1, - maximum: MAX_SEARCH_COUNT, - }), - ), - country: Type.Optional(Type.String({ description: "Not supported by Kimi." })), - language: Type.Optional(Type.String({ description: "Not supported by Kimi." })), - freshness: Type.Optional(Type.String({ description: "Not supported by Kimi." })), - date_after: Type.Optional(Type.String({ description: "Not supported by Kimi." })), - date_before: Type.Optional(Type.String({ description: "Not supported by Kimi." })), - }); -} - -function createKimiToolDefinition( - searchConfig: SearchConfigRecord | undefined, - openClawConfig: OpenClawConfig | undefined, -): WebSearchProviderToolDefinition { - return { - description: - "Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search.", - parameters: createKimiSchema(), - execute: async (args) => { - const params = args; - const unsupportedResponse = buildUnsupportedSearchFilterResponse(params, "kimi"); - if (unsupportedResponse) { - return unsupportedResponse; - } - - const kimiConfig = resolveKimiConfig(searchConfig); - const apiKey = resolveKimiApiKey(kimiConfig); - if (!apiKey) { - return { - error: "missing_kimi_api_key", - message: - "web_search (kimi) needs a Moonshot API key. Set KIMI_API_KEY or MOONSHOT_API_KEY in the Gateway environment, or configure tools.web.search.kimi.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - - const query = readStringParam(params, "query", { required: true }); - const count = - readNumberParam(params, "count", { integer: true }) ?? - searchConfig?.maxResults ?? - undefined; - const model = resolveKimiModel(kimiConfig); - const baseUrl = resolveKimiBaseUrl(kimiConfig, openClawConfig); - const cacheKey = buildSearchCacheKey([ - "kimi", - query, - resolveSearchCount(count, DEFAULT_SEARCH_COUNT), - baseUrl, - model, - ]); - const cached = readCachedSearchPayload(cacheKey); - if (cached) { - return cached; - } - - const start = Date.now(); - const result = await runKimiSearch({ - query, - apiKey, - baseUrl, - model, - timeoutSeconds: resolveSearchTimeoutSeconds(searchConfig), - }); - const payload = { - query, - provider: "kimi", - model, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: "kimi", - wrapped: true, - }, - content: wrapWebContent(result.content), - citations: result.citations, - }; - writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); - return payload; +const KIMI_CREDENTIAL_PATH = "plugins.entries.moonshot.config.webSearch.apiKey"; +const KimiSearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, }, - }; -} + country: { type: "string", description: "Not supported by Kimi." }, + language: { type: "string", description: "Not supported by Kimi." }, + freshness: { type: "string", description: "Not supported by Kimi." }, + date_after: { type: "string", description: "Not supported by Kimi." }, + date_before: { type: "string", description: "Not supported by Kimi." }, + }, +} satisfies Record; async function runKimiSearchProviderSetup( ctx: WebSearchProviderSetupContext, ): Promise { - const existingPluginConfig = resolveProviderWebSearchPluginConfig(ctx.config, "moonshot"); - const existingBaseUrl = normalizeOptionalString(existingPluginConfig?.baseUrl) ?? ""; - // Normalize trailing slashes so initialValue matches canonical option values. - const normalizedBaseUrl = existingBaseUrl.replace(/\/+$/, ""); - const existingModel = normalizeOptionalString(existingPluginConfig?.model) ?? ""; - - // Region selection (baseUrl) - const isCustomBaseUrl = normalizedBaseUrl && !isNativeMoonshotBaseUrl(normalizedBaseUrl); - const regionOptions: Array<{ value: string; label: string; hint?: string }> = []; - if (isCustomBaseUrl) { - regionOptions.push({ - value: normalizedBaseUrl, - label: `Keep current (${normalizedBaseUrl})`, - hint: "custom endpoint", - }); - } - regionOptions.push( - { - value: MOONSHOT_BASE_URL, - label: "Moonshot API key (.ai)", - hint: "api.moonshot.ai", - }, - { - value: MOONSHOT_CN_BASE_URL, - label: "Moonshot API key (.cn)", - hint: "api.moonshot.cn", - }, - ); - - const regionChoice = await ctx.prompter.select({ - message: "Kimi API region", - options: regionOptions, - initialValue: normalizedBaseUrl || MOONSHOT_BASE_URL, - }); - const baseUrl = regionChoice; - - // Model selection - const currentModelLabel = existingModel - ? `Keep current (moonshot/${existingModel})` - : `Use default (moonshot/${DEFAULT_KIMI_SEARCH_MODEL})`; - const modelChoice = await ctx.prompter.select({ - message: "Kimi web search model", - options: [ - { - value: "__keep__", - label: currentModelLabel, - }, - { - value: "__custom__", - label: "Enter model manually", - }, - { - value: DEFAULT_KIMI_SEARCH_MODEL, - label: `moonshot/${DEFAULT_KIMI_SEARCH_MODEL}`, - }, - ], - initialValue: "__keep__", - }); - - let model: string; - if (modelChoice === "__keep__") { - model = existingModel || DEFAULT_KIMI_SEARCH_MODEL; - } else if (modelChoice === "__custom__") { - const customModel = await ctx.prompter.text({ - message: "Kimi model name", - initialValue: existingModel || DEFAULT_KIMI_SEARCH_MODEL, - placeholder: DEFAULT_KIMI_SEARCH_MODEL, - }); - model = customModel?.trim() || DEFAULT_KIMI_SEARCH_MODEL; - } else { - model = modelChoice; - } - - // Write baseUrl and model into plugins.entries.moonshot.config.webSearch - const next = { ...ctx.config }; - setProviderWebSearchPluginConfigValue(next, "moonshot", "baseUrl", baseUrl); - setProviderWebSearchPluginConfigValue(next, "moonshot", "model", model); - return next; + const runtime = await import("./kimi-web-search-provider.runtime.js"); + return await runtime.runKimiSearchProviderSetup(ctx); } export function createKimiWebSearchProvider(): WebSearchProviderPlugin { @@ -446,33 +42,22 @@ export function createKimiWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://platform.moonshot.cn/", docsUrl: "https://docs.openclaw.ai/tools/web", autoDetectOrder: 40, - credentialPath: "plugins.entries.moonshot.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.moonshot.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "kimi"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "kimi", value), - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "moonshot")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "moonshot", "apiKey", value); - }, + credentialPath: KIMI_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: KIMI_CREDENTIAL_PATH, + searchCredential: { type: "scoped", scopeId: "kimi" }, + configuredCredential: { pluginId: "moonshot" }, + }), runSetup: runKimiSearchProviderSetup, - createTool: (ctx) => - createKimiToolDefinition( - mergeScopedSearchConfig( - ctx.searchConfig as SearchConfigRecord | undefined, - "kimi", - resolveProviderWebSearchPluginConfig(ctx.config, "moonshot"), - ) as SearchConfigRecord | undefined, - ctx.config, - ), + createTool: (ctx) => ({ + description: + "Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search.", + parameters: KimiSearchSchema, + execute: async (args) => { + const { executeKimiWebSearchProviderTool } = + await import("./kimi-web-search-provider.runtime.js"); + return await executeKimiWebSearchProviderTool(ctx, args); + }, + }), }; } - -export const __testing = { - resolveKimiApiKey, - resolveKimiModel, - resolveKimiBaseUrl, - extractKimiCitations, - extractKimiToolResultContent, -} as const; diff --git a/extensions/moonshot/test-api.ts b/extensions/moonshot/test-api.ts index 9974ca37872..e348a83d5ee 100644 --- a/extensions/moonshot/test-api.ts +++ b/extensions/moonshot/test-api.ts @@ -1,2 +1,2 @@ -export { __testing } from "./src/kimi-web-search-provider.js"; +export { __testing } from "./src/kimi-web-search-provider.runtime.js"; export { moonshotMediaUnderstandingProvider } from "./media-understanding-provider.js"; diff --git a/extensions/openai/api.ts b/extensions/openai/api.ts index 7f144f9aa11..96f5a56e8f5 100644 --- a/extensions/openai/api.ts +++ b/extensions/openai/api.ts @@ -10,6 +10,7 @@ export { OPENAI_DEFAULT_TTS_VOICE, } from "./default-models.js"; export { buildOpenAICodexProvider } from "./openai-codex-catalog.js"; +export { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js"; export { buildOpenAIProvider } from "./openai-provider.js"; export { buildOpenAIRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js"; export { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; diff --git a/extensions/openai/openai-codex-cli-auth.test.ts b/extensions/openai/openai-codex-cli-auth.test.ts index a8bcd103fad..267b3202f0d 100644 --- a/extensions/openai/openai-codex-cli-auth.test.ts +++ b/extensions/openai/openai-codex-cli-auth.test.ts @@ -96,6 +96,49 @@ describe("readOpenAICodexCliOAuthProfile", () => { expect(parsed).toBeNull(); }); + it("allows the runtime-only Codex CLI profile when the stored default already matches", () => { + const accessToken = buildJwt({ + exp: Math.floor(Date.now() / 1000) + 600, + "https://api.openai.com/profile": { + email: "codex@example.com", + }, + }); + vi.spyOn(fs, "readFileSync").mockReturnValue( + JSON.stringify({ + auth_mode: "chatgpt", + tokens: { + access_token: accessToken, + refresh_token: "refresh-token", + account_id: "acct_123", + }, + }), + ); + + const firstParse = readOpenAICodexCliOAuthProfile({ + store: { version: 1, profiles: {} }, + }); + expect(firstParse).not.toBeNull(); + + const parsed = readOpenAICodexCliOAuthProfile({ + store: { + version: 1, + profiles: { + [OPENAI_CODEX_DEFAULT_PROFILE_ID]: firstParse!.credential, + }, + }, + }); + + expect(parsed).toMatchObject({ + profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, + credential: { + access: accessToken, + refresh: "refresh-token", + accountId: "acct_123", + email: "codex@example.com", + }, + }); + }); + it("returns null without logging when the Codex CLI auth file is missing", () => { const error = Object.assign(new Error("missing"), { code: "ENOENT", diff --git a/extensions/openai/openai-codex-cli-auth.ts b/extensions/openai/openai-codex-cli-auth.ts index 314d7560063..63c5fd111e5 100644 --- a/extensions/openai/openai-codex-cli-auth.ts +++ b/extensions/openai/openai-codex-cli-auth.ts @@ -67,7 +67,6 @@ function oauthCredentialMatches(a: OAuthCredential, b: OAuthCredential): boolean a.provider === b.provider && a.access === b.access && a.refresh === b.refresh && - a.expires === b.expires && a.clientId === b.clientId && a.email === b.email && a.displayName === b.displayName && diff --git a/extensions/openai/openai.live.test.ts b/extensions/openai/openai.live.test.ts index a04495b71ad..116cd58faea 100644 --- a/extensions/openai/openai.live.test.ts +++ b/extensions/openai/openai.live.test.ts @@ -241,8 +241,9 @@ describeLive("openai plugin live", () => { }); const text = (transcription?.text ?? "").toLowerCase(); + const collapsedText = text.replace(/[\s-]+/g, ""); expect(text.length).toBeGreaterThan(0); - expect(text).toContain("openclaw"); + expect(collapsedText).toContain("openclaw"); expect(text).toMatch(/\bok\b/); }, 45_000); diff --git a/extensions/openai/provider-contract-api.ts b/extensions/openai/provider-contract-api.ts new file mode 100644 index 00000000000..fbfdac6f459 --- /dev/null +++ b/extensions/openai/provider-contract-api.ts @@ -0,0 +1,54 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +const noopAuth = async () => ({ profiles: [] }); + +export function createOpenAICodexProvider(): ProviderPlugin { + return { + id: "openai-codex", + label: "OpenAI Codex", + docsPath: "/providers/models", + auth: [ + { + id: "oauth", + kind: "oauth", + label: "ChatGPT OAuth", + hint: "Browser sign-in", + run: noopAuth, + }, + ], + wizard: { + setup: { + choiceId: "openai-codex", + choiceLabel: "OpenAI Codex (ChatGPT OAuth)", + choiceHint: "Browser sign-in", + methodId: "oauth", + }, + }, + }; +} + +export function createOpenAIProvider(): ProviderPlugin { + return { + id: "openai", + label: "OpenAI", + hookAliases: ["azure-openai", "azure-openai-responses"], + docsPath: "/providers/models", + envVars: ["OPENAI_API_KEY"], + auth: [ + { + id: "api-key", + kind: "api_key", + label: "OpenAI API key", + hint: "Direct OpenAI API key", + run: noopAuth, + wizard: { + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + groupId: "openai", + groupLabel: "OpenAI", + groupHint: "Codex OAuth + API key", + }, + }, + ], + }; +} diff --git a/extensions/openrouter/provider-contract-api.ts b/extensions/openrouter/provider-contract-api.ts new file mode 100644 index 00000000000..792f0ec5b0f --- /dev/null +++ b/extensions/openrouter/provider-contract-api.ts @@ -0,0 +1,26 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +export function createOpenrouterProvider(): ProviderPlugin { + return { + id: "openrouter", + label: "OpenRouter", + docsPath: "/providers/models", + envVars: ["OPENROUTER_API_KEY"], + auth: [ + { + id: "api-key", + kind: "api_key", + label: "OpenRouter API key", + hint: "API key", + run: async () => ({ profiles: [] }), + wizard: { + choiceId: "openrouter-api-key", + choiceLabel: "OpenRouter API key", + groupId: "openrouter", + groupLabel: "OpenRouter", + groupHint: "API key", + }, + }, + ], + }; +} diff --git a/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts b/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts new file mode 100644 index 00000000000..570e53b73d6 --- /dev/null +++ b/extensions/perplexity/src/perplexity-web-search-provider.runtime.ts @@ -0,0 +1,539 @@ +import { + readNumberParam, + readStringArrayParam, + readStringParam, +} from "openclaw/plugin-sdk/provider-web-search"; +import { + buildSearchCacheKey, + DEFAULT_SEARCH_COUNT, + isoToPerplexityDate, + normalizeFreshness, + normalizeToIsoDate, + readCachedSearchPayload, + readConfiguredSecretString, + readProviderEnvValue, + resolveSearchCacheTtlMs, + resolveSearchCount, + resolveSearchTimeoutSeconds, + resolveSiteName, + throwWebSearchApiError, + type SearchConfigRecord, + withTrustedWebSearchEndpoint, + wrapWebContent, + writeCachedSearchPayload, +} from "openclaw/plugin-sdk/provider-web-search"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import { + DEFAULT_PERPLEXITY_BASE_URL, + inferPerplexityBaseUrlFromApiKey, + isDirectPerplexityBaseUrl, + PERPLEXITY_DIRECT_BASE_URL, + type PerplexityTransport, +} from "./perplexity-web-search-provider.shared.js"; + +const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search"; +const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro"; + +type PerplexityConfig = { + apiKey?: string; + baseUrl?: string; + model?: string; +}; + +type PerplexitySearchResponse = { + choices?: Array<{ + message?: { + content?: string; + annotations?: Array<{ + type?: string; + url?: string; + url_citation?: { + url?: string; + }; + }>; + }; + }>; + citations?: string[]; +}; + +type PerplexitySearchApiResponse = { + results?: Array<{ + title?: string; + url?: string; + snippet?: string; + date?: string; + }>; +}; + +function resolvePerplexityConfig(searchConfig?: SearchConfigRecord): PerplexityConfig { + const perplexity = searchConfig?.perplexity; + return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity) + ? (perplexity as PerplexityConfig) + : {}; +} + +function resolvePerplexityApiKey(perplexity?: PerplexityConfig): { + apiKey?: string; + source: "config" | "perplexity_env" | "openrouter_env" | "none"; +} { + const fromConfig = readConfiguredSecretString( + perplexity?.apiKey, + "tools.web.search.perplexity.apiKey", + ); + if (fromConfig) { + return { apiKey: fromConfig, source: "config" }; + } + const fromPerplexityEnv = readProviderEnvValue(["PERPLEXITY_API_KEY"]); + if (fromPerplexityEnv) { + return { apiKey: fromPerplexityEnv, source: "perplexity_env" }; + } + const fromOpenRouterEnv = readProviderEnvValue(["OPENROUTER_API_KEY"]); + if (fromOpenRouterEnv) { + return { apiKey: fromOpenRouterEnv, source: "openrouter_env" }; + } + return { apiKey: undefined, source: "none" }; +} + +function resolvePerplexityBaseUrl( + perplexity?: PerplexityConfig, + authSource: "config" | "perplexity_env" | "openrouter_env" | "none" = "none", + configuredKey?: string, +): string { + const fromConfig = normalizeOptionalString(perplexity?.baseUrl) ?? ""; + if (fromConfig) { + return fromConfig; + } + if (authSource === "perplexity_env") { + return PERPLEXITY_DIRECT_BASE_URL; + } + if (authSource === "openrouter_env") { + return DEFAULT_PERPLEXITY_BASE_URL; + } + if (authSource === "config") { + return inferPerplexityBaseUrlFromApiKey(configuredKey) === "openrouter" + ? DEFAULT_PERPLEXITY_BASE_URL + : PERPLEXITY_DIRECT_BASE_URL; + } + return DEFAULT_PERPLEXITY_BASE_URL; +} + +function resolvePerplexityModel(perplexity?: PerplexityConfig): string { + const model = normalizeOptionalString(perplexity?.model) ?? ""; + return model || DEFAULT_PERPLEXITY_MODEL; +} + +function resolvePerplexityRequestModel(baseUrl: string, model: string): string { + if (!isDirectPerplexityBaseUrl(baseUrl)) { + return model; + } + return model.startsWith("perplexity/") ? model.slice("perplexity/".length) : model; +} + +function resolvePerplexityTransport(perplexity?: PerplexityConfig): { + apiKey?: string; + source: "config" | "perplexity_env" | "openrouter_env" | "none"; + baseUrl: string; + model: string; + transport: PerplexityTransport; +} { + const auth = resolvePerplexityApiKey(perplexity); + const baseUrl = resolvePerplexityBaseUrl(perplexity, auth.source, auth.apiKey); + const model = resolvePerplexityModel(perplexity); + const hasLegacyOverride = Boolean( + normalizeOptionalString(perplexity?.baseUrl) || normalizeOptionalString(perplexity?.model), + ); + return { + ...auth, + baseUrl, + model, + transport: + hasLegacyOverride || !isDirectPerplexityBaseUrl(baseUrl) ? "chat_completions" : "search_api", + }; +} + +function extractPerplexityCitations(data: PerplexitySearchResponse): string[] { + const topLevel = (data.citations ?? []).filter((url): url is string => + Boolean(normalizeOptionalString(url)), + ); + if (topLevel.length > 0) { + return [...new Set(topLevel)]; + } + const citations: string[] = []; + for (const choice of data.choices ?? []) { + for (const annotation of choice.message?.annotations ?? []) { + if (annotation.type !== "url_citation") { + continue; + } + const url = + typeof annotation.url_citation?.url === "string" + ? annotation.url_citation.url + : typeof annotation.url === "string" + ? annotation.url + : undefined; + const normalizedUrl = normalizeOptionalString(url); + if (normalizedUrl) { + citations.push(normalizedUrl); + } + } + } + return [...new Set(citations)]; +} + +async function runPerplexitySearchApi(params: { + query: string; + apiKey: string; + count: number; + timeoutSeconds: number; + country?: string; + searchDomainFilter?: string[]; + searchRecencyFilter?: string; + searchLanguageFilter?: string[]; + searchAfterDate?: string; + searchBeforeDate?: string; + maxTokens?: number; + maxTokensPerPage?: number; +}): Promise>> { + const body: Record = { + query: params.query, + max_results: params.count, + }; + if (params.country) { + body.country = params.country; + } + if (params.searchDomainFilter?.length) { + body.search_domain_filter = params.searchDomainFilter; + } + if (params.searchRecencyFilter) { + body.search_recency_filter = params.searchRecencyFilter; + } + if (params.searchLanguageFilter?.length) { + body.search_language_filter = params.searchLanguageFilter; + } + if (params.searchAfterDate) { + body.search_after_date = params.searchAfterDate; + } + if (params.searchBeforeDate) { + body.search_before_date = params.searchBeforeDate; + } + if (params.maxTokens !== undefined) { + body.max_tokens = params.maxTokens; + } + if (params.maxTokensPerPage !== undefined) { + body.max_tokens_per_page = params.maxTokensPerPage; + } + + return withTrustedWebSearchEndpoint( + { + url: PERPLEXITY_SEARCH_ENDPOINT, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${params.apiKey}`, + "HTTP-Referer": "https://openclaw.ai", + "X-Title": "OpenClaw Web Search", + }, + body: JSON.stringify(body), + }, + }, + async (res) => { + if (!res.ok) { + return await throwWebSearchApiError(res, "Perplexity Search"); + } + const data = (await res.json()) as PerplexitySearchApiResponse; + return (data.results ?? []).map((entry) => ({ + title: entry.title ? wrapWebContent(entry.title, "web_search") : "", + url: entry.url ?? "", + description: entry.snippet ? wrapWebContent(entry.snippet, "web_search") : "", + published: entry.date ?? undefined, + siteName: resolveSiteName(entry.url) || undefined, + })); + }, + ); +} + +async function runPerplexitySearch(params: { + query: string; + apiKey: string; + baseUrl: string; + model: string; + timeoutSeconds: number; + freshness?: string; +}): Promise<{ content: string; citations: string[] }> { + const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`; + const body: Record = { + model: resolvePerplexityRequestModel(params.baseUrl, params.model), + messages: [{ role: "user", content: params.query }], + }; + if (params.freshness) { + body.search_recency_filter = params.freshness; + } + + return withTrustedWebSearchEndpoint( + { + url: endpoint, + timeoutSeconds: params.timeoutSeconds, + init: { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + "HTTP-Referer": "https://openclaw.ai", + "X-Title": "OpenClaw Web Search", + }, + body: JSON.stringify(body), + }, + }, + async (res) => { + if (!res.ok) { + return await throwWebSearchApiError(res, "Perplexity"); + } + const data = (await res.json()) as PerplexitySearchResponse; + return { + content: data.choices?.[0]?.message?.content ?? "No response", + citations: extractPerplexityCitations(data), + }; + }, + ); +} + +export async function executePerplexitySearch( + args: Record, + searchConfig?: SearchConfigRecord, +): Promise> { + const perplexityConfig = resolvePerplexityConfig(searchConfig); + const runtime = resolvePerplexityTransport(perplexityConfig); + if (!runtime.apiKey) { + return { + error: "missing_perplexity_api_key", + message: + "web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + + const query = readStringParam(args, "query", { required: true }); + const count = + readNumberParam(args, "count", { integer: true }) ?? searchConfig?.maxResults ?? undefined; + const rawFreshness = readStringParam(args, "freshness"); + const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "perplexity") : undefined; + if (rawFreshness && !freshness) { + return { + error: "invalid_freshness", + message: "freshness must be day, week, month, or year.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + + const structured = runtime.transport === "search_api"; + const country = readStringParam(args, "country"); + const language = readStringParam(args, "language"); + const rawDateAfter = readStringParam(args, "date_after"); + const rawDateBefore = readStringParam(args, "date_before"); + const domainFilter = readStringArrayParam(args, "domain_filter"); + const maxTokens = readNumberParam(args, "max_tokens", { integer: true }); + const maxTokensPerPage = readNumberParam(args, "max_tokens_per_page", { integer: true }); + + if (!structured) { + if (country) { + return { + error: "unsupported_country", + message: + "country filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (language) { + return { + error: "unsupported_language", + message: + "language filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (rawDateAfter || rawDateBefore) { + return { + error: "unsupported_date_filter", + message: + "date_after/date_before are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (domainFilter?.length) { + return { + error: "unsupported_domain_filter", + message: + "domain_filter is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (maxTokens !== undefined || maxTokensPerPage !== undefined) { + return { + error: "unsupported_content_budget", + message: + "max_tokens and max_tokens_per_page are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + } + + if (language && !/^[a-z]{2}$/iu.test(language)) { + return { + error: "invalid_language", + message: "language must be a 2-letter ISO 639-1 code like 'en', 'de', or 'fr'.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (rawFreshness && (rawDateAfter || rawDateBefore)) { + return { + error: "conflicting_time_filters", + message: + "freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined; + const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined; + if (rawDateAfter && !dateAfter) { + return { + error: "invalid_date", + message: "date_after must be YYYY-MM-DD format.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (rawDateBefore && !dateBefore) { + return { + error: "invalid_date", + message: "date_before must be YYYY-MM-DD format.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (dateAfter && dateBefore && dateAfter > dateBefore) { + return { + error: "invalid_date_range", + message: "date_after must be before date_before.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (domainFilter?.length) { + const hasDeny = domainFilter.some((entry) => entry.startsWith("-")); + const hasAllow = domainFilter.some((entry) => !entry.startsWith("-")); + if (hasDeny && hasAllow) { + return { + error: "invalid_domain_filter", + message: + "domain_filter cannot mix allowlist and denylist entries. Use either all positive entries (allowlist) or all entries prefixed with '-' (denylist).", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + if (domainFilter.length > 20) { + return { + error: "invalid_domain_filter", + message: "domain_filter supports a maximum of 20 domains.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + } + + const cacheKey = buildSearchCacheKey([ + "perplexity", + runtime.transport, + runtime.baseUrl, + runtime.model, + query, + resolveSearchCount(count, DEFAULT_SEARCH_COUNT), + country, + language, + freshness, + dateAfter, + dateBefore, + domainFilter?.join(","), + maxTokens, + maxTokensPerPage, + ]); + const cached = readCachedSearchPayload(cacheKey); + if (cached) { + return cached; + } + + const start = Date.now(); + const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig); + const payload = + runtime.transport === "chat_completions" + ? { + query, + provider: "perplexity", + model: runtime.model, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: "perplexity", + wrapped: true, + }, + ...(await (async () => { + const result = await runPerplexitySearch({ + query, + apiKey: runtime.apiKey!, + baseUrl: runtime.baseUrl, + model: runtime.model, + timeoutSeconds, + freshness, + }); + return { + content: wrapWebContent(result.content, "web_search"), + citations: result.citations, + }; + })()), + } + : { + query, + provider: "perplexity", + count: 0, + tookMs: Date.now() - start, + externalContent: { + untrusted: true, + source: "web_search", + provider: "perplexity", + wrapped: true, + }, + results: await runPerplexitySearchApi({ + query, + apiKey: runtime.apiKey, + count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT), + timeoutSeconds, + country: country ?? undefined, + searchDomainFilter: domainFilter, + searchRecencyFilter: freshness, + searchLanguageFilter: language ? [language] : undefined, + searchAfterDate: dateAfter ? isoToPerplexityDate(dateAfter) : undefined, + searchBeforeDate: dateBefore ? isoToPerplexityDate(dateBefore) : undefined, + maxTokens: maxTokens ?? undefined, + maxTokensPerPage: maxTokensPerPage ?? undefined, + }), + }; + + if (Array.isArray((payload as { results?: unknown[] }).results)) { + (payload as { count: number }).count = (payload as { results: unknown[] }).results.length; + (payload as { tookMs: number }).tookMs = Date.now() - start; + } else { + (payload as { tookMs: number }).tookMs = Date.now() - start; + } + + writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); + return payload; +} + +export const __testing = { + inferPerplexityBaseUrlFromApiKey, + resolvePerplexityBaseUrl, + resolvePerplexityModel, + resolvePerplexityTransport, + isDirectPerplexityBaseUrl, + resolvePerplexityRequestModel, + resolvePerplexityApiKey, + normalizeToIsoDate, + isoToPerplexityDate, +} as const; diff --git a/extensions/perplexity/src/perplexity-web-search-provider.shared.ts b/extensions/perplexity/src/perplexity-web-search-provider.shared.ts new file mode 100644 index 00000000000..016b0979362 --- /dev/null +++ b/extensions/perplexity/src/perplexity-web-search-provider.shared.ts @@ -0,0 +1,82 @@ +export const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; +export const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; + +const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; +const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; + +export type PerplexityTransport = "search_api" | "chat_completions"; +export type PerplexityBaseUrlHint = "direct" | "openrouter"; +export type PerplexityRuntimeTransportContext = { + searchConfig?: Record; + resolvedKey?: string; + keySource: "config" | "secretRef" | "env" | "missing"; + fallbackEnvVar?: string; +}; + +function trimToUndefined(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return trimToUndefined(value)?.toLowerCase() ?? ""; +} + +export function inferPerplexityBaseUrlFromApiKey( + apiKey?: string, +): PerplexityBaseUrlHint | undefined { + if (!apiKey) { + return undefined; + } + const normalized = normalizeLowercaseStringOrEmpty(apiKey); + if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return "direct"; + } + if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return "openrouter"; + } + return undefined; +} + +export function isDirectPerplexityBaseUrl(baseUrl: string): boolean { + try { + return ( + normalizeLowercaseStringOrEmpty(new URL(baseUrl.trim()).hostname) === "api.perplexity.ai" + ); + } catch { + return false; + } +} + +export function resolvePerplexityRuntimeTransport( + params: PerplexityRuntimeTransportContext, +): PerplexityTransport | undefined { + const perplexity = params.searchConfig?.perplexity; + const scoped = + perplexity && typeof perplexity === "object" && !Array.isArray(perplexity) + ? (perplexity as { baseUrl?: string; model?: string }) + : undefined; + const configuredBaseUrl = trimToUndefined(scoped?.baseUrl) ?? ""; + const configuredModel = trimToUndefined(scoped?.model) ?? ""; + const baseUrl = (() => { + if (configuredBaseUrl) { + return configuredBaseUrl; + } + if (params.keySource === "env") { + if (params.fallbackEnvVar === "PERPLEXITY_API_KEY") { + return PERPLEXITY_DIRECT_BASE_URL; + } + if (params.fallbackEnvVar === "OPENROUTER_API_KEY") { + return DEFAULT_PERPLEXITY_BASE_URL; + } + } + if ((params.keySource === "config" || params.keySource === "secretRef") && params.resolvedKey) { + return inferPerplexityBaseUrlFromApiKey(params.resolvedKey) === "openrouter" + ? DEFAULT_PERPLEXITY_BASE_URL + : PERPLEXITY_DIRECT_BASE_URL; + } + return DEFAULT_PERPLEXITY_BASE_URL; + })(); + return configuredBaseUrl || configuredModel || !isDirectPerplexityBaseUrl(baseUrl) + ? "chat_completions" + : "search_api"; +} diff --git a/extensions/perplexity/src/perplexity-web-search-provider.test.ts b/extensions/perplexity/src/perplexity-web-search-provider.test.ts index d507f605769..87bf1f2443f 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.test.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.test.ts @@ -1,6 +1,6 @@ import { withEnv } from "openclaw/plugin-sdk/testing"; import { describe, expect, it } from "vitest"; -import { __testing } from "./perplexity-web-search-provider.js"; +import { __testing } from "./perplexity-web-search-provider.runtime.js"; const openRouterApiKeyEnv = ["OPENROUTER_API", "KEY"].join("_"); const perplexityApiKeyEnv = ["PERPLEXITY_API", "KEY"].join("_"); diff --git a/extensions/perplexity/src/perplexity-web-search-provider.ts b/extensions/perplexity/src/perplexity-web-search-provider.ts index ad9748ce5b2..718ca1a14af 100644 --- a/extensions/perplexity/src/perplexity-web-search-provider.ts +++ b/extensions/perplexity/src/perplexity-web-search-provider.ts @@ -1,675 +1,103 @@ -import { Type } from "@sinclair/typebox"; import { - readNumberParam, - readStringArrayParam, - readStringParam, -} from "openclaw/plugin-sdk/provider-web-search"; -import { - buildSearchCacheKey, - DEFAULT_SEARCH_COUNT, - getScopedCredentialValue, - MAX_SEARCH_COUNT, - isoToPerplexityDate, + createWebSearchProviderContractFields, mergeScopedSearchConfig, - normalizeFreshness, - normalizeToIsoDate, - readCachedSearchPayload, - readConfiguredSecretString, - readProviderEnvValue, resolveProviderWebSearchPluginConfig, - resolveSearchCacheTtlMs, - resolveSearchCount, - resolveSearchTimeoutSeconds, - resolveSiteName, - setScopedCredentialValue, - setProviderWebSearchPluginConfigValue, - throwWebSearchApiError, - type SearchConfigRecord, - type WebSearchCredentialResolutionSource, type WebSearchProviderPlugin, type WebSearchProviderToolDefinition, - withTrustedWebSearchEndpoint, - wrapWebContent, - writeCachedSearchPayload, -} from "openclaw/plugin-sdk/provider-web-search"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "openclaw/plugin-sdk/text-runtime"; +} from "openclaw/plugin-sdk/provider-web-search-config-contract"; +import { resolvePerplexityRuntimeTransport } from "./perplexity-web-search-provider.shared.js"; -const DEFAULT_PERPLEXITY_BASE_URL = "https://openrouter.ai/api/v1"; -const PERPLEXITY_DIRECT_BASE_URL = "https://api.perplexity.ai"; -const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search"; -const DEFAULT_PERPLEXITY_MODEL = "perplexity/sonar-pro"; -const PERPLEXITY_KEY_PREFIXES = ["pplx-"]; -const OPENROUTER_KEY_PREFIXES = ["sk-or-"]; +const PERPLEXITY_CREDENTIAL_PATH = "plugins.entries.perplexity.config.webSearch.apiKey"; -type PerplexityConfig = { - apiKey?: string; - baseUrl?: string; - model?: string; -}; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} -type PerplexityTransport = "search_api" | "chat_completions"; -type PerplexityBaseUrlHint = "direct" | "openrouter"; +function createPerplexityParameters(transport?: string): Record { + const properties: Record = { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, + }, + freshness: { + type: "string", + description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.", + }, + }; -type PerplexitySearchResponse = { - choices?: Array<{ - message?: { - content?: string; - annotations?: Array<{ - type?: string; - url?: string; - url_citation?: { - url?: string; - }; - }>; + if (transport !== "chat_completions") { + properties.country = { + type: "string", + description: "Native Perplexity Search API only. 2-letter country code.", }; - }>; - citations?: string[]; -}; + properties.language = { + type: "string", + description: "Native Perplexity Search API only. ISO 639-1 language code.", + }; + properties.date_after = { + type: "string", + description: + "Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).", + }; + properties.date_before = { + type: "string", + description: + "Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).", + }; + properties.domain_filter = { + type: "array", + items: { type: "string" }, + description: "Native Perplexity Search API only. Domain filter (max 20).", + }; + properties.max_tokens = { + type: "number", + description: "Native Perplexity Search API only. Total content budget across all results.", + minimum: 1, + maximum: 1000000, + }; + properties.max_tokens_per_page = { + type: "number", + description: "Native Perplexity Search API only. Max tokens extracted per page.", + minimum: 1, + }; + } -type PerplexitySearchApiResponse = { - results?: Array<{ - title?: string; - url?: string; - snippet?: string; - date?: string; - }>; -}; - -function resolvePerplexityConfig(searchConfig?: SearchConfigRecord): PerplexityConfig { - const perplexity = searchConfig?.perplexity; - return perplexity && typeof perplexity === "object" && !Array.isArray(perplexity) - ? (perplexity as PerplexityConfig) - : {}; -} - -function inferPerplexityBaseUrlFromApiKey(apiKey?: string): PerplexityBaseUrlHint | undefined { - if (!apiKey) { - return undefined; - } - const normalized = normalizeLowercaseStringOrEmpty(apiKey); - if (PERPLEXITY_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return "direct"; - } - if (OPENROUTER_KEY_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return "openrouter"; - } - return undefined; -} - -function resolvePerplexityApiKey(perplexity?: PerplexityConfig): { - apiKey?: string; - source: "config" | "perplexity_env" | "openrouter_env" | "none"; -} { - const fromConfig = readConfiguredSecretString( - perplexity?.apiKey, - "tools.web.search.perplexity.apiKey", - ); - if (fromConfig) { - return { apiKey: fromConfig, source: "config" }; - } - const fromPerplexityEnv = readProviderEnvValue(["PERPLEXITY_API_KEY"]); - if (fromPerplexityEnv) { - return { apiKey: fromPerplexityEnv, source: "perplexity_env" }; - } - const fromOpenRouterEnv = readProviderEnvValue(["OPENROUTER_API_KEY"]); - if (fromOpenRouterEnv) { - return { apiKey: fromOpenRouterEnv, source: "openrouter_env" }; - } - return { apiKey: undefined, source: "none" }; -} - -function resolvePerplexityBaseUrl( - perplexity?: PerplexityConfig, - authSource: "config" | "perplexity_env" | "openrouter_env" | "none" = "none", - configuredKey?: string, -): string { - const fromConfig = normalizeOptionalString(perplexity?.baseUrl) ?? ""; - if (fromConfig) { - return fromConfig; - } - if (authSource === "perplexity_env") { - return PERPLEXITY_DIRECT_BASE_URL; - } - if (authSource === "openrouter_env") { - return DEFAULT_PERPLEXITY_BASE_URL; - } - if (authSource === "config") { - return inferPerplexityBaseUrlFromApiKey(configuredKey) === "openrouter" - ? DEFAULT_PERPLEXITY_BASE_URL - : PERPLEXITY_DIRECT_BASE_URL; - } - return DEFAULT_PERPLEXITY_BASE_URL; -} - -function resolvePerplexityModel(perplexity?: PerplexityConfig): string { - const model = normalizeOptionalString(perplexity?.model) ?? ""; - return model || DEFAULT_PERPLEXITY_MODEL; -} - -function isDirectPerplexityBaseUrl(baseUrl: string): boolean { - try { - return ( - normalizeLowercaseStringOrEmpty(new URL(baseUrl.trim()).hostname) === "api.perplexity.ai" - ); - } catch { - return false; - } -} - -function resolvePerplexityRequestModel(baseUrl: string, model: string): string { - if (!isDirectPerplexityBaseUrl(baseUrl)) { - return model; - } - return model.startsWith("perplexity/") ? model.slice("perplexity/".length) : model; -} - -function resolvePerplexityTransport(perplexity?: PerplexityConfig): { - apiKey?: string; - source: "config" | "perplexity_env" | "openrouter_env" | "none"; - baseUrl: string; - model: string; - transport: PerplexityTransport; -} { - const auth = resolvePerplexityApiKey(perplexity); - const baseUrl = resolvePerplexityBaseUrl(perplexity, auth.source, auth.apiKey); - const model = resolvePerplexityModel(perplexity); - const hasLegacyOverride = Boolean( - normalizeOptionalString(perplexity?.baseUrl) || normalizeOptionalString(perplexity?.model), - ); return { - ...auth, - baseUrl, - model, - transport: - hasLegacyOverride || !isDirectPerplexityBaseUrl(baseUrl) ? "chat_completions" : "search_api", + type: "object", + properties, + required: ["query"], }; } -function extractPerplexityCitations(data: PerplexitySearchResponse): string[] { - const topLevel = (data.citations ?? []).filter((url): url is string => - Boolean(normalizeOptionalString(url)), +function hasPerplexityLegacyOverride(searchConfig?: Record): boolean { + const perplexity = isRecord(searchConfig?.perplexity) ? searchConfig.perplexity : undefined; + return ( + (typeof perplexity?.baseUrl === "string" && perplexity.baseUrl.trim().length > 0) || + (typeof perplexity?.model === "string" && perplexity.model.trim().length > 0) ); - if (topLevel.length > 0) { - return [...new Set(topLevel)]; - } - const citations: string[] = []; - for (const choice of data.choices ?? []) { - for (const annotation of choice.message?.annotations ?? []) { - if (annotation.type !== "url_citation") { - continue; - } - const url = - typeof annotation.url_citation?.url === "string" - ? annotation.url_citation.url - : typeof annotation.url === "string" - ? annotation.url - : undefined; - const normalizedUrl = normalizeOptionalString(url); - if (normalizedUrl) { - citations.push(normalizedUrl); - } - } - } - return [...new Set(citations)]; -} - -async function runPerplexitySearchApi(params: { - query: string; - apiKey: string; - count: number; - timeoutSeconds: number; - country?: string; - searchDomainFilter?: string[]; - searchRecencyFilter?: string; - searchLanguageFilter?: string[]; - searchAfterDate?: string; - searchBeforeDate?: string; - maxTokens?: number; - maxTokensPerPage?: number; -}): Promise>> { - const body: Record = { - query: params.query, - max_results: params.count, - }; - if (params.country) { - body.country = params.country; - } - if (params.searchDomainFilter?.length) { - body.search_domain_filter = params.searchDomainFilter; - } - if (params.searchRecencyFilter) { - body.search_recency_filter = params.searchRecencyFilter; - } - if (params.searchLanguageFilter?.length) { - body.search_language_filter = params.searchLanguageFilter; - } - if (params.searchAfterDate) { - body.search_after_date = params.searchAfterDate; - } - if (params.searchBeforeDate) { - body.search_before_date = params.searchBeforeDate; - } - if (params.maxTokens !== undefined) { - body.max_tokens = params.maxTokens; - } - if (params.maxTokensPerPage !== undefined) { - body.max_tokens_per_page = params.maxTokensPerPage; - } - - return withTrustedWebSearchEndpoint( - { - url: PERPLEXITY_SEARCH_ENDPOINT, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - Authorization: `Bearer ${params.apiKey}`, - "HTTP-Referer": "https://openclaw.ai", - "X-Title": "OpenClaw Web Search", - }, - body: JSON.stringify(body), - }, - }, - async (res) => { - if (!res.ok) { - return await throwWebSearchApiError(res, "Perplexity Search"); - } - const data = (await res.json()) as PerplexitySearchApiResponse; - return (data.results ?? []).map((entry) => ({ - title: entry.title ? wrapWebContent(entry.title, "web_search") : "", - url: entry.url ?? "", - description: entry.snippet ? wrapWebContent(entry.snippet, "web_search") : "", - published: entry.date ?? undefined, - siteName: resolveSiteName(entry.url) || undefined, - })); - }, - ); -} - -async function runPerplexitySearch(params: { - query: string; - apiKey: string; - baseUrl: string; - model: string; - timeoutSeconds: number; - freshness?: string; -}): Promise<{ content: string; citations: string[] }> { - const endpoint = `${params.baseUrl.trim().replace(/\/$/, "")}/chat/completions`; - const body: Record = { - model: resolvePerplexityRequestModel(params.baseUrl, params.model), - messages: [{ role: "user", content: params.query }], - }; - if (params.freshness) { - body.search_recency_filter = params.freshness; - } - - return withTrustedWebSearchEndpoint( - { - url: endpoint, - timeoutSeconds: params.timeoutSeconds, - init: { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${params.apiKey}`, - "HTTP-Referer": "https://openclaw.ai", - "X-Title": "OpenClaw Web Search", - }, - body: JSON.stringify(body), - }, - }, - async (res) => { - if (!res.ok) { - return await throwWebSearchApiError(res, "Perplexity"); - } - const data = (await res.json()) as PerplexitySearchResponse; - return { - content: data.choices?.[0]?.message?.content ?? "No response", - citations: extractPerplexityCitations(data), - }; - }, - ); -} - -function resolveRuntimeTransport(params: { - searchConfig?: Record; - resolvedKey?: string; - keySource: WebSearchCredentialResolutionSource; - fallbackEnvVar?: string; -}): PerplexityTransport | undefined { - const perplexity = params.searchConfig?.perplexity; - const scoped = - perplexity && typeof perplexity === "object" && !Array.isArray(perplexity) - ? (perplexity as { baseUrl?: string; model?: string }) - : undefined; - const configuredBaseUrl = normalizeOptionalString(scoped?.baseUrl) ?? ""; - const configuredModel = normalizeOptionalString(scoped?.model) ?? ""; - const baseUrl = (() => { - if (configuredBaseUrl) { - return configuredBaseUrl; - } - if (params.keySource === "env") { - if (params.fallbackEnvVar === "PERPLEXITY_API_KEY") { - return PERPLEXITY_DIRECT_BASE_URL; - } - if (params.fallbackEnvVar === "OPENROUTER_API_KEY") { - return DEFAULT_PERPLEXITY_BASE_URL; - } - } - if ((params.keySource === "config" || params.keySource === "secretRef") && params.resolvedKey) { - return inferPerplexityBaseUrlFromApiKey(params.resolvedKey) === "openrouter" - ? DEFAULT_PERPLEXITY_BASE_URL - : PERPLEXITY_DIRECT_BASE_URL; - } - return DEFAULT_PERPLEXITY_BASE_URL; - })(); - return configuredBaseUrl || configuredModel || !isDirectPerplexityBaseUrl(baseUrl) - ? "chat_completions" - : "search_api"; -} - -function createPerplexitySchema(transport?: PerplexityTransport) { - const querySchema = { - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-10).", - minimum: 1, - maximum: MAX_SEARCH_COUNT, - }), - ), - freshness: Type.Optional( - Type.String({ description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'." }), - ), - }; - if (transport === "chat_completions") { - return Type.Object(querySchema); - } - return Type.Object({ - ...querySchema, - country: Type.Optional( - Type.String({ description: "Native Perplexity Search API only. 2-letter country code." }), - ), - language: Type.Optional( - Type.String({ description: "Native Perplexity Search API only. ISO 639-1 language code." }), - ), - date_after: Type.Optional( - Type.String({ - description: - "Native Perplexity Search API only. Only results published after this date (YYYY-MM-DD).", - }), - ), - date_before: Type.Optional( - Type.String({ - description: - "Native Perplexity Search API only. Only results published before this date (YYYY-MM-DD).", - }), - ), - domain_filter: Type.Optional( - Type.Array(Type.String(), { - description: "Native Perplexity Search API only. Domain filter (max 20).", - }), - ), - max_tokens: Type.Optional( - Type.Number({ - description: "Native Perplexity Search API only. Total content budget across all results.", - minimum: 1, - maximum: 1000000, - }), - ), - max_tokens_per_page: Type.Optional( - Type.Number({ - description: "Native Perplexity Search API only. Max tokens extracted per page.", - minimum: 1, - }), - ), - }); } function createPerplexityToolDefinition( - searchConfig?: SearchConfigRecord, - runtimeTransport?: PerplexityTransport, + searchConfig?: Record, + runtimeTransport?: string, ): WebSearchProviderToolDefinition { - const perplexityConfig = resolvePerplexityConfig(searchConfig); const schemaTransport = runtimeTransport ?? - (perplexityConfig.baseUrl || perplexityConfig.model ? "chat_completions" : undefined); + (hasPerplexityLegacyOverride(searchConfig) ? "chat_completions" : undefined); return { description: schemaTransport === "chat_completions" ? "Search the web using Perplexity Sonar via Perplexity/OpenRouter chat completions. Returns AI-synthesized answers with citations from web-grounded search." : "Search the web using Perplexity. Runtime routing decides between native Search API and Sonar chat-completions compatibility. Structured filters are available on the native Search API path.", - parameters: createPerplexitySchema(schemaTransport), + parameters: createPerplexityParameters(schemaTransport), execute: async (args) => { - const runtime = resolvePerplexityTransport(perplexityConfig); - if (!runtime.apiKey) { - return { - error: "missing_perplexity_api_key", - message: - "web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - - const params = args; - const query = readStringParam(params, "query", { required: true }); - const count = - readNumberParam(params, "count", { integer: true }) ?? - searchConfig?.maxResults ?? - undefined; - const rawFreshness = readStringParam(params, "freshness"); - const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "perplexity") : undefined; - if (rawFreshness && !freshness) { - return { - error: "invalid_freshness", - message: "freshness must be day, week, month, or year.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - - const structured = runtime.transport === "search_api"; - const country = readStringParam(params, "country"); - const language = readStringParam(params, "language"); - const rawDateAfter = readStringParam(params, "date_after"); - const rawDateBefore = readStringParam(params, "date_before"); - const domainFilter = readStringArrayParam(params, "domain_filter"); - const maxTokens = readNumberParam(params, "max_tokens", { integer: true }); - const maxTokensPerPage = readNumberParam(params, "max_tokens_per_page", { integer: true }); - - if (!structured) { - if (country) { - return { - error: "unsupported_country", - message: - "country filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (language) { - return { - error: "unsupported_language", - message: - "language filtering is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (rawDateAfter || rawDateBefore) { - return { - error: "unsupported_date_filter", - message: - "date_after/date_before are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (domainFilter?.length) { - return { - error: "unsupported_domain_filter", - message: - "domain_filter is only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable it.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (maxTokens !== undefined || maxTokensPerPage !== undefined) { - return { - error: "unsupported_content_budget", - message: - "max_tokens and max_tokens_per_page are only supported by the native Perplexity Search API path. Remove Perplexity baseUrl/model overrides or use a direct PERPLEXITY_API_KEY to enable them.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - } - - if (language && !/^[a-z]{2}$/i.test(language)) { - return { - error: "invalid_language", - message: "language must be a 2-letter ISO 639-1 code like 'en', 'de', or 'fr'.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (rawFreshness && (rawDateAfter || rawDateBefore)) { - return { - error: "conflicting_time_filters", - message: - "freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined; - const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined; - if (rawDateAfter && !dateAfter) { - return { - error: "invalid_date", - message: "date_after must be YYYY-MM-DD format.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (rawDateBefore && !dateBefore) { - return { - error: "invalid_date", - message: "date_before must be YYYY-MM-DD format.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (dateAfter && dateBefore && dateAfter > dateBefore) { - return { - error: "invalid_date_range", - message: "date_after must be before date_before.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (domainFilter?.length) { - const hasDeny = domainFilter.some((entry) => entry.startsWith("-")); - const hasAllow = domainFilter.some((entry) => !entry.startsWith("-")); - if (hasDeny && hasAllow) { - return { - error: "invalid_domain_filter", - message: - "domain_filter cannot mix allowlist and denylist entries. Use either all positive entries (allowlist) or all entries prefixed with '-' (denylist).", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - if (domainFilter.length > 20) { - return { - error: "invalid_domain_filter", - message: "domain_filter supports a maximum of 20 domains.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - } - - const cacheKey = buildSearchCacheKey([ - "perplexity", - runtime.transport, - runtime.baseUrl, - runtime.model, - query, - resolveSearchCount(count, DEFAULT_SEARCH_COUNT), - country, - language, - freshness, - dateAfter, - dateBefore, - domainFilter?.join(","), - maxTokens, - maxTokensPerPage, - ]); - const cached = readCachedSearchPayload(cacheKey); - if (cached) { - return cached; - } - - const start = Date.now(); - const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig); - const payload = - runtime.transport === "chat_completions" - ? { - query, - provider: "perplexity", - model: runtime.model, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: "perplexity", - wrapped: true, - }, - ...(await (async () => { - const result = await runPerplexitySearch({ - query, - apiKey: runtime.apiKey!, - baseUrl: runtime.baseUrl, - model: runtime.model, - timeoutSeconds, - freshness, - }); - return { - content: wrapWebContent(result.content, "web_search"), - citations: result.citations, - }; - })()), - } - : { - query, - provider: "perplexity", - count: 0, - tookMs: Date.now() - start, - externalContent: { - untrusted: true, - source: "web_search", - provider: "perplexity", - wrapped: true, - }, - results: await runPerplexitySearchApi({ - query, - apiKey: runtime.apiKey, - count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT), - timeoutSeconds, - country: country ?? undefined, - searchDomainFilter: domainFilter, - searchRecencyFilter: freshness, - searchLanguageFilter: language ? [language] : undefined, - searchAfterDate: dateAfter ? isoToPerplexityDate(dateAfter) : undefined, - searchBeforeDate: dateBefore ? isoToPerplexityDate(dateBefore) : undefined, - maxTokens: maxTokens ?? undefined, - maxTokensPerPage: maxTokensPerPage ?? undefined, - }), - }; - - if (Array.isArray((payload as { results?: unknown[] }).results)) { - (payload as { count: number }).count = (payload as { results: unknown[] }).results.length; - (payload as { tookMs: number }).tookMs = Date.now() - start; - } else { - (payload as { tookMs: number }).tookMs = Date.now() - start; - } - - writeCachedSearchPayload(cacheKey, payload, resolveSearchCacheTtlMs(searchConfig)); - return payload; + const { executePerplexitySearch } = + await import("./perplexity-web-search-provider.runtime.js"); + return await executePerplexitySearch(args, searchConfig); }, }; } @@ -686,18 +114,14 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://www.perplexity.ai/settings/api", docsUrl: "https://docs.openclaw.ai/perplexity", autoDetectOrder: 50, - credentialPath: "plugins.entries.perplexity.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.perplexity.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "perplexity"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "perplexity", value), - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "perplexity")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "perplexity", "apiKey", value); - }, + credentialPath: PERPLEXITY_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: PERPLEXITY_CREDENTIAL_PATH, + searchCredential: { type: "scoped", scopeId: "perplexity" }, + configuredCredential: { pluginId: "perplexity" }, + }), resolveRuntimeMetadata: (ctx) => ({ - perplexityTransport: resolveRuntimeTransport({ + perplexityTransport: resolvePerplexityRuntimeTransport({ searchConfig: mergeScopedSearchConfig( ctx.searchConfig, "perplexity", @@ -719,15 +143,3 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin { ), }; } - -export const __testing = { - inferPerplexityBaseUrlFromApiKey, - resolvePerplexityBaseUrl, - resolvePerplexityModel, - resolvePerplexityTransport, - isDirectPerplexityBaseUrl, - resolvePerplexityRequestModel, - resolvePerplexityApiKey, - normalizeToIsoDate, - isoToPerplexityDate, -} as const; diff --git a/extensions/perplexity/test-api.ts b/extensions/perplexity/test-api.ts index c8d2a91ce71..6fec3a93f7f 100644 --- a/extensions/perplexity/test-api.ts +++ b/extensions/perplexity/test-api.ts @@ -1 +1 @@ -export { __testing } from "./src/perplexity-web-search-provider.js"; +export { __testing } from "./src/perplexity-web-search-provider.runtime.js"; diff --git a/extensions/perplexity/web-search-contract-api.ts b/extensions/perplexity/web-search-contract-api.ts index 60bf42e2288..a5cdeb901a8 100644 --- a/extensions/perplexity/web-search-contract-api.ts +++ b/extensions/perplexity/web-search-contract-api.ts @@ -1,7 +1,10 @@ import { createWebSearchProviderContractFields, + mergeScopedSearchConfig, + resolveProviderWebSearchPluginConfig, type WebSearchProviderPlugin, } from "openclaw/plugin-sdk/provider-web-search-config-contract"; +import { resolvePerplexityRuntimeTransport } from "./src/perplexity-web-search-provider.shared.js"; export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin { const credentialPath = "plugins.entries.perplexity.config.webSearch.apiKey"; @@ -23,6 +26,18 @@ export function createPerplexityWebSearchProvider(): WebSearchProviderPlugin { searchCredential: { type: "scoped", scopeId: "perplexity" }, configuredCredential: { pluginId: "perplexity" }, }), + resolveRuntimeMetadata: (ctx) => ({ + perplexityTransport: resolvePerplexityRuntimeTransport({ + searchConfig: mergeScopedSearchConfig( + ctx.searchConfig, + "perplexity", + resolveProviderWebSearchPluginConfig(ctx.config, "perplexity"), + ), + resolvedKey: ctx.resolvedCredential?.value, + keySource: ctx.resolvedCredential?.source ?? "missing", + fallbackEnvVar: ctx.resolvedCredential?.fallbackEnvVar, + }), + }), createTool: () => null, }; } diff --git a/extensions/perplexity/web-search-provider.ts b/extensions/perplexity/web-search-provider.ts index c501c44a28c..9200070af22 100644 --- a/extensions/perplexity/web-search-provider.ts +++ b/extensions/perplexity/web-search-provider.ts @@ -1,4 +1 @@ -export { - __testing, - createPerplexityWebSearchProvider, -} from "./src/perplexity-web-search-provider.js"; +export { createPerplexityWebSearchProvider } from "./src/perplexity-web-search-provider.js"; diff --git a/extensions/qa-lab/src/cli.runtime.test.ts b/extensions/qa-lab/src/cli.runtime.test.ts index a87d055b068..418d8a001c1 100644 --- a/extensions/qa-lab/src/cli.runtime.test.ts +++ b/extensions/qa-lab/src/cli.runtime.test.ts @@ -72,6 +72,7 @@ import { runQaDockerScaffoldCommand, runQaDockerUpCommand, runQaCharacterEvalCommand, + runQaCoverageReportCommand, runQaManualLaneCommand, runQaParityReportCommand, runQaSuiteCommand, @@ -336,6 +337,13 @@ describe("qa cli runtime", () => { } }); + it("prints a markdown coverage report from scenario metadata", async () => { + await runQaCoverageReportCommand({ repoRoot: process.cwd() }); + + expect(stdoutWrite).toHaveBeenCalledWith(expect.stringContaining("# QA Coverage Inventory")); + expect(stdoutWrite).toHaveBeenCalledWith(expect.stringContaining("memory.recall")); + }); + it("resolves character eval paths and passes model refs through", async () => { await runQaCharacterEvalCommand({ repoRoot: "/tmp/openclaw-repo", diff --git a/extensions/qa-lab/src/cli.runtime.ts b/extensions/qa-lab/src/cli.runtime.ts index 6b2f78a86ce..8fa2ba216a2 100644 --- a/extensions/qa-lab/src/cli.runtime.ts +++ b/extensions/qa-lab/src/cli.runtime.ts @@ -9,6 +9,7 @@ import { import { resolveQaParityPackScenarioIds } from "./agentic-parity.js"; import { runQaCharacterEval, type QaCharacterModelOptions } from "./character-eval.js"; import { resolveRepoRelativeOutputDir } from "./cli-paths.js"; +import { buildQaCoverageInventory, renderQaCoverageMarkdownReport } from "./coverage-report.js"; import { buildQaDockerHarnessImage, writeQaDockerHarnessFiles } from "./docker-harness.js"; import { runQaDockerUp } from "./docker-up.runtime.js"; import type { QaCliBackendAuthMode } from "./gateway-child.js"; @@ -36,6 +37,7 @@ import { type QaProviderMode, type QaProviderModeInput, } from "./run-config.js"; +import { readQaScenarioPack } from "./scenario-catalog.js"; import { runQaSuiteFromRuntime } from "./suite-launch.runtime.js"; type InterruptibleServer = { @@ -442,6 +444,29 @@ export async function runQaParityReportCommand(opts: { process.exitCode = 1; } } + +export async function runQaCoverageReportCommand(opts: { + repoRoot?: string; + output?: string; + json?: boolean; +}) { + const repoRoot = path.resolve(opts.repoRoot ?? process.cwd()); + const inventory = buildQaCoverageInventory(readQaScenarioPack().scenarios); + const outputPath = opts.output ? path.resolve(repoRoot, opts.output) : undefined; + const body = opts.json + ? `${JSON.stringify(inventory, null, 2)}\n` + : renderQaCoverageMarkdownReport(inventory); + + if (outputPath) { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + await fs.writeFile(outputPath, body, "utf8"); + process.stdout.write(`QA coverage report: ${outputPath}\n`); + return; + } + + process.stdout.write(body); +} + export async function runQaCharacterEvalCommand(opts: { repoRoot?: string; outputDir?: string; diff --git a/extensions/qa-lab/src/cli.test.ts b/extensions/qa-lab/src/cli.test.ts index f71ba5ca5f7..5db64663f3e 100644 --- a/extensions/qa-lab/src/cli.test.ts +++ b/extensions/qa-lab/src/cli.test.ts @@ -44,12 +44,14 @@ const { runQaCredentialsAddCommand, runQaCredentialsListCommand, runQaCredentialsRemoveCommand, + runQaCoverageReportCommand, runQaProviderServerCommand, runQaTelegramCommand, } = vi.hoisted(() => ({ runQaCredentialsAddCommand: vi.fn(), runQaCredentialsListCommand: vi.fn(), runQaCredentialsRemoveCommand: vi.fn(), + runQaCoverageReportCommand: vi.fn(), runQaProviderServerCommand: vi.fn(), runQaTelegramCommand: vi.fn(), })); @@ -72,6 +74,7 @@ vi.mock("./cli.runtime.js", () => ({ runQaCredentialsAddCommand, runQaCredentialsListCommand, runQaCredentialsRemoveCommand, + runQaCoverageReportCommand, runQaProviderServerCommand, })); @@ -85,6 +88,7 @@ describe("qa cli registration", () => { runQaCredentialsAddCommand.mockReset(); runQaCredentialsListCommand.mockReset(); runQaCredentialsRemoveCommand.mockReset(); + runQaCoverageReportCommand.mockReset(); runQaProviderServerCommand.mockReset(); runQaTelegramCommand.mockReset(); listQaRunnerCliContributions @@ -101,10 +105,30 @@ describe("qa cli registration", () => { const qa = program.commands.find((command) => command.name() === "qa"); expect(qa).toBeDefined(); expect(qa?.commands.map((command) => command.name())).toEqual( - expect.arrayContaining([TEST_QA_RUNNER.commandName, "telegram", "credentials"]), + expect.arrayContaining([TEST_QA_RUNNER.commandName, "telegram", "credentials", "coverage"]), ); }); + it("routes coverage report flags into the qa runtime command", async () => { + await program.parseAsync([ + "node", + "openclaw", + "qa", + "coverage", + "--repo-root", + "/tmp/openclaw-repo", + "--output", + ".artifacts/qa-coverage.md", + "--json", + ]); + + expect(runQaCoverageReportCommand).toHaveBeenCalledWith({ + repoRoot: "/tmp/openclaw-repo", + output: ".artifacts/qa-coverage.md", + json: true, + }); + }); + it("delegates discovered qa runner registration through the generic host seam", () => { const [{ registration }] = listQaRunnerCliContributions.mock.results[0]?.value; expect(registration.register).toHaveBeenCalledTimes(1); diff --git a/extensions/qa-lab/src/cli.ts b/extensions/qa-lab/src/cli.ts index 0abba6901d8..d4f1feea9aa 100644 --- a/extensions/qa-lab/src/cli.ts +++ b/extensions/qa-lab/src/cli.ts @@ -60,6 +60,12 @@ async function runQaParityReport(opts: { const runtime = await loadQaLabCliRuntime(); await runtime.runQaParityReportCommand(opts); } + +async function runQaCoverageReport(opts: { repoRoot?: string; output?: string; json?: boolean }) { + const runtime = await loadQaLabCliRuntime(); + await runtime.runQaCoverageReportCommand(opts); +} + async function runQaCharacterEval(opts: { repoRoot?: string; outputDir?: string; @@ -302,6 +308,15 @@ export function registerQaLabCli(program: Command) { }, ); + qa.command("coverage") + .description("Print the markdown scenario coverage inventory") + .option("--repo-root ", "Repository root to target when writing --output") + .option("--output ", "Write the coverage inventory to this path") + .option("--json", "Print JSON instead of Markdown", false) + .action(async (opts: { repoRoot?: string; output?: string; json?: boolean }) => { + await runQaCoverageReport(opts); + }); + qa.command("character-eval") .description("Run the character QA scenario across live models and write a judged report") .option("--repo-root ", "Repository root to target when running from a neutral cwd") diff --git a/extensions/qa-lab/src/coverage-report.test.ts b/extensions/qa-lab/src/coverage-report.test.ts new file mode 100644 index 00000000000..2ced93d062c --- /dev/null +++ b/extensions/qa-lab/src/coverage-report.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { buildQaCoverageInventory, renderQaCoverageMarkdownReport } from "./coverage-report.js"; +import { readQaScenarioPack } from "./scenario-catalog.js"; + +describe("qa coverage report", () => { + it("groups scenario coverage metadata by theme and surface", () => { + const inventory = buildQaCoverageInventory(readQaScenarioPack().scenarios); + + expect(inventory.scenarioCount).toBeGreaterThan(0); + expect(inventory.coverageIdCount).toBeGreaterThan(0); + expect(inventory.primaryCoverageIdCount).toBeGreaterThan(0); + expect(inventory.secondaryCoverageIdCount).toBeGreaterThan(0); + expect(inventory.overlappingCoverage.length).toBeGreaterThan(0); + expect(inventory.missingCoverage).toEqual([]); + expect(inventory.byTheme.memory.some((feature) => feature.id === "memory.recall")).toBe(true); + expect(inventory.bySurface.memory.some((feature) => feature.id === "memory.recall")).toBe(true); + }); + + it("renders a compact markdown inventory", () => { + const report = renderQaCoverageMarkdownReport( + buildQaCoverageInventory(readQaScenarioPack().scenarios), + ); + + expect(report).toContain("# QA Coverage Inventory"); + expect(report).toContain("- Missing coverage metadata: 0"); + expect(report).toContain("- Overlapping coverage IDs:"); + expect(report).toContain("memory.recall"); + expect(report).toContain("primary: memory-recall (qa/scenarios/memory/memory-recall.md)"); + expect(report).toContain("secondary: active-memory-preprompt-recall"); + }); +}); diff --git a/extensions/qa-lab/src/coverage-report.ts b/extensions/qa-lab/src/coverage-report.ts new file mode 100644 index 00000000000..7ea2fa574a7 --- /dev/null +++ b/extensions/qa-lab/src/coverage-report.ts @@ -0,0 +1,192 @@ +import type { QaSeedScenarioWithSource } from "./scenario-catalog.js"; + +export type QaCoverageScenarioSummary = { + id: string; + title: string; + sourcePath: string; + theme: string; + surfaces: string[]; + risk: string; +}; + +export type QaCoverageIntent = "primary" | "secondary"; + +export type QaCoverageScenarioReference = QaCoverageScenarioSummary & { + intent: QaCoverageIntent; +}; + +export type QaCoverageFeatureSummary = { + id: string; + scenarios: QaCoverageScenarioReference[]; +}; + +export type QaCoverageInventory = { + scenarioCount: number; + coverageIdCount: number; + primaryCoverageIdCount: number; + secondaryCoverageIdCount: number; + features: QaCoverageFeatureSummary[]; + overlappingCoverage: QaCoverageFeatureSummary[]; + missingCoverage: QaCoverageScenarioSummary[]; + byTheme: Record; + bySurface: Record; +}; + +function scenarioTheme(sourcePath: string) { + const parts = sourcePath.split("/"); + return parts[2] ?? "unknown"; +} + +function scenarioSurfaces(scenario: QaSeedScenarioWithSource) { + return scenario.surfaces && scenario.surfaces.length > 0 ? scenario.surfaces : [scenario.surface]; +} + +function scenarioRisk(scenario: QaSeedScenarioWithSource) { + return scenario.risk ?? scenario.riskLevel ?? "unassigned"; +} + +function summarizeScenario(scenario: QaSeedScenarioWithSource): QaCoverageScenarioSummary { + return { + id: scenario.id, + title: scenario.title, + sourcePath: scenario.sourcePath, + theme: scenarioTheme(scenario.sourcePath), + surfaces: scenarioSurfaces(scenario), + risk: scenarioRisk(scenario), + }; +} + +function sortFeatures(features: readonly QaCoverageFeatureSummary[]) { + return features.toSorted((left, right) => left.id.localeCompare(right.id)); +} + +export function buildQaCoverageInventory( + scenarios: readonly QaSeedScenarioWithSource[], +): QaCoverageInventory { + const byCoverageId = new Map(); + const primaryCoverageIds = new Set(); + const secondaryCoverageIds = new Set(); + const missingCoverage: QaCoverageScenarioSummary[] = []; + + const addCoverage = ( + scenario: QaSeedScenarioWithSource, + coverageIds: readonly string[] | undefined, + intent: QaCoverageIntent, + ) => { + const summary = summarizeScenario(scenario); + for (const coverageId of coverageIds ?? []) { + const feature = byCoverageId.get(coverageId) ?? { + id: coverageId, + scenarios: [], + }; + feature.scenarios.push({ ...summary, intent }); + byCoverageId.set(coverageId, feature); + if (intent === "primary") { + primaryCoverageIds.add(coverageId); + } else { + secondaryCoverageIds.add(coverageId); + } + } + }; + + for (const scenario of scenarios) { + if (!scenario.coverage) { + missingCoverage.push(summarizeScenario(scenario)); + continue; + } + addCoverage(scenario, scenario.coverage.primary, "primary"); + addCoverage(scenario, scenario.coverage.secondary, "secondary"); + } + + const features = sortFeatures([...byCoverageId.values()]); + const overlappingCoverage = features.filter((feature) => feature.scenarios.length > 1); + const byTheme: Record = {}; + const bySurface: Record = {}; + + for (const feature of features) { + const themes = new Set(feature.scenarios.map((scenario) => scenario.theme)); + for (const theme of themes) { + byTheme[theme] ??= []; + byTheme[theme].push({ + ...feature, + scenarios: feature.scenarios.filter((scenario) => scenario.theme === theme), + }); + } + const surfaces = new Set(feature.scenarios.flatMap((scenario) => scenario.surfaces)); + for (const surface of surfaces) { + bySurface[surface] ??= []; + bySurface[surface].push({ + ...feature, + scenarios: feature.scenarios.filter((scenario) => scenario.surfaces.includes(surface)), + }); + } + } + + return { + scenarioCount: scenarios.length, + coverageIdCount: features.length, + primaryCoverageIdCount: primaryCoverageIds.size, + secondaryCoverageIdCount: secondaryCoverageIds.size, + features, + overlappingCoverage, + missingCoverage, + byTheme, + bySurface, + }; +} + +function pushFeatureLines(lines: string[], features: readonly QaCoverageFeatureSummary[]) { + for (const feature of sortFeatures(features)) { + const scenarios = feature.scenarios + .map((scenario) => `${scenario.intent}: ${scenario.id} (${scenario.sourcePath})`) + .join(", "); + lines.push(`- ${feature.id}: ${scenarios}`); + } +} + +export function renderQaCoverageMarkdownReport(inventory: QaCoverageInventory): string { + const lines: string[] = [ + "# QA Coverage Inventory", + "", + `- Scenarios: ${inventory.scenarioCount}`, + `- Coverage IDs: ${inventory.coverageIdCount}`, + `- Primary coverage IDs: ${inventory.primaryCoverageIdCount}`, + `- Secondary coverage IDs: ${inventory.secondaryCoverageIdCount}`, + `- Overlapping coverage IDs: ${inventory.overlappingCoverage.length}`, + `- Missing coverage metadata: ${inventory.missingCoverage.length}`, + "", + "## By Theme", + "", + ]; + + for (const theme of Object.keys(inventory.byTheme).toSorted()) { + lines.push(`### ${theme}`, ""); + pushFeatureLines(lines, inventory.byTheme[theme] ?? []); + lines.push(""); + } + + lines.push("## By Surface", ""); + for (const surface of Object.keys(inventory.bySurface).toSorted()) { + lines.push(`### ${surface}`, ""); + pushFeatureLines(lines, inventory.bySurface[surface] ?? []); + lines.push(""); + } + + if (inventory.overlappingCoverage.length > 0) { + lines.push("## Overlap", ""); + pushFeatureLines(lines, inventory.overlappingCoverage); + lines.push(""); + } + + if (inventory.missingCoverage.length > 0) { + lines.push("## Missing Metadata", ""); + for (const scenario of inventory.missingCoverage.toSorted((left, right) => + left.id.localeCompare(right.id), + )) { + lines.push(`- ${scenario.id}: ${scenario.sourcePath}`); + } + lines.push(""); + } + + return `${lines.join("\n").trimEnd()}\n`; +} diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index 820f60c2774..465f7138b62 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -268,38 +268,41 @@ describe("buildQaRuntimeEnv", () => { expect(env.CODEX_HOME).toBe("/custom/codex-home"); }); - it("scrubs direct and live provider keys in mock mode", () => { - const env = buildQaRuntimeEnv({ - ...createParams({ - ANTHROPIC_API_KEY: "anthropic-live", - ANTHROPIC_OAUTH_TOKEN: "anthropic-oauth", - GEMINI_API_KEY: "gemini-live", - GEMINI_API_KEYS: "gemini-a gemini-b", - GOOGLE_API_KEY: "google-live", - OPENAI_API_KEY: "openai-live", - OPENAI_API_KEYS: "openai-a,openai-b", - CODEX_HOME: "/host/.codex", - OPENCLAW_LIVE_ANTHROPIC_KEY: "anthropic-live", - OPENCLAW_LIVE_ANTHROPIC_KEYS: "anthropic-a,anthropic-b", - OPENCLAW_LIVE_GEMINI_KEY: "gemini-live", - OPENCLAW_LIVE_OPENAI_KEY: "openai-live", - }), - providerMode: "mock-openai", - }); + it.each(["mock-openai", "aimock"] as const)( + "scrubs direct and live provider keys in %s mode", + (providerMode) => { + const env = buildQaRuntimeEnv({ + ...createParams({ + ANTHROPIC_API_KEY: "anthropic-live", + ANTHROPIC_OAUTH_TOKEN: "anthropic-oauth", + GEMINI_API_KEY: "gemini-live", + GEMINI_API_KEYS: "gemini-a gemini-b", + GOOGLE_API_KEY: "google-live", + OPENAI_API_KEY: "openai-live", + OPENAI_API_KEYS: "openai-a,openai-b", + CODEX_HOME: "/host/.codex", + OPENCLAW_LIVE_ANTHROPIC_KEY: "anthropic-live", + OPENCLAW_LIVE_ANTHROPIC_KEYS: "anthropic-a,anthropic-b", + OPENCLAW_LIVE_GEMINI_KEY: "gemini-live", + OPENCLAW_LIVE_OPENAI_KEY: "openai-live", + }), + providerMode, + }); - expect(env.OPENAI_API_KEY).toBeUndefined(); - expect(env.OPENAI_API_KEYS).toBeUndefined(); - expect(env.CODEX_HOME).toBeUndefined(); - expect(env.ANTHROPIC_API_KEY).toBeUndefined(); - expect(env.ANTHROPIC_OAUTH_TOKEN).toBeUndefined(); - expect(env.GEMINI_API_KEY).toBeUndefined(); - expect(env.GEMINI_API_KEYS).toBeUndefined(); - expect(env.GOOGLE_API_KEY).toBeUndefined(); - expect(env.OPENCLAW_LIVE_OPENAI_KEY).toBeUndefined(); - expect(env.OPENCLAW_LIVE_ANTHROPIC_KEY).toBeUndefined(); - expect(env.OPENCLAW_LIVE_ANTHROPIC_KEYS).toBeUndefined(); - expect(env.OPENCLAW_LIVE_GEMINI_KEY).toBeUndefined(); - }); + expect(env.OPENAI_API_KEY).toBeUndefined(); + expect(env.OPENAI_API_KEYS).toBeUndefined(); + expect(env.CODEX_HOME).toBeUndefined(); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + expect(env.ANTHROPIC_OAUTH_TOKEN).toBeUndefined(); + expect(env.GEMINI_API_KEY).toBeUndefined(); + expect(env.GEMINI_API_KEYS).toBeUndefined(); + expect(env.GOOGLE_API_KEY).toBeUndefined(); + expect(env.OPENCLAW_LIVE_OPENAI_KEY).toBeUndefined(); + expect(env.OPENCLAW_LIVE_ANTHROPIC_KEY).toBeUndefined(); + expect(env.OPENCLAW_LIVE_ANTHROPIC_KEYS).toBeUndefined(); + expect(env.OPENCLAW_LIVE_GEMINI_KEY).toBeUndefined(); + }, + ); it("treats restart socket closures as retryable gateway call errors", () => { expect(__testing.isRetryableGatewayCallError("gateway closed (1006 abnormal closure)")).toBe( diff --git a/extensions/qa-lab/src/lab-server.test.ts b/extensions/qa-lab/src/lab-server.test.ts index 7dae0fdee02..bc17f0c8e14 100644 --- a/extensions/qa-lab/src/lab-server.test.ts +++ b/extensions/qa-lab/src/lab-server.test.ts @@ -3,9 +3,11 @@ import { createServer } from "node:http"; import os from "node:os"; import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { startQaLabServer } from "./lab-server.js"; +vi.mock("openclaw/plugin-sdk/qa-channel", async () => await import("../../qa-channel/api.js")); + const cleanups: Array<() => Promise> = []; afterEach(async () => { @@ -79,6 +81,43 @@ async function waitForFile(filePath: string, timeoutMs = 5_000) { throw new Error(`file did not appear: ${filePath}`); } +async function createQaLabRepoRootFixture(params?: { + uiHtml?: string; + models?: Array<{ + key: string; + name: string; + input?: string; + available?: boolean; + missing?: boolean; + }>; +}) { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-lab-repo-root-")); + cleanups.push(async () => { + await rm(repoRoot, { recursive: true, force: true }); + }); + await mkdir(path.join(repoRoot, "dist"), { recursive: true }); + await mkdir(path.join(repoRoot, "extensions/qa-lab/web/dist"), { recursive: true }); + const models = + params?.models?.map((model) => ({ + key: model.key, + name: model.name, + input: model.input ?? model.key, + available: model.available ?? true, + missing: model.missing ?? false, + })) ?? []; + await writeFile( + path.join(repoRoot, "dist/index.js"), + `process.stdout.write(${JSON.stringify(JSON.stringify({ models }))});\n`, + "utf8", + ); + await writeFile( + path.join(repoRoot, "extensions/qa-lab/web/dist/index.html"), + params?.uiHtml ?? "qa lab fixture", + "utf8", + ); + return repoRoot; +} + describe("qa-lab server", () => { it("serves bootstrap state and writes a self-check report", async () => { const tempDir = await mkdtemp(path.join(os.tmpdir(), "qa-lab-test-")); @@ -86,11 +125,13 @@ describe("qa-lab server", () => { await rm(tempDir, { recursive: true, force: true }); }); const outputPath = path.join(tempDir, "self-check.md"); + const repoRoot = await createQaLabRepoRootFixture(); const lab = await startQaLabServer({ host: "127.0.0.1", port: 0, outputPath, + repoRoot, controlUiUrl: "http://127.0.0.1:18789/", controlUiToken: "qa-token", }); @@ -299,32 +340,16 @@ describe("qa-lab server", () => { }); it("uses the explicit repo root for ui assets and runner model discovery", async () => { - const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-lab-repo-root-")); - cleanups.push(async () => { - await rm(repoRoot, { recursive: true, force: true }); + const repoRoot = await createQaLabRepoRootFixture({ + models: [ + { + key: "anthropic/qa-temp-model", + name: "QA Temp Model", + }, + ], + uiHtml: + "Temp QA Lab UIrepo-root-ui", }); - await mkdir(path.join(repoRoot, "dist"), { recursive: true }); - await mkdir(path.join(repoRoot, "extensions/qa-lab/web/dist"), { recursive: true }); - await writeFile( - path.join(repoRoot, "dist/index.js"), - [ - "process.stdout.write(JSON.stringify({", - " models: [{", - ' key: "anthropic/qa-temp-model",', - ' name: "QA Temp Model",', - ' input: "anthropic/qa-temp-model",', - " available: true,", - " missing: false,", - " }],", - "}));", - ].join("\n"), - "utf8", - ); - await writeFile( - path.join(repoRoot, "extensions/qa-lab/web/dist/index.html"), - "Temp QA Lab UIrepo-root-ui", - "utf8", - ); const lab = await startQaLabServer({ host: "127.0.0.1", diff --git a/extensions/qa-lab/src/providers/aimock/server.test.ts b/extensions/qa-lab/src/providers/aimock/server.test.ts index 936e64daca0..57e181c13f1 100644 --- a/extensions/qa-lab/src/providers/aimock/server.test.ts +++ b/extensions/qa-lab/src/providers/aimock/server.test.ts @@ -79,4 +79,32 @@ describe("qa aimock server", () => { await server.stop(); } }); + + it("treats OpenAI Codex model refs as OpenAI-compatible snapshots", async () => { + const server = await startQaAimockServer({ + host: "127.0.0.1", + port: 0, + }); + try { + const response = await fetch(`${server.baseUrl}/v1/responses`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "openai-codex/gpt-5.4", + stream: false, + input: [makeResponsesInput("hello codex-compatible aimock")], + }), + }); + expect(response.status).toBe(200); + + const debug = await fetch(`${server.baseUrl}/debug/last-request`); + expect(debug.status).toBe(200); + expect(await debug.json()).toMatchObject({ + model: "openai-codex/gpt-5.4", + providerVariant: "openai", + }); + } finally { + await server.stop(); + } + }); }); diff --git a/extensions/qa-lab/src/run-config.test.ts b/extensions/qa-lab/src/run-config.test.ts index b7d552e96d8..8096f87d257 100644 --- a/extensions/qa-lab/src/run-config.test.ts +++ b/extensions/qa-lab/src/run-config.test.ts @@ -98,6 +98,18 @@ describe("qa run config", () => { ).toEqual(["dm-chat-baseline", "thread-lifecycle"]); }); + it("keeps idle snapshots on static defaults so startup does not inspect auth profiles", () => { + defaultQaRuntimeModelForMode.mockReturnValue("openai-codex/gpt-5.4"); + defaultQaRuntimeModelForMode.mockClear(); + + expect(createIdleQaRunnerSnapshot(scenarios).selection).toMatchObject({ + providerMode: "live-frontier", + primaryModel: "openai/gpt-5.4", + alternateModel: "openai/gpt-5.4", + }); + expect(defaultQaRuntimeModelForMode).not.toHaveBeenCalled(); + }); + it("normalizes aimock selections", () => { expect( normalizeQaRunSelection( diff --git a/extensions/qa-lab/src/run-config.ts b/extensions/qa-lab/src/run-config.ts index b1a9e6f5e37..fea0f588eed 100644 --- a/extensions/qa-lab/src/run-config.ts +++ b/extensions/qa-lab/src/run-config.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { defaultQaModelForMode as defaultStaticQaModelForMode } from "./model-selection.js"; import { defaultQaRuntimeModelForMode } from "./model-selection.runtime.js"; import { DEFAULT_QA_LIVE_PROVIDER_MODE, @@ -40,12 +41,22 @@ export function defaultQaModelForMode(mode: QaProviderMode, alternate = false) { return defaultQaRuntimeModelForMode(mode, alternate ? { alternate: true } : undefined); } -export function createDefaultQaRunSelection(scenarios: QaSeedScenario[]): QaLabRunSelection { +type QaDefaultModelResolver = (mode: QaProviderMode, alternate?: boolean) => string; + +function defaultStaticModelForMode(mode: QaProviderMode, alternate = false) { + return defaultStaticQaModelForMode(mode, alternate ? { alternate: true } : undefined); +} + +export function createDefaultQaRunSelection( + scenarios: QaSeedScenario[], + options?: { resolveDefaultModel?: QaDefaultModelResolver }, +): QaLabRunSelection { const providerMode: QaProviderMode = DEFAULT_QA_LIVE_PROVIDER_MODE; + const resolveDefaultModel = options?.resolveDefaultModel ?? defaultQaModelForMode; return { providerMode, - primaryModel: defaultQaModelForMode(providerMode), - alternateModel: defaultQaModelForMode(providerMode, true), + primaryModel: resolveDefaultModel(providerMode), + alternateModel: resolveDefaultModel(providerMode, true), fastMode: true, scenarioIds: scenarios.map((scenario) => scenario.id), }; @@ -101,7 +112,9 @@ export function normalizeQaRunSelection( export function createIdleQaRunnerSnapshot(scenarios: QaSeedScenario[]): QaLabRunnerSnapshot { return { status: "idle", - selection: createDefaultQaRunSelection(scenarios), + selection: createDefaultQaRunSelection(scenarios, { + resolveDefaultModel: defaultStaticModelForMode, + }), artifacts: null, error: null, }; diff --git a/extensions/qa-lab/src/scenario-catalog.test.ts b/extensions/qa-lab/src/scenario-catalog.test.ts index dbebaa1182d..c237283535c 100644 --- a/extensions/qa-lab/src/scenario-catalog.test.ts +++ b/extensions/qa-lab/src/scenario-catalog.test.ts @@ -27,6 +27,8 @@ describe("qa scenario catalog", () => { expect(pack.scenarios.some((scenario) => scenario.id === "character-vibes-c3po")).toBe(true); expect(pack.scenarios.every((scenario) => scenario.execution?.kind === "flow")).toBe(true); expect(pack.scenarios.some((scenario) => scenario.execution.flow?.steps.length)).toBe(true); + expect(pack.scenarios.every((scenario) => scenario.coverage?.primary.length)).toBe(true); + expect(readQaScenarioById("memory-recall").coverage?.primary).toContain("memory.recall"); }); it("exposes bootstrap data from the markdown pack", () => { diff --git a/extensions/qa-lab/src/scenario-catalog.ts b/extensions/qa-lab/src/scenario-catalog.ts index 64dee666683..496ad55f96e 100644 --- a/extensions/qa-lab/src/scenario-catalog.ts +++ b/extensions/qa-lab/src/scenario-catalog.ts @@ -51,6 +51,44 @@ const qaScenarioExecutionSchema = z.object({ config: qaScenarioConfigSchema.optional(), }); +const qaCoverageIdSchema = z + .string() + .trim() + .regex(/^[a-z0-9]+(?:[.-][a-z0-9]+)*$/, { + message: "coverage ids must use lowercase dotted or dashed tokens", + }); + +const qaCoverageIdListSchema = z.array(qaCoverageIdSchema).min(1); + +const qaScenarioCoverageSchema = z + .object({ + primary: qaCoverageIdListSchema, + secondary: qaCoverageIdListSchema.optional(), + }) + .superRefine((coverage, ctx) => { + const seen = new Set(); + const coverageEntries = [ + ["primary", coverage.primary], + ["secondary", coverage.secondary], + ] as const; + for (const [intent, ids] of coverageEntries) { + if (!ids) { + continue; + } + for (const [index, id] of ids.entries()) { + if (!seen.has(id)) { + seen.add(id); + continue; + } + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [intent, index], + message: `duplicate coverage id: ${id}`, + }); + } + } + }); + const qaScenarioGatewayRuntimeSchema = z.object({ forwardHostHome: z.boolean().optional(), }); @@ -138,6 +176,9 @@ const qaSeedScenarioSchema = z.object({ title: z.string().trim().min(1), surface: z.string().trim().min(1), category: z.string().trim().min(1).optional(), + coverage: qaScenarioCoverageSchema.optional(), + surfaces: z.array(z.string().trim().min(1)).min(1).optional(), + risk: z.enum(["low", "medium", "high"]).optional(), capabilities: z.array(z.string().trim().min(1)).optional(), lane: z.record(z.string(), z.union([z.boolean(), z.string()])).optional(), riskLevel: z.string().trim().min(1).optional(), diff --git a/extensions/qa-matrix/src/runners/contract/runtime.ts b/extensions/qa-matrix/src/runners/contract/runtime.ts index da6da2db7c5..6deb35198a5 100644 --- a/extensions/qa-matrix/src/runners/contract/runtime.ts +++ b/extensions/qa-matrix/src/runners/contract/runtime.ts @@ -647,6 +647,23 @@ export async function runMatrixQaLive(params: { `gateway restart done ${scenario.id} ${formatMatrixQaDurationMs(measuredRestart.durationMs)}`, ); }, + restartGatewayWithQueuedMessage: async (queueMessage) => { + if (!gatewayHarness) { + throw new Error("Matrix restart catchup scenario requires a live gateway"); + } + writeMatrixQaProgress(`gateway restart+queue start ${scenario.id}`); + const measuredRestart = await measureMatrixQaStep(async () => { + await scenarioGateway.harness.gateway.restart(); + await sleep(250); + await queueMessage(); + await waitForMatrixChannelReady(scenarioGateway.harness.gateway, sutAccountId); + }); + gatewayRestartMs += measuredRestart.durationMs; + scenarioRestartGatewayMs += measuredRestart.durationMs; + writeMatrixQaProgress( + `gateway restart+queue done ${scenario.id} ${formatMatrixQaDurationMs(measuredRestart.durationMs)}`, + ); + }, roomId: provisioning.roomId, sutAccessToken: provisioning.sut.accessToken, sutDeviceId: provisioning.sut.deviceId, diff --git a/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts b/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts index b352c2dd7bf..8e864032a29 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-catalog.ts @@ -40,6 +40,7 @@ export type MatrixQaScenarioId = | "matrix-reaction-redaction-observed" | "matrix-restart-resume" | "matrix-post-restart-room-continue" + | "matrix-initial-catchup-then-incremental" | "matrix-room-membership-loss" | "matrix-homeserver-restart-resume" | "matrix-mention-gating" @@ -424,6 +425,12 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [ title: "Matrix restarted room continues after the first recovered reply", topology: MATRIX_QA_RESTART_ROOM_TOPOLOGY, }, + { + id: "matrix-initial-catchup-then-incremental", + timeoutMs: 90_000, + title: "Matrix initial catchup is followed by incremental replies", + topology: MATRIX_QA_RESTART_ROOM_TOPOLOGY, + }, { id: "matrix-room-membership-loss", timeoutMs: 75_000, diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-restart.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-restart.ts index 1679b04e190..c2f92c3a3d9 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-restart.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-restart.ts @@ -5,6 +5,13 @@ import { } from "./scenario-catalog.js"; import { buildMatrixReplyDetails, + buildMatrixQaToken, + buildMentionPrompt, + buildMatrixReplyArtifact, + isMatrixQaExactMarkerReply, + assertTopLevelReplyArtifact, + advanceMatrixQaActorCursor, + primeMatrixQaDriverScenarioClient, runAssertedDriverTopLevelScenario, type MatrixQaScenarioContext, } from "./scenario-runtime-shared.js"; @@ -107,3 +114,73 @@ export async function runPostRestartRoomContinueScenario(context: MatrixQaScenar ].join("\n"), } satisfies MatrixQaScenarioExecution; } + +export async function runInitialCatchupThenIncrementalScenario(context: MatrixQaScenarioContext) { + if (!context.restartGatewayWithQueuedMessage) { + throw new Error( + "Matrix initial catchup scenario requires a queued-message gateway restart callback", + ); + } + const roomId = resolveMatrixQaScenarioRoomId(context, MATRIX_QA_RESTART_ROOM_KEY); + const { client, startSince } = await primeMatrixQaDriverScenarioClient(context); + const catchupToken = buildMatrixQaToken("MATRIX_QA_CATCHUP"); + const catchupBody = buildMentionPrompt(context.sutUserId, catchupToken); + let catchupDriverEventId = ""; + + await context.restartGatewayWithQueuedMessage(async () => { + catchupDriverEventId = await client.sendTextMessage({ + body: catchupBody, + mentionUserIds: [context.sutUserId], + roomId, + }); + }); + + const catchupMatched = await client.waitForRoomEvent({ + observedEvents: context.observedEvents, + predicate: (event) => + isMatrixQaExactMarkerReply(event, { + roomId, + sutUserId: context.sutUserId, + token: catchupToken, + }) && event.relatesTo === undefined, + roomId, + since: startSince, + timeoutMs: context.timeoutMs, + }); + advanceMatrixQaActorCursor({ + actorId: "driver", + syncState: context.syncState, + nextSince: catchupMatched.since, + startSince, + }); + const catchupReply = buildMatrixReplyArtifact(catchupMatched.event, catchupToken); + assertTopLevelReplyArtifact("catchup reply", catchupReply); + + const incremental = await runAssertedDriverTopLevelScenario({ + context, + label: "incremental reply after catchup", + roomId, + tokenPrefix: "MATRIX_QA_INCREMENTAL", + }); + + return { + artifacts: { + catchupDriverEventId, + catchupReply, + catchupToken, + incrementalDriverEventId: incremental.driverEventId, + incrementalReply: incremental.reply, + incrementalToken: incremental.token, + restartSignal: "SIGUSR1", + roomId, + }, + details: [ + `room id: ${roomId}`, + "restart signal: SIGUSR1", + `catchup driver event: ${catchupDriverEventId}`, + ...buildMatrixReplyDetails("catchup reply", catchupReply), + `incremental driver event: ${incremental.driverEventId}`, + ...buildMatrixReplyDetails("incremental reply", incremental.reply), + ].join("\n"), + } satisfies MatrixQaScenarioExecution; +} diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime-shared.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime-shared.ts index 7847381c008..f6a216e5973 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime-shared.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime-shared.ts @@ -29,6 +29,7 @@ export type MatrixQaScenarioContext = { observerUserId: string; outputDir?: string; restartGateway?: () => Promise; + restartGatewayWithQueuedMessage?: (queueMessage: () => Promise) => Promise; roomId: string; interruptTransport?: () => Promise; sutAccessToken: string; diff --git a/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts b/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts index 10bea7d9e36..91c869828dc 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-runtime.ts @@ -41,6 +41,7 @@ import { } from "./scenario-runtime-reaction.js"; import { runHomeserverRestartResumeScenario, + runInitialCatchupThenIncrementalScenario, runPostRestartRoomContinueScenario, runRestartResumeScenario, } from "./scenario-runtime-restart.js"; @@ -229,6 +230,8 @@ export async function runMatrixQaScenario( return await runRestartResumeScenario(context); case "matrix-post-restart-room-continue": return await runPostRestartRoomContinueScenario(context); + case "matrix-initial-catchup-then-incremental": + return await runInitialCatchupThenIncrementalScenario(context); case "matrix-room-membership-loss": return await runMembershipLossScenario(context); case "matrix-homeserver-restart-resume": diff --git a/extensions/qa-matrix/src/runners/contract/scenario-types.ts b/extensions/qa-matrix/src/runners/contract/scenario-types.ts index d2b5958ee62..03c859ee918 100644 --- a/extensions/qa-matrix/src/runners/contract/scenario-types.ts +++ b/extensions/qa-matrix/src/runners/contract/scenario-types.ts @@ -32,6 +32,9 @@ export type MatrixQaScenarioArtifacts = { attachmentMsgtype?: string; actorUserId?: string; blocked?: MatrixQaScenarioArtifacts; + catchupDriverEventId?: string; + catchupReply?: MatrixQaReplyArtifact; + catchupToken?: string; driverEventId?: string; editEventId?: string; editedToken?: string; @@ -39,6 +42,9 @@ export type MatrixQaScenarioArtifacts = { firstDriverEventId?: string; firstReply?: MatrixQaReplyArtifact; firstToken?: string; + incrementalDriverEventId?: string; + incrementalReply?: MatrixQaReplyArtifact; + incrementalToken?: string; originalDriverEventId?: string; originalReply?: MatrixQaReplyArtifact; originalToken?: string; diff --git a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts index d14b31cb031..21ff8a7c89c 100644 --- a/extensions/qa-matrix/src/runners/contract/scenarios.test.ts +++ b/extensions/qa-matrix/src/runners/contract/scenarios.test.ts @@ -102,6 +102,7 @@ describe("matrix live qa scenarios", () => { "matrix-reaction-redaction-observed", "matrix-restart-resume", "matrix-post-restart-room-continue", + "matrix-initial-catchup-then-incremental", "matrix-room-membership-loss", "matrix-homeserver-restart-resume", "matrix-mention-gating", @@ -515,6 +516,107 @@ describe("matrix live qa scenarios", () => { }); }); + it("queues a Matrix trigger during restart before proving incremental sync continues", async () => { + const callOrder: string[] = []; + const primeRoom = vi.fn().mockResolvedValue("driver-sync-start"); + const sendTextMessage = vi.fn().mockImplementation(async (params) => { + callOrder.push(`send:${String(params.body).includes("CATCHUP") ? "catchup" : "incremental"}`); + return String(params.body).includes("CATCHUP") ? "$catchup-trigger" : "$incremental-trigger"; + }); + const waitForRoomEvent = vi.fn().mockImplementation(async () => { + const sentBody = String(sendTextMessage.mock.calls.at(-1)?.[0]?.body ?? ""); + const token = sentBody.replace("@sut:matrix-qa.test reply with only this exact marker: ", ""); + callOrder.push(`wait:${token.includes("CATCHUP") ? "catchup" : "incremental"}`); + return { + event: { + kind: "message", + roomId: "!restart:matrix-qa.test", + eventId: token.includes("CATCHUP") ? "$catchup-reply" : "$incremental-reply", + sender: "@sut:matrix-qa.test", + type: "m.room.message", + body: token, + }, + since: token.includes("CATCHUP") + ? "driver-sync-after-catchup" + : "driver-sync-after-incremental", + }; + }); + + createMatrixQaClient.mockReturnValue({ + primeRoom, + sendTextMessage, + waitForRoomEvent, + }); + + const scenario = MATRIX_QA_SCENARIOS.find( + (entry) => entry.id === "matrix-initial-catchup-then-incremental", + ); + expect(scenario).toBeDefined(); + + await expect( + runMatrixQaScenario(scenario!, { + baseUrl: "http://127.0.0.1:28008/", + canary: undefined, + driverAccessToken: "driver-token", + driverUserId: "@driver:matrix-qa.test", + observedEvents: [], + observerAccessToken: "observer-token", + observerUserId: "@observer:matrix-qa.test", + restartGatewayWithQueuedMessage: async (queueMessage) => { + callOrder.push("restart"); + await queueMessage(); + callOrder.push("ready"); + }, + roomId: "!room:matrix-qa.test", + syncState: {}, + sutAccessToken: "sut-token", + sutUserId: "@sut:matrix-qa.test", + timeoutMs: 8_000, + topology: { + defaultRoomId: "!room:matrix-qa.test", + defaultRoomKey: "main", + rooms: [ + { + key: "restart", + kind: "group", + memberRoles: ["driver", "observer", "sut"], + memberUserIds: [ + "@driver:matrix-qa.test", + "@observer:matrix-qa.test", + "@sut:matrix-qa.test", + ], + name: "Restart room", + requireMention: true, + roomId: "!restart:matrix-qa.test", + }, + ], + }, + }), + ).resolves.toMatchObject({ + artifacts: { + catchupDriverEventId: "$catchup-trigger", + catchupReply: { + eventId: "$catchup-reply", + tokenMatched: true, + }, + incrementalDriverEventId: "$incremental-trigger", + incrementalReply: { + eventId: "$incremental-reply", + tokenMatched: true, + }, + }, + }); + + expect(callOrder).toEqual([ + "restart", + "send:catchup", + "ready", + "wait:catchup", + "send:incremental", + "wait:incremental", + ]); + }); + it("runs the DM scenario against the provisioned DM room without a mention", async () => { const primeRoom = vi.fn().mockResolvedValue("driver-sync-start"); const sendTextMessage = vi.fn().mockResolvedValue("$dm-trigger"); diff --git a/extensions/searxng/src/searxng-search-provider.ts b/extensions/searxng/src/searxng-search-provider.ts index 5a95a93a07d..708e55ad93f 100644 --- a/extensions/searxng/src/searxng-search-provider.ts +++ b/extensions/searxng/src/searxng-search-provider.ts @@ -1,40 +1,32 @@ -import { Type } from "@sinclair/typebox"; +import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/param-readers"; import { - enablePluginInConfig, - getScopedCredentialValue, - readNumberParam, - readStringParam, - resolveProviderWebSearchPluginConfig, - setScopedCredentialValue, - setProviderWebSearchPluginConfigValue, + createWebSearchProviderContractFields, type WebSearchProviderPlugin, -} from "openclaw/plugin-sdk/provider-web-search"; -import { runSearxngSearch } from "./searxng-client.js"; +} from "openclaw/plugin-sdk/provider-web-search-contract"; -const SearxngSearchSchema = Type.Object( - { - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-10).", - minimum: 1, - maximum: 10, - }), - ), - categories: Type.Optional( - Type.String({ - description: - "Optional comma-separated search categories such as general, news, or science.", - }), - ), - language: Type.Optional( - Type.String({ - description: "Optional language code for results such as en, de, or fr.", - }), - ), +const SEARXNG_CREDENTIAL_PATH = "plugins.entries.searxng.config.webSearch.baseUrl"; + +const SearxngSearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, + }, + categories: { + type: "string", + description: "Optional comma-separated search categories such as general, news, or science.", + }, + language: { + type: "string", + description: "Optional language code for results such as en, de, or fr.", + }, }, - { additionalProperties: false }, -); + additionalProperties: false, +} satisfies Record; export function createSearxngWebSearchProvider(): WebSearchProviderPlugin { return { @@ -48,29 +40,27 @@ export function createSearxngWebSearchProvider(): WebSearchProviderPlugin { placeholder: "http://localhost:8080", signupUrl: "https://docs.searxng.org/", autoDetectOrder: 200, - credentialPath: "plugins.entries.searxng.config.webSearch.baseUrl", - inactiveSecretPaths: ["plugins.entries.searxng.config.webSearch.baseUrl"], - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "searxng"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "searxng", value), - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "searxng")?.baseUrl, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "searxng", "baseUrl", value); - }, - applySelectionConfig: (config) => enablePluginInConfig(config, "searxng").config, + credentialPath: SEARXNG_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: SEARXNG_CREDENTIAL_PATH, + searchCredential: { type: "scoped", scopeId: "searxng" }, + configuredCredential: { pluginId: "searxng", field: "baseUrl" }, + selectionPluginId: "searxng", + }), createTool: (ctx) => ({ description: "Search the web using a self-hosted SearXNG instance. Returns titles, URLs, and snippets.", parameters: SearxngSearchSchema, - execute: async (args) => - await runSearxngSearch({ + execute: async (args) => { + const { runSearxngSearch } = await import("./searxng-client.js"); + return await runSearxngSearch({ config: ctx.config, query: readStringParam(args, "query", { required: true }), count: readNumberParam(args, "count", { integer: true }), categories: readStringParam(args, "categories"), language: readStringParam(args, "language"), - }), + }); + }, }), }; } diff --git a/extensions/slack/directory-contract-api.ts b/extensions/slack/directory-contract-api.ts new file mode 100644 index 00000000000..183f055df40 --- /dev/null +++ b/extensions/slack/directory-contract-api.ts @@ -0,0 +1,4 @@ +export { + listSlackDirectoryGroupsFromConfig, + listSlackDirectoryPeersFromConfig, +} from "./src/directory-config.js"; diff --git a/extensions/slack/inbound-contract-test-api.ts b/extensions/slack/inbound-contract-test-api.ts new file mode 100644 index 00000000000..18d6d74fd45 --- /dev/null +++ b/extensions/slack/inbound-contract-test-api.ts @@ -0,0 +1,2 @@ +export { prepareSlackMessage } from "./src/monitor/message-handler/prepare.js"; +export { createInboundSlackTestContext } from "./src/monitor/message-handler/prepare.test-helpers.js"; diff --git a/extensions/slack/outbound-payload-test-api.ts b/extensions/slack/outbound-payload-test-api.ts new file mode 100644 index 00000000000..ef7148e1997 --- /dev/null +++ b/extensions/slack/outbound-payload-test-api.ts @@ -0,0 +1 @@ +export { createSlackOutboundPayloadHarness } from "./src/outbound-payload.test-harness.js"; diff --git a/extensions/slack/src/directory-config.ts b/extensions/slack/src/directory-config.ts index d1002befcb6..44d7acce97a 100644 --- a/extensions/slack/src/directory-config.ts +++ b/extensions/slack/src/directory-config.ts @@ -2,7 +2,7 @@ import { normalizeAccountId } from "openclaw/plugin-sdk/account-resolution"; import { createResolvedDirectoryEntriesLister, type DirectoryConfigParams, -} from "openclaw/plugin-sdk/directory-runtime"; +} from "openclaw/plugin-sdk/directory-config-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { mergeSlackAccountConfig, resolveDefaultSlackAccountId } from "./accounts.js"; import { parseSlackTarget } from "./targets.js"; diff --git a/extensions/slack/src/shared.test.ts b/extensions/slack/src/shared.test.ts index 69181159e61..c2ba08091bf 100644 --- a/extensions/slack/src/shared.test.ts +++ b/extensions/slack/src/shared.test.ts @@ -1,5 +1,27 @@ import { describe, expect, it } from "vitest"; -import { setSlackChannelAllowlist } from "./shared.js"; +import { createSlackPluginBase, setSlackChannelAllowlist } from "./shared.js"; + +describe("createSlackPluginBase", () => { + it("owns Slack native command name overrides", () => { + const plugin = createSlackPluginBase({ + setup: {} as never, + setupWizard: {} as never, + }); + + expect( + plugin.commands?.resolveNativeCommandName?.({ + commandKey: "status", + defaultName: "status", + }), + ).toBe("agentstatus"); + expect( + plugin.commands?.resolveNativeCommandName?.({ + commandKey: "tts", + defaultName: "tts", + }), + ).toBe("tts"); + }); +}); describe("setSlackChannelAllowlist", () => { it("writes canonical enabled entries for setup-generated channel allowlists", () => { diff --git a/extensions/tavily/src/tavily-search-provider.ts b/extensions/tavily/src/tavily-search-provider.ts index 6fe230521b9..c05981e54a1 100644 --- a/extensions/tavily/src/tavily-search-provider.ts +++ b/extensions/tavily/src/tavily-search-provider.ts @@ -1,27 +1,22 @@ -import { Type } from "@sinclair/typebox"; import { - enablePluginInConfig, - getScopedCredentialValue, - resolveProviderWebSearchPluginConfig, - setScopedCredentialValue, - setProviderWebSearchPluginConfigValue, + createWebSearchProviderContractFields, type WebSearchProviderPlugin, -} from "openclaw/plugin-sdk/provider-web-search"; -import { runTavilySearch } from "./tavily-client.js"; +} from "openclaw/plugin-sdk/provider-web-search-contract"; -const GenericTavilySearchSchema = Type.Object( - { - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-20).", - minimum: 1, - maximum: 20, - }), - ), +const TAVILY_CREDENTIAL_PATH = "plugins.entries.tavily.config.webSearch.apiKey"; +const GenericTavilySearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-20).", + minimum: 1, + maximum: 20, + }, }, - { additionalProperties: false }, -); + additionalProperties: false, +} satisfies Record; export function createTavilyWebSearchProvider(): WebSearchProviderPlugin { return { @@ -35,27 +30,25 @@ export function createTavilyWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://tavily.com/", docsUrl: "https://docs.openclaw.ai/tools/tavily", autoDetectOrder: 70, - credentialPath: "plugins.entries.tavily.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.tavily.config.webSearch.apiKey"], - getCredentialValue: (searchConfig) => getScopedCredentialValue(searchConfig, "tavily"), - setCredentialValue: (searchConfigTarget, value) => - setScopedCredentialValue(searchConfigTarget, "tavily", value), - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "tavily")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "tavily", "apiKey", value); - }, - applySelectionConfig: (config) => enablePluginInConfig(config, "tavily").config, + credentialPath: TAVILY_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: TAVILY_CREDENTIAL_PATH, + searchCredential: { type: "scoped", scopeId: "tavily" }, + configuredCredential: { pluginId: "tavily" }, + selectionPluginId: "tavily", + }), createTool: (ctx) => ({ description: "Search the web using Tavily. Returns structured results with snippets. Use tavily_search for Tavily-specific options like search depth, topic filtering, or AI answers.", parameters: GenericTavilySearchSchema, - execute: async (args) => - await runTavilySearch({ + execute: async (args) => { + const { runTavilySearch } = await import("./tavily-client.js"); + return await runTavilySearch({ cfg: ctx.config, query: typeof args.query === "string" ? args.query : "", maxResults: typeof args.count === "number" ? args.count : undefined, - }), + }); + }, }), }; } diff --git a/extensions/telegram/directory-contract-api.ts b/extensions/telegram/directory-contract-api.ts new file mode 100644 index 00000000000..d37033f2502 --- /dev/null +++ b/extensions/telegram/directory-contract-api.ts @@ -0,0 +1,4 @@ +export { + listTelegramDirectoryGroupsFromConfig, + listTelegramDirectoryPeersFromConfig, +} from "./src/directory-config.js"; diff --git a/extensions/telegram/legacy-state-migrations-api.ts b/extensions/telegram/legacy-state-migrations-api.ts new file mode 100644 index 00000000000..138d753daff --- /dev/null +++ b/extensions/telegram/legacy-state-migrations-api.ts @@ -0,0 +1 @@ +export { detectTelegramLegacyStateMigrations } from "./src/state-migrations.js"; diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index ed5dbd87185..93587009a84 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -17,6 +17,9 @@ "./index.ts" ], "setupEntry": "./setup-entry.ts", + "setupFeatures": { + "legacyStateMigrations": true + }, "channel": { "id": "telegram", "label": "Telegram", diff --git a/extensions/telegram/setup-entry.ts b/extensions/telegram/setup-entry.ts index d87b102fbfe..a3b942698ce 100644 --- a/extensions/telegram/setup-entry.ts +++ b/extensions/telegram/setup-entry.ts @@ -9,6 +9,10 @@ export default defineBundledChannelSetupEntry({ specifier: "./setup-plugin-api.js", exportName: "telegramSetupPlugin", }, + legacyStateMigrations: { + specifier: "./legacy-state-migrations-api.js", + exportName: "detectTelegramLegacyStateMigrations", + }, secrets: { specifier: "./secret-contract-api.js", exportName: "channelSecrets", diff --git a/extensions/telegram/src/account-config.ts b/extensions/telegram/src/account-config.ts new file mode 100644 index 00000000000..a6d3b4269a1 --- /dev/null +++ b/extensions/telegram/src/account-config.ts @@ -0,0 +1,37 @@ +import { + normalizeAccountId, + resolveAccountEntry, + type OpenClawConfig, +} from "openclaw/plugin-sdk/account-core"; +import type { TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime"; + +export function resolveTelegramAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): TelegramAccountConfig | undefined { + const normalized = normalizeAccountId(accountId); + return resolveAccountEntry(cfg.channels?.telegram?.accounts, normalized); +} + +export 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) ?? {}; + + // Multi-account bots must not inherit channel-level groups unless explicitly set. + const configuredAccountIds = Object.keys(cfg.channels?.telegram?.accounts ?? {}); + const isMultiAccount = configuredAccountIds.length > 1; + const groups = account.groups ?? (isMultiAccount ? undefined : channelGroups); + + return { ...base, ...account, groups }; +} diff --git a/extensions/telegram/src/account-selection.ts b/extensions/telegram/src/account-selection.ts new file mode 100644 index 00000000000..c942056aecd --- /dev/null +++ b/extensions/telegram/src/account-selection.ts @@ -0,0 +1,151 @@ +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + normalizeOptionalAccountId, +} from "openclaw/plugin-sdk/account-id"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; + +const DEFAULT_AGENT_ID = "main"; + +function normalizeAgentId(value: string | undefined | null): string { + const normalized = (value ?? "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/^-+/g, "") + .replace(/-+$/g, ""); + return normalized || DEFAULT_AGENT_ID; +} + +function normalizeChannelId(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function resolveDefaultAgentId(cfg: OpenClawConfig): string { + const agents = Array.isArray(cfg.agents?.list) ? cfg.agents.list : []; + const chosen = (agents.find((agent) => agent?.default) ?? agents[0])?.id; + return normalizeAgentId(chosen); +} + +function listConfiguredAccountIds(cfg: OpenClawConfig): string[] { + const ids = new Set(); + for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) { + if (key) { + ids.add(normalizeAccountId(key)); + } + } + return [...ids]; +} + +function resolveBindingAccount(params: { + binding: unknown; + channelId: string; +}): { agentId: string; accountId: string } | null { + if (!params.binding || typeof params.binding !== "object") { + return null; + } + const binding = params.binding as { + agentId?: unknown; + match?: { channel?: unknown; accountId?: unknown }; + }; + if (normalizeChannelId(binding.match?.channel) !== params.channelId) { + return null; + } + const accountId = typeof binding.match?.accountId === "string" ? binding.match.accountId : ""; + if (!accountId.trim() || accountId.trim() === "*") { + return null; + } + return { + agentId: normalizeAgentId(typeof binding.agentId === "string" ? binding.agentId : undefined), + accountId: normalizeAccountId(accountId), + }; +} + +function listBoundAccountIds(cfg: OpenClawConfig, channelId: string): string[] { + const ids = new Set(); + for (const binding of cfg.bindings ?? []) { + const resolved = resolveBindingAccount({ binding, channelId }); + if (resolved) { + ids.add(resolved.accountId); + } + } + return [...ids].toSorted((left, right) => left.localeCompare(right)); +} + +function resolveDefaultAgentBoundAccountId(cfg: OpenClawConfig, channelId: string): string | null { + const defaultAgentId = resolveDefaultAgentId(cfg); + for (const binding of cfg.bindings ?? []) { + const resolved = resolveBindingAccount({ binding, channelId }); + if (resolved?.agentId === defaultAgentId) { + return resolved.accountId; + } + } + return null; +} + +function combineAccountIds(params: { + configuredAccountIds: readonly string[]; + additionalAccountIds: readonly string[]; +}): string[] { + const ids = new Set(); + for (const id of [...params.configuredAccountIds, ...params.additionalAccountIds]) { + ids.add(normalizeAccountId(id)); + } + if (ids.size === 0) { + return [DEFAULT_ACCOUNT_ID]; + } + return [...ids].toSorted((left, right) => left.localeCompare(right)); +} + +function resolveListedDefaultAccountId(params: { + accountIds: readonly string[]; + configuredDefaultAccountId: string | null | undefined; +}): string { + const configured = normalizeOptionalAccountId(params.configuredDefaultAccountId); + if (configured && params.accountIds.includes(configured)) { + return configured; + } + if (params.accountIds.includes(DEFAULT_ACCOUNT_ID)) { + return DEFAULT_ACCOUNT_ID; + } + return params.accountIds[0] ?? DEFAULT_ACCOUNT_ID; +} + +export function listTelegramAccountIds(cfg: OpenClawConfig): string[] { + return combineAccountIds({ + configuredAccountIds: listConfiguredAccountIds(cfg), + additionalAccountIds: listBoundAccountIds(cfg, "telegram"), + }); +} + +export function resolveDefaultTelegramAccountSelection(cfg: OpenClawConfig): { + accountId: string; + accountIds: string[]; + shouldWarnMissingDefault: boolean; +} { + const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram"); + if (boundDefault) { + return { + accountId: boundDefault, + accountIds: listTelegramAccountIds(cfg), + shouldWarnMissingDefault: false, + }; + } + const accountIds = listTelegramAccountIds(cfg); + const resolved = resolveListedDefaultAccountId({ + accountIds, + configuredDefaultAccountId: cfg.channels?.telegram?.defaultAccount, + }); + return { + accountId: resolved, + accountIds, + shouldWarnMissingDefault: + resolved === accountIds[0] && + !accountIds.includes(DEFAULT_ACCOUNT_ID) && + accountIds.length > 1, + }; +} + +export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string { + return resolveDefaultTelegramAccountSelection(cfg).accountId; +} diff --git a/extensions/telegram/src/accounts.ts b/extensions/telegram/src/accounts.ts index db631928f1a..bfb2081f26f 100644 --- a/extensions/telegram/src/accounts.ts +++ b/extensions/telegram/src/accounts.ts @@ -1,12 +1,8 @@ import util from "node:util"; import { createAccountActionGate, - DEFAULT_ACCOUNT_ID, - listCombinedAccountIds, normalizeAccountId, normalizeOptionalAccountId, - resolveAccountEntry, - resolveListedDefaultAccountId, resolveAccountWithDefaultFallback, type OpenClawConfig, } from "openclaw/plugin-sdk/account-core"; @@ -14,16 +10,19 @@ import type { TelegramAccountConfig, TelegramActionConfig, } from "openclaw/plugin-sdk/config-runtime"; -import { - listBoundAccountIds, - resolveDefaultAgentBoundAccountId, -} from "openclaw/plugin-sdk/routing"; import { formatSetExplicitDefaultInstruction } from "openclaw/plugin-sdk/routing"; import { createSubsystemLogger, isTruthyEnvValue } from "openclaw/plugin-sdk/runtime-env"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { mergeTelegramAccountConfig, resolveTelegramAccountConfig } from "./account-config.js"; +import { + listTelegramAccountIds as listSelectedTelegramAccountIds, + resolveDefaultTelegramAccountSelection, +} from "./account-selection.js"; import type { TelegramTransport } from "./fetch.js"; import { resolveTelegramToken } from "./token.js"; +export { mergeTelegramAccountConfig, resolveTelegramAccountConfig } from "./account-config.js"; + let log: ReturnType | null = null; function getLog() { @@ -67,22 +66,8 @@ export type TelegramMediaRuntimeOptions = { dangerouslyAllowPrivateNetwork?: boolean; }; -function listConfiguredAccountIds(cfg: OpenClawConfig): string[] { - const ids = new Set(); - for (const key of Object.keys(cfg.channels?.telegram?.accounts ?? {})) { - if (key) { - ids.add(normalizeAccountId(key)); - } - } - return [...ids]; -} - export function listTelegramAccountIds(cfg: OpenClawConfig): string[] { - const ids = listCombinedAccountIds({ - configuredAccountIds: listConfiguredAccountIds(cfg), - additionalAccountIds: listBoundAccountIds(cfg, "telegram"), - fallbackAccountIdWhenEmpty: DEFAULT_ACCOUNT_ID, - }); + const ids = listSelectedTelegramAccountIds(cfg); debugAccounts("listTelegramAccountIds", ids); return ids; } @@ -95,63 +80,15 @@ export function resetMissingDefaultWarnFlag(): void { } export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string { - const boundDefault = resolveDefaultAgentBoundAccountId(cfg, "telegram"); - if (boundDefault) { - return boundDefault; - } - const ids = listTelegramAccountIds(cfg); - const resolved = resolveListedDefaultAccountId({ - accountIds: ids, - configuredDefaultAccountId: normalizeOptionalAccountId(cfg.channels?.telegram?.defaultAccount), - }); - if (resolved !== ids[0] || ids.includes(DEFAULT_ACCOUNT_ID) || ids.length <= 1) { - return resolved; - } - if (ids.length > 1 && !emittedMissingDefaultWarn) { + const selection = resolveDefaultTelegramAccountSelection(cfg); + if (selection.shouldWarnMissingDefault && !emittedMissingDefaultWarn) { emittedMissingDefaultWarn = true; getLog().warn( - `channels.telegram: accounts.default is missing; falling back to "${ids[0]}". ` + + `channels.telegram: accounts.default is missing; falling back to "${selection.accountId}". ` + `${formatSetExplicitDefaultInstruction("telegram")} to avoid routing surprises in multi-account setups.`, ); } - return resolved; -} - -export function resolveTelegramAccountConfig( - cfg: OpenClawConfig, - accountId: string, -): TelegramAccountConfig | undefined { - const normalized = normalizeAccountId(accountId); - return resolveAccountEntry(cfg.channels?.telegram?.accounts, normalized); -} - -export 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) ?? {}; - - // 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 - // member of a configured group will fail when handling group messages, and - // this failure disrupts message delivery for *all* accounts. - // Single-account setups keep backward compat: channel-level groups still - // applies when the account has no override. - // See: https://github.com/openclaw/openclaw/issues/30673 - const configuredAccountIds = Object.keys(cfg.channels?.telegram?.accounts ?? {}); - const isMultiAccount = configuredAccountIds.length > 1; - const groups = account.groups ?? (isMultiAccount ? undefined : channelGroups); - - return { ...base, ...account, groups }; + return selection.accountId; } export function createTelegramActionGate(params: { diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index b36a5f89aeb..eef3764e1f5 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -119,6 +119,10 @@ function installPerKeySequentializer(): void { }); } +function mockTelegramConfigWrites() { + return vi.spyOn(configRuntime, "writeConfigFile").mockResolvedValue(undefined); +} + describe("createTelegramBot", () => { beforeAll(() => { process.env.TZ = "UTC"; @@ -216,86 +220,53 @@ describe("createTelegramBot", () => { }, }); - const startedBodies: string[] = []; - let releaseConversationTurn!: () => void; - const conversationGate = new Promise((resolve) => { - releaseConversationTurn = resolve; - }); - - replySpy.mockImplementation(async (ctx: MsgContext, opts?: GetReplyOptions) => { - await opts?.onReplyStart?.(); - const body = String(ctx.CommandBody ?? ctx.Body ?? ""); - startedBodies.push(body); - if (body.includes("hello there")) { - await conversationGate; - } - return { text: `reply:${body}` }; + const events: string[] = []; + let releaseTopicTurn!: () => void; + const topicGate = new Promise((resolve) => { + releaseTopicTurn = resolve; }); createTelegramBot({ token: "tok" }); - const messageHandler = getOnHandler("message") as ( - ctx: TelegramMiddlewareTestContext, - ) => Promise; - const statusHandler = commandSpy.mock.calls.find((call) => call[0] === "status")?.[1] as - | ((ctx: TelegramMiddlewareTestContext) => Promise) + const sequentializer = sequentializeSpy.mock.results[0]?.value as + | TelegramMiddleware | undefined; - expect(statusHandler).toBeDefined(); - if (!statusHandler) { + expect(sequentializer).toBeDefined(); + if (!sequentializer) { return; } + const busyMessage = makeForumGroupMessageCtx({ threadId: 99, text: "hello there" }).message; + const statusMessage = makeForumGroupMessageCtx({ threadId: 99, text: "/status" }).message; const busyCtx = { ...makeForumGroupMessageCtx({ threadId: 99, text: "hello there" }), - message: { - ...makeForumGroupMessageCtx({ threadId: 99, text: "hello there" }).message, - message_id: 101, - }, + message: { ...busyMessage, message_id: 101 }, update: { update_id: 101 }, }; const statusCtx = { ...makeForumGroupMessageCtx({ threadId: 99, text: "/status" }), - message: { - ...makeForumGroupMessageCtx({ threadId: 99, text: "/status" }).message, - message_id: 102, - }, + message: { ...statusMessage, message_id: 102 }, update: { update_id: 102 }, - match: "", }; - const busyPromise = runTelegramMiddlewareChain({ - ctx: busyCtx, - finalHandler: messageHandler, + const busyPromise = sequentializer(busyCtx, async () => { + events.push("busy:start"); + await topicGate; + events.push("busy:end"); }); await vi.waitFor(() => { - expect(startedBodies).toHaveLength(1); - expect(startedBodies[0]).toContain("hello there"); + expect(events).toEqual(["busy:start"]); }); - const statusPromise = runTelegramMiddlewareChain({ - ctx: statusCtx, - finalHandler: statusHandler, + await sequentializer(statusCtx, async () => { + events.push("status"); }); - await vi.waitFor(() => { - expect(startedBodies).toHaveLength(2); - expect(startedBodies[0]).toContain("hello there"); - expect(startedBodies[1]).toBe("/status"); - expect(sendMessageSpy).toHaveBeenCalledTimes(1); - expect(sendMessageSpy.mock.calls[0]?.[1]).toContain("reply:/status"); - }); + expect(events).toEqual(["busy:start", "status"]); - await statusPromise; - - releaseConversationTurn(); + releaseTopicTurn(); await busyPromise; - - await vi.waitFor(() => { - expect(sendMessageSpy).toHaveBeenCalledTimes(2); - }); - const sentBodies = sendMessageSpy.mock.calls.map((call) => String(call[1])); - expect(sentBodies[0]).toContain("reply:/status"); - expect(sentBodies[1]).toContain("hello there"); + expect(events).toEqual(["busy:start", "status", "busy:end"]); }); it("keeps ordinary Telegram messages serialized within the same topic", async () => { @@ -1465,6 +1436,7 @@ describe("createTelegramBot", () => { }); it("retries group migration updates after a bubbled handler failure", async () => { + const writeConfigFileSpy = mockTelegramConfigWrites(); loadConfig.mockReturnValue({ channels: { telegram: { @@ -1514,12 +1486,17 @@ describe("createTelegramBot", () => { loadConfig.mockImplementationOnce(() => { throw new Error("cfg boom"); }); - await expect(runMiddlewareChain(ctx)).rejects.toThrow("cfg boom"); - const loadConfigCallsAfterFailure = loadConfig.mock.calls.length; - await runMiddlewareChain(ctx); + try { + await expect(runMiddlewareChain(ctx)).rejects.toThrow("cfg boom"); + const loadConfigCallsAfterFailure = loadConfig.mock.calls.length; + await runMiddlewareChain(ctx); - expect(loadConfigCallsAfterFailure).toBe(loadConfigCallsBeforeRetry + 1); - expect(loadConfig.mock.calls.length).toBeGreaterThan(loadConfigCallsAfterFailure); + expect(loadConfigCallsAfterFailure).toBe(loadConfigCallsBeforeRetry + 1); + expect(loadConfig.mock.calls.length).toBeGreaterThan(loadConfigCallsAfterFailure); + expect(writeConfigFileSpy).toHaveBeenCalledTimes(1); + } finally { + writeConfigFileSpy.mockRestore(); + } }); const groupPolicyCases: Array<{ @@ -3110,6 +3087,7 @@ describe("createTelegramBot", () => { }); it("retries group migration updates after a bubbled handler failure", async () => { + const writeConfigFileSpy = mockTelegramConfigWrites(); loadConfig.mockReturnValue({ channels: { telegram: { @@ -3159,12 +3137,17 @@ describe("createTelegramBot", () => { loadConfig.mockImplementationOnce(() => { throw new Error("cfg boom"); }); - await expect(runMiddlewareChain(ctx)).rejects.toThrow("cfg boom"); - const loadConfigCallsAfterFailure = loadConfig.mock.calls.length; - await runMiddlewareChain(ctx); + try { + await expect(runMiddlewareChain(ctx)).rejects.toThrow("cfg boom"); + const loadConfigCallsAfterFailure = loadConfig.mock.calls.length; + await runMiddlewareChain(ctx); - expect(loadConfigCallsAfterFailure).toBe(loadConfigCallsBeforeRetry + 1); - expect(loadConfig.mock.calls.length).toBeGreaterThan(loadConfigCallsAfterFailure); + expect(loadConfigCallsAfterFailure).toBe(loadConfigCallsBeforeRetry + 1); + expect(loadConfig.mock.calls.length).toBeGreaterThan(loadConfigCallsAfterFailure); + expect(writeConfigFileSpy).toHaveBeenCalledTimes(1); + } finally { + writeConfigFileSpy.mockRestore(); + } }); it("retries reaction updates after a bubbled enqueue failure", async () => { diff --git a/extensions/telegram/src/directory-config.ts b/extensions/telegram/src/directory-config.ts index aba2fca3de1..077be0bb1e5 100644 --- a/extensions/telegram/src/directory-config.ts +++ b/extensions/telegram/src/directory-config.ts @@ -1,12 +1,30 @@ +import { normalizeAccountId } from "openclaw/plugin-sdk/account-core"; import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers"; -import { createInspectedDirectoryEntriesLister } from "openclaw/plugin-sdk/directory-runtime"; -import { inspectTelegramAccount, type InspectedTelegramAccount } from "./account-inspect.js"; +import type { OpenClawConfig, TelegramAccountConfig } from "openclaw/plugin-sdk/config-runtime"; +import { createResolvedDirectoryEntriesLister } from "openclaw/plugin-sdk/directory-config-runtime"; +import { mergeTelegramAccountConfig } from "./account-config.js"; +import { resolveDefaultTelegramAccountSelection } from "./account-selection.js"; + +type TelegramDirectoryAccount = { + config: TelegramAccountConfig; +}; + +function resolveTelegramDirectoryAccount( + cfg: OpenClawConfig, + accountId?: string | null, +): TelegramDirectoryAccount { + const resolvedAccountId = accountId?.trim() + ? normalizeAccountId(accountId) + : resolveDefaultTelegramAccountSelection(cfg).accountId; + return { + config: mergeTelegramAccountConfig(cfg, resolvedAccountId), + }; +} export const listTelegramDirectoryPeersFromConfig = - createInspectedDirectoryEntriesLister({ + createResolvedDirectoryEntriesLister({ kind: "user", - inspectAccount: (cfg, accountId) => - inspectTelegramAccount({ cfg, accountId }) as InspectedTelegramAccount | null, + resolveAccount: (cfg, accountId) => resolveTelegramDirectoryAccount(cfg, accountId), resolveSources: (account) => [ mapAllowFromEntries(account.config.allowFrom), Object.keys(account.config.dms ?? {}), @@ -24,10 +42,9 @@ export const listTelegramDirectoryPeersFromConfig = }); export const listTelegramDirectoryGroupsFromConfig = - createInspectedDirectoryEntriesLister({ + createResolvedDirectoryEntriesLister({ kind: "group", - inspectAccount: (cfg, accountId) => - inspectTelegramAccount({ cfg, accountId }) as InspectedTelegramAccount | null, + resolveAccount: (cfg, accountId) => resolveTelegramDirectoryAccount(cfg, accountId), resolveSources: (account) => [Object.keys(account.config.groups ?? {})], normalizeId: (entry) => entry.trim() || null, }); diff --git a/extensions/telegram/src/state-migrations.ts b/extensions/telegram/src/state-migrations.ts index 19147405828..455d5a77126 100644 --- a/extensions/telegram/src/state-migrations.ts +++ b/extensions/telegram/src/state-migrations.ts @@ -1,8 +1,8 @@ import fs from "node:fs"; import type { ChannelLegacyStateMigrationPlan } from "openclaw/plugin-sdk/channel-contract"; -import { resolveChannelAllowFromPath } from "openclaw/plugin-sdk/channel-pairing"; +import { resolveChannelAllowFromPath } from "openclaw/plugin-sdk/channel-pairing-paths"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { resolveDefaultTelegramAccountId } from "./accounts.js"; +import { resolveDefaultTelegramAccountId } from "./account-selection.js"; function fileExists(pathValue: string): boolean { try { diff --git a/extensions/twitch/index.test.ts b/extensions/twitch/index.test.ts index 251bc173382..1bf76d359e2 100644 --- a/extensions/twitch/index.test.ts +++ b/extensions/twitch/index.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe } from "vitest"; import { assertBundledChannelEntries } from "../../test/helpers/bundled-channel-entry.ts"; import entry from "./index.js"; import setupEntry from "./setup-entry.js"; @@ -10,11 +10,4 @@ describe("twitch bundled entries", () => { expectedName: "Twitch", setupEntry, }); - - it("loads the setup-only channel plugin", () => { - const plugin = setupEntry.loadSetupPlugin?.(); - - expect(plugin?.id).toBe("twitch"); - expect(plugin?.setupWizard).toBeDefined(); - }); }); diff --git a/extensions/webhooks/src/http.ts b/extensions/webhooks/src/http.ts index f01d4839e08..365fc5767c5 100644 --- a/extensions/webhooks/src/http.ts +++ b/extensions/webhooks/src/http.ts @@ -724,9 +724,7 @@ export function createTaskFlowWebhookRequestHandler(params: { return false; } const resolvedSecret = await resolveTargetSecret(candidate); - return Boolean( - resolvedSecret && timingSafeEquals(resolvedSecret, presentedSecret), - ); + return Boolean(resolvedSecret && timingSafeEquals(resolvedSecret, presentedSecret)); }, }); if (!target) { diff --git a/extensions/whatsapp/contract-api.ts b/extensions/whatsapp/contract-api.ts index 1e0dc98c8c1..7ba0e12d044 100644 --- a/extensions/whatsapp/contract-api.ts +++ b/extensions/whatsapp/contract-api.ts @@ -5,6 +5,10 @@ import { isWhatsAppGroupJid as isWhatsAppGroupJidImpl, normalizeWhatsAppTarget as normalizeWhatsAppTargetImpl, } from "./src/normalize-target.js"; +export { + listWhatsAppDirectoryGroupsFromConfig, + listWhatsAppDirectoryPeersFromConfig, +} from "./src/directory-config.js"; import { resolveWhatsAppRuntimeGroupPolicy as resolveWhatsAppRuntimeGroupPolicyImpl } from "./src/runtime-group-policy.js"; import { canonicalizeLegacySessionKey as canonicalizeLegacySessionKeyImpl, diff --git a/extensions/whatsapp/directory-contract-api.ts b/extensions/whatsapp/directory-contract-api.ts new file mode 100644 index 00000000000..389f33d5e64 --- /dev/null +++ b/extensions/whatsapp/directory-contract-api.ts @@ -0,0 +1,4 @@ +export { + listWhatsAppDirectoryGroupsFromConfig, + listWhatsAppDirectoryPeersFromConfig, +} from "./src/directory-config.js"; diff --git a/extensions/whatsapp/legacy-session-surface-api.ts b/extensions/whatsapp/legacy-session-surface-api.ts new file mode 100644 index 00000000000..ed94357bd4d --- /dev/null +++ b/extensions/whatsapp/legacy-session-surface-api.ts @@ -0,0 +1,6 @@ +import { canonicalizeLegacySessionKey, isLegacyGroupSessionKey } from "./src/session-contract.js"; + +export const whatsappLegacySessionSurface = { + isLegacyGroupSessionKey, + canonicalizeLegacySessionKey, +}; diff --git a/extensions/whatsapp/legacy-state-migrations-api.ts b/extensions/whatsapp/legacy-state-migrations-api.ts new file mode 100644 index 00000000000..2b228f175ec --- /dev/null +++ b/extensions/whatsapp/legacy-state-migrations-api.ts @@ -0,0 +1 @@ +export { detectWhatsAppLegacyStateMigrations } from "./src/state-migrations.js"; diff --git a/extensions/whatsapp/outbound-payload-test-api.ts b/extensions/whatsapp/outbound-payload-test-api.ts new file mode 100644 index 00000000000..49ea8ff67b4 --- /dev/null +++ b/extensions/whatsapp/outbound-payload-test-api.ts @@ -0,0 +1 @@ +export { whatsappOutbound } from "./src/outbound-adapter.js"; diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index a695f97cff8..2c339ec282b 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -25,6 +25,10 @@ "./index.ts" ], "setupEntry": "./setup-entry.ts", + "setupFeatures": { + "legacyStateMigrations": true, + "legacySessionSurfaces": true + }, "channel": { "id": "whatsapp", "label": "WhatsApp", diff --git a/extensions/whatsapp/setup-entry.ts b/extensions/whatsapp/setup-entry.ts index b6c896a9dec..f7f88662785 100644 --- a/extensions/whatsapp/setup-entry.ts +++ b/extensions/whatsapp/setup-entry.ts @@ -10,4 +10,12 @@ export default defineBundledChannelSetupEntry({ specifier: "./setup-plugin-api.js", exportName: "whatsappSetupPlugin", }, + legacyStateMigrations: { + specifier: "./legacy-state-migrations-api.js", + exportName: "detectWhatsAppLegacyStateMigrations", + }, + legacySessionSurface: { + specifier: "./legacy-session-surface-api.js", + exportName: "whatsappLegacySessionSurface", + }, }); diff --git a/extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts b/extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts index 14df5f9cc2b..71674cf3474 100644 --- a/extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts +++ b/extensions/whatsapp/src/auto-reply.web-auto-reply.compresses-common-formats-jpeg-cap.test.ts @@ -294,20 +294,14 @@ describe("web auto-reply", () => { resetLoadConfigMock(); } }); - it("falls back to text when media is unsupported", async () => { + it("sends PDF media as a document", async () => { const sendMedia = vi.fn(); const { reply, dispatch } = await setupSingleInboundMessage({ resolverValue: { text: "hi", mediaUrl: "https://example.com/file.pdf" }, sendMedia, }); - const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({ - ok: true, - body: true, - arrayBuffer: async () => Buffer.from("%PDF-1.4").buffer, - headers: { get: () => "application/pdf" }, - status: 200, - } as unknown as Response); + const fetchMock = mockFetchMediaBuffer(Buffer.from("%PDF-1.4"), "application/pdf"); await dispatch("msg-pdf"); diff --git a/extensions/whatsapp/src/directory-config.ts b/extensions/whatsapp/src/directory-config.ts index 86939e7901c..16faf276f48 100644 --- a/extensions/whatsapp/src/directory-config.ts +++ b/extensions/whatsapp/src/directory-config.ts @@ -1,16 +1,25 @@ -import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers"; import { listResolvedDirectoryGroupEntriesFromMapKeys, listResolvedDirectoryUserEntriesFromAllowFrom, type DirectoryConfigParams, -} from "openclaw/plugin-sdk/directory-runtime"; -import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js"; +} from "openclaw/plugin-sdk/directory-config-runtime"; +import { resolveMergedWhatsAppAccountConfig } from "./account-config.js"; +import type { WhatsAppAccountConfig } from "./account-types.js"; import { isWhatsAppGroupJid, normalizeWhatsAppTarget } from "./normalize.js"; +type WhatsAppDirectoryAccount = WhatsAppAccountConfig & { accountId: string }; + +function resolveWhatsAppDirectoryAccount( + cfg: DirectoryConfigParams["cfg"], + accountId?: string | null, +): WhatsAppDirectoryAccount { + return resolveMergedWhatsAppAccountConfig({ cfg, accountId }); +} + export async function listWhatsAppDirectoryPeersFromConfig(params: DirectoryConfigParams) { - return listResolvedDirectoryUserEntriesFromAllowFrom({ + return listResolvedDirectoryUserEntriesFromAllowFrom({ ...params, - resolveAccount: adaptScopedAccountAccessor(resolveWhatsAppAccount), + resolveAccount: resolveWhatsAppDirectoryAccount, resolveAllowFrom: (account) => account.allowFrom, normalizeId: (entry) => { const normalized = normalizeWhatsAppTarget(entry); @@ -23,9 +32,9 @@ export async function listWhatsAppDirectoryPeersFromConfig(params: DirectoryConf } export async function listWhatsAppDirectoryGroupsFromConfig(params: DirectoryConfigParams) { - return listResolvedDirectoryGroupEntriesFromMapKeys({ + return listResolvedDirectoryGroupEntriesFromMapKeys({ ...params, - resolveAccount: adaptScopedAccountAccessor(resolveWhatsAppAccount), + resolveAccount: resolveWhatsAppDirectoryAccount, resolveGroups: (account) => account.groups, }); } diff --git a/extensions/whatsapp/src/outbound-adapter.ts b/extensions/whatsapp/src/outbound-adapter.ts index ffb1f1b6e7e..dd4ed99e86b 100644 --- a/extensions/whatsapp/src/outbound-adapter.ts +++ b/extensions/whatsapp/src/outbound-adapter.ts @@ -11,8 +11,7 @@ import { import { chunkText } from "openclaw/plugin-sdk/reply-runtime"; import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { WHATSAPP_LEGACY_OUTBOUND_SEND_DEP_KEYS } from "./outbound-send-deps.js"; -import { resolveWhatsAppOutboundTarget } from "./runtime-api.js"; -import { sendPollWhatsApp } from "./send.js"; +import { resolveWhatsAppOutboundTarget } from "./resolve-outbound-target.js"; function trimLeadingWhitespace(text: string | undefined): string { return text?.trimStart() ?? ""; @@ -90,7 +89,9 @@ export const whatsappOutbound: ChannelOutboundAdapter = { }); }, sendPoll: async ({ cfg, to, poll, accountId }) => - await sendPollWhatsApp(to, poll, { + await ( + await import("./send.js") + ).sendPollWhatsApp(to, poll, { verbose: shouldLogVerbose(), accountId: accountId ?? undefined, cfg, diff --git a/extensions/whatsapp/src/session-contract.ts b/extensions/whatsapp/src/session-contract.ts index a71fd843852..5e7f456f33f 100644 --- a/extensions/whatsapp/src/session-contract.ts +++ b/extensions/whatsapp/src/session-contract.ts @@ -1,4 +1,4 @@ -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; function extractLegacyWhatsAppGroupId(key: string): string | null { const trimmed = key.trim(); diff --git a/extensions/xai/provider-contract-api.ts b/extensions/xai/provider-contract-api.ts new file mode 100644 index 00000000000..94c58105f47 --- /dev/null +++ b/extensions/xai/provider-contract-api.ts @@ -0,0 +1,22 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +export function createXaiProvider(): ProviderPlugin { + return { + id: "xai", + label: "xAI", + aliases: ["x-ai"], + docsPath: "/providers/xai", + auth: [ + { + id: "api-key", + kind: "api_key", + label: "xAI API key", + hint: "API key", + run: async () => ({ profiles: [] }), + wizard: { + groupLabel: "xAI (Grok)", + }, + }, + ], + }; +} diff --git a/extensions/xai/src/web-search-provider.runtime.ts b/extensions/xai/src/web-search-provider.runtime.ts new file mode 100644 index 00000000000..3be666d2bc8 --- /dev/null +++ b/extensions/xai/src/web-search-provider.runtime.ts @@ -0,0 +1,216 @@ +import { + DEFAULT_CACHE_TTL_MINUTES, + DEFAULT_TIMEOUT_SECONDS, + formatCliCommand, + getScopedCredentialValue, + mergeScopedSearchConfig, + normalizeCacheKey, + readCache, + readNumberParam, + readStringParam, + resolveCacheTtlMs, + resolveProviderWebSearchPluginConfig, + resolveTimeoutSeconds, + resolveWebSearchProviderCredential, + type WebSearchProviderSetupContext, + writeCache, +} from "openclaw/plugin-sdk/provider-web-search"; +import { + buildXaiWebSearchPayload, + extractXaiWebSearchContent, + requestXaiWebSearch, + resolveXaiInlineCitations, + resolveXaiWebSearchModel, +} from "./web-search-shared.js"; +import { resolveEffectiveXSearchConfig, setPluginXSearchConfigValue } from "./x-search-config.js"; +import { XAI_DEFAULT_X_SEARCH_MODEL } from "./x-search-shared.js"; + +const XAI_WEB_SEARCH_CACHE = new Map< + string, + { value: Record; insertedAt: number; expiresAt: number } +>(); + +const X_SEARCH_MODEL_OPTIONS = [ + { + value: XAI_DEFAULT_X_SEARCH_MODEL, + label: XAI_DEFAULT_X_SEARCH_MODEL, + hint: "default · fast, no reasoning", + }, + { + value: "grok-4-1-fast", + label: "grok-4-1-fast", + hint: "fast with reasoning", + }, +] as const; + +function resolveXSearchConfigRecord( + config?: WebSearchProviderSetupContext["config"], +): Record | undefined { + return resolveEffectiveXSearchConfig(config); +} + +export async function runXaiSearchProviderSetup( + ctx: WebSearchProviderSetupContext, +): Promise { + const existingXSearch = resolveXSearchConfigRecord(ctx.config); + if (existingXSearch?.enabled === false) { + return ctx.config; + } + + await ctx.prompter.note( + [ + "x_search lets your agent search X (formerly Twitter) posts via xAI.", + "It reuses the same xAI API key you just configured for Grok web search.", + `You can change this later with ${formatCliCommand("openclaw configure --section web")}.`, + ].join("\n"), + "X search", + ); + + const enableChoice = await ctx.prompter.select<"yes" | "skip">({ + message: "Enable x_search too?", + options: [ + { + value: "yes", + label: "Yes, enable x_search", + hint: "Search X posts with the same xAI key", + }, + { + value: "skip", + label: "Skip for now", + hint: "Keep Grok web_search only", + }, + ], + initialValue: existingXSearch?.enabled === true || ctx.quickstartDefaults ? "yes" : "skip", + }); + + if (enableChoice === "skip") { + return ctx.config; + } + + const existingModel = + typeof existingXSearch?.model === "string" && existingXSearch.model.trim() + ? existingXSearch.model.trim() + : ""; + const knownModel = X_SEARCH_MODEL_OPTIONS.find((entry) => entry.value === existingModel)?.value; + const modelPick = await ctx.prompter.select({ + message: "Grok model for x_search", + options: [ + ...X_SEARCH_MODEL_OPTIONS, + { value: "__custom__", label: "Enter custom model name", hint: "" }, + ], + initialValue: knownModel ?? XAI_DEFAULT_X_SEARCH_MODEL, + }); + + let model = modelPick; + if (modelPick === "__custom__") { + const customModel = await ctx.prompter.text({ + message: "Custom Grok model name", + initialValue: existingModel || XAI_DEFAULT_X_SEARCH_MODEL, + placeholder: XAI_DEFAULT_X_SEARCH_MODEL, + }); + model = customModel.trim() || XAI_DEFAULT_X_SEARCH_MODEL; + } + + const next = structuredClone(ctx.config); + setPluginXSearchConfigValue(next, "enabled", true); + setPluginXSearchConfigValue(next, "model", model || XAI_DEFAULT_X_SEARCH_MODEL); + return next; +} + +function runXaiWebSearch(params: { + query: string; + model: string; + apiKey: string; + timeoutSeconds: number; + inlineCitations: boolean; + cacheTtlMs: number; +}): Promise> { + const cacheKey = normalizeCacheKey( + `grok:${params.model}:${String(params.inlineCitations)}:${params.query}`, + ); + const cached = readCache(XAI_WEB_SEARCH_CACHE, cacheKey); + if (cached) { + return Promise.resolve({ ...cached.value, cached: true }); + } + + return (async () => { + const startedAt = Date.now(); + const result = await requestXaiWebSearch({ + query: params.query, + model: params.model, + apiKey: params.apiKey, + timeoutSeconds: params.timeoutSeconds, + inlineCitations: params.inlineCitations, + }); + const payload = buildXaiWebSearchPayload({ + query: params.query, + provider: "grok", + model: params.model, + tookMs: Date.now() - startedAt, + content: result.content, + citations: result.citations, + inlineCitations: result.inlineCitations, + }); + + writeCache(XAI_WEB_SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); + return payload; + })(); +} + +function resolveXaiToolSearchConfig(ctx: { + config?: Record; + searchConfig?: Record; +}) { + return mergeScopedSearchConfig( + ctx.searchConfig, + "grok", + resolveProviderWebSearchPluginConfig(ctx.config, "xai"), + ); +} + +function resolveXaiWebSearchCredential(searchConfig?: Record): string | undefined { + return resolveWebSearchProviderCredential({ + credentialValue: getScopedCredentialValue(searchConfig, "grok"), + path: "tools.web.search.grok.apiKey", + envVars: ["XAI_API_KEY"], + }); +} + +export async function executeXaiWebSearchProviderTool( + ctx: { config?: Record; searchConfig?: Record }, + args: Record, +): Promise> { + const searchConfig = resolveXaiToolSearchConfig(ctx); + const apiKey = resolveXaiWebSearchCredential(searchConfig); + + if (!apiKey) { + return { + error: "missing_xai_api_key", + message: + "web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure plugins.entries.xai.config.webSearch.apiKey.", + docs: "https://docs.openclaw.ai/tools/web", + }; + } + + const query = readStringParam(args, "query", { required: true }); + void readNumberParam(args, "count", { integer: true }); + + return await runXaiWebSearch({ + query, + model: resolveXaiWebSearchModel(searchConfig), + apiKey, + timeoutSeconds: resolveTimeoutSeconds(searchConfig?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS), + inlineCitations: resolveXaiInlineCitations(searchConfig), + cacheTtlMs: resolveCacheTtlMs(searchConfig?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES), + }); +} + +export const __testing = { + buildXaiWebSearchPayload, + extractXaiWebSearchContent, + resolveXaiToolSearchConfig, + resolveXaiInlineCitations, + resolveXaiWebSearchCredential, + resolveXaiWebSearchModel, + requestXaiWebSearch, +}; diff --git a/extensions/xai/test-api.ts b/extensions/xai/test-api.ts new file mode 100644 index 00000000000..1f1a31cfcaa --- /dev/null +++ b/extensions/xai/test-api.ts @@ -0,0 +1 @@ +export { __testing } from "./src/web-search-provider.runtime.js"; diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts index b5f732236fa..b9d2208aa08 100644 --- a/extensions/xai/web-search.test.ts +++ b/extensions/xai/web-search.test.ts @@ -1,12 +1,13 @@ import { NON_ENV_SECRETREF_MARKER } from "openclaw/plugin-sdk/provider-auth-runtime"; import { createNonExitingRuntime } from "openclaw/plugin-sdk/runtime-env"; -import { withEnv } from "openclaw/plugin-sdk/testing"; +import { withEnv, withEnvAsync } from "openclaw/plugin-sdk/testing"; import { describe, expect, it, vi } from "vitest"; import { createWizardPrompter } from "../../test/helpers/wizard-prompter.js"; import { resolveXaiCatalogEntry } from "./model-definitions.js"; import { isModernXaiModel, resolveXaiForwardCompatModel } from "./provider-models.js"; import { resolveFallbackXaiAuth } from "./src/tool-auth-shared.js"; -import { __testing, createXaiWebSearchProvider } from "./web-search.js"; +import { __testing } from "./test-api.js"; +import { createXaiWebSearchProvider } from "./web-search.js"; const { extractXaiWebSearchContent, @@ -78,7 +79,7 @@ describe("xai web search config resolution", () => { }); it("treats unresolved non-env SecretRefs as missing credentials instead of throwing", async () => { - await withEnv({ XAI_API_KEY: undefined }, async () => { + await withEnvAsync({ XAI_API_KEY: undefined }, async () => { const provider = createXaiWebSearchProvider(); const maybeTool = provider.createTool({ config: { diff --git a/extensions/xai/web-search.ts b/extensions/xai/web-search.ts index 50c8ea022b2..bfbca549cba 100644 --- a/extensions/xai/web-search.ts +++ b/extensions/xai/web-search.ts @@ -1,186 +1,29 @@ -import { Type } from "@sinclair/typebox"; import { - DEFAULT_CACHE_TTL_MINUTES, - DEFAULT_TIMEOUT_SECONDS, - formatCliCommand, - getScopedCredentialValue, - mergeScopedSearchConfig, - normalizeCacheKey, - readCache, - readNumberParam, - readStringParam, - resolveCacheTtlMs, - resolveProviderWebSearchPluginConfig, - resolveTimeoutSeconds, - resolveWebSearchProviderCredential, - setProviderWebSearchPluginConfigValue, - setScopedCredentialValue, - type WebSearchProviderSetupContext, + createWebSearchProviderContractFields, type WebSearchProviderPlugin, - writeCache, -} from "openclaw/plugin-sdk/provider-web-search"; -import { - buildXaiWebSearchPayload, - extractXaiWebSearchContent, - requestXaiWebSearch, - resolveXaiInlineCitations, - resolveXaiWebSearchModel, -} from "./src/web-search-shared.js"; -import { - resolveEffectiveXSearchConfig, - setPluginXSearchConfigValue, -} from "./src/x-search-config.js"; -import { XAI_DEFAULT_X_SEARCH_MODEL } from "./src/x-search-shared.js"; + type WebSearchProviderSetupContext, +} from "openclaw/plugin-sdk/provider-web-search-config-contract"; -const XAI_WEB_SEARCH_CACHE = new Map< - string, - { value: Record; insertedAt: number; expiresAt: number } ->(); - -const X_SEARCH_MODEL_OPTIONS = [ - { - value: XAI_DEFAULT_X_SEARCH_MODEL, - label: XAI_DEFAULT_X_SEARCH_MODEL, - hint: "default · fast, no reasoning", +const XAI_CREDENTIAL_PATH = "plugins.entries.xai.config.webSearch.apiKey"; +const GenericXaiSearchSchema = { + type: "object", + properties: { + query: { type: "string", description: "Search query string." }, + count: { + type: "number", + description: "Number of results to return (1-10).", + minimum: 1, + maximum: 10, + }, }, - { - value: "grok-4-1-fast", - label: "grok-4-1-fast", - hint: "fast with reasoning", - }, -] as const; - -function resolveXSearchConfigRecord( - config?: WebSearchProviderSetupContext["config"], -): Record | undefined { - return resolveEffectiveXSearchConfig(config); -} + additionalProperties: false, +} satisfies Record; async function runXaiSearchProviderSetup( ctx: WebSearchProviderSetupContext, ): Promise { - const existingXSearch = resolveXSearchConfigRecord(ctx.config); - if (existingXSearch?.enabled === false) { - return ctx.config; - } - - await ctx.prompter.note( - [ - "x_search lets your agent search X (formerly Twitter) posts via xAI.", - "It reuses the same xAI API key you just configured for Grok web search.", - `You can change this later with ${formatCliCommand("openclaw configure --section web")}.`, - ].join("\n"), - "X search", - ); - - const enableChoice = await ctx.prompter.select<"yes" | "skip">({ - message: "Enable x_search too?", - options: [ - { - value: "yes", - label: "Yes, enable x_search", - hint: "Search X posts with the same xAI key", - }, - { - value: "skip", - label: "Skip for now", - hint: "Keep Grok web_search only", - }, - ], - initialValue: existingXSearch?.enabled === true || ctx.quickstartDefaults ? "yes" : "skip", - }); - - if (enableChoice === "skip") { - return ctx.config; - } - - const existingModel = - typeof existingXSearch?.model === "string" && existingXSearch.model.trim() - ? existingXSearch.model.trim() - : ""; - const knownModel = X_SEARCH_MODEL_OPTIONS.find((entry) => entry.value === existingModel)?.value; - const modelPick = await ctx.prompter.select({ - message: "Grok model for x_search", - options: [ - ...X_SEARCH_MODEL_OPTIONS, - { value: "__custom__", label: "Enter custom model name", hint: "" }, - ], - initialValue: knownModel ?? XAI_DEFAULT_X_SEARCH_MODEL, - }); - - let model = modelPick; - if (modelPick === "__custom__") { - const customModel = await ctx.prompter.text({ - message: "Custom Grok model name", - initialValue: existingModel || XAI_DEFAULT_X_SEARCH_MODEL, - placeholder: XAI_DEFAULT_X_SEARCH_MODEL, - }); - model = customModel.trim() || XAI_DEFAULT_X_SEARCH_MODEL; - } - - const next = structuredClone(ctx.config); - setPluginXSearchConfigValue(next, "enabled", true); - setPluginXSearchConfigValue(next, "model", model || XAI_DEFAULT_X_SEARCH_MODEL); - return next; -} - -function runXaiWebSearch(params: { - query: string; - model: string; - apiKey: string; - timeoutSeconds: number; - inlineCitations: boolean; - cacheTtlMs: number; -}): Promise> { - const cacheKey = normalizeCacheKey( - `grok:${params.model}:${String(params.inlineCitations)}:${params.query}`, - ); - const cached = readCache(XAI_WEB_SEARCH_CACHE, cacheKey); - if (cached) { - return Promise.resolve({ ...cached.value, cached: true }); - } - - return (async () => { - const startedAt = Date.now(); - const result = await requestXaiWebSearch({ - query: params.query, - model: params.model, - apiKey: params.apiKey, - timeoutSeconds: params.timeoutSeconds, - inlineCitations: params.inlineCitations, - }); - const payload = buildXaiWebSearchPayload({ - query: params.query, - provider: "grok", - model: params.model, - tookMs: Date.now() - startedAt, - content: result.content, - citations: result.citations, - inlineCitations: result.inlineCitations, - }); - - writeCache(XAI_WEB_SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs); - return payload; - })(); -} - -function resolveXaiToolSearchConfig(ctx: { - config?: Record; - searchConfig?: Record; -}) { - return mergeScopedSearchConfig( - ctx.searchConfig, - "grok", - resolveProviderWebSearchPluginConfig(ctx.config, "xai"), - ); -} - -function resolveXaiWebSearchCredential(searchConfig?: Record): string | undefined { - return resolveWebSearchProviderCredential({ - credentialValue: getScopedCredentialValue(searchConfig, "grok"), - path: "tools.web.search.grok.apiKey", - envVars: ["XAI_API_KEY"], - }); + const runtime = await import("./src/web-search-provider.runtime.js"); + return await runtime.runXaiSearchProviderSetup(ctx); } export function createXaiWebSearchProvider(): WebSearchProviderPlugin { @@ -195,71 +38,22 @@ export function createXaiWebSearchProvider(): WebSearchProviderPlugin { signupUrl: "https://console.x.ai/", docsUrl: "https://docs.openclaw.ai/tools/web", autoDetectOrder: 30, - credentialPath: "plugins.entries.xai.config.webSearch.apiKey", - inactiveSecretPaths: ["plugins.entries.xai.config.webSearch.apiKey"], - getCredentialValue: (searchConfig?: Record) => - getScopedCredentialValue(searchConfig, "grok"), - setCredentialValue: (searchConfigTarget: Record, value: unknown) => - setScopedCredentialValue(searchConfigTarget, "grok", value), - getConfiguredCredentialValue: (config) => - resolveProviderWebSearchPluginConfig(config, "xai")?.apiKey, - setConfiguredCredentialValue: (configTarget, value) => { - setProviderWebSearchPluginConfigValue(configTarget, "xai", "apiKey", value); - }, + credentialPath: XAI_CREDENTIAL_PATH, + ...createWebSearchProviderContractFields({ + credentialPath: XAI_CREDENTIAL_PATH, + searchCredential: { type: "scoped", scopeId: "grok" }, + configuredCredential: { pluginId: "xai" }, + }), runSetup: runXaiSearchProviderSetup, - createTool: (ctx) => { - const searchConfig = resolveXaiToolSearchConfig(ctx); - return { - description: - "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search.", - parameters: Type.Object({ - query: Type.String({ description: "Search query string." }), - count: Type.Optional( - Type.Number({ - description: "Number of results to return (1-10).", - minimum: 1, - maximum: 10, - }), - ), - }), - execute: async (args: Record) => { - const apiKey = resolveXaiWebSearchCredential(searchConfig); - - if (!apiKey) { - return { - error: "missing_xai_api_key", - message: - "web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure plugins.entries.xai.config.webSearch.apiKey.", - docs: "https://docs.openclaw.ai/tools/web", - }; - } - - const query = readStringParam(args, "query", { required: true }); - void readNumberParam(args, "count", { integer: true }); - - return await runXaiWebSearch({ - query, - model: resolveXaiWebSearchModel(searchConfig), - apiKey, - timeoutSeconds: resolveTimeoutSeconds( - searchConfig?.timeoutSeconds, - DEFAULT_TIMEOUT_SECONDS, - ), - inlineCitations: resolveXaiInlineCitations(searchConfig), - cacheTtlMs: resolveCacheTtlMs(searchConfig?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES), - }); - }, - }; - }, + createTool: (ctx) => ({ + description: + "Search the web using xAI Grok. Returns AI-synthesized answers with citations from real-time web search.", + parameters: GenericXaiSearchSchema, + execute: async (args) => { + const { executeXaiWebSearchProviderTool } = + await import("./src/web-search-provider.runtime.js"); + return await executeXaiWebSearchProviderTool(ctx, args); + }, + }), }; } - -export const __testing = { - buildXaiWebSearchPayload, - extractXaiWebSearchContent, - resolveXaiToolSearchConfig, - resolveXaiInlineCitations, - resolveXaiWebSearchCredential, - resolveXaiWebSearchModel, - requestXaiWebSearch, -}; diff --git a/extensions/zalo/src/group-access.ts b/extensions/zalo/src/group-access.ts index 0cd4755d16e..5b317ad70de 100644 --- a/extensions/zalo/src/group-access.ts +++ b/extensions/zalo/src/group-access.ts @@ -1,10 +1,8 @@ import { isNormalizedSenderAllowed } from "openclaw/plugin-sdk/allow-from"; import { + evaluateSenderGroupAccess, resolveOpenProviderRuntimeGroupPolicy, type GroupPolicy, -} from "openclaw/plugin-sdk/config-runtime"; -import { - evaluateSenderGroupAccess, type SenderGroupAccessDecision, } from "openclaw/plugin-sdk/group-access"; diff --git a/package.json b/package.json index ac3e51ec72f..db40952a030 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "dist/", "!dist/**/*.map", "!dist/plugin-sdk/.tsbuildinfo", + "!dist/extensions/node_modules/**", "!dist/extensions/*/node_modules/**", "!dist/extensions/qa-channel/**", "dist/extensions/qa-channel/runtime-api.js", @@ -640,6 +641,10 @@ "types": "./dist/plugin-sdk/channel-pairing.d.ts", "default": "./dist/plugin-sdk/channel-pairing.js" }, + "./plugin-sdk/channel-pairing-paths": { + "types": "./dist/plugin-sdk/channel-pairing-paths.d.ts", + "default": "./dist/plugin-sdk/channel-pairing-paths.js" + }, "./plugin-sdk/channel-policy": { "types": "./dist/plugin-sdk/channel-policy.d.ts", "default": "./dist/plugin-sdk/channel-policy.js" @@ -712,6 +717,10 @@ "types": "./dist/plugin-sdk/global-singleton.d.ts", "default": "./dist/plugin-sdk/global-singleton.js" }, + "./plugin-sdk/directory-config-runtime": { + "types": "./dist/plugin-sdk/directory-config-runtime.d.ts", + "default": "./dist/plugin-sdk/directory-config-runtime.js" + }, "./plugin-sdk/directory-runtime": { "types": "./dist/plugin-sdk/directory-runtime.d.ts", "default": "./dist/plugin-sdk/directory-runtime.js" diff --git a/packages/memory-host-sdk/src/host/internal.test.ts b/packages/memory-host-sdk/src/host/internal.test.ts index 45adf405867..6aa17953741 100644 --- a/packages/memory-host-sdk/src/host/internal.test.ts +++ b/packages/memory-host-sdk/src/host/internal.test.ts @@ -368,11 +368,10 @@ describe("chunkMarkdown", () => { }); it("does not break surrogate pairs when splitting long CJK lines", () => { // "𠀀" (U+20000) is a surrogate pair: 2 UTF-16 code units per character. - // A line of 500 such characters = 1000 UTF-16 code units. - // With tokens=99 (odd), the fine-split must not cut inside a pair. + // With an odd token budget, the fine-split must not cut inside a pair. const surrogateChar = "\u{20000}"; // 𠀀 - const longLine = surrogateChar.repeat(500); - const chunks = chunkMarkdown(longLine, { tokens: 99, overlap: 0 }); + const longLine = surrogateChar.repeat(120); + const chunks = chunkMarkdown(longLine, { tokens: 31, overlap: 0 }); for (const chunk of chunks) { // No chunk should contain the Unicode replacement character U+FFFD, // which would indicate a broken surrogate pair. diff --git a/qa/README.md b/qa/README.md index 98447b0c65c..cc07d65936d 100644 --- a/qa/README.md +++ b/qa/README.md @@ -13,5 +13,6 @@ Key workflow: - `qa suite` is the executable frontier subset / regression loop. - `qa manual` is the scoped personality and style probe after the executable subset is green. +- `qa coverage` prints the scenario coverage inventory from scenario frontmatter. Keep this folder in git. Add new scenarios here before wiring them into automation. diff --git a/qa/scenarios/agents/instruction-followthrough-repo-contract.md b/qa/scenarios/agents/instruction-followthrough-repo-contract.md index 8605da10c8c..8a7d756d298 100644 --- a/qa/scenarios/agents/instruction-followthrough-repo-contract.md +++ b/qa/scenarios/agents/instruction-followthrough-repo-contract.md @@ -4,6 +4,11 @@ id: instruction-followthrough-repo-contract title: Instruction followthrough repo contract surface: repo-contract +coverage: + primary: + - agents.instructions + secondary: + - runtime.first-action objective: Verify the agent reads repo instruction files first, follows the required tool order, and completes the first feasible action instead of stopping at a plan. successCriteria: - Agent reads the seeded instruction files before writing the requested artifact. diff --git a/qa/scenarios/agents/subagent-fanout-synthesis.md b/qa/scenarios/agents/subagent-fanout-synthesis.md index 60104f44de6..e8932431f01 100644 --- a/qa/scenarios/agents/subagent-fanout-synthesis.md +++ b/qa/scenarios/agents/subagent-fanout-synthesis.md @@ -4,6 +4,11 @@ id: subagent-fanout-synthesis title: Subagent fanout synthesis surface: subagents +coverage: + primary: + - agents.subagents + secondary: + - agents.synthesis objective: Verify the agent can delegate multiple bounded subagent tasks and fold both results back into one parent reply. successCriteria: - Parent flow launches at least two bounded subagent tasks. diff --git a/qa/scenarios/agents/subagent-handoff.md b/qa/scenarios/agents/subagent-handoff.md index 74853aa65d9..328935fbf06 100644 --- a/qa/scenarios/agents/subagent-handoff.md +++ b/qa/scenarios/agents/subagent-handoff.md @@ -4,6 +4,9 @@ id: subagent-handoff title: Subagent handoff surface: subagents +coverage: + primary: + - agents.subagents objective: Verify the agent can delegate a bounded task to a subagent and fold the result back into the main thread. successCriteria: - Agent launches a bounded subagent task. diff --git a/qa/scenarios/channels/channel-chat-baseline.md b/qa/scenarios/channels/channel-chat-baseline.md index 50d4b65b734..2aa90a60641 100644 --- a/qa/scenarios/channels/channel-chat-baseline.md +++ b/qa/scenarios/channels/channel-chat-baseline.md @@ -4,6 +4,11 @@ id: channel-chat-baseline title: Channel baseline conversation surface: channel +coverage: + primary: + - channels.group-messages + secondary: + - channels.qa-channel objective: Verify the QA agent can respond correctly in a shared channel and respect mention-driven group semantics. successCriteria: - Agent replies in the shared channel transcript. diff --git a/qa/scenarios/channels/dm-chat-baseline.md b/qa/scenarios/channels/dm-chat-baseline.md index a38ec8b2066..39d8fd474b9 100644 --- a/qa/scenarios/channels/dm-chat-baseline.md +++ b/qa/scenarios/channels/dm-chat-baseline.md @@ -4,6 +4,11 @@ id: dm-chat-baseline title: DM baseline conversation surface: dm +coverage: + primary: + - channels.dm + secondary: + - channels.qa-channel objective: Verify the QA agent can chat coherently in a DM, explain the QA setup, and stay in character. successCriteria: - Agent replies in DM without channel routing mistakes. diff --git a/qa/scenarios/channels/reaction-edit-delete.md b/qa/scenarios/channels/reaction-edit-delete.md index 9d858bdc9f5..67fa230126f 100644 --- a/qa/scenarios/channels/reaction-edit-delete.md +++ b/qa/scenarios/channels/reaction-edit-delete.md @@ -4,6 +4,11 @@ id: reaction-edit-delete title: Reaction, edit, delete lifecycle surface: message-actions +coverage: + primary: + - channels.message-actions + secondary: + - channels.qa-channel objective: Verify the agent can use channel-owned message actions and that the QA transcript reflects them. successCriteria: - Agent adds at least one reaction. diff --git a/qa/scenarios/channels/thread-follow-up.md b/qa/scenarios/channels/thread-follow-up.md index 0349445179a..1d13db939cf 100644 --- a/qa/scenarios/channels/thread-follow-up.md +++ b/qa/scenarios/channels/thread-follow-up.md @@ -4,6 +4,11 @@ id: thread-follow-up title: Threaded follow-up surface: thread +coverage: + primary: + - channels.threads + secondary: + - channels.qa-channel objective: Verify the agent can keep follow-up work inside a thread and not leak context into the root channel. successCriteria: - Agent creates or uses a thread for deeper work. diff --git a/qa/scenarios/character/character-vibes-c3po.md b/qa/scenarios/character/character-vibes-c3po.md index d708a6aa3a8..c75aee1ccb5 100644 --- a/qa/scenarios/character/character-vibes-c3po.md +++ b/qa/scenarios/character/character-vibes-c3po.md @@ -4,6 +4,11 @@ id: character-vibes-c3po title: "Nervous release protocol chat" surface: character +coverage: + primary: + - character.persona + secondary: + - workspace.artifacts objective: Capture a natural multi-turn C-3PO-flavored character conversation with real workspace help so another model can later grade naturalness, vibe, and funniness from the raw transcript. successCriteria: - Agent gets a natural multi-turn conversation, and any missed replies stay visible in the transcript instead of aborting capture. diff --git a/qa/scenarios/character/character-vibes-gollum.md b/qa/scenarios/character/character-vibes-gollum.md index 0fc0d62b642..e004ad07baf 100644 --- a/qa/scenarios/character/character-vibes-gollum.md +++ b/qa/scenarios/character/character-vibes-gollum.md @@ -4,6 +4,11 @@ id: character-vibes-gollum title: "Late-night deploy helper chat" surface: character +coverage: + primary: + - character.persona + secondary: + - workspace.artifacts objective: Capture a natural multi-turn character conversation with real workspace help so another model can later grade naturalness, vibe, and funniness from the raw transcript. successCriteria: - Agent gets a natural multi-turn conversation, and any missed replies stay visible in the transcript instead of aborting capture. diff --git a/qa/scenarios/config/config-apply-restart-wakeup.md b/qa/scenarios/config/config-apply-restart-wakeup.md index a22e97dd424..981569b679e 100644 --- a/qa/scenarios/config/config-apply-restart-wakeup.md +++ b/qa/scenarios/config/config-apply-restart-wakeup.md @@ -4,6 +4,11 @@ id: config-apply-restart-wakeup title: Config apply restart wake-up surface: config +coverage: + primary: + - config.restart-apply + secondary: + - runtime.gateway-restart objective: Verify a restart-required config.apply restarts cleanly and delivers the post-restart wake message back into the QA channel. successCriteria: - config.apply schedules a restart-required change. diff --git a/qa/scenarios/config/config-patch-hot-apply.md b/qa/scenarios/config/config-patch-hot-apply.md index 218f5f5199c..5569e3424b2 100644 --- a/qa/scenarios/config/config-patch-hot-apply.md +++ b/qa/scenarios/config/config-patch-hot-apply.md @@ -4,6 +4,11 @@ id: config-patch-hot-apply title: Config patch skill disable surface: config +coverage: + primary: + - config.hot-apply + secondary: + - plugins.skills objective: Verify config.patch can disable a workspace skill and the restarted gateway exposes the new disabled state cleanly. successCriteria: - config.patch succeeds for the skill toggle change. diff --git a/qa/scenarios/config/config-restart-capability-flip.md b/qa/scenarios/config/config-restart-capability-flip.md index b25cb5e40b7..0b180b5955a 100644 --- a/qa/scenarios/config/config-restart-capability-flip.md +++ b/qa/scenarios/config/config-restart-capability-flip.md @@ -4,6 +4,11 @@ id: config-restart-capability-flip title: Config restart capability flip surface: config +coverage: + primary: + - config.restart-apply + secondary: + - plugins.capabilities objective: Verify a restart-triggering config change flips capability inventory and the same session successfully uses the newly restored tool after wake-up. successCriteria: - Capability is absent before the restart-triggering patch. diff --git a/qa/scenarios/index.md b/qa/scenarios/index.md index 29ad2d1d6aa..d1d1edd4ef2 100644 --- a/qa/scenarios/index.md +++ b/qa/scenarios/index.md @@ -5,13 +5,24 @@ Single source of truth for repo-backed QA suite bootstrap data. - `index.md` defines pack-level bootstrap data - each nested `*.md` scenario defines one runnable test via `qa-scenario` + `qa-flow` -- scenario markdown may also define category metadata, required plugins, lane filters, - and gateway config patching +- scenario markdown may also define coverage IDs, category metadata, required plugins, + lane filters, and gateway config patching - kickoff mission - QA operator identity - scenario files under one-level theme directories +Coverage tracking: + +- add `coverage.primary` IDs to each scenario's `qa-scenario` block +- add `coverage.secondary` only when a scenario intentionally protects another behavior +- keep IDs behavior-shaped, broad enough to reuse, lowercase, and dotted or dashed +- prefer reusing an existing feature ID over minting a scenario-shaped ID +- avoid copying the scenario title into coverage IDs +- use `pnpm openclaw qa coverage` to render the current inventory +- treat the old `coverage: ["id"]` / `coverage: - id` list shape as invalid +- keep source-path tracking in the report, not in the scenario schema + Theme directories: - `agents/` - agent behavior, instructions, and subagent flows diff --git a/qa/scenarios/media/image-generation-roundtrip.md b/qa/scenarios/media/image-generation-roundtrip.md index 430e0f9dfdb..a3ba5ba6a04 100644 --- a/qa/scenarios/media/image-generation-roundtrip.md +++ b/qa/scenarios/media/image-generation-roundtrip.md @@ -4,6 +4,11 @@ id: image-generation-roundtrip title: Image generation roundtrip surface: image-generation +coverage: + primary: + - media.image-generation + secondary: + - channels.qa-channel objective: Verify a generated image is saved as media, reattached on the next turn, and described correctly through the vision path. successCriteria: - image_generate produces a saved MEDIA artifact. diff --git a/qa/scenarios/media/image-understanding-attachment.md b/qa/scenarios/media/image-understanding-attachment.md index 31801ee207f..c76d39ea588 100644 --- a/qa/scenarios/media/image-understanding-attachment.md +++ b/qa/scenarios/media/image-understanding-attachment.md @@ -4,6 +4,11 @@ id: image-understanding-attachment title: Image understanding from attachment surface: image-understanding +coverage: + primary: + - media.image-understanding + secondary: + - channels.qa-channel objective: Verify an attached image reaches the agent model and the agent can describe what it sees. successCriteria: - Agent receives at least one image attachment. diff --git a/qa/scenarios/media/native-image-generation.md b/qa/scenarios/media/native-image-generation.md index 805c54a7bc4..3a9ab415c9e 100644 --- a/qa/scenarios/media/native-image-generation.md +++ b/qa/scenarios/media/native-image-generation.md @@ -4,6 +4,11 @@ id: native-image-generation title: Native image generation surface: image-generation +coverage: + primary: + - media.image-generation + secondary: + - tools.native-image-generation objective: Verify image_generate appears when configured and returns a real saved media artifact. successCriteria: - image_generate appears in the effective tool inventory. diff --git a/qa/scenarios/memory/active-memory-preprompt-recall.md b/qa/scenarios/memory/active-memory-preprompt-recall.md index 02ca35fbb92..4f9a1c506e0 100644 --- a/qa/scenarios/memory/active-memory-preprompt-recall.md +++ b/qa/scenarios/memory/active-memory-preprompt-recall.md @@ -4,6 +4,11 @@ id: active-memory-preprompt-recall title: Active Memory pre-reply recall surface: memory +coverage: + primary: + - memory.active-recall + secondary: + - memory.recall objective: Verify Active Memory surfaces a memory-only preference before the main reply, and that the same question stays unresolved when the plugin is off. plugins: - active-memory diff --git a/qa/scenarios/memory/memory-dreaming-sweep.md b/qa/scenarios/memory/memory-dreaming-sweep.md index acd01a3c640..38ff22a8408 100644 --- a/qa/scenarios/memory/memory-dreaming-sweep.md +++ b/qa/scenarios/memory/memory-dreaming-sweep.md @@ -4,6 +4,9 @@ id: memory-dreaming-sweep title: Memory dreaming sweep surface: memory +coverage: + primary: + - memory.dreaming objective: Verify enabling dreaming creates the managed sweep, stages light and REM artifacts, and consolidates repeated recall signals into durable memory. successCriteria: - Dreaming can be enabled and doctor.memory.status reports the managed sweep cron. diff --git a/qa/scenarios/memory/memory-failure-fallback.md b/qa/scenarios/memory/memory-failure-fallback.md index ed48187376a..f8ca52ca509 100644 --- a/qa/scenarios/memory/memory-failure-fallback.md +++ b/qa/scenarios/memory/memory-failure-fallback.md @@ -4,6 +4,11 @@ id: memory-failure-fallback title: Memory failure fallback surface: memory +coverage: + primary: + - memory.failure-handling + secondary: + - runtime.fallbacks objective: Verify the agent degrades gracefully when memory tools are unavailable and the answer exists only in memory-backed notes. successCriteria: - Memory tools are absent from the effective tool inventory. diff --git a/qa/scenarios/memory/memory-recall.md b/qa/scenarios/memory/memory-recall.md index 908cbdca72c..bc1657170a8 100644 --- a/qa/scenarios/memory/memory-recall.md +++ b/qa/scenarios/memory/memory-recall.md @@ -35,6 +35,9 @@ id: memory-recall title: Memory recall after context switch surface: memory +coverage: + primary: + - memory.recall objective: Verify the agent can store a fact, switch topics, then recall the fact accurately later. successCriteria: - Agent acknowledges the seeded fact. diff --git a/qa/scenarios/memory/memory-tools-channel-context.md b/qa/scenarios/memory/memory-tools-channel-context.md index a13a1173d27..8e470d4c42e 100644 --- a/qa/scenarios/memory/memory-tools-channel-context.md +++ b/qa/scenarios/memory/memory-tools-channel-context.md @@ -4,6 +4,11 @@ id: memory-tools-channel-context title: Memory tools in channel context surface: memory +coverage: + primary: + - memory.tools + secondary: + - channels.group-messages objective: Verify the agent uses memory_search and memory_get in a shared channel when the answer lives only in memory files, not the live transcript. successCriteria: - Agent uses memory_search before answering. diff --git a/qa/scenarios/memory/session-memory-ranking.md b/qa/scenarios/memory/session-memory-ranking.md index dd153b5e6e6..a17dbcb24fb 100644 --- a/qa/scenarios/memory/session-memory-ranking.md +++ b/qa/scenarios/memory/session-memory-ranking.md @@ -4,6 +4,11 @@ id: session-memory-ranking title: Session memory ranking surface: memory +coverage: + primary: + - memory.ranking + secondary: + - memory.recall objective: Verify session-transcript memory can outrank stale durable notes and drive the final answer toward the newer fact. successCriteria: - Session memory indexing is enabled for the scenario. diff --git a/qa/scenarios/memory/thread-memory-isolation.md b/qa/scenarios/memory/thread-memory-isolation.md index 68d6923e603..49171352151 100644 --- a/qa/scenarios/memory/thread-memory-isolation.md +++ b/qa/scenarios/memory/thread-memory-isolation.md @@ -4,6 +4,11 @@ id: thread-memory-isolation title: Thread memory isolation surface: memory +coverage: + primary: + - memory.thread-isolation + secondary: + - channels.threads objective: Verify a memory-backed answer requested inside a thread stays in-thread and does not leak into the root channel. successCriteria: - Agent uses memory tools inside the thread. diff --git a/qa/scenarios/models/anthropic-opus-api-key-smoke.md b/qa/scenarios/models/anthropic-opus-api-key-smoke.md index b530620e50b..21b1f993171 100644 --- a/qa/scenarios/models/anthropic-opus-api-key-smoke.md +++ b/qa/scenarios/models/anthropic-opus-api-key-smoke.md @@ -4,6 +4,11 @@ id: anthropic-opus-api-key-smoke title: Anthropic Opus API key smoke surface: model-provider +coverage: + primary: + - models.provider-auth + secondary: + - models.anthropic objective: Verify the regular Anthropic Opus lane can complete a quick chat turn using API-key auth. successCriteria: - A live-frontier run fails fast unless the selected primary provider is anthropic. diff --git a/qa/scenarios/models/anthropic-opus-setup-token-smoke.md b/qa/scenarios/models/anthropic-opus-setup-token-smoke.md index df3a2ae6a06..231403d1e7c 100644 --- a/qa/scenarios/models/anthropic-opus-setup-token-smoke.md +++ b/qa/scenarios/models/anthropic-opus-setup-token-smoke.md @@ -4,6 +4,11 @@ id: anthropic-opus-setup-token-smoke title: Anthropic Opus setup-token smoke surface: model-provider +coverage: + primary: + - models.provider-auth + secondary: + - models.anthropic objective: Verify the regular Anthropic Opus lane can complete a quick chat turn using setup-token auth. successCriteria: - A live-frontier run fails fast unless the selected primary provider is anthropic. diff --git a/qa/scenarios/models/claude-cli-provider-capabilities-subscription.md b/qa/scenarios/models/claude-cli-provider-capabilities-subscription.md index 03d97d572fd..32778636aac 100644 --- a/qa/scenarios/models/claude-cli-provider-capabilities-subscription.md +++ b/qa/scenarios/models/claude-cli-provider-capabilities-subscription.md @@ -4,6 +4,11 @@ id: claude-cli-provider-capabilities-subscription title: Claude CLI provider capabilities subscription surface: model-provider +coverage: + primary: + - models.provider-capabilities + secondary: + - models.claude-cli objective: Verify the Claude CLI model-provider lane can use native Claude subscription auth to talk, read an attached image, use bundled MCP tools, and apply workspace skills. successCriteria: - A live-frontier run fails fast unless the selected primary provider is claude-cli. diff --git a/qa/scenarios/models/claude-cli-provider-capabilities.md b/qa/scenarios/models/claude-cli-provider-capabilities.md index 6d7cb123f27..f4b3cff31c7 100644 --- a/qa/scenarios/models/claude-cli-provider-capabilities.md +++ b/qa/scenarios/models/claude-cli-provider-capabilities.md @@ -4,6 +4,11 @@ id: claude-cli-provider-capabilities title: Claude CLI provider capabilities API key surface: model-provider +coverage: + primary: + - models.provider-capabilities + secondary: + - models.claude-cli objective: Verify the Claude CLI model-provider lane can use the Anthropic API key path to talk, read an attached image, use bundled MCP tools, and apply workspace skills. successCriteria: - A live-frontier run fails fast unless the selected primary provider is claude-cli. diff --git a/qa/scenarios/models/codex-harness-no-meta-leak.md b/qa/scenarios/models/codex-harness-no-meta-leak.md index a1ee6606207..1d568b003b0 100644 --- a/qa/scenarios/models/codex-harness-no-meta-leak.md +++ b/qa/scenarios/models/codex-harness-no-meta-leak.md @@ -4,6 +4,11 @@ id: codex-harness-no-meta-leak title: Codex harness no meta leak surface: dm +coverage: + primary: + - models.codex-cli + secondary: + - runtime.no-meta-leak objective: Verify the Codex app-server harness keeps coordination/meta chatter out of the visible reply. successCriteria: - The scenario forces the Codex embedded harness and disables PI fallback. diff --git a/qa/scenarios/models/model-switch-follow-up.md b/qa/scenarios/models/model-switch-follow-up.md index 2744dda4e47..733eff5e4fa 100644 --- a/qa/scenarios/models/model-switch-follow-up.md +++ b/qa/scenarios/models/model-switch-follow-up.md @@ -4,6 +4,11 @@ id: model-switch-follow-up title: Model switch follow-up surface: models +coverage: + primary: + - models.switching + secondary: + - runtime.session-continuity objective: Verify the agent can switch to a different configured model and continue coherently. successCriteria: - Agent reflects the model switch request. diff --git a/qa/scenarios/models/model-switch-tool-continuity.md b/qa/scenarios/models/model-switch-tool-continuity.md index 7e162b2e331..067bae0ec41 100644 --- a/qa/scenarios/models/model-switch-tool-continuity.md +++ b/qa/scenarios/models/model-switch-tool-continuity.md @@ -4,6 +4,11 @@ id: model-switch-tool-continuity title: Model switch with tool continuity surface: models +coverage: + primary: + - models.switching + secondary: + - runtime.tool-continuity objective: Verify switching models preserves session context and tool use instead of dropping into plain-text only behavior. successCriteria: - Alternate model is actually requested. diff --git a/qa/scenarios/plugins/bundled-plugin-skill-runtime.md b/qa/scenarios/plugins/bundled-plugin-skill-runtime.md index 0a959713abc..fdd29e141f5 100644 --- a/qa/scenarios/plugins/bundled-plugin-skill-runtime.md +++ b/qa/scenarios/plugins/bundled-plugin-skill-runtime.md @@ -4,6 +4,11 @@ id: bundled-plugin-skill-runtime title: Bundled plugin skill runtime surface: skills +coverage: + primary: + - plugins.skills + secondary: + - plugins.runtime objective: Verify packaged bundled plugin skills load from dist-runtime instead of being skipped by path-containment checks. successCriteria: - The runtime-packaged bundled plugin tree is used as OPENCLAW_BUNDLED_PLUGINS_DIR. diff --git a/qa/scenarios/plugins/mcp-plugin-tools-call.md b/qa/scenarios/plugins/mcp-plugin-tools-call.md index 04cdc26b79d..20a0f33a4b9 100644 --- a/qa/scenarios/plugins/mcp-plugin-tools-call.md +++ b/qa/scenarios/plugins/mcp-plugin-tools-call.md @@ -4,6 +4,11 @@ id: mcp-plugin-tools-call title: MCP plugin-tools call surface: mcp +coverage: + primary: + - plugins.mcp-tools + secondary: + - tools.invocation objective: Verify OpenClaw can expose plugin tools over MCP and a real MCP client can call one successfully. successCriteria: - Plugin tools MCP server lists memory_search. diff --git a/qa/scenarios/plugins/skill-install-hot-availability.md b/qa/scenarios/plugins/skill-install-hot-availability.md index 751f91e2230..39b669392c9 100644 --- a/qa/scenarios/plugins/skill-install-hot-availability.md +++ b/qa/scenarios/plugins/skill-install-hot-availability.md @@ -4,6 +4,11 @@ id: skill-install-hot-availability title: Skill install hot availability surface: skills +coverage: + primary: + - plugins.skills + secondary: + - plugins.hot-install objective: Verify a newly added workspace skill shows up without a broken intermediate state and can influence the next turn immediately. successCriteria: - Skill is absent before install. diff --git a/qa/scenarios/plugins/skill-visibility-invocation.md b/qa/scenarios/plugins/skill-visibility-invocation.md index 8ae68a2a302..4fc70003a48 100644 --- a/qa/scenarios/plugins/skill-visibility-invocation.md +++ b/qa/scenarios/plugins/skill-visibility-invocation.md @@ -4,6 +4,11 @@ id: skill-visibility-invocation title: Skill visibility and invocation surface: skills +coverage: + primary: + - plugins.skills + secondary: + - tools.invocation objective: Verify a workspace skill becomes visible in skills.status and influences the next agent turn. successCriteria: - skills.status reports the seeded skill as visible and eligible. diff --git a/qa/scenarios/runtime/approval-turn-tool-followthrough.md b/qa/scenarios/runtime/approval-turn-tool-followthrough.md index af2d87a3b47..bc086ca0674 100644 --- a/qa/scenarios/runtime/approval-turn-tool-followthrough.md +++ b/qa/scenarios/runtime/approval-turn-tool-followthrough.md @@ -4,6 +4,11 @@ id: approval-turn-tool-followthrough title: Approval turn tool followthrough surface: harness +coverage: + primary: + - runtime.approvals + secondary: + - tools.followthrough objective: Verify a short approval like "ok do it" triggers immediate tool use instead of fake-progress narration. successCriteria: - Agent can keep the pre-action turn brief. diff --git a/qa/scenarios/runtime/compaction-retry-mutating-tool.md b/qa/scenarios/runtime/compaction-retry-mutating-tool.md index 54c33702acb..c67ad6a53d9 100644 --- a/qa/scenarios/runtime/compaction-retry-mutating-tool.md +++ b/qa/scenarios/runtime/compaction-retry-mutating-tool.md @@ -4,6 +4,11 @@ id: compaction-retry-mutating-tool title: Compaction retry after mutating tool surface: runtime +coverage: + primary: + - runtime.compaction + secondary: + - runtime.retry-policy objective: Verify a real mutating tool step keeps replay-unsafety explicit instead of disappearing into a clean-looking success if the run compacts or retries. successCriteria: - Agent reads the seeded large context before it writes. diff --git a/qa/scenarios/runtime/empty-response-recovery-replay-safe-read.md b/qa/scenarios/runtime/empty-response-recovery-replay-safe-read.md index 0f25b56b5bb..d84107c0e42 100644 --- a/qa/scenarios/runtime/empty-response-recovery-replay-safe-read.md +++ b/qa/scenarios/runtime/empty-response-recovery-replay-safe-read.md @@ -4,6 +4,11 @@ id: empty-response-recovery-replay-safe-read title: Empty-response recovery after replay-safe read surface: runtime +coverage: + primary: + - runtime.empty-response-recovery + secondary: + - runtime.retry-policy objective: Verify an empty visible GPT turn after a replay-safe read auto-continues into a visible answer. successCriteria: - Scenario is mock-openai only so live lanes do not pick it up implicitly. diff --git a/qa/scenarios/runtime/empty-response-retry-budget-exhausted.md b/qa/scenarios/runtime/empty-response-retry-budget-exhausted.md index 1e69b1ef603..51fa187ca83 100644 --- a/qa/scenarios/runtime/empty-response-retry-budget-exhausted.md +++ b/qa/scenarios/runtime/empty-response-retry-budget-exhausted.md @@ -4,6 +4,11 @@ id: empty-response-retry-budget-exhausted title: Empty-response retry budget exhausted surface: runtime +coverage: + primary: + - runtime.empty-response-recovery + secondary: + - runtime.retry-policy objective: Verify repeated empty GPT turns exhaust the retry budget after one continuation attempt. successCriteria: - Scenario is mock-openai only so live lanes do not pick it up implicitly. diff --git a/qa/scenarios/runtime/reasoning-only-no-auto-retry-after-write.md b/qa/scenarios/runtime/reasoning-only-no-auto-retry-after-write.md index 21a15d54457..d98edf5491f 100644 --- a/qa/scenarios/runtime/reasoning-only-no-auto-retry-after-write.md +++ b/qa/scenarios/runtime/reasoning-only-no-auto-retry-after-write.md @@ -4,6 +4,11 @@ id: reasoning-only-no-auto-retry-after-write title: Reasoning-only no-auto-retry after write surface: runtime +coverage: + primary: + - runtime.reasoning-only-recovery + secondary: + - runtime.retry-policy objective: Verify a GPT-style reasoning-only turn after a mutating write stays replay-unsafe and does not auto-retry. successCriteria: - Scenario is mock-openai only so live lanes do not pick it up implicitly. diff --git a/qa/scenarios/runtime/reasoning-only-recovery-replay-safe-read.md b/qa/scenarios/runtime/reasoning-only-recovery-replay-safe-read.md index 95489b00c0f..1696cc6cadb 100644 --- a/qa/scenarios/runtime/reasoning-only-recovery-replay-safe-read.md +++ b/qa/scenarios/runtime/reasoning-only-recovery-replay-safe-read.md @@ -4,6 +4,11 @@ id: reasoning-only-recovery-replay-safe-read title: Reasoning-only recovery after replay-safe read surface: runtime +coverage: + primary: + - runtime.reasoning-only-recovery + secondary: + - runtime.retry-policy objective: Verify a GPT-style reasoning-only turn after a replay-safe read auto-continues into a visible answer. successCriteria: - Scenario is mock-openai only so live lanes do not pick it up implicitly. diff --git a/qa/scenarios/runtime/runtime-inventory-drift-check.md b/qa/scenarios/runtime/runtime-inventory-drift-check.md index 4305aa58482..9d3f978a175 100644 --- a/qa/scenarios/runtime/runtime-inventory-drift-check.md +++ b/qa/scenarios/runtime/runtime-inventory-drift-check.md @@ -4,6 +4,9 @@ id: runtime-inventory-drift-check title: Runtime inventory drift check surface: inventory +coverage: + primary: + - runtime.inventory objective: Verify tools.effective and skills.status stay aligned with runtime behavior after config changes. successCriteria: - Enabled tool appears before the config change. diff --git a/qa/scenarios/scheduling/cron-one-minute-ping.md b/qa/scenarios/scheduling/cron-one-minute-ping.md index 36039659460..2e7b5a464cf 100644 --- a/qa/scenarios/scheduling/cron-one-minute-ping.md +++ b/qa/scenarios/scheduling/cron-one-minute-ping.md @@ -4,6 +4,11 @@ id: cron-one-minute-ping title: Cron one-minute ping surface: cron +coverage: + primary: + - scheduling.cron + secondary: + - channels.qa-channel objective: Verify the agent can schedule a cron reminder one minute in the future and receive the follow-up in the QA channel. successCriteria: - Agent schedules a cron reminder roughly one minute ahead. diff --git a/qa/scenarios/ui/control-ui-qa-channel-image-roundtrip.md b/qa/scenarios/ui/control-ui-qa-channel-image-roundtrip.md index a8cc5f2bdbc..31ac791d7cc 100644 --- a/qa/scenarios/ui/control-ui-qa-channel-image-roundtrip.md +++ b/qa/scenarios/ui/control-ui-qa-channel-image-roundtrip.md @@ -4,6 +4,12 @@ id: control-ui-qa-channel-image-roundtrip title: Control UI plus qa-channel image roundtrip surface: control-ui +coverage: + primary: + - ui.control + secondary: + - media.image-understanding + - channels.qa-channel objective: Verify the embedded Control UI can observe a qa-channel-backed session while the fake channel injects text and image turns that the agent answers correctly. successCriteria: - Control UI opens directly on the target qa-channel session. diff --git a/qa/scenarios/workspace/lobster-invaders-build.md b/qa/scenarios/workspace/lobster-invaders-build.md index d10ac59c2ac..92292f8e013 100644 --- a/qa/scenarios/workspace/lobster-invaders-build.md +++ b/qa/scenarios/workspace/lobster-invaders-build.md @@ -4,6 +4,11 @@ id: lobster-invaders-build title: Build Lobster Invaders surface: workspace +coverage: + primary: + - workspace.artifacts + secondary: + - workspace.builds objective: Verify the agent can read the repo, create a tiny playable artifact, and report what changed. successCriteria: - Agent inspects source before coding. diff --git a/qa/scenarios/workspace/medium-game-plan-codex-harness.md b/qa/scenarios/workspace/medium-game-plan-codex-harness.md index e566f349f45..2e9d0bcb642 100644 --- a/qa/scenarios/workspace/medium-game-plan-codex-harness.md +++ b/qa/scenarios/workspace/medium-game-plan-codex-harness.md @@ -4,6 +4,11 @@ id: medium-game-plan-codex-harness title: Medium game plan Codex harness surface: workspace +coverage: + primary: + - workspace.planning + secondary: + - models.codex-cli objective: Verify the Codex app-server harness can plan and build a medium-complex self-contained browser game. successCriteria: - A live-frontier run fails fast unless the selected primary model is codex/gpt-5.4. diff --git a/qa/scenarios/workspace/medium-game-plan-pi-harness.md b/qa/scenarios/workspace/medium-game-plan-pi-harness.md index e4ce8ea56c1..9c22709285d 100644 --- a/qa/scenarios/workspace/medium-game-plan-pi-harness.md +++ b/qa/scenarios/workspace/medium-game-plan-pi-harness.md @@ -4,6 +4,11 @@ id: medium-game-plan-pi-harness title: Medium game plan PI harness surface: workspace +coverage: + primary: + - workspace.planning + secondary: + - agents.pi-harness objective: Verify GPT-5.4 can use the PI harness to plan and build a medium-complex self-contained browser game. successCriteria: - A live-frontier run fails fast unless the selected primary model is openai/gpt-5.4. diff --git a/qa/scenarios/workspace/source-docs-discovery-report.md b/qa/scenarios/workspace/source-docs-discovery-report.md index 8a4f999478a..e0f52673e99 100644 --- a/qa/scenarios/workspace/source-docs-discovery-report.md +++ b/qa/scenarios/workspace/source-docs-discovery-report.md @@ -4,6 +4,11 @@ id: source-docs-discovery-report title: Source and docs discovery report surface: discovery +coverage: + primary: + - workspace.repo-discovery + secondary: + - docs.discovery objective: Verify the agent can read repo docs and source, expand the QA plan, and publish a worked or did-not-work report. successCriteria: - Agent reads docs and source before proposing more tests. diff --git a/scripts/check-plugin-sdk-exports.mjs b/scripts/check-plugin-sdk-exports.mjs index 88ad794fb4e..fbfbc251251 100755 --- a/scripts/check-plugin-sdk-exports.mjs +++ b/scripts/check-plugin-sdk-exports.mjs @@ -10,7 +10,7 @@ import { readFileSync, existsSync } from "node:fs"; import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { pluginSdkSubpaths } from "./lib/plugin-sdk-entries.mjs"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -42,6 +42,16 @@ const exportedNames = exportMatch[1] const exportSet = new Set(exportedNames); const requiredRuntimeShimEntries = ["compat.js", "root-alias.cjs"]; +const requiredSubpathExports = { + "secret-input-runtime": [ + "coerceSecretRef", + "hasConfiguredSecretInput", + "isSecretRef", + "normalizeResolvedSecretInputString", + "normalizeSecretInputString", + "resolveSecretInputString", + ], +}; // The root plugin-sdk entry intentionally stays tiny. Keep this list aligned // with src/plugin-sdk/index.ts runtime exports. @@ -81,6 +91,28 @@ for (const entry of requiredRuntimeShimEntries) { } } +for (const [entry, names] of Object.entries(requiredSubpathExports)) { + const jsPath = resolve(__dirname, "..", "dist", "plugin-sdk", `${entry}.js`); + if (!existsSync(jsPath)) { + continue; + } + let runtime; + try { + runtime = await import(pathToFileURL(jsPath).href); + } catch (err) { + console.error(`BROKEN SUBPATH JS: dist/plugin-sdk/${entry}.js`); + console.error(err instanceof Error ? err.message : String(err)); + missing += 1; + continue; + } + for (const name of names) { + if (typeof runtime[name] !== "function") { + console.error(`MISSING SUBPATH EXPORT: dist/plugin-sdk/${entry}.js#${name}`); + missing += 1; + } + } +} + if (missing > 0) { console.error( `\nERROR: ${missing} required plugin-sdk artifact(s) missing (named exports or subpath files).`, diff --git a/scripts/lib/extension-test-plan.mjs b/scripts/lib/extension-test-plan.mjs index 9139228ce2d..191ef850627 100644 --- a/scripts/lib/extension-test-plan.mjs +++ b/scripts/lib/extension-test-plan.mjs @@ -21,6 +21,13 @@ import { listAvailableExtensionIds } from "./changed-extensions.mjs"; const repoRoot = path.resolve(import.meta.dirname, "..", ".."); export const DEFAULT_EXTENSION_TEST_SHARD_COUNT = 6; +const EXTENSION_TEST_COST_MULTIPLIERS = { + "test/vitest/vitest.extension-feishu.config.ts": 1.6, + "test/vitest/vitest.extension-msteams.config.ts": 1.6, + // This shared config is comparatively cheap per file, so raw file count + // overstates its real wall-clock cost during CI shard planning. + "test/vitest/vitest.extensions.config.ts": 0.45, +}; function normalizeRelative(inputPath) { return inputPath.split(path.sep).join("/"); @@ -53,6 +60,11 @@ function countTestFiles(rootPath) { return total; } +function estimatePlanCost(config, testFileCount) { + const multiplier = EXTENSION_TEST_COST_MULTIPLIERS[config] ?? 1; + return Math.max(1, Math.ceil(testFileCount * multiplier)); +} + function resolveExtensionDirectory(targetArg, cwd = process.cwd()) { if (targetArg) { const asGiven = path.resolve(cwd, targetArg); @@ -152,9 +164,11 @@ export function resolveExtensionTestPlan(params = {}) { (sum, root) => sum + countTestFiles(path.join(repoRoot, root)), 0, ); + const estimatedCost = estimatePlanCost(config, testFileCount); return { config, + estimatedCost, extensionDir: relativeExtensionDir, extensionId, hasTests: testFileCount > 0, @@ -171,11 +185,13 @@ function mergeTestPlans(plans) { config: plan.config, extensionIds: [], roots: [], + estimatedCost: 0, testFileCount: 0, }; current.extensionIds.push(plan.extensionId); current.roots.push(...plan.roots); + current.estimatedCost += plan.estimatedCost; current.testFileCount += plan.testFileCount; groupsByConfig.set(plan.config, current); } @@ -193,6 +209,7 @@ function mergeTestPlans(plans) { extensionIds: plans .map((plan) => plan.extensionId) .toSorted((left, right) => left.localeCompare(right)), + estimatedCost: plans.reduce((sum, plan) => sum + plan.estimatedCost, 0), hasTests: plans.length > 0, planGroups, testFileCount: plans.reduce((sum, plan) => sum + plan.testFileCount, 0), @@ -215,6 +232,9 @@ function pickLeastLoadedShard(shards) { return index; } const best = shards[bestIndex]; + if (shard.estimatedCost !== best.estimatedCost) { + return shard.estimatedCost < best.estimatedCost ? index : bestIndex; + } if (shard.testFileCount !== best.testFileCount) { return shard.testFileCount < best.testFileCount ? index : bestIndex; } @@ -233,6 +253,9 @@ export function createExtensionTestShards(params = {}) { .map((extensionId) => resolveExtensionTestPlan({ cwd, targetArg: extensionId })) .filter((plan) => plan.hasTests) .toSorted((left, right) => { + if (left.estimatedCost !== right.estimatedCost) { + return right.estimatedCost - left.estimatedCost; + } if (left.testFileCount !== right.testFileCount) { return right.testFileCount - left.testFileCount; } @@ -241,6 +264,7 @@ export function createExtensionTestShards(params = {}) { const effectiveShardCount = Math.min(shardCount, Math.max(1, plans.length)); const shards = Array.from({ length: effectiveShardCount }, () => ({ + estimatedCost: 0, plans: [], testFileCount: 0, })); @@ -248,6 +272,7 @@ export function createExtensionTestShards(params = {}) { for (const plan of plans) { const targetIndex = pickLeastLoadedShard(shards); shards[targetIndex].plans.push(plan); + shards[targetIndex].estimatedCost += plan.estimatedCost; shards[targetIndex].testFileCount += plan.testFileCount; } diff --git a/scripts/lib/live-docker-auth.sh b/scripts/lib/live-docker-auth.sh index 84229150313..63032d0c96a 100644 --- a/scripts/lib/live-docker-auth.sh +++ b/scripts/lib/live-docker-auth.sh @@ -18,12 +18,31 @@ openclaw_live_trim() { printf '%s' "$value" } +openclaw_live_validate_relative_home_path() { + local value + value="$(openclaw_live_trim "${1:-}")" + [[ -n "$value" ]] || { + echo "ERROR: empty auth path." >&2 + return 1 + } + case "$value" in + /* | *..* | *\\* | *:*) + echo "ERROR: invalid auth path '$value'." >&2 + return 1 + ;; + esac + printf '%s' "$value" +} + openclaw_live_normalize_auth_dir() { local value value="$(openclaw_live_trim "${1:-}")" [[ -n "$value" ]] || return 1 - value="${value#.}" - printf '.%s' "$value" + if [[ "$value" != .* ]]; then + value=".$value" + fi + value="$(openclaw_live_validate_relative_home_path "$value")" || return 1 + printf '%s' "$value" } openclaw_live_should_include_auth_dir_for_provider() { @@ -143,3 +162,44 @@ openclaw_live_join_csv() { fi done } + +openclaw_live_stage_auth_into_home() { + local dest_home="${1:?destination home directory required}" + shift + + local mode="dirs" + local relative_path source_path dest_path + + mkdir -p "$dest_home" + chmod u+rwx "$dest_home" || true + + while (($# > 0)); do + case "$1" in + --files) + mode="files" + shift + continue + ;; + esac + + relative_path="$(openclaw_live_validate_relative_home_path "$1")" || return 1 + source_path="$HOME/$relative_path" + dest_path="$dest_home/$relative_path" + + if [[ "$mode" == "dirs" ]]; then + if [[ -d "$source_path" ]]; then + mkdir -p "$dest_path" + cp -R "$source_path"/. "$dest_path" + chmod -R u+rwX "$dest_path" || true + fi + else + if [[ -f "$source_path" ]]; then + mkdir -p "$(dirname "$dest_path")" + cp "$source_path" "$dest_path" + chmod u+rw "$dest_path" || true + fi + fi + + shift + done +} diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index e2f2378acc7..fc22e141391 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -146,6 +146,7 @@ "channel-mention-gating", "channel-lifecycle", "channel-pairing", + "channel-pairing-paths", "channel-policy", "channel-send-result", "channel-targets", @@ -164,6 +165,7 @@ "string-coerce-runtime", "group-access", "global-singleton", + "directory-config-runtime", "directory-runtime", "googlechat", "googlechat-runtime-shared", diff --git a/scripts/prepare-codex-ci-config.ts b/scripts/prepare-codex-ci-config.ts new file mode 100644 index 00000000000..435fcc0bb12 --- /dev/null +++ b/scripts/prepare-codex-ci-config.ts @@ -0,0 +1,51 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +function tomlString(value: string): string { + return JSON.stringify(value); +} + +export function buildCiSafeCodexConfig(params: { + projectPath: string; + approvalPolicy?: string; + sandboxMode?: string; +}): string { + if (!params.projectPath || typeof params.projectPath !== "string") { + throw new Error("projectPath is required."); + } + const resolvedProjectPath = path.resolve(params.projectPath); + const approvalPolicy = params.approvalPolicy ?? "never"; + const sandboxMode = params.sandboxMode ?? "workspace-write"; + return [ + "# Generated for Codex CI runs.", + "# Keep the checked-out repo trusted while avoiding maintainer-local", + "# provider/profile overrides that do not exist on CI runners.", + `approval_policy = ${tomlString(approvalPolicy)}`, + `sandbox_mode = ${tomlString(sandboxMode)}`, + "", + `[projects.${tomlString(resolvedProjectPath)}]`, + 'trust_level = "trusted"', + "", + ].join("\n"); +} + +export async function writeCiSafeCodexConfig(params: { + outputPath: string; + projectPath: string; + approvalPolicy?: string; + sandboxMode?: string; +}): Promise { + if (!params.outputPath || typeof params.outputPath !== "string") { + throw new Error("outputPath is required."); + } + const rendered = buildCiSafeCodexConfig(params); + await fs.mkdir(path.dirname(params.outputPath), { recursive: true }); + await fs.writeFile(params.outputPath, rendered, "utf-8"); + return rendered; +} + +if (path.basename(process.argv[1] ?? "") === "prepare-codex-ci-config.ts") { + const outputPath = process.argv[2]; + const projectPath = process.argv[3] ?? process.cwd(); + await writeCiSafeCodexConfig({ outputPath, projectPath }); +} diff --git a/scripts/stage-bundled-plugin-runtime.mjs b/scripts/stage-bundled-plugin-runtime.mjs index fcf7cb750f5..65531b75fae 100644 --- a/scripts/stage-bundled-plugin-runtime.mjs +++ b/scripts/stage-bundled-plugin-runtime.mjs @@ -68,6 +68,57 @@ function symlinkPath(sourcePath, targetPath, type) { ensureSymlink(relativeSymlinkTarget(sourcePath, targetPath), targetPath, type, sourcePath); } +function writeJsonFile(targetPath, value) { + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function removeStaleOpenClawSelfReference(sourcePluginNodeModulesDir, repoRoot) { + if (!fs.existsSync(sourcePluginNodeModulesDir)) { + return; + } + + const selfReferencePath = path.join(sourcePluginNodeModulesDir, "openclaw"); + try { + const existing = fs.lstatSync(selfReferencePath); + if (!existing.isSymbolicLink()) { + return; + } + if (fs.realpathSync(selfReferencePath) === fs.realpathSync(repoRoot)) { + removePathIfExists(selfReferencePath); + } + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } +} + +function ensureOpenClawExtensionAlias(params) { + const pluginSdkDir = path.join(params.repoRoot, "dist", "plugin-sdk"); + if (!fs.existsSync(pluginSdkDir)) { + return; + } + + const aliasDir = path.join(params.distExtensionsRoot, "node_modules", "openclaw"); + const pluginSdkAliasPath = path.join(aliasDir, "plugin-sdk"); + fs.mkdirSync(aliasDir, { recursive: true }); + writeJsonFile(path.join(aliasDir, "package.json"), { + name: "openclaw", + type: "module", + exports: { + "./plugin-sdk": "./plugin-sdk/index.js", + "./plugin-sdk/*": "./plugin-sdk/*.js", + }, + }); + ensureSymlink( + relativeSymlinkTarget(pluginSdkDir, pluginSdkAliasPath), + pluginSdkAliasPath, + symlinkType(), + pluginSdkDir, + ); +} + function shouldWrapRuntimeJsFile(sourcePath) { return path.extname(sourcePath) === ".js"; } @@ -144,6 +195,7 @@ function linkPluginNodeModules(params) { if (!fs.existsSync(params.sourcePluginNodeModulesDir)) { return; } + removeStaleOpenClawSelfReference(params.sourcePluginNodeModulesDir, params.repoRoot); ensureSymlink( params.sourcePluginNodeModulesDir, runtimeNodeModulesDir, @@ -166,9 +218,10 @@ export function stageBundledPluginRuntime(params = {}) { removePathIfExists(runtimeRoot); fs.mkdirSync(runtimeExtensionsRoot, { recursive: true }); + ensureOpenClawExtensionAlias({ repoRoot, distExtensionsRoot }); for (const dirent of fs.readdirSync(distExtensionsRoot, { withFileTypes: true })) { - if (!dirent.isDirectory()) { + if (!dirent.isDirectory() || dirent.name === "node_modules") { continue; } const distPluginDir = path.join(distExtensionsRoot, dirent.name); @@ -177,6 +230,7 @@ export function stageBundledPluginRuntime(params = {}) { stagePluginRuntimeOverlay(distPluginDir, runtimePluginDir); linkPluginNodeModules({ + repoRoot, runtimePluginDir, sourcePluginNodeModulesDir: distPluginNodeModulesDir, }); diff --git a/scripts/test-install-sh-e2e-docker.sh b/scripts/test-install-sh-e2e-docker.sh index 74dbe88d861..3eb078a6d5e 100755 --- a/scripts/test-install-sh-e2e-docker.sh +++ b/scripts/test-install-sh-e2e-docker.sh @@ -24,7 +24,7 @@ docker run --rm \ -e OPENCLAW_INSTALL_E2E_PREVIOUS="${OPENCLAW_INSTALL_E2E_PREVIOUS:-}" \ -e OPENCLAW_INSTALL_E2E_SKIP_PREVIOUS="${OPENCLAW_INSTALL_E2E_SKIP_PREVIOUS:-0}" \ -e OPENCLAW_NO_ONBOARD=1 \ - -e OPENAI_API_KEY="$OPENAI_API_KEY" \ - -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ - -e ANTHROPIC_API_TOKEN="$ANTHROPIC_API_TOKEN" \ + -e OPENAI_API_KEY \ + -e ANTHROPIC_API_KEY \ + -e ANTHROPIC_API_TOKEN \ "$IMAGE_NAME" diff --git a/scripts/test-live-acp-bind-docker.sh b/scripts/test-live-acp-bind-docker.sh index 2da32d8bbeb..8158cbd8b7b 100644 --- a/scripts/test-live-acp-bind-docker.sh +++ b/scripts/test-live-acp-bind-docker.sh @@ -11,6 +11,8 @@ PROFILE_FILE="${OPENCLAW_PROFILE_FILE:-$HOME/.profile}" ACP_AGENT_LIST_RAW="${OPENCLAW_LIVE_ACP_BIND_AGENTS:-${OPENCLAW_LIVE_ACP_BIND_AGENT:-claude,codex,gemini}}" TEMP_DIRS=() DOCKER_USER="${OPENCLAW_DOCKER_USER:-node}" +DOCKER_HOME_MOUNT=() +DOCKER_AUTH_PRESTAGED=0 openclaw_live_acp_bind_resolve_auth_provider() { case "${1:-}" in @@ -80,27 +82,29 @@ export npm_config_cache="$NPM_CONFIG_CACHE" mkdir -p "$NPM_CONFIG_PREFIX" "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" chmod 700 "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" || true export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" -IFS=',' read -r -a auth_dirs <<<"${OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED:-}" -IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" -if ((${#auth_dirs[@]} > 0)); then - for auth_dir in "${auth_dirs[@]}"; do - [ -n "$auth_dir" ] || continue - if [ -d "/host-auth/$auth_dir" ]; then - mkdir -p "$HOME/$auth_dir" - cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" - chmod -R u+rwX "$HOME/$auth_dir" || true - fi - done -fi -if ((${#auth_files[@]} > 0)); then - for auth_file in "${auth_files[@]}"; do - [ -n "$auth_file" ] || continue - if [ -f "/host-auth-files/$auth_file" ]; then - mkdir -p "$(dirname "$HOME/$auth_file")" - cp "/host-auth-files/$auth_file" "$HOME/$auth_file" - chmod u+rw "$HOME/$auth_file" || true - fi - done +if [ "${OPENCLAW_DOCKER_AUTH_PRESTAGED:-0}" != "1" ]; then + IFS=',' read -r -a auth_dirs <<<"${OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED:-}" + IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" + if ((${#auth_dirs[@]} > 0)); then + for auth_dir in "${auth_dirs[@]}"; do + [ -n "$auth_dir" ] || continue + if [ -d "/host-auth/$auth_dir" ]; then + mkdir -p "$HOME/$auth_dir" + cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" + chmod -R u+rwX "$HOME/$auth_dir" || true + fi + done + fi + if ((${#auth_files[@]} > 0)); then + for auth_file in "${auth_files[@]}"; do + [ -n "$auth_file" ] || continue + if [ -f "/host-auth-files/$auth_file" ]; then + mkdir -p "$(dirname "$HOME/$auth_file")" + cp "/host-auth-files/$auth_file" "$HOME/$auth_file" + chmod u+rw "$HOME/$auth_file" || true + fi + done + fi fi agent="${OPENCLAW_LIVE_ACP_BIND_AGENT:-claude}" case "$agent" in @@ -217,9 +221,23 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do AUTH_FILES_CSV="$(openclaw_live_join_csv "${AUTH_FILES[@]}")" fi + DOCKER_HOME_MOUNT=() + DOCKER_AUTH_PRESTAGED=0 + if [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then + DOCKER_HOME_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-docker-home.XXXXXX")" + TEMP_DIRS+=("$DOCKER_HOME_DIR") + DOCKER_HOME_MOUNT=(-v "$DOCKER_HOME_DIR":/home/node) + fi + + if [[ -n "${DOCKER_HOME_DIR:-}" ]]; then + openclaw_live_stage_auth_into_home "$DOCKER_HOME_DIR" "${AUTH_DIRS[@]}" --files "${AUTH_FILES[@]}" + DOCKER_AUTH_PRESTAGED=1 + fi + EXTERNAL_AUTH_MOUNTS=() if ((${#AUTH_DIRS[@]} > 0)); then for auth_dir in "${AUTH_DIRS[@]}"; do + auth_dir="$(openclaw_live_validate_relative_home_path "$auth_dir")" host_path="$HOME/$auth_dir" if [[ -d "$host_path" ]]; then EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth/"$auth_dir":ro) @@ -228,6 +246,7 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do fi if ((${#AUTH_FILES[@]} > 0)); then for auth_file in "${AUTH_FILES[@]}"; do + auth_file="$(openclaw_live_validate_relative_home_path "$auth_file")" host_path="$HOME/$auth_file" if [[ -f "$host_path" ]]; then EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth-files/"$auth_file":ro) @@ -246,18 +265,22 @@ for ACP_AGENT in "${ACP_AGENTS[@]}"; do -e ANTHROPIC_API_KEY_OLD \ -e OPENCLAW_LIVE_ACP_BIND_ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}" \ -e OPENCLAW_LIVE_ACP_BIND_ANTHROPIC_API_KEY_OLD="${ANTHROPIC_API_KEY_OLD:-}" \ + -e GEMINI_API_KEY \ + -e GOOGLE_API_KEY \ -e OPENAI_API_KEY \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e HOME=/home/node \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ -e OPENCLAW_SKIP_CHANNELS=1 \ -e OPENCLAW_VITEST_FS_MODULE_CACHE=0 \ + -e OPENCLAW_DOCKER_AUTH_PRESTAGED="$DOCKER_AUTH_PRESTAGED" \ -e OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED="$AUTH_DIRS_CSV" \ -e OPENCLAW_DOCKER_AUTH_FILES_RESOLVED="$AUTH_FILES_CSV" \ -e OPENCLAW_LIVE_TEST=1 \ -e OPENCLAW_LIVE_ACP_BIND=1 \ -e OPENCLAW_LIVE_ACP_BIND_AGENT="$ACP_AGENT" \ -e OPENCLAW_LIVE_ACP_BIND_AGENT_COMMAND="$AGENT_COMMAND" \ + "${DOCKER_HOME_MOUNT[@]}" \ -v "$CACHE_HOME_DIR":/home/node/.cache \ -v "$ROOT_DIR":/src:ro \ -v "$CONFIG_DIR":/home/node/.openclaw \ diff --git a/scripts/test-live-cli-backend-docker.sh b/scripts/test-live-cli-backend-docker.sh index 0593b2f6014..1efdb45683a 100644 --- a/scripts/test-live-cli-backend-docker.sh +++ b/scripts/test-live-cli-backend-docker.sh @@ -15,6 +15,9 @@ CLI_DISABLE_MCP_CONFIG="${OPENCLAW_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG:-}" CLI_AUTH_MODE="${OPENCLAW_LIVE_CLI_BACKEND_AUTH:-auto}" TEMP_DIRS=() DOCKER_USER="${OPENCLAW_DOCKER_USER:-node}" +DOCKER_HOME_MOUNT=() +DOCKER_EXTRA_ENV_FILES=() +DOCKER_AUTH_PRESTAGED=0 if [[ -z "$CLI_PROVIDER" || "$CLI_PROVIDER" == "$CLI_MODEL" ]]; then CLI_PROVIDER="$DEFAULT_PROVIDER" @@ -34,6 +37,13 @@ if [[ "$CLI_AUTH_MODE" == "subscription" && "$CLI_PROVIDER" != "claude-cli" ]]; exit 1 fi +if [[ "$CLI_AUTH_MODE" == "api-key" && "$CLI_PROVIDER" == "codex-cli" ]]; then + if [[ -z "${OPENAI_API_KEY:-}" ]]; then + echo "ERROR: OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key for codex-cli requires OPENAI_API_KEY." >&2 + exit 1 + fi +fi + CLI_METADATA_JSON="$(node --import tsx "$ROOT_DIR/scripts/print-cli-backend-live-metadata.ts" "$CLI_PROVIDER")" read_metadata_field() { local field="$1" @@ -84,6 +94,9 @@ mkdir -p "$CLI_TOOLS_DIR" mkdir -p "$CACHE_HOME_DIR" if [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then DOCKER_USER="$(id -u):$(id -g)" + DOCKER_HOME_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-docker-home.XXXXXX")" + TEMP_DIRS+=("$DOCKER_HOME_DIR") + DOCKER_HOME_MOUNT=(-v "$DOCKER_HOME_DIR":/home/node) fi if [[ "$CLI_PROVIDER" == "claude-cli" && "$CLI_AUTH_MODE" == "subscription" ]]; then @@ -143,7 +156,9 @@ fi AUTH_DIRS=() AUTH_FILES=() -if [[ -n "${OPENCLAW_DOCKER_AUTH_DIRS:-}" ]]; then +if [[ "$CLI_AUTH_MODE" == "api-key" && "$CLI_PROVIDER" == "codex-cli" ]]; then + AUTH_FILES+=(".codex/config.toml") +elif [[ -n "${OPENCLAW_DOCKER_AUTH_DIRS:-}" ]]; then while IFS= read -r auth_dir; do [[ -n "$auth_dir" ]] || continue AUTH_DIRS+=("$auth_dir") @@ -171,9 +186,15 @@ if ((${#AUTH_FILES[@]} > 0)); then AUTH_FILES_CSV="$(openclaw_live_join_csv "${AUTH_FILES[@]}")" fi +if [[ -n "${DOCKER_HOME_DIR:-}" ]]; then + openclaw_live_stage_auth_into_home "$DOCKER_HOME_DIR" "${AUTH_DIRS[@]}" --files "${AUTH_FILES[@]}" + DOCKER_AUTH_PRESTAGED=1 +fi + EXTERNAL_AUTH_MOUNTS=() if ((${#AUTH_DIRS[@]} > 0)); then for auth_dir in "${AUTH_DIRS[@]}"; do + auth_dir="$(openclaw_live_validate_relative_home_path "$auth_dir")" host_path="$HOME/$auth_dir" if [[ -d "$host_path" ]]; then EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth/"$auth_dir":ro) @@ -182,6 +203,7 @@ if ((${#AUTH_DIRS[@]} > 0)); then fi if ((${#AUTH_FILES[@]} > 0)); then for auth_file in "${AUTH_FILES[@]}"; do + auth_file="$(openclaw_live_validate_relative_home_path "$auth_file")" host_path="$HOME/$auth_file" if [[ -f "$host_path" ]]; then EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth-files/"$auth_file":ro) @@ -201,27 +223,29 @@ export npm_config_cache="$NPM_CONFIG_CACHE" mkdir -p "$NPM_CONFIG_PREFIX" "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" chmod 700 "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" || true export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" -IFS=',' read -r -a auth_dirs <<<"${OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED:-}" -IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" -if ((${#auth_dirs[@]} > 0)); then - for auth_dir in "${auth_dirs[@]}"; do - [ -n "$auth_dir" ] || continue - if [ -d "/host-auth/$auth_dir" ]; then - mkdir -p "$HOME/$auth_dir" - cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" - chmod -R u+rwX "$HOME/$auth_dir" || true - fi - done -fi -if ((${#auth_files[@]} > 0)); then - for auth_file in "${auth_files[@]}"; do - [ -n "$auth_file" ] || continue - if [ -f "/host-auth-files/$auth_file" ]; then - mkdir -p "$(dirname "$HOME/$auth_file")" - cp "/host-auth-files/$auth_file" "$HOME/$auth_file" - chmod u+rw "$HOME/$auth_file" || true - fi - done +if [ "${OPENCLAW_DOCKER_AUTH_PRESTAGED:-0}" != "1" ]; then + IFS=',' read -r -a auth_dirs <<<"${OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED:-}" + IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" + if ((${#auth_dirs[@]} > 0)); then + for auth_dir in "${auth_dirs[@]}"; do + [ -n "$auth_dir" ] || continue + if [ -d "/host-auth/$auth_dir" ]; then + mkdir -p "$HOME/$auth_dir" + cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" + chmod -R u+rwX "$HOME/$auth_dir" || true + fi + done + fi + if ((${#auth_files[@]} > 0)); then + for auth_file in "${auth_files[@]}"; do + [ -n "$auth_file" ] || continue + if [ -f "/host-auth-files/$auth_file" ]; then + mkdir -p "$(dirname "$HOME/$auth_file")" + cp "/host-auth-files/$auth_file" "$HOME/$auth_file" + chmod u+rw "$HOME/$auth_file" || true + fi + done + fi fi provider="${OPENCLAW_DOCKER_CLI_BACKEND_PROVIDER:-claude-cli}" default_command="${OPENCLAW_DOCKER_CLI_BACKEND_COMMAND_DEFAULT:-}" @@ -236,6 +260,17 @@ fi if [ -n "${OPENCLAW_LIVE_CLI_BACKEND_COMMAND:-}" ] && [ ! -x "${OPENCLAW_LIVE_CLI_BACKEND_COMMAND}" ] && [ -n "$docker_package" ]; then npm install -g "$docker_package" fi +if [ "$provider" = "codex-cli" ] && [ "${OPENCLAW_LIVE_CLI_BACKEND_AUTH:-auto}" = "api-key" ]; then + codex_login_command="${OPENCLAW_LIVE_CLI_BACKEND_COMMAND:-$NPM_CONFIG_PREFIX/bin/codex}" + if [ ! -x "$codex_login_command" ] && [ -x "$NPM_CONFIG_PREFIX/bin/codex" ]; then + codex_login_command="$NPM_CONFIG_PREFIX/bin/codex" + fi + printf '%s\n' "$OPENAI_API_KEY" | "$codex_login_command" login --with-api-key >/dev/null +fi +if [ -n "${OPENCLAW_LIVE_CLI_BACKEND_COMMAND:-}" ] && [ -x "${OPENCLAW_LIVE_CLI_BACKEND_COMMAND}" ]; then + echo "==> CLI backend binary: ${OPENCLAW_LIVE_CLI_BACKEND_COMMAND}" + "${OPENCLAW_LIVE_CLI_BACKEND_COMMAND}" -V || "${OPENCLAW_LIVE_CLI_BACKEND_COMMAND}" --version || true +fi if [ "$provider" = "claude-cli" ]; then auth_mode="${OPENCLAW_LIVE_CLI_BACKEND_AUTH:-auto}" if [ "$auth_mode" = "subscription" ]; then @@ -324,6 +359,9 @@ openclaw_live_link_runtime_tree "$tmp_dir" openclaw_live_stage_state_dir "$tmp_dir/.openclaw-state" openclaw_live_prepare_staged_config cd "$tmp_dir" +if [ "${OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG:-0}" = "1" ]; then + node --import tsx /src/scripts/prepare-codex-ci-config.ts "$HOME/.codex/config.toml" "$tmp_dir" +fi pnpm test:live src/gateway/gateway-cli-backend.live.test.ts EOF @@ -346,7 +384,18 @@ echo "==> External auth files: ${AUTH_FILES_CSV:-none}" DOCKER_AUTH_ENV=( -e OPENCLAW_LIVE_CLI_BACKEND_AUTH="$CLI_AUTH_MODE" ) -if [[ "$CLI_PROVIDER" == "claude-cli" && "$CLI_AUTH_MODE" == "subscription" ]]; then +if [[ "$CLI_PROVIDER" == "codex-cli" && "$CLI_AUTH_MODE" == "api-key" ]]; then + docker_env_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-cli-backend-env.XXXXXX")" + TEMP_DIRS+=("$docker_env_dir") + docker_env_file="$docker_env_dir/openai.env" + { + printf 'OPENAI_API_KEY=%s\n' "${OPENAI_API_KEY}" + if [[ -n "${OPENAI_BASE_URL:-}" ]]; then + printf 'OPENAI_BASE_URL=%s\n' "${OPENAI_BASE_URL}" + fi + } >"$docker_env_file" + DOCKER_EXTRA_ENV_FILES+=(--env-file "$docker_env_file") +elif [[ "$CLI_PROVIDER" == "claude-cli" && "$CLI_AUTH_MODE" == "subscription" ]]; then DOCKER_AUTH_ENV+=( -e CLAUDE_CODE_OAUTH_TOKEN="${CLAUDE_CODE_OAUTH_TOKEN:-}" -e OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV="$OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV" @@ -369,8 +418,10 @@ docker run --rm -t \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ -e OPENCLAW_SKIP_CHANNELS=1 \ -e OPENCLAW_VITEST_FS_MODULE_CACHE=0 \ + -e OPENCLAW_DOCKER_AUTH_PRESTAGED="$DOCKER_AUTH_PRESTAGED" \ -e OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED="$AUTH_DIRS_CSV" \ -e OPENCLAW_DOCKER_AUTH_FILES_RESOLVED="$AUTH_FILES_CSV" \ + -e OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG="${OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG:-0}" \ -e OPENCLAW_DOCKER_CLI_BACKEND_PROVIDER="$CLI_PROVIDER" \ -e OPENCLAW_DOCKER_CLI_BACKEND_COMMAND_DEFAULT="$CLI_DEFAULT_COMMAND" \ -e OPENCLAW_DOCKER_CLI_BACKEND_NPM_PACKAGE="$CLI_DOCKER_NPM_PACKAGE" \ @@ -382,6 +433,7 @@ docker run --rm -t \ -e OPENCLAW_LIVE_CLI_BACKEND_MODEL="$CLI_MODEL" \ -e OPENCLAW_LIVE_CLI_BACKEND_COMMAND="${OPENCLAW_LIVE_CLI_BACKEND_COMMAND:-}" \ -e OPENCLAW_LIVE_CLI_BACKEND_ARGS="${OPENCLAW_LIVE_CLI_BACKEND_ARGS:-}" \ + -e OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS="${OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS:-}" \ -e OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV="${OPENCLAW_LIVE_CLI_BACKEND_CLEAR_ENV:-}" \ -e OPENCLAW_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG="$CLI_DISABLE_MCP_CONFIG" \ -e OPENCLAW_LIVE_CLI_BACKEND_RESUME_PROBE="${OPENCLAW_LIVE_CLI_BACKEND_RESUME_PROBE:-}" \ @@ -390,6 +442,8 @@ docker run --rm -t \ -e OPENCLAW_LIVE_CLI_BACKEND_MCP_PROBE="${OPENCLAW_LIVE_CLI_BACKEND_MCP_PROBE:-}" \ -e OPENCLAW_LIVE_CLI_BACKEND_IMAGE_ARG="${OPENCLAW_LIVE_CLI_BACKEND_IMAGE_ARG:-}" \ -e OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE="${OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE:-}" \ + "${DOCKER_HOME_MOUNT[@]}" \ + "${DOCKER_EXTRA_ENV_FILES[@]}" \ -v "$CACHE_HOME_DIR":/home/node/.cache \ -v "$ROOT_DIR":/src:ro \ -v "$CONFIG_DIR":/home/node/.openclaw \ diff --git a/scripts/test-live-codex-harness-docker.sh b/scripts/test-live-codex-harness-docker.sh index 85c3b9ea9d2..137ec2ced3c 100644 --- a/scripts/test-live-codex-harness-docker.sh +++ b/scripts/test-live-codex-harness-docker.sh @@ -8,8 +8,26 @@ LIVE_IMAGE_NAME="${OPENCLAW_LIVE_IMAGE:-${IMAGE_NAME}-live}" CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-$HOME/.openclaw}" WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}" PROFILE_FILE="${OPENCLAW_PROFILE_FILE:-$HOME/.profile}" +CODEX_HARNESS_AUTH_MODE="${OPENCLAW_LIVE_CODEX_HARNESS_AUTH:-codex-auth}" TEMP_DIRS=() DOCKER_USER="${OPENCLAW_DOCKER_USER:-node}" +DOCKER_HOME_MOUNT=() +DOCKER_EXTRA_ENV_FILES=() +DOCKER_AUTH_PRESTAGED=0 + +case "$CODEX_HARNESS_AUTH_MODE" in + codex-auth | api-key) + ;; + *) + echo "ERROR: OPENCLAW_LIVE_CODEX_HARNESS_AUTH must be one of: codex-auth, api-key." >&2 + exit 1 + ;; +esac + +if [[ "$CODEX_HARNESS_AUTH_MODE" == "api-key" && -z "${OPENAI_API_KEY:-}" ]]; then + echo "ERROR: OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key requires OPENAI_API_KEY." >&2 + exit 1 +fi cleanup_temp_dirs() { if ((${#TEMP_DIRS[@]} > 0)); then @@ -39,6 +57,9 @@ mkdir -p "$CLI_TOOLS_DIR" mkdir -p "$CACHE_HOME_DIR" if [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then DOCKER_USER="$(id -u):$(id -g)" + DOCKER_HOME_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-docker-home.XXXXXX")" + TEMP_DIRS+=("$DOCKER_HOME_DIR") + DOCKER_HOME_MOUNT=(-v "$DOCKER_HOME_DIR":/home/node) fi PROFILE_MOUNT=() @@ -47,19 +68,27 @@ if [[ -f "$PROFILE_FILE" && -r "$PROFILE_FILE" ]]; then fi AUTH_FILES=() -while IFS= read -r auth_file; do - [[ -n "$auth_file" ]] || continue - AUTH_FILES+=("$auth_file") -done < <(openclaw_live_collect_auth_files_from_csv "openai-codex") +if [[ "$CODEX_HARNESS_AUTH_MODE" != "api-key" ]]; then + while IFS= read -r auth_file; do + [[ -n "$auth_file" ]] || continue + AUTH_FILES+=("$auth_file") + done < <(openclaw_live_collect_auth_files_from_csv "openai-codex") +fi AUTH_FILES_CSV="" if ((${#AUTH_FILES[@]} > 0)); then AUTH_FILES_CSV="$(openclaw_live_join_csv "${AUTH_FILES[@]}")" fi +if [[ -n "${DOCKER_HOME_DIR:-}" ]]; then + openclaw_live_stage_auth_into_home "$DOCKER_HOME_DIR" --files "${AUTH_FILES[@]}" + DOCKER_AUTH_PRESTAGED=1 +fi + EXTERNAL_AUTH_MOUNTS=() if ((${#AUTH_FILES[@]} > 0)); then for auth_file in "${AUTH_FILES[@]}"; do + auth_file="$(openclaw_live_validate_relative_home_path "$auth_file")" host_path="$HOME/$auth_file" if [[ -f "$host_path" ]]; then EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth-files/"$auth_file":ro) @@ -67,6 +96,20 @@ if ((${#AUTH_FILES[@]} > 0)); then done fi +DOCKER_AUTH_ENV=() +if [[ "$CODEX_HARNESS_AUTH_MODE" == "api-key" ]]; then + docker_env_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-codex-harness-env.XXXXXX")" + TEMP_DIRS+=("$docker_env_dir") + docker_env_file="$docker_env_dir/openai.env" + { + printf 'OPENAI_API_KEY=%s\n' "${OPENAI_API_KEY}" + if [[ -n "${OPENAI_BASE_URL:-}" ]]; then + printf 'OPENAI_BASE_URL=%s\n' "${OPENAI_BASE_URL}" + fi + } >"$docker_env_file" + DOCKER_EXTRA_ENV_FILES+=(--env-file "$docker_env_file") +fi + read -r -d '' LIVE_TEST_CMD <<'EOF' || true set -euo pipefail [ -f "$HOME/.profile" ] && [ -r "$HOME/.profile" ] && source "$HOME/.profile" || true @@ -76,23 +119,38 @@ export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}" export COREPACK_HOME="${COREPACK_HOME:-$XDG_CACHE_HOME/node/corepack}" export NPM_CONFIG_CACHE="${NPM_CONFIG_CACHE:-$XDG_CACHE_HOME/npm}" export npm_config_cache="$NPM_CONFIG_CACHE" +# Force the Codex harness to use the staged `~/.codex` auth files. This lane +# is not meant to exercise raw OpenAI API-key routing unless the lane +# explicitly opts into API-key auth for CI. +if [ "${OPENCLAW_LIVE_CODEX_HARNESS_AUTH:-codex-auth}" != "api-key" ]; then + unset OPENAI_API_KEY OPENAI_BASE_URL +fi mkdir -p "$NPM_CONFIG_PREFIX" "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" chmod 700 "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" || true export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" -IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" -if ((${#auth_files[@]} > 0)); then - for auth_file in "${auth_files[@]}"; do - [ -n "$auth_file" ] || continue - if [ -f "/host-auth-files/$auth_file" ]; then - mkdir -p "$(dirname "$HOME/$auth_file")" - cp "/host-auth-files/$auth_file" "$HOME/$auth_file" - chmod u+rw "$HOME/$auth_file" || true - fi - done +if [ "${OPENCLAW_DOCKER_AUTH_PRESTAGED:-0}" != "1" ]; then + IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" + if ((${#auth_files[@]} > 0)); then + for auth_file in "${auth_files[@]}"; do + [ -n "$auth_file" ] || continue + if [ -f "/host-auth-files/$auth_file" ]; then + mkdir -p "$(dirname "$HOME/$auth_file")" + cp "/host-auth-files/$auth_file" "$HOME/$auth_file" + chmod u+rw "$HOME/$auth_file" || true + fi + done + fi +fi +if [ "${OPENCLAW_LIVE_CODEX_HARNESS_AUTH:-codex-auth}" != "api-key" ] && [ ! -s "$HOME/.codex/auth.json" ]; then + echo "ERROR: missing ~/.codex/auth.json for Codex harness live test." >&2 + exit 1 fi if [ ! -x "$NPM_CONFIG_PREFIX/bin/codex" ]; then npm install -g @openai/codex fi +if [ "${OPENCLAW_LIVE_CODEX_HARNESS_AUTH:-codex-auth}" = "api-key" ]; then + printf '%s\n' "$OPENAI_API_KEY" | "$NPM_CONFIG_PREFIX/bin/codex" login --with-api-key >/dev/null +fi tmp_dir="$(mktemp -d)" cleanup() { rm -rf "$tmp_dir" @@ -117,6 +175,7 @@ echo "==> Run Codex harness live test in Docker" echo "==> Model: ${OPENCLAW_LIVE_CODEX_HARNESS_MODEL:-codex/gpt-5.4}" echo "==> Image probe: ${OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE:-1}" echo "==> MCP probe: ${OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE:-1}" +echo "==> Auth mode: $CODEX_HARNESS_AUTH_MODE" echo "==> Harness fallback: none" echo "==> Auth files: ${AUTH_FILES_CSV:-none}" docker run --rm -t \ @@ -125,10 +184,11 @@ docker run --rm -t \ -e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ -e HOME=/home/node \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ - -e OPENAI_API_KEY \ -e OPENCLAW_AGENT_HARNESS_FALLBACK=none \ + -e OPENCLAW_DOCKER_AUTH_PRESTAGED="$DOCKER_AUTH_PRESTAGED" \ -e OPENCLAW_CODEX_APP_SERVER_BIN="${OPENCLAW_CODEX_APP_SERVER_BIN:-codex}" \ -e OPENCLAW_DOCKER_AUTH_FILES_RESOLVED="$AUTH_FILES_CSV" \ + -e OPENCLAW_LIVE_CODEX_HARNESS_AUTH="$CODEX_HARNESS_AUTH_MODE" \ -e OPENCLAW_LIVE_CODEX_HARNESS=1 \ -e OPENCLAW_LIVE_CODEX_HARNESS_DEBUG="${OPENCLAW_LIVE_CODEX_HARNESS_DEBUG:-}" \ -e OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE="${OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE:-1}" \ @@ -136,6 +196,9 @@ docker run --rm -t \ -e OPENCLAW_LIVE_CODEX_HARNESS_MODEL="${OPENCLAW_LIVE_CODEX_HARNESS_MODEL:-codex/gpt-5.4}" \ -e OPENCLAW_LIVE_TEST=1 \ -e OPENCLAW_VITEST_FS_MODULE_CACHE=0 \ + "${DOCKER_AUTH_ENV[@]}" \ + "${DOCKER_EXTRA_ENV_FILES[@]}" \ + "${DOCKER_HOME_MOUNT[@]}" \ -v "$CACHE_HOME_DIR":/home/node/.cache \ -v "$ROOT_DIR":/src:ro \ -v "$CONFIG_DIR":/home/node/.openclaw \ diff --git a/scripts/test-live-gateway-models-docker.sh b/scripts/test-live-gateway-models-docker.sh index 6a68211c34b..bd114d3df3f 100755 --- a/scripts/test-live-gateway-models-docker.sh +++ b/scripts/test-live-gateway-models-docker.sh @@ -10,6 +10,8 @@ WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-$HOME/.openclaw/workspace}" PROFILE_FILE="${OPENCLAW_PROFILE_FILE:-$HOME/.profile}" DOCKER_USER="${OPENCLAW_DOCKER_USER:-node}" TEMP_DIRS=() +DOCKER_HOME_MOUNT=() +DOCKER_AUTH_PRESTAGED=0 cleanup_temp_dirs() { if ((${#TEMP_DIRS[@]} > 0)); then rm -rf "${TEMP_DIRS[@]}" @@ -27,6 +29,9 @@ fi mkdir -p "$CACHE_HOME_DIR" if [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then DOCKER_USER="$(id -u):$(id -g)" + DOCKER_HOME_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-docker-home.XXXXXX")" + TEMP_DIRS+=("$DOCKER_HOME_DIR") + DOCKER_HOME_MOUNT=(-v "$DOCKER_HOME_DIR":/home/node) fi PROFILE_MOUNT=() @@ -73,9 +78,15 @@ if ((${#AUTH_FILES[@]} > 0)); then AUTH_FILES_CSV="$(openclaw_live_join_csv "${AUTH_FILES[@]}")" fi +if [[ -n "${DOCKER_HOME_DIR:-}" ]]; then + openclaw_live_stage_auth_into_home "$DOCKER_HOME_DIR" "${AUTH_DIRS[@]}" --files "${AUTH_FILES[@]}" + DOCKER_AUTH_PRESTAGED=1 +fi + EXTERNAL_AUTH_MOUNTS=() if ((${#AUTH_DIRS[@]} > 0)); then for auth_dir in "${AUTH_DIRS[@]}"; do + auth_dir="$(openclaw_live_validate_relative_home_path "$auth_dir")" host_path="$HOME/$auth_dir" if [[ -d "$host_path" ]]; then EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth/"$auth_dir":ro) @@ -84,6 +95,7 @@ if ((${#AUTH_DIRS[@]} > 0)); then fi if ((${#AUTH_FILES[@]} > 0)); then for auth_file in "${AUTH_FILES[@]}"; do + auth_file="$(openclaw_live_validate_relative_home_path "$auth_file")" host_path="$HOME/$auth_file" if [[ -f "$host_path" ]]; then EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth-files/"$auth_file":ro) @@ -100,27 +112,29 @@ export NPM_CONFIG_CACHE="${NPM_CONFIG_CACHE:-$XDG_CACHE_HOME/npm}" export npm_config_cache="$NPM_CONFIG_CACHE" mkdir -p "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" chmod 700 "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" || true -IFS=',' read -r -a auth_dirs <<<"${OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED:-}" -IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" -if ((${#auth_dirs[@]} > 0)); then - for auth_dir in "${auth_dirs[@]}"; do - [ -n "$auth_dir" ] || continue - if [ -d "/host-auth/$auth_dir" ]; then - mkdir -p "$HOME/$auth_dir" - cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" - chmod -R u+rwX "$HOME/$auth_dir" || true - fi - done -fi -if ((${#auth_files[@]} > 0)); then - for auth_file in "${auth_files[@]}"; do - [ -n "$auth_file" ] || continue - if [ -f "/host-auth-files/$auth_file" ]; then - mkdir -p "$(dirname "$HOME/$auth_file")" - cp "/host-auth-files/$auth_file" "$HOME/$auth_file" - chmod u+rw "$HOME/$auth_file" || true - fi - done +if [ "${OPENCLAW_DOCKER_AUTH_PRESTAGED:-0}" != "1" ]; then + IFS=',' read -r -a auth_dirs <<<"${OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED:-}" + IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" + if ((${#auth_dirs[@]} > 0)); then + for auth_dir in "${auth_dirs[@]}"; do + [ -n "$auth_dir" ] || continue + if [ -d "/host-auth/$auth_dir" ]; then + mkdir -p "$HOME/$auth_dir" + cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" + chmod -R u+rwX "$HOME/$auth_dir" || true + fi + done + fi + if ((${#auth_files[@]} > 0)); then + for auth_file in "${auth_files[@]}"; do + [ -n "$auth_file" ] || continue + if [ -f "/host-auth-files/$auth_file" ]; then + mkdir -p "$(dirname "$HOME/$auth_file")" + cp "/host-auth-files/$auth_file" "$HOME/$auth_file" + chmod u+rw "$HOME/$auth_file" || true + fi + done + fi fi tmp_dir="$(mktemp -d)" cleanup() { @@ -154,6 +168,7 @@ docker run --rm -t \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ -e OPENCLAW_SKIP_CHANNELS=1 \ -e OPENCLAW_SUPPRESS_NOTES=1 \ + -e OPENCLAW_DOCKER_AUTH_PRESTAGED="$DOCKER_AUTH_PRESTAGED" \ -e OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED="$AUTH_DIRS_CSV" \ -e OPENCLAW_DOCKER_AUTH_FILES_RESOLVED="$AUTH_FILES_CSV" \ -e OPENCLAW_LIVE_TEST=1 \ @@ -163,6 +178,7 @@ docker run --rm -t \ -e OPENCLAW_LIVE_GATEWAY_MAX_MODELS="${OPENCLAW_LIVE_GATEWAY_MAX_MODELS:-8}" \ -e OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS="${OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS:-45000}" \ -e OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS="${OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS:-90000}" \ + "${DOCKER_HOME_MOUNT[@]}" \ -v "$CACHE_HOME_DIR":/home/node/.cache \ -v "$ROOT_DIR":/src:ro \ -v "$CONFIG_DIR":/home/node/.openclaw \ diff --git a/scripts/test-live-models-docker.sh b/scripts/test-live-models-docker.sh index a7f2e260b02..24f8440e021 100755 --- a/scripts/test-live-models-docker.sh +++ b/scripts/test-live-models-docker.sh @@ -7,6 +7,7 @@ IMAGE_NAME="${OPENCLAW_IMAGE:-openclaw:local}" LIVE_IMAGE_NAME="${OPENCLAW_LIVE_IMAGE:-${IMAGE_NAME}-live}" PROFILE_FILE="${OPENCLAW_PROFILE_FILE:-$HOME/.profile}" DOCKER_USER="${OPENCLAW_DOCKER_USER:-node}" +DOCKER_AUTH_PRESTAGED=0 openclaw_live_truthy() { case "${1:-}" in @@ -20,6 +21,7 @@ openclaw_live_truthy() { } TEMP_DIRS=() +DOCKER_HOME_MOUNT=() cleanup_temp_dirs() { if ((${#TEMP_DIRS[@]} > 0)); then rm -rf "${TEMP_DIRS[@]}" @@ -47,6 +49,9 @@ fi mkdir -p "$CACHE_HOME_DIR" if [[ "${CI:-}" == "true" || "${GITHUB_ACTIONS:-}" == "true" ]]; then DOCKER_USER="$(id -u):$(id -g)" + DOCKER_HOME_DIR="$(mktemp -d "${RUNNER_TEMP:-/tmp}/openclaw-docker-home.XXXXXX")" + TEMP_DIRS+=("$DOCKER_HOME_DIR") + DOCKER_HOME_MOUNT=(-v "$DOCKER_HOME_DIR":/home/node) fi PROFILE_MOUNT=() @@ -103,9 +108,15 @@ if ((${#AUTH_FILES[@]} > 0)); then AUTH_FILES_CSV="$(openclaw_live_join_csv "${AUTH_FILES[@]}")" fi +if [[ -n "${DOCKER_HOME_DIR:-}" ]]; then + openclaw_live_stage_auth_into_home "$DOCKER_HOME_DIR" "${AUTH_DIRS[@]}" --files "${AUTH_FILES[@]}" + DOCKER_AUTH_PRESTAGED=1 +fi + EXTERNAL_AUTH_MOUNTS=() if ((${#AUTH_DIRS[@]} > 0)); then for auth_dir in "${AUTH_DIRS[@]}"; do + auth_dir="$(openclaw_live_validate_relative_home_path "$auth_dir")" host_path="$HOME/$auth_dir" if [[ -d "$host_path" ]]; then EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth/"$auth_dir":ro) @@ -114,6 +125,7 @@ if ((${#AUTH_DIRS[@]} > 0)); then fi if ((${#AUTH_FILES[@]} > 0)); then for auth_file in "${AUTH_FILES[@]}"; do + auth_file="$(openclaw_live_validate_relative_home_path "$auth_file")" host_path="$HOME/$auth_file" if [[ -f "$host_path" ]]; then EXTERNAL_AUTH_MOUNTS+=(-v "$host_path":/host-auth-files/"$auth_file":ro) @@ -130,27 +142,29 @@ export NPM_CONFIG_CACHE="${NPM_CONFIG_CACHE:-$XDG_CACHE_HOME/npm}" export npm_config_cache="$NPM_CONFIG_CACHE" mkdir -p "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" chmod 700 "$XDG_CACHE_HOME" "$COREPACK_HOME" "$NPM_CONFIG_CACHE" || true -IFS=',' read -r -a auth_dirs <<<"${OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED:-}" -IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" -if ((${#auth_dirs[@]} > 0)); then - for auth_dir in "${auth_dirs[@]}"; do - [ -n "$auth_dir" ] || continue - if [ -d "/host-auth/$auth_dir" ]; then - mkdir -p "$HOME/$auth_dir" - cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" - chmod -R u+rwX "$HOME/$auth_dir" || true - fi - done -fi -if ((${#auth_files[@]} > 0)); then - for auth_file in "${auth_files[@]}"; do - [ -n "$auth_file" ] || continue - if [ -f "/host-auth-files/$auth_file" ]; then - mkdir -p "$(dirname "$HOME/$auth_file")" - cp "/host-auth-files/$auth_file" "$HOME/$auth_file" - chmod u+rw "$HOME/$auth_file" || true - fi - done +if [ "${OPENCLAW_DOCKER_AUTH_PRESTAGED:-0}" != "1" ]; then + IFS=',' read -r -a auth_dirs <<<"${OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED:-}" + IFS=',' read -r -a auth_files <<<"${OPENCLAW_DOCKER_AUTH_FILES_RESOLVED:-}" + if ((${#auth_dirs[@]} > 0)); then + for auth_dir in "${auth_dirs[@]}"; do + [ -n "$auth_dir" ] || continue + if [ -d "/host-auth/$auth_dir" ]; then + mkdir -p "$HOME/$auth_dir" + cp -R "/host-auth/$auth_dir/." "$HOME/$auth_dir" + chmod -R u+rwX "$HOME/$auth_dir" || true + fi + done + fi + if ((${#auth_files[@]} > 0)); then + for auth_file in "${auth_files[@]}"; do + [ -n "$auth_file" ] || continue + if [ -f "/host-auth-files/$auth_file" ]; then + mkdir -p "$(dirname "$HOME/$auth_file")" + cp "/host-auth-files/$auth_file" "$HOME/$auth_file" + chmod u+rw "$HOME/$auth_file" || true + fi + done + fi fi tmp_dir="$(mktemp -d)" cleanup() { @@ -185,6 +199,7 @@ docker run --rm -t \ -e NODE_OPTIONS=--disable-warning=ExperimentalWarning \ -e OPENCLAW_SKIP_CHANNELS=1 \ -e OPENCLAW_SUPPRESS_NOTES=1 \ + -e OPENCLAW_DOCKER_AUTH_PRESTAGED="$DOCKER_AUTH_PRESTAGED" \ -e OPENCLAW_DOCKER_AUTH_DIRS_RESOLVED="$AUTH_DIRS_CSV" \ -e OPENCLAW_DOCKER_AUTH_FILES_RESOLVED="$AUTH_FILES_CSV" \ -e OPENCLAW_LIVE_TEST=1 \ @@ -196,6 +211,7 @@ docker run --rm -t \ -e OPENCLAW_LIVE_GATEWAY_MODELS="${OPENCLAW_LIVE_GATEWAY_MODELS:-}" \ -e OPENCLAW_LIVE_GATEWAY_PROVIDERS="${OPENCLAW_LIVE_GATEWAY_PROVIDERS:-}" \ -e OPENCLAW_LIVE_GATEWAY_MAX_MODELS="${OPENCLAW_LIVE_GATEWAY_MAX_MODELS:-}" \ + "${DOCKER_HOME_MOUNT[@]}" \ -v "$CACHE_HOME_DIR":/home/node/.cache \ -v "$ROOT_DIR":/src:ro \ -v "$CONFIG_DIR":/home/node/.openclaw \ diff --git a/scripts/write-plugin-sdk-entry-dts.ts b/scripts/write-plugin-sdk-entry-dts.ts index b225ea73f19..2f3186898e8 100644 --- a/scripts/write-plugin-sdk-entry-dts.ts +++ b/scripts/write-plugin-sdk-entry-dts.ts @@ -3,14 +3,6 @@ import path from "node:path"; import { pluginSdkEntrypoints } from "./lib/plugin-sdk-entries.mjs"; const RUNTIME_SHIMS: Partial> = { - "secret-input-runtime": [ - "export {", - " hasConfiguredSecretInput,", - " normalizeResolvedSecretInputString,", - " normalizeSecretInputString,", - '} from "./config-runtime.js";', - "", - ].join("\n"), "webhook-path": [ "/** Normalize webhook paths into the canonical registry form used by route lookup. */", "export function normalizeWebhookPath(raw) {", @@ -45,17 +37,6 @@ const RUNTIME_SHIMS: Partial> = { ].join("\n"), }; -const TYPE_SHIMS: Partial> = { - "secret-input-runtime": [ - "export {", - " hasConfiguredSecretInput,", - " normalizeResolvedSecretInputString,", - " normalizeSecretInputString,", - '} from "./config-runtime.js";', - "", - ].join("\n"), -}; - // `tsc` emits declarations under `dist/plugin-sdk/src/plugin-sdk/*` because the source lives // at `src/plugin-sdk/*` and `rootDir` is `.` (repo root, to support cross-src/extensions refs). // @@ -64,11 +45,7 @@ const TYPE_SHIMS: Partial> = { for (const entry of pluginSdkEntrypoints) { const typeOut = path.join(process.cwd(), `dist/plugin-sdk/${entry}.d.ts`); fs.mkdirSync(path.dirname(typeOut), { recursive: true }); - fs.writeFileSync( - typeOut, - TYPE_SHIMS[entry] ?? `export * from "./src/plugin-sdk/${entry}.js";\n`, - "utf8", - ); + fs.writeFileSync(typeOut, `export * from "./src/plugin-sdk/${entry}.js";\n`, "utf8"); const packageTypeOut = path.join( process.cwd(), diff --git a/src/acp/control-plane/manager.test.ts b/src/acp/control-plane/manager.test.ts index 192def6c4ca..612e73d489d 100644 --- a/src/acp/control-plane/manager.test.ts +++ b/src/acp/control-plane/manager.test.ts @@ -29,16 +29,10 @@ vi.mock("../runtime/session-meta.js", () => ({ upsertAcpSessionMeta: (params: unknown) => hoisted.upsertAcpSessionMetaMock(params), })); -vi.mock("../runtime/registry.js", async () => { - const actual = - await vi.importActual("../runtime/registry.js"); - return { - ...actual, - getAcpRuntimeBackend: (backendId?: string) => hoisted.getAcpRuntimeBackendMock(backendId), - requireAcpRuntimeBackend: (backendId?: string) => - hoisted.requireAcpRuntimeBackendMock(backendId), - }; -}); +vi.mock("../runtime/registry.js", () => ({ + getAcpRuntimeBackend: (backendId?: string) => hoisted.getAcpRuntimeBackendMock(backendId), + requireAcpRuntimeBackend: (backendId?: string) => hoisted.requireAcpRuntimeBackendMock(backendId), +})); let AcpSessionManager: typeof import("./manager.js").AcpSessionManager; let AcpRuntimeError: typeof import("../runtime/errors.js").AcpRuntimeError; diff --git a/src/acp/translator.stop-reason.test.ts b/src/acp/translator.stop-reason.test.ts index 39c36645047..a30fbcd8a55 100644 --- a/src/acp/translator.stop-reason.test.ts +++ b/src/acp/translator.stop-reason.test.ts @@ -537,11 +537,10 @@ describe("acp translator stop reason mapping", () => { await Promise.resolve(); agent.handleGatewayReconnect(); - await vi.waitFor(() => { - expect(settleSpy).toHaveBeenCalledWith({ - kind: "resolve", - value: { stopReason: "end_turn" }, - }); + await expect(promptPromise).resolves.toEqual({ stopReason: "end_turn" }); + expect(settleSpy).toHaveBeenCalledWith({ + kind: "resolve", + value: { stopReason: "end_turn" }, }); }); diff --git a/src/agents/auth-health.test.ts b/src/agents/auth-health.test.ts index fe782ec3039..076580ff433 100644 --- a/src/agents/auth-health.test.ts +++ b/src/agents/auth-health.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OAuthCredential } from "./auth-profiles/types.js"; const { readCodexCliCredentialsCachedMock } = vi.hoisted(() => ({ - readCodexCliCredentialsCachedMock: vi.fn(() => null), + readCodexCliCredentialsCachedMock: vi.fn<() => OAuthCredential | null>(() => null), })); vi.mock("./cli-credentials.js", () => ({ @@ -139,6 +140,39 @@ describe("buildAuthHealthSummary", () => { expect(statuses["openai-codex:default"]).toBe("ok"); }); + it("keeps healthy local oauth over fresher imported Codex CLI credentials in health status", () => { + vi.spyOn(Date, "now").mockReturnValue(now); + readCodexCliCredentialsCachedMock.mockReturnValue({ + type: "oauth", + provider: "openai-codex", + access: "fresh-cli-access", + refresh: "fresh-cli-refresh", + expires: now + 7 * DEFAULT_OAUTH_WARN_MS, + accountId: "acct-cli", + }); + const store = { + version: 1, + profiles: { + "openai-codex:default": { + type: "oauth" as const, + provider: "openai-codex", + access: "healthy-local-access", + refresh: "healthy-local-refresh", + expires: now + DEFAULT_OAUTH_WARN_MS + 10_000, + }, + }, + }; + + const summary = buildAuthHealthSummary({ + store, + warnAfterMs: DEFAULT_OAUTH_WARN_MS, + }); + + const profile = summary.profiles.find((entry) => entry.profileId === "openai-codex:default"); + expect(profile?.status).toBe("ok"); + expect(profile?.expiresAt).toBe(now + DEFAULT_OAUTH_WARN_MS + 10_000); + }); + it("marks token profiles with invalid expires as missing with reason code", () => { vi.spyOn(Date, "now").mockReturnValue(now); const store = { diff --git a/src/agents/auth-profiles.external-cli-sync.test.ts b/src/agents/auth-profiles.external-cli-sync.test.ts index fdabb6e6741..2c58ebf561a 100644 --- a/src/agents/auth-profiles.external-cli-sync.test.ts +++ b/src/agents/auth-profiles.external-cli-sync.test.ts @@ -6,9 +6,11 @@ const mocks = vi.hoisted(() => ({ readMiniMaxCliCredentialsCached: vi.fn<() => OAuthCredential | null>(() => null), })); -let syncExternalCliCredentials: typeof import("./auth-profiles/external-cli-sync.js").syncExternalCliCredentials; +let readManagedExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").readManagedExternalCliCredential; +let resolveExternalCliAuthProfiles: typeof import("./auth-profiles/external-cli-sync.js").resolveExternalCliAuthProfiles; +let hasUsableOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").hasUsableOAuthCredential; +let shouldBootstrapFromExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldBootstrapFromExternalCliCredential; let shouldReplaceStoredOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldReplaceStoredOAuthCredential; -let CODEX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").CODEX_CLI_PROFILE_ID; let OPENAI_CODEX_DEFAULT_PROFILE_ID: typeof import("./auth-profiles/constants.js").OPENAI_CODEX_DEFAULT_PROFILE_ID; let MINIMAX_CLI_PROFILE_ID: typeof import("./auth-profiles/constants.js").MINIMAX_CLI_PROFILE_ID; @@ -35,37 +37,23 @@ function makeStore(profileId?: string, credential?: OAuthCredential): AuthProfil }; } -function getProviderCases() { - return [ - { - label: "Codex", - profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, - provider: "openai-codex" as const, - readMock: mocks.readCodexCliCredentialsCached, - legacyProfileId: CODEX_CLI_PROFILE_ID, - }, - { - label: "MiniMax", - profileId: MINIMAX_CLI_PROFILE_ID, - provider: "minimax-portal" as const, - readMock: mocks.readMiniMaxCliCredentialsCached, - }, - ]; -} - -describe("syncExternalCliCredentials", () => { +describe("external cli oauth resolution", () => { beforeEach(async () => { vi.resetModules(); - vi.doUnmock("./auth-profiles/external-cli-sync.js"); - mocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null); - mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null); vi.doMock("./cli-credentials.js", () => ({ readCodexCliCredentialsCached: mocks.readCodexCliCredentialsCached, readMiniMaxCliCredentialsCached: mocks.readMiniMaxCliCredentialsCached, })); - ({ syncExternalCliCredentials, shouldReplaceStoredOAuthCredential } = - await import("./auth-profiles/external-cli-sync.js")); - ({ CODEX_CLI_PROFILE_ID, OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } = + mocks.readCodexCliCredentialsCached.mockReset().mockReturnValue(null); + mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null); + ({ + hasUsableOAuthCredential, + readManagedExternalCliCredential, + resolveExternalCliAuthProfiles, + shouldBootstrapFromExternalCliCredential, + shouldReplaceStoredOAuthCredential, + } = await import("./auth-profiles/external-cli-sync.js")); + ({ OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID } = await import("./auth-profiles/constants.js")); }); @@ -120,150 +108,199 @@ describe("syncExternalCliCredentials", () => { }); }); - it.each([{ providerLabel: "Codex" }, { providerLabel: "MiniMax" }])( - "syncs $providerLabel CLI credentials into the target auth profile", - ({ providerLabel }) => { - const providerCase = getProviderCases().find((entry) => entry.label === providerLabel); - expect(providerCase).toBeDefined(); - const current = providerCase!; - const expires = Date.now() + 60_000; - current.readMock.mockReturnValue( - makeOAuthCredential({ - provider: current.provider, - access: `${current.provider}-access-token`, - refresh: `${current.provider}-refresh-token`, - expires, - accountId: "acct_123", - }), - ); + describe("external cli bootstrap policy", () => { + it("treats only non-expired access tokens as usable local oauth", () => { + expect( + hasUsableOAuthCredential( + makeOAuthCredential({ + provider: "openai-codex", + access: "live-access", + expires: Date.now() + 60_000, + }), + ), + ).toBe(true); + expect( + hasUsableOAuthCredential( + makeOAuthCredential({ + provider: "openai-codex", + access: "expired-access", + expires: Date.now() - 60_000, + }), + ), + ).toBe(false); + expect( + hasUsableOAuthCredential( + makeOAuthCredential({ + provider: "openai-codex", + access: "", + expires: Date.now() + 60_000, + }), + ), + ).toBe(false); + }); - const store = makeStore(); - - const mutated = syncExternalCliCredentials(store); - - expect(mutated).toBe(true); - expect(current.readMock).toHaveBeenCalledWith( - expect.objectContaining({ ttlMs: expect.any(Number) }), - ); - expect(store.profiles[current.profileId]).toMatchObject({ - type: "oauth", - provider: current.provider, - access: `${current.provider}-access-token`, - refresh: `${current.provider}-refresh-token`, - expires, - accountId: "acct_123", - managedBy: current.provider === "openai-codex" ? "codex-cli" : ("minimax-cli" as const), + it("only bootstraps from external cli when the stored oauth is not usable", () => { + const imported = makeOAuthCredential({ + provider: "openai-codex", + access: "fresh-cli-access", + refresh: "fresh-cli-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, }); - if (current.legacyProfileId) { - expect(store.profiles[current.legacyProfileId]).toBeUndefined(); - } - }, - ); - it("refreshes stored Codex expiry from external CLI even when the cached profile looks fresh", () => { - const staleExpiry = Date.now() + 30 * 60_000; - const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000; - mocks.readCodexCliCredentialsCached.mockReturnValue( - makeOAuthCredential({ - provider: "openai-codex", - access: "new-access-token", - refresh: "new-refresh-token", - expires: freshExpiry, - accountId: "acct_456", - }), - ); - - const store = makeStore( - OPENAI_CODEX_DEFAULT_PROFILE_ID, - makeOAuthCredential({ - provider: "openai-codex", - access: "old-access-token", - refresh: "old-refresh-token", - expires: staleExpiry, - accountId: "acct_456", - }), - ); - - const mutated = syncExternalCliCredentials(store); - - expect(mutated).toBe(true); - expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({ - access: "new-access-token", - refresh: "new-refresh-token", - expires: freshExpiry, - managedBy: "codex-cli", + expect( + shouldBootstrapFromExternalCliCredential({ + existing: makeOAuthCredential({ + provider: "openai-codex", + access: "healthy-local-access", + refresh: "healthy-local-refresh", + expires: Date.now() + 60_000, + }), + imported, + }), + ).toBe(false); + expect( + shouldBootstrapFromExternalCliCredential({ + existing: makeOAuthCredential({ + provider: "openai-codex", + access: "expired-local-access", + refresh: "expired-local-refresh", + expires: Date.now() - 60_000, + }), + imported, + }), + ).toBe(true); }); }); - it.each([{ providerLabel: "Codex" }, { providerLabel: "MiniMax" }])( - "does not overwrite newer stored $providerLabel credentials", - ({ providerLabel }) => { - const providerCase = getProviderCases().find((entry) => entry.label === providerLabel); - expect(providerCase).toBeDefined(); - const current = providerCase!; - const staleExpiry = Date.now() + 30 * 60_000; - const freshExpiry = Date.now() + 5 * 24 * 60 * 60_000; - current.readMock.mockReturnValue( - makeOAuthCredential({ - provider: current.provider, - access: `stale-${current.provider}-access-token`, - refresh: `stale-${current.provider}-refresh-token`, - expires: staleExpiry, - accountId: "acct_789", - }), - ); - - const store = makeStore( - current.profileId, - makeOAuthCredential({ - provider: current.provider, - access: `fresh-${current.provider}-access-token`, - refresh: `fresh-${current.provider}-refresh-token`, - expires: freshExpiry, - accountId: "acct_789", - }), - ); - - const mutated = syncExternalCliCredentials(store); - - expect(mutated).toBe(false); - expect(store.profiles[current.profileId]).toMatchObject({ - access: `fresh-${current.provider}-access-token`, - refresh: `fresh-${current.provider}-refresh-token`, - expires: freshExpiry, - }); - }, - ); - - it("upgrades matching Codex CLI credentials with external ownership metadata", () => { - const expires = Date.now() + 60_000; + it("reads codex external cli credentials by profile id", () => { mocks.readCodexCliCredentialsCached.mockReturnValue( makeOAuthCredential({ provider: "openai-codex", - access: "same-access-token", - refresh: "same-refresh-token", - expires, + access: "codex-access-token", + refresh: "codex-refresh-token", }), ); - const store = makeStore( - OPENAI_CODEX_DEFAULT_PROFILE_ID, + const credential = readManagedExternalCliCredential({ + profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, + credential: makeOAuthCredential({ provider: "openai-codex" }), + }); + + expect(credential).toMatchObject({ + access: "codex-access-token", + refresh: "codex-refresh-token", + }); + }); + + it("returns null when the profile id/provider do not map to the same external source", () => { + mocks.readCodexCliCredentialsCached.mockReturnValue( + makeOAuthCredential({ provider: "openai-codex" }), + ); + + const credential = readManagedExternalCliCredential({ + profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, + credential: makeOAuthCredential({ provider: "anthropic" }), + }); + + expect(credential).toBeNull(); + }); + + it("resolves fresher codex and minimax external oauth profiles as runtime overlays", () => { + mocks.readCodexCliCredentialsCached.mockReturnValue( makeOAuthCredential({ provider: "openai-codex", - access: "same-access-token", - refresh: "same-refresh-token", - expires, + access: "codex-fresh-access", + refresh: "codex-fresh-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, + }), + ); + mocks.readMiniMaxCliCredentialsCached.mockReturnValue( + makeOAuthCredential({ + provider: "minimax-portal", + access: "minimax-fresh-access", + refresh: "minimax-fresh-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, }), ); - const mutated = syncExternalCliCredentials(store); - - expect(mutated).toBe(true); - expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({ - access: "same-access-token", - refresh: "same-refresh-token", - expires, - managedBy: "codex-cli", + const profiles = resolveExternalCliAuthProfiles({ + version: 1, + profiles: { + [OPENAI_CODEX_DEFAULT_PROFILE_ID]: makeOAuthCredential({ + provider: "openai-codex", + access: "codex-stale-access", + refresh: "codex-stale-refresh", + expires: Date.now() - 5_000, + }), + [MINIMAX_CLI_PROFILE_ID]: makeOAuthCredential({ + provider: "minimax-portal", + access: "minimax-stale-access", + refresh: "minimax-stale-refresh", + expires: Date.now() - 5_000, + }), + }, }); + + const profilesById = new Map( + profiles.map((profile) => [profile.profileId, profile.credential]), + ); + expect(profilesById.get(OPENAI_CODEX_DEFAULT_PROFILE_ID)).toMatchObject({ + access: "codex-fresh-access", + refresh: "codex-fresh-refresh", + }); + expect(profilesById.get(MINIMAX_CLI_PROFILE_ID)).toMatchObject({ + access: "minimax-fresh-access", + refresh: "minimax-fresh-refresh", + }); + }); + + it("does not emit runtime overlays when the stored credential is newer", () => { + mocks.readCodexCliCredentialsCached.mockReturnValue( + makeOAuthCredential({ + provider: "openai-codex", + access: "stale-external-access", + refresh: "stale-external-refresh", + expires: Date.now() - 5_000, + }), + ); + + const profiles = resolveExternalCliAuthProfiles( + makeStore( + OPENAI_CODEX_DEFAULT_PROFILE_ID, + makeOAuthCredential({ + provider: "openai-codex", + access: "fresh-store-access", + refresh: "fresh-store-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, + }), + ), + ); + + expect(profiles).toEqual([]); + }); + + it("does not overlay fresh external cli oauth over a still-usable local credential", () => { + mocks.readCodexCliCredentialsCached.mockReturnValue( + makeOAuthCredential({ + provider: "openai-codex", + access: "fresh-cli-access", + refresh: "fresh-cli-refresh", + expires: Date.now() + 5 * 24 * 60 * 60_000, + }), + ); + + const profiles = resolveExternalCliAuthProfiles( + makeStore( + OPENAI_CODEX_DEFAULT_PROFILE_ID, + makeOAuthCredential({ + provider: "openai-codex", + access: "healthy-local-access", + refresh: "healthy-local-refresh", + expires: Date.now() + 60_000, + }), + ), + ); + + expect(profiles).toEqual([]); }); }); diff --git a/src/agents/auth-profiles.readonly-sync.test.ts b/src/agents/auth-profiles.readonly-sync.test.ts index 45f0da9f883..f18bebd3164 100644 --- a/src/agents/auth-profiles.readonly-sync.test.ts +++ b/src/agents/auth-profiles.readonly-sync.test.ts @@ -5,37 +5,34 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; -const mocks = vi.hoisted(() => ({ - syncExternalCliCredentials: vi.fn((store: AuthProfileStore) => { - store.profiles["minimax-portal:default"] = { - type: "oauth", +const resolveExternalAuthProfilesWithPluginsMock = vi.fn(() => [ + { + profileId: "minimax-portal:default", + credential: { + type: "oauth" as const, provider: "minimax-portal", access: "access-token", refresh: "refresh-token", expires: Date.now() + 60_000, - }; - return true; - }), -})); - -vi.mock("./auth-profiles/external-cli-sync.js", () => ({ - syncExternalCliCredentials: mocks.syncExternalCliCredentials, -})); + }, + persistence: "runtime-only" as const, + }, +]); vi.mock("../plugins/provider-runtime.js", () => ({ - resolveExternalAuthProfilesWithPlugins: () => [], + resolveExternalAuthProfilesWithPlugins: resolveExternalAuthProfilesWithPluginsMock, })); let clearRuntimeAuthProfileStoreSnapshots: typeof import("./auth-profiles.js").clearRuntimeAuthProfileStoreSnapshots; let loadAuthProfileStoreForRuntime: typeof import("./auth-profiles.js").loadAuthProfileStoreForRuntime; -describe("auth profiles read-only external CLI sync", () => { +describe("auth profiles read-only external auth overlay", () => { beforeEach(async () => { vi.resetModules(); ({ clearRuntimeAuthProfileStoreSnapshots, loadAuthProfileStoreForRuntime } = await import("./auth-profiles.js")); clearRuntimeAuthProfileStoreSnapshots(); - mocks.syncExternalCliCredentials.mockClear(); + resolveExternalAuthProfilesWithPluginsMock.mockClear(); }); afterEach(() => { @@ -43,7 +40,7 @@ describe("auth profiles read-only external CLI sync", () => { vi.clearAllMocks(); }); - it("syncs external CLI credentials in-memory without writing auth-profiles.json in read-only mode", () => { + it("overlays runtime-only external auth without writing auth-profiles.json in read-only mode", () => { const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-readonly-sync-")); try { const authPath = path.join(agentDir, "auth-profiles.json"); @@ -61,10 +58,7 @@ describe("auth profiles read-only external CLI sync", () => { const loaded = loadAuthProfileStoreForRuntime(agentDir, { readOnly: true }); - expect(mocks.syncExternalCliCredentials).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ log: false }), - ); + expect(resolveExternalAuthProfilesWithPluginsMock).toHaveBeenCalled(); expect(loaded.profiles["minimax-portal:default"]).toMatchObject({ type: "oauth", provider: "minimax-portal", diff --git a/src/agents/auth-profiles.store-cache.test.ts b/src/agents/auth-profiles.store-cache.test.ts index 668fd76d97b..8f03bf2fae7 100644 --- a/src/agents/auth-profiles.store-cache.test.ts +++ b/src/agents/auth-profiles.store-cache.test.ts @@ -3,16 +3,16 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js"; -import type { AuthProfileStore } from "./auth-profiles/types.js"; +import type { OAuthCredential } from "./auth-profiles/types.js"; -const AUTH_STORE_CACHE_TTL_MS = 15 * 60 * 1000; +type RuntimeOnlyOverlay = { profileId: string; credential: OAuthCredential }; const mocks = vi.hoisted(() => ({ - syncExternalCliCredentials: vi.fn((_: AuthProfileStore) => false), + resolveExternalCliAuthProfiles: vi.fn<() => RuntimeOnlyOverlay[]>(() => []), })); vi.mock("./auth-profiles/external-cli-sync.js", () => ({ - syncExternalCliCredentials: mocks.syncExternalCliCredentials, + resolveExternalCliAuthProfiles: mocks.resolveExternalCliAuthProfiles, })); vi.mock("../plugins/provider-runtime.js", () => ({ @@ -85,14 +85,32 @@ describe("auth profile store cache", () => { vi.clearAllMocks(); }); - it("reuses the synced auth store while auth-profiles.json is unchanged", async () => { + function createRuntimeOnlyOverlay(access: string): RuntimeOnlyOverlay { + return { + profileId: "openai-codex:default", + credential: { + type: "oauth", + provider: "openai-codex", + access, + refresh: `refresh-${access}`, + expires: Date.now() + 60_000, + }, + }; + } + + it("recomputes runtime-only external auth overlays even while the base store is cached", async () => { await withAgentDirEnv("openclaw-auth-store-cache-", (agentDir) => { writeAuthStore(agentDir, "sk-test"); + mocks.resolveExternalCliAuthProfiles + .mockReturnValueOnce([createRuntimeOnlyOverlay("access-1")]) + .mockReturnValueOnce([createRuntimeOnlyOverlay("access-2")]); - ensureAuthProfileStore(agentDir); - ensureAuthProfileStore(agentDir); + const first = ensureAuthProfileStore(agentDir); + const second = ensureAuthProfileStore(agentDir); - expect(mocks.syncExternalCliCredentials).toHaveBeenCalledTimes(1); + expect(first.profiles["openai-codex:default"]).toMatchObject({ access: "access-1" }); + expect(second.profiles["openai-codex:default"]).toMatchObject({ access: "access-2" }); + expect(mocks.resolveExternalCliAuthProfiles).toHaveBeenCalledTimes(2); }); }); @@ -108,48 +126,25 @@ describe("auth profile store cache", () => { const reloaded = ensureAuthProfileStore(agentDir); - expect(mocks.syncExternalCliCredentials).toHaveBeenCalledTimes(2); expect(reloaded.profiles["openai:default"]).toMatchObject({ key: "sk-test-2", }); }); }); - it("re-syncs external CLI credentials after the cache ttl when auth-profiles.json is absent", () => { + it("keeps runtime-only external auth out of persisted auth-profiles.json files", () => { const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-auth-store-missing-")); const previousAgentDir = process.env.OPENCLAW_AGENT_DIR; const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR; - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-03-21T15:00:00.000Z")); - let syncCount = 0; - mocks.syncExternalCliCredentials.mockImplementation((store) => { - syncCount += 1; - store.profiles["openai-codex:default"] = { - type: "oauth", - provider: "openai-codex", - access: `access-${syncCount}`, - refresh: `refresh-${syncCount}`, - expires: Date.now() + 60_000, - }; - return true; - }); + mocks.resolveExternalCliAuthProfiles.mockReturnValue([createRuntimeOnlyOverlay("access-1")]); try { process.env.OPENCLAW_AGENT_DIR = agentDir; process.env.PI_CODING_AGENT_DIR = agentDir; - const first = ensureAuthProfileStore(agentDir); - const second = ensureAuthProfileStore(agentDir); + const store = ensureAuthProfileStore(agentDir); - expect(first.profiles["openai-codex:default"]).toMatchObject({ access: "access-1" }); - expect(second.profiles["openai-codex:default"]).toMatchObject({ access: "access-1" }); - expect(mocks.syncExternalCliCredentials).toHaveBeenCalledTimes(1); - - vi.advanceTimersByTime(AUTH_STORE_CACHE_TTL_MS + 1); - - const third = ensureAuthProfileStore(agentDir); - - expect(mocks.syncExternalCliCredentials).toHaveBeenCalledTimes(2); - expect(third.profiles["openai-codex:default"]).toMatchObject({ access: "access-2" }); + expect(store.profiles["openai-codex:default"]).toMatchObject({ access: "access-1" }); + expect(fs.existsSync(path.join(agentDir, "auth-profiles.json"))).toBe(false); } finally { if (previousAgentDir === undefined) { delete process.env.OPENCLAW_AGENT_DIR; diff --git a/src/agents/auth-profiles/effective-oauth.ts b/src/agents/auth-profiles/effective-oauth.ts index 8768982acd8..0624c1908c2 100644 --- a/src/agents/auth-profiles/effective-oauth.ts +++ b/src/agents/auth-profiles/effective-oauth.ts @@ -1,6 +1,8 @@ +import { log } from "./constants.js"; import { - readManagedExternalCliCredential, - shouldReplaceStoredOAuthCredential, + hasUsableOAuthCredential, + readExternalCliBootstrapCredential, + shouldBootstrapFromExternalCliCredential, } from "./external-cli-sync.js"; import type { OAuthCredential } from "./types.js"; @@ -8,14 +10,34 @@ export function resolveEffectiveOAuthCredential(params: { profileId: string; credential: OAuthCredential; }): OAuthCredential { - const imported = readManagedExternalCliCredential({ + const imported = readExternalCliBootstrapCredential({ profileId: params.profileId, credential: params.credential, }); if (!imported) { return params.credential; } - return shouldReplaceStoredOAuthCredential(params.credential, imported) - ? imported - : params.credential; + if (hasUsableOAuthCredential(params.credential)) { + log.debug("resolved oauth credential from canonical local store", { + profileId: params.profileId, + provider: params.credential.provider, + localExpires: params.credential.expires, + externalExpires: imported.expires, + }); + return params.credential; + } + const shouldBootstrap = shouldBootstrapFromExternalCliCredential({ + existing: params.credential, + imported, + }); + if (shouldBootstrap) { + log.debug("resolved oauth credential from external cli bootstrap", { + profileId: params.profileId, + provider: imported.provider, + localExpires: params.credential.expires, + externalExpires: imported.expires, + }); + return imported; + } + return params.credential; } diff --git a/src/agents/auth-profiles/external-auth.ts b/src/agents/auth-profiles/external-auth.ts index c34ab68ed39..bd46682f9ce 100644 --- a/src/agents/auth-profiles/external-auth.ts +++ b/src/agents/auth-profiles/external-auth.ts @@ -1,5 +1,6 @@ import type { ProviderExternalAuthProfile } from "../../plugins/provider-external-auth.types.js"; import { resolveExternalAuthProfilesWithPlugins } from "../../plugins/provider-runtime.js"; +import * as externalCliSync from "./external-cli-sync.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; type ExternalAuthProfileMap = Map; @@ -48,6 +49,14 @@ function resolveExternalAuthProfileMap(params: { }); const resolved: ExternalAuthProfileMap = new Map(); + const cliProfiles = externalCliSync.resolveExternalCliAuthProfiles?.(params.store) ?? []; + for (const profile of cliProfiles) { + resolved.set(profile.profileId, { + profileId: profile.profileId, + credential: profile.credential, + persistence: "runtime-only", + }); + } for (const rawProfile of profiles) { const profile = normalizeExternalAuthProfile(rawProfile); if (!profile) { diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 3884a072c62..226c32bcd1a 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -4,20 +4,21 @@ import { } from "../cli-credentials.js"; import { EXTERNAL_CLI_SYNC_TTL_MS, - OPENAI_CODEX_DEFAULT_PROFILE_ID, MINIMAX_CLI_PROFILE_ID, - log, + OPENAI_CODEX_DEFAULT_PROFILE_ID, } from "./constants.js"; -import type { AuthProfileStore, ExternalOAuthManager, OAuthCredential } from "./types.js"; +import { log } from "./constants.js"; +import { resolveTokenExpiryState } from "./credential-state.js"; +import type { AuthProfileStore, OAuthCredential } from "./types.js"; -type ExternalCliSyncOptions = { - log?: boolean; +export type ExternalCliResolvedProfile = { + profileId: string; + credential: OAuthCredential; }; type ExternalCliSyncProvider = { profileId: string; provider: string; - managedBy: ExternalOAuthManager; readCredentials: () => OAuthCredential | null; }; @@ -25,10 +26,7 @@ export function areOAuthCredentialsEquivalent( a: OAuthCredential | undefined, b: OAuthCredential, ): boolean { - if (!a) { - return false; - } - if (a.type !== "oauth") { + if (!a || a.type !== "oauth") { return false; } return ( @@ -39,8 +37,7 @@ export function areOAuthCredentialsEquivalent( a.email === b.email && a.enterpriseUrl === b.enterpriseUrl && a.projectId === b.projectId && - a.accountId === b.accountId && - a.managedBy === b.managedBy + a.accountId === b.accountId ); } @@ -69,128 +66,112 @@ export function shouldReplaceStoredOAuthCredential( return !hasNewerStoredOAuthCredential(existing, incoming); } +export function hasUsableOAuthCredential( + credential: OAuthCredential | undefined, + now = Date.now(), +): boolean { + if (!credential || credential.type !== "oauth") { + return false; + } + if (typeof credential.access !== "string" || credential.access.trim().length === 0) { + return false; + } + return resolveTokenExpiryState(credential.expires, now) === "valid"; +} + +export function shouldBootstrapFromExternalCliCredential(params: { + existing: OAuthCredential | undefined; + imported: OAuthCredential; + now?: number; +}): boolean { + const now = params.now ?? Date.now(); + if (hasUsableOAuthCredential(params.existing, now)) { + return false; + } + return hasUsableOAuthCredential(params.imported, now); +} + const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [ { profileId: MINIMAX_CLI_PROFILE_ID, provider: "minimax-portal", - managedBy: "minimax-cli", readCredentials: () => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), }, { profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, provider: "openai-codex", - managedBy: "codex-cli", readCredentials: () => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), }, ]; -function withExternalCliManager( - creds: OAuthCredential, - managedBy: ExternalOAuthManager, -): OAuthCredential { - return { - ...creds, - managedBy, - }; -} - function resolveExternalCliSyncProvider(params: { - profileId?: string; + profileId: string; credential?: OAuthCredential; }): ExternalCliSyncProvider | null { - const byProfileId = - typeof params.profileId === "string" - ? EXTERNAL_CLI_SYNC_PROVIDERS.find((entry) => entry.profileId === params.profileId) - : undefined; - if (byProfileId) { - return byProfileId; - } - const managedBy = params.credential?.managedBy; - if (!managedBy) { + const provider = EXTERNAL_CLI_SYNC_PROVIDERS.find( + (entry) => entry.profileId === params.profileId, + ); + if (!provider) { return null; } - return ( - EXTERNAL_CLI_SYNC_PROVIDERS.find( - (entry) => - entry.managedBy === managedBy && - (!params.credential || entry.provider === params.credential.provider), - ) ?? null - ); + if (params.credential && provider.provider !== params.credential.provider) { + return null; + } + return provider; } -export function readManagedExternalCliCredential(params: { - profileId?: string; +export function readExternalCliBootstrapCredential(params: { + profileId: string; credential: OAuthCredential; }): OAuthCredential | null { const provider = resolveExternalCliSyncProvider(params); if (!provider) { return null; } - const creds = provider.readCredentials(); - if (!creds) { - return null; - } - return withExternalCliManager(creds, provider.managedBy); + return provider.readCredentials(); } -/** Sync external CLI credentials into the store for a given provider. */ -function syncExternalCliCredentialsForProvider( - store: AuthProfileStore, - providerConfig: ExternalCliSyncProvider, - options: ExternalCliSyncOptions, -): boolean { - const { profileId, provider, managedBy, readCredentials } = providerConfig; - const existing = store.profiles[profileId]; - const creds = readCredentials(); - if (!creds) { - return false; - } - const managedCreds = withExternalCliManager(creds, managedBy); +export const readManagedExternalCliCredential = readExternalCliBootstrapCredential; - const existingOAuth = existing?.type === "oauth" ? existing : undefined; - if (!shouldReplaceStoredOAuthCredential(existingOAuth, managedCreds)) { - if (options.log !== false) { - if (!areOAuthCredentialsEquivalent(existingOAuth, managedCreds) && existingOAuth) { - log.debug(`kept newer stored ${provider} credentials over external cli sync`, { - profileId, - storedExpires: new Date(existingOAuth.expires).toISOString(), - externalExpires: Number.isFinite(managedCreds.expires) - ? new Date(managedCreds.expires).toISOString() - : null, +export function resolveExternalCliAuthProfiles( + store: AuthProfileStore, +): ExternalCliResolvedProfile[] { + const profiles: ExternalCliResolvedProfile[] = []; + const now = Date.now(); + for (const providerConfig of EXTERNAL_CLI_SYNC_PROVIDERS) { + const creds = providerConfig.readCredentials(); + if (!creds) { + continue; + } + const existing = store.profiles[providerConfig.profileId]; + const existingOAuth = existing?.type === "oauth" ? existing : undefined; + if ( + !shouldBootstrapFromExternalCliCredential({ + existing: existingOAuth, + imported: creds, + now, + }) + ) { + if (existingOAuth) { + log.debug("kept usable local oauth over external cli bootstrap", { + profileId: providerConfig.profileId, + provider: providerConfig.provider, + localExpires: existingOAuth.expires, + externalExpires: creds.expires, }); } + continue; } - return false; - } - - store.profiles[profileId] = managedCreds; - if (options.log !== false) { - log.info(`synced ${provider} credentials from external cli`, { - profileId, - expires: new Date(managedCreds.expires).toISOString(), - managedBy, + log.debug("used external cli oauth bootstrap because local oauth was missing or unusable", { + profileId: providerConfig.profileId, + provider: providerConfig.provider, + localExpires: existingOAuth?.expires, + externalExpires: creds.expires, + }); + profiles.push({ + profileId: providerConfig.profileId, + credential: creds, }); } - return true; -} - -/** - * Sync OAuth credentials from external CLI tools (MiniMax CLI, Codex CLI) - * into the store. - * - * Returns true if any credentials were updated. - */ -export function syncExternalCliCredentials( - store: AuthProfileStore, - options: ExternalCliSyncOptions = {}, -): boolean { - let mutated = false; - - for (const provider of EXTERNAL_CLI_SYNC_PROVIDERS) { - if (syncExternalCliCredentialsForProvider(store, provider, options)) { - mutated = true; - } - } - - return mutated; + return profiles; } diff --git a/src/agents/auth-profiles/external-oauth.test.ts b/src/agents/auth-profiles/external-oauth.test.ts index 161732aea48..3f907f376a5 100644 --- a/src/agents/auth-profiles/external-oauth.test.ts +++ b/src/agents/auth-profiles/external-oauth.test.ts @@ -10,6 +10,14 @@ import type { AuthProfileStore, OAuthCredential } from "./types.js"; const resolveExternalAuthProfilesWithPluginsMock = vi.fn< (params: unknown) => ProviderExternalAuthProfile[] >(() => []); +const readCodexCliCredentialsCachedMock = vi.hoisted(() => + vi.fn<() => OAuthCredential | null>(() => null), +); + +vi.mock("../cli-credentials.js", () => ({ + readCodexCliCredentialsCached: readCodexCliCredentialsCachedMock, + readMiniMaxCliCredentialsCached: () => null, +})); function createStore(profiles: AuthProfileStore["profiles"] = {}): AuthProfileStore { return { version: 1, profiles }; @@ -30,6 +38,8 @@ describe("auth external oauth helpers", () => { beforeEach(() => { resolveExternalAuthProfilesWithPluginsMock.mockReset(); resolveExternalAuthProfilesWithPluginsMock.mockReturnValue([]); + readCodexCliCredentialsCachedMock.mockReset(); + readCodexCliCredentialsCachedMock.mockReturnValue(null); __testing.setResolveExternalAuthProfilesForTest(resolveExternalAuthProfilesWithPluginsMock); }); @@ -108,4 +118,55 @@ describe("auth external oauth helpers", () => { expect(shouldPersist).toBe(true); }); + + it("overlays external CLI OAuth only when the stored credential is no longer usable", () => { + readCodexCliCredentialsCachedMock.mockReturnValue( + createCredential({ + access: "fresh-cli-access-token", + refresh: "fresh-cli-refresh-token", + expires: Date.now() + 60_000, + }), + ); + + const overlaid = overlayExternalOAuthProfiles( + createStore({ + "openai-codex:default": createCredential({ + access: "stale-store-access-token", + refresh: "stale-store-refresh-token", + expires: Date.now() - 60_000, + }), + }), + ); + + expect(overlaid.profiles["openai-codex:default"]).toMatchObject({ + access: "fresh-cli-access-token", + refresh: "fresh-cli-refresh-token", + expires: expect.any(Number), + }); + }); + + it("keeps healthy local oauth even when external cli has a fresher token", () => { + readCodexCliCredentialsCachedMock.mockReturnValue( + createCredential({ + access: "fresh-cli-access-token", + refresh: "fresh-cli-refresh-token", + expires: Date.now() + 5 * 24 * 60 * 60_000, + }), + ); + + const overlaid = overlayExternalOAuthProfiles( + createStore({ + "openai-codex:default": createCredential({ + access: "healthy-local-access-token", + refresh: "healthy-local-refresh-token", + expires: Date.now() + 60_000, + }), + }), + ); + + expect(overlaid.profiles["openai-codex:default"]).toMatchObject({ + access: "healthy-local-access-token", + refresh: "healthy-local-refresh-token", + }); + }); }); diff --git a/src/agents/auth-profiles/oauth-refresh-queue.test.ts b/src/agents/auth-profiles/oauth-refresh-queue.test.ts index 73ad164573d..f980b71d489 100644 --- a/src/agents/auth-profiles/oauth-refresh-queue.test.ts +++ b/src/agents/auth-profiles/oauth-refresh-queue.test.ts @@ -71,11 +71,17 @@ vi.mock("./external-auth.js", () => ({ shouldPersistExternalAuthProfile: () => true, })); -vi.mock("./external-cli-sync.js", () => ({ - syncExternalCliCredentials: () => false, - readManagedExternalCliCredential: () => null, - areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, -})); +vi.mock("./external-cli-sync.js", async () => { + const actual = + await vi.importActual("./external-cli-sync.js"); + return { + ...actual, + syncExternalCliCredentials: () => false, + readManagedExternalCliCredential: () => null, + resolveExternalCliAuthProfiles: () => [], + areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, + }; +}); function createExpiredOauthStore(params: { profileId: string; diff --git a/src/agents/auth-profiles/oauth.adopt-identity.test.ts b/src/agents/auth-profiles/oauth.adopt-identity.test.ts index 75eb8273faf..34f41a4ed8c 100644 --- a/src/agents/auth-profiles/oauth.adopt-identity.test.ts +++ b/src/agents/auth-profiles/oauth.adopt-identity.test.ts @@ -78,11 +78,17 @@ vi.mock("./doctor.js", () => ({ formatAuthDoctorHint: async () => undefined, })); -vi.mock("./external-cli-sync.js", () => ({ - syncExternalCliCredentials: () => false, - readManagedExternalCliCredential: () => null, - areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, -})); +vi.mock("./external-cli-sync.js", async () => { + const actual = + await vi.importActual("./external-cli-sync.js"); + return { + ...actual, + syncExternalCliCredentials: () => false, + readManagedExternalCliCredential: () => null, + resolveExternalCliAuthProfiles: () => [], + areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, + }; +}); function oauthCred(params: { provider: string; diff --git a/src/agents/auth-profiles/oauth.concurrent-20-agents.test.ts b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts similarity index 94% rename from src/agents/auth-profiles/oauth.concurrent-20-agents.test.ts rename to src/agents/auth-profiles/oauth.concurrent-agents.test.ts index e89a2e609d0..120827d1618 100644 --- a/src/agents/auth-profiles/oauth.concurrent-20-agents.test.ts +++ b/src/agents/auth-profiles/oauth.concurrent-agents.test.ts @@ -64,11 +64,17 @@ vi.mock("./doctor.js", () => ({ // External-CLI sync does real I/O against the user's Codex/MiniMax CLI // credential files; it is slow and can pollute test state. Stub it to a no-op // so the suite only exercises in-repo auth-profile logic. -vi.mock("./external-cli-sync.js", () => ({ - syncExternalCliCredentials: () => false, - readManagedExternalCliCredential: () => null, - areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, -})); +vi.mock("./external-cli-sync.js", async () => { + const actual = + await vi.importActual("./external-cli-sync.js"); + return { + ...actual, + syncExternalCliCredentials: () => false, + readManagedExternalCliCredential: () => null, + resolveExternalCliAuthProfiles: () => [], + areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, + }; +}); function createExpiredOauthStore(params: { profileId: string; diff --git a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts index f9d7d87c663..33b8eeff32e 100644 --- a/src/agents/auth-profiles/oauth.mirror-refresh.test.ts +++ b/src/agents/auth-profiles/oauth.mirror-refresh.test.ts @@ -75,11 +75,17 @@ vi.mock("./doctor.js", () => ({ formatAuthDoctorHint: async () => undefined, })); -vi.mock("./external-cli-sync.js", () => ({ - syncExternalCliCredentials: () => false, - readManagedExternalCliCredential: () => null, - areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, -})); +vi.mock("./external-cli-sync.js", async () => { + const actual = + await vi.importActual("./external-cli-sync.js"); + return { + ...actual, + syncExternalCliCredentials: () => false, + readManagedExternalCliCredential: () => null, + resolveExternalCliAuthProfiles: () => [], + areOAuthCredentialsEquivalent: (a: unknown, b: unknown) => a === b, + }; +}); function createExpiredOauthStore(params: { profileId: string; 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 273d1605be8..7961394688d 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 @@ -312,11 +312,6 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { refresh: "rotated-cli-refresh-token", accountId: "acct-rotated", }); - expect(persisted.profiles[profileId]).not.toEqual( - expect.objectContaining({ - managedBy: "codex-cli", - }), - ); expect(persisted.profiles[profileId]).not.toEqual( expect.objectContaining({ provider: "openai-codex", @@ -325,6 +320,47 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { ); }); + it("keeps healthy local Codex OAuth over fresher imported CLI credentials", async () => { + const profileId = "openai-codex:default"; + saveAuthProfileStore( + { + version: 1, + profiles: { + [profileId]: { + type: "oauth", + provider: "openai-codex", + access: "healthy-local-access-token", + refresh: "healthy-local-refresh-token", + expires: Date.now() + 60_000, + }, + }, + }, + agentDir, + ); + readCodexCliCredentialsCachedMock.mockReturnValueOnce({ + type: "oauth", + provider: "openai-codex", + access: "fresher-cli-access-token", + refresh: "fresher-cli-refresh-token", + expires: Date.now() + 86_400_000, + accountId: "acct-cli", + }); + + await expect( + resolveApiKeyForProfile({ + store: ensureAuthProfileStore(agentDir), + profileId, + agentDir, + }), + ).resolves.toEqual({ + apiKey: "healthy-local-access-token", + provider: "openai-codex", + email: undefined, + }); + + expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled(); + }); + it("keeps the canonical refresh token when imported Codex CLI state is stale", async () => { const profileId = "openai-codex:default"; saveAuthProfileStore( diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index a7c7d9a0a0c..fb920ab81fc 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -27,7 +27,8 @@ import { formatAuthDoctorHint } from "./doctor.js"; import { resolveEffectiveOAuthCredential } from "./effective-oauth.js"; import { areOAuthCredentialsEquivalent, - readManagedExternalCliCredential, + hasUsableOAuthCredential, + readExternalCliBootstrapCredential, shouldReplaceStoredOAuthCredential, } from "./external-cli-sync.js"; import { ensureAuthStoreFile, resolveAuthStorePath, resolveOAuthRefreshLockPath } from "./paths.js"; @@ -152,13 +153,6 @@ function hasOAuthCredentialChanged( ); } -function clearExternalOAuthManager( - credential: OAuthCredential, -): OAuthCredentials & { type: "oauth"; provider: string; email?: string } { - const { managedBy: _managedBy, ...canonicalCredential } = credential; - return canonicalCredential; -} - async function loadFreshStoredOAuthCredential(params: { profileId: string; agentDir?: string; @@ -168,10 +162,11 @@ async function loadFreshStoredOAuthCredential(params: { }): Promise { const reloadedStore = loadAuthProfileStoreForSecretsRuntime(params.agentDir); const reloaded = reloadedStore.profiles[params.profileId]; - if (reloaded?.type !== "oauth" || reloaded.provider !== params.provider) { - return null; - } - if (!Number.isFinite(reloaded.expires) || Date.now() >= reloaded.expires) { + if ( + reloaded?.type !== "oauth" || + reloaded.provider !== params.provider || + !hasUsableOAuthCredential(reloaded) + ) { return null; } if ( @@ -564,7 +559,7 @@ async function doRefreshOAuthTokenWithLock(params: { return null; } - if (Date.now() < cred.expires) { + if (hasUsableOAuthCredential(cred)) { return { apiKey: await buildOAuthApiKey(cred.provider, cred), newCredentials: cred, @@ -584,8 +579,7 @@ async function doRefreshOAuthTokenWithLock(params: { if ( mainCred?.type === "oauth" && mainCred.provider === cred.provider && - Number.isFinite(mainCred.expires) && - Date.now() < mainCred.expires && + hasUsableOAuthCredential(mainCred) && // Defense-in-depth identity gate. Tolerates the pure upgrade // case (sub predates identity capture) but refuses positive // mismatch, identity regression, and non-overlapping fields. @@ -605,8 +599,7 @@ async function doRefreshOAuthTokenWithLock(params: { } else if ( mainCred?.type === "oauth" && mainCred.provider === cred.provider && - Number.isFinite(mainCred.expires) && - Date.now() < mainCred.expires && + hasUsableOAuthCredential(mainCred) && !isSafeToCopyOAuthIdentity(cred, mainCred) ) { // Main has fresh creds but they belong to a DIFFERENT account — @@ -625,7 +618,7 @@ async function doRefreshOAuthTokenWithLock(params: { } } - const externallyManaged = readManagedExternalCliCredential({ + const externallyManaged = readExternalCliBootstrapCredential({ profileId: params.profileId, credential: cred, }); @@ -637,7 +630,7 @@ async function doRefreshOAuthTokenWithLock(params: { store.profiles[params.profileId] = externallyManaged; saveAuthProfileStore(store, params.agentDir); } - if (Date.now() < externallyManaged.expires) { + if (hasUsableOAuthCredential(externallyManaged)) { return { apiKey: await buildOAuthApiKey(externallyManaged.provider, externallyManaged), newCredentials: externallyManaged, @@ -656,7 +649,7 @@ async function doRefreshOAuthTokenWithLock(params: { ); if (pluginRefreshed) { const refreshedCredentials: OAuthCredential = { - ...clearExternalOAuthManager(cred), + ...cred, ...pluginRefreshed, type: "oauth", }; @@ -759,7 +752,7 @@ async function tryResolveOAuthProfile( credential: cred, }); - if (Date.now() < effectiveCred.expires) { + if (hasUsableOAuthCredential(effectiveCred)) { return await buildOAuthProfileResult({ provider: effectiveCred.provider, credentials: effectiveCred, @@ -915,7 +908,7 @@ export async function resolveApiKeyForProfile( credential: oauthCred, }); - if (Date.now() < effectiveOAuthCred.expires) { + if (hasUsableOAuthCredential(effectiveOAuthCred)) { return await buildOAuthProfileResult({ provider: effectiveOAuthCred.provider, credentials: effectiveOAuthCred, @@ -940,7 +933,7 @@ export async function resolveApiKeyForProfile( } catch (error) { const refreshedStore = loadAuthProfileStoreForSecretsRuntime(params.agentDir); const refreshed = refreshedStore.profiles[profileId]; - if (refreshed?.type === "oauth" && Date.now() < refreshed.expires) { + if (refreshed?.type === "oauth" && hasUsableOAuthCredential(refreshed)) { return await buildOAuthProfileResult({ provider: refreshed.provider, credentials: refreshed, @@ -1010,7 +1003,7 @@ export async function resolveApiKeyForProfile( if ( mainCred?.type === "oauth" && mainCred.provider === cred.provider && - Date.now() < mainCred.expires && + hasUsableOAuthCredential(mainCred) && // Defense-in-depth identity gate — refuse to inherit credentials // from a different account even under refresh failure. Tolerates // pre-capture credentials but refuses regression/non-overlap. diff --git a/src/agents/auth-profiles/persisted.ts b/src/agents/auth-profiles/persisted.ts index d6fc059bf99..cfc1a10aae8 100644 --- a/src/agents/auth-profiles/persisted.ts +++ b/src/agents/auth-profiles/persisted.ts @@ -245,7 +245,6 @@ export function applyLegacyAuthStore(store: AuthProfileStore, legacy: LegacyAuth ...(cred.projectId ? { projectId: cred.projectId } : {}), ...(cred.accountId ? { accountId: cred.accountId } : {}), ...(cred.email ? { email: cred.email } : {}), - ...(cred.managedBy ? { managedBy: cred.managedBy } : {}), }; } } diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index c939de13468..a036e56b1dc 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -8,7 +8,6 @@ import { log, } from "./constants.js"; import { overlayExternalAuthProfiles, shouldPersistExternalAuthProfile } from "./external-auth.js"; -import { syncExternalCliCredentials } from "./external-cli-sync.js"; import { ensureAuthStoreFile, resolveAuthStatePath, @@ -149,34 +148,9 @@ export async function updateAuthProfileStoreWithLock(params: { } } -function shouldLogAuthStoreTiming(): boolean { - return process.env.OPENCLAW_DEBUG_INGRESS_TIMING === "1"; -} - -function syncExternalCliCredentialsTimed( - store: AuthProfileStore, - options?: Parameters[1], -): boolean { - if (!shouldLogAuthStoreTiming()) { - return syncExternalCliCredentials(store, options); - } - const startMs = Date.now(); - const mutated = syncExternalCliCredentials(store, options); - log.info( - `auth-store stage=external-cli-sync elapsedMs=${Date.now() - startMs} mutated=${mutated}`, - ); - return mutated; -} - -function shouldSyncExternalCliCredentials(options?: { syncExternalCli?: boolean }): boolean { - return options?.syncExternalCli !== false; -} - export function loadAuthProfileStore(): AuthProfileStore { const asStore = loadPersistedAuthProfileStore(); if (asStore) { - // Sync from external CLI tools on every load. - syncExternalCliCredentialsTimed(asStore); return overlayExternalAuthProfiles(asStore); } const legacy = loadLegacyAuthProfileStore(); @@ -186,12 +160,10 @@ export function loadAuthProfileStore(): AuthProfileStore { profiles: {}, }; applyLegacyAuthStore(store, legacy); - syncExternalCliCredentialsTimed(store); return overlayExternalAuthProfiles(store); } const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {} }; - syncExternalCliCredentialsTimed(store); return overlayExternalAuthProfiles(store); } @@ -216,11 +188,6 @@ function loadAuthProfileStoreForAgent( } const asStore = loadPersistedAuthProfileStore(agentDir); if (asStore) { - // Runtime secret activation must remain read-only: - // sync external CLI credentials in-memory, but never persist while readOnly. - if (shouldSyncExternalCliCredentials(options)) { - syncExternalCliCredentialsTimed(asStore, { log: !readOnly }); - } if (!readOnly) { writeCachedAuthProfileStore({ authPath, @@ -260,10 +227,6 @@ function loadAuthProfileStoreForAgent( } const mergedOAuth = mergeOAuthFileIntoStore(store); - // Keep external CLI credentials visible in runtime even during read-only loads. - if (shouldSyncExternalCliCredentials(options)) { - syncExternalCliCredentialsTimed(store, { log: !readOnly }); - } const forceReadOnly = process.env.OPENCLAW_AUTH_STORE_READONLY === "1"; const shouldWrite = !readOnly && !forceReadOnly && (legacy !== null || mergedOAuth); if (shouldWrite) { @@ -394,9 +357,6 @@ export function saveAuthProfileStore( saveJsonFile(authPath, payload); savePersistedAuthProfileState(store, agentDir); const runtimeStore = cloneAuthProfileStore(store); - if (shouldSyncExternalCliCredentials(options)) { - syncExternalCliCredentialsTimed(runtimeStore, { log: false }); - } writeCachedAuthProfileStore({ authPath, authMtimeMs: readAuthStoreMtimeMs(authPath), diff --git a/src/agents/auth-profiles/types.ts b/src/agents/auth-profiles/types.ts index ee62b2b5880..0ee11504310 100644 --- a/src/agents/auth-profiles/types.ts +++ b/src/agents/auth-profiles/types.ts @@ -2,7 +2,6 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { SecretRef } from "../../config/types.secrets.js"; export type OAuthProvider = string; -export type ExternalOAuthManager = "codex-cli" | "minimax-cli"; export type OAuthCredentials = { access: string; @@ -47,14 +46,6 @@ export type OAuthCredential = OAuthCredentials & { clientId?: string; email?: string; displayName?: string; - /** - * Compatibility/runtime metadata for CLI-managed OAuth entries. - * - * Core routing should prefer external-auth overlay contracts over direct - * branching on this field. Persisted stores may still carry it while older - * CLI sync paths remain supported. - */ - managedBy?: ExternalOAuthManager; }; export type AuthProfileCredential = ApiKeyCredential | TokenCredential | OAuthCredential; diff --git a/src/agents/cli-auth-epoch.ts b/src/agents/cli-auth-epoch.ts index 3b7721c207b..ae1c6d28173 100644 --- a/src/agents/cli-auth-epoch.ts +++ b/src/agents/cli-auth-epoch.ts @@ -98,7 +98,6 @@ function encodeAuthProfileCredential(credential: AuthProfileCredential): string credential.enterpriseUrl ?? null, credential.projectId ?? null, credential.accountId ?? null, - credential.managedBy ?? null, ]); } throw new Error("Unsupported auth profile credential type"); diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts index a94564a880f..fa6ea8cffe0 100644 --- a/src/agents/cli-runner/execute.ts +++ b/src/agents/cli-runner/execute.ts @@ -152,6 +152,17 @@ function formatCliEnvKeyList(keys: readonly string[]): string { return keys.length > 0 ? keys.join(",") : "none"; } +function buildCliEnvMcpLog(childEnv: Record): string { + return [ + `token=${childEnv.OPENCLAW_MCP_TOKEN ? "set" : "missing"}`, + `sessionKey=${childEnv.OPENCLAW_MCP_SESSION_KEY || ""}`, + `agentId=${childEnv.OPENCLAW_MCP_AGENT_ID || ""}`, + `accountId=${childEnv.OPENCLAW_MCP_ACCOUNT_ID || ""}`, + `messageChannel=${childEnv.OPENCLAW_MCP_MESSAGE_CHANNEL || ""}`, + `senderIsOwner=${childEnv.OPENCLAW_MCP_SENDER_IS_OWNER || ""}`, + ].join(" "); +} + export function buildCliEnvAuthLog(childEnv: Record): string { const hostKeys = listPresentCliAuthEnvKeys(process.env); const childKeys = listPresentCliAuthEnvKeys(childEnv); @@ -304,6 +315,13 @@ export async function executePreparedCliRun( }); cliBackendLog.info(`cli argv: ${backend.command} ${logArgs.join(" ")}`); cliBackendLog.info(`cli env auth: ${buildCliEnvAuthLog(env)}`); + if ( + env.OPENCLAW_MCP_TOKEN || + env.OPENCLAW_MCP_SESSION_KEY || + env.OPENCLAW_MCP_SENDER_IS_OWNER + ) { + cliBackendLog.info(`cli env mcp: ${buildCliEnvMcpLog(env)}`); + } } const noOutputTimeoutMs = resolveCliNoOutputTimeoutMs({ diff --git a/src/agents/live-model-filter.test.ts b/src/agents/live-model-filter.test.ts new file mode 100644 index 00000000000..ccfb8c79f9a --- /dev/null +++ b/src/agents/live-model-filter.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { shouldExcludeProviderFromDefaultHighSignalLiveSweep } from "./live-model-filter.js"; + +describe("shouldExcludeProviderFromDefaultHighSignalLiveSweep", () => { + it("excludes dedicated harness providers from the default high-signal sweep", () => { + expect( + shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: "codex", + useExplicitModels: false, + providerFilter: null, + }), + ).toBe(true); + expect( + shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: "openai-codex", + useExplicitModels: false, + providerFilter: null, + }), + ).toBe(true); + expect( + shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: "codex-cli", + useExplicitModels: false, + providerFilter: null, + }), + ).toBe(true); + }); + + it("keeps dedicated harness providers when explicitly requested by provider filter", () => { + expect( + shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: "codex", + useExplicitModels: false, + providerFilter: new Set(["codex"]), + }), + ).toBe(false); + expect( + shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: "openai-codex", + useExplicitModels: false, + providerFilter: new Set(["codex-cli"]), + }), + ).toBe(false); + expect( + shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: "openai-codex", + useExplicitModels: false, + providerFilter: new Set(["openai"]), + }), + ).toBe(false); + }); + + it("keeps dedicated harness providers when the caller uses explicit model selection", () => { + expect( + shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: "codex", + useExplicitModels: true, + providerFilter: null, + }), + ).toBe(false); + }); + + it("does not exclude ordinary providers", () => { + expect( + shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: "openai", + useExplicitModels: false, + providerFilter: null, + }), + ).toBe(false); + }); +}); diff --git a/src/agents/live-model-filter.ts b/src/agents/live-model-filter.ts index c44c8d89115..217677353cb 100644 --- a/src/agents/live-model-filter.ts +++ b/src/agents/live-model-filter.ts @@ -1,4 +1,6 @@ +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveProviderModernModelRef } from "../plugins/provider-runtime.js"; +import { resolveOwningPluginIdsForProvider } from "../plugins/providers.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { normalizeProviderId } from "./provider-id.js"; @@ -25,6 +27,7 @@ const HIGH_SIGNAL_LIVE_MODEL_PRIORITY = [ ] as const; export const DEFAULT_HIGH_SIGNAL_LIVE_MODEL_LIMIT = HIGH_SIGNAL_LIVE_MODEL_PRIORITY.length; +const DEFAULT_HIGH_SIGNAL_LIVE_EXCLUDED_PROVIDERS = new Set(["codex", "codex-cli", "openai-codex"]); const HIGH_SIGNAL_LIVE_MODEL_PRIORITY_INDEX = new Map( HIGH_SIGNAL_LIVE_MODEL_PRIORITY.map((key, index) => [key, index]), @@ -97,6 +100,77 @@ export function isHighSignalLiveModelRef(ref: ModelRef): boolean { return isHighSignalClaudeModelId(id); } +function sharesOwningPlugin(params: { + left: string; + right: string; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + ownerCache: Map; +}): boolean { + const resolveOwners = (provider: string): readonly string[] => { + const normalized = normalizeProviderId(provider); + const cached = params.ownerCache.get(normalized); + if (cached) { + return cached; + } + const owners = + resolveOwningPluginIdsForProvider({ + provider: normalized, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + }) ?? []; + params.ownerCache.set(normalized, owners); + return owners; + }; + + const leftOwners = resolveOwners(params.left); + const rightOwners = resolveOwners(params.right); + return leftOwners.some((owner) => rightOwners.includes(owner)); +} + +export function shouldExcludeProviderFromDefaultHighSignalLiveSweep(params: { + provider?: string | null; + useExplicitModels: boolean; + providerFilter?: ReadonlySet | null; + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; +}): boolean { + const provider = normalizeProviderId(params.provider ?? ""); + if (!provider || params.useExplicitModels) { + return false; + } + if (!DEFAULT_HIGH_SIGNAL_LIVE_EXCLUDED_PROVIDERS.has(provider)) { + return false; + } + const ownerCache = new Map(); + for (const filterEntry of params.providerFilter ?? []) { + const requestedProvider = normalizeProviderId(filterEntry); + if (requestedProvider === provider) { + return false; + } + if ( + requestedProvider && + sharesOwningPlugin({ + left: requestedProvider, + right: provider, + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + ownerCache, + }) + ) { + return false; + } + if (requestedProvider && DEFAULT_HIGH_SIGNAL_LIVE_EXCLUDED_PROVIDERS.has(requestedProvider)) { + return false; + } + } + return true; +} + function toCanonicalHighSignalLiveModelKey(ref: ModelRef): string | null { const provider = normalizeProviderId(ref.provider ?? ""); const rawId = normalizeLowercaseStringOrEmpty(ref.id); diff --git a/src/agents/model-fallback.run-embedded.e2e.test.ts b/src/agents/model-fallback.run-embedded.e2e.test.ts index 25e575984fd..39d766889e1 100644 --- a/src/agents/model-fallback.run-embedded.e2e.test.ts +++ b/src/agents/model-fallback.run-embedded.e2e.test.ts @@ -441,6 +441,28 @@ describe("runWithModelFallback + runEmbeddedPiAgent failover behavior", () => { }); }); + it("falls back across providers after a bare leading 402 quota-refresh assistant error", async () => { + await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { + await writeAuthStore(agentDir); + mockPrimaryErrorThenFallbackSuccess( + "402 You have reached your subscription quota limit. Please wait for automatic quota refresh in the rolling time window, upgrade to a higher plan, or use a Pay-As-You-Go API Key for unlimited access.", + ); + + const result = await runEmbeddedFallback({ + agentDir, + workspaceDir, + sessionKey: "agent:test:bare-402-cross-provider", + runId: "run:bare-402-cross-provider", + }); + + expect(result.provider).toBe("groq"); + expect(result.model).toBe("mock-2"); + expect(result.attempts[0]?.reason).toBe("rate_limit"); + expect(result.result.payloads?.[0]?.text ?? "").toContain("fallback ok"); + expectOpenAiThenGroqAttemptOrder(); + }); + }); + it("surfaces a bounded overloaded summary when every fallback candidate is overloaded", async () => { await withAgentWorkspace(async ({ agentDir, workspaceDir }) => { await writeAuthStore(agentDir); diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 7420423d489..e9e3b81c28c 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -485,6 +485,16 @@ describe("runWithModelFallback", () => { }); }); + it("falls back on bare leading 402 quota-refresh errors", async () => { + await expectFallsBackToHaiku({ + provider: "openai", + model: "gpt-4.1-mini", + firstError: new Error( + "402 You have reached your subscription quota limit. Please wait for automatic quota refresh in the rolling time window, upgrade to a higher plan, or use a Pay-As-You-Go API Key for unlimited access.", + ), + }); + }); + it("records 400 insufficient_quota payloads as billing during fallback", async () => { const cfg = makeCfg(); const run = vi 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 deleted file mode 100644 index 50cc8e2b1a4..00000000000 --- a/src/agents/models-config.normalizes-gemini-3-ids-preview-google-providers.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import type { ModelDefinitionConfig } from "../config/types.models.js"; -import { normalizeProviders } from "./models-config.providers.normalize.js"; -import type { ProviderConfig } from "./models-config.providers.secrets.js"; - -function createGoogleModel(id: string): ModelDefinitionConfig { - return { - id, - name: id, - api: "google-generative-ai", - reasoning: id.includes("pro"), - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1_048_576, - maxTokens: 65_536, - }; -} - -function buildGoogleProvider( - modelIds: string[], - overrides: Partial = {}, -): ProviderConfig { - return { - baseUrl: "https://generativelanguage.googleapis.com", - apiKey: "GEMINI_KEY", // pragma: allowlist secret - api: "google-generative-ai", - models: modelIds.map((id) => createGoogleModel(id)), - ...overrides, - } satisfies ProviderConfig; -} - -function normalizeForTest(providers: Record) { - const agentDir = mkdtempSync(join(tmpdir(), "openclaw-models-normalize-")); - return normalizeProviders({ providers, agentDir }) ?? {}; -} - -function normalizedModelIds(provider: ProviderConfig | undefined): string[] { - return provider?.models?.map((model) => model.id) ?? []; -} - -describe("models-config", () => { - it("normalizes gemini 3 ids to preview for google providers", () => { - const normalized = normalizeForTest({ - google: buildGoogleProvider(["gemini-3-pro", "gemini-3-flash"]), - }); - - expect(normalizedModelIds(normalized.google)).toEqual([ - "gemini-3-pro-preview", - "gemini-3-flash-preview", - ]); - }); - - it("normalizes the deprecated google flash preview id to the working preview id", () => { - const normalized = normalizeForTest({ - google: buildGoogleProvider(["gemini-3.1-flash-preview"]), - }); - - expect(normalizedModelIds(normalized.google)).toEqual(["gemini-3-flash-preview"]); - }); - - it("normalizes custom Google Generative AI providers by api instead of provider name", () => { - const normalized = normalizeForTest({ - "google-paid": buildGoogleProvider(["gemini-3-pro"]), - }); - - expect(normalizedModelIds(normalized["google-paid"])).toEqual(["gemini-3-pro-preview"]); - expect(normalized["google-paid"]?.baseUrl).toBe( - "https://generativelanguage.googleapis.com/v1beta", - ); - }); - - it("keeps built-in google normalization when api is only defined on models", () => { - const normalized = normalizeForTest({ - google: buildGoogleProvider(["gemini-3-flash"], { api: undefined }), - }); - - expect(normalizedModelIds(normalized.google)).toEqual(["gemini-3-flash-preview"]); - expect(normalized.google?.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta"); - }); -}); 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 25b90981068..b5ab75083be 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 @@ -13,6 +13,7 @@ import { import type { ProviderConfig as ModelsProviderConfig } from "./models-config.providers.secrets.js"; vi.mock("./auth-profiles/external-cli-sync.js", () => ({ + resolveExternalCliAuthProfiles: () => [], syncExternalCliCredentials: () => false, })); diff --git a/src/agents/models.profiles.live.test.ts b/src/agents/models.profiles.live.test.ts index f65527b6e24..3fd03e4e01c 100644 --- a/src/agents/models.profiles.live.test.ts +++ b/src/agents/models.profiles.live.test.ts @@ -14,6 +14,7 @@ import { isHighSignalLiveModelRef, resolveHighSignalLiveModelLimit, selectHighSignalLiveItems, + shouldExcludeProviderFromDefaultHighSignalLiveSweep, } from "./live-model-filter.js"; import { createLiveTargetMatcher } from "./live-target-matcher.js"; import { isLiveProfileKeyModeEnabled, isLiveTestEnabled } from "./live-test-helpers.js"; @@ -484,6 +485,17 @@ describeLive("live models (profile keys)", () => { continue; } if (!filter && useModern) { + if ( + shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: model.provider, + useExplicitModels: useExplicit, + providerFilter: providers, + config: cfg, + env: process.env, + }) + ) { + continue; + } if (!isHighSignalLiveModelRef({ provider: model.provider, id: model.id })) { continue; } diff --git a/src/agents/openai-ws-stream.e2e.test.ts b/src/agents/openai-ws-stream.e2e.test.ts index c645eed15cd..2f6392c10c0 100644 --- a/src/agents/openai-ws-stream.e2e.test.ts +++ b/src/agents/openai-ws-stream.e2e.test.ts @@ -11,7 +11,7 @@ * Run manually with a valid OPENAI_API_KEY: * OPENCLAW_LIVE_TEST=1 pnpm test:e2e -- src/agents/openai-ws-stream.e2e.test.ts * - * Skipped in CI — no API key available and we avoid billable external calls. + * This now runs only in the keyed live/release lanes. */ import type { @@ -292,7 +292,9 @@ describe("OpenAI WebSocket e2e", () => { expect(assistantText(secondDone)).toMatch(/TOOL_OK/); }, - 60_000, + // Live CI can spend more than a minute waiting for a stable follow-up turn + // when websocket reuse and tool callbacks contend with other provider lanes. + 120_000, ); testFn( @@ -376,10 +378,12 @@ describe("OpenAI WebSocket e2e", () => { const sid = freshSession("warmup"); const streamFn = openAIWsStreamModule.createOpenAIWebSocketStreamFn(API_KEY!, sid); const events = await collectEvents( - streamFn(model, makeContext("Reply with the word warmed."), { + streamFn(model, makeContext("Reply with exactly the single word warmed."), { transport: "websocket", openaiWsWarmup: true, - maxTokens: 32, + maxTokens: 8, + reasoningEffort: "none", + textVerbosity: "low", } as unknown as StreamFnParams[2]), ); @@ -391,7 +395,10 @@ describe("OpenAI WebSocket e2e", () => { expect(assistantText(done).toLowerCase()).toContain("warmed"); } }, - 45_000, + // This transport check does not need expensive reasoning. Keep the timeout + // generous for CI jitter, but force a minimal response shape so the first + // websocket request stays bounded. + 720_000, ); testFn( diff --git a/src/agents/pi-auth-json.test.ts b/src/agents/pi-auth-json.test.ts index 2213030ebc2..212756aaee4 100644 --- a/src/agents/pi-auth-json.test.ts +++ b/src/agents/pi-auth-json.test.ts @@ -9,10 +9,16 @@ vi.mock("../plugins/provider-runtime.js", () => ({ resolveExternalAuthProfilesWithPlugins: () => [], })); -vi.mock("./auth-profiles/external-cli-sync.js", () => ({ - readManagedExternalCliCredential: () => null, - syncExternalCliCredentials: () => false, -})); +vi.mock("./auth-profiles/external-cli-sync.js", async () => { + const actual = await vi.importActual( + "./auth-profiles/external-cli-sync.js", + ); + return { + ...actual, + readManagedExternalCliCredential: () => null, + resolveExternalCliAuthProfiles: () => [], + }; +}); type AuthProfileStore = Parameters[0]; diff --git a/src/agents/pi-bundle-mcp-runtime.test.ts b/src/agents/pi-bundle-mcp-runtime.test.ts index 2645cd667df..bb2ceece485 100644 --- a/src/agents/pi-bundle-mcp-runtime.test.ts +++ b/src/agents/pi-bundle-mcp-runtime.test.ts @@ -266,7 +266,7 @@ describe("session MCP runtime", () => { const serverScriptPath = path.join(pluginRoot, "servers", "bundle-probe.mjs"); await writeBundleProbeMcpServer(serverScriptPath, { startupCounterPath, - startupDelayMs: 100, + startupDelayMs: 10, pidPath, exitMarkerPath, }); diff --git a/src/agents/pi-bundle-mcp-test-harness.ts b/src/agents/pi-bundle-mcp-test-harness.ts index 2d4ad3a7d97..d3d3bc2b636 100644 --- a/src/agents/pi-bundle-mcp-test-harness.ts +++ b/src/agents/pi-bundle-mcp-test-harness.ts @@ -38,7 +38,7 @@ export async function waitForFileText(filePath: string, timeoutMs = 5_000): Prom if (content != null) { return content; } - await new Promise((resolve) => setTimeout(resolve, 25)); + await new Promise((resolve) => setTimeout(resolve, 5)); } throw new Error(`Timed out waiting for ${filePath}`); } diff --git a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts index e0999a1d9bb..0faace3647e 100644 --- a/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts +++ b/src/agents/pi-embedded-helpers.isbillingerrormessage.test.ts @@ -45,6 +45,9 @@ const GROQ_TOO_MANY_REQUESTS_MESSAGE = "429 Too Many Requests: Too many requests were sent in a given timeframe."; const GROQ_SERVICE_UNAVAILABLE_MESSAGE = "503 Service Unavailable: The server is temporarily unable to handle the request due to overloading or maintenance."; // pragma: allowlist secret +const PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE = "Proxy notice: Status: Internal Server Error"; +const MIXED_INTERNAL_SERVER_ERROR_STATUS_SAMPLE = `${PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE}; upstream connect error`; +const INTERNAL_SERVER_ERROR_STATUS_WITH_500_SAMPLE = `${PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE}; code:500`; function expectMessageMatches( matcher: (message: string) => boolean, @@ -64,6 +67,12 @@ function expectTimeoutFailoverSamples(samples: readonly string[]) { } } +function expectNotFailoverSample(sample: string) { + expect(isTimeoutErrorMessage(sample)).toBe(false); + expect(classifyFailoverReason(sample)).toBeNull(); + expect(isFailoverErrorMessage(sample)).toBe(false); +} + describe("isAuthPermanentErrorMessage", () => { it.each([ { @@ -811,6 +820,30 @@ describe("isFailoverErrorMessage", () => { expect(classifyFailoverReason(sample)).toBe(null); expect(isFailoverErrorMessage(sample)).toBe(false); }); + + it("matches google INTERNAL status errors as timeout", () => { + const sample = + "provider=google model=gemini-3.1-flash-lite-preview got status: INTERNAL upstream failure code:500"; + expect(isTimeoutErrorMessage(sample)).toBe(true); + expect(classifyFailoverReason(sample)).toBe("timeout"); + expect(isFailoverErrorMessage(sample)).toBe(true); + }); + + it("does not treat plain status text with internal-server-error wording as timeout", () => { + expectNotFailoverSample(PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE); + }); + + it("keeps mixed upstream server errors retryable when they also mention status prose", () => { + expect(isTimeoutErrorMessage(MIXED_INTERNAL_SERVER_ERROR_STATUS_SAMPLE)).toBe(false); + expect(classifyFailoverReason(MIXED_INTERNAL_SERVER_ERROR_STATUS_SAMPLE)).toBe("timeout"); + expect(isFailoverErrorMessage(MIXED_INTERNAL_SERVER_ERROR_STATUS_SAMPLE)).toBe(true); + }); + + it("keeps status prose retryable when it is explicitly paired with code 500", () => { + expect(isTimeoutErrorMessage(INTERNAL_SERVER_ERROR_STATUS_WITH_500_SAMPLE)).toBe(false); + expect(classifyFailoverReason(INTERNAL_SERVER_ERROR_STATUS_WITH_500_SAMPLE)).toBe("timeout"); + expect(isFailoverErrorMessage(INTERNAL_SERVER_ERROR_STATUS_WITH_500_SAMPLE)).toBe(true); + }); }); describe("parseImageSizeError", () => { @@ -911,6 +944,17 @@ describe("classifyFailoverReasonFromHttpStatus – 402 temporary limits", () => expect(classifyFailoverReasonFromHttpStatus(402, transientMessage)).toBe("rate_limit"); }); + it("classifies bare leading 402 quota-refresh payloads as rate_limit", () => { + const zenMuxMessage = + "402 You have reached your subscription quota limit. Please wait for automatic quota refresh in the rolling time window, upgrade to a higher plan, or use a Pay-As-You-Go API Key for unlimited access."; + expect(classifyFailoverReason(zenMuxMessage)).toBe("rate_limit"); + }); + + it("does not classify numeric references that merely start with 402", () => { + expect(classifyFailoverReason("402 items found in the database")).toBeNull(); + expect(classifyFailoverReason("402 records processed")).toBeNull(); + }); + it("keeps plan-upgrade 402 limit messages in billing", () => { const billingMessage = "Your usage limit has been reached. Please upgrade your plan."; expect(classifyFailoverReason(`HTTP 402 Payment Required: ${billingMessage}`)).toBe("billing"); @@ -1219,4 +1263,33 @@ describe("classifyProviderRuntimeFailureKind", () => { ), ).not.toBe("proxy"); }); + + it("classifies google-style INTERNAL status payloads as timeout", () => { + expect( + classifyFailoverReason( + 'ERROR provider=google model=gemini-3.1-flash-lite-preview: got status: INTERNAL, details: {"code":500,"status":"INTERNAL"}', + ), + ).toBe("timeout"); + expect( + classifyFailoverReason( + 'got status: INTERNAL. {"error":{"code":500,"message":"Internal error encountered.","status":"INTERNAL"}}', + ), + ).toBe("timeout"); + }); + + it("does not classify google-style INTERNAL payloads without a 500 code as timeout", () => { + const sample = + 'got status: INTERNAL. {"error":{"code":400,"message":"Request malformed","status":"INTERNAL"}}'; + expect(isTimeoutErrorMessage(sample)).toBe(false); + expect(classifyFailoverReason(sample)).toBeNull(); + expect(isFailoverErrorMessage(sample)).toBe(false); + }); + + it("does not classify plain status text with internal server error wording as timeout", () => { + expectNotFailoverSample(PLAIN_INTERNAL_SERVER_ERROR_STATUS_SAMPLE); + }); + + it("classifies internal server error status prose with code 500 as timeout", () => { + expect(classifyFailoverReason(INTERNAL_SERVER_ERROR_STATUS_WITH_500_SAMPLE)).toBe("timeout"); + }); }); diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 1b065df245d..73f50535189 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -295,6 +295,7 @@ const RETRYABLE_402_SCOPED_RESULT_HINTS = [ ] as const; const RAW_402_MARKER_RE = /["']?(?:status|code)["']?\s*[:=]\s*402\b|\bhttp\s*402\b|\berror(?:\s+code)?\s*[:=]?\s*402\b|\b(?:got|returned|received)\s+(?:a\s+)?402\b|^\s*402\s+payment required\b|^\s*402\s+.*used up your points\b/i; +const BARE_LEADING_402_RE = /^\s*402\b/i; const LEADING_402_WRAPPER_RE = /^(?:error[:\s-]+)?(?:(?:http\s*)?402(?:\s+payment required)?|payment required)(?:[:\s-]+|$)/i; const TIMEOUT_ERROR_CODES = new Set([ @@ -476,6 +477,15 @@ function hasRetryable402TransientSignal(text: string): boolean { ); } +function hasKnownBareLeading402Signal(text: string): boolean { + return ( + hasQuotaRefreshWindowSignal(text) || + hasExplicit402BillingSignal(text) || + isRateLimitErrorMessage(text) || + hasRetryable402TransientSignal(text) + ); +} + function normalize402Message(raw: string): string { return normalizeOptionalLowercaseString(raw)?.replace(LEADING_402_WRAPPER_RE, "").trim() ?? ""; } @@ -506,7 +516,14 @@ function classify402Message(message: string): PaymentRequiredFailoverReason { } function classifyFailoverReasonFrom402Text(raw: string): PaymentRequiredFailoverReason | null { - if (!RAW_402_MARKER_RE.test(raw)) { + if (RAW_402_MARKER_RE.test(raw)) { + return classify402Message(raw); + } + if (!BARE_LEADING_402_RE.test(raw)) { + return null; + } + const normalized = normalize402Message(raw); + if (!normalized || !hasKnownBareLeading402Signal(normalized)) { return null; } return classify402Message(raw); @@ -1157,6 +1174,15 @@ export function classifyFailoverReason( ): FailoverReason | null { const trimmed = raw.trim(); const leadingStatus = extractLeadingHttpStatus(trimmed); + const reasonFrom402Text = + leadingStatus?.code === 402 ? classifyFailoverReasonFrom402Text(trimmed) : null; + if ( + leadingStatus?.code === 402 && + !reasonFrom402Text && + !isHtmlErrorResponse(trimmed, leadingStatus.code) + ) { + return null; + } return failoverReasonFromClassification( classifyFailoverSignal({ status: leadingStatus?.code, diff --git a/src/agents/pi-embedded-helpers/failover-matches.ts b/src/agents/pi-embedded-helpers/failover-matches.ts index 2130ff7fa0f..14d7ede0b1c 100644 --- a/src/agents/pi-embedded-helpers/failover-matches.ts +++ b/src/agents/pi-embedded-helpers/failover-matches.ts @@ -42,6 +42,9 @@ const COMMON_AUTH_ERROR_PATTERNS = [ const ZAI_BILLING_CODE_1311_RE = /"code"\s*:\s*1311\b/; const ZAI_AUTH_CODE_1113_RE = /"code"\s*:\s*1113\b/; +const STATUS_INTERNAL_SERVER_ERROR_RE = /\bstatus:\s*internal server error\b/i; +const STATUS_INTERNAL_SERVER_ERROR_WITH_500_RE = + /^(?=[\s\S]*\bstatus:\s*internal server error\b)(?=[\s\S]*\bcode["']?\s*[:=]\s*500\b)/i; const ZAI_AUTH_ERROR_PATTERNS = [ // Z.ai: error 1113 = wrong endpoint or invalid credentials (#48988) @@ -95,6 +98,8 @@ const ERROR_PATTERNS = { "service unavailable", "deadline exceeded", "context deadline exceeded", + /^(?=[\s\S]*\bgot status:\s*internal\b)(?=[\s\S]*\bcode["']?\s*[:=]\s*500\b)/i, + /^(?=[\s\S]*["']status["']\s*:\s*["']internal["'])(?=[\s\S]*["']code["']\s*:\s*500\b)/i, "connection error", "network error", "network request failed", @@ -233,5 +238,13 @@ export function isOverloadedErrorMessage(raw: string): boolean { } export function isServerErrorMessage(raw: string): boolean { - return matchesErrorPatterns(raw, ERROR_PATTERNS.serverError); + const value = normalizeLowercaseStringOrEmpty(raw); + if (!value) { + return false; + } + if (STATUS_INTERNAL_SERVER_ERROR_WITH_500_RE.test(value)) { + return true; + } + const scrubbed = value.replace(STATUS_INTERNAL_SERVER_ERROR_RE, "").trim(); + return scrubbed.length > 0 && matchesErrorPatterns(scrubbed, ERROR_PATTERNS.serverError); } diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index ccf980e0d7b..a7b10aced41 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -106,6 +106,7 @@ import { compactWithSafetyTimeout, resolveCompactionTimeoutMs, } from "./compaction-safety-timeout.js"; +import { applyFinalEffectiveToolPolicy } from "./effective-tool-policy.js"; import { buildEmbeddedExtensionFactories } from "./extensions.js"; import { applyExtraParamsToAgent } from "./extra-params.js"; import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js"; @@ -554,11 +555,33 @@ export async function compactEmbeddedPiSessionDirect( ], }) : undefined; - const effectiveTools = [ - ...tools, - ...(bundleMcpRuntime?.tools ?? []), - ...(bundleLspRuntime?.tools ?? []), - ]; + const filteredBundledTools = applyFinalEffectiveToolPolicy({ + bundledTools: [...(bundleMcpRuntime?.tools ?? []), ...(bundleLspRuntime?.tools ?? [])], + config: params.config, + sandboxToolPolicy: sandbox?.tools, + sessionKey: sandboxSessionKey, + // Intentionally omit explicit agentId: the core tools just built with + // createOpenClawCodingTools(...) also omit it, so both paths resolve + // agentId the same way via resolveAgentIdFromSessionKey(sessionKey). + // Passing effectiveSkillAgentId here would diverge from the core-tool + // policy for legacy/non-agent session keys where the two sources fall + // back to different ids. + modelProvider: model.provider, + modelId, + messageProvider: resolvedMessageProvider, + agentAccountId: params.agentAccountId, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, + spawnedBy: params.spawnedBy, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + senderIsOwner: params.senderIsOwner, + warn: (message) => log.warn(message), + }); + const effectiveTools = [...tools, ...filteredBundledTools]; const allowedToolNames = collectAllowedToolNames({ tools: effectiveTools }); logProviderToolSchemaDiagnostics({ tools: effectiveTools, diff --git a/src/agents/pi-embedded-runner/effective-tool-policy.test.ts b/src/agents/pi-embedded-runner/effective-tool-policy.test.ts new file mode 100644 index 00000000000..2e210d067f2 --- /dev/null +++ b/src/agents/pi-embedded-runner/effective-tool-policy.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import type { AnyAgentTool } from "../tools/common.js"; +import { applyFinalEffectiveToolPolicy } from "./effective-tool-policy.js"; + +function makeTool(name: string, ownerOnly = false): AnyAgentTool { + return { + name, + label: name, + description: name, + parameters: { type: "object", properties: {} }, + ownerOnly, + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }; +} + +describe("applyFinalEffectiveToolPolicy", () => { + it("filters bundled tools through the configured allowlist", () => { + const filtered = applyFinalEffectiveToolPolicy({ + bundledTools: [makeTool("mcp__bundle__fs_delete"), makeTool("mcp__bundle__fs_read")], + config: { tools: { allow: ["mcp__bundle__fs_read"] } }, + warn: () => {}, + }); + + expect(filtered.map((tool) => tool.name)).toEqual(["mcp__bundle__fs_read"]); + }); + + it("applies owner-only filtering to bundled tools", () => { + const filtered = applyFinalEffectiveToolPolicy({ + bundledTools: [makeTool("mcp__bundle__read"), makeTool("mcp__bundle__admin", true)], + senderIsOwner: false, + warn: () => {}, + }); + + expect(filtered.map((tool) => tool.name)).toEqual(["mcp__bundle__read"]); + }); + + it("returns the empty array unchanged when there are no bundled tools", () => { + const filtered = applyFinalEffectiveToolPolicy({ + bundledTools: [], + config: { tools: { allow: ["message"] } }, + warn: () => {}, + }); + + expect(filtered).toEqual([]); + }); + + it("drops caller-provided groupId when it disagrees with session-derived group context", () => { + const warnings: string[] = []; + applyFinalEffectiveToolPolicy({ + bundledTools: [makeTool("mcp__bundle__read")], + // Session key encodes a concrete group (discord room 111); caller tries + // to override with a different group id so a more permissive group + // policy for group 222 could be consulted. + sessionKey: "agent:alice:discord:group:111", + groupId: "222", + groupChannel: "#different", + warn: (message) => warnings.push(message), + }); + + expect(warnings).toContain( + "effective tool policy: dropping caller-provided groupId that does not match session-derived group context", + ); + }); + + it("drops caller-provided groupId when session encodes no group context (fail-closed)", () => { + const warnings: string[] = []; + applyFinalEffectiveToolPolicy({ + bundledTools: [makeTool("mcp__bundle__read")], + // Direct/non-group session key: no session-derived group ids. A caller + // supplying a groupId here has no server-verified ground truth; it + // must be dropped so a spoofed group cannot reach a permissive policy. + sessionKey: "agent:alice:main", + groupId: "admin-group", + groupChannel: "#admin", + warn: (message) => warnings.push(message), + }); + + expect(warnings).toContain( + "effective tool policy: dropping caller-provided groupId that does not match session-derived group context", + ); + }); + + it("leaves groupId untouched when caller did not supply one", () => { + const warnings: string[] = []; + applyFinalEffectiveToolPolicy({ + bundledTools: [makeTool("mcp__bundle__read")], + sessionKey: "agent:alice:main", + warn: (message) => warnings.push(message), + }); + + expect(warnings).not.toContain( + "effective tool policy: dropping caller-provided groupId that does not match session-derived group context", + ); + }); + + it("does not emit unknown-entry warnings for core tool allowlists in the bundled pass", () => { + const warnings: string[] = []; + applyFinalEffectiveToolPolicy({ + bundledTools: [makeTool("mcp__bundle__read")], + // Core tool names like `read` and `exec` are not in the bundled-only + // input here, but they are valid core tools resolved by the first + // pass. The bundled pass must not warn about them as "unknown". + config: { tools: { allow: ["read", "exec", "mcp__bundle__read"] } }, + warn: (message) => warnings.push(message), + }); + + expect(warnings.some((w) => w.includes("unknown entries"))).toBe(false); + }); + + it("still warns on genuinely unknown entries in the bundled pass", () => { + const warnings: string[] = []; + applyFinalEffectiveToolPolicy({ + bundledTools: [makeTool("mcp__bundle__read")], + config: { tools: { allow: ["mcp__bundle__read", "totally-made-up-tool"] } }, + warn: (message) => warnings.push(message), + }); + + expect(warnings.some((w) => w.includes("totally-made-up-tool"))).toBe(true); + }); +}); diff --git a/src/agents/pi-embedded-runner/effective-tool-policy.ts b/src/agents/pi-embedded-runner/effective-tool-policy.ts new file mode 100644 index 00000000000..e6598ec6cfd --- /dev/null +++ b/src/agents/pi-embedded-runner/effective-tool-policy.ts @@ -0,0 +1,178 @@ +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { getPluginToolMeta } from "../../plugins/tools.js"; +import { isSubagentSessionKey } from "../../routing/session-key.js"; +import { + resolveEffectiveToolPolicy, + resolveGroupContextFromSessionKey, + resolveGroupToolPolicy, + resolveSubagentToolPolicyForSession, +} from "../pi-tools.policy.js"; +import { + applyToolPolicyPipeline, + buildDefaultToolPolicyPipelineSteps, + type ToolPolicyPipelineStep, +} from "../tool-policy-pipeline.js"; +import { + applyOwnerOnlyToolPolicy, + mergeAlsoAllowPolicy, + resolveToolProfilePolicy, +} from "../tool-policy.js"; +import type { AnyAgentTool } from "../tools/common.js"; + +/** + * Identity inputs used by `resolveGroupToolPolicy` to look up channel/group + * tool policy. These fields are an authorization signal (they can widen + * bundled-tool availability via a group-scoped allowlist), so callers MUST + * pass values derived from server-verified session metadata (session key, + * inbound transport event), not from tool-call or model-controlled input. + * The helper cross-checks caller-provided `groupId` against session-derived + * group ids and drops the caller value when they disagree, but it cannot + * detect drift on fields that have no session-bound counterpart. + */ +type FinalEffectiveToolPolicyParams = { + // Tools appended to the core tool set after `createOpenClawCodingTools()` + // has already applied owner-only and tool-policy filtering (e.g. bundled + // MCP/LSP tools). Only these are filtered here; re-running the pipeline over + // the already-filtered core tools would drop plugin tools whose WeakMap + // metadata no longer survives core-tool wrapping/normalization. + bundledTools: AnyAgentTool[]; + config?: OpenClawConfig; + sandboxToolPolicy?: { allow?: string[]; deny?: string[] }; + sessionKey?: string; + agentId?: string; + modelProvider?: string; + modelId?: string; + messageProvider?: string; + agentAccountId?: string | null; + groupId?: string | null; + groupChannel?: string | null; + groupSpace?: string | null; + spawnedBy?: string | null; + senderId?: string | null; + senderName?: string | null; + senderUsername?: string | null; + senderE164?: string | null; + senderIsOwner?: boolean; + warn: (message: string) => void; +}; + +function resolveTrustedGroupId(params: FinalEffectiveToolPolicyParams): { + groupId: string | null | undefined; + dropped: boolean; +} { + const callerGroupId = (params.groupId ?? "").trim(); + if (!callerGroupId) { + return { groupId: params.groupId, dropped: false }; + } + const sessionGroupIds = resolveGroupContextFromSessionKey(params.sessionKey).groupIds ?? []; + const spawnedGroupIds = resolveGroupContextFromSessionKey(params.spawnedBy).groupIds ?? []; + const trusted = [...sessionGroupIds, ...spawnedGroupIds]; + // Fail-closed: if the session/spawnedBy keys do not encode a group context, + // we have no server-verified ground truth to compare the caller value + // against. A non-group session (direct, subagent, cron) should not consult + // a group-scoped tool policy at all, and accepting the caller's groupId + // here would let an attacker widen bundled-tool availability by sending + // an arbitrary group id. + if (trusted.length === 0) { + return { groupId: null, dropped: true }; + } + if (trusted.includes(callerGroupId)) { + return { groupId: params.groupId, dropped: false }; + } + return { groupId: null, dropped: true }; +} + +export function applyFinalEffectiveToolPolicy( + params: FinalEffectiveToolPolicyParams, +): AnyAgentTool[] { + if (params.bundledTools.length === 0) { + return params.bundledTools; + } + const trustedGroup = resolveTrustedGroupId(params); + if (trustedGroup.dropped) { + params.warn( + "effective tool policy: dropping caller-provided groupId that does not match session-derived group context", + ); + } + const { + agentId, + globalPolicy, + globalProviderPolicy, + agentPolicy, + agentProviderPolicy, + profile, + providerProfile, + profileAlsoAllow, + providerProfileAlsoAllow, + } = resolveEffectiveToolPolicy({ + config: params.config, + sessionKey: params.sessionKey, + agentId: params.agentId, + modelProvider: params.modelProvider, + modelId: params.modelId, + }); + + const groupPolicy = resolveGroupToolPolicy({ + config: params.config, + sessionKey: params.sessionKey, + spawnedBy: params.spawnedBy, + messageProvider: params.messageProvider, + groupId: trustedGroup.groupId, + groupChannel: trustedGroup.dropped ? null : params.groupChannel, + groupSpace: trustedGroup.dropped ? null : params.groupSpace, + accountId: params.agentAccountId, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + }); + const profilePolicy = resolveToolProfilePolicy(profile); + const providerProfilePolicy = resolveToolProfilePolicy(providerProfile); + const profilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(profilePolicy, profileAlsoAllow); + const providerProfilePolicyWithAlsoAllow = mergeAlsoAllowPolicy( + providerProfilePolicy, + providerProfileAlsoAllow, + ); + const subagentPolicy = + isSubagentSessionKey(params.sessionKey) && params.sessionKey + ? resolveSubagentToolPolicyForSession(params.config, params.sessionKey) + : undefined; + const ownerFiltered = applyOwnerOnlyToolPolicy( + params.bundledTools, + params.senderIsOwner === true, + ); + // Suppress unavailable-core-tool warnings on every step of this pass. + // `applyToolPolicyPipeline` infers `coreToolNames` from the `tools` array + // it's filtering, and this pass only sees the bundled MCP/LSP subset. + // Normal core allowlist entries (e.g. `tools.allow: ["read", "exec"]`) + // would look "unknown" relative to that reduced set even though they are + // valid core names already resolved by `createOpenClawCodingTools()` in + // the first pass — keeping those warnings on would pollute logs and evict + // real diagnostics from the shared warning cache. Genuinely unknown + // entries (typos) still surface through the `otherEntries` path in + // `applyToolPolicyPipeline`. + const pipelineSteps: ToolPolicyPipelineStep[] = [ + ...buildDefaultToolPolicyPipelineSteps({ + profilePolicy: profilePolicyWithAlsoAllow, + profile, + profileUnavailableCoreWarningAllowlist: profilePolicy?.allow, + providerProfilePolicy: providerProfilePolicyWithAlsoAllow, + providerProfile, + providerProfileUnavailableCoreWarningAllowlist: providerProfilePolicy?.allow, + globalPolicy, + globalProviderPolicy, + agentPolicy, + agentProviderPolicy, + groupPolicy, + agentId, + }), + { policy: params.sandboxToolPolicy, label: "sandbox tools.allow" }, + { policy: subagentPolicy, label: "subagent tools.allow" }, + ].map((step) => ({ ...step, suppressUnavailableCoreToolWarning: true })); + return applyToolPolicyPipeline({ + tools: ownerFiltered, + toolMeta: (tool) => getPluginToolMeta(tool), + warn: params.warn, + steps: pipelineSteps, + }); +} diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index e4af90a2115..ac17fea0302 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -10,8 +10,8 @@ import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { enqueueCommandInLane } from "../../process/command-queue.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { sanitizeForLog } from "../../terminal/ansi.js"; -import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; import { resolveUserPath } from "../../utils.js"; +import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; import { resolveOpenClawAgentDir } from "../agent-paths.js"; import { hasConfiguredModelFallbacks, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index df08822a05e..76d3e9d1407 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -125,6 +125,7 @@ import { isRunnerAbortError } from "../abort.js"; import { isCacheTtlEligibleProvider, readLastCacheTtlTimestamp } from "../cache-ttl.js"; import { resolveCompactionTimeoutMs } from "../compaction-safety-timeout.js"; import { runContextEngineMaintenance } from "../context-engine-maintenance.js"; +import { applyFinalEffectiveToolPolicy } from "../effective-tool-policy.js"; import { buildEmbeddedExtensionFactories } from "../extensions.js"; import { applyExtraParamsToAgent, resolveAgentTransportOverride } from "../extra-params.js"; import { prepareGooglePromptCacheStreamFn } from "../google-prompt-cache.js"; @@ -678,11 +679,28 @@ export async function runEmbeddedAttempt( ], }) : undefined; - const effectiveTools = [ - ...tools, - ...(bundleMcpRuntime?.tools ?? []), - ...(bundleLspRuntime?.tools ?? []), - ]; + const filteredBundledTools = applyFinalEffectiveToolPolicy({ + bundledTools: [...(bundleMcpRuntime?.tools ?? []), ...(bundleLspRuntime?.tools ?? [])], + config: params.config, + sandboxToolPolicy: sandbox?.tools, + sessionKey: sandboxSessionKey, + agentId: sessionAgentId, + modelProvider: params.provider, + modelId: params.modelId, + messageProvider: params.messageChannel ?? params.messageProvider, + agentAccountId: params.agentAccountId, + groupId: params.groupId, + groupChannel: params.groupChannel, + groupSpace: params.groupSpace, + spawnedBy: params.spawnedBy, + senderId: params.senderId, + senderName: params.senderName, + senderUsername: params.senderUsername, + senderE164: params.senderE164, + senderIsOwner: params.senderIsOwner, + warn: (message) => log.warn(message), + }); + const effectiveTools = [...tools, ...filteredBundledTools]; const allowedToolNames = collectAllowedToolNames({ tools: effectiveTools, clientTools, diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts index 048194b71c1..ac65d8bf924 100644 --- a/src/agents/pi-tools.policy.ts +++ b/src/agents/pi-tools.policy.ts @@ -174,7 +174,7 @@ function buildScopedGroupIdCandidates(groupId?: string | null): string[] { return [raw]; } -function resolveGroupContextFromSessionKey(sessionKey?: string | null): { +export function resolveGroupContextFromSessionKey(sessionKey?: string | null): { channel?: string; groupIds?: string[]; } { diff --git a/src/agents/skills.compact-skill-paths.test.ts b/src/agents/skills.compact-skill-paths.test.ts index bd0a2fabb9e..764af374594 100644 --- a/src/agents/skills.compact-skill-paths.test.ts +++ b/src/agents/skills.compact-skill-paths.test.ts @@ -1,67 +1,69 @@ -import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { buildWorkspaceSkillsPrompt } from "./skills.js"; -import { writeSkill } from "./skills.test-helpers.js"; - -async function withTempWorkspace(run: (workspaceDir: string) => Promise) { - const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compact-")); - try { - await run(workspaceDir); - } finally { - await fs.rm(workspaceDir, { recursive: true, force: true }); - } -} +import { createCanonicalFixtureSkill } from "./skills.test-helpers.js"; describe("compactSkillPaths", () => { - it("replaces home directory prefix with ~ in skill locations", async () => { - await withTempWorkspace(async (workspaceDir) => { - const skillDir = path.join(workspaceDir, "skills", "test-skill"); + it("replaces home directory prefix with ~ in skill locations", () => { + const home = os.homedir(); + const skillDir = path.join(home, ".openclaw-test-skills", "test-skill"); - await writeSkill({ - dir: skillDir, - name: "test-skill", - description: "A test skill for path compaction", - }); - - const prompt = buildWorkspaceSkillsPrompt(workspaceDir, { - bundledSkillsDir: path.join(workspaceDir, ".bundled-empty"), - managedSkillsDir: path.join(workspaceDir, ".managed-empty"), - }); - - const home = os.homedir(); - // The prompt should NOT contain the absolute home directory path - // when the skill is under the home directory (which tmpdir usually is on macOS) - if (workspaceDir.startsWith(home)) { - expect(prompt).not.toContain(home + path.sep); - expect(prompt).toContain("~/"); - } - - // The skill name and description should still be present - expect(prompt).toContain("test-skill"); - expect(prompt).toContain("A test skill for path compaction"); + const prompt = buildWorkspaceSkillsPrompt(home, { + entries: [ + { + skill: createCanonicalFixtureSkill({ + name: "test-skill", + description: "A test skill for path compaction", + filePath: path.join(skillDir, "SKILL.md"), + baseDir: skillDir, + source: "test", + }), + frontmatter: {}, + metadata: undefined, + invocation: { disableModelInvocation: false, userInvocable: true }, + exposure: { + includeInRuntimeRegistry: true, + includeInAvailableSkillsPrompt: true, + userInvocable: true, + }, + }, + ], }); + + expect(prompt).not.toContain(home + path.sep); + expect(prompt).toContain("~/"); + expect(prompt).toContain("test-skill"); + expect(prompt).toContain("A test skill for path compaction"); }); - it("preserves paths outside home directory", async () => { - // Skills outside ~ should keep their absolute paths - await withTempWorkspace(async (workspaceDir) => { - const skillDir = path.join(workspaceDir, "skills", "ext-skill"); + it("preserves paths outside home directory", () => { + const outsideHome = path.join(path.parse(os.homedir()).root, "openclaw-external-skills"); + const skillDir = path.join(outsideHome, "skills", "ext-skill"); - await writeSkill({ - dir: skillDir, - name: "ext-skill", - description: "External skill", - }); - - const prompt = buildWorkspaceSkillsPrompt(workspaceDir, { - bundledSkillsDir: path.join(workspaceDir, ".bundled-empty"), - managedSkillsDir: path.join(workspaceDir, ".managed-empty"), - }); - - // Should still contain a valid location tag - expect(prompt).toMatch(/[^<]+SKILL\.md<\/location>/); + const prompt = buildWorkspaceSkillsPrompt(outsideHome, { + entries: [ + { + skill: createCanonicalFixtureSkill({ + name: "ext-skill", + description: "External skill", + filePath: path.join(skillDir, "SKILL.md"), + baseDir: skillDir, + source: "test", + }), + frontmatter: {}, + metadata: undefined, + invocation: { disableModelInvocation: false, userInvocable: true }, + exposure: { + includeInRuntimeRegistry: true, + includeInAvailableSkillsPrompt: true, + userInvocable: true, + }, + }, + ], }); + + expect(prompt).toMatch(/[^<]+SKILL\.md<\/location>/); + expect(prompt).toContain(path.join(skillDir, "SKILL.md")); }); }); diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index a9d184aac46..d9f1d5d1262 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -261,7 +261,7 @@ describe("buildWorkspaceSkillCommandSpecs", () => { config, }); - const pluginRoot = path.join(tempHome!.home, ".openclaw", "extensions", "compound-bundle"); + const pluginRoot = path.join(workspaceDir, ".openclaw", "extensions", "compound-bundle"); await fs.mkdir(path.join(pluginRoot, ".claude-plugin"), { recursive: true }); await fs.mkdir(path.join(pluginRoot, "commands"), { recursive: true }); await fs.writeFile( diff --git a/src/agents/skills/workspace.ts b/src/agents/skills/workspace.ts index a90480a144f..cb488daa407 100644 --- a/src/agents/skills/workspace.ts +++ b/src/agents/skills/workspace.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { resolveOsHomeDir } from "../../infra/home-dir.js"; import { isPathInside } from "../../infra/path-guards.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; @@ -40,6 +41,10 @@ const skillsLogger = createSubsystemLogger("skills"); * Saves ~5–6 tokens per skill path × N skills ≈ 400–600 tokens total. */ function resolveUserHomeDir(): string | undefined { + return resolveOsHomeDir(process.env, os.homedir); +} + +function resolveNativeUserHomeDir(): string | undefined { try { return path.resolve(os.homedir()); } catch { @@ -48,7 +53,9 @@ function resolveUserHomeDir(): string | undefined { } function resolveCompactHomePrefixes(): string[] { - const homes = [resolveHomeDir(), resolveUserHomeDir()].filter((home): home is string => !!home); + const homes = [resolveHomeDir(), resolveUserHomeDir(), resolveNativeUserHomeDir()].filter( + (home): home is string => !!home, + ); const resolvedHomes = homes.map((home) => path.resolve(home)); const realHomes = resolvedHomes .map((home) => tryRealpath(home)) diff --git a/src/agents/tools/tts-tool.test.ts b/src/agents/tools/tts-tool.test.ts index f559166d7bb..7faa0790950 100644 --- a/src/agents/tools/tts-tool.test.ts +++ b/src/agents/tools/tts-tool.test.ts @@ -41,4 +41,17 @@ describe("createTtsTool", () => { }); expect(JSON.stringify(result.content)).not.toContain("MEDIA:"); }); + + it("throws when synthesis fails so the agent records a tool error", async () => { + textToSpeechSpy.mockResolvedValue({ + success: false, + error: "TTS conversion failed: openai: not configured", + }); + + const tool = createTtsTool(); + + await expect(tool.execute("call-1", { text: "hello" })).rejects.toThrow( + "TTS conversion failed: openai: not configured", + ); + }); }); diff --git a/src/agents/tools/tts-tool.ts b/src/agents/tools/tts-tool.ts index 38ac7b4e65f..ba37c73e0c4 100644 --- a/src/agents/tools/tts-tool.ts +++ b/src/agents/tools/tts-tool.ts @@ -49,15 +49,7 @@ export function createTtsTool(opts?: { }; } - return { - content: [ - { - type: "text", - text: result.error ?? "TTS conversion failed", - }, - ], - details: { error: result.error }, - }; + throw new Error(result.error ?? "TTS conversion failed"); }, }; } diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 989c835f959..a4309e1232e 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -19,6 +19,47 @@ import { } from "./commands-registry.js"; import type { ChatCommandDefinition } from "./commands-registry.types.js"; +type NativeCommandNameResolver = (params: { commandKey: string; defaultName: string }) => string; + +function installNativeCommandOverridePlugin(params: { + id: "discord" | "slack"; + resolveNativeCommandName: NativeCommandNameResolver; +}) { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: params.id, + plugin: { + ...createChannelTestPluginBase({ + id: params.id, + capabilities: { nativeCommands: true, chatTypes: ["direct"] }, + }), + commands: { + resolveNativeCommandName: params.resolveNativeCommandName, + }, + }, + source: "test", + }, + ]), + ); +} + +function installDiscordNativeCommandOverrides() { + installNativeCommandOverridePlugin({ + id: "discord", + resolveNativeCommandName: ({ commandKey, defaultName }) => + commandKey === "tts" ? "voice" : defaultName, + }); +} + +function installSlackNativeCommandOverrides() { + installNativeCommandOverridePlugin({ + id: "slack", + resolveNativeCommandName: ({ commandKey, defaultName }) => + commandKey === "status" ? "agentstatus" : defaultName, + }); +} + beforeEach(() => { vi.doUnmock("../channels/plugins/index.js"); setActivePluginRegistry(createTestRegistry([])); @@ -112,6 +153,7 @@ describe("commands registry", () => { }); it("applies discord native command overrides", () => { + installDiscordNativeCommandOverrides(); const native = listNativeCommandSpecsForConfig( { commands: { native: true } }, { provider: "discord" }, @@ -122,6 +164,7 @@ describe("commands registry", () => { }); it("applies slack native command overrides", () => { + installSlackNativeCommandOverrides(); const native = listNativeCommandSpecsForConfig( { commands: { native: true } }, { provider: "slack" }, @@ -129,9 +172,28 @@ describe("commands registry", () => { expect(native.find((spec) => spec.name === "agentstatus")).toBeTruthy(); expect(findCommandByNativeName("agentstatus", "slack")?.key).toBe("status"); expect(findCommandByNativeName("status", "slack")).toBeUndefined(); + expect( + findCommandByNativeName("agentstatus", "slack", { + includeBundledChannelFallback: false, + })?.key, + ).toBe("status"); + expect( + findCommandByNativeName("status", "slack", { + includeBundledChannelFallback: false, + }), + ).toBeUndefined(); + }); + + it("can resolve default native command names without loading bundled channel fallbacks", () => { + expect( + findCommandByNativeName("status", "discord", { + includeBundledChannelFallback: false, + })?.key, + ).toBe("status"); }); it("keeps discord native command specs within slash-command limits", () => { + installDiscordNativeCommandOverrides(); const cfg = { commands: { native: true } }; const native = listNativeCommandSpecsForConfig(cfg, { provider: "discord" }); for (const spec of native) { diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 8a5555cd268..4fb7687b9d9 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -1,7 +1,7 @@ import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; import type { SkillCommandSpec } from "../agents/skills.js"; -import { getChannelPlugin } from "../channels/plugins/index.js"; +import { getChannelPlugin, getLoadedChannelPlugin } from "../channels/plugins/index.js"; import type { OpenClawConfig } from "../config/types.js"; import { normalizeLowercaseStringOrEmpty, @@ -55,15 +55,27 @@ export type { ShouldHandleTextCommandsParams, } from "./commands-registry.types.js"; -function resolveNativeName(command: ChatCommandDefinition, provider?: string): string | undefined { +type NativeCommandProviderLookupOptions = { + includeBundledChannelFallback?: boolean; +}; + +function resolveNativeName( + command: ChatCommandDefinition, + provider?: string, + options?: NativeCommandProviderLookupOptions, +): string | undefined { if (!command.nativeName) { return undefined; } if (!provider) { return command.nativeName; } + const channelPlugin = + options?.includeBundledChannelFallback === false + ? getLoadedChannelPlugin(provider) + : getChannelPlugin(provider); return ( - getChannelPlugin(provider)?.commands?.resolveNativeCommandName?.({ + channelPlugin?.commands?.resolveNativeCommandName?.({ commandKey: command.key, defaultName: command.nativeName, }) ?? command.nativeName @@ -108,6 +120,7 @@ export function listNativeCommandSpecsForConfig( export function findCommandByNativeName( name: string, provider?: string, + options?: NativeCommandProviderLookupOptions, ): ChatCommandDefinition | undefined { const normalized = normalizeOptionalLowercaseString(name); if (!normalized) { @@ -116,7 +129,8 @@ export function findCommandByNativeName( return getChatCommands().find( (command) => command.scope !== "text" && - normalizeOptionalLowercaseString(resolveNativeName(command, provider)) === normalized, + normalizeOptionalLowercaseString(resolveNativeName(command, provider, options)) === + normalized, ); } diff --git a/src/auto-reply/inbound.group-require-mention-test-plugins.ts b/src/auto-reply/inbound.group-require-mention-test-plugins.ts new file mode 100644 index 00000000000..13c78cb0c14 --- /dev/null +++ b/src/auto-reply/inbound.group-require-mention-test-plugins.ts @@ -0,0 +1,118 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; + +type TestChannelGroupContext = { + cfg: OpenClawConfig; + groupId?: string | null; + groupChannel?: string | null; + groupSpace?: string | null; + accountId?: string | null; +}; + +function normalizeTestSlug(raw?: string | null): string { + return raw?.trim().replace(/^#/, "").toLowerCase() ?? ""; +} + +function resolveDiscordRequireMentionForTest(params: TestChannelGroupContext): boolean { + const discordCfg = params.cfg.channels?.discord as + | { + guilds?: Record< + string, + { + requireMention?: boolean; + slug?: string; + channels?: Record; + } + >; + } + | undefined; + const guilds = discordCfg?.guilds; + if (!guilds) { + return true; + } + const space = params.groupSpace?.trim() ?? ""; + const spaceSlug = normalizeTestSlug(space); + const guild = + (space ? guilds[space] : undefined) ?? + (spaceSlug ? guilds[spaceSlug] : undefined) ?? + Object.values(guilds).find((entry) => normalizeTestSlug(entry?.slug) === spaceSlug) ?? + guilds["*"]; + const channelSlug = normalizeTestSlug(params.groupChannel); + const channel = + (params.groupId ? guild?.channels?.[params.groupId] : undefined) ?? + (channelSlug ? guild?.channels?.[channelSlug] : undefined) ?? + (channelSlug ? guild?.channels?.[`#${channelSlug}`] : undefined); + return channel?.requireMention ?? guild?.requireMention ?? true; +} + +function resolveSlackRequireMentionForTest(params: TestChannelGroupContext): boolean { + const slackCfg = params.cfg.channels?.slack as + | { + defaultAccount?: string; + channels?: Record; + accounts?: Record }>; + } + | undefined; + if (!slackCfg) { + return true; + } + const accountId = params.accountId ?? slackCfg.defaultAccount; + const channels = + (accountId ? slackCfg.accounts?.[accountId]?.channels : undefined) ?? slackCfg.channels; + if (!channels) { + return true; + } + const channelName = params.groupChannel?.trim().replace(/^#/, ""); + const channelSlug = normalizeTestSlug(channelName); + const candidates = [ + params.groupId?.trim(), + channelName ? `#${channelName}` : undefined, + channelName, + channelSlug, + "*", + ]; + for (const candidate of candidates) { + if (!candidate) { + continue; + } + const entry = channels[candidate]; + if (typeof entry?.requireMention === "boolean") { + return entry.requireMention; + } + } + return true; +} + +export function installGroupRequireMentionTestPlugins() { + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "discord", + plugin: { + ...createChannelTestPluginBase({ id: "discord" }), + groups: { resolveRequireMention: resolveDiscordRequireMentionForTest }, + }, + source: "test", + }, + { + pluginId: "slack", + plugin: { + ...createChannelTestPluginBase({ id: "slack" }), + groups: { resolveRequireMention: resolveSlackRequireMentionForTest }, + }, + source: "test", + }, + { + pluginId: "line", + plugin: createChannelTestPluginBase({ id: "line" }), + source: "test", + }, + { + pluginId: "bluebubbles", + plugin: createChannelTestPluginBase({ id: "bluebubbles" }), + source: "test", + }, + ]), + ); +} diff --git a/src/auto-reply/inbound.test.ts b/src/auto-reply/inbound.test.ts index d8bd1d76be9..c83cc4bfb89 100644 --- a/src/auto-reply/inbound.test.ts +++ b/src/auto-reply/inbound.test.ts @@ -1,11 +1,12 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { GroupKeyResolution } from "../config/sessions.js"; import { resetPluginRuntimeStateForTest } from "../plugins/runtime.js"; import { createInboundDebouncer } from "./inbound-debounce.js"; +import { installGroupRequireMentionTestPlugins } from "./inbound.group-require-mention-test-plugins.js"; import { resolveGroupRequireMention } from "./reply/groups.js"; import { finalizeInboundContext } from "./reply/inbound-context.js"; import { @@ -809,8 +810,12 @@ describe("mention helpers", () => { }); describe("resolveGroupRequireMention", () => { - it("respects Discord guild/channel requireMention settings", async () => { + beforeEach(() => { resetPluginRuntimeStateForTest(); + installGroupRequireMentionTestPlugins(); + }); + + it("respects Discord guild/channel requireMention settings", async () => { const cfg: OpenClawConfig = { channels: { discord: { @@ -841,7 +846,6 @@ describe("resolveGroupRequireMention", () => { }); it("respects Slack channel requireMention settings", async () => { - resetPluginRuntimeStateForTest(); const cfg: OpenClawConfig = { channels: { slack: { @@ -867,7 +871,6 @@ describe("resolveGroupRequireMention", () => { }); it("uses Slack fallback resolver semantics for default-account wildcard channels", async () => { - resetPluginRuntimeStateForTest(); const cfg: OpenClawConfig = { channels: { slack: { @@ -898,7 +901,6 @@ describe("resolveGroupRequireMention", () => { }); it("keeps core reply-stage resolution aligned for Slack default-account wildcard fallbacks", async () => { - resetPluginRuntimeStateForTest(); const cfg: OpenClawConfig = { channels: { slack: { @@ -929,7 +931,6 @@ describe("resolveGroupRequireMention", () => { }); it("uses Discord fallback resolver semantics for guild slug matches", async () => { - resetPluginRuntimeStateForTest(); const cfg: OpenClawConfig = { channels: { discord: { @@ -959,7 +960,6 @@ describe("resolveGroupRequireMention", () => { }); it("keeps core reply-stage resolution aligned for Discord slug + wildcard guild fallbacks", async () => { - resetPluginRuntimeStateForTest(); const cfg: OpenClawConfig = { channels: { discord: { @@ -991,7 +991,6 @@ describe("resolveGroupRequireMention", () => { }); it("respects LINE prefixed group keys in reply-stage requireMention resolution", async () => { - resetPluginRuntimeStateForTest(); const cfg: OpenClawConfig = { channels: { line: { @@ -1016,7 +1015,6 @@ describe("resolveGroupRequireMention", () => { }); it("preserves plugin-backed channel requireMention resolution", async () => { - resetPluginRuntimeStateForTest(); const cfg: OpenClawConfig = { channels: { bluebubbles: { diff --git a/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts b/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts index e905c984d88..cd793d07420 100644 --- a/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts +++ b/src/auto-reply/reply.stage-sandbox-media.scp-remote-path.test.ts @@ -13,8 +13,12 @@ const sandboxMocks = vi.hoisted(() => ({ const childProcessMocks = vi.hoisted(() => ({ spawn: vi.fn(), })); +const mediaRootMocks = vi.hoisted(() => ({ + resolveChannelRemoteInboundAttachmentRoots: vi.fn(), +})); vi.mock("../agents/sandbox.js", () => sandboxMocks); +vi.mock("../media/channel-inbound-roots.js", () => mediaRootMocks); vi.mock("node:child_process", async () => { const actual = await vi.importActual("node:child_process"); return { @@ -28,6 +32,7 @@ import { stageSandboxMedia } from "./reply/stage-sandbox-media.js"; afterEach(() => { vi.restoreAllMocks(); childProcessMocks.spawn.mockClear(); + mediaRootMocks.resolveChannelRemoteInboundAttachmentRoots.mockReset(); }); function createRemoteStageParams(home: string): { @@ -38,6 +43,9 @@ function createRemoteStageParams(home: string): { } { const sessionKey = "agent:main:main"; vi.mocked(sandboxMocks.ensureSandboxWorkspaceForSession).mockResolvedValue(null); + mediaRootMocks.resolveChannelRemoteInboundAttachmentRoots.mockReturnValue([ + "/Users/demo/Library/Messages/Attachments", + ]); return { cfg: createSandboxMediaStageConfig(home), workspaceDir: join(home, "openclaw"), diff --git a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts index b71ae36c656..d4cbbd12630 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts @@ -1,7 +1,8 @@ import fs from "node:fs/promises"; import path, { basename, dirname, join } from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MEDIA_MAX_BYTES } from "../media/store.js"; +import { stageSandboxMedia } from "./reply/stage-sandbox-media.js"; import { createSandboxMediaContexts, createSandboxMediaStageConfig, @@ -10,93 +11,111 @@ import { const sandboxMocks = vi.hoisted(() => ({ ensureSandboxWorkspaceForSession: vi.fn(), + assertSandboxPath: vi.fn(), })); const childProcessMocks = vi.hoisted(() => ({ spawn: vi.fn(), })); -const sandboxModuleId = new URL("../agents/sandbox.js", import.meta.url).pathname; -const fsSafeModuleId = new URL("../infra/fs-safe.js", import.meta.url).pathname; +const fsSafeMocks = vi.hoisted(() => { + class MockSafeOpenError extends Error { + readonly code: string; -let stageSandboxMedia: typeof import("./reply/stage-sandbox-media.js").stageSandboxMedia; + constructor(code: string, message: string) { + super(message); + this.name = "SafeOpenError"; + this.code = code; + } + } -async function loadFreshStageSandboxMediaModuleForTest() { - vi.resetModules(); - vi.doMock(sandboxModuleId, () => sandboxMocks); - vi.doMock("node:child_process", async () => { - const actual = await vi.importActual("node:child_process"); - return { - ...actual, - spawn: childProcessMocks.spawn, - }; - }); - vi.doMock(fsSafeModuleId, async () => { - const actual = await vi.importActual(fsSafeModuleId); - return { - ...actual, - copyFileWithinRoot: vi.fn(async ({ sourcePath, rootDir, relativePath, maxBytes }) => { - const sourceStat = await fs.stat(sourcePath); - if (typeof maxBytes === "number" && sourceStat.size > maxBytes) { - throw new actual.SafeOpenError( - "too-large", - `file exceeds limit of ${maxBytes} bytes (got ${sourceStat.size})`, - ); - } - - await fs.mkdir(rootDir, { recursive: true }); - const rootReal = await fs.realpath(rootDir); - const destPath = path.resolve(rootReal, relativePath); - const rootPrefix = `${rootReal}${path.sep}`; - if (destPath !== rootReal && !destPath.startsWith(rootPrefix)) { - throw new actual.SafeOpenError("outside-workspace", "file is outside workspace root"); - } - - const parentDir = dirname(destPath); - const relativeParent = path.relative(rootReal, parentDir); - if (relativeParent && !relativeParent.startsWith("..")) { - let cursor = rootReal; - for (const segment of relativeParent.split(path.sep)) { - cursor = path.join(cursor, segment); - try { - const stat = await fs.lstat(cursor); - if (stat.isSymbolicLink()) { - throw new actual.SafeOpenError("symlink", "symlink not allowed"); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - await fs.mkdir(cursor, { recursive: true }); - continue; - } - throw error; - } - } - } - - try { - const destStat = await fs.lstat(destPath); - if (destStat.isSymbolicLink()) { - throw new actual.SafeOpenError("symlink", "symlink not allowed"); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - throw error; - } - } - - await fs.copyFile(sourcePath, destPath); - }), - }; - }); - const replyModule = await import("./reply/stage-sandbox-media.js"); return { - stageSandboxMedia: replyModule.stageSandboxMedia, + SafeOpenError: MockSafeOpenError, + copyFileWithinRoot: vi.fn(), + readLocalFileSafely: vi.fn(), }; +}); +const mediaRootMocks = vi.hoisted(() => ({ + resolveChannelRemoteInboundAttachmentRoots: vi.fn(), +})); + +vi.mock("../agents/sandbox.js", () => sandboxMocks); +vi.mock("../agents/sandbox-paths.js", () => ({ + assertSandboxPath: sandboxMocks.assertSandboxPath, +})); +vi.mock("node:child_process", () => childProcessMocks); +vi.mock("../infra/fs-safe.js", () => fsSafeMocks); +vi.mock("../media/channel-inbound-roots.js", () => mediaRootMocks); + +async function copyFileWithinRootForTest({ + sourcePath, + rootDir, + relativePath, + maxBytes, +}: { + sourcePath: string; + rootDir: string; + relativePath: string; + maxBytes?: number; +}) { + const sourceStat = await fs.stat(sourcePath); + if (typeof maxBytes === "number" && sourceStat.size > maxBytes) { + throw new fsSafeMocks.SafeOpenError( + "too-large", + `file exceeds limit of ${maxBytes} bytes (got ${sourceStat.size})`, + ); + } + + await fs.mkdir(rootDir, { recursive: true }); + const rootReal = await fs.realpath(rootDir); + const destPath = path.resolve(rootReal, relativePath); + const rootPrefix = `${rootReal}${path.sep}`; + if (destPath !== rootReal && !destPath.startsWith(rootPrefix)) { + throw new fsSafeMocks.SafeOpenError("outside-workspace", "file is outside workspace root"); + } + + const parentDir = dirname(destPath); + const relativeParent = path.relative(rootReal, parentDir); + if (relativeParent && !relativeParent.startsWith("..")) { + let cursor = rootReal; + for (const segment of relativeParent.split(path.sep)) { + cursor = path.join(cursor, segment); + try { + const stat = await fs.lstat(cursor); + if (stat.isSymbolicLink()) { + throw new fsSafeMocks.SafeOpenError("symlink", "symlink not allowed"); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + await fs.mkdir(cursor, { recursive: true }); + continue; + } + throw error; + } + } + } + + try { + const destStat = await fs.lstat(destPath); + if (destStat.isSymbolicLink()) { + throw new fsSafeMocks.SafeOpenError("symlink", "symlink not allowed"); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + + await fs.copyFile(sourcePath, destPath); } -async function loadStageSandboxMediaInTempHome() { +beforeEach(() => { sandboxMocks.ensureSandboxWorkspaceForSession.mockReset(); + sandboxMocks.assertSandboxPath.mockReset().mockResolvedValue({ resolved: "", relative: "" }); childProcessMocks.spawn.mockClear(); - ({ stageSandboxMedia } = await loadFreshStageSandboxMediaModuleForTest()); -} + fsSafeMocks.copyFileWithinRoot.mockReset().mockImplementation(copyFileWithinRootForTest); + mediaRootMocks.resolveChannelRemoteInboundAttachmentRoots + .mockReset() + .mockReturnValue(["/Users/demo/Library/Messages/Attachments"]); +}); afterEach(() => { vi.restoreAllMocks(); @@ -134,7 +153,6 @@ async function writeInboundMedia( describe("stageSandboxMedia", () => { it("stages allowed media and blocks unsafe paths", async () => { await withSandboxMediaTempHome("openclaw-triggers-", async (home) => { - await loadStageSandboxMediaInTempHome(); const { cfg, workspaceDir, sandboxDir } = await setupSandboxWorkspace(home); { @@ -179,6 +197,7 @@ describe("stageSandboxMedia", () => { } { + expect(mediaRootMocks.resolveChannelRemoteInboundAttachmentRoots).not.toHaveBeenCalled(); childProcessMocks.spawn.mockClear(); const { ctx, sessionCtx } = createSandboxMediaContexts("/etc/passwd"); ctx.Provider = "imessage"; @@ -202,7 +221,6 @@ describe("stageSandboxMedia", () => { it("blocks destination symlink escapes when staging into sandbox workspace", async () => { await withSandboxMediaTempHome("openclaw-triggers-", async (home) => { - await loadStageSandboxMediaInTempHome(); const { cfg, workspaceDir, sandboxDir } = await setupSandboxWorkspace(home); const mediaPath = await writeInboundMedia(home, "payload.txt", "PAYLOAD"); @@ -234,7 +252,6 @@ describe("stageSandboxMedia", () => { it("skips oversized media staging and keeps original media paths", async () => { await withSandboxMediaTempHome("openclaw-triggers-", async (home) => { - await loadStageSandboxMediaInTempHome(); const { cfg, workspaceDir, sandboxDir } = await setupSandboxWorkspace(home); const mediaPath = await writeInboundMedia( diff --git a/src/auto-reply/reply/commands-context-report.test.ts b/src/auto-reply/reply/commands-context-report.test.ts index bef22415e7c..a6d777ceec3 100644 --- a/src/auto-reply/reply/commands-context-report.test.ts +++ b/src/auto-reply/reply/commands-context-report.test.ts @@ -36,8 +36,8 @@ function makeParams( source: "run", generatedAt: Date.now(), workspaceDir: "/tmp/workspace", - bootstrapMaxChars: options?.omitBootstrapLimits ? undefined : 20_000, - bootstrapTotalMaxChars: options?.omitBootstrapLimits ? undefined : 150_000, + bootstrapMaxChars: options?.omitBootstrapLimits ? undefined : 12_000, + bootstrapTotalMaxChars: options?.omitBootstrapLimits ? undefined : 60_000, sandbox: { mode: "off", sandboxed: false }, systemPrompt: { chars: 1_000, @@ -50,7 +50,7 @@ function makeParams( path: "/tmp/workspace/AGENTS.md", missing: false, rawChars: truncated ? 200_000 : 10_000, - injectedChars: truncated ? 20_000 : 10_000, + injectedChars: truncated ? 12_000 : 10_000, truncated, }, ], @@ -76,7 +76,7 @@ function makeParams( describe("buildContextReply", () => { it("shows bootstrap truncation warning in list output when context exceeds configured limits", async () => { const result = await buildContextReply(makeParams("/context list", true)); - expect(result.text).toContain("Bootstrap max/total: 150,000 chars"); + expect(result.text).toContain("Bootstrap max/total: 60,000 chars"); expect(result.text).toContain("⚠ Bootstrap context is over configured limits"); expect(result.text).toContain("Causes: 1 file(s) exceeded max/file."); }); diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 6943847ccda..c2c0704e03a 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -15,7 +15,11 @@ import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logVerbose } from "../../globals.js"; import { clearCommandLane, getQueueSize } from "../../process/command-queue.js"; -import { isAcpSessionKey, isSubagentSessionKey, normalizeMainKey } from "../../routing/session-key.js"; +import { + isAcpSessionKey, + isSubagentSessionKey, + normalizeMainKey, +} from "../../routing/session-key.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { hasControlCommand } from "../command-detection.js"; diff --git a/src/auto-reply/reply/stage-sandbox-media.ts b/src/auto-reply/reply/stage-sandbox-media.ts index 46e4980427f..1aab3f7371b 100644 --- a/src/auto-reply/reply/stage-sandbox-media.ts +++ b/src/auto-reply/reply/stage-sandbox-media.ts @@ -48,7 +48,9 @@ export async function stageSandboxMedia(params: { } await fs.mkdir(effectiveWorkspaceDir, { recursive: true }); - const remoteAttachmentRoots = resolveChannelRemoteInboundAttachmentRoots({ cfg, ctx }) ?? []; + const remoteAttachmentRoots = ctx.MediaRemoteHost + ? (resolveChannelRemoteInboundAttachmentRoots({ cfg, ctx }) ?? []) + : []; const usedNames = new Set(); const staged = new Map(); // absolute source -> relative sandbox path diff --git a/src/auto-reply/reply/strip-inbound-meta.ts b/src/auto-reply/reply/strip-inbound-meta.ts index aac05f85df9..e4db1246c57 100644 --- a/src/auto-reply/reply/strip-inbound-meta.ts +++ b/src/auto-reply/reply/strip-inbound-meta.ts @@ -12,9 +12,6 @@ * do not show AI-facing envelope metadata as user text. */ -import { z } from "zod"; -import { safeParseJsonWithSchema } from "../../utils/zod-parse.js"; - const LEADING_TIMESTAMP_PREFIX_RE = /^\[[A-Za-z]{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2}[^\]]*\] */; /** @@ -35,7 +32,6 @@ const UNTRUSTED_CONTEXT_HEADER = const ACTIVE_MEMORY_OPEN_TAG = ""; const ACTIVE_MEMORY_CLOSE_TAG = ""; const [CONVERSATION_INFO_SENTINEL, SENDER_INFO_SENTINEL] = INBOUND_META_SENTINELS; -const InboundMetaBlockSchema = z.record(z.string(), z.unknown()); // Pre-compiled fast-path regex — avoids line-by-line parse when no blocks present. const SENTINEL_FAST_RE = new RegExp( @@ -64,6 +60,18 @@ function restoreNeutralizedMarkdownFences(value: unknown): unknown { ); } +function parseJsonObjectRecord(jsonText: string): Record | null { + try { + const parsed: unknown = JSON.parse(jsonText); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + return parsed as Record; + } catch { + return null; + } +} + function parseInboundMetaBlock(lines: string[], sentinel: string): Record | null { for (let i = 0; i < lines.length; i++) { if (lines[i]?.trim() !== sentinel) { @@ -86,7 +94,7 @@ function parseInboundMetaBlock(lines: string[], sentinel: string): Record) : null; } return null; diff --git a/src/auto-reply/stage-sandbox-media.test-harness.ts b/src/auto-reply/stage-sandbox-media.test-harness.ts index f9c36364cb2..f72613f3f6f 100644 --- a/src/auto-reply/stage-sandbox-media.test-harness.ts +++ b/src/auto-reply/stage-sandbox-media.test-harness.ts @@ -7,7 +7,7 @@ export async function withSandboxMediaTempHome( prefix: string, fn: (home: string) => Promise, ): Promise { - return withTempHomeBase(async (home) => await fn(home), { prefix }); + return withTempHomeBase(async (home) => await fn(home), { prefix, skipSessionCleanup: true }); } export function createSandboxMediaContexts(mediaPath: string): { diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index 6cb6894668b..74e3c6089df 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -1,14 +1,18 @@ import fs from "node:fs/promises"; -import { createServer, type IncomingMessage } from "node:http"; -import { createRequire } from "node:module"; -import type { AddressInfo } from "node:net"; +import type { IncomingMessage } from "node:http"; import os from "node:os"; import path from "node:path"; import type { Duplex } from "node:stream"; import { setTimeout as sleep } from "node:timers/promises"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { defaultRuntime } from "../runtime.js"; -import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH, injectCanvasLiveReload } from "./a2ui.js"; +import { + A2UI_PATH, + CANVAS_HOST_PATH, + CANVAS_WS_PATH, + handleA2uiHttpRequest, + injectCanvasLiveReload, +} from "./a2ui.js"; type MockWatcher = { on: (event: string, cb: (...args: unknown[]) => void) => MockWatcher; @@ -24,10 +28,12 @@ type TrackingWebSocket = { send: (message: string) => void; }; -function isLoopbackBindDenied(error: unknown) { - const code = (error as NodeJS.ErrnoException | undefined)?.code; - return code === "EPERM" || code === "EACCES"; -} +type CapturedResponse = { + handled: boolean; + status: number; + headers: Record; + body: string; +}; function createMockWatcherState() { const watchers: MockWatcher[] = []; @@ -56,6 +62,68 @@ function createMockWatcherState() { }; } +async function captureHandlerResponse( + handler: Pick, + url: string, + method = "GET", +): Promise { + const response: CapturedResponse = { + handled: false, + status: 200, + headers: {}, + body: "", + }; + const res = { + statusCode: 200, + setHeader(name: string, value: number | string | readonly string[]) { + const headerValue: number | string | string[] = + typeof value === "object" ? [...value] : value; + response.headers[name.toLowerCase()] = headerValue; + return this; + }, + end(chunk?: string | Buffer) { + response.status = this.statusCode; + response.body = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : (chunk ?? ""); + return this; + }, + }; + response.handled = await handler.handleHttpRequest( + { method, url } as IncomingMessage, + res as import("node:http").ServerResponse, + ); + response.status = res.statusCode; + return response; +} + +async function captureA2uiResponse(url: string, method = "GET"): Promise { + const response: CapturedResponse = { + handled: false, + status: 200, + headers: {}, + body: "", + }; + const res = { + statusCode: 200, + setHeader(name: string, value: number | string | readonly string[]) { + const headerValue: number | string | string[] = + typeof value === "object" ? [...value] : value; + response.headers[name.toLowerCase()] = headerValue; + return this; + }, + end(chunk?: string | Buffer) { + response.status = this.statusCode; + response.body = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : (chunk ?? ""); + return this; + }, + }; + response.handled = await handleA2uiHttpRequest( + { method, url } as IncomingMessage, + res as import("node:http").ServerResponse, + ); + response.status = res.statusCode; + return response; +} + describe("canvas host", () => { const quietRuntime = { ...defaultRuntime, @@ -63,7 +131,6 @@ describe("canvas host", () => { }; let createCanvasHostHandler: typeof import("./server.js").createCanvasHostHandler; let startCanvasHost: typeof import("./server.js").startCanvasHost; - let realFetch: typeof import("undici").fetch; let WebSocketServerClass: typeof import("ws").WebSocketServer; let watcherState: ReturnType; let fixtureRoot = ""; @@ -75,35 +142,10 @@ describe("canvas host", () => { return dir; }; - const startFixtureCanvasHost = async ( - rootDir: string, - overrides: Partial[0]> = {}, - ) => - await startCanvasHost({ - runtime: quietRuntime, - rootDir, - port: 0, - listenHost: "127.0.0.1", - allowInTests: true, - watchFactory: watcherState.watchFactory as unknown as Parameters< - typeof startCanvasHost - >[0]["watchFactory"], - webSocketServerClass: WebSocketServerClass, - ...overrides, - }); - - const fetchCanvasHtml = async (port: number) => { - const res = await realFetch(`http://127.0.0.1:${port}${CANVAS_HOST_PATH}/`); - const html = await res.text(); - return { res, html }; - }; - beforeAll(async () => { vi.doUnmock("undici"); vi.resetModules(); - const require = createRequire(import.meta.url); ({ createCanvasHostHandler, startCanvasHost } = await import("./server.js")); - ({ fetch: realFetch } = require("undici") as typeof import("undici")); const wsModule = await vi.importActual("ws"); WebSocketServerClass = wsModule.WebSocketServer; fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-fixtures-")); @@ -128,52 +170,55 @@ describe("canvas host", () => { it("creates a default index.html when missing", async () => { const dir = await createCaseDir(); - let server: Awaited>; - try { - server = await startFixtureCanvasHost(dir); - } catch (error) { - if (isLoopbackBindDenied(error)) { - return; - } - throw error; - } + const handler = await createCanvasHostHandler({ + runtime: quietRuntime, + rootDir: dir, + basePath: CANVAS_HOST_PATH, + allowInTests: true, + watchFactory: watcherState.watchFactory as unknown as Parameters< + typeof createCanvasHostHandler + >[0]["watchFactory"], + webSocketServerClass: WebSocketServerClass, + }); try { - const { res, html } = await fetchCanvasHtml(server.port); - expect(res.status).toBe(200); - expect(html).toContain("Interactive test page"); - expect(html).toContain("openclawSendUserAction"); - expect(html).toContain(CANVAS_WS_PATH); - expect(html).toContain('document.createElement("span")'); - expect(html).not.toContain("statusEl.innerHTML"); + const response = await captureHandlerResponse(handler, `${CANVAS_HOST_PATH}/`); + expect(response.status).toBe(200); + expect(response.body).toContain("Interactive test page"); + expect(response.body).toContain("openclawSendUserAction"); + expect(response.body).toContain(CANVAS_WS_PATH); + expect(response.body).toContain('document.createElement("span")'); + expect(response.body).not.toContain("statusEl.innerHTML"); } finally { - await server.close(); + await handler.close(); } }); it("skips live reload injection when disabled", async () => { const dir = await createCaseDir(); await fs.writeFile(path.join(dir, "index.html"), "no-reload", "utf8"); - let server: Awaited>; - try { - server = await startFixtureCanvasHost(dir, { liveReload: false }); - } catch (error) { - if (isLoopbackBindDenied(error)) { - return; - } - throw error; - } + const handler = await createCanvasHostHandler({ + runtime: quietRuntime, + rootDir: dir, + basePath: CANVAS_HOST_PATH, + allowInTests: true, + liveReload: false, + watchFactory: watcherState.watchFactory as unknown as Parameters< + typeof createCanvasHostHandler + >[0]["watchFactory"], + webSocketServerClass: WebSocketServerClass, + }); try { - const { res, html } = await fetchCanvasHtml(server.port); - expect(res.status).toBe(200); - expect(html).toContain("no-reload"); - expect(html).not.toContain(CANVAS_WS_PATH); + const response = await captureHandlerResponse(handler, `${CANVAS_HOST_PATH}/`); + expect(response.status).toBe(200); + expect(response.body).toContain("no-reload"); + expect(response.body).not.toContain(CANVAS_WS_PATH); - const wsRes = await realFetch(`http://127.0.0.1:${server.port}${CANVAS_WS_PATH}`); - expect(wsRes.status).toBe(404); + const wsResponse = await captureHandlerResponse(handler, CANVAS_WS_PATH); + expect(wsResponse.status).toBe(404); } finally { - await server.close(); + await handler.close(); } }); @@ -192,77 +237,35 @@ describe("canvas host", () => { webSocketServerClass: WebSocketServerClass, }); - const server = createServer((req, res) => { - void (async () => { - if (await handler.handleHttpRequest(req, res)) { - return; - } - res.statusCode = 404; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Not Found"); - })(); - }); - server.on("upgrade", (req, socket, head) => { - if (handler.handleUpgrade(req, socket, head)) { - return; - } - socket.destroy(); - }); - try { - await new Promise((resolve, reject) => { - const onError = (error: Error) => { - server.off("listening", onListening); - reject(error); - }; - const onListening = () => { - server.off("error", onError); - resolve(); - }; - server.once("error", onError); - server.once("listening", onListening); - server.listen(0, "127.0.0.1"); - }); - } catch (error) { - await handler.close(); - if (isLoopbackBindDenied(error)) { - return; - } - throw error; - } - const port = (server.address() as AddressInfo).port; - - try { - const res = await realFetch(`http://127.0.0.1:${port}${CANVAS_HOST_PATH}/`); - const html = await res.text(); - expect(res.status).toBe(200); - expect(html).toContain("v1"); - expect(html).toContain(CANVAS_WS_PATH); - - const miss = await realFetch(`http://127.0.0.1:${port}/`); - expect(miss.status).toBe(404); - } finally { - await new Promise((resolve, reject) => - server.close((err) => (err ? reject(err) : resolve())), - ); - } const originalClose = handler.close; const closeSpy = vi.fn(async () => originalClose()); - handler.close = closeSpy; - - const hosted = await startCanvasHost({ - runtime: quietRuntime, - handler, - ownsHandler: false, - port: 0, - listenHost: "127.0.0.1", - allowInTests: true, - }); try { - expect(hosted.port).toBeGreaterThan(0); + const response = await captureHandlerResponse(handler, `${CANVAS_HOST_PATH}/`); + expect(response.status).toBe(200); + expect(response.body).toContain("v1"); + expect(response.body).toContain(CANVAS_WS_PATH); + + const miss = await captureHandlerResponse(handler, "/"); + expect(miss.handled).toBe(false); + + handler.close = closeSpy; + const hosted = await startCanvasHost({ + runtime: quietRuntime, + handler, + ownsHandler: false, + port: 0, + listenHost: "127.0.0.1", + allowInTests: true, + }); + + try { + expect(hosted.port).toBeGreaterThan(0); + } finally { + await hosted.close(); + expect(closeSpy).not.toHaveBeenCalled(); + } } finally { - await hosted.close(); - expect(closeSpy).not.toHaveBeenCalled(); await originalClose(); } }); @@ -369,14 +372,12 @@ describe("canvas host", () => { ); it("serves A2UI scaffold and blocks traversal/symlink escapes", async () => { - const dir = await createCaseDir(); const a2uiRoot = path.resolve(process.cwd(), "src/canvas-host/a2ui"); const bundlePath = path.join(a2uiRoot, "a2ui.bundle.js"); const linkName = `test-link-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`; const linkPath = path.join(a2uiRoot, linkName); let createdBundle = false; let createdLink = false; - let server: Awaited> | undefined; try { await fs.stat(bundlePath); @@ -389,37 +390,23 @@ describe("canvas host", () => { createdLink = true; try { - try { - server = await startFixtureCanvasHost(dir); - } catch (error) { - if (isLoopbackBindDenied(error)) { - return; - } - throw error; - } - - const res = await realFetch(`http://127.0.0.1:${server.port}/__openclaw__/a2ui/`); - const html = await res.text(); + const res = await captureA2uiResponse(`${A2UI_PATH}/`); + const html = res.body; expect(res.status).toBe(200); expect(html).toContain("openclaw-a2ui-host"); expect(html).toContain("openclawCanvasA2UIAction"); - const bundleRes = await realFetch( - `http://127.0.0.1:${server.port}/__openclaw__/a2ui/a2ui.bundle.js`, - ); - const js = await bundleRes.text(); + const bundleRes = await captureA2uiResponse(`${A2UI_PATH}/a2ui.bundle.js`); + const js = bundleRes.body; expect(bundleRes.status).toBe(200); expect(js).toContain("openclawA2UI"); - const traversalRes = await realFetch( - `http://127.0.0.1:${server.port}${A2UI_PATH}/%2e%2e%2fpackage.json`, - ); + const traversalRes = await captureA2uiResponse(`${A2UI_PATH}/%2e%2e%2fpackage.json`); expect(traversalRes.status).toBe(404); - expect(await traversalRes.text()).toBe("not found"); - const symlinkRes = await realFetch(`http://127.0.0.1:${server.port}${A2UI_PATH}/${linkName}`); + expect(traversalRes.body).toBe("not found"); + const symlinkRes = await captureA2uiResponse(`${A2UI_PATH}/${linkName}`); expect(symlinkRes.status).toBe(404); - expect(await symlinkRes.text()).toBe("not found"); + expect(symlinkRes.body).toBe("not found"); } finally { - await server?.close(); if (createdLink) { await fs.rm(linkPath, { force: true }); } diff --git a/src/channels/plugins/bundled.shape-guard.test.ts b/src/channels/plugins/bundled.shape-guard.test.ts index d1e855a7d14..eef427856d4 100644 --- a/src/channels/plugins/bundled.shape-guard.test.ts +++ b/src/channels/plugins/bundled.shape-guard.test.ts @@ -385,6 +385,121 @@ describe("bundled channel entry shape guards", () => { } }); + it("loads setup-entry feature plugins without loading the main channel entry", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-setup-only-")); + const previousBundledPluginsDir = process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + const pluginDir = path.join(root, "dist", "extensions", "alpha"); + const testGlobal = globalThis as typeof globalThis & { + __bundledSetupOnlyMainLoaded?: boolean; + __bundledSetupOnlySetupLoaded?: number; + __bundledSetupOnlyPluginLoaded?: boolean; + }; + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, "index.js"), + [ + "globalThis.__bundledSetupOnlyMainLoaded = true;", + "throw new Error('main entry loaded');", + "", + ].join("\n"), + "utf8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.js"), + [ + "globalThis.__bundledSetupOnlySetupLoaded = (globalThis.__bundledSetupOnlySetupLoaded ?? 0) + 1;", + "export default {", + " kind: 'bundled-channel-setup-entry',", + " features: { legacyStateMigrations: true },", + " loadSetupPlugin() {", + " globalThis.__bundledSetupOnlyPluginLoaded = true;", + " throw new Error('setup plugin loaded');", + " },", + " loadLegacyStateMigrationDetector() {", + " return ({ oauthDir }) => [{", + " kind: 'copy',", + " label: 'Alpha state',", + " sourcePath: oauthDir + '/legacy.json',", + " targetPath: oauthDir + '/alpha/legacy.json',", + " }];", + " },", + "};", + "", + ].join("\n"), + "utf8", + ); + + vi.doMock("../../plugins/bundled-channel-runtime.js", () => ({ + listBundledChannelPluginMetadata: () => [ + { + dirName: "alpha", + manifest: { + id: "alpha", + channels: ["alpha"], + }, + source: { + source: "./index.js", + built: "./index.js", + }, + setupSource: { + source: "./setup-entry.js", + built: "./setup-entry.js", + }, + }, + ], + resolveBundledChannelGeneratedPath: ( + rootDir: string, + entry: { built?: string; source?: string }, + pluginDirName?: string, + ) => + path.join( + rootDir, + "dist", + "extensions", + pluginDirName ?? "alpha", + (entry.built ?? entry.source ?? "./index.js").replace(/^\.\//u, ""), + ), + })); + + try { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = path.join(root, "dist", "extensions"); + + const bundled = await importFreshModule( + import.meta.url, + "./bundled.js?scope=bundled-setup-only-feature", + ); + + const detectors = bundled.listBundledChannelLegacyStateMigrationDetectors(); + expect( + detectors.map((detector) => + detector({ cfg: {}, env: {}, stateDir: "/state", oauthDir: "/oauth" } as never), + ), + ).toEqual([ + [ + { + kind: "copy", + label: "Alpha state", + sourcePath: "/oauth/legacy.json", + targetPath: "/oauth/alpha/legacy.json", + }, + ], + ]); + expect(testGlobal.__bundledSetupOnlySetupLoaded).toBe(1); + expect(testGlobal.__bundledSetupOnlyMainLoaded).toBeUndefined(); + expect(testGlobal.__bundledSetupOnlyPluginLoaded).toBeUndefined(); + } finally { + if (previousBundledPluginsDir === undefined) { + delete process.env.OPENCLAW_BUNDLED_PLUGINS_DIR; + } else { + process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = previousBundledPluginsDir; + } + fs.rmSync(root, { recursive: true, force: true }); + delete testGlobal.__bundledSetupOnlyMainLoaded; + delete testGlobal.__bundledSetupOnlySetupLoaded; + delete testGlobal.__bundledSetupOnlyPluginLoaded; + } + }); + it("keeps channel entrypoints on the dedicated entry-contract SDK surface", () => { const offenders: string[] = []; @@ -414,6 +529,33 @@ describe("bundled channel entry shape guards", () => { expect(offenders).toEqual([]); }); + it("keeps setup-entry legacy feature hints mirrored in package metadata", () => { + const offenders: string[] = []; + + for (const extensionDir of bundledPluginRoots) { + const setupEntryPath = path.join(extensionDir, "setup-entry.ts"); + const packageJsonPath = path.join(extensionDir, "package.json"); + if (!fs.existsSync(setupEntryPath) || !fs.existsSync(packageJsonPath)) { + continue; + } + const setupEntrySource = fs.readFileSync(setupEntryPath, "utf8"); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as { + openclaw?: { + setupFeatures?: Record; + }; + }; + for (const feature of ["legacyStateMigrations", "legacySessionSurfaces"]) { + const usesFeature = setupEntrySource.includes(`${feature}: true`); + const hasHint = packageJson.openclaw?.setupFeatures?.[feature] === true; + if (usesFeature !== hasHint) { + offenders.push(`${path.relative(process.cwd(), extensionDir)}:${feature}`); + } + } + } + + expect(offenders).toEqual([]); + }); + it("keeps bundled channel entrypoints free of static src imports", () => { const offenders: string[] = []; diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 6f9cec3cf7c..e8966199049 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -1,6 +1,10 @@ import path from "node:path"; import { formatErrorMessage } from "../../infra/errors.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; +import type { + BundledChannelLegacySessionSurface, + BundledChannelLegacyStateMigrationDetector, +} from "../../plugin-sdk/channel-entry-contract.js"; import { listBundledChannelPluginMetadata, resolveBundledChannelGeneratedPath, @@ -32,6 +36,8 @@ type BundledChannelSetupEntryRuntimeContract = { kind: "bundled-channel-setup-entry"; loadSetupPlugin: () => ChannelPlugin; loadSetupSecrets?: () => ChannelPlugin["secrets"] | undefined; + loadLegacyStateMigrationDetector?: () => BundledChannelLegacyStateMigrationDetector; + loadLegacySessionSurface?: () => BundledChannelLegacySessionSurface; features?: { legacyStateMigrations?: boolean; legacySessionSurfaces?: boolean; @@ -41,14 +47,15 @@ type BundledChannelSetupEntryRuntimeContract = { type GeneratedBundledChannelEntry = { id: string; entry: BundledChannelEntryRuntimeContract; - setupEntry?: BundledChannelSetupEntryRuntimeContract; }; type BundledChannelCacheContext = { pluginLoadInProgressIds: Set; setupPluginLoadInProgressIds: Set; entryLoadInProgressIds: Set; + setupEntryLoadInProgressIds: Set; lazyEntriesById: Map; + lazySetupEntriesById: Map; lazyPluginsById: Map; lazySetupPluginsById: Map; lazySecretsById: Map; @@ -102,7 +109,7 @@ function resolveChannelSetupModuleEntry( } function hasSetupEntryFeature( - entry: BundledChannelSetupEntryRuntimeContract | undefined, + entry: BundledChannelSetupEntryRuntimeContract | null | undefined, feature: keyof NonNullable, ): boolean { return entry?.features?.[feature] === true; @@ -186,7 +193,6 @@ function loadGeneratedBundledChannelModule(params: { function loadGeneratedBundledChannelEntry(params: { rootScope: BundledChannelRootScope; metadata: BundledChannelPluginMetadata; - includeSetup: boolean; }): GeneratedBundledChannelEntry | null { try { const entry = resolveChannelPluginModuleEntry( @@ -202,20 +208,9 @@ function loadGeneratedBundledChannelEntry(params: { ); return null; } - const setupEntry = - params.includeSetup && params.metadata.setupSource - ? resolveChannelSetupModuleEntry( - loadGeneratedBundledChannelModule({ - rootScope: params.rootScope, - metadata: params.metadata, - entry: params.metadata.setupSource, - }), - ) - : null; return { id: params.metadata.manifest.id, entry, - ...(setupEntry ? { setupEntry } : {}), }; } catch (error) { const detail = formatErrorMessage(error); @@ -224,6 +219,37 @@ function loadGeneratedBundledChannelEntry(params: { } } +function loadGeneratedBundledChannelSetupEntry(params: { + rootScope: BundledChannelRootScope; + metadata: BundledChannelPluginMetadata; +}): BundledChannelSetupEntryRuntimeContract | null { + if (!params.metadata.setupSource) { + return null; + } + try { + const setupEntry = resolveChannelSetupModuleEntry( + loadGeneratedBundledChannelModule({ + rootScope: params.rootScope, + metadata: params.metadata, + entry: params.metadata.setupSource, + }), + ); + if (!setupEntry) { + log.warn( + `[channels] bundled channel setup entry ${params.metadata.manifest.id} missing bundled-channel-setup-entry contract; skipping`, + ); + return null; + } + return setupEntry; + } catch (error) { + const detail = formatErrorMessage(error); + log.warn( + `[channels] failed to load bundled channel setup entry ${params.metadata.manifest.id}: ${detail}`, + ); + return null; + } +} + const cachedBundledChannelMetadata = new Map(); const bundledChannelCacheContexts = new Map(); @@ -232,7 +258,9 @@ function createBundledChannelCacheContext(): BundledChannelCacheContext { pluginLoadInProgressIds: new Set(), setupPluginLoadInProgressIds: new Set(), entryLoadInProgressIds: new Set(), + setupEntryLoadInProgressIds: new Set(), lazyEntriesById: new Map(), + lazySetupEntriesById: new Map(), lazyPluginsById: new Map(), lazySetupPluginsById: new Map(), lazySecretsById: new Map(), @@ -288,6 +316,17 @@ function listBundledChannelPluginIdsForRoot( .toSorted((left, right) => left.localeCompare(right)); } +function listBundledChannelPluginIdsForSetupFeature( + rootScope: BundledChannelRootScope, + feature: keyof NonNullable, +): readonly ChannelId[] { + const hinted = listBundledChannelMetadata(rootScope) + .filter((metadata) => metadata.packageManifest?.setupFeatures?.[feature] === true) + .map((metadata) => metadata.manifest.id) + .toSorted((left, right) => left.localeCompare(right)); + return hinted.length > 0 ? hinted : listBundledChannelPluginIdsForRoot(rootScope); +} + export function listBundledChannelPluginIds(): readonly ChannelId[] { return listBundledChannelPluginIdsForRoot(resolveBundledChannelRootScope()); } @@ -305,13 +344,12 @@ function getLazyGeneratedBundledChannelEntryForRoot( id: ChannelId, rootScope: BundledChannelRootScope, cacheContext: BundledChannelCacheContext, - params?: { includeSetup?: boolean }, ): GeneratedBundledChannelEntry | null { const cached = cacheContext.lazyEntriesById.get(id); - if (cached && (!params?.includeSetup || cached.setupEntry)) { + if (cached) { return cached; } - if (cached === null && !params?.includeSetup) { + if (cached === null) { return null; } const metadata = resolveBundledChannelMetadata(id, rootScope); @@ -327,7 +365,6 @@ function getLazyGeneratedBundledChannelEntryForRoot( const entry = loadGeneratedBundledChannelEntry({ rootScope, metadata, - includeSetup: params?.includeSetup === true, }); cacheContext.lazyEntriesById.set(id, entry); if (entry?.entry.id && entry.entry.id !== id) { @@ -339,6 +376,51 @@ function getLazyGeneratedBundledChannelEntryForRoot( } } +function cacheBundledChannelSetupEntry( + metadata: BundledChannelPluginMetadata, + cacheContext: BundledChannelCacheContext, + entry: BundledChannelSetupEntryRuntimeContract | null, + requestedId?: ChannelId, +) { + const ids = new Set([ + metadata.manifest.id, + ...(metadata.manifest.channels ?? []), + ...(requestedId ? [requestedId] : []), + ]); + for (const id of ids) { + cacheContext.lazySetupEntriesById.set(id, entry); + } +} + +function getLazyGeneratedBundledChannelSetupEntryForRoot( + id: ChannelId, + rootScope: BundledChannelRootScope, + cacheContext: BundledChannelCacheContext, +): BundledChannelSetupEntryRuntimeContract | null { + if (cacheContext.lazySetupEntriesById.has(id)) { + return cacheContext.lazySetupEntriesById.get(id) ?? null; + } + const metadata = resolveBundledChannelMetadata(id, rootScope); + if (!metadata) { + cacheContext.lazySetupEntriesById.set(id, null); + return null; + } + if (cacheContext.setupEntryLoadInProgressIds.has(id)) { + return null; + } + cacheContext.setupEntryLoadInProgressIds.add(id); + try { + const setupEntry = loadGeneratedBundledChannelSetupEntry({ + rootScope, + metadata, + }); + cacheBundledChannelSetupEntry(metadata, cacheContext, setupEntry, id); + return setupEntry; + } finally { + cacheContext.setupEntryLoadInProgressIds.delete(id); + } +} + function getBundledChannelPluginForRoot( id: ChannelId, rootScope: BundledChannelRootScope, @@ -414,9 +496,7 @@ function getBundledChannelSetupPluginForRoot( if (cacheContext.setupPluginLoadInProgressIds.has(id)) { return undefined; } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, { - includeSetup: true, - })?.setupEntry; + const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext); if (!entry) { return undefined; } @@ -438,9 +518,7 @@ function getBundledChannelSetupSecretsForRoot( if (cacheContext.lazySetupSecretsById.has(id)) { return cacheContext.lazySetupSecretsById.get(id) ?? undefined; } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, { - includeSetup: true, - })?.setupEntry; + const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext); if (!entry) { return undefined; } @@ -471,10 +549,8 @@ export function listBundledChannelSetupPluginsByFeature( feature: keyof NonNullable, ): readonly ChannelPlugin[] { const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); - return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => { - const setupEntry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, cacheContext, { - includeSetup: true, - })?.setupEntry; + return listBundledChannelPluginIdsForSetupFeature(rootScope, feature).flatMap((id) => { + const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, cacheContext); if (!hasSetupEntryFeature(setupEntry, feature)) { return []; } @@ -483,6 +559,52 @@ export function listBundledChannelSetupPluginsByFeature( }); } +export function listBundledChannelLegacySessionSurfaces(): readonly BundledChannelLegacySessionSurface[] { + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacySessionSurfaces").flatMap( + (id) => { + const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot( + id, + rootScope, + cacheContext, + ); + const surface = setupEntry?.loadLegacySessionSurface?.(); + if (surface) { + return [surface]; + } + if (!hasSetupEntryFeature(setupEntry, "legacySessionSurfaces")) { + return []; + } + const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); + return plugin?.messaging ? [plugin.messaging] : []; + }, + ); +} + +export function listBundledChannelLegacyStateMigrationDetectors(): readonly BundledChannelLegacyStateMigrationDetector[] { + const { rootScope, cacheContext } = resolveActiveBundledChannelCacheScope(); + return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacyStateMigrations").flatMap( + (id) => { + const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot( + id, + rootScope, + cacheContext, + ); + const detector = setupEntry?.loadLegacyStateMigrationDetector?.(); + if (detector) { + return [detector]; + } + if (!hasSetupEntryFeature(setupEntry, "legacyStateMigrations")) { + return []; + } + const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, cacheContext); + return plugin?.lifecycle?.detectLegacyStateMigrations + ? [plugin.lifecycle.detectLegacyStateMigrations] + : []; + }, + ); +} + export function hasBundledChannelEntryFeature( id: ChannelId, feature: keyof NonNullable, diff --git a/src/channels/plugins/contracts/channel-import-guardrails.test.ts b/src/channels/plugins/contracts/channel-import-guardrails.test.ts index 455724bfd29..3bac99b3b6b 100644 --- a/src/channels/plugins/contracts/channel-import-guardrails.test.ts +++ b/src/channels/plugins/contracts/channel-import-guardrails.test.ts @@ -248,6 +248,14 @@ const sourceAnalysisCache = new Map(); let extensionSourceFilesCache: string[] | null = null; let coreSourceFilesCache: string[] | null = null; const extensionFilesCache = new Map(); +const STATIC_FROM_IMPORT_RE = + /^\s*import(?:\s+type)?\s+(?!["'])(?:[\s\S]*?)\s+from\s*["']([^"']+)["']/gmu; +const STATIC_SIDE_EFFECT_IMPORT_RE = /^\s*import\s*["']([^"']+)["']/gmu; +const RE_EXPORT_STAR_RE = + /^\s*export\s+(?:type\s+)?\*\s*(?:as\s+\w+\s+)?from\s*["']([^"']+)["']/gmu; +const RE_EXPORT_NAMED_RE = /^\s*export\s+(?:type\s+)?\{[^}]*\}\s+from\s*["']([^"']+)["']/gmu; +const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/gmu; +const REQUIRE_RE = /\brequire\s*\(\s*["']([^"']+)["']\s*\)/gmu; type SourceFileCollectorOptions = { rootDir: string; @@ -388,16 +396,18 @@ function collectExtensionFiles(extensionId: string): string[] { function collectModuleSpecifiers(text: string): string[] { const patterns = [ - /\bimport\s*\(\s*["']([^"']+\.(?:[cm]?[jt]sx?))["']\s*\)/g, - /\brequire\s*\(\s*["']([^"']+\.(?:[cm]?[jt]sx?))["']\s*\)/g, - /\b(?:import|export)\b[\s\S]*?\bfrom\s*["']([^"']+\.(?:[cm]?[jt]sx?))["']/g, - /\bimport\s*["']([^"']+\.(?:[cm]?[jt]sx?))["']/g, + DYNAMIC_IMPORT_RE, + REQUIRE_RE, + STATIC_FROM_IMPORT_RE, + STATIC_SIDE_EFFECT_IMPORT_RE, + RE_EXPORT_STAR_RE, + RE_EXPORT_NAMED_RE, ] as const; const specifiers = new Set(); for (const pattern of patterns) { for (const match of text.matchAll(pattern)) { const specifier = match[1]?.trim(); - if (specifier) { + if (specifier && /\.(?:[cm]?[jt]sx?)$/u.test(specifier)) { specifiers.add(specifier); } } diff --git a/src/channels/plugins/contracts/dm-policy.contract.test.ts b/src/channels/plugins/contracts/dm-policy.contract.test.ts index a9b6466286f..bc633c9650b 100644 --- a/src/channels/plugins/contracts/dm-policy.contract.test.ts +++ b/src/channels/plugins/contracts/dm-policy.contract.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { - isSignalSenderAllowed, + getSignalContractSurface, type SignalSender, } from "../../../../test/helpers/channels/dm-policy-contract.js"; import { @@ -19,24 +19,29 @@ const signalSender: SignalSender = { raw: "+15550001111", e164: "+15550001111", }; +const signalSenderE164 = "+15550001111"; -const channelSmokeCases: ChannelSmokeCase[] = [ - { - name: "bluebubbles", - storeAllowFrom: ["attacker-user"], - isSenderAllowed: (allowFrom) => allowFrom.includes("attacker-user"), - }, - { - name: "signal", - storeAllowFrom: [signalSender.e164], - isSenderAllowed: (allowFrom) => isSignalSenderAllowed(signalSender, allowFrom), - }, - { - name: "mattermost", - storeAllowFrom: ["user:attacker-user"], - isSenderAllowed: (allowFrom) => allowFrom.includes("user:attacker-user"), - }, -]; +function createChannelSmokeCases( + isSignalSenderAllowed: (sender: SignalSender, allowFrom: string[]) => boolean, +): ChannelSmokeCase[] { + return [ + { + name: "bluebubbles", + storeAllowFrom: ["attacker-user"], + isSenderAllowed: (allowFrom) => allowFrom.includes("attacker-user"), + }, + { + name: "signal", + storeAllowFrom: [signalSenderE164], + isSenderAllowed: (allowFrom) => isSignalSenderAllowed(signalSender, allowFrom), + }, + { + name: "mattermost", + storeAllowFrom: ["user:attacker-user"], + isSenderAllowed: (allowFrom) => allowFrom.includes("user:attacker-user"), + }, + ]; +} function expandChannelIngressCases(cases: readonly ChannelSmokeCase[]) { return cases.flatMap((testCase) => @@ -66,13 +71,15 @@ describe("security/dm-policy-shared channel smoke", () => { expect(access.reason).toBe("groupPolicy=allowlist (not allowlisted)"); } - it.each(expandChannelIngressCases(channelSmokeCases))( - "[$testCase.name] blocks group $ingress when sender is only in pairing store", - ({ testCase }) => { + it("blocks group ingress when sender is only in pairing store", async () => { + const { isSignalSenderAllowed } = await getSignalContractSurface(); + for (const { testCase } of expandChannelIngressCases( + createChannelSmokeCases(isSignalSenderAllowed), + )) { expectBlockedGroupAccess({ storeAllowFrom: testCase.storeAllowFrom, isSenderAllowed: testCase.isSenderAllowed, }); - }, - ); + } + }); }); diff --git a/src/channels/plugins/contracts/inbound.contract.test.ts b/src/channels/plugins/contracts/inbound.contract.test.ts new file mode 100644 index 00000000000..aa06edf2570 --- /dev/null +++ b/src/channels/plugins/contracts/inbound.contract.test.ts @@ -0,0 +1,28 @@ +import { describe } from "vitest"; +import { installDiscordInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.discord.js"; +import { installSignalInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.signal.js"; +import { installSlackInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.slack.js"; +import { installTelegramInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.telegram.js"; +import { installWhatsAppInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.whatsapp.js"; + +describe("inbound channel contracts", () => { + describe("discord", () => { + installDiscordInboundContractSuite(); + }); + + describe("signal", () => { + installSignalInboundContractSuite(); + }); + + describe("slack", () => { + installSlackInboundContractSuite(); + }); + + describe("telegram", () => { + installTelegramInboundContractSuite(); + }); + + describe("whatsapp", () => { + installWhatsAppInboundContractSuite(); + }); +}); diff --git a/src/channels/plugins/contracts/inbound.discord.contract.test.ts b/src/channels/plugins/contracts/inbound.discord.contract.test.ts deleted file mode 100644 index c1861b87b4e..00000000000 --- a/src/channels/plugins/contracts/inbound.discord.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installDiscordInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.discord.js"; - -describe("discord inbound contract", () => { - installDiscordInboundContractSuite(); -}); diff --git a/src/channels/plugins/contracts/inbound.signal.contract.test.ts b/src/channels/plugins/contracts/inbound.signal.contract.test.ts deleted file mode 100644 index eada8a6e6e0..00000000000 --- a/src/channels/plugins/contracts/inbound.signal.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installSignalInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.signal.js"; - -describe("signal inbound contract", () => { - installSignalInboundContractSuite(); -}); diff --git a/src/channels/plugins/contracts/inbound.slack.contract.test.ts b/src/channels/plugins/contracts/inbound.slack.contract.test.ts deleted file mode 100644 index 34910741c8b..00000000000 --- a/src/channels/plugins/contracts/inbound.slack.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installSlackInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.slack.js"; - -describe("slack inbound contract", () => { - installSlackInboundContractSuite(); -}); diff --git a/src/channels/plugins/contracts/inbound.telegram.contract.test.ts b/src/channels/plugins/contracts/inbound.telegram.contract.test.ts deleted file mode 100644 index 2b4cae5eb76..00000000000 --- a/src/channels/plugins/contracts/inbound.telegram.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installTelegramInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.telegram.js"; - -describe("telegram inbound contract", () => { - installTelegramInboundContractSuite(); -}); diff --git a/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts b/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts deleted file mode 100644 index 2c5f6169b6d..00000000000 --- a/src/channels/plugins/contracts/inbound.whatsapp.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installWhatsAppInboundContractSuite } from "../../../../test/helpers/channels/inbound-contract.whatsapp.js"; - -describe("whatsapp inbound contract", () => { - installWhatsAppInboundContractSuite(); -}); diff --git a/src/channels/plugins/contracts/outbound-payload.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.contract.test.ts new file mode 100644 index 00000000000..1a0f1de8e5d --- /dev/null +++ b/src/channels/plugins/contracts/outbound-payload.contract.test.ts @@ -0,0 +1,35 @@ +import { describe } from "vitest"; +import { + installDirectTextMediaOutboundPayloadContractSuite, + installDiscordOutboundPayloadContractSuite, + installSlackOutboundPayloadContractSuite, + installWhatsAppOutboundPayloadContractSuite, + installZaloOutboundPayloadContractSuite, + installZalouserOutboundPayloadContractSuite, +} from "../../../../test/helpers/channels/outbound-payload-contract.js"; + +describe("outbound payload contracts", () => { + describe("discord", () => { + installDiscordOutboundPayloadContractSuite(); + }); + + describe("imessage", () => { + installDirectTextMediaOutboundPayloadContractSuite(); + }); + + describe("slack", () => { + installSlackOutboundPayloadContractSuite(); + }); + + describe("whatsapp", () => { + installWhatsAppOutboundPayloadContractSuite(); + }); + + describe("zalo", () => { + installZaloOutboundPayloadContractSuite(); + }); + + describe("zalouser", () => { + installZalouserOutboundPayloadContractSuite(); + }); +}); diff --git a/src/channels/plugins/contracts/outbound-payload.discord.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.discord.contract.test.ts deleted file mode 100644 index f3e478897cb..00000000000 --- a/src/channels/plugins/contracts/outbound-payload.discord.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installDiscordOutboundPayloadContractSuite } from "../../../../test/helpers/channels/outbound-payload-contract.js"; - -describe("discord outbound payload contract", () => { - installDiscordOutboundPayloadContractSuite(); -}); diff --git a/src/channels/plugins/contracts/outbound-payload.imessage.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.imessage.contract.test.ts deleted file mode 100644 index fa82608026f..00000000000 --- a/src/channels/plugins/contracts/outbound-payload.imessage.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installDirectTextMediaOutboundPayloadContractSuite } from "../../../../test/helpers/channels/outbound-payload-contract.js"; - -describe("imessage outbound payload contract", () => { - installDirectTextMediaOutboundPayloadContractSuite(); -}); diff --git a/src/channels/plugins/contracts/outbound-payload.slack.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.slack.contract.test.ts deleted file mode 100644 index e1c347313ca..00000000000 --- a/src/channels/plugins/contracts/outbound-payload.slack.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installSlackOutboundPayloadContractSuite } from "../../../../test/helpers/channels/outbound-payload-contract.js"; - -describe("slack outbound payload contract", () => { - installSlackOutboundPayloadContractSuite(); -}); diff --git a/src/channels/plugins/contracts/outbound-payload.whatsapp.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.whatsapp.contract.test.ts deleted file mode 100644 index c8bd1f9a72f..00000000000 --- a/src/channels/plugins/contracts/outbound-payload.whatsapp.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installWhatsAppOutboundPayloadContractSuite } from "../../../../test/helpers/channels/outbound-payload-contract.js"; - -describe("whatsapp outbound payload contract", () => { - installWhatsAppOutboundPayloadContractSuite(); -}); diff --git a/src/channels/plugins/contracts/outbound-payload.zalo.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.zalo.contract.test.ts deleted file mode 100644 index 5d395bbf661..00000000000 --- a/src/channels/plugins/contracts/outbound-payload.zalo.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installZaloOutboundPayloadContractSuite } from "../../../../test/helpers/channels/outbound-payload-contract.js"; - -describe("zalo outbound payload contract", () => { - installZaloOutboundPayloadContractSuite(); -}); diff --git a/src/channels/plugins/contracts/outbound-payload.zalouser.contract.test.ts b/src/channels/plugins/contracts/outbound-payload.zalouser.contract.test.ts deleted file mode 100644 index 0e1a79b6fc4..00000000000 --- a/src/channels/plugins/contracts/outbound-payload.zalouser.contract.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { describe } from "vitest"; -import { installZalouserOutboundPayloadContractSuite } from "../../../../test/helpers/channels/outbound-payload-contract.js"; - -describe("zalouser outbound payload contract", () => { - installZalouserOutboundPayloadContractSuite(); -}); diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index d618c5c3d69..faa054839a8 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -38,9 +38,15 @@ vi.mock("./channel-setup/discovery.js", () => ({ isCatalogChannelInstalled: discoveryMocks.isCatalogChannelInstalled, })); -vi.mock("../channels/plugins/bundled.js", () => ({ - getBundledChannelPlugin: vi.fn(() => undefined), -})); +vi.mock("../channels/plugins/bundled.js", async () => { + const actual = await vi.importActual( + "../channels/plugins/bundled.js", + ); + return { + ...actual, + getBundledChannelPlugin: vi.fn(() => undefined), + }; +}); vi.mock("./channel-setup/plugin-install.js", () => pluginInstallMocks); diff --git a/src/commands/configure.channels.ts b/src/commands/configure.channels.ts index 69321f09f95..dd3deb063da 100644 --- a/src/commands/configure.channels.ts +++ b/src/commands/configure.channels.ts @@ -16,9 +16,17 @@ type ConfiguredChannelRemovalChoice = { }; type ChannelRemovalSelectValue = { kind: "channel"; id: string } | { kind: "done" }; +type ChannelRemovalOption = Parameters< + typeof select +>[0]["options"][number]; +type ChannelRemovalChoiceOption = Extract< + ChannelRemovalOption, + { value: { kind: "channel"; id: string } } +>; +type ChannelRemovalDoneOption = Extract; const RESERVED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); -const DONE_VALUE: ChannelRemovalSelectValue = { kind: "done" }; +const DONE_VALUE: Extract = { kind: "done" }; function listConfiguredChannelRemovalChoices( cfg: OpenClawConfig, @@ -77,17 +85,17 @@ export async function removeChannelConfigWizard( return next; } + const channelOptions = configured.map((meta) => ({ + value: { kind: "channel" as const, id: meta.id }, + label: meta.label, + hint: "Deletes tokens + settings from config (credentials stay on disk)", + })); + const doneOption: ChannelRemovalDoneOption = { value: DONE_VALUE, label: "Done" }; + const options: ChannelRemovalOption[] = [...channelOptions, doneOption]; const choice = guardCancel( await select({ message: "Remove which channel config?", - options: [ - ...configured.map((meta) => ({ - value: { kind: "channel" as const, id: meta.id }, - label: meta.label, - hint: "Deletes tokens + settings from config (credentials stay on disk)", - })), - { value: DONE_VALUE, label: "Done" }, - ], + options, }), runtime, ); diff --git a/src/commands/doctor-state-migrations.test.ts b/src/commands/doctor-state-migrations.test.ts index cb03249f6d7..9588fc49113 100644 --- a/src/commands/doctor-state-migrations.test.ts +++ b/src/commands/doctor-state-migrations.test.ts @@ -14,7 +14,10 @@ import { let tempRoots: string[] = []; -vi.mock("../channels/plugins/bundled.js", () => { +vi.mock("../channels/plugins/bundled.js", async () => { + const actual = await vi.importActual( + "../channels/plugins/bundled.js", + ); function fileExists(filePath: string): boolean { try { return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); @@ -88,6 +91,21 @@ vi.mock("../channels/plugins/bundled.js", () => { } return { + ...actual, + listBundledChannelLegacySessionSurfaces: vi.fn(() => [ + { + isLegacyGroupSessionKey: (key: string) => /^group:.+@g\.us$/i.test(key.trim()), + canonicalizeLegacySessionKey: ({ key, agentId }: { key: string; agentId: string }) => + /^group:.+@g\.us$/i.test(key.trim()) + ? `agent:${agentId}:whatsapp:${key.trim().toLowerCase()}` + : null, + }, + ]), + listBundledChannelLegacyStateMigrationDetectors: vi.fn(() => [ + ({ oauthDir }: { oauthDir: string }) => detectWhatsAppLegacyStateMigrations({ oauthDir }), + ({ cfg, env }: { cfg: OpenClawConfig; env: NodeJS.ProcessEnv }) => + detectTelegramAllowFromMigration({ cfg, env }), + ]), listBundledChannelSetupPluginsByFeature: vi.fn((feature: string) => { if (feature === "legacySessionSurfaces") { return [ diff --git a/src/config/plugin-auto-enable.shared.ts b/src/config/plugin-auto-enable.shared.ts index 2b8642f8ac3..c280d94ae64 100644 --- a/src/config/plugin-auto-enable.shared.ts +++ b/src/config/plugin-auto-enable.shared.ts @@ -304,6 +304,37 @@ function hasBrowserToolReference(cfg: OpenClawConfig): boolean { : false; } +function collectConfiguredPluginEntryIds(cfg: OpenClawConfig): string[] { + const entries = cfg.plugins?.entries; + if (!entries || typeof entries !== "object") { + return []; + } + return Object.keys(entries) + .map((pluginId) => pluginId.trim()) + .filter(Boolean); +} + +function resolveRelevantSetupAutoEnablePluginIds(cfg: OpenClawConfig): string[] { + const pluginIds = new Set(collectConfiguredPluginEntryIds(cfg)); + if ( + isRecord(cfg.browser) || + isRecord(cfg.plugins?.entries?.browser) || + hasBrowserToolReference(cfg) + ) { + pluginIds.add("browser"); + } + if (isRecord(cfg.acp) || isRecord(cfg.plugins?.entries?.acpx)) { + pluginIds.add("acpx"); + } + if ( + isRecord(cfg.plugins?.entries?.xai) || + (isRecord(cfg.tools?.web) && isRecord((cfg.tools.web as Record).x_search)) + ) { + pluginIds.add("xai"); + } + return [...pluginIds].toSorted((left, right) => left.localeCompare(right)); +} + function hasSetupAutoEnableRelevantConfig(cfg: OpenClawConfig): boolean { const entries = cfg.plugins?.entries; if (isRecord(cfg.browser) || isRecord(cfg.acp) || hasBrowserToolReference(cfg)) { @@ -396,6 +427,7 @@ export function configMayNeedPluginAutoEnable( resolvePluginSetupAutoEnableReasons({ config: cfg, env, + pluginIds: resolveRelevantSetupAutoEnablePluginIds(cfg), }).length > 0 ); } @@ -516,6 +548,7 @@ export function resolveConfiguredPluginAutoEnableCandidates(params: { for (const entry of resolvePluginSetupAutoEnableReasons({ config: params.config, env: params.env, + pluginIds: resolveRelevantSetupAutoEnablePluginIds(params.config), })) { changes.push({ pluginId: entry.pluginId, diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 59b3d9420e2..40ebc852c7e 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -3236,7 +3236,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { maximum: 9007199254740991, title: "Bootstrap Max Chars", description: - "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", + "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 12000).", }, bootstrapTotalMaxChars: { type: "integer", @@ -3244,7 +3244,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { maximum: 9007199254740991, title: "Bootstrap Total Max Chars", description: - "Max total characters across all injected workspace bootstrap files (default: 150000).", + "Max total characters across all injected workspace bootstrap files (default: 60000).", }, experimental: { type: "object", @@ -24682,12 +24682,12 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { }, "agents.defaults.bootstrapMaxChars": { label: "Bootstrap Max Chars", - help: "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", + help: "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 12000).", tags: ["performance"], }, "agents.defaults.bootstrapTotalMaxChars": { label: "Bootstrap Total Max Chars", - help: "Max total characters across all injected workspace bootstrap files (default: 150000).", + help: "Max total characters across all injected workspace bootstrap files (default: 60000).", tags: ["performance"], }, "agents.defaults.experimental": { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 820529bb4fa..6e840fac318 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -869,9 +869,9 @@ export const FIELD_HELP: Record = { "agents.defaults.contextInjection": 'Controls when workspace bootstrap files are injected into the system prompt: "always" (default) or "continuation-skip" for safe continuation turns after a completed assistant response.', "agents.defaults.bootstrapMaxChars": - "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 20000).", + "Max characters of each workspace bootstrap file injected into the system prompt before truncation (default: 12000).", "agents.defaults.bootstrapTotalMaxChars": - "Max total characters across all injected workspace bootstrap files (default: 150000).", + "Max total characters across all injected workspace bootstrap files (default: 60000).", "agents.defaults.experimental": "Experimental agent-default flags. Keep these off unless you are intentionally testing a preview surface.", "agents.defaults.experimental.localModelLean": diff --git a/src/config/sessions/explicit-session-key-normalization.ts b/src/config/sessions/explicit-session-key-normalization.ts index 6a2bc61ea7f..c8ba4af8127 100644 --- a/src/config/sessions/explicit-session-key-normalization.ts +++ b/src/config/sessions/explicit-session-key-normalization.ts @@ -1,5 +1,5 @@ import type { MsgContext } from "../../auto-reply/templating.js"; -import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js"; +import { getLoadedChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, @@ -38,7 +38,7 @@ function resolveExplicitSessionKeyNormalizerCandidates( export function normalizeExplicitSessionKey(sessionKey: string, ctx: MsgContext): string { const normalized = normalizeLowercaseStringOrEmpty(sessionKey); for (const channelId of resolveExplicitSessionKeyNormalizerCandidates(normalized, ctx)) { - const normalize = getChannelPlugin(channelId)?.messaging?.normalizeExplicitSessionKey; + const normalize = getLoadedChannelPlugin(channelId)?.messaging?.normalizeExplicitSessionKey; const next = normalize?.({ sessionKey: normalized, ctx }); if (typeof next === "string" && next.trim()) { return normalizeLowercaseStringOrEmpty(next); diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts index d662a6e3db7..e0c98d05a67 100644 --- a/src/config/types.agent-defaults.ts +++ b/src/config/types.agent-defaults.ts @@ -205,9 +205,9 @@ export type AgentDefaultsConfig = { * transcript already contains a completed assistant turn */ contextInjection?: AgentContextInjection; - /** Max chars for injected bootstrap files before truncation (default: 20000). */ + /** Max chars for injected bootstrap files before truncation (default: 12000). */ bootstrapMaxChars?: number; - /** Max total chars across all injected bootstrap files (default: 150000). */ + /** Max total chars across all injected bootstrap files (default: 60000). */ bootstrapTotalMaxChars?: number; /** Experimental agent-default flags. Keep off unless you are intentionally testing a preview surface. */ experimental?: { diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index a62b1c15cdf..239ccd7b334 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -1009,12 +1009,16 @@ describe("Bundle chunk isolation (#40096)", () => { it("shares registrations and keeps concurrent chunk registration visible", async () => { const ts = Date.now().toString(36); const registryUrl = new URL("./registry.ts", import.meta.url).href; - const chunks = await Promise.all( - Array.from( - { length: 3 }, - (_, i) => import(/* @vite-ignore */ `${registryUrl}?chunk=${ts}-${i}`), - ), - ); + const dynamicChunk = await import(/* @vite-ignore */ `${registryUrl}?chunk=${ts}-dynamic`); + const chunks = [ + { + registerContextEngine, + getContextEngineFactory, + listContextEngineIds, + resolveContextEngine, + }, + dynamicChunk, + ]; const engineId = `cross-chunk-${ts}`; const factory = () => ({ diff --git a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts index d50d8d99d15..7de50a2f6a9 100644 --- a/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts +++ b/src/cron/isolated-agent/delivery-dispatch.double-announce.test.ts @@ -324,6 +324,7 @@ describe("dispatchCronDelivery — double-announce guard", () => { expect(enqueueSystemEvent).toHaveBeenCalledWith("Morning briefing complete.", { sessionKey: "agent:main:main", contextKey: "cron-direct-delivery:v1:run-123:telegram::123456:", + trusted: false, }); }); diff --git a/src/cron/isolated-agent/delivery-dispatch.ts b/src/cron/isolated-agent/delivery-dispatch.ts index 085ada30644..25d098a37cb 100644 --- a/src/cron/isolated-agent/delivery-dispatch.ts +++ b/src/cron/isolated-agent/delivery-dispatch.ts @@ -351,6 +351,7 @@ async function queueCronAwarenessSystemEvent(params: { agentId: params.agentId, }), contextKey: params.deliveryIdempotencyKey, + trusted: false, }); } catch (err) { await logCronDeliveryWarn( diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index 073efd8f459..f57073fbf0e 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -62,7 +62,7 @@ export type CronServiceDeps = { maxMissedJobsPerRestart?: number; enqueueSystemEvent: ( text: string, - opts?: { agentId?: string; sessionKey?: string; contextKey?: string }, + opts?: { agentId?: string; sessionKey?: string; contextKey?: string; trusted?: boolean }, ) => void; requestHeartbeatNow: (opts?: { reason?: string; agentId?: string; sessionKey?: string }) => void; runHeartbeatOnce?: (opts?: { diff --git a/src/docs/clawhub-plugin-docs.test.ts b/src/docs/clawhub-plugin-docs.test.ts index 0e53f5ea2ce..130b4918537 100644 --- a/src/docs/clawhub-plugin-docs.test.ts +++ b/src/docs/clawhub-plugin-docs.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { validateExternalCodePluginPackageJson } from "../../packages/plugin-package-contract/src/index.js"; +import { validateExternalCodePluginPackageJson } from "../../packages/plugin-package-contract/src/index.ts"; const DOCS_ROOT = path.join(process.cwd(), "docs"); const pluginDocs = [ diff --git a/src/entry.version-fast-path.test.ts b/src/entry.version-fast-path.test.ts index 673a0aef751..0edf58527ba 100644 --- a/src/entry.version-fast-path.test.ts +++ b/src/entry.version-fast-path.test.ts @@ -79,6 +79,12 @@ async function importEntry(scope: string) { ); } +async function flushEntrySideEffects() { + await Promise.resolve(); + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + describe("entry root version fast path", () => { let originalArgv: string[]; let originalGatewayToken: string | undefined; @@ -109,13 +115,9 @@ describe("entry root version fast path", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); await importEntry("commit-tagged"); - await vi.waitFor( - () => { - expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test (abc1234)"); - expect(exitSpy).toHaveBeenCalledWith(0); - }, - { interval: 1 }, - ); + await flushEntrySideEffects(); + expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test (abc1234)"); + expect(exitSpy).toHaveBeenCalledWith(0); logSpy.mockRestore(); }); @@ -125,13 +127,9 @@ describe("entry root version fast path", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); await importEntry("plain-version"); - await vi.waitFor( - () => { - expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test"); - expect(exitSpy).toHaveBeenCalledWith(0); - }, - { interval: 1 }, - ); + await flushEntrySideEffects(); + expect(logSpy).toHaveBeenCalledWith("OpenClaw 9.9.9-test"); + expect(exitSpy).toHaveBeenCalledWith(0); logSpy.mockRestore(); }); @@ -141,12 +139,8 @@ describe("entry root version fast path", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); await importEntry("container-target"); - await vi.waitFor( - () => { - expect(runCliMock).toHaveBeenCalledWith(["node", "openclaw", "--version"]); - }, - { interval: 1 }, - ); + await flushEntrySideEffects(); + expect(runCliMock).toHaveBeenCalledWith(["node", "openclaw", "--version"]); expect(logSpy).not.toHaveBeenCalled(); expect(exitSpy).not.toHaveBeenCalled(); @@ -159,12 +153,8 @@ describe("entry root version fast path", () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); await importEntry("gateway-override"); - await vi.waitFor( - () => { - expect(runCliMock).toHaveBeenCalledWith(["node", "openclaw", "--version"]); - }, - { interval: 1 }, - ); + await flushEntrySideEffects(); + expect(runCliMock).toHaveBeenCalledWith(["node", "openclaw", "--version"]); expect(errorSpy).not.toHaveBeenCalled(); expect(exitSpy).not.toHaveBeenCalled(); diff --git a/src/flows/channel-setup.status.test.ts b/src/flows/channel-setup.status.test.ts index b9f3816d428..82604b8a576 100644 --- a/src/flows/channel-setup.status.test.ts +++ b/src/flows/channel-setup.status.test.ts @@ -1,13 +1,51 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +type ChannelMeta = import("../channels/plugins/types.core.js").ChannelMeta; +type ChannelPluginCatalogEntry = import("../channels/plugins/catalog.js").ChannelPluginCatalogEntry; +type ListChatChannels = typeof import("../channels/chat-meta.js").listChatChannels; +type ResolveChannelSetupEntries = + typeof import("../commands/channel-setup/discovery.js").resolveChannelSetupEntries; +type FormatChannelPrimerLine = typeof import("../channels/registry.js").formatChannelPrimerLine; +type FormatChannelSelectionLine = + typeof import("../channels/registry.js").formatChannelSelectionLine; +type IsChannelConfigured = typeof import("../config/channel-configured.js").isChannelConfigured; +type NoteChannelPrimerChannels = Parameters< + typeof import("./channel-setup.status.js").noteChannelPrimer +>[1]; + +function makeMeta(id: string, label: string, overrides: Partial = {}): ChannelMeta { + return { + id: id as ChannelMeta["id"], + label, + selectionLabel: overrides.selectionLabel ?? label, + docsPath: overrides.docsPath ?? `/channels/${id}`, + blurb: overrides.blurb ?? "", + ...overrides, + }; +} + +function makeCatalogEntry( + id: string, + label: string, + overrides: Partial = {}, +): ChannelPluginCatalogEntry { + return { + id, + pluginId: overrides.pluginId ?? id, + meta: makeMeta(id, label, overrides.meta), + install: overrides.install ?? { npmSpec: `@openclaw/${id}` }, + ...overrides, + }; +} + const listChatChannels = vi.hoisted(() => - vi.fn(() => [ - { id: "discord", label: "Discord" }, - { id: "bluebubbles", label: "BlueBubbles" }, + vi.fn(() => [ + makeMeta("discord", "Discord"), + makeMeta("bluebubbles", "BlueBubbles"), ]), ); const resolveChannelSetupEntries = vi.hoisted(() => - vi.fn(() => ({ + vi.fn(() => ({ entries: [], installedCatalogEntries: [], installableCatalogEntries: [], @@ -16,31 +54,38 @@ const resolveChannelSetupEntries = vi.hoisted(() => })), ); const formatChannelPrimerLine = vi.hoisted(() => - vi.fn((meta: { label: string; blurb: string }) => `${meta.label}: ${meta.blurb}`), + vi.fn((meta) => `${meta.label}: ${meta.blurb}`), ); const formatChannelSelectionLine = vi.hoisted(() => - vi.fn((meta: { label: string; blurb: string }) => `${meta.label} — ${meta.blurb}`), + vi.fn((meta) => `${meta.label} — ${meta.blurb}`), ); -const isChannelConfigured = vi.hoisted(() => vi.fn(() => false)); +const isChannelConfigured = vi.hoisted(() => vi.fn(() => false)); vi.mock("../channels/chat-meta.js", () => ({ listChatChannels: () => listChatChannels(), })); vi.mock("../channels/registry.js", () => ({ - formatChannelPrimerLine: (meta: unknown) => formatChannelPrimerLine(meta), - formatChannelSelectionLine: (meta: unknown, docsLink: unknown) => - formatChannelSelectionLine(meta, docsLink), + formatChannelPrimerLine: (meta: Parameters[0]) => + formatChannelPrimerLine(meta), + formatChannelSelectionLine: ( + meta: Parameters[0], + docsLink: Parameters[1], + ) => formatChannelSelectionLine(meta, docsLink), })); vi.mock("../commands/channel-setup/discovery.js", () => ({ - resolveChannelSetupEntries: (params: unknown) => resolveChannelSetupEntries(params), + resolveChannelSetupEntries: (params: Parameters[0]) => + resolveChannelSetupEntries(params), shouldShowChannelInSetup: (meta: { exposure?: { setup?: boolean }; showInSetup?: boolean }) => meta.showInSetup !== false && meta.exposure?.setup !== false, })); vi.mock("../config/channel-configured.js", () => ({ - isChannelConfigured: (cfg: unknown, channelId: string) => isChannelConfigured(cfg, channelId), + isChannelConfigured: ( + cfg: Parameters[0], + channelId: Parameters[1], + ) => isChannelConfigured(cfg, channelId), })); import { @@ -54,8 +99,8 @@ describe("resolveChannelSetupSelectionContributions", () => { beforeEach(() => { vi.clearAllMocks(); listChatChannels.mockReturnValue([ - { id: "discord", label: "Discord" }, - { id: "bluebubbles", label: "BlueBubbles" }, + makeMeta("discord", "Discord"), + makeMeta("bluebubbles", "BlueBubbles"), ]); resolveChannelSetupEntries.mockReturnValue({ entries: [], @@ -67,9 +112,7 @@ describe("resolveChannelSetupSelectionContributions", () => { formatChannelPrimerLine.mockImplementation( (meta: { label: string; blurb: string }) => `${meta.label}: ${meta.blurb}`, ); - formatChannelSelectionLine.mockImplementation( - (meta: { label: string; blurb: string }) => `${meta.label} — ${meta.blurb}`, - ); + formatChannelSelectionLine.mockImplementation((meta) => `${meta.label} — ${meta.blurb}`); isChannelConfigured.mockReturnValue(false); }); @@ -100,7 +143,7 @@ describe("resolveChannelSetupSelectionContributions", () => { selectionLabel: "BlueBubbles (macOS app)", }, }, - ] as never, + ], statusByChannel: new Map(), resolveDisabledHint: () => undefined, }); @@ -121,10 +164,9 @@ describe("resolveChannelSetupSelectionContributions", () => { id: "zalo", label: "Zalo", selectionLabel: "Zalo (Bot API)", - quickstartAllowFrom: true, }, }, - ] as never, + ], statusByChannel: new Map(), resolveDisabledHint: () => undefined, }); @@ -146,10 +188,9 @@ describe("resolveChannelSetupSelectionContributions", () => { id: "zalo", label: "Zalo", selectionLabel: "Zalo (Bot API)", - quickstartAllowFrom: true, }, }, - ] as never, + ], statusByChannel: new Map([["zalo", { selectionHint: "configured" }]]), resolveDisabledHint: () => "disabled", }); @@ -171,7 +212,7 @@ describe("resolveChannelSetupSelectionContributions", () => { label: "Zalo\u001B[31m\nBot\u0007", }, }, - ] as never, + ], statusByChannel: new Map([["zalo", { selectionHint: "configured\u001B[2K\nnow" }]]), resolveDisabledHint: () => "disabled\u0007", }); @@ -193,7 +234,7 @@ describe("resolveChannelSetupSelectionContributions", () => { label: "\u001B[31m\u0007", }, }, - ] as never, + ], statusByChannel: new Map(), resolveDisabledHint: () => undefined, }); @@ -205,23 +246,11 @@ describe("resolveChannelSetupSelectionContributions", () => { }); it("sanitizes channel labels in status note lines", async () => { - listChatChannels.mockReturnValue([{ id: "discord", label: "Discord\u001B[31m\nCore\u0007" }]); + listChatChannels.mockReturnValue([makeMeta("discord", "Discord\u001B[31m\nCore\u0007")]); resolveChannelSetupEntries.mockReturnValue({ entries: [], - installedCatalogEntries: [ - { - id: "matrix", - pluginId: "matrix", - meta: { id: "matrix", label: "Matrix\u001B[2K\nPlugin\u0007" }, - }, - ], - installableCatalogEntries: [ - { - id: "zalo", - pluginId: "zalo", - meta: { id: "zalo", label: "Zalo\u001B[2K\nPlugin\u0007" }, - }, - ], + installedCatalogEntries: [makeCatalogEntry("matrix", "Matrix\u001B[2K\nPlugin\u0007")], + installableCatalogEntries: [makeCatalogEntry("zalo", "Zalo\u001B[2K\nPlugin\u0007")], installedCatalogById: new Map(), installableCatalogById: new Map(), }); @@ -249,8 +278,8 @@ describe("resolveChannelSetupSelectionContributions", () => { id: "bad\u001B[31m\nid", label: "\u001B[31m\u0007", blurb: "Blurb\u001B[2K\nline\u0007", - }, - ] as never, + } satisfies NoteChannelPrimerChannels[number], + ] as NoteChannelPrimerChannels, ); expect(formatChannelPrimerLine).toHaveBeenCalledWith( diff --git a/src/flows/channel-setup.test.ts b/src/flows/channel-setup.test.ts index 040c2ea4b46..7b4ef145fee 100644 --- a/src/flows/channel-setup.test.ts +++ b/src/flows/channel-setup.test.ts @@ -1,5 +1,90 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +type ChannelMeta = import("../channels/plugins/types.core.js").ChannelMeta; +type ChannelPluginCatalogEntry = import("../channels/plugins/catalog.js").ChannelPluginCatalogEntry; +type ChannelSetupPlugin = import("../channels/plugins/setup-wizard-types.js").ChannelSetupPlugin; +type ResolveChannelSetupEntries = + typeof import("../commands/channel-setup/discovery.js").resolveChannelSetupEntries; +type CollectChannelStatus = typeof import("./channel-setup.status.js").collectChannelStatus; +type LoadChannelSetupPluginRegistrySnapshotForChannel = + typeof import("../commands/channel-setup/plugin-install.js").loadChannelSetupPluginRegistrySnapshotForChannel; +type PluginRegistry = ReturnType; + +function makeMeta(id: string, label: string, overrides: Partial = {}): ChannelMeta { + return { + id: id as ChannelMeta["id"], + label, + selectionLabel: overrides.selectionLabel ?? label, + docsPath: overrides.docsPath ?? `/channels/${id}`, + blurb: overrides.blurb ?? "", + ...overrides, + }; +} + +function makeCatalogEntry( + id: string, + label: string, + overrides: Partial = {}, +): ChannelPluginCatalogEntry { + return { + id, + pluginId: overrides.pluginId ?? id, + origin: overrides.origin, + meta: makeMeta(id, label, overrides.meta), + install: overrides.install ?? { npmSpec: `@openclaw/${id}` }, + }; +} + +function makeSetupPlugin(params: { + id: string; + label: string; + setupWizard?: ChannelSetupPlugin["setupWizard"]; +}): ChannelSetupPlugin { + return { + id: params.id as ChannelSetupPlugin["id"], + meta: makeMeta(params.id, params.label), + capabilities: { chatTypes: [] }, + config: { + resolveAccount: vi.fn(() => ({})), + } as unknown as ChannelSetupPlugin["config"], + ...(params.setupWizard ? { setupWizard: params.setupWizard } : {}), + }; +} + +function makePluginRegistry(overrides: Partial = {}): PluginRegistry { + return { + plugins: [], + channels: [], + channelSetups: [], + providers: [], + authProviders: [], + authRequirements: [], + webSearchProviders: [], + webFetchProviders: [], + mediaUnderstandingProviders: [], + imageGenerationProviders: [], + videoGenerationProviders: [], + musicGenerationProviders: [], + speechProviders: [], + realtimeTranscriptionProviders: [], + realtimeVoiceProviders: [], + cliBackends: [], + tools: [], + hooks: [], + typedHooks: [], + bundledExtensionDescriptors: [], + doctorChecks: [], + flowContributions: [], + flowContributionResolvers: [], + providerExtensions: [], + toolsets: [], + toolDisplayEntries: [], + textTransforms: [], + diagnostics: [], + ...overrides, + } as unknown as PluginRegistry; +} + const resolveAgentWorkspaceDir = vi.hoisted(() => vi.fn((_cfg?: unknown, _agentId?: unknown) => "/tmp/openclaw-workspace"), ); @@ -11,29 +96,19 @@ const getChannelSetupPlugin = vi.hoisted(() => vi.fn((_channel?: unknown) => und const listChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => [])); const listActiveChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => [])); const loadChannelSetupPluginRegistrySnapshotForChannel = vi.hoisted(() => - vi.fn((_params?: unknown) => ({ channels: [], channelSetups: [] })), + vi.fn((_params) => makePluginRegistry()), ); const resolveChannelSetupEntries = vi.hoisted(() => - vi.fn( - ( - _params?: unknown, - ): { - entries: unknown[]; - installedCatalogEntries: unknown[]; - installableCatalogEntries: unknown[]; - installedCatalogById: Map; - installableCatalogById: Map; - } => ({ - entries: [], - installedCatalogEntries: [], - installableCatalogEntries: [], - installedCatalogById: new Map(), - installableCatalogById: new Map(), - }), - ), + vi.fn((_params) => ({ + entries: [], + installedCatalogEntries: [], + installableCatalogEntries: [], + installedCatalogById: new Map(), + installableCatalogById: new Map(), + })), ); const collectChannelStatus = vi.hoisted(() => - vi.fn(async (_params?: unknown) => ({ + vi.fn(async (_params) => ({ installedPlugins: [], catalogEntries: [], installedCatalogEntries: [], @@ -63,14 +138,16 @@ vi.mock("../channels/registry.js", () => ({ })); vi.mock("../commands/channel-setup/discovery.js", () => ({ - resolveChannelSetupEntries: (params?: unknown) => resolveChannelSetupEntries(params), + resolveChannelSetupEntries: (params: Parameters[0]) => + resolveChannelSetupEntries(params), shouldShowChannelInSetup: () => true, })); vi.mock("../commands/channel-setup/plugin-install.js", () => ({ ensureChannelSetupPluginInstalled: vi.fn(), - loadChannelSetupPluginRegistrySnapshotForChannel: (params?: unknown) => - loadChannelSetupPluginRegistrySnapshotForChannel(params), + loadChannelSetupPluginRegistrySnapshotForChannel: ( + params: Parameters[0], + ) => loadChannelSetupPluginRegistrySnapshotForChannel(params), })); vi.mock("../commands/channel-setup/registry.js", () => ({ @@ -95,7 +172,8 @@ vi.mock("./channel-setup.prompts.js", () => ({ })); vi.mock("./channel-setup.status.js", () => ({ - collectChannelStatus: (params?: unknown) => collectChannelStatus(params), + collectChannelStatus: (params: Parameters[0]) => + collectChannelStatus(params), noteChannelPrimer: vi.fn(), noteChannelStatus: vi.fn(), resolveChannelSelectionNoteLines: vi.fn(() => []), @@ -120,10 +198,7 @@ describe("setupChannels workspace shadow exclusion", () => { getChannelSetupPlugin.mockReturnValue(undefined); listActiveChannelSetupPlugins.mockReturnValue([]); listChannelSetupPlugins.mockReturnValue([]); - loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({ - channels: [], - channelSetups: [], - }); + loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(makePluginRegistry()); resolveChannelSetupEntries.mockReturnValue({ entries: [], installedCatalogEntries: [], @@ -199,7 +274,7 @@ describe("setupChannels workspace shadow exclusion", () => { entries: [ { id: "telegram", - meta: { id: "telegram", label: "Telegram", blurb: "" }, + meta: makeMeta("telegram", "Telegram"), }, ], installedCatalogEntries: [], @@ -233,8 +308,7 @@ describe("setupChannels workspace shadow exclusion", () => { it("keeps already-active setup plugins in the deferred picker without registry fallback", async () => { const activePlugin = { - id: "custom-chat", - meta: { id: "custom-chat", label: "Custom Chat", blurb: "" }, + ...makeSetupPlugin({ id: "custom-chat", label: "Custom Chat" }), }; listActiveChannelSetupPlugins.mockReturnValue([activePlugin]); resolveChannelSetupEntries.mockImplementation(() => ({ @@ -286,21 +360,17 @@ describe("setupChannels workspace shadow exclusion", () => { }, })), }; - const activePlugin = { + const activePlugin = makeSetupPlugin({ id: "custom-chat", - meta: { id: "custom-chat", label: "Custom Chat", blurb: "" }, - capabilities: {}, - config: { - resolveAccount: vi.fn(() => ({})), - }, + label: "Custom Chat", setupWizard, - }; + }); listActiveChannelSetupPlugins.mockReturnValue([activePlugin]); resolveChannelSetupEntries.mockReturnValue({ entries: [ { id: "custom-chat", - meta: { id: "custom-chat", label: "Custom Chat", blurb: "" }, + meta: makeMeta("custom-chat", "Custom Chat"), }, ], installedCatalogEntries: [], @@ -339,6 +409,14 @@ describe("setupChannels workspace shadow exclusion", () => { }); it("loads the selected bundled catalog plugin without writing explicit plugin enablement", async () => { + const configure = vi.fn(async ({ cfg }: { cfg: Record }) => ({ + cfg: { + ...cfg, + channels: { + telegram: { token: "secret" }, + }, + } as never, + })); const setupWizard = { channel: "telegram", getStatus: vi.fn(async () => ({ @@ -346,35 +424,22 @@ describe("setupChannels workspace shadow exclusion", () => { configured: false, statusLines: [], })), - configure: vi.fn(async ({ cfg }: { cfg: Record }) => ({ - cfg: { - ...cfg, - channels: { - telegram: { token: "secret" }, - }, - }, - })), - }; - const telegramPlugin = { + configure, + } as ChannelSetupPlugin["setupWizard"]; + const telegramPlugin = makeSetupPlugin({ id: "telegram", - meta: { id: "telegram", label: "Telegram", blurb: "" }, - capabilities: {}, - config: { - resolveAccount: vi.fn(() => ({})), - }, + label: "Telegram", setupWizard, - }; - const installedCatalogEntry = { - id: "telegram", + }); + const installedCatalogEntry = makeCatalogEntry("telegram", "Telegram", { pluginId: "telegram", origin: "bundled", - meta: { id: "telegram", label: "Telegram", blurb: "" }, - }; + }); resolveChannelSetupEntries.mockReturnValue({ entries: [ { id: "telegram", - meta: { id: "telegram", label: "Telegram", blurb: "" }, + meta: makeMeta("telegram", "Telegram"), }, ], installedCatalogEntries: [installedCatalogEntry], @@ -382,10 +447,17 @@ describe("setupChannels workspace shadow exclusion", () => { installedCatalogById: new Map([["telegram", installedCatalogEntry]]), installableCatalogById: new Map(), }); - loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({ - channels: [{ plugin: telegramPlugin }], - channelSetups: [], - }); + loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue( + makePluginRegistry({ + channels: [ + { + pluginId: "telegram", + source: "bundled", + plugin: telegramPlugin, + }, + ], + }), + ); const select = vi.fn().mockResolvedValueOnce("telegram").mockResolvedValueOnce("__done__"); const next = await setupChannels( @@ -413,7 +485,7 @@ describe("setupChannels workspace shadow exclusion", () => { ); expect(getChannelSetupPlugin).not.toHaveBeenCalled(); expect(collectChannelStatus).not.toHaveBeenCalled(); - expect(setupWizard.configure).toHaveBeenCalledWith( + expect(configure).toHaveBeenCalledWith( expect.objectContaining({ cfg: {}, }), @@ -439,7 +511,7 @@ describe("setupChannels workspace shadow exclusion", () => { entries: [ { id: "telegram", - meta: { id: "telegram", label: "Telegram", blurb: "" }, + meta: makeMeta("telegram", "Telegram"), }, ], installedCatalogEntries: [], @@ -488,7 +560,7 @@ describe("setupChannels workspace shadow exclusion", () => { entries: [ { id: "telegram", - meta: { id: "telegram", label: "Telegram", blurb: "" }, + meta: makeMeta("telegram", "Telegram"), }, ], installedCatalogEntries: [], diff --git a/src/gateway/control-ui.http.test.ts b/src/gateway/control-ui.http.test.ts index c48a0d7134b..fff1557252b 100644 --- a/src/gateway/control-ui.http.test.ts +++ b/src/gateway/control-ui.http.test.ts @@ -294,6 +294,82 @@ describe("handleControlUiHttpRequest", () => { }); }); + it("rejects trusted-proxy assistant media file reads without operator.read scope", async () => { + await withAllowedAssistantMediaRoot({ + prefix: "ui-media-scope-file-", + fn: async (tmpRoot) => { + const filePath = path.join(tmpRoot, "photo.png"); + await fs.writeFile(filePath, Buffer.from("not-a-real-png")); + const { res, handled, end } = await runAssistantMediaRequest({ + url: `/__openclaw__/assistant-media?source=${encodeURIComponent(filePath)}`, + method: "GET", + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + trustedProxies: ["10.0.0.1"], + remoteAddress: "10.0.0.1", + headers: { + host: "gateway.example.com", + "x-forwarded-user": "nick@example.com", + "x-forwarded-proto": "https", + "x-openclaw-scopes": "operator.approvals", + }, + }); + expect(handled).toBe(true); + expect(res.statusCode).toBe(403); + expect(JSON.parse(String(end.mock.calls[0]?.[0] ?? ""))).toMatchObject({ + ok: false, + error: { + type: "forbidden", + message: "missing scope: operator.read", + }, + }); + }, + }); + }); + + it("rejects trusted-proxy assistant media metadata requests with an empty scope set", async () => { + await withAllowedAssistantMediaRoot({ + prefix: "ui-media-scope-meta-", + fn: async (tmpRoot) => { + const filePath = path.join(tmpRoot, "photo.png"); + await fs.writeFile(filePath, Buffer.from("not-a-real-png")); + const { res, handled, end } = await runAssistantMediaRequest({ + url: `/__openclaw__/assistant-media?meta=1&source=${encodeURIComponent(filePath)}`, + method: "GET", + auth: { + mode: "trusted-proxy", + allowTailscale: false, + trustedProxy: { + userHeader: "x-forwarded-user", + }, + }, + trustedProxies: ["10.0.0.1"], + remoteAddress: "10.0.0.1", + headers: { + host: "gateway.example.com", + "x-forwarded-user": "nick@example.com", + "x-forwarded-proto": "https", + "x-openclaw-scopes": "", + }, + }); + expect(handled).toBe(true); + expect(res.statusCode).toBe(403); + expect(JSON.parse(String(end.mock.calls[0]?.[0] ?? ""))).toMatchObject({ + ok: false, + error: { + type: "forbidden", + message: "missing scope: operator.read", + }, + }); + }, + }); + }); + it("includes CSP hash for inline scripts in index.html", async () => { const scriptContent = "(function(){ var x = 1; })();"; const html = `\n`; diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 1f43123920c..41e487f32ae 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -38,7 +38,12 @@ import { resolveAssistantAvatarUrl, } from "./control-ui-shared.js"; import { sendGatewayAuthFailure } from "./http-common.js"; -import { getBearerToken, resolveHttpBrowserOriginPolicy } from "./http-utils.js"; +import { + getBearerToken, + resolveHttpBrowserOriginPolicy, + resolveTrustedHttpOperatorScopes, +} from "./http-utils.js"; +import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; const ROOT_PREFIX = "/"; const CONTROL_UI_ASSISTANT_MEDIA_PREFIX = "/__openclaw__/assistant-media"; @@ -307,6 +312,26 @@ export async function handleControlUiAssistantMediaRequest( sendGatewayAuthFailure(res, authResult); return true; } + const trustDeclaredOperatorScopes = + authResult.method !== "token" && + authResult.method !== "password" && + authResult.method !== "none"; + if (trustDeclaredOperatorScopes) { + const requestedScopes = resolveTrustedHttpOperatorScopes(req, { + trustDeclaredOperatorScopes, + }); + const scopeAuth = authorizeOperatorScopesForMethod("assistant.media.get", requestedScopes); + if (!scopeAuth.allowed) { + sendJson(res, 403, { + ok: false, + error: { + type: "forbidden", + message: `missing scope: ${scopeAuth.missingScope}`, + }, + }); + return true; + } + } } const source = normalizeAssistantMediaSource(url.searchParams.get("source") ?? ""); if (!source) { diff --git a/src/gateway/gateway-cli-backend.live-helpers.test.ts b/src/gateway/gateway-cli-backend.live-helpers.test.ts index 3d43b8f8af9..105e7fae8ed 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.test.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.test.ts @@ -122,4 +122,106 @@ describe("gateway cli backend live helpers", () => { expect(shouldRunCliModelSwitchProbe("claude-cli", "claude-cli/claude-sonnet-4-6")).toBe(false); }); + + it("allows live env overrides for fresh and resume CLI args", async () => { + const { resolveCliBackendLiveArgs } = await import("./gateway-cli-backend.live-helpers.js"); + + process.env.OPENCLAW_LIVE_CLI_BACKEND_ARGS = JSON.stringify([ + "exec", + "--sandbox", + "danger-full-access", + ]); + process.env.OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS = JSON.stringify([ + "exec", + "resume", + "{sessionId}", + "-c", + 'sandbox_mode="danger-full-access"', + ]); + + expect( + resolveCliBackendLiveArgs({ + providerId: "codex-cli", + defaultArgs: ["exec", "--sandbox", "workspace-write"], + defaultResumeArgs: [ + "exec", + "resume", + "{sessionId}", + "-c", + 'sandbox_mode="workspace-write"', + ], + }), + ).toEqual({ + args: ["exec", "--sandbox", "danger-full-access"], + resumeArgs: ["exec", "resume", "{sessionId}", "-c", 'sandbox_mode="danger-full-access"'], + }); + }); + + it("retries cancelled cron MCP replies", async () => { + const { shouldRetryCliCronMcpProbeReply } = + await import("./gateway-cli-backend.live-helpers.js"); + + expect( + shouldRetryCliCronMcpProbeReply( + "The `cron` MCP tool call was cancelled again, so the job was not created.", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The cron tool call was cancelled again, so the job still was not created.", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The `cron` MCP call was cancelled again, so the job was not created.", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The cron tool call was cancelled again, so nothing was created.", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The `cron` MCP tool call was cancelled (`user cancelled MCP tool call`).", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The tool call was cancelled before completion, so I can’t verify the cron job was created.", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The cron tool call was cancelled twice, so I could not create the job.", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The cron tool call was cancelled twice, so I couldn’t create `live-mcp-67f4e9`. Please retry and I’ll do it again.", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The cron tool call was canceled twice on the host side, so I couldn’t create `live-mcp-2d1afb`. If you want, send the same request again and I’ll retry.", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "I tried the `cron` tool call twice, but both attempts were canceled by the environment (`user cancelled MCP tool call`), so I can’t honestly reply with the success token.", + ), + ).toBe(true); + expect(shouldRetryCliCronMcpProbeReply(" ")).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The cron tool call was cancelled twice, so I couldn’t create `live-mcp-932c6b`. If you want, I can try again.", + ), + ).toBe(true); + expect( + shouldRetryCliCronMcpProbeReply( + "The cron job was not created because the schedule payload was invalid.", + ), + ).toBe(false); + expect(shouldRetryCliCronMcpProbeReply("live-mcp-abc123")).toBe(false); + }); }); diff --git a/src/gateway/gateway-cli-backend.live-helpers.ts b/src/gateway/gateway-cli-backend.live-helpers.ts index 72c745a4abe..b1dc8a90818 100644 --- a/src/gateway/gateway-cli-backend.live-helpers.ts +++ b/src/gateway/gateway-cli-backend.live-helpers.ts @@ -27,11 +27,32 @@ import { type CronListJob, } from "./live-agent-probes.js"; import { renderCatFacePngBase64 } from "./live-image-probe.js"; +import { getActiveMcpLoopbackRuntime } from "./mcp-http.js"; import { extractPayloadText } from "./test-helpers.agent-results.js"; // Aggregate docker live runs can contend on startup enough that the gateway // websocket handshake needs a wider budget than the single-provider reruns. const CLI_GATEWAY_CONNECT_TIMEOUT_MS = 60_000; +// CI Docker live lanes can see repeated cancelled cron tool calls before a job +// finally sticks, and the created job may take extra time to surface via the CLI. +const CLI_CRON_MCP_PROBE_MAX_ATTEMPTS = 10; +const CLI_CRON_MCP_PROBE_VERIFY_POLLS = 20; +const CLI_CRON_MCP_PROBE_VERIFY_POLL_MS = 2_000; + +function shouldLogCliCronProbe(): boolean { + return ( + isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_DEBUG) || + isTruthyEnvValue(process.env.OPENCLAW_CLI_BACKEND_LOG_OUTPUT) + ); +} + +function logCliCronProbe(step: string, details?: Record): void { + if (!shouldLogCliCronProbe()) { + return; + } + const suffix = details && Object.keys(details).length > 0 ? ` ${JSON.stringify(details)}` : ""; + console.error(`[gateway-cli-live:cron] ${step}${suffix}`); +} export type BootstrapWorkspaceContext = { expectedInjectedFiles: string[]; @@ -98,6 +119,29 @@ export function shouldRunCliMcpProbe(providerId: string): boolean { return resolveCliBackendLiveTest(providerId)?.defaultMcpProbe === true; } +export function resolveCliBackendLiveArgs(params: { + providerId: string; + defaultArgs?: string[]; + defaultResumeArgs?: string[]; +}): { args: string[]; resumeArgs?: string[] } { + const args = + parseJsonStringArray( + "OPENCLAW_LIVE_CLI_BACKEND_ARGS", + process.env.OPENCLAW_LIVE_CLI_BACKEND_ARGS, + ) ?? params.defaultArgs; + if (!args || args.length === 0) { + throw new Error( + `OPENCLAW_LIVE_CLI_BACKEND_ARGS is required for provider "${params.providerId}".`, + ); + } + const resumeArgs = + parseJsonStringArray( + "OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS", + process.env.OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS, + ) ?? params.defaultResumeArgs; + return { args, resumeArgs }; +} + export function resolveCliModelSwitchProbeTarget( providerId: string, modelRef: string, @@ -171,6 +215,259 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function pollCliCronJobVisible(params: { + port: number; + token: string; + env: NodeJS.ProcessEnv; + expectedName: string; + expectedMessage: string; + polls?: number; + pollMs?: number; +}): Promise<{ job?: CronListJob; pollsUsed: number }> { + const polls = Math.max(1, params.polls ?? CLI_CRON_MCP_PROBE_VERIFY_POLLS); + const pollMs = Math.max(0, params.pollMs ?? CLI_CRON_MCP_PROBE_VERIFY_POLL_MS); + for (let verifyAttempt = 0; verifyAttempt < polls; verifyAttempt += 1) { + const job = await assertCronJobVisibleViaCli({ + port: params.port, + token: params.token, + env: params.env, + expectedName: params.expectedName, + expectedMessage: params.expectedMessage, + }); + if (job) { + return { job, pollsUsed: verifyAttempt + 1 }; + } + if (verifyAttempt < polls - 1) { + await sleep(pollMs); + } + } + return { pollsUsed: polls }; +} + +type LoopbackJsonRpcResponse = { + result?: unknown; + error?: { message?: string }; +}; + +async function callLoopbackJsonRpc(params: { + sessionKey: string; + senderIsOwner: boolean; + messageProvider?: string; + accountId?: string; + body: Record; +}): Promise { + const runtime = getActiveMcpLoopbackRuntime(); + if (!runtime) { + throw new Error("mcp loopback runtime is not active"); + } + const headers: Record = { + Authorization: `Bearer ${runtime.token}`, + "Content-Type": "application/json", + "x-session-key": params.sessionKey, + "x-openclaw-sender-is-owner": params.senderIsOwner ? "true" : "false", + }; + if (params.messageProvider) { + headers["x-openclaw-message-channel"] = params.messageProvider; + } + if (params.accountId) { + headers["x-openclaw-account-id"] = params.accountId; + } + const response = await fetch(`http://127.0.0.1:${runtime.port}/mcp`, { + method: "POST", + headers, + body: JSON.stringify(params.body), + }); + const text = await response.text(); + if (!response.ok) { + throw new Error(`mcp loopback http ${response.status}: ${text}`); + } + if (!text.trim()) { + return {}; + } + const parsed = JSON.parse(text) as LoopbackJsonRpcResponse; + if (parsed.error?.message) { + throw new Error(`mcp loopback json-rpc error: ${parsed.error.message}`); + } + return parsed; +} + +export async function verifyCliCronMcpLoopbackPreflight(params: { + sessionKey: string; + port: number; + token: string; + env: NodeJS.ProcessEnv; + senderIsOwner: boolean; + messageProvider?: string; + accountId?: string; +}): Promise { + const cronProbe = createLiveCronProbeSpec(); + logCliCronProbe("loopback-preflight:start", { + sessionKey: params.sessionKey, + senderIsOwner: params.senderIsOwner, + jobName: cronProbe.name, + }); + + await callLoopbackJsonRpc({ + sessionKey: params.sessionKey, + senderIsOwner: params.senderIsOwner, + messageProvider: params.messageProvider, + accountId: params.accountId, + body: { + jsonrpc: "2.0", + id: "init", + method: "initialize", + params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "vitest" } }, + }, + }); + await callLoopbackJsonRpc({ + sessionKey: params.sessionKey, + senderIsOwner: params.senderIsOwner, + messageProvider: params.messageProvider, + accountId: params.accountId, + body: { jsonrpc: "2.0", method: "notifications/initialized" }, + }); + const toolsList = await callLoopbackJsonRpc({ + sessionKey: params.sessionKey, + senderIsOwner: params.senderIsOwner, + messageProvider: params.messageProvider, + accountId: params.accountId, + body: { jsonrpc: "2.0", id: "tools-list", method: "tools/list" }, + }); + const tools = Array.isArray((toolsList.result as { tools?: unknown[] } | undefined)?.tools) + ? (((toolsList.result as { tools?: unknown[] }).tools ?? []) as Array<{ name?: string }>) + : []; + const toolNames = tools + .map((tool) => (typeof tool.name === "string" ? tool.name : "")) + .filter(Boolean); + logCliCronProbe("loopback-preflight:tools", { + senderIsOwner: params.senderIsOwner, + toolCount: toolNames.length, + cronVisible: toolNames.includes("cron"), + }); + if (!toolNames.includes("cron")) { + throw new Error( + `mcp loopback tools/list did not expose cron (senderIsOwner=${String(params.senderIsOwner)})`, + ); + } + + const toolCall = await callLoopbackJsonRpc({ + sessionKey: params.sessionKey, + senderIsOwner: params.senderIsOwner, + messageProvider: params.messageProvider, + accountId: params.accountId, + body: { + jsonrpc: "2.0", + id: "cron-add", + method: "tools/call", + params: { + name: "cron", + arguments: JSON.parse(cronProbe.argsJson) as Record, + }, + }, + }); + const toolCallError = + (toolCall.result as { isError?: unknown } | undefined)?.isError === true || + !(toolCall.result as { content?: unknown } | undefined); + logCliCronProbe("loopback-preflight:call", { + isError: toolCallError, + jobName: cronProbe.name, + }); + if (toolCallError) { + throw new Error(`mcp loopback cron tools/call returned isError for job ${cronProbe.name}`); + } + + const { job: createdJob, pollsUsed } = await pollCliCronJobVisible({ + port: params.port, + token: params.token, + env: params.env, + expectedName: cronProbe.name, + expectedMessage: cronProbe.message, + }); + logCliCronProbe("loopback-preflight:verify", { + jobName: cronProbe.name, + pollsUsed, + createdJob: Boolean(createdJob), + }); + if (!createdJob) { + throw new Error(`mcp loopback cron tools/call did not create job ${cronProbe.name}`); + } + assertCronJobMatches({ + job: createdJob, + expectedName: cronProbe.name, + expectedMessage: cronProbe.message, + expectedSessionKey: params.sessionKey, + }); + if (createdJob.id) { + await runOpenClawCliJson( + [ + "cron", + "rm", + createdJob.id, + "--json", + "--url", + `ws://127.0.0.1:${params.port}`, + "--token", + params.token, + ], + params.env, + ); + } + logCliCronProbe("loopback-preflight:done", { jobName: cronProbe.name }); +} + +export function shouldRetryCliCronMcpProbeReply(text: string): boolean { + const normalized = normalizeLowercaseStringOrEmpty(text); + if (!normalized) { + return true; + } + const mentionsCancellation = + normalized.includes("tool call was cancelled") || + normalized.includes("tool call was canceled") || + normalized.includes("tool call was cancelled before completion") || + normalized.includes("tool call was canceled before completion") || + normalized.includes("attempts were cancelled") || + normalized.includes("attempts were canceled") || + normalized.includes("cancelled by the environment") || + normalized.includes("canceled by the environment") || + normalized.includes("mcp call was cancelled") || + normalized.includes("mcp call was canceled"); + const mentionsUserCancellation = + normalized.includes("user cancelled mcp tool call") || + normalized.includes("user canceled mcp tool call"); + const mentionsCreateFailure = + normalized.includes("could not create ") || + normalized.includes("couldn't create ") || + normalized.includes("couldn’t create ") || + normalized.includes("could not create the job") || + normalized.includes("couldn't create the job") || + normalized.includes("couldn’t create the job") || + normalized.includes("could not create job") || + normalized.includes("couldn't create job") || + normalized.includes("couldn’t create job"); + const mentionsRetryRequest = + normalized.includes("please retry") || + normalized.includes("i can try again") || + normalized.includes("i'll retry") || + normalized.includes("i’ll retry") || + normalized.includes("send the same request again"); + const mentionsMissingJob = + normalized.includes("job was not created") || + normalized.includes("job still was not created") || + normalized.includes("nothing was created") || + normalized.includes("verify the cron job was created") || + normalized.includes("was not created"); + if (mentionsUserCancellation) { + return true; + } + return ( + mentionsCancellation && (mentionsMissingJob || mentionsCreateFailure || mentionsRetryRequest) + ); +} + +function getCliBackendProbeThinking(providerId: string): "low" | undefined { + return normalizeLowercaseStringOrEmpty(providerId) === "codex-cli" ? "low" : undefined; +} + export async function connectTestGatewayClient(params: { url: string; token: string; @@ -368,6 +665,7 @@ export async function verifyCliBackendImageProbe(params: { tempDir: string; bootstrapWorkspace: BootstrapWorkspaceContext | null; }): Promise { + const thinking = getCliBackendProbeThinking(params.providerId); const imageBase64 = renderCatFacePngBase64(); const runIdImage = randomUUID(); const imageProbe = await params.client.request( @@ -389,6 +687,7 @@ export async function verifyCliBackendImageProbe(params: { }, ], deliver: false, + ...(thinking ? { thinking } : {}), }, { expectFinal: true }, ); @@ -407,11 +706,18 @@ export async function verifyCliCronMcpProbe(params: { env: NodeJS.ProcessEnv; }): Promise { const cronProbe = createLiveCronProbeSpec(); + const thinking = getCliBackendProbeThinking(params.providerId); let createdJob: CronListJob | undefined; let lastCronText = ""; - for (let attempt = 0; attempt < 2 && !createdJob; attempt += 1) { + for (let attempt = 0; attempt < CLI_CRON_MCP_PROBE_MAX_ATTEMPTS && !createdJob; attempt += 1) { + logCliCronProbe("agent-attempt:start", { + attempt, + providerId: params.providerId, + sessionKey: params.sessionKey, + expectedJob: cronProbe.name, + }); const runIdMcp = randomUUID(); const cronResult = await params.client.request( "agent", @@ -425,6 +731,7 @@ export async function verifyCliCronMcpProbe(params: { exactReply: cronProbe.name, }), deliver: false, + ...(thinking ? { thinking } : {}), }, { expectFinal: true }, ); @@ -432,22 +739,37 @@ export async function verifyCliCronMcpProbe(params: { throw new Error(`cron mcp probe failed: status=${String(cronResult?.status)}`); } lastCronText = extractPayloadText(cronResult?.result).trim(); - createdJob = await assertCronJobVisibleViaCli({ + const retryableReply = shouldRetryCliCronMcpProbeReply(lastCronText); + logCliCronProbe("agent-attempt:reply", { + attempt, + retryableReply, + reply: lastCronText, + }); + const verifyResult = await pollCliCronJobVisible({ port: params.port, token: params.token, env: params.env, expectedName: cronProbe.name, expectedMessage: cronProbe.message, }); - if (!createdJob && attempt === 1) { + createdJob = verifyResult.job; + logCliCronProbe("agent-attempt:verify", { + attempt, + pollsUsed: verifyResult.pollsUsed, + createdJob: Boolean(createdJob), + retryableReply, + }); + if (!createdJob && !retryableReply) { throw new Error( - `cron cli verify could not find job ${cronProbe.name}: reply=${JSON.stringify(lastCronText)}`, + `cron cli verify could not find job ${cronProbe.name} after attempt ${attempt + 1}: reply=${JSON.stringify(lastCronText)}`, ); } } if (!createdJob) { - throw new Error(`cron cli verify did not create job ${cronProbe.name}`); + throw new Error( + `cron cli verify did not create job ${cronProbe.name} after ${CLI_CRON_MCP_PROBE_MAX_ATTEMPTS} attempts: reply=${JSON.stringify(lastCronText)}`, + ); } assertCronJobMatches({ job: createdJob, diff --git a/src/gateway/gateway-cli-backend.live.test.ts b/src/gateway/gateway-cli-backend.live.test.ts index bce1ec7d336..287ddfd68cf 100644 --- a/src/gateway/gateway-cli-backend.live.test.ts +++ b/src/gateway/gateway-cli-backend.live.test.ts @@ -15,14 +15,16 @@ import { getFreeGatewayPort, matchesCliBackendReply, parseImageMode, - parseJsonStringArray, resolveCliModelSwitchProbeTarget, + resolveCliBackendLiveArgs, + parseJsonStringArray, restoreCliBackendLiveEnv, shouldRunCliImageProbe, shouldRunCliModelSwitchProbe, shouldRunCliMcpProbe, snapshotCliBackendLiveEnv, type SystemPromptReport, + verifyCliCronMcpLoopbackPreflight, verifyCliCronMcpProbe, verifyCliBackendImageProbe, withClaudeMcpConfigOverrides, @@ -40,7 +42,9 @@ const describeLive = LIVE && CLI_LIVE ? describe : describe.skip; const DEFAULT_PROVIDER = "claude-cli"; const DEFAULT_MODEL = resolveCliBackendLiveTest(DEFAULT_PROVIDER)?.defaultModelRef ?? "claude-cli/claude-sonnet-4-6"; -const CLI_BACKEND_LIVE_TIMEOUT_MS = 420_000; +// The cron/MCP live probe now tolerates more cancelled tool-call retries in CI, +// so the outer test budget needs enough headroom to finish those retries. +const CLI_BACKEND_LIVE_TIMEOUT_MS = 720_000; function logCliBackendLiveStep(step: string, details?: Record): void { if (!CLI_DEBUG) { @@ -104,14 +108,11 @@ describeLive("gateway live (cli backend)", () => { ); } - const baseCliArgs = - parseJsonStringArray( - "OPENCLAW_LIVE_CLI_BACKEND_ARGS", - process.env.OPENCLAW_LIVE_CLI_BACKEND_ARGS, - ) ?? providerDefaults?.args; - if (!baseCliArgs || baseCliArgs.length === 0) { - throw new Error(`OPENCLAW_LIVE_CLI_BACKEND_ARGS is required for provider "${providerId}".`); - } + const { args: baseCliArgs, resumeArgs: baseCliResumeArgs } = resolveCliBackendLiveArgs({ + providerId, + defaultArgs: providerDefaults?.args, + defaultResumeArgs: providerDefaults?.resumeArgs, + }); const cliClearEnv = parseJsonStringArray( @@ -190,6 +191,7 @@ describeLive("gateway live (cli backend)", () => { [providerId]: { command: cliCommand, args: cliArgs, + resumeArgs: baseCliResumeArgs, clearEnv: filteredCliClearEnv.length > 0 ? filteredCliClearEnv : undefined, env: Object.keys(preservedCliEnv).length > 0 ? preservedCliEnv : undefined, systemPromptWhen: providerDefaults?.systemPromptWhen ?? "never", @@ -352,6 +354,18 @@ describeLive("gateway live (cli backend)", () => { } if (enableCliMcpProbe) { + logCliBackendLiveStep("cron-mcp-loopback-preflight:start", { + sessionKey, + senderIsOwner: true, + }); + await verifyCliCronMcpLoopbackPreflight({ + sessionKey, + port, + token, + env: process.env, + senderIsOwner: true, + }); + logCliBackendLiveStep("cron-mcp-loopback-preflight:done"); logCliBackendLiveStep("cron-mcp-probe:start", { sessionKey }); await verifyCliCronMcpProbe({ client, diff --git a/src/gateway/gateway-codex-harness.live-helpers.test.ts b/src/gateway/gateway-codex-harness.live-helpers.test.ts new file mode 100644 index 00000000000..23bb426b514 --- /dev/null +++ b/src/gateway/gateway-codex-harness.live-helpers.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { + EXPECTED_CODEX_MODELS_COMMAND_TEXT, + isExpectedCodexModelsCommandText, +} from "./gateway-codex-harness.live-helpers.js"; + +describe("gateway codex harness live helpers", () => { + it("accepts the interactive model-selection summary emitted by current codex", () => { + const text = [ + "`/codex models` opened an interactive model-selection prompt rather than printing a plain list.", + "", + "Visible options in this session:", + "- `GPT-5.4`", + "- `GPT-5.3-Codex` (listed as the existing model)", + "", + "Current active model is `codex/gpt-5.4`.", + ].join("\n"); + + expect( + EXPECTED_CODEX_MODELS_COMMAND_TEXT.some((expectedText) => text.includes(expectedText)), + ).toBe(true); + expect(isExpectedCodexModelsCommandText(text)).toBe(true); + }); + + it("accepts the configured-model fallback summary", () => { + const text = [ + "Configured models in this session:", + "- `codex/gpt-5.4`", + "Current session model is `codex/gpt-5.4`.", + ].join("\n"); + + expect(isExpectedCodexModelsCommandText(text)).toBe(true); + }); + + it("rejects unrelated codex command output", () => { + expect(isExpectedCodexModelsCommandText("Codex is healthy.")).toBe(false); + }); + + it("rejects generic current-status output that is not a model listing", () => { + const text = [ + "Current: waiting for the Codex CLI to finish booting.", + "Try again in a few seconds.", + ].join("\n"); + + expect( + EXPECTED_CODEX_MODELS_COMMAND_TEXT.some((expectedText) => text.includes(expectedText)), + ).toBe(false); + expect(isExpectedCodexModelsCommandText(text)).toBe(false); + }); +}); diff --git a/src/gateway/gateway-codex-harness.live-helpers.ts b/src/gateway/gateway-codex-harness.live-helpers.ts new file mode 100644 index 00000000000..7715699930d --- /dev/null +++ b/src/gateway/gateway-codex-harness.live-helpers.ts @@ -0,0 +1,95 @@ +export const EXPECTED_CODEX_MODELS_COMMAND_TEXT = [ + "Codex models:", + "Available Codex models", + "Available agent target:", + "Available agent targets:", + "opened an interactive trust prompt", + "opened an interactive model-selection prompt", + "running as Codex on `codex/", + "currently running on `codex/", + "stdin is not a terminal", + "The local `codex models` entrypoint is interactive in this environment", + "`codex models` did not run in this environment.", + "`codex models` failed in this sandbox", + "`codex models` could not be run in this sandbox.", + "`codex models` is not runnable in this sandboxed session.", + "I couldn’t get a direct `codex models` CLI listing because the local sandbox blocked that command.", + "I couldn’t list all installed/available Codex models from the local CLI because the sandboxed `codex` command failed to start in this environment.", + "I couldn’t get `codex models` from the CLI because the sandbox blocks the namespace setup it needs", + "I can only see the current session model from this environment", + "Available in this session:", + "Available models in this session:", + "Available models in this environment:", + "Available models in this Codex environment:", + "Available agent models:", + "Visible options in this session:", + "Current: `codex/", + "Current model:", + "Current model: `codex/", + "Current model is `codex/", + "Current session model: `codex/", + "Current session model is `codex/", + "The current session is using `codex/", + "Configured model from `~/.codex/config.toml`:", + "Configured models in this session:", + "Default model:", + "This harness is configured with a single Codex model: `codex/", + "Primary model: `codex/", + "Registered models: `codex/", + "Current active model is `codex/", + "Current OpenClaw session status reports the active model as:", +] as const; + +export function isExpectedCodexModelsCommandText(text: string): boolean { + const normalized = text.toLowerCase(); + const isSandboxFallback = + text.includes("`codex models`") && + (text.includes("did not run") || + text.includes("could not run") || + text.includes("could not be run") || + text.includes("failed in this sandbox") || + text.includes("failed with:") || + text.includes("repo-local fallback") || + text.includes("sandbox blocks") || + text.includes("interactive in this environment") || + text.includes("sandboxed session") || + text.includes("required user namespace")); + + const mentionsConfiguredModels = + normalized.includes("configured model") || + normalized.includes("configured codex model") || + normalized.includes("configured models"); + const mentionsSessionModel = + normalized.includes("current session is using") || + normalized.includes("current session model") || + normalized.includes("the current session is using"); + const mentionsConfigSummary = + normalized.includes("default model") || + normalized.includes("primary model") || + normalized.includes("registered models") || + normalized.includes("only listed model") || + normalized.includes("single codex model") || + normalized.includes("live openclaw config shows") || + normalized.includes("current gateway config"); + const isSessionConfigFallback = + text.includes("`codex/") && + ((mentionsConfiguredModels && mentionsSessionModel) || + (mentionsConfigSummary && (mentionsConfiguredModels || mentionsSessionModel))); + + const mentionsInteractiveSelection = + normalized.includes("interactive model-selection prompt") || + normalized.includes("interactive model selection prompt"); + const mentionsVisibleOptions = + normalized.includes("visible options in this session:") || + normalized.includes("visible options:"); + const mentionsCurrentActiveModel = + normalized.includes("current active model is `codex/") || + normalized.includes("current active model is codex/"); + const isInteractiveSelectionSummary = + text.includes("`/codex models`") && + mentionsInteractiveSelection && + mentionsVisibleOptions && + mentionsCurrentActiveModel; + + return isSandboxFallback || isSessionConfigFallback || isInteractiveSelectionSummary; +} diff --git a/src/gateway/gateway-codex-harness.live.test.ts b/src/gateway/gateway-codex-harness.live.test.ts index c84dea6c8b4..e6079317312 100644 --- a/src/gateway/gateway-codex-harness.live.test.ts +++ b/src/gateway/gateway-codex-harness.live.test.ts @@ -9,6 +9,10 @@ import type { OpenClawConfig } from "../config/config.js"; import type { DeviceIdentity } from "../infra/device-identity.js"; import { isTruthyEnvValue } from "../infra/env.js"; import type { GatewayClient } from "./client.js"; +import { + EXPECTED_CODEX_MODELS_COMMAND_TEXT, + isExpectedCodexModelsCommandText, +} from "./gateway-codex-harness.live-helpers.js"; import { assertCronJobMatches, assertCronJobVisibleViaCli, @@ -27,6 +31,8 @@ const CODEX_HARNESS_IMAGE_PROBE = isTruthyEnvValue( process.env.OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE, ); const CODEX_HARNESS_MCP_PROBE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE); +const CODEX_HARNESS_AUTH_MODE = + process.env.OPENCLAW_LIVE_CODEX_HARNESS_AUTH === "api-key" ? "api-key" : "codex-auth"; const describeLive = LIVE && CODEX_HARNESS_LIVE ? describe : describe.skip; const describeDisabled = LIVE && !CODEX_HARNESS_LIVE ? describe : describe.skip; const CODEX_HARNESS_TIMEOUT_MS = 420_000; @@ -38,6 +44,7 @@ type EnvSnapshot = { configPath?: string; gatewayToken?: string; openaiApiKey?: string; + openaiBaseUrl?: string; skipBrowserControl?: string; skipCanvas?: string; skipChannels?: string; @@ -60,6 +67,7 @@ function snapshotEnv(): EnvSnapshot { configPath: process.env.OPENCLAW_CONFIG_PATH, gatewayToken: process.env.OPENCLAW_GATEWAY_TOKEN, openaiApiKey: process.env.OPENAI_API_KEY, + openaiBaseUrl: process.env.OPENAI_BASE_URL, skipBrowserControl: process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER, skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST, skipChannels: process.env.OPENCLAW_SKIP_CHANNELS, @@ -74,6 +82,7 @@ function restoreEnv(snapshot: EnvSnapshot): void { restoreEnvVar("OPENCLAW_CONFIG_PATH", snapshot.configPath); restoreEnvVar("OPENCLAW_GATEWAY_TOKEN", snapshot.gatewayToken); restoreEnvVar("OPENAI_API_KEY", snapshot.openaiApiKey); + restoreEnvVar("OPENAI_BASE_URL", snapshot.openaiBaseUrl); restoreEnvVar("OPENCLAW_SKIP_BROWSER_CONTROL_SERVER", snapshot.skipBrowserControl); restoreEnvVar("OPENCLAW_SKIP_CANVAS_HOST", snapshot.skipCanvas); restoreEnvVar("OPENCLAW_SKIP_CHANNELS", snapshot.skipChannels); @@ -273,6 +282,7 @@ async function requestCodexCommandText(params: { client: GatewayClient; command: string; expectedText: string | string[]; + isExpectedText?: (text: string) => boolean; sessionKey: string; }): Promise { const { extractPayloadText } = await import("./test-helpers.agent-results.js"); @@ -296,8 +306,10 @@ async function requestCodexCommandText(params: { const expectedTexts = Array.isArray(params.expectedText) ? params.expectedText : [params.expectedText]; + const matchedByText = expectedTexts.some((expectedText) => text.includes(expectedText)); + const matchedByPredicate = params.isExpectedText?.(text) ?? false; expect( - expectedTexts.some((expectedText) => text.includes(expectedText)), + matchedByText || matchedByPredicate, `Expected "${params.command}" response to contain one of: ${expectedTexts.join(", ")}\nReceived:\n${text}`, ).toBe(true); return text; @@ -411,10 +423,6 @@ describeLive("gateway live (Codex harness)", () => { "runs gateway agent turns through the plugin-owned Codex app-server harness", async () => { const modelKey = process.env.OPENCLAW_LIVE_CODEX_HARNESS_MODEL ?? DEFAULT_CODEX_MODEL; - const openaiKey = process.env.OPENAI_API_KEY?.trim(); - if (!openaiKey) { - throw new Error("OPENAI_API_KEY is required for the Codex harness live test."); - } const { clearRuntimeConfigSnapshot } = await import("../config/config.js"); const { startGatewayServer } = await import("./server.js"); @@ -429,6 +437,17 @@ describeLive("gateway live (Codex harness)", () => { clearRuntimeConfigSnapshot(); process.env.OPENCLAW_AGENT_RUNTIME = "codex"; process.env.OPENCLAW_AGENT_HARNESS_FALLBACK = "none"; + // Keep the runtime fixed on the plugin-owned Codex app-server harness. + // CI can opt into API-key auth to avoid stale OAuth refresh secrets, + // while local maintainer runs can continue exercising staged ~/.codex auth. + // Only the Codex-auth path should force-clear OpenAI overrides; API-key + // mode may intentionally point at a custom endpoint. + if (CODEX_HARNESS_AUTH_MODE !== "api-key") { + delete process.env.OPENAI_BASE_URL; + delete process.env.OPENAI_API_KEY; + } else if (!process.env.OPENAI_BASE_URL?.trim()) { + delete process.env.OPENAI_BASE_URL; + } process.env.OPENCLAW_CONFIG_PATH = configPath; process.env.OPENCLAW_GATEWAY_TOKEN = token; process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = "1"; @@ -500,18 +519,8 @@ describeLive("gateway live (Codex harness)", () => { client, sessionKey, command: "/codex models", - expectedText: [ - "Codex models:", - "Available Codex models", - "Available agent target:", - "Available agent targets:", - "opened an interactive trust prompt", - "running as Codex on `codex/", - "currently running on `codex/", - "stdin is not a terminal", - "Configured model from `~/.codex/config.toml`:", - "Current OpenClaw session status reports the active model as:", - ], + expectedText: [...EXPECTED_CODEX_MODELS_COMMAND_TEXT], + isExpectedText: isExpectedCodexModelsCommandText, }); logCodexLiveStep("codex-models-command", { modelsText }); diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index e3a748a3e6b..5ed3a6e338d 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -7,7 +7,8 @@ import type { Api, Model } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; import { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; -import type { AuthProfileStore } from "../agents/auth-profiles.js"; +import { ensureAuthProfileStore, saveAuthProfileStore } from "../agents/auth-profiles/store.js"; +import type { AuthProfileStore } from "../agents/auth-profiles/types.js"; import { collectAnthropicApiKeys, isAnthropicBillingError, @@ -20,6 +21,7 @@ import { isHighSignalLiveModelRef, resolveHighSignalLiveModelLimit, selectHighSignalLiveItems, + shouldExcludeProviderFromDefaultHighSignalLiveSweep, } from "../agents/live-model-filter.js"; import { createLiveTargetMatcher } from "../agents/live-target-matcher.js"; import { isLiveProfileKeyModeEnabled, isLiveTestEnabled } from "../agents/live-test-helpers.js"; @@ -29,7 +31,8 @@ import { shouldSuppressBuiltInModel } from "../agents/model-suppression.js"; import { ensureOpenClawModelsJson } from "../agents/models-config.js"; import { isRateLimitErrorMessage } from "../agents/pi-embedded-helpers/errors.js"; import { discoverAuthStorage, discoverModels } from "../agents/pi-model-discovery.js"; -import type { ModelsConfig, OpenClawConfig, ModelProviderConfig } from "../config/types.js"; +import { clearRuntimeConfigSnapshot, loadConfig } from "../config/io.js"; +import type { ModelsConfig, ModelProviderConfig, OpenClawConfig } from "../config/types.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { normalizeGoogleModelId } from "../plugin-sdk/google-model-id.js"; import { DEFAULT_AGENT_ID } from "../routing/session-key.js"; @@ -43,7 +46,7 @@ import { shouldRetryExecReadProbe, shouldRetryToolReadProbe, } from "./live-tool-probe-utils.js"; -import { startGatewayServer } from "./server.js"; +import { startGatewayServer } from "./server.impl.js"; import { loadSessionEntry, readSessionMessages } from "./session-utils.js"; const ZAI_FALLBACK = isTruthyEnvValue(process.env.OPENCLAW_LIVE_GATEWAY_ZAI_FALLBACK); @@ -233,19 +236,6 @@ async function withGatewayLiveModelTimeout(operation: Promise, context: st }); } -let gatewayConfigModulePromise: Promise | undefined; -let authProfilesModulePromise: Promise | undefined; - -async function getGatewayConfigModule() { - gatewayConfigModulePromise ??= import("../config/config.js"); - return await gatewayConfigModulePromise; -} - -async function getAuthProfilesModule() { - authProfilesModulePromise ??= import("../agents/auth-profiles.js"); - return await authProfilesModulePromise; -} - function logProgress(message: string): void { process.stderr.write(`[live] ${message}\n`); } @@ -1250,7 +1240,6 @@ async function sanitizeAuthConfig(params: { if (!auth) { return auth; } - const { ensureAuthProfileStore } = await getAuthProfilesModule(); const store = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false, }); @@ -1311,7 +1300,7 @@ function buildMinimaxProviderOverride(params: { } async function runGatewayModelSuite(params: GatewayModelSuiteParams) { - (await getGatewayConfigModule()).clearRuntimeConfigSnapshot(); + clearRuntimeConfigSnapshot(); const runtimeEnv = enterProductionEnvForLiveRun(); const previous = { configPath: process.env.OPENCLAW_CONFIG_PATH, @@ -1343,7 +1332,6 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { const agentId = "dev"; const hostAgentDir = resolveOpenClawAgentDir(); - const { ensureAuthProfileStore, saveAuthProfileStore } = await getAuthProfilesModule(); const hostStore = ensureAuthProfileStore(hostAgentDir, { allowKeychainPrompt: false, }); @@ -1396,6 +1384,9 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { await fs.writeFile(modelsPath, `${JSON.stringify({ providers: liveProviders }, null, 2)}\n`); } + // Keep the broad live Docker suite on the impl entrypoint. The lazy public + // boundary (`./server.js`) is covered elsewhere, but under Vitest's live Docker + // worker this path can trip a Node module-status loader bug during startup. let server: Awaited> | undefined; let client: GatewayClient | undefined; try { @@ -2006,7 +1997,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) { logProgress(`[${params.label}] skipped all models (missing profiles)`); } } finally { - (await getGatewayConfigModule()).clearRuntimeConfigSnapshot(); + clearRuntimeConfigSnapshot(); restoreProductionEnvForLiveRun(runtimeEnv); client.stop(); await server.close({ reason: "live test complete" }); @@ -2040,8 +2031,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { "runs meaningful prompts across models with available keys", async () => await withSuppressedGatewayLiveWarnings(async () => { - const { loadConfig } = await getGatewayConfigModule(); - (await getGatewayConfigModule()).clearRuntimeConfigSnapshot(); + clearRuntimeConfigSnapshot(); const cfg = loadConfig(); await ensureOpenClawModelsJson(cfg); @@ -2063,7 +2053,16 @@ describeLive("gateway live (dev agent, profile keys)", () => { }); const wanted = filter ? all.filter((m) => targetMatcher.matchesModel(m.provider, m.id)) - : all.filter((m) => isHighSignalLiveModelRef({ provider: m.provider, id: m.id })); + : all.filter( + (m) => + !shouldExcludeProviderFromDefaultHighSignalLiveSweep({ + provider: m.provider, + useExplicitModels: useExplicit, + providerFilter: PROVIDERS, + config: cfg, + env: process.env, + }) && isHighSignalLiveModelRef({ provider: m.provider, id: m.id }), + ); const candidates: Array> = []; const skipped: Array<{ model: string; error: string }> = []; @@ -2164,8 +2163,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { if (!ZAI_FALLBACK) { return; } - const { loadConfig } = await getGatewayConfigModule(); - (await getGatewayConfigModule()).clearRuntimeConfigSnapshot(); + clearRuntimeConfigSnapshot(); const runtimeEnv = enterProductionEnvForLiveRun(); const previous = { configPath: process.env.OPENCLAW_CONFIG_PATH, @@ -2323,10 +2321,7 @@ describeLive("gateway live (dev agent, profile keys)", () => { throw new Error(`zai followup missing nonce: ${followupText}`); } } finally { - { - const { clearRuntimeConfigSnapshot } = await getGatewayConfigModule(); - clearRuntimeConfigSnapshot(); - } + clearRuntimeConfigSnapshot(); restoreProductionEnvForLiveRun(runtimeEnv); client.stop(); await server.close({ reason: "live test complete" }); diff --git a/src/gateway/http-common.fuzz.test.ts b/src/gateway/http-common.fuzz.test.ts new file mode 100644 index 00000000000..878da3070e4 --- /dev/null +++ b/src/gateway/http-common.fuzz.test.ts @@ -0,0 +1,451 @@ +import { EventEmitter } from "node:events"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { GatewayAuthResult } from "./auth.js"; +import { + readJsonBodyOrError, + sendGatewayAuthFailure, + sendInvalidRequest, + sendJson, + sendMethodNotAllowed, + sendRateLimited, + sendText, + sendUnauthorized, + setDefaultSecurityHeaders, + setSseHeaders, + watchClientDisconnect, + writeDone, +} from "./http-common.js"; +import { makeMockHttpResponse } from "./test-http-response.js"; + +/** + * Seeded property-based / fuzz coverage for http-common. + * + * The repo does not pull in fast-check, so this file ships a small, + * deterministic PRNG (mulberry32) + generators. Every property runs + * N iterations; any failure prints the seed-derived inputs so failures + * are reproducible. + */ + +const readJsonBodyMock = vi.hoisted(() => vi.fn()); + +vi.mock("./hooks.js", () => ({ + readJsonBody: readJsonBodyMock, +})); + +beforeEach(() => { + readJsonBodyMock.mockReset(); +}); + +/** Deterministic 32-bit PRNG. */ +function makeRng(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function randInt(rng: () => number, loInclusive: number, hiInclusive: number): number { + return Math.floor(rng() * (hiInclusive - loInclusive + 1)) + loInclusive; +} + +function randString(rng: () => number, maxLen = 48): string { + const len = randInt(rng, 0, maxLen); + let out = ""; + for (let i = 0; i < len; i += 1) { + // Mix ASCII printables, whitespace, and a few higher codepoints. + const bucket = rng(); + if (bucket < 0.7) { + out += String.fromCharCode(randInt(rng, 0x20, 0x7e)); + } else if (bucket < 0.85) { + out += " \t\n\r"[randInt(rng, 0, 3)]; + } else { + out += String.fromCharCode(randInt(rng, 0xa0, 0x2fff)); + } + } + return out; +} + +function randBody(rng: () => number): unknown { + const kind = randInt(rng, 0, 5); + if (kind === 0) { + return null; + } + if (kind === 1) { + return randString(rng, 32); + } + if (kind === 2) { + return randInt(rng, -1_000_000, 1_000_000); + } + if (kind === 3) { + return rng() < 0.5; + } + if (kind === 4) { + const n = randInt(rng, 0, 4); + const arr: unknown[] = []; + for (let i = 0; i < n; i += 1) { + arr.push(randInt(rng, 0, 100)); + } + return arr; + } + return { a: randString(rng, 12), b: randInt(rng, 0, 1000), c: rng() < 0.5 }; +} + +const ITERATIONS = 200; + +describe("fuzz: setDefaultSecurityHeaders", () => { + it("always emits the three baseline headers regardless of opts", () => { + const rng = makeRng(0xa11ce); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader } = makeMockHttpResponse(); + const shape = randInt(rng, 0, 3); + if (shape === 0) { + setDefaultSecurityHeaders(res); + } else if (shape === 1) { + setDefaultSecurityHeaders(res, undefined); + } else if (shape === 2) { + setDefaultSecurityHeaders(res, {}); + } else { + setDefaultSecurityHeaders(res, { strictTransportSecurity: randString(rng) }); + } + expect(setHeader).toHaveBeenCalledWith("X-Content-Type-Options", "nosniff"); + expect(setHeader).toHaveBeenCalledWith("Referrer-Policy", "no-referrer"); + expect(setHeader).toHaveBeenCalledWith( + "Permissions-Policy", + "camera=(), microphone=(self), geolocation=()", + ); + } + }); + + it("sets Strict-Transport-Security iff opts.strictTransportSecurity is a non-empty string", () => { + const rng = makeRng(0xb0b); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader } = makeMockHttpResponse(); + const value = randString(rng); + setDefaultSecurityHeaders(res, { strictTransportSecurity: value }); + const stsCalls = setHeader.mock.calls.filter( + (call) => call[0] === "Strict-Transport-Security", + ); + if (value.length > 0) { + expect(stsCalls).toHaveLength(1); + expect(stsCalls[0]?.[1]).toBe(value); + } else { + expect(stsCalls).toHaveLength(0); + } + } + }); +}); + +describe("fuzz: sendJson", () => { + it("propagates status, sets JSON content type, and serializes the body", () => { + const rng = makeRng(0xdecaf); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader, end } = makeMockHttpResponse(); + const status = randInt(rng, 100, 599); + const body = randBody(rng); + sendJson(res, status, body); + expect(res.statusCode).toBe(status); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "application/json; charset=utf-8"); + expect(end).toHaveBeenCalledWith(JSON.stringify(body)); + } + }); +}); + +describe("fuzz: sendText", () => { + it("propagates status, sets plain-text content type, and forwards the body", () => { + const rng = makeRng(0xfeed); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader, end } = makeMockHttpResponse(); + const status = randInt(rng, 100, 599); + const body = randString(rng, 64); + sendText(res, status, body); + expect(res.statusCode).toBe(status); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8"); + expect(end).toHaveBeenCalledWith(body); + } + }); +}); + +describe("fuzz: sendMethodNotAllowed", () => { + it("always responds 405 with the supplied Allow header (or POST when omitted)", () => { + const rng = makeRng(0x405); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader, end } = makeMockHttpResponse(); + const useDefault = rng() < 0.3; + const allow = useDefault ? undefined : randString(rng, 24); + if (allow === undefined) { + sendMethodNotAllowed(res); + expect(setHeader).toHaveBeenCalledWith("Allow", "POST"); + } else { + sendMethodNotAllowed(res, allow); + expect(setHeader).toHaveBeenCalledWith("Allow", allow); + } + expect(res.statusCode).toBe(405); + expect(end).toHaveBeenCalledWith("Method Not Allowed"); + } + }); +}); + +describe("fuzz: sendUnauthorized", () => { + it("is deterministic: always 401 with the canonical error payload", () => { + const expected = JSON.stringify({ + error: { message: "Unauthorized", type: "unauthorized" }, + }); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, end } = makeMockHttpResponse(); + sendUnauthorized(res); + expect(res.statusCode).toBe(401); + expect(end).toHaveBeenCalledWith(expected); + } + }); +}); + +describe("fuzz: sendRateLimited", () => { + it("sets Retry-After iff retryAfterMs is truthy and > 0, with ceil-seconds value", () => { + const rng = makeRng(0x429); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader } = makeMockHttpResponse(); + const pick = randInt(rng, 0, 4); + let retryAfterMs: number | undefined; + if (pick === 0) { + retryAfterMs = undefined; + } else if (pick === 1) { + retryAfterMs = 0; + } else if (pick === 2) { + retryAfterMs = -randInt(rng, 1, 100_000); + } else if (pick === 3) { + retryAfterMs = randInt(rng, 1, 3_600_000); + } else { + // Fractional positive values exercise Math.ceil. + retryAfterMs = rng() * 5000 + 0.001; + } + sendRateLimited(res, retryAfterMs); + expect(res.statusCode).toBe(429); + const retryCalls = setHeader.mock.calls.filter((call) => call[0] === "Retry-After"); + if (typeof retryAfterMs === "number" && retryAfterMs > 0) { + expect(retryCalls).toHaveLength(1); + expect(retryCalls[0]?.[1]).toBe(String(Math.ceil(retryAfterMs / 1000))); + } else { + expect(retryCalls).toHaveLength(0); + } + } + }); +}); + +describe("fuzz: sendGatewayAuthFailure", () => { + it("delegates to rate-limited vs unauthorized based on authResult.rateLimited", () => { + const rng = makeRng(0xba5e); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader, end } = makeMockHttpResponse(); + const rateLimited = rng() < 0.5; + const retryAfterMs = rateLimited && rng() < 0.7 ? randInt(rng, 1, 120_000) : undefined; + const authResult = { ok: false, rateLimited, retryAfterMs } as GatewayAuthResult; + sendGatewayAuthFailure(res, authResult); + if (rateLimited) { + expect(res.statusCode).toBe(429); + const retryCalls = setHeader.mock.calls.filter((call) => call[0] === "Retry-After"); + if (typeof retryAfterMs === "number" && retryAfterMs > 0) { + expect(retryCalls).toHaveLength(1); + } else { + expect(retryCalls).toHaveLength(0); + } + } else { + expect(res.statusCode).toBe(401); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ error: { message: "Unauthorized", type: "unauthorized" } }), + ); + } + } + }); +}); + +describe("fuzz: sendInvalidRequest", () => { + it("always responds 400 with the supplied message echoed into the payload", () => { + const rng = makeRng(0xbad); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, end } = makeMockHttpResponse(); + const message = randString(rng, 64); + sendInvalidRequest(res, message); + expect(res.statusCode).toBe(400); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ error: { message, type: "invalid_request_error" } }), + ); + } + }); +}); + +describe("fuzz: readJsonBodyOrError", () => { + const makeRequest = () => ({}) as IncomingMessage; + + it("maps readJsonBody results to the documented status/body contract", async () => { + const rng = makeRng(0xc0de); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, end } = makeMockHttpResponse(); + const pick = randInt(rng, 0, 3); + let expectedStatus: number | undefined; + let expectedBody: string | undefined; + let expectedValue: unknown; + + if (pick === 0) { + const value = randBody(rng); + expectedValue = value; + readJsonBodyMock.mockResolvedValueOnce({ ok: true, value }); + } else if (pick === 1) { + expectedStatus = 413; + expectedBody = JSON.stringify({ + error: { message: "Payload too large", type: "invalid_request_error" }, + }); + readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "payload too large" }); + } else if (pick === 2) { + expectedStatus = 408; + expectedBody = JSON.stringify({ + error: { message: "Request body timeout", type: "invalid_request_error" }, + }); + readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "request body timeout" }); + } else { + // Arbitrary error text must neither collide with the 413/408 sentinels + // nor accidentally reuse them; pick a prefix that can never match. + const text = `err-${randString(rng, 24)}`; + expectedStatus = 400; + expectedBody = JSON.stringify({ + error: { message: text, type: "invalid_request_error" }, + }); + readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: text }); + } + + const maxBytes = randInt(rng, 1, 1 << 20); + const result = await readJsonBodyOrError(makeRequest(), res, maxBytes); + if (pick === 0) { + expect(result).toEqual(expectedValue); + } else { + expect(result).toBeUndefined(); + expect(res.statusCode).toBe(expectedStatus); + expect(end).toHaveBeenCalledWith(expectedBody); + } + expect(readJsonBodyMock).toHaveBeenLastCalledWith(expect.anything(), maxBytes); + } + }); +}); + +describe("fuzz: writeDone", () => { + it("always writes the DONE sentinel exactly once per call", () => { + for (let i = 0; i < ITERATIONS; i += 1) { + const { res } = makeMockHttpResponse(); + const write = vi.spyOn(res, "write"); + writeDone(res); + expect(write).toHaveBeenCalledTimes(1); + expect(write).toHaveBeenCalledWith("data: [DONE]\n\n"); + } + }); +}); + +describe("fuzz: setSseHeaders", () => { + it("sets SSE headers and invokes flushHeaders when present", () => { + const rng = makeRng(0x55e); + for (let i = 0; i < ITERATIONS; i += 1) { + const { res, setHeader } = makeMockHttpResponse(); + const hasFlush = rng() < 0.5; + const flushHeaders = vi.fn(); + if (hasFlush) { + (res as unknown as { flushHeaders: () => void }).flushHeaders = flushHeaders; + } + setSseHeaders(res); + expect(res.statusCode).toBe(200); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/event-stream; charset=utf-8"); + expect(setHeader).toHaveBeenCalledWith("Cache-Control", "no-cache"); + expect(setHeader).toHaveBeenCalledWith("Connection", "keep-alive"); + if (hasFlush) { + expect(flushHeaders).toHaveBeenCalledTimes(1); + } else { + expect(flushHeaders).not.toHaveBeenCalled(); + } + } + }); +}); + +describe("fuzz: watchClientDisconnect", () => { + function buildReqRes( + reqSocket: EventEmitter | null, + resSocket: EventEmitter | null, + ): { req: IncomingMessage; res: ServerResponse } { + return { + req: { socket: reqSocket } as unknown as IncomingMessage, + res: { socket: resSocket } as unknown as ServerResponse, + }; + } + + it("invariants hold for arbitrary socket/controller/callback combinations", () => { + const rng = makeRng(0xc105e); + for (let i = 0; i < ITERATIONS; i += 1) { + const shape = randInt(rng, 0, 3); + const same = rng() < 0.4; + let reqSocket: EventEmitter | null = null; + let resSocket: EventEmitter | null = null; + if (shape === 0) { + // both null + } else if (shape === 1) { + reqSocket = new EventEmitter(); + } else if (shape === 2) { + resSocket = new EventEmitter(); + } else if (same) { + reqSocket = new EventEmitter(); + resSocket = reqSocket; + } else { + reqSocket = new EventEmitter(); + resSocket = new EventEmitter(); + } + + const preAborted = rng() < 0.25; + const hasCallback = rng() < 0.5; + const controller = new AbortController(); + if (preAborted) { + controller.abort(); + } + const onDisconnect = hasCallback ? vi.fn() : undefined; + + const { req, res } = buildReqRes(reqSocket, resSocket); + const cleanup = watchClientDisconnect(req, res, controller, onDisconnect); + expect(typeof cleanup).toBe("function"); + + const uniqueSockets = new Set(); + if (reqSocket) { + uniqueSockets.add(reqSocket); + } + if (resSocket) { + uniqueSockets.add(resSocket); + } + + // Each unique socket should have exactly one "close" listener registered + // (or zero when there are no sockets at all). + for (const s of uniqueSockets) { + expect(s.listenerCount("close")).toBe(1); + } + + // Fire close on every unique socket; invariants: callback fires once per + // close, controller becomes aborted (regardless of whether it started so). + let expectedCallbackCalls = 0; + for (const s of uniqueSockets) { + s.emit("close"); + expectedCallbackCalls += 1; + } + if (uniqueSockets.size > 0) { + expect(controller.signal.aborted).toBe(true); + if (onDisconnect) { + expect(onDisconnect).toHaveBeenCalledTimes(expectedCallbackCalls); + } + } else { + expect(controller.signal.aborted).toBe(preAborted); + } + + // Cleanup removes all registered listeners. + cleanup(); + for (const s of uniqueSockets) { + expect(s.listenerCount("close")).toBe(0); + } + } + }); +}); diff --git a/src/gateway/http-common.test.ts b/src/gateway/http-common.test.ts index 3292baed8c4..8342626937b 100644 --- a/src/gateway/http-common.test.ts +++ b/src/gateway/http-common.test.ts @@ -1,7 +1,33 @@ -import { describe, expect, it } from "vitest"; -import { setDefaultSecurityHeaders } from "./http-common.js"; +import { EventEmitter } from "node:events"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { GatewayAuthResult } from "./auth.js"; +import { + readJsonBodyOrError, + sendGatewayAuthFailure, + sendInvalidRequest, + sendJson, + sendMethodNotAllowed, + sendRateLimited, + sendText, + sendUnauthorized, + setDefaultSecurityHeaders, + setSseHeaders, + watchClientDisconnect, + writeDone, +} from "./http-common.js"; import { makeMockHttpResponse } from "./test-http-response.js"; +const readJsonBodyMock = vi.hoisted(() => vi.fn()); + +vi.mock("./hooks.js", () => ({ + readJsonBody: readJsonBodyMock, +})); + +beforeEach(() => { + readJsonBodyMock.mockReset(); +}); + describe("setDefaultSecurityHeaders", () => { it("sets X-Content-Type-Options", () => { const { res, setHeader } = makeMockHttpResponse(); @@ -15,12 +41,12 @@ describe("setDefaultSecurityHeaders", () => { expect(setHeader).toHaveBeenCalledWith("Referrer-Policy", "no-referrer"); }); - it("sets Permissions-Policy", () => { + it("sets Permissions-Policy that allows microphone for same-origin", () => { const { res, setHeader } = makeMockHttpResponse(); setDefaultSecurityHeaders(res); expect(setHeader).toHaveBeenCalledWith( "Permissions-Policy", - "camera=(), microphone=(), geolocation=()", + "camera=(), microphone=(self), geolocation=()", ); }); @@ -46,4 +72,292 @@ describe("setDefaultSecurityHeaders", () => { setDefaultSecurityHeaders(res, { strictTransportSecurity: "" }); expect(setHeader).not.toHaveBeenCalledWith("Strict-Transport-Security", expect.anything()); }); + + it("does not set Strict-Transport-Security when opts is omitted", () => { + const { res, setHeader } = makeMockHttpResponse(); + setDefaultSecurityHeaders(res, undefined); + expect(setHeader).not.toHaveBeenCalledWith("Strict-Transport-Security", expect.anything()); + }); +}); + +describe("sendJson", () => { + it("sets status, content-type and writes JSON body", () => { + const { res, setHeader, end } = makeMockHttpResponse(); + sendJson(res, 201, { ok: true }); + expect(res.statusCode).toBe(201); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "application/json; charset=utf-8"); + expect(end).toHaveBeenCalledWith(JSON.stringify({ ok: true })); + }); +}); + +describe("sendText", () => { + it("sets status, content-type and writes plain-text body", () => { + const { res, setHeader, end } = makeMockHttpResponse(); + sendText(res, 202, "hello"); + expect(res.statusCode).toBe(202); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8"); + expect(end).toHaveBeenCalledWith("hello"); + }); +}); + +describe("sendMethodNotAllowed", () => { + it("defaults the Allow header to POST and responds 405", () => { + const { res, setHeader, end } = makeMockHttpResponse(); + sendMethodNotAllowed(res); + expect(setHeader).toHaveBeenCalledWith("Allow", "POST"); + expect(res.statusCode).toBe(405); + expect(end).toHaveBeenCalledWith("Method Not Allowed"); + }); + + it("honours a custom Allow header value", () => { + const { res, setHeader } = makeMockHttpResponse(); + sendMethodNotAllowed(res, "GET, POST"); + expect(setHeader).toHaveBeenCalledWith("Allow", "GET, POST"); + }); +}); + +describe("sendUnauthorized", () => { + it("responds with 401 and a structured unauthorized payload", () => { + const { res, end } = makeMockHttpResponse(); + sendUnauthorized(res); + expect(res.statusCode).toBe(401); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ error: { message: "Unauthorized", type: "unauthorized" } }), + ); + }); +}); + +describe("sendRateLimited", () => { + it("responds with 429 and no Retry-After when retryAfterMs is omitted", () => { + const { res, setHeader, end } = makeMockHttpResponse(); + sendRateLimited(res); + expect(res.statusCode).toBe(429); + expect(setHeader).not.toHaveBeenCalledWith("Retry-After", expect.anything()); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ + error: { + message: "Too many failed authentication attempts. Please try again later.", + type: "rate_limited", + }, + }), + ); + }); + + it("responds with 429 and no Retry-After when retryAfterMs is zero", () => { + const { res, setHeader } = makeMockHttpResponse(); + sendRateLimited(res, 0); + expect(res.statusCode).toBe(429); + expect(setHeader).not.toHaveBeenCalledWith("Retry-After", expect.anything()); + }); + + it("responds with 429 and no Retry-After when retryAfterMs is negative", () => { + const { res, setHeader } = makeMockHttpResponse(); + sendRateLimited(res, -500); + expect(res.statusCode).toBe(429); + expect(setHeader).not.toHaveBeenCalledWith("Retry-After", expect.anything()); + }); + + it("sets Retry-After (seconds, ceiled) when retryAfterMs is positive", () => { + const { res, setHeader } = makeMockHttpResponse(); + sendRateLimited(res, 1500); + expect(res.statusCode).toBe(429); + expect(setHeader).toHaveBeenCalledWith("Retry-After", "2"); + }); +}); + +describe("sendGatewayAuthFailure", () => { + it("delegates to sendRateLimited when the auth result is rate limited", () => { + const { res, setHeader, end } = makeMockHttpResponse(); + const authResult = { ok: false, rateLimited: true, retryAfterMs: 3000 } as GatewayAuthResult; + sendGatewayAuthFailure(res, authResult); + expect(res.statusCode).toBe(429); + expect(setHeader).toHaveBeenCalledWith("Retry-After", "3"); + expect(end).toHaveBeenCalledTimes(1); + }); + + it("delegates to sendUnauthorized when the auth result is not rate limited", () => { + const { res, end } = makeMockHttpResponse(); + const authResult = { ok: false, rateLimited: false } as GatewayAuthResult; + sendGatewayAuthFailure(res, authResult); + expect(res.statusCode).toBe(401); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ error: { message: "Unauthorized", type: "unauthorized" } }), + ); + }); +}); + +describe("sendInvalidRequest", () => { + it("responds with 400 and includes the supplied message", () => { + const { res, end } = makeMockHttpResponse(); + sendInvalidRequest(res, "bad input"); + expect(res.statusCode).toBe(400); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ error: { message: "bad input", type: "invalid_request_error" } }), + ); + }); +}); + +describe("readJsonBodyOrError", () => { + const makeRequest = () => ({}) as IncomingMessage; + + it("returns the parsed body on success", async () => { + readJsonBodyMock.mockResolvedValueOnce({ ok: true, value: { hello: "world" } }); + const { res } = makeMockHttpResponse(); + const result = await readJsonBodyOrError(makeRequest(), res, 1024); + expect(result).toEqual({ hello: "world" }); + expect(readJsonBodyMock).toHaveBeenCalledWith(expect.anything(), 1024); + }); + + it("responds with 413 when the body is too large", async () => { + readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "payload too large" }); + const { res, end } = makeMockHttpResponse(); + const result = await readJsonBodyOrError(makeRequest(), res, 1024); + expect(result).toBeUndefined(); + expect(res.statusCode).toBe(413); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ + error: { message: "Payload too large", type: "invalid_request_error" }, + }), + ); + }); + + it("responds with 408 when the request body times out", async () => { + readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "request body timeout" }); + const { res, end } = makeMockHttpResponse(); + const result = await readJsonBodyOrError(makeRequest(), res, 1024); + expect(result).toBeUndefined(); + expect(res.statusCode).toBe(408); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ + error: { message: "Request body timeout", type: "invalid_request_error" }, + }), + ); + }); + + it("responds with 400 for other parse failures", async () => { + readJsonBodyMock.mockResolvedValueOnce({ ok: false, error: "bad json" }); + const { res, end } = makeMockHttpResponse(); + const result = await readJsonBodyOrError(makeRequest(), res, 1024); + expect(result).toBeUndefined(); + expect(res.statusCode).toBe(400); + expect(end).toHaveBeenCalledWith( + JSON.stringify({ error: { message: "bad json", type: "invalid_request_error" } }), + ); + }); +}); + +describe("writeDone", () => { + it("writes the SSE termination sentinel to the response stream", () => { + const { res } = makeMockHttpResponse(); + const write = vi.spyOn(res, "write"); + writeDone(res); + expect(write).toHaveBeenCalledWith("data: [DONE]\n\n"); + }); +}); + +describe("setSseHeaders", () => { + it("sets the SSE headers and calls flushHeaders when present", () => { + const { res, setHeader } = makeMockHttpResponse(); + const flushHeaders = vi.fn(); + (res as unknown as { flushHeaders: () => void }).flushHeaders = flushHeaders; + setSseHeaders(res); + expect(res.statusCode).toBe(200); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/event-stream; charset=utf-8"); + expect(setHeader).toHaveBeenCalledWith("Cache-Control", "no-cache"); + expect(setHeader).toHaveBeenCalledWith("Connection", "keep-alive"); + expect(flushHeaders).toHaveBeenCalledTimes(1); + }); + + it("skips flushHeaders gracefully when the response does not expose one", () => { + const { res, setHeader } = makeMockHttpResponse(); + // Ensure flushHeaders is not defined on the mock response. + expect((res as unknown as { flushHeaders?: () => void }).flushHeaders).toBeUndefined(); + expect(() => setSseHeaders(res)).not.toThrow(); + expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/event-stream; charset=utf-8"); + }); +}); + +describe("watchClientDisconnect", () => { + function buildReqRes( + reqSocket: EventEmitter | null, + resSocket: EventEmitter | null, + ): { req: IncomingMessage; res: ServerResponse } { + return { + req: { socket: reqSocket } as unknown as IncomingMessage, + res: { socket: resSocket } as unknown as ServerResponse, + }; + } + + it("returns a no-op cleanup when no sockets are available", () => { + const { req, res } = buildReqRes(null, null); + const controller = new AbortController(); + const cleanup = watchClientDisconnect(req, res, controller); + expect(typeof cleanup).toBe("function"); + expect(() => cleanup()).not.toThrow(); + expect(controller.signal.aborted).toBe(false); + }); + + it("aborts the controller and calls onDisconnect when a socket closes", () => { + const socket = new EventEmitter(); + const { req, res } = buildReqRes(socket, socket); + const controller = new AbortController(); + const onDisconnect = vi.fn(); + watchClientDisconnect(req, res, controller, onDisconnect); + socket.emit("close"); + expect(onDisconnect).toHaveBeenCalledTimes(1); + expect(controller.signal.aborted).toBe(true); + }); + + it("does not double-abort when the controller is already aborted", () => { + const socket = new EventEmitter(); + const { req, res } = buildReqRes(socket, null); + const controller = new AbortController(); + controller.abort(); + const abortSpy = vi.spyOn(controller, "abort"); + const onDisconnect = vi.fn(); + watchClientDisconnect(req, res, controller, onDisconnect); + socket.emit("close"); + expect(onDisconnect).toHaveBeenCalledTimes(1); + expect(abortSpy).not.toHaveBeenCalled(); + }); + + it("works without an onDisconnect callback", () => { + const socket = new EventEmitter(); + const { req, res } = buildReqRes(null, socket); + const controller = new AbortController(); + watchClientDisconnect(req, res, controller); + socket.emit("close"); + expect(controller.signal.aborted).toBe(true); + }); + + it("deduplicates identical request and response sockets", () => { + const socket = new EventEmitter(); + const onSpy = vi.spyOn(socket, "on"); + const { req, res } = buildReqRes(socket, socket); + const controller = new AbortController(); + watchClientDisconnect(req, res, controller); + expect(onSpy).toHaveBeenCalledTimes(1); + }); + + it("registers handlers on distinct request and response sockets", () => { + const reqSocket = new EventEmitter(); + const resSocket = new EventEmitter(); + const reqOn = vi.spyOn(reqSocket, "on"); + const resOn = vi.spyOn(resSocket, "on"); + const { req, res } = buildReqRes(reqSocket, resSocket); + const controller = new AbortController(); + watchClientDisconnect(req, res, controller); + expect(reqOn).toHaveBeenCalledWith("close", expect.any(Function)); + expect(resOn).toHaveBeenCalledWith("close", expect.any(Function)); + }); + + it("cleanup detaches the close listener from each socket", () => { + const socket = new EventEmitter(); + const { req, res } = buildReqRes(socket, null); + const controller = new AbortController(); + const cleanup = watchClientDisconnect(req, res, controller); + expect(socket.listenerCount("close")).toBe(1); + cleanup(); + expect(socket.listenerCount("close")).toBe(0); + }); }); diff --git a/src/gateway/http-common.ts b/src/gateway/http-common.ts index d1927de021f..55da0613ee7 100644 --- a/src/gateway/http-common.ts +++ b/src/gateway/http-common.ts @@ -14,7 +14,7 @@ export function setDefaultSecurityHeaders( ) { res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("Referrer-Policy", "no-referrer"); - res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + res.setHeader("Permissions-Policy", "camera=(), microphone=(self), geolocation=()"); const strictTransportSecurity = opts?.strictTransportSecurity; if (typeof strictTransportSecurity === "string" && strictTransportSecurity.length > 0) { res.setHeader("Strict-Transport-Security", strictTransportSecurity); diff --git a/src/gateway/live-agent-probes.test.ts b/src/gateway/live-agent-probes.test.ts index 90bd4c77b56..e6786af2f01 100644 --- a/src/gateway/live-agent-probes.test.ts +++ b/src/gateway/live-agent-probes.test.ts @@ -28,7 +28,7 @@ describe("live-agent-probes", () => { attempt: 1, exactReply: spec.name, }), - ).toContain("Return only a tool call"); + ).toContain(`reply exactly: ${spec.name}`); expect( buildLiveCronProbeMessage({ agent: "codex", @@ -36,7 +36,15 @@ describe("live-agent-probes", () => { attempt: 1, exactReply: spec.name, }), - ).toContain("No prose before the tool call"); + ).toContain("ask me to retry"); + expect( + buildLiveCronProbeMessage({ + agent: "codex", + argsJson: spec.argsJson, + attempt: 1, + exactReply: spec.name, + }), + ).toContain("previous OpenClaw cron MCP tool call was cancelled"); }); it("validates cron cli job shape for the shared live probe", () => { diff --git a/src/gateway/live-agent-probes.ts b/src/gateway/live-agent-probes.ts index 38c6ae02e33..b340c049c8e 100644 --- a/src/gateway/live-agent-probes.ts +++ b/src/gateway/live-agent-probes.ts @@ -85,15 +85,22 @@ export function buildLiveCronProbeMessage(params: { } if (family === "claude") { return ( - "Return only a tool call for the OpenClaw MCP tool `cron`. " + + "Retry the OpenClaw MCP tool named `cron` now. " + `Use these exact JSON arguments: ${params.argsJson}. ` + - "No prose. I will verify externally with the OpenClaw cron CLI." + `If the cron job is created, reply exactly: ${params.exactReply}. ` + + "If the tool call is cancelled, the job is not created, or you cannot confirm creation, " + + "reply briefly saying that and ask me to retry. No markdown. " + + "I will verify externally with the OpenClaw cron CLI." ); } return ( - "Use the OpenClaw MCP tool named cron. " + + "Your previous OpenClaw cron MCP tool call was cancelled before the job was created. " + + "Retry the OpenClaw MCP tool named cron now. " + `Use these exact JSON arguments: ${params.argsJson}. ` + - "No prose before the tool call. I will verify externally with the OpenClaw cron CLI." + `If the cron job is created, reply exactly: ${params.exactReply}. ` + + "If the tool call is cancelled, the job is not created, or you cannot confirm creation, " + + "reply briefly saying that and ask me to retry. No markdown. " + + "I will verify externally with the OpenClaw cron CLI." ); } diff --git a/src/gateway/mcp-http.request.ts b/src/gateway/mcp-http.request.ts index 687f97f4591..412297eb23f 100644 --- a/src/gateway/mcp-http.request.ts +++ b/src/gateway/mcp-http.request.ts @@ -1,6 +1,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { resolveMainSessionKey } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isTruthyEnvValue } from "../infra/env.js"; import { safeEqualSecret } from "../security/secret-equal.js"; import { normalizeOptionalLowercaseString, @@ -13,6 +14,20 @@ import { checkBrowserOrigin } from "./origin-check.js"; const MAX_MCP_BODY_BYTES = 1_048_576; +function shouldLogMcpLoopbackHttp(): boolean { + return ( + isTruthyEnvValue(process.env.OPENCLAW_CLI_BACKEND_LOG_OUTPUT) || + isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_DEBUG) + ); +} + +function logMcpLoopbackHttp(step: string, details: Record): void { + if (!shouldLogMcpLoopbackHttp()) { + return; + } + console.error(`[mcp-loopback] ${step} ${JSON.stringify(details)}`); +} + export type McpRequestContext = { sessionKey: string; messageProvider: string | undefined; @@ -57,6 +72,7 @@ export function validateMcpLoopbackRequest(params: { try { url = new URL(params.req.url ?? "/", `http://${params.req.headers.host ?? "localhost"}`); } catch { + logMcpLoopbackHttp("reject", { reason: "bad_request_url", method: params.req.method ?? "" }); params.res.writeHead(400, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "bad_request" })); return false; @@ -69,18 +85,33 @@ export function validateMcpLoopbackRequest(params: { } if (url.pathname !== "/mcp") { + logMcpLoopbackHttp("reject", { + reason: "not_found", + method: params.req.method ?? "", + path: url.pathname, + }); params.res.writeHead(404, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "not_found" })); return false; } if (params.req.method !== "POST") { + logMcpLoopbackHttp("reject", { + reason: "method_not_allowed", + method: params.req.method ?? "", + path: url.pathname, + }); params.res.writeHead(405, { Allow: "POST" }); params.res.end(); return false; } if (rejectsBrowserLoopbackRequest(params.req)) { + logMcpLoopbackHttp("reject", { + reason: "forbidden_origin", + method: params.req.method ?? "", + origin: getHeader(params.req, "origin") ?? "", + }); params.res.writeHead(403, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "forbidden" })); return false; @@ -88,6 +119,11 @@ export function validateMcpLoopbackRequest(params: { const authHeader = getHeader(params.req, "authorization") ?? ""; if (!safeEqualSecret(authHeader, `Bearer ${params.token}`)) { + logMcpLoopbackHttp("reject", { + reason: "unauthorized", + method: params.req.method ?? "", + hasAuthorization: authHeader.length > 0, + }); params.res.writeHead(401, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "unauthorized" })); return false; @@ -95,6 +131,11 @@ export function validateMcpLoopbackRequest(params: { const contentType = getHeader(params.req, "content-type") ?? ""; if (!contentType.startsWith("application/json")) { + logMcpLoopbackHttp("reject", { + reason: "unsupported_media_type", + method: params.req.method ?? "", + contentType, + }); params.res.writeHead(415, { "Content-Type": "application/json" }); params.res.end(JSON.stringify({ error: "unsupported_media_type" })); return false; diff --git a/src/gateway/mcp-http.ts b/src/gateway/mcp-http.ts index 2b91e4e8921..2f8c38de05f 100644 --- a/src/gateway/mcp-http.ts +++ b/src/gateway/mcp-http.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import { createServer as createHttpServer } from "node:http"; import { loadConfig } from "../config/config.js"; +import { isTruthyEnvValue } from "../infra/env.js"; import { formatErrorMessage } from "../infra/errors.js"; import { logDebug, logWarn } from "../logger.js"; import { handleMcpJsonRpc } from "./mcp-http.handlers.js"; @@ -31,6 +32,24 @@ type McpLoopbackServer = { let activeMcpLoopbackServer: McpLoopbackServer | undefined; let activeMcpLoopbackServerPromise: Promise | null = null; +function shouldLogMcpLoopbackTraffic(): boolean { + return ( + isTruthyEnvValue(process.env.OPENCLAW_CLI_BACKEND_LOG_OUTPUT) || + isTruthyEnvValue(process.env.OPENCLAW_LIVE_CLI_BACKEND_DEBUG) + ); +} + +function logMcpLoopbackTraffic(step: string, details: Record): void { + if (!shouldLogMcpLoopbackTraffic()) { + return; + } + console.error(`[mcp-loopback] ${step} ${JSON.stringify(details)}`); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + export async function startMcpLoopbackServer(port = 0): Promise<{ port: number; close: () => Promise; @@ -58,6 +77,14 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ }); const messages = Array.isArray(parsed) ? parsed : [parsed]; + logMcpLoopbackTraffic("request", { + batchSize: messages.length, + methods: messages.map((message) => message.method), + sessionKey: requestContext.sessionKey, + senderIsOwner: requestContext.senderIsOwner, + toolCount: scopedTools.toolSchema.length, + cronVisible: scopedTools.toolSchema.some((tool) => tool.name === "cron"), + }); const responses: object[] = []; for (const message of messages) { const response = await handleMcpJsonRpc({ @@ -66,6 +93,17 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ toolSchema: scopedTools.toolSchema, }); if (response !== null) { + const toolName = + message.method === "tools/call" && isRecord(message.params) + ? message.params.name + : undefined; + const isError = + isRecord(response) && isRecord(response.result) && response.result.isError === true; + logMcpLoopbackTraffic("response", { + method: message.method, + toolName: typeof toolName === "string" ? toolName : undefined, + isError, + }); responses.push(response); } } @@ -83,6 +121,9 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ res.end(payload); } catch (error) { logWarn(`mcp loopback: request handling failed: ${formatErrorMessage(error)}`); + logMcpLoopbackTraffic("request-failed", { + message: formatErrorMessage(error), + }); if (!res.headersSent) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify(jsonRpcError(null, -32700, "Parse error"))); diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index db728df75c5..0dcc8c992f8 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -66,6 +66,7 @@ const METHOD_SCOPE_GROUPS: Record = { "node.rename", ], [READ_SCOPE]: [ + "assistant.media.get", "health", "doctor.memory.status", "doctor.memory.dreamDiary", diff --git a/src/gateway/protocol/index.test.ts b/src/gateway/protocol/index.test.ts index 97a97e6cbf1..184b714215f 100644 --- a/src/gateway/protocol/index.test.ts +++ b/src/gateway/protocol/index.test.ts @@ -1,7 +1,7 @@ import type { ErrorObject } from "ajv"; import { describe, expect, it } from "vitest"; import { TALK_TEST_PROVIDER_ID } from "../../test-utils/talk-test-provider.js"; -import { formatValidationErrors, validateTalkConfigResult } from "./index.js"; +import { formatValidationErrors, validateTalkConfigResult, validateWakeParams } from "./index.js"; const makeError = (overrides: Partial): ErrorObject => ({ keyword: "type", @@ -113,3 +113,35 @@ describe("validateTalkConfigResult", () => { ).toBe(false); }); }); + +describe("validateWakeParams", () => { + it("accepts valid wake params", () => { + expect(validateWakeParams({ mode: "now", text: "hello" })).toBe(true); + expect(validateWakeParams({ mode: "next-heartbeat", text: "remind me" })).toBe(true); + }); + + it("rejects missing required fields", () => { + expect(validateWakeParams({ mode: "now" })).toBe(false); + expect(validateWakeParams({ text: "hello" })).toBe(false); + expect(validateWakeParams({})).toBe(false); + }); + + it("accepts unknown properties for forward compatibility", () => { + expect( + validateWakeParams({ + mode: "now", + text: "hello", + paperclip: { version: "2026.416.0", source: "wake" }, + }), + ).toBe(true); + + expect( + validateWakeParams({ + mode: "next-heartbeat", + text: "check back", + unknownFutureField: 42, + anotherExtra: true, + }), + ).toBe(true); + }); +}); diff --git a/src/gateway/protocol/schema/agent.ts b/src/gateway/protocol/schema/agent.ts index 173aab37571..22fe3816602 100644 --- a/src/gateway/protocol/schema/agent.ts +++ b/src/gateway/protocol/schema/agent.ts @@ -70,6 +70,10 @@ export const MessageActionParamsSchema = Type.Object( params: Type.Record(Type.String(), Type.Unknown()), accountId: Type.Optional(Type.String()), requesterSenderId: Type.Optional(Type.String()), + // Honored only when the RPC caller has the full operator scope set + // (shared-secret bearer or `operator.admin`). For narrowly-scoped + // callers (e.g. `operator.write`-only) the gateway forces this to + // `false` regardless of the value sent here. senderIsOwner: Type.Optional(Type.Boolean()), sessionKey: Type.Optional(Type.String()), sessionId: Type.Optional(Type.String()), @@ -192,5 +196,5 @@ export const WakeParamsSchema = Type.Object( mode: Type.Union([Type.Literal("now"), Type.Literal("next-heartbeat")]), text: NonEmptyString, }, - { additionalProperties: false }, + { additionalProperties: true }, // external wake senders may attach opaque metadata ); diff --git a/src/gateway/server-cron.test.ts b/src/gateway/server-cron.test.ts index 28e048732de..3b83cbd70d4 100644 --- a/src/gateway/server-cron.test.ts +++ b/src/gateway/server-cron.test.ts @@ -140,6 +140,50 @@ describe("buildGatewayCronService", () => { } }); + it("preserves trust downgrades when cron enqueues system events", () => { + const cfg = createCronConfig("server-cron-untrusted"); + loadConfigMock.mockReturnValue(cfg); + + const state = buildGatewayCronService({ + cfg, + deps: {} as CliDeps, + broadcast: () => {}, + }); + try { + const cronDeps = ( + state.cron as unknown as { + state?: { + deps?: { + enqueueSystemEvent?: ( + optsText: string, + opts?: { + agentId?: string; + sessionKey?: string; + contextKey?: string; + trusted?: boolean; + }, + ) => void; + }; + }; + } + ).state?.deps; + + cronDeps?.enqueueSystemEvent?.("hello", { + sessionKey: "discord:channel:ops", + contextKey: "cron:test", + trusted: false, + }); + + expect(enqueueSystemEventMock).toHaveBeenCalledWith("hello", { + sessionKey: "agent:main:discord:channel:ops", + contextKey: "cron:test", + trusted: false, + }); + } finally { + state.cron.stop(); + } + }); + it("blocks private webhook URLs via SSRF-guarded fetch", async () => { const cfg = createCronConfig("server-cron-ssrf"); loadConfigMock.mockReturnValue(cfg); diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 0d9b49a8600..f73289facd1 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -285,7 +285,11 @@ export function buildGatewayCronService(params: { agentId, requestedSessionKey: opts?.sessionKey, }); - enqueueSystemEvent(text, { sessionKey, contextKey: opts?.contextKey }); + enqueueSystemEvent(text, { + sessionKey, + contextKey: opts?.contextKey, + trusted: opts?.trusted, + }); }, requestHeartbeatNow: (opts) => { const { agentId, sessionKey } = resolveCronWakeTarget(opts); diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts index b22eb368212..159a3a6b2e6 100644 --- a/src/gateway/server-methods/agent.test.ts +++ b/src/gateway/server-methods/agent.test.ts @@ -72,9 +72,9 @@ vi.mock("../../agents/agent-scope.js", () => ({ })); vi.mock("../../auto-reply/reply/session-reset-prompt.js", async () => { - const actual = await vi.importActual( - "../../auto-reply/reply/session-reset-prompt.js", - ); + const actual = await vi.importActual< + typeof import("../../auto-reply/reply/session-reset-prompt.js") + >("../../auto-reply/reply/session-reset-prompt.js"); return { ...actual, resolveBareResetBootstrapFileAccess: mocks.resolveBareResetBootstrapFileAccess, @@ -1278,45 +1278,48 @@ describe("gateway agent handler", () => { }); it("uses request model override when resolving bare /new bootstrap file access", async () => { - await withTempDir({ prefix: "openclaw-gateway-reset-model-override-" }, async (workspaceDir) => { - await fs.writeFile(`${workspaceDir}/BOOTSTRAP.md`, "bootstrap ritual", "utf-8"); - mocks.loadConfigReturn = { - agents: { - defaults: { - workspace: workspaceDir, + await withTempDir( + { prefix: "openclaw-gateway-reset-model-override-" }, + async (workspaceDir) => { + await fs.writeFile(`${workspaceDir}/BOOTSTRAP.md`, "bootstrap ritual", "utf-8"); + mocks.loadConfigReturn = { + agents: { + defaults: { + workspace: workspaceDir, + }, }, - }, - }; - mockSessionResetSuccess({ reason: "new" }); - primeMainAgentRun({ sessionId: "reset-session-id", cfg: mocks.loadConfigReturn }); + }; + mockSessionResetSuccess({ reason: "new" }); + primeMainAgentRun({ sessionId: "reset-session-id", cfg: mocks.loadConfigReturn }); - await invokeAgent( - { - message: "/new", - sessionKey: "agent:main:main", - provider: "openai", - model: "gpt-5.4-mini", - idempotencyKey: "test-idem-new-bootstrap-model-override", - }, - { - reqId: "4-bootstrap-model-override", - client: { - connect: { scopes: ["operator.admin"] }, - internal: { allowModelOverride: true }, - } as AgentHandlerArgs["client"], - }, - ); + await invokeAgent( + { + message: "/new", + sessionKey: "agent:main:main", + provider: "openai", + model: "gpt-5.4-mini", + idempotencyKey: "test-idem-new-bootstrap-model-override", + }, + { + reqId: "4-bootstrap-model-override", + client: { + connect: { scopes: ["operator.admin"] }, + internal: { allowModelOverride: true }, + } as AgentHandlerArgs["client"], + }, + ); - await waitForAssertion(() => - expect(mocks.resolveBareResetBootstrapFileAccess).toHaveBeenCalled(), - ); - expect(mocks.resolveBareResetBootstrapFileAccess).toHaveBeenCalledWith( - expect.objectContaining({ - modelProvider: "openai", - modelId: "gpt-5.4-mini", - }), - ); - }); + await waitForAssertion(() => + expect(mocks.resolveBareResetBootstrapFileAccess).toHaveBeenCalled(), + ); + expect(mocks.resolveBareResetBootstrapFileAccess).toHaveBeenCalledWith( + expect.objectContaining({ + modelProvider: "openai", + modelId: "gpt-5.4-mini", + }), + ); + }, + ); }); it("rejects malformed agent session keys early in agent handler", async () => { diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 3b1b7f141b1..81eb243e40c 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -156,14 +156,17 @@ async function runPollWithClient( return { respond }; } -async function runMessageActionRequest(params: Record) { +async function runMessageActionRequest( + params: Record, + client?: { connect?: { scopes?: string[] } } | null, +) { const respond = vi.fn(); await sendHandlers["message.action"]({ params: params as never, respond, context: makeContext(), req: { type: "req", id: "1", method: "message.action" }, - client: null as never, + client: (client ?? null) as never, isWebchatConnect: () => false, }); return { respond }; @@ -954,4 +957,89 @@ describe("gateway send mirroring", () => { { channel: "whatsapp" }, ); }); + + it("forces senderIsOwner=false for narrowly-scoped callers but honors it for full operators", async () => { + const capture = { senderIsOwner: undefined as boolean | undefined }; + const reactPlugin: ChannelPlugin = { + id: "whatsapp", + meta: { + id: "whatsapp", + label: "WhatsApp", + selectionLabel: "WhatsApp", + docsPath: "/channels/whatsapp", + blurb: "WhatsApp owner-derivation test plugin.", + }, + capabilities: { chatTypes: ["direct"], reactions: true }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ enabled: true }), + isConfigured: () => true, + }, + actions: { + describeMessageTool: () => ({ actions: ["react"] }), + supportsAction: ({ action }) => action === "react", + handleAction: async ({ senderIsOwner }) => { + capture.senderIsOwner = senderIsOwner; + return jsonResult({ ok: true }); + }, + }, + }; + mocks.getChannelPlugin.mockReturnValue(reactPlugin); + + // Narrowly-scoped caller (e.g. gateway-forwarding least-privilege path + // that only requests operator.write): wire senderIsOwner=true must be + // forced to false so a non-admin scoped caller cannot unlock owner-only + // channel actions. + setActivePluginRegistry( + createTestRegistry([{ pluginId: "whatsapp", source: "test", plugin: reactPlugin }]), + "send-test-owner-derive-non-admin", + ); + await runMessageActionRequest( + { + channel: "whatsapp", + action: "react", + params: { chatJid: "+15551234567", messageId: "wamid.x", emoji: "✅" }, + senderIsOwner: true, + idempotencyKey: "idem-owner-derive-non-admin", + }, + { connect: { scopes: ["operator.write"] } }, + ); + expect(capture.senderIsOwner).toBe(false); + + // Full operator (admin-scoped): the trusted runtime is allowed to + // forward the real channel-sender ownership bit. Wire true → true. + setActivePluginRegistry( + createTestRegistry([{ pluginId: "whatsapp", source: "test", plugin: reactPlugin }]), + "send-test-owner-derive-admin-true", + ); + await runMessageActionRequest( + { + channel: "whatsapp", + action: "react", + params: { chatJid: "+15551234567", messageId: "wamid.y", emoji: "✅" }, + senderIsOwner: true, + idempotencyKey: "idem-owner-derive-admin-true", + }, + { connect: { scopes: ["operator.admin"] } }, + ); + expect(capture.senderIsOwner).toBe(true); + + // Full operator forwarding a non-owner sender: wire false → false + // (admin scope does not inflate ownership on its own). + setActivePluginRegistry( + createTestRegistry([{ pluginId: "whatsapp", source: "test", plugin: reactPlugin }]), + "send-test-owner-derive-admin-false", + ); + await runMessageActionRequest( + { + channel: "whatsapp", + action: "react", + params: { chatJid: "+15551234567", messageId: "wamid.z", emoji: "✅" }, + senderIsOwner: false, + idempotencyKey: "idem-owner-derive-admin-false", + }, + { connect: { scopes: ["operator.admin"] } }, + ); + expect(capture.senderIsOwner).toBe(false); + }); }); diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 7626ffea36d..4316dec9653 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -26,6 +26,7 @@ import { normalizeOptionalString, readStringValue, } from "../../shared/string-coerce.js"; +import { ADMIN_SCOPE } from "../method-scopes.js"; import { ErrorCodes, errorShape, @@ -185,7 +186,7 @@ function cacheGatewayDedupeFailure(params: { } export const sendHandlers: GatewayRequestHandlers = { - "message.action": async ({ params, respond, context }) => { + "message.action": async ({ params, respond, context, client }) => { const p = params; if (!validateMessageActionParams(p)) { respond( @@ -216,6 +217,22 @@ export const sendHandlers: GatewayRequestHandlers = { }; idempotencyKey: string; }; + // Owner status is an authorization signal used to unlock owner-only + // channel actions and owner-only tool policy. The legitimate propagation + // path is the trusted runtime forwarding a real channel-sender ownership + // bit through the gateway RPC — but that wire value must not be honored + // for callers who are not already full operators. Per SECURITY.md, + // shared-secret bearer and admin-scoped callers get the full default + // operator scope set (including `operator.admin`); those callers are + // trusted to forward `senderIsOwner`. Narrowly-scoped callers + // (e.g. `operator.write`-only, including the gateway-forwarding + // least-privilege path) are not trusted to assert ownership, so their + // wire value is forced to `false` to prevent a non-admin scoped caller + // from unlocking owner-only channel actions by setting + // `senderIsOwner: true` on the request. + const callerScopes = client?.connect?.scopes ?? []; + const callerIsFullOperator = Array.isArray(callerScopes) && callerScopes.includes(ADMIN_SCOPE); + const senderIsOwner = callerIsFullOperator && request.senderIsOwner === true; const idem = request.idempotencyKey; const dedupeKey = `message.action:${idem}`; const cached = context.dedupe.get(dedupeKey); @@ -265,7 +282,7 @@ export const sendHandlers: GatewayRequestHandlers = { params: request.params, accountId: normalizeOptionalString(request.accountId) ?? undefined, requesterSenderId: normalizeOptionalString(request.requesterSenderId) ?? undefined, - senderIsOwner: request.senderIsOwner, + senderIsOwner, sessionKey: normalizeOptionalString(request.sessionKey) ?? undefined, sessionId: normalizeOptionalString(request.sessionId) ?? undefined, agentId: normalizeOptionalString(request.agentId) ?? undefined, diff --git a/src/gateway/server-plugin-bootstrap.browser-plugin.integration.test.ts b/src/gateway/server-plugin-bootstrap.browser-plugin.integration.test.ts index 8511a2d418d..efb29915460 100644 --- a/src/gateway/server-plugin-bootstrap.browser-plugin.integration.test.ts +++ b/src/gateway/server-plugin-bootstrap.browser-plugin.integration.test.ts @@ -5,8 +5,6 @@ import { clearPluginDiscoveryCache } from "../plugins/discovery.js"; import { clearPluginLoaderCache } from "../plugins/loader.js"; import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js"; import { resetPluginRuntimeStateForTest } from "../plugins/runtime.js"; -import { listGatewayMethods } from "./server-methods-list.js"; -import { coreGatewayHandlers } from "./server-methods.js"; import { loadGatewayStartupPlugins } from "./server-plugin-bootstrap.js"; function resetPluginState() { @@ -50,8 +48,8 @@ describe("loadGatewayStartupPlugins browser plugin integration", () => { } as OpenClawConfig, workspaceDir: process.cwd(), log: createTestLog(), - coreGatewayHandlers, - baseMethods: listGatewayMethods(), + coreGatewayHandlers: {}, + baseMethods: [], pluginIds: ["browser"], logDiagnostics: false, }); @@ -63,30 +61,4 @@ describe("loadGatewayStartupPlugins browser plugin integration", () => { ), ).toBe(true); }); - - it("omits browser gateway ownership when the bundled browser plugin is disabled", () => { - const loaded = loadGatewayStartupPlugins({ - cfg: { - plugins: { - allow: ["browser"], - entries: { - browser: { - enabled: false, - }, - }, - }, - } as OpenClawConfig, - workspaceDir: process.cwd(), - log: createTestLog(), - coreGatewayHandlers, - baseMethods: listGatewayMethods(), - pluginIds: ["browser"], - logDiagnostics: false, - }); - - expect(loaded.gatewayMethods).not.toContain("browser.request"); - expect(loaded.pluginRegistry.services.some((entry) => entry.pluginId === "browser")).toBe( - false, - ); - }); }); diff --git a/src/gateway/server-runtime-services.ts b/src/gateway/server-runtime-services.ts index 0b79f3e02fc..dd5cf2460df 100644 --- a/src/gateway/server-runtime-services.ts +++ b/src/gateway/server-runtime-services.ts @@ -1,4 +1,5 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { isVitestRuntimeEnv } from "../infra/env.js"; import { startHeartbeatRunner, type HeartbeatRunner } from "../infra/heartbeat-runner.js"; import type { ChannelHealthMonitor } from "./channel-health-monitor.js"; import { startChannelHealthMonitor } from "./channel-health-monitor.js"; @@ -87,7 +88,7 @@ export function startGatewayRuntimeServices(params: { heartbeatRunner: createNoopHeartbeatRunner(), channelHealthMonitor, stopModelPricingRefresh: - !params.minimalTestGateway && process.env.VITEST !== "1" + !params.minimalTestGateway && !isVitestRuntimeEnv() ? startGatewayModelPricingRefresh({ config: params.cfgAtStart }) : () => {}, }; diff --git a/src/gateway/server-runtime-subscriptions.ts b/src/gateway/server-runtime-subscriptions.ts index 5ef062d071e..c442133a32e 100644 --- a/src/gateway/server-runtime-subscriptions.ts +++ b/src/gateway/server-runtime-subscriptions.ts @@ -15,7 +15,6 @@ import { } from "./server-session-events.js"; export function startGatewayEventSubscriptions(params: { - minimalTestGateway: boolean; broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void; broadcastToConnIds: ( event: string, @@ -33,47 +32,39 @@ export function startGatewayEventSubscriptions(params: { sessionMessageSubscribers: SessionMessageSubscriberRegistry; chatAbortControllers: Map; }) { - const agentUnsub = params.minimalTestGateway - ? null - : onAgentEvent( - createAgentEventHandler({ - broadcast: params.broadcast, - broadcastToConnIds: params.broadcastToConnIds, - nodeSendToSession: params.nodeSendToSession, - agentRunSeq: params.agentRunSeq, - chatRunState: params.chatRunState, - resolveSessionKeyForRun: params.resolveSessionKeyForRun, - clearAgentRunContext: params.clearAgentRunContext, - toolEventRecipients: params.toolEventRecipients, - sessionEventSubscribers: params.sessionEventSubscribers, - isChatSendRunActive: (runId) => params.chatAbortControllers.has(runId), - }), - ); + const agentUnsub = onAgentEvent( + createAgentEventHandler({ + broadcast: params.broadcast, + broadcastToConnIds: params.broadcastToConnIds, + nodeSendToSession: params.nodeSendToSession, + agentRunSeq: params.agentRunSeq, + chatRunState: params.chatRunState, + resolveSessionKeyForRun: params.resolveSessionKeyForRun, + clearAgentRunContext: params.clearAgentRunContext, + toolEventRecipients: params.toolEventRecipients, + sessionEventSubscribers: params.sessionEventSubscribers, + isChatSendRunActive: (runId) => params.chatAbortControllers.has(runId), + }), + ); - const heartbeatUnsub = params.minimalTestGateway - ? null - : onHeartbeatEvent((evt) => { - params.broadcast("heartbeat", evt, { dropIfSlow: true }); - }); + const heartbeatUnsub = onHeartbeatEvent((evt) => { + params.broadcast("heartbeat", evt, { dropIfSlow: true }); + }); - const transcriptUnsub = params.minimalTestGateway - ? null - : onSessionTranscriptUpdate( - createTranscriptUpdateBroadcastHandler({ - broadcastToConnIds: params.broadcastToConnIds, - sessionEventSubscribers: params.sessionEventSubscribers, - sessionMessageSubscribers: params.sessionMessageSubscribers, - }), - ); + const transcriptUnsub = onSessionTranscriptUpdate( + createTranscriptUpdateBroadcastHandler({ + broadcastToConnIds: params.broadcastToConnIds, + sessionEventSubscribers: params.sessionEventSubscribers, + sessionMessageSubscribers: params.sessionMessageSubscribers, + }), + ); - const lifecycleUnsub = params.minimalTestGateway - ? null - : onSessionLifecycleEvent( - createLifecycleEventBroadcastHandler({ - broadcastToConnIds: params.broadcastToConnIds, - sessionEventSubscribers: params.sessionEventSubscribers, - }), - ); + const lifecycleUnsub = onSessionLifecycleEvent( + createLifecycleEventBroadcastHandler({ + broadcastToConnIds: params.broadcastToConnIds, + sessionEventSubscribers: params.sessionEventSubscribers, + }), + ); return { agentUnsub, diff --git a/src/gateway/server.auth.control-ui.suite.ts b/src/gateway/server.auth.control-ui.suite.ts index 234396082f0..9eec1d8aa6b 100644 --- a/src/gateway/server.auth.control-ui.suite.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -21,6 +21,7 @@ import { rpcReq, startRateLimitedTokenServerWithPairedDeviceToken, startGatewayServer, + startServer, startServerWithClient, TEST_OPERATOR_CLIENT, testState, @@ -142,6 +143,14 @@ export function registerControlUiAndPairingSuite(): void { return { server, ws, port, prevToken, identityPath, identity, client }; }; + const startControlUiServerWithOperatorIdentity = async ( + identityPrefix = "openclaw-device-scope-", + ) => { + const { server, port, prevToken } = await startControlUiServer("secret"); + const { identityPath, identity, client } = await createOperatorIdentityFixture(identityPrefix); + return { server, port, prevToken, identityPath, identity, client }; + }; + const withControlUiGatewayServer = async ( fn: (ctx: { port: number; @@ -163,6 +172,13 @@ export function registerControlUiAndPairingSuite(): void { }); }; + const startControlUiServer = async (token?: string, opts?: Parameters[1]) => { + return await startServer(token, { + ...opts, + controlUiEnabled: true, + }); + }; + const getRequiredPairedMetadata = ( paired: Record>, deviceId: string, @@ -294,33 +310,36 @@ export function registerControlUiAndPairingSuite(): void { }); }); - test("allows localhost control ui without device identity when insecure auth is enabled", async () => { + test("allows localhost ui clients without device identity when insecure auth is enabled", async () => { testState.gatewayControlUi = { allowInsecureAuth: true }; - const { server, ws, prevToken } = await startControlUiServerWithClient("secret", { + const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret", { wsHeaders: { origin: "http://127.0.0.1" }, }); - await connectControlUiWithoutDeviceAndExpectOk({ ws, token: "secret" }); - ws.close(); - await server.close(); - restoreGatewayToken(prevToken); - }); + let tuiWs: WebSocket | undefined; + try { + await connectControlUiWithoutDeviceAndExpectOk({ ws, token: "secret" }); - test("allows localhost tui without device identity when insecure auth is enabled", async () => { - testState.gatewayControlUi = { allowInsecureAuth: true }; - const { server, ws, prevToken } = await startControlUiServerWithClient("secret"); - await connectControlUiWithoutDeviceAndExpectOk({ - ws, - token: "secret", - client: { - id: GATEWAY_CLIENT_NAMES.TUI, - version: "1.0.0", - platform: "darwin", - mode: GATEWAY_CLIENT_MODES.UI, - }, - }); - ws.close(); - await server.close(); - restoreGatewayToken(prevToken); + tuiWs = await openWs(port); + await connectControlUiWithoutDeviceAndExpectOk({ + ws: tuiWs, + token: "secret", + client: { + id: GATEWAY_CLIENT_NAMES.TUI, + version: "1.0.0", + platform: "darwin", + mode: GATEWAY_CLIENT_MODES.UI, + }, + }); + } finally { + ws.close(); + tuiWs?.close(); + await Promise.all([ + waitForWsClose(ws, 1_000), + ...(tuiWs ? [waitForWsClose(tuiWs, 1_000)] : []), + ]); + await server.close(); + restoreGatewayToken(prevToken); + } }); test("allows control ui password-only auth on localhost when insecure auth is enabled", async () => { @@ -631,9 +650,8 @@ export function registerControlUiAndPairingSuite(): void { test("auto-approves local-direct operator pairing despite a remote-looking host header", async () => { const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken, identityPath, identity, client } = - await startServerWithOperatorIdentity(); - ws.close(); + const { server, port, prevToken, identityPath, identity, client } = + await startControlUiServerWithOperatorIdentity(); const wsRemoteRead = await openWs(port, { host: "gateway.example" }); const initialNonce = await readConnectChallengeNonce(wsRemoteRead); @@ -686,7 +704,7 @@ export function registerControlUiAndPairingSuite(): void { test("requires approval for loopback scope upgrades for control ui clients", async () => { const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); + const { server, port, prevToken } = await startControlUiServer("secret"); const { identity, identityPath } = await seedApprovedOperatorReadPairing({ identityPrefix: "openclaw-device-token-scope-", clientId: CONTROL_UI_CLIENT.id, @@ -695,8 +713,6 @@ export function registerControlUiAndPairingSuite(): void { platform: CONTROL_UI_CLIENT.platform, }); - ws.close(); - const ws2 = await openWs(port, { origin: originForPort(port) }); const nonce2 = await readConnectChallengeNonce(ws2); const upgraded = await connectReq(ws2, { @@ -730,8 +746,7 @@ export function registerControlUiAndPairingSuite(): void { const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); const { getPairedDevice, listDevicePairing, verifyDeviceToken } = await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const { identityPath, identity } = await createOperatorIdentityFixture( "openclaw-bootstrap-node-", @@ -901,8 +916,7 @@ export function registerControlUiAndPairingSuite(): void { const reconcileSpy = vi .spyOn(reconcileModule, "reconcileNodePairingOnConnect") .mockRejectedValueOnce(new Error("boom")); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const { identityPath, client } = await createOperatorIdentityFixture( "openclaw-bootstrap-reconcile-fail-", @@ -960,8 +974,7 @@ export function registerControlUiAndPairingSuite(): void { const { approveDevicePairing, getPairedDevice, listDevicePairing, requestDevicePairing } = await import("../infra/device-pairing.js"); const { publicKeyRawBase64UrlFromPem } = await import("../infra/device-identity.js"); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const { identityPath, identity } = await createOperatorIdentityFixture( "openclaw-bootstrap-role-upgrade-", @@ -1031,8 +1044,7 @@ export function registerControlUiAndPairingSuite(): void { test("requires approval for bootstrap-auth operator pairing outside the qr baseline profile", async () => { const { issueDeviceBootstrapToken } = await import("../infra/device-bootstrap.js"); const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const { identityPath, identity, client } = await createOperatorIdentityFixture( "openclaw-bootstrap-operator-", @@ -1076,8 +1088,7 @@ export function registerControlUiAndPairingSuite(): void { test("auto-approves local-direct node pairing, then queues operator scope approval", async () => { const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const { identityPath, identity, client } = await createOperatorIdentityFixture("openclaw-device-scope-"); const connectWithNonce = async (role: "operator" | "node", scopes: string[]) => { @@ -1209,11 +1220,9 @@ export function registerControlUiAndPairingSuite(): void { await stripPairedMetadataRolesAndScopes(deviceId); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); + const { server, port, prevToken } = await startControlUiServer("secret"); let ws2: WebSocket | undefined; try { - ws.close(); - const wsReconnect = await openWs(port); ws2 = wsReconnect; const reconnectNonce = await readConnectChallengeNonce(wsReconnect); @@ -1239,7 +1248,6 @@ export function registerControlUiAndPairingSuite(): void { } finally { await server.close(); restoreGatewayToken(prevToken); - ws.close(); ws2?.close(); } }); @@ -1256,13 +1264,11 @@ export function registerControlUiAndPairingSuite(): void { await stripPairedMetadataRolesAndScopes(identity.deviceId); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); + const { server, port, prevToken } = await startControlUiServer("secret"); let ws2: WebSocket | undefined; try { const client = { ...TEST_OPERATOR_CLIENT }; - ws.close(); - const wsUpgrade = await openWs(port); ws2 = wsUpgrade; const upgradeNonce = await readConnectChallengeNonce(wsUpgrade); @@ -1290,7 +1296,6 @@ export function registerControlUiAndPairingSuite(): void { expect(repaired?.role).toBe("operator"); expect(repaired?.approvedScopes ?? []).toEqual(expect.arrayContaining(["operator.read"])); } finally { - ws.close(); ws2?.close(); await server.close(); restoreGatewayToken(prevToken); @@ -1320,16 +1325,35 @@ export function registerControlUiAndPairingSuite(): void { } }); - test("allows local gateway backend shared-auth connections without device pairing", async () => { - const { server, ws, prevToken } = await startControlUiServerWithClient("secret"); + test("allows gateway backend loopback shared-auth connections without device pairing", async () => { + const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); + const sockets = [ws]; try { - const localBackend = await connectReq(ws, { - token: "secret", - client: BACKEND_GATEWAY_CLIENT, - }); - expect(localBackend.ok).toBe(true); + const backendCases: Array<{ + name: string; + headers?: Record; + socket?: WebSocket; + }> = [ + { name: "default host", socket: ws }, + { name: "remote-looking host", headers: { host: "gateway.example" } }, + { name: "private host", headers: { host: "172.17.0.2:18789" } }, + ]; + + for (const backendCase of backendCases) { + const socket = backendCase.socket ?? (await openWs(port, backendCase.headers)); + if (!backendCase.socket) { + sockets.push(socket); + } + const backendConnect = await connectReq(socket, { + token: "secret", + client: BACKEND_GATEWAY_CLIENT, + }); + expect(backendConnect.ok, backendCase.name).toBe(true); + } } finally { - ws.close(); + for (const socket of sockets) { + socket.close(); + } await server.close(); restoreGatewayToken(prevToken); } @@ -1337,8 +1361,7 @@ export function registerControlUiAndPairingSuite(): void { test("auto-approves Docker-style CLI connects on loopback with a private host header", async () => { const { getPairedDevice, listDevicePairing } = await import("../infra/device-pairing.js"); - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const wsDockerCli = await openWs(port, { host: "172.17.0.2:18789" }); try { const { identity, identityPath } = @@ -1374,8 +1397,7 @@ export function registerControlUiAndPairingSuite(): void { }); test("allows gateway backend clients on loopback even with a remote-looking host header", async () => { - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const wsRemoteLike = await openWs(port, { host: "gateway.example" }); try { const remoteLikeBackend = await connectReq(wsRemoteLike, { @@ -1391,8 +1413,7 @@ export function registerControlUiAndPairingSuite(): void { }); test("allows gateway backend clients on loopback with a private host header", async () => { - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const wsPrivateHost = await openWs(port, { host: "172.17.0.2:18789" }); try { const remoteLikeBackend = await connectReq(wsPrivateHost, { @@ -1408,8 +1429,7 @@ export function registerControlUiAndPairingSuite(): void { }); test("allows CLI clients on loopback even when the host header is not private-or-loopback", async () => { - const { server, ws, port, prevToken } = await startControlUiServerWithClient("secret"); - ws.close(); + const { server, port, prevToken } = await startControlUiServer("secret"); const wsRemoteLike = await openWs(port, { host: "gateway.example" }); try { const remoteCli = await connectReq(wsRemoteLike, { diff --git a/src/gateway/server.auth.default-token.suite.ts b/src/gateway/server.auth.default-token.suite.ts index 9c61af09857..c68a90a4b5b 100644 --- a/src/gateway/server.auth.default-token.suite.ts +++ b/src/gateway/server.auth.default-token.suite.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; import { connectReq, @@ -28,20 +28,13 @@ export function registerDefaultAuthTokenSuite(): void { describe("default auth (token)", () => { let server: Awaited> | undefined; let port: number; - const testsWithoutDefaultServer = new Set([ - "closes silent handshakes after timeout", - "prefers OPENCLAW_HANDSHAKE_TIMEOUT_MS and falls back on empty string", - ]); - beforeEach(async (context) => { - if (testsWithoutDefaultServer.has(context.task.name)) { - return; - } + beforeAll(async () => { port = await getFreePort(); server = await startGatewayServer(port); }); - afterEach(async () => { + afterAll(async () => { await server?.close(); server = undefined; }); diff --git a/src/gateway/server.auth.shared.ts b/src/gateway/server.auth.shared.ts index 3b22ec86e00..e57523c11f4 100644 --- a/src/gateway/server.auth.shared.ts +++ b/src/gateway/server.auth.shared.ts @@ -15,6 +15,7 @@ import { onceMessage, rpcReq, startGatewayServer, + startServer, startServerWithClient, trackConnectChallengeNonce, testTailscaleWhois, @@ -395,6 +396,7 @@ export { sendRawConnectReq, startGatewayServer, startRateLimitedTokenServerWithPairedDeviceToken, + startServer, startServerWithClient, TEST_OPERATOR_CLIENT, trackConnectChallengeNonce, diff --git a/src/gateway/server.canvas-auth.test.ts b/src/gateway/server.canvas-auth.test.ts index 7391af0845a..e2af3d31a60 100644 --- a/src/gateway/server.canvas-auth.test.ts +++ b/src/gateway/server.canvas-auth.test.ts @@ -13,7 +13,7 @@ import { withTempConfig } from "./test-temp-config.js"; const WS_REJECT_TIMEOUT_MS = 2_000; const WS_CONNECT_TIMEOUT_MS = 5_000; -const HTTP_REQUEST_TIMEOUT_MS = 5_000; +const HTTP_REQUEST_TIMEOUT_MS = 15_000; const SERVER_CLOSE_TIMEOUT_MS = 5_000; async function fetchCanvas(input: string, init?: RequestInit): Promise { diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index d6af3bb14c8..6befe00b864 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -666,6 +666,7 @@ describe("gateway server cron", () => { test("returns from cron.run immediately while isolated work continues in background", async () => { const { prevSkipCron } = await setupCronTestRun({ tempPrefix: "openclaw-gw-cron-run-detached-", + cronEnabled: false, }); const { server, ws } = await startServerWithClient(); @@ -733,6 +734,7 @@ describe("gateway server cron", () => { const { prevSkipCron } = await setupCronTestRun({ tempPrefix: "openclaw-gw-cron-run-busy-", + cronEnabled: false, jobs: [ { id: "busy-job", @@ -786,6 +788,7 @@ describe("gateway server cron", () => { const now = Date.now(); const { prevSkipCron } = await setupCronTestRun({ tempPrefix: "openclaw-gw-cron-run-not-due-", + cronEnabled: false, jobs: [ { id: "future-job", diff --git a/src/gateway/server.device-token-rotate-authz.test.ts b/src/gateway/server.device-token-rotate-authz.test.ts index 2b75126842e..9b02f80770a 100644 --- a/src/gateway/server.device-token-rotate-authz.test.ts +++ b/src/gateway/server.device-token-rotate-authz.test.ts @@ -12,6 +12,7 @@ import { connectOk, installGatewayTestHooks, rpcReq, + startServer, startServerWithClient, } from "./test-helpers.js"; @@ -127,8 +128,8 @@ async function issuePairingScopedTokenForAdminApprovedDevice(name: string): Prom } describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => { - test("rejects a device-token caller rotating another device's token", async () => { - const started = await startServerWithClient("secret"); + test("rejects a device-token caller rotating or revoking another device's token", async () => { + const started = await startServer("secret"); const deviceA = await issuePairingScopedTokenForAdminApprovedDevice("idor-device-a"); const deviceB = await issuePairingScopedTokenForAdminApprovedDevice("idor-device-b"); @@ -150,17 +151,26 @@ describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => { const pairedB = await getPairedDevice(deviceB.deviceId); expect(pairedB?.tokens?.operator?.token).toBe(deviceB.pairingToken); + + const revoke = await rpcReq(pairingWs, "device.token.revoke", { + deviceId: deviceB.deviceId, + role: "operator", + }); + expect(revoke.ok).toBe(false); + expect(revoke.error?.message).toBe("device token revocation denied"); + + const pairedBAfterRevoke = await getPairedDevice(deviceB.deviceId); + expect(pairedBAfterRevoke?.tokens?.operator?.revokedAtMs).toBeUndefined(); } finally { pairingWs?.close(); - started.ws.close(); await started.server.close(); started.envSnapshot.restore(); } }); - test("allows an admin-scoped caller to rotate another device's token", async () => { + test("allows an admin-scoped caller to rotate and revoke another device's token", async () => { const started = await startServerWithClient("secret"); - const device = await issuePairingScopedTokenForAdminApprovedDevice("idor-admin-rotate"); + const device = await issuePairingScopedTokenForAdminApprovedDevice("idor-admin-rotate-revoke"); try { await connectOk(started.ws); @@ -172,49 +182,6 @@ describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => { }); expect(rotate.ok).toBe(true); expect(rotate.payload?.token).toBeTruthy(); - } finally { - started.ws.close(); - await started.server.close(); - started.envSnapshot.restore(); - } - }); - - test("rejects a device-token caller revoking another device's token", async () => { - const started = await startServerWithClient("secret"); - const deviceA = await issuePairingScopedTokenForAdminApprovedDevice("idor-revoke-a"); - const deviceB = await issuePairingScopedTokenForAdminApprovedDevice("idor-revoke-b"); - - let pairingWs: WebSocket | undefined; - try { - pairingWs = await connectPairingScopedOperator({ - port: started.port, - identityPath: deviceA.identityPath, - deviceToken: deviceA.pairingToken, - }); - - const revoke = await rpcReq(pairingWs, "device.token.revoke", { - deviceId: deviceB.deviceId, - role: "operator", - }); - expect(revoke.ok).toBe(false); - expect(revoke.error?.message).toBe("device token revocation denied"); - - const pairedB = await getPairedDevice(deviceB.deviceId); - expect(pairedB?.tokens?.operator?.revokedAtMs).toBeUndefined(); - } finally { - pairingWs?.close(); - started.ws.close(); - await started.server.close(); - started.envSnapshot.restore(); - } - }); - - test("allows an admin-scoped caller to revoke another device's token", async () => { - const started = await startServerWithClient("secret"); - const device = await issuePairingScopedTokenForAdminApprovedDevice("idor-admin-revoke"); - - try { - await connectOk(started.ws); const revoke = await rpcReq<{ revokedAtMs?: number }>(started.ws, "device.token.revoke", { deviceId: device.deviceId, @@ -235,7 +202,7 @@ describe("gateway device.token.rotate/revoke ownership guard (IDOR)", () => { describe("gateway device.token.rotate caller scope guard", () => { test("rejects rotating an admin-approved device token above the caller session scopes", async () => { - const started = await startServerWithClient("secret"); + const started = await startServer("secret"); const attacker = await issueOperatorToken({ name: "rotate-attacker", approvedScopes: ["operator.admin"], @@ -265,7 +232,6 @@ describe("gateway device.token.rotate caller scope guard", () => { expect(paired?.approvedScopes).toEqual(["operator.admin"]); } finally { pairingWs?.close(); - started.ws.close(); await started.server.close(); started.envSnapshot.restore(); } @@ -326,7 +292,7 @@ describe("gateway device.token.rotate caller scope guard", () => { }); test("returns the same public deny for unknown devices and caller scope failures", async () => { - const started = await startServerWithClient("secret"); + const started = await startServer("secret"); const attacker = await issueOperatorToken({ name: "rotate-deny-shape", approvedScopes: ["operator.admin"], @@ -360,14 +326,13 @@ describe("gateway device.token.rotate caller scope guard", () => { expect(unknownDevice.error?.message).toBe("device token rotation denied"); } finally { pairingWs?.close(); - started.ws.close(); await started.server.close(); started.envSnapshot.restore(); } }); test("rejects rotating a token for an unapproved role on an existing paired device", async () => { - const started = await startServerWithClient("secret"); + const started = await startServer("secret"); const attacker = await issueOperatorToken({ name: "rotate-unapproved-role", approvedScopes: ["operator.pairing"], @@ -397,7 +362,6 @@ describe("gateway device.token.rotate caller scope guard", () => { expect(paired?.tokens?.operator?.scopes).toEqual(["operator.pairing"]); } finally { pairingWs?.close(); - started.ws.close(); await started.server.close(); started.envSnapshot.restore(); } diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index df43a301298..2bbc3367c0a 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -18,7 +18,7 @@ import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { resolveMainSessionKey } from "../config/sessions.js"; import { clearAgentRunContext } from "../infra/agent-events.js"; import { isDiagnosticsEnabled } from "../infra/diagnostic-events.js"; -import { logAcceptedEnvOption } from "../infra/env.js"; +import { isVitestRuntimeEnv, logAcceptedEnvOption } from "../infra/env.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; @@ -208,7 +208,7 @@ export async function startGatewayServer( opts: GatewayServerOptions = {}, ): Promise { const minimalTestGateway = - process.env.VITEST === "1" && process.env.OPENCLAW_TEST_MINIMAL_GATEWAY === "1"; + isVitestRuntimeEnv() && process.env.OPENCLAW_TEST_MINIMAL_GATEWAY === "1"; // Ensure all default port derivations (browser/canvas) see the actual runtime port. process.env.OPENCLAW_GATEWAY_PORT = String(port); @@ -599,7 +599,6 @@ export async function startGatewayServer( Object.assign( runtimeState, startGatewayEventSubscriptions({ - minimalTestGateway, broadcast, broadcastToConnIds, nodeSendToSession, diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.test.ts index d6f3ec8c0af..fba65ecdc16 100644 --- a/src/gateway/server.models-voicewake-misc.test.ts +++ b/src/gateway/server.models-voicewake-misc.test.ts @@ -542,9 +542,11 @@ describe("gateway server misc", () => { "utf-8", ); - const autoPort = await getFreePort(); - const autoServer = await startGatewayServer(autoPort); - await autoServer.close(); + await withEnvAsync({ OPENCLAW_TEST_MINIMAL_GATEWAY: undefined }, async () => { + const autoPort = await getFreePort(); + const autoServer = await startGatewayServer(autoPort); + await autoServer.close(); + }); const updated = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record; const channels = updated.channels as Record | undefined; diff --git a/src/gateway/server.node-pairing-authz.test.ts b/src/gateway/server.node-pairing-authz.test.ts index bd9fdaf071a..17dbfb63f13 100644 --- a/src/gateway/server.node-pairing-authz.test.ts +++ b/src/gateway/server.node-pairing-authz.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "vitest"; import { WebSocket } from "ws"; -import { approveNodePairing, listNodePairing, requestNodePairing } from "../infra/node-pairing.js"; +import { + approveNodePairing, + getPairedNode, + listNodePairing, + requestNodePairing, +} from "../infra/node-pairing.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { issueOperatorToken, @@ -40,13 +45,15 @@ async function connectNodeClient(params: { } async function expectPairingApprovalRejected(params: { + started: Awaited>; + nodeId: string; approverName: string; tokenScopes: string[]; connectedScopes: string[]; requestCommands?: string[]; expectedMessage: string; }) { - const started = await startServerWithClient("secret"); + const { started } = params; const approver = await issueOperatorToken({ name: params.approverName, approvedScopes: ["operator.admin"], @@ -58,7 +65,7 @@ async function expectPairingApprovalRejected(params: { let pairingWs: WebSocket | undefined; try { const request = await requestNodePairing({ - nodeId: "node-approve-target", + nodeId: params.nodeId, platform: "darwin", ...(params.requestCommands ? { commands: params.requestCommands } : {}), }); @@ -77,14 +84,9 @@ async function expectPairingApprovalRejected(params: { expect(approve.ok).toBe(false); expect(approve.error?.message).toBe(params.expectedMessage); - await expect( - import("../infra/node-pairing.js").then((m) => m.getPairedNode("node-approve-target")), - ).resolves.toBeNull(); + await expect(getPairedNode(params.nodeId)).resolves.toBeNull(); } finally { pairingWs?.close(); - started.ws.close(); - await started.server.close(); - started.envSnapshot.restore(); } } @@ -182,38 +184,38 @@ async function expectRePairingRequest(params: { } describe("gateway node pairing authorization", () => { - test("requires operator.admin for exec-capable node pairing approvals", async () => { - await expectPairingApprovalRejected({ - approverName: "node-pair-approve-pairing-only", - tokenScopes: ["operator.pairing"], - connectedScopes: ["operator.pairing"], - requestCommands: ["system.run"], - expectedMessage: "missing scope: operator.admin", - }); - }); - - test("requires operator.pairing before node pairing approvals", async () => { - await expectPairingApprovalRejected({ - approverName: "node-pair-approve-attacker", - tokenScopes: ["operator.write"], - connectedScopes: ["operator.write"], - requestCommands: ["system.run"], - expectedMessage: "missing scope: operator.pairing", - }); - }); - - test("allows pairing-only operators to approve commandless node requests", async () => { + test("enforces node pairing approval scopes", async () => { const started = await startServerWithClient("secret"); - const approver = await issueOperatorToken({ - name: "node-pair-approve-commandless", - approvedScopes: ["operator.admin"], - tokenScopes: ["operator.pairing"], - clientId: GATEWAY_CLIENT_NAMES.TEST, - clientMode: GATEWAY_CLIENT_MODES.TEST, - }); - let pairingWs: WebSocket | undefined; try { + await expectPairingApprovalRejected({ + started, + nodeId: "node-approve-reject-admin", + approverName: "node-pair-approve-pairing-only", + tokenScopes: ["operator.pairing"], + connectedScopes: ["operator.pairing"], + requestCommands: ["system.run"], + expectedMessage: "missing scope: operator.admin", + }); + + await expectPairingApprovalRejected({ + started, + nodeId: "node-approve-reject-pairing", + approverName: "node-pair-approve-attacker", + tokenScopes: ["operator.write"], + connectedScopes: ["operator.write"], + requestCommands: ["system.run"], + expectedMessage: "missing scope: operator.pairing", + }); + + const approver = await issueOperatorToken({ + name: "node-pair-approve-commandless", + approvedScopes: ["operator.admin"], + tokenScopes: ["operator.pairing"], + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + }); + const request = await requestNodePairing({ nodeId: "node-approve-target", platform: "darwin", @@ -237,9 +239,7 @@ describe("gateway node pairing authorization", () => { expect(approve.payload?.requestId).toBe(request.request.requestId); expect(approve.payload?.node?.nodeId).toBe("node-approve-target"); - await expect( - import("../infra/node-pairing.js").then((m) => m.getPairedNode("node-approve-target")), - ).resolves.toEqual( + await expect(getPairedNode("node-approve-target")).resolves.toEqual( expect.objectContaining({ nodeId: "node-approve-target", }), diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.test.ts index 765562bd61a..b8ad945c47f 100644 --- a/src/gateway/server.reload.test.ts +++ b/src/gateway/server.reload.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { WebSocket } from "ws"; import { resolveMainSessionKeyFromConfig } from "../config/sessions.js"; import { drainSystemEvents } from "../infra/system-events.js"; +import { withEnvAsync } from "../test-utils/env.js"; import { TALK_TEST_PROVIDER_API_KEY_PATH, TALK_TEST_PROVIDER_ID, @@ -369,8 +370,16 @@ describe("gateway hot reload", () => { ); } + async function withNonMinimalGatewayServer( + fn: Parameters[0], + ): ReturnType { + return await withEnvAsync({ OPENCLAW_TEST_MINIMAL_GATEWAY: undefined }, async () => + withGatewayServer(fn), + ); + } + it("applies hot reload actions and emits restart signal", async () => { - await withGatewayServer(async () => { + await withNonMinimalGatewayServer(async () => { const onHotReload = hoisted.getOnHotReload(); expect(onHotReload).toBeTypeOf("function"); @@ -473,7 +482,7 @@ describe("gateway hot reload", () => { await writeEnvRefConfig(); process.env.OPENAI_API_KEY = "sk-startup"; // pragma: allowlist secret - await withGatewayServer(async () => { + await withNonMinimalGatewayServer(async () => { const onHotReload = hoisted.getOnHotReload(); expect(onHotReload).toBeTypeOf("function"); const sessionKey = resolveMainSessionKeyFromConfig(); diff --git a/src/gateway/server.roles-allowlist-update.test.ts b/src/gateway/server.roles-allowlist-update.test.ts index a05bb87aa87..2409250195d 100644 --- a/src/gateway/server.roles-allowlist-update.test.ts +++ b/src/gateway/server.roles-allowlist-update.test.ts @@ -4,6 +4,9 @@ import path from "node:path"; import { describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; import type { DeviceIdentity } from "../infra/device-identity.js"; +import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; +import { approveDevicePairing, listDevicePairing } from "../infra/device-pairing.js"; +import { approveNodePairing, requestNodePairing } from "../infra/node-pairing.js"; import { resolveRestartSentinelPath } from "../infra/restart-sentinel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import type { GatewayClient } from "./client.js"; @@ -68,7 +71,6 @@ const connectNodeClient = async (params: { }; const approveAllPendingPairings = async () => { - const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js"); const list = await listDevicePairing(); for (const pending of list.pending) { await approveDevicePairing(pending.requestId, { @@ -119,7 +121,6 @@ const connectNodeClientWithNodePairing = async ( await provisionalClient.stopAndWait(); - const { approveNodePairing, requestNodePairing } = await import("../infra/node-pairing.js"); const request = await requestNodePairing({ nodeId, displayName: params.displayName, @@ -271,7 +272,6 @@ describe("gateway update.run", () => { describe("gateway node command allowlist", () => { test("enforces command allowlists across node clients", async () => { - const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const waitForConnectedCount = async (count: number) => { await expect .poll(async () => { @@ -441,7 +441,6 @@ describe("gateway node command allowlist", () => { }); test("records only allowlisted commands in pending node pairing requests", async () => { - const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const deviceIdentityPath = path.join( os.tmpdir(), `openclaw-allowlisted-pending-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, @@ -481,7 +480,6 @@ describe("gateway node command allowlist", () => { }); test("rejects reconnect metadata spoof for paired node devices", async () => { - const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); const deviceIdentityPath = path.join( os.tmpdir(), `openclaw-spoof-test-device-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, @@ -528,65 +526,49 @@ describe("gateway node command allowlist", () => { }); test("filters system.run for confusable iOS metadata at connect time", async () => { - const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); - const cases = [ - { - label: "dotted-i-platform", - platform: "İOS", - deviceFamily: "iPhone", - }, - { - label: "greek-omicron-family", + const deviceIdentityPath = path.join( + os.tmpdir(), + `openclaw-confusable-node-greek-omicron-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, + ); + const deviceIdentity = loadOrCreateDeviceIdentity(deviceIdentityPath); + const displayName = "node-greek-omicron-family"; + + let client: GatewayClient | undefined; + try { + client = await connectNodeClientWithNodePairing({ + port, + commands: ["system.run", "canvas.snapshot"], platform: "ios", deviceFamily: "iPhοne", - }, - ] as const; + instanceId: displayName, + displayName, + deviceIdentity, + }); - for (const testCase of cases) { - const deviceIdentityPath = path.join( - os.tmpdir(), - `openclaw-confusable-node-${testCase.label}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, - ); - const deviceIdentity = loadOrCreateDeviceIdentity(deviceIdentityPath); - const displayName = `node-${testCase.label}`; + await expect + .poll( + async () => { + const node = await findConnectedNodeByDisplayName(displayName); + return node?.commands?.toSorted() ?? []; + }, + { timeout: 2_000, interval: 10 }, + ) + .toEqual(["canvas.snapshot"]); - let client: GatewayClient | undefined; - try { - client = await connectNodeClientWithNodePairing({ - port, - commands: ["system.run", "canvas.snapshot"], - platform: testCase.platform, - deviceFamily: testCase.deviceFamily, - instanceId: displayName, - displayName, - deviceIdentity, - }); + const node = await findConnectedNodeByDisplayName(displayName); + const nodeId = node?.nodeId ?? ""; + expect(nodeId).toBeTruthy(); - await expect - .poll( - async () => { - const node = await findConnectedNodeByDisplayName(displayName); - return node?.commands?.toSorted() ?? []; - }, - { timeout: 2_000, interval: 10 }, - ) - .toEqual(["canvas.snapshot"]); - - const node = await findConnectedNodeByDisplayName(displayName); - const nodeId = node?.nodeId ?? ""; - expect(nodeId).toBeTruthy(); - - const systemRunRes = await rpcReq(ws, "node.invoke", { - nodeId, - command: "system.run", - params: { command: "echo blocked" }, - idempotencyKey: `allowlist-confusable-${testCase.label}`, - }); - expect(systemRunRes.ok).toBe(false); - expect(systemRunRes.error?.message ?? "").toContain("node command not allowed"); - } finally { - await client?.stopAndWait(); - } + const systemRunRes = await rpcReq(ws, "node.invoke", { + nodeId, + command: "system.run", + params: { command: "echo blocked" }, + idempotencyKey: "allowlist-confusable-greek-omicron", + }); + expect(systemRunRes.ok).toBe(false); + expect(systemRunRes.error?.message ?? "").toContain("node command not allowed"); + } finally { + await client?.stopAndWait(); } }); }); diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index cff85371b1b..4b53d99a88a 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -712,11 +712,7 @@ export async function createGatewaySuiteHarness(opts?: { }; } -export async function startServerWithClient( - token?: string, - opts?: GatewayServerOptions & { wsHeaders?: Record }, -) { - const { wsHeaders, ...gatewayOpts } = opts ?? {}; +export async function startServer(token?: string, opts?: GatewayServerOptions) { let port = await getFreePort(); const envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN"]); const prev = process.env.OPENCLAW_GATEWAY_TOKEN; @@ -735,19 +731,29 @@ export async function startServerWithClient( } const resolvedGatewayOpts: GatewayServerOptions = - fallbackToken && !gatewayOpts.auth + fallbackToken && !opts?.auth ? { - ...gatewayOpts, + ...opts, auth: { mode: "token", token: fallbackToken }, } - : gatewayOpts; + : (opts ?? {}); const started = await startGatewayServerWithRetries({ port, opts: resolvedGatewayOpts }); port = started.port; const server = started.server; + return { server, port, prevToken: prev, envSnapshot }; +} + +export async function startServerWithClient( + token?: string, + opts?: GatewayServerOptions & { wsHeaders?: Record }, +) { + const { wsHeaders, ...gatewayOpts } = opts ?? {}; + const started = await startServer(token, gatewayOpts); + const { server, port, prevToken, envSnapshot } = started; const ws = await openTrackedWebSocket({ port, headers: wsHeaders }); - return { server, ws, port, prevToken: prev, envSnapshot }; + return { server, ws, port, prevToken, envSnapshot }; } export async function startConnectedServerWithClient( diff --git a/src/infra/env.ts b/src/infra/env.ts index 1b87d730e9c..716f67e18ae 100644 --- a/src/infra/env.ts +++ b/src/infra/env.ts @@ -67,6 +67,16 @@ export function isTruthyEnvValue(value?: string): boolean { } } +export function isVitestRuntimeEnv(env: NodeJS.ProcessEnv = process.env): boolean { + return ( + env.VITEST === "true" || + env.VITEST === "1" || + env.VITEST_POOL_ID !== undefined || + env.VITEST_WORKER_ID !== undefined || + env.NODE_ENV === "test" + ); +} + export function normalizeEnv(): void { normalizeZaiEnv(); } diff --git a/src/infra/exec-approval-command-display.test.ts b/src/infra/exec-approval-command-display.test.ts index a69a63b94b5..b3b328ca6dc 100644 --- a/src/infra/exec-approval-command-display.test.ts +++ b/src/infra/exec-approval-command-display.test.ts @@ -8,6 +8,9 @@ describe("sanitizeExecApprovalDisplayText", () => { it.each([ ["echo hi\u200Bthere", "echo hi\\u{200B}there"], ["date\u3164\uFFA0\u115F\u1160가", "date\\u{3164}\\u{FFA0}\\u{115F}\\u{1160}가"], + ["echo safe\n\rcurl https://example.test", "echo safe\\u{A}\\u{D}curl https://example.test"], + ["echo ok\u2028curl https://example.test", "echo ok\\u{2028}curl https://example.test"], + ["echo ok\u2029curl https://example.test", "echo ok\\u{2029}curl https://example.test"], ])("sanitizes exec approval display text for %j", (input, expected) => { expect(sanitizeExecApprovalDisplayText(input)).toBe(expected); }); @@ -34,6 +37,124 @@ describe("sanitizeExecApprovalDisplayText", () => { expect(result).not.toContain("ghp_1234567890abcdefghij1234567890abcdef"); expect(result).toContain("git clone"); }); + + it("masks the full token when a zero-width character is spliced into the middle", () => { + const cmd = "echo sk-abc123\u200B456789012345678 remainder"; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).not.toContain("sk-abc123"); + expect(result).not.toContain("456789012345678"); + expect(result).toContain("echo "); + expect(result).toContain("remainder"); + }); + + it("masks the full token when NBSP (Zs) is spliced into the middle", () => { + const cmd = "echo sk-abc123\u00A0456789012345678 remainder"; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).not.toContain("sk-abc123"); + expect(result).not.toContain("456789012345678"); + expect(result).toContain("echo "); + expect(result).toContain("remainder"); + }); + + it("masks the full token when narrow no-break space is spliced into the middle", () => { + const cmd = "echo sk-abc123\u202F456789012345678 remainder"; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).not.toContain("sk-abc123"); + expect(result).not.toContain("456789012345678"); + expect(result).toContain("remainder"); + }); + + it("keeps newline boundaries visible as escape markers even when bypass is detected", () => { + // Stripping invisibles lets the stripped-view greedy-match across the original newline + // boundaries, so the trailing `line3` gets absorbed into the union mask alongside the + // secret. The important guarantees are: (1) the secret is not visible, and (2) the + // newlines that existed in the original are still visible as `\u{A}` escapes so the + // operator is not misled about multi-line structure. + const cmd = "line1\necho sk-abc123\u00A0456789012345678\nline3"; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).not.toContain("sk-abc123"); + expect(result).not.toContain("456789012345678"); + expect(result).toContain("line1"); + expect(result).toContain("\\u{A}"); + }); + + it("detects bypass even when raw and stripped redactions happen to produce the same normalized length", () => { + // Raw masks the 16-char prefix `sk-abc1234567890` as the fixed literal `***` while the + // trailing 8 chars past the zero-width stay visible. The stripped view masks the full + // 24-char token as `sk-abc…5678`. Both normalized outputs are the same length (11 chars), + // so a length-based bypass check would falsely return the raw view and leak the tail. + const cmd = "sk-abc1234567890\u200B12345678"; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).not.toContain("12345678"); + expect(result).not.toContain("1234567890"); + }); + + it("does not leak bearer tokens when bypass is triggered by a separate spliced secret", () => { + // Bearer+NBSP is caught by the raw view (NBSP matches \s in non-u JS regex) but stripping + // removes NBSP, turning `Bearer` into a pattern the bearer regex no longer matches. + // A separate spliced-invisible token triggers bypass detection, and the union-mask output + // must cover both the bearer span (from raw) and the spliced sk- span (from stripped). + const cmd = + 'curl -H "Authorization: Bearer\u00A0eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.longtoken.sig" https://api.example.com; echo sk-abc123\u200B456789012345678'; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).not.toContain("eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.longtoken.sig"); + expect(result).not.toContain("456789012345678"); + expect(result).toContain("https://api.example.com"); + }); + + it("keeps PEM private-key context visible when raw redaction already covers the key (not a bypass)", () => { + const cmd = + "echo -----BEGIN RSA PRIVATE KEY-----\nABCDEF0123456789abcdef\n-----END RSA PRIVATE KEY----- > key.pem"; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).not.toContain("ABCDEF0123456789abcdef"); + expect(result).toContain("BEGIN RSA PRIVATE KEY"); + expect(result).toContain("END RSA PRIVATE KEY"); + expect(result).toContain("> key.pem"); + }); + + it("truncates the redacted output (not the raw input) so large commands are bounded", () => { + const padding = "x".repeat(20 * 1024); + const result = sanitizeExecApprovalDisplayText(padding); + expect(result.length).toBeLessThan(padding.length); + expect(result).toContain("[truncated]"); + }); + + it("refuses to display commands above the hard input cap", () => { + const huge = "x".repeat(300 * 1024); + const result = sanitizeExecApprovalDisplayText(huge); + expect(result).toContain("exceeds display size limit"); + expect(result.length).toBeLessThan(1024); + }); + + it("redacts tokens at the tail of long inputs instead of truncating them below pattern length", () => { + // Pad with non-token content, then append a secret at the end. Truncating BEFORE redaction + // would split the token below the pattern's minimum length and leak the prefix. With + // redaction first, the full token is masked before any size-based truncation runs. + const padding = "a ".repeat(10 * 1024); + const cmd = padding + "ghp_1234567890abcdefghij1234567890abcdef"; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).not.toContain("ghp_1234567890abcdefghij1234567890abcdef"); + expect(result).not.toContain("ghp_1234567890"); + }); + + it("escapes astral-plane invisible characters (e.g. U+E0061 tag characters)", () => { + const cmd = "echo hi\u{E0061}there"; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).toContain("\\u{E0061}"); + expect(result).not.toMatch(/hi[\uDB40\uDC61]there/u); + }); + + it("masks a secret spliced with an astral-plane invisible character", () => { + // U+E0061 is a Cf (format) code point in the supplementary plane. Iterating the input by + // UTF-16 code unit would see two surrogate halves, neither of which matches \p{Cf}, so + // the splice would survive stripping and the stripped-view redaction would miss the + // full token. Code-point iteration strips it correctly and bypass detection fires. + const cmd = "echo sk-abc123\u{E0061}456789012345678 remainder"; + const result = sanitizeExecApprovalDisplayText(cmd); + expect(result).not.toContain("sk-abc123"); + expect(result).not.toContain("456789012345678"); + expect(result).toContain("remainder"); + }); }); describe("resolveExecApprovalCommandDisplay", () => { diff --git a/src/infra/exec-approval-command-display.ts b/src/infra/exec-approval-command-display.ts index 273f0fd4c33..ddee6e278b2 100644 --- a/src/infra/exec-approval-command-display.ts +++ b/src/infra/exec-approval-command-display.ts @@ -1,16 +1,153 @@ -import { redactSensitiveText } from "../logging/redact.js"; +import { redactSensitiveText, resolveRedactOptions } from "../logging/redact.js"; import type { ExecApprovalRequestPayload } from "./exec-approvals.js"; -// Escape invisible characters that can spoof approval prompts in common UIs. -const EXEC_APPROVAL_INVISIBLE_CHAR_REGEX = /[\p{Cf}\u115F\u1160\u3164\uFFA0]/gu; +// Escape control characters, Unicode format/line/paragraph separators, and non-ASCII space +// separators that can spoof approval prompts in common UIs. Ordinary ASCII space (U+0020) is +// intentionally excluded so normal command text renders unchanged. +const EXEC_APPROVAL_INVISIBLE_CHAR_REGEX = + /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000\u115F\u1160\u3164\uFFA0]/gu; +const EXEC_APPROVAL_INVISIBLE_CHAR_SINGLE = + /^[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000\u115F\u1160\u3164\uFFA0]$/u; + +// Hard cap on input the sanitizer will process at all. Above this size we return a constant +// marker without running any regex work, so an attacker cannot force unbounded CPU/memory. +const EXEC_APPROVAL_MAX_INPUT = 256 * 1024; +// Soft cap on displayed output. Truncation happens AFTER redaction so a secret near the +// cutoff is not partially exposed when the cut lands mid-token below a pattern's minimum +// length (e.g. `ghp_` needs 20+ trailing chars before the `\b` match). +const EXEC_APPROVAL_MAX_OUTPUT = 16 * 1024; +const EXEC_APPROVAL_TRUNCATION_MARKER = "…[truncated]"; +const EXEC_APPROVAL_OVERSIZED_MARKER = + "[exec approval command exceeds display size limit; full text suppressed]"; + +const BYPASS_MASK = "***"; function formatCodePointEscape(char: string): string { return `\\u{${char.codePointAt(0)?.toString(16).toUpperCase() ?? "FFFD"}}`; } +function escapeInvisibles(text: string): string { + return text.replace(EXEC_APPROVAL_INVISIBLE_CHAR_REGEX, formatCodePointEscape); +} + +function truncateForDisplay(text: string): string { + if (text.length <= EXEC_APPROVAL_MAX_OUTPUT) { + return text; + } + return text.slice(0, EXEC_APPROVAL_MAX_OUTPUT) + EXEC_APPROVAL_TRUNCATION_MARKER; +} + +// Build a boolean bitmap of positions in `text` that ANY redaction pattern would match. +// Patterns are applied independently to the raw text (not sequentially against a +// progressively-redacted view) so later patterns can still find matches that the in-place +// redaction would have replaced first. That is conservative — it may over-count overlapping +// matches — but that is acceptable for a coverage check. Indices are UTF-16 code-unit +// offsets, matching what `matchAll` returns and aligning with `String#length`. +function computeRedactionBitmap(text: string, patterns: RegExp[]): boolean[] { + const bitmap: boolean[] = Array.from({ length: text.length }, () => false); + for (const pattern of patterns) { + const iter = pattern.flags.includes("g") + ? new RegExp(pattern.source, pattern.flags) + : new RegExp(pattern.source, `${pattern.flags}g`); + for (const match of text.matchAll(iter)) { + if (match.index === undefined) { + continue; + } + const end = match.index + match[0].length; + for (let i = match.index; i < end; i++) { + bitmap[i] = true; + } + } + } + return bitmap; +} + +// Iterate by full Unicode code point so astral-plane invisibles (e.g. U+E0061 TAG LATIN +// SMALL LETTER A, category Cf) are matched as single characters instead of being seen as a +// surrogate pair whose halves are category Cs and would escape the invisible-char regex. +function buildStrippedView(original: string): { stripped: string; strippedToOrig: number[] } { + const strippedChars: string[] = []; + const strippedToOrig: number[] = []; + let offset = 0; + for (const cp of original) { + if (!EXEC_APPROVAL_INVISIBLE_CHAR_SINGLE.test(cp)) { + strippedChars.push(cp); + for (let k = 0; k < cp.length; k++) { + strippedToOrig.push(offset + k); + } + } + offset += cp.length; + } + return { stripped: strippedChars.join(""), strippedToOrig }; +} + export function sanitizeExecApprovalDisplayText(commandText: string): string { - const escaped = commandText.replace(EXEC_APPROVAL_INVISIBLE_CHAR_REGEX, formatCodePointEscape); - return redactSensitiveText(escaped, { mode: "tools" }); + if (commandText.length > EXEC_APPROVAL_MAX_INPUT) { + // Refuse to display inputs above the hard cap; anything larger must be approved through + // another channel. Running redaction on a multi-megabyte payload would be a DoS vector. + return EXEC_APPROVAL_OVERSIZED_MARKER; + } + const rawRedacted = redactSensitiveText(commandText, { mode: "tools" }); + const { stripped, strippedToOrig } = buildStrippedView(commandText); + const strippedRedacted = redactSensitiveText(stripped, { mode: "tools" }); + // Fast path: stripping invisibles did not expose any additional secret-like content, so the + // raw-view redaction is sufficient. Preserve structure and show invisible-character spoof + // attempts as `\u{...}` escapes. + if (strippedRedacted === stripped) { + return truncateForDisplay(escapeInvisibles(rawRedacted)); + } + // Detect bypass by position-bitmap coverage. Run each redaction pattern independently on + // both views and map stripped-view match positions back to original coordinates. If every + // position the stripped view would mask is also masked by the raw view, the raw view + // already covered everything — for example, an ordinary multi-line PEM private key where + // raw produces `BEGIN/…redacted…/END` while stripped collapses to `***`. A real bypass + // exists only when the stripped view masks at least one original position raw missed (e.g. + // the tail of an `sk-` token whose prefix-boundary was broken by a spliced zero-width or + // NBSP character). + const { patterns } = resolveRedactOptions({ mode: "tools" }); + const rawMask = computeRedactionBitmap(commandText, patterns); + const strippedMask = computeRedactionBitmap(stripped, patterns); + let bypassDetected = false; + for (let i = 0; i < strippedMask.length; i++) { + if (strippedMask[i] && !rawMask[strippedToOrig[i]]) { + bypassDetected = true; + break; + } + } + if (!bypassDetected) { + return truncateForDisplay(escapeInvisibles(rawRedacted)); + } + // Bypass path. Project the stripped-view mask back onto original positions, union with the + // raw-view mask, and emit a rendering where each contiguous masked run becomes a single + // `***` marker. Invisible characters that fall outside masked runs still render as visible + // `\u{...}` escapes so multi-line structure and spliced invisibles stay readable. The + // render loop advances by full code point so astral-plane invisibles are escaped as one + // `\u{...}` token rather than two separate surrogate escapes (or, worse, passed through + // unescaped because neither surrogate half matches the Cf regex). + const unionMask = rawMask.slice(); + for (let i = 0; i < strippedMask.length; i++) { + if (strippedMask[i]) { + unionMask[strippedToOrig[i]] = true; + } + } + let out = ""; + let i = 0; + while (i < commandText.length) { + if (unionMask[i]) { + let j = i; + while (j < commandText.length && unionMask[j]) { + j++; + } + out += BYPASS_MASK; + i = j; + continue; + } + const codePoint = commandText.codePointAt(i) ?? 0xfffd; + const cp = String.fromCodePoint(codePoint); + out += EXEC_APPROVAL_INVISIBLE_CHAR_SINGLE.test(cp) ? formatCodePointEscape(cp) : cp; + i += cp.length; + } + return truncateForDisplay(out); } function normalizePreview(commandText: string, commandPreview?: string | null): string | null { diff --git a/src/infra/exec-approval-forwarder.test.ts b/src/infra/exec-approval-forwarder.test.ts index e5ed2e2cb3c..9def9322942 100644 --- a/src/infra/exec-approval-forwarder.test.ts +++ b/src/infra/exec-approval-forwarder.test.ts @@ -565,7 +565,7 @@ describe("exec approval forwarder", () => { }, { command: "echo `uname`\necho done", - expectedText: "```\necho `uname`\necho done\n```", + expectedText: "```\necho `uname`\\u{A}echo done\n```", }, { command: "echo ```danger```", diff --git a/src/infra/outbound/message-action-param-keys.ts b/src/infra/outbound/message-action-param-keys.ts new file mode 100644 index 00000000000..e6eda89e585 --- /dev/null +++ b/src/infra/outbound/message-action-param-keys.ts @@ -0,0 +1,57 @@ +import { normalizeOptionalString } from "../../shared/string-coerce.js"; + +const STANDARD_MESSAGE_ACTION_PARAM_KEYS = new Set([ + "accountId", + "asDocument", + "base64", + "bestEffort", + "blocks", + "buttons", + "caption", + "card", + "channel", + "channelId", + "components", + "contentType", + "dryRun", + "filePath", + "fileUrl", + "filename", + "forceDocument", + "gifPlayback", + "image", + "interactive", + "media", + "mediaUrl", + "message", + "mimeType", + "path", + "pollAnonymous", + "pollDurationHours", + "pollMulti", + "pollOption", + "pollPublic", + "pollQuestion", + "replyTo", + "silent", + "target", + "targets", + "text", + "threadId", + "to", +]); + +export function hasPotentialPluginActionParam(params: Record): boolean { + return Object.entries(params).some(([key, value]) => { + if (STANDARD_MESSAGE_ACTION_PARAM_KEYS.has(key)) { + return false; + } + if (typeof value === "string") { + return Boolean(normalizeOptionalString(value)); + } + if (typeof value === "number") { + return Number.isFinite(value); + } + return value !== undefined; + }); +} diff --git a/src/infra/outbound/message-action-params.test.ts b/src/infra/outbound/message-action-params.test.ts index ad418b5aa69..9bda17441dd 100644 --- a/src/infra/outbound/message-action-params.test.ts +++ b/src/infra/outbound/message-action-params.test.ts @@ -1,13 +1,23 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; + +const { resolveChannelMessageToolMediaSourceParamKeysMock } = vi.hoisted(() => ({ + resolveChannelMessageToolMediaSourceParamKeysMock: vi.fn(() => ["avatarPath", "avatarUrl"]), +})); + +vi.mock("../../channels/plugins/message-action-discovery.js", () => ({ + resolveChannelMessageToolMediaSourceParamKeys: resolveChannelMessageToolMediaSourceParamKeysMock, +})); + import { collectActionMediaSourceHints, hydrateAttachmentParamsForAction, normalizeSandboxMediaList, normalizeSandboxMediaParams, + resolveExtraActionMediaSourceParamKeys, resolveAttachmentMediaPolicy, } from "./message-action-params.js"; @@ -16,6 +26,52 @@ const maybeIt = process.platform === "win32" ? it.skip : it; const matrixMediaSourceParamKeys = ["avatarPath", "avatarUrl"] as const; describe("message action media helpers", () => { + beforeEach(() => { + resolveChannelMessageToolMediaSourceParamKeysMock.mockClear(); + }); + + it("skips plugin media discovery when args only use standard action params", () => { + expect( + resolveExtraActionMediaSourceParamKeys({ + cfg, + action: "send", + channel: "slack", + args: { + channel: "slack", + target: "#C12345678", + message: "hi", + media: "https://example.com/photo.png", + }, + }), + ).toEqual([]); + expect(resolveChannelMessageToolMediaSourceParamKeysMock).not.toHaveBeenCalled(); + }); + + it("discovers plugin media params when args include an extension-owned field", () => { + expect( + resolveExtraActionMediaSourceParamKeys({ + cfg, + action: "set-profile", + channel: "matrix", + args: { + channel: "matrix", + avatarPath: "/workspace/avatars/profile.png", + }, + }), + ).toEqual(["avatarPath", "avatarUrl"]); + expect(resolveChannelMessageToolMediaSourceParamKeysMock).toHaveBeenCalledWith({ + cfg, + action: "set-profile", + channel: "matrix", + accountId: undefined, + sessionKey: undefined, + sessionId: undefined, + agentId: undefined, + requesterSenderId: undefined, + senderIsOwner: undefined, + }); + }); + it("prefers sandbox media policy when sandbox roots are non-blank", () => { expect( resolveAttachmentMediaPolicy({ diff --git a/src/infra/outbound/message-action-params.ts b/src/infra/outbound/message-action-params.ts index a81ba45d99a..83027ccccdb 100644 --- a/src/infra/outbound/message-action-params.ts +++ b/src/infra/outbound/message-action-params.ts @@ -17,6 +17,7 @@ import { loadWebMedia } from "../../media/web-media.js"; import { resolveSnakeCaseParamKey } from "../../param-key.js"; import { readBooleanParam as readBooleanParamShared } from "../../plugin-sdk/boolean-param.js"; import { normalizeOptionalString } from "../../shared/string-coerce.js"; +import { hasPotentialPluginActionParam } from "./message-action-param-keys.js"; export const readBooleanParam = readBooleanParamShared; @@ -60,6 +61,7 @@ function buildActionMediaSourceParamKeys(extraParamKeys?: readonly string[]): st export function resolveExtraActionMediaSourceParamKeys(params: { cfg: OpenClawConfig; action?: ChannelMessageActionName; + args: Record; channel?: string; accountId?: string | null; sessionKey?: string | null; @@ -68,6 +70,9 @@ export function resolveExtraActionMediaSourceParamKeys(params: { requesterSenderId?: string | null; senderIsOwner?: boolean; }): string[] { + if (!hasPotentialPluginActionParam(params.args)) { + return []; + } return resolveChannelMessageToolMediaSourceParamKeys({ cfg: params.cfg, action: params.action, diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 0e613bed9dc..31d2c9acdd6 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -860,6 +860,7 @@ export async function runMessageAction( const extraActionMediaSourceParamKeys = resolveExtraActionMediaSourceParamKeys({ cfg, action, + args: params, channel, accountId, sessionKey: input.sessionKey, diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index dba62f954af..fef1bd9254f 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -4,6 +4,7 @@ import { normalizeOptionalLowercaseString, normalizeOptionalString, } from "../../shared/string-coerce.js"; +import { hasPotentialPluginActionParam } from "./message-action-param-keys.js"; export type MessageActionTargetMode = "to" | "channelId" | "none"; @@ -84,6 +85,7 @@ const ACTION_TARGET_ALIASES: Partial, channel?: string, ): ActionTargetAliasSpec[] { const specs: ActionTargetAliasSpec[] = []; @@ -92,7 +94,7 @@ function listActionTargetAliasSpecs( specs.push(coreSpec); } const normalizedChannel = normalizeOptionalLowercaseString(channel); - if (!normalizedChannel) { + if (!normalizedChannel || !hasPotentialPluginActionParam(params)) { return specs; } const plugin = getBootstrapChannelPlugin(normalizedChannel); @@ -120,7 +122,7 @@ export function actionHasTarget( if (channelId) { return true; } - const specs = listActionTargetAliasSpecs(action, options?.channel); + const specs = listActionTargetAliasSpecs(action, params, options?.channel); if (specs.length === 0) { return false; } diff --git a/src/infra/package-dist-inventory.test.ts b/src/infra/package-dist-inventory.test.ts index 5fddaa9b99e..e9033e552ad 100644 --- a/src/infra/package-dist-inventory.test.ts +++ b/src/infra/package-dist-inventory.test.ts @@ -79,12 +79,22 @@ describe("package dist inventory", () => { ".bin", "color-support", ); + const omittedExtensionRootAliasSymlink = path.join( + packageRoot, + "dist", + "extensions", + "node_modules", + "openclaw", + "plugin-sdk", + ); const omittedMap = path.join(packageRoot, "dist", "feature.runtime.js.map"); await fs.mkdir(path.dirname(packagedQaChannelRuntime), { recursive: true }); await fs.mkdir(path.dirname(packagedQaLabRuntime), { recursive: true }); await fs.mkdir(path.dirname(omittedQaMatrixChunk), { recursive: true }); await fs.mkdir(path.dirname(omittedQaLabTypes), { recursive: true }); await fs.mkdir(path.dirname(omittedExtensionNodeModuleSymlink), { recursive: true }); + await fs.mkdir(path.dirname(omittedExtensionRootAliasSymlink), { recursive: true }); + await fs.mkdir(path.join(packageRoot, "dist", "plugin-sdk"), { recursive: true }); await fs.writeFile(path.join(packageRoot, "color-support.js"), "export {};\n", "utf8"); await fs.writeFile(packagedQaChannelRuntime, "export {};\n", "utf8"); await fs.writeFile(packagedQaLabRuntime, "export {};\n", "utf8"); @@ -98,6 +108,10 @@ describe("package dist inventory", () => { path.join(packageRoot, "color-support.js"), omittedExtensionNodeModuleSymlink, ); + await fs.symlink( + path.join(packageRoot, "dist", "plugin-sdk"), + omittedExtensionRootAliasSymlink, + ); await fs.writeFile(omittedMap, "{}", "utf8"); await expect(writePackageDistInventory(packageRoot)).resolves.toEqual([ diff --git a/src/infra/package-dist-inventory.ts b/src/infra/package-dist-inventory.ts index debd7c9c580..d9630323be2 100644 --- a/src/infra/package-dist-inventory.ts +++ b/src/infra/package-dist-inventory.ts @@ -22,6 +22,7 @@ const OMITTED_PRIVATE_QA_PLUGIN_SDK_FILES = new Set([ ]); const OMITTED_PRIVATE_QA_DIST_PREFIXES = ["dist/qa-runtime-"]; const OMITTED_DIST_SUBTREE_PATTERNS = [ + /^dist\/extensions\/node_modules(?:\/|$)/u, /^dist\/extensions\/[^/]+\/node_modules(?:\/|$)/u, /^dist\/extensions\/qa-matrix(?:\/|$)/u, /^dist\/plugin-sdk\/extensions\/qa-lab(?:\/|$)/u, diff --git a/src/infra/state-migrations.ts b/src/infra/state-migrations.ts index f43d77e6350..91e30f5bf73 100644 --- a/src/infra/state-migrations.ts +++ b/src/infra/state-migrations.ts @@ -2,7 +2,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { resolveDefaultAgentId } from "../agents/agent-scope.js"; -import { listBundledChannelSetupPluginsByFeature } from "../channels/plugins/bundled.js"; +import { + listBundledChannelLegacySessionSurfaces, + listBundledChannelLegacyStateMigrationDetectors, +} from "../channels/plugins/bundled.js"; import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js"; import { resolveLegacyStateDirs, @@ -86,12 +89,7 @@ function getLegacySessionSurfaces(): LegacySessionSurface[] { // Legacy migrations run on cold doctor/startup paths. Prefer the narrower // setup plugin surface here so session-key cleanup does not materialize full // bundled channel runtimes. - cachedLegacySessionSurfaces ??= listBundledChannelSetupPluginsByFeature( - "legacySessionSurfaces", - ).flatMap((plugin) => { - const surface = plugin.messaging; - return surface && typeof surface === "object" ? [surface] : []; - }); + cachedLegacySessionSurfaces ??= [...listBundledChannelLegacySessionSurfaces()]; return cachedLegacySessionSurfaces; } @@ -670,10 +668,11 @@ async function collectChannelLegacyStateMigrationPlans(params: { oauthDir: string; }): Promise { const plans: ChannelLegacyStateMigrationPlan[] = []; - // Legacy state detection belongs on the lightweight setup surface so doctor + // Legacy state detection belongs on a narrow setup-entry surface so doctor // does not cold-load unrelated runtime channel code. - for (const plugin of listBundledChannelSetupPluginsByFeature("legacyStateMigrations")) { - const detected = await plugin.lifecycle?.detectLegacyStateMigrations?.({ + const detectors = listBundledChannelLegacyStateMigrationDetectors(); + for (const detectLegacyStateMigrations of detectors) { + const detected = await detectLegacyStateMigrations({ cfg: params.cfg, env: params.env, stateDir: params.stateDir, diff --git a/src/install-sh-version.test.ts b/src/install-sh-version.test.ts index 9eb60242bcd..18785433aac 100644 --- a/src/install-sh-version.test.ts +++ b/src/install-sh-version.test.ts @@ -28,11 +28,17 @@ function resolveInstallerVersionCases(params: { }): string[] { const installerPath = path.join(process.cwd(), "scripts", "install.sh"); const installerSource = fs.readFileSync(installerPath, "utf-8"); + const versionHelperStart = installerSource.indexOf("load_install_version_helpers() {"); + const versionHelperEnd = installerSource.indexOf("\nis_gateway_daemon_loaded() {"); + if (versionHelperStart < 0 || versionHelperEnd < 0) { + throw new Error("install.sh version helper block not found"); + } + const versionHelperSource = installerSource.slice(versionHelperStart, versionHelperEnd); const output = execFileSync( "bash", [ - "-lc", - `source "${installerPath}" >/dev/null 2>&1 + "-c", + `${versionHelperSource} for openclaw_bin in "\${@:3}"; do OPENCLAW_BIN="$openclaw_bin" resolve_openclaw_version @@ -40,7 +46,7 @@ done ( cd "$2" FAKE_OPENCLAW_BIN="\${@:1:1}" bash -s <<'OPENCLAW_STDIN_INSTALLER' -${installerSource} +${versionHelperSource} OPENCLAW_BIN="$FAKE_OPENCLAW_BIN" resolve_openclaw_version OPENCLAW_STDIN_INSTALLER diff --git a/src/mcp/plugin-tools-handlers.ts b/src/mcp/plugin-tools-handlers.ts new file mode 100644 index 00000000000..2d7c67e73a5 --- /dev/null +++ b/src/mcp/plugin-tools-handlers.ts @@ -0,0 +1,66 @@ +import { + isToolWrappedWithBeforeToolCallHook, + wrapToolWithBeforeToolCallHook, +} from "../agents/pi-tools.before-tool-call.js"; +import type { AnyAgentTool } from "../agents/tools/common.js"; +import { formatErrorMessage } from "../infra/errors.js"; + +type CallPluginToolParams = { + name: string; + arguments?: unknown; +}; + +function resolveJsonSchemaForTool(tool: AnyAgentTool): Record { + const params = tool.parameters; + if (params && typeof params === "object" && "type" in params) { + return params as Record; + } + return { type: "object", properties: {} }; +} + +export function createPluginToolsMcpHandlers(tools: AnyAgentTool[]) { + const wrappedTools = tools.map((tool) => { + if (isToolWrappedWithBeforeToolCallHook(tool)) { + return tool; + } + // The ACPX MCP bridge should enforce the same pre-execution hook boundary + // as the agent and HTTP tool execution paths. + return wrapToolWithBeforeToolCallHook(tool); + }); + const toolMap = new Map(); + for (const tool of wrappedTools) { + toolMap.set(tool.name, tool); + } + + return { + listTools: async () => ({ + tools: wrappedTools.map((tool) => ({ + name: tool.name, + description: tool.description ?? "", + inputSchema: resolveJsonSchemaForTool(tool), + })), + }), + callTool: async (params: CallPluginToolParams) => { + const tool = toolMap.get(params.name); + if (!tool) { + return { + content: [{ type: "text", text: `Unknown tool: ${params.name}` }], + isError: true, + }; + } + try { + const result = await tool.execute(`mcp-${Date.now()}`, params.arguments ?? {}); + return { + content: Array.isArray(result.content) + ? result.content + : [{ type: "text", text: String(result.content) }], + }; + } catch (err) { + return { + content: [{ type: "text", text: `Tool error: ${formatErrorMessage(err)}` }], + isError: true, + }; + } + }, + }; +} diff --git a/src/mcp/plugin-tools-serve.test.ts b/src/mcp/plugin-tools-serve.test.ts index 04cdd339cef..cea3e50cb3b 100644 --- a/src/mcp/plugin-tools-serve.test.ts +++ b/src/mcp/plugin-tools-serve.test.ts @@ -1,5 +1,3 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AnyAgentTool } from "../agents/tools/common.js"; import { @@ -7,25 +5,17 @@ import { resetGlobalHookRunner, } from "../plugins/hook-runner-global.js"; import { createMockPluginRegistry } from "../plugins/hooks.test-helpers.js"; -import { createPluginToolsMcpServer } from "./plugin-tools-serve.js"; +import { createPluginToolsMcpHandlers } from "./plugin-tools-handlers.js"; -async function connectPluginToolsServer(tools: AnyAgentTool[]) { - const server = createPluginToolsMcpServer({ tools }); - const client = new Client({ name: "plugin-tools-test-client", version: "1.0.0" }); - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); - return { - client, - close: async () => { - await client.close(); - await server.close(); - }, - }; -} +const callGatewayTool = vi.hoisted(() => vi.fn()); + +vi.mock("../agents/tools/gateway.js", () => ({ + callGatewayTool, +})); afterEach(() => { vi.restoreAllMocks(); + callGatewayTool.mockReset(); resetGlobalHookRunner(); }); @@ -47,36 +37,32 @@ describe("plugin tools MCP server", () => { execute, } as unknown as AnyAgentTool; - const session = await connectPluginToolsServer([tool]); - try { - const listed = await session.client.listTools(); - expect(listed.tools).toEqual([ - expect.objectContaining({ - name: "memory_recall", - description: "Recall stored memory", - inputSchema: expect.objectContaining({ - type: "object", - required: ["query"], - }), - }), - ]); - - const result = await session.client.callTool({ + const handlers = createPluginToolsMcpHandlers([tool]); + const listed = await handlers.listTools(); + expect(listed.tools).toEqual([ + expect.objectContaining({ name: "memory_recall", - arguments: { query: "remember this" }, - }); - expect(execute).toHaveBeenCalledWith( - expect.stringMatching(/^mcp-\d+$/), - { - query: "remember this", - }, - undefined, - undefined, - ); - expect(result.content).toEqual([{ type: "text", text: "Stored." }]); - } finally { - await session.close(); - } + description: "Recall stored memory", + inputSchema: expect.objectContaining({ + type: "object", + required: ["query"], + }), + }), + ]); + + const result = await handlers.callTool({ + name: "memory_recall", + arguments: { query: "remember this" }, + }); + expect(execute).toHaveBeenCalledWith( + expect.stringMatching(/^mcp-\d+$/), + { + query: "remember this", + }, + undefined, + undefined, + ); + expect(result.content).toEqual([{ type: "text", text: "Stored." }]); }); it("returns MCP errors for unknown tools and thrown tool errors", async () => { @@ -87,24 +73,20 @@ describe("plugin tools MCP server", () => { execute: vi.fn().mockRejectedValue(new Error("boom")), } as unknown as AnyAgentTool; - const session = await connectPluginToolsServer([failingTool]); - try { - const unknown = await session.client.callTool({ - name: "missing_tool", - arguments: {}, - }); - expect(unknown.isError).toBe(true); - expect(unknown.content).toEqual([{ type: "text", text: "Unknown tool: missing_tool" }]); + const handlers = createPluginToolsMcpHandlers([failingTool]); + const unknown = await handlers.callTool({ + name: "missing_tool", + arguments: {}, + }); + expect(unknown.isError).toBe(true); + expect(unknown.content).toEqual([{ type: "text", text: "Unknown tool: missing_tool" }]); - const failed = await session.client.callTool({ - name: "memory_forget", - arguments: {}, - }); - expect(failed.isError).toBe(true); - expect(failed.content).toEqual([{ type: "text", text: "Tool error: boom" }]); - } finally { - await session.close(); - } + const failed = await handlers.callTool({ + name: "memory_forget", + arguments: {}, + }); + expect(failed.isError).toBe(true); + expect(failed.content).toEqual([{ type: "text", text: "Tool error: boom" }]); }); it("blocks tool execution when before_tool_call requires approval on the MCP bridge", async () => { @@ -129,6 +111,7 @@ describe("plugin tools MCP server", () => { }, ]), ); + callGatewayTool.mockRejectedValueOnce(new Error("gateway unavailable")); const tool = { name: "memory_store", description: "Store memory", @@ -136,20 +119,16 @@ describe("plugin tools MCP server", () => { execute, } as unknown as AnyAgentTool; - const session = await connectPluginToolsServer([tool]); - try { - const result = await session.client.callTool({ - name: "memory_store", - arguments: { text: "remember this" }, - }); - expect(hookCalls).toBe(1); - expect(execute).not.toHaveBeenCalled(); - expect(result.isError).toBe(true); - expect(result.content).toEqual([ - { type: "text", text: "Tool error: Plugin approval required (gateway unavailable)" }, - ]); - } finally { - await session.close(); - } + const handlers = createPluginToolsMcpHandlers([tool]); + const result = await handlers.callTool({ + name: "memory_store", + arguments: { text: "remember this" }, + }); + expect(hookCalls).toBe(1); + expect(execute).not.toHaveBeenCalled(); + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + { type: "text", text: "Tool error: Plugin approval required (gateway unavailable)" }, + ]); }); }); diff --git a/src/mcp/plugin-tools-serve.ts b/src/mcp/plugin-tools-serve.ts index 3414529aad6..8303998b2e1 100644 --- a/src/mcp/plugin-tools-serve.ts +++ b/src/mcp/plugin-tools-serve.ts @@ -10,10 +10,6 @@ import { pathToFileURL } from "node:url"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js"; -import { - isToolWrappedWithBeforeToolCallHook, - wrapToolWithBeforeToolCallHook, -} from "../agents/pi-tools.before-tool-call.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; import { loadConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -21,15 +17,7 @@ import { formatErrorMessage } from "../infra/errors.js"; import { routeLogsToStderr } from "../logging/console.js"; import { resolvePluginTools } from "../plugins/tools.js"; import { VERSION } from "../version.js"; - -function resolveJsonSchemaForTool(tool: AnyAgentTool): Record { - const params = tool.parameters; - if (params && typeof params === "object" && "type" in params) { - return params as Record; - } - // Fallback: accept any object - return { type: "object", properties: {} }; -} +import { createPluginToolsMcpHandlers } from "./plugin-tools-handlers.js"; function resolveTools(config: OpenClawConfig): AnyAgentTool[] { return resolvePluginTools({ @@ -45,54 +33,18 @@ export function createPluginToolsMcpServer( } = {}, ): Server { const cfg = params.config ?? loadConfig(); - const tools = (params.tools ?? resolveTools(cfg)).map((tool) => { - if (isToolWrappedWithBeforeToolCallHook(tool)) { - return tool; - } - // The ACPX MCP bridge should enforce the same pre-execution hook boundary - // as the agent and HTTP tool execution paths. - return wrapToolWithBeforeToolCallHook(tool); - }); - - const toolMap = new Map(); - for (const tool of tools) { - toolMap.set(tool.name, tool); - } + const tools = params.tools ?? resolveTools(cfg); + const handlers = createPluginToolsMcpHandlers(tools); const server = new Server( { name: "openclaw-plugin-tools", version: VERSION }, { capabilities: { tools: {} } }, ); - server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: tools.map((tool) => ({ - name: tool.name, - description: tool.description ?? "", - inputSchema: resolveJsonSchemaForTool(tool), - })), - })); + server.setRequestHandler(ListToolsRequestSchema, handlers.listTools); server.setRequestHandler(CallToolRequestSchema, async (request) => { - const tool = toolMap.get(request.params.name); - if (!tool) { - return { - content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }], - isError: true, - }; - } - try { - const result = await tool.execute(`mcp-${Date.now()}`, request.params.arguments ?? {}); - return { - content: Array.isArray(result.content) - ? result.content - : [{ type: "text", text: String(result.content) }], - }; - } catch (err) { - return { - content: [{ type: "text", text: `Tool error: ${formatErrorMessage(err)}` }], - isError: true, - }; - } + return await handlers.callTool(request.params); }); return server; diff --git a/src/media/channel-inbound-roots.fast-path.test.ts b/src/media/channel-inbound-roots.fast-path.test.ts new file mode 100644 index 00000000000..bbaf6194fe6 --- /dev/null +++ b/src/media/channel-inbound-roots.fast-path.test.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { MsgContext } from "../auto-reply/templating.js"; +import type { OpenClawConfig } from "../config/types.js"; + +const publicSurfaceLoaderMocks = vi.hoisted(() => ({ + loadBundledPluginPublicArtifactModuleSync: vi.fn(), +})); + +vi.mock("../plugins/public-surface-loader.js", () => publicSurfaceLoaderMocks); + +import { + resolveChannelInboundAttachmentRoots, + resolveChannelRemoteInboundAttachmentRoots, +} from "./channel-inbound-roots.js"; + +const cfg = { + channels: {}, +} as OpenClawConfig; + +function unableToResolve(dirName: string, artifactBasename: string): Error { + return new Error( + `Unable to resolve bundled plugin public surface ${dirName}/${artifactBasename}`, + ); +} + +function createContext(provider: string, accountId = "work"): MsgContext { + return { + Body: "hi", + From: "imessage:work:demo", + To: "+2000", + ChatType: "direct", + Provider: provider, + AccountId: accountId, + }; +} + +beforeEach(() => { + publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync.mockReset(); +}); + +describe("channel inbound roots fast path", () => { + it("prefers media contract artifacts over full channel bootstrap", () => { + publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync.mockImplementation( + ({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => { + if (dirName === "imessage" && artifactBasename === "media-contract-api.js") { + return { + resolveInboundAttachmentRoots: ({ accountId }: { accountId?: string }) => [ + `/local/${accountId}`, + ], + resolveRemoteInboundAttachmentRoots: ({ accountId }: { accountId?: string }) => [ + `/remote/${accountId}`, + ], + }; + } + throw unableToResolve(dirName, artifactBasename); + }, + ); + + expect( + resolveChannelInboundAttachmentRoots({ + cfg, + ctx: createContext("imessage"), + }), + ).toEqual(["/local/work"]); + expect( + resolveChannelRemoteInboundAttachmentRoots({ + cfg, + ctx: createContext("imessage"), + }), + ).toEqual(["/remote/work"]); + expect(publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith( + { + dirName: "imessage", + artifactBasename: "media-contract-api.js", + }, + ); + }); + + it("does not load broad generic contract artifacts on the media-root path", () => { + publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync.mockImplementation( + ({ artifactBasename, dirName }: { artifactBasename: string; dirName: string }) => { + throw unableToResolve(dirName, artifactBasename); + }, + ); + + expect( + resolveChannelRemoteInboundAttachmentRoots({ + cfg, + ctx: createContext("whatsapp"), + }), + ).toBeUndefined(); + expect(publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync).toHaveBeenCalledWith( + { + dirName: "whatsapp", + artifactBasename: "media-contract-api.js", + }, + ); + expect( + publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync, + ).not.toHaveBeenCalledWith({ + dirName: "whatsapp", + artifactBasename: "contract-api.js", + }); + expect( + publicSurfaceLoaderMocks.loadBundledPluginPublicArtifactModuleSync, + ).not.toHaveBeenCalledWith({ + dirName: "whatsapp", + artifactBasename: "index.js", + }); + }); +}); diff --git a/src/media/channel-inbound-roots.ts b/src/media/channel-inbound-roots.ts index 41259b6bfdf..8698cedca3d 100644 --- a/src/media/channel-inbound-roots.ts +++ b/src/media/channel-inbound-roots.ts @@ -1,34 +1,100 @@ import type { MsgContext } from "../auto-reply/templating.js"; -import { getBootstrapChannelPlugin } from "../channels/plugins/bootstrap-registry.js"; import type { OpenClawConfig } from "../config/types.js"; +import { loadBundledPluginPublicArtifactModuleSync } from "../plugins/public-surface-loader.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; -function findChannelMessagingAdapter(channelId?: string | null) { +type ChannelMediaContractApi = { + resolveInboundAttachmentRoots?: (params: { + cfg: OpenClawConfig; + accountId?: string; + }) => readonly string[] | undefined; + resolveRemoteInboundAttachmentRoots?: (params: { + cfg: OpenClawConfig; + accountId?: string; + }) => readonly string[] | undefined; +}; +type ChannelMediaRootResolver = keyof ChannelMediaContractApi; + +const mediaContractApiByResolver = new Map(); + +function mediaContractCacheKey(channelId: string, resolver: ChannelMediaRootResolver): string { + return `${channelId}:${resolver}`; +} + +function loadChannelMediaContractApi( + channelId: string, + resolver: ChannelMediaRootResolver, +): ChannelMediaContractApi | undefined { + const cacheKey = mediaContractCacheKey(channelId, resolver); + if (mediaContractApiByResolver.has(cacheKey)) { + return mediaContractApiByResolver.get(cacheKey) ?? undefined; + } + + try { + const loaded = loadBundledPluginPublicArtifactModuleSync({ + dirName: channelId, + artifactBasename: "media-contract-api.js", + }); + if (typeof loaded[resolver] === "function") { + mediaContractApiByResolver.set(cacheKey, loaded); + return loaded; + } + } catch (error) { + if ( + !( + error instanceof Error && + error.message.startsWith("Unable to resolve bundled plugin public surface ") + ) + ) { + throw error; + } + } + + mediaContractApiByResolver.set(cacheKey, null); + return undefined; +} + +function findChannelMediaContractApi( + channelId: string | null | undefined, + resolver: ChannelMediaRootResolver, +) { const normalized = normalizeOptionalLowercaseString(channelId); if (!normalized) { return undefined; } - return getBootstrapChannelPlugin(normalized)?.messaging; + return loadChannelMediaContractApi(normalized, resolver); } export function resolveChannelInboundAttachmentRoots(params: { cfg: OpenClawConfig; ctx: MsgContext; }): readonly string[] | undefined { - const messaging = findChannelMessagingAdapter(params.ctx.Surface ?? params.ctx.Provider); - return messaging?.resolveInboundAttachmentRoots?.({ - cfg: params.cfg, - accountId: params.ctx.AccountId, - }); + const contractApi = findChannelMediaContractApi( + params.ctx.Surface ?? params.ctx.Provider, + "resolveInboundAttachmentRoots", + ); + if (contractApi?.resolveInboundAttachmentRoots) { + return contractApi.resolveInboundAttachmentRoots({ + cfg: params.cfg, + accountId: params.ctx.AccountId, + }); + } + return undefined; } export function resolveChannelRemoteInboundAttachmentRoots(params: { cfg: OpenClawConfig; ctx: MsgContext; }): readonly string[] | undefined { - const messaging = findChannelMessagingAdapter(params.ctx.Surface ?? params.ctx.Provider); - return messaging?.resolveRemoteInboundAttachmentRoots?.({ - cfg: params.cfg, - accountId: params.ctx.AccountId, - }); + const contractApi = findChannelMediaContractApi( + params.ctx.Surface ?? params.ctx.Provider, + "resolveRemoteInboundAttachmentRoots", + ); + if (contractApi?.resolveRemoteInboundAttachmentRoots) { + return contractApi.resolveRemoteInboundAttachmentRoots({ + cfg: params.cfg, + accountId: params.ctx.AccountId, + }); + } + return undefined; } diff --git a/src/memory-host-sdk/host/internal.test.ts b/src/memory-host-sdk/host/internal.test.ts index a68d1a98137..298a3e78dd4 100644 --- a/src/memory-host-sdk/host/internal.test.ts +++ b/src/memory-host-sdk/host/internal.test.ts @@ -360,11 +360,10 @@ describe("chunkMarkdown", () => { }); it("does not break surrogate pairs when splitting long CJK lines", () => { // "𠀀" (U+20000) is a surrogate pair: 2 UTF-16 code units per character. - // A line of 500 such characters = 1000 UTF-16 code units. - // With tokens=99 (odd), the fine-split must not cut inside a pair. + // With an odd token budget, the fine-split must not cut inside a pair. const surrogateChar = "\u{20000}"; // 𠀀 - const longLine = surrogateChar.repeat(500); - const chunks = chunkMarkdown(longLine, { tokens: 99, overlap: 0 }); + const longLine = surrogateChar.repeat(120); + const chunks = chunkMarkdown(longLine, { tokens: 31, overlap: 0 }); for (const chunk of chunks) { // No chunk should contain the Unicode replacement character U+FFFD, // which would indicate a broken surrogate pair. diff --git a/src/memory-host-sdk/host/session-files.test.ts b/src/memory-host-sdk/host/session-files.test.ts index 47e6213ce31..670e039be93 100644 --- a/src/memory-host-sdk/host/session-files.test.ts +++ b/src/memory-host-sdk/host/session-files.test.ts @@ -1,25 +1,35 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; import { buildSessionEntry, listSessionFilesForAgent } from "./session-files.js"; +let fixtureRoot: string; let tmpDir: string; let originalStateDir: string | undefined; +let fixtureId = 0; + +beforeAll(async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "session-entry-test-")); +}); + +afterAll(async () => { + await fs.rm(fixtureRoot, { recursive: true, force: true }); +}); beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "session-entry-test-")); + tmpDir = path.join(fixtureRoot, `case-${fixtureId++}`); + await fs.mkdir(tmpDir, { recursive: true }); originalStateDir = process.env.OPENCLAW_STATE_DIR; process.env.OPENCLAW_STATE_DIR = tmpDir; }); -afterEach(async () => { +afterEach(() => { if (originalStateDir === undefined) { delete process.env.OPENCLAW_STATE_DIR; } else { process.env.OPENCLAW_STATE_DIR = originalStateDir; } - await fs.rm(tmpDir, { recursive: true, force: true }); }); describe("listSessionFilesForAgent", () => { diff --git a/src/node-host/invoke-system-run-plan.test.ts b/src/node-host/invoke-system-run-plan.test.ts index 3ecf7e517b5..04da21e1d5a 100644 --- a/src/node-host/invoke-system-run-plan.test.ts +++ b/src/node-host/invoke-system-run-plan.test.ts @@ -167,20 +167,16 @@ function expectShellPayloadApprovalDenied(params: { if (process.platform === "win32") { return; } - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), params.tmpPrefix)); - try { - const scriptPath = path.join(tmp, params.fileName); - fs.writeFileSync(scriptPath, params.body); - fs.chmodSync(scriptPath, 0o755); - const prepared = buildSystemRunApprovalPlan({ - command: ["/bin/sh", "-lc", scriptPath], - rawCommand: scriptPath, - cwd: tmp, - }); - expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + const tmp = createFixtureDir(params.tmpPrefix); + const scriptPath = path.join(tmp, params.fileName); + fs.writeFileSync(scriptPath, params.body); + fs.chmodSync(scriptPath, 0o755); + const prepared = buildSystemRunApprovalPlan({ + command: ["/bin/sh", "-lc", scriptPath], + rawCommand: scriptPath, + cwd: tmp, + }); + expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); } function expectMutableFileOperandApprovalPlan(fixture: ScriptOperandFixture, cwd: string) { @@ -478,7 +474,7 @@ describe("hardenApprovedExecutionPaths", () => { ]; it.runIf(process.platform !== "win32").each(cases)("$name", (testCase) => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-approval-hardening-")); + const tmp = createFixtureDir("openclaw-approval-hardening-"); const oldPath = process.env.PATH; let pathToken: PathTokenSetup | null = null; if (testCase.withPathToken) { @@ -534,7 +530,6 @@ describe("hardenApprovedExecutionPaths", () => { process.env.PATH = oldPath; } } - fs.rmSync(tmp, { recursive: true, force: true }); } }); @@ -847,49 +842,41 @@ describe("hardenApprovedExecutionPaths", () => { if (process.platform === "win32") { return; } - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-relative-binary-binding-")); - try { - const binaryPath = resolveNativeBinaryFixturePath(); - const relativeBinaryPath = path.join(tmp, "tool"); - fs.copyFileSync(binaryPath, relativeBinaryPath); - fs.chmodSync(relativeBinaryPath, 0o755); - const prepared = buildSystemRunApprovalPlan({ - command: ["/bin/sh", "-lc", "./tool"], - rawCommand: "./tool", - cwd: tmp, - }); - expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + const tmp = createFixtureDir("openclaw-shell-relative-binary-binding-"); + const binaryPath = resolveNativeBinaryFixturePath(); + const relativeBinaryPath = path.join(tmp, "tool"); + fs.copyFileSync(binaryPath, relativeBinaryPath); + fs.chmodSync(relativeBinaryPath, 0o755); + const prepared = buildSystemRunApprovalPlan({ + command: ["/bin/sh", "-lc", "./tool"], + rawCommand: "./tool", + cwd: tmp, + }); + expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); }); it("keeps fail-closed behavior for writable absolute native-binary shell payloads", () => { if (process.platform === "win32") { return; } - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-absolute-binary-binding-")); - try { - const binaryPath = resolveNativeBinaryFixturePath(); - const copiedBinaryPath = path.join(tmp, "tool"); - fs.copyFileSync(binaryPath, copiedBinaryPath); - fs.chmodSync(copiedBinaryPath, 0o755); - const prepared = buildSystemRunApprovalPlan({ - command: ["/bin/sh", "-lc", copiedBinaryPath], - rawCommand: copiedBinaryPath, - cwd: tmp, - }); - expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + const tmp = createFixtureDir("openclaw-shell-absolute-binary-binding-"); + const binaryPath = resolveNativeBinaryFixturePath(); + const copiedBinaryPath = path.join(tmp, "tool"); + fs.copyFileSync(binaryPath, copiedBinaryPath); + fs.chmodSync(copiedBinaryPath, 0o755); + const prepared = buildSystemRunApprovalPlan({ + command: ["/bin/sh", "-lc", copiedBinaryPath], + rawCommand: copiedBinaryPath, + cwd: tmp, + }); + expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); }); it("keeps fail-closed behavior for owner-controlled read-only absolute binaries", () => { if (process.platform === "win32") { return; } - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-owned-readonly-binding-")); + const tmp = createFixtureDir("openclaw-shell-owned-readonly-binding-"); const binaryPath = path.join(tmp, "tool"); try { fs.copyFileSync(resolveNativeBinaryFixturePath(), binaryPath); @@ -903,7 +890,6 @@ describe("hardenApprovedExecutionPaths", () => { expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); } finally { fs.chmodSync(tmp, 0o755); - fs.rmSync(tmp, { recursive: true, force: true }); } }); @@ -911,7 +897,7 @@ describe("hardenApprovedExecutionPaths", () => { if (process.platform === "win32") { return; } - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-symlink-binary-binding-")); + const tmp = createFixtureDir("openclaw-shell-symlink-binary-binding-"); const stableDir = path.join(tmp, "stable"); const mutableDir = path.join(tmp, "mutable"); try { @@ -932,75 +918,65 @@ describe("hardenApprovedExecutionPaths", () => { expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); } finally { fs.chmodSync(stableDir, 0o755); - fs.rmSync(tmp, { recursive: true, force: true }); } }); - it("keeps fail-closed behavior for shell payloads that invoke mutable script files", () => { - expectShellPayloadApprovalDenied({ - tmpPrefix: "openclaw-shell-script-binding-", - fileName: "run.sh", - body: "#!/bin/sh\necho SAFE\n", - }); - }); - - it("keeps fail-closed behavior for empty shell payload files", () => { - expectShellPayloadApprovalDenied({ - tmpPrefix: "openclaw-shell-empty-binding-", - fileName: "empty", - body: "", - }); - }); - - it("does not treat weak MZ text headers as native binaries", () => { - expectShellPayloadApprovalDenied({ - tmpPrefix: "openclaw-shell-mz-text-binding-", - fileName: "mz-script", - body: "MZ not really a PE file\n", - }); - }); - - it("keeps fail-closed behavior for unknown NUL-bearing headers", () => { - expectShellPayloadApprovalDenied({ - tmpPrefix: "openclaw-shell-nul-header-binding-", - fileName: "nul-script", - body: "SAFE\u0000maybe-binary\n", - }); + it("keeps fail-closed behavior for mutable or ambiguous shell payload files", () => { + for (const testCase of [ + { + tmpPrefix: "openclaw-shell-script-binding-", + fileName: "run.sh", + body: "#!/bin/sh\necho SAFE\n", + }, + { + tmpPrefix: "openclaw-shell-empty-binding-", + fileName: "empty", + body: "", + }, + { + tmpPrefix: "openclaw-shell-mz-text-binding-", + fileName: "mz-script", + body: "MZ not really a PE file\n", + }, + { + tmpPrefix: "openclaw-shell-nul-header-binding-", + fileName: "nul-script", + body: "SAFE\u0000maybe-binary\n", + }, + ]) { + expectShellPayloadApprovalDenied(testCase); + } }); it("keeps fail-closed behavior when the shell payload probe stops seeing a file", () => { if (process.platform === "win32") { return; } - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-race-binding-")); - try { - const scriptPath = path.join(tmp, "run.sh"); - fs.writeFileSync(scriptPath, "#!/bin/sh\necho SAFE\n"); - fs.chmodSync(scriptPath, 0o755); - const realStatSync = fs.statSync; - let targetStatCalls = 0; - const statSyncSpy = vi.spyOn(fs, "statSync").mockImplementation((pathLike, options) => { - const targetPath = typeof pathLike === "string" ? pathLike : pathLike.toString(); - if (targetPath === scriptPath) { - targetStatCalls += 1; - if (targetStatCalls === 2) { - return realStatSync(tmp, options); - } + const tmp = createFixtureDir("openclaw-shell-race-binding-"); + const scriptPath = path.join(tmp, "run.sh"); + fs.writeFileSync(scriptPath, "#!/bin/sh\necho SAFE\n"); + fs.chmodSync(scriptPath, 0o755); + const realStatSync = fs.statSync; + let targetStatCalls = 0; + const statSyncSpy = vi.spyOn(fs, "statSync").mockImplementation((pathLike, options) => { + const targetPath = typeof pathLike === "string" ? pathLike : pathLike.toString(); + if (targetPath === scriptPath) { + targetStatCalls += 1; + if (targetStatCalls === 2) { + return realStatSync(tmp, options); } - return realStatSync(pathLike, options); - }); - try { - const prepared = buildSystemRunApprovalPlan({ - command: ["/bin/sh", "-lc", scriptPath], - rawCommand: scriptPath, - cwd: tmp, - }); - expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); - } finally { - statSyncSpy.mockRestore(); } + return realStatSync(pathLike, options); + }); + try { + const prepared = buildSystemRunApprovalPlan({ + command: ["/bin/sh", "-lc", scriptPath], + rawCommand: scriptPath, + cwd: tmp, + }); + expect(prepared).toEqual(DENIED_RUNTIME_APPROVAL); } finally { - fs.rmSync(tmp, { recursive: true, force: true }); + statSyncSpy.mockRestore(); } }); @@ -1008,13 +984,9 @@ describe("hardenApprovedExecutionPaths", () => { withFakeRuntimeBin({ binName: testCase.binName, run: () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), testCase.tmpPrefix)); - try { - testCase.setup?.(tmp); - expectRuntimeApprovalDenied(testCase.command, tmp); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + const tmp = createFixtureDir(testCase.tmpPrefix); + testCase.setup?.(tmp); + expectRuntimeApprovalDenied(testCase.command, tmp); }, }); }); @@ -1062,98 +1034,56 @@ describe("hardenApprovedExecutionPaths", () => { withFakeRuntimeBins({ binNames: ["pnpm", "tsx"], run: () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pnpm-dlx-shell-mode-")); - try { - fs.writeFileSync(path.join(tmp, "run.ts"), 'console.log("SAFE");\n'); - expect( - resolveMutableFileOperandSnapshotSync({ - argv: ["pnpm", "dlx", "--shell-mode", "tsx ./run.ts"], - cwd: tmp, - shellCommand: null, - }), - ).toEqual({ ok: true, snapshot: null }); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + const tmp = createFixtureDir("openclaw-pnpm-dlx-shell-mode-"); + fs.writeFileSync(path.join(tmp, "run.ts"), 'console.log("SAFE");\n'); + expect( + resolveMutableFileOperandSnapshotSync({ + argv: ["pnpm", "dlx", "--shell-mode", "tsx ./run.ts"], + cwd: tmp, + shellCommand: null, + }), + ).toEqual({ ok: true, snapshot: null }); }, }); }); - it("allows pnpm dlx package binaries that do not bind a mutable local file", () => { - withFakeRuntimeBin({ - binName: "pnpm", - run: () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pnpm-dlx-package-bin-")); - try { - expectApprovalPlanWithoutMutableOperand(["pnpm", "dlx", "cowsay", "hello"], tmp); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }, - }); - }); - - it("allows pnpm dlx package binaries with data-like runtime names", () => { - withFakeRuntimeBin({ - binName: "pnpm", - run: () => { - const tmp = fs.mkdtempSync( - path.join(os.tmpdir(), "openclaw-pnpm-dlx-package-runtime-token-"), - ); - try { - expectApprovalPlanWithoutMutableOperand(["pnpm", "dlx", "cowsay", "node"], tmp); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }, - }); - }); - - it("allows pnpm dlx package binaries with multi-token data-like runtime names", () => { - withFakeRuntimeBin({ - binName: "pnpm", - run: () => { - const tmp = fs.mkdtempSync( - path.join(os.tmpdir(), "openclaw-pnpm-dlx-package-runtime-token-multi-"), - ); - try { - expectApprovalPlanWithoutMutableOperand(["pnpm", "dlx", "cowsay", "node", "hello"], tmp); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }, - }); - }); - - it("allows pnpm dlx package binaries with local file arguments", () => { + it("allows pnpm dlx package binaries that do not bind mutable local files", () => { withFakeRuntimeBins({ binNames: ["pnpm", "eslint"], run: () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pnpm-dlx-package-file-")); - try { - fs.mkdirSync(path.join(tmp, "src"), { recursive: true }); - fs.writeFileSync(path.join(tmp, "src", "index.ts"), 'console.log("SAFE");\n'); - expectApprovalPlanWithoutMutableOperand(["pnpm", "dlx", "eslint", "src/index.ts"], tmp); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }, - }); - }); - - it("allows pnpm dlx package binaries with interpreter-like data tails", () => { - withFakeRuntimeBin({ - binName: "pnpm", - run: () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-pnpm-dlx-package-data-tail-")); - try { - fs.writeFileSync(path.join(tmp, "run.ts"), 'console.log("SAFE");\n'); - expectApprovalPlanWithoutMutableOperand( - ["pnpm", "dlx", "cowsay", "tsx", "./run.ts"], - tmp, - ); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); + const cases = [ + { + prefix: "openclaw-pnpm-dlx-package-bin-", + command: ["pnpm", "dlx", "cowsay", "hello"], + }, + { + prefix: "openclaw-pnpm-dlx-package-runtime-token-", + command: ["pnpm", "dlx", "cowsay", "node"], + }, + { + prefix: "openclaw-pnpm-dlx-package-runtime-token-multi-", + command: ["pnpm", "dlx", "cowsay", "node", "hello"], + }, + { + prefix: "openclaw-pnpm-dlx-package-file-", + command: ["pnpm", "dlx", "eslint", "src/index.ts"], + setup: (tmp: string) => { + fs.mkdirSync(path.join(tmp, "src"), { recursive: true }); + fs.writeFileSync(path.join(tmp, "src", "index.ts"), 'console.log("SAFE");\n'); + }, + }, + { + prefix: "openclaw-pnpm-dlx-package-data-tail-", + command: ["pnpm", "dlx", "cowsay", "tsx", "./run.ts"], + setup: (tmp: string) => { + fs.writeFileSync(path.join(tmp, "run.ts"), 'console.log("SAFE");\n'); + }, + }, + ]; + for (const testCase of cases) { + const tmp = createFixtureDir(testCase.prefix); + testCase.setup?.(tmp); + expectApprovalPlanWithoutMutableOperand(testCase.command, tmp); } }, }); @@ -1183,26 +1113,22 @@ describe("hardenApprovedExecutionPaths", () => { }); it("captures the real shell script operand after value-taking shell flags", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shell-option-value-")); - try { - const scriptPath = path.join(tmp, "run.sh"); - fs.writeFileSync(scriptPath, "#!/bin/sh\necho SAFE\n"); - fs.writeFileSync(path.join(tmp, "errexit"), "decoy\n"); - const snapshot = resolveMutableFileOperandSnapshotSync({ - argv: ["/bin/bash", "-o", "errexit", "./run.sh"], - cwd: tmp, - shellCommand: null, - }); - expect(snapshot).toEqual({ - ok: true, - snapshot: { - argvIndex: 3, - path: fs.realpathSync(scriptPath), - sha256: expect.any(String), - }, - }); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + const tmp = createFixtureDir("openclaw-shell-option-value-"); + const scriptPath = path.join(tmp, "run.sh"); + fs.writeFileSync(scriptPath, "#!/bin/sh\necho SAFE\n"); + fs.writeFileSync(path.join(tmp, "errexit"), "decoy\n"); + const snapshot = resolveMutableFileOperandSnapshotSync({ + argv: ["/bin/bash", "-o", "errexit", "./run.sh"], + cwd: tmp, + shellCommand: null, + }); + expect(snapshot).toEqual({ + ok: true, + snapshot: { + argvIndex: 3, + path: fs.realpathSync(scriptPath), + sha256: expect.any(String), + }, + }); }); }); diff --git a/src/node-host/invoke-system-run.test.ts b/src/node-host/invoke-system-run.test.ts index 7ee2c2633f4..e7ed41e74f1 100644 --- a/src/node-host/invoke-system-run.test.ts +++ b/src/node-host/invoke-system-run.test.ts @@ -19,7 +19,11 @@ import { setRuntimeConfigSnapshot, } from "../config/runtime-snapshot.js"; import type { SystemRunApprovalPlan } from "../infra/exec-approvals.js"; -import { loadExecApprovals, saveExecApprovals } from "../infra/exec-approvals.js"; +import { + loadExecApprovals, + resolveExecApprovalsPath, + saveExecApprovals, +} from "../infra/exec-approvals.js"; import type { ExecHostResponse } from "../infra/exec-host.js"; import { buildSystemRunApprovalPlan } from "./invoke-system-run-plan.js"; import { handleSystemRunInvoke, formatSystemRunAllowlistMissMessage } from "./invoke-system-run.js"; @@ -32,11 +36,8 @@ type MockedSendExecFinishedEvent = Mock; describe("formatSystemRunAllowlistMissMessage", () => { - it("returns legacy allowlist miss message by default", () => { + it("returns the default message and cmd.exe guidance variant", () => { expect(formatSystemRunAllowlistMissMessage()).toBe("SYSTEM_RUN_DENIED: allowlist miss"); - }); - - it("adds Windows shell-wrapper guidance when blocked by cmd.exe policy", () => { expect( formatSystemRunAllowlistMissMessage({ windowsShellWrapperBlocked: true, @@ -47,12 +48,18 @@ describe("formatSystemRunAllowlistMissMessage", () => { describe("handleSystemRunInvoke mac app exec host routing", () => { let sharedFixtureRoot = ""; + let sharedOpenClawHome = ""; + let sharedRuntimeBinDir = ""; let sharedFixtureId = 0; - let testOpenClawHome = ""; let previousOpenClawHome: string | undefined; + const sharedRuntimeBins = new Set(); beforeAll(() => { sharedFixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-node-host-fixtures-")); + sharedOpenClawHome = path.join(sharedFixtureRoot, "openclaw-home"); + sharedRuntimeBinDir = path.join(sharedFixtureRoot, "bin"); + fs.mkdirSync(sharedOpenClawHome, { recursive: true }); + fs.mkdirSync(sharedRuntimeBinDir, { recursive: true }); }); afterAll(() => { @@ -69,8 +76,8 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { beforeEach(() => { previousOpenClawHome = process.env.OPENCLAW_HOME; - testOpenClawHome = createFixtureDir("openclaw-node-host-home-"); - process.env.OPENCLAW_HOME = testOpenClawHome; + process.env.OPENCLAW_HOME = sharedOpenClawHome; + fs.rmSync(resolveExecApprovalsPath(), { force: true }); clearRuntimeConfigSnapshot(); }); @@ -81,7 +88,6 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { } else { process.env.OPENCLAW_HOME = previousOpenClawHome; } - testOpenClawHome = ""; }); function createLocalRunResult(stdout = "local-ok") { @@ -267,7 +273,7 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { approvals: Parameters[0]; run: (ctx: { tempHome: string }) => Promise; }): Promise { - const tempHome = createFixtureDir("openclaw-exec-approvals-"); + const tempHome = sharedOpenClawHome; const previousOpenClawHome = process.env.OPENCLAW_HOME; process.env.OPENCLAW_HOME = tempHome; saveExecApprovals(params.approvals); @@ -309,21 +315,21 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { runtime: "bun" | "deno" | "jiti" | "tsx"; run: () => Promise; }): Promise { - const tmp = createFixtureDir(`openclaw-${params.runtime}-path-`); - const binDir = path.join(tmp, "bin"); - fs.mkdirSync(binDir, { recursive: true }); - const runtimePath = - process.platform === "win32" - ? path.join(binDir, `${params.runtime}.cmd`) - : path.join(binDir, params.runtime); - const runtimeBody = - process.platform === "win32" ? "@echo off\r\nexit /b 0\r\n" : "#!/bin/sh\nexit 0\n"; - fs.writeFileSync(runtimePath, runtimeBody, { mode: 0o755 }); - if (process.platform !== "win32") { - fs.chmodSync(runtimePath, 0o755); + if (!sharedRuntimeBins.has(params.runtime)) { + const runtimePath = + process.platform === "win32" + ? path.join(sharedRuntimeBinDir, `${params.runtime}.cmd`) + : path.join(sharedRuntimeBinDir, params.runtime); + const runtimeBody = + process.platform === "win32" ? "@echo off\r\nexit /b 0\r\n" : "#!/bin/sh\nexit 0\n"; + fs.writeFileSync(runtimePath, runtimeBody, { mode: 0o755 }); + if (process.platform !== "win32") { + fs.chmodSync(runtimePath, 0o755); + } + sharedRuntimeBins.add(params.runtime); } const oldPath = process.env.PATH; - process.env.PATH = `${binDir}${path.delimiter}${oldPath ?? ""}`; + process.env.PATH = `${sharedRuntimeBinDir}${path.delimiter}${oldPath ?? ""}`; try { return await params.run(); } finally { @@ -946,77 +952,75 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { } }); - for (const runtime of ["bun", "deno", "tsx", "jiti"] as const) { - it(`validates approved ${runtime} script operand stability`, async () => { - await withFakeRuntimeOnPath({ - runtime, - run: async () => { - const tmp = createFixtureDir(`openclaw-approval-${runtime}-script-drift-`); - const fixture = createRuntimeScriptOperandFixture({ tmp, runtime }); - fs.writeFileSync(fixture.scriptPath, fixture.initialBody); - try { - const prepared = buildSystemRunApprovalPlan({ - command: fixture.command, - cwd: tmp, - }); - expect(prepared.ok).toBe(true); - if (!prepared.ok) { - throw new Error("unreachable"); - } - - fs.writeFileSync(fixture.scriptPath, fixture.changedBody); - const { runCommand, sendInvokeResult } = await runSystemInvoke({ - preferMacAppExecHost: false, - command: prepared.plan.argv, - rawCommand: prepared.plan.commandText, - systemRunPlan: prepared.plan, - cwd: prepared.plan.cwd ?? tmp, - approved: true, - security: "full", - ask: "off", - }); - - expect(runCommand).not.toHaveBeenCalled(); - expectInvokeErrorMessage(sendInvokeResult, { - message: "SYSTEM_RUN_DENIED: approval script operand changed before execution", - exact: true, - }); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); + it("validates approved runtime script operand stability at dispatch", async () => { + await withFakeRuntimeOnPath({ + runtime: "tsx", + run: async () => { + const tmp = createFixtureDir("openclaw-approval-tsx-script-drift-"); + const fixture = createRuntimeScriptOperandFixture({ tmp, runtime: "tsx" }); + fs.writeFileSync(fixture.scriptPath, fixture.initialBody); + try { + const prepared = buildSystemRunApprovalPlan({ + command: fixture.command, + cwd: tmp, + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + throw new Error("unreachable"); } - const stableTmp = createFixtureDir(`openclaw-approval-${runtime}-script-stable-`); - const stableFixture = createRuntimeScriptOperandFixture({ tmp: stableTmp, runtime }); - fs.writeFileSync(stableFixture.scriptPath, stableFixture.initialBody); - try { - const prepared = buildSystemRunApprovalPlan({ - command: stableFixture.command, - cwd: stableTmp, - }); - expect(prepared.ok).toBe(true); - if (!prepared.ok) { - throw new Error("unreachable"); - } - const { runCommand, sendInvokeResult } = await runSystemInvoke({ - preferMacAppExecHost: false, - command: prepared.plan.argv, - rawCommand: prepared.plan.commandText, - systemRunPlan: prepared.plan, - cwd: prepared.plan.cwd ?? stableTmp, - approved: true, - security: "full", - ask: "off", - }); + fs.writeFileSync(fixture.scriptPath, fixture.changedBody); + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + command: prepared.plan.argv, + rawCommand: prepared.plan.commandText, + systemRunPlan: prepared.plan, + cwd: prepared.plan.cwd ?? tmp, + approved: true, + security: "full", + ask: "off", + }); - expect(runCommand).toHaveBeenCalledTimes(1); - expectInvokeOk(sendInvokeResult); - } finally { - fs.rmSync(stableTmp, { recursive: true, force: true }); + expect(runCommand).not.toHaveBeenCalled(); + expectInvokeErrorMessage(sendInvokeResult, { + message: "SYSTEM_RUN_DENIED: approval script operand changed before execution", + exact: true, + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + const stableTmp = createFixtureDir("openclaw-approval-tsx-script-stable-"); + const stableFixture = createRuntimeScriptOperandFixture({ tmp: stableTmp, runtime: "tsx" }); + fs.writeFileSync(stableFixture.scriptPath, stableFixture.initialBody); + try { + const prepared = buildSystemRunApprovalPlan({ + command: stableFixture.command, + cwd: stableTmp, + }); + expect(prepared.ok).toBe(true); + if (!prepared.ok) { + throw new Error("unreachable"); } - }, - }); + + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + command: prepared.plan.argv, + rawCommand: prepared.plan.commandText, + systemRunPlan: prepared.plan, + cwd: prepared.plan.cwd ?? stableTmp, + approved: true, + security: "full", + ask: "off", + }); + + expect(runCommand).toHaveBeenCalledTimes(1); + expectInvokeOk(sendInvokeResult); + } finally { + fs.rmSync(stableTmp, { recursive: true, force: true }); + } + }, }); - } + }); it("denies approval-based execution when tsx is missing a required mutable script binding", async () => { await withFakeRuntimeOnPath({ @@ -1158,46 +1162,65 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult }); }); - it("rejects blocked environment overrides before execution", async () => { - const { runCommand, sendInvokeResult } = await runSystemInvoke({ - preferMacAppExecHost: false, - security: "full", - ask: "off", - env: { CLASSPATH: "/tmp/evil-classpath" }, - }); - - expect(runCommand).not.toHaveBeenCalled(); - expectInvokeErrorMessage(sendInvokeResult, { - message: "SYSTEM_RUN_DENIED: environment override rejected", - }); - expectInvokeErrorMessage(sendInvokeResult, { - message: "CLASSPATH", - }); - }); - - it("rejects blocked environment overrides for shell-wrapper commands", async () => { + it("rejects unsafe environment inputs before execution", async () => { const shellCommand = process.platform === "win32" ? ["cmd.exe", "/d", "/s", "/c", "echo ok"] : ["/bin/sh", "-lc", "echo ok"]; - const { runCommand, sendInvokeResult } = await runSystemInvoke({ - preferMacAppExecHost: false, - security: "full", - ask: "off", - command: shellCommand, - env: { - CLASSPATH: "/tmp/evil-classpath", - LANG: "C", + const cases: Array<{ + label: string; + command?: string[]; + env?: Record; + message: string; + details: string[]; + }> = [ + { + label: "blocked override", + env: { CLASSPATH: "/tmp/evil-classpath" }, + message: "SYSTEM_RUN_DENIED: environment override rejected", + details: ["CLASSPATH"], }, - }); + { + label: "blocked override for shell-wrapper", + command: shellCommand, + env: { + CLASSPATH: "/tmp/evil-classpath", + LANG: "C", + }, + message: "SYSTEM_RUN_DENIED: environment override rejected", + details: ["CLASSPATH"], + }, + { + label: "blocked argv assignment", + command: ["/usr/bin/env", "SHELLOPTS=xtrace", "PS4=$(id)", "bash", "-lc", "echo ok"], + message: "SYSTEM_RUN_DENIED: command env assignment rejected", + details: ["SHELLOPTS", "PS4"], + }, + { + label: "invalid override key", + env: { "BAD-KEY": "x" }, + message: "SYSTEM_RUN_DENIED: environment override rejected", + details: ["BAD-KEY"], + }, + ]; - expect(runCommand).not.toHaveBeenCalled(); - expectInvokeErrorMessage(sendInvokeResult, { - message: "SYSTEM_RUN_DENIED: environment override rejected", - }); - expectInvokeErrorMessage(sendInvokeResult, { - message: "CLASSPATH", - }); + for (const testCase of cases) { + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + security: "full", + ask: "off", + command: testCase.command, + env: testCase.env, + }); + + expect(runCommand, testCase.label).not.toHaveBeenCalled(); + expectInvokeErrorMessage(sendInvokeResult, { + message: testCase.message, + }); + for (const detail of testCase.details) { + expectInvokeErrorMessage(sendInvokeResult, { message: detail }); + } + } }); it("applies shell-wrapper env allowlist for shell executable commands without inline payload", async () => { @@ -1223,43 +1246,6 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { expectInvokeOk(sendInvokeResult); }); - it("rejects blocked env assignment keys embedded in command argv", async () => { - const { runCommand, sendInvokeResult } = await runSystemInvoke({ - preferMacAppExecHost: false, - security: "full", - ask: "off", - command: ["/usr/bin/env", "SHELLOPTS=xtrace", "PS4=$(id)", "bash", "-lc", "echo ok"], - }); - - expect(runCommand).not.toHaveBeenCalled(); - expectInvokeErrorMessage(sendInvokeResult, { - message: "SYSTEM_RUN_DENIED: command env assignment rejected", - }); - expectInvokeErrorMessage(sendInvokeResult, { - message: "SHELLOPTS", - }); - expectInvokeErrorMessage(sendInvokeResult, { - message: "PS4", - }); - }); - - it("rejects invalid non-portable environment override keys before execution", async () => { - const { runCommand, sendInvokeResult } = await runSystemInvoke({ - preferMacAppExecHost: false, - security: "full", - ask: "off", - env: { "BAD-KEY": "x" }, - }); - - expect(runCommand).not.toHaveBeenCalled(); - expectInvokeErrorMessage(sendInvokeResult, { - message: "SYSTEM_RUN_DENIED: environment override rejected", - }); - expectInvokeErrorMessage(sendInvokeResult, { - message: "BAD-KEY", - }); - }); - async function expectNestedEnvShellDenied(params: { depth: number; markerName: string; @@ -1301,26 +1287,24 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { expectApprovalRequiredDenied({ sendNodeEvent, sendInvokeResult }); } - it("denies env-wrapped shell payloads at the dispatch depth boundary", async () => { + it("denies env-wrapped shell payloads at and past the dispatch depth boundary", async () => { if (process.platform === "win32") { return; } - await expectNestedEnvShellDenied({ - depth: 4, - markerName: "depth4-pwned.txt", - errorLabel: "runCommand should not be called for depth-boundary shell wrappers", - }); - }); - - it("denies nested env shell payloads when wrapper depth is exceeded", async () => { - if (process.platform === "win32") { - return; + for (const testCase of [ + { + depth: 4, + markerName: "depth4-pwned.txt", + errorLabel: "runCommand should not be called for depth-boundary shell wrappers", + }, + { + depth: 5, + markerName: "pwned.txt", + errorLabel: "runCommand should not be called for nested env depth overflow", + }, + ]) { + await expectNestedEnvShellDenied(testCase); } - await expectNestedEnvShellDenied({ - depth: 5, - markerName: "pwned.txt", - errorLabel: "runCommand should not be called for nested env depth overflow", - }); }); it("requires explicit approval for strict inline-eval carriers", async () => { @@ -1430,29 +1414,25 @@ describe("handleSystemRunInvoke mac app exec host routing", () => { await withTempApprovalsHome({ approvals: createAllowlistOnMissApprovals(), run: async () => { + const tempDir = createFixtureDir("openclaw-inline-eval-bin-"); for (const testCase of cases) { - const tempDir = createFixtureDir("openclaw-inline-eval-bin-"); - try { - const executablePath = createTempExecutable({ - dir: tempDir, - name: testCase.executable, - }); - const { runCommand, sendInvokeResult } = await runSystemInvoke({ - preferMacAppExecHost: false, - command: [executablePath, ...testCase.args], - security: "allowlist", - ask: "on-miss", - approvalDecision: "allow-always", - approved: true, - runCommand: vi.fn(async () => createLocalRunResult("inline-eval-ok")), - }); + const executablePath = createTempExecutable({ + dir: tempDir, + name: testCase.executable, + }); + const { runCommand, sendInvokeResult } = await runSystemInvoke({ + preferMacAppExecHost: false, + command: [executablePath, ...testCase.args], + security: "allowlist", + ask: "on-miss", + approvalDecision: "allow-always", + approved: true, + runCommand: vi.fn(async () => createLocalRunResult("inline-eval-ok")), + }); - expect(runCommand).toHaveBeenCalledTimes(1); - expectInvokeOk(sendInvokeResult, { payloadContains: "inline-eval-ok" }); - expect(loadExecApprovals().agents?.main?.allowlist ?? []).toEqual([]); - } finally { - fs.rmSync(tempDir, { recursive: true, force: true }); - } + expect(runCommand).toHaveBeenCalledTimes(1); + expectInvokeOk(sendInvokeResult, { payloadContains: "inline-eval-ok" }); + expect(loadExecApprovals().agents?.main?.allowlist ?? []).toEqual([]); } }, }); diff --git a/src/node-host/invoke-system-run.ts b/src/node-host/invoke-system-run.ts index e4a00e78070..ef1e969093c 100644 --- a/src/node-host/invoke-system-run.ts +++ b/src/node-host/invoke-system-run.ts @@ -1,5 +1,4 @@ import crypto from "node:crypto"; -import { resolveAgentConfig } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { GatewayClient } from "../gateway/client.js"; import { @@ -32,6 +31,7 @@ import { import { normalizeSystemRunApprovalPlan } from "../infra/system-run-approval-binding.js"; import { resolveSystemRunCommandRequest } from "../infra/system-run-command.js"; import { logWarn } from "../logger.js"; +import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeOptionalString } from "../shared/string-coerce.js"; import { evaluateSystemRunPolicy, resolveExecApprovalDecision } from "./exec-policy.js"; import { @@ -123,6 +123,7 @@ const APPROVAL_SCRIPT_OPERAND_BINDING_DENIED_MESSAGE = "SYSTEM_RUN_DENIED: approval missing script operand binding"; const APPROVAL_SCRIPT_OPERAND_DRIFT_DENIED_MESSAGE = "SYSTEM_RUN_DENIED: approval script operand changed before execution"; +type ExecToolConfig = NonNullable["exec"]>; function warnWritableTrustedDirOnce(message: string): void { if (safeBinTrustedDirWarningCache.has(message)) { @@ -146,6 +147,23 @@ function normalizeDeniedReason(reason: string | null | undefined): SystemRunDeni } } +function resolveAgentExecConfig( + cfg: OpenClawConfig, + agentId: string | undefined, +): ExecToolConfig | undefined { + if (!agentId) { + return undefined; + } + const normalizedAgentId = normalizeAgentId(agentId); + const entry = cfg.agents?.list?.find( + (candidate) => + candidate !== null && + typeof candidate === "object" && + normalizeAgentId(candidate.id) === normalizedAgentId, + ); + return entry?.tools?.exec; +} + export type HandleSystemRunInvokeOptions = { client: GatewayClient; params: SystemRunParams; @@ -353,9 +371,7 @@ async function evaluateSystemRunPolicyPhase( parsed: SystemRunParsePhase, ): Promise { const cfg = await loadSystemRunConfig(opts); - const agentExec = parsed.agentId - ? resolveAgentConfig(cfg, parsed.agentId)?.tools?.exec - : undefined; + const agentExec = resolveAgentExecConfig(cfg, parsed.agentId); const configuredSecurity = opts.resolveExecSecurity( agentExec?.security ?? cfg.tools?.exec?.security, ); diff --git a/src/plugin-activation-boundary.test.ts b/src/plugin-activation-boundary.test.ts index 6a28c606e9c..0a87ce18e9c 100644 --- a/src/plugin-activation-boundary.test.ts +++ b/src/plugin-activation-boundary.test.ts @@ -227,26 +227,10 @@ describe("plugin activation boundary", () => { loadBundledPluginPublicSurfaceModuleSync.mock.calls.map( ([params]) => params.artifactBasename, ), - ).toEqual([ - "browser-host-inspection.js", - "browser-control-auth.js", - "browser-profiles.js", - "browser-profiles.js", - "browser-host-inspection.js", - "browser-host-inspection.js", - ]); + ).toEqual(["browser-host-inspection.js", "browser-control-auth.js", "browser-profiles.js"]); loadBundledPluginPublicSurfaceModuleSync.mockReset(); - const { getSessionBindingService } = - await import("./infra/outbound/session-binding-service.js"); - await expect(browser.closeTrackedBrowserTabsForSessions({ sessionKeys: [] })).resolves.toBe(0); - await expect( - getSessionBindingService().unbind({ - targetSessionKey: "agent:main:test", - reason: "session-reset", - }), - ).resolves.toEqual([]); expect(loadBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled(); }); }); diff --git a/src/plugin-sdk/browser-control-auth.ts b/src/plugin-sdk/browser-control-auth.ts index 89be3a1b751..6ec64283de8 100644 --- a/src/plugin-sdk/browser-control-auth.ts +++ b/src/plugin-sdk/browser-control-auth.ts @@ -24,11 +24,15 @@ type BrowserControlAuthSurface = { ) => Promise; }; +let cachedBrowserControlAuthSurface: BrowserControlAuthSurface | undefined; + function loadBrowserControlAuthSurface(): BrowserControlAuthSurface { - return loadBundledPluginPublicSurfaceModuleSync({ - dirName: "browser", - artifactBasename: "browser-control-auth.js", - }); + cachedBrowserControlAuthSurface ??= + loadBundledPluginPublicSurfaceModuleSync({ + dirName: "browser", + artifactBasename: "browser-control-auth.js", + }); + return cachedBrowserControlAuthSurface; } export function resolveBrowserControlAuth( diff --git a/src/plugin-sdk/browser-facades.test.ts b/src/plugin-sdk/browser-facades.test.ts index 47906fe0718..faa834420ad 100644 --- a/src/plugin-sdk/browser-facades.test.ts +++ b/src/plugin-sdk/browser-facades.test.ts @@ -8,6 +8,8 @@ vi.mock("./facade-loader.js", () => ({ describe("plugin-sdk browser facades", () => { beforeEach(() => { + // Facade wrappers cache successful loads; each case needs a clean wrapper module. + vi.resetModules(); loadBundledPluginPublicSurfaceModuleSync.mockReset(); }); diff --git a/src/plugin-sdk/browser-host-inspection.test.ts b/src/plugin-sdk/browser-host-inspection.test.ts index 21f9593d351..114829c93e8 100644 --- a/src/plugin-sdk/browser-host-inspection.test.ts +++ b/src/plugin-sdk/browser-host-inspection.test.ts @@ -8,6 +8,8 @@ vi.mock("./facade-loader.js", () => ({ describe("browser host inspection", () => { beforeEach(() => { + // Facade wrappers cache successful loads; each case needs a clean wrapper module. + vi.resetModules(); loadBundledPluginPublicSurfaceModuleSync.mockReset(); }); diff --git a/src/plugin-sdk/browser-host-inspection.ts b/src/plugin-sdk/browser-host-inspection.ts index b3db53ae17f..889adfc1acc 100644 --- a/src/plugin-sdk/browser-host-inspection.ts +++ b/src/plugin-sdk/browser-host-inspection.ts @@ -11,11 +11,15 @@ type BrowserHostInspectionSurface = { parseBrowserMajorVersion: (rawVersion: string | null | undefined) => number | null; }; +let cachedBrowserHostInspectionSurface: BrowserHostInspectionSurface | undefined; + function loadBrowserHostInspectionSurface(): BrowserHostInspectionSurface { - return loadBundledPluginPublicSurfaceModuleSync({ - dirName: "browser", - artifactBasename: "browser-host-inspection.js", - }); + cachedBrowserHostInspectionSurface ??= + loadBundledPluginPublicSurfaceModuleSync({ + dirName: "browser", + artifactBasename: "browser-host-inspection.js", + }); + return cachedBrowserHostInspectionSurface; } export function resolveGoogleChromeExecutableForPlatform( diff --git a/src/plugin-sdk/browser-maintenance.ts b/src/plugin-sdk/browser-maintenance.ts index d430ea448eb..1d0afcb9663 100644 --- a/src/plugin-sdk/browser-maintenance.ts +++ b/src/plugin-sdk/browser-maintenance.ts @@ -10,15 +10,19 @@ type BrowserMaintenanceSurface = { closeTrackedBrowserTabsForSessions: (params: CloseTrackedBrowserTabsParams) => Promise; }; +let cachedBrowserMaintenanceSurface: BrowserMaintenanceSurface | undefined; + function hasRequestedSessionKeys(sessionKeys: Array): boolean { return sessionKeys.some((key) => Boolean(key?.trim())); } function loadBrowserMaintenanceSurface(): BrowserMaintenanceSurface { - return loadBundledPluginPublicSurfaceModuleSync({ - dirName: "browser", - artifactBasename: "browser-maintenance.js", - }); + cachedBrowserMaintenanceSurface ??= + loadBundledPluginPublicSurfaceModuleSync({ + dirName: "browser", + artifactBasename: "browser-maintenance.js", + }); + return cachedBrowserMaintenanceSurface; } export async function closeTrackedBrowserTabsForSessions( diff --git a/src/plugin-sdk/browser-profiles.ts b/src/plugin-sdk/browser-profiles.ts index 31b5068957a..61791c47b75 100644 --- a/src/plugin-sdk/browser-profiles.ts +++ b/src/plugin-sdk/browser-profiles.ts @@ -57,11 +57,16 @@ type BrowserProfilesSurface = { ) => ResolvedBrowserProfile | null; }; +let cachedBrowserProfilesSurface: BrowserProfilesSurface | undefined; + function loadBrowserProfilesSurface(): BrowserProfilesSurface { - return loadBundledPluginPublicSurfaceModuleSync({ - dirName: "browser", - artifactBasename: "browser-profiles.js", - }); + cachedBrowserProfilesSurface ??= loadBundledPluginPublicSurfaceModuleSync( + { + dirName: "browser", + artifactBasename: "browser-profiles.js", + }, + ); + return cachedBrowserProfilesSurface; } export function resolveBrowserConfig( diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index e62e6f19d5f..621f42afcf3 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -4,7 +4,9 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { emptyChannelConfigSchema } from "../channels/plugins/config-schema.js"; import type { ChannelConfigSchema } from "../channels/plugins/types.config.js"; +import type { ChannelLegacyStateMigrationPlan } from "../channels/plugins/types.core.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { getCachedPluginJitiLoader, @@ -47,6 +49,8 @@ type DefineBundledChannelSetupEntryOptions = { plugin: BundledEntryModuleRef; secrets?: BundledEntryModuleRef; runtime?: BundledEntryModuleRef; + legacyStateMigrations?: BundledEntryModuleRef; + legacySessionSurface?: BundledEntryModuleRef; features?: BundledChannelSetupEntryFeatures; }; @@ -59,6 +63,25 @@ export type BundledChannelEntryFeatures = { accountInspect?: boolean; }; +export type BundledChannelLegacySessionSurface = { + isLegacyGroupSessionKey?: (key: string) => boolean; + canonicalizeLegacySessionKey?: (params: { + key: string; + agentId: string; + }) => string | null | undefined; +}; + +export type BundledChannelLegacyStateMigrationDetector = (params: { + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; + stateDir: string; + oauthDir: string; +}) => + | ChannelLegacyStateMigrationPlan[] + | Promise + | null + | undefined; + export type BundledChannelEntryContract = { kind: "bundled-channel-entry"; id: string; @@ -77,6 +100,8 @@ export type BundledChannelSetupEntryContract = { kind: "bundled-channel-setup-entry"; loadSetupPlugin: () => TPlugin; loadSetupSecrets?: () => ChannelPlugin["secrets"] | undefined; + loadLegacyStateMigrationDetector?: () => BundledChannelLegacyStateMigrationDetector; + loadLegacySessionSurface?: () => BundledChannelLegacySessionSurface; setChannelRuntime?: (runtime: PluginRuntime) => void; features?: BundledChannelSetupEntryFeatures; }; @@ -404,6 +429,8 @@ export function defineBundledChannelSetupEntry({ plugin, secrets, runtime, + legacyStateMigrations, + legacySessionSurface, features, }: DefineBundledChannelSetupEntryOptions): BundledChannelSetupEntryContract { // Bundled setup entries stay on a light path during setup-only/setup-runtime loads. @@ -418,6 +445,20 @@ export function defineBundledChannelSetupEntry({ setter(pluginRuntime); } : undefined; + const loadLegacyStateMigrationDetector = legacyStateMigrations + ? () => + loadBundledEntryExportSync( + importMetaUrl, + legacyStateMigrations, + ) + : undefined; + const loadLegacySessionSurface = legacySessionSurface + ? () => + loadBundledEntryExportSync( + importMetaUrl, + legacySessionSurface, + ) + : undefined; return { kind: "bundled-channel-setup-entry", loadSetupPlugin: () => loadBundledEntryExportSync(importMetaUrl, plugin), @@ -430,6 +471,8 @@ export function defineBundledChannelSetupEntry({ ), } : {}), + ...(loadLegacyStateMigrationDetector ? { loadLegacyStateMigrationDetector } : {}), + ...(loadLegacySessionSurface ? { loadLegacySessionSurface } : {}), ...(setChannelRuntime ? { setChannelRuntime } : {}), ...(features ? { features } : {}), }; diff --git a/src/plugin-sdk/channel-pairing-paths.ts b/src/plugin-sdk/channel-pairing-paths.ts new file mode 100644 index 00000000000..2059b3086a7 --- /dev/null +++ b/src/plugin-sdk/channel-pairing-paths.ts @@ -0,0 +1 @@ +export { resolveChannelAllowFromPath } from "../pairing/allow-from-store-read.js"; diff --git a/src/plugin-sdk/directory-config-runtime.ts b/src/plugin-sdk/directory-config-runtime.ts new file mode 100644 index 00000000000..5b6ecf2598a --- /dev/null +++ b/src/plugin-sdk/directory-config-runtime.ts @@ -0,0 +1,22 @@ +/** Slim directory-config helper surface for config-backed plugin directory contracts. */ +export type { DirectoryConfigParams } from "../channels/plugins/directory-types.js"; +export type { + ChannelDirectoryEntry, + ChannelDirectoryEntryKind, +} from "../channels/plugins/types.public.js"; +export { + applyDirectoryQueryAndLimit, + collectNormalizedDirectoryIds, + createInspectedDirectoryEntriesLister, + createResolvedDirectoryEntriesLister, + listDirectoryEntriesFromSources, + listDirectoryGroupEntriesFromMapKeys, + listDirectoryGroupEntriesFromMapKeysAndAllowFrom, + listDirectoryUserEntriesFromAllowFrom, + listDirectoryUserEntriesFromAllowFromAndMapKeys, + listInspectedDirectoryEntriesFromSources, + listResolvedDirectoryEntriesFromSources, + listResolvedDirectoryGroupEntriesFromMapKeys, + listResolvedDirectoryUserEntriesFromAllowFrom, + toDirectoryEntries, +} from "../channels/plugins/directory-config-helpers.js"; diff --git a/src/plugin-sdk/group-access.ts b/src/plugin-sdk/group-access.ts index bec84b4ba7c..494a78c7b6c 100644 --- a/src/plugin-sdk/group-access.ts +++ b/src/plugin-sdk/group-access.ts @@ -1,6 +1,9 @@ import { resolveOpenProviderRuntimeGroupPolicy } from "../config/runtime-group-policy.js"; import type { GroupPolicy } from "../config/types.base.js"; +export { resolveOpenProviderRuntimeGroupPolicy }; +export type { GroupPolicy }; + export type SenderGroupAccessReason = | "allowed" | "disabled" diff --git a/src/plugin-sdk/zalo-setup.ts b/src/plugin-sdk/zalo-setup.ts index d9745a051b7..8912c285c2b 100644 --- a/src/plugin-sdk/zalo-setup.ts +++ b/src/plugin-sdk/zalo-setup.ts @@ -1,29 +1,38 @@ -// Manual facade. Keep loader boundary explicit. -type FacadeModule = typeof import("@openclaw/zalo/setup-api.js"); +// Manual facade. Keep loader boundaries explicit and narrow. +type SetupFacadeModule = typeof import("@openclaw/zalo/setup-api.js"); +type GroupAccessFacadeModule = typeof import("@openclaw/zalo/contract-api.js"); import { createLazyFacadeObjectValue, loadBundledPluginPublicSurfaceModuleSync, } from "./facade-loader.js"; -function loadFacadeModule(): FacadeModule { - return loadBundledPluginPublicSurfaceModuleSync({ +function loadSetupFacadeModule(): SetupFacadeModule { + return loadBundledPluginPublicSurfaceModuleSync({ dirName: "zalo", artifactBasename: "setup-api.js", }); } -export const evaluateZaloGroupAccess: FacadeModule["evaluateZaloGroupAccess"] = ((...args) => - loadFacadeModule()["evaluateZaloGroupAccess"]( - ...args, - )) as FacadeModule["evaluateZaloGroupAccess"]; -export const resolveZaloRuntimeGroupPolicy: FacadeModule["resolveZaloRuntimeGroupPolicy"] = (( +function loadGroupAccessFacadeModule(): GroupAccessFacadeModule { + return loadBundledPluginPublicSurfaceModuleSync({ + dirName: "zalo", + artifactBasename: "contract-api.js", + }); +} + +export const evaluateZaloGroupAccess: GroupAccessFacadeModule["evaluateZaloGroupAccess"] = (( ...args ) => - loadFacadeModule()["resolveZaloRuntimeGroupPolicy"]( + loadGroupAccessFacadeModule()["evaluateZaloGroupAccess"]( ...args, - )) as FacadeModule["resolveZaloRuntimeGroupPolicy"]; -export const zaloSetupAdapter: FacadeModule["zaloSetupAdapter"] = createLazyFacadeObjectValue( - () => loadFacadeModule()["zaloSetupAdapter"] as object, -) as FacadeModule["zaloSetupAdapter"]; -export const zaloSetupWizard: FacadeModule["zaloSetupWizard"] = createLazyFacadeObjectValue( - () => loadFacadeModule()["zaloSetupWizard"] as object, -) as FacadeModule["zaloSetupWizard"]; + )) as GroupAccessFacadeModule["evaluateZaloGroupAccess"]; +export const resolveZaloRuntimeGroupPolicy: GroupAccessFacadeModule["resolveZaloRuntimeGroupPolicy"] = + ((...args) => + loadGroupAccessFacadeModule()["resolveZaloRuntimeGroupPolicy"]( + ...args, + )) as GroupAccessFacadeModule["resolveZaloRuntimeGroupPolicy"]; +export const zaloSetupAdapter: SetupFacadeModule["zaloSetupAdapter"] = createLazyFacadeObjectValue( + () => loadSetupFacadeModule()["zaloSetupAdapter"] as object, +) as SetupFacadeModule["zaloSetupAdapter"]; +export const zaloSetupWizard: SetupFacadeModule["zaloSetupWizard"] = createLazyFacadeObjectValue( + () => loadSetupFacadeModule()["zaloSetupWizard"] as object, +) as SetupFacadeModule["zaloSetupWizard"]; diff --git a/src/plugins/contracts/boundary-invariants.test.ts b/src/plugins/contracts/boundary-invariants.test.ts index 8ff3c55cad8..7508e234ddd 100644 --- a/src/plugins/contracts/boundary-invariants.test.ts +++ b/src/plugins/contracts/boundary-invariants.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it } from "vitest"; const SRC_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const REPO_ROOT = resolve(SRC_ROOT, ".."); +const sourceCache = new Map(); +const tsFilesCache = new Map(); const ALLOWED_BUNDLED_CAPABILITY_METADATA_CONSUMERS = new Set([ "src/media-generation/provider-capabilities.contract.test.ts", @@ -39,6 +41,11 @@ type FileFilter = { }; function listTsFiles(rootRelativePath: string, filter: FileFilter = {}): string[] { + const cacheKey = `${rootRelativePath}:${filter.excludeTests ? "exclude-tests" : ""}:${filter.testOnly ? "test-only" : ""}`; + const cached = tsFilesCache.get(cacheKey); + if (cached) { + return cached; + } const root = resolve(REPO_ROOT, rootRelativePath); const files: string[] = []; @@ -64,7 +71,19 @@ function listTsFiles(rootRelativePath: string, filter: FileFilter = {}): string[ } walk(root); - return files.toSorted(); + const sorted = files.toSorted(); + tsFilesCache.set(cacheKey, sorted); + return sorted; +} + +function readRepoSource(file: string): string { + const cached = sourceCache.get(file); + if (cached !== undefined) { + return cached; + } + const source = readFileSync(resolve(REPO_ROOT, file), "utf8"); + sourceCache.set(file, source); + return source; } describe("plugin contract boundary invariants", () => { @@ -74,8 +93,7 @@ describe("plugin contract boundary invariants", () => { if (ALLOWED_BUNDLED_CAPABILITY_METADATA_CONSUMERS.has(file)) { return false; } - const source = readFileSync(resolve(REPO_ROOT, file), "utf8"); - return source.includes("contracts/inventory/bundled-capability-metadata"); + return readRepoSource(file).includes("contracts/inventory/bundled-capability-metadata"); }); expect(offenders).toEqual([]); }); @@ -83,8 +101,7 @@ describe("plugin contract boundary invariants", () => { it("keeps the bundled contract inventory out of non-test runtime code", () => { const files = listTsFiles("src", { excludeTests: true }); const offenders = files.filter((file) => { - const source = readFileSync(resolve(REPO_ROOT, file), "utf8"); - return source.includes("contracts/inventory/bundled-capability-metadata"); + return readRepoSource(file).includes("contracts/inventory/bundled-capability-metadata"); }); expect(offenders).toEqual([]); }); @@ -95,7 +112,7 @@ describe("plugin contract boundary invariants", () => { if (ALLOWED_EXTENSION_PATH_STRING_TESTS.has(file)) { return false; } - const source = readFileSync(resolve(REPO_ROOT, file), "utf8"); + const source = readRepoSource(file); return ( /from\s+["'][^"']*extensions\/.+(?:api|runtime-api|test-api)\.js["']/u.test(source) || /vi\.(?:mock|doMock)\(\s*["'][^"']*extensions\/.+["']/u.test(source) || @@ -111,8 +128,7 @@ describe("plugin contract boundary invariants", () => { if (ALLOWED_CONTRACT_BUNDLED_PATH_HELPERS.has(file)) { return false; } - const source = readFileSync(resolve(REPO_ROOT, file), "utf8"); - return source.includes("test/helpers/bundled-plugin-paths"); + return readRepoSource(file).includes("test/helpers/bundled-plugin-paths"); }); expect(offenders).toEqual([]); }); @@ -123,8 +139,7 @@ describe("plugin contract boundary invariants", () => { if (ALLOWED_CHANNEL_BUNDLED_METADATA_CONSUMERS.has(file)) { return false; } - const source = readFileSync(resolve(REPO_ROOT, file), "utf8"); - return source.includes("plugins/bundled-plugin-metadata"); + return readRepoSource(file).includes("plugins/bundled-plugin-metadata"); }); expect(offenders).toEqual([]); }); @@ -135,7 +150,7 @@ describe("plugin contract boundary invariants", () => { ...listTsFiles("src/channels", { excludeTests: true }), ].toSorted(); const offenders = files.filter((file) => { - const source = readFileSync(resolve(REPO_ROOT, file), "utf8"); + const source = readRepoSource(file); return /extensions\/\$\{|\.\.\/\.\.\/\.\.\/\.\.\/extensions\//u.test(source); }); expect(offenders).toEqual([]); diff --git a/src/plugins/contracts/bundled-web-search.brave.contract.test.ts b/src/plugins/contracts/bundled-web-search.brave.contract.test.ts deleted file mode 100644 index 7ae67d2c669..00000000000 --- a/src/plugins/contracts/bundled-web-search.brave.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("brave"); diff --git a/src/plugins/contracts/bundled-web-search.duckduckgo.contract.test.ts b/src/plugins/contracts/bundled-web-search.duckduckgo.contract.test.ts deleted file mode 100644 index a8b12f8058f..00000000000 --- a/src/plugins/contracts/bundled-web-search.duckduckgo.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("duckduckgo"); diff --git a/src/plugins/contracts/bundled-web-search.exa.contract.test.ts b/src/plugins/contracts/bundled-web-search.exa.contract.test.ts deleted file mode 100644 index 59744936d96..00000000000 --- a/src/plugins/contracts/bundled-web-search.exa.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("exa"); diff --git a/src/plugins/contracts/bundled-web-search.firecrawl.contract.test.ts b/src/plugins/contracts/bundled-web-search.firecrawl.contract.test.ts deleted file mode 100644 index 514c469a768..00000000000 --- a/src/plugins/contracts/bundled-web-search.firecrawl.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("firecrawl"); diff --git a/src/plugins/contracts/bundled-web-search.google.contract.test.ts b/src/plugins/contracts/bundled-web-search.google.contract.test.ts deleted file mode 100644 index d2b4e0fd2a2..00000000000 --- a/src/plugins/contracts/bundled-web-search.google.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("google"); diff --git a/src/plugins/contracts/bundled-web-search.minimax.contract.test.ts b/src/plugins/contracts/bundled-web-search.minimax.contract.test.ts deleted file mode 100644 index f4b5fcd81a2..00000000000 --- a/src/plugins/contracts/bundled-web-search.minimax.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("minimax"); diff --git a/src/plugins/contracts/bundled-web-search.moonshot.contract.test.ts b/src/plugins/contracts/bundled-web-search.moonshot.contract.test.ts deleted file mode 100644 index e5ede65aa65..00000000000 --- a/src/plugins/contracts/bundled-web-search.moonshot.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("moonshot"); diff --git a/src/plugins/contracts/bundled-web-search.perplexity.contract.test.ts b/src/plugins/contracts/bundled-web-search.perplexity.contract.test.ts deleted file mode 100644 index 127315ec5da..00000000000 --- a/src/plugins/contracts/bundled-web-search.perplexity.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("perplexity"); diff --git a/src/plugins/contracts/bundled-web-search.searxng.contract.test.ts b/src/plugins/contracts/bundled-web-search.searxng.contract.test.ts deleted file mode 100644 index d26351d5e62..00000000000 --- a/src/plugins/contracts/bundled-web-search.searxng.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("searxng"); diff --git a/src/plugins/contracts/bundled-web-search.tavily.contract.test.ts b/src/plugins/contracts/bundled-web-search.tavily.contract.test.ts deleted file mode 100644 index d642a631be5..00000000000 --- a/src/plugins/contracts/bundled-web-search.tavily.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("tavily"); diff --git a/src/plugins/contracts/bundled-web-search.xai.contract.test.ts b/src/plugins/contracts/bundled-web-search.xai.contract.test.ts deleted file mode 100644 index 2528ab62d7d..00000000000 --- a/src/plugins/contracts/bundled-web-search.xai.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeBundledWebSearchFastPathContract } from "../../../test/helpers/plugins/bundled-web-search-fast-path-contract.js"; - -describeBundledWebSearchFastPathContract("xai"); diff --git a/src/plugins/contracts/loader.contract.test.ts b/src/plugins/contracts/loader.contract.test.ts index 85fb44173cc..afc94461d51 100644 --- a/src/plugins/contracts/loader.contract.test.ts +++ b/src/plugins/contracts/loader.contract.test.ts @@ -1,20 +1,13 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { uniqueSortedStrings } from "../../../test/helpers/plugins/contracts-testkit.js"; import { withBundledPluginAllowlistCompat } from "../bundled-compat.js"; -import { - loadPluginManifestRegistry, - resolveManifestContractPluginIds, -} from "../manifest-registry.js"; +import { resolveManifestContractPluginIds } from "../manifest-registry.js"; import { __testing as providerTesting } from "../providers.js"; -import { resolvePluginWebSearchProviders } from "../web-search-providers.runtime.js"; +import { resolveBundledContractSnapshotPluginIds } from "./inventory/bundled-capability-metadata.js"; import { providerContractCompatPluginIds } from "./registry.js"; function resolveBundledManifestProviderPluginIds() { - return uniqueSortedStrings( - loadPluginManifestRegistry({}) - .plugins.filter((plugin) => plugin.origin === "bundled" && plugin.providers.length > 0) - .map((plugin) => plugin.id), - ); + return uniqueSortedStrings(resolveBundledContractSnapshotPluginIds("providerIds")); } function expectPluginAllowlistContains( @@ -68,7 +61,7 @@ describe("plugin loader contract", () => { env: { VITEST: "1" } as NodeJS.ProcessEnv, }); webSearchPluginIds = uniqueSortedStrings( - resolvePluginWebSearchProviders({ origin: "bundled" }).map((entry) => entry.pluginId), + resolveBundledContractSnapshotPluginIds("webSearchProviderIds"), ); bundledWebSearchPluginIds = uniqueSortedStrings( resolveManifestContractPluginIds({ diff --git a/src/plugins/contracts/plugin-entry-guardrails.test.ts b/src/plugins/contracts/plugin-entry-guardrails.test.ts index 9fe86947bba..7dfd455156c 100644 --- a/src/plugins/contracts/plugin-entry-guardrails.test.ts +++ b/src/plugins/contracts/plugin-entry-guardrails.test.ts @@ -1,7 +1,6 @@ import { existsSync, readFileSync } from "node:fs"; import path, { dirname, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import ts from "typescript"; import { describe, expect, it } from "vitest"; import { listBundledPluginMetadata } from "../bundled-plugin-metadata.js"; import { loadPluginManifestRegistry } from "../manifest-registry.js"; @@ -25,6 +24,13 @@ const FORBIDDEN_CONTRACT_MODULE_PATH_PATTERNS = [ /(^|\/)[^/]*\.test(?:[-.][^/]*)?\.[cm]?[jt]s$/u, /(^|\/)[^/]*(?:test-harness|test-plugin|test-helper|test-support|harness)[^/]*\.[cm]?[jt]s$/u, ] as const; +const STATIC_FROM_IMPORT_RE = + /^\s*import(?:\s+type)?\s+(?!["'])([\s\S]*?)\s+from\s*["']([^"']+)["']/gmu; +const STATIC_SIDE_EFFECT_IMPORT_RE = /^\s*import\s*["']([^"']+)["']/gmu; +const RE_EXPORT_STAR_RE = + /^\s*export\s+(?:type\s+)?\*\s*(?:as\s+\w+\s+)?from\s*["']([^"']+)["']/gmu; +const RE_EXPORT_NAMED_RE = /^\s*export\s+(?:type\s+)?\{[^}]*\}\s+from\s*["']([^"']+)["']/gmu; + function listBundledPluginRoots() { return loadPluginManifestRegistry({}) .plugins.filter((plugin) => plugin.origin === "bundled") @@ -60,77 +66,92 @@ function collectProductionContractEntryPaths(): Array<{ entryPath: string; pluginRoot: string; }> { - return listBundledPluginMetadata({ rootDir: REPO_ROOT }).flatMap((plugin) => { - const pluginRoot = resolve(REPO_ROOT, "extensions", plugin.dirName); - const entryPaths = new Set(); - for (const artifact of plugin.publicSurfaceArtifacts ?? []) { - if (!isGuardedContractArtifactBasename(artifact)) { - continue; + return listBundledPluginMetadata({ rootDir: REPO_ROOT, includeChannelConfigs: false }).flatMap( + (plugin) => { + const pluginRoot = resolve(REPO_ROOT, "extensions", plugin.dirName); + const entryPaths = new Set(); + for (const artifact of plugin.publicSurfaceArtifacts ?? []) { + if (!isGuardedContractArtifactBasename(artifact)) { + continue; + } + const sourcePath = resolvePublicSurfaceSourcePath(pluginRoot, artifact); + if (sourcePath) { + entryPaths.add(sourcePath); + } } - const sourcePath = resolvePublicSurfaceSourcePath(pluginRoot, artifact); - if (sourcePath) { - entryPaths.add(sourcePath); - } - } - return [...entryPaths].map((entryPath) => ({ - pluginId: plugin.manifest.id, - entryPath, - pluginRoot, - })); - }); + return [...entryPaths].map((entryPath) => ({ + pluginId: plugin.manifest.id, + entryPath, + pluginRoot, + })); + }, + ); } function formatRepoRelativePath(filePath: string): string { return relative(REPO_ROOT, filePath).replaceAll(path.sep, "/"); } +function stripSourceComments(source: string): string { + return source.replaceAll(/\/\*[\s\S]*?\*\//gu, "").replaceAll(/(^|[^:])\/\/.*$/gmu, "$1"); +} + +function importsDefinePluginEntry(importClause: string | undefined): boolean { + const namedImports = importClause?.match(/\{([\s\S]*)\}/u)?.[1]; + if (!namedImports) { + return false; + } + + return namedImports + .split(",") + .map((part) => part.trim().replace(/^type\s+/u, "")) + .some((part) => part.split(/\s+as\s+/u)[0]?.trim() === "definePluginEntry"); +} + function analyzeSourceModule(params: { filePath: string; source: string }): { specifiers: string[]; relativeSpecifiers: string[]; importsDefinePluginEntryFromCore: boolean; } { - const sourceFile = ts.createSourceFile( - params.filePath, - params.source, - ts.ScriptTarget.Latest, - true, - ); + const source = stripSourceComments(params.source); const specifiers = new Set(); let importsDefinePluginEntryFromCore = false; - for (const statement of sourceFile.statements) { - if (ts.isImportDeclaration(statement)) { - const specifier = ts.isStringLiteral(statement.moduleSpecifier) - ? statement.moduleSpecifier.text - : undefined; - if (specifier) { - specifiers.add(specifier); - } - - if ( - specifier === "openclaw/plugin-sdk/core" && - statement.importClause?.namedBindings && - ts.isNamedImports(statement.importClause.namedBindings) && - statement.importClause.namedBindings.elements.some( - (element) => (element.propertyName?.text ?? element.name.text) === "definePluginEntry", - ) - ) { - importsDefinePluginEntryFromCore = true; - } - + for (const match of source.matchAll(STATIC_FROM_IMPORT_RE)) { + const importClause = match[1]; + const specifier = match[2]; + if (!specifier) { continue; } + specifiers.add(specifier); - if (!ts.isExportDeclaration(statement)) { - continue; - } - - if (statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier)) { - specifiers.add(statement.moduleSpecifier.text); + if (specifier === "openclaw/plugin-sdk/core" && importsDefinePluginEntry(importClause)) { + importsDefinePluginEntryFromCore = true; } } - const nextSpecifiers = [...specifiers]; + for (const match of source.matchAll(STATIC_SIDE_EFFECT_IMPORT_RE)) { + const specifier = match[1]; + if (specifier) { + specifiers.add(specifier); + } + } + + for (const match of source.matchAll(RE_EXPORT_STAR_RE)) { + const specifier = match[1]; + if (specifier) { + specifiers.add(specifier); + } + } + + for (const match of source.matchAll(RE_EXPORT_NAMED_RE)) { + const specifier = match[1]; + if (specifier) { + specifiers.add(specifier); + } + } + + const nextSpecifiers = [...specifiers].toSorted(); return { specifiers: nextSpecifiers, relativeSpecifiers: nextSpecifiers.filter((specifier) => specifier.startsWith(".")), diff --git a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts index a9e0712d6d5..35815290d53 100644 --- a/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-package-contract-guardrails.test.ts @@ -1,12 +1,9 @@ -import { spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; +import { readdirSync, readFileSync } from "node:fs"; import { createRequire } from "node:module"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import * as tar from "tar"; -import { afterEach, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { pluginSdkEntrypoints } from "../../plugin-sdk/entrypoints.js"; -import { cleanupTrackedTempDirs, makeTrackedTempDir } from "../test-helpers/fs-fixtures.js"; const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const REPO_ROOT = resolve(ROOT_DIR, ".."); @@ -15,9 +12,6 @@ const PUBLIC_CONTRACT_REFERENCE_FILES = [ "src/plugins/contracts/plugin-sdk-subpaths.test.ts", ] as const; const PLUGIN_SDK_SUBPATH_PATTERN = /openclaw\/plugin-sdk\/([a-z0-9][a-z0-9-]*)\b/g; -const NPM_PACK_MAX_BUFFER_BYTES = 64 * 1024 * 1024; -const WINDOWS_UNSAFE_CMD_CHARS_RE = /[&|<>^%\r\n]/; -const tempDirs: string[] = []; function collectPluginSdkPackageExports(): string[] { const packageJson = JSON.parse(readFileSync(resolve(REPO_ROOT, "package.json"), "utf8")) as { @@ -87,124 +81,6 @@ function createRootPackageRequire() { return createRequire(pathToFileURL(resolve(REPO_ROOT, "package.json")).href); } -function isNpmExecPath(value: string): boolean { - return /^npm(?:-cli)?(?:\.(?:c?js|cmd|exe))?$/.test( - value.split(/[\\/]/).at(-1)?.toLowerCase() ?? "", - ); -} - -function escapeForCmdExe(arg: string): string { - if (WINDOWS_UNSAFE_CMD_CHARS_RE.test(arg)) { - throw new Error(`unsafe Windows cmd.exe argument detected: ${JSON.stringify(arg)}`); - } - if (!arg.includes(" ") && !arg.includes('"')) { - return arg; - } - return `"${arg.replace(/"/g, '""')}"`; -} - -function buildCmdExeCommandLine(command: string, args: string[]): string { - return [escapeForCmdExe(command), ...args.map(escapeForCmdExe)].join(" "); -} - -type NpmCommandInvocation = { - command: string; - args: string[]; - env?: NodeJS.ProcessEnv; - windowsVerbatimArguments?: boolean; -}; - -function resolveNpmCommandInvocation(npmArgs: string[]): NpmCommandInvocation { - const npmExecPath = process.env.npm_execpath; - if (typeof npmExecPath === "string" && npmExecPath.length > 0 && isNpmExecPath(npmExecPath)) { - return { command: process.execPath, args: [npmExecPath, ...npmArgs] }; - } - - if (process.platform !== "win32") { - return { command: "npm", args: npmArgs }; - } - - const nodeDir = dirname(process.execPath); - const npmCliCandidates = [ - resolve(nodeDir, "../lib/node_modules/npm/bin/npm-cli.js"), - resolve(nodeDir, "node_modules/npm/bin/npm-cli.js"), - ]; - const npmCliPath = npmCliCandidates.find((candidate) => existsSync(candidate)); - if (npmCliPath) { - return { command: process.execPath, args: [npmCliPath, ...npmArgs] }; - } - - const npmExePath = resolve(nodeDir, "npm.exe"); - if (existsSync(npmExePath)) { - return { command: npmExePath, args: npmArgs }; - } - - const npmCmdPath = resolve(nodeDir, "npm.cmd"); - if (existsSync(npmCmdPath)) { - return { - command: process.env.ComSpec ?? "cmd.exe", - args: ["/d", "/s", "/c", buildCmdExeCommandLine(npmCmdPath, npmArgs)], - windowsVerbatimArguments: true, - }; - } - - return { - command: process.env.ComSpec ?? "cmd.exe", - args: ["/d", "/s", "/c", buildCmdExeCommandLine("npm.cmd", npmArgs)], - windowsVerbatimArguments: true, - }; -} - -function packOpenClawToTempDir(packDir: string): string { - const invocation = resolveNpmCommandInvocation([ - "pack", - "--ignore-scripts", - "--json", - "--pack-destination", - packDir, - ]); - const result = spawnSync(invocation.command, invocation.args, { - cwd: REPO_ROOT, - encoding: "utf8", - env: { - ...process.env, - ...invocation.env, - COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", - }, - maxBuffer: NPM_PACK_MAX_BUFFER_BYTES, - stdio: ["ignore", "pipe", "pipe"], - windowsVerbatimArguments: invocation.windowsVerbatimArguments, - }); - if (result.error) { - throw result.error; - } - if (result.status !== 0) { - throw new Error((result.stderr || result.stdout || "npm pack failed").trim()); - } - const raw = result.stdout; - const parsed = JSON.parse(raw) as Array<{ filename?: string }>; - const filename = parsed[0]?.filename?.trim(); - if (!filename) { - throw new Error(`npm pack did not return a filename: ${raw}`); - } - return join(packDir, filename); -} - -async function readPackedRootPackageJson(archivePath: string): Promise<{ - dependencies?: Record; -}> { - const extractDir = makeTrackedTempDir("openclaw-packed-root-package-json", tempDirs); - await tar.x({ - file: archivePath, - cwd: extractDir, - filter: (entryPath) => entryPath === "package/package.json", - strict: true, - }); - return JSON.parse(readFileSync(join(extractDir, "package", "package.json"), "utf8")) as { - dependencies?: Record; - }; -} - function collectExtensionFiles(dir: string): string[] { const entries = readdirSync(dir, { withFileTypes: true }); const files: string[] = []; @@ -259,10 +135,6 @@ function collectExtensionCoreImportLeaks(): Array<{ file: string; specifier: str } describe("plugin-sdk package contract guardrails", () => { - afterEach(() => { - cleanupTrackedTempDirs(tempDirs); - }); - it("keeps package.json exports aligned with built plugin-sdk entrypoints", () => { expect(collectPluginSdkPackageExports()).toEqual([...pluginSdkEntrypoints].toSorted()); }); @@ -291,7 +163,7 @@ describe("plugin-sdk package contract guardrails", () => { expect(failures).toEqual([]); }); - it("mirrors matrix runtime deps needed by the bundled host graph", () => { + it("mirrors package runtime deps needed by bundled host graphs", () => { const rootRuntimeDeps = collectRuntimeDependencySpecs(readRootPackageJson()); const matrixPackageJson = readMatrixPackageJson(); const matrixRuntimeDeps = collectRuntimeDependencySpecs(matrixPackageJson); @@ -304,6 +176,7 @@ describe("plugin-sdk package contract guardrails", () => { ]) { expect(rootRuntimeDeps.get(dep)).toBe(matrixRuntimeDeps.get(dep)); } + expect(rootRuntimeDeps.has("@openclaw/plugin-package-contract")).toBe(false); }); it("resolves matrix crypto WASM from the root runtime surface", () => { @@ -316,20 +189,6 @@ describe("plugin-sdk package contract guardrails", () => { expect(resolvedPath).toContain("@matrix-org/matrix-sdk-crypto-wasm"); }); - it("keeps matrix crypto WASM in the packed artifact manifest", async () => { - const tempRoot = makeTrackedTempDir("openclaw-matrix-wasm-pack", tempDirs); - const packDir = join(tempRoot, "pack"); - mkdirSync(packDir, { recursive: true }); - - const archivePath = packOpenClawToTempDir(packDir); - const packedPackageJson = await readPackedRootPackageJson(archivePath); - const matrixPackageJson = readMatrixPackageJson(); - expect(packedPackageJson.dependencies?.["@matrix-org/matrix-sdk-crypto-wasm"]).toBe( - matrixPackageJson.dependencies?.["@matrix-org/matrix-sdk-crypto-wasm"], - ); - expect(packedPackageJson.dependencies?.["@openclaw/plugin-package-contract"]).toBeUndefined(); - }); - it("keeps extension sources on public sdk or local package seams", () => { expect(collectExtensionCoreImportLeaks()).toEqual([]); }); diff --git a/src/plugins/contracts/plugin-sdk-subpaths.test.ts b/src/plugins/contracts/plugin-sdk-subpaths.test.ts index 185cdccefbf..a8d4dafec53 100644 --- a/src/plugins/contracts/plugin-sdk-subpaths.test.ts +++ b/src/plugins/contracts/plugin-sdk-subpaths.test.ts @@ -49,6 +49,7 @@ const SRC_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const REPO_ROOT = resolve(SRC_ROOT, ".."); const PLUGIN_SDK_DIR = resolve(SRC_ROOT, "plugin-sdk"); const sourceCache = new Map(); +const repoTsFilesCache = new Map(); const representativeRuntimeSmokeSubpaths = ["channel-runtime", "conversation-runtime"] as const; const importResolvedPluginSdkSubpath = async (specifier: string) => import(specifier); @@ -226,8 +227,12 @@ function expectNamedExportParity(params: BrowserHelperExportParityContract) { } function listRepoTsFiles(dir: string): string[] { + const cached = repoTsFilesCache.get(dir); + if (cached) { + return cached; + } const entries = readdirSync(dir, { withFileTypes: true }); - return entries.flatMap((entry) => { + const files = entries.flatMap((entry) => { const absolute = resolve(dir, entry.name); if (entry.isDirectory()) { if (entry.name === "dist" || entry.name === "node_modules") { @@ -240,6 +245,8 @@ function listRepoTsFiles(dir: string): string[] { } return absolute.endsWith(".ts") ? [absolute] : []; }); + repoTsFilesCache.set(dir, files); + return files; } function findRepoFilesContaining(params: { @@ -253,7 +260,7 @@ function findRepoFilesContaining(params: { .flatMap((root) => listRepoTsFiles(root)) .filter((file) => !excluded.has(file)) .filter((file) => !(params.excludeFilesMatching ?? []).some((pattern) => pattern.test(file))) - .filter((file) => params.pattern.test(readFileSync(file, "utf8"))) + .filter((file) => params.pattern.test(readCachedSource(file))) .map((file) => file.slice(REPO_ROOT.length + 1)) .toSorted(); } diff --git a/src/plugins/contracts/provider-vitest-registry.ts b/src/plugins/contracts/provider-vitest-registry.ts new file mode 100644 index 00000000000..75e48540785 --- /dev/null +++ b/src/plugins/contracts/provider-vitest-registry.ts @@ -0,0 +1,27 @@ +import { loadBundledPluginApiSync } from "../../test-utils/bundled-plugin-public-surface.js"; +import type { ProviderPlugin } from "../types.js"; + +export type ProviderContractEntry = { + pluginId: string; + provider: ProviderPlugin; +}; + +let providerContractRegistryCache: ProviderContractEntry[] | null = null; + +type AnthropicApiSurface = typeof import("../../../extensions/anthropic/api.js"); +type GoogleApiSurface = typeof import("../../../extensions/google/api.js"); +type OpenAIApiSurface = typeof import("../../../extensions/openai/api.js"); + +export function loadVitestProviderContractRegistry(): ProviderContractEntry[] { + const anthropicApi = loadBundledPluginApiSync("anthropic"); + const googleApi = loadBundledPluginApiSync("google"); + const openAIApi = loadBundledPluginApiSync("openai"); + providerContractRegistryCache ??= [ + { pluginId: "anthropic", provider: anthropicApi.buildAnthropicProvider() }, + { pluginId: "google", provider: googleApi.buildGoogleProvider() }, + { pluginId: "google", provider: googleApi.buildGoogleGeminiCliProvider() }, + { pluginId: "openai", provider: openAIApi.buildOpenAIProvider() }, + { pluginId: "openai", provider: openAIApi.buildOpenAICodexProviderPlugin() }, + ]; + return providerContractRegistryCache; +} diff --git a/src/plugins/contracts/provider.anthropic.contract.test.ts b/src/plugins/contracts/provider.anthropic.contract.test.ts deleted file mode 100644 index b861f241857..00000000000 --- a/src/plugins/contracts/provider.anthropic.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; - -describeProviderContracts("anthropic"); diff --git a/src/plugins/contracts/provider.fal.contract.test.ts b/src/plugins/contracts/provider.fal.contract.test.ts deleted file mode 100644 index bdcbeabdb5f..00000000000 --- a/src/plugins/contracts/provider.fal.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; - -describeProviderContracts("fal"); diff --git a/src/plugins/contracts/provider.google.contract.test.ts b/src/plugins/contracts/provider.google.contract.test.ts deleted file mode 100644 index 1558c6e796f..00000000000 --- a/src/plugins/contracts/provider.google.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; - -describeProviderContracts("google"); diff --git a/src/plugins/contracts/provider.minimax.contract.test.ts b/src/plugins/contracts/provider.minimax.contract.test.ts deleted file mode 100644 index cd1891114e4..00000000000 --- a/src/plugins/contracts/provider.minimax.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; - -describeProviderContracts("minimax"); diff --git a/src/plugins/contracts/provider.moonshot.contract.test.ts b/src/plugins/contracts/provider.moonshot.contract.test.ts deleted file mode 100644 index eb130db17c5..00000000000 --- a/src/plugins/contracts/provider.moonshot.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; - -describeProviderContracts("moonshot"); diff --git a/src/plugins/contracts/provider.openai.contract.test.ts b/src/plugins/contracts/provider.openai.contract.test.ts deleted file mode 100644 index d157654814a..00000000000 --- a/src/plugins/contracts/provider.openai.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; - -describeProviderContracts("openai"); diff --git a/src/plugins/contracts/provider.openrouter.contract.test.ts b/src/plugins/contracts/provider.openrouter.contract.test.ts deleted file mode 100644 index e65a4e14b78..00000000000 --- a/src/plugins/contracts/provider.openrouter.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; - -describeProviderContracts("openrouter"); diff --git a/src/plugins/contracts/provider.xai.contract.test.ts b/src/plugins/contracts/provider.xai.contract.test.ts deleted file mode 100644 index 837bcd3e4eb..00000000000 --- a/src/plugins/contracts/provider.xai.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; - -describeProviderContracts("xai"); diff --git a/src/plugins/contracts/providers.contract.test.ts b/src/plugins/contracts/providers.contract.test.ts new file mode 100644 index 00000000000..a8abe94381c --- /dev/null +++ b/src/plugins/contracts/providers.contract.test.ts @@ -0,0 +1,29 @@ +import { describeProviderContracts } from "../../../test/helpers/plugins/provider-contract.js"; +import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; + +for (const providerId of [ + "anthropic", + "fal", + "google", + "minimax", + "moonshot", + "openai", + "openrouter", + "xai", +] as const) { + describeProviderContracts(providerId); +} + +for (const providerId of [ + "brave", + "duckduckgo", + "exa", + "firecrawl", + "google", + "moonshot", + "perplexity", + "tavily", + "xai", +] as const) { + describeWebSearchProviderContracts(providerId); +} diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index b270955162e..1e3da039060 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -5,21 +5,11 @@ import { resolveManifestContractPluginIds, } from "../manifest-registry.js"; import { - imageGenerationProviderContractRegistry, - mediaUnderstandingProviderContractRegistry, pluginRegistrationContractRegistry, providerContractLoadError, providerContractPluginIds, - realtimeTranscriptionProviderContractRegistry, - realtimeVoiceProviderContractRegistry, - resolveWebFetchProviderContractEntriesForPluginId, - resolveWebSearchProviderContractEntriesForPluginId, - speechProviderContractRegistry, - webFetchProviderContractRegistry, } from "./registry.js"; -const REGISTRY_CONTRACT_TIMEOUT_MS = 300_000; - describe("plugin contract registry", () => { function expectUniqueIds(ids: readonly string[]) { expect(ids).toEqual([...new Set(ids)]); @@ -79,31 +69,33 @@ describe("plugin contract registry", () => { }, { name: "does not duplicate bundled media provider ids", - ids: () => mediaUnderstandingProviderContractRegistry.map((entry) => entry.provider.id), + ids: () => + pluginRegistrationContractRegistry.flatMap((entry) => entry.mediaUnderstandingProviderIds), }, { name: "does not duplicate bundled realtime transcription provider ids", - ids: () => realtimeTranscriptionProviderContractRegistry.map((entry) => entry.provider.id), + ids: () => + pluginRegistrationContractRegistry.flatMap( + (entry) => entry.realtimeTranscriptionProviderIds, + ), }, { name: "does not duplicate bundled realtime voice provider ids", - ids: () => realtimeVoiceProviderContractRegistry.map((entry) => entry.provider.id), + ids: () => + pluginRegistrationContractRegistry.flatMap((entry) => entry.realtimeVoiceProviderIds), }, { name: "does not duplicate bundled image-generation provider ids", - ids: () => imageGenerationProviderContractRegistry.map((entry) => entry.provider.id), + ids: () => + pluginRegistrationContractRegistry.flatMap((entry) => entry.imageGenerationProviderIds), }, ] as const)("$name", ({ ids }) => { expectUniqueIds(ids()); }); - it( - "does not duplicate bundled speech provider ids", - { timeout: REGISTRY_CONTRACT_TIMEOUT_MS }, - () => { - expectUniqueIds(speechProviderContractRegistry.map((entry) => entry.provider.id)); - }, - ); + it("does not duplicate bundled speech provider ids", () => { + expectUniqueIds(pluginRegistrationContractRegistry.flatMap((entry) => entry.speechProviderIds)); + }); it("covers every bundled provider plugin discovered from manifests", () => { expectRegistryPluginIds({ @@ -114,7 +106,9 @@ describe("plugin contract registry", () => { it("covers every bundled speech plugin discovered from manifests", () => { expectRegistryPluginIds({ - actualPluginIds: speechProviderContractRegistry.map((entry) => entry.pluginId), + actualPluginIds: pluginRegistrationContractRegistry + .filter((entry) => entry.speechProviderIds.length > 0) + .map((entry) => entry.pluginId), predicate: (plugin) => plugin.origin === "bundled" && (plugin.contracts?.speechProviders?.length ?? 0) > 0, }); @@ -122,7 +116,9 @@ describe("plugin contract registry", () => { it("covers every bundled realtime voice plugin discovered from manifests", () => { expectRegistryPluginIds({ - actualPluginIds: realtimeVoiceProviderContractRegistry.map((entry) => entry.pluginId), + actualPluginIds: pluginRegistrationContractRegistry + .filter((entry) => entry.realtimeVoiceProviderIds.length > 0) + .map((entry) => entry.pluginId), predicate: (plugin) => plugin.origin === "bundled" && (plugin.contracts?.realtimeVoiceProviders?.length ?? 0) > 0, }); @@ -130,7 +126,9 @@ describe("plugin contract registry", () => { it("covers every bundled realtime transcription plugin discovered from manifests", () => { expectRegistryPluginIds({ - actualPluginIds: realtimeTranscriptionProviderContractRegistry.map((entry) => entry.pluginId), + actualPluginIds: pluginRegistrationContractRegistry + .filter((entry) => entry.realtimeTranscriptionProviderIds.length > 0) + .map((entry) => entry.pluginId), predicate: (plugin) => plugin.origin === "bundled" && (plugin.contracts?.realtimeTranscriptionProviders?.length ?? 0) > 0, @@ -152,22 +150,6 @@ describe("plugin contract registry", () => { ).toEqual(bundledWebFetchPluginIds); }); - it( - "loads bundled web fetch providers for each shared-resolver plugin", - { timeout: REGISTRY_CONTRACT_TIMEOUT_MS }, - () => { - for (const pluginId of resolveManifestContractPluginIds({ - contract: "webFetchProviders", - origin: "bundled", - })) { - expect(resolveWebFetchProviderContractEntriesForPluginId(pluginId).length).toBeGreaterThan( - 0, - ); - } - expect(webFetchProviderContractRegistry.length).toBeGreaterThan(0); - }, - ); - it("covers every bundled web search plugin from the shared resolver", () => { const bundledWebSearchPluginIds = resolveManifestContractPluginIds({ contract: "webSearchProviders", @@ -182,19 +164,4 @@ describe("plugin contract registry", () => { ), ).toEqual(bundledWebSearchPluginIds); }); - - it( - "loads bundled web search providers for each shared-resolver plugin", - { timeout: REGISTRY_CONTRACT_TIMEOUT_MS }, - () => { - for (const pluginId of resolveManifestContractPluginIds({ - contract: "webSearchProviders", - origin: "bundled", - })) { - expect(resolveWebSearchProviderContractEntriesForPluginId(pluginId).length).toBeGreaterThan( - 0, - ); - } - }, - ); }); diff --git a/src/plugins/contracts/registry.retry.test.ts b/src/plugins/contracts/registry.retry.test.ts index 97e10da95e3..3421aedda94 100644 --- a/src/plugins/contracts/registry.retry.test.ts +++ b/src/plugins/contracts/registry.retry.test.ts @@ -187,6 +187,97 @@ describe("plugin contract registry scoped retries", () => { expect(loadBundledCapabilityRuntimeRegistry).toHaveBeenCalledTimes(1); }); + it("uses provider public artifacts before falling back to the bundled runtime registry", async () => { + const loadBundledCapabilityRuntimeRegistry = vi.fn(() => { + throw new Error("provider contract vitest fast path should not hit bundled runtime registry"); + }); + const loadVitestProviderContractRegistry = vi.fn(() => [ + { + pluginId: "openai", + provider: { + id: "openai", + label: "OpenAI", + docsPath: "/providers/openai", + auth: [ + { + id: "api-key", + label: "API key", + kind: "api_key", + run: async () => ({ profiles: [] }), + }, + ], + } as ProviderPlugin, + }, + { + pluginId: "openai", + provider: { + id: "openai-codex", + label: "OpenAI Codex", + docsPath: "/providers/openai", + auth: [ + { + id: "oauth", + label: "OAuth", + kind: "oauth", + run: async () => ({ profiles: [] }), + }, + ], + } as ProviderPlugin, + }, + ]); + + vi.doMock("../bundled-capability-runtime.js", () => ({ + loadBundledCapabilityRuntimeRegistry, + })); + vi.doMock("./provider-vitest-registry.js", () => ({ + loadVitestProviderContractRegistry, + })); + + const { resolveProviderContractProvidersForPluginIds } = await import("./registry.js"); + + expect( + resolveProviderContractProvidersForPluginIds(["openai"]).map((provider) => provider.id), + ).toEqual(["openai", "openai-codex"]); + expect(loadVitestProviderContractRegistry).toHaveBeenCalledTimes(1); + expect(loadBundledCapabilityRuntimeRegistry).not.toHaveBeenCalled(); + }); + + it("uses web search public artifacts before falling back to the bundled runtime registry", async () => { + const loadBundledCapabilityRuntimeRegistry = vi.fn(() => { + throw new Error( + "web search contract vitest fast path should not hit bundled runtime registry", + ); + }); + const loadVitestWebSearchProviderContractRegistry = vi.fn(() => [ + { + pluginId: "google", + provider: { + id: "gemini", + label: "Gemini", + credentialPath: "plugins.entries.google.config.webSearch.apiKey", + } as WebSearchProviderPlugin, + credentialValue: "AIzaSyDUMMY", + }, + ]); + + vi.doMock("../bundled-capability-runtime.js", () => ({ + loadBundledCapabilityRuntimeRegistry, + })); + vi.doMock("./web-provider-vitest-registry.js", () => ({ + loadVitestWebSearchProviderContractRegistry, + })); + + const { resolveWebSearchProviderContractEntriesForPluginId } = await import("./registry.js"); + + expect( + resolveWebSearchProviderContractEntriesForPluginId("google").map( + (entry) => entry.provider.id, + ), + ).toEqual(["gemini"]); + expect(loadVitestWebSearchProviderContractRegistry).toHaveBeenCalledTimes(1); + expect(loadBundledCapabilityRuntimeRegistry).not.toHaveBeenCalled(); + }); + it("retries web fetch provider loads after a transient plugin-scoped runtime error", async () => { const loadBundledCapabilityRuntimeRegistry = vi .fn() diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 24b706879fc..3a82a78beb3 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -17,6 +17,7 @@ import type { WebSearchProviderPlugin, } from "../types.js"; import { BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS } from "./inventory/bundled-capability-metadata.js"; +import { loadVitestProviderContractRegistry } from "./provider-vitest-registry.js"; import { uniqueStrings } from "./shared.js"; import { loadVitestImageGenerationProviderContractRegistry, @@ -27,6 +28,7 @@ import { loadVitestSpeechProviderContractRegistry, loadVitestVideoGenerationProviderContractRegistry, } from "./speech-vitest-registry.js"; +import { loadVitestWebSearchProviderContractRegistry } from "./web-provider-vitest-registry.js"; type BundledCapabilityRuntimeRegistry = ReturnType; type CapabilityContractEntry = { @@ -314,6 +316,16 @@ function loadProviderContractEntriesForPluginId(pluginId: string): ProviderContr return cached; } + if (process.env.VITEST) { + const vitestEntries = loadVitestProviderContractRegistry().filter( + (entry) => entry.pluginId === pluginId, + ); + if (vitestEntries.length > 0) { + cache.set(pluginId, vitestEntries); + return vitestEntries; + } + } + try { providerContractLoadError = undefined; const entries = loadScopedCapabilityRuntimeRegistryEntries({ @@ -344,13 +356,22 @@ function loadProviderContractRegistry(): ProviderContractEntry[] { if (!providerContractRegistryCache) { try { providerContractLoadError = undefined; - providerContractRegistryCache = loadBundledCapabilityRuntimeRegistry({ - pluginIds: resolveBundledProviderContractPluginIds(), - pluginSdkResolution: "dist", - }).providers.map((entry) => ({ - pluginId: entry.pluginId, - provider: entry.provider, - })); + const vitestEntries = process.env.VITEST ? loadVitestProviderContractRegistry() : []; + const coveredPluginIds = new Set(vitestEntries.map((entry) => entry.pluginId)); + const remainingPluginIds = resolveBundledProviderContractPluginIds().filter( + (pluginId) => !coveredPluginIds.has(pluginId), + ); + const runtimeEntries = + remainingPluginIds.length > 0 + ? loadBundledCapabilityRuntimeRegistry({ + pluginIds: remainingPluginIds, + pluginSdkResolution: "dist", + }).providers.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })) + : []; + providerContractRegistryCache = [...vitestEntries, ...runtimeEntries]; } catch (error) { providerContractLoadError = error instanceof Error ? error : new Error(String(error)); providerContractRegistryCache = []; @@ -454,15 +475,23 @@ export function resolveWebFetchProviderContractEntriesForPluginId( function loadWebSearchProviderContractRegistry(): WebSearchProviderContractEntry[] { if (!webSearchProviderContractRegistryCache) { - const registry = loadBundledCapabilityRuntimeRegistry({ - pluginIds: resolveBundledManifestContractPluginIds("webSearchProviders"), - pluginSdkResolution: "dist", - }); - webSearchProviderContractRegistryCache = registry.webSearchProviders.map((entry) => ({ - pluginId: entry.pluginId, - provider: entry.provider, - credentialValue: resolveWebSearchCredentialValue(entry.provider), - })); + const vitestEntries = process.env.VITEST ? loadVitestWebSearchProviderContractRegistry() : []; + const coveredPluginIds = new Set(vitestEntries.map((entry) => entry.pluginId)); + const remainingPluginIds = resolveBundledManifestContractPluginIds("webSearchProviders").filter( + (pluginId) => !coveredPluginIds.has(pluginId), + ); + const runtimeEntries = + remainingPluginIds.length > 0 + ? loadBundledCapabilityRuntimeRegistry({ + pluginIds: remainingPluginIds, + pluginSdkResolution: "dist", + }).webSearchProviders.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + credentialValue: resolveWebSearchCredentialValue(entry.provider), + })) + : []; + webSearchProviderContractRegistryCache = [...vitestEntries, ...runtimeEntries]; } return webSearchProviderContractRegistryCache; } @@ -483,6 +512,16 @@ export function resolveWebSearchProviderContractEntriesForPluginId( return cached; } + if (process.env.VITEST) { + const vitestEntries = loadVitestWebSearchProviderContractRegistry().filter( + (entry) => entry.pluginId === pluginId, + ); + if (vitestEntries.length > 0) { + cache.set(pluginId, vitestEntries); + return vitestEntries; + } + } + const entries = loadScopedCapabilityRuntimeRegistryEntries({ pluginId, capabilityLabel: "web search provider", diff --git a/src/plugins/contracts/runtime-import-side-effects.contract.test.ts b/src/plugins/contracts/runtime-import-side-effects.contract.test.ts index 6586089e254..85b9f80b0bb 100644 --- a/src/plugins/contracts/runtime-import-side-effects.contract.test.ts +++ b/src/plugins/contracts/runtime-import-side-effects.contract.test.ts @@ -64,13 +64,6 @@ describe("runtime import side-effect contracts", () => { getActivePluginChannelRegistryVersion.mockClear().mockReturnValue(1); }); - it("keeps config/markdown-tables cold on import", async () => { - mockChannelRegistry(); - await import("../../config/markdown-tables.js"); - - expectNoChannelRegistryDuringImport("src/config/markdown-tables.ts"); - }); - it("keeps markdown table defaults lazy and memoized after import", async () => { mockChannelRegistry(); const markdownTables = await import("../../config/markdown-tables.js"); @@ -85,52 +78,26 @@ describe("runtime import side-effect contracts", () => { expect(listChannelPlugins).toHaveBeenCalledTimes(1); }); - it("keeps plugins/runtime/runtime-channel cold on import", async () => { + it("keeps hot runtime imports cold", async () => { mockChannelRegistry(); - await import("../runtime/runtime-channel.js"); - - expectNoChannelRegistryDuringImport("src/plugins/runtime/runtime-channel.ts"); - }); - - it("keeps plugin-sdk/approval-handler-adapter-runtime cold on import", async () => { - mockChannelRegistry(); - await import("../../plugin-sdk/approval-handler-adapter-runtime.js"); - - expectNoChannelRegistryDuringImport("src/plugin-sdk/approval-handler-adapter-runtime.ts"); - }); - - it("keeps plugin-sdk/approval-gateway-runtime cold on import", async () => { - mockChannelRegistry(); - await import("../../plugin-sdk/approval-gateway-runtime.js"); - - expectNoChannelRegistryDuringImport("src/plugin-sdk/approval-gateway-runtime.ts"); - }); - - it("keeps plugins/runtime/runtime-system cold on import", async () => { - mockChannelRegistry(); - await import("../runtime/runtime-system.js"); - - expectNoChannelRegistryDuringImport("src/plugins/runtime/runtime-system.ts"); - }); - - it("keeps web-search/runtime cold on import", async () => { - mockChannelRegistry(); - await import("../../web-search/runtime.js"); - - expectNoChannelRegistryDuringImport("src/web-search/runtime.ts"); - }); - - it("keeps web-fetch/runtime cold on import", async () => { - mockChannelRegistry(); - await import("../../web-fetch/runtime.js"); - - expectNoChannelRegistryDuringImport("src/web-fetch/runtime.ts"); - }); - - it("keeps plugins/runtime/index cold on import", async () => { - mockChannelRegistry(); - await import("../runtime/index.js"); - - expectNoChannelRegistryDuringImport("src/plugins/runtime/index.ts"); + for (const [moduleId, importModule] of [ + ["src/config/markdown-tables.ts", () => import("../../config/markdown-tables.js")], + ["src/plugins/runtime/runtime-channel.ts", () => import("../runtime/runtime-channel.js")], + [ + "src/plugin-sdk/approval-handler-adapter-runtime.ts", + () => import("../../plugin-sdk/approval-handler-adapter-runtime.js"), + ], + [ + "src/plugin-sdk/approval-gateway-runtime.ts", + () => import("../../plugin-sdk/approval-gateway-runtime.js"), + ], + ["src/plugins/runtime/runtime-system.ts", () => import("../runtime/runtime-system.js")], + ["src/web-search/runtime.ts", () => import("../../web-search/runtime.js")], + ["src/web-fetch/runtime.ts", () => import("../../web-fetch/runtime.js")], + ["src/plugins/runtime/index.ts", () => import("../runtime/index.js")], + ] as const) { + await importModule(); + expectNoChannelRegistryDuringImport(moduleId); + } }); }); diff --git a/src/plugins/contracts/shape.contract.test.ts b/src/plugins/contracts/shape.contract.test.ts index 9a1a90cf2c7..f121d0e100c 100644 --- a/src/plugins/contracts/shape.contract.test.ts +++ b/src/plugins/contracts/shape.contract.test.ts @@ -3,7 +3,7 @@ import { createPluginRegistryFixture, registerVirtualTestPlugin, } from "../../../test/helpers/plugins/contracts-testkit.js"; -import { buildAllPluginInspectReports } from "../status.js"; +import { buildPluginShapeSummary } from "../inspect-shape.js"; describe("plugin shape compatibility matrix", () => { it("keeps legacy hook-only, plain capability, and hybrid capability shapes explicit", () => { @@ -94,13 +94,14 @@ describe("plugin shape compatibility matrix", () => { }, }); - const inspect = buildAllPluginInspectReports({ - config, - report: { - workspaceDir: "/virtual-workspace", - ...registry.registry, - }, - }); + const report = { + workspaceDir: "/virtual-workspace", + ...registry.registry, + }; + const inspect = report.plugins.map((plugin) => ({ + plugin, + ...buildPluginShapeSummary({ plugin, report }), + })); expect( inspect.map((entry) => ({ diff --git a/src/plugins/contracts/tts.auto-apply.contract.test.ts b/src/plugins/contracts/tts.auto-apply.contract.test.ts deleted file mode 100644 index d9fc7bcde7e..00000000000 --- a/src/plugins/contracts/tts.auto-apply.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeTtsAutoApplyContract } from "../../../test/helpers/plugins/tts-contract-suites.js"; - -describeTtsAutoApplyContract(); diff --git a/src/plugins/contracts/tts.config.contract.test.ts b/src/plugins/contracts/tts.config.contract.test.ts deleted file mode 100644 index 8c041f4d4e7..00000000000 --- a/src/plugins/contracts/tts.config.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeTtsConfigContract } from "../../../test/helpers/plugins/tts-contract-suites.js"; - -describeTtsConfigContract(); diff --git a/src/plugins/contracts/tts.contract.test.ts b/src/plugins/contracts/tts.contract.test.ts new file mode 100644 index 00000000000..d0900acfb3b --- /dev/null +++ b/src/plugins/contracts/tts.contract.test.ts @@ -0,0 +1,11 @@ +import { + describeTtsAutoApplyContract, + describeTtsConfigContract, + describeTtsProviderRuntimeContract, + describeTtsSummarizationContract, +} from "../../../test/helpers/plugins/tts-contract-suites.js"; + +describeTtsAutoApplyContract(); +describeTtsConfigContract(); +describeTtsProviderRuntimeContract(); +describeTtsSummarizationContract(); diff --git a/src/plugins/contracts/tts.provider-runtime.contract.test.ts b/src/plugins/contracts/tts.provider-runtime.contract.test.ts deleted file mode 100644 index 2eabcd3635d..00000000000 --- a/src/plugins/contracts/tts.provider-runtime.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeTtsProviderRuntimeContract } from "../../../test/helpers/plugins/tts-contract-suites.js"; - -describeTtsProviderRuntimeContract(); diff --git a/src/plugins/contracts/tts.summarization.contract.test.ts b/src/plugins/contracts/tts.summarization.contract.test.ts deleted file mode 100644 index d2bcb4897c2..00000000000 --- a/src/plugins/contracts/tts.summarization.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeTtsSummarizationContract } from "../../../test/helpers/plugins/tts-contract-suites.js"; - -describeTtsSummarizationContract(); diff --git a/src/plugins/contracts/web-provider-vitest-registry.ts b/src/plugins/contracts/web-provider-vitest-registry.ts new file mode 100644 index 00000000000..501b62eb12a --- /dev/null +++ b/src/plugins/contracts/web-provider-vitest-registry.ts @@ -0,0 +1,29 @@ +import { loadBundledPluginPublicSurfaceSync } from "../../test-utils/bundled-plugin-public-surface.js"; +import type { WebSearchProviderPlugin } from "../types.js"; + +export type WebSearchProviderContractEntry = { + pluginId: string; + provider: WebSearchProviderPlugin; + credentialValue: unknown; +}; + +let webSearchProviderContractRegistryCache: WebSearchProviderContractEntry[] | null = null; + +type GoogleWebSearchContractApiSurface = + typeof import("../../../extensions/google/web-search-contract-api.js"); + +export function loadVitestWebSearchProviderContractRegistry(): WebSearchProviderContractEntry[] { + const googleWebSearchContractApi = + loadBundledPluginPublicSurfaceSync({ + pluginId: "google", + artifactBasename: "web-search-contract-api.js", + }); + webSearchProviderContractRegistryCache ??= [ + { + pluginId: "google", + provider: googleWebSearchContractApi.createGeminiWebSearchProvider(), + credentialValue: "AIzaSyDUMMY", + }, + ]; + return webSearchProviderContractRegistryCache; +} diff --git a/src/plugins/contracts/web-search-provider.brave.contract.test.ts b/src/plugins/contracts/web-search-provider.brave.contract.test.ts deleted file mode 100644 index 290c236b400..00000000000 --- a/src/plugins/contracts/web-search-provider.brave.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeWebSearchProviderContracts("brave"); diff --git a/src/plugins/contracts/web-search-provider.duckduckgo.contract.test.ts b/src/plugins/contracts/web-search-provider.duckduckgo.contract.test.ts deleted file mode 100644 index 8ec728d434e..00000000000 --- a/src/plugins/contracts/web-search-provider.duckduckgo.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeWebSearchProviderContracts("duckduckgo"); diff --git a/src/plugins/contracts/web-search-provider.exa.contract.test.ts b/src/plugins/contracts/web-search-provider.exa.contract.test.ts deleted file mode 100644 index 842ac660ed2..00000000000 --- a/src/plugins/contracts/web-search-provider.exa.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeWebSearchProviderContracts("exa"); diff --git a/src/plugins/contracts/web-search-provider.firecrawl.contract.test.ts b/src/plugins/contracts/web-search-provider.firecrawl.contract.test.ts deleted file mode 100644 index 6a9bea4f145..00000000000 --- a/src/plugins/contracts/web-search-provider.firecrawl.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeWebSearchProviderContracts("firecrawl"); diff --git a/src/plugins/contracts/web-search-provider.google.contract.test.ts b/src/plugins/contracts/web-search-provider.google.contract.test.ts deleted file mode 100644 index d3fffaadb3f..00000000000 --- a/src/plugins/contracts/web-search-provider.google.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeWebSearchProviderContracts("google"); diff --git a/src/plugins/contracts/web-search-provider.moonshot.contract.test.ts b/src/plugins/contracts/web-search-provider.moonshot.contract.test.ts deleted file mode 100644 index 8ae96350c2f..00000000000 --- a/src/plugins/contracts/web-search-provider.moonshot.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeWebSearchProviderContracts("moonshot"); diff --git a/src/plugins/contracts/web-search-provider.perplexity.contract.test.ts b/src/plugins/contracts/web-search-provider.perplexity.contract.test.ts deleted file mode 100644 index 704c1363e36..00000000000 --- a/src/plugins/contracts/web-search-provider.perplexity.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeWebSearchProviderContracts("perplexity"); diff --git a/src/plugins/contracts/web-search-provider.tavily.contract.test.ts b/src/plugins/contracts/web-search-provider.tavily.contract.test.ts deleted file mode 100644 index 83f9febb07e..00000000000 --- a/src/plugins/contracts/web-search-provider.tavily.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeWebSearchProviderContracts("tavily"); diff --git a/src/plugins/contracts/web-search-provider.xai.contract.test.ts b/src/plugins/contracts/web-search-provider.xai.contract.test.ts deleted file mode 100644 index ef50d9f4bac..00000000000 --- a/src/plugins/contracts/web-search-provider.xai.contract.test.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { describeWebSearchProviderContracts } from "../../../test/helpers/plugins/web-search-provider-contract.js"; - -describeWebSearchProviderContracts("xai"); diff --git a/src/plugins/inspect-shape.ts b/src/plugins/inspect-shape.ts new file mode 100644 index 00000000000..417b00b11ef --- /dev/null +++ b/src/plugins/inspect-shape.ts @@ -0,0 +1,127 @@ +import type { PluginRegistry } from "./registry.js"; +import { hasKind } from "./slots.js"; + +export type PluginCapabilityKind = + | "cli-backend" + | "text-inference" + | "speech" + | "realtime-transcription" + | "realtime-voice" + | "media-understanding" + | "image-generation" + | "web-search" + | "agent-harness" + | "context-engine" + | "channel"; + +export type PluginInspectShape = + | "hook-only" + | "plain-capability" + | "hybrid-capability" + | "non-capability"; + +export type PluginCapabilityEntry = { + kind: PluginCapabilityKind; + ids: string[]; +}; + +export type PluginShapeSummary = { + shape: PluginInspectShape; + capabilityMode: "none" | "plain" | "hybrid"; + capabilityCount: number; + capabilities: PluginCapabilityEntry[]; + usesLegacyBeforeAgentStart: boolean; +}; + +export function buildPluginCapabilityEntries( + plugin: PluginRegistry["plugins"][number], +): PluginCapabilityEntry[] { + return [ + { kind: "cli-backend" as const, ids: plugin.cliBackendIds ?? [] }, + { kind: "text-inference" as const, ids: plugin.providerIds }, + { kind: "speech" as const, ids: plugin.speechProviderIds }, + { kind: "realtime-transcription" as const, ids: plugin.realtimeTranscriptionProviderIds }, + { kind: "realtime-voice" as const, ids: plugin.realtimeVoiceProviderIds }, + { kind: "media-understanding" as const, ids: plugin.mediaUnderstandingProviderIds }, + { kind: "image-generation" as const, ids: plugin.imageGenerationProviderIds }, + { kind: "web-search" as const, ids: plugin.webSearchProviderIds }, + { kind: "agent-harness" as const, ids: plugin.agentHarnessIds }, + { + kind: "context-engine" as const, + ids: + plugin.status === "loaded" && hasKind(plugin.kind, "context-engine") + ? (plugin.contextEngineIds ?? []) + : [], + }, + { kind: "channel" as const, ids: plugin.channelIds }, + ].filter((entry) => entry.ids.length > 0); +} + +export function derivePluginInspectShape(params: { + capabilityCount: number; + typedHookCount: number; + customHookCount: number; + toolCount: number; + commandCount: number; + cliCount: number; + serviceCount: number; + gatewayMethodCount: number; + httpRouteCount: number; +}): PluginInspectShape { + if (params.capabilityCount > 1) { + return "hybrid-capability"; + } + if (params.capabilityCount === 1) { + return "plain-capability"; + } + const hasOnlyHooks = + params.typedHookCount + params.customHookCount > 0 && + params.toolCount === 0 && + params.commandCount === 0 && + params.cliCount === 0 && + params.serviceCount === 0 && + params.gatewayMethodCount === 0 && + params.httpRouteCount === 0; + if (hasOnlyHooks) { + return "hook-only"; + } + return "non-capability"; +} + +export function buildPluginShapeSummary(params: { + plugin: PluginRegistry["plugins"][number]; + report: Pick; +}): PluginShapeSummary { + const capabilities = buildPluginCapabilityEntries(params.plugin); + const typedHookCount = params.report.typedHooks.filter( + (entry) => entry.pluginId === params.plugin.id, + ).length; + const customHookCount = params.report.hooks.filter( + (entry) => entry.pluginId === params.plugin.id, + ).length; + const toolCount = params.report.tools.filter( + (entry) => entry.pluginId === params.plugin.id, + ).length; + const capabilityCount = capabilities.length; + const shape = derivePluginInspectShape({ + capabilityCount, + typedHookCount, + customHookCount, + toolCount, + commandCount: params.plugin.commands.length, + cliCount: params.plugin.cliCommands.length, + serviceCount: params.plugin.services.length, + gatewayMethodCount: params.plugin.gatewayMethods.length, + httpRouteCount: params.plugin.httpRoutes, + }); + + return { + shape, + capabilityMode: capabilityCount === 0 ? "none" : capabilityCount === 1 ? "plain" : "hybrid", + capabilityCount, + capabilities, + usesLegacyBeforeAgentStart: params.report.typedHooks.some( + (entry) => entry.pluginId === params.plugin.id && entry.hookName === "before_agent_start", + ), + }; +} diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index e7a0b8c9407..f8117aa5cfc 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -809,9 +809,15 @@ export type OpenClawPackageStartup = { deferConfiguredChannelFullLoadUntilAfterListen?: boolean; }; +export type OpenClawPackageSetupFeatures = { + legacyStateMigrations?: boolean; + legacySessionSurfaces?: boolean; +}; + export type OpenClawPackageManifest = { extensions?: string[]; setupEntry?: string; + setupFeatures?: OpenClawPackageSetupFeatures; channel?: PluginPackageChannel; install?: PluginPackageInstall; startup?: OpenClawPackageStartup; diff --git a/src/plugins/setup-registry.ts b/src/plugins/setup-registry.ts index 4243fff7931..e9708b4303e 100644 --- a/src/plugins/setup-registry.ts +++ b/src/plugins/setup-registry.ts @@ -689,6 +689,7 @@ export function resolvePluginSetupAutoEnableReasons(params: { config: OpenClawConfig; workspaceDir?: string; env?: NodeJS.ProcessEnv; + pluginIds?: readonly string[]; }): SetupAutoEnableReason[] { const env = params.env ?? process.env; const reasons: SetupAutoEnableReason[] = []; @@ -697,6 +698,7 @@ export function resolvePluginSetupAutoEnableReasons(params: { for (const entry of resolvePluginSetupRegistry({ workspaceDir: params.workspaceDir, env, + pluginIds: params.pluginIds, }).autoEnableProbes) { const raw = entry.probe({ config: params.config, diff --git a/src/plugins/stage-bundled-plugin-runtime.test.ts b/src/plugins/stage-bundled-plugin-runtime.test.ts index 6cfb034f3b9..ff6c7aa30ee 100644 --- a/src/plugins/stage-bundled-plugin-runtime.test.ts +++ b/src/plugins/stage-bundled-plugin-runtime.test.ts @@ -80,6 +80,7 @@ describe("stageBundledPluginRuntime", () => { const repoRoot = makeRepoRoot("openclaw-stage-bundled-runtime-"); const distPluginDir = createDistPluginDir(repoRoot, "diffs"); fs.mkdirSync(path.join(repoRoot, "dist"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, "dist", "plugin-sdk"), { recursive: true }); fs.mkdirSync(path.join(distPluginDir, "node_modules", "@pierre", "diffs"), { recursive: true, }); @@ -102,6 +103,31 @@ describe("stageBundledPluginRuntime", () => { fs.realpathSync(path.join(distPluginDir, "node_modules")), ); expect(fs.existsSync(path.join(distPluginDir, "node_modules"))).toBe(true); + expect( + fs + .lstatSync( + path.join(repoRoot, "dist", "extensions", "node_modules", "openclaw", "plugin-sdk"), + ) + .isSymbolicLink(), + ).toBe(true); + expect( + fs.readFileSync( + path.join(repoRoot, "dist", "extensions", "node_modules", "openclaw", "package.json"), + "utf8", + ), + ).toContain('"./plugin-sdk": "./plugin-sdk/index.js"'); + expect( + fs.readFileSync( + path.join(repoRoot, "dist", "extensions", "node_modules", "openclaw", "package.json"), + "utf8", + ), + ).toContain('"./plugin-sdk/*": "./plugin-sdk/*.js"'); + expect( + fs.realpathSync( + path.join(repoRoot, "dist", "extensions", "node_modules", "openclaw", "plugin-sdk"), + ), + ).toBe(fs.realpathSync(path.join(repoRoot, "dist", "plugin-sdk"))); + expect(fs.existsSync(path.join(runtimePluginDir, "node_modules", "openclaw"))).toBe(false); }); it("writes wrappers that forward plugin entry imports into canonical dist files", async () => { diff --git a/src/plugins/status.ts b/src/plugins/status.ts index e8fee4010ef..0b7c4efe511 100644 --- a/src/plugins/status.ts +++ b/src/plugins/status.ts @@ -11,6 +11,11 @@ import { withBundledPluginEnablementCompat, } from "./bundled-compat.js"; import { normalizePluginsConfig } from "./config-state.js"; +import { + buildPluginShapeSummary, + type PluginCapabilityEntry, + type PluginInspectShape, +} from "./inspect-shape.js"; import { loadOpenClawPlugins } from "./loader.js"; import type { PluginDiagnostic } from "./manifest-types.js"; import { resolveBundledProviderCompatPluginIds } from "./providers.js"; @@ -21,31 +26,13 @@ import { resolvePluginRuntimeLoadContext, } from "./runtime/load-context.js"; import { loadPluginMetadataRegistrySnapshot } from "./runtime/metadata-registry-loader.js"; -import { hasKind } from "./slots.js"; import type { PluginHookName } from "./types.js"; export type PluginStatusReport = PluginRegistry & { workspaceDir?: string; }; -export type PluginCapabilityKind = - | "cli-backend" - | "text-inference" - | "speech" - | "realtime-transcription" - | "realtime-voice" - | "media-understanding" - | "image-generation" - | "web-search" - | "agent-harness" - | "context-engine" - | "channel"; - -export type PluginInspectShape = - | "hook-only" - | "plain-capability" - | "hybrid-capability" - | "non-capability"; +export type { PluginCapabilityKind, PluginInspectShape } from "./inspect-shape.js"; export type PluginCompatibilityNotice = { pluginId: string; @@ -65,10 +52,7 @@ export type PluginInspectReport = { shape: PluginInspectShape; capabilityMode: "none" | "plain" | "hybrid"; capabilityCount: number; - capabilities: Array<{ - kind: PluginCapabilityKind; - ids: string[]; - }>; + capabilities: PluginCapabilityEntry[]; typedHooks: Array<{ name: PluginHookName; priority?: number; @@ -240,59 +224,6 @@ export function buildPluginDiagnosticsReport(params?: PluginReportParams): Plugi return buildPluginReport(params, true); } -function buildCapabilityEntries(plugin: PluginRegistry["plugins"][number]) { - return [ - { kind: "cli-backend" as const, ids: plugin.cliBackendIds ?? [] }, - { kind: "text-inference" as const, ids: plugin.providerIds }, - { kind: "speech" as const, ids: plugin.speechProviderIds }, - { kind: "realtime-transcription" as const, ids: plugin.realtimeTranscriptionProviderIds }, - { kind: "realtime-voice" as const, ids: plugin.realtimeVoiceProviderIds }, - { kind: "media-understanding" as const, ids: plugin.mediaUnderstandingProviderIds }, - { kind: "image-generation" as const, ids: plugin.imageGenerationProviderIds }, - { kind: "web-search" as const, ids: plugin.webSearchProviderIds }, - { kind: "agent-harness" as const, ids: plugin.agentHarnessIds }, - { - kind: "context-engine" as const, - ids: - plugin.status === "loaded" && hasKind(plugin.kind, "context-engine") - ? (plugin.contextEngineIds ?? []) - : [], - }, - { kind: "channel" as const, ids: plugin.channelIds }, - ].filter((entry) => entry.ids.length > 0); -} - -function deriveInspectShape(params: { - capabilityCount: number; - typedHookCount: number; - customHookCount: number; - toolCount: number; - commandCount: number; - cliCount: number; - serviceCount: number; - gatewayMethodCount: number; - httpRouteCount: number; -}): PluginInspectShape { - if (params.capabilityCount > 1) { - return "hybrid-capability"; - } - if (params.capabilityCount === 1) { - return "plain-capability"; - } - const hasOnlyHooks = - params.typedHookCount + params.customHookCount > 0 && - params.toolCount === 0 && - params.commandCount === 0 && - params.cliCount === 0 && - params.serviceCount === 0 && - params.gatewayMethodCount === 0 && - params.httpRouteCount === 0; - if (hasOnlyHooks) { - return "hook-only"; - } - return "non-capability"; -} - export function buildPluginInspectReport(params: { id: string; config?: OpenClawConfig; @@ -318,7 +249,6 @@ export function buildPluginInspectReport(params: { return null; } - const capabilities = buildCapabilityEntries(plugin); const typedHooks = report.typedHooks .filter((entry) => entry.pluginId === plugin.id) .map((entry) => ({ @@ -341,18 +271,8 @@ export function buildPluginInspectReport(params: { })); const diagnostics = report.diagnostics.filter((entry) => entry.pluginId === plugin.id); const policyEntry = normalizePluginsConfig(config.plugins).entries[plugin.id]; - const capabilityCount = capabilities.length; - const shape = deriveInspectShape({ - capabilityCount, - typedHookCount: typedHooks.length, - customHookCount: customHooks.length, - toolCount: tools.length, - commandCount: plugin.commands.length, - cliCount: plugin.cliCommands.length, - serviceCount: plugin.services.length, - gatewayMethodCount: plugin.gatewayMethods.length, - httpRouteCount: plugin.httpRoutes, - }); + const shapeSummary = buildPluginShapeSummary({ plugin, report }); + const shape = shapeSummary.shape; // Populate MCP server info for bundle-format plugins with a known rootDir. let mcpServers: PluginInspectReport["mcpServers"] = []; @@ -394,9 +314,7 @@ export function buildPluginInspectReport(params: { ]; } - const usesLegacyBeforeAgentStart = typedHooks.some( - (entry) => entry.name === "before_agent_start", - ); + const usesLegacyBeforeAgentStart = shapeSummary.usesLegacyBeforeAgentStart; const compatibility = buildCompatibilityNoticesForInspect({ plugin, shape, @@ -406,9 +324,9 @@ export function buildPluginInspectReport(params: { workspaceDir: report.workspaceDir, plugin, shape, - capabilityMode: capabilityCount === 0 ? "none" : capabilityCount === 1 ? "plain" : "hybrid", - capabilityCount, - capabilities, + capabilityMode: shapeSummary.capabilityMode, + capabilityCount: shapeSummary.capabilityCount, + capabilities: shapeSummary.capabilities, typedHooks, customHooks, tools, diff --git a/src/plugins/web-fetch-providers.runtime.test.ts b/src/plugins/web-fetch-providers.runtime.test.ts index 2739d8e025f..de5630f1e7f 100644 --- a/src/plugins/web-fetch-providers.runtime.test.ts +++ b/src/plugins/web-fetch-providers.runtime.test.ts @@ -133,6 +133,18 @@ describe("resolvePluginWebFetchProviders", () => { expect(loadOpenClawPluginsMock).toHaveBeenCalledTimes(1); }); + it("loads manifest-declared web-fetch providers in setup mode without the plugin loader", () => { + const providers = resolvePluginWebFetchProviders({ + config: createFirecrawlAllowConfig(), + mode: "setup", + }); + + expect(providers.map((provider) => `${provider.pluginId}:${provider.id}`)).toEqual([ + "firecrawl:firecrawl", + ]); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); + }); + it("does not force a fresh snapshot load when the same web-provider load is already in flight", () => { const inFlightSpy = vi .spyOn(loaderModule, "isPluginRegistryLoadInFlight") diff --git a/src/plugins/web-fetch-providers.runtime.ts b/src/plugins/web-fetch-providers.runtime.ts index 52744558f25..4048a5bbeb2 100644 --- a/src/plugins/web-fetch-providers.runtime.ts +++ b/src/plugins/web-fetch-providers.runtime.ts @@ -6,6 +6,7 @@ import { resolveBundledWebFetchResolutionConfig, sortWebFetchProviders, } from "./web-fetch-providers.shared.js"; +import { resolveBundledWebFetchProvidersFromPublicArtifacts } from "./web-provider-public-artifacts.js"; import { mapRegistryProviders, resolveManifestDeclaredWebProviderCandidatePluginIds, @@ -71,6 +72,7 @@ export function resolvePluginWebFetchProviders(params: { resolveBundledResolutionConfig: resolveBundledWebFetchResolutionConfig, resolveCandidatePluginIds: resolveWebFetchCandidatePluginIds, mapRegistryProviders: mapRegistryWebFetchProviders, + resolveBundledPublicArtifactProviders: resolveBundledWebFetchProvidersFromPublicArtifacts, }); } diff --git a/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts b/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts index 2e02de9c405..459daefdbec 100644 --- a/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts +++ b/src/plugins/web-provider-public-artifacts.explicit-fast-path.test.ts @@ -14,6 +14,7 @@ vi.mock("./manifest-registry.js", async (importOriginal) => { }; }); +import { resolveBundledExplicitRuntimeWebSearchProvidersFromPublicArtifacts as resolveExplicitRuntimeWebSearchProviders } from "./web-provider-public-artifacts.explicit.js"; import { resolveBundledWebFetchProvidersFromPublicArtifacts, resolveBundledWebSearchProvidersFromPublicArtifacts, @@ -35,6 +36,16 @@ describe("web provider public artifacts explicit fast path", () => { expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled(); }); + it("resolves bundled runtime web search providers by explicit plugin id", () => { + const provider = resolveExplicitRuntimeWebSearchProviders({ + onlyPluginIds: ["google"], + })?.[0]; + + expect(provider?.pluginId).toBe("google"); + expect(provider?.createTool({ config: {} as never })).not.toBeNull(); + expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled(); + }); + it("resolves bundled web fetch providers by explicit plugin id without manifest scans", () => { const provider = resolveBundledWebFetchProvidersFromPublicArtifacts({ bundledAllowlistCompat: true, diff --git a/src/plugins/web-provider-public-artifacts.explicit.ts b/src/plugins/web-provider-public-artifacts.explicit.ts index 60550892da1..c801b87b5d7 100644 --- a/src/plugins/web-provider-public-artifacts.explicit.ts +++ b/src/plugins/web-provider-public-artifacts.explicit.ts @@ -14,6 +14,7 @@ const WEB_SEARCH_ARTIFACT_CANDIDATES = [ "web-search-provider.js", "web-search.js", ] as const; +const WEB_SEARCH_RUNTIME_ARTIFACT_CANDIDATES = ["web-search-provider.js", "web-search.js"] as const; const WEB_FETCH_ARTIFACT_CANDIDATES = [ "web-fetch-contract-api.js", "web-fetch-provider.js", @@ -128,6 +129,28 @@ export function loadBundledWebSearchProviderEntriesFromDir(params: { return providers.map((provider) => ({ ...provider, pluginId: params.pluginId })); } +export function loadBundledRuntimeWebSearchProviderEntriesFromDir(params: { + dirName: string; + pluginId: string; +}): PluginWebSearchProviderEntry[] | null { + const mod = tryLoadBundledPublicArtifactModule({ + dirName: params.dirName, + artifactCandidates: WEB_SEARCH_RUNTIME_ARTIFACT_CANDIDATES, + }); + if (!mod) { + return null; + } + const providers = collectProviderFactories({ + mod, + suffix: "WebSearchProvider", + isProvider: isWebSearchProviderPlugin, + }); + if (providers.length === 0) { + return null; + } + return providers.map((provider) => ({ ...provider, pluginId: params.pluginId })); +} + export function loadBundledWebFetchProviderEntriesFromDir(params: { dirName: string; pluginId: string; @@ -167,6 +190,23 @@ export function resolveBundledExplicitWebSearchProvidersFromPublicArtifacts(para return providers; } +export function resolveBundledExplicitRuntimeWebSearchProvidersFromPublicArtifacts(params: { + onlyPluginIds: readonly string[]; +}): PluginWebSearchProviderEntry[] | null { + const providers: PluginWebSearchProviderEntry[] = []; + for (const pluginId of normalizeExplicitBundledPluginIds(params.onlyPluginIds)) { + const loadedProviders = loadBundledRuntimeWebSearchProviderEntriesFromDir({ + dirName: pluginId, + pluginId, + }); + if (!loadedProviders) { + return null; + } + providers.push(...loadedProviders); + } + return providers; +} + export function resolveBundledExplicitWebFetchProvidersFromPublicArtifacts(params: { onlyPluginIds: readonly string[]; }): PluginWebFetchProviderEntry[] | null { diff --git a/src/plugins/web-provider-public-artifacts.test.ts b/src/plugins/web-provider-public-artifacts.test.ts index fb04f8329bc..f2b349d2f1c 100644 --- a/src/plugins/web-provider-public-artifacts.test.ts +++ b/src/plugins/web-provider-public-artifacts.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vitest"; -import { resolveManifestContractPluginIds } from "./manifest-registry.js"; +import { + resolveManifestContractOwnerPluginId, + resolveManifestContractPluginIds, +} from "./manifest-registry.js"; import { hasBundledWebFetchProviderPublicArtifact, hasBundledWebSearchProviderPublicArtifact, + resolveBundledExplicitWebSearchProvidersFromPublicArtifacts, } from "./web-provider-public-artifacts.explicit.js"; describe("web provider public artifacts", () => { @@ -18,6 +22,28 @@ describe("web provider public artifacts", () => { } }); + it("keeps public web search artifacts mapped to their manifest owner plugin", () => { + const pluginIds = resolveManifestContractPluginIds({ + contract: "webSearchProviders", + origin: "bundled", + }); + + const providers = resolveBundledExplicitWebSearchProvidersFromPublicArtifacts({ + onlyPluginIds: pluginIds, + }); + + expect(providers).not.toBeNull(); + for (const provider of providers ?? []) { + expect( + resolveManifestContractOwnerPluginId({ + contract: "webSearchProviders", + value: provider.id, + origin: "bundled", + }), + ).toBe(provider.pluginId); + } + }); + it("has a public artifact for every bundled web fetch provider declared in manifests", () => { const pluginIds = resolveManifestContractPluginIds({ contract: "webFetchProviders", diff --git a/src/plugins/web-provider-runtime-shared.ts b/src/plugins/web-provider-runtime-shared.ts index 2f108d9dbc0..54fa992b1c6 100644 --- a/src/plugins/web-provider-runtime-shared.ts +++ b/src/plugins/web-provider-runtime-shared.ts @@ -67,6 +67,13 @@ type ResolveWebProviderRuntimeDeps = { registry: PluginRegistry; onlyPluginIds?: readonly string[]; }) => TEntry[]; + resolveBundledPublicArtifactProviders?: (params: { + config?: PluginLoadOptions["config"]; + workspaceDir?: string; + env?: PluginLoadOptions["env"]; + bundledAllowlistCompat?: boolean; + onlyPluginIds?: readonly string[]; + }) => TEntry[] | null; }; export function createWebProviderSnapshotCache(): WebProviderSnapshotCache { @@ -132,6 +139,18 @@ export function resolvePluginWebProviders( if (pluginIds.length === 0) { return []; } + if (params.activate !== true) { + const bundledArtifactProviders = deps.resolveBundledPublicArtifactProviders?.({ + config: params.config, + workspaceDir, + env, + bundledAllowlistCompat: params.bundledAllowlistCompat, + onlyPluginIds: pluginIds, + }); + if (bundledArtifactProviders) { + return bundledArtifactProviders; + } + } const registry = loadOpenClawPlugins( buildPluginRuntimeLoadOptionsFromValues( { diff --git a/src/plugins/web-search-providers.runtime.test.ts b/src/plugins/web-search-providers.runtime.test.ts index f7b35a11e41..228d60c1769 100644 --- a/src/plugins/web-search-providers.runtime.test.ts +++ b/src/plugins/web-search-providers.runtime.test.ts @@ -401,19 +401,7 @@ describe("resolvePluginWebSearchProviders", () => { }); expect(toRuntimeProviderKeys(providers)).toEqual(["brave:brave"]); - expect(loadOpenClawPluginsMock).toHaveBeenCalledWith( - expect.objectContaining({ - onlyPluginIds: ["brave"], - config: expect.objectContaining({ - plugins: expect.objectContaining({ - allow: ["perplexity", "brave"], - entries: { - brave: { enabled: true }, - }, - }), - }), - }), - ); + expect(loadOpenClawPluginsMock).not.toHaveBeenCalled(); }); it("loads plugin web-search providers from the auto-enabled config snapshot", () => { diff --git a/src/plugins/web-search-providers.runtime.ts b/src/plugins/web-search-providers.runtime.ts index 1136b2ffe0b..9738fb0fa04 100644 --- a/src/plugins/web-search-providers.runtime.ts +++ b/src/plugins/web-search-providers.runtime.ts @@ -2,6 +2,7 @@ import { loadOpenClawPlugins } from "./loader.js"; import type { PluginLoadOptions } from "./loader.js"; import { type PluginManifestRecord } from "./manifest-registry.js"; import type { PluginWebSearchProviderEntry } from "./types.js"; +import { resolveBundledWebSearchProvidersFromPublicArtifacts } from "./web-provider-public-artifacts.js"; import { mapRegistryProviders, resolveManifestDeclaredWebProviderCandidatePluginIds, @@ -71,6 +72,7 @@ export function resolvePluginWebSearchProviders(params: { resolveBundledResolutionConfig: resolveBundledWebSearchResolutionConfig, resolveCandidatePluginIds: resolveWebSearchCandidatePluginIds, mapRegistryProviders: mapRegistryWebSearchProviders, + resolveBundledPublicArtifactProviders: resolveBundledWebSearchProvidersFromPublicArtifacts, }); } diff --git a/src/proxy-capture/runtime.test.ts b/src/proxy-capture/runtime.test.ts index 50f2698635e..0388e4f0b53 100644 --- a/src/proxy-capture/runtime.test.ts +++ b/src/proxy-capture/runtime.test.ts @@ -1,8 +1,34 @@ -import { mkdtempSync, rmSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const storeState = vi.hoisted(() => { + const events: Record[] = []; + const store = { + upsertSession: vi.fn(), + endSession: vi.fn(), + recordEvent: vi.fn((event: Record) => { + events.push(event); + }), + }; + return { + events, + store, + closeDebugProxyCaptureStore: vi.fn(), + }; +}); + +vi.mock("./store.sqlite.js", () => ({ + closeDebugProxyCaptureStore: storeState.closeDebugProxyCaptureStore, + getDebugProxyCaptureStore: () => storeState.store, + persistEventPayload: ( + _store: unknown, + payload: { data?: Buffer | string | null; contentType?: string }, + ) => ({ + contentType: payload.contentType, + ...(typeof payload.data === "string" ? { dataText: payload.data } : {}), + }), + safeJsonString: (value: unknown) => (value == null ? undefined : JSON.stringify(value)), +})); + describe("debug proxy runtime", () => { const envKeys = [ "OPENCLAW_DEBUG_PROXY_ENABLED", @@ -13,13 +39,16 @@ describe("debug proxy runtime", () => { ] as const; const savedEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]])); const originalFetch = globalThis.fetch; - let tempDir = ""; beforeEach(() => { - tempDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-proxy-runtime-")); + storeState.events.length = 0; + storeState.store.upsertSession.mockClear(); + storeState.store.endSession.mockClear(); + storeState.store.recordEvent.mockClear(); + storeState.closeDebugProxyCaptureStore.mockClear(); process.env.OPENCLAW_DEBUG_PROXY_ENABLED = "1"; - process.env.OPENCLAW_DEBUG_PROXY_DB_PATH = path.join(tempDir, "capture.sqlite"); - process.env.OPENCLAW_DEBUG_PROXY_BLOB_DIR = path.join(tempDir, "blobs"); + process.env.OPENCLAW_DEBUG_PROXY_DB_PATH = "/tmp/openclaw-proxy-runtime-test.sqlite"; + process.env.OPENCLAW_DEBUG_PROXY_BLOB_DIR = "/tmp/openclaw-proxy-runtime-test-blobs"; process.env.OPENCLAW_DEBUG_PROXY_SESSION_ID = "runtime-test-session"; process.env.OPENCLAW_DEBUG_PROXY_SOURCE_PROCESS = "runtime-test"; }); @@ -34,8 +63,6 @@ describe("debug proxy runtime", () => { process.env[key] = value; } } - rmSync(tempDir, { recursive: true, force: true }); - vi.resetModules(); }); it("captures ambient global fetch calls when debug proxy mode is enabled", async () => { @@ -44,7 +71,6 @@ describe("debug proxy runtime", () => { ) as typeof fetch; const runtime = await import("./runtime.js"); - const storeModule = await import("./store.sqlite.js"); runtime.initializeDebugProxyCapture("test"); await globalThis.fetch("https://api.minimax.io/anthropic/messages", { method: "POST", @@ -54,14 +80,9 @@ describe("debug proxy runtime", () => { await new Promise((resolve) => setTimeout(resolve, 0)); runtime.finalizeDebugProxyCapture(); - const store = storeModule.getDebugProxyCaptureStore( - process.env.OPENCLAW_DEBUG_PROXY_DB_PATH!, - process.env.OPENCLAW_DEBUG_PROXY_BLOB_DIR!, - ); - const events = store.getSessionEvents("runtime-test-session", 20); + const events = storeState.events.filter((event) => event.sessionId === "runtime-test-session"); expect(events.some((event) => event.host === "api.minimax.io")).toBe(true); expect(events.some((event) => event.kind === "request")).toBe(true); expect(events.some((event) => event.kind === "response")).toBe(true); - store.close(); }); }); diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index d04f11f2449..923c5611243 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -1062,29 +1062,40 @@ describe("wildcard peer bindings (peer.id=*)", () => { }); describe("binding evaluation cache scalability", () => { - test("does not rescan full bindings after channel/account cache rollover (#36915)", () => { - const bindingCount = 2_001; + test("does not rescan full bindings across distinct channel/account cache entries (#36915)", () => { + const cacheKeyCount = 64; const cfg: OpenClawConfig = { - bindings: Array.from({ length: bindingCount }, (_, idx) => ({ - agentId: `agent-${idx}`, - match: { - channel: "dingtalk", - accountId: `acct-${idx}`, - peer: { kind: "direct", id: `user-${idx}` }, + bindings: [ + { + agentId: "agent-0", + match: { + channel: "dingtalk", + accountId: "acct-0", + peer: { kind: "direct", id: "user-0" }, + }, }, - })), + ], }; const listBindingsSpy = vi.spyOn(routingBindings, "listBindings"); try { - for (let idx = 0; idx < bindingCount; idx += 1) { + const boundRoute = resolveAgentRoute({ + cfg, + channel: "dingtalk", + accountId: "acct-0", + peer: { kind: "direct", id: "user-0" }, + }); + expect(boundRoute.agentId).toBe("agent-0"); + expect(boundRoute.matchedBy).toBe("binding.peer"); + + for (let idx = 1; idx < cacheKeyCount; idx += 1) { const route = resolveAgentRoute({ cfg, channel: "dingtalk", accountId: `acct-${idx}`, peer: { kind: "direct", id: `user-${idx}` }, }); - expect(route.agentId).toBe(`agent-${idx}`); - expect(route.matchedBy).toBe("binding.peer"); + expect(route.agentId).toBe("main"); + expect(route.matchedBy).toBe("default"); } const repeated = resolveAgentRoute({ @@ -1099,4 +1110,36 @@ describe("binding evaluation cache scalability", () => { listBindingsSpy.mockRestore(); } }); + + test("uses indexed channel/account bindings without per-route scans", () => { + const bindingCount = 101; + const cfg: OpenClawConfig = { + bindings: Array.from({ length: bindingCount }, (_, idx) => ({ + agentId: `agent-${idx}`, + match: { + channel: "dingtalk", + accountId: `acct-${idx}`, + peer: { kind: "direct", id: `user-${idx}` }, + }, + })), + }; + + const route = resolveAgentRoute({ + cfg, + channel: "dingtalk", + accountId: "acct-100", + peer: { kind: "direct", id: "user-100" }, + }); + expect(route.agentId).toBe("agent-100"); + expect(route.matchedBy).toBe("binding.peer"); + + const defaultRoute = resolveAgentRoute({ + cfg, + channel: "dingtalk", + accountId: "acct-missing", + peer: { kind: "direct", id: "user-missing" }, + }); + expect(defaultRoute.agentId).toBe("main"); + expect(defaultRoute.matchedBy).toBe("default"); + }); }); diff --git a/src/scripts/prepare-codex-ci-config.test.ts b/src/scripts/prepare-codex-ci-config.test.ts new file mode 100644 index 00000000000..d52cd850b1c --- /dev/null +++ b/src/scripts/prepare-codex-ci-config.test.ts @@ -0,0 +1,49 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + buildCiSafeCodexConfig, + writeCiSafeCodexConfig, +} from "../../scripts/prepare-codex-ci-config.ts"; +import { withTempDir } from "../test-utils/temp-dir.js"; + +describe("prepare-codex-ci-config", () => { + it("renders a minimal trusted non-interactive Codex config for the target repo", () => { + expect( + buildCiSafeCodexConfig({ + projectPath: "/tmp/openclaw-pr-sync.xph5uu", + }), + ).toBe( + [ + "# Generated for Codex CI runs.", + "# Keep the checked-out repo trusted while avoiding maintainer-local", + "# provider/profile overrides that do not exist on CI runners.", + 'approval_policy = "never"', + 'sandbox_mode = "workspace-write"', + "", + '[projects."/tmp/openclaw-pr-sync.xph5uu"]', + 'trust_level = "trusted"', + "", + ].join("\n"), + ); + }); + + it("writes the generated config to disk", async () => { + await withTempDir("codex-ci-config-", async (tempDir) => { + const outputPath = path.join(tempDir, ".codex", "config.toml"); + const projectPath = path.join(tempDir, "repo"); + + await writeCiSafeCodexConfig({ + outputPath, + projectPath, + }); + + await expect(fs.readFile(outputPath, "utf-8")).resolves.toContain( + `approval_policy = "never"`, + ); + await expect(fs.readFile(outputPath, "utf-8")).resolves.toContain( + `[projects."${projectPath}"]`, + ); + }); + }); +}); diff --git a/src/test-utils/bundled-plugin-public-surface.ts b/src/test-utils/bundled-plugin-public-surface.ts index a84bda882bd..1e27415fe40 100644 --- a/src/test-utils/bundled-plugin-public-surface.ts +++ b/src/test-utils/bundled-plugin-public-surface.ts @@ -65,6 +65,43 @@ function findBundledPluginMetadata(pluginId: string): BundledPluginPublicSurface return metadata; } +function readPackageName(packageDir: string): string | undefined { + try { + const packageJsonPath = path.join(packageDir, "package.json"); + const parsed = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8")) as { name?: unknown }; + return typeof parsed.name === "string" ? parsed.name : undefined; + } catch { + return undefined; + } +} + +function resolveWorkspacePackageDir(packageName: string): string { + const roots = [ + resolveBundledPluginsDir(), + path.resolve(OPENCLAW_PACKAGE_ROOT, "extensions"), + path.resolve(OPENCLAW_PACKAGE_ROOT, "dist-runtime", "extensions"), + path.resolve(OPENCLAW_PACKAGE_ROOT, "dist", "extensions"), + ].filter( + (entry, index, values): entry is string => Boolean(entry) && values.indexOf(entry) === index, + ); + + for (const root of roots) { + let entries: string[]; + try { + entries = fs.readdirSync(root); + } catch { + continue; + } + for (const entry of entries) { + const packageDir = path.join(root, entry); + if (readPackageName(packageDir) === packageName) { + return packageDir; + } + } + } + throw new Error(`Unknown workspace package: ${packageName}`); +} + export function loadBundledPluginPublicSurfaceSync(params: { pluginId: string; artifactBasename: string; @@ -150,3 +187,36 @@ export function resolveRelativeBundledPluginPublicModuleId(params: { .replaceAll(path.sep, "/"); return relativePath.startsWith(".") ? relativePath : `./${relativePath}`; } + +export function resolveRelativeExtensionPublicModuleId(params: { + fromModuleUrl: string; + dirName: string; + artifactBasename: string; +}): string { + const fromFilePath = fileURLToPath(params.fromModuleUrl); + const targetPath = resolveVitestSourceModulePath( + path.resolve(OPENCLAW_PACKAGE_ROOT, "extensions", params.dirName, params.artifactBasename), + ); + const relativePath = path + .relative(path.dirname(fromFilePath), targetPath) + .replaceAll(path.sep, "/"); + return relativePath.startsWith(".") ? relativePath : `./${relativePath}`; +} + +export function resolveRelativeWorkspacePackagePublicModuleId(params: { + fromModuleUrl: string; + packageName: string; + artifactBasename: string; +}): string { + const fromFilePath = fileURLToPath(params.fromModuleUrl); + const targetPath = resolveVitestSourceModulePath( + path.resolve( + resolveWorkspacePackageDir(params.packageName), + normalizeBundledPluginArtifactSubpath(params.artifactBasename), + ), + ); + const relativePath = path + .relative(path.dirname(fromFilePath), targetPath) + .replaceAll(path.sep, "/"); + return relativePath.startsWith(".") ? relativePath : `./${relativePath}`; +} diff --git a/src/tts/tts.test.ts b/src/tts/tts.test.ts index 2bc9618f4c8..a5e36185682 100644 --- a/src/tts/tts.test.ts +++ b/src/tts/tts.test.ts @@ -2,18 +2,24 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); const loadActivatedBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); - -vi.mock("../plugin-sdk/facade-runtime.js", async () => { - const actual = await vi.importActual( - "../plugin-sdk/facade-runtime.js", - ); - return { - ...actual, - loadActivatedBundledPluginPublicSurfaceModuleSync, - loadBundledPluginPublicSurfaceModuleSync, - }; +const createLazyFacadeObjectValue = vi.hoisted(() => { + return (load: () => T): T => + new Proxy( + {}, + { + get(_target, property, receiver) { + return Reflect.get(load(), property, receiver); + }, + }, + ) as T; }); +vi.mock("../plugin-sdk/facade-runtime.js", () => ({ + createLazyFacadeObjectValue, + loadActivatedBundledPluginPublicSurfaceModuleSync, + loadBundledPluginPublicSurfaceModuleSync, +})); + describe("tts runtime facade", () => { let ttsModulePromise: Promise | undefined; diff --git a/test/extension-test-boundary.test.ts b/test/extension-test-boundary.test.ts index 4e73ee80944..bd8647b1d1c 100644 --- a/test/extension-test-boundary.test.ts +++ b/test/extension-test-boundary.test.ts @@ -175,6 +175,8 @@ describe("non-extension test boundaries", () => { "src/auto-reply/reply.triggers.trigger-handling.test-harness.ts", "src/agents/models-config.providers.ollama.test.ts", "src/commands/channel-test-registry.ts", + "src/plugins/contracts/provider-vitest-registry.ts", + "src/plugins/contracts/web-provider-vitest-registry.ts", "src/plugin-sdk/testing.ts", ]); const files = walkCode(path.join(repoRoot, "src")); diff --git a/test/helpers/channels/dm-policy-contract.ts b/test/helpers/channels/dm-policy-contract.ts index 2f080e2e32d..5f34da9ea40 100644 --- a/test/helpers/channels/dm-policy-contract.ts +++ b/test/helpers/channels/dm-policy-contract.ts @@ -1,19 +1,21 @@ import type { SignalSender } from "@openclaw/signal/contract-api.js"; -import { loadBundledPluginContractApiSync } from "../../../src/test-utils/bundled-plugin-public-surface.js"; +import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js"; type SignalContractApiSurface = Pick< typeof import("@openclaw/signal/contract-api.js"), "isSignalSenderAllowed" >; -let signalContractSurface: SignalContractApiSurface | undefined; +let signalContractSurface: Promise | undefined; -function getSignalContractSurface(): SignalContractApiSurface { - signalContractSurface ??= loadBundledPluginContractApiSync("signal"); +export function getSignalContractSurface(): Promise { + signalContractSurface ??= import( + resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId: "signal", + artifactBasename: "contract-api.js", + }) + ) as Promise; return signalContractSurface; } - -export const isSignalSenderAllowed = ( - ...args: Parameters -) => getSignalContractSurface().isSignalSenderAllowed(...args); export type { SignalSender }; diff --git a/test/helpers/channels/inbound-contract.slack.ts b/test/helpers/channels/inbound-contract.slack.ts index 4dbd9cb6e40..a6a6ce72bfe 100644 --- a/test/helpers/channels/inbound-contract.slack.ts +++ b/test/helpers/channels/inbound-contract.slack.ts @@ -34,7 +34,7 @@ type SlackTestApi = { const slackPrepareTestApiModuleId = resolveRelativeBundledPluginPublicModuleId({ fromModuleUrl: import.meta.url, pluginId: "slack", - artifactBasename: "test-api.js", + artifactBasename: "inbound-contract-test-api.js", }); let slackTestApiPromise: Promise | undefined; diff --git a/test/helpers/channels/outbound-payload-contract.ts b/test/helpers/channels/outbound-payload-contract.ts index e84620c0a60..ffe676709b3 100644 --- a/test/helpers/channels/outbound-payload-contract.ts +++ b/test/helpers/channels/outbound-payload-contract.ts @@ -3,43 +3,15 @@ import type { ReplyPayload } from "../../../src/auto-reply/types.js"; import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/test-helpers.js"; import { createDirectTextMediaOutbound } from "../../../src/channels/plugins/outbound/direct-text-media.js"; import type { ChannelOutboundAdapter } from "../../../src/channels/plugins/types.js"; +import { sendPayloadWithChunkedTextAndMedia } from "../../../src/plugin-sdk/reply-payload.js"; +import { chunkTextForOutbound } from "../../../src/plugin-sdk/text-chunking.js"; import { resetGlobalHookRunner } from "../../../src/plugins/hook-runner-global.js"; -import { - loadBundledPluginPublicSurfaceSync, - loadBundledPluginTestApiSync, - resolveRelativeBundledPluginPublicModuleId, -} from "../../../src/test-utils/bundled-plugin-public-surface.js"; -type ParseZalouserOutboundTarget = (raw: string) => { threadId: string; isGroup: boolean }; +import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js"; type CreateSlackOutboundPayloadHarness = (params: PayloadHarnessParams) => { run: () => Promise>; sendMock: Mock; to: string; }; -type ChunkZaloTextForOutbound = (text: string, maxLength?: number) => string[]; -type SendPayloadWithChunkedTextAndMedia = (params: { - ctx: { - cfg: unknown; - to: string; - text: string; - payload: ReplyPayload; - }; - sendText: (ctx: { - cfg: unknown; - to: string; - text: string; - payload: ReplyPayload; - }) => Promise<{ channel: string; messageId: string }>; - sendMedia: (ctx: { - cfg: unknown; - to: string; - text: string; - payload: ReplyPayload; - mediaUrl?: string; - }) => Promise<{ channel: string; messageId: string }>; - emptyResult: { channel: string; messageId: string }; - textChunkLimit?: number; - chunker?: ChunkZaloTextForOutbound | null; -}) => Promise<{ channel: string; messageId: string }>; const discordOutboundAdapterModuleId = resolveRelativeBundledPluginPublicModuleId({ fromModuleUrl: import.meta.url, @@ -49,16 +21,15 @@ const discordOutboundAdapterModuleId = resolveRelativeBundledPluginPublicModuleI const slackTestApiModuleId = resolveRelativeBundledPluginPublicModuleId({ fromModuleUrl: import.meta.url, pluginId: "slack", - artifactBasename: "test-api.js", + artifactBasename: "outbound-payload-test-api.js", }); const whatsappTestApiModuleId = resolveRelativeBundledPluginPublicModuleId({ fromModuleUrl: import.meta.url, pluginId: "whatsapp", - artifactBasename: "test-api.js", + artifactBasename: "outbound-payload-test-api.js", }); let discordOutboundCache: Promise | undefined; -let parseZalouserOutboundTargetCache: ParseZalouserOutboundTarget | undefined; let slackTestApiPromise: | Promise<{ createSlackOutboundPayloadHarness: CreateSlackOutboundPayloadHarness; @@ -69,9 +40,6 @@ let whatsappTestApiPromise: whatsappOutbound: ChannelOutboundAdapter; }> | undefined; -let chunkZaloTextForOutboundCache: ChunkZaloTextForOutbound | undefined; -let sendZaloPayloadWithChunkedTextAndMediaCache: SendPayloadWithChunkedTextAndMedia | undefined; -let sendZalouserPayloadWithChunkedTextAndMediaCache: SendPayloadWithChunkedTextAndMedia | undefined; async function getDiscordOutbound(): Promise { discordOutboundCache ??= (async () => { @@ -99,54 +67,6 @@ async function getWhatsAppOutboundAsync(): Promise { return whatsappOutbound; } -function getChunkZaloTextForOutbound(): ChunkZaloTextForOutbound { - if (!chunkZaloTextForOutboundCache) { - ({ chunkTextForOutbound: chunkZaloTextForOutboundCache } = loadBundledPluginPublicSurfaceSync<{ - chunkTextForOutbound: ChunkZaloTextForOutbound; - }>({ - pluginId: "zalo", - artifactBasename: "runtime-api.js", - })); - } - return chunkZaloTextForOutboundCache; -} - -function getSendZaloPayloadWithChunkedTextAndMedia(): SendPayloadWithChunkedTextAndMedia { - if (!sendZaloPayloadWithChunkedTextAndMediaCache) { - ({ sendPayloadWithChunkedTextAndMedia: sendZaloPayloadWithChunkedTextAndMediaCache } = - loadBundledPluginPublicSurfaceSync<{ - sendPayloadWithChunkedTextAndMedia: SendPayloadWithChunkedTextAndMedia; - }>({ - pluginId: "zalo", - artifactBasename: "runtime-api.js", - })); - } - return sendZaloPayloadWithChunkedTextAndMediaCache; -} - -function getParseZalouserOutboundTarget(): ParseZalouserOutboundTarget { - if (!parseZalouserOutboundTargetCache) { - ({ parseZalouserOutboundTarget: parseZalouserOutboundTargetCache } = - loadBundledPluginTestApiSync<{ - parseZalouserOutboundTarget: ParseZalouserOutboundTarget; - }>("zalouser")); - } - return parseZalouserOutboundTargetCache; -} - -function getSendZalouserPayloadWithChunkedTextAndMedia(): SendPayloadWithChunkedTextAndMedia { - if (!sendZalouserPayloadWithChunkedTextAndMediaCache) { - ({ sendPayloadWithChunkedTextAndMedia: sendZalouserPayloadWithChunkedTextAndMediaCache } = - loadBundledPluginPublicSurfaceSync<{ - sendPayloadWithChunkedTextAndMedia: SendPayloadWithChunkedTextAndMedia; - }>({ - pluginId: "zalouser", - artifactBasename: "runtime-api.js", - })); - } - return sendZalouserPayloadWithChunkedTextAndMediaCache; -} - type PayloadHarnessParams = { payload: ReplyPayload; sendResults?: Array<{ messageId: string }>; @@ -367,10 +287,10 @@ function createZaloHarness(params: PayloadHarnessParams) { }; return { run: async () => - await getSendZaloPayloadWithChunkedTextAndMedia()({ + await sendPayloadWithChunkedTextAndMedia({ ctx, textChunkLimit: 2000, - chunker: getChunkZaloTextForOutbound(), + chunker: chunkTextForOutbound, sendText: async (nextCtx) => buildChannelSendResult( "zalo", @@ -400,21 +320,20 @@ function createZalouserHarness(params: PayloadHarnessParams) { primeChannelOutboundSendMock(sendZalouser, { ok: true, messageId: "zlu-1" }, params.sendResults); const ctx = { cfg: {}, - to: "user:987654321", + to: "987654321", text: "", payload: params.payload, }; return { run: async () => - await getSendZalouserPayloadWithChunkedTextAndMedia()({ + await sendPayloadWithChunkedTextAndMedia({ ctx, sendText: async (nextCtx) => { - const target = getParseZalouserOutboundTarget()(nextCtx.to); return buildChannelSendResult( "zalouser", - await sendZalouser(target.threadId, nextCtx.text, { + await sendZalouser(nextCtx.to, nextCtx.text, { profile: "default", - isGroup: target.isGroup, + isGroup: false, textMode: "markdown", textChunkMode: "length", textChunkLimit: 1200, @@ -422,12 +341,11 @@ function createZalouserHarness(params: PayloadHarnessParams) { ); }, sendMedia: async (nextCtx) => { - const target = getParseZalouserOutboundTarget()(nextCtx.to); return buildChannelSendResult( "zalouser", - await sendZalouser(target.threadId, nextCtx.text, { + await sendZalouser(nextCtx.to, nextCtx.text, { profile: "default", - isGroup: target.isGroup, + isGroup: false, mediaUrl: nextCtx.mediaUrl, textMode: "markdown", textChunkMode: "length", @@ -438,7 +356,7 @@ function createZalouserHarness(params: PayloadHarnessParams) { emptyResult: { channel: "zalouser", messageId: "" }, }), sendMock: sendZalouser, - to: "987654321", + to: ctx.to, }; } diff --git a/test/helpers/channels/plugins-core-extension-contract.ts b/test/helpers/channels/plugins-core-extension-contract.ts index 4604ad4cfd6..99a18bb4f83 100644 --- a/test/helpers/channels/plugins-core-extension-contract.ts +++ b/test/helpers/channels/plugins-core-extension-contract.ts @@ -6,56 +6,69 @@ import type { } from "../../../src/channels/plugins/types.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { LineProbeResult } from "../../../src/plugin-sdk/line.js"; -import { - loadBundledPluginApiSync, - loadBundledPluginContractApiSync, -} from "../../../src/test-utils/bundled-plugin-public-surface.js"; +import { resolveRelativeBundledPluginPublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js"; import { withEnvAsync } from "../../../src/test-utils/env.js"; -type DiscordContractApiSurface = Pick< - typeof import("@openclaw/discord/contract-api.js"), +type DiscordDirectoryContractApiSurface = Pick< + typeof import("@openclaw/discord/directory-contract-api.js"), "listDiscordDirectoryPeersFromConfig" | "listDiscordDirectoryGroupsFromConfig" >; type DiscordProbe = import("@openclaw/discord/api.js").DiscordProbe; type DiscordTokenResolution = import("@openclaw/discord/api.js").DiscordTokenResolution; type IMessageProbe = import("@openclaw/imessage/runtime-api.js").IMessageProbe; type SignalProbe = import("@openclaw/signal/api.js").SignalProbe; -type SlackContractApiSurface = Pick< - typeof import("@openclaw/slack/contract-api.js"), +type SlackDirectoryContractApiSurface = Pick< + typeof import("@openclaw/slack/directory-contract-api.js"), "listSlackDirectoryPeersFromConfig" | "listSlackDirectoryGroupsFromConfig" >; type SlackProbe = import("@openclaw/slack/api.js").SlackProbe; -type TelegramContractApiSurface = Pick< - typeof import("@openclaw/telegram/contract-api.js"), +type TelegramDirectoryContractApiSurface = Pick< + typeof import("@openclaw/telegram/directory-contract-api.js"), "listTelegramDirectoryPeersFromConfig" | "listTelegramDirectoryGroupsFromConfig" >; type TelegramProbe = import("@openclaw/telegram/api.js").TelegramProbe; type TelegramTokenResolution = import("@openclaw/telegram/api.js").TelegramTokenResolution; -type WhatsAppApiSurface = typeof import("@openclaw/whatsapp/api.js"); +type WhatsAppDirectoryContractApiSurface = Pick< + typeof import("@openclaw/whatsapp/directory-contract-api.js"), + "listWhatsAppDirectoryPeersFromConfig" | "listWhatsAppDirectoryGroupsFromConfig" +>; -let discordContractApi: DiscordContractApiSurface | undefined; -let slackContractApi: SlackContractApiSurface | undefined; -let telegramContractApi: TelegramContractApiSurface | undefined; -let whatsappApi: WhatsAppApiSurface | undefined; +let discordDirectoryContractApi: Promise | undefined; +let slackDirectoryContractApi: Promise | undefined; +let telegramDirectoryContractApi: Promise | undefined; +let whatsappDirectoryContractApi: Promise | undefined; -function getDiscordContractApi(): DiscordContractApiSurface { - discordContractApi ??= loadBundledPluginContractApiSync("discord"); - return discordContractApi; +async function importDirectoryContractApi(pluginId: string): Promise { + const moduleId = resolveRelativeBundledPluginPublicModuleId({ + fromModuleUrl: import.meta.url, + pluginId, + artifactBasename: "directory-contract-api.js", + }); + return (await import(moduleId)) as T; } -function getSlackContractApi(): SlackContractApiSurface { - slackContractApi ??= loadBundledPluginContractApiSync("slack"); - return slackContractApi; +function getDiscordDirectoryContractApi(): Promise { + discordDirectoryContractApi ??= + importDirectoryContractApi("discord"); + return discordDirectoryContractApi; } -function getTelegramContractApi(): TelegramContractApiSurface { - telegramContractApi ??= loadBundledPluginContractApiSync("telegram"); - return telegramContractApi; +function getSlackDirectoryContractApi(): Promise { + slackDirectoryContractApi ??= + importDirectoryContractApi("slack"); + return slackDirectoryContractApi; } -function getWhatsAppApi(): WhatsAppApiSurface { - whatsappApi ??= loadBundledPluginApiSync("whatsapp"); - return whatsappApi; +function getTelegramDirectoryContractApi(): Promise { + telegramDirectoryContractApi ??= + importDirectoryContractApi("telegram"); + return telegramDirectoryContractApi; +} + +function getWhatsAppDirectoryContractApi(): Promise { + whatsappDirectoryContractApi ??= + importDirectoryContractApi("whatsapp"); + return whatsappDirectoryContractApi; } type DirectoryListFn = (params: { @@ -87,9 +100,6 @@ async function expectDirectoryIds( export function describeDiscordPluginsCoreExtensionContract() { describe("discord plugins-core extension contract", () => { - const listPeers = () => getDiscordContractApi().listDiscordDirectoryPeersFromConfig; - const listGroups = () => getDiscordContractApi().listDiscordDirectoryGroupsFromConfig; - it("DiscordProbe satisfies BaseProbeResult", () => { expectTypeOf().toMatchTypeOf(); }); @@ -99,6 +109,8 @@ export function describeDiscordPluginsCoreExtensionContract() { }); it("lists peers/groups from config (numeric ids only)", async () => { + const { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig } = + await getDiscordDirectoryContractApi(); const cfg = { channels: { discord: { @@ -121,17 +133,24 @@ export function describeDiscordPluginsCoreExtensionContract() { } as unknown as OpenClawConfig; await expectDirectoryIds( - listPeers(), + listDiscordDirectoryPeersFromConfig, cfg, ["user:111", "user:12345", "user:222", "user:333", "user:444"], { sorted: true }, ); - await expectDirectoryIds(listGroups(), cfg, ["channel:555", "channel:666", "channel:777"], { - sorted: true, - }); + await expectDirectoryIds( + listDiscordDirectoryGroupsFromConfig, + cfg, + ["channel:555", "channel:666", "channel:777"], + { + sorted: true, + }, + ); }); it("keeps directories readable when tokens are unresolved SecretRefs", async () => { + const { listDiscordDirectoryGroupsFromConfig, listDiscordDirectoryPeersFromConfig } = + await getDiscordDirectoryContractApi(); const envSecret = { source: "env", provider: "default", @@ -153,11 +172,12 @@ export function describeDiscordPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - await expectDirectoryIds(listPeers(), cfg, ["user:111"]); - await expectDirectoryIds(listGroups(), cfg, ["channel:555"]); + await expectDirectoryIds(listDiscordDirectoryPeersFromConfig, cfg, ["user:111"]); + await expectDirectoryIds(listDiscordDirectoryGroupsFromConfig, cfg, ["channel:555"]); }); it("applies query and limit filtering for config-backed directories", async () => { + const { listDiscordDirectoryGroupsFromConfig } = await getDiscordDirectoryContractApi(); const cfg = { channels: { discord: { @@ -175,7 +195,7 @@ export function describeDiscordPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - const groups = await listGroups()({ + const groups = await listDiscordDirectoryGroupsFromConfig({ cfg, accountId: "default", query: "666", @@ -188,14 +208,13 @@ export function describeDiscordPluginsCoreExtensionContract() { export function describeSlackPluginsCoreExtensionContract() { describe("slack plugins-core extension contract", () => { - const listPeers = () => getSlackContractApi().listSlackDirectoryPeersFromConfig; - const listGroups = () => getSlackContractApi().listSlackDirectoryGroupsFromConfig; - it("SlackProbe satisfies BaseProbeResult", () => { expectTypeOf().toMatchTypeOf(); }); it("lists peers/groups from config", async () => { + const { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig } = + await getSlackDirectoryContractApi(); const cfg = { channels: { slack: { @@ -209,15 +228,17 @@ export function describeSlackPluginsCoreExtensionContract() { } as unknown as OpenClawConfig; await expectDirectoryIds( - listPeers(), + listSlackDirectoryPeersFromConfig, cfg, ["user:u123", "user:u234", "user:u777", "user:u999"], { sorted: true }, ); - await expectDirectoryIds(listGroups(), cfg, ["channel:c111"]); + await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]); }); it("keeps directories readable when tokens are unresolved SecretRefs", async () => { + const { listSlackDirectoryGroupsFromConfig, listSlackDirectoryPeersFromConfig } = + await getSlackDirectoryContractApi(); const envSecret = { source: "env", provider: "default", @@ -234,11 +255,12 @@ export function describeSlackPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - await expectDirectoryIds(listPeers(), cfg, ["user:u123"]); - await expectDirectoryIds(listGroups(), cfg, ["channel:c111"]); + await expectDirectoryIds(listSlackDirectoryPeersFromConfig, cfg, ["user:u123"]); + await expectDirectoryIds(listSlackDirectoryGroupsFromConfig, cfg, ["channel:c111"]); }); it("applies query and limit filtering for config-backed directories", async () => { + const { listSlackDirectoryPeersFromConfig } = await getSlackDirectoryContractApi(); const cfg = { channels: { slack: { @@ -250,7 +272,7 @@ export function describeSlackPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - const peers = await listPeers()({ + const peers = await listSlackDirectoryPeersFromConfig({ cfg, accountId: "default", query: "user:u", @@ -264,9 +286,6 @@ export function describeSlackPluginsCoreExtensionContract() { export function describeTelegramPluginsCoreExtensionContract() { describe("telegram plugins-core extension contract", () => { - const listPeers = () => getTelegramContractApi().listTelegramDirectoryPeersFromConfig; - const listGroups = () => getTelegramContractApi().listTelegramDirectoryGroupsFromConfig; - it("TelegramProbe satisfies BaseProbeResult", () => { expectTypeOf().toMatchTypeOf(); }); @@ -276,6 +295,8 @@ export function describeTelegramPluginsCoreExtensionContract() { }); it("lists peers/groups from config", async () => { + const { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig } = + await getTelegramDirectoryContractApi(); const cfg = { channels: { telegram: { @@ -287,13 +308,20 @@ export function describeTelegramPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - await expectDirectoryIds(listPeers(), cfg, ["123", "456", "@alice", "@bob"], { - sorted: true, - }); - await expectDirectoryIds(listGroups(), cfg, ["-1001"]); + await expectDirectoryIds( + listTelegramDirectoryPeersFromConfig, + cfg, + ["123", "456", "@alice", "@bob"], + { + sorted: true, + }, + ); + await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]); }); it("keeps fallback semantics when accountId is omitted", async () => { + const { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig } = + await getTelegramDirectoryContractApi(); await withEnvAsync({ TELEGRAM_BOT_TOKEN: "tok-env" }, async () => { const cfg = { channels: { @@ -311,12 +339,14 @@ export function describeTelegramPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - await expectDirectoryIds(listPeers(), cfg, ["@alice"]); - await expectDirectoryIds(listGroups(), cfg, ["-1001"]); + await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]); + await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]); }); }); it("keeps directories readable when tokens are unresolved SecretRefs", async () => { + const { listTelegramDirectoryGroupsFromConfig, listTelegramDirectoryPeersFromConfig } = + await getTelegramDirectoryContractApi(); const envSecret = { source: "env", provider: "default", @@ -332,11 +362,12 @@ export function describeTelegramPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - await expectDirectoryIds(listPeers(), cfg, ["@alice"]); - await expectDirectoryIds(listGroups(), cfg, ["-1001"]); + await expectDirectoryIds(listTelegramDirectoryPeersFromConfig, cfg, ["@alice"]); + await expectDirectoryIds(listTelegramDirectoryGroupsFromConfig, cfg, ["-1001"]); }); it("applies query and limit filtering for config-backed directories", async () => { + const { listTelegramDirectoryGroupsFromConfig } = await getTelegramDirectoryContractApi(); const cfg = { channels: { telegram: { @@ -346,7 +377,7 @@ export function describeTelegramPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - const groups = await listGroups()({ + const groups = await listTelegramDirectoryGroupsFromConfig({ cfg, accountId: "default", query: "-100", @@ -359,10 +390,9 @@ export function describeTelegramPluginsCoreExtensionContract() { export function describeWhatsAppPluginsCoreExtensionContract() { describe("whatsapp plugins-core extension contract", () => { - const listPeers = () => getWhatsAppApi().listWhatsAppDirectoryPeersFromConfig; - const listGroups = () => getWhatsAppApi().listWhatsAppDirectoryGroupsFromConfig; - it("lists peers/groups from config", async () => { + const { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig } = + await getWhatsAppDirectoryContractApi(); const cfg = { channels: { whatsapp: { @@ -372,11 +402,12 @@ export function describeWhatsAppPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - await expectDirectoryIds(listPeers(), cfg, ["+15550000000"]); - await expectDirectoryIds(listGroups(), cfg, ["999@g.us"]); + await expectDirectoryIds(listWhatsAppDirectoryPeersFromConfig, cfg, ["+15550000000"]); + await expectDirectoryIds(listWhatsAppDirectoryGroupsFromConfig, cfg, ["999@g.us"]); }); it("applies query and limit filtering for config-backed directories", async () => { + const { listWhatsAppDirectoryGroupsFromConfig } = await getWhatsAppDirectoryContractApi(); const cfg = { channels: { whatsapp: { @@ -385,7 +416,7 @@ export function describeWhatsAppPluginsCoreExtensionContract() { }, } as unknown as OpenClawConfig; - const groups = await listGroups()({ + const groups = await listWhatsAppDirectoryGroupsFromConfig({ cfg, accountId: "default", query: "@g.us", diff --git a/test/helpers/channels/registry-plugin.ts b/test/helpers/channels/registry-plugin.ts index b04f954361c..f4b02e78001 100644 --- a/test/helpers/channels/registry-plugin.ts +++ b/test/helpers/channels/registry-plugin.ts @@ -1,4 +1,5 @@ import { listBundledChannelPlugins } from "../../../src/channels/plugins/bundled.js"; +import { normalizeChannelMeta } from "../../../src/channels/plugins/meta-normalization.js"; import type { ChannelPlugin } from "../../../src/channels/plugins/types.js"; type PluginContractEntry = { @@ -11,10 +12,7 @@ export function getPluginContractRegistry(): PluginContractEntry[] { id: plugin.id, plugin: { ...plugin, - meta: { - ...plugin.meta, - id: plugin.id, - }, + meta: normalizeChannelMeta({ id: plugin.id, meta: plugin.meta }), }, })); } diff --git a/test/helpers/channels/session-binding-registry-backed-contract.ts b/test/helpers/channels/session-binding-registry-backed-contract.ts index f5d0222e1da..b667d8b61b0 100644 --- a/test/helpers/channels/session-binding-registry-backed-contract.ts +++ b/test/helpers/channels/session-binding-registry-backed-contract.ts @@ -33,31 +33,24 @@ function installSessionBindingContractSuite(params: { cleanup: () => Promise | void; expectedCapabilities: SessionBindingCapabilities; }) { - it("registers the expected session binding capabilities", async () => { + it("registers, binds, unbinds, and cleans up session bindings", async () => { expect(await Promise.resolve(params.getCapabilities())).toEqual(params.expectedCapabilities); - }); - - it("binds and resolves a session binding through the shared service", async () => { const binding = await params.bindAndResolve(); - expect(typeof binding.bindingId).toBe("string"); - expect(binding.bindingId.trim()).not.toBe(""); - expect(typeof binding.targetSessionKey).toBe("string"); - expect(binding.targetSessionKey.trim()).not.toBe(""); - expect(["session", "subagent"]).toContain(binding.targetKind); - expect(typeof binding.conversation.channel).toBe("string"); - expect(typeof binding.conversation.accountId).toBe("string"); - expect(typeof binding.conversation.conversationId).toBe("string"); - expect(["active", "ending", "ended"]).toContain(binding.status); - expect(typeof binding.boundAt).toBe("number"); - }); - - it("unbinds a registered binding through the shared service", async () => { - const binding = await params.bindAndResolve(); - await params.unbindAndVerify(binding); - }); - - it("cleans up registered bindings", async () => { - await params.cleanup(); + try { + expect(typeof binding.bindingId).toBe("string"); + expect(binding.bindingId.trim()).not.toBe(""); + expect(typeof binding.targetSessionKey).toBe("string"); + expect(binding.targetSessionKey.trim()).not.toBe(""); + expect(["session", "subagent"]).toContain(binding.targetKind); + expect(typeof binding.conversation.channel).toBe("string"); + expect(typeof binding.conversation.accountId).toBe("string"); + expect(typeof binding.conversation.conversationId).toBe("string"); + expect(["active", "ending", "ended"]).toContain(binding.status); + expect(typeof binding.boundAt).toBe("number"); + await params.unbindAndVerify(binding); + } finally { + await params.cleanup(); + } }); } diff --git a/test/helpers/plugins/bundled-web-search-fast-path-contract.ts b/test/helpers/plugins/bundled-web-search-fast-path-contract.ts deleted file mode 100644 index d56d50d84d2..00000000000 --- a/test/helpers/plugins/bundled-web-search-fast-path-contract.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../../src/config/config.js"; -import { loadBundledCapabilityRuntimeRegistry } from "../../../src/plugins/bundled-capability-runtime.js"; -import { resolveManifestContractOwnerPluginId } from "../../../src/plugins/manifest-registry.js"; -import { resolveBundledExplicitWebSearchProvidersFromPublicArtifacts } from "../../../src/plugins/web-provider-public-artifacts.explicit.js"; -import { resolvePluginWebSearchProviders } from "../../../src/plugins/web-search-providers.runtime.js"; - -type ComparableProvider = { - pluginId: string; - id: string; - label: string; - hint: string; - envVars: string[]; - placeholder: string; - signupUrl: string; - docsUrl?: string; - autoDetectOrder?: number; - requiresCredential?: boolean; - credentialPath: string; - inactiveSecretPaths?: string[]; - hasConfiguredCredentialAccessors: boolean; - hasApplySelectionConfig: boolean; - hasResolveRuntimeMetadata: boolean; -}; - -function toComparableEntry(params: { - pluginId: string; - provider: { - id: string; - label: string; - hint: string; - envVars: string[]; - placeholder: string; - signupUrl: string; - docsUrl?: string; - autoDetectOrder?: number; - requiresCredential?: boolean; - credentialPath: string; - inactiveSecretPaths?: string[]; - getConfiguredCredentialValue?: unknown; - setConfiguredCredentialValue?: unknown; - applySelectionConfig?: unknown; - resolveRuntimeMetadata?: unknown; - }; -}): ComparableProvider { - return { - pluginId: params.pluginId, - id: params.provider.id, - label: params.provider.label, - hint: params.provider.hint, - envVars: params.provider.envVars, - placeholder: params.provider.placeholder, - signupUrl: params.provider.signupUrl, - docsUrl: params.provider.docsUrl, - autoDetectOrder: params.provider.autoDetectOrder, - requiresCredential: params.provider.requiresCredential, - credentialPath: params.provider.credentialPath, - inactiveSecretPaths: params.provider.inactiveSecretPaths, - hasConfiguredCredentialAccessors: - typeof params.provider.getConfiguredCredentialValue === "function" && - typeof params.provider.setConfiguredCredentialValue === "function", - hasApplySelectionConfig: typeof params.provider.applySelectionConfig === "function", - hasResolveRuntimeMetadata: typeof params.provider.resolveRuntimeMetadata === "function", - }; -} - -function sortComparableEntries(entries: ComparableProvider[]): ComparableProvider[] { - return [...entries].toSorted((left, right) => { - const leftOrder = left.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; - const rightOrder = right.autoDetectOrder ?? Number.MAX_SAFE_INTEGER; - return ( - leftOrder - rightOrder || - left.id.localeCompare(right.id) || - left.pluginId.localeCompare(right.pluginId) - ); - }); -} - -export function describeBundledWebSearchFastPathContract(pluginId: string) { - describe(`${pluginId} bundled web search fast-path contract`, () => { - it("keeps provider-to-plugin ids aligned with bundled contracts", () => { - const providers = - resolveBundledExplicitWebSearchProvidersFromPublicArtifacts({ - onlyPluginIds: [pluginId], - }) ?? []; - expect(providers.length).toBeGreaterThan(0); - for (const provider of providers) { - expect( - resolveManifestContractOwnerPluginId({ - contract: "webSearchProviders", - value: provider.id, - origin: "bundled", - }), - ).toBe(pluginId); - } - }); - - it("keeps fast-path provider metadata aligned with the bundled runtime registry", async () => { - const fastPathProviders = resolvePluginWebSearchProviders({ - origin: "bundled", - onlyPluginIds: [pluginId], - }).filter((provider) => provider.pluginId === pluginId); - const bundledProviderEntries = loadBundledCapabilityRuntimeRegistry({ - pluginIds: [pluginId], - pluginSdkResolution: "dist", - }) - .webSearchProviders.filter((entry) => entry.pluginId === pluginId) - .map((entry) => ({ - pluginId: entry.pluginId, - ...entry.provider, - })); - - expect( - sortComparableEntries( - fastPathProviders.map((provider) => - toComparableEntry({ - pluginId: provider.pluginId, - provider, - }), - ), - ), - ).toEqual( - sortComparableEntries( - bundledProviderEntries.map(({ pluginId: entryPluginId, ...provider }) => - toComparableEntry({ - pluginId: entryPluginId, - provider, - }), - ), - ), - ); - - for (const fastPathProvider of fastPathProviders) { - const bundledEntry = bundledProviderEntries.find( - (entry) => entry.id === fastPathProvider.id, - ); - expect(bundledEntry).toBeDefined(); - const contractProvider = bundledEntry!; - - const fastSearchConfig: Record = {}; - const contractSearchConfig: Record = {}; - fastPathProvider.setCredentialValue(fastSearchConfig, "test-key"); - contractProvider.setCredentialValue(contractSearchConfig, "test-key"); - expect(fastSearchConfig).toEqual(contractSearchConfig); - expect(fastPathProvider.getCredentialValue(fastSearchConfig)).toEqual( - contractProvider.getCredentialValue(contractSearchConfig), - ); - - const fastConfig = {} as OpenClawConfig; - const contractConfig = {} as OpenClawConfig; - fastPathProvider.setConfiguredCredentialValue?.(fastConfig, "test-key"); - contractProvider.setConfiguredCredentialValue?.(contractConfig, "test-key"); - expect(fastConfig).toEqual(contractConfig); - expect(fastPathProvider.getConfiguredCredentialValue?.(fastConfig)).toEqual( - contractProvider.getConfiguredCredentialValue?.(contractConfig), - ); - - if (fastPathProvider.applySelectionConfig || contractProvider.applySelectionConfig) { - expect(fastPathProvider.applySelectionConfig?.({} as OpenClawConfig)).toEqual( - contractProvider.applySelectionConfig?.({} as OpenClawConfig), - ); - } - - if (fastPathProvider.resolveRuntimeMetadata || contractProvider.resolveRuntimeMetadata) { - const metadataCases = [ - { - searchConfig: fastSearchConfig, - resolvedCredential: { - value: "pplx-test", - source: "secretRef" as const, - fallbackEnvVar: undefined, - }, - }, - { - searchConfig: fastSearchConfig, - resolvedCredential: { - value: undefined, - source: "env" as const, - fallbackEnvVar: "OPENROUTER_API_KEY", - }, - }, - { - searchConfig: { - ...fastSearchConfig, - perplexity: { - ...(fastSearchConfig.perplexity as Record | undefined), - model: "custom-model", - }, - }, - resolvedCredential: { - value: "pplx-test", - source: "secretRef" as const, - fallbackEnvVar: undefined, - }, - }, - ]; - - for (const testCase of metadataCases) { - expect( - await fastPathProvider.resolveRuntimeMetadata?.({ - config: fastConfig, - searchConfig: testCase.searchConfig, - runtimeMetadata: { - diagnostics: [], - providerSource: "configured", - }, - resolvedCredential: testCase.resolvedCredential, - }), - ).toEqual( - await contractProvider.resolveRuntimeMetadata?.({ - config: contractConfig, - searchConfig: testCase.searchConfig, - runtimeMetadata: { - diagnostics: [], - providerSource: "configured", - }, - resolvedCredential: testCase.resolvedCredential, - }), - ); - } - } - } - }); - }); -} diff --git a/test/helpers/plugins/provider-contract-suites.ts b/test/helpers/plugins/provider-contract-suites.ts index 0961e8202b6..d000f6c98dd 100644 --- a/test/helpers/plugins/provider-contract-suites.ts +++ b/test/helpers/plugins/provider-contract-suites.ts @@ -115,22 +115,8 @@ export function installWebSearchProviderContractSuite(params: { provider.setCredentialValue(searchConfigTarget, credentialValue); expect(provider.getCredentialValue(searchConfigTarget)).toEqual(credentialValue); - const config = { - tools: { - web: { - search: { - provider: provider.id, - ...searchConfigTarget, - }, - }, - }, - } as OpenClawConfig; - const tool = provider.createTool({ config, searchConfig: searchConfigTarget }); - - expect(tool).not.toBeNull(); - expect(tool?.description.trim()).not.toBe(""); - expect(tool?.parameters).toEqual(expect.any(Object)); - expect(typeof tool?.execute).toBe("function"); + expect(typeof provider.createTool).toBe("function"); + expect(provider.getCredentialValue(searchConfigTarget)).toEqual(credentialValue); if (provider.runSetup) { expect(typeof provider.runSetup).toBe("function"); } @@ -178,21 +164,6 @@ export function installWebFetchProviderContractSuite(params: { expect(applied.plugins?.entries?.[params.pluginId]?.enabled).toBe(true); } - const config = { - tools: { - web: { - fetch: { - provider: provider.id, - ...fetchConfigTarget, - }, - }, - }, - } as OpenClawConfig; - const tool = provider.createTool({ config, fetchConfig: fetchConfigTarget }); - - expect(tool).not.toBeNull(); - expect(tool?.description.trim()).not.toBe(""); - expect(tool?.parameters).toEqual(expect.any(Object)); - expect(typeof tool?.execute).toBe("function"); + expect(typeof provider.createTool).toBe("function"); }); } diff --git a/test/helpers/plugins/provider-contract.ts b/test/helpers/plugins/provider-contract.ts index 00caf0e7646..a907bf422bf 100644 --- a/test/helpers/plugins/provider-contract.ts +++ b/test/helpers/plugins/provider-contract.ts @@ -2,19 +2,87 @@ import { describe, expect, it } from "vitest"; import { pluginRegistrationContractRegistry, providerContractLoadError, - requireProviderContractProvider, resolveProviderContractProvidersForPluginIds, } from "../../../src/plugins/contracts/registry.js"; +import { loadBundledPluginPublicArtifactModuleSync } from "../../../src/plugins/public-surface-loader.js"; +import type { ProviderPlugin } from "../../../src/plugins/types.js"; import { installProviderPluginContractSuite } from "./provider-contract-suites.js"; +type ProviderContractEntry = { + pluginId: string; + provider: ProviderPlugin; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isProviderPlugin(value: unknown): value is ProviderPlugin { + return ( + isRecord(value) && + typeof value.id === "string" && + typeof value.label === "string" && + Array.isArray(value.auth) + ); +} + +function resolveProviderContractProvidersFromPublicArtifact( + pluginId: string, +): ProviderContractEntry[] | null { + let mod: Record; + try { + mod = loadBundledPluginPublicArtifactModuleSync>({ + dirName: pluginId, + artifactBasename: "provider-contract-api.js", + }); + } catch (error) { + if ( + error instanceof Error && + error.message.startsWith("Unable to resolve bundled plugin public surface ") + ) { + return null; + } + throw error; + } + + const providers: ProviderContractEntry[] = []; + for (const [name, exported] of Object.entries(mod).toSorted(([left], [right]) => + left.localeCompare(right), + )) { + if ( + typeof exported !== "function" || + exported.length !== 0 || + !name.startsWith("create") || + !name.endsWith("Provider") + ) { + continue; + } + const provider = exported(); + if (isProviderPlugin(provider)) { + providers.push({ pluginId, provider }); + } + } + return providers.length > 0 ? providers : null; +} + export function describeProviderContracts(pluginId: string) { const providerIds = pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId)?.providerIds ?? []; + const resolveProviderEntries = (): ProviderContractEntry[] => { + const publicArtifactProviders = resolveProviderContractProvidersFromPublicArtifact(pluginId); + if (publicArtifactProviders) { + return publicArtifactProviders; + } + return resolveProviderContractProvidersForPluginIds([pluginId]).map((provider) => ({ + pluginId, + provider, + })); + }; describe(`${pluginId} provider contract registry load`, () => { it("loads bundled providers without import-time registry failure", () => { - const providers = resolveProviderContractProvidersForPluginIds([pluginId]); + const providers = resolveProviderEntries(); expect(providerContractLoadError).toBeUndefined(); expect(providers.length).toBeGreaterThan(0); }); @@ -25,7 +93,13 @@ export function describeProviderContracts(pluginId: string) { // Resolve provider entries lazily so the non-isolated extension runner // does not race provider contract collection against other file imports. installProviderPluginContractSuite({ - provider: () => requireProviderContractProvider(providerId), + provider: () => { + const entry = resolveProviderEntries().find((entry) => entry.provider.id === providerId); + if (!entry) { + throw new Error(`provider contract entry missing for ${pluginId}:${providerId}`); + } + return entry.provider; + }, }); }); } diff --git a/test/helpers/plugins/provider-discovery-contract.ts b/test/helpers/plugins/provider-discovery-contract.ts index 8783701fed7..c581129203c 100644 --- a/test/helpers/plugins/provider-discovery-contract.ts +++ b/test/helpers/plugins/provider-discovery-contract.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { AuthProfileStore } from "../../../src/agents/auth-profiles/types.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import type { ModelDefinitionConfig } from "../../../src/config/types.models.js"; @@ -171,14 +171,14 @@ function runCatalog( } async function importBundledProviderPlugin(moduleUrl: string): Promise { - return (await import(`${moduleUrl}?t=${Date.now()}`)) as T; + return (await import(moduleUrl)) as T; } function installDiscoveryHooks( state: DiscoveryState, providerIds: readonly BundledProviderUnderTest[], ) { - beforeEach(async () => { + beforeAll(async () => { vi.resetModules(); vi.doMock("openclaw/plugin-sdk/agent-runtime", () => { return { @@ -312,6 +312,9 @@ function installDiscoveryHooks( "cloudflare-ai-gateway", ); } + }); + + beforeEach(() => { setRuntimeAuthStore(); }); @@ -323,6 +326,7 @@ function installDiscoveryHooks( buildSglangProviderMock.mockReset(); ensureAuthProfileStoreMock.mockReset(); listProfilesForProviderMock.mockReset(); + setRuntimeAuthStore(); }); } diff --git a/test/helpers/plugins/tts-contract-suites.ts b/test/helpers/plugins/tts-contract-suites.ts index f30997ed9bd..fec4d1d1dd6 100644 --- a/test/helpers/plugins/tts-contract-suites.ts +++ b/test/helpers/plugins/tts-contract-suites.ts @@ -1,49 +1,61 @@ import type { AssistantMessage } from "@mariozechner/pi-ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { __testing as pluginLoaderTesting } from "../../../src/plugins/loader.js"; import { createEmptyPluginRegistry } from "../../../src/plugins/registry-empty.js"; import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; import type { SpeechProviderPlugin } from "../../../src/plugins/types.js"; +import { resolveRelativeWorkspacePackagePublicModuleId } from "../../../src/test-utils/bundled-plugin-public-surface.js"; import { withEnv } from "../../../src/test-utils/env.js"; +import type { ResolvedTtsConfig } from "../../../src/tts/tts-types.js"; type TtsRuntimeModule = typeof import("../../../src/tts/tts.js"); +type TtsCoreModule = typeof import("../../../src/tts/tts-core.js"); + +const speechCoreRuntimeApiModuleId = resolveRelativeWorkspacePackagePublicModuleId({ + fromModuleUrl: import.meta.url, + packageName: "@openclaw/speech-core", + artifactBasename: "runtime-api.js", +}); let ttsRuntime: TtsRuntimeModule; let ttsRuntimePromise: Promise | null = null; +let ttsRuntimeInitialized = false; +let ttsCorePromise: Promise | null = null; let completeSimple: typeof import("@mariozechner/pi-ai").completeSimple; let getApiKeyForModelMock: typeof import("../../../src/agents/model-auth.js").getApiKeyForModel; let requireApiKeyMock: typeof import("../../../src/agents/model-auth.js").requireApiKey; let resolveModelAsyncMock: typeof import("../../../src/agents/pi-embedded-runner/model.js").resolveModelAsync; let ensureCustomApiRegisteredMock: typeof import("../../../src/agents/custom-api-registry.js").ensureCustomApiRegistered; let prepareModelForSimpleCompletionMock: typeof import("../../../src/agents/simple-completion-transport.js").prepareModelForSimpleCompletion; +let summarizeTextCore: TtsCoreModule["summarizeText"]; let resolveTtsConfig: TtsRuntimeModule["resolveTtsConfig"]; let maybeApplyTtsToPayload: TtsRuntimeModule["maybeApplyTtsToPayload"]; let getTtsProvider: TtsRuntimeModule["getTtsProvider"]; let parseTtsDirectives: TtsRuntimeModule["_test"]["parseTtsDirectives"]; let resolveModelOverridePolicy: TtsRuntimeModule["_test"]["resolveModelOverridePolicy"]; -let summarizeText: TtsRuntimeModule["_test"]["summarizeText"]; let getResolvedSpeechProviderConfig: TtsRuntimeModule["_test"]["getResolvedSpeechProviderConfig"]; let formatTtsProviderError: TtsRuntimeModule["_test"]["formatTtsProviderError"]; let sanitizeTtsErrorForLog: TtsRuntimeModule["_test"]["sanitizeTtsErrorForLog"]; -vi.mock("@mariozechner/pi-ai", async () => { - const original = - await vi.importActual("@mariozechner/pi-ai"); +vi.mock("@mariozechner/pi-ai", () => { + const getApiProvider = vi.fn(() => undefined); return { - ...original, completeSimple: vi.fn(), + createAssistantMessageEventStream: vi.fn(), + getApiProvider, + getModel: vi.fn(), + registerApiProvider: vi.fn(), + streamAnthropic: vi.fn(), + streamSimple: vi.fn(), + streamSimpleOpenAICompletions: vi.fn(), }; }); -vi.mock("@mariozechner/pi-ai/oauth", async () => { - const actual = await vi.importActual( - "@mariozechner/pi-ai/oauth", - ); +vi.mock("@mariozechner/pi-ai/oauth", () => { return { - ...actual, getOAuthProviders: () => [], getOAuthApiKey: vi.fn(async () => null), + loginOpenAICodex: vi.fn(), }; }); @@ -396,11 +408,19 @@ function buildTestGoogleSpeechProvider(): SpeechProviderPlugin { } async function loadTtsRuntime(): Promise { - ttsRuntimePromise ??= import("../../../src/tts/tts.js"); + ttsRuntimePromise ??= import(speechCoreRuntimeApiModuleId) as Promise; return await ttsRuntimePromise; } +async function loadTtsCore(): Promise { + ttsCorePromise ??= import("../../../src/tts/tts-core.js"); + return await ttsCorePromise; +} + async function setupTtsRuntime() { + if (ttsRuntimeInitialized) { + return; + } ttsRuntime = await loadTtsRuntime(); resolveTtsConfig = ttsRuntime.resolveTtsConfig; maybeApplyTtsToPayload = ttsRuntime.maybeApplyTtsToPayload; @@ -408,11 +428,11 @@ async function setupTtsRuntime() { ({ parseTtsDirectives, resolveModelOverridePolicy, - summarizeText, getResolvedSpeechProviderConfig, formatTtsProviderError, sanitizeTtsErrorForLog, } = ttsRuntime._test); + ttsRuntimeInitialized = true; } function setupTestSpeechProviderRegistry() { @@ -424,11 +444,41 @@ function setupTestSpeechProviderRegistry() { { pluginId: "elevenlabs", provider: buildTestElevenLabsSpeechProvider(), source: "test" }, { pluginId: "google", provider: buildTestGoogleSpeechProvider(), source: "test" }, ]; - const { cacheKey } = pluginLoaderTesting.resolvePluginLoadCacheContext({ config: {} }); - setActivePluginRegistry(registry, cacheKey); + setActivePluginRegistry(registry); +} + +function createResolvedSummarizationConfig(cfg: OpenClawConfig): ResolvedTtsConfig { + const rawConfig = + typeof cfg.messages?.tts === "object" && cfg.messages?.tts !== null ? cfg.messages.tts : {}; + return { + auto: "off", + mode: rawConfig.mode ?? "final", + provider: "", + providerSource: + typeof rawConfig.provider === "string" && rawConfig.provider ? "config" : "default", + summaryModel: typeof rawConfig.summaryModel === "string" ? rawConfig.summaryModel : undefined, + modelOverrides: { + enabled: true, + allowText: true, + allowProvider: false, + allowVoice: true, + allowModelId: true, + allowVoiceSettings: true, + allowNormalization: true, + allowSeed: true, + }, + providerConfigs: {}, + prefsPath: typeof rawConfig.prefsPath === "string" ? rawConfig.prefsPath : undefined, + maxTextLength: typeof rawConfig.maxTextLength === "number" ? rawConfig.maxTextLength : 4096, + timeoutMs: typeof rawConfig.timeoutMs === "number" ? rawConfig.timeoutMs : 30_000, + rawConfig, + sourceConfig: cfg, + }; } async function setupSummarizationMocks() { + ({ summarizeText: summarizeTextCore } = await loadTtsCore()); + prepareModelForSimpleCompletionMock = vi.fn(({ model }) => model); ({ completeSimple } = await import("@mariozechner/pi-ai")); ({ getApiKeyForModel: getApiKeyForModelMock, requireApiKey: requireApiKeyMock } = await import("../../../src/agents/model-auth.js")); @@ -452,6 +502,7 @@ async function setupSummarizationMocks() { >, ); vi.mocked(ensureCustomApiRegisteredMock).mockReset(); + prepareModelForSimpleCompletionMock = vi.fn(({ model }) => model); } async function setupTtsContractTest() { @@ -461,7 +512,7 @@ async function setupTtsContractTest() { } async function setupTtsSummarizationTest() { - await setupTtsContractTest(); + vi.clearAllMocks(); await setupSummarizationMocks(); } @@ -797,8 +848,8 @@ export function describeTtsSummarizationContract() { cfg?: OpenClawConfig; }) { const cfg = params?.cfg ?? baseCfg; - const config = resolveTtsConfig(cfg); - return await summarizeText( + const config = createResolvedSummarizationConfig(cfg); + return await summarizeTextCore( { text: params?.text ?? "Long text to summarize", targetLength: params?.targetLength ?? 500, @@ -954,8 +1005,7 @@ export function describeTtsProviderRuntimeContract() { { pluginId: "openai", provider: throwingPrimary, source: "test" }, { pluginId: "microsoft", provider: fallback, source: "test" }, ]; - const { cacheKey } = pluginLoaderTesting.resolvePluginLoadCacheContext({ config: {} }); - setActivePluginRegistry(registry, cacheKey); + setActivePluginRegistry(registry); const result = await ttsRuntime.synthesizeSpeech({ text: "hello fallback", @@ -1023,8 +1073,7 @@ export function describeTtsProviderRuntimeContract() { { pluginId: "primary-throws", provider: throwingPrimary, source: "test" }, { pluginId: "microsoft", provider: fallback, source: "test" }, ]; - const { cacheKey } = pluginLoaderTesting.resolvePluginLoadCacheContext({ config: {} }); - setActivePluginRegistry(registry, cacheKey); + setActivePluginRegistry(registry); const result = await ttsRuntime.textToSpeechTelephony({ text: "hello telephony fallback", @@ -1071,8 +1120,7 @@ export function describeTtsProviderRuntimeContract() { registry.speechProviders = [ { pluginId: "openai", provider: failingProvider, source: "test" }, ]; - const { cacheKey } = pluginLoaderTesting.resolvePluginLoadCacheContext({ config: {} }); - setActivePluginRegistry(registry, cacheKey); + setActivePluginRegistry(registry); const result = await ttsRuntime.textToSpeech({ text: "hello", diff --git a/test/helpers/plugins/web-fetch-provider-contract.ts b/test/helpers/plugins/web-fetch-provider-contract.ts index 34298a65441..d4c6a5c59ad 100644 --- a/test/helpers/plugins/web-fetch-provider-contract.ts +++ b/test/helpers/plugins/web-fetch-provider-contract.ts @@ -3,14 +3,39 @@ import { pluginRegistrationContractRegistry, resolveWebFetchProviderContractEntriesForPluginId, } from "../../../src/plugins/contracts/registry.js"; +import type { WebFetchProviderPlugin } from "../../../src/plugins/types.js"; +import { resolveBundledExplicitWebFetchProvidersFromPublicArtifacts } from "../../../src/plugins/web-provider-public-artifacts.explicit.js"; import { installWebFetchProviderContractSuite } from "./provider-contract-suites.js"; +function resolveWebFetchCredentialValue(provider: WebFetchProviderPlugin): unknown { + if (provider.requiresCredential === false) { + return `${provider.id}-no-key-needed`; + } + const envVar = provider.envVars.find((entry) => entry.trim().length > 0); + if (!envVar) { + return `${provider.id}-test`; + } + return envVar.toLowerCase().includes("api_key") ? `${provider.id}-test` : "sk-test"; +} + export function describeWebFetchProviderContracts(pluginId: string) { const providerIds = pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId) ?.webFetchProviderIds ?? []; - const resolveProviders = () => resolveWebFetchProviderContractEntriesForPluginId(pluginId); + const resolveProviders = () => { + const publicArtifactProviders = resolveBundledExplicitWebFetchProvidersFromPublicArtifacts({ + onlyPluginIds: [pluginId], + }); + if (publicArtifactProviders) { + return publicArtifactProviders.map((provider) => ({ + pluginId: provider.pluginId, + provider, + credentialValue: resolveWebFetchCredentialValue(provider), + })); + } + return resolveWebFetchProviderContractEntriesForPluginId(pluginId); + }; describe(`${pluginId} web fetch provider contract registry load`, () => { it("loads bundled web fetch providers", () => { diff --git a/test/helpers/plugins/web-search-provider-contract.ts b/test/helpers/plugins/web-search-provider-contract.ts index d4bcdf84968..76b7b502ae0 100644 --- a/test/helpers/plugins/web-search-provider-contract.ts +++ b/test/helpers/plugins/web-search-provider-contract.ts @@ -3,14 +3,49 @@ import { pluginRegistrationContractRegistry, resolveWebSearchProviderContractEntriesForPluginId, } from "../../../src/plugins/contracts/registry.js"; +import { resolveBundledExplicitWebSearchProvidersFromPublicArtifacts } from "../../../src/plugins/web-provider-public-artifacts.explicit.js"; import { installWebSearchProviderContractSuite } from "./provider-contract-suites.js"; +type WebSearchContractEntry = ReturnType< + typeof resolveWebSearchProviderContractEntriesForPluginId +>[number]; + +function resolveWebSearchCredentialValue(provider: { + id: string; + requiresCredential?: boolean; + envVars: readonly string[]; +}): unknown { + if (provider.requiresCredential === false) { + return `${provider.id}-no-key-needed`; + } + const envVar = provider.envVars.find((entry) => entry.trim().length > 0); + if (!envVar) { + return `${provider.id}-test`; + } + if (envVar === "OPENROUTER_API_KEY") { + return "openrouter-test"; + } + return envVar.toLowerCase().includes("api_key") ? `${provider.id}-test` : "sk-test"; +} + export function describeWebSearchProviderContracts(pluginId: string) { const providerIds = pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId) ?.webSearchProviderIds ?? []; - const resolveProviders = () => resolveWebSearchProviderContractEntriesForPluginId(pluginId); + const resolveProviders = (): WebSearchContractEntry[] => { + const publicArtifactProviders = resolveBundledExplicitWebSearchProvidersFromPublicArtifacts({ + onlyPluginIds: [pluginId], + }); + if (publicArtifactProviders) { + return publicArtifactProviders.map((provider) => ({ + pluginId: provider.pluginId, + provider, + credentialValue: resolveWebSearchCredentialValue(provider), + })); + } + return resolveWebSearchProviderContractEntriesForPluginId(pluginId); + }; describe(`${pluginId} web search provider contract registry load`, () => { it("loads bundled web search providers", () => { @@ -22,7 +57,7 @@ export function describeWebSearchProviderContracts(pluginId: string) { describe(`${pluginId}:${providerId} web search contract`, () => { installWebSearchProviderContractSuite({ provider: () => { - const entry = resolveProviders().find((provider) => provider.provider.id === providerId); + const entry = resolveProviders().find((entry) => entry.provider.id === providerId); if (!entry) { throw new Error( `web search provider contract entry missing for ${pluginId}:${providerId}`, @@ -31,7 +66,7 @@ export function describeWebSearchProviderContracts(pluginId: string) { return entry.provider; }, credentialValue: () => { - const entry = resolveProviders().find((provider) => provider.provider.id === providerId); + const entry = resolveProviders().find((entry) => entry.provider.id === providerId); if (!entry) { throw new Error( `web search provider contract entry missing for ${pluginId}:${providerId}`, diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts index a84235dd023..f4c24330182 100644 --- a/test/scripts/test-extension.test.ts +++ b/test/scripts/test-extension.test.ts @@ -282,96 +282,112 @@ describe("scripts/test-extension.mjs", () => { expect(batch.planGroups).toEqual([ { config: "test/vitest/vitest.extension-acpx.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["acpx"], roots: [bundledPluginRoot("acpx")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-bluebubbles.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["bluebubbles"], roots: [bundledPluginRoot("bluebubbles")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-channels.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["line", "slack"], roots: [bundledPluginRoot("slack"), bundledPluginRoot("line")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-diffs.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["diffs"], roots: [bundledPluginRoot("diffs")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-feishu.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["feishu"], roots: [bundledPluginRoot("feishu")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-irc.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["irc"], roots: [bundledPluginRoot("irc")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-matrix.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["matrix"], roots: [bundledPluginRoot("matrix")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-mattermost.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["mattermost"], roots: [bundledPluginRoot("mattermost")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-memory.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["memory-core"], roots: [bundledPluginRoot("memory-core")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-msteams.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["msteams"], roots: [bundledPluginRoot("msteams")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-providers.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["openai"], roots: [bundledPluginRoot("openai")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-telegram.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["telegram"], roots: [bundledPluginRoot("telegram")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-voice-call.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["voice-call"], roots: [bundledPluginRoot("voice-call")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-whatsapp.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["whatsapp"], roots: [bundledPluginRoot("whatsapp")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extension-zalo.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["zalo", "zalouser"], roots: [bundledPluginRoot("zalo"), bundledPluginRoot("zalouser")], testFileCount: expect.any(Number), }, { config: "test/vitest/vitest.extensions.config.ts", + estimatedCost: expect.any(Number), extensionIds: ["firecrawl"], roots: [bundledPluginRoot("firecrawl")], testFileCount: expect.any(Number), @@ -379,7 +395,7 @@ describe("scripts/test-extension.mjs", () => { ]); }); - it("balances extension test shards by test file count", () => { + it("balances extension test shards by estimated CI cost", () => { const shards = createExtensionTestShards({ cwd: process.cwd(), shardCount: DEFAULT_EXTENSION_TEST_SHARD_COUNT, @@ -402,8 +418,15 @@ describe("scripts/test-extension.mjs", () => { ); expect(assigned).toHaveLength(expected.length); - const totals = shards.map((shard) => shard.testFileCount); + const totals = shards.map((shard) => shard.estimatedCost); expect(Math.max(...totals) - Math.min(...totals)).toBeLessThanOrEqual(1); + + const msTeamsShardIndex = shards.findIndex((shard) => shard.extensionIds.includes("msteams")); + const feishuShardIndex = shards.findIndex((shard) => shard.extensionIds.includes("feishu")); + + expect(msTeamsShardIndex).toBeGreaterThanOrEqual(0); + expect(feishuShardIndex).toBeGreaterThanOrEqual(0); + expect(msTeamsShardIndex).not.toBe(feishuShardIndex); }); it("treats extensions without tests as a no-op by default", () => { diff --git a/test/scripts/test-live-cli-backend-docker.test.ts b/test/scripts/test-live-cli-backend-docker.test.ts new file mode 100644 index 00000000000..807a175ce17 --- /dev/null +++ b/test/scripts/test-live-cli-backend-docker.test.ts @@ -0,0 +1,22 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const SCRIPT_PATH = path.resolve( + import.meta.dirname, + "../../scripts/test-live-cli-backend-docker.sh", +); + +function readForwardedDockerEnvVars(): string[] { + const script = fs.readFileSync(SCRIPT_PATH, "utf8"); + return Array.from(script.matchAll(/-e\s+([A-Z0-9_]+)=/g), (match) => match[1] ?? ""); +} + +describe("scripts/test-live-cli-backend-docker.sh", () => { + it("forwards both fresh and resume CLI arg overrides into the Docker container", () => { + const forwardedVars = readForwardedDockerEnvVars(); + + expect(forwardedVars).toContain("OPENCLAW_LIVE_CLI_BACKEND_ARGS"); + expect(forwardedVars).toContain("OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS"); + }); +}); diff --git a/test/vitest-projects-config.test.ts b/test/vitest-projects-config.test.ts index 9186350b702..93b6dd0a985 100644 --- a/test/vitest-projects-config.test.ts +++ b/test/vitest-projects-config.test.ts @@ -41,6 +41,19 @@ describe("projects vitest config", () => { expect(normalizeConfigPath(config.test.runner)).toBe("test/non-isolated-runner.ts"); }); + it("narrows the contracts lane to targeted contract files", () => { + const config = createContractsVitestConfig({}, [ + "node", + "vitest", + "run", + "src/plugins/contracts/bundled-web-search.google.contract.test.ts", + ]); + + expect(config.test.include).toEqual([ + "src/plugins/contracts/bundled-web-search.google.contract.test.ts", + ]); + }); + it("keeps the root ui lane aligned with the isolated jsdom setup", () => { const config = createUiVitestConfig(); expect(config.test.environment).toBe("jsdom"); diff --git a/test/vitest/vitest.contracts.config.ts b/test/vitest/vitest.contracts.config.ts index eacb6ce8981..adc768e7696 100644 --- a/test/vitest/vitest.contracts.config.ts +++ b/test/vitest/vitest.contracts.config.ts @@ -1,10 +1,25 @@ import { defineConfig } from "vitest/config"; +import { loadPatternListFromEnv, narrowIncludePatternsForCli } from "./vitest.pattern-file.ts"; import { nonIsolatedRunnerPath, sharedVitestConfig } from "./vitest.shared.config.ts"; const base = sharedVitestConfig as Record; const baseTest = sharedVitestConfig.test ?? {}; +const contractIncludePatterns = [ + "src/channels/plugins/contracts/**/*.test.ts", + "src/plugins/contracts/**/*.test.ts", +]; -export function createContractsVitestConfig() { +export function loadContractsIncludePatternsFromEnv( + env: Record = process.env, +): string[] | null { + return loadPatternListFromEnv("OPENCLAW_VITEST_INCLUDE_FILE", env); +} + +export function createContractsVitestConfig( + env: Record = process.env, + argv: string[] = process.argv, +) { + const cliIncludePatterns = narrowIncludePatternsForCli(contractIncludePatterns, argv); return defineConfig({ ...base, test: { @@ -16,10 +31,8 @@ export function createContractsVitestConfig() { pool: "forks", runner: nonIsolatedRunnerPath, setupFiles: baseTest.setupFiles ?? [], - include: [ - "src/channels/plugins/contracts/**/*.test.ts", - "src/plugins/contracts/**/*.test.ts", - ], + include: + loadContractsIncludePatternsFromEnv(env) ?? cliIncludePatterns ?? contractIncludePatterns, passWithNoTests: true, }, }); diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts index 51ffb7d3a2b..f3d8078a325 100644 --- a/ui/src/ui/app-settings.test.ts +++ b/ui/src/ui/app-settings.test.ts @@ -299,7 +299,7 @@ describe("applySettingsFromUrl", () => { }); it("hydrates query token params and strips them from the URL", () => { - setTestWindowUrl("https://control.example/ui/overview?token=abc123"); + setTestWindowUrl("https://control.example/ui/overview?token=abc123&password=sekret"); const host = createHost("overview"); host.settings.gatewayUrl = "wss://control.example/openclaw"; @@ -307,6 +307,9 @@ describe("applySettingsFromUrl", () => { expect(host.settings.token).toBe("abc123"); expect(window.location.search).toBe(""); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( + undefined, + ); }); it("prefers fragment tokens over legacy query tokens when both are present", () => { diff --git a/ui/src/ui/chat/context-notice.test.ts b/ui/src/ui/chat/context-notice.test.ts new file mode 100644 index 00000000000..20ae5a9e14c --- /dev/null +++ b/ui/src/ui/chat/context-notice.test.ts @@ -0,0 +1,149 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { GatewaySessionRow } from "../types.ts"; + +vi.mock("../markdown.ts", () => ({ + toSanitizedMarkdownHtml: (value: string) => value, +})); + +import { + getContextNoticeViewModel, + renderContextNotice, + resetContextNoticeThemeCacheForTest, +} from "./context-notice.ts"; +import { renderSideResult } from "./side-result-render.ts"; + +describe("context notice", () => { + afterEach(() => { + vi.restoreAllMocks(); + resetContextNoticeThemeCacheForTest(); + }); + + it("renders only for fresh high current usage", () => { + const container = document.createElement("div"); + vi.spyOn(window, "getComputedStyle").mockReturnValue({ + getPropertyValue: (name: string) => + name === "--warn" ? "#010203" : name === "--danger" ? "#040506" : "", + } as CSSStyleDeclaration); + resetContextNoticeThemeCacheForTest(); + + expect( + getContextNoticeViewModel( + { + key: "main", + kind: "direct", + updatedAt: null, + inputTokens: 757_300, + totalTokens: 46_000, + contextTokens: 200_000, + }, + 200_000, + ), + ).toBeNull(); + + const session: GatewaySessionRow = { + key: "main", + kind: "direct", + updatedAt: null, + inputTokens: 757_300, + totalTokens: 190_000, + contextTokens: 200_000, + }; + render(renderContextNotice(session, 200_000), container); + + expect(container.textContent).toContain("95% context used"); + expect(container.textContent).toContain("190k / 200k"); + expect(container.textContent).not.toContain("757.3k / 200k"); + const notice = container.querySelector(".context-notice"); + expect(notice).not.toBeNull(); + expect(notice?.style.getPropertyValue("--ctx-color")).toContain("rgb("); + expect(notice?.style.getPropertyValue("--ctx-color")).toContain("4, 5, 6"); + expect(notice?.style.getPropertyValue("--ctx-color")).not.toContain("NaN"); + expect(notice?.style.getPropertyValue("--ctx-bg")).not.toContain("NaN"); + + const icon = container.querySelector(".context-notice__icon"); + expect(icon).not.toBeNull(); + expect(icon?.tagName.toLowerCase()).toBe("svg"); + expect(icon?.classList.contains("context-notice__icon")).toBe(true); + expect(icon?.getAttribute("width")).toBe("16"); + expect(icon?.getAttribute("height")).toBe("16"); + expect(icon?.querySelector("path")).not.toBeNull(); + + expect( + getContextNoticeViewModel( + { + key: "main", + kind: "direct", + updatedAt: null, + inputTokens: 500_000, + contextTokens: 200_000, + }, + 200_000, + ), + ).toBeNull(); + expect( + getContextNoticeViewModel( + { + key: "main", + kind: "direct", + updatedAt: null, + totalTokens: 190_000, + totalTokensFresh: false, + contextTokens: 200_000, + }, + 200_000, + ), + ).toBeNull(); + }); +}); + +describe("side result render", () => { + it("renders, dismisses, and styles BTW side results outside transcript history", () => { + const container = document.createElement("div"); + const onDismissSideResult = vi.fn(); + + render( + renderSideResult( + { + kind: "btw", + runId: "btw-run-1", + sessionKey: "main", + question: "what changed?", + text: "The web UI now renders **BTW** separately.", + isError: false, + ts: 2, + }, + onDismissSideResult, + ), + container, + ); + + expect(container.querySelector(".chat-side-result")).not.toBeNull(); + expect(container.textContent).toContain("BTW"); + expect(container.textContent).toContain("what changed?"); + expect(container.textContent).toContain("Not saved to chat history"); + expect(container.querySelectorAll(".chat-side-result")).toHaveLength(1); + + const button = container.querySelector(".chat-side-result__dismiss"); + expect(button).not.toBeNull(); + button?.click(); + expect(onDismissSideResult).toHaveBeenCalledTimes(1); + + render( + renderSideResult({ + kind: "btw", + runId: "btw-run-3", + sessionKey: "main", + question: "what failed?", + text: "The side question could not be answered.", + isError: true, + ts: 4, + }), + container, + ); + + expect(container.querySelector(".chat-side-result--error")).not.toBeNull(); + }); +}); diff --git a/ui/src/ui/chat/context-notice.ts b/ui/src/ui/chat/context-notice.ts new file mode 100644 index 00000000000..79fd05bba5f --- /dev/null +++ b/ui/src/ui/chat/context-notice.ts @@ -0,0 +1,125 @@ +import { html, nothing } from "lit"; +import type { GatewaySessionRow } from "../types.ts"; + +/** Parse a 6-digit CSS hex color string to [r, g, b] integer components. */ +function parseHexRgb(hex: string): [number, number, number] | null { + const h = hex.trim().replace(/^#/, ""); + if (!/^[0-9a-fA-F]{6}$/.test(h)) { + return null; + } + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; +} + +let cachedThemeNoticeColors: { + warnHex: string; + dangerHex: string; + warnRgb: [number, number, number]; + dangerRgb: [number, number, number]; +} | null = null; + +function getThemeNoticeColors() { + if (cachedThemeNoticeColors) { + return cachedThemeNoticeColors; + } + const rootStyle = getComputedStyle(document.documentElement); + const warnHex = rootStyle.getPropertyValue("--warn").trim() || "#f59e0b"; + const dangerHex = rootStyle.getPropertyValue("--danger").trim() || "#ef4444"; + cachedThemeNoticeColors = { + warnHex, + dangerHex, + warnRgb: parseHexRgb(warnHex) ?? [245, 158, 11], + dangerRgb: parseHexRgb(dangerHex) ?? [239, 68, 68], + }; + return cachedThemeNoticeColors; +} + +export function resetContextNoticeThemeCacheForTest(): void { + cachedThemeNoticeColors = null; +} + +export function getContextNoticeViewModel( + session: GatewaySessionRow | undefined, + defaultContextTokens: number | null, +): { + pct: number; + detail: string; + color: string; + bg: string; +} | null { + if (session?.totalTokensFresh === false) { + return null; + } + const used = session?.totalTokens ?? 0; + const limit = session?.contextTokens ?? defaultContextTokens ?? 0; + if (!used || !limit) { + return null; + } + const ratio = used / limit; + if (ratio < 0.85) { + return null; + } + const pct = Math.min(Math.round(ratio * 100), 100); + // Read theme semantic tokens so color tracks the active theme (Dash, dark, light ...). + const { warnRgb, dangerRgb } = getThemeNoticeColors(); + const [wr, wg, wb] = warnRgb; + const [dr, dg, db] = dangerRgb; + const t = Math.min(Math.max((ratio - 0.85) / 0.1, 0), 1); + const r = Math.round(wr + (dr - wr) * t); + const g = Math.round(wg + (dg - wg) * t); + const b = Math.round(wb + (db - wb) * t); + const color = `rgb(${r}, ${g}, ${b})`; + const bgOpacity = 0.08 + 0.08 * t; + const bg = `rgba(${r}, ${g}, ${b}, ${bgOpacity})`; + return { + pct, + detail: `${formatTokensCompact(used)} / ${formatTokensCompact(limit)}`, + color, + bg, + }; +} + +export function renderContextNotice( + session: GatewaySessionRow | undefined, + defaultContextTokens: number | null, +) { + const model = getContextNoticeViewModel(session, defaultContextTokens); + if (!model) { + return nothing; + } + return html` +
+ + + + + + ${model.pct}% context used + ${model.detail} +
+ `; +} + +/** Format token count compactly (e.g. 128000 -> "128k"). */ +function formatTokensCompact(n: number): string { + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; + } + if (n >= 1_000) { + return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; + } + return String(n); +} diff --git a/ui/src/ui/chat/side-result-render.ts b/ui/src/ui/chat/side-result-render.ts new file mode 100644 index 00000000000..de4ca6f5085 --- /dev/null +++ b/ui/src/ui/chat/side-result-render.ts @@ -0,0 +1,43 @@ +import { html, nothing, type TemplateResult } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { icons } from "../icons.ts"; +import { toSanitizedMarkdownHtml } from "../markdown.ts"; +import { detectTextDirection } from "../text-direction.ts"; +import type { ChatSideResult } from "./side-result.ts"; + +export function renderSideResult( + sideResult: ChatSideResult | null | undefined, + onDismiss?: () => void, +): TemplateResult | typeof nothing { + if (!sideResult) { + return nothing; + } + return html` +
+
+
+ BTW + Not saved to chat history +
+ +
+
${sideResult.question}
+
+ ${unsafeHTML(toSanitizedMarkdownHtml(sideResult.text))} +
+
+ `; +} diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts index 555454c2426..dee630e3e82 100644 --- a/ui/src/ui/config-form.browser.test.ts +++ b/ui/src/ui/config-form.browser.test.ts @@ -32,12 +32,13 @@ const rootSchema = { }, }, }; +const rootAnalysis = analyzeConfigSchema(rootSchema); describe("config form renderer", () => { it("renders inputs and patches values", () => { const onPatch = vi.fn(); const container = document.createElement("div"); - const analysis = analyzeConfigSchema(rootSchema); + const analysis = rootAnalysis; render( renderConfigForm({ schema: analysis.schema, @@ -45,7 +46,7 @@ describe("config form renderer", () => { "gateway.auth.token": { label: "Gateway Token", sensitive: true }, }, unsupportedPaths: analysis.unsupportedPaths, - value: {}, + value: { allowFrom: ["+1"], bind: "auto" }, revealSensitive: true, onPatch, }), @@ -78,22 +79,6 @@ describe("config form renderer", () => { checkbox.checked = true; checkbox.dispatchEvent(new Event("change", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["enabled"], true); - }); - - it("adds and removes array entries", () => { - const onPatch = vi.fn(); - const container = document.createElement("div"); - const analysis = analyzeConfigSchema(rootSchema); - render( - renderConfigForm({ - schema: analysis.schema, - uiHints: {}, - unsupportedPaths: analysis.unsupportedPaths, - value: { allowFrom: ["+1"] }, - onPatch, - }), - container, - ); const addButton = container.querySelector(".cfg-array__add"); expect(addButton).not.toBeUndefined(); @@ -104,22 +89,6 @@ describe("config form renderer", () => { expect(removeButton).not.toBeUndefined(); removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []); - }); - - it("renders union literals as select options", () => { - const onPatch = vi.fn(); - const container = document.createElement("div"); - const analysis = analyzeConfigSchema(rootSchema); - render( - renderConfigForm({ - schema: analysis.schema, - uiHints: {}, - unsupportedPaths: analysis.unsupportedPaths, - value: { bind: "auto" }, - onPatch, - }), - container, - ); const tailnetButton = Array.from( container.querySelectorAll(".cfg-segmented__btn"), @@ -203,7 +172,7 @@ describe("config form renderer", () => { it("renders tags from uiHints metadata", () => { const onPatch = vi.fn(); const container = document.createElement("div"); - const analysis = analyzeConfigSchema(rootSchema); + const analysis = rootAnalysis; render( renderConfigForm({ schema: analysis.schema, @@ -222,12 +191,7 @@ describe("config form renderer", () => { ); expect(tags).toContain("security"); expect(tags).toContain("secret"); - }); - it("filters by tag query", () => { - const onPatch = vi.fn(); - const container = document.createElement("div"); - const analysis = analyzeConfigSchema(rootSchema); render( renderConfigForm({ schema: analysis.schema, @@ -248,65 +212,6 @@ describe("config form renderer", () => { expect(container.textContent).not.toContain("Mode"); }); - it("does not treat plain text as tag filter", () => { - const onPatch = vi.fn(); - const container = document.createElement("div"); - const analysis = analyzeConfigSchema(rootSchema); - render( - renderConfigForm({ - schema: analysis.schema, - uiHints: { - "gateway.auth.token": { tags: ["security"] }, - }, - unsupportedPaths: analysis.unsupportedPaths, - value: {}, - searchQuery: "security", - onPatch, - }), - container, - ); - - expect(container.textContent).toContain('No settings match "security"'); - }); - - it("requires both text and tag when combined", () => { - const onPatch = vi.fn(); - const container = document.createElement("div"); - const analysis = analyzeConfigSchema(rootSchema); - render( - renderConfigForm({ - schema: analysis.schema, - uiHints: { - "gateway.auth.token": { tags: ["security"] }, - }, - unsupportedPaths: analysis.unsupportedPaths, - value: {}, - searchQuery: "token tag:security", - onPatch, - }), - container, - ); - - expect(container.textContent).toContain("Token"); - expect(container.textContent).not.toContain('No settings match "token tag:security"'); - - const noMatchContainer = document.createElement("div"); - render( - renderConfigForm({ - schema: analysis.schema, - uiHints: { - "gateway.auth.token": { tags: ["security"] }, - }, - unsupportedPaths: analysis.unsupportedPaths, - value: {}, - searchQuery: "mode tag:security", - onPatch, - }), - noMatchContainer, - ); - expect(noMatchContainer.textContent).toContain('No settings match "mode tag:security"'); - }); - it("supports SecretInput unions in additionalProperties maps", () => { const onPatch = vi.fn(); const container = document.createElement("div"); @@ -388,7 +293,7 @@ describe("config form renderer", () => { }); it("accepts renderable unions", () => { - const schema = { + const renderableUnionSchema = { type: "object", properties: { mixed: { @@ -396,23 +301,19 @@ describe("config form renderer", () => { }, }, }; - const analysis = analyzeConfigSchema(schema); + let analysis = analyzeConfigSchema(renderableUnionSchema); expect(analysis.unsupportedPaths).not.toContain("mixed"); - }); - it("supports nullable types", () => { - const schema = { + const nullableSchema = { type: "object", properties: { note: { type: ["string", "null"] }, }, }; - const analysis = analyzeConfigSchema(schema); + analysis = analyzeConfigSchema(nullableSchema); expect(analysis.unsupportedPaths).not.toContain("note"); - }); - it("ignores untyped additionalProperties schemas", () => { - const schema = { + const untypedAdditionalPropertiesSchema = { type: "object", properties: { channels: { @@ -429,7 +330,7 @@ describe("config form renderer", () => { }, }, }; - const analysis = analyzeConfigSchema(schema); + analysis = analyzeConfigSchema(untypedAdditionalPropertiesSchema); expect(analysis.unsupportedPaths).not.toContain("channels"); }); diff --git a/ui/src/ui/markdown.test.ts b/ui/src/ui/markdown.test.ts index 473b1fc07b3..9794ca8dd6a 100644 --- a/ui/src/ui/markdown.test.ts +++ b/ui/src/ui/markdown.test.ts @@ -1,5 +1,7 @@ +import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; import { md, toSanitizedMarkdownHtml } from "./markdown.ts"; +import { renderMarkdownSidebar } from "./views/markdown-sidebar.ts"; describe("toSanitizedMarkdownHtml", () => { // ── Original tests from before markdown-it migration ── @@ -424,8 +426,10 @@ describe("toSanitizedMarkdownHtml", () => { describe("ReDoS protection", () => { it("does not throw on deeply nested emphasis markers (#36213)", () => { const nested = "*".repeat(500) + "text" + "*".repeat(500); - expect(() => toSanitizedMarkdownHtml(nested)).not.toThrow(); - const html = toSanitizedMarkdownHtml(nested); + let html = ""; + expect(() => { + html = toSanitizedMarkdownHtml(nested); + }).not.toThrow(); expect(html).toContain("text"); }); @@ -467,7 +471,7 @@ describe("toSanitizedMarkdownHtml", () => { it("uses plain text fallback for oversized content", () => { // MARKDOWN_PARSE_LIMIT is 40_000 chars const input = Array.from( - { length: 320 }, + { length: 220 }, (_, i) => `Paragraph ${i + 1}: ${"Long plain-text reply. ".repeat(8)}`, ).join("\n\n"); const html = toSanitizedMarkdownHtml(input); @@ -475,7 +479,7 @@ describe("toSanitizedMarkdownHtml", () => { }); it("preserves indentation in plain text fallback", () => { - const input = `${"Header line\n".repeat(5000)}\n indented log line\n deeper indent`; + const input = `${"Header line\n".repeat(3400)}\n indented log line\n deeper indent`; const html = toSanitizedMarkdownHtml(input); expect(html).toContain('class="markdown-plain-text-fallback"'); expect(html).toContain(" indented log line"); @@ -508,3 +512,21 @@ describe("toSanitizedMarkdownHtml", () => { }); }); }); + +describe("renderMarkdownSidebar", () => { + it("renders sanitized markdown content", () => { + const container = document.createElement("div"); + + render( + renderMarkdownSidebar({ + content: { kind: "markdown", content: "Hello **world**" }, + error: null, + onClose: () => undefined, + onViewRawText: () => undefined, + }), + container, + ); + + expect(container.querySelector(".sidebar-markdown strong")?.textContent).toBe("world"); + }); +}); diff --git a/ui/src/ui/navigation.browser.test.ts b/ui/src/ui/navigation.browser.test.ts index 7ff06be9f70..00bd09c3315 100644 --- a/ui/src/ui/navigation.browser.test.ts +++ b/ui/src/ui/navigation.browser.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import "../test-helpers/load-styles.ts"; import { mountApp as mountTestApp, registerAppMountHooks } from "./test-helpers/app-mount.ts"; registerAppMountHooks(); @@ -39,130 +38,15 @@ function expectConfirmedGatewayChange(app: ReturnType) { } describe("control UI routing", () => { - it("hydrates the tab from the location", async () => { - const app = mountApp("/sessions"); - await app.updateComplete; - - expect(app.tab).toBe("sessions"); - expect(window.location.pathname).toBe("/sessions"); - }); - - it("respects /ui base paths", async () => { - const app = mountApp("/ui/cron"); - await app.updateComplete; - - expect(app.basePath).toBe("/ui"); - expect(app.tab).toBe("cron"); - expect(window.location.pathname).toBe("/ui/cron"); - }); - - it("infers nested base paths", async () => { - const app = mountApp("/apps/openclaw/cron"); - await app.updateComplete; - - expect(app.basePath).toBe("/apps/openclaw"); - expect(app.tab).toBe("cron"); - expect(window.location.pathname).toBe("/apps/openclaw/cron"); - }); - - it("honors explicit base path overrides", async () => { - window.__OPENCLAW_CONTROL_UI_BASE_PATH__ = "/openclaw"; - const app = mountApp("/openclaw/sessions"); - await app.updateComplete; - - expect(app.basePath).toBe("/openclaw"); - expect(app.tab).toBe("sessions"); - expect(window.location.pathname).toBe("/openclaw/sessions"); - }); - - it("keeps chat navigation links visible and updates the URL when clicked", async () => { - const app = mountApp("/chat"); - await app.updateComplete; - - const dreamsLink = app.querySelector('a.nav-item[href="/dreaming"]'); - expect(dreamsLink).not.toBeNull(); - - const link = app.querySelector('a.nav-item[href="/channels"]'); - expect(link).not.toBeNull(); - link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); - - await app.updateComplete; - expect(app.tab).toBe("channels"); - expect(window.location.pathname).toBe("/channels"); - }); - - it("renders the dreaming view on the /dreaming route", async () => { - const app = mountApp("/dreaming"); - app.dreamingStatus = { - enabled: true, - timezone: "Europe/Madrid", - verboseLogging: false, - storageMode: "inline", - separateReports: false, - shortTermCount: 2, - recallSignalCount: 1, - dailySignalCount: 1, - groundedSignalCount: 0, - totalSignalCount: 2, - phaseSignalCount: 0, - lightPhaseHitCount: 0, - remPhaseHitCount: 0, - promotedTotal: 1, - promotedToday: 1, - shortTermEntries: [], - signalEntries: [], - promotedEntries: [], - phases: { - light: { enabled: true, cron: "", managedCronPresent: false, lookbackDays: 7, limit: 20 }, - deep: { - enabled: true, - cron: "", - managedCronPresent: false, - limit: 20, - minScore: 0.75, - minRecallCount: 3, - minUniqueQueries: 2, - recencyHalfLifeDays: 7, - }, - rem: { - enabled: true, - cron: "", - managedCronPresent: false, - lookbackDays: 7, - limit: 20, - minPatternStrength: 0.6, - }, - }, - }; - app.dreamDiaryPath = "DREAMS.md"; - app.dreamDiaryContent = [ - "# Dream Diary", - "", - "", - "", - "---", - "", - "*January 1, 2026*", - "", - "What Happened", - "1. Stable operator rule surfaced.", - "", - "", - ].join("\n"); - app.requestUpdate(); - await app.updateComplete; - - expect(app.tab).toBe("dreams"); - expect(app.querySelector(".dreams__tab")).not.toBeNull(); - expect(app.querySelector(".dreams__lobster")).not.toBeNull(); - }); - it("renders responsive navigation shell, drawer, and collapsed states", async () => { const app = mountApp("/chat"); await app.updateComplete; expect(window.matchMedia("(max-width: 768px)").matches).toBe(true); + const dreamsLink = app.querySelector('a.nav-item[href="/dreaming"]'); + expect(dreamsLink).not.toBeNull(); + expect(app.querySelector(".topnav-shell")).not.toBeNull(); expect(app.querySelector(".topnav-shell__content")).not.toBeNull(); expect(app.querySelector(".topnav-shell__actions")).not.toBeNull(); @@ -176,6 +60,19 @@ describe("control UI routing", () => { expect(app.querySelector(".sidebar-brand__logo")).not.toBeNull(); expect(app.querySelector(".sidebar-brand__copy")).not.toBeNull(); + app.hello = { + ok: true, + server: { version: "1.2.3" }, + } as never; + app.requestUpdate(); + await app.updateComplete; + + const version = app.querySelector(".sidebar-version"); + const statusDot = app.querySelector(".sidebar-version__status"); + expect(version).not.toBeNull(); + expect(statusDot).not.toBeNull(); + expect(statusDot?.getAttribute("aria-label")).toContain("Online"); + app.applySettings({ ...app.settings, navWidth: 360 }); await app.updateComplete; @@ -186,7 +83,6 @@ describe("control UI routing", () => { const split = app.querySelector(".chat-split-container"); expect(split).not.toBeNull(); if (split) { - expect(getComputedStyle(split).position).not.toBe("fixed"); split.classList.add("chat-split-container--open"); await app.updateComplete; expect(split.classList.contains("chat-split-container--open")).toBe(true); @@ -194,9 +90,6 @@ describe("control UI routing", () => { const chatMain = app.querySelector(".chat-main"); expect(chatMain).not.toBeNull(); - if (chatMain) { - expect(getComputedStyle(chatMain).display).not.toBe("none"); - } const topShell = app.querySelector(".topnav-shell"); const content = app.querySelector(".topnav-shell__content"); @@ -275,7 +168,7 @@ describe("control UI routing", () => { expect(header.querySelector(".nav-collapse-toggle")).not.toBeNull(); }); - it("preserves the active session when opening chat from sidebar navigation", async () => { + it("preserves session navigation and keeps focus mode scoped to chat", async () => { const app = mountApp("/sessions?session=agent:main:subagent:task-123"); await app.updateComplete; @@ -288,11 +181,6 @@ describe("control UI routing", () => { expect(app.sessionKey).toBe("agent:main:subagent:task-123"); expect(window.location.pathname).toBe("/chat"); expect(window.location.search).toBe("?session=agent%3Amain%3Asubagent%3Atask-123"); - }); - - it("keeps focus mode scoped to the chat tab", async () => { - const app = mountApp("/chat"); - await app.updateComplete; const shell = app.querySelector(".shell"); expect(shell).not.toBeNull(); @@ -305,9 +193,11 @@ describe("control UI routing", () => { await app.updateComplete; expect(shell?.classList.contains("shell--chat-focus")).toBe(true); - const link = app.querySelector('a.nav-item[href="/channels"]'); - expect(link).not.toBeNull(); - link?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 })); + const channelsLink = app.querySelector('a.nav-item[href="/channels"]'); + expect(channelsLink).not.toBeNull(); + channelsLink?.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }), + ); await app.updateComplete; expect(app.tab).toBe("channels"); @@ -323,24 +213,6 @@ describe("control UI routing", () => { expect(shell?.classList.contains("shell--chat-focus")).toBe(true); }); - it("shows one online status dot next to the sidebar version", async () => { - const app = mountApp("/chat"); - await app.updateComplete; - - app.hello = { - ok: true, - server: { version: "1.2.3" }, - } as never; - app.requestUpdate(); - await app.updateComplete; - - const version = app.querySelector(".sidebar-version"); - const statusDot = app.querySelector(".sidebar-version__status"); - expect(version).not.toBeNull(); - expect(statusDot).not.toBeNull(); - expect(statusDot?.getAttribute("aria-label")).toContain("Online"); - }); - it("auto-scrolls chat history to the latest message", async () => { vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { queueMicrotask(() => callback(performance.now())); @@ -436,39 +308,7 @@ describe("control UI routing", () => { expect(container.scrollTop).toBe(targetScrollTop); }); - it("hydrates safe query params and strips unsafe credentials from the URL", async () => { - const app = mountApp("/ui/overview?token=abc123&password=sekret"); - await app.updateComplete; - - expect(app.settings.token).toBe("abc123"); - expect(app.password).toBe(""); - expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( - undefined, - ); - expect(window.location.pathname).toBe("/ui/overview"); - expect(window.location.search).toBe(""); - }); - - it("hydrates token from URL hash when settings already set", async () => { - localStorage.setItem( - "openclaw.control.settings.v1", - 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.hash).toBe(""); - }); - - it("hydrates token from URL hash, strips it, and clears it after gateway changes", async () => { + it("hydrates hash tokens, restores same-tab refreshes, and clears after gateway changes", async () => { const app = mountApp("/ui/overview#token=abc123"); await app.updateComplete; @@ -478,17 +318,26 @@ describe("control UI routing", () => { ); expect(window.location.pathname).toBe("/ui/overview"); expect(window.location.hash).toBe(""); + app.remove(); - const gatewayUrlInput = app.querySelector( + const refreshed = mountApp("/ui/overview"); + await refreshed.updateComplete; + + expect(refreshed.settings.token).toBe("abc123"); + expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( + undefined, + ); + + const gatewayUrlInput = refreshed.querySelector( 'input[placeholder="ws://100.x.y.z:18789"]', ); expect(gatewayUrlInput).not.toBeNull(); gatewayUrlInput!.value = "wss://other-gateway.example/openclaw"; gatewayUrlInput!.dispatchEvent(new Event("input", { bubbles: true })); - await app.updateComplete; + await refreshed.updateComplete; - expect(app.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw"); - expect(app.settings.token).toBe(""); + expect(refreshed.settings.gatewayUrl).toBe("wss://other-gateway.example/openclaw"); + expect(refreshed.settings.token).toBe(""); }); it("keeps a hash token pending until the gateway URL change is confirmed", async () => { @@ -504,32 +353,4 @@ describe("control UI routing", () => { expectConfirmedGatewayChange(app); }); - - it("keeps a query token pending until the gateway URL change is confirmed", async () => { - const app = mountApp( - "/ui/overview?gatewayUrl=wss://other-gateway.example/openclaw&token=abc123", - ); - await app.updateComplete; - - expect(app.settings.gatewayUrl).not.toBe("wss://other-gateway.example/openclaw"); - expect(app.settings.token).toBe(""); - - await confirmPendingGatewayChange(app); - - expectConfirmedGatewayChange(app); - }); - - it("restores the token after a same-tab refresh", async () => { - const first = mountApp("/ui/overview#token=abc123"); - await first.updateComplete; - first.remove(); - - const refreshed = mountApp("/ui/overview"); - await refreshed.updateComplete; - - expect(refreshed.settings.token).toBe("abc123"); - expect(JSON.parse(localStorage.getItem("openclaw.control.settings.v1") ?? "{}").token).toBe( - undefined, - ); - }); }); diff --git a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts index a1cb98d96b1..23d424616da 100644 --- a/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts +++ b/ui/src/ui/views/agents-panels-tools-skills.browser.test.ts @@ -30,7 +30,7 @@ function createBaseParams(overrides: Partial } describe("agents tools panel (browser)", () => { - it("renders per-tool provenance badges and optional marker", async () => { + it("renders catalog provenance and effective runtime tools", async () => { const container = document.createElement("div"); render( renderAgentTools( @@ -77,39 +77,6 @@ describe("agents tools panel (browser)", () => { }, ], }, - }), - ), - container, - ); - await Promise.resolve(); - - const text = container.textContent ?? ""; - expect(text).toContain("core"); - expect(text).toContain("plugin:voice-call"); - expect(text).toContain("optional"); - }); - - it("shows fallback warning when runtime catalog fails", async () => { - const container = document.createElement("div"); - render( - renderAgentTools( - createBaseParams({ - toolsCatalogError: "unavailable", - toolsCatalogResult: null, - }), - ), - container, - ); - await Promise.resolve(); - - expect(container.textContent ?? "").toContain("Could not load runtime tool catalog"); - }); - - it("renders effective runtime tools separately from the config catalog", async () => { - const container = document.createElement("div"); - render( - renderAgentTools( - createBaseParams({ toolsEffectiveResult: { agentId: "main", profile: "messaging", @@ -138,8 +105,27 @@ describe("agents tools panel (browser)", () => { await Promise.resolve(); const text = container.textContent ?? ""; + expect(text).toContain("core"); + expect(text).toContain("plugin:voice-call"); + expect(text).toContain("optional"); expect(text).toContain("Available Right Now"); expect(text).toContain("Message Actions"); expect(text).toContain("Channel: discord"); }); + + it("shows fallback warning when runtime catalog fails", async () => { + const container = document.createElement("div"); + render( + renderAgentTools( + createBaseParams({ + toolsCatalogError: "unavailable", + toolsCatalogResult: null, + }), + ), + container, + ); + await Promise.resolve(); + + expect(container.textContent ?? "").toContain("Could not load runtime tool catalog"); + }); }); diff --git a/ui/src/ui/views/agents.test.ts b/ui/src/ui/views/agents.test.ts index 0735790ee7a..c48fb3bda09 100644 --- a/ui/src/ui/views/agents.test.ts +++ b/ui/src/ui/views/agents.test.ts @@ -144,15 +144,12 @@ describe("renderAgents", () => { ); await Promise.resolve(); - const skillsTab = Array.from(container.querySelectorAll(".agent-tab")).find( + let skillsTab = Array.from(container.querySelectorAll(".agent-tab")).find( (button) => button.textContent?.includes("Skills"), ); expect(skillsTab?.textContent?.trim()).toBe("Skills"); - }); - it("shows the selected agent's skills count when the report matches", async () => { - const container = document.createElement("div"); render( renderAgents( createProps({ @@ -173,7 +170,7 @@ describe("renderAgents", () => { ); await Promise.resolve(); - const skillsTab = Array.from(container.querySelectorAll(".agent-tab")).find( + skillsTab = Array.from(container.querySelectorAll(".agent-tab")).find( (button) => button.textContent?.includes("Skills"), ); diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 1d548d62535..9cd466b46d6 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -3,11 +3,29 @@ import { render } from "lit"; import { describe, expect, it, vi } from "vitest"; import { getSafeLocalStorage } from "../../local-storage.ts"; -import { resetAssistantAttachmentAvailabilityCacheForTest } from "../chat/grouped-render.ts"; +import { + renderMessageGroup, + resetAssistantAttachmentAvailabilityCacheForTest, +} from "../chat/grouped-render.ts"; import { normalizeMessage } from "../chat/message-normalizer.ts"; import type { SessionsListResult } from "../types.ts"; +import type { MessageGroup } from "../types/chat-types.ts"; import { renderChat, type ChatProps } from "./chat.ts"; +vi.mock("../markdown.ts", () => ({ + toSanitizedMarkdownHtml: (value: string) => value, +})); + +vi.mock("./markdown-sidebar.ts", async () => { + const { html } = await import("lit"); + return { + renderMarkdownSidebar: (props: { content?: { content?: string; title?: string } | null }) => + html``, + }; +}); + function createSessions(): SessionsListResult { return { ts: 0, @@ -72,6 +90,39 @@ function createProps(overrides: Partial = {}): ChatProps { }; } +type RenderMessageGroupOptions = Parameters[1]; + +function renderAssistantMessage( + container: HTMLElement, + message: unknown, + opts: Partial = {}, +) { + const timestamp = + typeof message === "object" && + message !== null && + typeof (message as { timestamp?: unknown }).timestamp === "number" + ? (message as { timestamp: number }).timestamp + : Date.now(); + const group: MessageGroup = { + kind: "group", + key: "assistant-group", + role: "assistant", + messages: [{ key: "assistant-message", message }], + timestamp, + isStreaming: false, + }; + render( + renderMessageGroup(group, { + showReasoning: true, + showToolCalls: true, + assistantName: "OpenClaw", + assistantAvatar: null, + ...opts, + }), + container, + ); +} + function clearDeleteConfirmSkip() { try { getSafeLocalStorage()?.removeItem("openclaw:skipDeleteConfirm"); @@ -81,147 +132,6 @@ function clearDeleteConfirmSkip() { } describe("chat view", () => { - it("renders, dismisses, and styles BTW side results outside transcript history", () => { - const container = document.createElement("div"); - const onDismissSideResult = vi.fn(); - render( - renderChat( - createProps({ - messages: [ - { - role: "assistant", - content: [{ type: "text", text: "Saved transcript message" }], - timestamp: 1, - }, - ], - sideResult: { - kind: "btw", - runId: "btw-run-1", - sessionKey: "main", - question: "what changed?", - text: "The web UI now renders **BTW** separately.", - isError: false, - ts: 2, - }, - onDismissSideResult, - }), - ), - container, - ); - - expect(container.querySelector(".chat-side-result")).not.toBeNull(); - expect(container.textContent).toContain("BTW"); - expect(container.textContent).toContain("what changed?"); - expect(container.textContent).toContain("Not saved to chat history"); - expect(container.textContent).toContain("Saved transcript message"); - expect(container.querySelectorAll(".chat-side-result")).toHaveLength(1); - - const button = container.querySelector(".chat-side-result__dismiss"); - expect(button).not.toBeNull(); - button?.click(); - expect(onDismissSideResult).toHaveBeenCalledTimes(1); - - render( - renderChat( - createProps({ - sideResult: { - kind: "btw", - runId: "btw-run-3", - sessionKey: "main", - question: "what failed?", - text: "The side question could not be answered.", - isError: true, - ts: 4, - }, - }), - ), - container, - ); - - expect(container.querySelector(".chat-side-result--error")).not.toBeNull(); - }); - - it("renders the context notice only for fresh high current usage", () => { - const container = document.createElement("div"); - document.documentElement.style.setProperty("--warn", "rgb(1, 2, 3)"); - document.documentElement.style.setProperty("--danger", "tomato"); - - const renderWithSession = (session: NonNullable["sessions"][number]) => - render( - renderChat( - createProps({ - sessions: { - ts: 0, - path: "", - count: 1, - defaults: { modelProvider: "openai", model: "gpt-5", contextTokens: 200_000 }, - sessions: [session], - }, - }), - ), - container, - ); - - renderWithSession({ - key: "main", - kind: "direct", - updatedAt: null, - inputTokens: 757_300, - totalTokens: 46_000, - contextTokens: 200_000, - }); - expect(container.textContent).not.toContain("context used"); - expect(container.textContent).not.toContain("757.3k / 200k"); - - renderWithSession({ - key: "main", - kind: "direct", - updatedAt: null, - inputTokens: 757_300, - totalTokens: 190_000, - contextTokens: 200_000, - }); - expect(container.textContent).toContain("95% context used"); - expect(container.textContent).toContain("190k / 200k"); - expect(container.textContent).not.toContain("757.3k / 200k"); - const notice = container.querySelector(".context-notice"); - expect(notice).not.toBeNull(); - expect(notice?.style.getPropertyValue("--ctx-color")).toContain("rgb("); - expect(notice?.style.getPropertyValue("--ctx-color")).not.toContain("NaN"); - expect(notice?.style.getPropertyValue("--ctx-bg")).not.toContain("NaN"); - - const icon = container.querySelector(".context-notice__icon"); - expect(icon).not.toBeNull(); - expect(icon?.tagName.toLowerCase()).toBe("svg"); - expect(icon?.classList.contains("context-notice__icon")).toBe(true); - expect(icon?.getAttribute("width")).toBe("16"); - expect(icon?.getAttribute("height")).toBe("16"); - expect(icon?.querySelector("path")).not.toBeNull(); - - document.documentElement.style.removeProperty("--warn"); - document.documentElement.style.removeProperty("--danger"); - - renderWithSession({ - key: "main", - kind: "direct", - updatedAt: null, - inputTokens: 500_000, - contextTokens: 200_000, - }); - expect(container.textContent).not.toContain("context used"); - - renderWithSession({ - key: "main", - kind: "direct", - updatedAt: null, - totalTokens: 190_000, - totalTokensFresh: false, - contextTokens: 200_000, - }); - expect(container.textContent).not.toContain("context used"); - expect(container.textContent).not.toContain("190k / 200k"); - }); - it("uses the assistant avatar URL or bundled logo fallbacks", () => { const container = document.createElement("div"); render( @@ -245,6 +155,7 @@ describe("chat view", () => { assistantName: "Assistant", assistantAvatar: "A", assistantAvatarUrl: null, + basePath: "/openclaw/", }), ), container, @@ -254,42 +165,20 @@ describe("chat view", () => { ); expect(container.querySelector(".agent-chat__welcome > img")).toBeNull(); expect(logoImage).not.toBeNull(); - expect(logoImage?.getAttribute("src")).toBe("favicon.svg"); - - render( - renderChat( - createProps({ - assistantName: "Assistant", - assistantAvatar: "A", - assistantAvatarUrl: null, - basePath: "/openclaw/", - }), - ), - container, - ); expect( container .querySelector(".agent-chat__welcome .agent-chat__avatar--logo img") ?.getAttribute("src"), ).toBe("/openclaw/favicon.svg"); - render( - renderChat( - createProps({ - assistantName: "Assistant", - assistantAvatar: "A", - assistantAvatarUrl: null, - basePath: "/openclaw/", - messages: [ - { - role: "assistant", - content: "hello", - timestamp: 1000, - }, - ], - }), - ), + renderAssistantMessage( container, + { + role: "assistant", + content: "hello", + timestamp: 1000, + }, + { basePath: "/openclaw/" }, ); const groupedLogo = container.querySelector( ".chat-group.assistant .chat-avatar--logo", @@ -468,31 +357,6 @@ describe("chat view", () => { expect(container.textContent).not.toContain("Stop"); }); - it("shows sender labels from sanitized gateway messages instead of generic You", () => { - const container = document.createElement("div"); - render( - renderChat( - createProps({ - messages: [ - { - role: "user", - content: "hello from topic", - senderLabel: "Iris", - timestamp: 1000, - }, - ], - }), - ), - container, - ); - - const senderLabels = Array.from(container.querySelectorAll(".chat-sender-name")).map((node) => - node.textContent?.trim(), - ); - expect(senderLabels).toContain("Iris"); - expect(senderLabels).not.toContain("You"); - }); - it("keeps consecutive user messages from different senders in separate groups", () => { const container = document.createElement("div"); render( @@ -524,6 +388,7 @@ describe("chat view", () => { ); expect(senderLabels).toContain("Iris"); expect(senderLabels).toContain("Joaquin De Rojas"); + expect(senderLabels).not.toContain("You"); }); it("positions delete confirm by message side", () => { @@ -767,36 +632,7 @@ describe("chat view", () => { expect(container.textContent).toContain('"childSessionKey": "agent:test:subagent:abc123"'); }); - it("renders [embed] shortcodes inside the assistant bubble", () => { - const container = document.createElement("div"); - render( - renderChat( - createProps({ - showToolCalls: false, - messages: [ - { - id: "assistant-anki-inline", - role: "assistant", - content: [ - { - type: "text", - text: 'Still the same current card.\n[embed ref="cv_shortcode" title="Shortcode view" /]', - }, - ], - timestamp: Date.now(), - }, - ], - }), - ), - container, - ); - - expect(container.querySelector(".chat-tool-card__preview-frame")).not.toBeNull(); - expect(container.textContent).toContain("Still the same current card."); - expect(container.textContent).toContain("Shortcode view"); - }); - - it("renders canvas-only assistant bubbles", () => { + it("renders canvas-only [embed] shortcodes inside the assistant bubble", () => { const container = document.createElement("div"); render( renderChat( @@ -806,7 +642,12 @@ describe("chat view", () => { { id: "assistant-canvas-only", role: "assistant", - content: [{ type: "text", text: '[embed ref="cv_tictactoe" title="Tic-Tac-Toe" /]' }], + content: [ + { + type: "text", + text: '[embed ref="cv_tictactoe" title="Tic-Tac-Toe" /]', + }, + ], timestamp: Date.now(), }, ], @@ -1043,22 +884,16 @@ describe("chat view", () => { it("renders assistant MEDIA attachments, voice-note badge, and reply pill", () => { const container = document.createElement("div"); - render( - renderChat( - createProps({ - showToolCalls: false, - messages: [ - { - id: "assistant-media-inline", - role: "assistant", - content: - "[[reply_to_current]]Here is the image.\nMEDIA:https://example.com/photo.png\nMEDIA:https://example.com/voice.ogg\n[[audio_as_voice]]", - timestamp: Date.now(), - }, - ], - }), - ), + renderAssistantMessage( container, + { + id: "assistant-media-inline", + role: "assistant", + content: + "[[reply_to_current]]Here is the image.\nMEDIA:https://example.com/photo.png\nMEDIA:https://example.com/voice.ogg\n[[audio_as_voice]]", + timestamp: Date.now(), + }, + { showToolCalls: false }, ); expect(container.querySelector(".chat-reply-pill")?.textContent).toContain( @@ -1079,20 +914,11 @@ describe("chat view", () => { const container = document.createElement("div"); const openSpy = vi.spyOn(window, "open").mockReturnValue(null); const renderAssistantImage = (url: string) => - render( - renderChat( - createProps({ - messages: [ - { - role: "assistant", - content: [{ type: "image_url", image_url: { url } }], - timestamp: Date.now(), - }, - ], - }), - ), - container, - ); + renderAssistantMessage(container, { + role: "assistant", + content: [{ type: "image_url", image_url: { url } }], + timestamp: Date.now(), + }); try { renderAssistantImage("https://example.com/cat.png"); @@ -1137,27 +963,26 @@ describe("chat view", () => { }); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); const container = document.createElement("div"); - const template = () => - renderChat( - createProps({ + const renderMessage = () => + renderAssistantMessage( + container, + { + id: "assistant-local-media-inline", + role: "assistant", + content: + "Local image\nMEDIA:/tmp/openclaw/test image.png\nMEDIA:/tmp/openclaw/test-doc.pdf", + timestamp: Date.now(), + }, + { showToolCalls: false, basePath: "/openclaw", assistantAttachmentAuthToken: "session-token", localMediaPreviewRoots: ["/tmp/openclaw"], - onRequestUpdate: () => render(template(), container), - messages: [ - { - id: "assistant-local-media-inline", - role: "assistant", - content: - "Local image\nMEDIA:/tmp/openclaw/test image.png\nMEDIA:/tmp/openclaw/test-doc.pdf", - timestamp: Date.now(), - }, - ], - }), + onRequestUpdate: renderMessage, + }, ); - render(template(), container); + renderMessage(); expect(container.textContent).toContain("Checking..."); await flushAssistantAttachmentAvailabilityChecks(); @@ -1195,25 +1020,21 @@ describe("chat view", () => { const container = document.createElement("div"); const renderWithToken = (token: string | null) => - render( - renderChat( - createProps({ - showToolCalls: false, - basePath: "/openclaw", - assistantAttachmentAuthToken: token, - localMediaPreviewRoots: ["/tmp/openclaw"], - onRequestUpdate: () => renderWithToken(token), - messages: [ - { - id: "assistant-local-media-auth-refresh", - role: "assistant", - content: "Local image\nMEDIA:/tmp/openclaw/test image.png", - timestamp: Date.now(), - }, - ], - }), - ), + renderAssistantMessage( container, + { + id: "assistant-local-media-auth-refresh", + role: "assistant", + content: "Local image\nMEDIA:/tmp/openclaw/test image.png", + timestamp: Date.now(), + }, + { + showToolCalls: false, + basePath: "/openclaw", + assistantAttachmentAuthToken: token, + localMediaPreviewRoots: ["/tmp/openclaw"], + onRequestUpdate: () => renderWithToken(token), + }, ); renderWithToken(null); @@ -1242,24 +1063,20 @@ describe("chat view", () => { it("preserves same-origin assistant attachments without local preview rewriting", () => { resetAssistantAttachmentAvailabilityCacheForTest(); const container = document.createElement("div"); - render( - renderChat( - createProps({ - showToolCalls: false, - basePath: "/openclaw", - localMediaPreviewRoots: ["/tmp/openclaw"], - messages: [ - { - id: "assistant-same-origin-media-inline", - role: "assistant", - content: - "Inline\nMEDIA:/media/inbound/test-image.png\nMEDIA:/__openclaw__/media/test-doc.pdf", - timestamp: Date.now(), - }, - ], - }), - ), + renderAssistantMessage( container, + { + id: "assistant-same-origin-media-inline", + role: "assistant", + content: + "Inline\nMEDIA:/media/inbound/test-image.png\nMEDIA:/__openclaw__/media/test-doc.pdf", + timestamp: Date.now(), + }, + { + showToolCalls: false, + basePath: "/openclaw", + localMediaPreviewRoots: ["/tmp/openclaw"], + }, ); const image = container.querySelector(".chat-message-image"); @@ -1274,23 +1091,19 @@ describe("chat view", () => { it("renders blocked local assistant files as unavailable with a reason", () => { resetAssistantAttachmentAvailabilityCacheForTest(); const container = document.createElement("div"); - render( - renderChat( - createProps({ - showToolCalls: false, - basePath: "/openclaw", - localMediaPreviewRoots: ["/tmp/openclaw"], - messages: [ - { - id: "assistant-blocked-local-media", - role: "assistant", - content: "Blocked\nMEDIA:/Users/test/Documents/private.pdf\nDone", - timestamp: Date.now(), - }, - ], - }), - ), + renderAssistantMessage( container, + { + id: "assistant-blocked-local-media", + role: "assistant", + content: "Blocked\nMEDIA:/Users/test/Documents/private.pdf\nDone", + timestamp: Date.now(), + }, + { + showToolCalls: false, + basePath: "/openclaw", + localMediaPreviewRoots: ["/tmp/openclaw"], + }, ); expect(container.querySelector(".chat-assistant-attachment-card__link")).toBeNull(); @@ -1320,18 +1133,12 @@ describe("chat view", () => { message: ChatProps["messages"][number]; roots: string[]; }) => { - render( - renderChat( - createProps({ - showToolCalls: false, - basePath: "/openclaw", - localMediaPreviewRoots: params.roots, - onRequestUpdate: () => undefined, - messages: [params.message], - }), - ), - container, - ); + renderAssistantMessage(container, params.message, { + showToolCalls: false, + basePath: "/openclaw", + localMediaPreviewRoots: params.roots, + onRequestUpdate: () => undefined, + }); return params.expectedUrl; }; @@ -1411,24 +1218,20 @@ describe("chat view", () => { const container = document.createElement("div"); const renderMessage = () => - render( - renderChat( - createProps({ - showToolCalls: false, - basePath: "/openclaw", - localMediaPreviewRoots: ["/tmp/openclaw"], - onRequestUpdate: renderMessage, - messages: [ - { - id: "assistant-local-media-retry-after-unavailable", - role: "assistant", - content: "Local image\nMEDIA:/tmp/openclaw/test image.png", - timestamp: Date.now(), - }, - ], - }), - ), + renderAssistantMessage( container, + { + id: "assistant-local-media-retry-after-unavailable", + role: "assistant", + content: "Local image\nMEDIA:/tmp/openclaw/test image.png", + timestamp: Date.now(), + }, + { + showToolCalls: false, + basePath: "/openclaw", + localMediaPreviewRoots: ["/tmp/openclaw"], + onRequestUpdate: renderMessage, + }, ); renderMessage(); @@ -1452,35 +1255,31 @@ describe("chat view", () => { it("routes inline canvas blocks through the scoped canvas host when available", () => { const container = document.createElement("div"); - render( - renderChat( - createProps({ - canvasHostUrl: "http://127.0.0.1:19003/__openclaw__/cap/cap_123", - messages: [ - { - id: "assistant-scoped-canvas", - role: "assistant", - content: [ - { type: "text", text: "Rendered inline." }, - { - type: "canvas", - preview: { - kind: "canvas", - surface: "assistant_message", - render: "url", - viewId: "cv_inline_scoped", - title: "Scoped preview", - url: "/__openclaw__/canvas/documents/cv_inline_scoped/index.html", - preferredHeight: 320, - }, - }, - ], - timestamp: Date.now(), - }, - ], - }), - ), + renderAssistantMessage( container, + { + id: "assistant-scoped-canvas", + role: "assistant", + content: [ + { type: "text", text: "Rendered inline." }, + { + type: "canvas", + preview: { + kind: "canvas", + surface: "assistant_message", + render: "url", + viewId: "cv_inline_scoped", + title: "Scoped preview", + url: "/__openclaw__/canvas/documents/cv_inline_scoped/index.html", + preferredHeight: 320, + }, + }, + ], + timestamp: Date.now(), + }, + { + canvasHostUrl: "http://127.0.0.1:19003/__openclaw__/cap/cap_123", + }, ); const iframe = container.querySelector(".chat-tool-card__preview-frame"); @@ -1705,57 +1504,6 @@ describe("chat view", () => { ); }); - it("renders markdown inside tool output sidebar", async () => { - const container = document.createElement("div"); - let sidebarContent: ChatProps["sidebarContent"] = null; - const messages = [ - { - role: "assistant", - content: [ - { type: "toolcall", name: "noop", arguments: {} }, - { type: "toolresult", name: "noop", text: "Hello **world**" }, - ], - timestamp: Date.now(), - }, - ]; - const renderWithSidebar = () => - render( - renderChat( - createProps({ - messages, - sidebarOpen: sidebarContent !== null, - sidebarContent, - sidebarError: null, - onOpenSidebar: (content) => { - sidebarContent = content; - renderWithSidebar(); - }, - onCloseSidebar: () => { - sidebarContent = null; - renderWithSidebar(); - }, - onRequestUpdate: renderWithSidebar, - }), - ), - container, - ); - - renderWithSidebar(); - - const toolSummary = container.querySelector(".chat-tool-msg-summary"); - expect(toolSummary).not.toBeNull(); - toolSummary?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await flushTasks(); - - const openSidebarButton = container.querySelector(".chat-tool-card__action-btn"); - expect(openSidebarButton).not.toBeNull(); - openSidebarButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await flushTasks(); - - const strongNodes = Array.from(container.querySelectorAll(".sidebar-markdown strong")); - expect(strongNodes.some((node) => node.textContent === "world")).toBe(true); - }); - it("lets a tool call collapse while keeping matching tool output visible", async () => { const container = document.createElement("div"); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index e8ea6cc40ac..b55e9066454 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -1,12 +1,12 @@ import { html, nothing, type TemplateResult } from "lit"; import { ref } from "lit/directives/ref.js"; import { repeat } from "lit/directives/repeat.js"; -import { unsafeHTML } from "lit/directives/unsafe-html.js"; import type { CompactionStatus, FallbackStatus } from "../app-tool-stream.ts"; import { CHAT_ATTACHMENT_ACCEPT, isSupportedChatAttachmentMimeType, } from "../chat/attachment-support.ts"; +import { renderContextNotice } from "../chat/context-notice.ts"; import { DeletedMessages } from "../chat/deleted-messages.ts"; import { exportChatMarkdown } from "../chat/export.ts"; import { @@ -25,6 +25,7 @@ import { PinnedMessages } from "../chat/pinned-messages.ts"; import { getPinnedMessageSummary } from "../chat/pinned-summary.ts"; import { messageMatchesSearchQuery } from "../chat/search-match.ts"; import { getOrCreateSessionCacheValue } from "../chat/session-cache.ts"; +import { renderSideResult } from "../chat/side-result-render.ts"; import type { ChatSideResult } from "../chat/side-result.ts"; import { CATEGORY_LABELS, @@ -38,10 +39,9 @@ import { isSttSupported, startStt, stopStt } from "../chat/speech.ts"; import { buildSidebarContent, extractToolCards, extractToolPreview } from "../chat/tool-cards.ts"; import type { EmbedSandboxMode } from "../embed-sandbox.ts"; import { icons } from "../icons.ts"; -import { toSanitizedMarkdownHtml } from "../markdown.ts"; import type { SidebarContent } from "../sidebar-content.ts"; import { detectTextDirection } from "../text-direction.ts"; -import type { GatewaySessionRow, SessionsListResult } from "../types.ts"; +import type { SessionsListResult } from "../types.ts"; import type { ChatItem, MessageGroup, ToolCard } from "../types/chat-types.ts"; import type { ChatAttachment, ChatQueueItem } from "../ui-types.ts"; import { agentLogoUrl, resolveAgentAvatarUrl } from "./agents-utils.ts"; @@ -456,144 +456,6 @@ function renderFallbackIndicator(status: FallbackStatus | null | undefined) { `; } -function renderSideResult( - sideResult: ChatSideResult | null | undefined, - onDismiss?: () => void, -): TemplateResult | typeof nothing { - if (!sideResult) { - return nothing; - } - return html` -
-
-
- BTW - Not saved to chat history -
- -
-
${sideResult.question}
-
- ${unsafeHTML(toSanitizedMarkdownHtml(sideResult.text))} -
-
- `; -} - -/** - * Compact notice when context usage reaches 85%+. - * Progressively shifts from amber (85%) to red (90%+). - */ -/** Parse a 6-digit CSS hex color string to [r, g, b] integer components. */ -function parseHexRgb(hex: string): [number, number, number] | null { - const h = hex.trim().replace(/^#/, ""); - if (!/^[0-9a-fA-F]{6}$/.test(h)) { - return null; - } - return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; -} - -let cachedThemeNoticeColors: { - warnHex: string; - dangerHex: string; - warnRgb: [number, number, number]; - dangerRgb: [number, number, number]; -} | null = null; - -function getThemeNoticeColors() { - if (cachedThemeNoticeColors) { - return cachedThemeNoticeColors; - } - const rootStyle = getComputedStyle(document.documentElement); - const warnHex = rootStyle.getPropertyValue("--warn").trim() || "#f59e0b"; - const dangerHex = rootStyle.getPropertyValue("--danger").trim() || "#ef4444"; - cachedThemeNoticeColors = { - warnHex, - dangerHex, - warnRgb: parseHexRgb(warnHex) ?? [245, 158, 11], - dangerRgb: parseHexRgb(dangerHex) ?? [239, 68, 68], - }; - return cachedThemeNoticeColors; -} - -function renderContextNotice( - session: GatewaySessionRow | undefined, - defaultContextTokens: number | null, -) { - if (session?.totalTokensFresh === false) { - return nothing; - } - const used = session?.totalTokens ?? 0; - const limit = session?.contextTokens ?? defaultContextTokens ?? 0; - if (!used || !limit) { - return nothing; - } - const ratio = used / limit; - if (ratio < 0.85) { - return nothing; - } - const pct = Math.min(Math.round(ratio * 100), 100); - // Read theme semantic tokens so color tracks the active theme (Dash, dark, light …) - const { warnRgb, dangerRgb } = getThemeNoticeColors(); - const [wr, wg, wb] = warnRgb; - const [dr, dg, db] = dangerRgb; - // Blend from --warn at 85% usage to --danger at 95%+ usage - const t = Math.min(Math.max((ratio - 0.85) / 0.1, 0), 1); - const r = Math.round(wr + (dr - wr) * t); - const g = Math.round(wg + (dg - wg) * t); - const b = Math.round(wb + (db - wb) * t); - const color = `rgb(${r}, ${g}, ${b})`; - const bgOpacity = 0.08 + 0.08 * t; - const bg = `rgba(${r}, ${g}, ${b}, ${bgOpacity})`; - return html` -
- - - - - - ${pct}% context used - ${formatTokensCompact(used)} / ${formatTokensCompact(limit)} -
- `; -} - -/** Format token count compactly (e.g. 128000 → "128k"). */ -function formatTokensCompact(n: number): string { - if (n >= 1_000_000) { - return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; - } - if (n >= 1_000) { - return `${(n / 1_000).toFixed(1).replace(/\.0$/, "")}k`; - } - return String(n); -} - function generateAttachmentId(): string { return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; } diff --git a/ui/src/ui/views/config.browser.test.ts b/ui/src/ui/views/config.browser.test.ts index 86e30008dee..63d88830ed2 100644 --- a/ui/src/ui/views/config.browser.test.ts +++ b/ui/src/ui/views/config.browser.test.ts @@ -361,43 +361,46 @@ describe("config view", () => { expect(onRawChange).toHaveBeenCalledWith(textarea.value); }); - it("renders structured SecretRef values as read-only text inputs without stringifying", () => { + it("renders structured SecretRef values without stringifying", () => { const onFormPatch = vi.fn(); - const { container } = renderConfigView({ - schema: { - type: "object", - properties: { - channels: { - type: "object", - properties: { - discord: { - type: "object", - properties: { - token: { type: "string" }, - }, + const secretRefSchema = { + type: "object" as const, + properties: { + channels: { + type: "object" as const, + properties: { + discord: { + type: "object" as const, + properties: { + token: { type: "string" as const }, }, }, }, }, }, + }; + const secretRefValue = { + channels: { + discord: { + token: { source: "env", provider: "default", id: "__OPENCLAW_REDACTED__" }, + }, + }, + }; + const secretRefOriginalValue = { + channels: { + discord: { + token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + }, + }, + }; + const { container } = renderConfigView({ + schema: secretRefSchema, uiHints: { "channels.discord.token": { sensitive: true }, }, formMode: "form", - formValue: { - channels: { - discord: { - token: { source: "env", provider: "default", id: "__OPENCLAW_REDACTED__" }, - }, - }, - }, - originalValue: { - channels: { - discord: { - token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, - }, - }, - }, + formValue: secretRefValue, + originalValue: secretRefOriginalValue, onFormPatch, }); @@ -415,50 +418,27 @@ describe("config view", () => { input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); expect(onFormPatch).not.toHaveBeenCalled(); - }); - it("uses a file-edit placeholder for structured SecretRefs when raw mode is unavailable", () => { - const { container } = renderConfigView({ - rawAvailable: false, - formMode: "raw", - schema: { - type: "object", - properties: { - channels: { - type: "object", - properties: { - discord: { - type: "object", - properties: { - token: { type: "string" }, - }, - }, - }, - }, + render( + renderConfig({ + ...baseProps(), + rawAvailable: false, + formMode: "raw", + schema: secretRefSchema, + uiHints: { + "channels.discord.token": { sensitive: true }, }, - }, - uiHints: { - "channels.discord.token": { sensitive: true }, - }, - formValue: { - channels: { - discord: { - token: { source: "env", provider: "default", id: "__OPENCLAW_REDACTED__" }, - }, - }, - }, - originalValue: { - channels: { - discord: { - token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, - }, - }, - }, - }); + formValue: secretRefValue, + originalValue: secretRefOriginalValue, + }), + container, + ); - const input = container.querySelector(".cfg-input"); - expect(input).not.toBeNull(); - expect(input?.placeholder).toBe("Structured value (SecretRef) - edit the config file directly"); + const rawUnavailableInput = container.querySelector(".cfg-input"); + expect(rawUnavailableInput).not.toBeNull(); + expect(rawUnavailableInput?.placeholder).toBe( + "Structured value (SecretRef) - edit the config file directly", + ); }); it("keeps malformed non-SecretRef object values editable when raw mode is unavailable", () => { diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index 1e772798aa9..12a32a124ff 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -84,10 +84,30 @@ function getButtonByText(container: Element, text: string) { } describe("cron view", () => { - it("shows all-job history mode and toggles the run status filter", () => { + it("shows all-job history mode and wires run/job filters", () => { const container = document.createElement("div"); const onRunsFiltersChange = vi.fn(); - render(renderCron(createProps({ onRunsFiltersChange })), container); + const onJobsFiltersChange = vi.fn(); + const onJobsFiltersReset = vi.fn(); + render( + renderCron( + createProps({ + onRunsFiltersChange, + onJobsFiltersChange, + runsScope: "all", + runs: [ + { + ts: Date.now(), + jobId: "job-1", + status: "ok", + summary: "done", + nextRunAtMs: Date.now() - 13 * 60_000, + }, + ], + }), + ), + container, + ); expect(container.textContent).toContain("Latest runs across all jobs."); expect(container.textContent).toContain("Status"); @@ -107,133 +127,9 @@ describe("cron view", () => { statusOk.dispatchEvent(new Event("change", { bubbles: true })); expect(onRunsFiltersChange).toHaveBeenCalledWith({ cronRunsStatuses: ["ok"] }); - }); - - it("loads run history when clicking a job row", () => { - const container = document.createElement("div"); - const onLoadRuns = vi.fn(); - const job = createJob("job-1"); - render( - renderCron( - createProps({ - jobs: [job], - onLoadRuns, - }), - ), - container, - ); - - const row = container.querySelector(".list-item-clickable"); - expect(row).not.toBeNull(); - row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - - expect(onLoadRuns).toHaveBeenCalledWith("job-1"); - }); - - it("marks the selected job and keeps History button to a single call", () => { - const container = document.createElement("div"); - const onLoadRuns = vi.fn(); - const job = createJob("job-1"); - render( - renderCron( - createProps({ - jobs: [job], - runsJobId: "job-1", - runsScope: "job", - onLoadRuns, - }), - ), - container, - ); - - const selected = container.querySelector(".list-item-selected"); - expect(selected).not.toBeNull(); - - const historyButton = Array.from(container.querySelectorAll("button")).find( - (btn) => btn.textContent?.trim() === "History", - ); - expect(historyButton).not.toBeUndefined(); - historyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - - expect(onLoadRuns).toHaveBeenCalledTimes(1); - expect(onLoadRuns).toHaveBeenCalledWith("job-1"); - }); - - it("shows selected job run history sorted newest first with chat links", () => { - const container = document.createElement("div"); - const job = createJob("job-1"); - render( - renderCron( - createProps({ - basePath: "/ui", - jobs: [job], - runsJobId: "job-1", - runsScope: "job", - runs: [ - { ts: 1, jobId: "job-1", status: "ok", summary: "older run" }, - { - ts: 2, - jobId: "job-1", - status: "ok", - summary: "newer run", - sessionKey: "agent:main:cron:job-1:run:abc", - }, - ], - }), - ), - container, - ); - - const link = container.querySelector("a.session-link"); - expect(link).not.toBeNull(); - expect(link?.getAttribute("href")).toContain( - "/ui/chat?session=agent%3Amain%3Acron%3Ajob-1%3Arun%3Aabc", - ); - - expect(container.textContent).toContain("Latest runs for Daily ping."); - - const cards = Array.from(container.querySelectorAll(".card")); - const runHistoryCard = cards.find( - (card) => card.querySelector(".card-title")?.textContent?.trim() === "Run history", - ); - expect(runHistoryCard).not.toBeUndefined(); - - const summaries = Array.from( - runHistoryCard?.querySelectorAll(".list-item .list-sub") ?? [], - ).map((el) => (el.textContent ?? "").trim()); - expect(summaries[0]).toBe("newer run"); - expect(summaries[1]).toBe("older run"); - }); - - it("labels past nextRunAtMs as due instead of next", () => { - const container = document.createElement("div"); - render( - renderCron( - createProps({ - runsScope: "all", - runs: [ - { - ts: Date.now(), - jobId: "job-1", - status: "ok", - summary: "done", - nextRunAtMs: Date.now() - 13 * 60_000, - }, - ], - }), - ), - container, - ); expect(container.textContent).toContain("Due"); expect(container.textContent).not.toContain("Next 13"); - }); - - it("wires jobs filter changes and reset", () => { - const container = document.createElement("div"); - const onJobsFiltersChange = vi.fn(); - const onJobsFiltersReset = vi.fn(); - render(renderCron(createProps({ onJobsFiltersChange })), container); const scheduleSelect = container.querySelector( 'select[data-test-id="cron-jobs-schedule-filter"]', @@ -276,6 +172,72 @@ describe("cron view", () => { expect(onJobsFiltersReset).toHaveBeenCalledTimes(1); }); + it("marks the selected job, routes history clicks, and sorts runs newest first", () => { + const container = document.createElement("div"); + const onLoadRuns = vi.fn(); + const job = createJob("job-1"); + render( + renderCron( + createProps({ + basePath: "/ui", + jobs: [job], + runsJobId: "job-1", + runsScope: "job", + runs: [ + { ts: 1, jobId: "job-1", status: "ok", summary: "older run" }, + { + ts: 2, + jobId: "job-1", + status: "ok", + summary: "newer run", + sessionKey: "agent:main:cron:job-1:run:abc", + }, + ], + onLoadRuns, + }), + ), + container, + ); + + const selected = container.querySelector(".list-item-selected"); + expect(selected).not.toBeNull(); + + const row = container.querySelector(".list-item-clickable"); + expect(row).not.toBeNull(); + row?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onLoadRuns).toHaveBeenCalledWith("job-1"); + + const historyButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "History", + ); + expect(historyButton).not.toBeUndefined(); + historyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onLoadRuns).toHaveBeenCalledTimes(2); + expect(onLoadRuns).toHaveBeenNthCalledWith(1, "job-1"); + expect(onLoadRuns).toHaveBeenNthCalledWith(2, "job-1"); + + const link = container.querySelector("a.session-link"); + expect(link).not.toBeNull(); + expect(link?.getAttribute("href")).toContain( + "/ui/chat?session=agent%3Amain%3Acron%3Ajob-1%3Arun%3Aabc", + ); + + expect(container.textContent).toContain("Latest runs for Daily ping."); + + const cards = Array.from(container.querySelectorAll(".card")); + const runHistoryCard = cards.find( + (card) => card.querySelector(".card-title")?.textContent?.trim() === "Run history", + ); + expect(runHistoryCard).not.toBeUndefined(); + + const summaries = Array.from( + runHistoryCard?.querySelectorAll(".list-item .list-sub") ?? [], + ).map((el) => (el.textContent ?? "").trim()); + expect(summaries[0]).toBe("newer run"); + expect(summaries[1]).toBe("older run"); + }); + it("renders supported delivery options and normalizes stale announce selection", () => { const container = document.createElement("div"); render( @@ -528,25 +490,6 @@ describe("cron view", () => { it("wires job row actions and selects the row before acting", () => { const container = document.createElement("div"); const onClone = vi.fn(); - const onLoadRuns = vi.fn(); - const job = createJob("job-clone"); - render( - renderCron( - createProps({ - jobs: [job], - onClone, - onLoadRuns, - }), - ), - container, - ); - - const cloneButton = getButtonByText(container, "Clone"); - expect(cloneButton).not.toBeUndefined(); - cloneButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - expect(onClone).toHaveBeenCalledWith(job); - expect(onLoadRuns).toHaveBeenCalledWith("job-clone"); - const onToggle = vi.fn(); const onRun = vi.fn(); const onRemove = vi.fn(); @@ -556,6 +499,7 @@ describe("cron view", () => { renderCron( createProps({ jobs: [actionJob], + onClone, onToggle, onRun, onRemove, @@ -565,6 +509,10 @@ describe("cron view", () => { container, ); + const cloneButton = getButtonByText(container, "Clone"); + expect(cloneButton).not.toBeUndefined(); + cloneButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + const enableButton = getButtonByText(container, "Disable"); expect(enableButton).not.toBeUndefined(); enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -573,35 +521,25 @@ describe("cron view", () => { expect(runButton).not.toBeUndefined(); runButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - const removeButton = getButtonByText(container, "Remove"); - expect(removeButton).not.toBeUndefined(); - removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - - expect(onToggle).toHaveBeenCalledWith(actionJob, false); - expect(onRun).toHaveBeenCalledWith(actionJob, "force"); - expect(onRemove).toHaveBeenCalledWith(actionJob); - expect(actionLoadRuns).toHaveBeenCalledTimes(3); - expect(actionLoadRuns).toHaveBeenNthCalledWith(1, "job-actions"); - expect(actionLoadRuns).toHaveBeenNthCalledWith(2, "job-actions"); - expect(actionLoadRuns).toHaveBeenNthCalledWith(3, "job-actions"); - - const onRunDue = vi.fn(); - const dueJob = createJob("job-due"); - render( - renderCron( - createProps({ - jobs: [dueJob], - onRun: onRunDue, - }), - ), - container, - ); - const runDueButton = getButtonByText(container, "Run if due"); expect(runDueButton).not.toBeUndefined(); runDueButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - expect(onRunDue).toHaveBeenCalledWith(dueJob, "due"); + const removeButton = getButtonByText(container, "Remove"); + expect(removeButton).not.toBeUndefined(); + removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onClone).toHaveBeenCalledWith(actionJob); + expect(onToggle).toHaveBeenCalledWith(actionJob, false); + expect(onRun).toHaveBeenNthCalledWith(1, actionJob, "force"); + expect(onRun).toHaveBeenNthCalledWith(2, actionJob, "due"); + expect(onRemove).toHaveBeenCalledWith(actionJob); + expect(actionLoadRuns).toHaveBeenCalledTimes(5); + expect(actionLoadRuns).toHaveBeenNthCalledWith(1, "job-actions"); + expect(actionLoadRuns).toHaveBeenNthCalledWith(2, "job-actions"); + expect(actionLoadRuns).toHaveBeenNthCalledWith(3, "job-actions"); + expect(actionLoadRuns).toHaveBeenNthCalledWith(4, "job-actions"); + expect(actionLoadRuns).toHaveBeenNthCalledWith(5, "job-actions"); }); it("renders suggestion datalists for agent/model/thinking/timezone", () => { diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index 62367dfc2c9..7d629c079a9 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -65,55 +65,7 @@ function buildProps(result: SessionsListResult): SessionsProps { } describe("sessions view", () => { - it("renders verbose=full without falling back to inherit", async () => { - const container = document.createElement("div"); - render( - renderSessions( - buildProps( - buildResult({ - key: "agent:main:main", - kind: "direct", - updatedAt: Date.now(), - verboseLevel: "full", - }), - ), - ), - container, - ); - await Promise.resolve(); - - const selects = container.querySelectorAll("select"); - const verbose = selects[2] as HTMLSelectElement | undefined; - expect(verbose?.value).toBe("full"); - expect(Array.from(verbose?.options ?? []).some((option) => option.value === "full")).toBe(true); - }); - - it("keeps unknown stored values selectable instead of forcing inherit", async () => { - const container = document.createElement("div"); - render( - renderSessions( - buildProps( - buildResult({ - key: "agent:main:main", - kind: "direct", - updatedAt: Date.now(), - reasoningLevel: "custom-mode", - }), - ), - ), - container, - ); - await Promise.resolve(); - - const selects = container.querySelectorAll("select"); - const reasoning = selects[3] as HTMLSelectElement | undefined; - expect(reasoning?.value).toBe("custom-mode"); - expect( - Array.from(reasoning?.options ?? []).some((option) => option.value === "custom-mode"), - ).toBe(true); - }); - - it("renders explicit fast mode without falling back to inherit", async () => { + it("keeps session selects stable and deselects only the current page", async () => { const container = document.createElement("div"); render( renderSessions( @@ -123,6 +75,8 @@ describe("sessions view", () => { kind: "direct", updatedAt: Date.now(), fastMode: true, + verboseLevel: "full", + reasoningLevel: "custom-mode", }), ), ), @@ -132,14 +86,19 @@ describe("sessions view", () => { const selects = container.querySelectorAll("select"); const fast = selects[1] as HTMLSelectElement | undefined; + const verbose = selects[2] as HTMLSelectElement | undefined; + const reasoning = selects[3] as HTMLSelectElement | undefined; expect(fast?.value).toBe("on"); - }); + expect(verbose?.value).toBe("full"); + expect(Array.from(verbose?.options ?? []).some((option) => option.value === "full")).toBe(true); + expect(reasoning?.value).toBe("custom-mode"); + expect( + Array.from(reasoning?.options ?? []).some((option) => option.value === "custom-mode"), + ).toBe(true); - it("deselects only the current page from the header checkbox", async () => { const onSelectPage = vi.fn(); const onDeselectPage = vi.fn(); const onDeselectAll = vi.fn(); - const container = document.createElement("div"); render( renderSessions({ ...buildProps( diff --git a/ui/src/ui/views/skills.test.ts b/ui/src/ui/views/skills.test.ts index f222d884cf8..28a5163f777 100644 --- a/ui/src/ui/views/skills.test.ts +++ b/ui/src/ui/views/skills.test.ts @@ -98,34 +98,16 @@ describe("renderSkills", () => { } }); - it("opens the skill detail dialog as a modal", async () => { + it("opens detail dialogs and routes ClawHub actions", async () => { const container = document.createElement("div"); + const onDetailClose = vi.fn(); const showModal = vi.fn(function (this: HTMLDialogElement) { this.setAttribute("open", ""); }); + const onClawHubDetailOpen = vi.fn(); + const onClawHubInstall = vi.fn(); + installDialogMethod("showModal", showModal); - - render( - renderSkills( - createProps({ - detailKey: "repo-skill", - }), - ), - container, - ); - await Promise.resolve(); - - expect(showModal).toHaveBeenCalledTimes(1); - expect(container.querySelector("dialog")?.hasAttribute("open")).toBe(true); - }); - - it("closes the skill detail dialog through the dialog close event", async () => { - const container = document.createElement("div"); - const onDetailClose = vi.fn(); - - installDialogMethod("showModal", function (this: HTMLDialogElement) { - this.setAttribute("open", ""); - }); installDialogMethod("close", function (this: HTMLDialogElement) { this.removeAttribute("open"); this.dispatchEvent(new Event("close")); @@ -142,15 +124,12 @@ describe("renderSkills", () => { ); await Promise.resolve(); + expect(showModal).toHaveBeenCalledTimes(1); + expect(container.querySelector("dialog")?.hasAttribute("open")).toBe(true); + container.querySelector(".md-preview-dialog__header .btn")?.click(); expect(onDetailClose).toHaveBeenCalledTimes(1); - }); - - it("renders ClawHub search results and routes detail/install actions", async () => { - const container = document.createElement("div"); - const onClawHubDetailOpen = vi.fn(); - const onClawHubInstall = vi.fn(); render( renderSkills( @@ -173,7 +152,7 @@ describe("renderSkills", () => { ); await Promise.resolve(); - const text = normalizeText(container); + let text = normalizeText(container); expect(text).toContain("GitHub"); expect(text).toContain("GitHub integration for OpenClaw"); expect(text).toContain("v1.2.3"); @@ -187,15 +166,9 @@ describe("renderSkills", () => { expect(onClawHubDetailOpen).toHaveBeenCalledWith("github"); expect(onClawHubInstall).toHaveBeenCalledTimes(1); expect(onClawHubInstall).toHaveBeenCalledWith("github"); - }); - it("opens the ClawHub detail dialog and renders install feedback", async () => { - const container = document.createElement("div"); - const showModal = vi.fn(function (this: HTMLDialogElement) { - this.setAttribute("open", ""); - }); - const onClawHubInstall = vi.fn(); - installDialogMethod("showModal", showModal); + onClawHubInstall.mockClear(); + showModal.mockClear(); render( renderSkills( @@ -232,7 +205,7 @@ describe("renderSkills", () => { await Promise.resolve(); expect(showModal).toHaveBeenCalledTimes(1); - const text = normalizeText(container); + text = normalizeText(container); expect(text).toContain("rate limited"); expect(text).toContain("Installed github"); expect(text).toContain("By OpenClaw (@openclaw)");