Compare commits

..

23 Commits

Author SHA1 Message Date
Peter Steinberger
188aff1b8b refactor: stage plugin enable-state resolution 2026-03-09 04:14:53 +00:00
Peter Steinberger
1b837a6b24 refactor: unify tool policy allow merging 2026-03-09 04:14:46 +00:00
Ayaan Zaidi
26e76f9a61 fix: dedupe inbound Telegram DM replies per agent (#40519)
Merged via squash.

Prepared head SHA: 6e235e7d1f
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-09 09:31:05 +05:30
Peter Steinberger
8befd88119 build(protocol): sync generated swift models 2026-03-09 03:49:50 +00:00
Peter Steinberger
99cbda83a2 fix(media): accept reader read result type 2026-03-09 03:49:50 +00:00
Peter Steinberger
e8775cda93 fix(agents): re-expose configured tools under restrictive profiles 2026-03-09 03:49:50 +00:00
Tak Hoffman
ef36cb8cbc chore(acpx): move runtime test fixtures to test-utils (openclaw#40548)
Verified:
- pnpm install --frozen-lockfile
- pnpm build
- pnpm check
- pnpm test:macmini
2026-03-08 22:47:04 -05:00
Ayaan Zaidi
f114a5c638 test: fix android talk config contract fixture 2026-03-09 09:15:49 +05:30
Kyle
a438ff4397 fix(plugin-sdk): remove remaining bundled plugin src imports (openclaw#39638)
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Kyle <3477429+kyledh@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-08 22:32:45 -05:00
Kesku
adec8b28bb alphabetize web search providers (#40259)
Merged via squash.

Prepared head SHA: be6350e5ae
Co-authored-by: kesku <62210496+kesku@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-09 08:54:54 +05:30
Mariano
e3df94365b ACP: add optional ingress provenance receipts (#40473)
Merged via squash.

Prepared head SHA: b63e46dd94
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
2026-03-09 04:19:03 +01:00
Tyson Cung
4d501e4ccf fix(telegram): add download timeout to prevent polling loop hang (#40098)
Merged via squash.

Prepared head SHA: abdfa1a35f
Co-authored-by: tysoncung <45380903+tysoncung@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
2026-03-09 08:29:21 +05:30
yuweuii
f6243916b5 fix(models): use 1M context for openai-codex gpt-5.4 (#37876)
Merged via squash.

Prepared head SHA: c41020779e
Co-authored-by: yuweuii <82372187+yuweuii@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-08 18:23:49 -07:00
Radek Sienkiewicz
b34158086a docs(changelog): correct Control UI contributor credit (#40420)
Merged via squash.

Prepared head SHA: e4295fe18b
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-09 02:18:30 +01:00
Vincent Koc
eabda6e3a4 fix(tests): correct security check failure 2026-03-08 18:13:35 -07:00
Vincent Koc
6d5e142b93 Docker: improve build cache reuse (#40351)
* Docker: improve build cache reuse

* Tests: cover Docker build cache layout

* Docker: fix sandbox cache mount continuations

* Docker: document qr-import manifest scope

* Docker: narrow e2e install inputs

* CI: cache Docker builds in workflows

* CI: route sandbox smoke through setup script

* CI: keep sandbox smoke on script path
2026-03-08 17:57:46 -07:00
Radek Sienkiewicz
4f42c03a49 gateway: fix global Control UI 404s for symlinked wrappers and bundled package roots (#40385)
Merged via squash.

Prepared head SHA: 567b3ed684
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-09 01:50:42 +01:00
Peter Steinberger
13bd3db307 chore(docs): drop refactor cleanup tracker 2026-03-09 00:26:20 +00:00
Peter Steinberger
ff4745fc3f refactor(models): split provider discovery helpers 2026-03-09 00:26:20 +00:00
Peter Steinberger
c29b098744 refactor(models): split models.json planning from writes 2026-03-09 00:26:20 +00:00
Peter Steinberger
24b53fcf47 refactor(agents): extract provider model normalization 2026-03-09 00:26:20 +00:00
Peter Steinberger
dfc18b7a2b refactor(models): extract list row builders 2026-03-09 00:26:20 +00:00
Peter Steinberger
141738f717 refactor: harden browser runtime profile handling 2026-03-09 00:25:43 +00:00
132 changed files with 3439 additions and 1431 deletions

View File

@@ -11584,7 +11584,7 @@
"filename": "src/agents/pi-embedded-runner/model.ts",
"hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c",
"is_verified": false,
"line_number": 331
"line_number": 279
}
],
"src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts": [
@@ -13035,5 +13035,5 @@
}
]
},
"generated_at": "2026-03-09T01:01:03Z"
"generated_at": "2026-03-09T01:11:58Z"
}

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
- Extensions/ACPX tests: move the shared runtime fixture helper from `src/runtime-internals/` to `src/test-utils/` so the test-only helper no longer looks like shipped runtime code.
- TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7.
- Tools/Brave web search: add opt-in `tools.web.search.brave.mode: "llm-context"` so `web_search` can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp.
- Talk mode: add top-level `talk.silenceTimeoutMs` config so Talk waits a configurable amount of silence before auto-sending the current transcript, while keeping each platform's existing default pause window when unset. (#39607) Thanks @danodoesdesign. Fixes #17147.
@@ -14,6 +15,8 @@ Docs: https://docs.openclaw.ai
- macOS/onboarding: add a remote gateway token field for remote mode, preserve existing non-plaintext `gateway.remote.token` config values until explicitly replaced, and warn when the loaded token shape cannot be used directly from the macOS app. (#40187, supersedes #34614) Thanks @cgdusek.
- CLI/backup: add `openclaw backup create` and `openclaw backup verify` for local state archives, including `--only-config`, `--no-include-workspace`, manifest/payload validation, and backup guidance in destructive flows. (#40163) thanks @shichangs.
- CLI/backup: improve archive naming for date sorting, add config-only backup mode, and harden backup planning, publication, and verification edge cases. (#40163) Thanks @gumadeiras.
- ACP/Provenance: add optional ACP ingress provenance metadata and visible receipt injection (`openclaw acp --provenance off|meta|meta+receipt`) so OpenClaw agents can retain and report ACP-origin context with session trace IDs. (#40473) thanks @mbelinky.
- Tools/web search: alphabetize provider ordering across runtime selection, onboarding/configure pickers, and config metadata, so provider lists stay neutral and multi-key auto-detect now prefers Grok before Kimi. (#40259) thanks @kesku.
### Breaking
@@ -46,6 +49,11 @@ Docs: https://docs.openclaw.ai
- TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc.
- Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232)
- Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending.
- Gateway/Control UI: resolve bundled dashboard assets through symlinked global wrappers and auto-detected package roots, while keeping configured and custom roots on the strict hardlink boundary. (#40385) Thanks @LarytheLord.
- Docs/Changelog: correct the contributor credit for the bundled Control UI global-install fix to @LarytheLord. (#40420) Thanks @velvet-shark.
- Models/openai-codex GPT-5.4 forward-compat: use the GPT-5.4 1,050,000-token context window and 128,000 max tokens for `openai-codex/gpt-5.4` instead of inheriting stale legacy Codex limits in resolver fallbacks and model listing. (#37876) thanks @yuweuii.
- Telegram/media downloads: time out only stalled body reads so polling recovers from hung file downloads without aborting slow downloads that are still streaming data. (#40098) thanks @tysoncung.
- Telegram/DM routing: dedupe inbound Telegram DMs per agent instead of per session key so the same DM cannot trigger duplicate replies when both `agent:main:main` and `agent:main:telegram:direct:<id>` resolve for one agent. Fixes #40005. Supersedes #40116. (#40519) thanks @obviyus.
## 2026.3.7
@@ -761,6 +769,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/macOS restart: remove self-issued `launchctl kickstart -k` from launchd supervised restart path to prevent race with launchd's async bootout state machine that permanently unloads the LaunchAgent. With `ThrottleInterval=1` (current default), `exit(0)` + `KeepAlive=true` restarts the service within ~1s without the race condition. (#39760) Landed from contributor PR #39763 by @daymade. Thanks @daymade.
- Plugin SDK/bundled subpath contracts: add regression coverage for newly routed bundled-plugin SDK exports so BlueBubbles, Mattermost, Nextcloud Talk, and Twitch subpath symbols stay pinned during future plugin-sdk cleanup. (#39638)
- Exec/system.run env sanitization: block dangerous override-only env pivots such as `GIT_SSH_COMMAND`, editor/pager hooks, and `GIT_CONFIG_` / `NPM_CONFIG_` override prefixes so allowlisted tools cannot smuggle helper command execution through subprocess environment overrides. Thanks @tdjackey and @SnailSploit for reporting.
- Network/fetch guard redirect auth stripping: switch cross-origin redirect handling in `fetchWithSsrFGuard` from a narrow sensitive-header denylist to a safe-header allowlist so custom auth headers like `X-Api-Key` and `Private-Token` no longer leak on origin changes. Thanks @Rickidevs for reporting.
- Security/Sandbox media reads: eliminate sandbox media TOCTOU symlink-retarget escapes by enforcing root-scoped boundary-safe reads at attachment/image load time and consolidating shared safe-read helpers across sandbox media callsites. This ships in the next npm release. Thanks @tdjackey for reporting.

View File

@@ -3257,6 +3257,8 @@ public struct ChatSendParams: Codable, Sendable {
public let deliver: Bool?
public let attachments: [AnyCodable]?
public let timeoutms: Int?
public let systeminputprovenance: [String: AnyCodable]?
public let systemprovenancereceipt: String?
public let idempotencykey: String
public init(
@@ -3266,6 +3268,8 @@ public struct ChatSendParams: Codable, Sendable {
deliver: Bool?,
attachments: [AnyCodable]?,
timeoutms: Int?,
systeminputprovenance: [String: AnyCodable]?,
systemprovenancereceipt: String?,
idempotencykey: String)
{
self.sessionkey = sessionkey
@@ -3274,6 +3278,8 @@ public struct ChatSendParams: Codable, Sendable {
self.deliver = deliver
self.attachments = attachments
self.timeoutms = timeoutms
self.systeminputprovenance = systeminputprovenance
self.systemprovenancereceipt = systemprovenancereceipt
self.idempotencykey = idempotencykey
}
@@ -3284,6 +3290,8 @@ public struct ChatSendParams: Codable, Sendable {
case deliver
case attachments
case timeoutms = "timeoutMs"
case systeminputprovenance = "systemInputProvenance"
case systemprovenancereceipt = "systemProvenanceReceipt"
case idempotencykey = "idempotencyKey"
}
}

View File

@@ -3257,6 +3257,8 @@ public struct ChatSendParams: Codable, Sendable {
public let deliver: Bool?
public let attachments: [AnyCodable]?
public let timeoutms: Int?
public let systeminputprovenance: [String: AnyCodable]?
public let systemprovenancereceipt: String?
public let idempotencykey: String
public init(
@@ -3266,6 +3268,8 @@ public struct ChatSendParams: Codable, Sendable {
deliver: Bool?,
attachments: [AnyCodable]?,
timeoutms: Int?,
systeminputprovenance: [String: AnyCodable]?,
systemprovenancereceipt: String?,
idempotencykey: String)
{
self.sessionkey = sessionkey
@@ -3274,6 +3278,8 @@ public struct ChatSendParams: Codable, Sendable {
self.deliver = deliver
self.attachments = attachments
self.timeoutms = timeoutms
self.systeminputprovenance = systeminputprovenance
self.systemprovenancereceipt = systemprovenancereceipt
self.idempotencykey = idempotencykey
}
@@ -3284,6 +3290,8 @@ public struct ChatSendParams: Codable, Sendable {
case deliver
case attachments
case timeoutms = "timeoutMs"
case systeminputprovenance = "systemInputProvenance"
case systemprovenancereceipt = "systemProvenanceReceipt"
case idempotencykey = "idempotencyKey"
}
}

View File

@@ -96,6 +96,52 @@ Each ACP session maps to a single Gateway session key. One agent can have many
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
the key or label.
## Use from `acpx` (Codex, Claude, other ACP clients)
If you want a coding agent such as Codex or Claude Code to talk to your
OpenClaw bot over ACP, use `acpx` with its built-in `openclaw` target.
Typical flow:
1. Run the Gateway and make sure the ACP bridge can reach it.
2. Point `acpx openclaw` at `openclaw acp`.
3. Target the OpenClaw session key you want the coding agent to use.
Examples:
```bash
# One-shot request into your default OpenClaw ACP session
acpx openclaw exec "Summarize the active OpenClaw session state."
# Persistent named session for follow-up turns
acpx openclaw sessions ensure --name codex-bridge
acpx openclaw -s codex-bridge --cwd /path/to/repo \
"Ask my OpenClaw work agent for recent context relevant to this repo."
```
If you want `acpx openclaw` to target a specific Gateway and session key every
time, override the `openclaw` agent command in `~/.acpx/config.json`:
```json
{
"agents": {
"openclaw": {
"command": "env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 openclaw acp --url ws://127.0.0.1:18789 --token-file ~/.openclaw/gateway.token --session agent:main:main"
}
}
}
```
For a repo-local OpenClaw checkout, use the direct CLI entrypoint instead of the
dev runner so the ACP stream stays clean. For example:
```bash
env OPENCLAW_HIDE_BANNER=1 OPENCLAW_SUPPRESS_NOTES=1 node openclaw.mjs acp ...
```
This is the easiest way to let Codex, Claude Code, or another ACP-aware client
pull contextual information from an OpenClaw agent without scraping a terminal.
## Zed editor setup
Add a custom ACP agent in `~/.config/zed/settings.json` (or use Zeds Settings UI):

View File

@@ -43,9 +43,9 @@ The table above is alphabetical. If no `provider` is explicitly set, runtime aut
1. **Brave**`BRAVE_API_KEY` env var or `tools.web.search.apiKey` config
2. **Gemini**`GEMINI_API_KEY` env var or `tools.web.search.gemini.apiKey` config
3. **Kimi**`KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `tools.web.search.kimi.apiKey` config
4. **Perplexity**`PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` config
5. **Grok**`XAI_API_KEY` env var or `tools.web.search.grok.apiKey` config
3. **Grok**`XAI_API_KEY` env var or `tools.web.search.grok.apiKey` config
4. **Kimi**`KIMI_API_KEY` / `MOONSHOT_API_KEY` env var or `tools.web.search.kimi.apiKey` config
5. **Perplexity**`PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey` config
If no keys are found, it falls back to Brave (you'll get a missing-key error prompting you to configure one).
@@ -212,10 +212,10 @@ Search the web using your configured provider.
- `tools.web.search.enabled` must not be `false` (default: enabled)
- API key for your chosen provider:
- **Brave**: `BRAVE_API_KEY` or `tools.web.search.apiKey`
- **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey`
- **Gemini**: `GEMINI_API_KEY` or `tools.web.search.gemini.apiKey`
- **Grok**: `XAI_API_KEY` or `tools.web.search.grok.apiKey`
- **Kimi**: `KIMI_API_KEY`, `MOONSHOT_API_KEY`, or `tools.web.search.kimi.apiKey`
- **Perplexity**: `PERPLEXITY_API_KEY`, `OPENROUTER_API_KEY`, or `tools.web.search.perplexity.apiKey`
### Config

View File

@@ -2,13 +2,13 @@ import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
import {
cleanupMockRuntimeFixtures,
createMockRuntimeFixture,
NOOP_LOGGER,
readMockRuntimeLogEntries,
} from "./runtime-internals/test-fixtures.js";
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
} from "./test-utils/runtime-fixtures.js";
let sharedFixture: Awaited<ReturnType<typeof createMockRuntimeFixture>> | null = null;
let missingCommandRuntime: AcpxRuntime | null = null;

View File

@@ -1,5 +1,8 @@
import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk";
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles";
import {
AllowFromEntrySchema,
buildCatchallMultiAccountChannelSchema,
} from "openclaw/plugin-sdk/compat";
import { z } from "zod";
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";

View File

@@ -1,4 +1,4 @@
import { parseFiniteNumber } from "../../../src/infra/parse-finite-number.js";
import { parseFiniteNumber } from "openclaw/plugin-sdk/bluebubbles";
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
import type { BlueBubblesAttachment } from "./types.js";

View File

@@ -1,5 +1,5 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
const runtimeStore = createPluginRuntimeStore<PluginRuntime>("BlueBubbles runtime not initialized");
type LegacyRuntimeLogShape = { log?: (message: string) => void };

View File

@@ -1,4 +1,4 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
buildAccountScopedDmSecurityPolicy,
collectOpenProviderGroupPolicyWarnings,

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/discord";
const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/feishu";
const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } =

View File

@@ -1,4 +1,4 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
buildAccountScopedDmSecurityPolicy,
buildOpenGroupPolicyConfigureRouteAllowlistWarning,

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/googlechat";
const { setRuntime: setGoogleChatRuntime, getRuntime: getGoogleChatRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/imessage";
const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/irc";
const { setRuntime: setIrcRuntime, getRuntime: getIrcRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/line";
const { setRuntime: setLineRuntime, getRuntime: getLineRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix";
const { setRuntime: setMatrixRuntime, getRuntime: getMatrixRuntime } =

View File

@@ -19,6 +19,7 @@ import {
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntryIfEnabled,
isDangerousNameMatchingEnabled,
parseStrictPositiveInteger,
registerPluginHttpRoute,
resolveControlCommandGate,
readStoreAllowFromForDmPolicy,
@@ -30,7 +31,6 @@ import {
listSkillCommandsForAgents,
type HistoryEntry,
} from "openclaw/plugin-sdk/mattermost";
import { parseStrictPositiveInteger } from "../../../../src/infra/parse-finite-number.js";
import { getMattermostRuntime } from "../runtime.js";
import { resolveMattermostAccount } from "./accounts.js";
import {

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/mattermost";
const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/msteams";
const { setRuntime: setMSTeamsRuntime, getRuntime: getMSTeamsRuntime } =

View File

@@ -15,11 +15,11 @@ import {
deleteAccountFromConfigSection,
normalizeAccountId,
setAccountEnabledInConfigSection,
waitForAbortSignal,
type ChannelPlugin,
type OpenClawConfig,
type ChannelSetupInput,
} from "openclaw/plugin-sdk/nextcloud-talk";
import { waitForAbortSignal } from "../../../src/infra/abort-signal.js";
import {
listNextcloudTalkAccountIds,
resolveDefaultNextcloudTalkAccountId,

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/nextcloud-talk";
const { setRuntime: setNextcloudTalkRuntime, getRuntime: getNextcloudTalkRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/nostr";
const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/signal";
const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } =

View File

@@ -1,4 +1,4 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
buildAccountScopedDmSecurityPolicy,
collectOpenProviderGroupPolicyWarnings,

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/slack";
const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/synology-chat";
const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } =

View File

@@ -1,4 +1,4 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
import {
collectAllowlistProviderGroupPolicyWarnings,
buildAccountScopedDmSecurityPolicy,

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/telegram";
const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/tlon";
const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } =

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/twitch";
const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } =

View File

@@ -9,8 +9,11 @@
* 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
*/
import type { OpenClawConfig } from "../../../src/config/config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
type OpenClawConfig,
} from "openclaw/plugin-sdk/twitch";
export type TwitchTokenSource = "env" | "config" | "none";

View File

@@ -5,26 +5,24 @@
* from OpenClaw core.
*/
import type {
ChannelGatewayContext,
ChannelOutboundAdapter,
ChannelOutboundContext,
ChannelResolveKind,
ChannelResolveResult,
ChannelStatusAdapter,
} from "../../../src/channels/plugins/types.adapters.js";
import type {
ChannelAccountSnapshot,
ChannelCapabilities,
ChannelGatewayContext,
ChannelLogSink,
ChannelMessageActionAdapter,
ChannelMessageActionContext,
ChannelMeta,
} from "../../../src/channels/plugins/types.core.js";
import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { OutboundDeliveryResult } from "../../../src/infra/outbound/deliver.js";
import type { RuntimeEnv } from "../../../src/runtime.js";
ChannelOutboundAdapter,
ChannelOutboundContext,
ChannelPlugin,
ChannelResolveKind,
ChannelResolveResult,
ChannelStatusAdapter,
OpenClawConfig,
OutboundDeliveryResult,
RuntimeEnv,
} from "openclaw/plugin-sdk/twitch";
// ============================================================================
// Twitch-Specific Types

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/whatsapp";
const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } =

View File

@@ -1,4 +1,7 @@
import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk";
import {
AllowFromEntrySchema,
buildCatchallMultiAccountChannelSchema,
} from "openclaw/plugin-sdk/compat";
import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo";
import { z } from "zod";
import { buildSecretInputSchema } from "./secret-input.js";

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/zalo";
const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } =

View File

@@ -1,4 +1,7 @@
import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk";
import {
AllowFromEntrySchema,
buildCatchallMultiAccountChannelSchema,
} from "openclaw/plugin-sdk/compat";
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/zalouser";
import { z } from "zod";

View File

@@ -1,4 +1,4 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
import type { PluginRuntime } from "openclaw/plugin-sdk/zalouser";
const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } =

View File

@@ -10,7 +10,7 @@ import { isMainModule } from "../infra/is-main.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { readSecretFromFile } from "./secret-file.js";
import { AcpGatewayAgent } from "./translator.js";
import type { AcpServerOptions } from "./types.js";
import { normalizeAcpProvenanceMode, type AcpServerOptions } from "./types.js";
export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
const cfg = loadConfig();
@@ -186,6 +186,15 @@ function parseArgs(args: string[]): AcpServerOptions {
opts.prefixCwd = false;
continue;
}
if (arg === "--provenance") {
const provenanceMode = normalizeAcpProvenanceMode(args[i + 1]);
if (!provenanceMode) {
throw new Error("Invalid --provenance value. Use off, meta, or meta+receipt.");
}
opts.provenanceMode = provenanceMode;
i += 1;
continue;
}
if (arg === "--verbose" || arg === "-v") {
opts.verbose = true;
continue;
@@ -226,6 +235,7 @@ Options:
--require-existing Fail if the session key/label does not exist
--reset-session Reset the session key before first use
--no-prefix-cwd Do not prefix prompts with the working directory
--provenance <mode> ACP provenance mode: off, meta, or meta+receipt
--verbose, -v Verbose logging to stderr
--help, -h Show this help message
`);

View File

@@ -81,4 +81,117 @@ describe("acp prompt cwd prefix", () => {
{ expectFinal: true },
);
});
it("injects system provenance metadata when enabled", async () => {
const sessionStore = createInMemorySessionStore();
sessionStore.createSession({
sessionId: "session-1",
sessionKey: "agent:main:main",
cwd: path.join(os.homedir(), "openclaw-test"),
});
const requestSpy = vi.fn(async (method: string) => {
if (method === "chat.send") {
throw new Error("stop-after-send");
}
return {};
});
const agent = new AcpGatewayAgent(
createAcpConnection(),
createAcpGateway(requestSpy as unknown as GatewayClient["request"]),
{
sessionStore,
provenanceMode: "meta",
},
);
await expect(
agent.prompt({
sessionId: "session-1",
prompt: [{ type: "text", text: "hello" }],
_meta: {},
} as unknown as PromptRequest),
).rejects.toThrow("stop-after-send");
expect(requestSpy).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
systemInputProvenance: {
kind: "external_user",
originSessionId: "session-1",
sourceChannel: "acp",
sourceTool: "openclaw_acp",
},
systemProvenanceReceipt: undefined,
}),
{ expectFinal: true },
);
});
it("injects a system provenance receipt when requested", async () => {
const sessionStore = createInMemorySessionStore();
sessionStore.createSession({
sessionId: "session-1",
sessionKey: "agent:main:main",
cwd: path.join(os.homedir(), "openclaw-test"),
});
const requestSpy = vi.fn(async (method: string) => {
if (method === "chat.send") {
throw new Error("stop-after-send");
}
return {};
});
const agent = new AcpGatewayAgent(
createAcpConnection(),
createAcpGateway(requestSpy as unknown as GatewayClient["request"]),
{
sessionStore,
provenanceMode: "meta+receipt",
},
);
await expect(
agent.prompt({
sessionId: "session-1",
prompt: [{ type: "text", text: "hello" }],
_meta: {},
} as unknown as PromptRequest),
).rejects.toThrow("stop-after-send");
expect(requestSpy).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
systemInputProvenance: {
kind: "external_user",
originSessionId: "session-1",
sourceChannel: "acp",
sourceTool: "openclaw_acp",
},
systemProvenanceReceipt: expect.stringContaining("[Source Receipt]"),
}),
{ expectFinal: true },
);
expect(requestSpy).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
systemProvenanceReceipt: expect.stringContaining("bridge=openclaw-acp"),
}),
{ expectFinal: true },
);
expect(requestSpy).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
systemProvenanceReceipt: expect.stringContaining("originSessionId=session-1"),
}),
{ expectFinal: true },
);
expect(requestSpy).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
systemProvenanceReceipt: expect.stringContaining("targetSession=agent:main:main"),
}),
{ expectFinal: true },
);
});
});

View File

@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import os from "node:os";
import type {
Agent,
AgentSideConnection,
@@ -61,6 +62,32 @@ type AcpGatewayAgentOptions = AcpServerOptions & {
const SESSION_CREATE_RATE_LIMIT_DEFAULT_MAX_REQUESTS = 120;
const SESSION_CREATE_RATE_LIMIT_DEFAULT_WINDOW_MS = 10_000;
function buildSystemInputProvenance(originSessionId: string) {
return {
kind: "external_user" as const,
originSessionId,
sourceChannel: "acp",
sourceTool: "openclaw_acp",
};
}
function buildSystemProvenanceReceipt(params: {
cwd: string;
sessionId: string;
sessionKey: string;
}) {
return [
"[Source Receipt]",
"bridge=openclaw-acp",
`originHost=${os.hostname()}`,
`originCwd=${shortenHomePath(params.cwd)}`,
`acpSessionId=${params.sessionId}`,
`originSessionId=${params.sessionId}`,
`targetSession=${params.sessionKey}`,
"[/Source Receipt]",
].join("\n");
}
export class AcpGatewayAgent implements Agent {
private connection: AgentSideConnection;
private gateway: GatewayClient;
@@ -251,6 +278,17 @@ export class AcpGatewayAgent implements Agent {
const prefixCwd = meta.prefixCwd ?? this.opts.prefixCwd ?? true;
const displayCwd = shortenHomePath(session.cwd);
const message = prefixCwd ? `[Working directory: ${displayCwd}]\n\n${userText}` : userText;
const provenanceMode = this.opts.provenanceMode ?? "off";
const systemInputProvenance =
provenanceMode === "off" ? undefined : buildSystemInputProvenance(params.sessionId);
const systemProvenanceReceipt =
provenanceMode === "meta+receipt"
? buildSystemProvenanceReceipt({
cwd: session.cwd,
sessionId: params.sessionId,
sessionKey: session.sessionKey,
})
: undefined;
// Defense-in-depth: also check the final assembled message (includes cwd prefix)
if (Buffer.byteLength(message, "utf-8") > MAX_PROMPT_BYTES) {
@@ -281,6 +319,8 @@ export class AcpGatewayAgent implements Agent {
thinking: readString(params._meta, ["thinking", "thinkingLevel"]),
deliver: readBool(params._meta, ["deliver"]),
timeoutMs: readNumber(params._meta, ["timeoutMs"]),
systemInputProvenance,
systemProvenanceReceipt,
},
{ expectFinal: true },
)

View File

@@ -1,6 +1,22 @@
import type { SessionId } from "@agentclientprotocol/sdk";
import { VERSION } from "../version.js";
export const ACP_PROVENANCE_MODE_VALUES = ["off", "meta", "meta+receipt"] as const;
export type AcpProvenanceMode = (typeof ACP_PROVENANCE_MODE_VALUES)[number];
export function normalizeAcpProvenanceMode(
value: string | undefined,
): AcpProvenanceMode | undefined {
if (!value) {
return undefined;
}
const normalized = value.trim().toLowerCase();
return (ACP_PROVENANCE_MODE_VALUES as readonly string[]).includes(normalized)
? (normalized as AcpProvenanceMode)
: undefined;
}
export type AcpSession = {
sessionId: SessionId;
sessionKey: string;
@@ -20,6 +36,7 @@ export type AcpServerOptions = {
requireExistingSession?: boolean;
resetSession?: boolean;
prefixCwd?: boolean;
provenanceMode?: AcpProvenanceMode;
sessionCreateRateLimit?: {
maxRequests?: number;
windowMs?: number;

View File

@@ -363,7 +363,7 @@ describe("resolveForwardCompatModel", () => {
expectResolvedForwardCompat(model, { provider: "openai-codex", id: "gpt-5.4" });
expect(model?.api).toBe("openai-codex-responses");
expect(model?.baseUrl).toBe("https://chatgpt.com/backend-api");
expect(model?.contextWindow).toBe(272_000);
expect(model?.contextWindow).toBe(1_050_000);
expect(model?.maxTokens).toBe(128_000);
});

View File

@@ -12,6 +12,8 @@ const OPENAI_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.2"] as const;
const OPENAI_GPT_54_PRO_TEMPLATE_MODEL_IDS = ["gpt-5.2-pro", "gpt-5.2"] as const;
const OPENAI_CODEX_GPT_54_MODEL_ID = "gpt-5.4";
const OPENAI_CODEX_GPT_54_CONTEXT_TOKENS = 1_050_000;
const OPENAI_CODEX_GPT_54_MAX_TOKENS = 128_000;
const OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS = ["gpt-5.3-codex", "gpt-5.2-codex"] as const;
const OPENAI_CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex";
const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const;
@@ -123,9 +125,14 @@ function resolveOpenAICodexForwardCompatModel(
let templateIds: readonly string[];
let eligibleProviders: Set<string>;
let patch: Partial<Model<Api>> | undefined;
if (lower === OPENAI_CODEX_GPT_54_MODEL_ID) {
templateIds = OPENAI_CODEX_GPT_54_TEMPLATE_MODEL_IDS;
eligibleProviders = CODEX_GPT54_ELIGIBLE_PROVIDERS;
patch = {
contextWindow: OPENAI_CODEX_GPT_54_CONTEXT_TOKENS,
maxTokens: OPENAI_CODEX_GPT_54_MAX_TOKENS,
};
} else if (lower === OPENAI_CODEX_GPT_53_MODEL_ID) {
templateIds = OPENAI_CODEX_TEMPLATE_MODEL_IDS;
eligibleProviders = CODEX_GPT53_ELIGIBLE_PROVIDERS;
@@ -146,6 +153,7 @@ function resolveOpenAICodexForwardCompatModel(
...template,
id: trimmedModelId,
name: trimmedModelId,
...patch,
} as Model<Api>);
}
@@ -158,8 +166,8 @@ function resolveOpenAICodexForwardCompatModel(
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_TOKENS,
maxTokens: DEFAULT_CONTEXT_TOKENS,
contextWindow: patch?.contextWindow ?? DEFAULT_CONTEXT_TOKENS,
maxTokens: patch?.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
} as Model<Api>);
}

View File

@@ -0,0 +1,128 @@
import type { OpenClawConfig } from "../config/config.js";
import { isRecord } from "../utils.js";
import {
mergeProviders,
mergeWithExistingProviderSecrets,
type ExistingProviderConfig,
} from "./models-config.merge.js";
import {
normalizeProviders,
resolveImplicitProviders,
type ProviderConfig,
} from "./models-config.providers.js";
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
export type ModelsJsonPlan =
| {
action: "skip";
}
| {
action: "noop";
}
| {
action: "write";
contents: string;
};
async function resolveProvidersForModelsJson(params: {
cfg: OpenClawConfig;
agentDir: string;
env: NodeJS.ProcessEnv;
}): Promise<Record<string, ProviderConfig>> {
const { cfg, agentDir, env } = params;
const explicitProviders = cfg.models?.providers ?? {};
const implicitProviders = await resolveImplicitProviders({
agentDir,
config: cfg,
env,
explicitProviders,
});
return mergeProviders({
implicit: implicitProviders,
explicit: explicitProviders,
});
}
function resolveExplicitBaseUrlProviders(
providers: OpenClawConfig["models"] | undefined,
): ReadonlySet<string> {
return new Set(
Object.entries(providers?.providers ?? {})
.map(([key, provider]) => [key.trim(), provider] as const)
.filter(
([key, provider]) =>
Boolean(key) && typeof provider?.baseUrl === "string" && provider.baseUrl.trim(),
)
.map(([key]) => key),
);
}
async function resolveProvidersForMode(params: {
mode: NonNullable<ModelsConfig["mode"]>;
existingParsed: unknown;
providers: Record<string, ProviderConfig>;
secretRefManagedProviders: ReadonlySet<string>;
explicitBaseUrlProviders: ReadonlySet<string>;
}): Promise<Record<string, ProviderConfig>> {
if (params.mode !== "merge") {
return params.providers;
}
const existing = params.existingParsed;
if (!isRecord(existing) || !isRecord(existing.providers)) {
return params.providers;
}
const existingProviders = existing.providers as Record<
string,
NonNullable<ModelsConfig["providers"]>[string]
>;
return mergeWithExistingProviderSecrets({
nextProviders: params.providers,
existingProviders: existingProviders as Record<string, ExistingProviderConfig>,
secretRefManagedProviders: params.secretRefManagedProviders,
explicitBaseUrlProviders: params.explicitBaseUrlProviders,
});
}
export async function planOpenClawModelsJson(params: {
cfg: OpenClawConfig;
agentDir: string;
env: NodeJS.ProcessEnv;
existingRaw: string;
existingParsed: unknown;
}): Promise<ModelsJsonPlan> {
const { cfg, agentDir, env } = params;
const providers = await resolveProvidersForModelsJson({ cfg, agentDir, env });
if (Object.keys(providers).length === 0) {
return { action: "skip" };
}
const mode = cfg.models?.mode ?? "merge";
const secretRefManagedProviders = new Set<string>();
const normalizedProviders =
normalizeProviders({
providers,
agentDir,
env,
secretDefaults: cfg.secrets?.defaults,
secretRefManagedProviders,
}) ?? providers;
const mergedProviders = await resolveProvidersForMode({
mode,
existingParsed: params.existingParsed,
providers: normalizedProviders,
secretRefManagedProviders,
explicitBaseUrlProviders: resolveExplicitBaseUrlProviders(cfg.models),
});
const nextContents = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`;
if (params.existingRaw === nextContents) {
return { action: "noop" };
}
return {
action: "write",
contents: nextContents,
};
}

View File

@@ -0,0 +1,292 @@
import type { OpenClawConfig } from "../config/config.js";
import type { ModelDefinitionConfig } from "../config/types.models.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { KILOCODE_BASE_URL } from "../providers/kilocode-shared.js";
import {
discoverHuggingfaceModels,
HUGGINGFACE_BASE_URL,
HUGGINGFACE_MODEL_CATALOG,
buildHuggingfaceModelDefinition,
} from "./huggingface-models.js";
import { discoverKilocodeModels } from "./kilocode-models.js";
import { OLLAMA_NATIVE_BASE_URL } from "./ollama-stream.js";
import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js";
import { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.js";
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
const log = createSubsystemLogger("agents/model-providers");
const OLLAMA_BASE_URL = OLLAMA_NATIVE_BASE_URL;
const OLLAMA_API_BASE_URL = OLLAMA_BASE_URL;
const OLLAMA_SHOW_CONCURRENCY = 8;
const OLLAMA_SHOW_MAX_MODELS = 200;
const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000;
const OLLAMA_DEFAULT_MAX_TOKENS = 8192;
const OLLAMA_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const VLLM_BASE_URL = "http://127.0.0.1:8000/v1";
const VLLM_DEFAULT_CONTEXT_WINDOW = 128000;
const VLLM_DEFAULT_MAX_TOKENS = 8192;
const VLLM_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
interface OllamaModel {
name: string;
modified_at: string;
size: number;
digest: string;
details?: {
family?: string;
parameter_size?: string;
};
}
interface OllamaTagsResponse {
models: OllamaModel[];
}
type VllmModelsResponse = {
data?: Array<{
id?: string;
}>;
};
/**
* Derive the Ollama native API base URL from a configured base URL.
*
* Users typically configure `baseUrl` with a `/v1` suffix (e.g.
* `http://192.168.20.14:11434/v1`) for the OpenAI-compatible endpoint.
* The native Ollama API lives at the root (e.g. `/api/tags`), so we
* strip the `/v1` suffix when present.
*/
export function resolveOllamaApiBase(configuredBaseUrl?: string): string {
if (!configuredBaseUrl) {
return OLLAMA_API_BASE_URL;
}
// Strip trailing slash, then strip /v1 suffix if present
const trimmed = configuredBaseUrl.replace(/\/+$/, "");
return trimmed.replace(/\/v1$/i, "");
}
async function queryOllamaContextWindow(
apiBase: string,
modelName: string,
): Promise<number | undefined> {
try {
const response = await fetch(`${apiBase}/api/show`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: modelName }),
signal: AbortSignal.timeout(3000),
});
if (!response.ok) {
return undefined;
}
const data = (await response.json()) as { model_info?: Record<string, unknown> };
if (!data.model_info) {
return undefined;
}
for (const [key, value] of Object.entries(data.model_info)) {
if (key.endsWith(".context_length") && typeof value === "number" && Number.isFinite(value)) {
const contextWindow = Math.floor(value);
if (contextWindow > 0) {
return contextWindow;
}
}
}
return undefined;
} catch {
return undefined;
}
}
async function discoverOllamaModels(
baseUrl?: string,
opts?: { quiet?: boolean },
): Promise<ModelDefinitionConfig[]> {
if (process.env.VITEST || process.env.NODE_ENV === "test") {
return [];
}
try {
const apiBase = resolveOllamaApiBase(baseUrl);
const response = await fetch(`${apiBase}/api/tags`, {
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
if (!opts?.quiet) {
log.warn(`Failed to discover Ollama models: ${response.status}`);
}
return [];
}
const data = (await response.json()) as OllamaTagsResponse;
if (!data.models || data.models.length === 0) {
log.debug("No Ollama models found on local instance");
return [];
}
const modelsToInspect = data.models.slice(0, OLLAMA_SHOW_MAX_MODELS);
if (modelsToInspect.length < data.models.length && !opts?.quiet) {
log.warn(
`Capping Ollama /api/show inspection to ${OLLAMA_SHOW_MAX_MODELS} models (received ${data.models.length})`,
);
}
const discovered: ModelDefinitionConfig[] = [];
for (let index = 0; index < modelsToInspect.length; index += OLLAMA_SHOW_CONCURRENCY) {
const batch = modelsToInspect.slice(index, index + OLLAMA_SHOW_CONCURRENCY);
const batchDiscovered = await Promise.all(
batch.map(async (model) => {
const modelId = model.name;
const contextWindow = await queryOllamaContextWindow(apiBase, modelId);
const isReasoning =
modelId.toLowerCase().includes("r1") || modelId.toLowerCase().includes("reasoning");
return {
id: modelId,
name: modelId,
reasoning: isReasoning,
input: ["text"],
cost: OLLAMA_DEFAULT_COST,
contextWindow: contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW,
maxTokens: OLLAMA_DEFAULT_MAX_TOKENS,
} satisfies ModelDefinitionConfig;
}),
);
discovered.push(...batchDiscovered);
}
return discovered;
} catch (error) {
if (!opts?.quiet) {
log.warn(`Failed to discover Ollama models: ${String(error)}`);
}
return [];
}
}
async function discoverVllmModels(
baseUrl: string,
apiKey?: string,
): Promise<ModelDefinitionConfig[]> {
if (process.env.VITEST || process.env.NODE_ENV === "test") {
return [];
}
const trimmedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
const url = `${trimmedBaseUrl}/models`;
try {
const trimmedApiKey = apiKey?.trim();
const response = await fetch(url, {
headers: trimmedApiKey ? { Authorization: `Bearer ${trimmedApiKey}` } : undefined,
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
log.warn(`Failed to discover vLLM models: ${response.status}`);
return [];
}
const data = (await response.json()) as VllmModelsResponse;
const models = data.data ?? [];
if (models.length === 0) {
log.warn("No vLLM models found on local instance");
return [];
}
return models
.map((model) => ({ id: typeof model.id === "string" ? model.id.trim() : "" }))
.filter((model) => Boolean(model.id))
.map((model) => {
const modelId = model.id;
const lower = modelId.toLowerCase();
const isReasoning =
lower.includes("r1") || lower.includes("reasoning") || lower.includes("think");
return {
id: modelId,
name: modelId,
reasoning: isReasoning,
input: ["text"],
cost: VLLM_DEFAULT_COST,
contextWindow: VLLM_DEFAULT_CONTEXT_WINDOW,
maxTokens: VLLM_DEFAULT_MAX_TOKENS,
} satisfies ModelDefinitionConfig;
});
} catch (error) {
log.warn(`Failed to discover vLLM models: ${String(error)}`);
return [];
}
}
export async function buildVeniceProvider(): Promise<ProviderConfig> {
const models = await discoverVeniceModels();
return {
baseUrl: VENICE_BASE_URL,
api: "openai-completions",
models,
};
}
export async function buildOllamaProvider(
configuredBaseUrl?: string,
opts?: { quiet?: boolean },
): Promise<ProviderConfig> {
const models = await discoverOllamaModels(configuredBaseUrl, opts);
return {
baseUrl: resolveOllamaApiBase(configuredBaseUrl),
api: "ollama",
models,
};
}
export async function buildHuggingfaceProvider(discoveryApiKey?: string): Promise<ProviderConfig> {
const resolvedSecret = discoveryApiKey?.trim() ?? "";
const models =
resolvedSecret !== ""
? await discoverHuggingfaceModels(resolvedSecret)
: HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition);
return {
baseUrl: HUGGINGFACE_BASE_URL,
api: "openai-completions",
models,
};
}
export async function buildVercelAiGatewayProvider(): Promise<ProviderConfig> {
return {
baseUrl: VERCEL_AI_GATEWAY_BASE_URL,
api: "anthropic-messages",
models: await discoverVercelAiGatewayModels(),
};
}
export async function buildVllmProvider(params?: {
baseUrl?: string;
apiKey?: string;
}): Promise<ProviderConfig> {
const baseUrl = (params?.baseUrl?.trim() || VLLM_BASE_URL).replace(/\/+$/, "");
const models = await discoverVllmModels(baseUrl, params?.apiKey);
return {
baseUrl,
api: "openai-completions",
models,
};
}
/**
* Build the Kilocode provider with dynamic model discovery from the gateway
* API. Falls back to the static catalog on failure.
*/
export async function buildKilocodeProviderWithDiscovery(): Promise<ProviderConfig> {
const models = await discoverKilocodeModels();
return {
baseUrl: KILOCODE_BASE_URL,
api: "openai-completions",
models,
};
}

View File

@@ -1,12 +1,9 @@
import type { OpenClawConfig } from "../config/config.js";
import type { ModelDefinitionConfig } from "../config/types.models.js";
import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import {
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
} from "../providers/github-copilot-token.js";
import { KILOCODE_BASE_URL } from "../providers/kilocode-shared.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { discoverBedrockModels } from "./bedrock-discovery.js";
@@ -15,12 +12,14 @@ import {
resolveCloudflareAiGatewayBaseUrl,
} from "./cloudflare-ai-gateway.js";
import {
discoverHuggingfaceModels,
HUGGINGFACE_BASE_URL,
HUGGINGFACE_MODEL_CATALOG,
buildHuggingfaceModelDefinition,
} from "./huggingface-models.js";
import { discoverKilocodeModels } from "./kilocode-models.js";
buildHuggingfaceProvider,
buildKilocodeProviderWithDiscovery,
buildOllamaProvider,
buildVeniceProvider,
buildVercelAiGatewayProvider,
buildVllmProvider,
resolveOllamaApiBase,
} from "./models-config.providers.discovery.js";
import {
buildBytePlusCodingProvider,
buildBytePlusProvider,
@@ -63,222 +62,11 @@ import {
resolveEnvSecretRefHeaderValueMarker,
} from "./model-auth-markers.js";
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
import { OLLAMA_NATIVE_BASE_URL } from "./ollama-stream.js";
import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js";
import { discoverVercelAiGatewayModels, VERCEL_AI_GATEWAY_BASE_URL } from "./vercel-ai-gateway.js";
export { resolveOllamaApiBase } from "./models-config.providers.discovery.js";
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
const OLLAMA_BASE_URL = OLLAMA_NATIVE_BASE_URL;
const OLLAMA_API_BASE_URL = OLLAMA_BASE_URL;
const OLLAMA_SHOW_CONCURRENCY = 8;
const OLLAMA_SHOW_MAX_MODELS = 200;
const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000;
const OLLAMA_DEFAULT_MAX_TOKENS = 8192;
const OLLAMA_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const VLLM_BASE_URL = "http://127.0.0.1:8000/v1";
const VLLM_DEFAULT_CONTEXT_WINDOW = 128000;
const VLLM_DEFAULT_MAX_TOKENS = 8192;
const VLLM_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
const log = createSubsystemLogger("agents/model-providers");
interface OllamaModel {
name: string;
modified_at: string;
size: number;
digest: string;
details?: {
family?: string;
parameter_size?: string;
};
}
interface OllamaTagsResponse {
models: OllamaModel[];
}
type VllmModelsResponse = {
data?: Array<{
id?: string;
}>;
};
/**
* Derive the Ollama native API base URL from a configured base URL.
*
* Users typically configure `baseUrl` with a `/v1` suffix (e.g.
* `http://192.168.20.14:11434/v1`) for the OpenAI-compatible endpoint.
* The native Ollama API lives at the root (e.g. `/api/tags`), so we
* strip the `/v1` suffix when present.
*/
export function resolveOllamaApiBase(configuredBaseUrl?: string): string {
if (!configuredBaseUrl) {
return OLLAMA_API_BASE_URL;
}
// Strip trailing slash, then strip /v1 suffix if present
const trimmed = configuredBaseUrl.replace(/\/+$/, "");
return trimmed.replace(/\/v1$/i, "");
}
async function queryOllamaContextWindow(
apiBase: string,
modelName: string,
): Promise<number | undefined> {
try {
const response = await fetch(`${apiBase}/api/show`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: modelName }),
signal: AbortSignal.timeout(3000),
});
if (!response.ok) {
return undefined;
}
const data = (await response.json()) as { model_info?: Record<string, unknown> };
if (!data.model_info) {
return undefined;
}
for (const [key, value] of Object.entries(data.model_info)) {
if (key.endsWith(".context_length") && typeof value === "number" && Number.isFinite(value)) {
const contextWindow = Math.floor(value);
if (contextWindow > 0) {
return contextWindow;
}
}
}
return undefined;
} catch {
return undefined;
}
}
async function discoverOllamaModels(
baseUrl?: string,
opts?: { quiet?: boolean },
): Promise<ModelDefinitionConfig[]> {
// Skip Ollama discovery in test environments
if (process.env.VITEST || process.env.NODE_ENV === "test") {
return [];
}
try {
const apiBase = resolveOllamaApiBase(baseUrl);
const response = await fetch(`${apiBase}/api/tags`, {
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
if (!opts?.quiet) {
log.warn(`Failed to discover Ollama models: ${response.status}`);
}
return [];
}
const data = (await response.json()) as OllamaTagsResponse;
if (!data.models || data.models.length === 0) {
log.debug("No Ollama models found on local instance");
return [];
}
const modelsToInspect = data.models.slice(0, OLLAMA_SHOW_MAX_MODELS);
if (modelsToInspect.length < data.models.length && !opts?.quiet) {
log.warn(
`Capping Ollama /api/show inspection to ${OLLAMA_SHOW_MAX_MODELS} models (received ${data.models.length})`,
);
}
const discovered: ModelDefinitionConfig[] = [];
for (let index = 0; index < modelsToInspect.length; index += OLLAMA_SHOW_CONCURRENCY) {
const batch = modelsToInspect.slice(index, index + OLLAMA_SHOW_CONCURRENCY);
const batchDiscovered = await Promise.all(
batch.map(async (model) => {
const modelId = model.name;
const contextWindow = await queryOllamaContextWindow(apiBase, modelId);
const isReasoning =
modelId.toLowerCase().includes("r1") || modelId.toLowerCase().includes("reasoning");
return {
id: modelId,
name: modelId,
reasoning: isReasoning,
input: ["text"],
cost: OLLAMA_DEFAULT_COST,
contextWindow: contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW,
maxTokens: OLLAMA_DEFAULT_MAX_TOKENS,
} satisfies ModelDefinitionConfig;
}),
);
discovered.push(...batchDiscovered);
}
return discovered;
} catch (error) {
if (!opts?.quiet) {
log.warn(`Failed to discover Ollama models: ${String(error)}`);
}
return [];
}
}
async function discoverVllmModels(
baseUrl: string,
apiKey?: string,
): Promise<ModelDefinitionConfig[]> {
// Skip vLLM discovery in test environments
if (process.env.VITEST || process.env.NODE_ENV === "test") {
return [];
}
const trimmedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
const url = `${trimmedBaseUrl}/models`;
try {
const trimmedApiKey = apiKey?.trim();
const response = await fetch(url, {
headers: trimmedApiKey ? { Authorization: `Bearer ${trimmedApiKey}` } : undefined,
signal: AbortSignal.timeout(5000),
});
if (!response.ok) {
log.warn(`Failed to discover vLLM models: ${response.status}`);
return [];
}
const data = (await response.json()) as VllmModelsResponse;
const models = data.data ?? [];
if (models.length === 0) {
log.warn("No vLLM models found on local instance");
return [];
}
return models
.map((m) => ({ id: typeof m.id === "string" ? m.id.trim() : "" }))
.filter((m) => Boolean(m.id))
.map((m) => {
const modelId = m.id;
const lower = modelId.toLowerCase();
const isReasoning =
lower.includes("r1") || lower.includes("reasoning") || lower.includes("think");
return {
id: modelId,
name: modelId,
reasoning: isReasoning,
input: ["text"],
cost: VLLM_DEFAULT_COST,
contextWindow: VLLM_DEFAULT_CONTEXT_WINDOW,
maxTokens: VLLM_DEFAULT_MAX_TOKENS,
} satisfies ModelDefinitionConfig;
});
} catch (error) {
log.warn(`Failed to discover vLLM models: ${String(error)}`);
return [];
}
}
const ENV_VAR_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
function normalizeApiKeyConfig(value: string): string {
@@ -641,78 +429,6 @@ export function normalizeProviders(params: {
return mutated ? next : providers;
}
async function buildVeniceProvider(): Promise<ProviderConfig> {
const models = await discoverVeniceModels();
return {
baseUrl: VENICE_BASE_URL,
api: "openai-completions",
models,
};
}
async function buildOllamaProvider(
configuredBaseUrl?: string,
opts?: { quiet?: boolean },
): Promise<ProviderConfig> {
const models = await discoverOllamaModels(configuredBaseUrl, opts);
return {
baseUrl: resolveOllamaApiBase(configuredBaseUrl),
api: "ollama",
models,
};
}
async function buildHuggingfaceProvider(discoveryApiKey?: string): Promise<ProviderConfig> {
const resolvedSecret = toDiscoveryApiKey(discoveryApiKey) ?? "";
const models =
resolvedSecret !== ""
? await discoverHuggingfaceModels(resolvedSecret)
: HUGGINGFACE_MODEL_CATALOG.map(buildHuggingfaceModelDefinition);
return {
baseUrl: HUGGINGFACE_BASE_URL,
api: "openai-completions",
models,
};
}
async function buildVercelAiGatewayProvider(): Promise<ProviderConfig> {
return {
baseUrl: VERCEL_AI_GATEWAY_BASE_URL,
api: "anthropic-messages",
models: await discoverVercelAiGatewayModels(),
};
}
async function buildVllmProvider(params?: {
baseUrl?: string;
apiKey?: string;
}): Promise<ProviderConfig> {
const baseUrl = (params?.baseUrl?.trim() || VLLM_BASE_URL).replace(/\/+$/, "");
const models = await discoverVllmModels(baseUrl, params?.apiKey);
return {
baseUrl,
api: "openai-completions",
models,
};
}
/**
* Build the Kilocode provider with dynamic model discovery from the gateway
* API. Falls back to the static catalog on failure.
*
* Used by {@link resolveImplicitProviders} (async context). The sync
* {@link buildKilocodeProvider} is kept for the onboarding config path
* which cannot await.
*/
async function buildKilocodeProviderWithDiscovery(): Promise<ProviderConfig> {
const models = await discoverKilocodeModels();
return {
baseUrl: KILOCODE_BASE_URL,
api: "openai-completions",
models,
};
}
type ImplicitProviderParams = {
agentDir: string;
config?: OpenClawConfig;

View File

@@ -7,22 +7,9 @@ import {
loadConfig,
} from "../config/config.js";
import { createConfigRuntimeEnv } from "../config/env-vars.js";
import { isRecord } from "../utils.js";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
import {
mergeProviders,
mergeWithExistingProviderSecrets,
type ExistingProviderConfig,
} from "./models-config.merge.js";
import {
normalizeProviders,
type ProviderConfig,
resolveImplicitProviders,
} from "./models-config.providers.js";
import { planOpenClawModelsJson } from "./models-config.plan.js";
type ModelsConfig = NonNullable<OpenClawConfig["models"]>;
const DEFAULT_MODE: NonNullable<ModelsConfig["mode"]> = "merge";
const MODELS_JSON_WRITE_LOCKS = new Map<string, Promise<void>>();
async function readExistingModelsFile(pathname: string): Promise<{
@@ -43,52 +30,6 @@ async function readExistingModelsFile(pathname: string): Promise<{
}
}
async function resolveProvidersForModelsJson(params: {
cfg: OpenClawConfig;
agentDir: string;
env: NodeJS.ProcessEnv;
}): Promise<Record<string, ProviderConfig>> {
const { cfg, agentDir, env } = params;
const explicitProviders = cfg.models?.providers ?? {};
const implicitProviders = await resolveImplicitProviders({
agentDir,
config: cfg,
env,
explicitProviders,
});
const providers: Record<string, ProviderConfig> = mergeProviders({
implicit: implicitProviders,
explicit: explicitProviders,
});
return providers;
}
async function resolveProvidersForMode(params: {
mode: NonNullable<ModelsConfig["mode"]>;
existingParsed: unknown;
providers: Record<string, ProviderConfig>;
secretRefManagedProviders: ReadonlySet<string>;
explicitBaseUrlProviders: ReadonlySet<string>;
}): Promise<Record<string, ProviderConfig>> {
if (params.mode !== "merge") {
return params.providers;
}
const existing = params.existingParsed;
if (!isRecord(existing) || !isRecord(existing.providers)) {
return params.providers;
}
const existingProviders = existing.providers as Record<
string,
NonNullable<ModelsConfig["providers"]>[string]
>;
return mergeWithExistingProviderSecrets({
nextProviders: params.providers,
existingProviders: existingProviders as Record<string, ExistingProviderConfig>,
secretRefManagedProviders: params.secretRefManagedProviders,
explicitBaseUrlProviders: params.explicitBaseUrlProviders,
});
}
async function ensureModelsFileMode(pathname: string): Promise<void> {
await fs.chmod(pathname, 0o600).catch(() => {
// best-effort
@@ -147,50 +88,26 @@ export async function ensureOpenClawModelsJson(
// Ensure config env vars (e.g. AWS_PROFILE, AWS_ACCESS_KEY_ID) are
// are available to provider discovery without mutating process.env.
const env = createConfigRuntimeEnv(cfg);
const existingModelsFile = await readExistingModelsFile(targetPath);
const plan = await planOpenClawModelsJson({
cfg,
agentDir,
env,
existingRaw: existingModelsFile.raw,
existingParsed: existingModelsFile.parsed,
});
const providers = await resolveProvidersForModelsJson({ cfg, agentDir, env });
if (Object.keys(providers).length === 0) {
if (plan.action === "skip") {
return { agentDir, wrote: false };
}
const mode = cfg.models?.mode ?? DEFAULT_MODE;
const secretRefManagedProviders = new Set<string>();
const explicitBaseUrlProviders = new Set(
Object.entries(cfg.models?.providers ?? {})
.map(([key, provider]) => [key.trim(), provider] as const)
.filter(
([key, provider]) =>
Boolean(key) && typeof provider?.baseUrl === "string" && provider.baseUrl.trim(),
)
.map(([key]) => key),
);
const normalizedProviders =
normalizeProviders({
providers,
agentDir,
env,
secretDefaults: cfg.secrets?.defaults,
secretRefManagedProviders,
}) ?? providers;
const existingModelsFile = await readExistingModelsFile(targetPath);
const mergedProviders = await resolveProvidersForMode({
mode,
existingParsed: existingModelsFile.parsed,
providers: normalizedProviders,
secretRefManagedProviders,
explicitBaseUrlProviders,
});
const next = `${JSON.stringify({ providers: mergedProviders }, null, 2)}\n`;
if (existingModelsFile.raw === next) {
if (plan.action === "noop") {
await ensureModelsFileMode(targetPath);
return { agentDir, wrote: false };
}
await fs.mkdir(agentDir, { recursive: true, mode: 0o700 });
await writeModelsFileAtomic(targetPath, next);
await writeModelsFileAtomic(targetPath, plan.contents);
await ensureModelsFileMode(targetPath);
return { agentDir, wrote: true };
});

View File

@@ -0,0 +1,62 @@
import type { Api, Model } from "@mariozechner/pi-ai";
import { normalizeModelCompat } from "../model-compat.js";
import { normalizeProviderId } from "../model-selection.js";
const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
function isOpenAIApiBaseUrl(baseUrl?: string): boolean {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return false;
}
return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed);
}
function isOpenAICodexBaseUrl(baseUrl?: string): boolean {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return false;
}
return /^https?:\/\/chatgpt\.com\/backend-api\/?$/i.test(trimmed);
}
function normalizeOpenAICodexTransport(params: {
provider: string;
model: Model<Api>;
}): Model<Api> {
if (normalizeProviderId(params.provider) !== "openai-codex") {
return params.model;
}
const useCodexTransport =
!params.model.baseUrl ||
isOpenAIApiBaseUrl(params.model.baseUrl) ||
isOpenAICodexBaseUrl(params.model.baseUrl);
const nextApi =
useCodexTransport && params.model.api === "openai-responses"
? ("openai-codex-responses" as const)
: params.model.api;
const nextBaseUrl =
nextApi === "openai-codex-responses" &&
(!params.model.baseUrl || isOpenAIApiBaseUrl(params.model.baseUrl))
? OPENAI_CODEX_BASE_URL
: params.model.baseUrl;
if (nextApi === params.model.api && nextBaseUrl === params.model.baseUrl) {
return params.model;
}
return {
...params.model,
api: nextApi,
baseUrl: nextBaseUrl,
} as Model<Api>;
}
export function normalizeResolvedProviderModel(params: {
provider: string;
model: Model<Api>;
}): Model<Api> {
return normalizeModelCompat(normalizeOpenAICodexTransport(params));
}

View File

@@ -36,13 +36,14 @@ export function mockOpenAICodexTemplateModel(): void {
export function buildOpenAICodexForwardCompatExpectation(
id: string = "gpt-5.3-codex",
): Partial<typeof OPENAI_CODEX_TEMPLATE_MODEL> & { provider: string; id: string } {
const isGpt54 = id === "gpt-5.4";
return {
provider: "openai-codex",
id,
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
contextWindow: 272000,
contextWindow: isGpt54 ? 1_050_000 : 272000,
maxTokens: 128000,
};
}

View File

@@ -6,10 +6,10 @@ import { resolveOpenClawAgentDir } from "../agent-paths.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
import { buildModelAliasLines } from "../model-alias-lines.js";
import { isSecretRefHeaderValueMarker } from "../model-auth-markers.js";
import { normalizeModelCompat } from "../model-compat.js";
import { resolveForwardCompatModel } from "../model-forward-compat.js";
import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js";
import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
import { normalizeResolvedProviderModel } from "./model.provider-normalization.js";
type InlineModelEntry = ModelDefinitionConfig & {
provider: string;
@@ -23,8 +23,6 @@ type InlineProviderConfig = {
headers?: unknown;
};
const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
function sanitizeModelHeaders(
headers: unknown,
opts?: { stripSecretRefMarkers?: boolean },
@@ -45,58 +43,8 @@ function sanitizeModelHeaders(
return Object.keys(next).length > 0 ? next : undefined;
}
function isOpenAIApiBaseUrl(baseUrl?: string): boolean {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return false;
}
return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed);
}
function isOpenAICodexBaseUrl(baseUrl?: string): boolean {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return false;
}
return /^https?:\/\/chatgpt\.com\/backend-api\/?$/i.test(trimmed);
}
function normalizeOpenAICodexTransport(params: {
provider: string;
model: Model<Api>;
}): Model<Api> {
if (normalizeProviderId(params.provider) !== "openai-codex") {
return params.model;
}
const useCodexTransport =
!params.model.baseUrl ||
isOpenAIApiBaseUrl(params.model.baseUrl) ||
isOpenAICodexBaseUrl(params.model.baseUrl);
const nextApi =
useCodexTransport && params.model.api === "openai-responses"
? ("openai-codex-responses" as const)
: params.model.api;
const nextBaseUrl =
nextApi === "openai-codex-responses" &&
(!params.model.baseUrl || isOpenAIApiBaseUrl(params.model.baseUrl))
? OPENAI_CODEX_BASE_URL
: params.model.baseUrl;
if (nextApi === params.model.api && nextBaseUrl === params.model.baseUrl) {
return params.model;
}
return {
...params.model,
api: nextApi,
baseUrl: nextBaseUrl,
} as Model<Api>;
}
function normalizeResolvedModel(params: { provider: string; model: Model<Api> }): Model<Api> {
return normalizeModelCompat(normalizeOpenAICodexTransport(params));
return normalizeResolvedProviderModel(params);
}
export { buildModelAliasLines };

View File

@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/config.js";
import {
filterToolsByPolicy,
isToolAllowedByPolicyName,
resolveEffectiveToolPolicy,
resolveSubagentToolPolicy,
} from "./pi-tools.policy.js";
import { createStubTool } from "./test-helpers/pi-tool-stubs.js";
@@ -176,3 +177,59 @@ describe("resolveSubagentToolPolicy depth awareness", () => {
expect(isToolAllowedByPolicyName("sessions_spawn", policy)).toBe(false);
});
});
describe("resolveEffectiveToolPolicy", () => {
it("implicitly re-exposes exec and process when tools.exec is configured", () => {
const cfg = {
tools: {
profile: "messaging",
exec: { host: "sandbox" },
},
} as OpenClawConfig;
const result = resolveEffectiveToolPolicy({ config: cfg });
expect(result.profiles.primary.alsoAllow).toEqual(["exec", "process"]);
});
it("implicitly re-exposes read, write, and edit when tools.fs is configured", () => {
const cfg = {
tools: {
profile: "messaging",
fs: { workspaceOnly: false },
},
} as OpenClawConfig;
const result = resolveEffectiveToolPolicy({ config: cfg });
expect(result.profiles.primary.alsoAllow).toEqual(["read", "write", "edit"]);
});
it("merges explicit alsoAllow with implicit tool-section exposure", () => {
const cfg = {
tools: {
profile: "messaging",
alsoAllow: ["web_search"],
exec: { host: "sandbox" },
},
} as OpenClawConfig;
const result = resolveEffectiveToolPolicy({ config: cfg });
expect(result.profiles.primary.alsoAllow).toEqual(["web_search", "exec", "process"]);
});
it("uses agent tool sections when resolving implicit exposure", () => {
const cfg = {
tools: {
profile: "messaging",
},
agents: {
list: [
{
id: "coder",
tools: {
fs: { workspaceOnly: true },
},
},
],
},
} as OpenClawConfig;
const result = resolveEffectiveToolPolicy({ config: cfg, agentId: "coder" });
expect(result.profiles.primary.alsoAllow).toEqual(["read", "write", "edit"]);
});
});

View File

@@ -2,6 +2,7 @@ import { getChannelDock } from "../channels/dock.js";
import { DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH } from "../config/agent-limits.js";
import type { OpenClawConfig } from "../config/config.js";
import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js";
import type { ToolPolicyConfig } from "../config/types.tools.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { resolveThreadParentSessionKey } from "../sessions/session-key-utils.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
@@ -10,6 +11,11 @@ import { compileGlobPatterns, matchesAnyGlobPattern } from "./glob-pattern.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js";
import type { SandboxToolPolicy } from "./sandbox.js";
import {
mergeAlsoAllowIntoAllowlist,
resolveProfileAlsoAllow,
resolveProviderProfileAlsoAllow,
} from "./tool-policy-also-allow.js";
import { expandToolGroups, normalizeToolName } from "./tool-policy.js";
function makeToolPolicyMatcher(policy: SandboxToolPolicy) {
@@ -98,7 +104,7 @@ export function resolveSubagentToolPolicy(cfg?: OpenClawConfig, depth?: number):
...baseDeny.filter((toolName) => !explicitAllow.has(normalizeToolName(toolName))),
...(Array.isArray(configured?.deny) ? configured.deny : []),
];
const mergedAllow = allow && alsoAllow ? Array.from(new Set([...allow, ...alsoAllow])) : allow;
const mergedAllow = mergeAlsoAllowIntoAllowlist({ allow, alsoAllow });
return { allow: mergedAllow, deny };
}
@@ -117,11 +123,9 @@ export function filterToolsByPolicy(tools: AnyAgentTool[], policy?: SandboxToolP
return tools.filter((tool) => matcher(tool.name));
}
type ToolPolicyConfig = {
allow?: string[];
export type ResolvedToolProfileScope = {
id?: string;
alsoAllow?: string[];
deny?: string[];
profile?: string;
};
function normalizeProviderKey(value: string): string {
@@ -196,13 +200,27 @@ function resolveProviderToolPolicy(params: {
return undefined;
}
export type ResolvedToolPolicyContext = {
agentId?: string;
profiles: {
primary: ResolvedToolProfileScope;
provider: ResolvedToolProfileScope;
};
sandboxPolicies: {
global?: SandboxToolPolicy;
globalProvider?: SandboxToolPolicy;
agent?: SandboxToolPolicy;
agentProvider?: SandboxToolPolicy;
};
};
export function resolveEffectiveToolPolicy(params: {
config?: OpenClawConfig;
sessionKey?: string;
agentId?: string;
modelProvider?: string;
modelId?: string;
}) {
}): ResolvedToolPolicyContext {
const explicitAgentId =
typeof params.agentId === "string" && params.agentId.trim()
? normalizeAgentId(params.agentId)
@@ -228,23 +246,25 @@ export function resolveEffectiveToolPolicy(params: {
});
return {
agentId,
globalPolicy: pickSandboxToolPolicy(globalTools),
globalProviderPolicy: pickSandboxToolPolicy(providerPolicy),
agentPolicy: pickSandboxToolPolicy(agentTools),
agentProviderPolicy: pickSandboxToolPolicy(agentProviderPolicy),
profile,
providerProfile: agentProviderPolicy?.profile ?? providerPolicy?.profile,
// alsoAllow is applied at the profile stage (to avoid being filtered out early).
profileAlsoAllow: Array.isArray(agentTools?.alsoAllow)
? agentTools?.alsoAllow
: Array.isArray(globalTools?.alsoAllow)
? globalTools?.alsoAllow
: undefined,
providerProfileAlsoAllow: Array.isArray(agentProviderPolicy?.alsoAllow)
? agentProviderPolicy?.alsoAllow
: Array.isArray(providerPolicy?.alsoAllow)
? providerPolicy?.alsoAllow
: undefined,
profiles: {
primary: {
id: profile,
alsoAllow: resolveProfileAlsoAllow({ globalTools, agentTools }),
},
provider: {
id: agentProviderPolicy?.profile ?? providerPolicy?.profile,
alsoAllow: resolveProviderProfileAlsoAllow({
providerPolicy,
agentProviderPolicy,
}),
},
},
sandboxPolicies: {
global: pickSandboxToolPolicy(globalTools),
globalProvider: pickSandboxToolPolicy(providerPolicy),
agent: pickSandboxToolPolicy(agentTools),
agentProvider: pickSandboxToolPolicy(agentProviderPolicy),
},
};
}

View File

@@ -1,6 +1,10 @@
import { codingTools, createReadTool, readTool } from "@mariozechner/pi-coding-agent";
import type { OpenClawConfig } from "../config/config.js";
import type { ToolLoopDetectionConfig } from "../config/types.tools.js";
import type {
AgentToolsConfig,
ToolLoopDetectionConfig,
ToolsConfig,
} from "../config/types.tools.js";
import { resolveMergedSafeBinProfileFixtures } from "../infra/exec-safe-bin-runtime-policy.js";
import { logWarn } from "../logger.js";
import { getPluginToolMeta } from "../plugins/tools.js";
@@ -129,32 +133,33 @@ function isApplyPatchAllowedForModel(params: {
});
}
function resolveExecConfig(params: { cfg?: OpenClawConfig; agentId?: string }) {
function resolveToolSectionConfig<T>(
params: { cfg?: OpenClawConfig; agentId?: string },
select: (tools: ToolsConfig | AgentToolsConfig | undefined) => T | undefined,
): {
global?: T;
agent?: T;
} {
const cfg = params.cfg;
const globalExec = cfg?.tools?.exec;
const agentExec =
cfg && params.agentId ? resolveAgentConfig(cfg, params.agentId)?.tools?.exec : undefined;
return {
host: agentExec?.host ?? globalExec?.host,
security: agentExec?.security ?? globalExec?.security,
ask: agentExec?.ask ?? globalExec?.ask,
node: agentExec?.node ?? globalExec?.node,
pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend,
safeBins: agentExec?.safeBins ?? globalExec?.safeBins,
safeBinTrustedDirs: agentExec?.safeBinTrustedDirs ?? globalExec?.safeBinTrustedDirs,
global: select(cfg?.tools),
agent:
cfg && params.agentId ? select(resolveAgentConfig(cfg, params.agentId)?.tools) : undefined,
};
}
function resolveExecConfig(params: { cfg?: OpenClawConfig; agentId?: string }) {
const { global: globalExec, agent: agentExec } = resolveToolSectionConfig(
params,
(tools) => tools?.exec,
);
return {
...globalExec,
...agentExec,
safeBinProfiles: resolveMergedSafeBinProfileFixtures({
global: globalExec,
local: agentExec,
}),
backgroundMs: agentExec?.backgroundMs ?? globalExec?.backgroundMs,
timeoutSec: agentExec?.timeoutSec ?? globalExec?.timeoutSec,
approvalRunningNoticeMs:
agentExec?.approvalRunningNoticeMs ?? globalExec?.approvalRunningNoticeMs,
cleanupMs: agentExec?.cleanupMs ?? globalExec?.cleanupMs,
notifyOnExit: agentExec?.notifyOnExit ?? globalExec?.notifyOnExit,
notifyOnExitEmptySuccess:
agentExec?.notifyOnExitEmptySuccess ?? globalExec?.notifyOnExitEmptySuccess,
applyPatch: agentExec?.applyPatch ?? globalExec?.applyPatch,
};
}
@@ -162,11 +167,7 @@ export function resolveToolLoopDetectionConfig(params: {
cfg?: OpenClawConfig;
agentId?: string;
}): ToolLoopDetectionConfig | undefined {
const global = params.cfg?.tools?.loopDetection;
const agent =
params.agentId && params.cfg
? resolveAgentConfig(params.cfg, params.agentId)?.tools?.loopDetection
: undefined;
const { global, agent } = resolveToolSectionConfig(params, (tools) => tools?.loopDetection);
if (!agent) {
return global;
@@ -258,23 +259,14 @@ export function createOpenClawCodingTools(options?: {
}): AnyAgentTool[] {
const execToolName = "exec";
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
const {
agentId,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
profile,
providerProfile,
profileAlsoAllow,
providerProfileAlsoAllow,
} = resolveEffectiveToolPolicy({
const policyContext = resolveEffectiveToolPolicy({
config: options?.config,
sessionKey: options?.sessionKey,
agentId: options?.agentId,
modelProvider: options?.modelProvider,
modelId: options?.modelId,
});
const { agentId } = policyContext;
const groupPolicy = resolveGroupToolPolicy({
config: options?.config,
sessionKey: options?.sessionKey,
@@ -289,13 +281,13 @@ export function createOpenClawCodingTools(options?: {
senderUsername: options?.senderUsername,
senderE164: options?.senderE164,
});
const profilePolicy = resolveToolProfilePolicy(profile);
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
const profilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(profilePolicy, profileAlsoAllow);
const profilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(
resolveToolProfilePolicy(policyContext.profiles.primary.id),
policyContext.profiles.primary.alsoAllow,
);
const providerProfilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(
providerProfilePolicy,
providerProfileAlsoAllow,
resolveToolProfilePolicy(policyContext.profiles.provider.id),
policyContext.profiles.provider.alsoAllow,
);
// Prefer sessionKey for process isolation scope to prevent cross-session process visibility/killing.
// Fallback to agentId if no sessionKey is available (e.g. legacy or global contexts).
@@ -311,10 +303,10 @@ export function createOpenClawCodingTools(options?: {
const allowBackground = isToolAllowedByPolicies("process", [
profilePolicyWithAlsoAllow,
providerProfilePolicyWithAlsoAllow,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
policyContext.sandboxPolicies.global,
policyContext.sandboxPolicies.globalProvider,
policyContext.sandboxPolicies.agent,
policyContext.sandboxPolicies.agentProvider,
groupPolicy,
sandbox?.tools,
subagentPolicy,
@@ -491,12 +483,12 @@ export function createOpenClawCodingTools(options?: {
sandboxed: !!sandbox,
config: options?.config,
pluginToolAllowlist: collectExplicitAllowlist([
profilePolicy,
providerProfilePolicy,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
profilePolicyWithAlsoAllow,
providerProfilePolicyWithAlsoAllow,
policyContext.sandboxPolicies.global,
policyContext.sandboxPolicies.globalProvider,
policyContext.sandboxPolicies.agent,
policyContext.sandboxPolicies.agentProvider,
groupPolicy,
sandbox?.tools,
subagentPolicy,
@@ -530,13 +522,13 @@ export function createOpenClawCodingTools(options?: {
steps: [
...buildDefaultToolPolicyPipelineSteps({
profilePolicy: profilePolicyWithAlsoAllow,
profile,
profile: policyContext.profiles.primary.id,
providerProfilePolicy: providerProfilePolicyWithAlsoAllow,
providerProfile,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
providerProfile: policyContext.profiles.provider.id,
globalPolicy: policyContext.sandboxPolicies.global,
globalProviderPolicy: policyContext.sandboxPolicies.globalProvider,
agentPolicy: policyContext.sandboxPolicies.agent,
agentProviderPolicy: policyContext.sandboxPolicies.agentProvider,
groupPolicy,
agentId,
}),

View File

@@ -1,4 +1,5 @@
import type { SandboxToolPolicy } from "./sandbox/types.js";
import { mergeAlsoAllowIntoAllowlist } from "./tool-policy-also-allow.js";
type SandboxToolPolicyConfig = {
allow?: string[];
@@ -6,29 +7,17 @@ type SandboxToolPolicyConfig = {
deny?: string[];
};
function unionAllow(base?: string[], extra?: string[]): string[] | undefined {
if (!Array.isArray(extra) || extra.length === 0) {
return base;
}
// If the user is using alsoAllow without an allowlist, treat it as additive on top of
// an implicit allow-all policy.
if (!Array.isArray(base) || base.length === 0) {
return Array.from(new Set(["*", ...extra]));
}
return Array.from(new Set([...base, ...extra]));
}
export function pickSandboxToolPolicy(
config?: SandboxToolPolicyConfig,
): SandboxToolPolicy | undefined {
if (!config) {
return undefined;
}
const allow = Array.isArray(config.allow)
? unionAllow(config.allow, config.alsoAllow)
: Array.isArray(config.alsoAllow) && config.alsoAllow.length > 0
? unionAllow(undefined, config.alsoAllow)
: undefined;
const allow = mergeAlsoAllowIntoAllowlist({
allow: Array.isArray(config.allow) ? config.allow : undefined,
alsoAllow: config.alsoAllow,
assumeAllowAll: true,
});
const deny = Array.isArray(config.deny) ? config.deny : undefined;
if (!allow && !deny) {
return undefined;

View File

@@ -0,0 +1,6 @@
export const CONFIGURED_TOOL_SECTION_EXPOSURES = {
exec: ["exec", "process"],
fs: ["read", "write", "edit"],
} as const;
export type ConfiguredToolSectionId = keyof typeof CONFIGURED_TOOL_SECTION_EXPOSURES;

View File

@@ -0,0 +1,74 @@
import type { AgentToolsConfig, ToolPolicyConfig, ToolsConfig } from "../config/types.tools.js";
import { CONFIGURED_TOOL_SECTION_EXPOSURES } from "./tool-config-exposure.js";
type AlsoAllowConfig = Pick<ToolPolicyConfig, "alsoAllow">;
export function mergeUniqueToolNames(...lists: Array<string[] | undefined>): string[] | undefined {
const merged: string[] = [];
for (const list of lists) {
if (!Array.isArray(list)) {
continue;
}
for (const raw of list) {
const trimmed = typeof raw === "string" ? raw.trim() : "";
if (trimmed) {
merged.push(trimmed);
}
}
}
return merged.length > 0 ? Array.from(new Set(merged)) : undefined;
}
export function mergeAlsoAllowIntoAllowlist(params: {
allow?: string[];
alsoAllow?: string[];
assumeAllowAll?: boolean;
}): string[] | undefined {
if (!Array.isArray(params.alsoAllow) || params.alsoAllow.length === 0) {
return params.allow;
}
if (!Array.isArray(params.allow) || params.allow.length === 0) {
return params.assumeAllowAll ? mergeUniqueToolNames(["*"], params.alsoAllow) : params.allow;
}
return mergeUniqueToolNames(params.allow, params.alsoAllow);
}
export function resolveExplicitAlsoAllow(...configs: Array<AlsoAllowConfig | undefined>) {
for (const config of configs) {
if (Array.isArray(config?.alsoAllow) && config.alsoAllow.length > 0) {
return config.alsoAllow;
}
}
return undefined;
}
export function resolveImplicitToolSectionAlsoAllow(params: {
globalTools?: ToolsConfig;
agentTools?: AgentToolsConfig;
}): string[] | undefined {
const exposures: string[][] = [];
if (params.agentTools?.exec != null || params.globalTools?.exec != null) {
exposures.push([...CONFIGURED_TOOL_SECTION_EXPOSURES.exec]);
}
if (params.agentTools?.fs != null || params.globalTools?.fs != null) {
exposures.push([...CONFIGURED_TOOL_SECTION_EXPOSURES.fs]);
}
return mergeUniqueToolNames(...exposures);
}
export function resolveProfileAlsoAllow(params: {
globalTools?: ToolsConfig;
agentTools?: AgentToolsConfig;
}) {
return mergeUniqueToolNames(
resolveExplicitAlsoAllow(params.agentTools, params.globalTools),
resolveImplicitToolSectionAlsoAllow(params),
);
}
export function resolveProviderProfileAlsoAllow(params: {
providerPolicy?: ToolPolicyConfig;
agentProviderPolicy?: ToolPolicyConfig;
}) {
return resolveExplicitAlsoAllow(params.agentProviderPolicy, params.providerPolicy);
}

View File

@@ -1,3 +1,4 @@
import { mergeAlsoAllowIntoAllowlist } from "./tool-policy-also-allow.js";
import {
expandToolGroups,
normalizeToolList,
@@ -198,8 +199,12 @@ export function mergeAlsoAllowPolicy<TPolicy extends { allow?: string[] }>(
policy: TPolicy | undefined,
alsoAllow?: string[],
): TPolicy | undefined {
if (!policy?.allow || !Array.isArray(alsoAllow) || alsoAllow.length === 0) {
const allow = mergeAlsoAllowIntoAllowlist({
allow: policy?.allow,
alsoAllow,
});
if (allow === policy?.allow) {
return policy;
}
return { ...policy, allow: Array.from(new Set([...policy.allow, ...alsoAllow])) };
return { ...policy, allow } as TPolicy;
}

View File

@@ -21,7 +21,7 @@ import {
writeCache,
} from "./web-shared.js";
const SEARCH_PROVIDERS = ["brave", "perplexity", "grok", "gemini", "kimi"] as const;
const SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const;
const DEFAULT_SEARCH_COUNT = 5;
const MAX_SEARCH_COUNT = 10;
@@ -492,19 +492,10 @@ function resolveSearchApiKey(search?: WebSearchConfig): string | undefined {
}
function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) {
if (provider === "perplexity") {
if (provider === "brave") {
return {
error: "missing_perplexity_api_key",
message:
"web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (provider === "grok") {
return {
error: "missing_xai_api_key",
message:
"web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure tools.web.search.grok.apiKey.",
error: "missing_brave_api_key",
message: `web_search (brave) needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`,
docs: "https://docs.openclaw.ai/tools/web",
};
}
@@ -516,6 +507,14 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) {
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (provider === "grok") {
return {
error: "missing_xai_api_key",
message:
"web_search (grok) needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure tools.web.search.grok.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
if (provider === "kimi") {
return {
error: "missing_kimi_api_key",
@@ -525,8 +524,9 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) {
};
}
return {
error: "missing_brave_api_key",
message: `web_search needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`,
error: "missing_perplexity_api_key",
message:
"web_search (perplexity) needs an API key. Set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in the Gateway environment, or configure tools.web.search.perplexity.apiKey.",
docs: "https://docs.openclaw.ai/tools/web",
};
}
@@ -536,32 +536,32 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
search && "provider" in search && typeof search.provider === "string"
? search.provider.trim().toLowerCase()
: "";
if (raw === "perplexity") {
return "perplexity";
}
if (raw === "grok") {
return "grok";
if (raw === "brave") {
return "brave";
}
if (raw === "gemini") {
return "gemini";
}
if (raw === "grok") {
return "grok";
}
if (raw === "kimi") {
return "kimi";
}
if (raw === "brave") {
return "brave";
if (raw === "perplexity") {
return "perplexity";
}
// Auto-detect provider from available API keys (priority order)
// Auto-detect provider from available API keys (alphabetical order)
if (raw === "") {
// 1. Brave
// Brave
if (resolveSearchApiKey(search)) {
logVerbose(
'web_search: no provider configured, auto-detected "brave" from available API keys',
);
return "brave";
}
// 2. Gemini
// Gemini
const geminiConfig = resolveGeminiConfig(search);
if (resolveGeminiApiKey(geminiConfig)) {
logVerbose(
@@ -569,7 +569,15 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
);
return "gemini";
}
// 3. Kimi
// Grok
const grokConfig = resolveGrokConfig(search);
if (resolveGrokApiKey(grokConfig)) {
logVerbose(
'web_search: no provider configured, auto-detected "grok" from available API keys',
);
return "grok";
}
// Kimi
const kimiConfig = resolveKimiConfig(search);
if (resolveKimiApiKey(kimiConfig)) {
logVerbose(
@@ -577,7 +585,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
);
return "kimi";
}
// 4. Perplexity
// Perplexity
const perplexityConfig = resolvePerplexityConfig(search);
const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig);
if (perplexityKey) {
@@ -586,14 +594,6 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
);
return "perplexity";
}
// 5. Grok
const grokConfig = resolveGrokConfig(search);
if (resolveGrokApiKey(grokConfig)) {
logVerbose(
'web_search: no provider configured, auto-detected "grok" from available API keys',
);
return "grok";
}
}
return "brave";

View File

@@ -236,7 +236,7 @@ describe("inbound dedupe", () => {
).toBe(false);
});
it("does not dedupe across session keys", () => {
it("does not dedupe across agent ids", () => {
resetInboundDedupe();
const base: MsgContext = {
Provider: "whatsapp",
@@ -248,12 +248,36 @@ describe("inbound dedupe", () => {
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 100 }),
).toBe(false);
expect(
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:bravo:main" }, { now: 200 }),
shouldSkipDuplicateInbound(
{ ...base, SessionKey: "agent:bravo:whatsapp:direct:+1555" },
{
now: 200,
},
),
).toBe(false);
expect(
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 300 }),
).toBe(true);
});
it("dedupes when the same agent sees the same inbound message under different session keys", () => {
resetInboundDedupe();
const base: MsgContext = {
Provider: "telegram",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:7463849194",
MessageSid: "msg-1",
};
expect(
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:main:main" }, { now: 100 }),
).toBe(false);
expect(
shouldSkipDuplicateInbound(
{ ...base, SessionKey: "agent:main:telegram:direct:7463849194" },
{ now: 200 },
),
).toBe(true);
});
});
describe("createInboundDebouncer", () => {

View File

@@ -175,6 +175,7 @@ export function buildEmbeddedRunBaseParams(params: {
config: params.run.config,
skillsSnapshot: params.run.skillsSnapshot,
ownerNumbers: params.run.ownerNumbers,
inputProvenance: params.run.inputProvenance,
senderIsOwner: params.run.senderIsOwner,
enforceFinalTag: resolveEnforceFinalTag(params.run, params.provider),
provider: params.provider,

View File

@@ -1539,6 +1539,38 @@ describe("dispatchReplyFromConfig", () => {
expect(replyResolver).toHaveBeenCalledTimes(1);
});
it("deduplicates same-agent inbound replies across main and direct session keys", async () => {
setNoAbort();
const cfg = emptyConfig;
const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
const baseCtx = buildTestCtx({
Provider: "telegram",
Surface: "telegram",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:7463849194",
MessageSid: "msg-1",
SessionKey: "agent:main:main",
});
await dispatchReplyFromConfig({
ctx: baseCtx,
cfg,
dispatcher: createDispatcher(),
replyResolver,
});
await dispatchReplyFromConfig({
ctx: {
...baseCtx,
SessionKey: "agent:main:telegram:direct:7463849194",
},
cfg,
dispatcher: createDispatcher(),
replyResolver,
});
expect(replyResolver).toHaveBeenCalledTimes(1);
});
it("emits message_received hook with originating channel metadata", async () => {
setNoAbort();
hookMocks.runner.hasHooks.mockReturnValue(true);

View File

@@ -521,6 +521,7 @@ export async function runPreparedReply(
timeoutMs,
blockReplyBreak: resolvedBlockStreamingBreak,
ownerNumbers: command.ownerList.length > 0 ? command.ownerList : undefined,
inputProvenance: ctx.InputProvenance ?? sessionCtx.InputProvenance,
extraSystemPrompt: extraSystemPromptParts.join("\n\n") || undefined,
...(isReasoningTagProvider(provider) ? { enforceFinalTag: true } : {}),
},

View File

@@ -1,5 +1,6 @@
import { logVerbose, shouldLogVerbose } from "../../globals.js";
import { createDedupeCache, type DedupeCache } from "../../infra/dedupe.js";
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
import type { MsgContext } from "../templating.js";
const DEFAULT_INBOUND_DEDUPE_TTL_MS = 20 * 60_000;
@@ -15,6 +16,23 @@ const normalizeProvider = (value?: string | null) => value?.trim().toLowerCase()
const resolveInboundPeerId = (ctx: MsgContext) =>
ctx.OriginatingTo ?? ctx.To ?? ctx.From ?? ctx.SessionKey;
function resolveInboundDedupeSessionScope(ctx: MsgContext): string {
const sessionKey =
(ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey : undefined)?.trim() ||
ctx.SessionKey?.trim() ||
"";
if (!sessionKey) {
return "";
}
const parsed = parseAgentSessionKey(sessionKey);
if (!parsed) {
return sessionKey;
}
// The same physical inbound message should never run twice for the same
// agent, even if a routing bug presents it under both main and direct keys.
return `agent:${parsed.agentId}`;
}
export function buildInboundDedupeKey(ctx: MsgContext): string | null {
const provider = normalizeProvider(ctx.OriginatingChannel ?? ctx.Provider ?? ctx.Surface);
const messageId = ctx.MessageSid?.trim();
@@ -25,13 +43,13 @@ export function buildInboundDedupeKey(ctx: MsgContext): string | null {
if (!peerId) {
return null;
}
const sessionKey = ctx.SessionKey?.trim() ?? "";
const sessionScope = resolveInboundDedupeSessionScope(ctx);
const accountId = ctx.AccountId?.trim() ?? "";
const threadId =
ctx.MessageThreadId !== undefined && ctx.MessageThreadId !== null
? String(ctx.MessageThreadId)
: "";
return [provider, accountId, sessionKey, peerId, threadId, messageId].filter(Boolean).join("|");
return [provider, accountId, sessionScope, peerId, threadId, messageId].filter(Boolean).join("|");
}
export function shouldSkipDuplicateInbound(

View File

@@ -2,6 +2,7 @@ import type { ExecToolDefaults } from "../../../agents/bash-tools.js";
import type { SkillSnapshot } from "../../../agents/skills.js";
import type { OpenClawConfig } from "../../../config/config.js";
import type { SessionEntry } from "../../../config/sessions.js";
import type { InputProvenance } from "../../../sessions/input-provenance.js";
import type { OriginatingChannelType } from "../../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../directives.js";
@@ -77,6 +78,7 @@ export type FollowupRun = {
timeoutMs: number;
blockReplyBreak: "text_end" | "message_end";
ownerNumbers?: string[];
inputProvenance?: InputProvenance;
extraSystemPrompt?: string;
enforceFinalTag?: boolean;
};

View File

@@ -3,6 +3,7 @@ import type {
MediaUnderstandingDecision,
MediaUnderstandingOutput,
} from "../media-understanding/types.js";
import type { InputProvenance } from "../sessions/input-provenance.js";
import type { StickerMetadata } from "../telegram/bot/types.js";
import type { InternalMessageChannel } from "../utils/message-channel.js";
import type { CommandArgs } from "./commands-registry.types.js";
@@ -117,6 +118,8 @@ export type MsgContext = {
GroupSystemPrompt?: string;
/** Untrusted metadata that must not be treated as system instructions. */
UntrustedContext?: string[];
/** System-attached provenance for the current inbound message. */
InputProvenance?: InputProvenance;
/** Explicit owner allowlist overrides (trusted, configuration-derived). */
OwnerAllowFrom?: Array<string | number>;
SenderName?: string;

View File

@@ -30,6 +30,8 @@ export type ProfileStatus = {
tabCount: number;
isDefault: boolean;
isRemote: boolean;
missingFromConfig?: boolean;
reconcileReason?: string | null;
};
export type BrowserResetProfileResult = {

View File

@@ -2,8 +2,8 @@ import { loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveBrowserConfig } from "./config.js";
import { ensureBrowserControlAuth } from "./control-auth.js";
import { createBrowserRuntimeState, stopBrowserRuntime } from "./runtime-lifecycle.js";
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
let state: BrowserServerState | null = null;
const log = createSubsystemLogger("browser");
@@ -39,14 +39,9 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
logService.warn(`failed to auto-configure browser auth: ${String(err)}`);
}
state = {
state = await createBrowserRuntimeState({
server: null,
port: resolved.controlPort,
resolved,
profiles: new Map(),
};
await ensureExtensionRelayForProfiles({
resolved,
onWarn: (message) => logService.warn(message),
});
@@ -59,22 +54,12 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
export async function stopBrowserControlService(): Promise<void> {
const current = state;
if (!current) {
return;
}
await stopKnownBrowserProfiles({
await stopBrowserRuntime({
current,
getState: () => state,
clearState: () => {
state = null;
},
onWarn: (message) => logService.warn(message),
});
state = null;
// Optional: Playwright is not always available (e.g. embedded gateway builds).
try {
const mod = await import("./pw-ai.js");
await mod.closePlaywrightBrowserConnection();
} catch {
// ignore
}
}

82
src/browser/errors.ts Normal file
View File

@@ -0,0 +1,82 @@
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
export class BrowserError extends Error {
status: number;
constructor(message: string, status = 500, options?: ErrorOptions) {
super(message, options);
this.name = new.target.name;
this.status = status;
}
}
export class BrowserValidationError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 400, options);
}
}
export class BrowserConfigurationError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 400, options);
}
}
export class BrowserTargetAmbiguousError extends BrowserError {
constructor(message = "ambiguous target id prefix", options?: ErrorOptions) {
super(message, 409, options);
}
}
export class BrowserTabNotFoundError extends BrowserError {
constructor(message = "tab not found", options?: ErrorOptions) {
super(message, 404, options);
}
}
export class BrowserProfileNotFoundError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 404, options);
}
}
export class BrowserConflictError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 409, options);
}
}
export class BrowserResetUnsupportedError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 400, options);
}
}
export class BrowserProfileUnavailableError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 409, options);
}
}
export class BrowserResourceExhaustedError extends BrowserError {
constructor(message: string, options?: ErrorOptions) {
super(message, 507, options);
}
}
export function toBrowserErrorResponse(err: unknown): {
status: number;
message: string;
} | null {
if (err instanceof BrowserError) {
return { status: err.status, message: err.message };
}
if (err instanceof SsrFBlockedError) {
return { status: 400, message: err.message };
}
if (err instanceof InvalidBrowserNavigationUrlError) {
return { status: 400, message: err.message };
}
return null;
}

View File

@@ -0,0 +1,100 @@
import type { ResolvedBrowserProfile } from "./config.js";
export type BrowserProfileMode = "local-managed" | "local-extension-relay" | "remote-cdp";
export type BrowserProfileCapabilities = {
mode: BrowserProfileMode;
isRemote: boolean;
requiresRelay: boolean;
requiresAttachedTab: boolean;
usesPersistentPlaywright: boolean;
supportsPerTabWs: boolean;
supportsJsonTabEndpoints: boolean;
supportsReset: boolean;
supportsManagedTabLimit: boolean;
};
export function getBrowserProfileCapabilities(
profile: ResolvedBrowserProfile,
): BrowserProfileCapabilities {
if (profile.driver === "extension") {
return {
mode: "local-extension-relay",
isRemote: false,
requiresRelay: true,
requiresAttachedTab: true,
usesPersistentPlaywright: false,
supportsPerTabWs: false,
supportsJsonTabEndpoints: true,
supportsReset: true,
supportsManagedTabLimit: false,
};
}
if (!profile.cdpIsLoopback) {
return {
mode: "remote-cdp",
isRemote: true,
requiresRelay: false,
requiresAttachedTab: false,
usesPersistentPlaywright: true,
supportsPerTabWs: false,
supportsJsonTabEndpoints: false,
supportsReset: false,
supportsManagedTabLimit: false,
};
}
return {
mode: "local-managed",
isRemote: false,
requiresRelay: false,
requiresAttachedTab: false,
usesPersistentPlaywright: false,
supportsPerTabWs: true,
supportsJsonTabEndpoints: true,
supportsReset: true,
supportsManagedTabLimit: true,
};
}
export function resolveDefaultSnapshotFormat(params: {
profile: ResolvedBrowserProfile;
hasPlaywright: boolean;
explicitFormat?: "ai" | "aria";
mode?: "efficient";
}): "ai" | "aria" {
if (params.explicitFormat) {
return params.explicitFormat;
}
if (params.mode === "efficient") {
return "ai";
}
const capabilities = getBrowserProfileCapabilities(params.profile);
if (capabilities.mode === "local-extension-relay") {
return "aria";
}
return params.hasPlaywright ? "ai" : "aria";
}
export function shouldUsePlaywrightForScreenshot(params: {
profile: ResolvedBrowserProfile;
wsUrl?: string;
ref?: string;
element?: string;
}): boolean {
const capabilities = getBrowserProfileCapabilities(params.profile);
return (
capabilities.requiresRelay || !params.wsUrl || Boolean(params.ref) || Boolean(params.element)
);
}
export function shouldUsePlaywrightForAriaSnapshot(params: {
profile: ResolvedBrowserProfile;
wsUrl?: string;
}): boolean {
const capabilities = getBrowserProfileCapabilities(params.profile);
return capabilities.requiresRelay || !params.wsUrl;
}

View File

@@ -132,6 +132,37 @@ describe("BrowserProfilesService", () => {
);
});
it("rejects driver=extension with non-loopback cdpUrl", async () => {
const resolved = resolveBrowserConfig({});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
await expect(
service.createProfile({
name: "chrome-remote",
driver: "extension",
cdpUrl: "http://10.0.0.42:9222",
}),
).rejects.toThrow(/loopback cdpUrl host/i);
});
it("rejects driver=extension without an explicit cdpUrl", async () => {
const resolved = resolveBrowserConfig({});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
await expect(
service.createProfile({
name: "chrome-extension",
driver: "extension",
}),
).rejects.toThrow(/requires an explicit loopback cdpUrl/i);
});
it("deletes remote profiles without stopping or removing local data", async () => {
const resolved = resolveBrowserConfig({
profiles: {

View File

@@ -3,9 +3,16 @@ import path from "node:path";
import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
import { isLoopbackHost } from "../gateway/net.js";
import { resolveOpenClawUserDataDir } from "./chrome.js";
import { parseHttpUrl, resolveProfile } from "./config.js";
import { DEFAULT_BROWSER_DEFAULT_PROFILE_NAME } from "./constants.js";
import {
BrowserConflictError,
BrowserProfileNotFoundError,
BrowserResourceExhaustedError,
BrowserValidationError,
} from "./errors.js";
import {
allocateCdpPort,
allocateColor,
@@ -75,19 +82,21 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
const driver = params.driver === "extension" ? "extension" : undefined;
if (!isValidProfileName(name)) {
throw new Error("invalid profile name: use lowercase letters, numbers, and hyphens only");
throw new BrowserValidationError(
"invalid profile name: use lowercase letters, numbers, and hyphens only",
);
}
const state = ctx.state();
const resolvedProfiles = state.resolved.profiles;
if (name in resolvedProfiles) {
throw new Error(`profile "${name}" already exists`);
throw new BrowserConflictError(`profile "${name}" already exists`);
}
const cfg = loadConfig();
const rawProfiles = cfg.browser?.profiles ?? {};
if (name in rawProfiles) {
throw new Error(`profile "${name}" already exists`);
throw new BrowserConflictError(`profile "${name}" already exists`);
}
const usedColors = getUsedColors(resolvedProfiles);
@@ -97,17 +106,32 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
let profileConfig: BrowserProfileConfig;
if (rawCdpUrl) {
const parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl");
if (driver === "extension") {
if (!isLoopbackHost(parsed.parsed.hostname)) {
throw new BrowserValidationError(
`driver=extension requires a loopback cdpUrl host, got: ${parsed.parsed.hostname}`,
);
}
if (parsed.parsed.protocol !== "http:" && parsed.parsed.protocol !== "https:") {
throw new BrowserValidationError(
`driver=extension requires an http(s) cdpUrl, got: ${parsed.parsed.protocol.replace(":", "")}`,
);
}
}
profileConfig = {
cdpUrl: parsed.normalized,
...(driver ? { driver } : {}),
color: profileColor,
};
} else {
if (driver === "extension") {
throw new BrowserValidationError("driver=extension requires an explicit loopback cdpUrl");
}
const usedPorts = getUsedPorts(resolvedProfiles);
const range = cdpPortRange(state.resolved);
const cdpPort = allocateCdpPort(usedPorts, range);
if (cdpPort === null) {
throw new Error("no available CDP ports in range");
throw new BrowserResourceExhaustedError("no available CDP ports in range");
}
profileConfig = {
cdpPort,
@@ -132,7 +156,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
state.resolved.profiles[name] = profileConfig;
const resolved = resolveProfile(state.resolved, name);
if (!resolved) {
throw new Error(`profile "${name}" not found after creation`);
throw new BrowserProfileNotFoundError(`profile "${name}" not found after creation`);
}
return {
@@ -148,21 +172,21 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
const deleteProfile = async (nameRaw: string): Promise<DeleteProfileResult> => {
const name = nameRaw.trim();
if (!name) {
throw new Error("profile name is required");
throw new BrowserValidationError("profile name is required");
}
if (!isValidProfileName(name)) {
throw new Error("invalid profile name");
throw new BrowserValidationError("invalid profile name");
}
const cfg = loadConfig();
const profiles = cfg.browser?.profiles ?? {};
if (!(name in profiles)) {
throw new Error(`profile "${name}" not found`);
throw new BrowserProfileNotFoundError(`profile "${name}" not found`);
}
const defaultProfile = cfg.browser?.defaultProfile ?? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME;
if (name === defaultProfile) {
throw new Error(
throw new BrowserValidationError(
`cannot delete the default profile "${name}"; change browser.defaultProfile first`,
);
}

View File

@@ -19,6 +19,7 @@ import {
} from "./cdp.helpers.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import { getChromeWebSocketUrl } from "./chrome.js";
import { BrowserTabNotFoundError } from "./errors.js";
import {
assertBrowserNavigationAllowed,
assertBrowserNavigationResultAllowed,
@@ -495,7 +496,7 @@ async function resolvePageByTargetIdOrThrow(opts: {
const { browser } = await connectBrowser(opts.cdpUrl);
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) {
throw new Error("tab not found");
throw new BrowserTabNotFoundError();
}
return page;
}
@@ -521,7 +522,7 @@ export async function getPageForTargetId(opts: {
if (pages.length === 1) {
return first;
}
throw new Error("tab not found");
throw new BrowserTabNotFoundError();
}
return found;
}

View File

@@ -2,6 +2,29 @@ import { createConfigIO, loadConfig } from "../config/config.js";
import { resolveBrowserConfig, resolveProfile, type ResolvedBrowserProfile } from "./config.js";
import type { BrowserServerState } from "./server-context.types.js";
function changedProfileInvariants(
current: ResolvedBrowserProfile,
next: ResolvedBrowserProfile,
): string[] {
const changed: string[] = [];
if (current.cdpUrl !== next.cdpUrl) {
changed.push("cdpUrl");
}
if (current.cdpPort !== next.cdpPort) {
changed.push("cdpPort");
}
if (current.driver !== next.driver) {
changed.push("driver");
}
if (current.attachOnly !== next.attachOnly) {
changed.push("attachOnly");
}
if (current.cdpIsLoopback !== next.cdpIsLoopback) {
changed.push("cdpIsLoopback");
}
return changed;
}
function applyResolvedConfig(
current: BrowserServerState,
freshResolved: BrowserServerState["resolved"],
@@ -10,9 +33,22 @@ function applyResolvedConfig(
for (const [name, runtime] of current.profiles) {
const nextProfile = resolveProfile(freshResolved, name);
if (nextProfile) {
const changed = changedProfileInvariants(runtime.profile, nextProfile);
if (changed.length > 0) {
runtime.reconcile = {
previousProfile: runtime.profile,
reason: `profile invariants changed: ${changed.join(", ")}`,
};
runtime.lastTargetId = null;
}
runtime.profile = nextProfile;
continue;
}
runtime.reconcile = {
previousProfile: runtime.profile,
reason: "profile removed from config",
};
runtime.lastTargetId = null;
if (!runtime.running) {
current.profiles.delete(name);
}

View File

@@ -1,3 +1,4 @@
import { toBrowserErrorResponse } from "../errors.js";
import type { PwAiModule } from "../pw-ai-module.js";
import { getPwAiModule as getPwAiModuleBase } from "../pw-ai-module.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
@@ -37,6 +38,10 @@ export function handleRouteError(ctx: BrowserRouteContext, res: BrowserResponse,
if (mapped) {
return jsonError(res, mapped.status, mapped.message);
}
const browserMapped = toBrowserErrorResponse(err);
if (browserMapped) {
return jsonError(res, browserMapped.status, browserMapped.message);
}
jsonError(res, 500, String(err));
}

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { resolveBrowserConfig, resolveProfile } from "../config.js";
import { resolveSnapshotPlan } from "./agent.snapshot.plan.js";
describe("resolveSnapshotPlan", () => {
it("defaults chrome extension relay snapshots to aria when format is omitted", () => {
const resolved = resolveBrowserConfig({});
const profile = resolveProfile(resolved, "chrome");
expect(profile).toBeTruthy();
const plan = resolveSnapshotPlan({
profile: profile as NonNullable<typeof profile>,
query: {},
hasPlaywright: true,
});
expect(plan.format).toBe("aria");
});
it("keeps ai snapshots for managed browsers when Playwright is available", () => {
const resolved = resolveBrowserConfig({});
const profile = resolveProfile(resolved, "openclaw");
expect(profile).toBeTruthy();
const plan = resolveSnapshotPlan({
profile: profile as NonNullable<typeof profile>,
query: {},
hasPlaywright: true,
});
expect(plan.format).toBe("ai");
});
});

View File

@@ -0,0 +1,97 @@
import type { ResolvedBrowserProfile } from "../config.js";
import {
DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH,
DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS,
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
} from "../constants.js";
import {
resolveDefaultSnapshotFormat,
shouldUsePlaywrightForAriaSnapshot,
shouldUsePlaywrightForScreenshot,
} from "../profile-capabilities.js";
import { toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
export type BrowserSnapshotPlan = {
format: "ai" | "aria";
mode?: "efficient";
labels?: boolean;
limit?: number;
resolvedMaxChars?: number;
interactive?: boolean;
compact?: boolean;
depth?: number;
refsMode?: "aria" | "role";
selectorValue?: string;
frameSelectorValue?: string;
wantsRoleSnapshot: boolean;
};
export function resolveSnapshotPlan(params: {
profile: ResolvedBrowserProfile;
query: Record<string, unknown>;
hasPlaywright: boolean;
}): BrowserSnapshotPlan {
const mode = params.query.mode === "efficient" ? "efficient" : undefined;
const labels = toBoolean(params.query.labels) ?? undefined;
const explicitFormat =
params.query.format === "aria" ? "aria" : params.query.format === "ai" ? "ai" : undefined;
const format = resolveDefaultSnapshotFormat({
profile: params.profile,
hasPlaywright: params.hasPlaywright,
explicitFormat,
mode,
});
const limitRaw = typeof params.query.limit === "string" ? Number(params.query.limit) : undefined;
const hasMaxChars = Object.hasOwn(params.query, "maxChars");
const maxCharsRaw =
typeof params.query.maxChars === "string" ? Number(params.query.maxChars) : undefined;
const limit = Number.isFinite(limitRaw) ? limitRaw : undefined;
const maxChars =
typeof maxCharsRaw === "number" && Number.isFinite(maxCharsRaw) && maxCharsRaw > 0
? Math.floor(maxCharsRaw)
: undefined;
const resolvedMaxChars =
format === "ai"
? hasMaxChars
? maxChars
: mode === "efficient"
? DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS
: DEFAULT_AI_SNAPSHOT_MAX_CHARS
: undefined;
const interactiveRaw = toBoolean(params.query.interactive);
const compactRaw = toBoolean(params.query.compact);
const depthRaw = toNumber(params.query.depth);
const refsModeRaw = toStringOrEmpty(params.query.refs).trim();
const refsMode: "aria" | "role" | undefined =
refsModeRaw === "aria" ? "aria" : refsModeRaw === "role" ? "role" : undefined;
const interactive = interactiveRaw ?? (mode === "efficient" ? true : undefined);
const compact = compactRaw ?? (mode === "efficient" ? true : undefined);
const depth =
depthRaw ?? (mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH : undefined);
const selectorValue = toStringOrEmpty(params.query.selector).trim() || undefined;
const frameSelectorValue = toStringOrEmpty(params.query.frame).trim() || undefined;
return {
format,
mode,
labels,
limit,
resolvedMaxChars,
interactive,
compact,
depth,
refsMode,
selectorValue,
frameSelectorValue,
wantsRoleSnapshot:
labels === true ||
mode === "efficient" ||
interactive === true ||
compact === true ||
depth !== undefined ||
Boolean(selectorValue) ||
Boolean(frameSelectorValue),
};
}
export { shouldUsePlaywrightForAriaSnapshot, shouldUsePlaywrightForScreenshot };

View File

@@ -38,8 +38,8 @@ describe("resolveTargetIdAfterNavigate", () => {
{ targetId: "fresh-777", url: "https://example.com" },
]),
});
// Both differ from old targetId; the first non-stale match wins.
expect(result).toBe("preexisting-000");
// Ambiguous replacement; prefer staying on the old target rather than guessing wrong.
expect(result).toBe("old-123");
});
it("retries and resolves targetId when first listTabs has no URL match", async () => {
@@ -114,4 +114,24 @@ describe("resolveTargetIdAfterNavigate", () => {
});
expect(result).toBe("old-123");
});
it("keeps the old target when multiple replacement candidates still match after retry", async () => {
vi.useFakeTimers();
const result$ = resolveTargetIdAfterNavigate({
oldTargetId: "old-123",
navigatedUrl: "https://example.com",
listTabs: staticListTabs([
{ targetId: "preexisting-000", url: "https://example.com" },
{ targetId: "fresh-777", url: "https://example.com" },
]),
});
await vi.advanceTimersByTimeAsync(800);
const result = await result$;
expect(result).toBe("old-123");
vi.useRealTimers();
});
});

View File

@@ -1,11 +1,6 @@
import path from "node:path";
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
import { captureScreenshot, snapshotAria } from "../cdp.js";
import {
DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH,
DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS,
DEFAULT_AI_SNAPSHOT_MAX_CHARS,
} from "../constants.js";
import { withBrowserNavigationPolicy } from "../navigation-guard.js";
import {
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
@@ -22,8 +17,13 @@ import {
withPlaywrightRouteContext,
withRouteTabContext,
} from "./agent.shared.js";
import {
resolveSnapshotPlan,
shouldUsePlaywrightForAriaSnapshot,
shouldUsePlaywrightForScreenshot,
} from "./agent.snapshot.plan.js";
import type { BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
import { jsonError, toBoolean, toStringOrEmpty } from "./utils.js";
async function saveBrowserMediaResponse(params: {
res: BrowserResponse;
@@ -56,26 +56,28 @@ export async function resolveTargetIdAfterNavigate(opts: {
}): Promise<string> {
let currentTargetId = opts.oldTargetId;
try {
const refreshed = await opts.listTabs();
if (!refreshed.some((t) => t.targetId === opts.oldTargetId)) {
// Renderer swap: old target gone, resolve the replacement.
// Prefer a URL match whose targetId differs from the old one
// to avoid picking a pre-existing tab when multiple share the URL.
const byUrl = refreshed.filter((t) => t.url === opts.navigatedUrl);
const replaced = byUrl.find((t) => t.targetId !== opts.oldTargetId) ?? byUrl[0];
if (replaced) {
currentTargetId = replaced.targetId;
} else {
await new Promise((r) => setTimeout(r, 800));
const retried = await opts.listTabs();
const match =
retried.find((t) => t.url === opts.navigatedUrl && t.targetId !== opts.oldTargetId) ??
retried.find((t) => t.url === opts.navigatedUrl) ??
(retried.length === 1 ? retried[0] : null);
if (match) {
currentTargetId = match.targetId;
}
const pickReplacement = (tabs: Array<{ targetId: string; url: string }>) => {
if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) {
return opts.oldTargetId;
}
const byUrl = tabs.filter((tab) => tab.url === opts.navigatedUrl);
if (byUrl.length === 1) {
return byUrl[0]?.targetId ?? opts.oldTargetId;
}
const uniqueReplacement = byUrl.filter((tab) => tab.targetId !== opts.oldTargetId);
if (uniqueReplacement.length === 1) {
return uniqueReplacement[0]?.targetId ?? opts.oldTargetId;
}
if (tabs.length === 1) {
return tabs[0]?.targetId ?? opts.oldTargetId;
}
return opts.oldTargetId;
};
currentTargetId = pickReplacement(await opts.listTabs());
if (currentTargetId === opts.oldTargetId) {
await new Promise((r) => setTimeout(r, 800));
currentTargetId = pickReplacement(await opts.listTabs());
}
} catch {
// Best-effort: fall back to pre-navigation targetId
@@ -162,11 +164,12 @@ export function registerBrowserAgentSnapshotRoutes(
targetId,
run: async ({ profileCtx, tab, cdpUrl }) => {
let buffer: Buffer;
const shouldUsePlaywright =
profileCtx.profile.driver === "extension" ||
!tab.wsUrl ||
Boolean(ref) ||
Boolean(element);
const shouldUsePlaywright = shouldUsePlaywrightForScreenshot({
profile: profileCtx.profile,
wsUrl: tab.wsUrl,
ref,
element,
});
if (shouldUsePlaywright) {
const pw = await requirePwAi(res, "screenshot");
if (!pw) {
@@ -212,81 +215,45 @@ export function registerBrowserAgentSnapshotRoutes(
return;
}
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
const mode = req.query.mode === "efficient" ? "efficient" : undefined;
const labels = toBoolean(req.query.labels) ?? undefined;
const explicitFormat =
req.query.format === "aria" ? "aria" : req.query.format === "ai" ? "ai" : undefined;
const format = explicitFormat ?? (mode ? "ai" : (await getPwAiModule()) ? "ai" : "aria");
const limitRaw = typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
const hasMaxChars = Object.hasOwn(req.query, "maxChars");
const maxCharsRaw =
typeof req.query.maxChars === "string" ? Number(req.query.maxChars) : undefined;
const limit = Number.isFinite(limitRaw) ? limitRaw : undefined;
const maxChars =
typeof maxCharsRaw === "number" && Number.isFinite(maxCharsRaw) && maxCharsRaw > 0
? Math.floor(maxCharsRaw)
: undefined;
const resolvedMaxChars =
format === "ai"
? hasMaxChars
? maxChars
: mode === "efficient"
? DEFAULT_AI_SNAPSHOT_EFFICIENT_MAX_CHARS
: DEFAULT_AI_SNAPSHOT_MAX_CHARS
: undefined;
const interactiveRaw = toBoolean(req.query.interactive);
const compactRaw = toBoolean(req.query.compact);
const depthRaw = toNumber(req.query.depth);
const refsModeRaw = toStringOrEmpty(req.query.refs).trim();
const refsMode: "aria" | "role" | undefined =
refsModeRaw === "aria" ? "aria" : refsModeRaw === "role" ? "role" : undefined;
const interactive = interactiveRaw ?? (mode === "efficient" ? true : undefined);
const compact = compactRaw ?? (mode === "efficient" ? true : undefined);
const depth =
depthRaw ?? (mode === "efficient" ? DEFAULT_AI_SNAPSHOT_EFFICIENT_DEPTH : undefined);
const selector = toStringOrEmpty(req.query.selector);
const frameSelector = toStringOrEmpty(req.query.frame);
const selectorValue = selector.trim() || undefined;
const frameSelectorValue = frameSelector.trim() || undefined;
const hasPlaywright = Boolean(await getPwAiModule());
const plan = resolveSnapshotPlan({
profile: profileCtx.profile,
query: req.query,
hasPlaywright,
});
try {
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
if ((labels || mode === "efficient") && format === "aria") {
if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") {
return jsonError(res, 400, "labels/mode=efficient require format=ai");
}
if (format === "ai") {
if (plan.format === "ai") {
const pw = await requirePwAi(res, "ai snapshot");
if (!pw) {
return;
}
const wantsRoleSnapshot =
labels === true ||
mode === "efficient" ||
interactive === true ||
compact === true ||
depth !== undefined ||
Boolean(selectorValue) ||
Boolean(frameSelectorValue);
const roleSnapshotArgs = {
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
selector: selectorValue,
frameSelector: frameSelectorValue,
refsMode,
selector: plan.selectorValue,
frameSelector: plan.frameSelectorValue,
refsMode: plan.refsMode,
options: {
interactive: interactive ?? undefined,
compact: compact ?? undefined,
maxDepth: depth ?? undefined,
interactive: plan.interactive ?? undefined,
compact: plan.compact ?? undefined,
maxDepth: plan.depth ?? undefined,
},
};
const snap = wantsRoleSnapshot
const snap = plan.wantsRoleSnapshot
? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs)
: await pw
.snapshotAiViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
...(typeof plan.resolvedMaxChars === "number"
? { maxChars: plan.resolvedMaxChars }
: {}),
})
.catch(async (err) => {
// Public-API fallback when Playwright's private _snapshotForAI is missing.
@@ -295,7 +262,7 @@ export function registerBrowserAgentSnapshotRoutes(
}
throw err;
});
if (labels) {
if (plan.labels) {
const labeled = await pw.screenshotWithLabelsViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
@@ -316,7 +283,7 @@ export function registerBrowserAgentSnapshotRoutes(
const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png";
return res.json({
ok: true,
format,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
labels: true,
@@ -330,30 +297,32 @@ export function registerBrowserAgentSnapshotRoutes(
return res.json({
ok: true,
format,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...snap,
});
}
const snap =
profileCtx.profile.driver === "extension" || !tab.wsUrl
? (() => {
// Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session.
// Also covers cases where wsUrl is missing/unusable.
return requirePwAi(res, "aria snapshot").then(async (pw) => {
if (!pw) {
return null;
}
return await pw.snapshotAriaViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
limit,
});
const snap = shouldUsePlaywrightForAriaSnapshot({
profile: profileCtx.profile,
wsUrl: tab.wsUrl,
})
? (() => {
// Extension relay doesn't expose per-page WS URLs; run AX snapshot via Playwright CDP session.
// Also covers cases where wsUrl is missing/unusable.
return requirePwAi(res, "aria snapshot").then(async (pw) => {
if (!pw) {
return null;
}
return await pw.snapshotAriaViaPlaywright({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
limit: plan.limit,
});
})()
: snapshotAria({ wsUrl: tab.wsUrl ?? "", limit });
});
})()
: snapshotAria({ wsUrl: tab.wsUrl ?? "", limit: plan.limit });
const resolved = await Promise.resolve(snap);
if (!resolved) {
@@ -361,7 +330,7 @@ export function registerBrowserAgentSnapshotRoutes(
}
return res.json({
ok: true,
format,
format: plan.format,
targetId: tab.targetId,
url: tab.url,
...resolved,

View File

@@ -1,4 +1,5 @@
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
import { toBrowserErrorResponse } from "../errors.js";
import { createBrowserProfilesService } from "../profiles-service.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import { resolveProfileContext } from "./agent.shared.js";
@@ -18,6 +19,10 @@ async function withBasicProfileRoute(params: {
try {
await params.run(profileCtx);
} catch (err) {
const mapped = toBrowserErrorResponse(err);
if (mapped) {
return jsonError(params.res, mapped.status, mapped.message);
}
jsonError(params.res, 500, String(err));
}
}
@@ -157,20 +162,11 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
});
res.json(result);
} catch (err) {
const msg = String(err);
if (msg.includes("already exists")) {
return jsonError(res, 409, msg);
const mapped = toBrowserErrorResponse(err);
if (mapped) {
return jsonError(res, mapped.status, mapped.message);
}
if (msg.includes("invalid profile name")) {
return jsonError(res, 400, msg);
}
if (msg.includes("no available CDP ports")) {
return jsonError(res, 507, msg);
}
if (msg.includes("cdpUrl")) {
return jsonError(res, 400, msg);
}
jsonError(res, 500, msg);
jsonError(res, 500, String(err));
}
});
@@ -186,17 +182,11 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
const result = await service.deleteProfile(name);
res.json(result);
} catch (err) {
const msg = String(err);
if (msg.includes("invalid profile name")) {
return jsonError(res, 400, msg);
const mapped = toBrowserErrorResponse(err);
if (mapped) {
return jsonError(res, mapped.status, mapped.message);
}
if (msg.includes("default profile")) {
return jsonError(res, 400, msg);
}
if (msg.includes("not found")) {
return jsonError(res, 404, msg);
}
jsonError(res, 500, msg);
jsonError(res, 500, String(err));
}
});
}

View File

@@ -1,3 +1,4 @@
import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "../errors.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
@@ -50,7 +51,11 @@ async function withTabsProfileRoute(params: {
async function ensureBrowserRunning(profileCtx: ProfileContext, res: BrowserResponse) {
if (!(await profileCtx.isReachable(300))) {
jsonError(res, 409, "browser not running");
jsonError(
res,
new BrowserProfileUnavailableError("browser not running").status,
"browser not running",
);
return false;
}
return true;
@@ -191,7 +196,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
const tabs = await profileCtx.listTabs();
const target = resolveIndexedTab(tabs, index);
if (!target) {
return jsonError(res, 404, "tab not found");
throw new BrowserTabNotFoundError();
}
await profileCtx.closeTab(target.targetId);
return res.json({ ok: true, targetId: target.targetId });
@@ -204,7 +209,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
const tabs = await profileCtx.listTabs();
const target = tabs[index];
if (!target) {
return jsonError(res, 404, "tab not found");
throw new BrowserTabNotFoundError();
}
await profileCtx.focusTab(target.targetId);
return res.json({ ok: true, targetId: target.targetId });

View File

@@ -0,0 +1,60 @@
import type { Server } from "node:http";
import { isPwAiLoaded } from "./pw-ai-state.js";
import type { BrowserServerState } from "./server-context.js";
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
export async function createBrowserRuntimeState(params: {
resolved: BrowserServerState["resolved"];
port: number;
server?: Server | null;
onWarn: (message: string) => void;
}): Promise<BrowserServerState> {
const state: BrowserServerState = {
server: params.server ?? null,
port: params.port,
resolved: params.resolved,
profiles: new Map(),
};
await ensureExtensionRelayForProfiles({
resolved: params.resolved,
onWarn: params.onWarn,
});
return state;
}
export async function stopBrowserRuntime(params: {
current: BrowserServerState | null;
getState: () => BrowserServerState | null;
clearState: () => void;
closeServer?: boolean;
onWarn: (message: string) => void;
}): Promise<void> {
if (!params.current) {
return;
}
await stopKnownBrowserProfiles({
getState: params.getState,
onWarn: params.onWarn,
});
if (params.closeServer && params.current.server) {
await new Promise<void>((resolve) => {
params.current?.server?.close(() => resolve());
});
}
params.clearState();
if (!isPwAiLoaded()) {
return;
}
try {
const mod = await import("./pw-ai.js");
await mod.closePlaywrightBrowserConnection();
} catch {
// ignore
}
}

View File

@@ -10,10 +10,12 @@ import {
stopOpenClawChrome,
} from "./chrome.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserConfigurationError, BrowserProfileUnavailableError } from "./errors.js";
import {
ensureChromeExtensionRelayServer,
stopChromeExtensionRelayServer,
} from "./extension-relay.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import {
CDP_READY_AFTER_LAUNCH_MAX_TIMEOUT_MS,
CDP_READY_AFTER_LAUNCH_MIN_TIMEOUT_MS,
@@ -48,6 +50,7 @@ export function createProfileAvailability({
getProfileState,
setProfileRunning,
}: AvailabilityDeps): AvailabilityOps {
const capabilities = getBrowserProfileCapabilities(profile);
const resolveTimeouts = (timeoutMs: number | undefined) =>
resolveCdpReachabilityTimeouts({
profileIsLoopback: profile.cdpIsLoopback,
@@ -80,6 +83,38 @@ export function createProfileAvailability({
});
};
const closePlaywrightBrowserConnectionForProfile = async (cdpUrl?: string): Promise<void> => {
try {
const mod = await import("./pw-ai.js");
await mod.closePlaywrightBrowserConnection(cdpUrl ? { cdpUrl } : undefined);
} catch {
// ignore
}
};
const reconcileProfileRuntime = async (): Promise<void> => {
const profileState = getProfileState();
const reconcile = profileState.reconcile;
if (!reconcile) {
return;
}
profileState.reconcile = null;
profileState.lastTargetId = null;
const previousProfile = reconcile.previousProfile;
if (profileState.running) {
await stopOpenClawChrome(profileState.running).catch(() => {});
setProfileRunning(null);
}
if (previousProfile.driver === "extension") {
await stopChromeExtensionRelayServer({ cdpUrl: previousProfile.cdpUrl }).catch(() => false);
}
await closePlaywrightBrowserConnectionForProfile(previousProfile.cdpUrl);
if (previousProfile.cdpUrl !== profile.cdpUrl) {
await closePlaywrightBrowserConnectionForProfile(profile.cdpUrl);
}
};
const waitForCdpReadyAfterLaunch = async (): Promise<void> => {
// launchOpenClawChrome() can return before Chrome is fully ready to serve /json/version + CDP WS.
// If a follow-up call races ahead, we can hit PortInUseError trying to launch again on the same port.
@@ -102,15 +137,16 @@ export function createProfileAvailability({
};
const ensureBrowserAvailable = async (): Promise<void> => {
await reconcileProfileRuntime();
const current = state();
const remoteCdp = !profile.cdpIsLoopback;
const remoteCdp = capabilities.isRemote;
const attachOnly = profile.attachOnly;
const isExtension = profile.driver === "extension";
const isExtension = capabilities.requiresRelay;
const profileState = getProfileState();
const httpReachable = await isHttpReachable();
if (isExtension && remoteCdp) {
throw new Error(
throw new BrowserConfigurationError(
`Profile "${profile.name}" uses driver=extension but cdpUrl is not loopback (${profile.cdpUrl}).`,
);
}
@@ -122,7 +158,7 @@ export function createProfileAvailability({
bindHost: current.resolved.relayBindHost,
});
if (!(await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS))) {
throw new Error(
throw new BrowserProfileUnavailableError(
`Chrome extension relay for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`,
);
}
@@ -140,7 +176,7 @@ export function createProfileAvailability({
}
}
if (attachOnly || remoteCdp) {
throw new Error(
throw new BrowserProfileUnavailableError(
remoteCdp
? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`
: `Browser attachOnly is enabled and profile "${profile.name}" is not running.`,
@@ -172,7 +208,7 @@ export function createProfileAvailability({
return;
}
}
throw new Error(
throw new BrowserProfileUnavailableError(
remoteCdp
? `Remote CDP websocket for profile "${profile.name}" is not reachable.`
: `Browser attachOnly is enabled and CDP websocket for profile "${profile.name}" is not reachable.`,
@@ -181,7 +217,7 @@ export function createProfileAvailability({
// HTTP responds but WebSocket fails - port in use by something else.
if (!profileState.running) {
throw new Error(
throw new BrowserProfileUnavailableError(
`Port ${profile.cdpPort} is in use for profile "${profile.name}" but not by openclaw. ` +
`Run action=reset-profile profile=${profile.name} to kill the process.`,
);
@@ -201,7 +237,8 @@ export function createProfileAvailability({
};
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
if (profile.driver === "extension") {
await reconcileProfileRuntime();
if (capabilities.requiresRelay) {
const stopped = await stopChromeExtensionRelayServer({
cdpUrl: profile.cdpUrl,
});

View File

@@ -1,9 +1,10 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveBrowserConfig } from "./config.js";
import { resolveBrowserConfig, resolveProfile } from "./config.js";
import {
refreshResolvedBrowserConfigFromDisk,
resolveBrowserProfileWithHotReload,
} from "./resolved-config-refresh.js";
import type { BrowserServerState } from "./server-context.types.js";
let cfgProfiles: Record<string, { cdpPort?: number; cdpUrl?: string; color?: string }> = {};
@@ -166,4 +167,42 @@ describe("server-context hot-reload profiles", () => {
});
expect(Object.keys(state.resolved.profiles)).toContain("desktop");
});
it("marks existing runtime state for reconcile when profile invariants change", async () => {
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const openclawProfile = resolveProfile(resolved, "openclaw");
expect(openclawProfile).toBeTruthy();
const state: BrowserServerState = {
server: null,
port: 18791,
resolved,
profiles: new Map([
[
"openclaw",
{
profile: openclawProfile!,
running: { pid: 123 } as never,
lastTargetId: "tab-1",
reconcile: null,
},
],
]),
};
cfgProfiles.openclaw = { cdpPort: 19999, color: "#FF4500" };
cachedConfig = null;
refreshResolvedBrowserConfigFromDisk({
current: state,
refreshConfigFromDisk: true,
mode: "cached",
});
const runtime = state.profiles.get("openclaw");
expect(runtime).toBeTruthy();
expect(runtime?.profile.cdpPort).toBe(19999);
expect(runtime?.lastTargetId).toBeNull();
expect(runtime?.reconcile?.reason).toContain("cdpPort");
});
});

View File

@@ -1,6 +1,8 @@
import fs from "node:fs";
import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserResetUnsupportedError } from "./errors.js";
import { stopChromeExtensionRelayServer } from "./extension-relay.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import type { ProfileRuntimeState } from "./server-context.types.js";
import { movePathToTrash } from "./trash.js";
@@ -32,13 +34,14 @@ export function createProfileResetOps({
isHttpReachable,
resolveOpenClawUserDataDir,
}: ResetDeps): ResetOps {
const capabilities = getBrowserProfileCapabilities(profile);
const resetProfile = async () => {
if (profile.driver === "extension") {
if (capabilities.requiresRelay) {
await stopChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch(() => {});
return { moved: false, from: profile.cdpUrl };
}
if (!profile.cdpIsLoopback) {
throw new Error(
if (!capabilities.supportsReset) {
throw new BrowserResetUnsupportedError(
`reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`,
);
}

View File

@@ -1,6 +1,8 @@
import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
import { appendCdpPath } from "./cdp.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserTabNotFoundError, BrowserTargetAmbiguousError } from "./errors.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import type { PwAiModule } from "./pw-ai-module.js";
import { getPwAiModule } from "./pw-ai-module.js";
import type { BrowserTab, ProfileRuntimeState } from "./server-context.types.js";
@@ -28,13 +30,14 @@ export function createProfileSelectionOps({
openTab,
}: SelectionDeps): SelectionOps {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
const capabilities = getBrowserProfileCapabilities(profile);
const ensureTabAvailable = async (targetId?: string): Promise<BrowserTab> => {
await ensureBrowserAvailable();
const profileState = getProfileState();
let tabs1 = await listTabs();
if (tabs1.length === 0) {
if (profile.driver === "extension") {
if (capabilities.requiresAttachedTab) {
// Chrome extension relay can briefly drop its WebSocket connection (MV3 service worker
// lifecycle, relay restart). If we previously had a target selected, wait briefly for
// the extension to reconnect and re-announce its attached tabs before failing.
@@ -46,7 +49,7 @@ export function createProfileSelectionOps({
}
}
if (tabs1.length === 0) {
throw new Error(
throw new BrowserTabNotFoundError(
`tab not found (no attached Chrome tabs for profile "${profile.name}"). ` +
"Click the OpenClaw Browser Relay toolbar icon on the tab you want to control (badge ON).",
);
@@ -57,12 +60,7 @@ export function createProfileSelectionOps({
}
const tabs = await listTabs();
// For remote profiles using Playwright's persistent connection, we don't need wsUrl
// because we access pages directly through Playwright, not via individual WebSocket URLs.
const candidates =
profile.driver === "extension" || !profile.cdpIsLoopback
? tabs
: tabs.filter((t) => Boolean(t.wsUrl));
const candidates = capabilities.supportsPerTabWs ? tabs.filter((t) => Boolean(t.wsUrl)) : tabs;
const resolveById = (raw: string) => {
const resolved = resolveTargetIdFromTabs(raw, candidates);
@@ -89,10 +87,10 @@ export function createProfileSelectionOps({
const chosen = targetId ? resolveById(targetId) : pickDefault();
if (chosen === "AMBIGUOUS") {
throw new Error("ambiguous target id prefix");
throw new BrowserTargetAmbiguousError();
}
if (!chosen) {
throw new Error("tab not found");
throw new BrowserTabNotFoundError();
}
profileState.lastTargetId = chosen.targetId;
return chosen;
@@ -103,9 +101,9 @@ export function createProfileSelectionOps({
const resolved = resolveTargetIdFromTabs(targetId, tabs);
if (!resolved.ok) {
if (resolved.reason === "ambiguous") {
throw new Error("ambiguous target id prefix");
throw new BrowserTargetAmbiguousError();
}
throw new Error("tab not found");
throw new BrowserTabNotFoundError();
}
return resolved.targetId;
};
@@ -113,7 +111,7 @@ export function createProfileSelectionOps({
const focusTab = async (targetId: string): Promise<void> => {
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
if (!profile.cdpIsLoopback) {
if (capabilities.usesPersistentPlaywright) {
const mod = await getPwAiModule({ mode: "strict" });
const focusPageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
?.focusPageByTargetIdViaPlaywright;
@@ -137,7 +135,7 @@ export function createProfileSelectionOps({
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
// For remote profiles, use Playwright's persistent connection to close tabs
if (!profile.cdpIsLoopback) {
if (capabilities.usesPersistentPlaywright) {
const mod = await getPwAiModule({ mode: "strict" });
const closePageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
?.closePageByTargetIdViaPlaywright;

View File

@@ -7,6 +7,7 @@ import {
assertBrowserNavigationResultAllowed,
withBrowserNavigationPolicy,
} from "./navigation-guard.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import type { PwAiModule } from "./pw-ai-module.js";
import { getPwAiModule } from "./pw-ai-module.js";
import {
@@ -59,10 +60,10 @@ export function createProfileTabOps({
getProfileState,
}: TabOpsDeps): ProfileTabOps {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
const capabilities = getBrowserProfileCapabilities(profile);
const listTabs = async (): Promise<BrowserTab[]> => {
// For remote profiles, use Playwright's persistent connection to avoid ephemeral sessions
if (!profile.cdpIsLoopback) {
if (capabilities.usesPersistentPlaywright) {
const mod = await getPwAiModule({ mode: "strict" });
const listPagesViaPlaywright = (mod as Partial<PwAiModule> | null)?.listPagesViaPlaywright;
if (typeof listPagesViaPlaywright === "function") {
@@ -99,8 +100,7 @@ export function createProfileTabOps({
const enforceManagedTabLimit = async (keepTargetId: string): Promise<void> => {
const profileState = getProfileState();
if (
profile.driver !== "openclaw" ||
!profile.cdpIsLoopback ||
!capabilities.supportsManagedTabLimit ||
state().resolved.attachOnly ||
!profileState.running
) {
@@ -132,9 +132,7 @@ export function createProfileTabOps({
const openTab = async (url: string): Promise<BrowserTab> => {
const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy);
// For remote profiles, use Playwright's persistent connection to create tabs
// This ensures the tab persists beyond a single request.
if (!profile.cdpIsLoopback) {
if (capabilities.usesPersistentPlaywright) {
const mod = await getPwAiModule({ mode: "strict" });
const createPageViaPlaywright = (mod as Partial<PwAiModule> | null)?.createPageViaPlaywright;
if (typeof createPageViaPlaywright === "function") {

View File

@@ -2,6 +2,7 @@ import { SsrFBlockedError } from "../infra/net/ssrf.js";
import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { resolveProfile } from "./config.js";
import { BrowserProfileNotFoundError, toBrowserErrorResponse } from "./errors.js";
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
import {
refreshResolvedBrowserConfigFromDisk,
@@ -57,7 +58,7 @@ function createProfileContext(
const current = state();
let profileState = current.profiles.get(profile.name);
if (!profileState) {
profileState = { profile, running: null, lastTargetId: null };
profileState = { profile, running: null, lastTargetId: null, reconcile: null };
current.profiles.set(profile.name, profileState);
}
return profileState;
@@ -136,7 +137,9 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
if (!profile) {
const available = Object.keys(current.resolved.profiles).join(", ");
throw new Error(`Profile "${name}" not found. Available profiles: ${available || "(none)"}`);
throw new BrowserProfileNotFoundError(
`Profile "${name}" not found. Available profiles: ${available || "(none)"}`,
);
}
return createProfileContext(opts, profile);
};
@@ -150,9 +153,9 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
});
const result: ProfileStatus[] = [];
for (const name of Object.keys(current.resolved.profiles)) {
for (const name of listKnownProfileNames(current)) {
const profileState = current.profiles.get(name);
const profile = resolveProfile(current.resolved, name);
const profile = resolveProfile(current.resolved, name) ?? profileState?.profile;
if (!profile) {
continue;
}
@@ -193,6 +196,8 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
tabCount,
isDefault: name === current.resolved.defaultProfile,
isRemote: !profile.cdpIsLoopback,
missingFromConfig: !(name in current.resolved.profiles) || undefined,
reconcileReason: profileState?.reconcile?.reason ?? null,
});
}
@@ -203,22 +208,16 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
const getDefaultContext = () => forProfile();
const mapTabError = (err: unknown) => {
const browserMapped = toBrowserErrorResponse(err);
if (browserMapped) {
return browserMapped;
}
if (err instanceof SsrFBlockedError) {
return { status: 400, message: err.message };
}
if (err instanceof InvalidBrowserNavigationUrlError) {
return { status: 400, message: err.message };
}
const msg = String(err);
if (msg.includes("ambiguous target id prefix")) {
return { status: 409, message: "ambiguous target id prefix" };
}
if (msg.includes("tab not found")) {
return { status: 404, message: msg };
}
if (msg.includes("not found")) {
return { status: 404, message: msg };
}
return null;
};

View File

@@ -13,6 +13,10 @@ export type ProfileRuntimeState = {
running: RunningChrome | null;
/** Sticky tab selection when callers omit targetId (keeps snapshot+act consistent). */
lastTargetId?: string | null;
reconcile?: {
previousProfile: ResolvedBrowserProfile;
reason: string;
} | null;
};
export type BrowserServerState = {
@@ -56,6 +60,8 @@ export type ProfileStatus = {
tabCount: number;
isDefault: boolean;
isRemote: boolean;
missingFromConfig?: boolean;
reconcileReason?: string | null;
};
export type ContextOptions = {

View File

@@ -116,6 +116,19 @@ describe("profile CRUD endpoints", () => {
const createBadRemoteBody = (await createBadRemote.json()) as { error: string };
expect(createBadRemoteBody.error).toContain("cdpUrl");
const createBadExtension = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "badextension",
driver: "extension",
cdpUrl: "http://10.0.0.42:9222",
}),
});
expect(createBadExtension.status).toBe(400);
const createBadExtensionBody = (await createBadExtension.json()) as { error: string };
expect(createBadExtensionBody.error).toContain("loopback cdpUrl host");
const deleteMissing = await realFetch(`${base}/profiles/nonexistent`, {
method: "DELETE",
});

View File

@@ -4,11 +4,10 @@ import { loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveBrowserConfig } from "./config.js";
import { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./control-auth.js";
import { isPwAiLoaded } from "./pw-ai-state.js";
import { registerBrowserRoutes } from "./routes/index.js";
import type { BrowserRouteRegistrar } from "./routes/types.js";
import { createBrowserRuntimeState, stopBrowserRuntime } from "./runtime-lifecycle.js";
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
import {
installBrowserAuthMiddleware,
installBrowserCommonMiddleware,
@@ -74,14 +73,9 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
return null;
}
state = {
state = await createBrowserRuntimeState({
server,
port,
resolved,
profiles: new Map(),
};
await ensureExtensionRelayForProfiles({
resolved,
onWarn: (message) => logServer.warn(message),
});
@@ -93,29 +87,13 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
export async function stopBrowserControlServer(): Promise<void> {
const current = state;
if (!current) {
return;
}
await stopKnownBrowserProfiles({
await stopBrowserRuntime({
current,
getState: () => state,
clearState: () => {
state = null;
},
closeServer: true,
onWarn: (message) => logServer.warn(message),
});
if (current.server) {
await new Promise<void>((resolve) => {
current.server?.close(() => resolve());
});
}
state = null;
// Optional: avoid importing heavy Playwright bridge when this process never used it.
if (isPwAiLoaded()) {
try {
const mod = await import("./pw-ai.js");
await mod.closePlaywrightBrowserConnection();
} catch {
// ignore
}
}
}

View File

@@ -2,6 +2,7 @@ import type { Command } from "commander";
import { runAcpClientInteractive } from "../acp/client.js";
import { readSecretFromFile } from "../acp/secret-file.js";
import { serveAcpGateway } from "../acp/server.js";
import { normalizeAcpProvenanceMode } from "../acp/types.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
@@ -45,6 +46,7 @@ export function registerAcpCli(program: Command) {
.option("--require-existing", "Fail if the session key/label does not exist", false)
.option("--reset-session", "Reset the session key before first use", false)
.option("--no-prefix-cwd", "Do not prefix prompts with the working directory", false)
.option("--provenance <mode>", "ACP provenance mode: off, meta, or meta+receipt")
.option("-v, --verbose", "Verbose logging to stderr", false)
.addHelpText(
"after",
@@ -72,6 +74,10 @@ export function registerAcpCli(program: Command) {
if (opts.password) {
warnSecretCliFlag("--password");
}
const provenanceMode = normalizeAcpProvenanceMode(opts.provenance as string | undefined);
if (opts.provenance && !provenanceMode) {
throw new Error("Invalid --provenance value. Use off, meta, or meta+receipt.");
}
await serveAcpGateway({
gatewayUrl: opts.url as string | undefined,
gatewayToken,
@@ -81,6 +87,7 @@ export function registerAcpCli(program: Command) {
requireExistingSession: Boolean(opts.requireExisting),
resetSession: Boolean(opts.resetSession),
prefixCwd: !opts.noPrefixCwd,
provenanceMode,
verbose: Boolean(opts.verbose),
});
} catch (err) {

View File

@@ -36,17 +36,16 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"])
const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]);
const resolveGatewayPort = vi.fn(() => 18789);
const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []);
const probeGateway =
vi.fn<
(opts: {
url: string;
auth?: { token?: string; password?: string };
timeoutMs: number;
}) => Promise<{
ok: boolean;
configSnapshot: unknown;
}>
>();
const probeGateway = vi.fn<
(opts: {
url: string;
auth?: { token?: string; password?: string };
timeoutMs: number;
}) => Promise<{
ok: boolean;
configSnapshot: unknown;
}>
>();
const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true);
const loadConfig = vi.fn(() => ({}));

View File

@@ -188,7 +188,10 @@ async function promptWebToolsConfig(
if (stored && SEARCH_PROVIDER_OPTIONS.some((e) => e.value === stored)) {
return stored;
}
return SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ?? "brave";
return (
SEARCH_PROVIDER_OPTIONS.find((e) => hasKeyForProvider(e.value))?.value ??
SEARCH_PROVIDER_OPTIONS[0].value
);
})();
note(

View File

@@ -1,7 +1,24 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
const OPENAI_CODEX_MODEL = {
provider: "openai-codex",
id: "gpt-5.4",
name: "GPT-5.4",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
input: ["text"],
contextWindow: 1_050_000,
maxTokens: 128000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
};
const OPENAI_CODEX_53_MODEL = {
...OPENAI_CODEX_MODEL,
id: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
};
const mocks = vi.hoisted(() => {
const printModelTable = vi.fn();
const sourceConfig = {
agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } },
models: {
@@ -23,48 +40,62 @@ const mocks = vi.hoisted(() => {
},
};
return {
loadConfig: vi.fn().mockReturnValue({
agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } },
models: { providers: {} },
}),
sourceConfig,
resolvedConfig,
loadModelsConfigWithSource: vi.fn().mockResolvedValue({
sourceConfig,
resolvedConfig,
diagnostics: [],
}),
ensureAuthProfileStore: vi.fn().mockReturnValue({ version: 1, profiles: {}, order: {} }),
loadModelRegistry: vi
.fn()
.mockResolvedValue({ models: [], availableKeys: new Set(), registry: {} }),
loadModelCatalog: vi.fn().mockResolvedValue([]),
resolveConfiguredEntries: vi.fn().mockReturnValue({
entries: [
{
key: "openai-codex/gpt-5.4",
ref: { provider: "openai-codex", model: "gpt-5.4" },
tags: new Set(["configured"]),
aliases: [],
},
],
}),
printModelTable,
listProfilesForProvider: vi.fn().mockReturnValue([]),
resolveModelWithRegistry: vi.fn().mockReturnValue({
provider: "openai-codex",
id: "gpt-5.4",
name: "GPT-5.4",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
input: ["text"],
contextWindow: 272000,
maxTokens: 128000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
}),
loadConfig: vi.fn(),
loadModelsConfigWithSource: vi.fn(),
ensureAuthProfileStore: vi.fn(),
loadModelRegistry: vi.fn(),
loadModelCatalog: vi.fn(),
resolveConfiguredEntries: vi.fn(),
printModelTable: vi.fn(),
listProfilesForProvider: vi.fn(),
resolveModelWithRegistry: vi.fn(),
};
});
function resetMocks() {
mocks.loadConfig.mockReturnValue({
agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } },
models: { providers: {} },
});
mocks.loadModelsConfigWithSource.mockResolvedValue({
sourceConfig: mocks.sourceConfig,
resolvedConfig: mocks.resolvedConfig,
diagnostics: [],
});
mocks.ensureAuthProfileStore.mockReturnValue({ version: 1, profiles: {}, order: {} });
mocks.loadModelRegistry.mockResolvedValue({
models: [],
availableKeys: new Set(),
registry: {
getAll: () => [],
},
});
mocks.loadModelCatalog.mockResolvedValue([]);
mocks.resolveConfiguredEntries.mockReturnValue({
entries: [
{
key: "openai-codex/gpt-5.4",
ref: { provider: "openai-codex", model: "gpt-5.4" },
tags: new Set(["configured"]),
aliases: [],
},
],
});
mocks.printModelTable.mockReset();
mocks.listProfilesForProvider.mockReturnValue([]);
mocks.resolveModelWithRegistry.mockReturnValue({ ...OPENAI_CODEX_MODEL });
}
function createRuntime() {
return { log: vi.fn(), error: vi.fn() };
}
function lastPrintedRows<T>() {
return (mocks.printModelTable.mock.calls.at(-1)?.[0] ?? []) as T[];
}
vi.mock("../../config/config.js", () => ({
loadConfig: mocks.loadConfig,
getRuntimeConfigSnapshot: vi.fn().mockReturnValue(null),
@@ -114,188 +145,174 @@ vi.mock("../../agents/pi-embedded-runner/model.js", async (importOriginal) => {
import { modelsListCommand } from "./list.list-command.js";
beforeEach(() => {
vi.clearAllMocks();
resetMocks();
});
describe("modelsListCommand forward-compat", () => {
it("does not mark configured codex model as missing when forward-compat can build a fallback", async () => {
const runtime = { log: vi.fn(), error: vi.fn() };
describe("configured rows", () => {
it("does not mark configured codex model as missing when forward-compat can build a fallback", async () => {
const runtime = createRuntime();
await modelsListCommand({ json: true }, runtime as never);
expect(mocks.printModelTable).toHaveBeenCalled();
const rows = mocks.printModelTable.mock.calls[0]?.[0] as Array<{
key: string;
tags: string[];
missing: boolean;
}>;
const codex = rows.find((r) => r.key === "openai-codex/gpt-5.4");
expect(codex).toBeTruthy();
expect(codex?.missing).toBe(false);
expect(codex?.tags).not.toContain("missing");
});
it("passes source config to model registry loading for persistence safety", async () => {
const runtime = { log: vi.fn(), error: vi.fn() };
await modelsListCommand({ json: true }, runtime as never);
expect(mocks.loadModelRegistry).toHaveBeenCalledWith(mocks.resolvedConfig, {
sourceConfig: mocks.sourceConfig,
});
});
it("keeps configured local openai gpt-5.4 entries visible in --local output", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({
entries: [
{
key: "openai/gpt-5.4",
ref: { provider: "openai", model: "gpt-5.4" },
tags: new Set(["configured"]),
aliases: [],
},
],
});
mocks.resolveModelWithRegistry.mockReturnValueOnce({
provider: "openai",
id: "gpt-5.4",
name: "GPT-5.4",
api: "openai-responses",
baseUrl: "http://localhost:4000/v1",
input: ["text", "image"],
contextWindow: 1_050_000,
maxTokens: 128_000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
});
const runtime = { log: vi.fn(), error: vi.fn() };
await modelsListCommand({ json: true, local: true }, runtime as never);
expect(mocks.printModelTable).toHaveBeenCalled();
const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ key: string }>;
expect(rows).toEqual([
expect.objectContaining({
key: "openai/gpt-5.4",
}),
]);
});
it("marks synthetic codex gpt-5.4 rows as available when provider auth exists", async () => {
mocks.loadModelRegistry.mockResolvedValueOnce({
models: [],
availableKeys: new Set(),
registry: {},
});
mocks.listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "openai-codex" ? ([{ id: "profile-1" }] as Array<Record<string, unknown>>) : [],
);
const runtime = { log: vi.fn(), error: vi.fn() };
try {
await modelsListCommand({ json: true }, runtime as never);
expect(mocks.printModelTable).toHaveBeenCalled();
const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{
const rows = lastPrintedRows<{
key: string;
available: boolean;
}>;
tags: string[];
missing: boolean;
}>();
expect(rows).toContainEqual(
const codex = rows.find((row) => row.key === "openai-codex/gpt-5.4");
expect(codex).toBeTruthy();
expect(codex?.missing).toBe(false);
expect(codex?.tags).not.toContain("missing");
});
it("passes source config to model registry loading for persistence safety", async () => {
const runtime = createRuntime();
await modelsListCommand({ json: true }, runtime as never);
expect(mocks.loadModelRegistry).toHaveBeenCalledWith(mocks.resolvedConfig, {
sourceConfig: mocks.sourceConfig,
});
});
it("keeps configured local openai gpt-5.4 entries visible in --local output", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({
entries: [
{
key: "openai/gpt-5.4",
ref: { provider: "openai", model: "gpt-5.4" },
tags: new Set(["configured"]),
aliases: [],
},
],
});
mocks.resolveModelWithRegistry.mockReturnValueOnce({
provider: "openai",
id: "gpt-5.4",
name: "GPT-5.4",
api: "openai-responses",
baseUrl: "http://localhost:4000/v1",
input: ["text", "image"],
contextWindow: 1_050_000,
maxTokens: 128_000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
});
const runtime = createRuntime();
await modelsListCommand({ json: true, local: true }, runtime as never);
expect(mocks.printModelTable).toHaveBeenCalled();
expect(lastPrintedRows<{ key: string }>()).toEqual([
expect.objectContaining({
key: "openai/gpt-5.4",
}),
]);
});
});
describe("availability fallback", () => {
it("marks synthetic codex gpt-5.4 rows as available when provider auth exists", async () => {
mocks.listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "openai-codex"
? ([{ id: "profile-1" }] as Array<Record<string, unknown>>)
: [],
);
const runtime = createRuntime();
await modelsListCommand({ json: true }, runtime as never);
expect(mocks.printModelTable).toHaveBeenCalled();
expect(lastPrintedRows<{ key: string; available: boolean }>()).toContainEqual(
expect.objectContaining({
key: "openai-codex/gpt-5.4",
available: true,
}),
);
} finally {
mocks.listProfilesForProvider.mockReturnValue([]);
}
});
it("exits with an error when configured-mode listing has no model registry", async () => {
const previousExitCode = process.exitCode;
process.exitCode = undefined;
mocks.loadModelRegistry.mockResolvedValueOnce({
models: [],
availableKeys: new Set<string>(),
registry: undefined,
});
const runtime = createRuntime();
let observedExitCode: number | undefined;
try {
await modelsListCommand({ json: true }, runtime as never);
observedExitCode = process.exitCode;
} finally {
process.exitCode = previousExitCode;
}
expect(runtime.error).toHaveBeenCalledWith("Model registry unavailable.");
expect(observedExitCode).toBe(1);
expect(mocks.printModelTable).not.toHaveBeenCalled();
});
});
it("includes synthetic codex gpt-5.4 in --all output when catalog supports it", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.loadModelRegistry.mockResolvedValueOnce({
models: [
describe("--all catalog supplementation", () => {
it("includes synthetic codex gpt-5.4 in --all output when catalog supports it", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.loadModelRegistry.mockResolvedValueOnce({
models: [{ ...OPENAI_CODEX_53_MODEL }],
availableKeys: new Set(["openai-codex/gpt-5.3-codex"]),
registry: {
getAll: () => [{ ...OPENAI_CODEX_53_MODEL }],
},
});
mocks.loadModelCatalog.mockResolvedValueOnce([
{
provider: "openai-codex",
id: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
input: ["text"],
contextWindow: 272000,
maxTokens: 128000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
],
availableKeys: new Set(["openai-codex/gpt-5.3-codex"]),
registry: {},
});
mocks.loadModelCatalog.mockResolvedValueOnce([
{
provider: "openai-codex",
id: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
input: ["text"],
contextWindow: 272000,
},
{
provider: "openai-codex",
id: "gpt-5.4",
name: "GPT-5.4",
input: ["text"],
contextWindow: 272000,
},
]);
mocks.listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "openai-codex" ? ([{ id: "profile-1" }] as Array<Record<string, unknown>>) : [],
);
mocks.resolveModelWithRegistry.mockImplementation(
({ provider, modelId }: { provider: string; modelId: string }) => {
if (provider !== "openai-codex") {
{
provider: "openai-codex",
id: "gpt-5.4",
name: "GPT-5.4",
input: ["text"],
contextWindow: 272000,
},
]);
mocks.listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "openai-codex"
? ([{ id: "profile-1" }] as Array<Record<string, unknown>>)
: [],
);
mocks.resolveModelWithRegistry.mockImplementation(
({ provider, modelId }: { provider: string; modelId: string }) => {
if (provider !== "openai-codex") {
return undefined;
}
if (modelId === "gpt-5.3-codex") {
return { ...OPENAI_CODEX_53_MODEL };
}
if (modelId === "gpt-5.4") {
return { ...OPENAI_CODEX_MODEL };
}
return undefined;
}
if (modelId === "gpt-5.3-codex") {
return {
provider: "openai-codex",
id: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
input: ["text"],
contextWindow: 272000,
maxTokens: 128000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
};
}
if (modelId === "gpt-5.4") {
return {
provider: "openai-codex",
id: "gpt-5.4",
name: "GPT-5.4",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
input: ["text"],
contextWindow: 272000,
maxTokens: 128000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
};
}
return undefined;
},
);
const runtime = { log: vi.fn(), error: vi.fn() };
},
);
const runtime = createRuntime();
try {
await modelsListCommand(
{ all: true, provider: "openai-codex", json: true },
runtime as never,
);
expect(mocks.printModelTable).toHaveBeenCalled();
const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{
key: string;
available: boolean;
}>;
expect(rows).toEqual([
expect(lastPrintedRows<{ key: string; available: boolean }>()).toEqual([
expect.objectContaining({
key: "openai-codex/gpt-5.3-codex",
}),
@@ -304,66 +321,31 @@ describe("modelsListCommand forward-compat", () => {
available: true,
}),
]);
} finally {
mocks.listProfilesForProvider.mockReturnValue([]);
}
});
});
it("keeps discovered rows in --all output when catalog lookup is empty", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.loadModelRegistry.mockResolvedValueOnce({
models: [
{
provider: "openai-codex",
id: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
input: ["text"],
contextWindow: 272000,
maxTokens: 128000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
it("keeps discovered rows in --all output when catalog lookup is empty", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.loadModelRegistry.mockResolvedValueOnce({
models: [{ ...OPENAI_CODEX_53_MODEL }],
availableKeys: new Set(["openai-codex/gpt-5.3-codex"]),
registry: {
getAll: () => [{ ...OPENAI_CODEX_53_MODEL }],
},
],
availableKeys: new Set(["openai-codex/gpt-5.3-codex"]),
registry: {},
});
mocks.loadModelCatalog.mockResolvedValueOnce([]);
const runtime = createRuntime();
await modelsListCommand(
{ all: true, provider: "openai-codex", json: true },
runtime as never,
);
expect(mocks.printModelTable).toHaveBeenCalled();
expect(lastPrintedRows<{ key: string }>()).toEqual([
expect.objectContaining({
key: "openai-codex/gpt-5.3-codex",
}),
]);
});
mocks.loadModelCatalog.mockResolvedValueOnce([]);
const runtime = { log: vi.fn(), error: vi.fn() };
await modelsListCommand({ all: true, provider: "openai-codex", json: true }, runtime as never);
expect(mocks.printModelTable).toHaveBeenCalled();
const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ key: string }>;
expect(rows).toEqual([
expect.objectContaining({
key: "openai-codex/gpt-5.3-codex",
}),
]);
});
it("exits with an error when configured-mode listing has no model registry", async () => {
vi.clearAllMocks();
const previousExitCode = process.exitCode;
process.exitCode = undefined;
mocks.loadModelRegistry.mockResolvedValueOnce({
models: [],
availableKeys: new Set<string>(),
registry: undefined,
});
const runtime = { log: vi.fn(), error: vi.fn() };
let observedExitCode: number | undefined;
try {
await modelsListCommand({ json: true }, runtime as never);
observedExitCode = process.exitCode;
} finally {
process.exitCode = previousExitCode;
}
expect(runtime.error).toHaveBeenCalledWith("Model registry unavailable.");
expect(observedExitCode).toBe(1);
expect(mocks.printModelTable).not.toHaveBeenCalled();
});
});

View File

@@ -1,16 +1,18 @@
import type { Api, Model } from "@mariozechner/pi-ai";
import type { ModelRegistry } from "@mariozechner/pi-coding-agent";
import { loadModelCatalog } from "../../agents/model-catalog.js";
import { parseModelRef } from "../../agents/model-selection.js";
import { resolveModelWithRegistry } from "../../agents/pi-embedded-runner/model.js";
import type { RuntimeEnv } from "../../runtime.js";
import { resolveConfiguredEntries } from "./list.configured.js";
import { formatErrorWithStack } from "./list.errors.js";
import { loadModelRegistry, toModelRow } from "./list.registry.js";
import {
appendCatalogSupplementRows,
appendConfiguredRows,
appendDiscoveredRows,
loadListModelRegistry,
} from "./list.rows.js";
import { printModelTable } from "./list.table.js";
import type { ModelRow } from "./list.types.js";
import { loadModelsConfigWithSource } from "./load-config.js";
import { DEFAULT_PROVIDER, ensureFlagCompatibility, isLocalBaseUrl, modelKey } from "./shared.js";
import { DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js";
export async function modelsListCommand(
opts: {
@@ -39,17 +41,17 @@ export async function modelsListCommand(
return parsed?.provider ?? raw.toLowerCase();
})();
let models: Model<Api>[] = [];
let modelRegistry: ModelRegistry | undefined;
let discoveredKeys = new Set<string>();
let availableKeys: Set<string> | undefined;
let availabilityErrorMessage: string | undefined;
try {
// Keep command behavior explicit: sync models.json from the source config
// before building the read-only model registry view.
await ensureOpenClawModelsJson(sourceConfig ?? cfg);
const loaded = await loadModelRegistry(cfg, { sourceConfig });
const loaded = await loadListModelRegistry(cfg, { sourceConfig });
modelRegistry = loaded.registry;
models = loaded.models;
discoveredKeys = loaded.discoveredKeys;
availableKeys = loaded.availableKeys;
availabilityErrorMessage = loaded.availabilityErrorMessage;
} catch (err) {
@@ -62,83 +64,36 @@ export async function modelsListCommand(
`Model availability lookup failed; falling back to auth heuristics for discovered models: ${availabilityErrorMessage}`,
);
}
const discoveredKeys = new Set(models.map((model) => modelKey(model.provider, model.id)));
const { entries } = resolveConfiguredEntries(cfg);
const configuredByKey = new Map(entries.map((entry) => [entry.key, entry]));
const rows: ModelRow[] = [];
const rowContext = {
cfg,
authStore,
availableKeys,
configuredByKey,
discoveredKeys,
filter: {
provider: providerFilter,
local: opts.local,
},
};
if (opts.all) {
const seenKeys = new Set<string>();
const sorted = [...models].toSorted((a, b) => {
const p = a.provider.localeCompare(b.provider);
if (p !== 0) {
return p;
}
return a.id.localeCompare(b.id);
const seenKeys = appendDiscoveredRows({
rows,
models: modelRegistry?.getAll() ?? [],
context: rowContext,
});
for (const model of sorted) {
if (providerFilter && model.provider.toLowerCase() !== providerFilter) {
continue;
}
if (opts.local && !isLocalBaseUrl(model.baseUrl)) {
continue;
}
const key = modelKey(model.provider, model.id);
const configured = configuredByKey.get(key);
rows.push(
toModelRow({
model,
key,
tags: configured ? Array.from(configured.tags) : [],
aliases: configured?.aliases ?? [],
availableKeys,
cfg,
authStore,
}),
);
seenKeys.add(key);
}
if (modelRegistry) {
const catalog = await loadModelCatalog({ config: cfg });
for (const entry of catalog) {
if (providerFilter && entry.provider.toLowerCase() !== providerFilter) {
continue;
}
const key = modelKey(entry.provider, entry.id);
if (seenKeys.has(key)) {
continue;
}
const model = resolveModelWithRegistry({
provider: entry.provider,
modelId: entry.id,
modelRegistry,
cfg,
});
if (!model) {
continue;
}
if (opts.local && !isLocalBaseUrl(model.baseUrl)) {
continue;
}
const configured = configuredByKey.get(key);
rows.push(
toModelRow({
model,
key,
tags: configured ? Array.from(configured.tags) : [],
aliases: configured?.aliases ?? [],
availableKeys,
cfg,
authStore,
allowProviderAvailabilityFallback: !discoveredKeys.has(key),
}),
);
seenKeys.add(key);
}
await appendCatalogSupplementRows({
rows,
modelRegistry,
context: rowContext,
seenKeys,
});
}
} else {
const registry = modelRegistry;
@@ -147,37 +102,12 @@ export async function modelsListCommand(
process.exitCode = 1;
return;
}
for (const entry of entries) {
if (providerFilter && entry.ref.provider.toLowerCase() !== providerFilter) {
continue;
}
const model = resolveModelWithRegistry({
provider: entry.ref.provider,
modelId: entry.ref.model,
modelRegistry: registry,
cfg,
});
if (opts.local && model && !isLocalBaseUrl(model.baseUrl)) {
continue;
}
if (opts.local && !model) {
continue;
}
rows.push(
toModelRow({
model,
key: entry.key,
tags: Array.from(entry.tags),
aliases: entry.aliases,
availableKeys,
cfg,
authStore,
allowProviderAvailabilityFallback: model
? !discoveredKeys.has(modelKey(model.provider, model.id))
: false,
}),
);
}
appendConfiguredRows({
rows,
entries,
modelRegistry: registry,
context: rowContext,
});
}
if (rows.length === 0) {

View File

@@ -0,0 +1,178 @@
import type { Api, Model } from "@mariozechner/pi-ai";
import type { ModelRegistry } from "@mariozechner/pi-coding-agent";
import type { AuthProfileStore } from "../../agents/auth-profiles.js";
import { loadModelCatalog } from "../../agents/model-catalog.js";
import { resolveModelWithRegistry } from "../../agents/pi-embedded-runner/model.js";
import type { OpenClawConfig } from "../../config/config.js";
import { loadModelRegistry, toModelRow } from "./list.registry.js";
import type { ConfiguredEntry, ModelRow } from "./list.types.js";
import { isLocalBaseUrl, modelKey } from "./shared.js";
type ConfiguredByKey = Map<string, ConfiguredEntry>;
type RowFilter = {
provider?: string;
local?: boolean;
};
type RowBuilderContext = {
cfg: OpenClawConfig;
authStore: AuthProfileStore;
availableKeys?: Set<string>;
configuredByKey: ConfiguredByKey;
discoveredKeys: Set<string>;
filter: RowFilter;
};
function matchesRowFilter(filter: RowFilter, model: { provider: string; baseUrl?: string }) {
if (filter.provider && model.provider.toLowerCase() !== filter.provider) {
return false;
}
if (filter.local && !isLocalBaseUrl(model.baseUrl ?? "")) {
return false;
}
return true;
}
function buildRow(params: {
model: Model<Api>;
key: string;
context: RowBuilderContext;
allowProviderAvailabilityFallback?: boolean;
}): ModelRow {
const configured = params.context.configuredByKey.get(params.key);
return toModelRow({
model: params.model,
key: params.key,
tags: configured ? Array.from(configured.tags) : [],
aliases: configured?.aliases ?? [],
availableKeys: params.context.availableKeys,
cfg: params.context.cfg,
authStore: params.context.authStore,
allowProviderAvailabilityFallback: params.allowProviderAvailabilityFallback ?? false,
});
}
export async function loadListModelRegistry(
cfg: OpenClawConfig,
opts?: { sourceConfig?: OpenClawConfig },
) {
const loaded = await loadModelRegistry(cfg, opts);
return {
...loaded,
discoveredKeys: new Set(loaded.models.map((model) => modelKey(model.provider, model.id))),
};
}
export function appendDiscoveredRows(params: {
rows: ModelRow[];
models: Model<Api>[];
context: RowBuilderContext;
}): Set<string> {
const seenKeys = new Set<string>();
const sorted = [...params.models].toSorted((a, b) => {
const providerCompare = a.provider.localeCompare(b.provider);
if (providerCompare !== 0) {
return providerCompare;
}
return a.id.localeCompare(b.id);
});
for (const model of sorted) {
if (!matchesRowFilter(params.context.filter, model)) {
continue;
}
const key = modelKey(model.provider, model.id);
params.rows.push(
buildRow({
model,
key,
context: params.context,
}),
);
seenKeys.add(key);
}
return seenKeys;
}
export async function appendCatalogSupplementRows(params: {
rows: ModelRow[];
modelRegistry: ModelRegistry;
context: RowBuilderContext;
seenKeys: Set<string>;
}): Promise<void> {
const catalog = await loadModelCatalog({ config: params.context.cfg });
for (const entry of catalog) {
if (
params.context.filter.provider &&
entry.provider.toLowerCase() !== params.context.filter.provider
) {
continue;
}
const key = modelKey(entry.provider, entry.id);
if (params.seenKeys.has(key)) {
continue;
}
const model = resolveModelWithRegistry({
provider: entry.provider,
modelId: entry.id,
modelRegistry: params.modelRegistry,
cfg: params.context.cfg,
});
if (!model || !matchesRowFilter(params.context.filter, model)) {
continue;
}
params.rows.push(
buildRow({
model,
key,
context: params.context,
allowProviderAvailabilityFallback: !params.context.discoveredKeys.has(key),
}),
);
params.seenKeys.add(key);
}
}
export function appendConfiguredRows(params: {
rows: ModelRow[];
entries: ConfiguredEntry[];
modelRegistry: ModelRegistry;
context: RowBuilderContext;
}) {
for (const entry of params.entries) {
if (
params.context.filter.provider &&
entry.ref.provider.toLowerCase() !== params.context.filter.provider
) {
continue;
}
const model = resolveModelWithRegistry({
provider: entry.ref.provider,
modelId: entry.ref.model,
modelRegistry: params.modelRegistry,
cfg: params.context.cfg,
});
if (params.context.filter.local && model && !isLocalBaseUrl(model.baseUrl ?? "")) {
continue;
}
if (params.context.filter.local && !model) {
continue;
}
params.rows.push(
toModelRow({
model,
key: entry.key,
tags: Array.from(entry.tags),
aliases: entry.aliases,
availableKeys: params.context.availableKeys,
cfg: params.context.cfg,
authStore: params.context.authStore,
allowProviderAvailabilityFallback: model
? !params.context.discoveredKeys.has(modelKey(model.provider, model.id))
: false,
}),
);
}
}

Some files were not shown because too many files have changed in this diff Show More