Compare commits

..

11 Commits

Author SHA1 Message Date
Vincent Koc
06649f468d Tests: allowlist runtime secret fixture 2026-03-08 18:02:21 -07:00
Vincent Koc
f236e913dd CI: keep sandbox smoke on script path 2026-03-08 17:54:28 -07:00
Vincent Koc
f99f346eef CI: route sandbox smoke through setup script 2026-03-08 17:51:17 -07:00
Vincent Koc
291b3398fd CI: cache Docker builds in workflows 2026-03-08 17:10:37 -07:00
Vincent Koc
5fab5c6284 Docker: narrow e2e install inputs 2026-03-08 16:59:57 -07:00
Vincent Koc
7757f6ff71 Merge branch 'main' into vincentkoc-code/docker-cache-layer-fixes 2026-03-08 16:57:14 -07:00
Vincent Koc
d585731b12 Docker: document qr-import manifest scope 2026-03-08 16:54:33 -07:00
Vincent Koc
fcfa6373bf Docker: fix sandbox cache mount continuations 2026-03-08 16:34:33 -07:00
Vincent Koc
8b56fffc64 Merge branch 'main' into vincentkoc-code/docker-cache-layer-fixes 2026-03-08 16:27:46 -07:00
Vincent Koc
7f407a809a Tests: cover Docker build cache layout 2026-03-08 16:24:55 -07:00
Vincent Koc
31785c3f7f Docker: improve build cache reuse 2026-03-08 16:24:47 -07:00
159 changed files with 1758 additions and 4992 deletions

View File

@@ -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"
}

View File

@@ -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.

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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 Zeds Settings UI):

View File

@@ -43,9 +43,9 @@ The table above is alphabetical. If no `provider` is explicitly set, runtime aut
1. **Brave**`BRAVE_API_KEY` env var or `tools.web.search.apiKey` config
2. **Gemini**`GEMINI_API_KEY` env var or `tools.web.search.gemini.apiKey` config
3. **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

View File

@@ -2,13 +2,13 @@ import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { runAcpRuntimeAdapterContract } from "../../../src/acp/runtime/adapter-contract.testkit.js";
import { AcpxRuntime, decodeAcpxRuntimeHandleState } from "./runtime.js";
import {
cleanupMockRuntimeFixtures,
createMockRuntimeFixture,
NOOP_LOGGER,
readMockRuntimeLogEntries,
} from "./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;

View File

@@ -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";

View File

@@ -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";

View File

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

View File

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

View File

@@ -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 } =

View File

@@ -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 } =

View File

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

View File

@@ -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 } =

View File

@@ -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 } =

View File

@@ -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 } =

View File

@@ -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 } =

View File

@@ -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);
});
});

View File

@@ -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;
},

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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");
});
});
});

View File

@@ -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 } =

View File

@@ -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 {

View File

@@ -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 } =

View File

@@ -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();
});
});

View File

@@ -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({

View File

@@ -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 } =

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 } =

View File

@@ -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 } =

View File

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

View File

@@ -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 } =

View File

@@ -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 } =

View File

@@ -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 } =

View File

@@ -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";

View File

@@ -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

View File

@@ -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 } =

View File

@@ -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";

View File

@@ -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 } =

View File

@@ -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";

View File

@@ -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 } =

View File

@@ -10,7 +10,7 @@ import { isMainModule } from "../infra/is-main.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { readSecretFromFile } from "./secret-file.js";
import { AcpGatewayAgent } from "./translator.js";
import { 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
`);

View File

@@ -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 },
);
});
});

View File

@@ -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 },
)

View File

@@ -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;

View File

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

View File

@@ -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>);
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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;

View File

@@ -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 };
});

View File

@@ -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));
}

View File

@@ -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,
};
}

View File

@@ -6,10 +6,10 @@ import { resolveOpenClawAgentDir } from "../agent-paths.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
import { buildModelAliasLines } from "../model-alias-lines.js";
import { isSecretRefHeaderValueMarker } from "../model-auth-markers.js";
import { normalizeModelCompat } from "../model-compat.js";
import { resolveForwardCompatModel } from "../model-forward-compat.js";
import { findNormalizedProviderValue, normalizeProviderId } from "../model-selection.js";
import { discoverAuthStorage, discoverModels } from "../pi-model-discovery.js";
import { normalizeResolvedProviderModel } from "./model.provider-normalization.js";
type InlineModelEntry = ModelDefinitionConfig & {
provider: string;
@@ -23,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 };

View File

@@ -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"]);
});
});

View File

@@ -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)

View File

@@ -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";

View File

@@ -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", () => {

View File

@@ -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,

View File

@@ -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);

View File

@@ -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 } : {}),
},

View File

@@ -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(

View File

@@ -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;
};

View File

@@ -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;

View File

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

View File

@@ -2,8 +2,8 @@ import { loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveBrowserConfig } from "./config.js";
import { ensureBrowserControlAuth } from "./control-auth.js";
import { createBrowserRuntimeState, stopBrowserRuntime } from "./runtime-lifecycle.js";
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
import { ensureExtensionRelayForProfiles, stopKnownBrowserProfiles } from "./server-lifecycle.js";
let state: BrowserServerState | null = null;
const log = createSubsystemLogger("browser");
@@ -39,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
}
}

View File

@@ -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;
}

View File

@@ -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,
);
});
});

View File

@@ -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,
});
}
}

View File

@@ -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;
}

View File

@@ -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: {

View File

@@ -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`,
);
}

View File

@@ -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);
});
});

View File

@@ -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(),

View File

@@ -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);
});
});

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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));
}

View File

@@ -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");
});
});

View File

@@ -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 };

View File

@@ -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();
});
});

View File

@@ -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,

View File

@@ -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);
}
});
}

View File

@@ -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 });

View File

@@ -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
}
}

View File

@@ -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,
});

View File

@@ -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");
});
});

View File

@@ -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()

View File

@@ -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).`,
);
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;
};

View File

@@ -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