mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-11 16:41:22 +08:00
Compare commits
11 Commits
fix/launch
...
vincentkoc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06649f468d | ||
|
|
f236e913dd | ||
|
|
f99f346eef | ||
|
|
291b3398fd | ||
|
|
5fab5c6284 | ||
|
|
7757f6ff71 | ||
|
|
d585731b12 | ||
|
|
fcfa6373bf | ||
|
|
8b56fffc64 | ||
|
|
7f407a809a | ||
|
|
31785c3f7f |
@@ -11584,7 +11584,7 @@
|
||||
"filename": "src/agents/pi-embedded-runner/model.ts",
|
||||
"hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c",
|
||||
"is_verified": false,
|
||||
"line_number": 279
|
||||
"line_number": 331
|
||||
}
|
||||
],
|
||||
"src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts": [
|
||||
@@ -13035,5 +13035,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-03-09T01:11:58Z"
|
||||
"generated_at": "2026-03-09T01:01:03Z"
|
||||
}
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -2,18 +2,10 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixes
|
||||
|
||||
- Browser/SSRF: block private-network intermediate redirect hops in strict browser navigation flows and fail closed when remote tab-open paths cannot inspect redirect chains. Thanks @zpbrent.
|
||||
- MS Teams/authz: keep `groupPolicy: "allowlist"` enforcing sender allowlists even when a team/channel route allowlist is configured, so route matches no longer widen group access to every sender in that route. Thanks @zpbrent.
|
||||
|
||||
## 2026.3.8
|
||||
|
||||
### 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.
|
||||
@@ -22,8 +14,6 @@ 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
|
||||
|
||||
@@ -31,7 +21,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Docker/runtime image: prune dev dependencies, strip build-only dist metadata for smaller Docker images. (#40307) Thanks @vincentkoc.
|
||||
- Plugins/channel onboarding: prefer bundled channel plugins over duplicate npm-installed copies during onboarding and release-channel sync, preventing bundled plugins from being shadowed by npm installs with the same plugin ID. (#40092)
|
||||
- Feishu/plugin onboarding: clear the short-lived plugin discovery cache before reloading the registry after installing a channel plugin, so onboarding no longer re-prompts to download Feishu immediately after a successful install. Fixes #39642. (#39752) Thanks @GazeKingNuWu.
|
||||
- macOS app/chat UI: route browser proxy through the local node browser service, preserve plain-text paste semantics, strip completed assistant trace/debug wrapper noise from transcripts, refresh permission state after returning from System Settings, and tolerate malformed cron rows in the macOS tab. (#39516) Thanks @Imhermes1.
|
||||
- Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda.
|
||||
- Agents/failover: detect Amazon Bedrock `Too many tokens per day` quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window `too many tokens per request` errors out of the rate-limit lane. (#39377) Thanks @gambletan.
|
||||
@@ -57,17 +46,6 @@ 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.
|
||||
- Matrix/DM routing: add safer fallback detection for broken `m.direct` homeservers, honor explicit room bindings over DM classification, and preserve room-bound agent selection for Matrix DM rooms. (#19736) Thanks @derbronko.
|
||||
- Cron/Telegram announce delivery: route text-only announce jobs through the real outbound adapters after finalizing descendant output so plain Telegram targets no longer report `delivered: true` when no message actually reached Telegram. (#40575) thanks @obviyus.
|
||||
- Gateway/restart timeout recovery: exit non-zero when restart-triggered shutdown drains time out so launchd/systemd restart the gateway instead of treating the failed restart as a clean stop. Landed from contributor PR #40380 by @dsantoreis. Thanks @dsantoreis.
|
||||
- Gateway/config restart guard: validate config before service start/restart and keep post-SIGUSR1 startup failures from crashing the gateway process, reducing invalid-config restart loops and macOS permission loss. Landed from contributor PR #38699 by @lml2468. Thanks @lml2468.
|
||||
- Gateway/launchd respawn detection: treat `XPC_SERVICE_NAME` as a launchd supervision hint so macOS restarts exit cleanly under launchd instead of attempting detached self-respawn. Landed from contributor PR #20555 by @dimat. Thanks @dimat.
|
||||
- Cron/owner-only tools: pass trusted isolated cron runs into the embedded agent with owner context so `cron`/`gateway` tooling remains available after the owner-auth hardening narrowed direct-message ownership inference.
|
||||
|
||||
## 2026.3.7
|
||||
|
||||
@@ -783,7 +761,6 @@ 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.
|
||||
|
||||
@@ -3257,8 +3257,6 @@ 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(
|
||||
@@ -3268,8 +3266,6 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
deliver: Bool?,
|
||||
attachments: [AnyCodable]?,
|
||||
timeoutms: Int?,
|
||||
systeminputprovenance: [String: AnyCodable]?,
|
||||
systemprovenancereceipt: String?,
|
||||
idempotencykey: String)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
@@ -3278,8 +3274,6 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
self.deliver = deliver
|
||||
self.attachments = attachments
|
||||
self.timeoutms = timeoutms
|
||||
self.systeminputprovenance = systeminputprovenance
|
||||
self.systemprovenancereceipt = systemprovenancereceipt
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
|
||||
@@ -3290,8 +3284,6 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
case deliver
|
||||
case attachments
|
||||
case timeoutms = "timeoutMs"
|
||||
case systeminputprovenance = "systemInputProvenance"
|
||||
case systemprovenancereceipt = "systemProvenanceReceipt"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3257,8 +3257,6 @@ 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(
|
||||
@@ -3268,8 +3266,6 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
deliver: Bool?,
|
||||
attachments: [AnyCodable]?,
|
||||
timeoutms: Int?,
|
||||
systeminputprovenance: [String: AnyCodable]?,
|
||||
systemprovenancereceipt: String?,
|
||||
idempotencykey: String)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
@@ -3278,8 +3274,6 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
self.deliver = deliver
|
||||
self.attachments = attachments
|
||||
self.timeoutms = timeoutms
|
||||
self.systeminputprovenance = systeminputprovenance
|
||||
self.systemprovenancereceipt = systemprovenancereceipt
|
||||
self.idempotencykey = idempotencykey
|
||||
}
|
||||
|
||||
@@ -3290,8 +3284,6 @@ public struct ChatSendParams: Codable, Sendable {
|
||||
case deliver
|
||||
case attachments
|
||||
case timeoutms = "timeoutMs"
|
||||
case systeminputprovenance = "systemInputProvenance"
|
||||
case systemprovenancereceipt = "systemProvenanceReceipt"
|
||||
case idempotencykey = "idempotencyKey"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,52 +96,6 @@ 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 Zed’s Settings UI):
|
||||
|
||||
@@ -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. **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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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 "./test-utils/runtime-fixtures.js";
|
||||
} from "./runtime-internals/test-fixtures.js";
|
||||
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
|
||||
|
||||
let sharedFixture: Awaited<ReturnType<typeof createMockRuntimeFixture>> | null = null;
|
||||
let missingCommandRuntime: AcpxRuntime | null = null;
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
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";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseFiniteNumber } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { parseFiniteNumber } from "../../../src/infra/parse-finite-number.js";
|
||||
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
|
||||
import type { BlueBubblesAttachment } from "./types.js";
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
collectOpenProviderGroupPolicyWarnings,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/discord";
|
||||
|
||||
const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/feishu";
|
||||
|
||||
const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
buildOpenGroupPolicyConfigureRouteAllowlistWarning,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/googlechat";
|
||||
|
||||
const { setRuntime: setGoogleChatRuntime, getRuntime: getGoogleChatRuntime } =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/imessage";
|
||||
|
||||
const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/irc";
|
||||
|
||||
const { setRuntime: setIrcRuntime, getRuntime: getIrcRuntime } =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/line";
|
||||
|
||||
const { setRuntime: setLineRuntime, getRuntime: getLineRuntime } =
|
||||
|
||||
@@ -1,400 +1,65 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createDirectRoomTracker } from "./direct.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers -- minimal MatrixClient stub
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type StateEvent = Record<string, unknown>;
|
||||
type DmMap = Record<string, boolean>;
|
||||
|
||||
function createMockClient(opts: {
|
||||
dmRooms?: DmMap;
|
||||
membersByRoom?: Record<string, string[]>;
|
||||
stateEvents?: Record<string, StateEvent>;
|
||||
selfUserId?: string;
|
||||
function createMockClient(params: {
|
||||
isDm?: boolean;
|
||||
senderDirect?: boolean;
|
||||
selfDirect?: boolean;
|
||||
members?: string[];
|
||||
}) {
|
||||
const {
|
||||
dmRooms = {},
|
||||
membersByRoom = {},
|
||||
stateEvents = {},
|
||||
selfUserId = "@bot:example.org",
|
||||
} = opts;
|
||||
|
||||
const members = params.members ?? ["@alice:example.org", "@bot:example.org"];
|
||||
return {
|
||||
dms: {
|
||||
isDm: (roomId: string) => dmRooms[roomId] ?? false,
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
isDm: vi.fn().mockReturnValue(params.isDm === true),
|
||||
},
|
||||
getUserId: vi.fn().mockResolvedValue(selfUserId),
|
||||
getJoinedRoomMembers: vi.fn().mockImplementation(async (roomId: string) => {
|
||||
return membersByRoom[roomId] ?? [];
|
||||
}),
|
||||
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
|
||||
getJoinedRoomMembers: vi.fn().mockResolvedValue(members),
|
||||
getRoomStateEvent: vi
|
||||
.fn()
|
||||
.mockImplementation(async (roomId: string, eventType: string, stateKey: string) => {
|
||||
const key = `${roomId}|${eventType}|${stateKey}`;
|
||||
const ev = stateEvents[key];
|
||||
if (ev === undefined) {
|
||||
// Simulate real homeserver M_NOT_FOUND response (matches MatrixError shape)
|
||||
const err = new Error(`State event not found: ${key}`) as Error & {
|
||||
errcode?: string;
|
||||
statusCode?: number;
|
||||
};
|
||||
err.errcode = "M_NOT_FOUND";
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
.mockImplementation(async (_roomId: string, _event: string, stateKey: string) => {
|
||||
if (stateKey === "@alice:example.org") {
|
||||
return { is_direct: params.senderDirect === true };
|
||||
}
|
||||
return ev;
|
||||
if (stateKey === "@bot:example.org") {
|
||||
return { is_direct: params.selfDirect === true };
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
};
|
||||
} as unknown as MatrixClient;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests -- isDirectMessage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("createDirectRoomTracker", () => {
|
||||
describe("m.direct detection (SDK DM cache)", () => {
|
||||
it("returns true when SDK DM cache marks room as DM", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: { "!dm:example.org": true },
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!dm:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for rooms not in SDK DM cache (with >2 members)", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
"!group:example.org": ["@alice:example.org", "@bob:example.org", "@carol:example.org"],
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!group:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("is_direct state flag detection", () => {
|
||||
it("returns true when sender's membership has is_direct=true", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: { "!room:example.org": ["@alice:example.org", "@bot:example.org"] },
|
||||
stateEvents: {
|
||||
"!room:example.org|m.room.member|@alice:example.org": { is_direct: true },
|
||||
"!room:example.org|m.room.member|@bot:example.org": { is_direct: false },
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
it("treats m.direct rooms as DMs", async () => {
|
||||
const tracker = createDirectRoomTracker(createMockClient({ isDm: true }));
|
||||
await expect(
|
||||
tracker.isDirectMessage({
|
||||
roomId: "!room:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when bot's own membership has is_direct=true", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: { "!room:example.org": ["@alice:example.org", "@bot:example.org"] },
|
||||
stateEvents: {
|
||||
"!room:example.org|m.room.member|@alice:example.org": { is_direct: false },
|
||||
"!room:example.org|m.room.member|@bot:example.org": { is_direct: true },
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
it("does not classify 2-member rooms as DMs without direct flags", async () => {
|
||||
const client = createMockClient({ isDm: false });
|
||||
const tracker = createDirectRoomTracker(client);
|
||||
await expect(
|
||||
tracker.isDirectMessage({
|
||||
roomId: "!room:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
selfUserId: "@bot:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
expect(client.getJoinedRoomMembers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("conservative fallback (memberCount + room name)", () => {
|
||||
it("returns true for 2-member room WITHOUT a room name (broken flags)", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
"!broken-dm:example.org": ["@alice:example.org", "@bot:example.org"],
|
||||
},
|
||||
stateEvents: {
|
||||
// is_direct not set on either member (e.g. Continuwuity bug)
|
||||
"!broken-dm:example.org|m.room.member|@alice:example.org": {},
|
||||
"!broken-dm:example.org|m.room.member|@bot:example.org": {},
|
||||
// No m.room.name -> getRoomStateEvent will throw (event not found)
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!broken-dm:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for 2-member room with empty room name", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
"!broken-dm:example.org": ["@alice:example.org", "@bot:example.org"],
|
||||
},
|
||||
stateEvents: {
|
||||
"!broken-dm:example.org|m.room.member|@alice:example.org": {},
|
||||
"!broken-dm:example.org|m.room.member|@bot:example.org": {},
|
||||
"!broken-dm:example.org|m.room.name|": { name: "" },
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!broken-dm:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for 2-member room WITH a room name (named group)", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
"!named-group:example.org": ["@alice:example.org", "@bob:example.org"],
|
||||
},
|
||||
stateEvents: {
|
||||
"!named-group:example.org|m.room.member|@alice:example.org": {},
|
||||
"!named-group:example.org|m.room.member|@bob:example.org": {},
|
||||
"!named-group:example.org|m.room.name|": { name: "Project Alpha" },
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!named-group:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for 3+ member room without any DM signals", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
"!group:example.org": ["@alice:example.org", "@bob:example.org", "@carol:example.org"],
|
||||
},
|
||||
stateEvents: {
|
||||
"!group:example.org|m.room.member|@alice:example.org": {},
|
||||
"!group:example.org|m.room.member|@bob:example.org": {},
|
||||
"!group:example.org|m.room.member|@carol:example.org": {},
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!group:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for 1-member room (self-chat)", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
"!solo:example.org": ["@bot:example.org"],
|
||||
},
|
||||
stateEvents: {
|
||||
"!solo:example.org|m.room.member|@bot:example.org": {},
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!solo:example.org",
|
||||
senderId: "@bot:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detection priority", () => {
|
||||
it("m.direct takes priority -- skips state and fallback checks", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: { "!dm:example.org": true },
|
||||
membersByRoom: {
|
||||
"!dm:example.org": ["@alice:example.org", "@bob:example.org", "@carol:example.org"],
|
||||
},
|
||||
stateEvents: {
|
||||
"!dm:example.org|m.room.name|": { name: "Named Room" },
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!dm:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
// Should not have checked member state or room name
|
||||
expect(client.getRoomStateEvent).not.toHaveBeenCalled();
|
||||
expect(client.getJoinedRoomMembers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is_direct takes priority over fallback -- skips member count", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
stateEvents: {
|
||||
"!room:example.org|m.room.member|@alice:example.org": { is_direct: true },
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
it("uses is_direct member flags when present", async () => {
|
||||
const tracker = createDirectRoomTracker(createMockClient({ senderDirect: true }));
|
||||
await expect(
|
||||
tracker.isDirectMessage({
|
||||
roomId: "!room:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
// Should not have checked member count
|
||||
expect(client.getJoinedRoomMembers).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("handles member count API failure gracefully", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
stateEvents: {
|
||||
"!failing:example.org|m.room.member|@alice:example.org": {},
|
||||
"!failing:example.org|m.room.member|@bot:example.org": {},
|
||||
},
|
||||
});
|
||||
client.getJoinedRoomMembers.mockRejectedValue(new Error("API unavailable"));
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!failing:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
// Cannot determine member count -> conservative: classify as group
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("treats M_NOT_FOUND for room name as no name (DM)", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
"!no-name:example.org": ["@alice:example.org", "@bot:example.org"],
|
||||
},
|
||||
stateEvents: {
|
||||
"!no-name:example.org|m.room.member|@alice:example.org": {},
|
||||
"!no-name:example.org|m.room.member|@bot:example.org": {},
|
||||
// m.room.name not in stateEvents -> mock throws generic Error
|
||||
},
|
||||
});
|
||||
// Override to throw M_NOT_FOUND like a real homeserver
|
||||
const originalImpl = client.getRoomStateEvent.getMockImplementation()!;
|
||||
client.getRoomStateEvent.mockImplementation(
|
||||
async (roomId: string, eventType: string, stateKey: string) => {
|
||||
if (eventType === "m.room.name") {
|
||||
const err = new Error("not found") as Error & {
|
||||
errcode?: string;
|
||||
statusCode?: number;
|
||||
};
|
||||
err.errcode = "M_NOT_FOUND";
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
return originalImpl(roomId, eventType, stateKey);
|
||||
},
|
||||
);
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!no-name:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("treats non-404 room name errors as unknown (falls through to group)", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
"!error-room:example.org": ["@alice:example.org", "@bot:example.org"],
|
||||
},
|
||||
stateEvents: {
|
||||
"!error-room:example.org|m.room.member|@alice:example.org": {},
|
||||
"!error-room:example.org|m.room.member|@bot:example.org": {},
|
||||
},
|
||||
});
|
||||
// Simulate a network/auth error (not M_NOT_FOUND)
|
||||
const originalImpl = client.getRoomStateEvent.getMockImplementation()!;
|
||||
client.getRoomStateEvent.mockImplementation(
|
||||
async (roomId: string, eventType: string, stateKey: string) => {
|
||||
if (eventType === "m.room.name") {
|
||||
throw new Error("Connection refused");
|
||||
}
|
||||
return originalImpl(roomId, eventType, stateKey);
|
||||
},
|
||||
);
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!error-room:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
// Network error -> don't assume DM, classify as group
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("whitespace-only room name is treated as no name", async () => {
|
||||
const client = createMockClient({
|
||||
dmRooms: {},
|
||||
membersByRoom: {
|
||||
"!ws-name:example.org": ["@alice:example.org", "@bot:example.org"],
|
||||
},
|
||||
stateEvents: {
|
||||
"!ws-name:example.org|m.room.member|@alice:example.org": {},
|
||||
"!ws-name:example.org|m.room.member|@bot:example.org": {},
|
||||
"!ws-name:example.org|m.room.name|": { name: " " },
|
||||
},
|
||||
});
|
||||
const tracker = createDirectRoomTracker(client as never);
|
||||
|
||||
const result = await tracker.isDirectMessage({
|
||||
roomId: "!ws-name:example.org",
|
||||
senderId: "@alice:example.org",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,22 +13,14 @@ type DirectRoomTrackerOptions = {
|
||||
|
||||
const DM_CACHE_TTL_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Check if an error is a Matrix M_NOT_FOUND response (missing state event).
|
||||
* The bot-sdk throws MatrixError with errcode/statusCode on the error object.
|
||||
*/
|
||||
function isMatrixNotFoundError(err: unknown): boolean {
|
||||
if (typeof err !== "object" || err === null) return false;
|
||||
const e = err as { errcode?: string; statusCode?: number };
|
||||
return e.errcode === "M_NOT_FOUND" || e.statusCode === 404;
|
||||
}
|
||||
|
||||
export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTrackerOptions = {}) {
|
||||
const log = opts.log ?? (() => {});
|
||||
const includeMemberCountInLogs = opts.includeMemberCountInLogs === true;
|
||||
let lastDmUpdateMs = 0;
|
||||
let cachedSelfUserId: string | null = null;
|
||||
const memberCountCache = new Map<string, { count: number; ts: number }>();
|
||||
const memberCountCache = includeMemberCountInLogs
|
||||
? new Map<string, { count: number; ts: number }>()
|
||||
: undefined;
|
||||
|
||||
const ensureSelfUserId = async (): Promise<string | null> => {
|
||||
if (cachedSelfUserId) {
|
||||
@@ -56,6 +48,9 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
|
||||
};
|
||||
|
||||
const resolveMemberCount = async (roomId: string): Promise<number | null> => {
|
||||
if (!memberCountCache) {
|
||||
return null;
|
||||
}
|
||||
const cached = memberCountCache.get(roomId);
|
||||
const now = Date.now();
|
||||
if (cached && now - cached.ts < DM_CACHE_TTL_MS) {
|
||||
@@ -96,6 +91,7 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check m.room.member state for is_direct flag
|
||||
const selfUserId = params.selfUserId ?? (await ensureSelfUserId());
|
||||
const directViaState =
|
||||
(await hasDirectFlag(roomId, senderId)) || (await hasDirectFlag(roomId, selfUserId ?? ""));
|
||||
@@ -104,47 +100,16 @@ export function createDirectRoomTracker(client: MatrixClient, opts: DirectRoomTr
|
||||
return true;
|
||||
}
|
||||
|
||||
// Conservative fallback: 2-member rooms without an explicit room name are likely
|
||||
// DMs with broken m.direct / is_direct flags. This has been observed on Continuwuity
|
||||
// where m.direct pointed to the wrong room and is_direct was never set on the invite.
|
||||
// Unlike the removed heuristic, this requires two signals (member count + no name)
|
||||
// to avoid false positives on named 2-person group rooms.
|
||||
//
|
||||
// Performance: member count is cached (resolveMemberCount). The room name state
|
||||
// check is not cached but only runs for the subset of 2-member rooms that reach
|
||||
// this fallback path (no m.direct, no is_direct). In typical deployments this is
|
||||
// a small minority of rooms.
|
||||
//
|
||||
// Note: there is a narrow race where a room name is being set concurrently with
|
||||
// this check. The consequence is a one-time misclassification that self-corrects
|
||||
// on the next message (once the state event is synced). This is acceptable given
|
||||
// the alternative of an additional API call on every message.
|
||||
const memberCount = await resolveMemberCount(roomId);
|
||||
if (memberCount === 2) {
|
||||
try {
|
||||
const nameState = await client.getRoomStateEvent(roomId, "m.room.name", "");
|
||||
if (!nameState?.name?.trim()) {
|
||||
log(`matrix: dm detected via fallback (2 members, no room name) room=${roomId}`);
|
||||
return true;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// Missing state events (M_NOT_FOUND) are expected for unnamed rooms and
|
||||
// strongly indicate a DM. Any other error (network, auth) is ambiguous,
|
||||
// so we fall through to classify as group rather than guess.
|
||||
if (isMatrixNotFoundError(err)) {
|
||||
log(`matrix: dm detected via fallback (2 members, no room name) room=${roomId}`);
|
||||
return true;
|
||||
}
|
||||
log(
|
||||
`matrix: dm fallback skipped (room name check failed: ${String(err)}) room=${roomId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Member count alone is NOT a reliable DM indicator.
|
||||
// Explicitly configured group rooms with 2 members (e.g. bot + one user)
|
||||
// were being misclassified as DMs, causing messages to be routed through
|
||||
// DM policy instead of group policy and silently dropped.
|
||||
// See: https://github.com/openclaw/openclaw/issues/20145
|
||||
if (!includeMemberCountInLogs) {
|
||||
log(`matrix: dm check room=${roomId} result=group`);
|
||||
return false;
|
||||
}
|
||||
const memberCount = await resolveMemberCount(roomId);
|
||||
log(`matrix: dm check room=${roomId} result=group members=${memberCount ?? "unknown"}`);
|
||||
return false;
|
||||
},
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
|
||||
import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createMatrixRoomMessageHandler,
|
||||
resolveMatrixBaseRouteSession,
|
||||
shouldOverrideMatrixDmToGroup,
|
||||
} from "./handler.js";
|
||||
import { createMatrixRoomMessageHandler } from "./handler.js";
|
||||
import { EventType, type MatrixRawEvent } from "./types.js";
|
||||
|
||||
describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => {
|
||||
@@ -22,15 +18,8 @@ describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => {
|
||||
channel: {
|
||||
pairing: {
|
||||
readAllowFromStore: vi.fn().mockResolvedValue([]),
|
||||
upsertPairingRequest: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
routing: {
|
||||
buildAgentSessionKey: vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(params: { agentId: string; channel: string; peer?: { kind: string; id: string } }) =>
|
||||
`agent:${params.agentId}:${params.channel}:${params.peer?.kind ?? "direct"}:${params.peer?.id ?? "unknown"}`,
|
||||
),
|
||||
resolveAgentRoute: vi.fn().mockReturnValue({
|
||||
agentId: "main",
|
||||
accountId: undefined,
|
||||
@@ -150,47 +139,4 @@ describe("createMatrixRoomMessageHandler BodyForAgent sender label", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses room-scoped session keys for DM rooms matched via parentPeer binding", () => {
|
||||
const buildAgentSessionKey = vi
|
||||
.fn()
|
||||
.mockReturnValue("agent:main:matrix:channel:!dmroom:example.org");
|
||||
|
||||
const resolved = resolveMatrixBaseRouteSession({
|
||||
buildAgentSessionKey,
|
||||
baseRoute: {
|
||||
agentId: "main",
|
||||
sessionKey: "agent:main:main",
|
||||
mainSessionKey: "agent:main:main",
|
||||
matchedBy: "binding.peer.parent",
|
||||
},
|
||||
isDirectMessage: true,
|
||||
roomId: "!dmroom:example.org",
|
||||
accountId: undefined,
|
||||
});
|
||||
|
||||
expect(buildAgentSessionKey).toHaveBeenCalledWith({
|
||||
agentId: "main",
|
||||
channel: "matrix",
|
||||
accountId: undefined,
|
||||
peer: { kind: "channel", id: "!dmroom:example.org" },
|
||||
});
|
||||
expect(resolved).toEqual({
|
||||
sessionKey: "agent:main:matrix:channel:!dmroom:example.org",
|
||||
lastRoutePolicy: "session",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not override DMs to groups for explicit allow:false room config", () => {
|
||||
expect(
|
||||
shouldOverrideMatrixDmToGroup({
|
||||
isDirectMessage: true,
|
||||
roomConfigInfo: {
|
||||
config: { allow: false },
|
||||
allowed: false,
|
||||
matchSource: "direct",
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,56 +77,6 @@ export type MatrixMonitorHandlerParams = {
|
||||
accountId?: string | null;
|
||||
};
|
||||
|
||||
export function resolveMatrixBaseRouteSession(params: {
|
||||
buildAgentSessionKey: (params: {
|
||||
agentId: string;
|
||||
channel: string;
|
||||
accountId?: string | null;
|
||||
peer?: { kind: "direct" | "channel"; id: string } | null;
|
||||
}) => string;
|
||||
baseRoute: {
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
mainSessionKey: string;
|
||||
matchedBy?: string;
|
||||
};
|
||||
isDirectMessage: boolean;
|
||||
roomId: string;
|
||||
accountId?: string | null;
|
||||
}): { sessionKey: string; lastRoutePolicy: "main" | "session" } {
|
||||
const sessionKey =
|
||||
params.isDirectMessage && params.baseRoute.matchedBy === "binding.peer.parent"
|
||||
? params.buildAgentSessionKey({
|
||||
agentId: params.baseRoute.agentId,
|
||||
channel: "matrix",
|
||||
accountId: params.accountId,
|
||||
peer: { kind: "channel", id: params.roomId },
|
||||
})
|
||||
: params.baseRoute.sessionKey;
|
||||
return {
|
||||
sessionKey,
|
||||
lastRoutePolicy: sessionKey === params.baseRoute.mainSessionKey ? "main" : "session",
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldOverrideMatrixDmToGroup(params: {
|
||||
isDirectMessage: boolean;
|
||||
roomConfigInfo?:
|
||||
| {
|
||||
config?: MatrixRoomConfig;
|
||||
allowed: boolean;
|
||||
matchSource?: string;
|
||||
}
|
||||
| undefined;
|
||||
}): boolean {
|
||||
return (
|
||||
params.isDirectMessage === true &&
|
||||
params.roomConfigInfo?.config !== undefined &&
|
||||
params.roomConfigInfo.allowed === true &&
|
||||
params.roomConfigInfo.matchSource === "direct"
|
||||
);
|
||||
}
|
||||
|
||||
export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) {
|
||||
const {
|
||||
client,
|
||||
@@ -238,37 +188,22 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
}
|
||||
}
|
||||
|
||||
let isDirectMessage = await directTracker.isDirectMessage({
|
||||
const isDirectMessage = await directTracker.isDirectMessage({
|
||||
roomId,
|
||||
senderId,
|
||||
selfUserId,
|
||||
});
|
||||
|
||||
// Resolve room config early so explicitly configured rooms can override DM classification.
|
||||
// This ensures rooms in the groups config are always treated as groups regardless of
|
||||
// member count or protocol-level DM flags. Only explicit matches (not wildcards) trigger
|
||||
// the override to avoid breaking DM routing when a wildcard entry exists. (See #9106)
|
||||
const roomConfigInfo = resolveMatrixRoomConfig({
|
||||
rooms: roomsConfig,
|
||||
roomId,
|
||||
aliases: roomAliases,
|
||||
name: roomName,
|
||||
});
|
||||
if (shouldOverrideMatrixDmToGroup({ isDirectMessage, roomConfigInfo })) {
|
||||
logVerboseMessage(
|
||||
`matrix: overriding DM to group for configured room=${roomId} (${roomConfigInfo.matchKey})`,
|
||||
);
|
||||
isDirectMessage = false;
|
||||
}
|
||||
|
||||
const isRoom = !isDirectMessage;
|
||||
|
||||
if (isRoom && groupPolicy === "disabled") {
|
||||
return;
|
||||
}
|
||||
// Only expose room config for confirmed group rooms. DMs should never inherit
|
||||
// group settings (skills, systemPrompt, autoReply) even when a wildcard entry exists.
|
||||
const roomConfig = isRoom ? roomConfigInfo?.config : undefined;
|
||||
const roomConfigInfo = isRoom
|
||||
? resolveMatrixRoomConfig({
|
||||
rooms: roomsConfig,
|
||||
roomId,
|
||||
aliases: roomAliases,
|
||||
name: roomName,
|
||||
})
|
||||
: undefined;
|
||||
const roomConfig = roomConfigInfo?.config;
|
||||
const roomMatchMeta = roomConfigInfo
|
||||
? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
|
||||
roomConfigInfo.matchSource ?? "none"
|
||||
@@ -500,24 +435,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
kind: isDirectMessage ? "direct" : "channel",
|
||||
id: isDirectMessage ? senderId : roomId,
|
||||
},
|
||||
// For DMs, pass roomId as parentPeer so the conversation is bindable by room ID
|
||||
// while preserving DM trust semantics (secure 1:1, no group restrictions).
|
||||
parentPeer: isDirectMessage ? { kind: "channel", id: roomId } : undefined,
|
||||
});
|
||||
const baseRouteSession = resolveMatrixBaseRouteSession({
|
||||
buildAgentSessionKey: core.channel.routing.buildAgentSessionKey,
|
||||
baseRoute,
|
||||
isDirectMessage,
|
||||
roomId,
|
||||
accountId,
|
||||
});
|
||||
|
||||
const route = {
|
||||
...baseRoute,
|
||||
lastRoutePolicy: baseRouteSession.lastRoutePolicy,
|
||||
sessionKey: threadRootId
|
||||
? `${baseRouteSession.sessionKey}:thread:${threadRootId}`
|
||||
: baseRouteSession.sessionKey,
|
||||
? `${baseRoute.sessionKey}:thread:${threadRootId}`
|
||||
: baseRoute.sessionKey,
|
||||
};
|
||||
|
||||
let threadStarterBody: string | undefined;
|
||||
|
||||
@@ -36,89 +36,4 @@ describe("resolveMatrixRoomConfig", () => {
|
||||
expect(byName.allowed).toBe(false);
|
||||
expect(byName.config).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("matchSource classification", () => {
|
||||
it('returns matchSource="direct" for exact room ID match', () => {
|
||||
const result = resolveMatrixRoomConfig({
|
||||
rooms: { "!room:example.org": { allow: true } },
|
||||
roomId: "!room:example.org",
|
||||
aliases: [],
|
||||
});
|
||||
expect(result.matchSource).toBe("direct");
|
||||
expect(result.config).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns matchSource="direct" for alias match', () => {
|
||||
const result = resolveMatrixRoomConfig({
|
||||
rooms: { "#alias:example.org": { allow: true } },
|
||||
roomId: "!room:example.org",
|
||||
aliases: ["#alias:example.org"],
|
||||
});
|
||||
expect(result.matchSource).toBe("direct");
|
||||
expect(result.config).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns matchSource="wildcard" for wildcard match', () => {
|
||||
const result = resolveMatrixRoomConfig({
|
||||
rooms: { "*": { allow: true } },
|
||||
roomId: "!any:example.org",
|
||||
aliases: [],
|
||||
});
|
||||
expect(result.matchSource).toBe("wildcard");
|
||||
expect(result.config).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns undefined matchSource when no match", () => {
|
||||
const result = resolveMatrixRoomConfig({
|
||||
rooms: { "!other:example.org": { allow: true } },
|
||||
roomId: "!room:example.org",
|
||||
aliases: [],
|
||||
});
|
||||
expect(result.matchSource).toBeUndefined();
|
||||
expect(result.config).toBeUndefined();
|
||||
});
|
||||
|
||||
it("direct match takes priority over wildcard", () => {
|
||||
const result = resolveMatrixRoomConfig({
|
||||
rooms: {
|
||||
"!room:example.org": { allow: true, systemPrompt: "room-specific" },
|
||||
"*": { allow: true, systemPrompt: "generic" },
|
||||
},
|
||||
roomId: "!room:example.org",
|
||||
aliases: [],
|
||||
});
|
||||
expect(result.matchSource).toBe("direct");
|
||||
expect(result.config?.systemPrompt).toBe("room-specific");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DM override safety (matchSource distinction)", () => {
|
||||
// These tests verify the matchSource property that handler.ts uses
|
||||
// to decide whether a configured room should override DM classification.
|
||||
// Only "direct" matches should trigger the override -- never "wildcard".
|
||||
|
||||
it("wildcard config should NOT be usable to override DM classification", () => {
|
||||
const result = resolveMatrixRoomConfig({
|
||||
rooms: { "*": { allow: true, skills: ["general"] } },
|
||||
roomId: "!dm-room:example.org",
|
||||
aliases: [],
|
||||
});
|
||||
// handler.ts checks: matchSource === "direct" -> this is "wildcard", so no override
|
||||
expect(result.matchSource).not.toBe("direct");
|
||||
expect(result.matchSource).toBe("wildcard");
|
||||
});
|
||||
|
||||
it("explicitly configured room should be usable to override DM classification", () => {
|
||||
const result = resolveMatrixRoomConfig({
|
||||
rooms: {
|
||||
"!configured-room:example.org": { allow: true },
|
||||
"*": { allow: true },
|
||||
},
|
||||
roomId: "!configured-room:example.org",
|
||||
aliases: [],
|
||||
});
|
||||
// handler.ts checks: matchSource === "direct" -> this IS "direct", so override is safe
|
||||
expect(result.matchSource).toBe("direct");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix";
|
||||
|
||||
const { setRuntime: setMatrixRuntime, getRuntime: getMatrixRuntime } =
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
isDangerousNameMatchingEnabled,
|
||||
parseStrictPositiveInteger,
|
||||
registerPluginHttpRoute,
|
||||
resolveControlCommandGate,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
@@ -31,6 +30,7 @@ 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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/mattermost";
|
||||
|
||||
const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } =
|
||||
|
||||
@@ -5,7 +5,7 @@ import { setMSTeamsRuntime } from "../runtime.js";
|
||||
import { createMSTeamsMessageHandler } from "./message-handler.js";
|
||||
|
||||
describe("msteams monitor handler authz", () => {
|
||||
function createDeps(cfg: OpenClawConfig) {
|
||||
it("does not treat DM pairing-store entries as group allowlist entries", async () => {
|
||||
const readAllowFromStore = vi.fn(async () => ["attacker-aad"]);
|
||||
setMSTeamsRuntime({
|
||||
logging: { shouldLogVerbose: () => false },
|
||||
@@ -35,7 +35,16 @@ describe("msteams monitor handler authz", () => {
|
||||
};
|
||||
|
||||
const deps: MSTeamsMessageHandlerDeps = {
|
||||
cfg,
|
||||
cfg: {
|
||||
channels: {
|
||||
msteams: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
runtime: { error: vi.fn() } as unknown as RuntimeEnv,
|
||||
appId: "test-app",
|
||||
adapter: {} as MSTeamsMessageHandlerDeps["adapter"],
|
||||
@@ -56,21 +65,6 @@ describe("msteams monitor handler authz", () => {
|
||||
} as unknown as MSTeamsMessageHandlerDeps["log"],
|
||||
};
|
||||
|
||||
return { conversationStore, deps, readAllowFromStore };
|
||||
}
|
||||
|
||||
it("does not treat DM pairing-store entries as group allowlist entries", async () => {
|
||||
const { conversationStore, deps, readAllowFromStore } = createDeps({
|
||||
channels: {
|
||||
msteams: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [],
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
|
||||
const handler = createMSTeamsMessageHandler(deps);
|
||||
await handler({
|
||||
activity: {
|
||||
@@ -102,54 +96,4 @@ describe("msteams monitor handler authz", () => {
|
||||
});
|
||||
expect(conversationStore.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not widen sender auth when only a teams route allowlist is configured", async () => {
|
||||
const { conversationStore, deps } = createDeps({
|
||||
channels: {
|
||||
msteams: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [],
|
||||
teams: {
|
||||
team123: {
|
||||
channels: {
|
||||
"19:group@thread.tacv2": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
|
||||
const handler = createMSTeamsMessageHandler(deps);
|
||||
await handler({
|
||||
activity: {
|
||||
id: "msg-1",
|
||||
type: "message",
|
||||
text: "hello",
|
||||
from: {
|
||||
id: "attacker-id",
|
||||
aadObjectId: "attacker-aad",
|
||||
name: "Attacker",
|
||||
},
|
||||
recipient: {
|
||||
id: "bot-id",
|
||||
name: "Bot",
|
||||
},
|
||||
conversation: {
|
||||
id: "19:group@thread.tacv2",
|
||||
conversationType: "groupChat",
|
||||
},
|
||||
channelData: {
|
||||
team: { id: "team123", name: "Team 123" },
|
||||
channel: { name: "General" },
|
||||
},
|
||||
attachments: [],
|
||||
},
|
||||
sendActivity: vi.fn(async () => undefined),
|
||||
} as unknown as Parameters<typeof handler>[0]);
|
||||
|
||||
expect(conversationStore.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -242,7 +242,10 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
|
||||
}
|
||||
const senderGroupAccess = evaluateSenderGroupAccessForPolicy({
|
||||
groupPolicy,
|
||||
groupAllowFrom: effectiveGroupAllowFrom,
|
||||
groupAllowFrom:
|
||||
effectiveGroupAllowFrom.length > 0 || !channelGate.allowlistConfigured
|
||||
? effectiveGroupAllowFrom
|
||||
: ["*"],
|
||||
senderId,
|
||||
isSenderAllowed: (_senderId, allowFrom) =>
|
||||
resolveMSTeamsAllowlistMatch({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/msteams";
|
||||
|
||||
const { setRuntime: setMSTeamsRuntime, getRuntime: getMSTeamsRuntime } =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/nextcloud-talk";
|
||||
|
||||
const { setRuntime: setNextcloudTalkRuntime, getRuntime: getNextcloudTalkRuntime } =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/nostr";
|
||||
|
||||
const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/signal";
|
||||
|
||||
const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
collectOpenProviderGroupPolicyWarnings,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/slack";
|
||||
|
||||
const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/synology-chat";
|
||||
|
||||
const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk/compat";
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
collectAllowlistProviderGroupPolicyWarnings,
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/telegram";
|
||||
|
||||
const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/tlon";
|
||||
|
||||
const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/twitch";
|
||||
|
||||
const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } =
|
||||
|
||||
@@ -9,11 +9,8 @@
|
||||
* 2. Environment variable: OPENCLAW_TWITCH_ACCESS_TOKEN (default account only)
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/twitch";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||
|
||||
export type TwitchTokenSource = "env" | "config" | "none";
|
||||
|
||||
|
||||
@@ -5,24 +5,26 @@
|
||||
* 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,
|
||||
ChannelOutboundAdapter,
|
||||
ChannelOutboundContext,
|
||||
ChannelPlugin,
|
||||
ChannelResolveKind,
|
||||
ChannelResolveResult,
|
||||
ChannelStatusAdapter,
|
||||
OpenClawConfig,
|
||||
OutboundDeliveryResult,
|
||||
RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk/twitch";
|
||||
} 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";
|
||||
|
||||
// ============================================================================
|
||||
// Twitch-Specific Types
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/whatsapp";
|
||||
|
||||
const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } =
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
AllowFromEntrySchema,
|
||||
buildCatchallMultiAccountChannelSchema,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk";
|
||||
import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo";
|
||||
import { z } from "zod";
|
||||
import { buildSecretInputSchema } from "./secret-input.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/zalo";
|
||||
|
||||
const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } =
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
AllowFromEntrySchema,
|
||||
buildCatchallMultiAccountChannelSchema,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk";
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/zalouser";
|
||||
import { z } from "zod";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/zalouser";
|
||||
|
||||
const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } =
|
||||
|
||||
@@ -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 { normalizeAcpProvenanceMode, type AcpServerOptions } from "./types.js";
|
||||
import type { AcpServerOptions } from "./types.js";
|
||||
|
||||
export async function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
|
||||
const cfg = loadConfig();
|
||||
@@ -186,15 +186,6 @@ 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;
|
||||
@@ -235,7 +226,6 @@ 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
|
||||
`);
|
||||
|
||||
@@ -81,117 +81,4 @@ 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import os from "node:os";
|
||||
import type {
|
||||
Agent,
|
||||
AgentSideConnection,
|
||||
@@ -62,32 +61,6 @@ 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;
|
||||
@@ -278,17 +251,6 @@ 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) {
|
||||
@@ -319,8 +281,6 @@ 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 },
|
||||
)
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
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;
|
||||
@@ -36,7 +20,6 @@ export type AcpServerOptions = {
|
||||
requireExistingSession?: boolean;
|
||||
resetSession?: boolean;
|
||||
prefixCwd?: boolean;
|
||||
provenanceMode?: AcpProvenanceMode;
|
||||
sessionCreateRateLimit?: {
|
||||
maxRequests?: number;
|
||||
windowMs?: number;
|
||||
|
||||
@@ -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(1_050_000);
|
||||
expect(model?.contextWindow).toBe(272_000);
|
||||
expect(model?.maxTokens).toBe(128_000);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@ 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;
|
||||
@@ -125,14 +123,9 @@ 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;
|
||||
@@ -153,7 +146,6 @@ function resolveOpenAICodexForwardCompatModel(
|
||||
...template,
|
||||
id: trimmedModelId,
|
||||
name: trimmedModelId,
|
||||
...patch,
|
||||
} as Model<Api>);
|
||||
}
|
||||
|
||||
@@ -166,8 +158,8 @@ function resolveOpenAICodexForwardCompatModel(
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: patch?.contextWindow ?? DEFAULT_CONTEXT_TOKENS,
|
||||
maxTokens: patch?.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
|
||||
contextWindow: DEFAULT_CONTEXT_TOKENS,
|
||||
maxTokens: DEFAULT_CONTEXT_TOKENS,
|
||||
} as Model<Api>);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
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";
|
||||
@@ -12,14 +15,12 @@ import {
|
||||
resolveCloudflareAiGatewayBaseUrl,
|
||||
} from "./cloudflare-ai-gateway.js";
|
||||
import {
|
||||
buildHuggingfaceProvider,
|
||||
buildKilocodeProviderWithDiscovery,
|
||||
buildOllamaProvider,
|
||||
buildVeniceProvider,
|
||||
buildVercelAiGatewayProvider,
|
||||
buildVllmProvider,
|
||||
resolveOllamaApiBase,
|
||||
} from "./models-config.providers.discovery.js";
|
||||
discoverHuggingfaceModels,
|
||||
HUGGINGFACE_BASE_URL,
|
||||
HUGGINGFACE_MODEL_CATALOG,
|
||||
buildHuggingfaceModelDefinition,
|
||||
} from "./huggingface-models.js";
|
||||
import { discoverKilocodeModels } from "./kilocode-models.js";
|
||||
import {
|
||||
buildBytePlusCodingProvider,
|
||||
buildBytePlusProvider,
|
||||
@@ -62,11 +63,222 @@ import {
|
||||
resolveEnvSecretRefHeaderValueMarker,
|
||||
} from "./model-auth-markers.js";
|
||||
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
|
||||
export { resolveOllamaApiBase } from "./models-config.providers.discovery.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"]>;
|
||||
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 {
|
||||
@@ -429,6 +641,78 @@ 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;
|
||||
|
||||
@@ -7,9 +7,22 @@ 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 { planOpenClawModelsJson } from "./models-config.plan.js";
|
||||
import {
|
||||
mergeProviders,
|
||||
mergeWithExistingProviderSecrets,
|
||||
type ExistingProviderConfig,
|
||||
} from "./models-config.merge.js";
|
||||
import {
|
||||
normalizeProviders,
|
||||
type ProviderConfig,
|
||||
resolveImplicitProviders,
|
||||
} from "./models-config.providers.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<{
|
||||
@@ -30,6 +43,52 @@ 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
|
||||
@@ -88,26 +147,50 @@ 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,
|
||||
});
|
||||
|
||||
if (plan.action === "skip") {
|
||||
const providers = await resolveProvidersForModelsJson({ cfg, agentDir, env });
|
||||
|
||||
if (Object.keys(providers).length === 0) {
|
||||
return { agentDir, wrote: false };
|
||||
}
|
||||
|
||||
if (plan.action === "noop") {
|
||||
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) {
|
||||
await ensureModelsFileMode(targetPath);
|
||||
return { agentDir, wrote: false };
|
||||
}
|
||||
|
||||
await fs.mkdir(agentDir, { recursive: true, mode: 0o700 });
|
||||
await writeModelsFileAtomic(targetPath, plan.contents);
|
||||
await writeModelsFileAtomic(targetPath, next);
|
||||
await ensureModelsFileMode(targetPath);
|
||||
return { agentDir, wrote: true };
|
||||
});
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
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));
|
||||
}
|
||||
@@ -36,14 +36,13 @@ 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: isGpt54 ? 1_050_000 : 272000,
|
||||
contextWindow: 272000,
|
||||
maxTokens: 128000,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,6 +23,8 @@ type InlineProviderConfig = {
|
||||
headers?: unknown;
|
||||
};
|
||||
|
||||
const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
||||
|
||||
function sanitizeModelHeaders(
|
||||
headers: unknown,
|
||||
opts?: { stripSecretRefMarkers?: boolean },
|
||||
@@ -43,8 +45,58 @@ 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 normalizeResolvedProviderModel(params);
|
||||
return normalizeModelCompat(normalizeOpenAICodexTransport(params));
|
||||
}
|
||||
|
||||
export { buildModelAliasLines };
|
||||
|
||||
@@ -3,7 +3,6 @@ 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";
|
||||
@@ -177,59 +176,3 @@ 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.profileAlsoAllow).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.profileAlsoAllow).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.profileAlsoAllow).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.profileAlsoAllow).toEqual(["read", "write", "edit"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ 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 { AgentToolsConfig } 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";
|
||||
@@ -197,37 +196,6 @@ function resolveProviderToolPolicy(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveExplicitProfileAlsoAllow(tools?: OpenClawConfig["tools"]): string[] | undefined {
|
||||
return Array.isArray(tools?.alsoAllow) ? tools.alsoAllow : undefined;
|
||||
}
|
||||
|
||||
function hasExplicitToolSection(section: unknown): boolean {
|
||||
return section !== undefined && section !== null;
|
||||
}
|
||||
|
||||
function resolveImplicitProfileAlsoAllow(params: {
|
||||
globalTools?: OpenClawConfig["tools"];
|
||||
agentTools?: AgentToolsConfig;
|
||||
}): string[] | undefined {
|
||||
const implicit = new Set<string>();
|
||||
if (
|
||||
hasExplicitToolSection(params.agentTools?.exec) ||
|
||||
hasExplicitToolSection(params.globalTools?.exec)
|
||||
) {
|
||||
implicit.add("exec");
|
||||
implicit.add("process");
|
||||
}
|
||||
if (
|
||||
hasExplicitToolSection(params.agentTools?.fs) ||
|
||||
hasExplicitToolSection(params.globalTools?.fs)
|
||||
) {
|
||||
implicit.add("read");
|
||||
implicit.add("write");
|
||||
implicit.add("edit");
|
||||
}
|
||||
return implicit.size > 0 ? Array.from(implicit) : undefined;
|
||||
}
|
||||
|
||||
export function resolveEffectiveToolPolicy(params: {
|
||||
config?: OpenClawConfig;
|
||||
sessionKey?: string;
|
||||
@@ -258,15 +226,6 @@ export function resolveEffectiveToolPolicy(params: {
|
||||
modelProvider: params.modelProvider,
|
||||
modelId: params.modelId,
|
||||
});
|
||||
const explicitProfileAlsoAllow =
|
||||
resolveExplicitProfileAlsoAllow(agentTools) ?? resolveExplicitProfileAlsoAllow(globalTools);
|
||||
const implicitProfileAlsoAllow = resolveImplicitProfileAlsoAllow({ globalTools, agentTools });
|
||||
const profileAlsoAllow =
|
||||
explicitProfileAlsoAllow || implicitProfileAlsoAllow
|
||||
? Array.from(
|
||||
new Set([...(explicitProfileAlsoAllow ?? []), ...(implicitProfileAlsoAllow ?? [])]),
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
agentId,
|
||||
globalPolicy: pickSandboxToolPolicy(globalTools),
|
||||
@@ -276,7 +235,11 @@ export function resolveEffectiveToolPolicy(params: {
|
||||
profile,
|
||||
providerProfile: agentProviderPolicy?.profile ?? providerPolicy?.profile,
|
||||
// alsoAllow is applied at the profile stage (to avoid being filtered out early).
|
||||
profileAlsoAllow,
|
||||
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)
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
writeCache,
|
||||
} from "./web-shared.js";
|
||||
|
||||
const SEARCH_PROVIDERS = ["brave", "gemini", "grok", "kimi", "perplexity"] as const;
|
||||
const SEARCH_PROVIDERS = ["brave", "perplexity", "grok", "gemini", "kimi"] as const;
|
||||
const DEFAULT_SEARCH_COUNT = 5;
|
||||
const MAX_SEARCH_COUNT = 10;
|
||||
|
||||
@@ -492,18 +492,11 @@ function resolveSearchApiKey(search?: WebSearchConfig): string | undefined {
|
||||
}
|
||||
|
||||
function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) {
|
||||
if (provider === "brave") {
|
||||
if (provider === "perplexity") {
|
||||
return {
|
||||
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",
|
||||
};
|
||||
}
|
||||
if (provider === "gemini") {
|
||||
return {
|
||||
error: "missing_gemini_api_key",
|
||||
error: "missing_perplexity_api_key",
|
||||
message:
|
||||
"web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.",
|
||||
"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",
|
||||
};
|
||||
}
|
||||
@@ -515,6 +508,14 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) {
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (provider === "gemini") {
|
||||
return {
|
||||
error: "missing_gemini_api_key",
|
||||
message:
|
||||
"web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, or configure tools.web.search.gemini.apiKey.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (provider === "kimi") {
|
||||
return {
|
||||
error: "missing_kimi_api_key",
|
||||
@@ -524,9 +525,8 @@ function missingSearchKeyPayload(provider: (typeof SEARCH_PROVIDERS)[number]) {
|
||||
};
|
||||
}
|
||||
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.",
|
||||
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.`,
|
||||
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 === "brave") {
|
||||
return "brave";
|
||||
}
|
||||
if (raw === "gemini") {
|
||||
return "gemini";
|
||||
if (raw === "perplexity") {
|
||||
return "perplexity";
|
||||
}
|
||||
if (raw === "grok") {
|
||||
return "grok";
|
||||
}
|
||||
if (raw === "gemini") {
|
||||
return "gemini";
|
||||
}
|
||||
if (raw === "kimi") {
|
||||
return "kimi";
|
||||
}
|
||||
if (raw === "perplexity") {
|
||||
return "perplexity";
|
||||
if (raw === "brave") {
|
||||
return "brave";
|
||||
}
|
||||
|
||||
// Auto-detect provider from available API keys (alphabetical order)
|
||||
// Auto-detect provider from available API keys (priority order)
|
||||
if (raw === "") {
|
||||
// Brave
|
||||
// 1. Brave
|
||||
if (resolveSearchApiKey(search)) {
|
||||
logVerbose(
|
||||
'web_search: no provider configured, auto-detected "brave" from available API keys',
|
||||
);
|
||||
return "brave";
|
||||
}
|
||||
// Gemini
|
||||
// 2. Gemini
|
||||
const geminiConfig = resolveGeminiConfig(search);
|
||||
if (resolveGeminiApiKey(geminiConfig)) {
|
||||
logVerbose(
|
||||
@@ -569,15 +569,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
|
||||
);
|
||||
return "gemini";
|
||||
}
|
||||
// Grok
|
||||
const grokConfig = resolveGrokConfig(search);
|
||||
if (resolveGrokApiKey(grokConfig)) {
|
||||
logVerbose(
|
||||
'web_search: no provider configured, auto-detected "grok" from available API keys',
|
||||
);
|
||||
return "grok";
|
||||
}
|
||||
// Kimi
|
||||
// 3. Kimi
|
||||
const kimiConfig = resolveKimiConfig(search);
|
||||
if (resolveKimiApiKey(kimiConfig)) {
|
||||
logVerbose(
|
||||
@@ -585,7 +577,7 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
|
||||
);
|
||||
return "kimi";
|
||||
}
|
||||
// Perplexity
|
||||
// 4. Perplexity
|
||||
const perplexityConfig = resolvePerplexityConfig(search);
|
||||
const { apiKey: perplexityKey } = resolvePerplexityApiKey(perplexityConfig);
|
||||
if (perplexityKey) {
|
||||
@@ -594,6 +586,14 @@ 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";
|
||||
|
||||
@@ -236,7 +236,7 @@ describe("inbound dedupe", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not dedupe across agent ids", () => {
|
||||
it("does not dedupe across session keys", () => {
|
||||
resetInboundDedupe();
|
||||
const base: MsgContext = {
|
||||
Provider: "whatsapp",
|
||||
@@ -248,36 +248,12 @@ describe("inbound dedupe", () => {
|
||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 100 }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldSkipDuplicateInbound(
|
||||
{ ...base, SessionKey: "agent:bravo:whatsapp:direct:+1555" },
|
||||
{
|
||||
now: 200,
|
||||
},
|
||||
),
|
||||
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:bravo:main" }, { 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", () => {
|
||||
|
||||
@@ -175,7 +175,6 @@ 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,
|
||||
|
||||
@@ -1539,38 +1539,6 @@ 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);
|
||||
|
||||
@@ -521,7 +521,6 @@ 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 } : {}),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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;
|
||||
@@ -16,23 +15,6 @@ 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();
|
||||
@@ -43,13 +25,13 @@ export function buildInboundDedupeKey(ctx: MsgContext): string | null {
|
||||
if (!peerId) {
|
||||
return null;
|
||||
}
|
||||
const sessionScope = resolveInboundDedupeSessionScope(ctx);
|
||||
const sessionKey = ctx.SessionKey?.trim() ?? "";
|
||||
const accountId = ctx.AccountId?.trim() ?? "";
|
||||
const threadId =
|
||||
ctx.MessageThreadId !== undefined && ctx.MessageThreadId !== null
|
||||
? String(ctx.MessageThreadId)
|
||||
: "";
|
||||
return [provider, accountId, sessionScope, peerId, threadId, messageId].filter(Boolean).join("|");
|
||||
return [provider, accountId, sessionKey, peerId, threadId, messageId].filter(Boolean).join("|");
|
||||
}
|
||||
|
||||
export function shouldSkipDuplicateInbound(
|
||||
|
||||
@@ -2,7 +2,6 @@ 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";
|
||||
|
||||
@@ -78,7 +77,6 @@ export type FollowupRun = {
|
||||
timeoutMs: number;
|
||||
blockReplyBreak: "text_end" | "message_end";
|
||||
ownerNumbers?: string[];
|
||||
inputProvenance?: InputProvenance;
|
||||
extraSystemPrompt?: string;
|
||||
enforceFinalTag?: boolean;
|
||||
};
|
||||
|
||||
@@ -3,7 +3,6 @@ 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";
|
||||
@@ -118,8 +117,6 @@ 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;
|
||||
|
||||
@@ -30,8 +30,6 @@ export type ProfileStatus = {
|
||||
tabCount: number;
|
||||
isDefault: boolean;
|
||||
isRemote: boolean;
|
||||
missingFromConfig?: boolean;
|
||||
reconcileReason?: string | null;
|
||||
};
|
||||
|
||||
export type BrowserResetProfileResult = {
|
||||
|
||||
@@ -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,9 +39,14 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
|
||||
logService.warn(`failed to auto-configure browser auth: ${String(err)}`);
|
||||
}
|
||||
|
||||
state = await createBrowserRuntimeState({
|
||||
state = {
|
||||
server: null,
|
||||
port: resolved.controlPort,
|
||||
resolved,
|
||||
profiles: new Map(),
|
||||
};
|
||||
|
||||
await ensureExtensionRelayForProfiles({
|
||||
resolved,
|
||||
onWarn: (message) => logService.warn(message),
|
||||
});
|
||||
@@ -54,12 +59,22 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
|
||||
|
||||
export async function stopBrowserControlService(): Promise<void> {
|
||||
const current = state;
|
||||
await stopBrowserRuntime({
|
||||
current,
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
await stopKnownBrowserProfiles({
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -2,10 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SsrFBlockedError, type LookupFn } from "../infra/net/ssrf.js";
|
||||
import {
|
||||
assertBrowserNavigationAllowed,
|
||||
assertBrowserNavigationRedirectChainAllowed,
|
||||
assertBrowserNavigationResultAllowed,
|
||||
InvalidBrowserNavigationUrlError,
|
||||
requiresInspectableBrowserNavigationRedirects,
|
||||
} from "./navigation-guard.js";
|
||||
|
||||
function createLookupFn(address: string): LookupFn {
|
||||
@@ -149,58 +147,4 @@ describe("browser navigation guard", () => {
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks private intermediate redirect hops", async () => {
|
||||
const publicLookup = createLookupFn("93.184.216.34");
|
||||
const privateLookup = createLookupFn("127.0.0.1");
|
||||
const finalRequest = {
|
||||
url: () => "https://public.example/final",
|
||||
redirectedFrom: () => ({
|
||||
url: () => "http://private.example/internal",
|
||||
redirectedFrom: () => ({
|
||||
url: () => "https://public.example/start",
|
||||
redirectedFrom: () => null,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(
|
||||
assertBrowserNavigationRedirectChainAllowed({
|
||||
request: finalRequest,
|
||||
lookupFn: vi.fn(async (hostname: string) =>
|
||||
hostname === "private.example"
|
||||
? privateLookup(hostname, { all: true })
|
||||
: publicLookup(hostname, { all: true }),
|
||||
) as unknown as LookupFn,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SsrFBlockedError);
|
||||
});
|
||||
|
||||
it("allows redirect chains when every hop is public", async () => {
|
||||
const lookupFn = createLookupFn("93.184.216.34");
|
||||
const finalRequest = {
|
||||
url: () => "https://public.example/final",
|
||||
redirectedFrom: () => ({
|
||||
url: () => "https://public.example/middle",
|
||||
redirectedFrom: () => ({
|
||||
url: () => "https://public.example/start",
|
||||
redirectedFrom: () => null,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(
|
||||
assertBrowserNavigationRedirectChainAllowed({
|
||||
request: finalRequest,
|
||||
lookupFn,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats default browser SSRF mode as requiring redirect-hop inspection", () => {
|
||||
expect(requiresInspectableBrowserNavigationRedirects()).toBe(true);
|
||||
expect(requiresInspectableBrowserNavigationRedirects({ allowPrivateNetwork: true })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,21 +25,12 @@ export type BrowserNavigationPolicyOptions = {
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
};
|
||||
|
||||
export type BrowserNavigationRequestLike = {
|
||||
url(): string;
|
||||
redirectedFrom(): BrowserNavigationRequestLike | null;
|
||||
};
|
||||
|
||||
export function withBrowserNavigationPolicy(
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
): BrowserNavigationPolicyOptions {
|
||||
return ssrfPolicy ? { ssrfPolicy } : {};
|
||||
}
|
||||
|
||||
export function requiresInspectableBrowserNavigationRedirects(ssrfPolicy?: SsrFPolicy): boolean {
|
||||
return !isPrivateNetworkAllowedByPolicy(ssrfPolicy);
|
||||
}
|
||||
|
||||
export async function assertBrowserNavigationAllowed(
|
||||
opts: {
|
||||
url: string;
|
||||
@@ -111,24 +102,3 @@ export async function assertBrowserNavigationResultAllowed(
|
||||
await assertBrowserNavigationAllowed(opts);
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertBrowserNavigationRedirectChainAllowed(
|
||||
opts: {
|
||||
request?: BrowserNavigationRequestLike | null;
|
||||
lookupFn?: LookupFn;
|
||||
} & BrowserNavigationPolicyOptions,
|
||||
): Promise<void> {
|
||||
const chain: string[] = [];
|
||||
let current = opts.request ?? null;
|
||||
while (current) {
|
||||
chain.push(current.url());
|
||||
current = current.redirectedFrom();
|
||||
}
|
||||
for (const url of chain.toReversed()) {
|
||||
await assertBrowserNavigationAllowed({
|
||||
url,
|
||||
lookupFn: opts.lookupFn,
|
||||
ssrfPolicy: opts.ssrfPolicy,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -132,37 +132,6 @@ 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: {
|
||||
|
||||
@@ -3,16 +3,9 @@ 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,
|
||||
@@ -82,21 +75,19 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
||||
const driver = params.driver === "extension" ? "extension" : undefined;
|
||||
|
||||
if (!isValidProfileName(name)) {
|
||||
throw new BrowserValidationError(
|
||||
"invalid profile name: use lowercase letters, numbers, and hyphens only",
|
||||
);
|
||||
throw new Error("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 BrowserConflictError(`profile "${name}" already exists`);
|
||||
throw new Error(`profile "${name}" already exists`);
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const rawProfiles = cfg.browser?.profiles ?? {};
|
||||
if (name in rawProfiles) {
|
||||
throw new BrowserConflictError(`profile "${name}" already exists`);
|
||||
throw new Error(`profile "${name}" already exists`);
|
||||
}
|
||||
|
||||
const usedColors = getUsedColors(resolvedProfiles);
|
||||
@@ -106,32 +97,17 @@ 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 BrowserResourceExhaustedError("no available CDP ports in range");
|
||||
throw new Error("no available CDP ports in range");
|
||||
}
|
||||
profileConfig = {
|
||||
cdpPort,
|
||||
@@ -156,7 +132,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
||||
state.resolved.profiles[name] = profileConfig;
|
||||
const resolved = resolveProfile(state.resolved, name);
|
||||
if (!resolved) {
|
||||
throw new BrowserProfileNotFoundError(`profile "${name}" not found after creation`);
|
||||
throw new Error(`profile "${name}" not found after creation`);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -172,21 +148,21 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
|
||||
const deleteProfile = async (nameRaw: string): Promise<DeleteProfileResult> => {
|
||||
const name = nameRaw.trim();
|
||||
if (!name) {
|
||||
throw new BrowserValidationError("profile name is required");
|
||||
throw new Error("profile name is required");
|
||||
}
|
||||
if (!isValidProfileName(name)) {
|
||||
throw new BrowserValidationError("invalid profile name");
|
||||
throw new Error("invalid profile name");
|
||||
}
|
||||
|
||||
const cfg = loadConfig();
|
||||
const profiles = cfg.browser?.profiles ?? {};
|
||||
if (!(name in profiles)) {
|
||||
throw new BrowserProfileNotFoundError(`profile "${name}" not found`);
|
||||
throw new Error(`profile "${name}" not found`);
|
||||
}
|
||||
|
||||
const defaultProfile = cfg.browser?.defaultProfile ?? DEFAULT_BROWSER_DEFAULT_PROFILE_NAME;
|
||||
if (name === defaultProfile) {
|
||||
throw new BrowserValidationError(
|
||||
throw new Error(
|
||||
`cannot delete the default profile "${name}"; change browser.defaultProfile first`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { chromium } from "playwright-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SsrFBlockedError } from "../infra/net/ssrf.js";
|
||||
import * as chromeModule from "./chrome.js";
|
||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||
import { closePlaywrightBrowserConnection, createPageViaPlaywright } from "./pw-session.js";
|
||||
@@ -10,9 +9,7 @@ const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl")
|
||||
|
||||
function installBrowserMocks() {
|
||||
const pageOn = vi.fn();
|
||||
const pageGoto = vi.fn<
|
||||
(...args: unknown[]) => Promise<null | { request: () => Record<string, unknown> }>
|
||||
>(async () => null);
|
||||
const pageGoto = vi.fn(async () => {});
|
||||
const pageTitle = vi.fn(async () => "");
|
||||
const pageUrl = vi.fn(() => "about:blank");
|
||||
const contextOn = vi.fn();
|
||||
@@ -87,27 +84,4 @@ describe("pw-session createPageViaPlaywright navigation guard", () => {
|
||||
expect(created.targetId).toBe("TARGET_1");
|
||||
expect(pageGoto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks private intermediate redirect hops", async () => {
|
||||
const { pageGoto } = installBrowserMocks();
|
||||
pageGoto.mockResolvedValueOnce({
|
||||
request: () => ({
|
||||
url: () => "https://93.184.216.34/final",
|
||||
redirectedFrom: () => ({
|
||||
url: () => "http://127.0.0.1:18080/internal-hop",
|
||||
redirectedFrom: () => ({
|
||||
url: () => "https://93.184.216.34/start",
|
||||
redirectedFrom: () => null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
createPageViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
url: "https://93.184.216.34/start",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SsrFBlockedError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,10 +19,8 @@ import {
|
||||
} from "./cdp.helpers.js";
|
||||
import { normalizeCdpWsUrl } from "./cdp.js";
|
||||
import { getChromeWebSocketUrl } from "./chrome.js";
|
||||
import { BrowserTabNotFoundError } from "./errors.js";
|
||||
import {
|
||||
assertBrowserNavigationAllowed,
|
||||
assertBrowserNavigationRedirectChainAllowed,
|
||||
assertBrowserNavigationResultAllowed,
|
||||
withBrowserNavigationPolicy,
|
||||
} from "./navigation-guard.js";
|
||||
@@ -497,7 +495,7 @@ async function resolvePageByTargetIdOrThrow(opts: {
|
||||
const { browser } = await connectBrowser(opts.cdpUrl);
|
||||
const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
|
||||
if (!page) {
|
||||
throw new BrowserTabNotFoundError();
|
||||
throw new Error("tab not found");
|
||||
}
|
||||
return page;
|
||||
}
|
||||
@@ -523,7 +521,7 @@ export async function getPageForTargetId(opts: {
|
||||
if (pages.length === 1) {
|
||||
return first;
|
||||
}
|
||||
throw new BrowserTabNotFoundError();
|
||||
throw new Error("tab not found");
|
||||
}
|
||||
return found;
|
||||
}
|
||||
@@ -788,13 +786,8 @@ export async function createPageViaPlaywright(opts: {
|
||||
url: targetUrl,
|
||||
...navigationPolicy,
|
||||
});
|
||||
const response = await page.goto(targetUrl, { timeout: 30_000 }).catch(() => {
|
||||
await page.goto(targetUrl, { timeout: 30_000 }).catch(() => {
|
||||
// Navigation might fail for some URLs, but page is still created
|
||||
return null;
|
||||
});
|
||||
await assertBrowserNavigationRedirectChainAllowed({
|
||||
request: response?.request(),
|
||||
...navigationPolicy,
|
||||
});
|
||||
await assertBrowserNavigationResultAllowed({
|
||||
url: page.url(),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SsrFBlockedError } from "../infra/net/ssrf.js";
|
||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||
import {
|
||||
getPwToolsCoreSessionMocks,
|
||||
@@ -76,32 +75,4 @@ describe("pw-tools-core.snapshot navigate guard", () => {
|
||||
expect(goto).toHaveBeenCalledTimes(2);
|
||||
expect(result.url).toBe("https://example.com/recovered");
|
||||
});
|
||||
|
||||
it("blocks private intermediate redirect hops during navigation", async () => {
|
||||
const goto = vi.fn(async () => ({
|
||||
request: () => ({
|
||||
url: () => "https://93.184.216.34/final",
|
||||
redirectedFrom: () => ({
|
||||
url: () => "http://127.0.0.1:18080/internal-hop",
|
||||
redirectedFrom: () => ({
|
||||
url: () => "https://93.184.216.34/start",
|
||||
redirectedFrom: () => null,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
setPwToolsCoreCurrentPage({
|
||||
goto,
|
||||
url: vi.fn(() => "https://93.184.216.34/final"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
mod.navigateViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
url: "https://93.184.216.34/start",
|
||||
}),
|
||||
).rejects.toBeInstanceOf(SsrFBlockedError);
|
||||
|
||||
expect(goto).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js";
|
||||
import {
|
||||
assertBrowserNavigationAllowed,
|
||||
assertBrowserNavigationRedirectChainAllowed,
|
||||
assertBrowserNavigationResultAllowed,
|
||||
withBrowserNavigationPolicy,
|
||||
} from "./navigation-guard.js";
|
||||
@@ -197,10 +196,8 @@ export async function navigateViaPlaywright(opts: {
|
||||
const timeout = Math.max(1000, Math.min(120_000, opts.timeoutMs ?? 20_000));
|
||||
let page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
const navigate = async () => await page.goto(url, { timeout });
|
||||
let response;
|
||||
try {
|
||||
response = await navigate();
|
||||
await page.goto(url, { timeout });
|
||||
} catch (err) {
|
||||
if (!isRetryableNavigateError(err)) {
|
||||
throw err;
|
||||
@@ -214,12 +211,8 @@ export async function navigateViaPlaywright(opts: {
|
||||
}).catch(() => {});
|
||||
page = await getPageForTargetId(opts);
|
||||
ensurePageState(page);
|
||||
response = await navigate();
|
||||
await page.goto(url, { timeout });
|
||||
}
|
||||
await assertBrowserNavigationRedirectChainAllowed({
|
||||
request: response?.request(),
|
||||
...withBrowserNavigationPolicy(opts.ssrfPolicy),
|
||||
});
|
||||
const finalUrl = page.url();
|
||||
await assertBrowserNavigationResultAllowed({
|
||||
url: finalUrl,
|
||||
|
||||
@@ -2,29 +2,6 @@ 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"],
|
||||
@@ -33,22 +10,9 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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";
|
||||
@@ -38,10 +37,6 @@ 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));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,97 +0,0 @@
|
||||
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 };
|
||||
@@ -38,8 +38,8 @@ describe("resolveTargetIdAfterNavigate", () => {
|
||||
{ targetId: "fresh-777", url: "https://example.com" },
|
||||
]),
|
||||
});
|
||||
// Ambiguous replacement; prefer staying on the old target rather than guessing wrong.
|
||||
expect(result).toBe("old-123");
|
||||
// Both differ from old targetId; the first non-stale match wins.
|
||||
expect(result).toBe("preexisting-000");
|
||||
});
|
||||
|
||||
it("retries and resolves targetId when first listTabs has no URL match", async () => {
|
||||
@@ -114,24 +114,4 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
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,
|
||||
@@ -17,13 +22,8 @@ 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, toStringOrEmpty } from "./utils.js";
|
||||
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
async function saveBrowserMediaResponse(params: {
|
||||
res: BrowserResponse;
|
||||
@@ -56,28 +56,26 @@ export async function resolveTargetIdAfterNavigate(opts: {
|
||||
}): Promise<string> {
|
||||
let currentTargetId = opts.oldTargetId;
|
||||
try {
|
||||
const pickReplacement = (tabs: Array<{ targetId: string; url: string }>) => {
|
||||
if (tabs.some((tab) => tab.targetId === opts.oldTargetId)) {
|
||||
return opts.oldTargetId;
|
||||
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 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
|
||||
@@ -164,12 +162,11 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
targetId,
|
||||
run: async ({ profileCtx, tab, cdpUrl }) => {
|
||||
let buffer: Buffer;
|
||||
const shouldUsePlaywright = shouldUsePlaywrightForScreenshot({
|
||||
profile: profileCtx.profile,
|
||||
wsUrl: tab.wsUrl,
|
||||
ref,
|
||||
element,
|
||||
});
|
||||
const shouldUsePlaywright =
|
||||
profileCtx.profile.driver === "extension" ||
|
||||
!tab.wsUrl ||
|
||||
Boolean(ref) ||
|
||||
Boolean(element);
|
||||
if (shouldUsePlaywright) {
|
||||
const pw = await requirePwAi(res, "screenshot");
|
||||
if (!pw) {
|
||||
@@ -215,45 +212,81 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
return;
|
||||
}
|
||||
const targetId = typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||
const hasPlaywright = Boolean(await getPwAiModule());
|
||||
const plan = resolveSnapshotPlan({
|
||||
profile: profileCtx.profile,
|
||||
query: req.query,
|
||||
hasPlaywright,
|
||||
});
|
||||
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;
|
||||
|
||||
try {
|
||||
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
|
||||
if ((plan.labels || plan.mode === "efficient") && plan.format === "aria") {
|
||||
if ((labels || mode === "efficient") && format === "aria") {
|
||||
return jsonError(res, 400, "labels/mode=efficient require format=ai");
|
||||
}
|
||||
if (plan.format === "ai") {
|
||||
if (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: plan.selectorValue,
|
||||
frameSelector: plan.frameSelectorValue,
|
||||
refsMode: plan.refsMode,
|
||||
selector: selectorValue,
|
||||
frameSelector: frameSelectorValue,
|
||||
refsMode,
|
||||
options: {
|
||||
interactive: plan.interactive ?? undefined,
|
||||
compact: plan.compact ?? undefined,
|
||||
maxDepth: plan.depth ?? undefined,
|
||||
interactive: interactive ?? undefined,
|
||||
compact: compact ?? undefined,
|
||||
maxDepth: depth ?? undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const snap = plan.wantsRoleSnapshot
|
||||
const snap = wantsRoleSnapshot
|
||||
? await pw.snapshotRoleViaPlaywright(roleSnapshotArgs)
|
||||
: await pw
|
||||
.snapshotAiViaPlaywright({
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
...(typeof plan.resolvedMaxChars === "number"
|
||||
? { maxChars: plan.resolvedMaxChars }
|
||||
: {}),
|
||||
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
|
||||
})
|
||||
.catch(async (err) => {
|
||||
// Public-API fallback when Playwright's private _snapshotForAI is missing.
|
||||
@@ -262,7 +295,7 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
if (plan.labels) {
|
||||
if (labels) {
|
||||
const labeled = await pw.screenshotWithLabelsViaPlaywright({
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
@@ -283,7 +316,7 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
const imageType = normalized.contentType?.includes("jpeg") ? "jpeg" : "png";
|
||||
return res.json({
|
||||
ok: true,
|
||||
format: plan.format,
|
||||
format,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
labels: true,
|
||||
@@ -297,32 +330,30 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
format: plan.format,
|
||||
format,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
...snap,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
})()
|
||||
: snapshotAria({ wsUrl: tab.wsUrl ?? "", limit: plan.limit });
|
||||
})()
|
||||
: snapshotAria({ wsUrl: tab.wsUrl ?? "", limit });
|
||||
|
||||
const resolved = await Promise.resolve(snap);
|
||||
if (!resolved) {
|
||||
@@ -330,7 +361,7 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
}
|
||||
return res.json({
|
||||
ok: true,
|
||||
format: plan.format,
|
||||
format,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
...resolved,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
@@ -19,10 +18,6 @@ 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));
|
||||
}
|
||||
}
|
||||
@@ -162,11 +157,20 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const mapped = toBrowserErrorResponse(err);
|
||||
if (mapped) {
|
||||
return jsonError(res, mapped.status, mapped.message);
|
||||
const msg = String(err);
|
||||
if (msg.includes("already exists")) {
|
||||
return jsonError(res, 409, msg);
|
||||
}
|
||||
jsonError(res, 500, String(err));
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -182,11 +186,17 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
|
||||
const result = await service.deleteProfile(name);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const mapped = toBrowserErrorResponse(err);
|
||||
if (mapped) {
|
||||
return jsonError(res, mapped.status, mapped.message);
|
||||
const msg = String(err);
|
||||
if (msg.includes("invalid profile name")) {
|
||||
return jsonError(res, 400, msg);
|
||||
}
|
||||
jsonError(res, 500, String(err));
|
||||
if (msg.includes("default profile")) {
|
||||
return jsonError(res, 400, msg);
|
||||
}
|
||||
if (msg.includes("not found")) {
|
||||
return jsonError(res, 404, msg);
|
||||
}
|
||||
jsonError(res, 500, msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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";
|
||||
@@ -51,11 +50,7 @@ async function withTabsProfileRoute(params: {
|
||||
|
||||
async function ensureBrowserRunning(profileCtx: ProfileContext, res: BrowserResponse) {
|
||||
if (!(await profileCtx.isReachable(300))) {
|
||||
jsonError(
|
||||
res,
|
||||
new BrowserProfileUnavailableError("browser not running").status,
|
||||
"browser not running",
|
||||
);
|
||||
jsonError(res, 409, "browser not running");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -196,7 +191,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
|
||||
const tabs = await profileCtx.listTabs();
|
||||
const target = resolveIndexedTab(tabs, index);
|
||||
if (!target) {
|
||||
throw new BrowserTabNotFoundError();
|
||||
return jsonError(res, 404, "tab not found");
|
||||
}
|
||||
await profileCtx.closeTab(target.targetId);
|
||||
return res.json({ ok: true, targetId: target.targetId });
|
||||
@@ -209,7 +204,7 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
|
||||
const tabs = await profileCtx.listTabs();
|
||||
const target = tabs[index];
|
||||
if (!target) {
|
||||
throw new BrowserTabNotFoundError();
|
||||
return jsonError(res, 404, "tab not found");
|
||||
}
|
||||
await profileCtx.focusTab(target.targetId);
|
||||
return res.json({ ok: true, targetId: target.targetId });
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,10 @@ 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,
|
||||
@@ -50,7 +48,6 @@ export function createProfileAvailability({
|
||||
getProfileState,
|
||||
setProfileRunning,
|
||||
}: AvailabilityDeps): AvailabilityOps {
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
const resolveTimeouts = (timeoutMs: number | undefined) =>
|
||||
resolveCdpReachabilityTimeouts({
|
||||
profileIsLoopback: profile.cdpIsLoopback,
|
||||
@@ -83,38 +80,6 @@ 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.
|
||||
@@ -137,16 +102,15 @@ export function createProfileAvailability({
|
||||
};
|
||||
|
||||
const ensureBrowserAvailable = async (): Promise<void> => {
|
||||
await reconcileProfileRuntime();
|
||||
const current = state();
|
||||
const remoteCdp = capabilities.isRemote;
|
||||
const remoteCdp = !profile.cdpIsLoopback;
|
||||
const attachOnly = profile.attachOnly;
|
||||
const isExtension = capabilities.requiresRelay;
|
||||
const isExtension = profile.driver === "extension";
|
||||
const profileState = getProfileState();
|
||||
const httpReachable = await isHttpReachable();
|
||||
|
||||
if (isExtension && remoteCdp) {
|
||||
throw new BrowserConfigurationError(
|
||||
throw new Error(
|
||||
`Profile "${profile.name}" uses driver=extension but cdpUrl is not loopback (${profile.cdpUrl}).`,
|
||||
);
|
||||
}
|
||||
@@ -158,7 +122,7 @@ export function createProfileAvailability({
|
||||
bindHost: current.resolved.relayBindHost,
|
||||
});
|
||||
if (!(await isHttpReachable(PROFILE_ATTACH_RETRY_TIMEOUT_MS))) {
|
||||
throw new BrowserProfileUnavailableError(
|
||||
throw new Error(
|
||||
`Chrome extension relay for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`,
|
||||
);
|
||||
}
|
||||
@@ -176,7 +140,7 @@ export function createProfileAvailability({
|
||||
}
|
||||
}
|
||||
if (attachOnly || remoteCdp) {
|
||||
throw new BrowserProfileUnavailableError(
|
||||
throw new Error(
|
||||
remoteCdp
|
||||
? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.`
|
||||
: `Browser attachOnly is enabled and profile "${profile.name}" is not running.`,
|
||||
@@ -208,7 +172,7 @@ export function createProfileAvailability({
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new BrowserProfileUnavailableError(
|
||||
throw new Error(
|
||||
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.`,
|
||||
@@ -217,7 +181,7 @@ export function createProfileAvailability({
|
||||
|
||||
// HTTP responds but WebSocket fails - port in use by something else.
|
||||
if (!profileState.running) {
|
||||
throw new BrowserProfileUnavailableError(
|
||||
throw new Error(
|
||||
`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.`,
|
||||
);
|
||||
@@ -237,8 +201,7 @@ export function createProfileAvailability({
|
||||
};
|
||||
|
||||
const stopRunningBrowser = async (): Promise<{ stopped: boolean }> => {
|
||||
await reconcileProfileRuntime();
|
||||
if (capabilities.requiresRelay) {
|
||||
if (profile.driver === "extension") {
|
||||
const stopped = await stopChromeExtensionRelayServer({
|
||||
cdpUrl: profile.cdpUrl,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveBrowserConfig, resolveProfile } from "./config.js";
|
||||
import { resolveBrowserConfig } 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 }> = {};
|
||||
|
||||
@@ -167,42 +166,4 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import "./server-context.chrome-test-harness.js";
|
||||
import * as chromeModule from "./chrome.js";
|
||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||
import * as pwAiModule from "./pw-ai-module.js";
|
||||
import { createBrowserRouteContext } from "./server-context.js";
|
||||
import {
|
||||
@@ -231,17 +230,6 @@ describe("browser server-context remote profile tab operations", () => {
|
||||
expect(tabs.map((t) => t.targetId)).toEqual(["T1"]);
|
||||
});
|
||||
|
||||
it("fails closed for remote tab opens in strict mode without Playwright", async () => {
|
||||
vi.spyOn(pwAiModule, "getPwAiModule").mockResolvedValue(null);
|
||||
const { state, remote, fetchMock } = createRemoteRouteHarness();
|
||||
state.resolved.ssrfPolicy = {};
|
||||
|
||||
await expect(remote.openTab("https://example.com")).rejects.toBeInstanceOf(
|
||||
InvalidBrowserNavigationUrlError,
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not enforce managed tab cap for remote openclaw profiles", async () => {
|
||||
const listPagesViaPlaywright = vi
|
||||
.fn()
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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";
|
||||
|
||||
@@ -34,14 +32,13 @@ export function createProfileResetOps({
|
||||
isHttpReachable,
|
||||
resolveOpenClawUserDataDir,
|
||||
}: ResetDeps): ResetOps {
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
const resetProfile = async () => {
|
||||
if (capabilities.requiresRelay) {
|
||||
if (profile.driver === "extension") {
|
||||
await stopChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch(() => {});
|
||||
return { moved: false, from: profile.cdpUrl };
|
||||
}
|
||||
if (!capabilities.supportsReset) {
|
||||
throw new BrowserResetUnsupportedError(
|
||||
if (!profile.cdpIsLoopback) {
|
||||
throw new Error(
|
||||
`reset-profile is only supported for local profiles (profile "${profile.name}" is remote).`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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";
|
||||
@@ -30,14 +28,13 @@ 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 (capabilities.requiresAttachedTab) {
|
||||
if (profile.driver === "extension") {
|
||||
// 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.
|
||||
@@ -49,7 +46,7 @@ export function createProfileSelectionOps({
|
||||
}
|
||||
}
|
||||
if (tabs1.length === 0) {
|
||||
throw new BrowserTabNotFoundError(
|
||||
throw new Error(
|
||||
`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).",
|
||||
);
|
||||
@@ -60,7 +57,12 @@ export function createProfileSelectionOps({
|
||||
}
|
||||
|
||||
const tabs = await listTabs();
|
||||
const candidates = capabilities.supportsPerTabWs ? tabs.filter((t) => Boolean(t.wsUrl)) : tabs;
|
||||
// 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 resolveById = (raw: string) => {
|
||||
const resolved = resolveTargetIdFromTabs(raw, candidates);
|
||||
@@ -87,10 +89,10 @@ export function createProfileSelectionOps({
|
||||
const chosen = targetId ? resolveById(targetId) : pickDefault();
|
||||
|
||||
if (chosen === "AMBIGUOUS") {
|
||||
throw new BrowserTargetAmbiguousError();
|
||||
throw new Error("ambiguous target id prefix");
|
||||
}
|
||||
if (!chosen) {
|
||||
throw new BrowserTabNotFoundError();
|
||||
throw new Error("tab not found");
|
||||
}
|
||||
profileState.lastTargetId = chosen.targetId;
|
||||
return chosen;
|
||||
@@ -101,9 +103,9 @@ export function createProfileSelectionOps({
|
||||
const resolved = resolveTargetIdFromTabs(targetId, tabs);
|
||||
if (!resolved.ok) {
|
||||
if (resolved.reason === "ambiguous") {
|
||||
throw new BrowserTargetAmbiguousError();
|
||||
throw new Error("ambiguous target id prefix");
|
||||
}
|
||||
throw new BrowserTabNotFoundError();
|
||||
throw new Error("tab not found");
|
||||
}
|
||||
return resolved.targetId;
|
||||
};
|
||||
@@ -111,7 +113,7 @@ export function createProfileSelectionOps({
|
||||
const focusTab = async (targetId: string): Promise<void> => {
|
||||
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
|
||||
|
||||
if (capabilities.usesPersistentPlaywright) {
|
||||
if (!profile.cdpIsLoopback) {
|
||||
const mod = await getPwAiModule({ mode: "strict" });
|
||||
const focusPageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
|
||||
?.focusPageByTargetIdViaPlaywright;
|
||||
@@ -135,7 +137,7 @@ export function createProfileSelectionOps({
|
||||
const resolvedTargetId = await resolveTargetIdOrThrow(targetId);
|
||||
|
||||
// For remote profiles, use Playwright's persistent connection to close tabs
|
||||
if (capabilities.usesPersistentPlaywright) {
|
||||
if (!profile.cdpIsLoopback) {
|
||||
const mod = await getPwAiModule({ mode: "strict" });
|
||||
const closePageByTargetIdViaPlaywright = (mod as Partial<PwAiModule> | null)
|
||||
?.closePageByTargetIdViaPlaywright;
|
||||
|
||||
@@ -5,11 +5,8 @@ import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import {
|
||||
assertBrowserNavigationAllowed,
|
||||
assertBrowserNavigationResultAllowed,
|
||||
InvalidBrowserNavigationUrlError,
|
||||
requiresInspectableBrowserNavigationRedirects,
|
||||
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 {
|
||||
@@ -62,10 +59,10 @@ export function createProfileTabOps({
|
||||
getProfileState,
|
||||
}: TabOpsDeps): ProfileTabOps {
|
||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
|
||||
const capabilities = getBrowserProfileCapabilities(profile);
|
||||
|
||||
const listTabs = async (): Promise<BrowserTab[]> => {
|
||||
if (capabilities.usesPersistentPlaywright) {
|
||||
// For remote profiles, use Playwright's persistent connection to avoid ephemeral sessions
|
||||
if (!profile.cdpIsLoopback) {
|
||||
const mod = await getPwAiModule({ mode: "strict" });
|
||||
const listPagesViaPlaywright = (mod as Partial<PwAiModule> | null)?.listPagesViaPlaywright;
|
||||
if (typeof listPagesViaPlaywright === "function") {
|
||||
@@ -102,7 +99,8 @@ export function createProfileTabOps({
|
||||
const enforceManagedTabLimit = async (keepTargetId: string): Promise<void> => {
|
||||
const profileState = getProfileState();
|
||||
if (
|
||||
!capabilities.supportsManagedTabLimit ||
|
||||
profile.driver !== "openclaw" ||
|
||||
!profile.cdpIsLoopback ||
|
||||
state().resolved.attachOnly ||
|
||||
!profileState.running
|
||||
) {
|
||||
@@ -134,7 +132,9 @@ export function createProfileTabOps({
|
||||
const openTab = async (url: string): Promise<BrowserTab> => {
|
||||
const ssrfPolicyOpts = withBrowserNavigationPolicy(state().resolved.ssrfPolicy);
|
||||
|
||||
if (capabilities.usesPersistentPlaywright) {
|
||||
// For remote profiles, use Playwright's persistent connection to create tabs
|
||||
// This ensures the tab persists beyond a single request.
|
||||
if (!profile.cdpIsLoopback) {
|
||||
const mod = await getPwAiModule({ mode: "strict" });
|
||||
const createPageViaPlaywright = (mod as Partial<PwAiModule> | null)?.createPageViaPlaywright;
|
||||
if (typeof createPageViaPlaywright === "function") {
|
||||
@@ -155,12 +155,6 @@ export function createProfileTabOps({
|
||||
}
|
||||
}
|
||||
|
||||
if (requiresInspectableBrowserNavigationRedirects(state().resolved.ssrfPolicy)) {
|
||||
throw new InvalidBrowserNavigationUrlError(
|
||||
"Navigation blocked: strict browser SSRF policy requires Playwright-backed redirect-hop inspection",
|
||||
);
|
||||
}
|
||||
|
||||
const createdViaCdp = await createTargetViaCdp({
|
||||
cdpUrl: profile.cdpUrl,
|
||||
url,
|
||||
|
||||
@@ -2,7 +2,6 @@ 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,
|
||||
@@ -58,7 +57,7 @@ function createProfileContext(
|
||||
const current = state();
|
||||
let profileState = current.profiles.get(profile.name);
|
||||
if (!profileState) {
|
||||
profileState = { profile, running: null, lastTargetId: null, reconcile: null };
|
||||
profileState = { profile, running: null, lastTargetId: null };
|
||||
current.profiles.set(profile.name, profileState);
|
||||
}
|
||||
return profileState;
|
||||
@@ -137,9 +136,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
|
||||
|
||||
if (!profile) {
|
||||
const available = Object.keys(current.resolved.profiles).join(", ");
|
||||
throw new BrowserProfileNotFoundError(
|
||||
`Profile "${name}" not found. Available profiles: ${available || "(none)"}`,
|
||||
);
|
||||
throw new Error(`Profile "${name}" not found. Available profiles: ${available || "(none)"}`);
|
||||
}
|
||||
return createProfileContext(opts, profile);
|
||||
};
|
||||
@@ -153,9 +150,9 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
|
||||
});
|
||||
const result: ProfileStatus[] = [];
|
||||
|
||||
for (const name of listKnownProfileNames(current)) {
|
||||
for (const name of Object.keys(current.resolved.profiles)) {
|
||||
const profileState = current.profiles.get(name);
|
||||
const profile = resolveProfile(current.resolved, name) ?? profileState?.profile;
|
||||
const profile = resolveProfile(current.resolved, name);
|
||||
if (!profile) {
|
||||
continue;
|
||||
}
|
||||
@@ -196,8 +193,6 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -208,16 +203,22 @@ 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;
|
||||
};
|
||||
|
||||
|
||||
@@ -13,10 +13,6 @@ 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 = {
|
||||
@@ -60,8 +56,6 @@ export type ProfileStatus = {
|
||||
tabCount: number;
|
||||
isDefault: boolean;
|
||||
isRemote: boolean;
|
||||
missingFromConfig?: boolean;
|
||||
reconcileReason?: string | null;
|
||||
};
|
||||
|
||||
export type ContextOptions = {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user