Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 323493fa1b | |||
| 64d237dd02 | |||
| 62f9cf53c9 | |||
| d2240a9476 | |||
| 10dbb21380 | |||
| 3329824eed | |||
| d7cc6f7643 | |||
| 6c0bff111c | |||
| 3587e0ef95 | |||
| 82364e901a | |||
| 5a5ca6d62c | |||
| 8820a43818 | |||
| e9f5619716 | |||
| f4372613d8 | |||
| e58d50b7a8 | |||
| 6ee8e194c0 | |||
| dfed74b254 | |||
| 072e8cfe62 | |||
| a70fdc88e0 | |||
| 4f15d77ecc | |||
| b90d4ea3d7 | |||
| 381a8e860a | |||
| 56625a189b | |||
| fecd4fcc55 | |||
| 33a698fe10 | |||
| 7eecfa411d | |||
| e59f5ecac3 | |||
| aa0dc118f1 | |||
| 38de896419 | |||
| 900681751d | |||
| 37f449d7e1 | |||
| 6a5ff83b24 | |||
| 26e80cc6cc | |||
| 213c36cf51 | |||
| 024f4614a1 | |||
| 1dabfef28d | |||
| 1b76966f05 | |||
| bf1d49093a | |||
| a743b30b8b | |||
| 0381852c26 | |||
| 0abe64a4ff | |||
| 852484965f | |||
| 55604a9a91 | |||
| 73d3cf9920 | |||
| 3deea5a426 | |||
| df84225504 | |||
| 67ffb6f6c2 | |||
| 1277294293 | |||
| 296471b692 | |||
| 4b127adc9d | |||
| 0d6643e244 | |||
| 311bc842b8 | |||
| d4f556a052 | |||
| 5b24009271 | |||
| cf3d27ab94 | |||
| 00415e2010 | |||
| ff8605f3c2 | |||
| 1e11b36d80 | |||
| c09031f15a | |||
| 63965dc70b | |||
| ca9f969831 | |||
| a2ab9e6a8e | |||
| 3c501d3554 | |||
| 556905a3f4 | |||
| 0eebb49fef | |||
| 8a9d5e37be | |||
| c91d3d4537 | |||
| 4f92b1fbb0 | |||
| 6eafb5f844 | |||
| 59afcf9922 | |||
| ad181b2361 | |||
| 29f206243b | |||
| df192c514c | |||
| 1c35795fce | |||
| 49d99c7500 |
@@ -86,6 +86,10 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
|
||||
- For stable correction releases like `YYYY.M.D-N`, it also verifies the
|
||||
upgrade path from `YYYY.M.D` to `YYYY.M.D-N` so a correction publish cannot
|
||||
silently leave existing global installs on the old base stable payload.
|
||||
- Treat install smoke as a pack-budget gate too. `pnpm test:install:smoke`
|
||||
now fails the candidate update tarball when npm reports an oversized
|
||||
`unpackedSize`, so release-time e2e cannot miss pack bloat that would risk
|
||||
low-memory install/startup failures.
|
||||
|
||||
## Check all relevant release builds
|
||||
|
||||
|
||||
+35
-1
@@ -8,14 +8,42 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
## 2026.4.14-beta.1
|
||||
## 2026.4.14
|
||||
|
||||
### Changes
|
||||
|
||||
- OpenAI Codex/models: add forward-compat support for `gpt-5.4-pro`, including Codex pricing/limits and list/status visibility before the upstream catalog catches up. (#66453) Thanks @jepson-liu.
|
||||
- Telegram/forum topics: surface human topic names in agent context, prompt metadata, and plugin hook metadata by learning names from Telegram forum service messages. (#65973) Thanks @ptahdunbar.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents/Ollama: forward the configured embedded-run timeout into the global undici stream timeout tuning so slow local Ollama runs no longer inherit the default stream cutoff instead of the operator-set run timeout. (#63175) Thanks @mindcraftreader and @vincentkoc.
|
||||
- Models/Codex: include `apiKey` in the codex provider catalog output so the Pi ModelRegistry validator no longer rejects the entry and silently drops all custom models from every provider in `models.json`. (#66180) Thanks @hoyyeva.
|
||||
- Tools/image+pdf: normalize configured provider/model refs before media-tool registry lookup so image and PDF tool runs stop rejecting valid Ollama vision models as unknown just because the tool path skipped the usual model-ref normalization step. (#59943) Thanks @yqli2420 and @vincentkoc.
|
||||
- Slack/interactions: apply the configured global `allowFrom` owner allowlist to channel block-action and modal interactive events, require an expected sender id for cross-verification, and reject ambiguous channel types so interactive triggers can no longer bypass the documented allowlist intent in channels without a `users` list. Open-by-default behavior is preserved when no allowlists are configured. (#66028) Thanks @eleqtrizit.
|
||||
- Media-understanding/attachments: fail closed when a local attachment path cannot be canonically resolved via `realpath`, so a `realpath` error can no longer downgrade the canonical-roots allowlist check to a non-canonical comparison; attachments that also have a URL still fall back to the network fetch path. (#66022) Thanks @eleqtrizit.
|
||||
- Agents/gateway-tool: reject `config.patch` and `config.apply` calls from the model-facing gateway tool when they would newly enable any flag enumerated by `openclaw security audit` (for example `dangerouslyDisableDeviceAuth`, `allowInsecureAuth`, `dangerouslyAllowHostHeaderOriginFallback`, `hooks.gmail.allowUnsafeExternalContent`, `tools.exec.applyPatch.workspaceOnly: false`); already-enabled flags pass through unchanged so non-dangerous edits in the same patch still apply, and direct authenticated operator RPC behavior is unchanged. (#62006) Thanks @eleqtrizit.
|
||||
- Google image generation: strip a trailing `/openai` suffix from configured Google base URLs only when calling the native Gemini image API so Gemini image requests stop 404ing without breaking explicit OpenAI-compatible Google endpoints. (#66445) Thanks @dapzthelegend.
|
||||
- Telegram/forum topics: persist learned topic names to the Telegram session sidecar store so agent context can keep using human topic names after a restart instead of relearning from future service metadata. (#66107) Thanks @obviyus.
|
||||
- Doctor/systemd: keep `openclaw doctor --repair` and service reinstall from re-embedding dotenv-backed secrets in user systemd units, while preserving newer inline overrides over stale state-dir `.env` values. (#66249) Thanks @tmimmanuel.
|
||||
- Ollama/OpenAI-compat: send `stream_options.include_usage` for Ollama streaming completions so local Ollama runs report real usage instead of falling back to bogus prompt-token counts that trigger premature compaction. (#64568) Thanks @xchunzhao and @vincentkoc.
|
||||
- Doctor/plugins: cache external `preferOver` catalog lookups within each plugin auto-enable pass so large `agents.list` configs no longer peg CPU and repeatedly reread plugin catalogs during doctor/plugins resolution. (#66246) Thanks @yfge.
|
||||
- GitHub Copilot/thinking: allow `github-copilot/gpt-5.4` to use `xhigh` reasoning so Copilot GPT-5.4 matches the rest of the GPT-5.4 family. (#50168) Thanks @jakepresent and @vincentkoc.
|
||||
- Memory/embeddings: preserve non-OpenAI provider prefixes when normalizing OpenAI-compatible embedding model refs so proxy-backed memory providers stop failing with `Unknown memory embedding provider`. (#66452) Thanks @jlapenna.
|
||||
- Agents/local models: clarify low-context preflight hints for self-hosted models, point config-backed caps at the relevant OpenClaw setting, and stop suggesting larger models when `agents.defaults.contextTokens` is the real limit. (#66236) Thanks @ImLukeF.
|
||||
- Browser/SSRF: restore hostname navigation under the default browser SSRF policy while keeping explicit strict mode reachable from config, and keep managed loopback CDP `/json/new` fallback requests on the local CDP control policy so browser follow-up fixes stop regressing normal navigation or self-blocking local CDP control. (#66386) Thanks @obviyus.
|
||||
- Models/Codex: canonicalize the legacy `openai-codex/gpt-5.4-codex` runtime alias to `openai-codex/gpt-5.4` while still honoring alias-specific and canonical per-model overrides. (#43060) Thanks @Sapientropic and @vincentkoc.
|
||||
- Browser/SSRF: preserve explicit strict browser navigation mode for legacy `browser.ssrfPolicy.allowPrivateNetwork: false` configs by normalizing the legacy alias to the canonical strict marker instead of silently widening those installs to the default non-strict hostname-navigation path.
|
||||
- Onboarding/custom providers: use `max_tokens=16` for OpenAI-compatible verification probes so stricter custom endpoints stop rejecting onboarding checks that only need a tiny completion. (#66450) Thanks @WuKongAI-CMU.
|
||||
- Agents/subagents: emit the subagent registry lazy-runtime stub on the stable dist path that both source and bundled runtime imports resolve, so the follow-up dist fix no longer still fails with `ERR_MODULE_NOT_FOUND` at runtime. (#66420) Thanks @obviyus.
|
||||
- Media-understanding/proxy env: auto-upgrade provider HTTP helper requests to trusted env-proxy mode only when `HTTP_PROXY`/`HTTPS_PROXY` is active and the target is not bypassed by `NO_PROXY`, so remote media-understanding and transcription requests stop failing local DNS pre-resolution in proxy-only environments without widening SSRF bypasses. (#52162) Thanks @mjamiv and @vincentkoc.
|
||||
- Telegram/media downloads: let Telegram media fetches trust an operator-configured explicit proxy for target DNS resolution after hostname-policy checks, so proxy-backed installs stop failing `could not download media` on Bot API file downloads after the DNS-pinning regression. (#66245) Thanks @dawei41468 and @vincentkoc.
|
||||
- Browser: keep loopback CDP readiness checks reachable under strict SSRF defaults so OpenClaw can reconnect to locally started managed Chrome. (#66354) Thanks @hxy91819.
|
||||
- Agents/context engine: compact engine-owned sessions from the first tool-loop delta and preserve ingest fallback when `afterTurn` is absent, so long-running tool loops can stay bounded without dropping engine state. (#63555) Thanks @Bikkies.
|
||||
- OpenAI Codex/auth: keep malformed Codex CLI auth-file diagnostics on the debug logger instead of stdout so interactive command output stays clean while auth read failures remain traceable. (#66451) Thanks @SimbaKingjoe.
|
||||
- Discord/native commands: return the real status card for native `/status` interactions instead of falling through to the synthetic `✅ Done.` ack when the generic dispatcher produces no visible reply. (#54629) Thanks @tkozzer and @vincentkoc.
|
||||
- Hooks/Ollama: let LLM-backed session-memory slug generation honor an explicit `agents.defaults.timeoutSeconds` override instead of always aborting after 15 seconds, so slow local Ollama runs stop silently dropping back to generic filenames. (#66237) Thanks @dmak and @vincentkoc.
|
||||
- Media/transcription: remap `.aac` filenames to `.m4a` for OpenAI-compatible audio uploads so AAC voice notes stop failing MIME-sensitive transcription endpoints. (#66446) Thanks @ben-z.
|
||||
- UI/chat: replace marked.js with markdown-it so maliciously crafted markdown can no longer freeze the Control UI via ReDoS. (#46707) Thanks @zhangfnf.
|
||||
- Auto-reply/send policy: keep `sendPolicy: "deny"` from blocking inbound message processing, so the agent still runs its turn while all outbound delivery is suppressed for observer-style setups. (#65461, #53328) Thanks @omarshahine.
|
||||
- BlueBubbles: lazy-refresh the Private API server-info cache on send when reply threading or message effects are requested but status is unknown, so sends no longer silently degrade to plain messages when the 10-minute cache expires. (#65447, #43764) Thanks @omarshahine.
|
||||
@@ -35,21 +63,26 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron/scheduler: stop inventing short retries when cron next-run calculation returns no valid future slot, and keep a maintenance wake armed so enabled unscheduled jobs recover without entering a refire loop. (#66019, #66083) Thanks @mbelinky.
|
||||
- Cron/scheduler: preserve the active error-backoff floor when maintenance repair recomputes a missing cron next-run, so recurring errored jobs do not resume early after a transient next-run resolution failure. (#66019, #66083, #66113) Thanks @mbelinky.
|
||||
- Outbound/delivery-queue: persist the originating outbound `session` context on queued delivery entries and replay it during recovery, so write-ahead-queued sends keep their original outbound media policy context after restart instead of evaluating against a missing session. (#66025) Thanks @eleqtrizit.
|
||||
- Memory/Ollama: restore the built-in `ollama` embedding adapter in memory-core so explicit `memorySearch.provider: "ollama"` works again, and include endpoint-aware cache keys so different Ollama hosts do not reuse each other's embeddings. (#63429, #66078, #66163) Thanks @nnish16 and @vincentkoc.
|
||||
- Auto-reply/queue: split collect-mode followup drains into contiguous groups by per-message authorization context (sender id, owner status, exec/bash-elevated overrides), so queued items from different senders or exec configs no longer execute under the last queued run's owner-only and exec-approval context. (#66024) Thanks @eleqtrizit.
|
||||
- Dreaming/memory-core: require a live queued Dreaming cron event before the heartbeat hook runs the sweep, so managed Dreaming no longer replays on later heartbeats after the scheduled run was already consumed. (#66139) Thanks @mbelinky.
|
||||
- Control UI/Dreaming: stop Imported Insights and Memory Palace from calling optional `memory-wiki` gateway methods when the plugin is off, and refresh config before wiki reloads so the Dreaming tab stops showing misleading unknown-method failures. (#66140) Thanks @mbelinky.
|
||||
- Agents/tools: only mark streamed unknown-tool retries as counted when a streamed message actually classifies an unavailable tool, and keep incomplete streamed tool names from resetting the retry streak before the final assistant message arrives. (#66145) Thanks @dutifulbob.
|
||||
- Memory/active-memory: move recalled memory onto the hidden untrusted prompt-prefix path instead of system prompt injection, label the visible Active Memory status line fields, and include the resolved recall provider/model in gateway debug logs so trace/debug output matches what the model actually saw. (#66144) Thanks @Takhoffman.
|
||||
- Memory/QMD: stop treating legacy lowercase `memory.md` as a second default root collection, so QMD recall no longer searches phantom `memory-alt-*` collections and builtin/QMD root-memory fallback stays aligned. (#66141) Thanks @mbelinky.
|
||||
- Agents/subagents: ship `dist/agents/subagent-registry.runtime.js` in npm builds so `runtime: "subagent"` runs stop stalling in `queued` after the registry import fails. (#66189) Thanks @yqli2420 and @vincentkoc.
|
||||
- Agents/OpenAI: map `minimal` thinking to OpenAI's supported `low` reasoning effort for GPT-5.4 requests, so embedded runs stop failing request validation. Thanks @steipete.
|
||||
- Voice-call/media-stream: resolve the source IP from trusted forwarding headers for per-IP pending-connection limits when `webhookSecurity.trustForwardingHeaders` and `trustedProxyIPs` are configured, and reserve `maxConnections` capacity for in-flight WebSocket upgrades so concurrent handshakes can no longer momentarily exceed the operator-set cap. (#66027) Thanks @eleqtrizit.
|
||||
- Feishu/allowlist: canonicalize allowlist entries by explicit `user`/`chat` kind, strip repeated `feishu:`/`lark:` provider prefixes, and stop folding opaque Feishu IDs to lowercase, so allowlist matching no longer crosses user/chat namespaces or widens to case-insensitive ID matches the operator did not intend. (#66021) Thanks @eleqtrizit.
|
||||
- Telegram/status commands: let read-only status slash commands bypass busy topic turns, while keeping `/export-session` on the normal lane so it cannot interleave with an in-flight session mutation. (#66226) Thanks @VACInc and @vincentkoc.
|
||||
- TTS/reply media: persist OpenClaw temp voice outputs into managed outbound media and allow them through reply-media normalization, so voice-note replies stop silently dropping. (#63511) Thanks @jetd1.
|
||||
- Agents/tools: treat Windows drive-letter paths (`C:\\...`) as absolute when resolving sandbox and read-tool paths so workspace root is not prepended under POSIX path rules. (#54039) Thanks @ly85206559 and @vincentkoc.
|
||||
- Agents/OpenAI: recover embedded GPT-style runs when reasoning-only or empty turns need bounded continuation, with replay-safe retry gating and incomplete-turn fallback when no visible answer arrives. (#66167) thanks @jalehman
|
||||
- Outbound/relay-status: suppress internal relay-status placeholder payloads (`No channel reply.`, `Replied in-thread.`, `Replied in #...`, wiki-update status variants ending in `No channel reply.`) before channel delivery so internal housekeeping text does not leak to users.
|
||||
- Slack/doctor: add a dedicated doctor-contract sidecar so config warmup paths such as `openclaw cron` no longer fall back to Slack's broader contract surface, which could trigger Slack-related config-read crashes on affected setups. (#63192) Thanks @shhtheonlyperson.
|
||||
- Hooks/session-memory: pass the resolved agent workspace into gateway `/new` and `/reset` session-memory hooks so reset snapshots stay scoped to the right agent workspace instead of leaking into the default workspace. (#64735) Thanks @suboss87 and @vincentkoc.
|
||||
- CLI/approvals: raise the default `openclaw approvals get` gateway timeout and report config-load timeouts explicitly, so slow hosts stop showing a misleading `Config unavailable.` note when the approvals snapshot succeeds but the follow-up config RPC needs more time. (#66239) Thanks @neeravmakwana.
|
||||
- Media/store: honor configured agent media limits when saving generated media and persisting outbound reply media, so the store no longer hard-stops those flows at 5 MB before the configured limit applies. (#66229) Thanks @neeravmakwana and @vincentkoc.
|
||||
|
||||
## 2026.4.12
|
||||
|
||||
@@ -310,6 +343,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/inbound metadata: strip NUL bytes from serialized inbound context blocks before they reach backend spawn args, so malformed message metadata cannot crash agent spawn with `ERR_INVALID_ARG_VALUE`. (#65389) Thanks @adminfedres and @vincentkoc.
|
||||
- iMessage: retry transient `watch.subscribe` startup failures before tearing down the monitor, so brief local transport stalls do not immediately bounce the channel. (#65393) Thanks @vincentkoc.
|
||||
- Status/session_status: move shared session status text into a neutral internal status module and keep the tool importing a local runtime shim, so built `session_status` no longer depends on reply command internals or a bundler-opaque runtime import. (#65807) Thanks @dutifulbob.
|
||||
- QQBot/security: replace raw `fetch()` in the image-size probe with SSRF-guarded `fetchRemoteMedia`, fix `resolveRepoRoot()` to walk up to `.git` instead of hardcoding two parent levels, and refresh the raw-fetch allowlist to match the corrected scan. (#63495) Thanks @dims.
|
||||
|
||||
## 2026.4.9
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ android {
|
||||
minSdk = 31
|
||||
targetSdk = 36
|
||||
versionCode = 2026041401
|
||||
versionName = "2026.4.14-beta.1"
|
||||
versionName = "2026.4.14"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.4.14-beta.1</string>
|
||||
<string>2026.4.14</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>2026041401</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
7003e0d0ba1cddb7eb388204825ac892206209a4a9c795e76c4e34b5fc7b50f0 plugin-sdk-api-baseline.json
|
||||
14e39520459abc7db7993a700a4f07adfa0855d9233d123c4725477b91f1cb13 plugin-sdk-api-baseline.jsonl
|
||||
7b121e2b694f80433fa91ce9037527ca58be546a7f18798470a4ade66593e5e1 plugin-sdk-api-baseline.json
|
||||
7b802cc04f0eac0b498b50711e39a7afe93bbb6b682a2013d2c303583fb73f40 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -33,6 +33,20 @@ openclaw browser --browser-profile openclaw open https://example.com
|
||||
openclaw browser --browser-profile openclaw snapshot
|
||||
```
|
||||
|
||||
## Quick troubleshooting
|
||||
|
||||
If `start` fails with `not reachable after start`, troubleshoot CDP readiness first. If `start` and `tabs` succeed but `open` or `navigate` fails, the browser control plane is healthy and the failure is usually navigation SSRF policy.
|
||||
|
||||
Minimal sequence:
|
||||
|
||||
```bash
|
||||
openclaw browser --browser-profile openclaw start
|
||||
openclaw browser --browser-profile openclaw tabs
|
||||
openclaw browser --browser-profile openclaw open https://example.com
|
||||
```
|
||||
|
||||
Detailed guidance: [Browser troubleshooting](/tools/browser#cdp-startup-failure-vs-navigation-ssrf-block)
|
||||
|
||||
## Lifecycle
|
||||
|
||||
```bash
|
||||
|
||||
@@ -174,6 +174,7 @@ Compatibility notes for stricter OpenAI-compatible backends:
|
||||
|
||||
- Gateway can reach the proxy? `curl http://127.0.0.1:1234/v1/models`.
|
||||
- LM Studio model unloaded? Reload; cold start is a common “hanging” cause.
|
||||
- OpenClaw warns when the detected context window is below **32k** and blocks below **16k**. If you hit that preflight, raise the server/model context limit or choose a larger model.
|
||||
- Context errors? Lower `contextWindow` or raise your server limit.
|
||||
- OpenAI-compatible server returns `messages[].content ... expected a string`?
|
||||
Add `compat.requiresStringContent: true` on that model entry.
|
||||
|
||||
@@ -90,6 +90,9 @@ OpenClaw has three public release lanes:
|
||||
- npm release preflight fails closed unless the tarball includes both
|
||||
`dist/control-ui/index.html` and a non-empty `dist/control-ui/assets/` payload
|
||||
so we do not ship an empty browser dashboard again
|
||||
- `pnpm test:install:smoke` also enforces the npm pack `unpackedSize` budget on
|
||||
the candidate update tarball, so installer e2e catches accidental pack bloat
|
||||
before the release publish path
|
||||
- If the release work touched CI planning, extension timing manifests, or
|
||||
extension test matrices, regenerate and review the planner-owned
|
||||
`checks-node-extensions` workflow matrix outputs from `.github/workflows/ci.yml`
|
||||
|
||||
@@ -884,6 +884,63 @@ For Linux-specific issues (especially snap Chromium), see
|
||||
For WSL2 Gateway + Windows Chrome split-host setups, see
|
||||
[WSL2 + Windows + remote Chrome CDP troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting).
|
||||
|
||||
### CDP startup failure vs navigation SSRF block
|
||||
|
||||
These are different failure classes and they point to different code paths.
|
||||
|
||||
- **CDP startup or readiness failure** means OpenClaw cannot confirm that the browser control plane is healthy.
|
||||
- **Navigation SSRF block** means the browser control plane is healthy, but a page navigation target is rejected by policy.
|
||||
|
||||
Common examples:
|
||||
|
||||
- CDP startup or readiness failure:
|
||||
- `Chrome CDP websocket for profile "openclaw" is not reachable after start`
|
||||
- `Remote CDP for profile "<name>" is not reachable at <cdpUrl>`
|
||||
- Navigation SSRF block:
|
||||
- `open`, `navigate`, snapshot, or tab-opening flows fail with a browser/network policy error while `start` and `tabs` still work
|
||||
|
||||
Use this minimal sequence to separate the two:
|
||||
|
||||
```bash
|
||||
openclaw browser --browser-profile openclaw start
|
||||
openclaw browser --browser-profile openclaw tabs
|
||||
openclaw browser --browser-profile openclaw open https://example.com
|
||||
```
|
||||
|
||||
How to read the results:
|
||||
|
||||
- If `start` fails with `not reachable after start`, troubleshoot CDP readiness first.
|
||||
- If `start` succeeds but `tabs` fails, the control plane is still unhealthy. Treat this as a CDP reachability problem, not a page-navigation problem.
|
||||
- If `start` and `tabs` succeed but `open` or `navigate` fails, the browser control plane is up and the failure is in navigation policy or the target page.
|
||||
- If `start`, `tabs`, and `open` all succeed, the basic managed-browser control path is healthy.
|
||||
|
||||
Important behavior details:
|
||||
|
||||
- Browser config defaults to a fail-closed SSRF policy object even when you do not configure `browser.ssrfPolicy`.
|
||||
- For the local loopback `openclaw` managed profile, CDP health checks intentionally skip browser SSRF reachability enforcement for OpenClaw's own local control plane.
|
||||
- Navigation protection is separate. A successful `start` or `tabs` result does not mean a later `open` or `navigate` target is allowed.
|
||||
|
||||
Security guidance:
|
||||
|
||||
- Do **not** relax browser SSRF policy by default.
|
||||
- Prefer narrow host exceptions such as `hostnameAllowlist` or `allowedHostnames` over broad private-network access.
|
||||
- Use `dangerouslyAllowPrivateNetwork: true` only in intentionally trusted environments where private-network browser access is required and reviewed.
|
||||
|
||||
Example: navigation blocked, control plane healthy
|
||||
|
||||
- `start` succeeds
|
||||
- `tabs` succeeds
|
||||
- `open http://internal.example` fails
|
||||
|
||||
That usually means browser startup is fine and the navigation target needs policy review.
|
||||
|
||||
Example: startup blocked before navigation matters
|
||||
|
||||
- `start` fails with `not reachable after start`
|
||||
- `tabs` also fails or cannot run
|
||||
|
||||
That points to browser launch or CDP reachability, not a page URL allowlist problem.
|
||||
|
||||
## Agent tools + how control works
|
||||
|
||||
The agent gets **one tool** for browser automation:
|
||||
|
||||
@@ -10,7 +10,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { fetchJson, fetchOk } from "./cdp.helpers.js";
|
||||
import { assertCdpEndpointAllowed, fetchJson, fetchOk } from "./cdp.helpers.js";
|
||||
|
||||
describe("cdp helpers", () => {
|
||||
afterEach(() => {
|
||||
@@ -43,6 +43,23 @@ describe("cdp helpers", () => {
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows loopback CDP endpoints in strict SSRF mode", async () => {
|
||||
await expect(
|
||||
assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("still enforces hostname allowlist for loopback CDP endpoints", async () => {
|
||||
await expect(
|
||||
assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
hostnameAllowlist: ["*.corp.example"],
|
||||
}),
|
||||
).rejects.toThrow("browser endpoint blocked by policy");
|
||||
});
|
||||
|
||||
it("releases guarded CDP fetches for bodyless requests", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
@@ -62,4 +79,62 @@ describe("cdp helpers", () => {
|
||||
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses an exact loopback allowlist for guarded loopback CDP fetches", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: {
|
||||
ok: true,
|
||||
status: 200,
|
||||
},
|
||||
release,
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchOk("http://127.0.0.1:9222/json/version", 250, undefined, {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "http://127.0.0.1:9222/json/version",
|
||||
policy: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
allowedHostnames: ["127.0.0.1"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("preserves hostname allowlist while allowing exact loopback CDP fetches", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: {
|
||||
ok: true,
|
||||
status: 200,
|
||||
},
|
||||
release,
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchOk("http://127.0.0.1:9222/json/version", 250, undefined, {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
hostnameAllowlist: ["*.corp.example"],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "http://127.0.0.1:9222/json/version",
|
||||
policy: {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
hostnameAllowlist: ["*.corp.example"],
|
||||
allowedHostnames: ["127.0.0.1"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,8 +69,16 @@ export async function assertCdpEndpointAllowed(
|
||||
throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`);
|
||||
}
|
||||
try {
|
||||
const policy = isLoopbackHost(parsed.hostname)
|
||||
? {
|
||||
...ssrfPolicy,
|
||||
allowedHostnames: Array.from(
|
||||
new Set([...(ssrfPolicy?.allowedHostnames ?? []), parsed.hostname]),
|
||||
),
|
||||
}
|
||||
: ssrfPolicy;
|
||||
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
|
||||
policy: ssrfPolicy,
|
||||
policy,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new BrowserCdpEndpointBlockedError({ cause: error });
|
||||
@@ -263,11 +271,20 @@ export async function fetchCdpChecked(
|
||||
try {
|
||||
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
|
||||
const res = await withNoProxyForCdpUrl(url, async () => {
|
||||
const parsedUrl = new URL(url);
|
||||
const policy = isLoopbackHost(parsedUrl.hostname)
|
||||
? {
|
||||
...ssrfPolicy,
|
||||
allowedHostnames: Array.from(
|
||||
new Set([...(ssrfPolicy?.allowedHostnames ?? []), parsedUrl.hostname]),
|
||||
),
|
||||
}
|
||||
: (ssrfPolicy ?? { allowPrivateNetwork: true });
|
||||
const guarded = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init: { ...init, headers },
|
||||
signal: ctrl.signal,
|
||||
policy: ssrfPolicy ?? { allowPrivateNetwork: true },
|
||||
policy,
|
||||
auditContext: "browser-cdp",
|
||||
});
|
||||
guardedRelease = guarded.release;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { getChromeWebSocketUrl, isChromeReachable } from "./chrome.js";
|
||||
|
||||
type RunningServer = {
|
||||
server: Server;
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
const runningServers: Server[] = [];
|
||||
|
||||
async function startLoopbackCdpServer(): Promise<RunningServer> {
|
||||
const server = createServer((req, res) => {
|
||||
if (req.url !== "/json/version") {
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
return;
|
||||
}
|
||||
const address = server.address() as AddressInfo;
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
Browser: "Chrome/999.0.0.0",
|
||||
webSocketDebuggerUrl: `ws://127.0.0.1:${address.port}/devtools/browser/TEST`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
|
||||
runningServers.push(server);
|
||||
const address = server.address() as AddressInfo;
|
||||
return {
|
||||
server,
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
runningServers
|
||||
.splice(0)
|
||||
.map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve, reject) =>
|
||||
server.close((err) => (err ? reject(err) : resolve())),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe("chrome loopback SSRF integration", () => {
|
||||
it("keeps loopback CDP HTTP reachability working under strict default SSRF policy", async () => {
|
||||
const { baseUrl } = await startLoopbackCdpServer();
|
||||
|
||||
await expect(isChromeReachable(baseUrl, 500, {})).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("returns the loopback websocket URL under strict default SSRF policy", async () => {
|
||||
const { baseUrl } = await startLoopbackCdpServer();
|
||||
|
||||
await expect(getChromeWebSocketUrl(baseUrl, 500, {})).resolves.toMatch(
|
||||
/\/devtools\/browser\/TEST$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -312,22 +312,28 @@ describe("browser chrome helpers", () => {
|
||||
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("blocks private CDP probes when strict SSRF policy is enabled", async () => {
|
||||
const fetchSpy = vi.fn().mockRejectedValue(new Error("should not be called"));
|
||||
it("allows loopback CDP probes while still blocking non-loopback private targets in strict SSRF mode", async () => {
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
|
||||
} as unknown as Response)
|
||||
.mockRejectedValue(new Error("should not be called"));
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
|
||||
await expect(
|
||||
isChromeReachable("http://127.0.0.1:12345", 50, {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
).resolves.toBe(true);
|
||||
await expect(
|
||||
isChromeReachable("ws://127.0.0.1:19999", 50, {
|
||||
isChromeReachable("http://169.254.169.254:12345", 50, {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks cross-host websocket pivots returned by /json/version in strict SSRF mode", async () => {
|
||||
|
||||
@@ -318,7 +318,16 @@ describe("browser config", () => {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
},
|
||||
});
|
||||
expect(resolved.ssrfPolicy).toEqual({});
|
||||
expect(resolved.ssrfPolicy).toEqual({ dangerouslyAllowPrivateNetwork: false });
|
||||
});
|
||||
|
||||
it("preserves legacy explicit strict mode from allowPrivateNetwork=false", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
ssrfPolicy: {
|
||||
allowPrivateNetwork: false,
|
||||
},
|
||||
} as unknown as BrowserConfig);
|
||||
expect(resolved.ssrfPolicy).toEqual({ dangerouslyAllowPrivateNetwork: false });
|
||||
});
|
||||
|
||||
it("keeps allowlist-only browser SSRF policy strict by default", () => {
|
||||
|
||||
@@ -149,7 +149,11 @@ function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy |
|
||||
}
|
||||
|
||||
return {
|
||||
...(resolvedAllowPrivateNetwork ? { dangerouslyAllowPrivateNetwork: true } : {}),
|
||||
...(resolvedAllowPrivateNetwork ||
|
||||
dangerouslyAllowPrivateNetwork === false ||
|
||||
allowPrivateNetwork === false
|
||||
? { dangerouslyAllowPrivateNetwork: resolvedAllowPrivateNetwork }
|
||||
: {}),
|
||||
...(allowedHostnames ? { allowedHostnames } : {}),
|
||||
...(hostnameAllowlist ? { hostnameAllowlist } : {}),
|
||||
};
|
||||
|
||||
@@ -128,6 +128,18 @@ describe("browser navigation guard", () => {
|
||||
expect(lookupFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows hostname navigation when the default strict policy object is present", async () => {
|
||||
const lookupFn = createLookupFn("93.184.216.34");
|
||||
await expect(
|
||||
assertBrowserNavigationAllowed({
|
||||
url: "https://example.com",
|
||||
lookupFn,
|
||||
ssrfPolicy: {},
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(lookupFn).toHaveBeenCalledWith("example.com", { all: true });
|
||||
});
|
||||
|
||||
it("allows explicitly allowed hostnames in strict mode", async () => {
|
||||
const lookupFn = createLookupFn("93.184.216.34");
|
||||
await expect(
|
||||
@@ -300,8 +312,11 @@ describe("browser navigation guard", () => {
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats default browser SSRF mode as requiring redirect-hop inspection", () => {
|
||||
expect(requiresInspectableBrowserNavigationRedirects()).toBe(true);
|
||||
it("requires redirect-hop inspection only in explicit strict mode", () => {
|
||||
expect(requiresInspectableBrowserNavigationRedirects()).toBe(false);
|
||||
expect(
|
||||
requiresInspectableBrowserNavigationRedirects({ dangerouslyAllowPrivateNetwork: false }),
|
||||
).toBe(true);
|
||||
expect(requiresInspectableBrowserNavigationRedirects({ allowPrivateNetwork: true })).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -43,7 +43,7 @@ export function withBrowserNavigationPolicy(
|
||||
}
|
||||
|
||||
export function requiresInspectableBrowserNavigationRedirects(ssrfPolicy?: SsrFPolicy): boolean {
|
||||
return !isPrivateNetworkAllowedByPolicy(ssrfPolicy);
|
||||
return ssrfPolicy?.dangerouslyAllowPrivateNetwork === false;
|
||||
}
|
||||
|
||||
export function requiresInspectableBrowserNavigationRedirectsForUrl(
|
||||
@@ -122,6 +122,7 @@ export async function assertBrowserNavigationAllowed(
|
||||
// the same address that passed policy checks.
|
||||
if (
|
||||
opts.ssrfPolicy &&
|
||||
opts.ssrfPolicy.dangerouslyAllowPrivateNetwork === false &&
|
||||
!isPrivateNetworkAllowedByPolicy(opts.ssrfPolicy) &&
|
||||
!isIpLiteralHostname(parsed.hostname) &&
|
||||
!isExplicitlyAllowedBrowserHostname(parsed.hostname, opts.ssrfPolicy)
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("browser tab routes attachOnly loopback profiles", () => {
|
||||
{
|
||||
id: "PAGE-1",
|
||||
title: "WordPress",
|
||||
url: "https://example.test/wp-login.php",
|
||||
url: "https://example.com/wp-login.php",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/page/PAGE-1",
|
||||
type: "page",
|
||||
},
|
||||
@@ -73,7 +73,7 @@ describe("browser tab routes attachOnly loopback profiles", () => {
|
||||
{
|
||||
targetId: "PAGE-1",
|
||||
title: "WordPress",
|
||||
url: "https://example.test/wp-login.php",
|
||||
url: "https://example.com/wp-login.php",
|
||||
wsUrl: "ws://127.0.0.1:9222/devtools/page/PAGE-1",
|
||||
type: "page",
|
||||
},
|
||||
|
||||
@@ -71,7 +71,6 @@ export function createProfileAvailability({
|
||||
|
||||
const getCdpReachabilityPolicy = () =>
|
||||
resolveCdpReachabilityPolicy(profile, state().resolved.ssrfPolicy);
|
||||
|
||||
const isReachable = async (timeoutMs?: number) => {
|
||||
if (capabilities.usesChromeMcp) {
|
||||
// listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ describe("browser remote profile fallback and attachOnly behavior", () => {
|
||||
it("fails closed for remote tab opens in strict mode without Playwright", async () => {
|
||||
vi.spyOn(deps.pwAiModule, "getPwAiModule").mockResolvedValue(null);
|
||||
const { state, remote, fetchMock } = deps.createRemoteRouteHarness();
|
||||
state.resolved.ssrfPolicy = {};
|
||||
state.resolved.ssrfPolicy = { dangerouslyAllowPrivateNetwork: false };
|
||||
|
||||
await expect(remote.openTab("https://example.com")).rejects.toBeInstanceOf(
|
||||
deps.InvalidBrowserNavigationUrlError,
|
||||
|
||||
@@ -230,14 +230,14 @@ export function createProfileTabOps({
|
||||
{
|
||||
method: "PUT",
|
||||
},
|
||||
ssrfPolicyOpts.ssrfPolicy,
|
||||
getCdpControlPolicy(),
|
||||
).catch(async (err) => {
|
||||
if (String(err).includes("HTTP 405")) {
|
||||
return await fetchJson<CdpTarget>(
|
||||
endpoint,
|
||||
CDP_JSON_NEW_TIMEOUT_MS,
|
||||
undefined,
|
||||
ssrfPolicyOpts.ssrfPolicy,
|
||||
getCdpControlPolicy(),
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
|
||||
@@ -6,6 +6,7 @@ vi.hoisted(() => {
|
||||
});
|
||||
|
||||
import "./server-context.chrome-test-harness.js";
|
||||
import * as cdpHelpersModule from "./cdp.helpers.js";
|
||||
import * as cdpModule from "./cdp.js";
|
||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||
import { createBrowserRouteContext } from "./server-context.js";
|
||||
@@ -296,4 +297,38 @@ describe("browser server-context tab selection state", () => {
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the loopback CDP control policy for /json/new fallback requests", async () => {
|
||||
vi.spyOn(cdpModule, "createTargetViaCdp").mockRejectedValue(new Error("cdp unavailable"));
|
||||
const fetchJson = vi.spyOn(cdpHelpersModule, "fetchJson");
|
||||
fetchJson.mockRejectedValueOnce(new Error("HTTP 405")).mockResolvedValueOnce({
|
||||
id: "NEW",
|
||||
title: "New Tab",
|
||||
url: "https://example.com",
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/NEW",
|
||||
type: "page",
|
||||
});
|
||||
|
||||
const state = makeState("openclaw");
|
||||
state.resolved.ssrfPolicy = {};
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const openclaw = ctx.forProfile("openclaw");
|
||||
|
||||
const opened = await openclaw.openTab("https://example.com");
|
||||
expect(opened.targetId).toBe("NEW");
|
||||
expect(fetchJson).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringContaining("/json/new"),
|
||||
expect.any(Number),
|
||||
{ method: "PUT" },
|
||||
undefined,
|
||||
);
|
||||
expect(fetchJson).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining("/json/new"),
|
||||
expect.any(Number),
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,6 +114,7 @@ export async function buildCodexProviderCatalog(
|
||||
return {
|
||||
provider: {
|
||||
baseUrl: CODEX_BASE_URL,
|
||||
apiKey: "codex-app-server",
|
||||
auth: "token",
|
||||
api: "openai-codex-responses",
|
||||
models,
|
||||
|
||||
@@ -52,8 +52,8 @@ function createConfig(): OpenClawConfig {
|
||||
|
||||
function createCommand(cfg: OpenClawConfig, discordConfig?: DiscordAccountConfig) {
|
||||
const commandSpec: NativeCommandSpec = {
|
||||
name: "status",
|
||||
description: "Status",
|
||||
name: "ping",
|
||||
description: "Ping",
|
||||
acceptsArgs: false,
|
||||
};
|
||||
return createDiscordNativeCommand({
|
||||
|
||||
@@ -19,6 +19,7 @@ const runtimeModuleMocks = vi.hoisted(() => ({
|
||||
matchPluginCommand: vi.fn(),
|
||||
executePluginCommand: vi.fn(),
|
||||
dispatchReplyWithDispatcher: vi.fn(),
|
||||
resolveDirectStatusReplyForSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/plugin-runtime", async () => {
|
||||
@@ -43,6 +44,11 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/command-status-runtime", () => ({
|
||||
resolveDirectStatusReplyForSession: (...args: unknown[]) =>
|
||||
runtimeModuleMocks.resolveDirectStatusReplyForSession(...args),
|
||||
}));
|
||||
|
||||
function createInteraction(params?: {
|
||||
channelType?: ChannelType;
|
||||
channelId?: string;
|
||||
@@ -306,35 +312,24 @@ function createDispatchSpy() {
|
||||
} as never);
|
||||
}
|
||||
|
||||
function expectBoundSessionDispatch(
|
||||
dispatchSpy: ReturnType<typeof createDispatchSpy>,
|
||||
expectedPattern: RegExp,
|
||||
) {
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
|
||||
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
|
||||
};
|
||||
if (!dispatchCall.ctx?.SessionKey || !dispatchCall.ctx.CommandTargetSessionKey) {
|
||||
throw new Error("native command dispatch did not include bound session context");
|
||||
}
|
||||
expect(dispatchCall.ctx.SessionKey).toMatch(expectedPattern);
|
||||
expect(dispatchCall.ctx.CommandTargetSessionKey).toMatch(expectedPattern);
|
||||
}
|
||||
|
||||
async function expectBoundStatusCommandDispatch(params: {
|
||||
async function expectBoundStatusCommandDirectReply(params: {
|
||||
cfg: OpenClawConfig;
|
||||
interaction: MockCommandInteraction;
|
||||
expectedPattern: RegExp;
|
||||
}) {
|
||||
runtimeModuleMocks.matchPluginCommand.mockReturnValue(null);
|
||||
const dispatchSpy = createDispatchSpy();
|
||||
const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher;
|
||||
const statusSpy = runtimeModuleMocks.resolveDirectStatusReplyForSession;
|
||||
const command = await createStatusCommand(params.cfg);
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(
|
||||
params.interaction as unknown,
|
||||
);
|
||||
|
||||
expectBoundSessionDispatch(dispatchSpy, params.expectedPattern);
|
||||
expect(dispatchSpy).not.toHaveBeenCalled();
|
||||
expect(statusSpy).toHaveBeenCalledTimes(1);
|
||||
const statusCall = statusSpy.mock.calls[0]?.[0] as { sessionKey?: string };
|
||||
expect(statusCall.sessionKey).toMatch(params.expectedPattern);
|
||||
}
|
||||
|
||||
describe("Discord native plugin command dispatch", () => {
|
||||
@@ -366,6 +361,10 @@ describe("Discord native plugin command dispatch", () => {
|
||||
tool: 0,
|
||||
},
|
||||
} as never);
|
||||
runtimeModuleMocks.resolveDirectStatusReplyForSession.mockReset();
|
||||
runtimeModuleMocks.resolveDirectStatusReplyForSession.mockResolvedValue({
|
||||
text: "status reply",
|
||||
});
|
||||
discordNativeCommandTesting.setMatchPluginCommand(
|
||||
runtimeModuleMocks.matchPluginCommand as typeof import("openclaw/plugin-sdk/plugin-runtime").matchPluginCommand,
|
||||
);
|
||||
@@ -632,7 +631,7 @@ describe("Discord native plugin command dispatch", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await expectBoundStatusCommandDispatch({
|
||||
await expectBoundStatusCommandDirectReply({
|
||||
cfg,
|
||||
interaction,
|
||||
expectedPattern: /^agent:codex:acp:binding:discord:default:/,
|
||||
@@ -683,7 +682,8 @@ describe("Discord native plugin command dispatch", () => {
|
||||
}),
|
||||
);
|
||||
runtimeModuleMocks.matchPluginCommand.mockReturnValue(null);
|
||||
const dispatchSpy = createDispatchSpy();
|
||||
const dispatchSpy = runtimeModuleMocks.dispatchReplyWithDispatcher;
|
||||
const statusSpy = runtimeModuleMocks.resolveDirectStatusReplyForSession;
|
||||
const command = await createStatusCommand(cfg);
|
||||
discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(async () => ({
|
||||
route: {
|
||||
@@ -712,14 +712,10 @@ describe("Discord native plugin command dispatch", () => {
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
|
||||
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
|
||||
};
|
||||
expect(dispatchCall.ctx?.SessionKey).toBe("agent:qwen:discord:slash:owner");
|
||||
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(
|
||||
"agent:qwen:discord:channel:1478836151241412759",
|
||||
);
|
||||
expect(dispatchSpy).not.toHaveBeenCalled();
|
||||
expect(statusSpy).toHaveBeenCalledTimes(1);
|
||||
const statusCall = statusSpy.mock.calls[0]?.[0] as { sessionKey?: string };
|
||||
expect(statusCall.sessionKey).toBe("agent:qwen:discord:channel:1478836151241412759");
|
||||
});
|
||||
|
||||
it("routes Discord DM native slash commands through configured ACP bindings", async () => {
|
||||
@@ -735,7 +731,7 @@ describe("Discord native plugin command dispatch", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await expectBoundStatusCommandDispatch({
|
||||
await expectBoundStatusCommandDirectReply({
|
||||
cfg,
|
||||
interaction,
|
||||
expectedPattern: /^agent:codex:acp:binding:discord:default:/,
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { ChannelType } from "discord-api-types/v10";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createMockCommandInteraction,
|
||||
type MockCommandInteraction,
|
||||
} from "./native-command.test-helpers.js";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
const runtimeModuleMocks = vi.hoisted(() => ({
|
||||
dispatchReplyWithDispatcher: vi.fn(),
|
||||
resolveDirectStatusReplyForSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/reply-dispatch-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/reply-dispatch-runtime")>(
|
||||
"openclaw/plugin-sdk/reply-dispatch-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
dispatchReplyWithDispatcher: (...args: unknown[]) =>
|
||||
runtimeModuleMocks.dispatchReplyWithDispatcher(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/command-status-runtime", () => ({
|
||||
resolveDirectStatusReplyForSession: (...args: unknown[]) =>
|
||||
runtimeModuleMocks.resolveDirectStatusReplyForSession(...args),
|
||||
}));
|
||||
|
||||
let createDiscordNativeCommand: typeof import("./native-command.js").createDiscordNativeCommand;
|
||||
let discordNativeCommandTesting: typeof import("./native-command.js").__testing;
|
||||
|
||||
function createInteraction(params?: {
|
||||
channelType?: ChannelType;
|
||||
channelId?: string;
|
||||
threadParentId?: string | null;
|
||||
guildId?: string | null;
|
||||
guildName?: string;
|
||||
}): MockCommandInteraction {
|
||||
return createMockCommandInteraction({
|
||||
userId: "owner",
|
||||
username: "tester",
|
||||
globalName: "Tester",
|
||||
channelType: params?.channelType ?? ChannelType.DM,
|
||||
channelId: params?.channelId ?? "dm-1",
|
||||
threadParentId: params?.threadParentId,
|
||||
guildId: params?.guildId ?? null,
|
||||
guildName: params?.guildName,
|
||||
interactionId: "interaction-1",
|
||||
});
|
||||
}
|
||||
|
||||
function createConfig(params?: { requireMention?: boolean }): OpenClawConfig {
|
||||
return {
|
||||
commands: {
|
||||
useAccessGroups: false,
|
||||
},
|
||||
channels: {
|
||||
discord: {
|
||||
dm: { enabled: true, policy: "open" },
|
||||
guilds: {
|
||||
guild1: {
|
||||
requireMention: true,
|
||||
channels: {
|
||||
chan1: {
|
||||
allow: true,
|
||||
requireMention: params?.requireMention ?? true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
async function createStatusCommand(cfg: OpenClawConfig) {
|
||||
return createDiscordNativeCommand({
|
||||
command: {
|
||||
name: "status",
|
||||
description: "Status",
|
||||
acceptsArgs: false,
|
||||
},
|
||||
cfg,
|
||||
discordConfig: cfg.channels?.discord ?? {},
|
||||
accountId: "default",
|
||||
sessionPrefix: "discord:slash",
|
||||
ephemeralDefault: true,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
});
|
||||
}
|
||||
|
||||
function setDefaultRouteState() {
|
||||
discordNativeCommandTesting.setResolveDiscordNativeInteractionRouteState(async (params) => ({
|
||||
route: {
|
||||
agentId: "main",
|
||||
channel: "discord",
|
||||
accountId: params.accountId ?? "default",
|
||||
sessionKey: "agent:main:main",
|
||||
mainSessionKey: "agent:main:main",
|
||||
lastRoutePolicy: "session",
|
||||
matchedBy: "default",
|
||||
},
|
||||
effectiveRoute: {
|
||||
agentId: "main",
|
||||
channel: "discord",
|
||||
accountId: params.accountId ?? "default",
|
||||
sessionKey: "agent:main:main",
|
||||
mainSessionKey: "agent:main:main",
|
||||
lastRoutePolicy: "session",
|
||||
matchedBy: "default",
|
||||
},
|
||||
boundSessionKey: undefined,
|
||||
configuredRoute: null,
|
||||
configuredBinding: null,
|
||||
bindingReadiness: null,
|
||||
}));
|
||||
}
|
||||
|
||||
function firstStatusCall(): {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
channel: string;
|
||||
isGroup: boolean;
|
||||
defaultGroupActivation: () => "always" | "mention";
|
||||
} {
|
||||
const call = runtimeModuleMocks.resolveDirectStatusReplyForSession.mock.calls[0]?.[0];
|
||||
if (!call) {
|
||||
throw new Error("expected resolveDirectStatusReplyForSession to be called");
|
||||
}
|
||||
return call as {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
channel: string;
|
||||
isGroup: boolean;
|
||||
defaultGroupActivation: () => "always" | "mention";
|
||||
};
|
||||
}
|
||||
|
||||
describe("discord native /status", () => {
|
||||
beforeAll(async () => {
|
||||
({ createDiscordNativeCommand, __testing: discordNativeCommandTesting } =
|
||||
await import("./native-command.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue({
|
||||
counts: {
|
||||
final: 0,
|
||||
block: 0,
|
||||
tool: 0,
|
||||
},
|
||||
queuedFinal: false,
|
||||
} as never);
|
||||
runtimeModuleMocks.resolveDirectStatusReplyForSession.mockResolvedValue({
|
||||
text: "status reply",
|
||||
});
|
||||
discordNativeCommandTesting.setDispatchReplyWithDispatcher(
|
||||
runtimeModuleMocks.dispatchReplyWithDispatcher as typeof import("openclaw/plugin-sdk/reply-dispatch-runtime").dispatchReplyWithDispatcher,
|
||||
);
|
||||
setDefaultRouteState();
|
||||
});
|
||||
|
||||
it("returns a direct status reply without falling through the generic dispatcher", async () => {
|
||||
const cfg = createConfig();
|
||||
const command = await createStatusCommand(cfg);
|
||||
const interaction = createInteraction();
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
|
||||
|
||||
expect(runtimeModuleMocks.resolveDirectStatusReplyForSession).toHaveBeenCalledTimes(1);
|
||||
expect(runtimeModuleMocks.dispatchReplyWithDispatcher).not.toHaveBeenCalled();
|
||||
expect(interaction.followUp).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: "status reply",
|
||||
}),
|
||||
);
|
||||
expect(interaction.reply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes through the effective guild activation when requireMention is disabled", async () => {
|
||||
const cfg = createConfig({ requireMention: false });
|
||||
const command = await createStatusCommand(cfg);
|
||||
const interaction = createInteraction({
|
||||
channelType: ChannelType.GuildText,
|
||||
channelId: "chan1",
|
||||
guildId: "guild1",
|
||||
guildName: "Guild One",
|
||||
});
|
||||
|
||||
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
|
||||
|
||||
const statusCall = firstStatusCall();
|
||||
expect(statusCall.channel).toBe("discord");
|
||||
expect(statusCall.isGroup).toBe(true);
|
||||
expect(statusCall.defaultGroupActivation()).toBe("always");
|
||||
});
|
||||
});
|
||||
@@ -145,7 +145,9 @@ describe("discord native /think autocomplete", () => {
|
||||
providerThinkingMocks.resolveProviderBinaryThinking.mockReturnValue(undefined);
|
||||
providerThinkingMocks.resolveProviderDefaultThinkingLevel.mockReturnValue(undefined);
|
||||
providerThinkingMocks.resolveProviderXHighThinking.mockImplementation(({ provider, context }) =>
|
||||
provider === "openai-codex" && context.modelId === "gpt-5.4" ? true : undefined,
|
||||
provider === "openai-codex" && ["gpt-5.4", "gpt-5.4-pro"].includes(context.modelId)
|
||||
? true
|
||||
: undefined,
|
||||
);
|
||||
buildModelsProviderDataMock.mockResolvedValue({
|
||||
byProvider: new Map<string, Set<string>>(),
|
||||
@@ -172,7 +174,9 @@ describe("discord native /think autocomplete", () => {
|
||||
providerThinkingMocks.resolveProviderDefaultThinkingLevel.mockReturnValue(undefined);
|
||||
providerThinkingMocks.resolveProviderXHighThinking.mockReset();
|
||||
providerThinkingMocks.resolveProviderXHighThinking.mockImplementation(({ provider, context }) =>
|
||||
provider === "openai-codex" && context.modelId === "gpt-5.4" ? true : undefined,
|
||||
provider === "openai-codex" && ["gpt-5.4", "gpt-5.4-pro"].includes(context.modelId)
|
||||
? true
|
||||
: undefined,
|
||||
);
|
||||
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resolveCommandAuthorizedFromAuthorizers,
|
||||
resolveNativeCommandSessionTargets,
|
||||
} from "openclaw/plugin-sdk/command-auth-native";
|
||||
import { resolveDirectStatusReplyForSession } from "openclaw/plugin-sdk/command-status-runtime";
|
||||
import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
@@ -755,6 +756,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
threadBindings,
|
||||
suppressReplies,
|
||||
} = params;
|
||||
const commandName = command.nativeName ?? command.key;
|
||||
const respond = async (content: string, options?: { ephemeral?: boolean }) => {
|
||||
const payload = {
|
||||
content,
|
||||
@@ -869,15 +871,10 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
conversationId: rawChannelId || "unknown",
|
||||
parentConversationId: threadParentId,
|
||||
threadBinding: isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined,
|
||||
enforceConfiguredBindingReadiness: !shouldBypassConfiguredAcpEnsure(
|
||||
command.nativeName ?? command.key,
|
||||
),
|
||||
enforceConfiguredBindingReadiness: !shouldBypassConfiguredAcpEnsure(commandName),
|
||||
}));
|
||||
const canBypassConfiguredAcpGuildGuards = async () => {
|
||||
if (
|
||||
!interaction.guild ||
|
||||
!shouldBypassConfiguredAcpGuildGuards(command.nativeName ?? command.key)
|
||||
) {
|
||||
if (!interaction.guild || !shouldBypassConfiguredAcpGuildGuards(commandName)) {
|
||||
return false;
|
||||
}
|
||||
const routeState = await getNativeRouteState();
|
||||
@@ -1131,6 +1128,36 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
targetSessionKey: effectiveRoute.sessionKey,
|
||||
boundSessionKey,
|
||||
});
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
|
||||
if (!suppressReplies && commandName === "status") {
|
||||
const statusReply = await resolveDirectStatusReplyForSession({
|
||||
cfg,
|
||||
sessionKey: commandTargetSessionKey?.trim() || sessionKey,
|
||||
channel: "discord",
|
||||
senderId: sender.id,
|
||||
senderIsOwner: ownerOk,
|
||||
isAuthorizedSender: commandAuthorized,
|
||||
isGroup: isGuild || isGroupDm,
|
||||
defaultGroupActivation: () =>
|
||||
!isGuild ? "always" : channelConfig?.requireMention === false ? "always" : "mention",
|
||||
});
|
||||
if (statusReply && hasRenderableReplyPayload(statusReply)) {
|
||||
await deliverDiscordInteractionReply({
|
||||
interaction,
|
||||
payload: statusReply,
|
||||
mediaLocalRoots,
|
||||
textLimit: resolveTextChunkLimit(cfg, "discord", accountId, {
|
||||
fallbackLimit: 2000,
|
||||
}),
|
||||
maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({ cfg, discordConfig, accountId }),
|
||||
preferFollowUp,
|
||||
chunkMode: resolveChunkMode(cfg, "discord", accountId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await respond("Status unavailable.");
|
||||
return;
|
||||
}
|
||||
const ctxPayload = buildDiscordNativeCommandContext({
|
||||
prompt,
|
||||
commandArgs: commandArgs ?? {},
|
||||
@@ -1164,7 +1191,6 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
channel: "discord",
|
||||
accountId: effectiveRoute.accountId,
|
||||
});
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
|
||||
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(discordConfig);
|
||||
|
||||
let didReply = false;
|
||||
|
||||
@@ -386,7 +386,7 @@ export async function expectFeishuReplyPipelineDedupedAfterPostSendFailure(param
|
||||
expect(params.dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
|
||||
expect(params.runtimeErrorMock).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
{ timeout: waitTimeoutMs },
|
||||
waitTimeoutMs == null ? undefined : { timeout: waitTimeoutMs },
|
||||
),
|
||||
waitForSecond: () =>
|
||||
vi.waitFor(
|
||||
@@ -394,7 +394,7 @@ export async function expectFeishuReplyPipelineDedupedAfterPostSendFailure(param
|
||||
expect(params.dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
|
||||
expect(params.runtimeErrorMock).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
{ timeout: waitTimeoutMs },
|
||||
waitTimeoutMs == null ? undefined : { timeout: waitTimeoutMs },
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { buildGithubCopilotReplayPolicy } from "./replay-policy.js";
|
||||
import { wrapCopilotProviderStream } from "./stream.js";
|
||||
|
||||
const COPILOT_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"];
|
||||
const COPILOT_XHIGH_MODEL_IDS = ["gpt-5.2", "gpt-5.2-codex"] as const;
|
||||
const COPILOT_XHIGH_MODEL_IDS = ["gpt-5.4", "gpt-5.2", "gpt-5.2-codex"] as const;
|
||||
|
||||
type GithubCopilotPluginConfig = {
|
||||
discovery?: {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ProviderRequestTransportOverrides } from "openclaw/plugin-sdk/provider-http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isGoogleGenerativeAiApi,
|
||||
normalizeGoogleApiBaseUrl,
|
||||
normalizeGoogleGenerativeAiBaseUrl,
|
||||
normalizeGoogleProviderConfig,
|
||||
parseGeminiAuth,
|
||||
resolveGoogleGenerativeAiHttpRequestConfig,
|
||||
resolveGoogleGenerativeAiApiOrigin,
|
||||
@@ -38,6 +41,20 @@ describe("google generative ai helpers", () => {
|
||||
expect(normalizeGoogleGenerativeAiBaseUrl()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps /openai on generic Google base URL normalization and strips it only for native Gemini callers", () => {
|
||||
expect(
|
||||
normalizeGoogleApiBaseUrl("https://generativelanguage.googleapis.com/v1beta/openai"),
|
||||
).toBe("https://generativelanguage.googleapis.com/v1beta/openai");
|
||||
expect(
|
||||
normalizeGoogleGenerativeAiBaseUrl("https://generativelanguage.googleapis.com/v1beta/openai"),
|
||||
).toBe("https://generativelanguage.googleapis.com/v1beta");
|
||||
expect(
|
||||
normalizeGoogleGenerativeAiBaseUrl(
|
||||
"https://generativelanguage.googleapis.com/v1alpha/openai/",
|
||||
),
|
||||
).toBe("https://generativelanguage.googleapis.com/v1alpha");
|
||||
});
|
||||
|
||||
it("normalizes Google provider configs by provider key, provider api, or model api", () => {
|
||||
expect(
|
||||
shouldNormalizeGoogleGenerativeAiProviderConfig("google", {
|
||||
@@ -61,6 +78,12 @@ describe("google generative ai helpers", () => {
|
||||
models: [{ api: "openai-completions" }],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldNormalizeGoogleGenerativeAiProviderConfig("google", {
|
||||
api: "openai-completions",
|
||||
models: [{ api: "openai-completions" }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("normalizes transport baseUrls only for Google Generative AI", () => {
|
||||
@@ -84,6 +107,36 @@ describe("google generative ai helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes google-vertex model ids without rewriting the OpenAI-compatible baseUrl", () => {
|
||||
expect(
|
||||
normalizeGoogleProviderConfig("google-vertex", {
|
||||
api: "openai-completions",
|
||||
baseUrl:
|
||||
"https://aiplatform.googleapis.com/v1/projects/test/locations/us-central1/endpoints/openapi",
|
||||
models: [
|
||||
{
|
||||
id: "gemini-3.1-flash-lite",
|
||||
name: "Gemini Flash Lite",
|
||||
input: ["text"],
|
||||
reasoning: false,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1,
|
||||
maxTokens: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toMatchObject({
|
||||
api: "openai-completions",
|
||||
baseUrl:
|
||||
"https://aiplatform.googleapis.com/v1/projects/test/locations/us-central1/endpoints/openapi",
|
||||
models: [
|
||||
expect.objectContaining({
|
||||
id: "gemini-3.1-flash-lite-preview",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("derives the Gemini API origin without duplicating /v1beta", () => {
|
||||
expect(resolveGoogleGenerativeAiApiOrigin()).toBe("https://generativelanguage.googleapis.com");
|
||||
expect(resolveGoogleGenerativeAiApiOrigin("https://generativelanguage.googleapis.com")).toBe(
|
||||
@@ -123,7 +176,7 @@ describe("google generative ai helpers", () => {
|
||||
});
|
||||
expect(oauthConfig).toMatchObject({
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||
allowPrivateNetwork: true,
|
||||
allowPrivateNetwork: false,
|
||||
});
|
||||
expect(Object.fromEntries(new Headers(oauthConfig.headers).entries())).toEqual({
|
||||
authorization: "Bearer oauth-token",
|
||||
@@ -144,4 +197,52 @@ describe("google generative ai helpers", () => {
|
||||
"x-goog-api-key": "api-key-123",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit OpenAI-compatible Google endpoints during provider normalization", () => {
|
||||
expect(
|
||||
resolveGoogleGenerativeAiTransport({
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
}),
|
||||
).toEqual({
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips URL credentials during Google base URL normalization", () => {
|
||||
const normalized = normalizeGoogleApiBaseUrl(
|
||||
"https://user:secret@generativelanguage.googleapis.com/v1beta/openai?x=1#frag",
|
||||
);
|
||||
expect(normalized).toBe("https://generativelanguage.googleapis.com/v1beta/openai");
|
||||
});
|
||||
|
||||
it("rejects non-Google Gemini base URLs and ignores smuggled private-network flags", () => {
|
||||
expect(() =>
|
||||
resolveGoogleGenerativeAiHttpRequestConfig({
|
||||
apiKey: "api-key-123",
|
||||
baseUrl: "https://proxy.example.com/v1beta",
|
||||
capability: "image",
|
||||
transport: "http",
|
||||
}),
|
||||
).toThrow("Google Generative AI baseUrl must use https://generativelanguage.googleapis.com");
|
||||
|
||||
expect(() =>
|
||||
resolveGoogleGenerativeAiHttpRequestConfig({
|
||||
apiKey: "api-key-123",
|
||||
baseUrl: "http://generativelanguage.googleapis.com/v1beta",
|
||||
capability: "image",
|
||||
transport: "http",
|
||||
}),
|
||||
).toThrow("Google Generative AI baseUrl must use https://generativelanguage.googleapis.com");
|
||||
|
||||
const config = resolveGoogleGenerativeAiHttpRequestConfig({
|
||||
apiKey: "api-key-123",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||||
capability: "image",
|
||||
transport: "http",
|
||||
request: { allowPrivateNetwork: true } as unknown as ProviderRequestTransportOverrides,
|
||||
});
|
||||
expect(config.allowPrivateNetwork).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { parseGoogleOauthApiKey } from "./oauth-token-shared.js";
|
||||
import { DEFAULT_GOOGLE_API_BASE_URL, normalizeGoogleApiBaseUrl } from "./provider-policy.js";
|
||||
import {
|
||||
DEFAULT_GOOGLE_API_BASE_URL,
|
||||
normalizeGoogleApiBaseUrl,
|
||||
normalizeGoogleGenerativeAiBaseUrl,
|
||||
} from "./provider-policy.js";
|
||||
export { normalizeAntigravityModelId, normalizeGoogleModelId } from "./model-id.js";
|
||||
export {
|
||||
DEFAULT_GOOGLE_API_BASE_URL,
|
||||
@@ -40,6 +44,29 @@ export function parseGeminiAuth(apiKey: string): { headers: Record<string, strin
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTrustedGoogleGenerativeAiBaseUrl(baseUrl?: string): string {
|
||||
const normalized =
|
||||
normalizeGoogleGenerativeAiBaseUrl(baseUrl ?? DEFAULT_GOOGLE_API_BASE_URL) ??
|
||||
DEFAULT_GOOGLE_API_BASE_URL;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(normalized);
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Google Generative AI baseUrl must be a valid https URL on generativelanguage.googleapis.com",
|
||||
);
|
||||
}
|
||||
if (
|
||||
url.protocol !== "https:" ||
|
||||
url.hostname.toLowerCase() !== "generativelanguage.googleapis.com"
|
||||
) {
|
||||
throw new Error(
|
||||
"Google Generative AI baseUrl must use https://generativelanguage.googleapis.com",
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveGoogleGenerativeAiHttpRequestConfig(params: {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
@@ -49,9 +76,9 @@ export function resolveGoogleGenerativeAiHttpRequestConfig(params: {
|
||||
transport: "http" | "media-understanding";
|
||||
}) {
|
||||
return resolveProviderHttpRequestConfig({
|
||||
baseUrl: normalizeGoogleApiBaseUrl(params.baseUrl ?? DEFAULT_GOOGLE_API_BASE_URL),
|
||||
baseUrl: resolveTrustedGoogleGenerativeAiBaseUrl(params.baseUrl),
|
||||
defaultBaseUrl: DEFAULT_GOOGLE_API_BASE_URL,
|
||||
allowPrivateNetwork: Boolean(params.baseUrl?.trim()),
|
||||
allowPrivateNetwork: false,
|
||||
headers: params.headers,
|
||||
request: params.request,
|
||||
defaultHeaders: parseGeminiAuth(params.apiKey).headers,
|
||||
|
||||
@@ -305,6 +305,33 @@ describe("Google image-generation provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("strips a configured /openai suffix before calling the native Gemini image API", async () => {
|
||||
mockGoogleApiKeyAuth();
|
||||
const fetchMock = installGoogleFetchMock();
|
||||
|
||||
const provider = buildGoogleImageGenerationProvider();
|
||||
await provider.generateImage({
|
||||
provider: "google",
|
||||
model: "gemini-3-pro-image-preview",
|
||||
prompt: "draw a fox",
|
||||
cfg: {
|
||||
models: {
|
||||
providers: {
|
||||
google: {
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-image-preview:generateContent",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers scoped configured Gemini API keys over environment fallbacks", () => {
|
||||
expect(
|
||||
geminiWebSearchTesting.resolveGeminiApiKey({
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("describeGeminiVideo", () => {
|
||||
fileName: "clip.mp4",
|
||||
apiKey: "test-key",
|
||||
timeoutMs: 1500,
|
||||
baseUrl: "https://example.com/v1beta/",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/",
|
||||
model: "gemini-3-pro",
|
||||
headers: { "X-Other": "1" },
|
||||
fetchFn,
|
||||
@@ -88,7 +88,9 @@ describe("describeGeminiVideo", () => {
|
||||
|
||||
expect(result.model).toBe("gemini-3-pro-preview");
|
||||
expect(result.text).toBe("first\nsecond");
|
||||
expect(seenUrl).toBe("https://example.com/v1beta/models/gemini-3-pro-preview:generateContent");
|
||||
expect(seenUrl).toBe(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-3-pro-preview:generateContent",
|
||||
);
|
||||
expect(seenInit?.method).toBe("POST");
|
||||
expect(seenInit?.signal).toBeInstanceOf(AbortSignal);
|
||||
|
||||
@@ -110,4 +112,21 @@ describe("describeGeminiVideo", () => {
|
||||
Buffer.from("video-bytes").toString("base64"),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects non-Google video base URLs before sending authenticated requests", async () => {
|
||||
await expect(
|
||||
describeGeminiVideo({
|
||||
buffer: Buffer.from("video-bytes"),
|
||||
fileName: "clip.mp4",
|
||||
apiKey: "test-key",
|
||||
timeoutMs: 1500,
|
||||
baseUrl: "https://example.com/v1beta/",
|
||||
fetchFn: async () => {
|
||||
throw new Error("fetch should not run");
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Google Generative AI baseUrl must use https://generativelanguage.googleapis.com",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,4 +28,20 @@ describe("google provider policy public artifact", () => {
|
||||
models: [{ id: "gemini-3-pro-preview" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit OpenAI-compatible Google endpoints during normalization", () => {
|
||||
expect(
|
||||
normalizeConfig({
|
||||
provider: "google",
|
||||
providerConfig: {
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
api: "openai-completions",
|
||||
models: [],
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
api: "openai-completions",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,14 +29,21 @@ function isGoogleGenerativeAiUrl(url: URL): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function stripUrlUserInfo(url: URL): void {
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
}
|
||||
|
||||
export function normalizeGoogleApiBaseUrl(baseUrl?: string): string {
|
||||
const raw = trimTrailingSlashes(normalizeOptionalString(baseUrl) || DEFAULT_GOOGLE_API_BASE_URL);
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
url.hash = "";
|
||||
url.search = "";
|
||||
if (isGoogleGenerativeAiUrl(url) && trimTrailingSlashes(url.pathname || "") === "") {
|
||||
url.pathname = "/v1beta";
|
||||
stripUrlUserInfo(url);
|
||||
if (isGoogleGenerativeAiUrl(url)) {
|
||||
const normalizedPath = trimTrailingSlashes(url.pathname || "");
|
||||
url.pathname = normalizedPath || "/v1beta";
|
||||
}
|
||||
return trimTrailingSlashes(url.toString());
|
||||
} catch {
|
||||
@@ -52,7 +59,23 @@ export function isGoogleGenerativeAiApi(api?: string | null): boolean {
|
||||
}
|
||||
|
||||
export function normalizeGoogleGenerativeAiBaseUrl(baseUrl?: string): string | undefined {
|
||||
return baseUrl ? normalizeGoogleApiBaseUrl(baseUrl) : baseUrl;
|
||||
if (!baseUrl) {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
const normalized = normalizeGoogleApiBaseUrl(baseUrl);
|
||||
try {
|
||||
const url = new URL(normalized);
|
||||
stripUrlUserInfo(url);
|
||||
if (isGoogleGenerativeAiUrl(url)) {
|
||||
url.pathname = trimTrailingSlashes(url.pathname || "").replace(/\/openai$/i, "") || "/v1beta";
|
||||
return trimTrailingSlashes(url.toString());
|
||||
}
|
||||
} catch {
|
||||
// `normalizeGoogleApiBaseUrl` already returned the best-effort input form.
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveGoogleGenerativeAiTransport<TApi extends string | null | undefined>(params: {
|
||||
@@ -68,20 +91,28 @@ export function resolveGoogleGenerativeAiTransport<TApi extends string | null |
|
||||
}
|
||||
|
||||
export function resolveGoogleGenerativeAiApiOrigin(baseUrl?: string): string {
|
||||
return normalizeGoogleApiBaseUrl(baseUrl).replace(/\/v1beta$/i, "");
|
||||
return (
|
||||
normalizeGoogleGenerativeAiBaseUrl(baseUrl) ?? normalizeGoogleApiBaseUrl(baseUrl)
|
||||
).replace(/\/v1beta$/i, "");
|
||||
}
|
||||
|
||||
export function shouldNormalizeGoogleGenerativeAiProviderConfig(
|
||||
providerKey: string,
|
||||
provider: GoogleProviderConfigLike,
|
||||
): boolean {
|
||||
if (providerKey === "google" || providerKey === "google-vertex") {
|
||||
return true;
|
||||
}
|
||||
if (isGoogleGenerativeAiApi(provider.api)) {
|
||||
return true;
|
||||
}
|
||||
return provider.models?.some((model) => isGoogleGenerativeAiApi(model?.api)) ?? false;
|
||||
const hasGoogleGenerativeAiModelApi =
|
||||
provider.models?.some((model) => isGoogleGenerativeAiApi(model?.api)) ?? false;
|
||||
if (hasGoogleGenerativeAiModelApi) {
|
||||
return true;
|
||||
}
|
||||
if (providerKey !== "google" && providerKey !== "google-vertex") {
|
||||
return false;
|
||||
}
|
||||
const hasExplicitNonGoogleApi = normalizeOptionalString(provider.api) !== undefined;
|
||||
return !hasExplicitNonGoogleApi;
|
||||
}
|
||||
|
||||
export function shouldNormalizeGoogleProviderConfig(
|
||||
@@ -121,14 +152,21 @@ export function normalizeGoogleProviderConfig(
|
||||
provider: ModelProviderConfig,
|
||||
): ModelProviderConfig {
|
||||
let nextProvider = provider;
|
||||
const shouldNormalizeModelIds =
|
||||
providerKey === "google-vertex" ||
|
||||
shouldNormalizeGoogleGenerativeAiProviderConfig(providerKey, nextProvider);
|
||||
|
||||
if (shouldNormalizeGoogleGenerativeAiProviderConfig(providerKey, nextProvider)) {
|
||||
if (shouldNormalizeModelIds) {
|
||||
const modelNormalized = normalizeProviderModels(nextProvider, normalizeGoogleModelId);
|
||||
const normalizedBaseUrl = normalizeGoogleGenerativeAiBaseUrl(modelNormalized.baseUrl);
|
||||
nextProvider =
|
||||
normalizedBaseUrl !== modelNormalized.baseUrl
|
||||
? { ...modelNormalized, baseUrl: normalizedBaseUrl ?? modelNormalized.baseUrl }
|
||||
: modelNormalized;
|
||||
if (shouldNormalizeGoogleGenerativeAiProviderConfig(providerKey, modelNormalized)) {
|
||||
const normalizedBaseUrl = normalizeGoogleGenerativeAiBaseUrl(modelNormalized.baseUrl);
|
||||
nextProvider =
|
||||
normalizedBaseUrl !== modelNormalized.baseUrl
|
||||
? { ...modelNormalized, baseUrl: normalizedBaseUrl ?? modelNormalized.baseUrl }
|
||||
: modelNormalized;
|
||||
} else {
|
||||
nextProvider = modelNormalized;
|
||||
}
|
||||
}
|
||||
|
||||
if (providerKey === "google-antigravity") {
|
||||
|
||||
@@ -6,12 +6,16 @@ import { resolveSessionTranscriptsDirForAgent } from "openclaw/plugin-sdk/memory
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearMemoryEmbeddingProviders as clearRegistry,
|
||||
listMemoryEmbeddingProviders as listRegisteredAdapters,
|
||||
registerMemoryEmbeddingProvider as registerAdapter,
|
||||
} from "../../../../src/plugins/memory-embedding-providers.js";
|
||||
import "./test-runtime-mocks.js";
|
||||
import type { MemoryIndexManager } from "./index.js";
|
||||
import { getMemorySearchManager, closeAllMemorySearchManagers } from "./index.js";
|
||||
import { registerBuiltInMemoryEmbeddingProviders } from "./provider-adapters.js";
|
||||
import {
|
||||
DEFAULT_OLLAMA_EMBEDDING_MODEL,
|
||||
registerBuiltInMemoryEmbeddingProviders,
|
||||
} from "./provider-adapters.js";
|
||||
|
||||
let embedBatchCalls = 0;
|
||||
let embedBatchInputCalls = 0;
|
||||
@@ -108,6 +112,18 @@ vi.mock("./embeddings.js", () => {
|
||||
});
|
||||
|
||||
describe("memory index", () => {
|
||||
it("registers the builtin ollama embedding provider", () => {
|
||||
const adapter = listRegisteredAdapters().find((entry) => entry.id === "ollama");
|
||||
|
||||
expect(adapter).toBeDefined();
|
||||
expect(adapter).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "ollama",
|
||||
defaultModel: DEFAULT_OLLAMA_EMBEDDING_MODEL,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
let fixtureRoot = "";
|
||||
let workspaceDir = "";
|
||||
let memoryDir = "";
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DEFAULT_LMSTUDIO_EMBEDDING_MODEL,
|
||||
DEFAULT_LOCAL_MODEL,
|
||||
DEFAULT_MISTRAL_EMBEDDING_MODEL,
|
||||
DEFAULT_OLLAMA_EMBEDDING_MODEL,
|
||||
DEFAULT_OPENAI_EMBEDDING_MODEL,
|
||||
DEFAULT_VOYAGE_EMBEDDING_MODEL,
|
||||
OPENAI_BATCH_ENDPOINT,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
createLmstudioEmbeddingProvider,
|
||||
createLocalEmbeddingProvider,
|
||||
createMistralEmbeddingProvider,
|
||||
createOllamaEmbeddingProvider,
|
||||
createOpenAiEmbeddingProvider,
|
||||
createVoyageEmbeddingProvider,
|
||||
hasNonTextEmbeddingParts,
|
||||
@@ -290,6 +292,31 @@ const mistralAdapter: MemoryEmbeddingProviderAdapter = {
|
||||
},
|
||||
};
|
||||
|
||||
const ollamaAdapter: MemoryEmbeddingProviderAdapter = {
|
||||
id: "ollama",
|
||||
defaultModel: DEFAULT_OLLAMA_EMBEDDING_MODEL,
|
||||
transport: "remote",
|
||||
create: async (options) => {
|
||||
const { provider, client } = await createOllamaEmbeddingProvider({
|
||||
...options,
|
||||
provider: "ollama",
|
||||
fallback: "none",
|
||||
});
|
||||
return {
|
||||
provider,
|
||||
runtime: {
|
||||
id: "ollama",
|
||||
cacheKeyData: {
|
||||
provider: "ollama",
|
||||
baseUrl: client.baseUrl,
|
||||
model: client.model,
|
||||
headers: sanitizeHeaders(client.headers, ["authorization"]),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const lmstudioAdapter: MemoryEmbeddingProviderAdapter = {
|
||||
id: "lmstudio",
|
||||
defaultModel: DEFAULT_LMSTUDIO_EMBEDDING_MODEL,
|
||||
@@ -347,6 +374,7 @@ export const builtinMemoryEmbeddingProviderAdapters = [
|
||||
geminiAdapter,
|
||||
voyageAdapter,
|
||||
mistralAdapter,
|
||||
ollamaAdapter,
|
||||
lmstudioAdapter,
|
||||
] as const;
|
||||
|
||||
@@ -409,6 +437,7 @@ export {
|
||||
DEFAULT_LMSTUDIO_EMBEDDING_MODEL,
|
||||
DEFAULT_LOCAL_MODEL,
|
||||
DEFAULT_MISTRAL_EMBEDDING_MODEL,
|
||||
DEFAULT_OLLAMA_EMBEDDING_MODEL,
|
||||
DEFAULT_OPENAI_EMBEDDING_MODEL,
|
||||
DEFAULT_VOYAGE_EMBEDDING_MODEL,
|
||||
canAutoSelectLocal,
|
||||
|
||||
@@ -99,13 +99,14 @@ describe("createNextcloudTalkWebhookServer replay handling", () => {
|
||||
stateDir: params.stateDir,
|
||||
});
|
||||
|
||||
return async (message: NextcloudTalkInboundMessage) =>
|
||||
return async (message: NextcloudTalkInboundMessage): Promise<void> => {
|
||||
await processNextcloudTalkReplayGuardedMessage({
|
||||
replayGuard,
|
||||
accountId: params.accountId ?? "acct",
|
||||
message,
|
||||
handleMessage: () => params.handleMessage(message),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
it("acknowledges replayed requests and skips onMessage side effects", async () => {
|
||||
|
||||
@@ -22,9 +22,15 @@ const runtimeMocks = vi.hoisted(() => ({
|
||||
refreshOpenAICodexToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
|
||||
ensureGlobalUndiciEnvProxyDispatcher: runtimeMocks.ensureGlobalUndiciEnvProxyDispatcher,
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/runtime-env")>(
|
||||
"openclaw/plugin-sdk/runtime-env",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ensureGlobalUndiciEnvProxyDispatcher: runtimeMocks.ensureGlobalUndiciEnvProxyDispatcher,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@mariozechner/pi-ai/oauth", async () => {
|
||||
const actual = await vi.importActual<typeof import("@mariozechner/pi-ai/oauth")>(
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import fs from "node:fs";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runtimeMocks = vi.hoisted(() => ({
|
||||
debug: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
|
||||
createSubsystemLogger: () => ({
|
||||
debug: runtimeMocks.debug,
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
OPENAI_CODEX_DEFAULT_PROFILE_ID,
|
||||
readOpenAICodexCliOAuthProfile,
|
||||
@@ -12,6 +23,10 @@ function buildJwt(payload: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
describe("readOpenAICodexCliOAuthProfile", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -80,4 +95,54 @@ describe("readOpenAICodexCliOAuthProfile", () => {
|
||||
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null without logging when the Codex CLI auth file is missing", () => {
|
||||
const error = Object.assign(new Error("missing"), {
|
||||
code: "ENOENT",
|
||||
});
|
||||
vi.spyOn(fs, "readFileSync").mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
const parsed = readOpenAICodexCliOAuthProfile({
|
||||
store: { version: 1, profiles: {} },
|
||||
});
|
||||
|
||||
expect(parsed).toBeNull();
|
||||
expect(runtimeMocks.debug).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs a sanitized code for invalid auth JSON", () => {
|
||||
vi.spyOn(fs, "readFileSync").mockReturnValue("{");
|
||||
|
||||
const parsed = readOpenAICodexCliOAuthProfile({
|
||||
store: { version: 1, profiles: {} },
|
||||
});
|
||||
|
||||
expect(parsed).toBeNull();
|
||||
expect(runtimeMocks.debug).toHaveBeenCalledWith(
|
||||
"Failed to read Codex CLI auth file (code=INVALID_JSON)",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not leak auth file paths in debug logs for filesystem failures", () => {
|
||||
const error = Object.assign(
|
||||
new Error("EACCES: permission denied, open '/Users/alice/.codex/auth.json'"),
|
||||
{
|
||||
code: "EACCES",
|
||||
},
|
||||
);
|
||||
vi.spyOn(fs, "readFileSync").mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
const parsed = readOpenAICodexCliOAuthProfile({
|
||||
store: { version: 1, profiles: {} },
|
||||
});
|
||||
|
||||
expect(parsed).toBeNull();
|
||||
expect(runtimeMocks.debug).toHaveBeenCalledWith(
|
||||
"Failed to read Codex CLI auth file (code=EACCES)",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { AuthProfileStore, OAuthCredential } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveRequiredHomeDir } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
resolveCodexAccessTokenExpiry,
|
||||
resolveCodexAuthIdentity,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
import { trimNonEmptyString } from "./openai-codex-shared.js";
|
||||
|
||||
const PROVIDER_ID = "openai-codex";
|
||||
const log = createSubsystemLogger("openai/codex-cli-auth");
|
||||
|
||||
export const CODEX_CLI_PROFILE_ID = `${PROVIDER_ID}:codex-cli`;
|
||||
export const OPENAI_CODEX_DEFAULT_PROFILE_ID = `${PROVIDER_ID}:default`;
|
||||
@@ -42,7 +44,19 @@ function readCodexCliAuthFile(env: NodeJS.ProcessEnv): CodexCliAuthFile | null {
|
||||
const raw = fs.readFileSync(authPath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" ? (parsed as CodexCliAuthFile) : null;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const code =
|
||||
error instanceof SyntaxError
|
||||
? "INVALID_JSON"
|
||||
: error instanceof Error && "code" in error
|
||||
? (error as NodeJS.ErrnoException).code
|
||||
: undefined;
|
||||
if (code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
log.debug(
|
||||
`Failed to read Codex CLI auth file (code=${typeof code === "string" ? code : "UNKNOWN"})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +134,117 @@ describe("openai codex provider", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves gpt-5.4-pro with pro pricing and codex-sized limits", () => {
|
||||
const provider = buildOpenAICodexProviderPlugin();
|
||||
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "openai-codex",
|
||||
modelId: "gpt-5.4-pro",
|
||||
modelRegistry: {
|
||||
find: (providerId: string, modelId: string) => {
|
||||
if (providerId === "openai-codex" && modelId === "gpt-5.3-codex") {
|
||||
return {
|
||||
id: "gpt-5.3-codex",
|
||||
name: "gpt-5.3-codex",
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
reasoning: true,
|
||||
input: ["text", "image"] as const,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 272_000,
|
||||
maxTokens: 128_000,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "gpt-5.4-pro",
|
||||
contextWindow: 1_050_000,
|
||||
contextTokens: 272_000,
|
||||
maxTokens: 128_000,
|
||||
cost: { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves gpt-5.4-pro from a gpt-5.4 runtime template when legacy codex rows are absent", () => {
|
||||
const provider = buildOpenAICodexProviderPlugin();
|
||||
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "openai-codex",
|
||||
modelId: "gpt-5.4-pro",
|
||||
modelRegistry: {
|
||||
find: (providerId: string, modelId: string) => {
|
||||
if (providerId === "openai-codex" && modelId === "gpt-5.4") {
|
||||
return {
|
||||
id: "gpt-5.4",
|
||||
name: "gpt-5.4",
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
reasoning: true,
|
||||
input: ["text", "image"] as const,
|
||||
cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
|
||||
contextWindow: 1_050_000,
|
||||
contextTokens: 272_000,
|
||||
maxTokens: 128_000,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "gpt-5.4-pro",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
contextWindow: 1_050_000,
|
||||
contextTokens: 272_000,
|
||||
maxTokens: 128_000,
|
||||
cost: { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves the legacy gpt-5.4-codex alias to canonical gpt-5.4", () => {
|
||||
const provider = buildOpenAICodexProviderPlugin();
|
||||
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "openai-codex",
|
||||
modelId: "gpt-5.4-codex",
|
||||
modelRegistry: {
|
||||
find: (providerId: string, modelId: string) => {
|
||||
if (providerId === "openai-codex" && modelId === "gpt-5.3-codex") {
|
||||
return {
|
||||
id: "gpt-5.3-codex",
|
||||
name: "gpt-5.3-codex",
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
reasoning: true,
|
||||
input: ["text", "image"] as const,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 272_000,
|
||||
maxTokens: 128_000,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "gpt-5.4",
|
||||
name: "gpt-5.4",
|
||||
contextWindow: 1_050_000,
|
||||
contextTokens: 272_000,
|
||||
maxTokens: 128_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves gpt-5.4-mini from codex templates with codex-sized limits", () => {
|
||||
const provider = buildOpenAICodexProviderPlugin();
|
||||
|
||||
@@ -192,13 +303,76 @@ describe("openai codex provider", () => {
|
||||
id: "gpt-5.4",
|
||||
contextWindow: 1_050_000,
|
||||
contextTokens: 272_000,
|
||||
cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
|
||||
}),
|
||||
);
|
||||
expect(entries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "gpt-5.4-pro",
|
||||
contextWindow: 1_050_000,
|
||||
contextTokens: 272_000,
|
||||
cost: { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 },
|
||||
}),
|
||||
);
|
||||
expect(entries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "gpt-5.4-mini",
|
||||
contextWindow: 272_000,
|
||||
cost: { input: 0.75, output: 4.5, cacheRead: 0.075, cacheWrite: 0 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("augments gpt-5.4-pro from catalog gpt-5.4 when legacy codex rows are absent", () => {
|
||||
const provider = buildOpenAICodexProviderPlugin();
|
||||
|
||||
const entries = provider.augmentModelCatalog?.({
|
||||
env: process.env,
|
||||
entries: [
|
||||
{
|
||||
id: "gpt-5.4",
|
||||
name: "gpt-5.4",
|
||||
provider: "openai-codex",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 272_000,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
|
||||
expect(entries).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "gpt-5.4-pro",
|
||||
contextWindow: 1_050_000,
|
||||
contextTokens: 272_000,
|
||||
cost: { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("canonicalizes legacy gpt-5.4-codex models during resolved-model normalization", () => {
|
||||
const provider = buildOpenAICodexProviderPlugin();
|
||||
|
||||
const model = provider.normalizeResolvedModel?.({
|
||||
provider: "openai-codex",
|
||||
model: {
|
||||
id: "gpt-5.4-codex",
|
||||
name: "gpt-5.4-codex",
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://chatgpt.com/backend-api",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_050_000,
|
||||
contextTokens: 272_000,
|
||||
maxTokens: 128_000,
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "gpt-5.4",
|
||||
name: "gpt-5.4",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,8 @@ import {
|
||||
const PROVIDER_ID = "openai-codex";
|
||||
const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
||||
const OPENAI_CODEX_GPT_54_MODEL_ID = "gpt-5.4";
|
||||
const OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID = "gpt-5.4-codex";
|
||||
const OPENAI_CODEX_GPT_54_PRO_MODEL_ID = "gpt-5.4-pro";
|
||||
const OPENAI_CODEX_GPT_54_MINI_MODEL_ID = "gpt-5.4-mini";
|
||||
const OPENAI_CODEX_GPT_54_NATIVE_CONTEXT_TOKENS = 1_050_000;
|
||||
const OPENAI_CODEX_GPT_54_DEFAULT_CONTEXT_TOKENS = 272_000;
|
||||
@@ -52,6 +54,12 @@ const OPENAI_CODEX_GPT_54_COST = {
|
||||
cacheRead: 0.25,
|
||||
cacheWrite: 0,
|
||||
} as const;
|
||||
const OPENAI_CODEX_GPT_54_PRO_COST = {
|
||||
input: 30,
|
||||
output: 180,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
} as const;
|
||||
const OPENAI_CODEX_GPT_54_MINI_COST = {
|
||||
input: 0.75,
|
||||
output: 4.5,
|
||||
@@ -59,6 +67,11 @@ const OPENAI_CODEX_GPT_54_MINI_COST = {
|
||||
cacheWrite: 0,
|
||||
} as const;
|
||||
const OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.3-codex", "gpt-5.2-codex"] as const;
|
||||
/** Legacy codex rows first; fall back to catalog `gpt-5.4` when the API omits 5.3/5.2. */
|
||||
const OPENAI_CODEX_GPT_54_CATALOG_SYNTH_TEMPLATE_MODEL_IDS = [
|
||||
...OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS,
|
||||
OPENAI_CODEX_GPT_54_MODEL_ID,
|
||||
] as const;
|
||||
const OPENAI_CODEX_GPT_54_MINI_TEMPLATE_MODEL_IDS = [
|
||||
OPENAI_CODEX_GPT_54_MODEL_ID,
|
||||
"gpt-5.1-codex-mini",
|
||||
@@ -71,6 +84,7 @@ const OPENAI_CODEX_GPT_53_SPARK_MAX_TOKENS = 128_000;
|
||||
const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const;
|
||||
const OPENAI_CODEX_XHIGH_MODEL_IDS = [
|
||||
OPENAI_CODEX_GPT_54_MODEL_ID,
|
||||
OPENAI_CODEX_GPT_54_PRO_MODEL_ID,
|
||||
OPENAI_CODEX_GPT_54_MINI_MODEL_ID,
|
||||
OPENAI_CODEX_GPT_53_MODEL_ID,
|
||||
OPENAI_CODEX_GPT_53_SPARK_MODEL_ID,
|
||||
@@ -79,6 +93,7 @@ const OPENAI_CODEX_XHIGH_MODEL_IDS = [
|
||||
] as const;
|
||||
const OPENAI_CODEX_MODERN_MODEL_IDS = [
|
||||
OPENAI_CODEX_GPT_54_MODEL_ID,
|
||||
OPENAI_CODEX_GPT_54_PRO_MODEL_ID,
|
||||
OPENAI_CODEX_GPT_54_MINI_MODEL_ID,
|
||||
"gpt-5.2",
|
||||
"gpt-5.2-codex",
|
||||
@@ -88,6 +103,13 @@ const OPENAI_CODEX_MODERN_MODEL_IDS = [
|
||||
const OPENAI_RESPONSES_STREAM_HOOKS = buildProviderStreamFamilyHooks("openai-responses-defaults");
|
||||
|
||||
function normalizeCodexTransport(model: ProviderRuntimeModel): ProviderRuntimeModel {
|
||||
const lowerModelId = normalizeLowercaseStringOrEmpty(model.id);
|
||||
const canonicalModelId =
|
||||
lowerModelId === OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID ? OPENAI_CODEX_GPT_54_MODEL_ID : model.id;
|
||||
const canonicalName =
|
||||
normalizeLowercaseStringOrEmpty(model.name) === OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID
|
||||
? OPENAI_CODEX_GPT_54_MODEL_ID
|
||||
: model.name;
|
||||
const useCodexTransport =
|
||||
!model.baseUrl || isOpenAIApiBaseUrl(model.baseUrl) || isOpenAICodexBaseUrl(model.baseUrl);
|
||||
const api =
|
||||
@@ -96,32 +118,45 @@ function normalizeCodexTransport(model: ProviderRuntimeModel): ProviderRuntimeMo
|
||||
api === "openai-codex-responses" && (!model.baseUrl || isOpenAIApiBaseUrl(model.baseUrl))
|
||||
? OPENAI_CODEX_BASE_URL
|
||||
: model.baseUrl;
|
||||
if (api === model.api && baseUrl === model.baseUrl) {
|
||||
if (
|
||||
api === model.api &&
|
||||
baseUrl === model.baseUrl &&
|
||||
canonicalModelId === model.id &&
|
||||
canonicalName === model.name
|
||||
) {
|
||||
return model;
|
||||
}
|
||||
return {
|
||||
...model,
|
||||
id: canonicalModelId,
|
||||
name: canonicalName,
|
||||
api,
|
||||
baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCodexForwardCompatModel(
|
||||
ctx: ProviderResolveDynamicModelContext,
|
||||
): ProviderRuntimeModel | undefined {
|
||||
function resolveCodexForwardCompatModel(ctx: ProviderResolveDynamicModelContext) {
|
||||
const trimmedModelId = ctx.modelId.trim();
|
||||
const lower = normalizeLowercaseStringOrEmpty(trimmedModelId);
|
||||
|
||||
let templateIds: readonly string[];
|
||||
let patch: Partial<ProviderRuntimeModel> | undefined;
|
||||
if (lower === OPENAI_CODEX_GPT_54_MODEL_ID) {
|
||||
templateIds = OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS;
|
||||
let patch: Parameters<typeof cloneFirstTemplateModel>[0]["patch"];
|
||||
if (lower === OPENAI_CODEX_GPT_54_MODEL_ID || lower === OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID) {
|
||||
templateIds = OPENAI_CODEX_GPT_54_CATALOG_SYNTH_TEMPLATE_MODEL_IDS;
|
||||
patch = {
|
||||
contextWindow: OPENAI_CODEX_GPT_54_NATIVE_CONTEXT_TOKENS,
|
||||
contextTokens: OPENAI_CODEX_GPT_54_DEFAULT_CONTEXT_TOKENS,
|
||||
maxTokens: OPENAI_CODEX_GPT_54_MAX_TOKENS,
|
||||
cost: OPENAI_CODEX_GPT_54_COST,
|
||||
};
|
||||
} else if (lower === OPENAI_CODEX_GPT_54_PRO_MODEL_ID) {
|
||||
templateIds = OPENAI_CODEX_GPT_54_CATALOG_SYNTH_TEMPLATE_MODEL_IDS;
|
||||
patch = {
|
||||
contextWindow: OPENAI_CODEX_GPT_54_NATIVE_CONTEXT_TOKENS,
|
||||
contextTokens: OPENAI_CODEX_GPT_54_DEFAULT_CONTEXT_TOKENS,
|
||||
maxTokens: OPENAI_CODEX_GPT_54_MAX_TOKENS,
|
||||
cost: OPENAI_CODEX_GPT_54_PRO_COST,
|
||||
};
|
||||
} else if (lower === OPENAI_CODEX_GPT_54_MINI_MODEL_ID) {
|
||||
templateIds = OPENAI_CODEX_GPT_54_MINI_TEMPLATE_MODEL_IDS;
|
||||
patch = {
|
||||
@@ -150,14 +185,23 @@ function resolveCodexForwardCompatModel(
|
||||
return (
|
||||
cloneFirstTemplateModel({
|
||||
providerId: PROVIDER_ID,
|
||||
modelId: trimmedModelId,
|
||||
modelId:
|
||||
lower === OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID
|
||||
? OPENAI_CODEX_GPT_54_MODEL_ID
|
||||
: trimmedModelId,
|
||||
templateIds,
|
||||
ctx,
|
||||
patch,
|
||||
}) ??
|
||||
normalizeModelCompat({
|
||||
id: trimmedModelId,
|
||||
name: trimmedModelId,
|
||||
id:
|
||||
lower === OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID
|
||||
? OPENAI_CODEX_GPT_54_MODEL_ID
|
||||
: trimmedModelId,
|
||||
name:
|
||||
lower === OPENAI_CODEX_GPT_54_LEGACY_MODEL_ID
|
||||
? OPENAI_CODEX_GPT_54_MODEL_ID
|
||||
: trimmedModelId,
|
||||
api: "openai-codex-responses",
|
||||
provider: PROVIDER_ID,
|
||||
baseUrl: OPENAI_CODEX_BASE_URL,
|
||||
@@ -284,9 +328,13 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
|
||||
supportsXHighThinking: ({ modelId }) =>
|
||||
matchesExactOrPrefix(modelId, OPENAI_CODEX_XHIGH_MODEL_IDS),
|
||||
isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_CODEX_MODERN_MODEL_IDS),
|
||||
preferRuntimeResolvedModel: (ctx) =>
|
||||
normalizeProviderId(ctx.provider) === PROVIDER_ID &&
|
||||
ctx.modelId.trim().toLowerCase() === OPENAI_CODEX_GPT_54_MODEL_ID,
|
||||
preferRuntimeResolvedModel: (ctx) => {
|
||||
if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) {
|
||||
return false;
|
||||
}
|
||||
const id = ctx.modelId.trim().toLowerCase();
|
||||
return id === OPENAI_CODEX_GPT_54_MODEL_ID || id === OPENAI_CODEX_GPT_54_PRO_MODEL_ID;
|
||||
},
|
||||
buildReplayPolicy: buildOpenAIReplayPolicy,
|
||||
prepareExtraParams: (ctx) => {
|
||||
const transport = ctx.extraParams?.transport;
|
||||
@@ -316,7 +364,7 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
|
||||
const gpt54Template = findCatalogTemplate({
|
||||
entries: ctx.entries,
|
||||
providerId: PROVIDER_ID,
|
||||
templateIds: OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS,
|
||||
templateIds: OPENAI_CODEX_GPT_54_CATALOG_SYNTH_TEMPLATE_MODEL_IDS,
|
||||
});
|
||||
const gpt54MiniTemplate = findCatalogTemplate({
|
||||
entries: ctx.entries,
|
||||
@@ -335,12 +383,22 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
|
||||
input: ["text", "image"],
|
||||
contextWindow: OPENAI_CODEX_GPT_54_NATIVE_CONTEXT_TOKENS,
|
||||
contextTokens: OPENAI_CODEX_GPT_54_DEFAULT_CONTEXT_TOKENS,
|
||||
cost: OPENAI_CODEX_GPT_54_COST,
|
||||
}),
|
||||
buildOpenAISyntheticCatalogEntry(gpt54Template, {
|
||||
id: OPENAI_CODEX_GPT_54_PRO_MODEL_ID,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: OPENAI_CODEX_GPT_54_NATIVE_CONTEXT_TOKENS,
|
||||
contextTokens: OPENAI_CODEX_GPT_54_DEFAULT_CONTEXT_TOKENS,
|
||||
cost: OPENAI_CODEX_GPT_54_PRO_COST,
|
||||
}),
|
||||
buildOpenAISyntheticCatalogEntry(gpt54MiniTemplate, {
|
||||
id: OPENAI_CODEX_GPT_54_MINI_MODEL_ID,
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: OPENAI_CODEX_GPT_54_MINI_CONTEXT_TOKENS,
|
||||
cost: OPENAI_CODEX_GPT_54_MINI_COST,
|
||||
}),
|
||||
buildOpenAISyntheticCatalogEntry(sparkTemplate, {
|
||||
id: OPENAI_CODEX_GPT_53_SPARK_MODEL_ID,
|
||||
|
||||
@@ -6,6 +6,13 @@ import {
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
type SyntheticOpenAIModelCatalogCost = {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
};
|
||||
|
||||
type SyntheticOpenAIModelCatalogEntry = {
|
||||
provider: string;
|
||||
id: string;
|
||||
@@ -14,6 +21,7 @@ type SyntheticOpenAIModelCatalogEntry = {
|
||||
input?: ("text" | "image")[];
|
||||
contextWindow?: number;
|
||||
contextTokens?: number;
|
||||
cost?: SyntheticOpenAIModelCatalogCost;
|
||||
};
|
||||
|
||||
export const OPENAI_API_BASE_URL = "https://api.openai.com/v1";
|
||||
@@ -50,6 +58,7 @@ export function buildOpenAISyntheticCatalogEntry(
|
||||
input: readonly ("text" | "image")[];
|
||||
contextWindow: number;
|
||||
contextTokens?: number;
|
||||
cost?: SyntheticOpenAIModelCatalogCost;
|
||||
},
|
||||
): SyntheticOpenAIModelCatalogEntry | undefined {
|
||||
if (!template) {
|
||||
@@ -63,6 +72,7 @@ export function buildOpenAISyntheticCatalogEntry(
|
||||
input: [...entry.input],
|
||||
contextWindow: entry.contextWindow,
|
||||
...(entry.contextTokens === undefined ? {} : { contextTokens: entry.contextTokens }),
|
||||
...(entry.cost === undefined ? {} : { cost: entry.cost }),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,30 @@ describe("qa scenario catalog", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("includes the seeded mock-only broken-turn scenarios in the markdown pack", () => {
|
||||
const scenarioIds = [
|
||||
"reasoning-only-recovery-replay-safe-read",
|
||||
"reasoning-only-no-auto-retry-after-write",
|
||||
"empty-response-recovery-replay-safe-read",
|
||||
"empty-response-retry-budget-exhausted",
|
||||
];
|
||||
|
||||
for (const scenarioId of scenarioIds) {
|
||||
const scenario = readQaScenarioById(scenarioId);
|
||||
const config = readQaScenarioExecutionConfig(scenarioId) as
|
||||
| {
|
||||
requiredProvider?: string;
|
||||
prompt?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
expect(scenario.sourcePath).toBe(`qa/scenarios/${scenarioId}.md`);
|
||||
expect(config?.requiredProvider).toBe("mock-openai");
|
||||
expect(config?.prompt).toContain("check");
|
||||
expect(scenario.execution.flow?.steps.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps mock-only image debug assertions guarded in live-frontier runs", () => {
|
||||
const scenario = readQaScenarioPack().scenarios.find(
|
||||
(candidate) => candidate.id === "image-understanding-attachment",
|
||||
|
||||
@@ -350,7 +350,7 @@ export function readQaBootstrapScenarioCatalog(): QaBootstrapScenarioCatalog {
|
||||
};
|
||||
}
|
||||
|
||||
export function readQaScenarioById(id: string): QaSeedScenario {
|
||||
export function readQaScenarioById(id: string): QaSeedScenarioWithSource {
|
||||
const scenario = readQaScenarioPack().scenarios.find((candidate) => candidate.id === id);
|
||||
if (!scenario) {
|
||||
throw new Error(`unknown qa scenario: ${id}`);
|
||||
|
||||
@@ -214,6 +214,16 @@ describe("qa suite failure reply handling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("enables Control UI only for Control UI scenario workers", () => {
|
||||
expect(
|
||||
qaSuiteTesting.scenarioRequiresControlUi({
|
||||
...makeScenario("control-ui"),
|
||||
surface: "control-ui",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(qaSuiteTesting.scenarioRequiresControlUi(makeScenario("plain"))).toBe(false);
|
||||
});
|
||||
|
||||
it("filters provider-specific scenarios from an implicit live lane", () => {
|
||||
const scenarios = [
|
||||
makeScenario("generic"),
|
||||
|
||||
@@ -68,7 +68,7 @@ import { readQaBootstrapScenarioCatalog } from "./scenario-catalog.js";
|
||||
import { runScenarioFlow } from "./scenario-flow-runner.js";
|
||||
import { createQaScenarioRuntimeApi } from "./scenario-runtime-api.js";
|
||||
import {
|
||||
closeAllQaWebSessions,
|
||||
closeQaWebSessions,
|
||||
qaWebEvaluate,
|
||||
qaWebOpenPage,
|
||||
qaWebSnapshot,
|
||||
@@ -98,6 +98,7 @@ type QaSuiteEnvironment = {
|
||||
providerMode: "mock-openai" | "live-frontier";
|
||||
primaryModel: string;
|
||||
alternateModel: string;
|
||||
webSessionIds: Set<string>;
|
||||
};
|
||||
|
||||
export type QaSuiteStartLabFn = (params?: QaLabServerStartParams) => Promise<QaLabServerHandle>;
|
||||
@@ -340,6 +341,12 @@ function collectQaSuiteGatewayRuntimeOptions(
|
||||
return forwardHostHome ? { forwardHostHome: true } : undefined;
|
||||
}
|
||||
|
||||
function scenarioRequiresControlUi(
|
||||
scenario: ReturnType<typeof readQaBootstrapScenarioCatalog>["scenarios"][number],
|
||||
) {
|
||||
return normalizeLowercaseStringOrEmpty(scenario.surface) === "control-ui";
|
||||
}
|
||||
|
||||
function liveTurnTimeoutMs(env: QaSuiteEnvironment, fallbackMs: number) {
|
||||
return resolveQaLiveTurnTimeoutMs(env, fallbackMs);
|
||||
}
|
||||
@@ -1268,7 +1275,11 @@ function createScenarioFlowApi(
|
||||
browserOpenTab: qaBrowserOpenTab,
|
||||
browserSnapshot: qaBrowserSnapshot,
|
||||
browserAct: qaBrowserAct,
|
||||
webOpenPage: qaWebOpenPage,
|
||||
webOpenPage: async (params: Parameters<typeof qaWebOpenPage>[0]) => {
|
||||
const opened = await qaWebOpenPage(params);
|
||||
env.webSessionIds.add(opened.pageId);
|
||||
return opened;
|
||||
},
|
||||
webWait: qaWebWait,
|
||||
webType: qaWebType,
|
||||
webSnapshot: qaWebSnapshot,
|
||||
@@ -1330,6 +1341,7 @@ export const qaSuiteTesting = {
|
||||
mapQaSuiteWithConcurrency,
|
||||
normalizeQaSuiteConcurrency,
|
||||
scenarioMatchesLiveLane,
|
||||
scenarioRequiresControlUi,
|
||||
selectQaSuiteScenarios,
|
||||
readTransportTranscript,
|
||||
formatTransportTranscript,
|
||||
@@ -1575,10 +1587,10 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResu
|
||||
scenarioIds: [scenario.id],
|
||||
concurrency: 1,
|
||||
startLab,
|
||||
// Isolated workers do not need their own Control UI proxy. The
|
||||
// outer lab already owns the watch surface, so skip per-worker
|
||||
// Control UI asset resolution and startup overhead.
|
||||
controlUiEnabled: false,
|
||||
// Most isolated workers do not need their own Control UI proxy.
|
||||
// Control UI scenarios do, because they open the worker's
|
||||
// gateway-backed app directly.
|
||||
controlUiEnabled: scenarioRequiresControlUi(scenario),
|
||||
});
|
||||
const scenarioResult: QaSuiteScenarioResult =
|
||||
result.scenarios[0] ??
|
||||
@@ -1740,6 +1752,7 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResu
|
||||
providerMode,
|
||||
primaryModel,
|
||||
alternateModel,
|
||||
webSessionIds: new Set(),
|
||||
};
|
||||
|
||||
let preserveGatewayRuntimeDir: string | undefined;
|
||||
@@ -1849,7 +1862,7 @@ export async function runQaSuite(params?: QaSuiteRunParams): Promise<QaSuiteResu
|
||||
preserveGatewayRuntimeDir = path.join(outputDir, "artifacts", "gateway-runtime");
|
||||
throw error;
|
||||
} finally {
|
||||
await closeAllQaWebSessions();
|
||||
await closeQaWebSessions(env.webSessionIds);
|
||||
const keepTemp = process.env.OPENCLAW_QA_KEEP_TEMP === "1" || false;
|
||||
await gateway.stop({
|
||||
keepTemp,
|
||||
|
||||
@@ -44,6 +44,7 @@ vi.mock("playwright-core", () => ({
|
||||
|
||||
import {
|
||||
closeAllQaWebSessions,
|
||||
closeQaWebSessions,
|
||||
qaWebEvaluate,
|
||||
qaWebOpenPage,
|
||||
qaWebSnapshot,
|
||||
@@ -114,4 +115,19 @@ describe("qa web runtime", () => {
|
||||
expect(contextClose).toHaveBeenCalledTimes(1);
|
||||
expect(browserClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("can close only selected page sessions", async () => {
|
||||
const first = await qaWebOpenPage({ url: "http://127.0.0.1:3000/one" });
|
||||
const second = await qaWebOpenPage({ url: "http://127.0.0.1:3000/two" });
|
||||
|
||||
await closeQaWebSessions([first.pageId]);
|
||||
|
||||
await expect(qaWebSnapshot({ pageId: first.pageId })).rejects.toThrow(
|
||||
`unknown web session: ${first.pageId}`,
|
||||
);
|
||||
await expect(qaWebSnapshot({ pageId: second.pageId })).resolves.toMatchObject({
|
||||
text: "hello from body",
|
||||
});
|
||||
await closeAllQaWebSessions();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,11 +144,23 @@ export async function qaWebEvaluate<T = unknown>(params: QaWebEvaluateParams): P
|
||||
])) as T;
|
||||
}
|
||||
|
||||
export async function closeAllQaWebSessions(): Promise<void> {
|
||||
const active = [...sessions.values()];
|
||||
sessions.clear();
|
||||
export async function closeQaWebSessions(pageIds?: Iterable<string>): Promise<void> {
|
||||
const active = pageIds
|
||||
? [...pageIds].flatMap((pageId) => {
|
||||
const session = sessions.get(pageId);
|
||||
sessions.delete(pageId);
|
||||
return session ? [session] : [];
|
||||
})
|
||||
: [...sessions.values()];
|
||||
if (!pageIds) {
|
||||
sessions.clear();
|
||||
}
|
||||
for (const session of active) {
|
||||
await session.context.close().catch(() => {});
|
||||
await session.browser.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeAllQaWebSessions(): Promise<void> {
|
||||
await closeQaWebSessions();
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ describe("qqbot local media path remapping", () => {
|
||||
it("allows structured payload files inside the QQ Bot media directory", () => {
|
||||
const { mediaFile } = createQqbotMediaFile("allowed.png");
|
||||
|
||||
expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(mediaFile);
|
||||
expect(resolveQQBotPayloadLocalFilePath(mediaFile)).toBe(fs.realpathSync(mediaFile));
|
||||
});
|
||||
|
||||
it("blocks structured payload files inside the QQ Bot data directory", () => {
|
||||
@@ -127,6 +127,6 @@ describe("qqbot local media path remapping", () => {
|
||||
"legacy.png",
|
||||
);
|
||||
|
||||
expect(resolveQQBotPayloadLocalFilePath(missingWorkspacePath)).toBe(mediaFile);
|
||||
expect(resolveQQBotPayloadLocalFilePath(missingWorkspacePath)).toBe(fs.realpathSync(mediaFile));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SlackMonitorContext } from "./context.js";
|
||||
|
||||
const readStoreAllowFromForDmPolicyMock = vi.hoisted(() => vi.fn());
|
||||
let authorizeSlackSystemEventSender: typeof import("./auth.js").authorizeSlackSystemEventSender;
|
||||
let clearSlackAllowFromCacheForTest: typeof import("./auth.js").clearSlackAllowFromCacheForTest;
|
||||
let resolveSlackEffectiveAllowFrom: typeof import("./auth.js").resolveSlackEffectiveAllowFrom;
|
||||
|
||||
@@ -24,12 +25,42 @@ function makeSlackCtx(allowFrom: string[]): SlackMonitorContext {
|
||||
} as unknown as SlackMonitorContext;
|
||||
}
|
||||
|
||||
function makeAuthorizeCtx(params?: {
|
||||
allowFrom?: string[];
|
||||
channelsConfig?: Record<string, { users?: string[] }>;
|
||||
resolveUserName?: (userId: string) => Promise<{ name?: string }>;
|
||||
resolveChannelName?: (
|
||||
channelId: string,
|
||||
) => Promise<{ name?: string; type?: "im" | "mpim" | "channel" | "group" }>;
|
||||
}) {
|
||||
return {
|
||||
allowFrom: params?.allowFrom ?? [],
|
||||
accountId: "main",
|
||||
dmPolicy: "open",
|
||||
dmEnabled: true,
|
||||
allowNameMatching: false,
|
||||
channelsConfig: params?.channelsConfig ?? {},
|
||||
channelsConfigKeys: Object.keys(params?.channelsConfig ?? {}),
|
||||
defaultRequireMention: true,
|
||||
isChannelAllowed: vi.fn(() => true),
|
||||
resolveUserName: vi.fn(
|
||||
params?.resolveUserName ?? ((_) => Promise.resolve({ name: undefined })),
|
||||
),
|
||||
resolveChannelName: vi.fn(
|
||||
params?.resolveChannelName ?? ((_) => Promise.resolve({ name: "general", type: "channel" })),
|
||||
),
|
||||
} as unknown as SlackMonitorContext;
|
||||
}
|
||||
|
||||
describe("resolveSlackEffectiveAllowFrom", () => {
|
||||
const prevTtl = process.env.OPENCLAW_SLACK_PAIRING_ALLOWFROM_CACHE_TTL_MS;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ clearSlackAllowFromCacheForTest, resolveSlackEffectiveAllowFrom } =
|
||||
await import("./auth.js"));
|
||||
({
|
||||
authorizeSlackSystemEventSender,
|
||||
clearSlackAllowFromCacheForTest,
|
||||
resolveSlackEffectiveAllowFrom,
|
||||
} = await import("./auth.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -83,3 +114,430 @@ describe("resolveSlackEffectiveAllowFrom", () => {
|
||||
expect(readStoreAllowFromForDmPolicyMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authorizeSlackSystemEventSender", () => {
|
||||
beforeAll(async () => {
|
||||
({ authorizeSlackSystemEventSender, clearSlackAllowFromCacheForTest } =
|
||||
await import("./auth.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearSlackAllowFromCacheForTest();
|
||||
});
|
||||
|
||||
it("keeps non-interactive channel senders open when only global allowFrom is configured", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx({ allowFrom: ["U_OWNER"] }),
|
||||
senderId: "U_ATTACKER",
|
||||
channelId: "C1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
channelType: "channel",
|
||||
channelName: "general",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps channel users as the non-interactive gate even when global allowFrom is configured", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx({
|
||||
allowFrom: ["U_OWNER"],
|
||||
channelsConfig: {
|
||||
C1: { users: ["U_ALLOWED"] },
|
||||
},
|
||||
}),
|
||||
senderId: "U_OWNER",
|
||||
channelId: "C1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: false,
|
||||
reason: "sender-not-channel-allowed",
|
||||
channelType: "channel",
|
||||
channelName: "general",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the channel denial reason for non-interactive senders who miss a channel users allowlist", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx({
|
||||
allowFrom: ["U_OWNER"],
|
||||
channelsConfig: {
|
||||
C1: { users: ["U_ALLOWED"] },
|
||||
},
|
||||
}),
|
||||
senderId: "U_ATTACKER",
|
||||
channelId: "C1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: false,
|
||||
reason: "sender-not-channel-allowed",
|
||||
channelType: "channel",
|
||||
channelName: "general",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows channel senders authorized by channel users even when not in global allowFrom", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx({
|
||||
allowFrom: ["U_OWNER"],
|
||||
channelsConfig: {
|
||||
C1: { users: ["U_ALLOWED"] },
|
||||
},
|
||||
}),
|
||||
senderId: "U_ALLOWED",
|
||||
channelId: "C1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
channelType: "channel",
|
||||
channelName: "general",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps channel interactions open when no global or channel allowlists are configured", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx(),
|
||||
senderId: "U_ANYONE",
|
||||
channelId: "C1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
channelType: "channel",
|
||||
channelName: "general",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let a wildcard global allowFrom bypass non-interactive channel users restrictions", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx({
|
||||
allowFrom: ["*"],
|
||||
channelsConfig: {
|
||||
C1: { users: ["U_ALLOWED"] },
|
||||
},
|
||||
}),
|
||||
senderId: "U_ATTACKER",
|
||||
channelId: "C1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: false,
|
||||
reason: "sender-not-channel-allowed",
|
||||
channelType: "channel",
|
||||
channelName: "general",
|
||||
});
|
||||
});
|
||||
|
||||
it("still allows a channel user when the global allowFrom is wildcard", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx({
|
||||
allowFrom: ["*"],
|
||||
channelsConfig: {
|
||||
C1: { users: ["U_ALLOWED"] },
|
||||
},
|
||||
}),
|
||||
senderId: "U_ALLOWED",
|
||||
channelId: "C1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
channelType: "channel",
|
||||
channelName: "general",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not give non-interactive owner bypass when channel users are configured, even if explicit owners are also listed", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx({
|
||||
allowFrom: ["U_OWNER", "*"],
|
||||
channelsConfig: {
|
||||
C1: { users: ["U_ALLOWED"] },
|
||||
},
|
||||
}),
|
||||
senderId: "U_ATTACKER",
|
||||
channelId: "C1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: false,
|
||||
reason: "sender-not-channel-allowed",
|
||||
channelType: "channel",
|
||||
channelName: "general",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps explicit owners behind the non-interactive channel users gate when allowFrom also contains wildcard", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx({
|
||||
allowFrom: ["U_OWNER", "*"],
|
||||
channelsConfig: {
|
||||
C1: { users: ["U_ALLOWED"] },
|
||||
},
|
||||
}),
|
||||
senderId: "U_OWNER",
|
||||
channelId: "C1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: false,
|
||||
reason: "sender-not-channel-allowed",
|
||||
channelType: "channel",
|
||||
channelName: "general",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows senders without channel context when no allowFrom is configured", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx(),
|
||||
senderId: "U_ANYONE",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
channelType: "channel",
|
||||
channelName: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("authorizeSlackSystemEventSender interactiveEvent", () => {
|
||||
beforeAll(async () => {
|
||||
({ authorizeSlackSystemEventSender, clearSlackAllowFromCacheForTest } =
|
||||
await import("./auth.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearSlackAllowFromCacheForTest();
|
||||
});
|
||||
|
||||
it("rejects interactive events when expectedSenderId is not provided", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx({ allowFrom: ["U_OWNER"] }),
|
||||
senderId: "U_OWNER",
|
||||
channelId: "C1",
|
||||
interactiveEvent: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: false,
|
||||
reason: "missing-expected-sender",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows interactive events when expectedSenderId matches senderId", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx({ allowFrom: ["U_OWNER"] }),
|
||||
senderId: "U_OWNER",
|
||||
channelId: "C1",
|
||||
expectedSenderId: "U_OWNER",
|
||||
interactiveEvent: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
channelType: "channel",
|
||||
channelName: "general",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows interactive channel senders who match the global allowFrom even when channel users are configured", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx({
|
||||
allowFrom: ["U_OWNER"],
|
||||
channelsConfig: {
|
||||
C1: { users: ["U_ALLOWED"] },
|
||||
},
|
||||
}),
|
||||
senderId: "U_OWNER",
|
||||
channelId: "C1",
|
||||
expectedSenderId: "U_OWNER",
|
||||
interactiveEvent: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
channelType: "channel",
|
||||
channelName: "general",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a combined denial reason when an interactive sender matches neither global nor channel allowlists", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx({
|
||||
allowFrom: ["U_OWNER"],
|
||||
channelsConfig: {
|
||||
C1: { users: ["U_ALLOWED"] },
|
||||
},
|
||||
}),
|
||||
senderId: "U_ATTACKER",
|
||||
channelId: "C1",
|
||||
expectedSenderId: "U_ATTACKER",
|
||||
interactiveEvent: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: false,
|
||||
reason: "sender-not-authorized",
|
||||
channelType: "channel",
|
||||
channelName: "general",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps interactive channel events open when no allowlists are configured", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx(),
|
||||
senderId: "U_ANYONE",
|
||||
channelId: "C1",
|
||||
expectedSenderId: "U_ANYONE",
|
||||
interactiveEvent: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
channelType: "channel",
|
||||
channelName: "general",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit owner access for interactive events when allowFrom also contains wildcard", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx({
|
||||
allowFrom: ["U_OWNER", "*"],
|
||||
channelsConfig: {
|
||||
C1: { users: ["U_ALLOWED"] },
|
||||
},
|
||||
}),
|
||||
senderId: "U_OWNER",
|
||||
channelId: "C1",
|
||||
expectedSenderId: "U_OWNER",
|
||||
interactiveEvent: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
channelType: "channel",
|
||||
channelName: "general",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps interactive no-channel events open when no allowFrom is configured", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx(),
|
||||
senderId: "U_ANYONE",
|
||||
expectedSenderId: "U_ANYONE",
|
||||
interactiveEvent: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
channelType: "channel",
|
||||
channelName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("denies interactive no-channel events when sender is not in allowFrom", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx({ allowFrom: ["U_OWNER"] }),
|
||||
senderId: "U_ATTACKER",
|
||||
expectedSenderId: "U_ATTACKER",
|
||||
interactiveEvent: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: false,
|
||||
reason: "sender-not-allowlisted",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows interactive no-channel events when sender is in allowFrom", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx({ allowFrom: ["U_OWNER"] }),
|
||||
senderId: "U_OWNER",
|
||||
expectedSenderId: "U_OWNER",
|
||||
interactiveEvent: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
channelType: "channel",
|
||||
channelName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects interactive events with ambiguous channel type", async () => {
|
||||
// Channel ID "X1" has no recognized prefix (D, C, G) so the type is ambiguous
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx({
|
||||
allowFrom: ["U_OWNER"],
|
||||
resolveChannelName: () => Promise.resolve({ name: "mystery" }),
|
||||
}),
|
||||
senderId: "U_OWNER",
|
||||
channelId: "X1",
|
||||
expectedSenderId: "U_OWNER",
|
||||
interactiveEvent: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: false,
|
||||
reason: "ambiguous-channel-type",
|
||||
channelType: "channel",
|
||||
channelName: "mystery",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows interactive events when channel type is known from ID prefix", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx({ allowFrom: ["U_OWNER"] }),
|
||||
senderId: "U_OWNER",
|
||||
channelId: "C1",
|
||||
expectedSenderId: "U_OWNER",
|
||||
interactiveEvent: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
channelType: "channel",
|
||||
channelName: "general",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows interactive events when channel type is known from explicit type", async () => {
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx({
|
||||
allowFrom: ["U_OWNER"],
|
||||
resolveChannelName: () => Promise.resolve({ name: "mystery", type: "group" }),
|
||||
}),
|
||||
senderId: "U_OWNER",
|
||||
channelId: "X1",
|
||||
channelType: "group",
|
||||
expectedSenderId: "U_OWNER",
|
||||
interactiveEvent: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
channelType: "group",
|
||||
channelName: "mystery",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not apply interactiveEvent restrictions to non-interactive events", async () => {
|
||||
// Same scenario as the denying test above, but without interactiveEvent flag
|
||||
const result = await authorizeSlackSystemEventSender({
|
||||
ctx: makeAuthorizeCtx(),
|
||||
senderId: "U_ANYONE",
|
||||
channelId: "C1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
channelType: "channel",
|
||||
channelName: "general",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,9 +3,11 @@ import {
|
||||
allowListMatches,
|
||||
normalizeAllowList,
|
||||
normalizeAllowListLower,
|
||||
resolveSlackAllowListMatch,
|
||||
resolveSlackUserAllowed,
|
||||
} from "./allow-list.js";
|
||||
import { resolveSlackChannelConfig } from "./channel-config.js";
|
||||
import { inferSlackChannelType } from "./channel-type.js";
|
||||
import { normalizeSlackChannelType, type SlackMonitorContext } from "./context.js";
|
||||
|
||||
type ResolvedAllowFromLists = {
|
||||
@@ -153,11 +155,14 @@ export type SlackSystemEventAuthResult = {
|
||||
allowed: boolean;
|
||||
reason?:
|
||||
| "missing-sender"
|
||||
| "missing-expected-sender"
|
||||
| "sender-mismatch"
|
||||
| "channel-not-allowed"
|
||||
| "ambiguous-channel-type"
|
||||
| "dm-disabled"
|
||||
| "sender-not-allowlisted"
|
||||
| "sender-not-channel-allowed";
|
||||
| "sender-not-channel-allowed"
|
||||
| "sender-not-authorized";
|
||||
channelType?: "im" | "mpim" | "channel" | "group";
|
||||
channelName?: string;
|
||||
};
|
||||
@@ -168,6 +173,10 @@ export async function authorizeSlackSystemEventSender(params: {
|
||||
channelId?: string;
|
||||
channelType?: string | null;
|
||||
expectedSenderId?: string;
|
||||
/** When true, requires expectedSenderId, rejects ambiguous channel types,
|
||||
* and applies interactive-only owner allowFrom checks without changing the
|
||||
* open-by-default channel behavior when no allowlists are configured. */
|
||||
interactiveEvent?: boolean;
|
||||
}): Promise<SlackSystemEventAuthResult> {
|
||||
const senderId = params.senderId?.trim();
|
||||
if (!senderId) {
|
||||
@@ -179,6 +188,11 @@ export async function authorizeSlackSystemEventSender(params: {
|
||||
return { allowed: false, reason: "sender-mismatch" };
|
||||
}
|
||||
|
||||
// Interactive events require an expected sender to cross-verify the actor.
|
||||
if (params.interactiveEvent && !expectedSenderId) {
|
||||
return { allowed: false, reason: "missing-expected-sender" };
|
||||
}
|
||||
|
||||
const channelId = params.channelId?.trim();
|
||||
let channelType = normalizeSlackChannelType(params.channelType, channelId);
|
||||
let channelName: string | undefined;
|
||||
@@ -188,7 +202,8 @@ export async function authorizeSlackSystemEventSender(params: {
|
||||
type?: "im" | "mpim" | "channel" | "group";
|
||||
} = await params.ctx.resolveChannelName(channelId).catch(() => ({}));
|
||||
channelName = info.name;
|
||||
channelType = normalizeSlackChannelType(params.channelType ?? info.type, channelId);
|
||||
const resolvedTypeSource = params.channelType ?? info.type;
|
||||
channelType = normalizeSlackChannelType(resolvedTypeSource, channelId);
|
||||
if (
|
||||
!params.ctx.isChannelAllowed({
|
||||
channelId,
|
||||
@@ -203,6 +218,31 @@ export async function authorizeSlackSystemEventSender(params: {
|
||||
channelName,
|
||||
};
|
||||
}
|
||||
|
||||
// For interactive events, reject when channel type could not be positively
|
||||
// determined from either the explicit type or the channel ID prefix. This
|
||||
// prevents a DM from being misclassified as "channel" and skipping
|
||||
// DM-specific authorization.
|
||||
if (params.interactiveEvent) {
|
||||
const inferredFromId = inferSlackChannelType(channelId);
|
||||
const sourceNormalized =
|
||||
typeof resolvedTypeSource === "string"
|
||||
? resolvedTypeSource.toLowerCase().trim()
|
||||
: undefined;
|
||||
const sourceIsKnownType =
|
||||
sourceNormalized === "im" ||
|
||||
sourceNormalized === "mpim" ||
|
||||
sourceNormalized === "channel" ||
|
||||
sourceNormalized === "group";
|
||||
if (inferredFromId === undefined && !sourceIsKnownType) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "ambiguous-channel-type",
|
||||
channelType,
|
||||
channelName,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const senderInfo: { name?: string } = await params.ctx
|
||||
@@ -235,8 +275,8 @@ export async function authorizeSlackSystemEventSender(params: {
|
||||
}
|
||||
}
|
||||
} else if (!channelId) {
|
||||
// No channel context. Apply allowFrom if configured so we fail closed
|
||||
// for privileged interactive events when owner allowlist is present.
|
||||
// No channel context. Preserve the existing open default unless a global
|
||||
// allowFrom list is configured.
|
||||
const allowFromLower = await resolveAllowFromLower(false);
|
||||
if (allowFromLower.length > 0) {
|
||||
const senderAllowListed = isSlackSenderAllowListed({
|
||||
@@ -250,6 +290,9 @@ export async function authorizeSlackSystemEventSender(params: {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const allowFromLower = await resolveAllowFromLower(false);
|
||||
const ownerAllowlistConfigured = allowFromLower.length > 0;
|
||||
const allowFromLowerWithoutWildcard = allowFromLower.filter((entry) => entry !== "*");
|
||||
const channelConfig = resolveSlackChannelConfig({
|
||||
channelId,
|
||||
channelName,
|
||||
@@ -260,6 +303,23 @@ export async function authorizeSlackSystemEventSender(params: {
|
||||
});
|
||||
const channelUsersAllowlistConfigured =
|
||||
Array.isArray(channelConfig?.users) && channelConfig.users.length > 0;
|
||||
const ownerMatch = ownerAllowlistConfigured
|
||||
? resolveSlackAllowListMatch({
|
||||
allowList: allowFromLower,
|
||||
id: senderId,
|
||||
name: senderName,
|
||||
allowNameMatching: params.ctx.allowNameMatching,
|
||||
})
|
||||
: { allowed: false };
|
||||
const ownerAllowed = ownerMatch.allowed;
|
||||
const ownerExplicitlyAllowed =
|
||||
allowFromLowerWithoutWildcard.length > 0 &&
|
||||
resolveSlackAllowListMatch({
|
||||
allowList: allowFromLowerWithoutWildcard,
|
||||
id: senderId,
|
||||
name: senderName,
|
||||
allowNameMatching: params.ctx.allowNameMatching,
|
||||
}).allowed;
|
||||
if (channelUsersAllowlistConfigured) {
|
||||
const channelUserAllowed = resolveSlackUserAllowed({
|
||||
allowList: channelConfig?.users,
|
||||
@@ -267,14 +327,37 @@ export async function authorizeSlackSystemEventSender(params: {
|
||||
userName: senderName,
|
||||
allowNameMatching: params.ctx.allowNameMatching,
|
||||
});
|
||||
if (!channelUserAllowed) {
|
||||
if (channelUserAllowed || (params.interactiveEvent && ownerExplicitlyAllowed)) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "sender-not-channel-allowed",
|
||||
allowed: true,
|
||||
channelType,
|
||||
channelName,
|
||||
};
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
reason:
|
||||
params.interactiveEvent && ownerAllowlistConfigured
|
||||
? "sender-not-authorized"
|
||||
: "sender-not-channel-allowed",
|
||||
channelType,
|
||||
channelName,
|
||||
};
|
||||
}
|
||||
if (params.interactiveEvent && ownerAllowed) {
|
||||
return {
|
||||
allowed: true,
|
||||
channelType,
|
||||
channelName,
|
||||
};
|
||||
}
|
||||
if (params.interactiveEvent && ownerAllowlistConfigured) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "sender-not-allowlisted",
|
||||
channelType,
|
||||
channelName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -472,6 +472,11 @@ async function authorizeSlackBlockAction(params: {
|
||||
ctx: params.ctx,
|
||||
senderId: params.parsed.userId,
|
||||
channelId: params.parsed.channelId,
|
||||
// Block action sender identity is verified by Slack's request signing.
|
||||
// Pass the Slack-verified userId as expectedSenderId to satisfy the
|
||||
// mandatory actor-binding requirement for interactive events.
|
||||
expectedSenderId: params.parsed.userId,
|
||||
interactiveEvent: true,
|
||||
});
|
||||
if (auth.allowed) {
|
||||
return auth;
|
||||
|
||||
@@ -219,6 +219,7 @@ export async function emitSlackModalLifecycleEvent(params: {
|
||||
channelId: sessionRouting.channelId,
|
||||
channelType: sessionRouting.channelType,
|
||||
expectedSenderId: expectedUserId,
|
||||
interactiveEvent: true,
|
||||
});
|
||||
if (!auth.allowed) {
|
||||
params.ctx.runtime.log?.(
|
||||
|
||||
@@ -138,9 +138,10 @@ function createContext(overrides?: {
|
||||
runtime: { log: runtimeLog },
|
||||
dmEnabled: overrides?.dmEnabled ?? true,
|
||||
dmPolicy: overrides?.dmPolicy ?? ("open" as const),
|
||||
allowFrom: overrides?.allowFrom ?? [],
|
||||
allowFrom: overrides?.allowFrom ?? ["*"],
|
||||
allowNameMatching: overrides?.allowNameMatching ?? false,
|
||||
channelsConfig: overrides?.channelsConfig ?? {},
|
||||
channelsConfigKeys: Object.keys(overrides?.channelsConfig ?? {}),
|
||||
defaultRequireMention: true,
|
||||
shouldDropMismatchedSlackEvent: (body: unknown) =>
|
||||
overrides?.shouldDropMismatchedSlackEvent?.(body) ?? false,
|
||||
@@ -771,6 +772,156 @@ describe("registerSlackInteractionEvents", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks channel block actions when sender is outside configured global allowFrom", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
const { ctx, app, getHandler } = createContext({
|
||||
allowFrom: ["U_OWNER"],
|
||||
});
|
||||
registerSlackInteractionEvents({ ctx: ctx as never });
|
||||
const handler = getHandler();
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const ack = vi.fn().mockResolvedValue(undefined);
|
||||
const respond = vi.fn().mockResolvedValue(undefined);
|
||||
await handler!({
|
||||
ack,
|
||||
respond,
|
||||
body: {
|
||||
user: { id: "U_ATTACKER" },
|
||||
channel: { id: "C1" },
|
||||
message: {
|
||||
ts: "250.251",
|
||||
blocks: [{ type: "actions", block_id: "verify_block", elements: [] }],
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "button",
|
||||
action_id: "openclaw:verify",
|
||||
block_id: "verify_block",
|
||||
},
|
||||
});
|
||||
|
||||
expect(ack).toHaveBeenCalled();
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
expect(app.client.chat.update).not.toHaveBeenCalled();
|
||||
expect(respond).toHaveBeenCalledWith({
|
||||
text: "You are not authorized to use this control.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows channel block actions when channel users allowlist authorizes the sender", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
const { ctx, app, getHandler } = createContext({
|
||||
allowFrom: ["U_OWNER"],
|
||||
channelsConfig: {
|
||||
C1: { users: ["U_ALLOWED"] },
|
||||
},
|
||||
});
|
||||
registerSlackInteractionEvents({ ctx: ctx as never });
|
||||
const handler = getHandler();
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const ack = vi.fn().mockResolvedValue(undefined);
|
||||
const respond = vi.fn().mockResolvedValue(undefined);
|
||||
await handler!({
|
||||
ack,
|
||||
respond,
|
||||
body: {
|
||||
user: { id: "U_ALLOWED" },
|
||||
channel: { id: "C1" },
|
||||
message: {
|
||||
ts: "260.261",
|
||||
blocks: [{ type: "actions", block_id: "verify_block", elements: [] }],
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "button",
|
||||
action_id: "openclaw:verify",
|
||||
block_id: "verify_block",
|
||||
},
|
||||
});
|
||||
|
||||
expect(ack).toHaveBeenCalled();
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(app.client.chat.update).toHaveBeenCalledTimes(1);
|
||||
expect(respond).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks wildcard global allowFrom from bypassing configured channel users", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
const { ctx, app, getHandler } = createContext({
|
||||
allowFrom: ["*"],
|
||||
channelsConfig: {
|
||||
C1: { users: ["U_ALLOWED"] },
|
||||
},
|
||||
});
|
||||
registerSlackInteractionEvents({ ctx: ctx as never });
|
||||
const handler = getHandler();
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const ack = vi.fn().mockResolvedValue(undefined);
|
||||
const respond = vi.fn().mockResolvedValue(undefined);
|
||||
await handler!({
|
||||
ack,
|
||||
respond,
|
||||
body: {
|
||||
user: { id: "U_ATTACKER" },
|
||||
channel: { id: "C1" },
|
||||
message: {
|
||||
ts: "270.271",
|
||||
blocks: [{ type: "actions", block_id: "verify_block", elements: [] }],
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "button",
|
||||
action_id: "openclaw:verify",
|
||||
block_id: "verify_block",
|
||||
},
|
||||
});
|
||||
|
||||
expect(ack).toHaveBeenCalled();
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
expect(app.client.chat.update).not.toHaveBeenCalled();
|
||||
expect(respond).toHaveBeenCalledWith({
|
||||
text: "You are not authorized to use this control.",
|
||||
response_type: "ephemeral",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps channel block actions open when no allowlists are configured", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
const { ctx, app, getHandler } = createContext({ allowFrom: [] });
|
||||
registerSlackInteractionEvents({ ctx: ctx as never });
|
||||
const handler = getHandler();
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const ack = vi.fn().mockResolvedValue(undefined);
|
||||
const respond = vi.fn().mockResolvedValue(undefined);
|
||||
await handler!({
|
||||
ack,
|
||||
respond,
|
||||
body: {
|
||||
user: { id: "U_ANYONE" },
|
||||
channel: { id: "C1" },
|
||||
message: {
|
||||
ts: "305.306",
|
||||
blocks: [{ type: "actions", block_id: "verify_block", elements: [] }],
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "button",
|
||||
action_id: "openclaw:verify",
|
||||
block_id: "verify_block",
|
||||
},
|
||||
});
|
||||
|
||||
expect(ack).toHaveBeenCalled();
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
||||
expect(app.client.chat.update).toHaveBeenCalledTimes(1);
|
||||
expect(respond).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks DM block actions when sender is not in allowFrom", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
const { ctx, app, getHandler } = createContext({
|
||||
@@ -1412,6 +1563,33 @@ describe("registerSlackInteractionEvents", () => {
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps no-channel modal events open when allowFrom is unset", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
const { ctx, getViewHandler } = createContext({ allowFrom: [] });
|
||||
registerSlackInteractionEvents({ ctx: ctx as never });
|
||||
const viewHandler = getViewHandler();
|
||||
expect(viewHandler).toBeTruthy();
|
||||
|
||||
const ack = vi.fn().mockResolvedValue(undefined);
|
||||
await viewHandler!({
|
||||
ack,
|
||||
body: {
|
||||
user: { id: "U444" },
|
||||
view: {
|
||||
id: "V444",
|
||||
callback_id: "openclaw:routing_form",
|
||||
private_metadata: JSON.stringify({ userId: "U444" }),
|
||||
state: {
|
||||
values: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(ack).toHaveBeenCalled();
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("captures modal input labels and picker values across block types", async () => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
const { ctx, getViewHandler } = createContext();
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const { recordInboundSessionMock } = vi.hoisted(() => ({
|
||||
recordInboundSessionMock: vi.fn().mockResolvedValue(undefined),
|
||||
import { resetTopicNameCacheForTest } from "./topic-name-cache.js";
|
||||
|
||||
type SessionRuntimeModule = typeof import("./bot-message-context.session.runtime.js");
|
||||
type RecordInboundSessionFn = SessionRuntimeModule["recordInboundSession"];
|
||||
type ResolveStorePathFn = SessionRuntimeModule["resolveStorePath"];
|
||||
|
||||
const { recordInboundSessionMock, resolveStorePathMock } = vi.hoisted(() => ({
|
||||
recordInboundSessionMock: vi.fn<RecordInboundSessionFn>(async () => undefined),
|
||||
resolveStorePathMock: vi.fn<ResolveStorePathFn>(() => "/tmp/openclaw-session-store.json"),
|
||||
}));
|
||||
|
||||
vi.mock("./bot-message-context.session.runtime.js", async () => {
|
||||
@@ -9,7 +19,10 @@ vi.mock("./bot-message-context.session.runtime.js", async () => {
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
|
||||
recordInboundSession: (...args: Parameters<typeof actual.recordInboundSession>) =>
|
||||
recordInboundSessionMock(...args),
|
||||
resolveStorePath: (...args: Parameters<typeof actual.resolveStorePath>) =>
|
||||
resolveStorePathMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -34,11 +47,15 @@ const { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } =
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
resetTopicNameCacheForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
resetTopicNameCacheForTest();
|
||||
recordInboundSessionMock.mockClear();
|
||||
resolveStorePathMock.mockReset();
|
||||
resolveStorePathMock.mockReturnValue("/tmp/openclaw-session-store.json");
|
||||
});
|
||||
|
||||
describe("buildTelegramMessageContext dm thread sessions", () => {
|
||||
@@ -161,6 +178,123 @@ describe("buildTelegramMessageContext group sessions without forum", () => {
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.TopicName).toBe("Deployments");
|
||||
});
|
||||
|
||||
it("handles forum messages without session runtime overrides", async () => {
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
message: {
|
||||
message_id: 3,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
|
||||
date: 1700000002,
|
||||
text: "@bot hello",
|
||||
message_thread_id: 99,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
reply_to_message: {
|
||||
message_id: 2,
|
||||
forum_topic_created: { name: "Deployments", icon_color: 0x6fb9f0 },
|
||||
},
|
||||
},
|
||||
options: { forceWasMentioned: true },
|
||||
resolveGroupActivation: () => true,
|
||||
sessionRuntime: null,
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.TopicName).toBe("Deployments");
|
||||
});
|
||||
|
||||
it("reloads topic name from disk after cache reset", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-topic-name-"));
|
||||
const sessionStorePath = path.join(tempDir, "sessions.json");
|
||||
const buildPersistedContext = async (message: Record<string, unknown>) =>
|
||||
await buildTelegramMessageContextForTest({
|
||||
message,
|
||||
options: { forceWasMentioned: true },
|
||||
resolveGroupActivation: () => true,
|
||||
sessionRuntime: {
|
||||
resolveStorePath: () => sessionStorePath,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await buildPersistedContext({
|
||||
message_id: 4,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
|
||||
date: 1700000003,
|
||||
text: "@bot hello",
|
||||
message_thread_id: 99,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
reply_to_message: {
|
||||
message_id: 3,
|
||||
forum_topic_created: { name: "Deployments", icon_color: 0x6fb9f0 },
|
||||
},
|
||||
});
|
||||
|
||||
resetTopicNameCacheForTest();
|
||||
|
||||
const ctx = await buildPersistedContext({
|
||||
message_id: 5,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
|
||||
date: 1700000004,
|
||||
text: "@bot again",
|
||||
message_thread_id: 99,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.TopicName).toBe("Deployments");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
resetTopicNameCacheForTest();
|
||||
}
|
||||
});
|
||||
|
||||
it("persists topic names through the default session runtime path", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-telegram-topic-name-"));
|
||||
const sessionStorePath = path.join(tempDir, "sessions.json");
|
||||
resolveStorePathMock.mockReturnValue(sessionStorePath);
|
||||
|
||||
try {
|
||||
await buildTelegramMessageContextForTest({
|
||||
message: {
|
||||
message_id: 6,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
|
||||
date: 1700000005,
|
||||
text: "@bot hello",
|
||||
message_thread_id: 99,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
reply_to_message: {
|
||||
message_id: 5,
|
||||
forum_topic_created: { name: "Deployments", icon_color: 0x6fb9f0 },
|
||||
},
|
||||
},
|
||||
options: { forceWasMentioned: true },
|
||||
resolveGroupActivation: () => true,
|
||||
sessionRuntime: null,
|
||||
});
|
||||
|
||||
resetTopicNameCacheForTest();
|
||||
|
||||
const ctx = await buildTelegramMessageContextForTest({
|
||||
message: {
|
||||
message_id: 7,
|
||||
chat: { id: -1001234567890, type: "supergroup", title: "Test Forum", is_forum: true },
|
||||
date: 1700000006,
|
||||
text: "@bot again",
|
||||
message_thread_id: 99,
|
||||
from: { id: 42, first_name: "Alice" },
|
||||
},
|
||||
options: { forceWasMentioned: true },
|
||||
resolveGroupActivation: () => true,
|
||||
sessionRuntime: null,
|
||||
});
|
||||
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx?.ctxPayload?.TopicName).toBe("Deployments");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
resetTopicNameCacheForTest();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTelegramMessageContext direct peer routing", () => {
|
||||
|
||||
@@ -76,6 +76,17 @@ async function loadTelegramMessageContextSessionRuntime(
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveTelegramMessageContextStorePath(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
sessionRuntime?: TelegramMessageContextSessionRuntimeOverrides;
|
||||
}): Promise<string> {
|
||||
const sessionRuntime = await loadTelegramMessageContextSessionRuntime(params.sessionRuntime);
|
||||
return sessionRuntime.resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: params.agentId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildTelegramInboundContextPayload(params: {
|
||||
cfg: OpenClawConfig;
|
||||
primaryCtx: TelegramContext;
|
||||
@@ -232,8 +243,10 @@ export async function buildTelegramInboundContextPayload(params: {
|
||||
? (groupLabel ?? `group:${chatId}`)
|
||||
: buildSenderLabel(msg, senderId || chatId);
|
||||
const sessionRuntime = await loadTelegramMessageContextSessionRuntime(sessionRuntimeOverride);
|
||||
const storePath = sessionRuntime.resolveStorePath(cfg.session?.store, {
|
||||
const storePath = await resolveTelegramMessageContextStorePath({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
sessionRuntime: sessionRuntimeOverride,
|
||||
});
|
||||
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
||||
const previousTimestamp = sessionRuntime.readSessionUpdatedAt({
|
||||
|
||||
@@ -36,7 +36,7 @@ type BuildTelegramMessageContextForTestParams = {
|
||||
cfg?: Record<string, unknown>;
|
||||
accountId?: string;
|
||||
runtime?: BuildTelegramMessageContextParams["runtime"];
|
||||
sessionRuntime?: BuildTelegramMessageContextParams["sessionRuntime"];
|
||||
sessionRuntime?: BuildTelegramMessageContextParams["sessionRuntime"] | null;
|
||||
resolveGroupActivation?: BuildTelegramMessageContextParams["resolveGroupActivation"];
|
||||
resolveGroupRequireMention?: BuildTelegramMessageContextParams["resolveGroupRequireMention"];
|
||||
resolveTelegramGroupConfig?: BuildTelegramMessageContextParams["resolveTelegramGroupConfig"];
|
||||
@@ -59,6 +59,13 @@ export async function buildTelegramMessageContextForTest(
|
||||
> {
|
||||
const { vi } = await loadVitestModule();
|
||||
const buildTelegramMessageContext = await loadBuildTelegramMessageContext();
|
||||
const sessionRuntime =
|
||||
params.sessionRuntime === null
|
||||
? undefined
|
||||
: {
|
||||
...telegramMessageContextSessionRuntimeForTest,
|
||||
...params.sessionRuntime,
|
||||
};
|
||||
return await buildTelegramMessageContext({
|
||||
primaryCtx: {
|
||||
message: {
|
||||
@@ -85,10 +92,7 @@ export async function buildTelegramMessageContextForTest(
|
||||
recordChannelActivity: () => undefined,
|
||||
...params.runtime,
|
||||
},
|
||||
sessionRuntime: {
|
||||
...telegramMessageContextSessionRuntimeForTest,
|
||||
...params.sessionRuntime,
|
||||
},
|
||||
sessionRuntime,
|
||||
account: { accountId: params.accountId ?? "default" } as never,
|
||||
historyLimit: 0,
|
||||
groupHistories: new Map(),
|
||||
|
||||
@@ -11,7 +11,10 @@ import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { withTelegramApiErrorLogging } from "./api-logging.js";
|
||||
import { firstDefined, normalizeAllowFrom, normalizeDmAllowFromWithStore } from "./bot-access.js";
|
||||
import { resolveTelegramInboundBody } from "./bot-message-context.body.js";
|
||||
import { buildTelegramInboundContextPayload } from "./bot-message-context.session.js";
|
||||
import {
|
||||
buildTelegramInboundContextPayload,
|
||||
resolveTelegramMessageContextStorePath,
|
||||
} from "./bot-message-context.session.js";
|
||||
import type { BuildTelegramMessageContextParams } from "./bot-message-context.types.js";
|
||||
import {
|
||||
buildTypingThreadParams,
|
||||
@@ -34,7 +37,7 @@ import {
|
||||
resolveTelegramReactionVariant,
|
||||
resolveTelegramStatusReactionEmojis,
|
||||
} from "./status-reaction-variants.js";
|
||||
import { getTopicName, updateTopicName } from "./topic-name-cache.js";
|
||||
import { getTopicName, resolveTopicNameCachePath, updateTopicName } from "./topic-name-cache.js";
|
||||
|
||||
export type {
|
||||
BuildTelegramMessageContextParams,
|
||||
@@ -149,40 +152,55 @@ export const buildTelegramMessageContext = async ({
|
||||
const resolvedThreadId = threadSpec.scope === "forum" ? threadSpec.id : undefined;
|
||||
const replyThreadId = threadSpec.id;
|
||||
const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined;
|
||||
const topicNameCachePath = resolveTopicNameCachePath(
|
||||
await resolveTelegramMessageContextStorePath({
|
||||
cfg,
|
||||
agentId: account.accountId,
|
||||
sessionRuntime,
|
||||
}),
|
||||
);
|
||||
let topicName: string | undefined;
|
||||
if (isForum && resolvedThreadId != null) {
|
||||
const ftCreated = msg.forum_topic_created;
|
||||
const ftEdited = msg.forum_topic_edited;
|
||||
const ftClosed = msg.forum_topic_closed;
|
||||
const ftReopened = msg.forum_topic_reopened;
|
||||
const topicPatch = ftCreated?.name
|
||||
? {
|
||||
name: ftCreated.name,
|
||||
iconColor: ftCreated.icon_color,
|
||||
iconCustomEmojiId: ftCreated.icon_custom_emoji_id,
|
||||
closed: false,
|
||||
}
|
||||
: ftEdited?.name
|
||||
? {
|
||||
name: ftEdited.name,
|
||||
iconCustomEmojiId: ftEdited.icon_custom_emoji_id,
|
||||
}
|
||||
: ftClosed
|
||||
? { closed: true }
|
||||
: ftReopened
|
||||
? { closed: false }
|
||||
: undefined;
|
||||
|
||||
if (ftCreated?.name) {
|
||||
updateTopicName(chatId, resolvedThreadId, {
|
||||
name: ftCreated.name,
|
||||
iconColor: ftCreated.icon_color,
|
||||
iconCustomEmojiId: ftCreated.icon_custom_emoji_id,
|
||||
closed: false,
|
||||
});
|
||||
} else if (ftEdited?.name) {
|
||||
updateTopicName(chatId, resolvedThreadId, {
|
||||
name: ftEdited.name,
|
||||
iconCustomEmojiId: ftEdited.icon_custom_emoji_id,
|
||||
});
|
||||
} else if (ftClosed) {
|
||||
updateTopicName(chatId, resolvedThreadId, { closed: true });
|
||||
} else if (ftReopened) {
|
||||
updateTopicName(chatId, resolvedThreadId, { closed: false });
|
||||
if (topicPatch) {
|
||||
updateTopicName(chatId, resolvedThreadId, topicPatch, topicNameCachePath);
|
||||
}
|
||||
|
||||
topicName = getTopicName(chatId, resolvedThreadId);
|
||||
topicName = getTopicName(chatId, resolvedThreadId, topicNameCachePath);
|
||||
if (!topicName) {
|
||||
const replyFtCreated = msg.reply_to_message?.forum_topic_created;
|
||||
if (replyFtCreated?.name) {
|
||||
updateTopicName(chatId, resolvedThreadId, {
|
||||
name: replyFtCreated.name,
|
||||
iconColor: replyFtCreated.icon_color,
|
||||
iconCustomEmojiId: replyFtCreated.icon_custom_emoji_id,
|
||||
});
|
||||
updateTopicName(
|
||||
chatId,
|
||||
resolvedThreadId,
|
||||
{
|
||||
name: replyFtCreated.name,
|
||||
iconColor: replyFtCreated.icon_color,
|
||||
iconCustomEmojiId: replyFtCreated.icon_custom_emoji_id,
|
||||
},
|
||||
topicNameCachePath,
|
||||
);
|
||||
topicName = replyFtCreated.name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -811,7 +811,10 @@ export const dispatchTelegramMessage = async ({
|
||||
: undefined,
|
||||
onToolStart: statusReactionController
|
||||
? async (payload) => {
|
||||
await Promise.resolve(statusReactionController.setTool(payload.name ?? "tool"));
|
||||
const toolName = payload.name?.trim();
|
||||
if (toolName) {
|
||||
await statusReactionController.setTool(toolName);
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
onCompactionStart: statusReactionController
|
||||
|
||||
@@ -62,6 +62,63 @@ const TELEGRAM_TEST_TIMINGS = {
|
||||
textFragmentGapMs: 30,
|
||||
} as const;
|
||||
|
||||
type TelegramMiddlewareTestContext = Record<string, unknown>;
|
||||
type TelegramMiddleware = (
|
||||
ctx: TelegramMiddlewareTestContext,
|
||||
next: () => Promise<void>,
|
||||
) => Promise<void> | void;
|
||||
|
||||
function getRegisteredTelegramMiddlewares(): TelegramMiddleware[] {
|
||||
return middlewareUseSpy.mock.calls
|
||||
.map((call) => call[0])
|
||||
.filter((fn): fn is TelegramMiddleware => typeof fn === "function");
|
||||
}
|
||||
|
||||
async function runTelegramMiddlewareChain(params: {
|
||||
ctx: TelegramMiddlewareTestContext;
|
||||
finalHandler: (ctx: TelegramMiddlewareTestContext) => Promise<void>;
|
||||
}): Promise<void> {
|
||||
const middlewares = getRegisteredTelegramMiddlewares();
|
||||
let idx = -1;
|
||||
const dispatch = async (i: number): Promise<void> => {
|
||||
if (i <= idx) {
|
||||
throw new Error("middleware dispatch called multiple times");
|
||||
}
|
||||
idx = i;
|
||||
const fn = middlewares[i];
|
||||
if (!fn) {
|
||||
await params.finalHandler(params.ctx);
|
||||
return;
|
||||
}
|
||||
await fn(params.ctx, async () => dispatch(i + 1));
|
||||
};
|
||||
await dispatch(0);
|
||||
}
|
||||
|
||||
function installPerKeySequentializer(): void {
|
||||
sequentializeSpy.mockImplementationOnce(() => {
|
||||
const lanes = new Map<string, Promise<void>>();
|
||||
return async (ctx: TelegramMiddlewareTestContext, next: () => Promise<void>) => {
|
||||
const key = harness.sequentializeKey?.(ctx) ?? "default";
|
||||
const previous = lanes.get(key) ?? Promise.resolve();
|
||||
const current = previous.then(async () => {
|
||||
await next();
|
||||
});
|
||||
lanes.set(
|
||||
key,
|
||||
current.catch(() => undefined),
|
||||
);
|
||||
try {
|
||||
await current;
|
||||
} finally {
|
||||
if (lanes.get(key) === current) {
|
||||
lanes.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe("createTelegramBot", () => {
|
||||
beforeAll(() => {
|
||||
process.env.TZ = "UTC";
|
||||
@@ -146,6 +203,182 @@ describe("createTelegramBot", () => {
|
||||
expect(harness.sequentializeKey).toBe(getTelegramSequentialKey);
|
||||
});
|
||||
|
||||
it("lets /status bypass a busy Telegram topic lane", async () => {
|
||||
installPerKeySequentializer();
|
||||
loadConfig.mockReturnValue({
|
||||
commands: { native: true },
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const startedBodies: string[] = [];
|
||||
let releaseConversationTurn!: () => void;
|
||||
const conversationGate = new Promise<void>((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}` };
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const messageHandler = getOnHandler("message") as (
|
||||
ctx: TelegramMiddlewareTestContext,
|
||||
) => Promise<void>;
|
||||
const statusHandler = commandSpy.mock.calls.find((call) => call[0] === "status")?.[1] as
|
||||
| ((ctx: TelegramMiddlewareTestContext) => Promise<void>)
|
||||
| undefined;
|
||||
expect(statusHandler).toBeDefined();
|
||||
if (!statusHandler) {
|
||||
return;
|
||||
}
|
||||
|
||||
const busyCtx = {
|
||||
...makeForumGroupMessageCtx({ threadId: 99, text: "hello there" }),
|
||||
message: {
|
||||
...makeForumGroupMessageCtx({ threadId: 99, text: "hello there" }).message,
|
||||
message_id: 101,
|
||||
},
|
||||
update: { update_id: 101 },
|
||||
};
|
||||
const statusCtx = {
|
||||
...makeForumGroupMessageCtx({ threadId: 99, text: "/status" }),
|
||||
message: {
|
||||
...makeForumGroupMessageCtx({ threadId: 99, text: "/status" }).message,
|
||||
message_id: 102,
|
||||
},
|
||||
update: { update_id: 102 },
|
||||
match: "",
|
||||
};
|
||||
|
||||
const busyPromise = runTelegramMiddlewareChain({
|
||||
ctx: busyCtx,
|
||||
finalHandler: messageHandler,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(startedBodies).toHaveLength(1);
|
||||
expect(startedBodies[0]).toContain("hello there");
|
||||
});
|
||||
|
||||
const statusPromise = runTelegramMiddlewareChain({
|
||||
ctx: statusCtx,
|
||||
finalHandler: statusHandler,
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
await statusPromise;
|
||||
|
||||
releaseConversationTurn();
|
||||
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");
|
||||
});
|
||||
|
||||
it("keeps ordinary Telegram messages serialized within the same topic", async () => {
|
||||
installPerKeySequentializer();
|
||||
loadConfig.mockReturnValue({
|
||||
channels: {
|
||||
telegram: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
groups: { "*": { requireMention: false } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const startedBodies: string[] = [];
|
||||
let releaseFirstTurn!: () => void;
|
||||
const firstTurnGate = new Promise<void>((resolve) => {
|
||||
releaseFirstTurn = resolve;
|
||||
});
|
||||
|
||||
replySpy.mockImplementation(async (ctx: MsgContext, opts?: GetReplyOptions) => {
|
||||
await opts?.onReplyStart?.();
|
||||
const body = String(ctx.Body ?? "");
|
||||
startedBodies.push(body);
|
||||
if (body.includes("first message")) {
|
||||
await firstTurnGate;
|
||||
}
|
||||
return { text: `reply:${body}` };
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const messageHandler = getOnHandler("message") as (
|
||||
ctx: TelegramMiddlewareTestContext,
|
||||
) => Promise<void>;
|
||||
|
||||
const firstCtx = {
|
||||
...makeForumGroupMessageCtx({ threadId: 99, text: "first message" }),
|
||||
message: {
|
||||
...makeForumGroupMessageCtx({ threadId: 99, text: "first message" }).message,
|
||||
message_id: 201,
|
||||
},
|
||||
update: { update_id: 201 },
|
||||
};
|
||||
const secondCtx = {
|
||||
...makeForumGroupMessageCtx({ threadId: 99, text: "second message" }),
|
||||
message: {
|
||||
...makeForumGroupMessageCtx({ threadId: 99, text: "second message" }).message,
|
||||
message_id: 202,
|
||||
},
|
||||
update: { update_id: 202 },
|
||||
};
|
||||
|
||||
const firstPromise = runTelegramMiddlewareChain({
|
||||
ctx: firstCtx,
|
||||
finalHandler: messageHandler,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(startedBodies).toHaveLength(1);
|
||||
expect(startedBodies[0]).toContain("first message");
|
||||
});
|
||||
|
||||
const secondPromise = runTelegramMiddlewareChain({
|
||||
ctx: secondCtx,
|
||||
finalHandler: messageHandler,
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
expect(startedBodies).toHaveLength(1);
|
||||
expect(startedBodies[0]).toContain("first message");
|
||||
expect(sendMessageSpy).not.toHaveBeenCalled();
|
||||
|
||||
releaseFirstTurn();
|
||||
await Promise.all([firstPromise, secondPromise]);
|
||||
|
||||
expect(startedBodies).toHaveLength(2);
|
||||
expect(startedBodies[0]).toContain("first message");
|
||||
expect(startedBodies[1]).toContain("second message");
|
||||
const sentBodies = sendMessageSpy.mock.calls.map((call) => String(call[1]));
|
||||
expect(sentBodies[0]).toContain("first message");
|
||||
expect(sentBodies[1]).toContain("second message");
|
||||
});
|
||||
|
||||
it("preserves same-chat reply order when a debounced run is still active", async () => {
|
||||
const DEBOUNCE_MS = 4321;
|
||||
loadConfig.mockReturnValue({
|
||||
|
||||
@@ -332,7 +332,15 @@ describe("resolveMedia getFile retry", () => {
|
||||
it("uses caller-provided fetch impl for file downloads", async () => {
|
||||
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
|
||||
const callerFetch = vi.fn() as unknown as typeof fetch;
|
||||
const dispatcherAttempts = [{ dispatcherPolicy: { mode: "direct" as const } }];
|
||||
const dispatcherAttempts = [
|
||||
{
|
||||
dispatcherPolicy: {
|
||||
mode: "explicit-proxy" as const,
|
||||
proxyUrl: "http://localhost:6152",
|
||||
allowPrivateProxy: true,
|
||||
},
|
||||
},
|
||||
];
|
||||
const callerTransport = {
|
||||
fetch: callerFetch,
|
||||
sourceFetch: callerFetch,
|
||||
@@ -357,6 +365,7 @@ describe("resolveMedia getFile retry", () => {
|
||||
expect.objectContaining({
|
||||
fetchImpl: callerFetch,
|
||||
dispatcherAttempts,
|
||||
trustExplicitProxyDns: true,
|
||||
shouldRetryFetchError: expect.any(Function),
|
||||
readIdleTimeoutMs: 30_000,
|
||||
ssrfPolicy: {
|
||||
|
||||
@@ -157,6 +157,14 @@ function resolveRequiredTelegramTransport(transport?: TelegramTransport): Telegr
|
||||
/** Default idle timeout for Telegram media downloads (30 seconds). */
|
||||
const TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS = 30_000;
|
||||
|
||||
function usesTrustedTelegramExplicitProxy(transport: TelegramTransport): boolean {
|
||||
return (
|
||||
transport.dispatcherAttempts?.some(
|
||||
(attempt) => attempt.dispatcherPolicy?.mode === "explicit-proxy",
|
||||
) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTrustedLocalTelegramRoot(
|
||||
filePath: string,
|
||||
trustedLocalFileRoots?: readonly string[],
|
||||
@@ -225,6 +233,7 @@ async function downloadAndSaveTelegramFile(params: {
|
||||
url,
|
||||
fetchImpl: transport.sourceFetch,
|
||||
dispatcherAttempts: transport.dispatcherAttempts,
|
||||
trustExplicitProxyDns: usesTrustedTelegramExplicitProxy(transport),
|
||||
shouldRetryFetchError: shouldRetryTelegramTransportFallback,
|
||||
filePathHint: params.filePath,
|
||||
maxBytes: params.maxBytes,
|
||||
|
||||
@@ -59,7 +59,39 @@ describe("getTelegramSequentialKey", () => {
|
||||
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/stop" }) },
|
||||
"telegram:123:control",
|
||||
],
|
||||
[{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/status" }) }, "telegram:123"],
|
||||
[
|
||||
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/status" }) },
|
||||
"telegram:123:control",
|
||||
],
|
||||
[
|
||||
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/commands" }) },
|
||||
"telegram:123:control",
|
||||
],
|
||||
[
|
||||
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/help" }) },
|
||||
"telegram:123:control",
|
||||
],
|
||||
[
|
||||
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/tools" }) },
|
||||
"telegram:123:control",
|
||||
],
|
||||
[
|
||||
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/tasks" }) },
|
||||
"telegram:123:control",
|
||||
],
|
||||
[
|
||||
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/context" }) },
|
||||
"telegram:123:control",
|
||||
],
|
||||
[
|
||||
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/whoami" }) },
|
||||
"telegram:123:control",
|
||||
],
|
||||
[
|
||||
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/export-session" }) },
|
||||
"telegram:123",
|
||||
],
|
||||
[{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/export" }) }, "telegram:123"],
|
||||
[
|
||||
{ message: mockMessage({ chat: mockChat({ id: 123 }), text: "/btw what is the time?" }) },
|
||||
"telegram:123:btw:1",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { type Message, type UserFromGetMe } from "@grammyjs/types";
|
||||
import {
|
||||
listChatCommands,
|
||||
maybeResolveTextAlias,
|
||||
normalizeCommandBody,
|
||||
} from "openclaw/plugin-sdk/command-auth";
|
||||
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { isAbortRequestText } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { isBtwRequestText } from "openclaw/plugin-sdk/reply-runtime";
|
||||
@@ -20,6 +25,27 @@ export type TelegramSequentialKeyContext = {
|
||||
};
|
||||
};
|
||||
|
||||
function resolveStatusCommandControlLane(params: {
|
||||
rawText?: string;
|
||||
botUsername?: string;
|
||||
}): boolean {
|
||||
// Only read-only status commands should bypass the per-topic lane. Commands
|
||||
// like /export-session stay on the normal lane because they materialize
|
||||
// session state to disk and should not interleave with an active turn.
|
||||
const normalizedBody = normalizeCommandBody(
|
||||
params.rawText?.trim() ?? "",
|
||||
params.botUsername ? { botUsername: params.botUsername } : undefined,
|
||||
);
|
||||
const alias = maybeResolveTextAlias(normalizedBody);
|
||||
if (!alias) {
|
||||
return false;
|
||||
}
|
||||
const command = listChatCommands().find((entry) =>
|
||||
entry.textAliases.some((candidate) => candidate.trim().toLowerCase() === alias),
|
||||
);
|
||||
return command?.category === "status" && command.key !== "export-session";
|
||||
}
|
||||
|
||||
export function getTelegramSequentialKey(ctx: TelegramSequentialKeyContext): string {
|
||||
const reaction = ctx.update?.message_reaction;
|
||||
if (reaction?.chat?.id) {
|
||||
@@ -43,6 +69,12 @@ export function getTelegramSequentialKey(ctx: TelegramSequentialKeyContext): str
|
||||
}
|
||||
return "telegram:control";
|
||||
}
|
||||
if (resolveStatusCommandControlLane({ rawText, botUsername })) {
|
||||
if (typeof chatId === "number") {
|
||||
return `telegram:${chatId}:control`;
|
||||
}
|
||||
return "telegram:control";
|
||||
}
|
||||
if (isBtwRequestText(rawText, botUsername ? { botUsername } : undefined)) {
|
||||
const messageId = msg?.message_id;
|
||||
if (typeof chatId === "number" && typeof messageId === "number") {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import syncFs from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearTopicNameCache,
|
||||
getTopicEntry,
|
||||
getTopicName,
|
||||
resetTopicNameCacheForTest,
|
||||
topicNameCacheSize,
|
||||
updateTopicName,
|
||||
} from "./topic-name-cache.js";
|
||||
@@ -11,6 +16,7 @@ describe("topic-name-cache", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
clearTopicNameCache();
|
||||
resetTopicNameCacheForTest();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -64,7 +70,6 @@ describe("topic-name-cache", () => {
|
||||
|
||||
it("updates timestamps on write", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-13T22:00:00.000Z"));
|
||||
updateTopicName(-100123, 42, { name: "A" });
|
||||
const t1 = getTopicEntry(-100123, 42)?.updatedAt ?? 0;
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
@@ -89,7 +94,6 @@ describe("topic-name-cache", () => {
|
||||
|
||||
it("refreshes recency on read so active topics survive eviction", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-13T22:00:00.000Z"));
|
||||
updateTopicName(-100000, 1, { name: "Active" });
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
for (let i = 2; i <= 2048; i++) {
|
||||
@@ -100,4 +104,37 @@ describe("topic-name-cache", () => {
|
||||
expect(getTopicName(-100000, 1)).toBe("Active");
|
||||
expect(topicNameCacheSize()).toBe(2048);
|
||||
});
|
||||
|
||||
it("reloads persisted entries from disk", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-topic-cache-"));
|
||||
const persistedPath = path.join(tempDir, "topic-names.json");
|
||||
try {
|
||||
updateTopicName(-100123, 42, { name: "Deployments" }, persistedPath);
|
||||
resetTopicNameCacheForTest();
|
||||
expect(getTopicName(-100123, 42, persistedPath)).toBe("Deployments");
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
resetTopicNameCacheForTest();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps separate in-memory stores for separate persisted paths", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-topic-cache-"));
|
||||
const firstPath = path.join(tempDir, "first-topic-names.json");
|
||||
const secondPath = path.join(tempDir, "second-topic-names.json");
|
||||
try {
|
||||
updateTopicName(-100123, 42, { name: "Deployments" }, firstPath);
|
||||
updateTopicName(-200456, 84, { name: "Incidents" }, secondPath);
|
||||
|
||||
const readFileSpy = vi.spyOn(syncFs, "readFileSync");
|
||||
|
||||
expect(getTopicName(-100123, 42, firstPath)).toBe("Deployments");
|
||||
expect(getTopicName(-200456, 84, secondPath)).toBe("Incidents");
|
||||
expect(readFileSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
resetTopicNameCacheForTest();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
|
||||
const MAX_ENTRIES = 2_048;
|
||||
const TOPIC_NAME_CACHE_STATE_KEY = Symbol.for("openclaw.telegramTopicNameCacheState");
|
||||
const DEFAULT_TOPIC_NAME_CACHE_KEY = "__default__";
|
||||
|
||||
export type TopicEntry = {
|
||||
name: string;
|
||||
@@ -8,27 +14,151 @@ export type TopicEntry = {
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
const cache = new Map<string, TopicEntry>();
|
||||
type TopicNameStore = Map<string, TopicEntry>;
|
||||
|
||||
type TopicNameStoreState = {
|
||||
lastUpdatedAt: number;
|
||||
store: TopicNameStore;
|
||||
};
|
||||
|
||||
type TopicNameCacheState = {
|
||||
stores: Map<string, TopicNameStoreState>;
|
||||
};
|
||||
|
||||
function createTopicNameStore(): TopicNameStore {
|
||||
return new Map<string, TopicEntry>();
|
||||
}
|
||||
|
||||
function createTopicNameStoreState(): TopicNameStoreState {
|
||||
return {
|
||||
lastUpdatedAt: 0,
|
||||
store: createTopicNameStore(),
|
||||
};
|
||||
}
|
||||
|
||||
function getTopicNameCacheState(): TopicNameCacheState {
|
||||
const globalStore = globalThis as Record<PropertyKey, unknown>;
|
||||
const existing = globalStore[TOPIC_NAME_CACHE_STATE_KEY] as TopicNameCacheState | undefined;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const state: TopicNameCacheState = { stores: new Map() };
|
||||
globalStore[TOPIC_NAME_CACHE_STATE_KEY] = state;
|
||||
return state;
|
||||
}
|
||||
|
||||
function cacheKey(chatId: number | string, threadId: number | string): string {
|
||||
return `${chatId}:${threadId}`;
|
||||
}
|
||||
|
||||
function evictOldest(): void {
|
||||
while (cache.size > MAX_ENTRIES) {
|
||||
const oldestKey = cache.keys().next().value;
|
||||
if (!oldestKey) {
|
||||
return;
|
||||
}
|
||||
cache.delete(oldestKey);
|
||||
export function resolveTopicNameCachePath(storePath: string): string {
|
||||
return `${storePath}.telegram-topic-names.json`;
|
||||
}
|
||||
|
||||
function evictOldest(store: TopicNameStore): void {
|
||||
if (store.size <= MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
let oldestKey: string | undefined;
|
||||
let oldestTime = Infinity;
|
||||
for (const [key, entry] of store) {
|
||||
if (entry.updatedAt < oldestTime) {
|
||||
oldestTime = entry.updatedAt;
|
||||
oldestKey = key;
|
||||
}
|
||||
}
|
||||
if (oldestKey) {
|
||||
store.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function isTopicEntry(value: unknown): value is TopicEntry {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const entry = value as Partial<TopicEntry>;
|
||||
return (
|
||||
typeof entry.name === "string" &&
|
||||
entry.name.length > 0 &&
|
||||
typeof entry.updatedAt === "number" &&
|
||||
Number.isFinite(entry.updatedAt)
|
||||
);
|
||||
}
|
||||
|
||||
function readPersistedTopicNames(persistedPath: string): TopicNameStore {
|
||||
if (!fs.existsSync(persistedPath)) {
|
||||
return createTopicNameStore();
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(persistedPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
const entries = Object.entries(parsed)
|
||||
.filter((entry): entry is [string, TopicEntry] => isTopicEntry(entry[1]))
|
||||
.toSorted(([, left], [, right]) => right.updatedAt - left.updatedAt)
|
||||
.slice(0, MAX_ENTRIES);
|
||||
return new Map(entries);
|
||||
} catch (error) {
|
||||
logVerbose(`telegram: failed to read topic-name cache: ${String(error)}`);
|
||||
return createTopicNameStore();
|
||||
}
|
||||
}
|
||||
|
||||
function getTopicStoreState(persistedPath?: string): TopicNameStoreState {
|
||||
const state = getTopicNameCacheState();
|
||||
const stateKey = persistedPath ?? DEFAULT_TOPIC_NAME_CACHE_KEY;
|
||||
const existing = state.stores.get(stateKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const next = persistedPath
|
||||
? {
|
||||
lastUpdatedAt: 0,
|
||||
store: readPersistedTopicNames(persistedPath),
|
||||
}
|
||||
: createTopicNameStoreState();
|
||||
next.lastUpdatedAt = Math.max(0, ...Array.from(next.store.values(), (entry) => entry.updatedAt));
|
||||
state.stores.set(stateKey, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function getTopicStore(persistedPath?: string): TopicNameStore {
|
||||
return getTopicStoreState(persistedPath).store;
|
||||
}
|
||||
|
||||
function nextUpdatedAt(persistedPath?: string): number {
|
||||
const state = getTopicStoreState(persistedPath);
|
||||
const now = Date.now();
|
||||
state.lastUpdatedAt = now > state.lastUpdatedAt ? now : state.lastUpdatedAt + 1;
|
||||
return state.lastUpdatedAt;
|
||||
}
|
||||
|
||||
function removeTopicStore(persistedPath?: string): void {
|
||||
const state = getTopicNameCacheState();
|
||||
const stateKey = persistedPath ?? DEFAULT_TOPIC_NAME_CACHE_KEY;
|
||||
if (persistedPath) {
|
||||
fs.rmSync(persistedPath, { force: true });
|
||||
}
|
||||
state.stores.delete(stateKey);
|
||||
}
|
||||
|
||||
function persistTopicStore(persistedPath: string, store: TopicNameStore): void {
|
||||
if (store.size === 0) {
|
||||
fs.rmSync(persistedPath, { force: true });
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(persistedPath), { recursive: true });
|
||||
const tempPath = `${persistedPath}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(Object.fromEntries(store)), "utf-8");
|
||||
fs.renameSync(tempPath, persistedPath);
|
||||
}
|
||||
|
||||
export function updateTopicName(
|
||||
chatId: number | string,
|
||||
threadId: number | string,
|
||||
patch: Partial<Omit<TopicEntry, "updatedAt">>,
|
||||
persistedPath?: string,
|
||||
): void {
|
||||
const cache = getTopicStore(persistedPath);
|
||||
const key = cacheKey(chatId, threadId);
|
||||
const existing = cache.get(key);
|
||||
const merged: TopicEntry = {
|
||||
@@ -36,45 +166,53 @@ export function updateTopicName(
|
||||
iconColor: patch.iconColor ?? existing?.iconColor,
|
||||
iconCustomEmojiId: patch.iconCustomEmojiId ?? existing?.iconCustomEmojiId,
|
||||
closed: patch.closed ?? existing?.closed,
|
||||
updatedAt: Date.now(),
|
||||
updatedAt: nextUpdatedAt(persistedPath),
|
||||
};
|
||||
if (!merged.name) {
|
||||
return;
|
||||
}
|
||||
cache.delete(key);
|
||||
cache.set(key, merged);
|
||||
evictOldest();
|
||||
evictOldest(cache);
|
||||
if (persistedPath) {
|
||||
try {
|
||||
persistTopicStore(persistedPath, cache);
|
||||
} catch (error) {
|
||||
logVerbose(`telegram: failed to persist topic-name cache: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getTopicName(
|
||||
chatId: number | string,
|
||||
threadId: number | string,
|
||||
persistedPath?: string,
|
||||
): string | undefined {
|
||||
const key = cacheKey(chatId, threadId);
|
||||
const entry = cache.get(key);
|
||||
const entry = getTopicStore(persistedPath).get(cacheKey(chatId, threadId));
|
||||
if (entry) {
|
||||
const refreshedEntry: TopicEntry = {
|
||||
...entry,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
cache.delete(key);
|
||||
cache.set(key, refreshedEntry);
|
||||
return refreshedEntry.name;
|
||||
entry.updatedAt = nextUpdatedAt(persistedPath);
|
||||
}
|
||||
return undefined;
|
||||
return entry?.name;
|
||||
}
|
||||
|
||||
export function getTopicEntry(
|
||||
chatId: number | string,
|
||||
threadId: number | string,
|
||||
persistedPath?: string,
|
||||
): TopicEntry | undefined {
|
||||
return cache.get(cacheKey(chatId, threadId));
|
||||
return getTopicStore(persistedPath).get(cacheKey(chatId, threadId));
|
||||
}
|
||||
|
||||
export function clearTopicNameCache(): void {
|
||||
cache.clear();
|
||||
const state = getTopicNameCacheState();
|
||||
for (const stateKey of state.stores.keys()) {
|
||||
removeTopicStore(stateKey === DEFAULT_TOPIC_NAME_CACHE_KEY ? undefined : stateKey);
|
||||
}
|
||||
}
|
||||
|
||||
export function topicNameCacheSize(): number {
|
||||
return cache.size;
|
||||
return getTopicStore().size;
|
||||
}
|
||||
|
||||
export function resetTopicNameCacheForTest(): void {
|
||||
getTopicNameCacheState().stores.clear();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveOpenClawAgentDir } from "../src/agents/agent-paths.js";
|
||||
import { collectProviderApiKeys } from "../src/agents/live-auth-keys.js";
|
||||
import { isModelNotFoundErrorMessage } from "../src/agents/live-model-errors.js";
|
||||
import { isLiveProfileKeyModeEnabled, isLiveTestEnabled } from "../src/agents/live-test-helpers.js";
|
||||
import { resolveApiKeyForProvider } from "../src/agents/model-auth.js";
|
||||
import {
|
||||
isAuthErrorMessage,
|
||||
isBillingErrorMessage,
|
||||
isOverloadedErrorMessage,
|
||||
isServerErrorMessage,
|
||||
isTimeoutErrorMessage,
|
||||
} from "../src/agents/pi-embedded-helpers/failover-matches.js";
|
||||
import { loadConfig, type OpenClawConfig } from "../src/config/config.js";
|
||||
import { isTruthyEnvValue } from "../src/infra/env.js";
|
||||
import { getShellEnvAppliedKeys, loadShellEnvFallback } from "../src/infra/shell-env.js";
|
||||
@@ -149,6 +157,29 @@ function maybeLoadShellEnvForVideoProviders(providerIds: string[]): void {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveLiveVideoSkipReason(message: string): string | null {
|
||||
if (isAuthErrorMessage(message)) {
|
||||
return "auth drift";
|
||||
}
|
||||
if (isModelNotFoundErrorMessage(message)) {
|
||||
return "model drift";
|
||||
}
|
||||
if (isBillingErrorMessage(message)) {
|
||||
return "billing drift";
|
||||
}
|
||||
if (
|
||||
isTimeoutErrorMessage(message) ||
|
||||
/did not finish in time/i.test(message) ||
|
||||
/last status:\s*in_progress/i.test(message)
|
||||
) {
|
||||
return "provider timeout";
|
||||
}
|
||||
if (isOverloadedErrorMessage(message) || isServerErrorMessage(message)) {
|
||||
return "provider outage";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expectBufferedVideo(
|
||||
video: { buffer?: Buffer; mimeType: string; fileName?: string } | undefined,
|
||||
): { buffer: Buffer; mimeType: string; fileName?: string } {
|
||||
@@ -162,225 +193,241 @@ function expectBufferedVideo(
|
||||
return { buffer, mimeType, fileName };
|
||||
}
|
||||
|
||||
describeLive("video generation provider live", () => {
|
||||
it(
|
||||
"covers declared video-generation modes with shell/profile auth",
|
||||
async () => {
|
||||
const cfg = withPluginsEnabled(loadConfig());
|
||||
const configuredModels = resolveConfiguredLiveVideoModels(cfg);
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
const attempted: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
const failures: string[] = [];
|
||||
async function runLiveVideoProviderCase(testCase: LiveProviderCase): Promise<void> {
|
||||
const cfg = withPluginsEnabled(loadConfig());
|
||||
const configuredModels = resolveConfiguredLiveVideoModels(cfg);
|
||||
const agentDir = resolveOpenClawAgentDir();
|
||||
const attempted: string[] = [];
|
||||
const skipped: string[] = [];
|
||||
const failures: string[] = [];
|
||||
|
||||
maybeLoadShellEnvForVideoProviders(CASES.map((entry) => entry.providerId));
|
||||
maybeLoadShellEnvForVideoProviders([testCase.providerId]);
|
||||
|
||||
for (const testCase of CASES) {
|
||||
const modelRef =
|
||||
envModelMap.get(testCase.providerId) ??
|
||||
configuredModels.get(testCase.providerId) ??
|
||||
DEFAULT_LIVE_VIDEO_MODELS[testCase.providerId];
|
||||
if (!modelRef) {
|
||||
skipped.push(`${testCase.providerId}: no model configured`);
|
||||
continue;
|
||||
}
|
||||
const modelRef =
|
||||
envModelMap.get(testCase.providerId) ??
|
||||
configuredModels.get(testCase.providerId) ??
|
||||
DEFAULT_LIVE_VIDEO_MODELS[testCase.providerId];
|
||||
if (!modelRef) {
|
||||
skipped.push(`${testCase.providerId}: no model configured`);
|
||||
console.log(
|
||||
`[live:video-generation] provider=${testCase.providerId} attempted=none skipped=${skipped.join(", ")} failures=none shellEnv=${getShellEnvAppliedKeys().join(", ") || "none"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasLiveKeys = collectProviderApiKeys(testCase.providerId).length > 0;
|
||||
const authStore = resolveLiveVideoAuthStore({
|
||||
requireProfileKeys: REQUIRE_PROFILE_KEYS,
|
||||
hasLiveKeys,
|
||||
const hasLiveKeys = collectProviderApiKeys(testCase.providerId).length > 0;
|
||||
const authStore = resolveLiveVideoAuthStore({
|
||||
requireProfileKeys: REQUIRE_PROFILE_KEYS,
|
||||
hasLiveKeys,
|
||||
});
|
||||
let authLabel = "unresolved";
|
||||
try {
|
||||
const auth = await resolveApiKeyForProvider({
|
||||
provider: testCase.providerId,
|
||||
cfg,
|
||||
agentDir,
|
||||
store: authStore,
|
||||
});
|
||||
authLabel = `${auth.source} ${redactLiveApiKey(auth.apiKey)}`;
|
||||
} catch {
|
||||
skipped.push(`${testCase.providerId}: no usable auth`);
|
||||
console.log(
|
||||
`[live:video-generation] provider=${testCase.providerId} attempted=none skipped=${skipped.join(", ")} failures=none shellEnv=${getShellEnvAppliedKeys().join(", ") || "none"}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { videoProviders } = await registerProviderPlugin({
|
||||
plugin: testCase.plugin,
|
||||
id: testCase.pluginId,
|
||||
name: testCase.pluginName,
|
||||
});
|
||||
const provider = requireRegisteredProvider(videoProviders, testCase.providerId, "video provider");
|
||||
const providerModel = resolveProviderModelForLiveTest(testCase.providerId, modelRef);
|
||||
const generateCaps = provider.capabilities.generate;
|
||||
const imageToVideoCaps = provider.capabilities.imageToVideo;
|
||||
const videoToVideoCaps = provider.capabilities.videoToVideo;
|
||||
const durationSeconds = Math.min(generateCaps?.maxDurationSeconds ?? 3, 3);
|
||||
const liveResolution = resolveLiveVideoResolution({
|
||||
providerId: testCase.providerId,
|
||||
modelRef,
|
||||
});
|
||||
const liveSize = testCase.providerId === "openai" ? "1280x720" : undefined;
|
||||
const logPrefix = `[live:video-generation] provider=${testCase.providerId} model=${providerModel}`;
|
||||
let generatedVideo = null as {
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
fileName?: string;
|
||||
} | null;
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
console.error(`${logPrefix} mode=generate start auth=${authLabel}`);
|
||||
const result = await provider.generateVideo({
|
||||
provider: testCase.providerId,
|
||||
model: providerModel,
|
||||
prompt: "A tiny paper diorama city at sunrise with slow cinematic camera motion and no text.",
|
||||
cfg,
|
||||
agentDir,
|
||||
authStore,
|
||||
durationSeconds,
|
||||
...(generateCaps?.supportsSize && liveSize ? { size: liveSize } : {}),
|
||||
...(generateCaps?.supportsAspectRatio ? { aspectRatio: "16:9" } : {}),
|
||||
...(generateCaps?.supportsResolution ? { resolution: liveResolution } : {}),
|
||||
...(generateCaps?.supportsAudio ? { audio: false } : {}),
|
||||
...(generateCaps?.supportsWatermark ? { watermark: false } : {}),
|
||||
});
|
||||
|
||||
expect(result.videos.length).toBeGreaterThan(0);
|
||||
generatedVideo = expectBufferedVideo(result.videos[0]);
|
||||
attempted.push(`${testCase.providerId}:generate:${providerModel} (${authLabel})`);
|
||||
console.error(
|
||||
`${logPrefix} mode=generate done ms=${Date.now() - startedAt} videos=${result.videos.length}`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const skipReason = resolveLiveVideoSkipReason(message);
|
||||
if (skipReason) {
|
||||
skipped.push(`${testCase.providerId}:generate (${authLabel}): ${skipReason}`);
|
||||
console.error(`${logPrefix} mode=generate skip (${skipReason}) error=${message}`);
|
||||
} else {
|
||||
failures.push(`${testCase.providerId}:generate (${authLabel}): ${message}`);
|
||||
console.error(`${logPrefix} mode=generate failed error=${message}`);
|
||||
}
|
||||
console.log(
|
||||
`[live:video-generation] provider=${testCase.providerId} attempted=${attempted.join(", ") || "none"} skipped=${skipped.join(", ") || "none"} failures=${failures.join(" | ") || "none"} shellEnv=${getShellEnvAppliedKeys().join(", ") || "none"}`,
|
||||
);
|
||||
expect(failures).toEqual([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (imageToVideoCaps?.enabled) {
|
||||
if (
|
||||
!canRunBufferBackedImageToVideoLiveLane({
|
||||
providerId: testCase.providerId,
|
||||
modelRef,
|
||||
})
|
||||
) {
|
||||
skipped.push(
|
||||
`${testCase.providerId}:imageToVideo requires remote URL or model-specific input`,
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
console.error(`${logPrefix} mode=imageToVideo start auth=${authLabel}`);
|
||||
const referenceImage =
|
||||
testCase.providerId === "openai"
|
||||
? createEditReferencePng({ width: 1280, height: 720 })
|
||||
: createEditReferencePng();
|
||||
const result = await provider.generateVideo({
|
||||
provider: testCase.providerId,
|
||||
model: providerModel,
|
||||
prompt:
|
||||
"Animate the reference art with subtle parallax motion and drifting camera movement.",
|
||||
cfg,
|
||||
agentDir,
|
||||
authStore,
|
||||
durationSeconds,
|
||||
...(imageToVideoCaps.supportsSize && liveSize ? { size: liveSize } : {}),
|
||||
inputImages: [
|
||||
{
|
||||
buffer: referenceImage,
|
||||
mimeType: "image/png",
|
||||
fileName: "reference.png",
|
||||
},
|
||||
],
|
||||
...(imageToVideoCaps.supportsAspectRatio ? { aspectRatio: "16:9" } : {}),
|
||||
...(imageToVideoCaps.supportsResolution ? { resolution: liveResolution } : {}),
|
||||
...(imageToVideoCaps.supportsAudio ? { audio: false } : {}),
|
||||
...(imageToVideoCaps.supportsWatermark ? { watermark: false } : {}),
|
||||
});
|
||||
let authLabel = "unresolved";
|
||||
try {
|
||||
const auth = await resolveApiKeyForProvider({
|
||||
provider: testCase.providerId,
|
||||
cfg,
|
||||
agentDir,
|
||||
store: authStore,
|
||||
});
|
||||
authLabel = `${auth.source} ${redactLiveApiKey(auth.apiKey)}`;
|
||||
} catch {
|
||||
skipped.push(`${testCase.providerId}: no usable auth`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { videoProviders } = await registerProviderPlugin({
|
||||
plugin: testCase.plugin,
|
||||
id: testCase.pluginId,
|
||||
name: testCase.pluginName,
|
||||
});
|
||||
const provider = requireRegisteredProvider(
|
||||
videoProviders,
|
||||
testCase.providerId,
|
||||
"video provider",
|
||||
expect(result.videos.length).toBeGreaterThan(0);
|
||||
expectBufferedVideo(result.videos[0]);
|
||||
attempted.push(`${testCase.providerId}:imageToVideo:${providerModel} (${authLabel})`);
|
||||
console.error(
|
||||
`${logPrefix} mode=imageToVideo done ms=${Date.now() - startedAt} videos=${result.videos.length}`,
|
||||
);
|
||||
const providerModel = resolveProviderModelForLiveTest(testCase.providerId, modelRef);
|
||||
const generateCaps = provider.capabilities.generate;
|
||||
const imageToVideoCaps = provider.capabilities.imageToVideo;
|
||||
const videoToVideoCaps = provider.capabilities.videoToVideo;
|
||||
const durationSeconds = Math.min(generateCaps?.maxDurationSeconds ?? 3, 3);
|
||||
const liveResolution = resolveLiveVideoResolution({
|
||||
providerId: testCase.providerId,
|
||||
modelRef,
|
||||
});
|
||||
const liveSize = testCase.providerId === "openai" ? "1280x720" : undefined;
|
||||
const logPrefix = `[live:video-generation] provider=${testCase.providerId} model=${providerModel}`;
|
||||
let generatedVideo = null as {
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
fileName?: string;
|
||||
} | null;
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
console.error(`${logPrefix} mode=generate start auth=${authLabel}`);
|
||||
const result = await provider.generateVideo({
|
||||
provider: testCase.providerId,
|
||||
model: providerModel,
|
||||
prompt:
|
||||
"A tiny paper diorama city at sunrise with slow cinematic camera motion and no text.",
|
||||
cfg,
|
||||
agentDir,
|
||||
authStore,
|
||||
durationSeconds,
|
||||
...(generateCaps?.supportsSize && liveSize ? { size: liveSize } : {}),
|
||||
...(generateCaps?.supportsAspectRatio ? { aspectRatio: "16:9" } : {}),
|
||||
...(generateCaps?.supportsResolution ? { resolution: liveResolution } : {}),
|
||||
...(generateCaps?.supportsAudio ? { audio: false } : {}),
|
||||
...(generateCaps?.supportsWatermark ? { watermark: false } : {}),
|
||||
});
|
||||
|
||||
expect(result.videos.length).toBeGreaterThan(0);
|
||||
generatedVideo = expectBufferedVideo(result.videos[0]);
|
||||
attempted.push(`${testCase.providerId}:generate:${providerModel} (${authLabel})`);
|
||||
console.error(
|
||||
`${logPrefix} mode=generate done ms=${Date.now() - startedAt} videos=${result.videos.length}`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
failures.push(`${testCase.providerId}:generate (${authLabel}): ${message}`);
|
||||
console.error(`${logPrefix} mode=generate failed error=${message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!imageToVideoCaps?.enabled) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!canRunBufferBackedImageToVideoLiveLane({
|
||||
providerId: testCase.providerId,
|
||||
modelRef,
|
||||
})
|
||||
) {
|
||||
skipped.push(
|
||||
`${testCase.providerId}:imageToVideo requires remote URL or model-specific input`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
console.error(`${logPrefix} mode=imageToVideo start auth=${authLabel}`);
|
||||
const referenceImage =
|
||||
testCase.providerId === "openai"
|
||||
? createEditReferencePng({ width: 1280, height: 720 })
|
||||
: createEditReferencePng();
|
||||
const result = await provider.generateVideo({
|
||||
provider: testCase.providerId,
|
||||
model: providerModel,
|
||||
prompt:
|
||||
"Animate the reference art with subtle parallax motion and drifting camera movement.",
|
||||
cfg,
|
||||
agentDir,
|
||||
authStore,
|
||||
durationSeconds,
|
||||
...(imageToVideoCaps.supportsSize && liveSize ? { size: liveSize } : {}),
|
||||
inputImages: [
|
||||
{
|
||||
buffer: referenceImage,
|
||||
mimeType: "image/png",
|
||||
fileName: "reference.png",
|
||||
},
|
||||
],
|
||||
...(imageToVideoCaps.supportsAspectRatio ? { aspectRatio: "16:9" } : {}),
|
||||
...(imageToVideoCaps.supportsResolution ? { resolution: liveResolution } : {}),
|
||||
...(imageToVideoCaps.supportsAudio ? { audio: false } : {}),
|
||||
...(imageToVideoCaps.supportsWatermark ? { watermark: false } : {}),
|
||||
});
|
||||
|
||||
expect(result.videos.length).toBeGreaterThan(0);
|
||||
expectBufferedVideo(result.videos[0]);
|
||||
attempted.push(`${testCase.providerId}:imageToVideo:${providerModel} (${authLabel})`);
|
||||
console.error(
|
||||
`${logPrefix} mode=imageToVideo done ms=${Date.now() - startedAt} videos=${result.videos.length}`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const skipReason = resolveLiveVideoSkipReason(message);
|
||||
if (skipReason) {
|
||||
skipped.push(`${testCase.providerId}:imageToVideo (${authLabel}): ${skipReason}`);
|
||||
console.error(`${logPrefix} mode=imageToVideo skip (${skipReason}) error=${message}`);
|
||||
} else {
|
||||
failures.push(`${testCase.providerId}:imageToVideo (${authLabel}): ${message}`);
|
||||
console.error(`${logPrefix} mode=imageToVideo failed error=${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!videoToVideoCaps?.enabled) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!canRunBufferBackedVideoToVideoLiveLane({
|
||||
providerId: testCase.providerId,
|
||||
modelRef,
|
||||
})
|
||||
) {
|
||||
skipped.push(
|
||||
`${testCase.providerId}:videoToVideo requires remote URL or model-specific input`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!generatedVideo?.buffer) {
|
||||
skipped.push(`${testCase.providerId}:videoToVideo missing generated seed video`);
|
||||
continue;
|
||||
}
|
||||
if (videoToVideoCaps?.enabled) {
|
||||
if (
|
||||
!canRunBufferBackedVideoToVideoLiveLane({
|
||||
providerId: testCase.providerId,
|
||||
modelRef,
|
||||
})
|
||||
) {
|
||||
skipped.push(
|
||||
`${testCase.providerId}:videoToVideo requires remote URL or model-specific input`,
|
||||
);
|
||||
} else if (!generatedVideo?.buffer) {
|
||||
skipped.push(`${testCase.providerId}:videoToVideo missing generated seed video`);
|
||||
} else {
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
console.error(`${logPrefix} mode=videoToVideo start auth=${authLabel}`);
|
||||
const result = await provider.generateVideo({
|
||||
provider: testCase.providerId,
|
||||
model: providerModel,
|
||||
prompt: "Rework the reference clip into a brighter, steadier cinematic continuation.",
|
||||
cfg,
|
||||
agentDir,
|
||||
authStore,
|
||||
durationSeconds: Math.min(videoToVideoCaps.maxDurationSeconds ?? durationSeconds, 3),
|
||||
inputVideos: [generatedVideo],
|
||||
...(videoToVideoCaps.supportsAspectRatio ? { aspectRatio: "16:9" } : {}),
|
||||
...(videoToVideoCaps.supportsResolution ? { resolution: liveResolution } : {}),
|
||||
...(videoToVideoCaps.supportsAudio ? { audio: false } : {}),
|
||||
...(videoToVideoCaps.supportsWatermark ? { watermark: false } : {}),
|
||||
});
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
console.error(`${logPrefix} mode=videoToVideo start auth=${authLabel}`);
|
||||
const result = await provider.generateVideo({
|
||||
provider: testCase.providerId,
|
||||
model: providerModel,
|
||||
prompt: "Rework the reference clip into a brighter, steadier cinematic continuation.",
|
||||
cfg,
|
||||
agentDir,
|
||||
authStore,
|
||||
durationSeconds: Math.min(videoToVideoCaps.maxDurationSeconds ?? durationSeconds, 3),
|
||||
inputVideos: [generatedVideo],
|
||||
...(videoToVideoCaps.supportsAspectRatio ? { aspectRatio: "16:9" } : {}),
|
||||
...(videoToVideoCaps.supportsResolution ? { resolution: liveResolution } : {}),
|
||||
...(videoToVideoCaps.supportsAudio ? { audio: false } : {}),
|
||||
...(videoToVideoCaps.supportsWatermark ? { watermark: false } : {}),
|
||||
});
|
||||
|
||||
expect(result.videos.length).toBeGreaterThan(0);
|
||||
expectBufferedVideo(result.videos[0]);
|
||||
attempted.push(`${testCase.providerId}:videoToVideo:${providerModel} (${authLabel})`);
|
||||
console.error(
|
||||
`${logPrefix} mode=videoToVideo done ms=${Date.now() - startedAt} videos=${result.videos.length}`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
expect(result.videos.length).toBeGreaterThan(0);
|
||||
expectBufferedVideo(result.videos[0]);
|
||||
attempted.push(`${testCase.providerId}:videoToVideo:${providerModel} (${authLabel})`);
|
||||
console.error(
|
||||
`${logPrefix} mode=videoToVideo done ms=${Date.now() - startedAt} videos=${result.videos.length}`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const skipReason = resolveLiveVideoSkipReason(message);
|
||||
if (skipReason) {
|
||||
skipped.push(`${testCase.providerId}:videoToVideo (${authLabel}): ${skipReason}`);
|
||||
console.error(`${logPrefix} mode=videoToVideo skip (${skipReason}) error=${message}`);
|
||||
} else {
|
||||
failures.push(`${testCase.providerId}:videoToVideo (${authLabel}): ${message}`);
|
||||
console.error(`${logPrefix} mode=videoToVideo failed error=${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[live:video-generation] attempted=${attempted.join(", ") || "none"} skipped=${skipped.join(", ") || "none"} failures=${failures.join(" | ") || "none"} shellEnv=${getShellEnvAppliedKeys().join(", ") || "none"}`,
|
||||
);
|
||||
|
||||
if (attempted.length === 0) {
|
||||
expect(failures).toEqual([]);
|
||||
console.warn("[live:video-generation] no provider had usable auth; skipping assertions");
|
||||
return;
|
||||
}
|
||||
expect(failures).toEqual([]);
|
||||
},
|
||||
15 * 60_000,
|
||||
console.log(
|
||||
`[live:video-generation] provider=${testCase.providerId} attempted=${attempted.join(", ") || "none"} skipped=${skipped.join(", ") || "none"} failures=${failures.join(" | ") || "none"} shellEnv=${getShellEnvAppliedKeys().join(", ") || "none"}`,
|
||||
);
|
||||
expect(failures).toEqual([]);
|
||||
}
|
||||
|
||||
describeLive("video generation provider live", () => {
|
||||
for (const testCase of CASES) {
|
||||
// One provider per test keeps cumulative suite runtime from tripping a single timeout cap.
|
||||
it(
|
||||
`covers declared video-generation modes with shell/profile auth (${testCase.providerId})`,
|
||||
async () => {
|
||||
await runLiveVideoProviderCase(testCase);
|
||||
},
|
||||
15 * 60_000,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -46,6 +46,10 @@ function shouldClearSocketRefAfterSendFailure(err: unknown): boolean {
|
||||
return /closed|reset|disconnect|no active socket/i.test(formatError(err));
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: string | undefined): value is string {
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
export type MonitorWebInboxOptions = {
|
||||
verbose: boolean;
|
||||
accountId: string;
|
||||
@@ -132,11 +136,7 @@ export async function attachWebInboxToSocket(
|
||||
error?: unknown,
|
||||
): Promise<void> => {
|
||||
const dedupeKeys = [
|
||||
...new Set(
|
||||
entries
|
||||
.map((entry) => entry.dedupeKey)
|
||||
.filter((dedupeKey): dedupeKey is string => Boolean(dedupeKey)),
|
||||
),
|
||||
...new Set(entries.map((entry) => entry.dedupeKey).filter(isNonEmptyString)),
|
||||
];
|
||||
if (dedupeKeys.length === 0) {
|
||||
return;
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw",
|
||||
"version": "2026.4.14-beta.1",
|
||||
"version": "2026.4.14",
|
||||
"description": "Multi-channel AI gateway with extensible messaging integrations",
|
||||
"keywords": [],
|
||||
"homepage": "https://github.com/openclaw/openclaw#readme",
|
||||
@@ -482,6 +482,10 @@
|
||||
"types": "./dist/plugin-sdk/command-status.d.ts",
|
||||
"default": "./dist/plugin-sdk/command-status.js"
|
||||
},
|
||||
"./plugin-sdk/command-status-runtime": {
|
||||
"types": "./dist/plugin-sdk/command-status-runtime.d.ts",
|
||||
"default": "./dist/plugin-sdk/command-status-runtime.js"
|
||||
},
|
||||
"./plugin-sdk/command-detection": {
|
||||
"types": "./dist/plugin-sdk/command-detection.d.ts",
|
||||
"default": "./dist/plugin-sdk/command-detection.js"
|
||||
|
||||
@@ -207,7 +207,7 @@ steps:
|
||||
args:
|
||||
- lambda:
|
||||
async: true
|
||||
expr: "await (async () => { const store = await readRawQaSessionStore(env); const entry = store[activeSessionKey]; if (!entry || !Array.isArray(entry.pluginDebugEntries)) return undefined; return entry.pluginDebugEntries.some((pluginEntry) => pluginEntry?.pluginId === 'active-memory' && Array.isArray(pluginEntry.lines) && pluginEntry.lines.some((line) => line.includes('Active Memory: ok'))) ? entry : undefined; })()"
|
||||
expr: "await (async () => { const store = await readRawQaSessionStore(env); const entry = store[activeSessionKey]; if (!entry || !Array.isArray(entry.pluginDebugEntries)) return undefined; return entry.pluginDebugEntries.some((pluginEntry) => pluginEntry?.pluginId === 'active-memory' && Array.isArray(pluginEntry.lines) && pluginEntry.lines.some((line) => line.includes('Active Memory: status=ok'))) ? entry : undefined; })()"
|
||||
- 10000
|
||||
- if:
|
||||
expr: "Boolean(env.mock)"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# Empty-response recovery after replay-safe read
|
||||
|
||||
```yaml qa-scenario
|
||||
id: empty-response-recovery-replay-safe-read
|
||||
title: Empty-response recovery after replay-safe read
|
||||
surface: runtime
|
||||
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.
|
||||
- The agent performs a replay-safe read before the empty response.
|
||||
- The runtime injects the visible-answer continuation instruction after the empty turn.
|
||||
- The final visible reply contains the exact recovery marker.
|
||||
docsRefs:
|
||||
- docs/help/testing.md
|
||||
codeRefs:
|
||||
- extensions/qa-lab/src/mock-openai-server.ts
|
||||
- src/agents/pi-embedded-runner/run/incomplete-turn.ts
|
||||
execution:
|
||||
kind: flow
|
||||
summary: Verify empty OpenAI turns recover after a replay-safe read.
|
||||
config:
|
||||
requiredProvider: mock-openai
|
||||
promptSnippet: Empty response continuation QA check
|
||||
prompt: "Empty response continuation QA check: read QA_KICKOFF_TASK.md, then answer with exactly EMPTY-RECOVERED-OK."
|
||||
expectedReply: EMPTY-RECOVERED-OK
|
||||
retryNeedle: The previous attempt did not produce a user-visible answer.
|
||||
```
|
||||
|
||||
```yaml qa-flow
|
||||
steps:
|
||||
- name: retries an empty replay-safe read into a visible answer
|
||||
actions:
|
||||
- assert:
|
||||
expr: "env.providerMode === 'mock-openai'"
|
||||
message: this seeded scenario is mock-openai only
|
||||
- call: waitForGatewayHealthy
|
||||
args:
|
||||
- ref: env
|
||||
- 60000
|
||||
- call: reset
|
||||
- set: requestCountBefore
|
||||
value:
|
||||
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length : 0"
|
||||
- set: sessionKey
|
||||
value:
|
||||
expr: "`agent:qa:empty-response-recovery:${randomUUID().slice(0, 8)}`"
|
||||
- call: runAgentPrompt
|
||||
args:
|
||||
- ref: env
|
||||
- sessionKey:
|
||||
ref: sessionKey
|
||||
message:
|
||||
expr: config.prompt
|
||||
timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 45000)
|
||||
- call: waitForOutboundMessage
|
||||
saveAs: outbound
|
||||
args:
|
||||
- ref: state
|
||||
- lambda:
|
||||
params: [candidate]
|
||||
expr: "candidate.conversation.id === 'qa-operator' && candidate.text.includes(config.expectedReply)"
|
||||
- expr: liveTurnTimeoutMs(env, 30000)
|
||||
- assert:
|
||||
expr: "outbound.text.includes(config.expectedReply)"
|
||||
message:
|
||||
expr: "`missing empty-response recovery marker: ${outbound.text}`"
|
||||
- if:
|
||||
expr: "Boolean(env.mock)"
|
||||
then:
|
||||
- set: scenarioRequests
|
||||
value:
|
||||
expr: "(await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBefore)"
|
||||
- assert:
|
||||
expr: "scenarioRequests.some((request) => String(request.allInputText ?? '').includes(config.promptSnippet) && request.plannedToolName === 'read')"
|
||||
message: expected replay-safe read request in mock trace
|
||||
- assert:
|
||||
expr: "scenarioRequests.some((request) => String(request.allInputText ?? '').includes(config.retryNeedle))"
|
||||
message: expected empty-response retry instruction in mock trace
|
||||
detailsExpr: "env.mock ? `${outbound.text}\\nrequests=${String(scenarioRequests?.length ?? 0)}` : outbound.text"
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
# Empty-response retry budget exhausted
|
||||
|
||||
```yaml qa-scenario
|
||||
id: empty-response-retry-budget-exhausted
|
||||
title: Empty-response retry budget exhausted
|
||||
surface: runtime
|
||||
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.
|
||||
- The agent performs the replay-safe read that makes retrying allowed.
|
||||
- Mock trace shows the run reaches a terminal post-read turn without ever producing the requested success marker.
|
||||
docsRefs:
|
||||
- docs/help/testing.md
|
||||
codeRefs:
|
||||
- extensions/qa-lab/src/mock-openai-server.ts
|
||||
- src/agents/pi-embedded-runner/run/incomplete-turn.ts
|
||||
execution:
|
||||
kind: flow
|
||||
summary: Verify empty-response retry exhaustion still surfaces a visible failure.
|
||||
config:
|
||||
requiredProvider: mock-openai
|
||||
promptSnippet: Empty response exhaustion QA check
|
||||
prompt: "Empty response exhaustion QA check: read QA_KICKOFF_TASK.md, then answer with exactly EMPTY-EXHAUSTED-OK."
|
||||
retryNeedle: The previous attempt did not produce a user-visible answer.
|
||||
```
|
||||
|
||||
```yaml qa-flow
|
||||
steps:
|
||||
- name: surfaces a retry error after empty-response exhaustion
|
||||
actions:
|
||||
- assert:
|
||||
expr: "env.providerMode === 'mock-openai'"
|
||||
message: this seeded scenario is mock-openai only
|
||||
- call: waitForGatewayHealthy
|
||||
args:
|
||||
- ref: env
|
||||
- 60000
|
||||
- call: reset
|
||||
- set: requestCountBefore
|
||||
value:
|
||||
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length : 0"
|
||||
- set: sessionKey
|
||||
value:
|
||||
expr: "`agent:qa:empty-response-exhausted:${randomUUID().slice(0, 8)}`"
|
||||
- call: startAgentRun
|
||||
saveAs: started
|
||||
args:
|
||||
- ref: env
|
||||
- sessionKey:
|
||||
ref: sessionKey
|
||||
message:
|
||||
expr: config.prompt
|
||||
timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 45000)
|
||||
- set: waited
|
||||
value:
|
||||
expr: "await env.gateway.call('agent.wait', { runId: started.runId, timeoutMs: liveTurnTimeoutMs(env, 45000) }, { timeoutMs: liveTurnTimeoutMs(env, 50000) })"
|
||||
- assert:
|
||||
expr: "waited?.status === 'ok'"
|
||||
message:
|
||||
expr: "`agent.wait returned ${String(waited?.status ?? 'unknown')}: ${String(waited?.error ?? '')}`"
|
||||
- if:
|
||||
expr: "Boolean(env.mock)"
|
||||
then:
|
||||
- set: scenarioRequests
|
||||
value:
|
||||
expr: "(await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBefore)"
|
||||
- assert:
|
||||
expr: "scenarioRequests.some((request) => String(request.allInputText ?? '').includes(config.promptSnippet) && request.plannedToolName === 'read')"
|
||||
message: expected replay-safe read request in mock trace
|
||||
- assert:
|
||||
expr: "scenarioRequests.length >= 2"
|
||||
message: expected at least the replay-safe read request and one terminal post-read turn
|
||||
detailsExpr: "env.mock ? `requests=${String(scenarioRequests?.length ?? 0)}` : String(waited?.status ?? '')"
|
||||
```
|
||||
@@ -0,0 +1,90 @@
|
||||
# Reasoning-only no-auto-retry after write
|
||||
|
||||
```yaml qa-scenario
|
||||
id: reasoning-only-no-auto-retry-after-write
|
||||
title: Reasoning-only no-auto-retry after write
|
||||
surface: runtime
|
||||
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.
|
||||
- The agent performs the seeded mutating write.
|
||||
- Mock trace does not include an automatic reasoning-only retry instruction.
|
||||
- Mock trace stops after the write-side reasoning-only terminal turn instead of attempting a continuation.
|
||||
docsRefs:
|
||||
- docs/help/testing.md
|
||||
- docs/help/gpt54-codex-agentic-parity.md
|
||||
codeRefs:
|
||||
- extensions/qa-lab/src/mock-openai-server.ts
|
||||
- src/agents/pi-embedded-runner/run/incomplete-turn.ts
|
||||
execution:
|
||||
kind: flow
|
||||
summary: Verify reasoning-only turns after a write do not auto-retry.
|
||||
config:
|
||||
requiredProvider: mock-openai
|
||||
promptSnippet: Reasoning-only after write safety check
|
||||
prompt: "Reasoning-only after write safety check: write reasoning-only-side-effect.txt, then answer with exactly SIDE-EFFECT-GUARD-OK."
|
||||
retryNeedle: recorded reasoning but did not produce a user-visible answer
|
||||
outputFile: reasoning-only-side-effect.txt
|
||||
```
|
||||
|
||||
```yaml qa-flow
|
||||
steps:
|
||||
- name: keeps replay-unsafety explicit after a mutating write
|
||||
actions:
|
||||
- assert:
|
||||
expr: "env.providerMode === 'mock-openai'"
|
||||
message: this seeded scenario is mock-openai only
|
||||
- call: waitForGatewayHealthy
|
||||
args:
|
||||
- ref: env
|
||||
- 60000
|
||||
- call: reset
|
||||
- set: requestCountBefore
|
||||
value:
|
||||
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length : 0"
|
||||
- set: sessionKey
|
||||
value:
|
||||
expr: "`agent:qa:reasoning-only-write:${randomUUID().slice(0, 8)}`"
|
||||
- call: startAgentRun
|
||||
saveAs: started
|
||||
args:
|
||||
- ref: env
|
||||
- sessionKey:
|
||||
ref: sessionKey
|
||||
message:
|
||||
expr: config.prompt
|
||||
timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 45000)
|
||||
- set: waited
|
||||
value:
|
||||
expr: "await env.gateway.call('agent.wait', { runId: started.runId, timeoutMs: liveTurnTimeoutMs(env, 45000) }, { timeoutMs: liveTurnTimeoutMs(env, 50000) })"
|
||||
- assert:
|
||||
expr: "waited?.status === 'ok'"
|
||||
message:
|
||||
expr: "`agent.wait returned ${String(waited?.status ?? 'unknown')}: ${String(waited?.error ?? '')}`"
|
||||
- call: fs.readFile
|
||||
saveAs: sideEffect
|
||||
args:
|
||||
- expr: "path.join(env.gateway.workspaceDir, config.outputFile)"
|
||||
- utf8
|
||||
- assert:
|
||||
expr: "sideEffect.includes('side effects already happened')"
|
||||
message:
|
||||
expr: "`side-effect file missing expected contents: ${sideEffect}`"
|
||||
- if:
|
||||
expr: "Boolean(env.mock)"
|
||||
then:
|
||||
- set: scenarioRequests
|
||||
value:
|
||||
expr: "(await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBefore)"
|
||||
- assert:
|
||||
expr: "scenarioRequests.some((request) => String(request.allInputText ?? '').includes(config.promptSnippet) && request.plannedToolName === 'write')"
|
||||
message: expected mutating write request in mock trace
|
||||
- assert:
|
||||
expr: "!scenarioRequests.some((request) => String(request.allInputText ?? '').includes(config.retryNeedle))"
|
||||
message: reasoning-only retry instruction should not be injected after a write
|
||||
- assert:
|
||||
expr: "scenarioRequests.filter((request) => String(request.allInputText ?? '').includes(config.promptSnippet)).length === 2"
|
||||
message: expected exactly the write request plus the reasoning-only terminal request
|
||||
detailsExpr: "env.mock ? `requests=${String(scenarioRequests?.length ?? 0)} sideEffect=${sideEffect.trim()}` : sideEffect"
|
||||
```
|
||||
@@ -0,0 +1,81 @@
|
||||
# Reasoning-only recovery after replay-safe read
|
||||
|
||||
```yaml qa-scenario
|
||||
id: reasoning-only-recovery-replay-safe-read
|
||||
title: Reasoning-only recovery after replay-safe read
|
||||
surface: runtime
|
||||
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.
|
||||
- The agent performs a replay-safe read before the reasoning-only turn.
|
||||
- The runtime injects the visible-answer continuation instruction after the reasoning-only turn.
|
||||
- The final visible reply contains the exact recovery marker.
|
||||
docsRefs:
|
||||
- docs/help/testing.md
|
||||
codeRefs:
|
||||
- extensions/qa-lab/src/mock-openai-server.ts
|
||||
- src/agents/pi-embedded-runner/run/incomplete-turn.ts
|
||||
execution:
|
||||
kind: flow
|
||||
summary: Verify reasoning-only OpenAI turns recover after a replay-safe read.
|
||||
config:
|
||||
requiredProvider: mock-openai
|
||||
promptSnippet: Reasoning-only continuation QA check
|
||||
prompt: "Reasoning-only continuation QA check: read QA_KICKOFF_TASK.md, then answer with exactly REASONING-RECOVERED-OK."
|
||||
expectedReply: REASONING-RECOVERED-OK
|
||||
retryNeedle: recorded reasoning but did not produce a user-visible answer
|
||||
```
|
||||
|
||||
```yaml qa-flow
|
||||
steps:
|
||||
- name: retries a replay-safe read into a visible answer
|
||||
actions:
|
||||
- assert:
|
||||
expr: "env.providerMode === 'mock-openai'"
|
||||
message: this seeded scenario is mock-openai only
|
||||
- call: waitForGatewayHealthy
|
||||
args:
|
||||
- ref: env
|
||||
- 60000
|
||||
- call: reset
|
||||
- set: requestCountBefore
|
||||
value:
|
||||
expr: "env.mock ? (await fetchJson(`${env.mock.baseUrl}/debug/requests`)).length : 0"
|
||||
- set: sessionKey
|
||||
value:
|
||||
expr: "`agent:qa:reasoning-only-recovery:${randomUUID().slice(0, 8)}`"
|
||||
- call: runAgentPrompt
|
||||
args:
|
||||
- ref: env
|
||||
- sessionKey:
|
||||
ref: sessionKey
|
||||
message:
|
||||
expr: config.prompt
|
||||
timeoutMs:
|
||||
expr: liveTurnTimeoutMs(env, 45000)
|
||||
- call: waitForOutboundMessage
|
||||
saveAs: outbound
|
||||
args:
|
||||
- ref: state
|
||||
- lambda:
|
||||
params: [candidate]
|
||||
expr: "candidate.conversation.id === 'qa-operator' && candidate.text.includes(config.expectedReply)"
|
||||
- expr: liveTurnTimeoutMs(env, 30000)
|
||||
- assert:
|
||||
expr: "outbound.text.includes(config.expectedReply)"
|
||||
message:
|
||||
expr: "`missing recovery marker: ${outbound.text}`"
|
||||
- if:
|
||||
expr: "Boolean(env.mock)"
|
||||
then:
|
||||
- set: scenarioRequests
|
||||
value:
|
||||
expr: "(await fetchJson(`${env.mock.baseUrl}/debug/requests`)).slice(requestCountBefore)"
|
||||
- assert:
|
||||
expr: "scenarioRequests.some((request) => String(request.allInputText ?? '').includes(config.promptSnippet) && request.plannedToolName === 'read')"
|
||||
message: expected replay-safe read request in mock trace
|
||||
- assert:
|
||||
expr: "scenarioRequests.some((request) => String(request.allInputText ?? '').includes(config.retryNeedle))"
|
||||
message: expected reasoning-only retry instruction in mock trace
|
||||
detailsExpr: "env.mock ? `${outbound.text}\\nrequests=${String(scenarioRequests?.length ?? 0)}` : outbound.text"
|
||||
```
|
||||
@@ -0,0 +1,22 @@
|
||||
export type NpmPackBudgetResult = {
|
||||
filename?: string;
|
||||
unpackedSize?: number;
|
||||
};
|
||||
|
||||
export declare const NPM_PACK_UNPACKED_SIZE_BUDGET_BYTES: number;
|
||||
|
||||
export declare function formatMiB(bytes: number): string;
|
||||
|
||||
export declare function formatPackUnpackedSizeBudgetError(params: {
|
||||
budgetBytes?: number;
|
||||
label: string;
|
||||
unpackedSize: number;
|
||||
}): string;
|
||||
|
||||
export declare function collectPackUnpackedSizeErrors(
|
||||
results: Iterable<NpmPackBudgetResult>,
|
||||
options?: {
|
||||
budgetBytes?: number;
|
||||
missingDataMessage?: string;
|
||||
},
|
||||
): string[];
|
||||
@@ -0,0 +1,55 @@
|
||||
// 2026.3.12 ballooned to ~213.6 MiB unpacked and correlated with low-memory
|
||||
// startup/doctor OOM reports. 2026.4.12 intentionally stages Matrix runtime
|
||||
// dependencies, including crypto wasm, so packaged installs do not miss Docker
|
||||
// and gateway runtime dependencies. Keep the budget below the 2026.3.12 bloat
|
||||
// level while allowing that mirrored runtime surface.
|
||||
export const NPM_PACK_UNPACKED_SIZE_BUDGET_BYTES = 202 * 1024 * 1024;
|
||||
|
||||
export function formatMiB(bytes) {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function resolvePackResultLabel(entry, index) {
|
||||
return entry.filename?.trim() || `pack result #${index + 1}`;
|
||||
}
|
||||
|
||||
export function formatPackUnpackedSizeBudgetError(params) {
|
||||
const budgetBytes = params.budgetBytes ?? NPM_PACK_UNPACKED_SIZE_BUDGET_BYTES;
|
||||
return [
|
||||
`${params.label} unpackedSize ${params.unpackedSize} bytes (${formatMiB(params.unpackedSize)}) exceeds budget ${budgetBytes} bytes (${formatMiB(budgetBytes)}).`,
|
||||
"Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function collectPackUnpackedSizeErrors(results, options = {}) {
|
||||
const entries = Array.from(results);
|
||||
const errors = [];
|
||||
const budgetBytes = options.budgetBytes ?? NPM_PACK_UNPACKED_SIZE_BUDGET_BYTES;
|
||||
let checkedCount = 0;
|
||||
|
||||
for (const [index, entry] of entries.entries()) {
|
||||
if (typeof entry.unpackedSize !== "number" || !Number.isFinite(entry.unpackedSize)) {
|
||||
continue;
|
||||
}
|
||||
checkedCount += 1;
|
||||
if (entry.unpackedSize <= budgetBytes) {
|
||||
continue;
|
||||
}
|
||||
errors.push(
|
||||
formatPackUnpackedSizeBudgetError({
|
||||
budgetBytes,
|
||||
label: resolvePackResultLabel(entry, index),
|
||||
unpackedSize: entry.unpackedSize,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (entries.length > 0 && checkedCount === 0) {
|
||||
errors.push(
|
||||
options.missingDataMessage ??
|
||||
"npm pack --dry-run produced no unpackedSize data; pack size budget was not verified.",
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
@@ -65,6 +65,9 @@ export const pluginSdkDocMetadata = {
|
||||
"command-status": {
|
||||
category: "channel",
|
||||
},
|
||||
"command-status-runtime": {
|
||||
category: "runtime",
|
||||
},
|
||||
"secret-input": {
|
||||
category: "channel",
|
||||
},
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
"command-auth",
|
||||
"command-auth-native",
|
||||
"command-status",
|
||||
"command-status-runtime",
|
||||
"command-detection",
|
||||
"command-surface",
|
||||
"collection-runtime",
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
collectBundledPluginRuntimeDependencySpecs,
|
||||
collectRootDistBundledRuntimeMirrors,
|
||||
} from "./lib/bundled-plugin-root-runtime-mirrors.mjs";
|
||||
import { collectPackUnpackedSizeErrors as collectNpmPackUnpackedSizeErrors } from "./lib/npm-pack-budget.mjs";
|
||||
import { listPluginSdkDistArtifacts } from "./lib/plugin-sdk-entries.mjs";
|
||||
import { listStaticExtensionAssetOutputs } from "./runtime-postbuild.mjs";
|
||||
import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts";
|
||||
@@ -53,12 +54,6 @@ const forbiddenPrefixes = [
|
||||
"dist/plugin-sdk/.tsbuildinfo",
|
||||
"docs/.generated/",
|
||||
];
|
||||
// 2026.3.12 ballooned to ~213.6 MiB unpacked and correlated with low-memory
|
||||
// startup/doctor OOM reports. 2026.4.12 intentionally stages Matrix runtime
|
||||
// dependencies, including crypto wasm, so packaged installs do not miss Docker
|
||||
// and gateway runtime dependencies. Keep the budget below the 2026.3.12 bloat
|
||||
// level while allowing that mirrored runtime surface.
|
||||
const npmPackUnpackedSizeBudgetBytes = 202 * 1024 * 1024;
|
||||
const appcastPath = resolve("appcast.xml");
|
||||
const laneBuildMin = 1_000_000_000;
|
||||
const laneFloorAdoptionDateKey = 20260227;
|
||||
@@ -269,49 +264,7 @@ export function collectForbiddenPackPaths(paths: Iterable<string>): string[] {
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function formatMiB(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function resolvePackResultLabel(entry: PackResult, index: number): string {
|
||||
return entry.filename?.trim() || `pack result #${index + 1}`;
|
||||
}
|
||||
|
||||
function formatPackUnpackedSizeBudgetError(params: {
|
||||
label: string;
|
||||
unpackedSize: number;
|
||||
}): string {
|
||||
return [
|
||||
`${params.label} unpackedSize ${params.unpackedSize} bytes (${formatMiB(params.unpackedSize)}) exceeds budget ${npmPackUnpackedSizeBudgetBytes} bytes (${formatMiB(npmPackUnpackedSizeBudgetBytes)}).`,
|
||||
"Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function collectPackUnpackedSizeErrors(results: Iterable<PackResult>): string[] {
|
||||
const entries = Array.from(results);
|
||||
const errors: string[] = [];
|
||||
let checkedCount = 0;
|
||||
|
||||
for (const [index, entry] of entries.entries()) {
|
||||
if (typeof entry.unpackedSize !== "number" || !Number.isFinite(entry.unpackedSize)) {
|
||||
continue;
|
||||
}
|
||||
checkedCount += 1;
|
||||
if (entry.unpackedSize <= npmPackUnpackedSizeBudgetBytes) {
|
||||
continue;
|
||||
}
|
||||
const label = resolvePackResultLabel(entry, index);
|
||||
errors.push(formatPackUnpackedSizeBudgetError({ label, unpackedSize: entry.unpackedSize }));
|
||||
}
|
||||
|
||||
if (entries.length > 0 && checkedCount === 0) {
|
||||
errors.push(
|
||||
"npm pack --dry-run produced no unpackedSize data; pack size budget was not verified.",
|
||||
);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
export { collectPackUnpackedSizeErrors } from "./lib/npm-pack-budget.mjs";
|
||||
|
||||
function extractTag(item: string, tag: string): string | null {
|
||||
const escapedTag = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
@@ -486,7 +439,7 @@ async function main() {
|
||||
})
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
const forbidden = collectForbiddenPackPaths(paths);
|
||||
const sizeErrors = collectPackUnpackedSizeErrors(results);
|
||||
const sizeErrors = collectNpmPackUnpackedSizeErrors(results);
|
||||
|
||||
if (missing.length > 0 || forbidden.length > 0 || sizeErrors.length > 0) {
|
||||
if (missing.length > 0) {
|
||||
|
||||
@@ -58,6 +58,39 @@ console.log(
|
||||
' "$label" "$pack_json_file"
|
||||
}
|
||||
|
||||
assert_pack_unpacked_size_budget() {
|
||||
local label="$1"
|
||||
local pack_json_file="$2"
|
||||
node --input-type=module - "$label" "$pack_json_file" <<'NODE'
|
||||
import { readFileSync } from "node:fs";
|
||||
import { collectPackUnpackedSizeErrors } from "./scripts/lib/npm-pack-budget.mjs";
|
||||
|
||||
const label = process.argv[2];
|
||||
const packJsonFile = process.argv[3];
|
||||
const raw = readFileSync(packJsonFile, "utf8") || "[]";
|
||||
const parsed = JSON.parse(raw);
|
||||
const budgetOverride = process.env.OPENCLAW_INSTALL_SMOKE_PACK_UNPACKED_BUDGET_BYTES;
|
||||
const budgetBytes = budgetOverride ? Number(budgetOverride) : undefined;
|
||||
if (budgetOverride && !Number.isFinite(budgetBytes)) {
|
||||
throw new Error(
|
||||
`OPENCLAW_INSTALL_SMOKE_PACK_UNPACKED_BUDGET_BYTES must be numeric, got ${JSON.stringify(
|
||||
budgetOverride,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
const errors = collectPackUnpackedSizeErrors(parsed, {
|
||||
budgetBytes,
|
||||
missingDataMessage: `${label} npm pack output did not include unpackedSize; install smoke cannot verify pack budget.`,
|
||||
});
|
||||
for (const error of errors) {
|
||||
console.error(`ERROR: ${error}`);
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
NODE
|
||||
}
|
||||
|
||||
print_pack_delta_audit() {
|
||||
local baseline_pack_json_file="$1"
|
||||
local update_pack_json_file="$2"
|
||||
@@ -191,6 +224,7 @@ process.stdout.write(last.filename);
|
||||
' "$pack_json_file"
|
||||
)"
|
||||
print_pack_audit "update" "$pack_json_file"
|
||||
assert_pack_unpacked_size_budget "update" "$pack_json_file"
|
||||
packed_update_version="$(
|
||||
node -e '
|
||||
const raw = require("node:fs").readFileSync(process.argv[1], "utf8") || "[]";
|
||||
|
||||
@@ -42,8 +42,7 @@ function getLog(): ReturnType<typeof createSubsystemLogger> {
|
||||
|
||||
/** Strip null bytes from paths to prevent ENOTDIR errors. */
|
||||
function stripNullBytes(s: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return s.replace(/\0/g, "");
|
||||
return s.replaceAll("\0", "");
|
||||
}
|
||||
|
||||
export function listAgentEntries(cfg: OpenClawConfig): AgentEntry[] {
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
CONTEXT_WINDOW_HARD_MIN_TOKENS,
|
||||
CONTEXT_WINDOW_WARN_BELOW_TOKENS,
|
||||
evaluateContextWindowGuard,
|
||||
formatContextWindowBlockMessage,
|
||||
formatContextWindowWarningMessage,
|
||||
resolveContextWindowInfo,
|
||||
} from "./context-window-guard.js";
|
||||
|
||||
@@ -222,4 +224,87 @@ describe("context-window-guard", () => {
|
||||
expect(CONTEXT_WINDOW_HARD_MIN_TOKENS).toBe(16_000);
|
||||
expect(CONTEXT_WINDOW_WARN_BELOW_TOKENS).toBe(32_000);
|
||||
});
|
||||
|
||||
it("adds a local-model hint to warning messages for localhost endpoints", () => {
|
||||
const guard = evaluateContextWindowGuard({
|
||||
info: { tokens: 24_000, source: "model" },
|
||||
});
|
||||
|
||||
expect(
|
||||
formatContextWindowWarningMessage({
|
||||
provider: "lmstudio",
|
||||
modelId: "qwen3",
|
||||
guard,
|
||||
runtimeBaseUrl: "http://127.0.0.1:1234/v1",
|
||||
}),
|
||||
).toContain("local/self-hosted runs work best at 32000+ tokens");
|
||||
});
|
||||
|
||||
it("does not add local-model hints for generic custom endpoints", () => {
|
||||
const guard = evaluateContextWindowGuard({
|
||||
info: { tokens: 24_000, source: "model" },
|
||||
});
|
||||
|
||||
expect(
|
||||
formatContextWindowWarningMessage({
|
||||
provider: "custom",
|
||||
modelId: "hosted-proxy-model",
|
||||
guard,
|
||||
runtimeBaseUrl: "https://models.example.com/v1",
|
||||
}),
|
||||
).toBe("low context window: custom/hosted-proxy-model ctx=24000 (warn<32000) source=model");
|
||||
});
|
||||
|
||||
it("adds a local-model hint to block messages for localhost endpoints", () => {
|
||||
const guard = evaluateContextWindowGuard({
|
||||
info: { tokens: 8_000, source: "model" },
|
||||
});
|
||||
|
||||
expect(
|
||||
formatContextWindowBlockMessage({
|
||||
guard,
|
||||
runtimeBaseUrl: "http://127.0.0.1:11434/v1",
|
||||
}),
|
||||
).toContain("This looks like a local model endpoint.");
|
||||
});
|
||||
|
||||
it("points config-backed block remediation at agents.defaults.contextTokens", () => {
|
||||
const guard = evaluateContextWindowGuard({
|
||||
info: { tokens: 8_000, source: "agentContextTokens" },
|
||||
});
|
||||
|
||||
const message = formatContextWindowBlockMessage({
|
||||
guard,
|
||||
runtimeBaseUrl: "http://127.0.0.1:11434/v1",
|
||||
});
|
||||
|
||||
expect(message).toContain("OpenClaw is capped by agents.defaults.contextTokens.");
|
||||
expect(message).not.toContain("choose a larger model");
|
||||
});
|
||||
|
||||
it("points model config block remediation at contextWindow/contextTokens", () => {
|
||||
const guard = evaluateContextWindowGuard({
|
||||
info: { tokens: 8_000, source: "modelsConfig" },
|
||||
});
|
||||
|
||||
expect(
|
||||
formatContextWindowBlockMessage({
|
||||
guard,
|
||||
runtimeBaseUrl: "http://127.0.0.1:11434/v1",
|
||||
}),
|
||||
).toContain("Raise contextWindow/contextTokens or choose a larger model.");
|
||||
});
|
||||
|
||||
it("keeps block messages concise for public providers", () => {
|
||||
const guard = evaluateContextWindowGuard({
|
||||
info: { tokens: 8_000, source: "model" },
|
||||
});
|
||||
|
||||
expect(
|
||||
formatContextWindowBlockMessage({
|
||||
guard,
|
||||
runtimeBaseUrl: "https://api.openai.com/v1",
|
||||
}),
|
||||
).toBe(`Model context window too small (8000 tokens; source=model). Minimum is 16000.`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { resolveProviderEndpoint } from "./provider-attribution.js";
|
||||
import { findNormalizedProviderValue } from "./provider-id.js";
|
||||
|
||||
export const CONTEXT_WINDOW_HARD_MIN_TOKENS = 16_000;
|
||||
@@ -61,6 +62,77 @@ export type ContextWindowGuardResult = ContextWindowInfo & {
|
||||
shouldBlock: boolean;
|
||||
};
|
||||
|
||||
export type ContextWindowGuardHint = {
|
||||
endpointClass: ReturnType<typeof resolveProviderEndpoint>["endpointClass"];
|
||||
likelySelfHosted: boolean;
|
||||
};
|
||||
|
||||
export function resolveContextWindowGuardHint(params: {
|
||||
runtimeBaseUrl?: string | null;
|
||||
}): ContextWindowGuardHint {
|
||||
const endpoint = resolveProviderEndpoint(params.runtimeBaseUrl ?? undefined);
|
||||
return {
|
||||
endpointClass: endpoint.endpointClass,
|
||||
likelySelfHosted: endpoint.endpointClass === "local",
|
||||
};
|
||||
}
|
||||
|
||||
export function formatContextWindowWarningMessage(params: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
guard: ContextWindowGuardResult;
|
||||
runtimeBaseUrl?: string | null;
|
||||
}): string {
|
||||
const base = `low context window: ${params.provider}/${params.modelId} ctx=${params.guard.tokens} (warn<${CONTEXT_WINDOW_WARN_BELOW_TOKENS}) source=${params.guard.source}`;
|
||||
const hint = resolveContextWindowGuardHint({ runtimeBaseUrl: params.runtimeBaseUrl });
|
||||
if (!hint.likelySelfHosted) {
|
||||
return base;
|
||||
}
|
||||
if (params.guard.source === "agentContextTokens") {
|
||||
return (
|
||||
`${base}; OpenClaw is capped by agents.defaults.contextTokens, so raise that cap ` +
|
||||
`if you want to use more of the model context window`
|
||||
);
|
||||
}
|
||||
if (params.guard.source === "modelsConfig") {
|
||||
return (
|
||||
`${base}; OpenClaw is using the configured model context limit for this model, ` +
|
||||
`so raise contextWindow/contextTokens if it is set too low`
|
||||
);
|
||||
}
|
||||
return (
|
||||
`${base}; local/self-hosted runs work best at ` +
|
||||
`${CONTEXT_WINDOW_WARN_BELOW_TOKENS}+ tokens and may show weaker tool use or more compaction until the server/model context limit is raised`
|
||||
);
|
||||
}
|
||||
|
||||
export function formatContextWindowBlockMessage(params: {
|
||||
guard: ContextWindowGuardResult;
|
||||
runtimeBaseUrl?: string | null;
|
||||
}): string {
|
||||
const base =
|
||||
`Model context window too small (${params.guard.tokens} tokens; ` +
|
||||
`source=${params.guard.source}). Minimum is ${CONTEXT_WINDOW_HARD_MIN_TOKENS}.`;
|
||||
const hint = resolveContextWindowGuardHint({ runtimeBaseUrl: params.runtimeBaseUrl });
|
||||
if (!hint.likelySelfHosted) {
|
||||
return base;
|
||||
}
|
||||
if (params.guard.source === "agentContextTokens") {
|
||||
return `${base} OpenClaw is capped by agents.defaults.contextTokens. Raise that cap.`;
|
||||
}
|
||||
if (params.guard.source === "modelsConfig") {
|
||||
return (
|
||||
`${base} OpenClaw is using the configured model context limit for this model. ` +
|
||||
`Raise contextWindow/contextTokens or choose a larger model.`
|
||||
);
|
||||
}
|
||||
return (
|
||||
`${base} This looks like a local model endpoint. ` +
|
||||
`Raise the server/model context limit or choose a larger model. ` +
|
||||
`OpenClaw local/self-hosted runs work best at ${CONTEXT_WINDOW_WARN_BELOW_TOKENS}+ tokens.`
|
||||
);
|
||||
}
|
||||
|
||||
export function evaluateContextWindowGuard(params: {
|
||||
info: ContextWindowInfo;
|
||||
warnBelowTokens?: number;
|
||||
|
||||
@@ -21,6 +21,16 @@ describe("resolveEffectiveExecutionContract", () => {
|
||||
).toBe("strict-agentic");
|
||||
});
|
||||
|
||||
it("auto-activates on the mock-openai qa lane", () => {
|
||||
expect(
|
||||
resolveEffectiveExecutionContract({
|
||||
config: emptyConfig,
|
||||
provider: "mock-openai",
|
||||
modelId: "mock-openai/gpt-5.4",
|
||||
}),
|
||||
).toBe("strict-agentic");
|
||||
});
|
||||
|
||||
it("auto-activates on gpt-5o and variants without a separator", () => {
|
||||
for (const modelId of ["gpt-5", "gpt-5o", "gpt-5o-mini"]) {
|
||||
expect(
|
||||
|
||||
@@ -39,14 +39,16 @@ const STRICT_AGENTIC_MODEL_ID_PATTERN = /^gpt-5(?:[.o-]|$)/i;
|
||||
* Supported provider + model combinations where strict-agentic is the intended
|
||||
* runtime contract. Kept as a narrow helper so both the execution-contract
|
||||
* resolver and the `update_plan` auto-enable gate converge on the same
|
||||
* definition of "GPT-5-family openai/openai-codex run".
|
||||
* definition of "GPT-5-family openai/openai-codex run". The embedded
|
||||
* `mock-openai` QA lane intentionally piggybacks on that contract so repo QA
|
||||
* can exercise the same incomplete-turn recovery rules end to end.
|
||||
*/
|
||||
export function isStrictAgenticSupportedProviderModel(params: {
|
||||
provider?: string | null;
|
||||
modelId?: string | null;
|
||||
}): boolean {
|
||||
const provider = normalizeLowercaseStringOrEmpty(params.provider ?? "");
|
||||
if (provider !== "openai" && provider !== "openai-codex") {
|
||||
if (provider !== "openai" && provider !== "openai-codex" && provider !== "mock-openai") {
|
||||
return false;
|
||||
}
|
||||
const modelId = typeof params.modelId === "string" ? params.modelId : "";
|
||||
|
||||
@@ -401,7 +401,8 @@ describe("isModernModelRef", () => {
|
||||
provider === "openai" &&
|
||||
["gpt-5.4", "gpt-5.4-pro", "gpt-5.4-mini", "gpt-5.4-nano"].includes(context.modelId)
|
||||
? true
|
||||
: provider === "openai-codex" && ["gpt-5.4", "gpt-5.4-mini"].includes(context.modelId)
|
||||
: provider === "openai-codex" &&
|
||||
["gpt-5.4", "gpt-5.4-pro", "gpt-5.4-mini"].includes(context.modelId)
|
||||
? true
|
||||
: provider === "opencode" && ["claude-opus-4-6", "gemini-3-pro"].includes(context.modelId)
|
||||
? true
|
||||
@@ -415,6 +416,7 @@ describe("isModernModelRef", () => {
|
||||
expect(isModernModelRef({ provider: "openai", id: "gpt-5.4-mini" })).toBe(true);
|
||||
expect(isModernModelRef({ provider: "openai", id: "gpt-5.4-nano" })).toBe(true);
|
||||
expect(isModernModelRef({ provider: "openai-codex", id: "gpt-5.4" })).toBe(true);
|
||||
expect(isModernModelRef({ provider: "openai-codex", id: "gpt-5.4-pro" })).toBe(true);
|
||||
expect(isModernModelRef({ provider: "openai-codex", id: "gpt-5.4-mini" })).toBe(true);
|
||||
expect(isModernModelRef({ provider: "opencode", id: "claude-opus-4-6" })).toBe(true);
|
||||
expect(isModernModelRef({ provider: "opencode", id: "gemini-3-pro" })).toBe(true);
|
||||
|
||||
@@ -11,6 +11,10 @@ vi.mock("./auth-profiles/store.js", () => ({
|
||||
loadAuthProfileStoreForRuntime: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./auth-profiles/source-check.js", () => ({
|
||||
hasAnyAuthProfileStoreSource: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./auth-profiles/usage.js", () => ({
|
||||
getSoonestCooldownExpiry: vi.fn(),
|
||||
isProfileInCooldown: vi.fn(),
|
||||
@@ -26,6 +30,7 @@ vi.mock("./auth-profiles/source-check.js", () => ({
|
||||
}));
|
||||
|
||||
type AuthProfilesStoreModule = typeof import("./auth-profiles/store.js");
|
||||
type AuthProfilesSourceCheckModule = typeof import("./auth-profiles/source-check.js");
|
||||
type AuthProfilesUsageModule = typeof import("./auth-profiles/usage.js");
|
||||
type AuthProfilesOrderModule = typeof import("./auth-profiles/order.js");
|
||||
type ModelFallbackModule = typeof import("./model-fallback.js");
|
||||
@@ -34,6 +39,9 @@ type LoggerModule = typeof import("../logging/logger.js");
|
||||
let mockedEnsureAuthProfileStore: ReturnType<
|
||||
typeof vi.mocked<AuthProfilesStoreModule["ensureAuthProfileStore"]>
|
||||
>;
|
||||
let mockedHasAnyAuthProfileStoreSource: ReturnType<
|
||||
typeof vi.mocked<AuthProfilesSourceCheckModule["hasAnyAuthProfileStoreSource"]>
|
||||
>;
|
||||
let mockedGetSoonestCooldownExpiry: ReturnType<
|
||||
typeof vi.mocked<AuthProfilesUsageModule["getSoonestCooldownExpiry"]>
|
||||
>;
|
||||
@@ -57,11 +65,15 @@ let unregisterLogTransport: (() => void) | undefined;
|
||||
|
||||
async function loadModelFallbackProbeModules() {
|
||||
const authProfilesStoreModule = await import("./auth-profiles/store.js");
|
||||
const authProfilesSourceCheckModule = await import("./auth-profiles/source-check.js");
|
||||
const authProfilesUsageModule = await import("./auth-profiles/usage.js");
|
||||
const authProfilesOrderModule = await import("./auth-profiles/order.js");
|
||||
const loggerModule = await import("../logging/logger.js");
|
||||
const modelFallbackModule = await import("./model-fallback.js");
|
||||
mockedEnsureAuthProfileStore = vi.mocked(authProfilesStoreModule.ensureAuthProfileStore);
|
||||
mockedHasAnyAuthProfileStoreSource = vi.mocked(
|
||||
authProfilesSourceCheckModule.hasAnyAuthProfileStoreSource,
|
||||
);
|
||||
mockedGetSoonestCooldownExpiry = vi.mocked(authProfilesUsageModule.getSoonestCooldownExpiry);
|
||||
mockedIsProfileInCooldown = vi.mocked(authProfilesUsageModule.isProfileInCooldown);
|
||||
mockedResolveProfilesUnavailableReason = vi.mocked(
|
||||
@@ -187,6 +199,7 @@ describe("runWithModelFallback – probe logic", () => {
|
||||
version: 1,
|
||||
profiles: {},
|
||||
};
|
||||
mockedHasAnyAuthProfileStoreSource.mockReturnValue(true);
|
||||
mockedEnsureAuthProfileStore.mockReturnValue(fakeStore);
|
||||
|
||||
// Default: resolveAuthProfileOrder returns profiles only for "openai" provider
|
||||
|
||||
@@ -17,12 +17,16 @@ function buildModel(id: string): NonNullable<ProviderConfig["models"]>[number] {
|
||||
};
|
||||
}
|
||||
|
||||
function buildProvider(modelIds: string[]): ProviderConfig {
|
||||
function buildProvider(
|
||||
modelIds: string[],
|
||||
overrides: Partial<ProviderConfig> = {},
|
||||
): ProviderConfig {
|
||||
return {
|
||||
baseUrl: "https://example.invalid/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "EXAMPLE_KEY", // pragma: allowlist secret
|
||||
models: modelIds.map((id) => buildModel(id)),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,7 +73,9 @@ describe("google-vertex provider normalization", () => {
|
||||
it("normalizes gemini flash-lite IDs for google-vertex providers", () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const providers = {
|
||||
"google-vertex": buildProvider(["gemini-3.1-flash-lite", "gemini-3-flash-preview"]),
|
||||
"google-vertex": buildProvider(["gemini-3.1-flash-lite", "gemini-3-flash-preview"], {
|
||||
api: undefined,
|
||||
}),
|
||||
openai: buildProvider(["gpt-5"]),
|
||||
};
|
||||
|
||||
@@ -86,7 +92,9 @@ describe("google-vertex provider normalization", () => {
|
||||
it("returns original providers object when no google-vertex IDs need normalization", () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const providers = {
|
||||
"google-vertex": buildProvider(["gemini-3.1-flash-lite-preview", "gemini-3-flash-preview"]),
|
||||
"google-vertex": buildProvider(["gemini-3.1-flash-lite-preview", "gemini-3-flash-preview"], {
|
||||
api: undefined,
|
||||
}),
|
||||
};
|
||||
|
||||
const normalized = normalizeProviders({ providers, agentDir });
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveOpenAICompletionsCompatDefaults } from "./openai-completions-compat.js";
|
||||
|
||||
describe("resolveOpenAICompletionsCompatDefaults", () => {
|
||||
it("enables streaming usage for local ollama OpenAI-compat endpoints", () => {
|
||||
expect(
|
||||
resolveOpenAICompletionsCompatDefaults({
|
||||
provider: "ollama",
|
||||
endpointClass: "local",
|
||||
knownProviderFamily: "ollama",
|
||||
}).supportsUsageInStreaming,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps streaming usage enabled for custom ollama OpenAI-compat endpoints", () => {
|
||||
expect(
|
||||
resolveOpenAICompletionsCompatDefaults({
|
||||
provider: "ollama",
|
||||
endpointClass: "custom",
|
||||
knownProviderFamily: "ollama",
|
||||
}).supportsUsageInStreaming,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not broaden streaming usage for generic custom providers", () => {
|
||||
expect(
|
||||
resolveOpenAICompletionsCompatDefaults({
|
||||
provider: "custom-cpa",
|
||||
endpointClass: "custom",
|
||||
knownProviderFamily: "custom-cpa",
|
||||
}).supportsUsageInStreaming,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ export function resolveOpenAICompletionsCompatDefaults(
|
||||
input: OpenAICompletionsCompatDefaultsInput,
|
||||
): OpenAICompletionsCompatDefaults {
|
||||
const {
|
||||
provider,
|
||||
endpointClass,
|
||||
knownProviderFamily,
|
||||
supportsNativeStreamingUsageCompat = false,
|
||||
@@ -64,7 +65,8 @@ export function resolveOpenAICompletionsCompatDefaults(
|
||||
endpointClass === "chutes-native" ||
|
||||
endpointClass === "mistral-public" ||
|
||||
knownProviderFamily === "mistral" ||
|
||||
(isDefaultRoute && isDefaultRouteProvider(input.provider, "chutes"));
|
||||
(isDefaultRoute && isDefaultRouteProvider(provider, "chutes"));
|
||||
const isOllamaCompatProvider = provider === "ollama";
|
||||
|
||||
return {
|
||||
supportsStore:
|
||||
@@ -76,7 +78,8 @@ export function resolveOpenAICompletionsCompatDefaults(
|
||||
endpointClass !== "xai-native" &&
|
||||
!usesExplicitProxyLikeEndpoint,
|
||||
supportsUsageInStreaming:
|
||||
!isNonStandard && (!usesConfiguredNonOpenAIEndpoint || supportsNativeStreamingUsageCompat),
|
||||
isOllamaCompatProvider ||
|
||||
(!isNonStandard && (!usesConfiguredNonOpenAIEndpoint || supportsNativeStreamingUsageCompat)),
|
||||
maxTokensField: usesMaxTokens ? "max_tokens" : "max_completion_tokens",
|
||||
thinkingFormat: isZai ? "zai" : isOpenRouterLike ? "openrouter" : "openai",
|
||||
supportsStrictMode: !isZai && !usesConfiguredNonOpenAIEndpoint,
|
||||
|
||||
@@ -1196,6 +1196,33 @@ describe("openai transport stream", () => {
|
||||
expect(params.stream_options).toMatchObject({ include_usage: true });
|
||||
});
|
||||
|
||||
it("enables streaming usage compat for Ollama OpenAI-compat endpoints", () => {
|
||||
const params = buildOpenAICompletionsParams(
|
||||
{
|
||||
id: "qwen2.5:7b",
|
||||
name: "Qwen 2.5 7B",
|
||||
api: "openai-completions",
|
||||
provider: "ollama",
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 32768,
|
||||
maxTokens: 8192,
|
||||
} satisfies Model<"openai-completions">,
|
||||
{
|
||||
systemPrompt: "system",
|
||||
messages: [],
|
||||
tools: [],
|
||||
} as never,
|
||||
undefined,
|
||||
) as {
|
||||
stream_options?: { include_usage?: boolean };
|
||||
};
|
||||
|
||||
expect(params.stream_options).toMatchObject({ include_usage: true });
|
||||
});
|
||||
|
||||
it("disables developer-role-only compat defaults for configured custom proxy completions providers", () => {
|
||||
const params = buildOpenAICompletionsParams(
|
||||
{
|
||||
|
||||
@@ -397,6 +397,149 @@ describe("gateway tool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects config.patch that enables dangerouslyDisableDeviceAuth", async () => {
|
||||
const tool = requireGatewayTool();
|
||||
|
||||
await expect(
|
||||
tool.execute("call-dangerous-device-auth", {
|
||||
action: "config.patch",
|
||||
raw: "{ gateway: { controlUi: { dangerouslyDisableDeviceAuth: true } } }",
|
||||
}),
|
||||
).rejects.toThrow("cannot enable dangerous config flags");
|
||||
expect(callGatewayTool).not.toHaveBeenCalledWith(
|
||||
"config.patch",
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects config.patch that enables allowUnsafeExternalContent on gmail hooks", async () => {
|
||||
const tool = requireGatewayTool();
|
||||
|
||||
await expect(
|
||||
tool.execute("call-dangerous-gmail", {
|
||||
action: "config.patch",
|
||||
raw: "{ hooks: { gmail: { allowUnsafeExternalContent: true } } }",
|
||||
}),
|
||||
).rejects.toThrow("cannot enable dangerous config flags");
|
||||
expect(callGatewayTool).not.toHaveBeenCalledWith(
|
||||
"config.patch",
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects config.patch that weakens applyPatch.workspaceOnly", async () => {
|
||||
const tool = requireGatewayTool();
|
||||
|
||||
await expect(
|
||||
tool.execute("call-dangerous-workspace", {
|
||||
action: "config.patch",
|
||||
raw: "{ tools: { exec: { applyPatch: { workspaceOnly: false } } } }",
|
||||
}),
|
||||
).rejects.toThrow("cannot enable dangerous config flags");
|
||||
expect(callGatewayTool).not.toHaveBeenCalledWith(
|
||||
"config.patch",
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects config.patch that enables allowInsecureAuth on control UI", async () => {
|
||||
const tool = requireGatewayTool();
|
||||
|
||||
await expect(
|
||||
tool.execute("call-dangerous-insecure-auth", {
|
||||
action: "config.patch",
|
||||
raw: "{ gateway: { controlUi: { allowInsecureAuth: true } } }",
|
||||
}),
|
||||
).rejects.toThrow("cannot enable dangerous config flags");
|
||||
expect(callGatewayTool).not.toHaveBeenCalledWith(
|
||||
"config.patch",
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects config.patch that enables dangerouslyAllowHostHeaderOriginFallback", async () => {
|
||||
const tool = requireGatewayTool();
|
||||
|
||||
await expect(
|
||||
tool.execute("call-dangerous-origin-fallback", {
|
||||
action: "config.patch",
|
||||
raw: "{ gateway: { controlUi: { dangerouslyAllowHostHeaderOriginFallback: true } } }",
|
||||
}),
|
||||
).rejects.toThrow("cannot enable dangerous config flags");
|
||||
expect(callGatewayTool).not.toHaveBeenCalledWith(
|
||||
"config.patch",
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows config.patch that does not enable any dangerous flag", async () => {
|
||||
const sessionKey = "agent:main:whatsapp:dm:+15555550123";
|
||||
const tool = requireGatewayTool(sessionKey);
|
||||
|
||||
const raw = '{ channels: { telegram: { groups: { "*": { requireMention: false } } } } }';
|
||||
await tool.execute("call-safe-patch", {
|
||||
action: "config.patch",
|
||||
raw,
|
||||
});
|
||||
|
||||
expect(callGatewayTool).toHaveBeenCalledWith(
|
||||
"config.patch",
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ raw: raw.trim() }),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows config.patch when a dangerous flag is already enabled and stays enabled", async () => {
|
||||
vi.mocked(callGatewayTool).mockImplementationOnce(async (method: string) => {
|
||||
if (method === "config.get") {
|
||||
return {
|
||||
hash: "hash-1",
|
||||
config: {
|
||||
tools: { exec: { ask: "on-miss", security: "allowlist" } },
|
||||
hooks: { gmail: { allowUnsafeExternalContent: true } },
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
const sessionKey = "agent:main:whatsapp:dm:+15555550123";
|
||||
const tool = requireGatewayTool(sessionKey);
|
||||
|
||||
const raw =
|
||||
'{ hooks: { gmail: { allowUnsafeExternalContent: true } }, agents: { defaults: { workspace: "~/test" } } }';
|
||||
await tool.execute("call-keep-dangerous", {
|
||||
action: "config.patch",
|
||||
raw,
|
||||
});
|
||||
|
||||
expect(callGatewayTool).toHaveBeenCalledWith(
|
||||
"config.patch",
|
||||
expect.any(Object),
|
||||
expect.objectContaining({ raw: raw.trim() }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects config.apply that introduces a dangerous flag", async () => {
|
||||
const tool = requireGatewayTool();
|
||||
|
||||
await expect(
|
||||
tool.execute("call-dangerous-apply", {
|
||||
action: "config.apply",
|
||||
raw: '{ tools: { exec: { ask: "on-miss", security: "allowlist", applyPatch: { workspaceOnly: false } } } }',
|
||||
}),
|
||||
).rejects.toThrow("cannot enable dangerous config flags");
|
||||
expect(callGatewayTool).not.toHaveBeenCalledWith(
|
||||
"config.apply",
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes update.run through gateway call", async () => {
|
||||
const sessionKey = "agent:main:whatsapp:dm:+15555550123";
|
||||
const tool = requireGatewayTool(sessionKey);
|
||||
|
||||
@@ -171,14 +171,16 @@ describe("buildContextEngineMaintenanceRuntimeContext", () => {
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
rewriteTranscriptEntriesInSessionFileMock.mockImplementationOnce(async (_params?: unknown) => {
|
||||
events.push("rewrite");
|
||||
return {
|
||||
changed: true,
|
||||
bytesFreed: 123,
|
||||
rewrittenEntries: 2,
|
||||
};
|
||||
});
|
||||
rewriteTranscriptEntriesInSessionFileMock.mockImplementationOnce(
|
||||
async (_params?: unknown) => {
|
||||
events.push("rewrite");
|
||||
return {
|
||||
changed: true,
|
||||
bytesFreed: 123,
|
||||
rewrittenEntries: 2,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const runtimeContext = buildContextEngineMaintenanceRuntimeContext({
|
||||
sessionId: "session-rewrite-handoff",
|
||||
@@ -836,19 +838,20 @@ describe("runContextEngineMaintenance", () => {
|
||||
allowRewrite = resolve;
|
||||
});
|
||||
events.push("maintenance-before-rewrite");
|
||||
await (params as { runtimeContext?: ContextEngineRuntimeContext }).runtimeContext
|
||||
?.rewriteTranscriptEntries?.({
|
||||
replacements: [
|
||||
{
|
||||
entryId: "entry-1",
|
||||
message: castAgentMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
timestamp: 2,
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
await (
|
||||
params as { runtimeContext?: ContextEngineRuntimeContext }
|
||||
).runtimeContext?.rewriteTranscriptEntries?.({
|
||||
replacements: [
|
||||
{
|
||||
entryId: "entry-1",
|
||||
message: castAgentMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
timestamp: 2,
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
events.push("maintenance-after-rewrite");
|
||||
return {
|
||||
changed: false,
|
||||
@@ -857,14 +860,16 @@ describe("runContextEngineMaintenance", () => {
|
||||
};
|
||||
});
|
||||
|
||||
rewriteTranscriptEntriesInSessionFileMock.mockImplementationOnce(async (_params?: unknown) => {
|
||||
events.push("rewrite");
|
||||
return {
|
||||
changed: true,
|
||||
bytesFreed: 123,
|
||||
rewrittenEntries: 2,
|
||||
};
|
||||
});
|
||||
rewriteTranscriptEntriesInSessionFileMock.mockImplementationOnce(
|
||||
async (_params?: unknown) => {
|
||||
events.push("rewrite");
|
||||
return {
|
||||
changed: true,
|
||||
bytesFreed: 123,
|
||||
rewrittenEntries: 2,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const backgroundEngine = {
|
||||
info: {
|
||||
|
||||
@@ -182,18 +182,19 @@ function buildDynamicModel(
|
||||
};
|
||||
}
|
||||
case "openai-codex": {
|
||||
const isLegacyGpt54Alias = lower === "gpt-5.4-codex";
|
||||
const template =
|
||||
lower === "gpt-5.4"
|
||||
? findTemplate(params, "openai-codex", ["gpt-5.4", "gpt-5.4"])
|
||||
lower === "gpt-5.4" || isLegacyGpt54Alias || lower === "gpt-5.4-pro"
|
||||
? findTemplate(params, "openai-codex", ["gpt-5.4", "gpt-5.3-codex", "gpt-5.2-codex"])
|
||||
: lower === "gpt-5.4-mini"
|
||||
? findTemplate(params, "openai-codex", [
|
||||
"gpt-5.4",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.4",
|
||||
"gpt-5.2-codex",
|
||||
])
|
||||
: lower === "gpt-5.3-codex-spark"
|
||||
? findTemplate(params, "openai-codex", ["gpt-5.4", "gpt-5.4"])
|
||||
? findTemplate(params, "openai-codex", ["gpt-5.4", "gpt-5.3-codex", "gpt-5.2-codex"])
|
||||
: findTemplate(params, "openai-codex", ["gpt-5.4"]);
|
||||
const fallback = {
|
||||
provider: "openai-codex",
|
||||
@@ -205,7 +206,23 @@ function buildDynamicModel(
|
||||
contextWindow: DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: DEFAULT_CONTEXT_WINDOW,
|
||||
};
|
||||
if (lower === "gpt-5.4") {
|
||||
if (lower === "gpt-5.4" || isLegacyGpt54Alias) {
|
||||
return cloneTemplate(
|
||||
template,
|
||||
"gpt-5.4",
|
||||
{
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: OPENAI_CODEX_BASE_URL,
|
||||
cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
|
||||
contextWindow: 1_050_000,
|
||||
contextTokens: 272_000,
|
||||
maxTokens: 128_000,
|
||||
},
|
||||
fallback,
|
||||
);
|
||||
}
|
||||
if (lower === "gpt-5.4-pro") {
|
||||
return cloneTemplate(
|
||||
template,
|
||||
modelId,
|
||||
@@ -213,7 +230,7 @@ function buildDynamicModel(
|
||||
provider: "openai-codex",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: OPENAI_CODEX_BASE_URL,
|
||||
cost: { input: 2.5, output: 15, cacheRead: 0.25, cacheWrite: 0 },
|
||||
cost: { input: 30, output: 180, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_050_000,
|
||||
contextTokens: 272_000,
|
||||
maxTokens: 128_000,
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
buildOpenAICodexForwardCompatExpectation,
|
||||
makeModel,
|
||||
mockDiscoveredModel,
|
||||
OPENAI_CODEX_TEMPLATE_MODEL,
|
||||
mockOpenAICodexTemplateModel,
|
||||
resetMockDiscoverModels,
|
||||
} from "./model.test-harness.js";
|
||||
@@ -827,6 +828,156 @@ describe("resolveModel", () => {
|
||||
expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.4"));
|
||||
});
|
||||
|
||||
it("upgrades stale exact openai-codex gpt-5.4 registry metadata via forward-compat", () => {
|
||||
vi.mocked(discoverModels).mockReturnValue({
|
||||
find: vi.fn((provider: string, modelId: string) => {
|
||||
if (provider !== "openai-codex") {
|
||||
return null;
|
||||
}
|
||||
if (modelId === "gpt-5.4") {
|
||||
return {
|
||||
...OPENAI_CODEX_TEMPLATE_MODEL,
|
||||
id: "gpt-5.4",
|
||||
name: "GPT-5.4",
|
||||
contextWindow: 272000,
|
||||
};
|
||||
}
|
||||
if (modelId === "gpt-5.3-codex") {
|
||||
return {
|
||||
...OPENAI_CODEX_TEMPLATE_MODEL,
|
||||
id: "gpt-5.3-codex",
|
||||
name: "GPT-5.3 Codex",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
} as unknown as ReturnType<typeof discoverModels>);
|
||||
|
||||
const result = resolveModelForTest("openai-codex", "gpt-5.4", "/tmp/agent");
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.4",
|
||||
contextWindow: 1_050_000,
|
||||
maxTokens: 128000,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not downgrade exact openai-codex gpt-5.3-codex registry metadata", () => {
|
||||
vi.mocked(discoverModels).mockReturnValue({
|
||||
find: vi.fn((provider: string, modelId: string) => {
|
||||
if (provider !== "openai-codex") {
|
||||
return null;
|
||||
}
|
||||
if (modelId === "gpt-5.3-codex") {
|
||||
return {
|
||||
...OPENAI_CODEX_TEMPLATE_MODEL,
|
||||
id: "gpt-5.3-codex",
|
||||
name: "GPT-5.3 Codex",
|
||||
contextWindow: 272000,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
} as unknown as ReturnType<typeof discoverModels>);
|
||||
|
||||
const result = resolveModelForTest("openai-codex", "gpt-5.3-codex", "/tmp/agent");
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.3-codex",
|
||||
contextWindow: 272000,
|
||||
maxTokens: 128000,
|
||||
});
|
||||
});
|
||||
|
||||
it("canonicalizes the legacy openai-codex gpt-5.4-codex alias at runtime", () => {
|
||||
mockOpenAICodexTemplateModel(discoverModels);
|
||||
|
||||
const result = resolveModelForTest("openai-codex", "gpt-5.4-codex", "/tmp/agent");
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject(buildOpenAICodexForwardCompatExpectation("gpt-5.4"));
|
||||
expect(result.model?.id).toBe("gpt-5.4");
|
||||
expect(result.model?.name).toBe("gpt-5.4");
|
||||
});
|
||||
|
||||
it("applies canonical openai-codex overrides when resolving the gpt-5.4-codex alias", () => {
|
||||
mockOpenAICodexTemplateModel(discoverModels);
|
||||
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
"openai-codex": {
|
||||
baseUrl: "https://proxy.example.com/backend-api",
|
||||
api: "openai-codex-responses",
|
||||
models: [
|
||||
{
|
||||
...makeModel("gpt-5.4"),
|
||||
contextWindow: 123456,
|
||||
contextTokens: 65432,
|
||||
maxTokens: 7777,
|
||||
reasoning: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveModelForTest("openai-codex", "gpt-5.4-codex", "/tmp/agent", cfg);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.4",
|
||||
api: "openai-codex-responses",
|
||||
baseUrl: "https://proxy.example.com/backend-api",
|
||||
contextWindow: 123456,
|
||||
contextTokens: 65432,
|
||||
maxTokens: 7777,
|
||||
reasoning: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers alias-specific overrides over canonical ones for gpt-5.4-codex", () => {
|
||||
mockOpenAICodexTemplateModel(discoverModels);
|
||||
|
||||
const cfg = {
|
||||
models: {
|
||||
providers: {
|
||||
"openai-codex": {
|
||||
api: "openai-codex-responses",
|
||||
models: [
|
||||
{
|
||||
...makeModel("gpt-5.4"),
|
||||
contextWindow: 222222,
|
||||
maxTokens: 22222,
|
||||
},
|
||||
{
|
||||
...makeModel("gpt-5.4-codex"),
|
||||
contextWindow: 111111,
|
||||
maxTokens: 11111,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const result = resolveModelForTest("openai-codex", "gpt-5.4-codex", "/tmp/agent", cfg);
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.model).toMatchObject({
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.4",
|
||||
contextWindow: 111111,
|
||||
maxTokens: 11111,
|
||||
});
|
||||
});
|
||||
|
||||
it("builds an openai-codex fallback for gpt-5.4-mini", () => {
|
||||
mockOpenAICodexTemplateModel(discoverModels);
|
||||
|
||||
|
||||
@@ -96,6 +96,24 @@ function resolveRuntimeHooks(params?: {
|
||||
return params?.runtimeHooks ?? DEFAULT_PROVIDER_RUNTIME_HOOKS;
|
||||
}
|
||||
|
||||
function canonicalizeLegacyResolvedModel(params: {
|
||||
provider: string;
|
||||
model: Model<Api>;
|
||||
}): Model<Api> {
|
||||
if (
|
||||
normalizeProviderId(params.provider) !== "openai-codex" ||
|
||||
params.model.id.trim().toLowerCase() !== "gpt-5.4-codex"
|
||||
) {
|
||||
return params.model;
|
||||
}
|
||||
return {
|
||||
...params.model,
|
||||
id: "gpt-5.4",
|
||||
name:
|
||||
params.model.name.trim().toLowerCase() === "gpt-5.4-codex" ? "gpt-5.4" : params.model.name,
|
||||
};
|
||||
}
|
||||
|
||||
function applyResolvedTransportFallback(params: {
|
||||
provider: string;
|
||||
cfg?: OpenClawConfig;
|
||||
@@ -184,10 +202,13 @@ function normalizeResolvedModel(params: {
|
||||
runtimeHooks,
|
||||
model: compatNormalized ?? pluginNormalized ?? normalizedInputModel,
|
||||
});
|
||||
return normalizeResolvedProviderModel({
|
||||
return canonicalizeLegacyResolvedModel({
|
||||
provider: params.provider,
|
||||
model:
|
||||
fallbackTransportNormalized ?? compatNormalized ?? pluginNormalized ?? normalizedInputModel,
|
||||
model: normalizeResolvedProviderModel({
|
||||
provider: params.provider,
|
||||
model:
|
||||
fallbackTransportNormalized ?? compatNormalized ?? pluginNormalized ?? normalizedInputModel,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -270,7 +291,11 @@ function applyConfiguredProviderOverrides(params: {
|
||||
headers: sanitizeModelHeaders(discoveredModel.headers, { stripSecretRefMarkers: true }),
|
||||
};
|
||||
}
|
||||
const configuredModel = providerConfig.models?.find((candidate) => candidate.id === modelId);
|
||||
const configuredModel =
|
||||
providerConfig.models?.find((candidate) => candidate.id === modelId) ??
|
||||
(discoveredModel.id !== modelId
|
||||
? providerConfig.models?.find((candidate) => candidate.id === discoveredModel.id)
|
||||
: undefined);
|
||||
const discoveredHeaders = sanitizeModelHeaders(discoveredModel.headers, {
|
||||
stripSecretRefMarkers: true,
|
||||
});
|
||||
|
||||
@@ -169,6 +169,14 @@ export const mockedResolveContextWindowInfo = vi.fn(() => ({
|
||||
tokens: 200000,
|
||||
source: "model",
|
||||
}));
|
||||
export const mockedFormatContextWindowWarningMessage = vi.fn(
|
||||
(params: { provider: string; modelId: string; guard: { tokens: number; source: string } }) =>
|
||||
`low context window: ${params.provider}/${params.modelId} ctx=${params.guard.tokens} source=${params.guard.source}`,
|
||||
);
|
||||
export const mockedFormatContextWindowBlockMessage = vi.fn(
|
||||
(params: { guard: { tokens: number; source: string } }) =>
|
||||
`Model context window too small (${params.guard.tokens} tokens; source=${params.guard.source}). Minimum is 1000.`,
|
||||
);
|
||||
export const mockedGetApiKeyForModel = vi.fn(
|
||||
async ({ profileId }: { profileId?: string } = {}) => ({
|
||||
apiKey: "test-key",
|
||||
@@ -300,6 +308,16 @@ export function resetRunOverflowCompactionHarnessMocks(): void {
|
||||
tokens: 200000,
|
||||
source: "model",
|
||||
});
|
||||
mockedFormatContextWindowWarningMessage.mockReset();
|
||||
mockedFormatContextWindowWarningMessage.mockImplementation(
|
||||
(params: { provider: string; modelId: string; guard: { tokens: number; source: string } }) =>
|
||||
`low context window: ${params.provider}/${params.modelId} ctx=${params.guard.tokens} source=${params.guard.source}`,
|
||||
);
|
||||
mockedFormatContextWindowBlockMessage.mockReset();
|
||||
mockedFormatContextWindowBlockMessage.mockImplementation(
|
||||
(params: { guard: { tokens: number; source: string } }) =>
|
||||
`Model context window too small (${params.guard.tokens} tokens; source=${params.guard.source}). Minimum is 1000.`,
|
||||
);
|
||||
mockedGetApiKeyForModel.mockReset();
|
||||
mockedGetApiKeyForModel.mockImplementation(
|
||||
async ({ profileId }: { profileId?: string } = {}) => ({
|
||||
@@ -443,6 +461,8 @@ export async function loadRunOverflowCompactionHarness(): Promise<{
|
||||
CONTEXT_WINDOW_HARD_MIN_TOKENS: 1000,
|
||||
CONTEXT_WINDOW_WARN_BELOW_TOKENS: 5000,
|
||||
evaluateContextWindowGuard: mockedEvaluateContextWindowGuard,
|
||||
formatContextWindowBlockMessage: mockedFormatContextWindowBlockMessage,
|
||||
formatContextWindowWarningMessage: mockedFormatContextWindowWarningMessage,
|
||||
resolveContextWindowInfo: mockedResolveContextWindowInfo,
|
||||
}));
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user