Merge branch 'main' into refactor/session-checkpoint-mutation

This commit is contained in:
Vincent Koc
2026-08-12 20:15:09 +08:00
committed by GitHub
150 changed files with 5690 additions and 1955 deletions
@@ -1904,6 +1904,9 @@ public struct EnvironmentSummary: Codable, Sendable {
public let type: String
public let label: String?
public let status: EnvironmentStatus
public let platform: String?
public let sessionhost: Bool?
public let trust: String?
public let capabilities: [String]?
public let worker: WorkerEnvironmentMetadata?
@@ -1912,6 +1915,9 @@ public struct EnvironmentSummary: Codable, Sendable {
type: String,
label: String? = nil,
status: EnvironmentStatus,
platform: String? = nil,
sessionhost: Bool? = nil,
trust: String? = nil,
capabilities: [String]? = nil,
worker: WorkerEnvironmentMetadata? = nil)
{
@@ -1919,6 +1925,9 @@ public struct EnvironmentSummary: Codable, Sendable {
self.type = type
self.label = label
self.status = status
self.platform = platform
self.sessionhost = sessionhost
self.trust = trust
self.capabilities = capabilities
self.worker = worker
}
@@ -1928,6 +1937,9 @@ public struct EnvironmentSummary: Codable, Sendable {
case type
case label
case status
case platform
case sessionhost = "sessionHost"
case trust
case capabilities
case worker
}
@@ -1956,6 +1968,9 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
public let type: String
public let label: String?
public let status: EnvironmentStatus
public let platform: String?
public let sessionhost: Bool?
public let trust: String?
public let capabilities: [String]?
public let worker: WorkerEnvironmentMetadata?
@@ -1964,6 +1979,9 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
type: String,
label: String? = nil,
status: EnvironmentStatus,
platform: String? = nil,
sessionhost: Bool? = nil,
trust: String? = nil,
capabilities: [String]? = nil,
worker: WorkerEnvironmentMetadata? = nil)
{
@@ -1971,6 +1989,9 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
self.type = type
self.label = label
self.status = status
self.platform = platform
self.sessionhost = sessionhost
self.trust = trust
self.capabilities = capabilities
self.worker = worker
}
@@ -1980,6 +2001,9 @@ public struct EnvironmentsCreateResult: Codable, Sendable {
case type
case label
case status
case platform
case sessionhost = "sessionHost"
case trust
case capabilities
case worker
}
@@ -2008,6 +2032,9 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
public let type: String
public let label: String?
public let status: EnvironmentStatus
public let platform: String?
public let sessionhost: Bool?
public let trust: String?
public let capabilities: [String]?
public let worker: WorkerEnvironmentMetadata?
@@ -2016,6 +2043,9 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
type: String,
label: String? = nil,
status: EnvironmentStatus,
platform: String? = nil,
sessionhost: Bool? = nil,
trust: String? = nil,
capabilities: [String]? = nil,
worker: WorkerEnvironmentMetadata? = nil)
{
@@ -2023,6 +2053,9 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
self.type = type
self.label = label
self.status = status
self.platform = platform
self.sessionhost = sessionhost
self.trust = trust
self.capabilities = capabilities
self.worker = worker
}
@@ -2032,6 +2065,9 @@ public struct EnvironmentsDestroyResult: Codable, Sendable {
case type
case label
case status
case platform
case sessionhost = "sessionHost"
case trust
case capabilities
case worker
}
@@ -2076,6 +2112,9 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
public let type: String
public let label: String?
public let status: EnvironmentStatus
public let platform: String?
public let sessionhost: Bool?
public let trust: String?
public let capabilities: [String]?
public let worker: WorkerEnvironmentMetadata?
@@ -2084,6 +2123,9 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
type: String,
label: String? = nil,
status: EnvironmentStatus,
platform: String? = nil,
sessionhost: Bool? = nil,
trust: String? = nil,
capabilities: [String]? = nil,
worker: WorkerEnvironmentMetadata? = nil)
{
@@ -2091,6 +2133,9 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
self.type = type
self.label = label
self.status = status
self.platform = platform
self.sessionhost = sessionhost
self.trust = trust
self.capabilities = capabilities
self.worker = worker
}
@@ -2100,6 +2145,9 @@ public struct EnvironmentsStatusResult: Codable, Sendable {
case type
case label
case status
case platform
case sessionhost = "sessionHost"
case trust
case capabilities
case worker
}
@@ -1 +1 @@
{"contentHash":"656879cbd7d8c60a593ea053ccb8ba0536d4b5eb5e4ae61755a31d3e18a49c42","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
{"contentHash":"e4a7ffbb2daf39077b19f28d0dbf9267bb645be42ce18e24161966e34b16bdc7","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"72bf26feecd57463e402f8ed15e6b4edba23fc8276ebc2f6ae55d016a4fef381","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
{"contentHash":"517a4d6efc324c135d1761b9d8e77ed573162bcc8b662372cebdfd7c5e3083c1","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"7c1094e554ba440a2204f18658611682a101e12dc3d79c1005ae8f40fd766d52","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
{"contentHash":"007c23cfcc4af2cc62967750beabdb9825352b54afc3057c989c7e9179790ad0","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
@@ -1 +1 @@
{"contentHash":"6bad9d034375b1641f89f78f224826127f9e8b74f29cd9086d24ac721e8bd714","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
{"contentHash":"454a8b3a3ee15a1a691970f4b5cb15878b8f2f4e166d6600bf639f57292232f3","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"}
@@ -1 +1 @@
{"contentHash":"efed0dfd7d5ade67b9f4549b1505e8def917262e1940c9cbe10f761ff154811b","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
{"contentHash":"aaadbf677c001d81a784acbe65ce9acb4d76794be31fecff9d2c9b8d984ec37c","entrypoint":"channel-inbound","importSpecifier":"openclaw/plugin-sdk/channel-inbound"}
@@ -1 +1 @@
{"contentHash":"74ba73d4b1b1e114886afabfb3942d72e6062b07486cd2d6f910c61d7183dabe","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
{"contentHash":"8bafa462130e5c1da30329c7face6a483957d4a0c3cc689b1632f1974420b69f","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
@@ -1 +1 @@
{"contentHash":"e71a02577d9098115855236dfb546ca2c2a5d05c855ae0c30d90f1fdcf3fad6a","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
{"contentHash":"9e02fe7d5c9f80328c7f557420ee84687954f753dfd84b19e1469eb94b8dd168","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
@@ -1 +1 @@
{"contentHash":"7375a14d1a2ead1dce9d13bc9a6c5c94dc7e809cde37860a7ace9fce480b6751","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"}
{"contentHash":"982f42cd1ef5594aff26969ac256e5cf2f124f409b7e020fcad5117fc8322055","entrypoint":"channel-pairing","importSpecifier":"openclaw/plugin-sdk/channel-pairing"}
@@ -1 +1 @@
{"contentHash":"b902346ba2e50c3ebf787aefa8b3415cd65fb87000d42278a6db9ee4960f9aa1","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
{"contentHash":"a0b2dd06b690fe208c45c02e858934b527ed3c419f1a7ebd394423fc295cbff9","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"c1deb3dd9ab64a993067f7f736ae31b40dee5b96e03e2196c3c482cd911ac0ff","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
{"contentHash":"402dc7fb02d64dc390510abbf1eb240ccb807b1bf3e26ddaa582c9393bdfb57a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"4f8b3a784b00784d7a250b5489bbc20d099f824b8d9ff914dd8d0c93389b07de","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
{"contentHash":"4b0344b65821cc88ea69e7d6e8ca37ddfd21d258035e6ba7d613748df743c761","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
@@ -1 +1 @@
{"contentHash":"38dbab19f1c23c82c20443339807822f5dfc626d42171dbb71f3322992d01097","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
{"contentHash":"ffbc6c5c809ed0ceb135ea42926e321edf4265eb96c80e0f9df536d7eb472811","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
@@ -1 +1 @@
{"contentHash":"1a0fef1510e351de8902c038e6cde7915707897dcc999455806e5063cb75af18","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
{"contentHash":"0ebc21dbb6435d83c96ddae60291abbf36a47292288533e1b921bf9501581895","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"d83147f1ddbab6a62f7afc6ec7bbf5ec960b3abf428e6c9abe4a0f9518b0a8c7","entrypoint":"infra-runtime","importSpecifier":"openclaw/plugin-sdk/infra-runtime"}
{"contentHash":"463ab9088e5cbf0f008716d00676aaf2a4bdf0f13a6621cd42a76aac34f5b7a0","entrypoint":"infra-runtime","importSpecifier":"openclaw/plugin-sdk/infra-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"04ca7bf038cd8ad69f3145ebd3c774b8b27b18ed96fa8227e289abcecdbd83b5","entrypoint":"media-runtime","importSpecifier":"openclaw/plugin-sdk/media-runtime"}
{"contentHash":"fe35d8eebcbfbdb20fc4a1e3b903e2dc7891dc2f12feb3e6130fec5dcd605376","entrypoint":"media-runtime","importSpecifier":"openclaw/plugin-sdk/media-runtime"}
@@ -1 +1 @@
{"contentHash":"3cf394e9cb5f76a7fa44727b7e13adcd8dff1b9d4b5b48e9ac2d0adc06f17a0e","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
{"contentHash":"458825733f53d1c06d468287d7059d2400202572082a8300d7806c71856498c7","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"1489259884d6df7354edd810aa984442ac3851451ba5991c85f491c99357281a","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
{"contentHash":"0cbd356b5509eb2bd0f744987a48e34726525e8932fe5a6bb9c7eb5884b55329","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"af318d3b8a4edec2d40ad123b97e076910c09b7d517cb03588a5f3c20b808f31","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
{"contentHash":"f662a31a8c5db27d767489160e9d443df45190429e8f4fb9f6e184d12bac3f19","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
@@ -1 +1 @@
{"contentHash":"e04b2dea0c6c27657372b826db2870cd399ed9f13d5d76d006dd71ac60024187","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
{"contentHash":"d0c70224a6b22aef9680ea81d32d071b11a2533c885dc8665080c6c00255ea4d","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"9a243e9ff6e6512ef4b635ed503159631e4e000a5dbc4d7743ffd091dfb1ef1d","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"}
{"contentHash":"babd3de9e3fbbc52a328d73ae815f4df4dbc57377aa0a0ec5de08636b87ab542","entrypoint":"runtime-store","importSpecifier":"openclaw/plugin-sdk/runtime-store"}
@@ -1 +1 @@
{"contentHash":"ea0c884272571c2494865e709f61fd3a33e0da4e2096c301ab5d292783b923bb","entrypoint":"security-runtime","importSpecifier":"openclaw/plugin-sdk/security-runtime"}
{"contentHash":"d293aefd15df648be5808cdbe6194cb363cc3410e9e0ae01cf1d2162f8a475c5","entrypoint":"security-runtime","importSpecifier":"openclaw/plugin-sdk/security-runtime"}
@@ -1 +1 @@
{"contentHash":"382193e8ef6f05d7bc0d872abefba471c374737bcc86db3810be780b893b72b4","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"}
{"contentHash":"e843ae3223098e08b57d4af6efd78cc1693bb5ed3b7f589dee10c223d914b1bb","entrypoint":"session-catalog","importSpecifier":"openclaw/plugin-sdk/session-catalog"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"c0619235a9636dc3fc22ab5ac74e7d14a01bf9c40594ced596a17143b6eb90c4","entrypoint":"ssrf-policy","importSpecifier":"openclaw/plugin-sdk/ssrf-policy"}
{"contentHash":"f15daa0e828f7f32901d14f7174b31883799c1bd6c1498d4295a85cbd045eeb6","entrypoint":"ssrf-policy","importSpecifier":"openclaw/plugin-sdk/ssrf-policy"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"77efaea8a61aa4e0dccdffcf2d6e6bb4bcc758587691126c17905ffdf11ddd23","entrypoint":"ssrf-runtime","importSpecifier":"openclaw/plugin-sdk/ssrf-runtime"}
{"contentHash":"9df7566aad3ff6c1d41e693351d47e09f77f30a0b6d43a828afe7439b84fe36f","entrypoint":"ssrf-runtime","importSpecifier":"openclaw/plugin-sdk/ssrf-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"10bdd5bfc10ab30dad145d36d7a78a052f52960c4f8e534a0ba258f5f777dba5","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
{"contentHash":"4962f66547dd18b573f4c3ee7ad08f35f220417559437ed2653ef1e3f07fb4be","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
@@ -1 +1 @@
{"contentHash":"56937e6aa9f4060b93d632f4271bbaa9ba992a7a2fe4c9a6f4b5a5c90b0a41da","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
{"contentHash":"326217df400252a236ce5f5e1f98adfcbbec79d3a5d068cf44fc3b7a70e0debe","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
+1 -1
View File
@@ -119,7 +119,7 @@ The slowest Node test families are split or balanced so each job stays small wit
- Auto-reply runs as balanced workers, with the reply subtree split into agent-runner, commands, dispatch, session, and state-routing shards.
- Agentic gateway/server (control-plane) configs split across chat, auth, model, HTTP/plugin, runtime, and startup lanes instead of waiting on built artifacts.
- Normal CI packs only isolated infra include-pattern shards into deterministic bundles of at most 64 test files, reducing the Node matrix without merging non-isolated command/cron, stateful agents-core, or gateway/server suites. Heavy fixed suites stay on 8 vCPU while the bundled and lower-weight lanes use 4 vCPU.
- Pull requests on the canonical repository reuse the changed-test resolver against the synthetic merged-tree diff. Precise changes run one targeted Node job; each selected test file gets its own process so stateful suite isolation remains intact. The planner combines sibling tests with import-graph dependents and falls back to the existing 14-job compact full-suite plan for workspace package, package/lockfile, shared harness, split-config, renamed, or deleted changes, public extension-contract changes, tests with special shard setup, partially resolved or empty targets, oversized path or target plans, and planner errors. Targeted plans always retain the full built-artifact boundary gate because its repository scanners cannot be derived from imports. `main` pushes run the same full compact suite: pending intermediate push events can be coalesced, so the newest surviving run must validate the complete integration tree rather than only its final single-push diff. Manual dispatches and release gates retain the full named per-shard matrix. Compact packing uses fleet timing hints without changing the bounded job count; the high-variance source/security group remains isolated so its tail does not serialize unrelated groups.
- Pull requests on the canonical repository reuse the changed-test resolver against the synthetic merged-tree diff. Precise changes run one targeted Node job; each selected test file gets its own process so stateful suite isolation remains intact. The planner combines sibling tests with import-graph dependents and falls back to the existing 23-job compact full-suite plan for workspace package, package/lockfile, shared harness, split-config, renamed, or deleted changes, public extension-contract changes, tests with special shard setup, partially resolved or empty targets, oversized path or target plans, and planner errors. Targeted plans always retain the full built-artifact boundary gate because its repository scanners cannot be derived from imports. `main` pushes run the same full compact suite: pending intermediate push events can be coalesced, so the newest surviving run must validate the complete integration tree rather than only its final single-push diff. Manual dispatches and release gates retain the full named per-shard matrix. Compact packing uses median group walls refreshed from multiple successful hosted runs without changing the bounded job count; the high-variance source/security group remains isolated so its tail does not serialize unrelated groups.
- The full Node matrix admits the consistently slow serial tooling, auto-reply command shards, and broad core-fast cache writer first. This keeps the 28-job cap while preventing critical-path work and the next run's transform seed from slipping into a later wave.
- The three serial Control UI browser shards greedily pack discovered test files by source byte size. This zero-state duration proxy avoids Vitest's equal-file-count hash clustering, automatically accounts for new and changed files, and preserves the same complete test inventory without adding runners.
- Broad browser, QA, media, and miscellaneous plugin tests use their dedicated Vitest configs instead of the shared plugin catch-all. Include-pattern shards record timing entries using the CI shard name, so `.artifacts/vitest-shard-timings.json` can distinguish a whole config from a filtered shard.
+15 -15
View File
@@ -13,21 +13,21 @@ Proposal, revision 2. Supersedes revision 1 in place (2026-08-11, operator
decision). Implementation in progress; update this table in every PR that
advances a milestone.
| # | Milestone | Status | PRs |
| --- | ---------------------------------------------------------- | ----------- | ------- |
| 0 | This plan (revision 2) | landed | #122454 |
| 1a | Naming: session copy revert | landed | #120667 |
| 1b | Naming: devices consolidation | landed | #120689 |
| 1c | Cleanup: node-pairing → device-pairing merge | not started | — |
| 2 | `openclaw resume` + web Continue in terminal | in progress | #120664 |
| 3 | `openclaw connect` one-paste onboarding + `/j/` join route | not started | — |
| 4 | Picker: grouping, placement, liveness, enrichment | in progress | #120804 |
| 5 | Public worker ingress path | not started | — |
| 6 | Node worker provider (device runners) | not started | — |
| 7 | Bundle push consent + runner updates | not started | — |
| 8 | Stop-and-continue moves | not started | — |
| 9 | Deletions (ssh sandbox, openshell, exec-host clones, …) | not started | — |
| 10 | Cloud convergence (provisioners run `openclaw connect`) | not started | — |
| # | Milestone | Status | PRs |
| --- | ---------------------------------------------------------- | ----------- | ---------------- |
| 0 | This plan (revision 2) | landed | #122454 |
| 1a | Naming: session copy revert | landed | #120667 |
| 1b | Naming: devices consolidation | landed | #120689 |
| 1c | Cleanup: node-pairing → device-pairing merge | not started | — |
| 2 | `openclaw resume` + web Continue in terminal | in progress | #120664 |
| 3 | `openclaw connect` one-paste onboarding + `/j/` join route | not started | — |
| 4 | Picker: grouping, placement, liveness, enrichment | in progress | #120804, #122531 |
| 5 | Public worker ingress path | not started | — |
| 6 | Node worker provider (device runners) | not started | — |
| 7 | Bundle push consent + runner updates | not started | — |
| 8 | Stop-and-continue moves | not started | — |
| 9 | Deletions (ssh sandbox, openshell, exec-host clones, …) | not started | — |
| 10 | Cloud convergence (provisioners run `openclaw connect`) | not started | — |
Revision history: revision 1 (2026-08-08) established the session/runner
vocabulary, the naming rulings, and the milestone skeleton after a
+15
View File
@@ -316,6 +316,21 @@ main model can read the screenshot directly.
- Browser navigation and open-tab requests are preflight checked. During the action and bounded post-action grace, guarded Playwright interactions (click, coordinate click, hover, drag, scroll, select, press, type, form fill, and evaluate) intercept policy-denied top-level and subframe document loads before HTTP request bytes, then best-effort re-check the final `http(s)` URL.
- Before each fresh OpenClaw-managed Chrome launch, OpenClaw best-effort disables network prediction, suppressing Chromium's observed speculative preconnect for those denied loads. This is defense in depth, not a policy boundary: a browser reused across a control-service restart and other browser backends may not share the hardening. Playwright routing is still not a network firewall and does not intercept redirect hops, a popup's first request, Service Worker traffic, page code that runs after the bounded guard window, or every background/subresource path. Complete egress isolation requires owner-side isolation or a policy-enforcing proxy.
- In strict SSRF mode, remote CDP endpoint discovery and `/json/version` probes (`cdpUrl`) are checked too.
- Guarded remote CDP connections now fail closed when the selected driver cannot
keep the approved endpoint bound to the actual socket. Use the regular
`openclaw` driver for Browserless, Browserbase, Notte, or other guarded
remote CDP providers. `existing-session`/Chrome MCP profiles with an explicit
`cdpUrl` or `--browserUrl`/`--wsEndpoint` MCP argument are rejected under the
default strict Browser policy because Chrome MCP cannot carry OpenClaw's
pinned DNS lookup or guarded discovery result across its subprocess boundary.
They remain supported only when private-network Browser access is explicitly
trusted. Otherwise, omit the explicit endpoint and attach Chrome MCP to a
host-local Chrome profile, or switch the profile to the regular driver for
guarded CDP.
- Redirecting CDP discovery to a different authority remains unsupported unless
the active policy explicitly allows that authority change. Revalidating a
returned hostname is not enough; the WebSocket transport must use the endpoint
that passed policy validation.
- Gateway/provider `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` environment variables do not automatically proxy the OpenClaw-managed browser. Managed Chrome launches direct by default so provider proxy settings do not weaken browser SSRF checks.
- OpenClaw-managed local CDP readiness probes and DevTools WebSocket connections bypass the managed network proxy for the exact launched loopback endpoint, so `openclaw browser start` still works when an operator proxy blocks loopback egress.
- To proxy the managed browser itself, pass explicit Chrome proxy flags through `browser.extraArgs`, such as `--proxy-server=...` or `--proxy-pac-url=...`. Strict SSRF mode blocks explicit browser proxy routing unless private-network browser access is intentionally enabled.
@@ -0,0 +1,45 @@
function decodeUrlUserInfo(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
/** Merge URL basic-auth credentials into headers without overriding explicit auth. */
export function getHeadersWithAuth(url: string, headers: Record<string, string> = {}) {
const mergedHeaders = { ...headers };
try {
const parsed = new URL(url);
const hasAuthHeader = Object.keys(mergedHeaders).some(
(key) => key.trim().toLowerCase() === "authorization",
);
if (hasAuthHeader) {
return mergedHeaders;
}
if (parsed.username || parsed.password) {
const username = decodeUrlUserInfo(parsed.username);
const password = decodeUrlUserInfo(parsed.password);
const auth = Buffer.from(`${username}:${password}`).toString("base64");
return { ...mergedHeaders, Authorization: `Basic ${auth}` };
}
} catch {
// ignore
}
return mergedHeaders;
}
/** Remove URL userinfo after callers have converted it to an Authorization header. */
export function stripCdpUrlCredentials(url: string): string {
try {
const parsed = new URL(url);
if (!parsed.username && !parsed.password) {
return url;
}
parsed.username = "";
parsed.password = "";
return parsed.toString();
} catch {
return url;
}
}
@@ -143,7 +143,7 @@ export async function waitForCdpCommittedNavigationUrl(opts: {
signal?: AbortSignal;
timeouts?: CdpActionTimeouts;
}): Promise<string | undefined> {
await assertCdpEndpointAllowed(opts.wsUrl, opts.cdpPolicy, {
const pinned = await assertCdpEndpointAllowed(opts.wsUrl, opts.cdpPolicy, {
source: "discovered",
configuredUrl: opts.configuredCdpUrl,
});
@@ -160,6 +160,7 @@ export async function waitForCdpCommittedNavigationUrl(opts: {
commandTimeoutMs: opts.timeouts?.httpTimeoutMs ?? CDP_TARGET_NAVIGATION_RESULT_TIMEOUT_MS,
handshakeTimeoutMs: opts.timeouts?.handshakeTimeoutMs,
handshakeRetries: 0,
lookup: pinned?.lookup,
},
);
} catch {
@@ -5,10 +5,17 @@
* is stricter, so this module scopes the exception to browser control only.
*/
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { matchesHostnameAllowlist, normalizeHostname } from "../sdk-security-runtime.js";
import { normalizeHostname } from "../sdk-security-runtime.js";
import { CHROME_MCP_ENDPOINT_FLAGS } from "./chrome-mcp-contracts.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { BrowserProfileUnavailableError } from "./errors.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
import { withExactHostnamePolicy } from "./ssrf-policy-helpers.js";
import { isCdpHostnameTrustedByPolicy, withExactHostnamePolicy } from "./ssrf-policy-helpers.js";
// Synthetic exact-host CDP policies must retain the operator's original intent;
// otherwise Chrome MCP cannot distinguish default control-plane scoping from a
// user-authored restriction that genuinely requires pinned transport.
const cdpControlSourcePolicyByScopedPolicy = new WeakMap<SsrFPolicy, SsrFPolicy>();
function withCdpControlHostname(
profile: ResolvedBrowserProfile,
@@ -19,17 +26,41 @@ function withCdpControlHostname(
if (!ssrfPolicy || !cdpHost) {
return ssrfPolicy;
}
const allowedHostnames = (ssrfPolicy.allowedHostnames ?? [])
.map((pattern) => normalizeHostname(pattern))
.filter((pattern) => pattern && pattern !== "*" && pattern !== "*.");
if (
requireAllowlistMatch &&
allowedHostnames.length > 0 &&
!matchesHostnameAllowlist(cdpHost, allowedHostnames)
) {
if (requireAllowlistMatch && !isCdpHostnameTrustedByPolicy(ssrfPolicy, cdpHost)) {
return ssrfPolicy;
}
return withExactHostnamePolicy(ssrfPolicy, cdpHost);
const scopedPolicy = withExactHostnamePolicy(ssrfPolicy, cdpHost);
cdpControlSourcePolicyByScopedPolicy.set(scopedPolicy, ssrfPolicy);
return scopedPolicy;
}
function hasPolicyEntries(values?: string[]): boolean {
return (values ?? []).some((value) => value.trim().length > 0);
}
function requiresPinnedChromeMcpCdpTransport(cdpPolicy?: SsrFPolicy): boolean {
if (!cdpPolicy) {
return false;
}
const policyIntent = cdpControlSourcePolicyByScopedPolicy.get(cdpPolicy) ?? cdpPolicy;
const hasScopedPolicy =
policyIntent.allowRfc2544BenchmarkRange === true ||
policyIntent.allowIpv6UniqueLocalRange === true ||
hasPolicyEntries(policyIntent.allowedHostnames) ||
hasPolicyEntries(policyIntent.hostnameAllowlist) ||
hasPolicyEntries(policyIntent.allowedOrigins);
return !(
!hasScopedPolicy &&
(policyIntent.dangerouslyAllowPrivateNetwork === true ||
policyIntent.allowPrivateNetwork === true)
);
}
function hasChromeMcpEndpointArg(args?: string[]): boolean {
return (args ?? []).some((arg) => {
const [name] = arg.split("=", 1);
return CHROME_MCP_ENDPOINT_FLAGS.has(name ?? arg);
});
}
export function resolveCdpReachabilityPolicy(
@@ -51,3 +82,19 @@ export function resolveCdpReachabilityPolicy(
/** Alias used by callers that treat reachability and control as one CDP policy. */
export const resolveCdpControlPolicy = resolveCdpReachabilityPolicy;
export function assertChromeMcpCdpTransportAllowed(
profile: ResolvedBrowserProfile,
cdpPolicy?: SsrFPolicy,
): void {
const hasExplicitEndpoint = Boolean(profile.cdpUrl) || hasChromeMcpEndpointArg(profile.mcpArgs);
if (profile.driver !== "existing-session" || !hasExplicitEndpoint) {
return;
}
if (!requiresPinnedChromeMcpCdpTransport(cdpPolicy)) {
return;
}
throw new BrowserProfileUnavailableError(
`Browser profile "${profile.name}" uses Chrome MCP with an explicit CDP endpoint, but the active Browser CDP policy requires OpenClaw to pin the approved endpoint. Chrome MCP cannot carry that pinned transport across its subprocess boundary. Use driver "openclaw" for guarded CDP endpoints, or remove cdpUrl and browserUrl/wsEndpoint mcpArgs from this existing-session profile so Chrome MCP attaches to a host-local Chrome profile.`,
);
}
@@ -0,0 +1,419 @@
import type { lookup as dnsLookupCb } from "node:dns";
import type { ClientRequest } from "node:http";
import http from "node:http";
import https from "node:https";
import net from "node:net";
import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime";
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";
import WebSocket from "ws";
import { getHeadersWithAuth, stripCdpUrlCredentials } from "./cdp-auth.js";
import { getDirectAgentForCdp, withManagedProxyForCdpUrl } from "./cdp-proxy-bypass.js";
import { CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js";
import { getPlaywrightUserAgent } from "./playwright-core.runtime.js";
import { normalizeBrowserTimerDelayMs } from "./timer-delay.js";
const PLAYWRIGHT_CDP_MAX_PAYLOAD_BYTES = 256 * 1024 * 1024;
const PLAYWRIGHT_CDP_PER_MESSAGE_DEFLATE = {
clientNoContextTakeover: true,
zlibDeflateOptions: { level: 3 },
zlibInflateOptions: { chunkSize: 10 * 1024 },
threshold: 10 * 1024,
} as const;
const PLAYWRIGHT_CDP_MAX_REDIRECTS = 10;
type CdpSocketLookup = typeof dnsLookupCb;
type CdpResponse = {
id: number;
result?: unknown;
error?: { message?: string };
};
type Pending = {
resolve: (value: unknown) => void;
reject: (err: Error) => void;
timer?: ReturnType<typeof setTimeout>;
};
export type CdpSendFn = (
method: string,
params?: Record<string, unknown>,
sessionId?: string,
) => Promise<unknown>;
function withDefaultPlaywrightUserAgent(headers: Record<string, string>): Record<string, string> {
if (Object.keys(headers).some((key) => key.trim().toLowerCase() === "user-agent")) {
return headers;
}
return { ...headers, "User-Agent": getPlaywrightUserAgent() };
}
function cdpWebSocketAuthority(url: string): string {
const parsed = new URL(url);
return `${parsed.protocol}//${parsed.host}`;
}
function assertSameAuthorityWebSocketRedirect(
originalUrl: string,
redirectedUrl: string,
request: ClientRequest,
): void {
if (cdpWebSocketAuthority(originalUrl) === cdpWebSocketAuthority(redirectedUrl)) {
return;
}
request.destroy(new Error("CDP WebSocket redirect changed authority"));
}
function defaultPortForWebSocketProtocol(protocol: string): string {
return protocol === "wss:" || protocol === "https:" ? "443" : "80";
}
function normalizeAuthorityHostname(hostname: string): string {
return hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase();
}
function hostnameFromAgentOptions(options: unknown): string | undefined {
if (options instanceof URL) {
return options.hostname;
}
if (!options || typeof options !== "object") {
return undefined;
}
if ("hostname" in options && typeof options.hostname === "string") {
return options.hostname;
}
const rawHost = "host" in options && typeof options.host === "string" ? options.host : undefined;
if (!rawHost) {
return undefined;
}
if (rawHost.startsWith("[")) {
const end = rawHost.indexOf("]");
return end > 0 ? rawHost.slice(1, end) : rawHost;
}
if ((rawHost.match(/:/g) ?? []).length > 1) {
return rawHost;
}
return rawHost.includes(":") ? rawHost.split(":")[0] : rawHost;
}
function portFromAgentOptions(options: unknown, fallbackProtocol: string): string {
if (options instanceof URL) {
return options.port || defaultPortForWebSocketProtocol(options.protocol);
}
if (!options || typeof options !== "object") {
return defaultPortForWebSocketProtocol(fallbackProtocol);
}
if ("port" in options) {
const rawPort = options.port;
if (typeof rawPort === "string" || typeof rawPort === "number") {
return String(rawPort);
}
}
return defaultPortForWebSocketProtocol(fallbackProtocol);
}
function assertPinnedAgentAuthority(originalUrl: string, options: unknown): void {
const parsed = new URL(originalUrl);
const expectedHostname = normalizeAuthorityHostname(parsed.hostname);
const expectedPort = parsed.port || defaultPortForWebSocketProtocol(parsed.protocol);
const requestedHostname = hostnameFromAgentOptions(options);
const requestedPort = portFromAgentOptions(options, parsed.protocol);
if (
!requestedHostname ||
normalizeAuthorityHostname(requestedHostname) !== expectedHostname ||
requestedPort !== expectedPort
) {
throw new Error("CDP WebSocket redirect changed authority");
}
}
function createPinnedAgentForCdpUrl(
url: string,
lookup: CdpSocketLookup,
): http.Agent | https.Agent {
const parsed = new URL(url);
const options = { keepAlive: false, lookup };
const agent =
parsed.protocol === "https:" || parsed.protocol === "wss:"
? new https.Agent(options)
: new http.Agent(options);
const createConnection = agent.createConnection.bind(agent);
agent.createConnection = ((connectionOptions, callback) => {
try {
assertPinnedAgentAuthority(url, connectionOptions);
} catch (err) {
const socket = new net.Socket();
const error = err instanceof Error ? err : new Error(String(err));
process.nextTick(() => {
callback?.(error, socket);
socket.destroy(error);
});
return socket;
}
return createConnection(connectionOptions, callback);
}) as typeof agent.createConnection;
return agent;
}
function createCdpSender(ws: WebSocket, opts?: { commandTimeoutMs?: number }) {
let nextId = 1;
const pending = new Map<number, Pending>();
const commandTimeoutMs =
typeof opts?.commandTimeoutMs === "number" && Number.isFinite(opts.commandTimeoutMs)
? normalizeBrowserTimerDelayMs(opts.commandTimeoutMs)
: undefined;
const clearPendingTimer = (p: Pending) => {
if (p.timer !== undefined) {
clearTimeout(p.timer);
}
};
const send: CdpSendFn = (
method: string,
params?: Record<string, unknown>,
sessionId?: string,
) => {
const id = nextId++;
const msg = { id, method, params, sessionId };
return new Promise<unknown>((resolve, reject) => {
if (ws.readyState !== WebSocket.OPEN) {
reject(new Error("CDP socket closed"));
return;
}
const entry: Pending = { resolve, reject };
if (commandTimeoutMs !== undefined) {
// A timed-out command closes the whole socket so pending calls do not
// hang on a connection whose CDP command stream is no longer reliable.
entry.timer = setTimeout(() => {
closeWithError(new Error(`CDP command ${method} timed out after ${commandTimeoutMs}ms`));
}, commandTimeoutMs);
}
pending.set(id, entry);
try {
ws.send(JSON.stringify(msg));
} catch (err) {
pending.delete(id);
clearPendingTimer(entry);
reject(toStringifiedError(err));
}
});
};
const closeWithError = (err: Error) => {
for (const [, p] of pending) {
clearPendingTimer(p);
p.reject(err);
}
pending.clear();
ws.close();
};
ws.on("error", (err) => {
// The `err instanceof Error` guard is defensive: Node's `ws` library
// always emits Error instances on the 'error' event. Triggering the
// non-Error branch would require synthetically emitting on the socket,
// which the library treats as an unhandled error and hangs the test.
/* c8 ignore next */
closeWithError(toStringifiedError(err));
});
ws.on("message", (data) => {
try {
const parsed = JSON.parse(rawDataToString(data)) as CdpResponse;
if (typeof parsed.id !== "number") {
return;
}
const p = pending.get(parsed.id);
if (!p) {
return;
}
pending.delete(parsed.id);
clearPendingTimer(p);
if (parsed.error?.message) {
p.reject(new Error(parsed.error.message));
return;
}
p.resolve(parsed.result);
} catch {
// ignore
}
});
ws.on("close", () => {
closeWithError(new Error("CDP socket closed"));
});
return { send, closeWithError };
}
/** Open a CDP WebSocket with URL basic-auth and proxy bypass handling. */
export function openCdpWebSocket(
wsUrl: string,
opts?: {
headers?: Record<string, string>;
handshakeTimeoutMs?: number;
lookup?: CdpSocketLookup;
playwrightTransportDefaults?: boolean;
},
): WebSocket {
const headersWithAuth = getHeadersWithAuth(wsUrl, opts?.headers ?? {});
const headers = opts?.playwrightTransportDefaults
? withDefaultPlaywrightUserAgent(headersWithAuth)
: headersWithAuth;
const handshakeTimeoutMs =
typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs)
? Math.max(1, Math.floor(opts.handshakeTimeoutMs))
: CDP_WS_HANDSHAKE_TIMEOUT_MS;
const connectionUrl = stripCdpUrlCredentials(wsUrl);
const agent = opts?.lookup
? createPinnedAgentForCdpUrl(connectionUrl, opts.lookup)
: getDirectAgentForCdp(connectionUrl);
return withManagedProxyForCdpUrl(connectionUrl, () => {
const ws = new WebSocket(connectionUrl, {
handshakeTimeout: handshakeTimeoutMs,
...(opts?.playwrightTransportDefaults
? {
followRedirects: true,
maxRedirects: PLAYWRIGHT_CDP_MAX_REDIRECTS,
maxPayload: PLAYWRIGHT_CDP_MAX_PAYLOAD_BYTES,
perMessageDeflate: PLAYWRIGHT_CDP_PER_MESSAGE_DEFLATE,
}
: {}),
...(Object.keys(headers).length ? { headers } : {}),
...(agent ? { agent } : {}),
});
if (opts?.playwrightTransportDefaults) {
ws.on("redirect", (redirectedUrl, request) => {
assertSameAuthorityWebSocketRedirect(connectionUrl, redirectedUrl, request);
});
}
return ws;
});
}
type CdpSocketOptions = {
headers?: Record<string, string>;
handshakeTimeoutMs?: number;
commandTimeoutMs?: number;
handshakeRetries?: number;
handshakeRetryDelayMs?: number;
handshakeMaxRetryDelayMs?: number;
lookup?: CdpSocketLookup;
signal?: AbortSignal;
};
function normalizeRetryCount(value: number | undefined, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
return Math.max(0, Math.floor(value));
}
function computeHandshakeRetryDelayMs(attempt: number, opts?: CdpSocketOptions): number {
const baseDelayMs =
typeof opts?.handshakeRetryDelayMs === "number" && Number.isFinite(opts.handshakeRetryDelayMs)
? Math.max(1, Math.floor(opts.handshakeRetryDelayMs))
: 200;
const maxDelayMs =
typeof opts?.handshakeMaxRetryDelayMs === "number" &&
Number.isFinite(opts.handshakeMaxRetryDelayMs)
? Math.max(baseDelayMs, Math.floor(opts.handshakeMaxRetryDelayMs))
: 3000;
const raw = Math.min(maxDelayMs, baseDelayMs * 2 ** Math.max(0, attempt - 1));
// Jitter keeps several browser sessions from retrying handshakes in lockstep
// after a shared Chrome or network hiccup.
const jitterScale = 0.8 + Math.random() * 0.4;
return Math.max(1, Math.floor(raw * jitterScale));
}
function shouldRetryCdpHandshakeError(err: unknown): boolean {
if (!(err instanceof Error)) {
return false;
}
const msg = err.message.toLowerCase();
if (!msg) {
return false;
}
if (msg.includes("rate limit")) {
return false;
}
const statusMatch = msg.match(/(?:unexpected server response|response):\s*(\d{3})/);
if (statusMatch?.[1]) {
return Number(statusMatch[1]) >= 500;
}
return (
msg.includes("cdp socket closed") ||
msg.includes("econnreset") ||
msg.includes("econnrefused") ||
msg.includes("econnaborted") ||
msg.includes("ehostunreach") ||
msg.includes("enetunreach") ||
msg.includes("etimedout") ||
msg.includes("socket hang up") ||
msg.includes("websocket error") ||
msg.includes("closed before")
);
}
export async function withCdpSocket<T>(
wsUrl: string,
fn: (send: CdpSendFn) => Promise<T>,
opts?: CdpSocketOptions,
): Promise<T> {
const maxHandshakeRetries = normalizeRetryCount(opts?.handshakeRetries, 2);
for (let attempt = 0; ; attempt += 1) {
opts?.signal?.throwIfAborted();
const ws = openCdpWebSocket(wsUrl, opts);
const { send, closeWithError } = createCdpSender(ws, opts);
const openPromise = new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", (err) => reject(err));
ws.once("close", () => reject(new Error("CDP socket closed")));
});
// A stalled HTTP upgrade must release its TCP socket on cancellation.
const abortHandshake = () => ws.terminate();
opts?.signal?.addEventListener("abort", abortHandshake, { once: true });
if (opts?.signal?.aborted) {
abortHandshake();
}
try {
await openPromise;
} catch (err) {
// openPromise is only rejected via `ws.once('error', err => reject(err))`
// or the close event's `new Error(...)`; the former always carries an
// Error from Node's `ws` library, the latter is already an Error. The
// non-Error wrap is defensive and structurally unreachable.
/* c8 ignore next */
closeWithError(toStringifiedError(err));
// Cancellation on the final attempt must not become a handshake error.
opts?.signal?.throwIfAborted();
if (attempt >= maxHandshakeRetries || !shouldRetryCdpHandshakeError(err)) {
throw err;
}
// Retry only handshake failures. Once CDP commands are flowing, callers
// own retry semantics because commands may already have side effects.
// Cancelled route requests must not keep retrying Chrome handshakes.
await sleepWithAbort(computeHandshakeRetryDelayMs(attempt + 1, opts), opts?.signal).catch(
(error: unknown) => {
opts?.signal?.throwIfAborted();
throw error;
},
);
continue;
} finally {
opts?.signal?.removeEventListener("abort", abortHandshake);
}
try {
return await fn(send);
} catch (err) {
closeWithError(toStringifiedError(err));
throw err;
} finally {
ws.close();
}
}
}
@@ -1,5 +1,5 @@
// Browser tests cover cdp.helpers.internal plugin behavior.
import { createServer } from "node:http";
import http, { createServer } from "node:http";
import type { Socket } from "node:net";
import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -115,7 +115,10 @@ describe("cdp.helpers internal", () => {
assertCdpEndpointAllowed("http://93.184.216.34:443/cdp", {
allowPrivateNetwork: true,
}),
).resolves.toBeUndefined();
).resolves.toMatchObject({
addresses: ["93.184.216.34"],
hostname: "93.184.216.34",
});
});
});
@@ -256,6 +259,176 @@ describe("cdp.helpers internal", () => {
});
describe("createCdpSender (via withCdpSocket)", () => {
function pinnedLookupMock() {
return vi.fn((hostname: string, options: unknown, callback?: unknown) => {
const cb = typeof options === "function" ? options : callback;
if (typeof cb === "function") {
if (typeof options === "object" && options !== null && "all" in options) {
cb(null, [{ address: "127.0.0.1", family: 4 }]);
return undefined as never;
}
cb(null, "127.0.0.1", 4);
}
return undefined as never;
});
}
it("uses a per-connection agent for pinned WebSocket handshakes", async () => {
const server = await startWsServer();
wss = server.wss;
const lookup = pinnedLookupMock();
const globalCreateConnection = vi
.spyOn(http.globalAgent, "createConnection")
.mockImplementation(() => {
throw new Error("global agent must not be used for pinned CDP sockets");
});
server.wss.on("connection", (socket) => {
socket.close();
});
try {
const ws = openCdpWebSocket(`ws://cdp-pinned.test:${server.port}/devtools/browser/TEST`, {
lookup: lookup as never,
});
await new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", reject);
});
expect(lookup).toHaveBeenCalled();
expect(globalCreateConnection).not.toHaveBeenCalled();
ws.close();
} finally {
globalCreateConnection.mockRestore();
}
});
it.each([
{ playwrightTransportDefaults: false, expectedMaxPayload: 100 * 1024 * 1024 },
{ playwrightTransportDefaults: true, expectedMaxPayload: 256 * 1024 * 1024 },
])(
"uses the expected payload limit when Playwright transport defaults are $playwrightTransportDefaults",
async ({ playwrightTransportDefaults, expectedMaxPayload }) => {
const server = await startWsServer();
wss = server.wss;
const ws = openCdpWebSocket(server.url, { playwrightTransportDefaults });
try {
await new Promise<void>((resolve, reject) => {
ws.once("open", resolve);
ws.once("error", reject);
});
const receiver = Reflect.get(ws, "_receiver") as object | undefined;
const maxPayload = receiver ? Reflect.get(receiver, "_maxPayload") : undefined;
expect(maxPayload).toBe(expectedMaxPayload);
} finally {
ws.close();
}
},
);
it("preserves IPv6 hostnames in pinned WebSocket agent checks", async () => {
const server = new WebSocketServer({ port: 0, host: "::1" });
try {
await new Promise<void>((resolve, reject) => {
server.once("listening", () => resolve());
server.once("error", reject);
});
} catch {
return;
}
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("IPv6 test server did not expose a TCP port");
}
server.on("connection", (socket) => {
socket.close();
});
const lookup = vi.fn((_hostname: string, options: unknown, callback?: unknown) => {
const cb = typeof options === "function" ? options : callback;
if (typeof cb === "function") {
if (typeof options === "object" && options !== null && "all" in options) {
cb(null, [{ address: "::1", family: 6 }]);
return undefined as never;
}
cb(null, "::1", 6);
}
return undefined as never;
});
try {
const ws = openCdpWebSocket(`ws://[::1]:${address.port}/devtools/browser/TEST`, {
lookup: lookup as never,
});
await new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", reject);
});
ws.close();
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it("blocks pinned WebSocket redirects before connecting to a new authority", async () => {
const redirectServer = http.createServer();
const targetServer = http.createServer();
let targetConnections = 0;
targetServer.on("connection", () => {
targetConnections += 1;
});
await new Promise<void>((resolve) => {
targetServer.listen(0, "127.0.0.1", () => resolve());
});
const targetAddress = targetServer.address();
if (!targetAddress || typeof targetAddress === "string") {
throw new Error("target server did not expose a TCP port");
}
redirectServer.on("upgrade", (_request, socket) => {
socket.write(
`HTTP/1.1 302 Found\r\nLocation: ws://127.0.0.1:${targetAddress.port}/devtools/browser/redirected\r\nConnection: close\r\n\r\n`,
);
socket.destroy();
});
await new Promise<void>((resolve) => {
redirectServer.listen(0, "127.0.0.1", () => resolve());
});
const redirectAddress = redirectServer.address();
if (!redirectAddress || typeof redirectAddress === "string") {
throw new Error("redirect server did not expose a TCP port");
}
const ws = openCdpWebSocket(
`ws://cdp-pinned.test:${redirectAddress.port}/devtools/browser/start`,
{
lookup: pinnedLookupMock() as never,
playwrightTransportDefaults: true,
},
);
try {
const error = await new Promise<Error>((resolve, reject) => {
ws.once("open", () => reject(new Error("redirect unexpectedly opened")));
ws.once("error", (err) => resolve(err instanceof Error ? err : new Error(String(err))));
});
expect(error.message).toContain("CDP WebSocket redirect changed authority");
await new Promise<void>((resolve) => {
setTimeout(resolve, 25);
});
expect(targetConnections).toBe(0);
} finally {
ws.close();
await new Promise<void>((resolve) => {
redirectServer.close(() => {
targetServer.close(() => resolve());
});
});
}
});
it("ignores messages with a non-numeric id", async () => {
const server = await startWsServer();
wss = server.wss;
@@ -732,4 +905,80 @@ describe("openCdpWebSocket option handling", () => {
ws.once("error", () => {});
ws.close();
});
it("uses a pinned lookup for websocket connections", async () => {
const server = await startWsServer();
try {
const url = server.url.replace("127.0.0.1", "cdp.test.local");
const lookup = vi.fn((hostname: string, options: unknown, callback?: unknown) => {
const cb = typeof options === "function" ? options : callback;
expect(hostname).toBe("cdp.test.local");
if (typeof cb === "function") {
const wantsAll =
typeof options === "object" && options !== null && (options as { all?: boolean }).all;
if (wantsAll) {
cb(null, [{ address: "127.0.0.1", family: 4 }]);
return;
}
cb(null, "127.0.0.1", 4);
}
});
const ws = openCdpWebSocket(url, {
handshakeTimeoutMs: 500,
lookup: lookup as never,
});
await new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", reject);
});
expect(lookup).toHaveBeenCalled();
ws.close();
} finally {
await new Promise<void>((resolve) => {
server.wss.close(() => resolve());
});
}
});
it("forwards pinned lookup options through withCdpSocket", async () => {
const server = await startWsServer();
server.wss.on("connection", (socket) => {
socket.on("message", (data) => {
const msg = JSON.parse(rawDataToString(data)) as { id?: number };
socket.send(JSON.stringify({ id: msg.id, result: { ok: true } }));
});
});
try {
const url = server.url.replace("127.0.0.1", "cdp.test.local");
const lookup = vi.fn((hostname: string, options: unknown, callback?: unknown) => {
const cb = typeof options === "function" ? options : callback;
expect(hostname).toBe("cdp.test.local");
if (typeof cb === "function") {
const wantsAll =
typeof options === "object" && options !== null && (options as { all?: boolean }).all;
if (wantsAll) {
cb(null, [{ address: "127.0.0.1", family: 4 }]);
return;
}
cb(null, "127.0.0.1", 4);
}
});
const result = await withCdpSocket(url, async (send) => await send("Browser.getVersion"), {
handshakeTimeoutMs: 500,
handshakeRetries: 0,
lookup: lookup as never,
});
expect(result).toStrictEqual({ ok: true });
expect(lookup).toHaveBeenCalled();
} finally {
await new Promise<void>((resolve) => {
server.wss.close(() => resolve());
});
}
});
});
@@ -1,7 +1,11 @@
// Browser tests cover cdp.helpers plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js";
import type { LookupFn } from "../infra/net/ssrf.js";
import {
assertChromeMcpCdpTransportAllowed,
resolveCdpReachabilityPolicy,
} from "./cdp-reachability-policy.js";
import { resolveCdpReachabilityTimeouts } from "./cdp-timeouts.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { assertBrowserNavigationAllowed } from "./navigation-guard.js";
@@ -10,6 +14,11 @@ const PROFILE_HTTP_REACHABILITY_TIMEOUT_MS = 300;
const PROFILE_WS_REACHABILITY_MIN_TIMEOUT_MS = 200;
const PROFILE_WS_REACHABILITY_MAX_TIMEOUT_MS = 2000;
function createLookupFn(address: string): LookupFn {
const family = address.includes(":") ? 6 : 4;
return vi.fn(async () => [{ address, family }]) as unknown as LookupFn;
}
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
@@ -87,7 +96,9 @@ describe("cdp helpers", () => {
assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", {
dangerouslyAllowPrivateNetwork: false,
}),
).resolves.toBeUndefined();
).resolves.toEqual(
expect.objectContaining({ hostname: "127.0.0.1", lookup: expect.any(Function) }),
);
});
it("adds exact loopback hosts to the CDP hostname allowlist", async () => {
@@ -96,7 +107,9 @@ describe("cdp helpers", () => {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["*.corp.example"],
}),
).resolves.toBeUndefined();
).resolves.toEqual(
expect.objectContaining({ hostname: "127.0.0.1", lookup: expect.any(Function) }),
);
});
it("still enforces hostname allowlist for non-loopback CDP endpoints", async () => {
@@ -131,7 +144,9 @@ describe("cdp helpers", () => {
source: "discovered",
configuredUrl: "http://127.0.0.1:9222",
}),
).resolves.toBeUndefined();
).resolves.toEqual(
expect.objectContaining({ hostname: "127.0.0.1", lookup: expect.any(Function) }),
);
});
it("preserves broad private authority permission through exact-host scoping", async () => {
@@ -143,7 +158,45 @@ describe("cdp helpers", () => {
source: "discovered",
configuredUrl: "http://127.0.0.1:9222",
}),
).resolves.toBeUndefined();
).resolves.toEqual(
expect.objectContaining({ hostname: "127.0.0.1", lookup: expect.any(Function) }),
);
});
it("does not turn a strict remote CDP hostname into a private-network grant", async () => {
const policy = { dangerouslyAllowPrivateNetwork: false };
const scoped = scopeCdpPolicyToConfiguredEndpoint("https://browser.example:9222", policy);
const { resolvePinnedHostnameWithPolicy } =
await vi.importActual<typeof import("../infra/net/ssrf.js")>("../infra/net/ssrf.js");
expect(scoped).toBe(policy);
await expect(
resolvePinnedHostnameWithPolicy("browser.example", {
policy: scoped,
lookupFn: createLookupFn("10.0.0.8"),
}),
).rejects.toThrow(/private\/internal\/special-use ip address/i);
});
it("keeps explicit remote CDP hostname grants available", async () => {
const policy = {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["browser.example"],
};
const scoped = scopeCdpPolicyToConfiguredEndpoint("https://browser.example:9222", policy);
const { resolvePinnedHostnameWithPolicy } =
await vi.importActual<typeof import("../infra/net/ssrf.js")>("../infra/net/ssrf.js");
expect(scoped).toEqual({
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["browser.example"],
});
await expect(
resolvePinnedHostnameWithPolicy("browser.example", {
policy: scoped,
lookupFn: createLookupFn("10.0.0.8"),
}),
).resolves.toEqual(expect.objectContaining({ addresses: ["10.0.0.8"] }));
});
it("blocks a discovered endpoint on another port in strict SSRF mode", async () => {
@@ -161,7 +214,9 @@ describe("cdp helpers", () => {
assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", {
allowedHostnames: ["api.example.com"],
}),
).resolves.toBeUndefined();
).resolves.toEqual(
expect.objectContaining({ hostname: "127.0.0.1", lookup: expect.any(Function) }),
);
});
it("releases guarded CDP fetches for bodyless requests", async () => {
@@ -344,6 +399,27 @@ describe("cdp helpers", () => {
expect(release).toHaveBeenCalledTimes(1);
});
it("passes the default remote CDP policy object into guarded discovery fetches", async () => {
const release = vi.fn(async () => {});
const policy = {};
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: {
ok: true,
status: 200,
},
release,
});
await expect(
fetchOk("https://browserless.example:9222/json/version", 250, undefined, policy),
).resolves.toBeUndefined();
const request = requireGuardedFetchRequest();
expect(request?.url).toBe("https://browserless.example:9222/json/version");
expect(request?.policy).toBe(policy);
expect(release).toHaveBeenCalledOnce();
});
it("replaces navigation grants with the exact loopback CDP host", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
@@ -463,13 +539,11 @@ describe("resolveCdpReachabilityTimeouts", () => {
});
describe("CDP reachability policy", () => {
it("allows the selected remote profile CDP host without widening browser navigation policy", async () => {
it("keeps the default remote CDP policy strict without widening browser navigation policy", async () => {
const browserPolicy = {};
const profile = createProfile({});
expect(resolveCdpReachabilityPolicy(profile, browserPolicy)).toEqual({
allowedHostnames: ["172.29.128.1"],
});
expect(resolveCdpReachabilityPolicy(profile, browserPolicy)).toBe(browserPolicy);
expect(browserPolicy).toStrictEqual({});
await expect(
assertBrowserNavigationAllowed({
@@ -583,4 +657,115 @@ describe("CDP reachability policy", () => {
allowedHostnames: ["127.0.0.1"],
});
});
it.each([
["cdpUrl", { cdpUrl: "http://127.0.0.1:9222" }],
["--browserUrl", { cdpUrl: "", mcpArgs: ["--browserUrl", "http://127.0.0.1:9222"] }],
["-u", { cdpUrl: "", mcpArgs: ["-u", "http://127.0.0.1:9222"] }],
["--u", { cdpUrl: "", mcpArgs: ["--u", "http://127.0.0.1:9222"] }],
["--wsEndpoint", { cdpUrl: "", mcpArgs: ["--wsEndpoint=ws://127.0.0.1:9222"] }],
["-w", { cdpUrl: "", mcpArgs: ["-w", "ws://127.0.0.1:9222"] }],
["--w", { cdpUrl: "", mcpArgs: ["--w=ws://127.0.0.1:9222"] }],
])("rejects Chrome MCP explicit %s endpoints under the default policy", (_source, endpoint) => {
const profile = createProfile({
driver: "existing-session",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
...endpoint,
});
expect(() => assertChromeMcpCdpTransportAllowed(profile, {})).toThrow(
/cannot carry that pinned transport/i,
);
});
it("rejects Chrome MCP explicit CDP URL profiles after default CDP scoping", () => {
const profile = createProfile({
driver: "existing-session",
cdpUrl: "http://127.0.0.1:9222",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
});
const cdpPolicy = resolveCdpReachabilityPolicy(profile, {});
expect(cdpPolicy).toEqual({ allowedHostnames: ["127.0.0.1"] });
expect(() => assertChromeMcpCdpTransportAllowed(profile, cdpPolicy)).toThrow(
/cannot carry that pinned transport/i,
);
});
it("preserves Chrome MCP explicit CDP URL profiles when private CDP endpoints are trusted", () => {
const profile = createProfile({
driver: "existing-session",
cdpUrl: "http://127.0.0.1:9222",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
});
expect(() =>
assertChromeMcpCdpTransportAllowed(profile, { dangerouslyAllowPrivateNetwork: true }),
).not.toThrow();
});
it("rejects Chrome MCP explicit CDP URL profiles under explicit strict policy", () => {
const profile = createProfile({
driver: "existing-session",
cdpUrl: "http://127.0.0.1:9222",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
});
expect(() =>
assertChromeMcpCdpTransportAllowed(profile, { dangerouslyAllowPrivateNetwork: false }),
).toThrow(/cannot carry that pinned transport/i);
});
it("rejects Chrome MCP explicit CDP URL profiles after explicit strict CDP scoping", () => {
const profile = createProfile({
driver: "existing-session",
cdpUrl: "http://127.0.0.1:9222",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
});
const cdpPolicy = resolveCdpReachabilityPolicy(profile, {
dangerouslyAllowPrivateNetwork: false,
});
expect(cdpPolicy).toEqual({
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
});
expect(() => assertChromeMcpCdpTransportAllowed(profile, cdpPolicy)).toThrow(
/cannot carry that pinned transport/i,
);
});
it("rejects Chrome MCP explicit CDP URL profiles under endpoint allowlists", () => {
const profile = createProfile({
driver: "existing-session",
cdpUrl: "http://127.0.0.1:9222",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
});
expect(() =>
assertChromeMcpCdpTransportAllowed(profile, { allowedHostnames: ["127.0.0.1"] }),
).toThrow(/cannot carry that pinned transport/i);
});
it("does not let trusted private CDP policy override endpoint allowlists for Chrome MCP", () => {
const profile = createProfile({
driver: "existing-session",
cdpUrl: "http://127.0.0.1:9222",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
});
expect(() =>
assertChromeMcpCdpTransportAllowed(profile, {
dangerouslyAllowPrivateNetwork: true,
allowedHostnames: ["127.0.0.1"],
}),
).toThrow(/cannot carry that pinned transport/i);
});
});
+30 -324
View File
@@ -1,16 +1,13 @@
/**
* Chrome DevTools Protocol URL, fetch, and socket helpers.
*
* Handles CDP URL normalization, SSRF-guarded HTTP discovery, credential
* redaction/headers, and request/response correlation over WebSocket.
*/
import { createHash } from "node:crypto";
import { parseBrowserHttpUrl, redactCdpUrl } from "openclaw/plugin-sdk/browser-config";
import { toStringifiedError } from "openclaw/plugin-sdk/error-runtime";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";
import WebSocket from "ws";
import { isLoopbackHost } from "../gateway/net.js";
import {
SsrFBlockedError,
@@ -18,24 +15,27 @@ import {
resolvePinnedHostnameWithPolicy,
} from "../infra/net/ssrf.js";
import { redactToolPayloadText } from "../logging/redact.js";
import {
getDirectAgentForCdp,
withManagedProxyForCdpUrl,
withNoProxyForCdpUrl,
} from "./cdp-proxy-bypass.js";
import { CDP_HTTP_REQUEST_TIMEOUT_MS, CDP_WS_HANDSHAKE_TIMEOUT_MS } from "./cdp-timeouts.js";
import { getHeadersWithAuth, stripCdpUrlCredentials } from "./cdp-auth.js";
import { withManagedProxyForCdpUrl, withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js";
import { CDP_HTTP_REQUEST_TIMEOUT_MS } from "./cdp-timeouts.js";
import { withCdpSocket } from "./cdp-websocket.js";
import type { BrowserTabOwnership } from "./client.types.js";
import { BrowserCdpEndpointBlockedError } from "./errors.js";
import { resolveBrowserRateLimitMessage } from "./rate-limit-message.js";
import {
allowsDiscoveredCdpAuthorityChange,
isCdpHostnameTrustedByPolicy,
withExactHostnamePolicy,
} from "./ssrf-policy-helpers.js";
import { normalizeBrowserTimerDelayMs } from "./timer-delay.js";
const CDP_URL_IN_TEXT_RE = /\b(?:https?|wss?):\/\/[^\s"'<>`]+/gi;
export { isLoopbackHost, parseBrowserHttpUrl, redactCdpUrl };
export { isLoopbackHost };
export { getHeadersWithAuth, stripCdpUrlCredentials } from "./cdp-auth.js";
export { openCdpWebSocket, withCdpSocket } from "./cdp-websocket.js";
export type { CdpSendFn } from "./cdp-websocket.js";
export { parseBrowserHttpUrl, redactCdpUrl };
/**
* Returns true when the URL uses a WebSocket protocol (ws: or wss:).
@@ -82,7 +82,7 @@ export function isDirectCdpWebSocketEndpoint(url: string): boolean {
/* c8 ignore stop */
}
/** Restricts discovered CDP endpoints to the configured control-plane host. */
/** Restrict a trusted CDP endpoint to its configured control-plane host. */
export function scopeCdpPolicyToConfiguredEndpoint(
cdpUrl: string,
ssrfPolicy?: SsrFPolicy,
@@ -90,12 +90,18 @@ export function scopeCdpPolicyToConfiguredEndpoint(
if (!ssrfPolicy) {
return undefined;
}
return withExactHostnamePolicy(ssrfPolicy, new URL(cdpUrl).hostname);
const hostname = new URL(cdpUrl).hostname;
// Never turn an otherwise strict remote hostname into a private-network grant.
if (!isLoopbackHost(hostname) && !isCdpHostnameTrustedByPolicy(ssrfPolicy, hostname)) {
return ssrfPolicy;
}
return withExactHostnamePolicy(ssrfPolicy, hostname);
}
type CdpEndpointSource =
| { source?: "configured" }
| { source: "discovered"; configuredUrl: string };
type CdpEndpointPin = Awaited<ReturnType<typeof resolvePinnedHostnameWithPolicy>>;
function cdpEndpointAuthority(url: string): string {
const parsed = new URL(url);
@@ -124,12 +130,12 @@ export async function assertCdpEndpointAllowed(
cdpUrl: string,
ssrfPolicy?: SsrFPolicy,
options?: CdpEndpointSource,
): Promise<void> {
): Promise<CdpEndpointPin | undefined> {
if (options?.source === "discovered") {
assertDiscoveredCdpEndpointMatchesConfigured(cdpUrl, options.configuredUrl, ssrfPolicy);
}
if (!ssrfPolicy) {
return;
return undefined;
}
const parsed = new URL(cdpUrl);
if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) {
@@ -143,7 +149,7 @@ export async function assertCdpEndpointAllowed(
isLoopbackHost(parsed.hostname) && options?.source !== "discovered"
? withExactHostnamePolicy(ssrfPolicy, parsed.hostname)
: ssrfPolicy;
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
return await resolvePinnedHostnameWithPolicy(parsed.hostname, {
policy,
});
} catch (error) {
@@ -151,70 +157,6 @@ export async function assertCdpEndpointAllowed(
}
}
type CdpResponse = {
id: number;
result?: unknown;
error?: { message?: string };
};
type Pending = {
resolve: (value: unknown) => void;
reject: (err: Error) => void;
timer?: ReturnType<typeof setTimeout>;
};
export type CdpSendFn = (
method: string,
params?: Record<string, unknown>,
sessionId?: string,
) => Promise<unknown>;
function decodeUrlUserInfo(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
/** Merge URL basic-auth credentials into headers without overriding explicit auth. */
export function getHeadersWithAuth(url: string, headers: Record<string, string> = {}) {
const mergedHeaders = { ...headers };
try {
const parsed = new URL(url);
const hasAuthHeader = Object.keys(mergedHeaders).some(
(key) => key.trim().toLowerCase() === "authorization",
);
if (hasAuthHeader) {
return mergedHeaders;
}
if (parsed.username || parsed.password) {
const username = decodeUrlUserInfo(parsed.username);
const password = decodeUrlUserInfo(parsed.password);
const auth = Buffer.from(`${username}:${password}`).toString("base64");
return { ...mergedHeaders, Authorization: `Basic ${auth}` };
}
} catch {
// ignore
}
return mergedHeaders;
}
/** Remove URL userinfo after callers have converted it to an Authorization header. */
export function stripCdpUrlCredentials(url: string): string {
try {
const parsed = new URL(url);
if (!parsed.username && !parsed.password) {
return url;
}
parsed.username = "";
parsed.password = "";
return parsed.toString();
} catch {
return url;
}
}
/** Redact CDP URLs and credential-shaped text before dependency errors leave Browser. */
export function redactCdpErrorText(text: string): string {
const redactedUrls = text.replace(CDP_URL_IN_TEXT_RE, (match) => redactCdpUrl(match) ?? match);
@@ -321,9 +263,11 @@ type CdpTabOwnershipParams = {
ssrfPolicy?: SsrFPolicy;
};
async function resolveCdpTabOwnershipContext(
params: CdpTabOwnershipParams,
): Promise<{ ownership: BrowserTabOwnership; browserWebSocketUrl?: string }> {
async function resolveCdpTabOwnershipContext(params: CdpTabOwnershipParams): Promise<{
ownership: BrowserTabOwnership;
browserWebSocketUrl?: string;
browserWebSocketLookup?: CdpEndpointPin["lookup"];
}> {
params.signal?.throwIfAborted();
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(params.cdpUrl);
let version: { webSocketDebuggerUrl?: unknown };
@@ -352,7 +296,7 @@ async function resolveCdpTabOwnershipContext(
return { ownership: { status: "non-durable", reason: "browser-identity-unavailable" } };
}
try {
await assertCdpEndpointAllowed(browserWebSocketUrl, params.ssrfPolicy, {
const pinned = await assertCdpEndpointAllowed(browserWebSocketUrl, params.ssrfPolicy, {
source: "discovered",
configuredUrl: params.cdpUrl,
});
@@ -367,6 +311,7 @@ async function resolveCdpTabOwnershipContext(
}),
},
browserWebSocketUrl,
browserWebSocketLookup: pinned?.lookup,
};
} catch (error) {
if (error instanceof BrowserCdpEndpointBlockedError) {
@@ -470,6 +415,7 @@ export async function closeTrackedCdpTarget(
commandTimeoutMs: params.timeoutMs,
handshakeTimeoutMs: params.timeoutMs,
handshakeRetries: 0,
lookup: resolved.browserWebSocketLookup,
},
);
} catch (error) {
@@ -488,98 +434,6 @@ type CdpFetchResult = {
release: () => Promise<void>;
};
function createCdpSender(ws: WebSocket, opts?: { commandTimeoutMs?: number }) {
let nextId = 1;
const pending = new Map<number, Pending>();
const commandTimeoutMs =
typeof opts?.commandTimeoutMs === "number" && Number.isFinite(opts.commandTimeoutMs)
? normalizeBrowserTimerDelayMs(opts.commandTimeoutMs)
: undefined;
const clearPendingTimer = (p: Pending) => {
if (p.timer !== undefined) {
clearTimeout(p.timer);
}
};
const send: CdpSendFn = (
method: string,
params?: Record<string, unknown>,
sessionId?: string,
) => {
const id = nextId++;
const msg = { id, method, params, sessionId };
return new Promise<unknown>((resolve, reject) => {
if (ws.readyState !== WebSocket.OPEN) {
reject(new Error("CDP socket closed"));
return;
}
const entry: Pending = { resolve, reject };
if (commandTimeoutMs !== undefined) {
// A timed-out command closes the whole socket so pending calls do not
// hang on a connection whose CDP command stream is no longer reliable.
entry.timer = setTimeout(() => {
closeWithError(new Error(`CDP command ${method} timed out after ${commandTimeoutMs}ms`));
}, commandTimeoutMs);
}
pending.set(id, entry);
try {
ws.send(JSON.stringify(msg));
} catch (err) {
pending.delete(id);
clearPendingTimer(entry);
reject(toStringifiedError(err));
}
});
};
const closeWithError = (err: Error) => {
for (const [, p] of pending) {
clearPendingTimer(p);
p.reject(err);
}
pending.clear();
ws.close();
};
ws.on("error", (err) => {
// The `err instanceof Error` guard is defensive: Node's `ws` library
// always emits Error instances on the 'error' event. Triggering the
// non-Error branch would require synthetically emitting on the socket,
// which the library treats as an unhandled error and hangs the test.
/* c8 ignore next */
closeWithError(toStringifiedError(err));
});
ws.on("message", (data) => {
try {
const parsed = JSON.parse(rawDataToString(data)) as CdpResponse;
if (typeof parsed.id !== "number") {
return;
}
const p = pending.get(parsed.id);
if (!p) {
return;
}
pending.delete(parsed.id);
clearPendingTimer(p);
if (parsed.error?.message) {
p.reject(new Error(parsed.error.message));
return;
}
p.resolve(parsed.result);
} catch {
// ignore
}
});
ws.on("close", () => {
closeWithError(new Error("CDP socket closed"));
});
return { send, closeWithError };
}
/** Fetch and parse a CDP JSON endpoint through the configured SSRF guard. */
export async function fetchJson<T>(
url: string,
@@ -679,151 +533,3 @@ export async function fetchOk(
const { release } = await fetchCdpChecked(url, timeoutMs, init, ssrfPolicy);
await release();
}
/** Open a CDP WebSocket with URL basic-auth and proxy bypass handling. */
export function openCdpWebSocket(
wsUrl: string,
opts?: { headers?: Record<string, string>; handshakeTimeoutMs?: number },
): WebSocket {
const headers = getHeadersWithAuth(wsUrl, opts?.headers ?? {});
const handshakeTimeoutMs =
typeof opts?.handshakeTimeoutMs === "number" && Number.isFinite(opts.handshakeTimeoutMs)
? Math.max(1, Math.floor(opts.handshakeTimeoutMs))
: CDP_WS_HANDSHAKE_TIMEOUT_MS;
const connectionUrl = stripCdpUrlCredentials(wsUrl);
const agent = getDirectAgentForCdp(connectionUrl);
return withManagedProxyForCdpUrl(
connectionUrl,
() =>
new WebSocket(connectionUrl, {
handshakeTimeout: handshakeTimeoutMs,
...(Object.keys(headers).length ? { headers } : {}),
...(agent ? { agent } : {}),
}),
);
}
type CdpSocketOptions = {
headers?: Record<string, string>;
handshakeTimeoutMs?: number;
commandTimeoutMs?: number;
handshakeRetries?: number;
handshakeRetryDelayMs?: number;
handshakeMaxRetryDelayMs?: number;
signal?: AbortSignal;
};
function normalizeRetryCount(value: number | undefined, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
return Math.max(0, Math.floor(value));
}
function computeHandshakeRetryDelayMs(attempt: number, opts?: CdpSocketOptions): number {
const baseDelayMs =
typeof opts?.handshakeRetryDelayMs === "number" && Number.isFinite(opts.handshakeRetryDelayMs)
? Math.max(1, Math.floor(opts.handshakeRetryDelayMs))
: 200;
const maxDelayMs =
typeof opts?.handshakeMaxRetryDelayMs === "number" &&
Number.isFinite(opts.handshakeMaxRetryDelayMs)
? Math.max(baseDelayMs, Math.floor(opts.handshakeMaxRetryDelayMs))
: 3000;
const raw = Math.min(maxDelayMs, baseDelayMs * 2 ** Math.max(0, attempt - 1));
// Jitter keeps several browser sessions from retrying handshakes in lockstep
// after a shared Chrome or network hiccup.
const jitterScale = 0.8 + Math.random() * 0.4;
return Math.max(1, Math.floor(raw * jitterScale));
}
function shouldRetryCdpHandshakeError(err: unknown): boolean {
if (!(err instanceof Error)) {
return false;
}
const msg = err.message.toLowerCase();
if (!msg) {
return false;
}
if (msg.includes("rate limit")) {
return false;
}
const statusMatch = msg.match(/(?:unexpected server response|response):\s*(\d{3})/);
if (statusMatch?.[1]) {
return Number(statusMatch[1]) >= 500;
}
return (
msg.includes("cdp socket closed") ||
msg.includes("econnreset") ||
msg.includes("econnrefused") ||
msg.includes("econnaborted") ||
msg.includes("ehostunreach") ||
msg.includes("enetunreach") ||
msg.includes("etimedout") ||
msg.includes("socket hang up") ||
msg.includes("websocket error") ||
msg.includes("closed before")
);
}
export async function withCdpSocket<T>(
wsUrl: string,
fn: (send: CdpSendFn) => Promise<T>,
opts?: CdpSocketOptions,
): Promise<T> {
const maxHandshakeRetries = normalizeRetryCount(opts?.handshakeRetries, 2);
for (let attempt = 0; ; attempt += 1) {
opts?.signal?.throwIfAborted();
const ws = openCdpWebSocket(wsUrl, opts);
const { send, closeWithError } = createCdpSender(ws, opts);
const openPromise = new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", (err) => reject(err));
ws.once("close", () => reject(new Error("CDP socket closed")));
});
// A stalled HTTP upgrade must release its TCP socket on cancellation.
const abortHandshake = () => ws.terminate();
opts?.signal?.addEventListener("abort", abortHandshake, { once: true });
if (opts?.signal?.aborted) {
abortHandshake();
}
try {
await openPromise;
} catch (err) {
// openPromise is only rejected via `ws.once('error', err => reject(err))`
// or the close event's `new Error(...)`; the former always carries an
// Error from Node's `ws` library, the latter is already an Error. The
// non-Error wrap is defensive and structurally unreachable.
/* c8 ignore next */
closeWithError(toStringifiedError(err));
// Cancellation on the final attempt must not become a handshake error.
opts?.signal?.throwIfAborted();
if (attempt >= maxHandshakeRetries || !shouldRetryCdpHandshakeError(err)) {
throw err;
}
// Retry only handshake failures. Once CDP commands are flowing, callers
// own retry semantics because commands may already have side effects.
// Cancelled route requests must not keep retrying Chrome handshakes.
await sleepWithAbort(computeHandshakeRetryDelayMs(attempt + 1, opts), opts?.signal).catch(
(error: unknown) => {
opts?.signal?.throwIfAborted();
throw error;
},
);
continue;
} finally {
opts?.signal?.removeEventListener("abort", abortHandshake);
}
try {
return await fn(send);
} catch (err) {
closeWithError(toStringifiedError(err));
throw err;
} finally {
ws.close();
}
}
}
+15 -6
View File
@@ -1,3 +1,4 @@
import type { lookup as dnsLookupCb } from "node:dns";
/**
* Chrome DevTools Protocol browser operations.
*
@@ -40,12 +41,13 @@ export { type CdpActionTimeouts, waitForCdpCommittedNavigationUrl } from "./cdp-
/** Read the current main-frame loader identity from a page-level CDP target. */
export async function getMainFrameDocumentIdentityViaCdp(opts: {
wsUrl: string;
lookup?: typeof dnsLookupCb;
timeoutMs?: number;
}): Promise<string | undefined> {
return await withCdpSocket(
opts.wsUrl,
async (send) => await readCdpMainFrameDocumentIdentity(send),
{ commandTimeoutMs: opts.timeoutMs ?? 5000 },
{ commandTimeoutMs: opts.timeoutMs ?? 5000, ...(opts.lookup ? { lookup: opts.lookup } : {}) },
);
}
@@ -92,6 +94,7 @@ export function normalizeCdpWsUrl(wsUrl: string, cdpUrl: string): string {
/** Capture a PNG or JPEG screenshot through CDP, optionally full-page. */
export async function captureScreenshot(opts: {
wsUrl: string;
lookup?: typeof dnsLookupCb;
fullPage?: boolean;
format?: "png" | "jpeg";
quality?: number; // jpeg only (0..100)
@@ -202,7 +205,7 @@ export async function captureScreenshot(opts: {
}
}
},
{ commandTimeoutMs: opts.timeoutMs },
{ commandTimeoutMs: opts.timeoutMs, lookup: opts.lookup },
);
}
@@ -221,7 +224,7 @@ export async function createTargetViaCdp(opts: {
url: opts.url,
...withBrowserNavigationPolicy(opts.ssrfPolicy),
});
await assertCdpEndpointAllowed(opts.cdpUrl, opts.ssrfPolicy);
const configuredCdpPin = await assertCdpEndpointAllowed(opts.cdpUrl, opts.ssrfPolicy);
const cdpControlPolicy = scopeCdpPolicyToConfiguredEndpoint(opts.cdpUrl, opts.ssrfPolicy);
let wsUrl: string;
@@ -274,7 +277,10 @@ export async function createTargetViaCdp(opts: {
candidateWsUrl === opts.cdpUrl
? ({ source: "configured" } as const)
: ({ source: "discovered", configuredUrl: opts.cdpUrl } as const);
await assertCdpEndpointAllowed(candidateWsUrl, cdpControlPolicy, endpointSource);
const candidateCdpPin =
candidateWsUrl === opts.cdpUrl
? configuredCdpPin
: await assertCdpEndpointAllowed(candidateWsUrl, cdpControlPolicy, endpointSource);
opts.signal?.throwIfAborted();
return await withCdpSocket(
candidateWsUrl,
@@ -299,6 +305,7 @@ export async function createTargetViaCdp(opts: {
{
commandTimeoutMs: opts.timeouts?.httpTimeoutMs ?? 5000,
handshakeTimeoutMs: opts.timeouts?.handshakeTimeoutMs,
lookup: candidateCdpPin?.lookup,
},
);
} catch (err) {
@@ -424,6 +431,7 @@ export function formatAriaSnapshot(nodes: RawAXNode[], limit: number): AriaSnaps
/** Capture an accessibility-tree snapshot through CDP. */
export async function snapshotAria(opts: {
wsUrl: string;
lookup?: typeof dnsLookupCb;
limit?: number;
timeoutMs?: number;
}): Promise<{ nodes: AriaSnapshotNode[] }> {
@@ -438,7 +446,7 @@ export async function snapshotAria(opts: {
const nodes = Array.isArray(res?.nodes) ? res.nodes : [];
return { nodes: formatAriaSnapshot(nodes, limit) };
},
{ commandTimeoutMs: opts.timeoutMs ?? 5000 },
{ commandTimeoutMs: opts.timeoutMs ?? 5000, lookup: opts.lookup },
);
}
@@ -917,6 +925,7 @@ async function buildCdpRoleSnapshot(params: {
/** Build a role/name text snapshot with stable refs from CDP DOM and AX data. */
export async function snapshotRoleViaCdp(opts: {
wsUrl: string;
lookup?: typeof dnsLookupCb;
options?: CdpRoleSnapshotOptions;
urls?: boolean;
timeoutMs?: number;
@@ -955,7 +964,7 @@ export async function snapshotRoleViaCdp(opts: {
? { ...finalized, truncated: true }
: finalized;
},
{ commandTimeoutMs: opts.timeoutMs ?? 5000 },
{ commandTimeoutMs: opts.timeoutMs ?? 5000, lookup: opts.lookup },
);
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
@@ -169,14 +169,20 @@ export const DEFAULT_CHROME_MCP_FEATURE_ARGS = [
"--experimental-page-id-routing",
];
export const CHROME_MCP_USAGE_STATISTICS_FLAG_RE = /^--(?:no-)?usage-?statistics(?:=.*)?$/i;
export const CHROME_MCP_CONNECTION_FLAGS = new Set([
"--autoConnect",
"--auto-connect",
export const CHROME_MCP_ENDPOINT_FLAGS = new Set([
"--browserUrl",
"--browser-url",
"-u",
"--u",
"--wsEndpoint",
"--ws-endpoint",
"-w",
"--w",
]);
export const CHROME_MCP_CONNECTION_FLAGS = new Set([
"--autoConnect",
"--auto-connect",
...CHROME_MCP_ENDPOINT_FLAGS,
]);
export const CHROME_MCP_USER_DATA_DIR_FLAGS = new Set(["--userDataDir", "--user-data-dir"]);
export const CHROME_MCP_NEW_PAGE_TIMEOUT_MS = 5_000;
@@ -6,6 +6,7 @@ import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { createOpenClawTestState } from "openclaw/plugin-sdk/test-state";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildChromeMcpArgsFromOptions, normalizeChromeMcpOptions } from "./chrome-mcp-options.js";
import {
ChromeMcpDocumentUnavailableError,
clickChromeMcpCoords,
@@ -243,6 +244,26 @@ describe("chrome MCP page parsing", () => {
vi.unstubAllEnvs();
});
it("passes HTTP CDP endpoints to Chrome MCP as browserUrl discovery endpoints", () => {
const args = buildChromeMcpArgsFromOptions(
normalizeChromeMcpOptions({ cdpUrl: "http://127.0.0.1:9222" }),
);
expect(args).toContain("--browserUrl");
expect(args).toContain("http://127.0.0.1:9222");
expect(args).not.toContain("--wsEndpoint");
});
it("passes direct WebSocket CDP endpoints to Chrome MCP as wsEndpoint attachments", () => {
const args = buildChromeMcpArgsFromOptions(
normalizeChromeMcpOptions({ cdpUrl: "ws://127.0.0.1:9222/devtools/browser/abc" }),
);
expect(args).toContain("--wsEndpoint");
expect(args).toContain("ws://127.0.0.1:9222/devtools/browser/abc");
expect(args).not.toContain("--browserUrl");
});
it("keeps document-bound evaluations on one pinned target and raw snapshot uid", async () => {
const session = createPageSession({
pid: 139,
@@ -20,11 +20,12 @@ import {
openCdpWebSocket,
redactCdpUrl,
scopeCdpPolicyToConfiguredEndpoint,
stripCdpUrlCredentials,
} from "./cdp.helpers.js";
import { normalizeCdpWsUrl } from "./cdp.js";
import { BrowserCdpEndpointBlockedError } from "./errors.js";
type ChromeCdpEndpointPin = NonNullable<Awaited<ReturnType<typeof assertCdpEndpointAllowed>>>;
/** Machine-readable failure codes for Chrome CDP diagnostics. */
type ChromeCdpDiagnosticCode =
| "ssrf_blocked"
@@ -127,7 +128,7 @@ async function readChromeVersion(
}
}
/** Preserve authenticated providers that expose only Playwright's trailing-slash route. */
/** Preserve providers that expose only Playwright's trailing-slash route. */
export async function readChromeVersionWithCredentialFallback(
cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
@@ -135,10 +136,7 @@ export async function readChromeVersionWithCredentialFallback(
): Promise<ChromeVersion> {
try {
const primaryVersion = await readChromeVersion(cdpUrl, timeoutMs, ssrfPolicy);
if (
normalizeOptionalString(primaryVersion.webSocketDebuggerUrl) ||
stripCdpUrlCredentials(cdpUrl) === cdpUrl
) {
if (normalizeOptionalString(primaryVersion.webSocketDebuggerUrl)) {
return primaryVersion;
}
try {
@@ -147,9 +145,6 @@ export async function readChromeVersionWithCredentialFallback(
return primaryVersion;
}
} catch (primaryError) {
if (stripCdpUrlCredentials(cdpUrl) === cdpUrl) {
throw primaryError;
}
try {
return await readChromeVersion(cdpUrl, timeoutMs, ssrfPolicy, "/json/version/");
} catch {
@@ -191,10 +186,12 @@ function chromeVersionFromCdpResult(result: unknown): ChromeVersion | undefined
async function diagnoseCdpHealthCommand(
wsUrl: string,
timeoutMs = CHROME_WS_READY_TIMEOUT_MS,
lookup?: ChromeCdpEndpointPin["lookup"],
): Promise<CdpHealthDiagnostic> {
return await new Promise<CdpHealthDiagnostic>((resolve) => {
const ws = openCdpWebSocket(wsUrl, {
handshakeTimeoutMs: timeoutMs,
lookup,
});
let settled = false;
let opened = false;
@@ -343,9 +340,14 @@ async function diagnoseCdpWebSocketEndpoint(params: {
wsUrl: string;
startedAt: number;
handshakeTimeoutMs: number;
lookup?: ChromeCdpEndpointPin["lookup"];
version?: ChromeVersion;
}): Promise<ChromeCdpDiagnostic> {
const health = await diagnoseCdpHealthCommand(params.wsUrl, params.handshakeTimeoutMs);
const health = await diagnoseCdpHealthCommand(
params.wsUrl,
params.handshakeTimeoutMs,
params.lookup,
);
if (!health.ok) {
return failureDiagnostic({
cdpUrl: params.cdpUrl,
@@ -373,8 +375,9 @@ export async function diagnoseChromeCdp(
ssrfPolicy?: SsrFPolicy,
): Promise<ChromeCdpDiagnostic> {
const startedAt = Date.now();
let configuredPin: ChromeCdpEndpointPin | undefined;
try {
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
configuredPin = await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
} catch (err) {
return failureDiagnostic({
cdpUrl,
@@ -391,6 +394,7 @@ export async function diagnoseChromeCdp(
wsUrl: cdpUrl,
startedAt,
handshakeTimeoutMs,
lookup: configuredPin?.lookup,
});
}
@@ -411,6 +415,7 @@ export async function diagnoseChromeCdp(
wsUrl: cdpUrl,
startedAt,
handshakeTimeoutMs,
lookup: configuredPin?.lookup,
});
}
const classified = classifyChromeVersionError(err);
@@ -430,6 +435,7 @@ export async function diagnoseChromeCdp(
wsUrl: cdpUrl,
startedAt,
handshakeTimeoutMs,
lookup: configuredPin?.lookup,
version,
});
}
@@ -441,8 +447,9 @@ export async function diagnoseChromeCdp(
});
}
const wsUrl = normalizeCdpWsUrl(wsUrlRaw, discoveryUrl);
let discoveredPin: ChromeCdpEndpointPin | undefined;
try {
await assertCdpEndpointAllowed(wsUrl, cdpControlPolicy, {
discoveredPin = await assertCdpEndpointAllowed(wsUrl, cdpControlPolicy, {
source: "discovered",
configuredUrl: cdpUrl,
});
@@ -456,10 +463,14 @@ export async function diagnoseChromeCdp(
});
}
const health = await diagnoseCdpHealthCommand(wsUrl, handshakeTimeoutMs);
const health = await diagnoseCdpHealthCommand(wsUrl, handshakeTimeoutMs, discoveredPin?.lookup);
if (!health.ok) {
if (isWebSocketUrl(cdpUrl) && wsUrl !== cdpUrl) {
const directHealth = await diagnoseCdpHealthCommand(cdpUrl, handshakeTimeoutMs);
const directHealth = await diagnoseCdpHealthCommand(
cdpUrl,
handshakeTimeoutMs,
configuredPin?.lookup,
);
if (directHealth.ok) {
return {
ok: true,
@@ -13,7 +13,7 @@ import {
*/
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { redactCdpErrorText, withCdpSocket } from "./cdp.helpers.js";
import { getChromeWebSocketUrl, type RunningChrome } from "./chrome.js";
import { getChromeWebSocketEndpoint, type RunningChrome } from "./chrome.js";
import type {
BrowserGraphicsAcceleration,
BrowserGraphicsDevice,
@@ -174,19 +174,28 @@ export async function inspectChromeGraphicsDiagnostics(
): Promise<BrowserGraphicsDiagnostics> {
const observedAt = Date.now();
try {
const wsUrl = await getChromeWebSocketUrl(cdpUrl, options.httpTimeoutMs, options.ssrfPolicy);
if (!wsUrl) {
const endpoint = await getChromeWebSocketEndpoint(
cdpUrl,
options.httpTimeoutMs,
options.ssrfPolicy,
);
if (!endpoint) {
return {
status: "unavailable",
observedAt,
reason: "browser-level CDP WebSocket was not advertised",
};
}
const result = await withCdpSocket(wsUrl, async (send) => await send("SystemInfo.getInfo"), {
handshakeTimeoutMs: options.handshakeTimeoutMs,
commandTimeoutMs: options.commandTimeoutMs,
handshakeRetries: 0,
});
const result = await withCdpSocket(
endpoint.url,
async (send) => await send("SystemInfo.getInfo"),
{
handshakeTimeoutMs: options.handshakeTimeoutMs,
commandTimeoutMs: options.commandTimeoutMs,
handshakeRetries: 0,
lookup: endpoint.lookup,
},
);
return normalizeChromeGraphicsInfo(result, observedAt);
} catch (error) {
return {
@@ -60,7 +60,7 @@ vi.mock("./cdp-timeouts.js", async () => {
import { CHROME_STDERR_HINT_MAX_CHARS } from "./cdp-timeouts.js";
import {
getChromeWebSocketUrl,
getChromeWebSocketEndpoint,
isChromeCdpReady,
isChromeReachable,
launchOpenClawChrome,
@@ -72,6 +72,12 @@ import { BROWSER_ERROR_REASONS, BrowserProfileUnavailableError } from "./errors.
const CHROME_TEST_WS_MAX_PAYLOAD_BYTES = 1024 * 1024;
async function getChromeWebSocketUrl(
...args: Parameters<typeof getChromeWebSocketEndpoint>
): Promise<string | null> {
return (await getChromeWebSocketEndpoint(...args))?.url ?? null;
}
/**
* Covers the parts of chrome.ts that the mainline chrome.test.ts does
* not exercise: launchOpenClawChrome (with child_process.spawn mocked),
@@ -2,7 +2,7 @@
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import { getChromeWebSocketUrl, isChromeReachable } from "./chrome.js";
import { getChromeWebSocketEndpoint, isChromeReachable } from "./chrome.js";
type RunningServer = {
server: Server;
@@ -62,8 +62,8 @@ describe("chrome loopback SSRF integration", () => {
it("returns the loopback websocket URL under strict default SSRF policy", async () => {
const { baseUrl } = await startLoopbackCdpServer();
await expect(getChromeWebSocketUrl(baseUrl, 500, {})).resolves.toMatch(
/\/devtools\/browser\/TEST$/,
);
await expect(
getChromeWebSocketEndpoint(baseUrl, 500, {}).then((endpoint) => endpoint?.url ?? null),
).resolves.toMatch(/\/devtools\/browser\/TEST$/);
});
});
+46 -1
View File
@@ -13,7 +13,7 @@ import {
resolveGoogleChromeExecutableForPlatform,
} from "./chrome.executables.js";
import {
getChromeWebSocketUrl,
getChromeWebSocketEndpoint,
isChromeCdpOwnedByPid,
isChromeCdpReady,
isChromeReachable,
@@ -52,6 +52,12 @@ function jsonResponse(payload: unknown, status = 200): Response {
});
}
async function getChromeWebSocketUrl(
...args: Parameters<typeof getChromeWebSocketEndpoint>
): Promise<string | null> {
return (await getChromeWebSocketEndpoint(...args))?.url ?? null;
}
async function withMockChromeCdpServer(params: {
wsPath: string;
onConnection?: (wss: WebSocketServer) => void;
@@ -289,6 +295,45 @@ describe("browser chrome helpers", () => {
}
});
it("keeps trailing-slash discovery inside the guarded fetch path for HTTP endpoints", async () => {
const requests: string[] = [];
const server = createServer((req, res) => {
requests.push(req.url ?? "");
if (req.url === "/json/version/") {
const addr = server.address() as AddressInfo;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
webSocketDebuggerUrl: `ws://127.0.0.1:${addr.port}/devtools/browser/trailing`,
}),
);
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>((resolve, reject) => {
server.listen(0, "127.0.0.1", () => resolve());
server.once("error", reject);
});
try {
const addr = server.address() as AddressInfo;
await expect(
getChromeWebSocketUrl(`http://127.0.0.1:${addr.port}`, 1000, {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
}),
).resolves.toBe(`ws://127.0.0.1:${addr.port}/devtools/browser/trailing`);
expect(requests).toEqual(["/json/version", "/json/version/"]);
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it("reports cdpReady only when Browser.getVersion command succeeds", async () => {
await withMockChromeCdpServer({
wsPath: "/devtools/browser/health",
+32 -19
View File
@@ -849,9 +849,20 @@ function buildOpenClawChromeLaunchArgs(params: {
return args;
}
async function canOpenWebSocket(url: string, timeoutMs: number): Promise<boolean> {
type ChromeCdpEndpointPin = NonNullable<Awaited<ReturnType<typeof assertCdpEndpointAllowed>>>;
export type ChromeWebSocketEndpoint = {
url: string;
lookup?: ChromeCdpEndpointPin["lookup"];
};
async function canOpenWebSocket(
url: string,
timeoutMs: number,
lookup?: ChromeCdpEndpointPin["lookup"],
): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const ws = openCdpWebSocket(url, { handshakeTimeoutMs: timeoutMs });
const ws = openCdpWebSocket(url, { handshakeTimeoutMs: timeoutMs, lookup });
ws.once("open", () => {
ws.close();
resolve(true);
@@ -868,10 +879,10 @@ export async function isChromeReachable(
ssrfPolicy?: SsrFPolicy,
): Promise<boolean> {
try {
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
const configuredPin = await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
if (isDirectCdpWebSocketEndpoint(cdpUrl)) {
// Handshake-ready direct WS endpoint — probe via WS handshake.
return await canOpenWebSocket(cdpUrl, timeoutMs);
return await canOpenWebSocket(cdpUrl, timeoutMs, configuredPin?.lookup);
}
// Either an http(s) discovery URL or a bare ws/wss root. Try
// /json/version discovery first. For bare ws/wss URLs, fall back to a
@@ -886,7 +897,7 @@ export async function isChromeReachable(
return true;
}
if (isWebSocketUrl(cdpUrl)) {
return await canOpenWebSocket(cdpUrl, timeoutMs);
return await canOpenWebSocket(cdpUrl, timeoutMs, configuredPin?.lookup);
}
return false;
} catch {
@@ -906,18 +917,18 @@ async function fetchChromeVersion(
}
}
/** Resolve a usable Chrome DevTools WebSocket URL from a CDP endpoint. */
export async function getChromeWebSocketUrl(
/** Resolve a usable Chrome DevTools WebSocket endpoint from a CDP endpoint. */
export async function getChromeWebSocketEndpoint(
cdpUrl: string,
timeoutMs = CHROME_REACHABILITY_TIMEOUT_MS,
ssrfPolicy?: SsrFPolicy,
): Promise<string | null> {
await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
): Promise<ChromeWebSocketEndpoint | null> {
const configuredPin = await assertCdpEndpointAllowed(cdpUrl, ssrfPolicy);
const cdpControlPolicy = scopeCdpPolicyToConfiguredEndpoint(cdpUrl, ssrfPolicy);
if (isDirectCdpWebSocketEndpoint(cdpUrl)) {
// Handshake-ready direct WebSocket endpoint — the cdpUrl is already
// the WebSocket URL.
return cdpUrl;
return { url: cdpUrl, lookup: configuredPin?.lookup };
}
// Either an http(s) endpoint or a bare ws/wss root; discover the
// actual WebSocket URL via /json/version. Normalise the scheme so
@@ -934,16 +945,16 @@ export async function getChromeWebSocketUrl(
// The SSRF check on cdpUrl was already performed at the start of this
// function, so we can return it directly.
if (isWebSocketUrl(cdpUrl)) {
return cdpUrl;
return { url: cdpUrl, lookup: configuredPin?.lookup };
}
return null;
}
const normalizedWsUrl = normalizeCdpWsUrl(wsUrl, discoveryUrl);
await assertCdpEndpointAllowed(normalizedWsUrl, cdpControlPolicy, {
const discoveredPin = await assertCdpEndpointAllowed(normalizedWsUrl, cdpControlPolicy, {
source: "discovered",
configuredUrl: cdpUrl,
});
return normalizedWsUrl;
return { url: normalizedWsUrl, lookup: discoveredPin?.lookup };
}
/** Return true when a Chrome CDP endpoint has a healthy WebSocket command path. */
@@ -1355,13 +1366,13 @@ export async function isChromeCdpOwnedByPid(
ssrfPolicy?: SsrFPolicy,
): Promise<boolean> {
try {
const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs, ssrfPolicy);
if (!wsUrl) {
const endpoint = await getChromeWebSocketEndpoint(cdpUrl, timeoutMs, ssrfPolicy);
if (!endpoint) {
return false;
}
let owned = false;
await withCdpSocket(
wsUrl,
endpoint.url,
async (send) => {
owned = cdpProcessListOwnsBrowser(await send("SystemInfo.getProcessInfo"), pid);
},
@@ -1369,6 +1380,7 @@ export async function isChromeCdpOwnedByPid(
commandTimeoutMs: timeoutMs,
handshakeRetries: 0,
handshakeTimeoutMs: timeoutMs,
lookup: endpoint.lookup,
},
);
return owned;
@@ -1387,15 +1399,15 @@ async function requestGracefulChromeClose(
);
let commandSent = false;
try {
const wsUrl = await getChromeWebSocketUrl(
const endpoint = await getChromeWebSocketEndpoint(
cdpUrlForPort(running.cdpPort),
Math.min(commandTimeoutMs, CHROME_STOP_PROBE_TIMEOUT_MS),
);
if (!wsUrl) {
if (!endpoint) {
return false;
}
await withCdpSocket(
wsUrl,
endpoint.url,
async (send) => {
// The fixed port can be rebound while this handle remains retained.
// Never ask a replacement browser to close on behalf of the old child.
@@ -1410,6 +1422,7 @@ async function requestGracefulChromeClose(
commandTimeoutMs,
handshakeTimeoutMs: commandTimeoutMs,
handshakeRetries: 0,
lookup: endpoint.lookup,
},
);
return commandSent;
@@ -3,6 +3,10 @@
*
* Shared by the browser control client, CLI, and Browser agent tool.
*/
import type { lookup as dnsLookupCb } from "node:dns";
type BrowserCdpLookup = typeof dnsLookupCb;
/** Browser transport backing the selected profile. */
export type BrowserTransport = "cdp" | "chrome-mcp" | "extension";
type BrowserHeadlessSource =
@@ -126,6 +130,8 @@ export type BrowserTab = {
title: string;
url: string;
wsUrl?: string;
/** Internal CDP lookup pin paired with wsUrl; omitted from model-facing summaries. */
wsLookup?: BrowserCdpLookup;
type?: string;
};
@@ -8,6 +8,12 @@ import { createRequire } from "node:module";
import type * as PlaywrightCore from "playwright-core";
const require = createRequire(import.meta.url);
const playwrightCoreBundle = require("playwright-core/lib/coreBundle") as {
getUserAgent: () => string;
};
/** Runtime playwright-core module instance. */
export const playwrightCore = require("playwright-core") as typeof PlaywrightCore;
/** Dependency-owned User-Agent used by Playwright's native CDP WebSocket transport. */
export const getPlaywrightUserAgent = playwrightCoreBundle.getUserAgent;
@@ -15,6 +15,8 @@ type BrowserProfileMode =
type BrowserProfileCapabilities = {
mode: BrowserProfileMode;
isRemote: boolean;
/** Browser process reads paths from the same filesystem as OpenClaw. */
browserFilesystemLocal: boolean;
/** Profile uses the Chrome DevTools MCP server (existing-session driver). */
usesChromeMcp: boolean;
usesPersistentPlaywright: boolean;
@@ -32,6 +34,7 @@ export function getBrowserProfileCapabilities(
return {
mode: "local-existing-session",
isRemote: false,
browserFilesystemLocal: false,
usesChromeMcp: true,
usesPersistentPlaywright: false,
supportsPerTabWs: false,
@@ -48,6 +51,7 @@ export function getBrowserProfileCapabilities(
return {
mode: "local-extension",
isRemote: false,
browserFilesystemLocal: true,
usesChromeMcp: false,
usesPersistentPlaywright: true,
supportsPerTabWs: false,
@@ -61,6 +65,7 @@ export function getBrowserProfileCapabilities(
return {
mode: "remote-cdp",
isRemote: true,
browserFilesystemLocal: false,
usesChromeMcp: false,
usesPersistentPlaywright: true,
supportsPerTabWs: false,
@@ -73,6 +78,9 @@ export function getBrowserProfileCapabilities(
return {
mode: "local-managed",
isRemote: false,
// A loopback attach-only endpoint can terminate in Docker or a tunnel.
// Only an OpenClaw-owned browser is known to share this filesystem.
browserFilesystemLocal: !profile.attachOnly,
usesChromeMcp: false,
usesPersistentPlaywright: false,
supportsPerTabWs: true,
@@ -1,6 +1,6 @@
// Browser tests cover pw ai plugin behavior.
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { connectOverCdpMock, getChromeWebSocketUrlMock } from "./pw-session.mock-setup.js";
import { connectOverCdpMock, getChromeWebSocketEndpointMock } from "./pw-session.mock-setup.js";
type FakeSession = {
send: ReturnType<typeof vi.fn>;
@@ -57,7 +57,7 @@ let clickViaPlaywright: typeof import("./pw-tools-core.interactions.js").clickVi
let closePlaywrightBrowserConnection: typeof import("./pw-session.js").closePlaywrightBrowserConnection;
beforeAll(async () => {
getChromeWebSocketUrlMock.mockResolvedValue(null);
getChromeWebSocketEndpointMock.mockResolvedValue(null);
({ snapshotAiViaPlaywright } = await import("./pw-tools-core.snapshot.js"));
({ clickViaPlaywright } = await import("./pw-tools-core.interactions.js"));
({ closePlaywrightBrowserConnection } = await import("./pw-session.js"));
@@ -201,7 +201,7 @@ async function tryTerminateExecutionViaCdp(opts: {
return;
}
const wsUrl = normalizeCdpWsUrl(wsUrlRaw, cdpHttpBase);
await assertCdpEndpointAllowed(wsUrl, cdpControlPolicy, {
const wsPin = await assertCdpEndpointAllowed(wsUrl, cdpControlPolicy, {
source: "discovered",
configuredUrl: opts.cdpUrl,
});
@@ -245,7 +245,7 @@ async function tryTerminateExecutionViaCdp(opts: {
// Best-effort; ignore
}
},
{ handshakeTimeoutMs: 2000 },
{ handshakeTimeoutMs: 2000, ...(wsPin?.lookup ? { lookup: wsPin.lookup } : {}) },
).catch(() => {});
}
@@ -0,0 +1,138 @@
import type { lookup as dnsLookupCb } from "node:dns";
import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";
import type { Browser, ConnectOverCDPTransport } from "playwright-core";
import WebSocket from "ws";
import { formatErrorMessage } from "../infra/errors.js";
import { openCdpWebSocket } from "./cdp.helpers.js";
import { playwrightCore } from "./playwright-core.runtime.js";
const { chromium } = playwrightCore;
type CdpSocketLookup = typeof dnsLookupCb;
export async function connectOverCdpPinnedTransport(
connectionUrl: string,
opts: {
timeout: number;
headers: Record<string, string>;
lookup: CdpSocketLookup;
},
): Promise<Browser> {
const ws = openCdpWebSocket(connectionUrl, {
headers: opts.headers,
handshakeTimeoutMs: opts.timeout,
lookup: opts.lookup,
playwrightTransportDefaults: true,
});
try {
await new Promise<void>((resolve, reject) => {
ws.once("open", () => resolve());
ws.once("error", reject);
ws.once("close", () => reject(new Error("CDP socket closed")));
});
let onMessage: ((message: object) => void) | undefined;
let onClose: ((reason?: string) => void) | undefined;
const pendingMessages: object[] = [];
let pendingCloseReason: string | undefined;
let transportClosed = false;
let transportCloseScheduled = false;
const notifyTransportClosed = (reason: string) => {
if (transportClosed) {
return;
}
transportClosed = true;
if (onClose) {
onClose(reason);
return;
}
pendingCloseReason = reason;
};
const scheduleTransportClosed = (reason: string) => {
if (transportClosed || transportCloseScheduled) {
return;
}
transportCloseScheduled = true;
setImmediate(() => {
transportCloseScheduled = false;
notifyTransportClosed(reason);
});
};
const closeTransportSocket = (reason = "CDP socket closed") => {
notifyTransportClosed(reason);
ws.close();
const terminateTimer = setTimeout(() => {
if (ws.readyState !== WebSocket.CLOSED) {
ws.terminate();
}
}, 100);
terminateTimer.unref?.();
};
const scheduleMessage = (message: object) => {
setImmediate(() => {
if (transportClosed) {
return;
}
if (!onMessage) {
pendingMessages.push(message);
return;
}
try {
onMessage(message);
} catch (error) {
closeTransportSocket(formatErrorMessage(error));
}
});
};
const transport: ConnectOverCDPTransport = {
send: (message) => {
ws.send(JSON.stringify(message));
},
close: () => {
closeTransportSocket();
},
get onmessage() {
return onMessage;
},
set onmessage(handler) {
onMessage = handler;
if (!handler) {
return;
}
while (pendingMessages.length > 0) {
const pending = pendingMessages.shift();
if (pending) {
scheduleMessage(pending);
}
}
},
get onclose() {
return onClose;
},
set onclose(handler) {
onClose = handler;
if (handler && pendingCloseReason !== undefined) {
const reason = pendingCloseReason;
pendingCloseReason = undefined;
handler(reason);
}
},
};
ws.on("message", (raw) => {
try {
const parsed = JSON.parse(rawDataToString(raw)) as object;
scheduleMessage(parsed);
} catch {
closeTransportSocket();
}
});
ws.on("close", () => {
scheduleTransportClosed("CDP socket closed");
});
ws.on("error", (error) => {
scheduleTransportClosed(formatErrorMessage(error));
});
return await chromium.connectOverCDP(transport, { timeout: opts.timeout });
} catch (error) {
ws.close();
throw error;
}
}
@@ -8,13 +8,15 @@ import { PLAYWRIGHT_TARGET_INFO_TIMEOUT_MS } from "./cdp-timeouts.js";
import {
assertCdpEndpointAllowed,
getHeadersWithAuth,
isLoopbackHost,
isWebSocketUrl,
redactCdpErrorText,
stripCdpUrlCredentials,
} from "./cdp.helpers.js";
import { getChromeWebSocketUrl } from "./chrome.js";
import { getChromeWebSocketEndpoint } from "./chrome.js";
import { BrowserTabNotFoundError } from "./errors.js";
import { playwrightCore } from "./playwright-core.runtime.js";
import { connectOverCdpPinnedTransport } from "./pw-session-cdp-transport.js";
import {
blockedPageRefsByCdpUrl,
blockedTargetsByCdpUrl,
@@ -39,6 +41,7 @@ import {
} from "./pw-session-state.js";
const { chromium } = playwrightCore;
type CdpEndpointPin = NonNullable<Awaited<ReturnType<typeof assertCdpEndpointAllowed>>>;
function resolveCdpConnectRetryDelayMs(attempt: number): number {
return 250 + attempt * 250;
@@ -393,7 +396,7 @@ export async function connectBrowser(
}
// Run SSRF policy check only on cache miss so transient DNS failures
// do not break active sessions that already hold a live CDP connection.
await assertCdpEndpointAllowed(normalized, ssrfPolicy);
const configuredPin = await assertCdpEndpointAllowed(normalized, ssrfPolicy);
const connecting = connectingByCdpUrl.get(normalized);
if (connecting) {
return await connecting.promise;
@@ -408,34 +411,59 @@ export async function connectBrowser(
}
try {
const timeout = 5000 + attempt * 2000;
const wsUrl = await getChromeWebSocketUrl(normalized, timeout, ssrfPolicy).catch(
() => null,
);
let endpointDiscoveryError: unknown;
const resolvedEndpoint = await getChromeWebSocketEndpoint(
normalized,
timeout,
ssrfPolicy,
).catch((err: unknown) => {
endpointDiscoveryError = err;
return null;
});
const hasUrlCredentials = stripCdpUrlCredentials(normalized) !== normalized;
if (!wsUrl && hasUrlCredentials && !isWebSocketUrl(normalized)) {
if (!resolvedEndpoint && hasUrlCredentials && !isWebSocketUrl(normalized)) {
// Playwright preserves explicit headers across HTTP discovery redirects.
// Keep credentialed discovery in OpenClaw's guarded fetch path instead.
throw new Error("Authenticated CDP HTTP endpoint did not expose a usable WebSocket URL.");
}
const endpoint = wsUrl ?? normalized;
const connectEndpoint = async (target: string) => {
if (!resolvedEndpoint && ssrfPolicy && !isWebSocketUrl(normalized)) {
const detail = endpointDiscoveryError
? ` Reason: ${redactCdpErrorText(formatErrorMessage(endpointDiscoveryError))}`
: "";
throw new Error(`Guarded CDP endpoint did not expose a usable WebSocket URL.${detail}`);
}
const normalizedCdpHostname = new URL(normalized).hostname;
const needsPinnedDependencyConnect =
Boolean(configuredPin?.lookup) && !isLoopbackHost(normalizedCdpHostname);
const endpointUrl = resolvedEndpoint?.url ?? normalized;
const endpointLookup =
resolvedEndpoint?.lookup ??
(needsPinnedDependencyConnect ? configuredPin?.lookup : undefined);
const connectEndpoint = async (target: string, lookup?: CdpEndpointPin["lookup"]) => {
const headers = getHeadersWithAuth(target);
const connectionUrl = stripCdpUrlCredentials(target);
// Keep both loopback bypasses active until the Playwright handshake settles.
return await withManagedProxyForCdpUrl(connectionUrl, () =>
withNoProxyForCdpUrl(connectionUrl, () =>
chromium.connectOverCDP(connectionUrl, { timeout, headers }),
),
withNoProxyForCdpUrl(connectionUrl, async () => {
if (lookup) {
return await connectOverCdpPinnedTransport(connectionUrl, {
timeout,
headers,
lookup,
});
}
return await chromium.connectOverCDP(connectionUrl, { timeout, headers });
}),
);
};
let browser: Browser;
try {
browser = await connectEndpoint(endpoint);
browser = await connectEndpoint(endpointUrl, endpointLookup);
} catch (err) {
if (!isWebSocketUrl(normalized) || endpoint === normalized) {
if (!isWebSocketUrl(normalized) || endpointUrl === normalized) {
throw err;
}
browser = await connectEndpoint(normalized);
browser = await connectEndpoint(normalized, configuredPin?.lookup);
}
if (connectionAttempt.cancelled) {
connectionAttempt.retired = { browser, cdpUrl: normalized };
@@ -24,7 +24,8 @@ const {
} = pwAi;
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
const getChromeWebSocketEndpointSpy = vi.spyOn(chromeModule, "getChromeWebSocketEndpoint");
const getChromeWebSocketUrlSpy = getChromeWebSocketEndpointSpy;
type BrowserMockBundle = {
browser: import("playwright-core").Browser;
@@ -244,7 +245,7 @@ describe("pw-session connection scoping", () => {
const wsUrl = "ws://127.0.0.1:9222/devtools/browser/discovered";
const release = vi.fn();
registerManagedProxyBrowserCdpBypassMock.mockReturnValue(release);
getChromeWebSocketUrlSpy.mockResolvedValue(wsUrl);
getChromeWebSocketUrlSpy.mockResolvedValue({ url: wsUrl });
connectOverCdpSpy.mockImplementationOnce(async () => {
expect(registerManagedProxyBrowserCdpBypassMock).toHaveBeenCalledWith(wsUrl);
expect(release).not.toHaveBeenCalled();
@@ -303,7 +304,7 @@ describe("pw-session connection scoping", () => {
releases.push(release);
return release;
});
getChromeWebSocketUrlSpy.mockResolvedValue(discoveredUrl);
getChromeWebSocketUrlSpy.mockResolvedValue({ url: discoveredUrl });
connectOverCdpSpy
.mockRejectedValueOnce(new Error("stale discovered endpoint"))
.mockResolvedValueOnce(browser.browser);
@@ -362,10 +363,42 @@ describe("pw-session connection scoping", () => {
expect(connectOverCdpSpy).not.toHaveBeenCalled();
});
it("does not fall back to Playwright discovery for guarded non-loopback CDP hosts", async () => {
getChromeWebSocketEndpointSpy.mockRejectedValue(new Error("discovery unavailable"));
const connection = listPagesViaPlaywright({
cdpUrl: "http://93.184.216.34:9222",
ssrfPolicy: { allowPrivateNetwork: true },
});
await expect(connection).rejects.toThrow(
"Guarded CDP endpoint did not expose a usable WebSocket URL.",
);
await expect(connection).rejects.toThrow("discovery unavailable");
expect(connectOverCdpSpy).not.toHaveBeenCalled();
});
it("does not fall back to Playwright discovery for guarded loopback HTTP CDP hosts", async () => {
getChromeWebSocketEndpointSpy.mockRejectedValue(new Error("loopback discovery blocked"));
const connection = listPagesViaPlaywright({
cdpUrl: "http://127.0.0.1:9222",
ssrfPolicy: {},
});
await expect(connection).rejects.toThrow(
"Guarded CDP endpoint did not expose a usable WebSocket URL.",
);
await expect(connection).rejects.toThrow("loopback discovery blocked");
expect(connectOverCdpSpy).not.toHaveBeenCalled();
});
it("allows loopback CDP control without widening the navigation allowlist", async () => {
const browser = makeBrowser("A", "https://example.com");
connectOverCdpSpy.mockResolvedValue(browser.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketUrlSpy.mockResolvedValue({
url: "ws://127.0.0.1:9222/devtools/browser/local",
});
const ssrfPolicy = {
dangerouslyAllowPrivateNetwork: true,
allowedHostnames: ["example.com"],
@@ -23,7 +23,7 @@ const {
} = pwAi;
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
const getChromeWebSocketEndpointSpy = vi.spyOn(chromeModule, "getChromeWebSocketEndpoint");
const PROXY_ENV_KEYS = [
"ALL_PROXY",
@@ -115,7 +115,7 @@ function installBrowserMocks() {
} as unknown as import("playwright-core").Browser;
connectOverCdpSpy.mockResolvedValue(browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketEndpointSpy.mockResolvedValue(null);
const getBrowserDisconnectedHandler = () =>
browserOn.mock.calls.find((call) => call[0] === "disconnected")?.[1] as
@@ -214,7 +214,7 @@ beforeEach(() => {
afterEach(async () => {
vi.unstubAllEnvs();
connectOverCdpSpy.mockClear();
getChromeWebSocketUrlSpy.mockClear();
getChromeWebSocketEndpointSpy.mockClear();
await closePlaywrightBrowserConnection().catch(() => {});
});
@@ -246,6 +246,9 @@ describe("pw-session createPageViaPlaywright navigation guard", () => {
it("blocks hostname navigation when strict SSRF policy is configured", async () => {
const { pageGoto } = installBrowserMocks();
getChromeWebSocketEndpointSpy.mockResolvedValue({
url: "ws://127.0.0.1:18792/devtools/browser/ROOT",
});
await expect(
createPageViaPlaywright({
@@ -14,7 +14,7 @@ const {
} = pwAi;
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
const getChromeWebSocketEndpointSpy = vi.spyOn(chromeModule, "getChromeWebSocketEndpoint");
type MockPageSpec = {
targetId?: string;
@@ -107,13 +107,13 @@ function makeBrowser(pages: MockPageSpec[]): BrowserMockBundle {
function installBrowser(pages: MockPageSpec[]): BrowserMockBundle {
const bundle = makeBrowser(pages);
connectOverCdpSpy.mockResolvedValue(bundle.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketEndpointSpy.mockResolvedValue(null);
return bundle;
}
afterEach(async () => {
connectOverCdpSpy.mockReset();
getChromeWebSocketUrlSpy.mockReset();
getChromeWebSocketEndpointSpy.mockReset();
await closePlaywrightBrowserConnection().catch(() => {});
});
@@ -227,7 +227,7 @@ describe("pw-session getPageForTargetId", () => {
const fresh = makeBrowser([{ targetId: "TARGET_OK", url: "https://fresh.example" }]);
connectOverCdpSpy.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketEndpointSpy.mockResolvedValue(null);
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9222" });
@@ -249,7 +249,7 @@ describe("pw-session getPageForTargetId", () => {
]);
connectOverCdpSpy.mockResolvedValueOnce(stale.browser).mockResolvedValueOnce(fresh.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketEndpointSpy.mockResolvedValue(null);
await getPageForTargetId({ cdpUrl: "http://127.0.0.1:9333" });
@@ -270,7 +270,7 @@ describe("pw-session getPageForTargetId", () => {
connectOverCdpSpy
.mockResolvedValueOnce(stale.browser)
.mockResolvedValueOnce(stillBroken.browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketEndpointSpy.mockResolvedValue(null);
await listPagesViaPlaywright({ cdpUrl: "http://127.0.0.1:9444" });
@@ -283,7 +283,7 @@ describe("pw-session getPageForTargetId", () => {
it("does not add an extra top-level retry for non-recoverable connect failures", async () => {
connectOverCdpSpy.mockRejectedValue(new Error("connectOverCDP exploded"));
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketEndpointSpy.mockResolvedValue(null);
await expect(getPageForTargetId({ cdpUrl: "http://127.0.0.1:9555" })).rejects.toThrow(
"connectOverCDP exploded",
@@ -10,9 +10,10 @@ import type { MockFn } from "../test-utils/vitest-mock-fn.js";
/** Mock for playwright.chromium.connectOverCDP. */
export const connectOverCdpMock: MockFn = vi.fn();
/** Mock for Chrome CDP WebSocket URL discovery. */
export const getChromeWebSocketUrlMock: MockFn = vi.fn();
export const getChromeWebSocketEndpointMock: MockFn = vi.fn();
vi.mock("./playwright-core.runtime.js", () => ({
getPlaywrightUserAgent: () => "Playwright/test",
playwrightCore: {
chromium: {
connectOverCDP: (...args: unknown[]) => connectOverCdpMock(...args),
@@ -22,5 +23,5 @@ vi.mock("./playwright-core.runtime.js", () => ({
}));
vi.mock("./chrome.js", () => ({
getChromeWebSocketUrl: (...args: unknown[]) => getChromeWebSocketUrlMock(...args),
getChromeWebSocketEndpoint: (...args: unknown[]) => getChromeWebSocketEndpointMock(...args),
}));
@@ -0,0 +1,373 @@
// Browser tests cover pinned Playwright CDP transport behavior.
import { createServer } from "node:http";
import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";
import { chromium } from "playwright-core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WebSocketServer } from "ws";
import * as chromeModule from "./chrome.js";
import { pwAi } from "./pw-ai.js";
const { registerManagedProxyBrowserCdpBypassMock } = vi.hoisted(() => ({
registerManagedProxyBrowserCdpBypassMock: vi.fn<(url: string) => (() => void) | undefined>(
() => undefined,
),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime-internal", () => ({
registerManagedProxyBrowserCdpBypass: registerManagedProxyBrowserCdpBypassMock,
}));
const { closePlaywrightBrowserConnection, listPagesViaPlaywright } = pwAi;
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketEndpointSpy = vi.spyOn(chromeModule, "getChromeWebSocketEndpoint");
const TEST_CDP_WS_MAX_PAYLOAD_BYTES = 1024 * 1024;
function webSocketMessageToString(data: import("ws").Data): string {
return typeof data === "string" ? data : rawDataToString(data);
}
function makeBrowser(
targetId: string,
url: string,
): { browser: import("playwright-core").Browser } {
const page = {
on: vi.fn(),
context: () => context,
title: vi.fn(async () => `title:${targetId}`),
url: vi.fn(() => url),
} as unknown as import("playwright-core").Page;
const context: import("playwright-core").BrowserContext = {
pages: () => [page],
on: vi.fn(),
newCDPSession: vi.fn(async () => ({
send: vi.fn(async (method: string) =>
method === "Target.getTargetInfo"
? { targetInfo: { targetId, title: `title:${targetId}` } }
: {},
),
detach: vi.fn(async () => {}),
})),
} as unknown as import("playwright-core").BrowserContext;
const browser = {
contexts: () => [context],
on: vi.fn(),
off: vi.fn(),
close: vi.fn(async () => {}),
} as unknown as import("playwright-core").Browser;
return { browser };
}
function pinnedLoopbackLookup() {
return ((_hostname: string, options: unknown, callback?: unknown) => {
const cb = typeof options === "function" ? options : callback;
if (typeof cb === "function") {
cb(null, "127.0.0.1", 4);
}
}) as never;
}
afterEach(async () => {
connectOverCdpSpy.mockReset();
getChromeWebSocketEndpointSpy.mockReset();
registerManagedProxyBrowserCdpBypassMock.mockReset();
registerManagedProxyBrowserCdpBypassMock.mockImplementation(() => undefined);
await closePlaywrightBrowserConnection().catch(() => {});
});
describe("pw-session pinned Playwright transport", () => {
it("connects guarded Playwright CDP through the pinned WebSocket transport", async () => {
const server = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
server.once("listening", () => resolve());
});
const port = (server.address() as { port: number }).port;
const cdpUrl = `ws://127.0.0.1:${port}/devtools/browser/test`;
const requestHeaders: Array<Record<string, string | string[] | undefined>> = [];
server.on("connection", (socket, request) => {
requestHeaders.push(request.headers);
socket.addEventListener("message", (event) => {
const msg = JSON.parse(webSocketMessageToString(event.data)) as { id?: number };
socket.send(JSON.stringify({ id: msg.id, result: { ok: true } }));
});
});
getChromeWebSocketEndpointSpy.mockResolvedValue({
url: cdpUrl,
lookup: pinnedLoopbackLookup(),
});
const browser = makeBrowser("A", "https://example.com");
connectOverCdpSpy.mockImplementationOnce((async (transportArg: unknown) => {
expect(typeof transportArg).not.toBe("string");
const transport = transportArg as import("playwright-core").ConnectOverCDPTransport;
let delivered = false;
const message = new Promise<object>((resolve) => {
// oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onmessage property.
transport.onmessage = (value) => {
delivered = true;
resolve(value);
};
});
transport.send({ id: 7, method: "Browser.getVersion" });
expect(delivered).toBe(false);
await expect(message).resolves.toStrictEqual({ id: 7, result: { ok: true } });
transport.close();
return browser.browser;
}) as never);
try {
const pages = await listPagesViaPlaywright({ cdpUrl, ssrfPolicy: {} });
expect(pages.map((page) => page.targetId)).toStrictEqual(["A"]);
expect(connectOverCdpSpy).toHaveBeenCalledTimes(1);
expect(requestHeaders[0]?.["user-agent"]).toContain("Playwright/");
expect(requestHeaders[0]?.["sec-websocket-extensions"]).toContain("permessage-deflate");
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it("follows same-authority redirects in the pinned Playwright CDP transport", async () => {
const server = createServer();
const wss = new WebSocketServer({
noServer: true,
maxPayload: TEST_CDP_WS_MAX_PAYLOAD_BYTES,
});
const redirectedUpgradePaths: string[] = [];
wss.on("connection", (socket) => {
socket.addEventListener("message", (event) => {
const msg = JSON.parse(webSocketMessageToString(event.data)) as { id?: number };
socket.send(JSON.stringify({ id: msg.id, result: { ok: true } }));
});
});
server.on("upgrade", (request, socket, head) => {
if (request.url === "/start") {
socket.write(
"HTTP/1.1 302 Found\r\nLocation: /devtools/browser/redirected\r\nConnection: close\r\n\r\n",
);
socket.destroy();
return;
}
redirectedUpgradePaths.push(request.url ?? "");
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit("connection", ws, request);
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("test server did not expose a TCP port");
}
const cdpUrl = `ws://127.0.0.1:${address.port}/start`;
getChromeWebSocketEndpointSpy.mockResolvedValue({
url: cdpUrl,
lookup: pinnedLoopbackLookup(),
});
const browser = makeBrowser("A", "https://example.com");
connectOverCdpSpy.mockImplementationOnce((async (transportArg: unknown) => {
const transport = transportArg as import("playwright-core").ConnectOverCDPTransport;
const message = new Promise<object>((resolve) => {
// oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onmessage property.
transport.onmessage = (value) => resolve(value);
});
transport.send({ id: 8, method: "Browser.getVersion" });
await expect(message).resolves.toStrictEqual({ id: 8, result: { ok: true } });
transport.close();
return browser.browser;
}) as never);
try {
await expect(listPagesViaPlaywright({ cdpUrl, ssrfPolicy: {} })).resolves.toEqual([
expect.objectContaining({ targetId: "A" }),
]);
expect(redirectedUpgradePaths).toStrictEqual(["/devtools/browser/redirected"]);
} finally {
await new Promise<void>((resolve) => {
wss.close(() => {
server.close(() => resolve());
});
});
}
});
it("closes the pinned Playwright transport on malformed CDP JSON", async () => {
const server = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
server.once("listening", () => resolve());
});
const port = (server.address() as { port: number }).port;
const cdpUrl = `ws://127.0.0.1:${port}/devtools/browser/test`;
const serverSocket = new Promise<import("ws").WebSocket>((resolve) => {
server.on("connection", (socket) => resolve(socket));
});
getChromeWebSocketEndpointSpy.mockResolvedValue({
url: cdpUrl,
lookup: pinnedLoopbackLookup(),
});
const browser = makeBrowser("A", "https://example.com");
connectOverCdpSpy.mockImplementationOnce((async (transportArg: unknown) => {
const transport = transportArg as import("playwright-core").ConnectOverCDPTransport;
const closed = new Promise<string | undefined>((resolve) => {
// oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onclose property.
transport.onclose = (reason) => resolve(reason);
});
(await serverSocket).send("{not-json");
await expect(closed).resolves.toBe("CDP socket closed");
return browser.browser;
}) as never);
try {
await expect(listPagesViaPlaywright({ cdpUrl, ssrfPolicy: {} })).resolves.toEqual([
expect.objectContaining({ targetId: "A" }),
]);
expect(connectOverCdpSpy).toHaveBeenCalledOnce();
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it("delivers queued CDP messages before reporting pinned transport closure", async () => {
const server = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
server.once("listening", () => resolve());
});
const port = (server.address() as { port: number }).port;
const cdpUrl = `ws://127.0.0.1:${port}/devtools/browser/test`;
const serverSocket = new Promise<import("ws").WebSocket>((resolve) => {
server.on("connection", (socket) => resolve(socket));
});
getChromeWebSocketEndpointSpy.mockResolvedValue({
url: cdpUrl,
lookup: pinnedLoopbackLookup(),
});
const browser = makeBrowser("A", "https://example.com");
connectOverCdpSpy.mockImplementationOnce((async (transportArg: unknown) => {
const transport = transportArg as import("playwright-core").ConnectOverCDPTransport;
const events: string[] = [];
const message = new Promise<void>((resolve) => {
// oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onmessage property.
transport.onmessage = () => {
events.push("message");
resolve();
};
});
const closed = new Promise<void>((resolve) => {
// oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onclose property.
transport.onclose = () => {
events.push("close");
resolve();
};
});
const socket = await serverSocket;
socket.send(JSON.stringify({ id: 1, result: { ok: true } }));
socket.close();
await message;
await closed;
expect(events).toStrictEqual(["message", "close"]);
return browser.browser;
}) as never);
try {
await expect(listPagesViaPlaywright({ cdpUrl, ssrfPolicy: {} })).resolves.toEqual([
expect.objectContaining({ targetId: "A" }),
]);
expect(connectOverCdpSpy).toHaveBeenCalledOnce();
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it("closes the pinned Playwright transport when message delivery fails", async () => {
const server = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
server.once("listening", () => resolve());
});
const port = (server.address() as { port: number }).port;
const cdpUrl = `ws://127.0.0.1:${port}/devtools/browser/test`;
const serverSocket = new Promise<import("ws").WebSocket>((resolve) => {
server.on("connection", (socket) => resolve(socket));
});
getChromeWebSocketEndpointSpy.mockResolvedValue({
url: cdpUrl,
lookup: pinnedLoopbackLookup(),
});
const browser = makeBrowser("A", "https://example.com");
connectOverCdpSpy.mockImplementationOnce((async (transportArg: unknown) => {
const transport = transportArg as import("playwright-core").ConnectOverCDPTransport;
const closed = new Promise<string | undefined>((resolve) => {
// oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onclose property.
transport.onclose = (reason) => resolve(reason);
});
// oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onmessage property.
transport.onmessage = () => {
throw new Error("handler failed");
};
(await serverSocket).send(JSON.stringify({ id: 1, result: {} }));
await expect(closed).resolves.toContain("handler failed");
return browser.browser;
}) as never);
try {
await expect(listPagesViaPlaywright({ cdpUrl, ssrfPolicy: {} })).resolves.toEqual([
expect.objectContaining({ targetId: "A" }),
]);
expect(connectOverCdpSpy).toHaveBeenCalledOnce();
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
it("propagates pinned WebSocket protocol errors through transport closure", async () => {
const server = new WebSocketServer({ port: 0, host: "127.0.0.1" });
await new Promise<void>((resolve) => {
server.once("listening", () => resolve());
});
const port = (server.address() as { port: number }).port;
const cdpUrl = `ws://127.0.0.1:${port}/devtools/browser/test`;
const serverSocket = new Promise<import("ws").WebSocket>((resolve) => {
server.on("connection", (socket) => resolve(socket));
});
getChromeWebSocketEndpointSpy.mockResolvedValue({
url: cdpUrl,
lookup: pinnedLoopbackLookup(),
});
const browser = makeBrowser("A", "https://example.com");
connectOverCdpSpy.mockImplementationOnce((async (transportArg: unknown) => {
const transport = transportArg as import("playwright-core").ConnectOverCDPTransport;
const closed = new Promise<string | undefined>((resolve) => {
// oxlint-disable-next-line unicorn/prefer-add-event-listener -- Playwright's ConnectOverCDPTransport contract uses an onclose property.
transport.onclose = (reason) => resolve(reason);
});
const socket = await serverSocket;
const rawSocket = Reflect.get(socket, "_socket") as { write(data: Buffer): void };
// Send an invalid reserved opcode so the real ws client emits an error.
rawSocket.write(Buffer.from([0x83, 0x00]));
await expect(closed).resolves.toContain("Invalid WebSocket frame");
return browser.browser;
}) as never);
try {
await expect(listPagesViaPlaywright({ cdpUrl, ssrfPolicy: {} })).resolves.toEqual([
expect.objectContaining({ targetId: "A" }),
]);
expect(connectOverCdpSpy).toHaveBeenCalledOnce();
} finally {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
});
});
@@ -12,6 +12,7 @@ const {
const wsMockState = vi.hoisted(() => ({
constructorUrls: [] as string[],
constructorOptions: [] as Array<{ agent?: unknown } | undefined>,
}));
vi.mock("ws", () => {
@@ -21,8 +22,9 @@ vi.mock("ws", () => {
readyState = 0;
private readonly handlers = new Map<string, (error?: Error) => void>();
constructor(url: string) {
constructor(url: string, options?: { agent?: unknown }) {
wsMockState.constructorUrls.push(url);
wsMockState.constructorOptions.push(options);
setTimeout(() => {
this.handlers.get("error")?.(new Error("test socket should not open"));
}, 0);
@@ -34,6 +36,9 @@ vi.mock("ws", () => {
}
close() {
if (this.readyState === 3) {
return;
}
this.readyState = 3;
this.handlers.get("close")?.();
}
@@ -45,7 +50,7 @@ vi.mock("ws", () => {
});
const connectOverCdpSpy = vi.spyOn(chromium, "connectOverCDP");
const getChromeWebSocketUrlSpy = vi.spyOn(chromeModule, "getChromeWebSocketUrl");
const getChromeWebSocketEndpointSpy = vi.spyOn(chromeModule, "getChromeWebSocketEndpoint");
function installBrowserMock() {
const sessionSend = vi.fn(async (method: string) => {
@@ -78,14 +83,17 @@ function installBrowserMock() {
} as unknown as import("playwright-core").Browser;
connectOverCdpSpy.mockResolvedValue(browser);
getChromeWebSocketUrlSpy.mockResolvedValue(null);
getChromeWebSocketEndpointSpy.mockResolvedValue({
url: "ws://127.0.0.1:18792/devtools/browser/ROOT",
});
return { browserClose };
}
afterEach(async () => {
connectOverCdpSpy.mockReset();
getChromeWebSocketUrlSpy.mockReset();
getChromeWebSocketEndpointSpy.mockReset();
wsMockState.constructorUrls = [];
wsMockState.constructorOptions = [];
await closePlaywrightBrowserConnection().catch(() => {});
});
@@ -116,12 +124,64 @@ describe("pw-session termination CDP SSRF guard", () => {
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy.mock.calls[0]?.[0]).toBe("http://127.0.0.1:18792/json/list");
const fetchUrls = fetchSpy.mock.calls.map((call) => call[0]);
expect(fetchUrls).toContain("http://127.0.0.1:18792/json/list");
expect(fetchUrls).not.toContain("http://169.254.169.254/json/list");
expect(wsMockState.constructorUrls).toEqual([]);
expect(browserClose).toHaveBeenCalledTimes(1);
} finally {
fetchSpy.mockRestore();
}
});
it("uses the discovered target lookup pin for best-effort termination sockets", async () => {
installBrowserMock();
const lookup = vi.fn((_hostname: string, options: unknown, callback?: unknown) => {
const cb = typeof options === "function" ? options : callback;
if (typeof cb === "function") {
cb(null, "127.0.0.1", 4);
}
});
const assertAllowedSpy = vi
.spyOn(await import("./cdp.helpers.js"), "assertCdpEndpointAllowed")
.mockImplementation(async (url: string) =>
url.includes("/devtools/page/")
? {
hostname: "cdp-pinned.test",
addresses: ["127.0.0.1"],
lookup: lookup as never,
}
: undefined,
);
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify([
{
id: "TARGET_1",
webSocketDebuggerUrl: "ws://cdp-pinned.test/devtools/page/TARGET_1",
},
]),
{ status: 200 },
),
);
try {
await listPagesViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
ssrfPolicy: {},
});
await forceDisconnectPlaywrightForTarget({
cdpUrl: "http://127.0.0.1:18792",
targetId: "TARGET_1",
ssrfPolicy: {},
});
expect(wsMockState.constructorUrls).toEqual(["ws://cdp-pinned.test/devtools/page/TARGET_1"]);
expect(wsMockState.constructorOptions[0]?.agent).toBeDefined();
} finally {
assertAllowedSpy.mockRestore();
fetchSpy.mockRestore();
}
});
});
@@ -69,12 +69,15 @@ function resolveImplicitDownloadRoot(): string {
}
/** Arms the next page file chooser and fills it with strict existing paths. */
export async function armFileUploadViaPlaywright(opts: {
cdpUrl: string;
targetId?: string;
paths?: string[];
timeoutMs?: number;
}): Promise<void> {
export async function armFileUploadViaPlaywright(
opts: {
cdpUrl: string;
browserFilesystemLocal?: boolean;
targetId?: string;
paths?: string[];
timeoutMs?: number;
} & BrowserNavigationPolicyOptions,
): Promise<void> {
const key = opts.cdpUrl;
const armId = bumpUploadArmId();
pendingUploadClaims.set(key, armId);
@@ -115,7 +118,17 @@ export async function armFileUploadViaPlaywright(opts: {
await dismissFileChooser(page);
return;
}
await fileChooser.setFiles(uploadPathsResult.paths);
await setFileChooserFilesViaPlaywright({
cdpUrl: opts.cdpUrl,
targetId: opts.targetId,
page,
fileChooser,
paths: uploadPathsResult.paths,
timeoutMs: timeout,
browserFilesystemLocal: opts.browserFilesystemLocal,
ssrfPolicy: opts.ssrfPolicy,
browserProxyMode: opts.browserProxyMode,
});
})
.catch(() => {
// Ignore timeouts; the chooser may never appear.
@@ -131,6 +144,7 @@ export async function armFileUploadViaPlaywright(opts: {
export async function uploadViaPlaywright(
opts: {
cdpUrl: string;
browserFilesystemLocal?: boolean;
targetId?: string;
ref: string;
paths: string[];
@@ -274,6 +288,7 @@ export async function uploadViaPlaywright(
fileChooser: chooser,
paths: uploadPathsResult.paths,
timeoutMs: Math.max(1, deadline - Date.now()),
browserFilesystemLocal: opts.browserFilesystemLocal,
ssrfPolicy: opts.ssrfPolicy,
browserProxyMode: opts.browserProxyMode,
});
@@ -1,3 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { detectMime } from "openclaw/plugin-sdk/media-mime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { FileChooser, Page } from "playwright-core";
import { ACT_MAX_WAIT_TIME_MS, resolveActWaitTimeoutMs } from "./act-policy.js";
@@ -32,6 +35,43 @@ import {
type RawAnnotationInput,
} from "./screenshot-annotate.js";
const DEFAULT_UPLOAD_MIME_TYPE = "application/octet-stream";
const PLAYWRIGHT_FILE_PAYLOAD_SIZE_LIMIT_BYTES = 50 * 1024 * 1024;
type PlaywrightFilePayload = {
name: string;
mimeType: string;
buffer: Buffer;
lastModifiedMs?: number;
};
async function toPlaywrightFilePayloads(paths: string[]): Promise<PlaywrightFilePayload[]> {
const stats = await Promise.all(paths.map(async (filePath) => await fs.stat(filePath)));
const totalSize = stats.reduce((size, stat) => size + stat.size, 0);
if (totalSize >= PLAYWRIGHT_FILE_PAYLOAD_SIZE_LIMIT_BYTES) {
throw new Error(
"Cannot set buffer larger than 50Mb, please write it to a file and pass its path instead.",
);
}
return await Promise.all(
paths.map(async (filePath, index) => {
const buffer = await fs.readFile(filePath);
return {
name: path.basename(filePath),
mimeType: (await detectMime({ buffer, filePath })) ?? DEFAULT_UPLOAD_MIME_TYPE,
buffer,
lastModifiedMs: stats[index]?.mtimeMs,
};
}),
);
}
function shouldUsePlaywrightFilePayloads(
opts: Pick<NavigationTargetOptions, "browserFilesystemLocal" | "ssrfPolicy">,
): boolean {
return Boolean(opts.ssrfPolicy) && opts.browserFilesystemLocal !== true;
}
type BrowserWaitPredicateState = {
document: unknown;
pending?: boolean;
@@ -402,9 +442,18 @@ export async function setFileChooserFilesViaPlaywright(
timeoutMs: number;
},
): Promise<void> {
const resolvedResult = await resolveStrictExistingUploadPaths({ requestedPaths: opts.paths });
if (!resolvedResult.ok) {
throw new Error(resolvedResult.error);
}
const resolvedPaths = resolvedResult.paths;
const resolvedFiles = shouldUsePlaywrightFilePayloads(opts)
? await toPlaywrightFilePayloads(resolvedPaths)
: resolvedPaths;
await awaitNavigationGuardedInteraction({
action: async () => {
await opts.fileChooser.setFiles(opts.paths, { timeout: opts.timeoutMs });
await opts.fileChooser.setFiles(resolvedFiles, { timeout: opts.timeoutMs });
},
cdpUrl: opts.cdpUrl,
page: opts.page,
@@ -441,11 +490,14 @@ export async function setInputFilesViaPlaywright(
throw new Error(resolvedResult.error);
}
const resolvedPaths = resolvedResult.paths;
const resolvedFiles = shouldUsePlaywrightFilePayloads(opts)
? await toPlaywrightFilePayloads(resolvedPaths)
: resolvedPaths;
try {
await awaitNavigationGuardedInteraction({
action: async () => {
await locator.setInputFiles(resolvedPaths);
await locator.setInputFiles(resolvedFiles);
},
cdpUrl: opts.cdpUrl,
page,
@@ -22,6 +22,7 @@ import { toAIFriendlyError } from "./pw-tools-core.shared.js";
export type InteractionTargetOptions = {
cdpUrl: string;
browserFilesystemLocal?: boolean;
targetId?: string;
};
@@ -1,6 +1,10 @@
// Browser tests cover pw tools core.interactions.set input files plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
const readFile = vi.fn();
const stat = vi.fn();
const detectMime = vi.fn();
let page: Record<string, unknown> | null = null;
let locator: Record<string, unknown> | null = null;
@@ -58,7 +62,19 @@ vi.mock("./paths.js", () => {
};
});
const { setInputFilesViaPlaywright } = await import("./pw-tools-core.interactions.js");
vi.mock("node:fs/promises", () => ({
default: {
readFile,
stat,
},
}));
vi.mock("openclaw/plugin-sdk/media-mime", () => ({
detectMime,
}));
const { setFileChooserFilesViaPlaywright, setInputFilesViaPlaywright } =
await import("./pw-tools-core.interactions.js");
function seedSingleLocatorPage(): {
setInputFiles: ReturnType<typeof vi.fn>;
@@ -79,11 +95,81 @@ function seedSingleLocatorPage(): {
return { setInputFiles, elementHandle };
}
describe("setFileChooserFilesViaPlaywright", () => {
beforeEach(() => {
vi.clearAllMocks();
page = {
url: vi.fn(() => "https://allowed.example/form"),
};
locator = null;
readFile.mockResolvedValue(Buffer.from("upload contents"));
stat.mockResolvedValue({ size: Buffer.byteLength("upload contents"), mtimeMs: 1700000000000 });
detectMime.mockResolvedValue("text/plain");
resolveStrictExistingUploadPaths.mockResolvedValue({
ok: true,
paths: ["/private/tmp/openclaw/uploads/ok.txt"],
});
});
it("keeps chooser path handoff for unguarded local sessions", async () => {
const fileChooser = { setFiles: vi.fn(async () => {}) };
await setFileChooserFilesViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
page: page as never,
fileChooser: fileChooser as never,
paths: ["/tmp/openclaw/uploads/ok.txt"],
timeoutMs: 250,
});
expect(resolveStrictExistingUploadPaths).toHaveBeenCalledWith({
requestedPaths: ["/tmp/openclaw/uploads/ok.txt"],
});
expect(stat).not.toHaveBeenCalled();
expect(readFile).not.toHaveBeenCalled();
expect(fileChooser.setFiles).toHaveBeenCalledWith(["/private/tmp/openclaw/uploads/ok.txt"], {
timeout: 250,
});
});
it("converts guarded chooser uploads to payloads before Playwright path handoff", async () => {
const fileChooser = { setFiles: vi.fn(async () => {}) };
await setFileChooserFilesViaPlaywright({
cdpUrl: "https://browser.example/cdp",
targetId: "T1",
page: page as never,
fileChooser: fileChooser as never,
paths: ["/tmp/openclaw/uploads/ok.txt"],
timeoutMs: 250,
ssrfPolicy: {},
});
expect(stat).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt");
expect(readFile).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt");
expect(fileChooser.setFiles).toHaveBeenCalledWith(
[
{
name: "ok.txt",
mimeType: "text/plain",
buffer: Buffer.from("upload contents"),
lastModifiedMs: 1700000000000,
},
],
{ timeout: 250 },
);
});
});
describe("setInputFilesViaPlaywright", () => {
beforeEach(() => {
vi.clearAllMocks();
page = null;
locator = null;
readFile.mockResolvedValue(Buffer.from("upload contents"));
stat.mockResolvedValue({ size: Buffer.byteLength("upload contents"), mtimeMs: 1700000000000 });
detectMime.mockResolvedValue("text/plain");
resolveStrictExistingUploadPaths.mockResolvedValue({
ok: true,
paths: ["/private/tmp/openclaw/uploads/ok.txt"],
@@ -104,27 +190,182 @@ describe("setInputFilesViaPlaywright", () => {
requestedPaths: ["/tmp/openclaw/uploads/ok.txt"],
});
expect(refLocator).toHaveBeenCalledWith(page, "e7");
expect(stat).not.toHaveBeenCalled();
expect(readFile).not.toHaveBeenCalled();
expect(detectMime).not.toHaveBeenCalled();
expect(setInputFiles).toHaveBeenCalledWith(["/private/tmp/openclaw/uploads/ok.txt"]);
expect(setInputFiles).toHaveBeenCalledTimes(1);
expect(elementHandle).not.toHaveBeenCalled();
});
it("keeps assignment-triggered navigation inside the browser policy guard", async () => {
it("converts guarded remote uploads to payloads before Playwright path handoff", async () => {
const { setInputFiles, elementHandle } = seedSingleLocatorPage();
await setInputFilesViaPlaywright({
cdpUrl: "https://browser.example/cdp",
targetId: "T1",
inputRef: "e7",
paths: ["/tmp/openclaw/uploads/ok.txt"],
ssrfPolicy: {},
});
expect(stat).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt");
expect(readFile).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt");
expect(detectMime).toHaveBeenCalledWith({
buffer: Buffer.from("upload contents"),
filePath: "/private/tmp/openclaw/uploads/ok.txt",
});
expect(setInputFiles).toHaveBeenCalledWith([
{
name: "ok.txt",
mimeType: "text/plain",
buffer: Buffer.from("upload contents"),
lastModifiedMs: 1700000000000,
},
]);
expect(setInputFiles).toHaveBeenCalledTimes(1);
expect(elementHandle).not.toHaveBeenCalled();
});
it("falls back to an octet-stream payload when mime detection has no answer", async () => {
detectMime.mockResolvedValueOnce(undefined);
const { setInputFiles } = seedSingleLocatorPage();
await setInputFilesViaPlaywright({
cdpUrl: "https://browser.example/cdp",
targetId: "T1",
inputRef: "e7",
paths: ["/tmp/openclaw/uploads/ok.txt"],
ssrfPolicy: {},
});
expect(setInputFiles).toHaveBeenCalledWith([
{
name: "ok.txt",
mimeType: "application/octet-stream",
buffer: Buffer.from("upload contents"),
lastModifiedMs: 1700000000000,
},
]);
});
it("checks the Playwright aggregate payload size cap before reading guarded remote upload files", async () => {
stat.mockResolvedValueOnce({ size: 50 * 1024 * 1024 });
const { setInputFiles } = seedSingleLocatorPage();
await expect(
setInputFilesViaPlaywright({
cdpUrl: "https://browser.example/cdp",
targetId: "T1",
inputRef: "e7",
paths: ["/tmp/openclaw/uploads/too-large.bin"],
ssrfPolicy: {},
}),
).rejects.toThrow("Cannot set buffer larger than 50Mb");
expect(readFile).not.toHaveBeenCalled();
expect(setInputFiles).not.toHaveBeenCalled();
});
it("allows a guarded remote upload below the aggregate payload cap", async () => {
stat.mockResolvedValueOnce({ size: 50 * 1024 * 1024 - 1, mtimeMs: 1700000000000 });
const { setInputFiles } = seedSingleLocatorPage();
await setInputFilesViaPlaywright({
cdpUrl: "https://browser.example/cdp",
targetId: "T1",
inputRef: "e7",
paths: ["/tmp/openclaw/uploads/limit.bin"],
ssrfPolicy: {},
});
expect(readFile).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt");
expect(setInputFiles).toHaveBeenCalledWith([
{
name: "ok.txt",
mimeType: "text/plain",
buffer: Buffer.from("upload contents"),
lastModifiedMs: 1700000000000,
},
]);
});
it("checks the aggregate cap across multiple guarded remote upload payloads", async () => {
stat
.mockResolvedValueOnce({ size: 30 * 1024 * 1024, mtimeMs: 1700000000000 })
.mockResolvedValueOnce({ size: 30 * 1024 * 1024, mtimeMs: 1700000001000 });
resolveStrictExistingUploadPaths.mockResolvedValueOnce({
ok: true,
paths: ["/private/tmp/openclaw/uploads/one.txt", "/private/tmp/openclaw/uploads/two.txt"],
});
const { setInputFiles } = seedSingleLocatorPage();
await expect(
setInputFilesViaPlaywright({
cdpUrl: "https://browser.example/cdp",
targetId: "T1",
inputRef: "e7",
paths: ["/tmp/openclaw/uploads/one.txt", "/tmp/openclaw/uploads/two.txt"],
ssrfPolicy: {},
}),
).rejects.toThrow("Cannot set buffer larger than 50Mb");
expect(readFile).not.toHaveBeenCalled();
expect(setInputFiles).not.toHaveBeenCalled();
});
it("keeps guarded loopback uploads as path handoffs inside the browser policy guard", async () => {
const { setInputFiles } = seedSingleLocatorPage();
await setInputFilesViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
browserFilesystemLocal: true,
targetId: "T1",
inputRef: "e7",
paths: ["/tmp/openclaw/uploads/ok.txt"],
ssrfPolicy: { dangerouslyAllowPrivateNetwork: true },
});
expect(stat).not.toHaveBeenCalled();
expect(readFile).not.toHaveBeenCalled();
expect(detectMime).not.toHaveBeenCalled();
expect(setInputFiles).toHaveBeenCalledWith(["/private/tmp/openclaw/uploads/ok.txt"]);
expect(withPageNavigationRequestGuard).toHaveBeenCalledTimes(1);
expect(setInputFiles).toHaveBeenCalledTimes(1);
expect(assertPageNavigationCompletedSafely).toHaveBeenCalledTimes(1);
});
it("converts guarded loopback uploads to payloads when the browser filesystem is remote", async () => {
const { setInputFiles, elementHandle } = seedSingleLocatorPage();
await setInputFilesViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
browserFilesystemLocal: false,
targetId: "T1",
inputRef: "e7",
paths: ["/tmp/openclaw/uploads/ok.txt"],
ssrfPolicy: { dangerouslyAllowPrivateNetwork: true },
});
expect(stat).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt");
expect(readFile).toHaveBeenCalledWith("/private/tmp/openclaw/uploads/ok.txt");
expect(detectMime).toHaveBeenCalledWith({
buffer: Buffer.from("upload contents"),
filePath: "/private/tmp/openclaw/uploads/ok.txt",
});
expect(setInputFiles).toHaveBeenCalledWith([
{
name: "ok.txt",
mimeType: "text/plain",
buffer: Buffer.from("upload contents"),
lastModifiedMs: 1700000000000,
},
]);
expect(withPageNavigationRequestGuard).toHaveBeenCalledTimes(1);
expect(setInputFiles).toHaveBeenCalledTimes(1);
expect(elementHandle).not.toHaveBeenCalled();
});
it("throws and skips setInputFiles when use-time validation fails", async () => {
resolveStrictExistingUploadPaths.mockResolvedValueOnce({
ok: false,
@@ -108,9 +108,10 @@ describe("armFileUploadViaPlaywright upload path validation", () => {
await Promise.resolve();
await vi.waitFor(() => {
expect(fileChooser.setFiles).toHaveBeenCalledWith([
"/home/user/.openclaw/media/inbound/report.pdf",
]);
expect(fileChooser.setFiles).toHaveBeenCalledWith(
["/home/user/.openclaw/media/inbound/report.pdf"],
{ timeout: expect.any(Number) },
);
});
expect(fileChooser.setFiles).toHaveBeenCalledTimes(1);
expect(fileChooser.element).not.toHaveBeenCalled();
@@ -14,7 +14,9 @@ const tmpDirMocks = vi.hoisted(() => ({
resolvePreferredOpenClawTmpDir: vi.fn(() => "/tmp/openclaw"),
}));
const chromeMocks = vi.hoisted(() => ({
getChromeWebSocketUrl: vi.fn(async () => "ws://127.0.0.1/devtools/browser/mock"),
getChromeWebSocketEndpoint: vi.fn(async () => ({
url: "ws://127.0.0.1/devtools/browser/mock",
})),
}));
const clientFetchMocks = vi.hoisted(() => ({
resolveBrowserRateLimitMessage: vi.fn(() => undefined),
@@ -36,25 +36,33 @@ vi.mock("../pw-ai-module.js", () => ({
const { registerBrowserAgentActHookRoutes } = await import("./agent.act.hooks.js");
function createProfileContext() {
function createProfileContext(options?: {
attachOnly?: boolean;
driver?: "openclaw" | "extension";
tabUrl?: string;
}) {
return {
profile: {
attachOnly: options?.attachOnly ?? false,
cdpIsLoopback: true,
cdpUrl: "http://127.0.0.1:9222",
driver: "openclaw" as const,
driver: options?.driver ?? ("openclaw" as const),
name: "default",
},
ensureTabAvailable: vi.fn(async () => ({
targetId: "tab-1",
title: "Internal Admin",
url: "http://127.0.0.1:8080/admin",
url: options?.tabUrl ?? "http://127.0.0.1:8080/admin",
type: "page",
})),
listTabs: vi.fn(async () => []),
};
}
function createRouteContext(profileCtx: ReturnType<typeof createProfileContext>) {
function createRouteContext(
profileCtx: ReturnType<typeof createProfileContext>,
options?: { allowPrivateNetwork?: boolean },
) {
return {
forProfile: () => profileCtx,
mapTabError: vi.fn(toBrowserErrorResponse),
@@ -62,7 +70,9 @@ function createRouteContext(profileCtx: ReturnType<typeof createProfileContext>)
resolved: {
actionTimeoutMs: 60_000,
extraArgs: [],
ssrfPolicy: { dangerouslyAllowPrivateNetwork: false },
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: options?.allowPrivateNetwork === true,
},
},
}),
};
@@ -72,9 +82,15 @@ async function callHook(params: {
path: "/hooks/file-chooser" | "/hooks/dialog";
body: Record<string, unknown>;
profileCtx: ReturnType<typeof createProfileContext>;
allowPrivateNetwork?: boolean;
}) {
const { app, postHandlers } = createBrowserRouteApp();
registerBrowserAgentActHookRoutes(app, createRouteContext(params.profileCtx) as never);
registerBrowserAgentActHookRoutes(
app,
createRouteContext(params.profileCtx, {
allowPrivateNetwork: params.allowPrivateNetwork,
}) as never,
);
const handler = postHandlers.get(params.path);
expect(handler).toBeTypeOf("function");
@@ -144,4 +160,52 @@ describe("agent act hook current URL guard", () => {
}
},
);
it("keeps file chooser path handoff local for extension-backed profiles", async () => {
const profileCtx = createProfileContext({
driver: "extension",
tabUrl: "http://127.0.0.1:8080/upload",
});
const response = await callHook({
path: "/hooks/file-chooser",
body: { paths: ["/tmp/upload.txt"], ref: "upload-button" },
profileCtx,
allowPrivateNetwork: true,
});
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ ok: true });
expect(pwMocks.uploadViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
browserFilesystemLocal: true,
ref: "upload-button",
paths: ["/tmp/upload.txt"],
}),
);
});
it("sends loopback attach-only uploads as payloads for a separate browser filesystem", async () => {
const profileCtx = createProfileContext({
attachOnly: true,
tabUrl: "http://127.0.0.1:8080/upload",
});
const response = await callHook({
path: "/hooks/file-chooser",
body: { paths: ["/tmp/upload.txt"], ref: "upload-button" },
profileCtx,
allowPrivateNetwork: true,
});
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ ok: true });
expect(pwMocks.uploadViaPlaywright).toHaveBeenCalledWith(
expect.objectContaining({
browserFilesystemLocal: false,
ref: "upload-button",
paths: ["/tmp/upload.txt"],
}),
);
});
});
@@ -55,8 +55,9 @@ export function registerBrowserAgentActHookRoutes(
return;
}
const resolvedPaths = resolvedResult.paths;
const capabilities = getBrowserProfileCapabilities(profileCtx.profile);
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
if (capabilities.usesChromeMcp) {
if (element) {
return jsonError(res, 501, EXISTING_SESSION_LIMITS.hooks.uploadElement);
}
@@ -84,12 +85,14 @@ export function registerBrowserAgentActHookRoutes(
return;
}
const browserFilesystemLocal = capabilities.browserFilesystemLocal;
if (inputRef || element) {
if (ref) {
return jsonError(res, 400, "ref cannot be combined with inputRef/element");
}
await pw.setInputFilesViaPlaywright({
cdpUrl,
browserFilesystemLocal,
targetId: tab.targetId,
inputRef,
element,
@@ -99,6 +102,7 @@ export function registerBrowserAgentActHookRoutes(
} else if (ref) {
await pw.uploadViaPlaywright({
cdpUrl,
browserFilesystemLocal,
targetId: tab.targetId,
paths: resolvedPaths,
timeoutMs: timeoutMs ?? undefined,
@@ -109,9 +113,11 @@ export function registerBrowserAgentActHookRoutes(
} else {
await pw.armFileUploadViaPlaywright({
cdpUrl,
browserFilesystemLocal,
targetId: tab.targetId,
paths: resolvedPaths,
timeoutMs: timeoutMs ?? undefined,
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
}
res.json({ ok: true });
@@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
import type { BrowserRequest } from "./types.js";
const tabLookup = vi.hoisted(() => vi.fn());
const routeState = vi.hoisted(() => ({
profileCtx: {
profile: {
@@ -15,12 +17,13 @@ const routeState = vi.hoisted(() => ({
targetId: "7",
url: "http://127.0.0.1:8080/admin",
wsUrl: "ws://127.0.0.1/devtools/page/7",
wsLookup: tabLookup,
})),
},
}));
const cdpMocks = vi.hoisted(() => ({
getMainFrameDocumentIdentityViaCdp: vi.fn<() => Promise<string | undefined>>(
getMainFrameDocumentIdentityViaCdp: vi.fn<(_opts?: unknown) => Promise<string | undefined>>(
async () => "cdp:test-document",
),
snapshotAria: vi.fn(async () => ({
@@ -122,6 +125,7 @@ describe("local-managed browser snapshot routes", () => {
cdpMocks.getMainFrameDocumentIdentityViaCdp.mockReset().mockResolvedValue("cdp:test-document");
cdpMocks.snapshotAria.mockClear();
cdpMocks.snapshotRoleViaCdp.mockClear();
tabLookup.mockClear();
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockClear();
navigationGuardMocks.withBrowserNavigationPolicy.mockClear();
});
@@ -193,6 +197,22 @@ describe("local-managed browser snapshot routes", () => {
});
});
it("uses the tab lookup pin when reading delta document identity via CDP", async () => {
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockResolvedValue(undefined);
const handler = getSnapshotGetHandler();
const response = createBrowserRouteResponse();
await handler?.({ params: {}, query: { format: "ai", interactive: "true" } }, response.res);
expect(response.statusCode).toBe(200);
expect(cdpMocks.getMainFrameDocumentIdentityViaCdp).toHaveBeenCalledWith(
expect.objectContaining({
wsUrl: "ws://127.0.0.1/devtools/page/7",
lookup: tabLookup,
}),
);
});
it("disables deltas when no stable document identity is available", async () => {
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockResolvedValue(undefined);
cdpMocks.getMainFrameDocumentIdentityViaCdp.mockResolvedValue(undefined);
@@ -12,6 +12,7 @@ const cdpMocks = vi.hoisted(() => ({
stats: { lines: 1, chars: 15, refs: 0, interactive: 0 },
})),
}));
const tabLookup = vi.hoisted(() => vi.fn());
const profileContext = vi.hoisted(() => ({
profile: {
@@ -29,6 +30,7 @@ const profileContext = vi.hoisted(() => ({
targetId: "tab-1",
url: "https://example.com",
wsUrl: "ws://127.0.0.1:18800/devtools/page/tab-1",
wsLookup: tabLookup,
})),
}));
@@ -80,7 +82,7 @@ vi.mock("./agent.shared.js", () => ({
async (params: {
run: (ctx: {
profileCtx: typeof profileContext;
tab: { targetId: string; url: string; wsUrl: string };
tab: { targetId: string; url: string; wsUrl: string; wsLookup: typeof tabLookup };
cdpUrl: string;
}) => Promise<void>;
}) =>
@@ -90,6 +92,7 @@ vi.mock("./agent.shared.js", () => ({
targetId: "tab-1",
url: "https://example.com",
wsUrl: "ws://127.0.0.1:18800/devtools/page/tab-1",
wsLookup: tabLookup,
},
cdpUrl: "http://127.0.0.1:18800",
}),
@@ -136,6 +139,7 @@ describe("browser agent snapshot timeout routing", () => {
expect(cdpMocks.snapshotAria).toHaveBeenCalledWith(
expect.objectContaining({
wsUrl: "ws://127.0.0.1:18800/devtools/page/tab-1",
lookup: tabLookup,
timeoutMs: 4321,
}),
);
@@ -151,6 +155,7 @@ describe("browser agent snapshot timeout routing", () => {
expect(cdpMocks.snapshotRoleViaCdp).toHaveBeenCalledWith(
expect.objectContaining({
wsUrl: "ws://127.0.0.1:18800/devtools/page/tab-1",
lookup: tabLookup,
timeoutMs: 9876,
}),
);
@@ -169,6 +174,7 @@ describe("browser agent snapshot timeout routing", () => {
expect(response.statusCode).toBe(200);
expect(cdpMocks.captureScreenshot).toHaveBeenCalledWith(
expect.objectContaining({
lookup: tabLookup,
timeoutMs: 2_147_483_647,
}),
);
@@ -569,6 +569,7 @@ export function registerBrowserAgentSnapshotRoutes(
} else {
buffer = await captureScreenshot({
wsUrl: tab.wsUrl ?? "",
...(tab.wsLookup ? { lookup: tab.wsLookup } : {}),
fullPage,
format: type,
quality: type === "jpeg" ? 85 : undefined,
@@ -807,6 +808,7 @@ export function registerBrowserAgentSnapshotRoutes(
}
return await getMainFrameDocumentIdentityViaCdp({
wsUrl: tab.wsUrl,
...(tab.wsLookup ? { lookup: tab.wsLookup } : {}),
timeoutMs: plan.timeoutMs,
}).catch(() => undefined);
};
@@ -851,6 +853,7 @@ export function registerBrowserAgentSnapshotRoutes(
}
return await snapshotRoleViaCdp({
wsUrl: tab.wsUrl,
...(tab.wsLookup ? { lookup: tab.wsLookup } : {}),
urls: plan.urls,
timeoutMs: plan.timeoutMs,
maxChars: plan.resolvedMaxChars,
@@ -979,6 +982,7 @@ export function registerBrowserAgentSnapshotRoutes(
})()
: snapshotAria({
wsUrl: tab.wsUrl ?? "",
...(tab.wsLookup ? { lookup: tab.wsLookup } : {}),
limit: plan.limit,
timeoutMs: plan.timeoutMs,
});
@@ -288,7 +288,11 @@ async function runBrowserLiveProbe(profileCtx: ProfileContext, signal: AbortSign
summary: "No per-tab CDP WebSocket available for the lightweight live snapshot probe",
};
}
const snap = await snapshotAria({ wsUrl: tab.wsUrl, limit: 25 });
const snap = await snapshotAria({
wsUrl: tab.wsUrl,
...(tab.wsLookup ? { lookup: tab.wsLookup } : {}),
limit: 25,
});
return {
id: "live-snapshot",
label: "Live snapshot",
@@ -4,7 +4,9 @@ import { BROWSER_ERROR_REASONS, BrowserProfileUnavailableError } from "../errors
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
const cdpMocks = vi.hoisted(() => ({
getChromeWebSocketUrl: vi.fn(async () => "ws://127.0.0.1:18800/devtools/browser/test"),
getChromeWebSocketEndpoint: vi.fn(async () => ({
url: "ws://127.0.0.1:18800/devtools/browser/test",
})),
send: vi.fn(
async (
_method: string,
@@ -32,7 +34,7 @@ const pwMocks = vi.hoisted(() => ({
}));
vi.mock("../chrome.js", () => ({
getChromeWebSocketUrl: cdpMocks.getChromeWebSocketUrl,
getChromeWebSocketEndpoint: cdpMocks.getChromeWebSocketEndpoint,
}));
vi.mock("../cdp.helpers.js", () => ({
@@ -107,7 +109,7 @@ async function callGrant(
describe("browser permission routes", () => {
beforeEach(() => {
cdpMocks.getChromeWebSocketUrl.mockClear();
cdpMocks.getChromeWebSocketEndpoint.mockClear();
cdpMocks.send.mockReset().mockResolvedValue({});
cdpMocks.withCdpSocket.mockClear();
pwMocks.getPwAiModule.mockReset().mockResolvedValue(null);
@@ -163,7 +165,7 @@ describe("browser permission routes", () => {
grantMethod: "cdp",
});
expect(profileCtx.ensureBrowserAvailable).toHaveBeenCalled();
expect(cdpMocks.getChromeWebSocketUrl).toHaveBeenCalledWith(
expect(cdpMocks.getChromeWebSocketEndpoint).toHaveBeenCalledWith(
"http://127.0.0.1:18800",
1234,
undefined,
@@ -171,7 +173,7 @@ describe("browser permission routes", () => {
expect(cdpMocks.withCdpSocket).toHaveBeenCalledWith(
"ws://127.0.0.1:18800/devtools/browser/test",
expect.any(Function),
{ commandTimeoutMs: 1234, signal: expect.any(AbortSignal) },
{ commandTimeoutMs: 1234, lookup: undefined, signal: expect.any(AbortSignal) },
);
expect(cdpMocks.send).toHaveBeenCalledWith("Browser.grantPermissions", {
origin: "https://meet.google.com",
@@ -217,7 +219,7 @@ describe("browser permission routes", () => {
displayPresent: false,
},
});
expect(cdpMocks.getChromeWebSocketUrl).not.toHaveBeenCalled();
expect(cdpMocks.getChromeWebSocketEndpoint).not.toHaveBeenCalled();
});
it("rejects loose timeoutMs values before granting permissions", async () => {
@@ -230,7 +232,7 @@ describe("browser permission routes", () => {
expect(response.statusCode).toBe(400);
expect(response.body).toStrictEqual({ error: "timeoutMs must be a positive integer." });
expect(profileCtx.ensureBrowserAvailable).not.toHaveBeenCalled();
expect(cdpMocks.getChromeWebSocketUrl).not.toHaveBeenCalled();
expect(cdpMocks.getChromeWebSocketEndpoint).not.toHaveBeenCalled();
expect(cdpMocks.send).not.toHaveBeenCalled();
});
@@ -242,7 +244,7 @@ describe("browser permission routes", () => {
});
expect(response.statusCode).toBe(200);
expect(cdpMocks.getChromeWebSocketUrl).toHaveBeenCalledWith(
expect(cdpMocks.getChromeWebSocketEndpoint).toHaveBeenCalledWith(
"http://127.0.0.1:18800",
1000,
undefined,
@@ -270,7 +272,7 @@ describe("browser permission routes", () => {
);
expect(response.statusCode).toBe(200);
expect(cdpMocks.getChromeWebSocketUrl).toHaveBeenCalledWith(
expect(cdpMocks.getChromeWebSocketEndpoint).toHaveBeenCalledWith(
"https://browser.example:9222",
5000,
{
@@ -9,7 +9,7 @@ import { formatErrorMessage } from "../../infra/errors.js";
import type { SsrFPolicy } from "../../infra/net/ssrf.js";
import { resolveCdpControlPolicy } from "../cdp-reachability-policy.js";
import { withCdpSocket } from "../cdp.helpers.js";
import { getChromeWebSocketUrl } from "../chrome.js";
import { getChromeWebSocketEndpoint, type ChromeWebSocketEndpoint } from "../chrome.js";
import { BrowserProfileUnavailableError, toBrowserErrorResponse } from "../errors.js";
import { getPwAiModule } from "../pw-ai-module.js";
import type { BrowserRouteContext } from "../server-context.js";
@@ -55,6 +55,7 @@ async function grantPermissions(params: {
requiredPermissions: string[];
optionalPermissions: string[];
timeoutMs: number;
wsLookup?: ChromeWebSocketEndpoint["lookup"];
ssrfPolicy?: SsrFPolicy;
signal: AbortSignal;
}) {
@@ -112,7 +113,7 @@ async function grantPermissions(params: {
});
unsupportedPermissions = params.optionalPermissions;
},
{ commandTimeoutMs: params.timeoutMs, signal: params.signal },
{ commandTimeoutMs: params.timeoutMs, lookup: params.wsLookup, signal: params.signal },
);
params.signal.throwIfAborted();
return {
@@ -172,19 +173,20 @@ export function registerBrowserPermissionRoutes(
profileCtx.profile,
ctx.state().resolved.ssrfPolicy,
);
const wsUrl = await getChromeWebSocketUrl(
const endpoint = await getChromeWebSocketEndpoint(
profileCtx.profile.cdpUrl,
timeoutMs,
cdpPolicy,
);
signal.throwIfAborted();
if (!wsUrl) {
if (!endpoint) {
throw new BrowserProfileUnavailableError("browser CDP WebSocket unavailable");
}
return await grantPermissions({
profileCtx,
targetId,
wsUrl,
wsUrl: endpoint.url,
wsLookup: endpoint.lookup,
origin,
requiredPermissions,
optionalPermissions,
@@ -3,7 +3,10 @@
* launch/restart, Chrome MCP attach, and profile stop handling.
*/
import fs from "node:fs";
import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js";
import {
assertChromeMcpCdpTransportAllowed,
resolveCdpReachabilityPolicy,
} from "./cdp-reachability-policy.js";
import {
CHROME_MCP_ATTACH_READY_POLL_MS,
CHROME_MCP_ATTACH_READY_WINDOW_MS,
@@ -190,6 +193,7 @@ export function createProfileAvailability({
// countChromeMcpTabs creates the session if needed — no separate availability call required.
// Status probes opt into ephemeral so they reuse a cached attach session if one exists,
// but do not seed a new persistent session as a side effect of read-only status calls.
assertChromeMcpCdpTransportAllowed(profile, getCdpReachabilityPolicy());
const { countChromeMcpTabs } = await getChromeMcpModule();
const callOptions: { timeoutMs?: number; ephemeral?: boolean; signal?: AbortSignal } = {};
if (timeoutMs != null) {
@@ -215,6 +219,7 @@ export function createProfileAvailability({
const isTransportAvailable = async (timeoutMs?: number, signal?: AbortSignal) => {
if (capabilities.usesChromeMcp) {
assertChromeMcpCdpTransportAllowed(profile, getCdpReachabilityPolicy());
const { ensureChromeMcpAvailable } = await getChromeMcpModule();
await ensureChromeMcpAvailable(profile.name, profile, {
ephemeral: true,
@@ -437,6 +442,7 @@ export function createProfileAvailability({
`Browser user data directory not found for profile "${profile.name}": ${profile.userDataDir}`,
);
}
assertChromeMcpCdpTransportAllowed(profile, getCdpReachabilityPolicy());
const { ensureChromeMcpAvailable } = await getChromeMcpModule();
await ensureChromeMcpAvailable(profile.name, profile, { signal });
await waitForChromeMcpReadyAfterAttach(signal);
@@ -37,6 +37,7 @@ type ChromeLiveProfile = {
name?: string;
cdpUrl?: string;
userDataDir?: string;
mcpArgs?: string[];
};
function deferred<T>() {
@@ -128,6 +129,50 @@ afterEach(() => {
});
describe("browser server-context existing-session profile", () => {
it("fails closed for Chrome MCP endpoint mcpArgs under the default CDP policy", async () => {
fs.mkdirSync("/tmp/brave-profile", { recursive: true });
const state = makeState();
state.resolved.ssrfPolicy = {};
state.resolved.profiles["chrome-live"] = {
...state.resolved.profiles["chrome-live"],
mcpArgs: ["--browserUrl", "http://127.0.0.1:9222"],
};
const live = createBrowserRouteContext({ getState: () => state }).forProfile("chrome-live");
await expect(live.listTabs()).rejects.toThrow(/Chrome MCP cannot carry that pinned transport/);
await expect(live.openTab("https://example.com")).rejects.toThrow(
/remove cdpUrl and browserUrl\/wsEndpoint mcpArgs/,
);
await expect(live.ensureBrowserAvailable()).rejects.toThrow(/host-local Chrome profile/);
expect(chromeMcp.listChromeMcpTabs).not.toHaveBeenCalled();
expect(chromeMcp.openChromeMcpTab).not.toHaveBeenCalled();
expect(chromeMcp.ensureChromeMcpAvailable).not.toHaveBeenCalled();
});
it("fails closed for explicit Chrome MCP cdpUrl under explicit restrictive CDP policy", async () => {
fs.mkdirSync("/tmp/brave-profile", { recursive: true });
const state = makeState();
state.resolved.ssrfPolicy = { dangerouslyAllowPrivateNetwork: false };
state.resolved.profiles["chrome-live"] = {
...state.resolved.profiles["chrome-live"],
cdpUrl: "http://127.0.0.1:9222",
};
const live = createBrowserRouteContext({ getState: () => state }).forProfile("chrome-live");
await expect(live.listTabs()).rejects.toThrow(/Chrome MCP cannot carry that pinned transport/);
await expect(live.openTab("https://93.184.216.34")).rejects.toThrow(
/Use driver "openclaw" for guarded CDP endpoints/,
);
await expect(live.ensureBrowserAvailable()).rejects.toThrow(
/remove cdpUrl and browserUrl\/wsEndpoint mcpArgs/,
);
expect(chromeMcp.listChromeMcpTabs).not.toHaveBeenCalled();
expect(chromeMcp.openChromeMcpTab).not.toHaveBeenCalled();
expect(chromeMcp.ensureChromeMcpAvailable).not.toHaveBeenCalled();
});
it("reports attach-only profiles as running when the MCP session is available but no page is selected", async () => {
fs.mkdirSync("/tmp/brave-profile", { recursive: true });
const state = makeState();
@@ -176,6 +221,7 @@ describe("browser server-context existing-session profile", () => {
state.resolved.profiles["chrome-live"],
"chrome-live browser profile",
);
state.resolved.ssrfPolicy = undefined;
state.resolved.profiles["chrome-live"] = {
...chromeLiveProfile,
cdpUrl: "http://openclaw:relay-token@127.0.0.1:9222",
@@ -78,6 +78,8 @@ describe("browser remote profile fallback and attachOnly behavior", () => {
const tabs = await remote.listTabs();
expect(tabs.map((t) => t.targetId)).toEqual(["T1"]);
expect(tabs[0]?.wsLookup).toBeTypeOf("function");
expect(JSON.stringify(tabs[0])).not.toContain("wsLookup");
});
it("filters browser-internal and non-page targets from raw CDP tab listing", async () => {
@@ -5,6 +5,7 @@ import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { formatErrorMessage } from "../infra/errors.js";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { assertChromeMcpCdpTransportAllowed } from "./cdp-reachability-policy.js";
import { fetchOk, normalizeCdpHttpBaseForJsonEndpoints } from "./cdp.helpers.js";
import { appendCdpPath } from "./cdp.js";
import { getChromeMcpModule } from "./chrome-mcp.runtime.js";
@@ -61,7 +62,11 @@ function mergeOpenedTabSnapshot(
return tabs;
}
const merged = tabs.slice();
merged[index] = { ...listedTab, wsUrl: openedTab.wsUrl };
merged[index] = {
...listedTab,
wsUrl: openedTab.wsUrl,
...(openedTab.wsLookup ? { wsLookup: openedTab.wsLookup } : {}),
};
return merged;
}
@@ -249,6 +254,7 @@ export function createProfileSelectionOps({
const resolvedTargetId = await resolveTargetIdOrThrow(targetId, options);
if (capabilities.usesChromeMcp) {
assertChromeMcpCdpTransportAllowed(profile, getCdpControlPolicy());
const { focusChromeMcpTab } = await getChromeMcpModule();
await focusChromeMcpTab(profile.name, resolvedTargetId, profile, options);
runtime.lastTargetId = resolvedTargetId;
@@ -283,6 +289,7 @@ export function createProfileSelectionOps({
const resolvedTargetId = await resolveTargetIdOrThrow(targetId, options);
if (capabilities.usesChromeMcp) {
assertChromeMcpCdpTransportAllowed(profile, getCdpControlPolicy());
const { closeChromeMcpTab } = await getChromeMcpModule();
await closeChromeMcpTab(profile.name, resolvedTargetId, profile, options);
} else {
@@ -3,7 +3,10 @@
*/
import { sleepWithAbort } from "openclaw/plugin-sdk/runtime-env";
import { resolveBrowserNavigationProxyMode } from "./browser-proxy-mode.js";
import { resolveCdpControlPolicy } from "./cdp-reachability-policy.js";
import {
assertChromeMcpCdpTransportAllowed,
resolveCdpControlPolicy,
} from "./cdp-reachability-policy.js";
import { isSelectableCdpBrowserTarget } from "./cdp-target-filter.js";
import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js";
import {
@@ -116,6 +119,7 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr
const readTabs = async (options?: BrowserOperationOptions): Promise<BrowserTab[]> => {
if (capabilities.usesChromeMcp) {
assertChromeMcpCdpTransportAllowed(profile, getCdpControlPolicy());
const { listChromeMcpTabs } = await getChromeMcpModule();
return await listChromeMcpTabs(profile.name, profile, options);
}
@@ -168,10 +172,13 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr
continue;
}
if (tab.wsUrl) {
await assertCdpEndpointAllowed(tab.wsUrl, cdpControlPolicy, {
const wsPin = await assertCdpEndpointAllowed(tab.wsUrl, cdpControlPolicy, {
source: "discovered",
configuredUrl: profile.cdpUrl,
});
if (wsPin?.lookup) {
tab.wsLookup = wsPin.lookup;
}
}
tabs.push(tab);
}
@@ -288,12 +295,14 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr
if (capabilities.usesChromeMcp) {
await assertBrowserNavigationAllowed({ url, ...ssrfPolicyOpts });
const cdpPolicy = getCdpControlPolicy();
assertChromeMcpCdpTransportAllowed(profile, cdpPolicy);
const { openChromeMcpTab } = await getChromeMcpModule();
const cdpTimeouts = getRemoteCdpActionTimeouts();
const page = await openChromeMcpTab(profile.name, url, profile, {
signal: opts?.signal,
timeoutMs: opts?.timeoutMs,
cdpPolicy: getCdpControlPolicy(),
cdpPolicy,
...(cdpTimeouts ? { cdpTimeouts } : {}),
});
await assertBrowserNavigationResultAllowed({ url: page.url, ...ssrfPolicyOpts });
@@ -434,6 +443,12 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr
}
await assertBrowserNavigationResultAllowed({ url: resolvedUrl, ...ssrfPolicyOpts });
const wsUrl = normalizeWsUrl(created.webSocketDebuggerUrl, profile.cdpUrl);
const wsPin = wsUrl
? await assertCdpEndpointAllowed(wsUrl, getCdpControlPolicy(), {
source: "discovered",
configuredUrl: profile.cdpUrl,
})
: undefined;
const committedUrl = wsUrl
? await waitForCdpCommittedNavigationUrl({
wsUrl,
@@ -452,6 +467,7 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr
title: created.title ?? "",
url: resolvedUrl,
wsUrl,
...(wsPin?.lookup ? { wsLookup: wsPin.lookup } : {}),
type: created.type,
},
opts,
@@ -465,6 +481,7 @@ export function createProfileTabOps({ profile, state, runtime }: TabOpsDeps): Pr
title: created.title ?? "",
url: committedUrl,
wsUrl,
...(wsPin?.lookup ? { wsLookup: wsPin.lookup } : {}),
type: created.type,
},
opts,
@@ -0,0 +1,139 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { withBrowserFetchPreconnect } from "../../test-fetch.js";
import "../test-support/browser-security.mock.js";
import "./server-context.chrome-test-harness.js";
import * as cdpHelpersModule from "./cdp.helpers.js";
import * as cdpModule from "./cdp.js";
import {
createTestBrowserRouteContext,
makeState,
originalFetch,
} from "./server-context.remote-tab-ops.harness.js";
afterEach(async () => {
const { closePlaywrightBrowserConnection } = await import("./pw-session.js");
await closePlaywrightBrowserConnection().catch(() => {});
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
function seedRunningProfileState(
state: ReturnType<typeof makeState>,
profileName = "openclaw",
): void {
(state.profiles as Map<string, unknown>).set(profileName, {
profile: { name: profileName },
running: { pid: 1234, proc: { on: vi.fn() } },
lastTargetId: null,
});
}
function fetchCallUrls(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls.map(([url]) => String(url));
}
describe("browser server-context tab selection lookup state", () => {
it("preserves the opened tab lookup when a same-target listing lacks a WebSocket URL", async () => {
vi.spyOn(cdpModule, "createTargetViaCdp").mockRejectedValue(new Error("raw create failed"));
vi.spyOn(cdpModule, "waitForCdpCommittedNavigationUrl").mockResolvedValue(undefined);
let listCalls = 0;
const lookupHosts: string[] = [];
const fetchJson = vi.spyOn(cdpHelpersModule, "fetchJson").mockImplementation(async (url) => {
if (url.includes("/json/list")) {
listCalls += 1;
return listCalls === 1
? []
: [
{
id: "NEW",
title: "Listed",
url: "about:blank",
type: "page",
},
];
}
if (url.includes("/json/new")) {
return {
id: "NEW",
title: "Opened",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1:18800/devtools/page/NEW",
type: "page",
};
}
throw new Error(`unexpected fetchJson: ${url}`);
});
vi.spyOn(cdpHelpersModule, "assertCdpEndpointAllowed").mockImplementation(async () => ({
hostname: "browser.example",
addresses: ["127.0.0.1"],
lookup: ((hostname: string, _options: unknown, callback?: unknown) => {
lookupHosts.push(hostname);
if (typeof callback === "function") {
callback(null, "127.0.0.1", 4);
}
}) as never,
}));
const state = makeState("openclaw");
state.resolved.ssrfPolicy = {};
seedRunningProfileState(state);
const openclaw = createTestBrowserRouteContext({ getState: () => state }).forProfile(
"openclaw",
);
const selected = await openclaw.ensureTabAvailable();
expect(selected).toEqual(
expect.objectContaining({
targetId: "NEW",
title: "Listed",
url: "about:blank",
wsUrl: "ws://127.0.0.1:18800/devtools/page/NEW",
}),
);
expect(selected.wsLookup).toBeTypeOf("function");
selected.wsLookup?.("browser.example", {}, () => {});
expect(lookupHosts).toEqual(["browser.example"]);
expect(fetchJson.mock.calls.some(([url]) => url.includes("/json/new"))).toBe(true);
});
it("resolves friendly tab references before backend focus and close calls", async () => {
const fetchMock = vi.fn(async (url: unknown) => {
const value = String(url);
if (value.includes("/json/list")) {
return {
ok: true,
json: async () => [
{
id: "DOCS_RAW",
title: "Docs",
url: "https://docs.example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/DOCS_RAW",
type: "page",
},
],
} as unknown as Response;
}
if (value.includes("/json/activate/DOCS_RAW") || value.includes("/json/close/DOCS_RAW")) {
return { ok: true } as unknown as Response;
}
throw new Error(`unexpected fetch: ${value}`);
});
global.fetch = withBrowserFetchPreconnect(fetchMock);
const state = makeState("openclaw");
const ctx = createTestBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
await openclaw.labelTab("DOCS_RAW", "docs");
await expect(openclaw.ensureTabAvailable("t1")).resolves.toEqual(
expect.objectContaining({ targetId: "DOCS_RAW" }),
);
await openclaw.focusTab("docs");
await openclaw.closeTab("t1");
expect(fetchCallUrls(fetchMock).some((url) => url.includes("/json/activate/DOCS_RAW"))).toBe(
true,
);
expect(fetchCallUrls(fetchMock).some((url) => url.includes("/json/close/DOCS_RAW"))).toBe(true);
});
});
@@ -984,45 +984,4 @@ describe("browser server-context tab selection state", () => {
}),
]);
});
it("resolves friendly tab references before backend focus and close calls", async () => {
const fetchMock = vi.fn(async (url: unknown) => {
const value = String(url);
if (value.includes("/json/list")) {
return {
ok: true,
json: async () => [
{
id: "DOCS_RAW",
title: "Docs",
url: "https://docs.example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/DOCS_RAW",
type: "page",
},
],
} as unknown as Response;
}
if (value.includes("/json/activate/DOCS_RAW") || value.includes("/json/close/DOCS_RAW")) {
return { ok: true } as unknown as Response;
}
throw new Error(`unexpected fetch: ${value}`);
});
global.fetch = withBrowserFetchPreconnect(fetchMock);
const state = makeState("openclaw");
const ctx = createTestBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
await openclaw.labelTab("DOCS_RAW", "docs");
await expect(openclaw.ensureTabAvailable("t1")).resolves.toEqual(
expect.objectContaining({ targetId: "DOCS_RAW" }),
);
await openclaw.focusTab("docs");
await openclaw.closeTab("t1");
expect(fetchCallUrls(fetchMock).some((url) => url.includes("/json/activate/DOCS_RAW"))).toBe(
true,
);
expect(fetchCallUrls(fetchMock).some((url) => url.includes("/json/close/DOCS_RAW"))).toBe(true);
});
});
@@ -2,6 +2,7 @@
* SSRF policy helpers for Browser routes that need one-off hostname grants.
*/
import { isPrivateNetworkAllowedByPolicy, type SsrFPolicy } from "../infra/net/ssrf.js";
import { matchesHostnameAllowlist, normalizeHostname } from "../sdk-security-runtime.js";
// Exact-host CDP scoping replaces allowedHostnames. Preserve whether the source
// policy allowed authority changes before that synthetic allowlist was added.
@@ -20,6 +21,27 @@ export function allowsDiscoveredCdpAuthorityChange(ssrfPolicy?: SsrFPolicy): boo
);
}
/** Return true when policy already trusts this hostname as a private-network destination. */
export function isCdpHostnameTrustedByPolicy(
ssrfPolicy: SsrFPolicy | undefined,
hostname: string,
): boolean {
const normalizedHostname = normalizeHostname(hostname);
if (!normalizedHostname) {
return false;
}
const allowedHostnames = (ssrfPolicy?.allowedHostnames ?? [])
.map((pattern) => normalizeHostname(pattern))
.filter(Boolean);
if (allowedHostnames.length === 0) {
return isPrivateNetworkAllowedByPolicy(ssrfPolicy);
}
if (allowedHostnames.some((pattern) => pattern === "*" || pattern === "*.")) {
return true;
}
return matchesHostnameAllowlist(normalizedHostname, allowedHostnames);
}
/** Returns an SSRF policy restricted to one exact control-plane hostname. */
export function withExactHostnamePolicy(
ssrfPolicy: SsrFPolicy | undefined,
@@ -35,10 +35,9 @@ type RegistrationFetchOptions = {
const HERMETIC_PUBLIC_LOOKUP_ADDRESS = "93.184.216.34";
const hermeticPublicLookup: LookupFn = (async (_hostname: string, _options?: unknown) => ({
address: HERMETIC_PUBLIC_LOOKUP_ADDRESS,
family: 4,
})) as LookupFn;
const hermeticPublicLookup: LookupFn = async () => [
{ address: HERMETIC_PUBLIC_LOOKUP_ADDRESS, family: 4 },
];
async function startLocalServer(
handler: (req: IncomingMessage, res: ServerResponse) => void,
+3 -4
View File
@@ -43,10 +43,9 @@ type StreamingRequest = {
const serverStops: Array<() => Promise<void>> = [];
const HERMETIC_PUBLIC_LOOKUP_ADDRESS = "93.184.216.34";
const hermeticPublicLookup: LookupFn = (async (_hostname: string, _options?: unknown) => ({
address: HERMETIC_PUBLIC_LOOKUP_ADDRESS,
family: 4,
})) as LookupFn;
const hermeticPublicLookup: LookupFn = async () => [
{ address: HERMETIC_PUBLIC_LOOKUP_ADDRESS, family: 4 },
];
async function readRequestBody(req: IncomingMessage): Promise<string> {
let body = "";
+5 -1
View File
@@ -677,6 +677,10 @@ async function safeFetch(params: {
}
if (!hasDispatcher) {
const lookupFn: LookupFn = async (hostname) => {
const resolved = await resolveFn(hostname);
return [{ ...resolved, family: resolved.address.includes(":") ? 6 : 4 }];
};
const guarded = await fetchWithSsrFGuard({
url: currentUrl,
fetchImpl: resolveGuardedFetchImpl({
@@ -690,7 +694,7 @@ async function safeFetch(params: {
maxRedirects: MAX_SAFE_REDIRECTS,
requireHttps: true,
policy: resolveMediaSsrfPolicy(params.allowHosts),
lookupFn: resolveFn as LookupFn,
lookupFn,
retainAuthorizationRedirectHostnameAllowlist:
resolveRetainedAuthorizationRedirectHostnameAllowlist(params.authorizationAllowHosts),
auditContext: "msteams.attachment",
+43 -16
View File
@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawCrablineChannelDriverSelection } from "@openclaw/crabline";
import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
import { assertQaSuiteArtifactWritten } from "./artifact-assertion.js";
import {
hasQaCrablineArtifactPath,
@@ -25,6 +26,28 @@ type QaCrablineChannelDriverSmokeResult = Awaited<
ReturnType<QaCrablineRuntime["runOpenClawCrablineChannelDriverSmoke"]>
>;
/** Atomically replaces each file in order; summary-last is a completion signal, not a set transaction. */
export async function publishQaSuiteArtifactFiles(params: {
outputDir: string;
files: readonly { content: string | Uint8Array; filePath: string }[];
}) {
await fs.mkdir(params.outputDir, { recursive: true });
const dirMode = (await fs.stat(params.outputDir)).mode & 0o7777;
for (const file of params.files) {
await replaceFileAtomic({
filePath: file.filePath,
content: file.content,
dirMode,
mode: 0o600,
preserveExistingMode: true,
tempPrefix: `${path.basename(file.filePath)}.qa-artifact`,
syncTempFile: true,
syncParentDir: true,
throwOnCleanupError: true,
});
}
}
export type QaSuiteSummaryJsonParams = {
scenarios: QaSuiteScenarioResult[];
startedAt: Date;
@@ -264,22 +287,26 @@ export async function writeQaSuiteArtifacts(params: {
);
}
const writeEvidenceFile = params.writeEvidenceFile ?? true;
await fs.writeFile(reportPath, report, "utf8");
if (evidence && writeEvidenceFile) {
await fs.writeFile(evidencePath, `${JSON.stringify(evidence, null, 2)}\n`, "utf8");
}
await fs.writeFile(
summaryPath,
`${JSON.stringify(
buildQaSuiteSummaryJson({
...params,
channelDriverSelection: effectiveChannelDriverSelection,
}),
null,
2,
)}\n`,
"utf8",
);
await publishQaSuiteArtifactFiles({
outputDir: params.outputDir,
files: [
{ filePath: reportPath, content: report },
...(evidence && writeEvidenceFile
? [{ filePath: evidencePath, content: `${JSON.stringify(evidence, null, 2)}\n` }]
: []),
{
filePath: summaryPath,
content: `${JSON.stringify(
buildQaSuiteSummaryJson({
...params,
channelDriverSelection: effectiveChannelDriverSelection,
}),
null,
2,
)}\n`,
},
],
});
await assertQaSuiteArtifactWritten("report", reportPath);
await assertQaSuiteArtifactWritten("summary", summaryPath);
if (evidence && writeEvidenceFile) {
@@ -4,8 +4,10 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QaSuiteInfraError } from "./errors.js";
import type { QaLabServerHandle } from "./lab-server.types.js";
import type { QaTransportAdapter } from "./qa-transport.js";
import { makeQaSuiteTestScenario } from "./suite-test-helpers.js";
import type { QaSuiteScenarioResult } from "./suite.js";
import { throwQaSuiteCleanupErrors } from "./suite.js";
import { qaSuiteProgressTesting, throwQaSuiteCleanupErrors } from "./suite.js";
import type {
QaTestFileScenario,
QaTestFileScenarioRunResult,
@@ -14,11 +16,13 @@ import type {
const {
crablineRuntimeLoads,
prepareDockerE2eEnvironment,
replaceFileAtomicMock,
runQaFlowSuite,
runQaTestFileScenarios,
} = vi.hoisted(() => ({
crablineRuntimeLoads: vi.fn(),
prepareDockerE2eEnvironment: vi.fn(),
replaceFileAtomicMock: vi.fn(),
runQaFlowSuite: vi.fn(),
runQaTestFileScenarios: vi.fn(),
}));
@@ -43,6 +47,12 @@ vi.mock("./test-file-scenario-docker-batch.js", async (importOriginal) => ({
prepareDockerE2eEnvironment,
}));
vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/security-runtime")>();
replaceFileAtomicMock.mockImplementation(actual.replaceFileAtomic);
return { ...actual, replaceFileAtomic: replaceFileAtomicMock };
});
import { runQaSuite, runQaSuiteWithInfraRetry } from "./suite-launch.runtime.js";
const tempRoots: string[] = [];
@@ -120,8 +130,67 @@ function mockFlowPartitionFailures(failuresByScenarioId: ReadonlyMap<string, rea
return attempts;
}
async function expectArtifactPublicationFailurePreservesPrior(params: {
canonicalFileNames: readonly string[];
failedFileName: string;
outputDir: string;
publish: () => Promise<unknown>;
}) {
const sentinels = new Map(
params.canonicalFileNames.map((fileName) => [fileName, `prior ${fileName}\n`]),
);
await fs.mkdir(params.outputDir, { recursive: true, mode: 0o750 });
await fs.chmod(params.outputDir, 0o750);
for (const [fileName, sentinel] of sentinels) {
const finalPath = path.join(params.outputDir, fileName);
await fs.writeFile(finalPath, sentinel, { encoding: "utf8", mode: 0o640 });
await fs.chmod(finalPath, 0o640);
}
const actualSecurityRuntime = await vi.importActual<
typeof import("openclaw/plugin-sdk/security-runtime")
>("openclaw/plugin-sdk/security-runtime");
const publicationOrder: string[] = [];
const failSelectedArtifact = async (options: Parameters<typeof replaceFileAtomicMock>[0]) => {
publicationOrder.push(path.basename(options.filePath));
return await actualSecurityRuntime.replaceFileAtomic({
...options,
...(path.basename(options.filePath) === params.failedFileName
? {
beforeRename: async ({ tempPath }: { tempPath: string }) => {
await fs.writeFile(tempPath, "partial replacement\n", "utf8");
throw Object.assign(new Error("injected QA artifact publication failure"), {
code: "EIO",
});
},
}
: {}),
});
};
await replaceFileAtomicMock.withImplementation(failSelectedArtifact, async () => {
await expect(params.publish()).rejects.toMatchObject({ code: "EIO" });
});
const selectedPath = path.join(params.outputDir, params.failedFileName);
await expect(fs.readFile(selectedPath, "utf8")).resolves.toBe(
sentinels.get(params.failedFileName),
);
if (process.platform !== "win32") {
expect((await fs.stat(selectedPath)).mode & 0o777).toBe(0o640);
expect((await fs.stat(params.outputDir)).mode & 0o7777).toBe(0o750);
}
const selectedIndex = params.canonicalFileNames.indexOf(params.failedFileName);
expect(publicationOrder).toEqual(params.canonicalFileNames.slice(0, selectedIndex + 1));
expect(
(await fs.readdir(params.outputDir)).filter((entry) =>
entry.startsWith(`${params.failedFileName}.qa-artifact.`),
),
).toEqual([]);
}
describe("qa suite runtime launcher", () => {
beforeEach(() => {
replaceFileAtomicMock.mockClear();
runQaFlowSuite.mockReset();
runQaTestFileScenarios.mockReset();
prepareDockerE2eEnvironment.mockReset();
@@ -1225,6 +1294,62 @@ describe("qa suite runtime launcher", () => {
);
});
it.each([
{ kind: "report", fileName: "qa-suite-report.md" },
{ kind: "evidence", fileName: "qa-evidence.json" },
{ kind: "summary", fileName: "qa-suite-summary.json" },
])(
"preserves the prior standard $kind artifact when atomic publication fails",
async ({ fileName }) => {
const outputDir = await makeTempRepo("qa-suite-standard-artifact-atomic-");
await expectArtifactPublicationFailurePreservesPrior({
canonicalFileNames: ["qa-suite-report.md", "qa-evidence.json", "qa-suite-summary.json"],
failedFileName: fileName,
outputDir,
publish: async () =>
await qaSuiteProgressTesting.writeQaSuiteArtifacts({
outputDir,
startedAt: new Date("2026-08-12T00:00:00.000Z"),
finishedAt: new Date("2026-08-12T00:01:00.000Z"),
scenarios: [{ name: "Atomic publication", status: "pass", steps: [] }],
scenarioDefinitions: [makeQaSuiteTestScenario("channel-chat-baseline")],
transport: {
id: "qa-channel",
createReportNotes: () => [],
} as unknown as QaTransportAdapter,
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.6-luna",
alternateModel: "mock-openai/gpt-5.6-luna-alt",
fastMode: true,
concurrency: 1,
}),
});
},
);
it.each([
{ kind: "evidence", fileName: "qa-evidence.json" },
{ kind: "report", fileName: "qa-suite-report.md" },
{ kind: "summary", fileName: "qa-suite-summary.json" },
])(
"preserves the prior unified $kind artifact when atomic publication fails",
async ({ fileName }) => {
const repoRoot = await makeTempRepo("qa-suite-unified-artifact-atomic-");
const outputDir = path.join(repoRoot, ".artifacts", "qa-e2e", "artifact-atomic");
await expectArtifactPublicationFailurePreservesPrior({
canonicalFileNames: ["qa-evidence.json", "qa-suite-report.md", "qa-suite-summary.json"],
failedFileName: fileName,
outputDir,
publish: async () =>
await runQaSuite({
repoRoot,
outputDir: ".artifacts/qa-e2e/artifact-atomic",
scenarioIds: ["control-ui-chat-flow-playwright"],
}),
});
},
);
it("aggregates mixed-kind progress through the parent lab", async () => {
const repoRoot = await makeTempRepo("qa-suite-mixed-progress-");
const scenarioRuns: Array<Parameters<QaLabServerHandle["setScenarioRun"]>[0]> = [];
@@ -28,6 +28,7 @@ import {
type QaSeedScenarioWithSource,
} from "./scenario-catalog.js";
import { expandQaScenarioExecutionCells, type QaScenarioExecutionCell } from "./scenario-lane.js";
import { publishQaSuiteArtifactFiles } from "./suite-artifacts.js";
import {
mapQaSuiteWithConcurrency,
normalizeQaSuiteConcurrency,
@@ -705,7 +706,6 @@ async function writeUnifiedQaSuiteArtifacts(params: {
scenarios: readonly QaSuiteScenarioResult[];
startedAt: Date;
}) {
await fs.mkdir(params.outputDir, { recursive: true });
const evidencePath = path.join(params.outputDir, QA_EVIDENCE_FILENAME);
const reportPath = path.join(params.outputDir, "qa-suite-report.md");
const summaryPath = path.join(params.outputDir, "qa-suite-summary.json");
@@ -729,9 +729,14 @@ async function writeUnifiedQaSuiteArtifacts(params: {
scenarios: [...params.scenarios],
startedAt: params.startedAt,
}) satisfies QaSuiteSummaryJson;
await fs.writeFile(evidencePath, `${JSON.stringify(params.evidence, null, 2)}\n`, "utf8");
await fs.writeFile(reportPath, report, "utf8");
await fs.writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
await publishQaSuiteArtifactFiles({
outputDir: params.outputDir,
files: [
{ filePath: evidencePath, content: `${JSON.stringify(params.evidence, null, 2)}\n` },
{ filePath: reportPath, content: report },
{ filePath: summaryPath, content: `${JSON.stringify(summary, null, 2)}\n` },
],
});
return {
evidencePath,
outputDir: params.outputDir,
+9
View File
@@ -608,6 +608,15 @@ describe("qa suite", () => {
evidence?: unknown;
};
expect(summary.evidence).toBeUndefined();
if (process.platform !== "win32") {
for (const artifactPath of [
artifacts.reportPath,
artifacts.evidencePath,
artifacts.summaryPath,
]) {
expect((await fs.stat(artifactPath)).mode & 0o777).toBe(0o600);
}
}
} finally {
await fs.rm(outputDir, { recursive: true, force: true });
}
+1 -1
View File
@@ -1967,7 +1967,7 @@
"test:unit:fast:audit": "node --import tsx scripts/test-unit-fast-audit.mts",
"test:voicecall:closedloop": "node --import tsx scripts/test-voicecall-closedloop.mts",
"test:watch": "node --import tsx scripts/test-projects.mts --watch",
"test:windows:ci": "node --import tsx scripts/test-projects.mts src/shared/runtime-import.test.ts src/config/sessions/session-accessor.sqlite-archive.worker.test.ts src/commands/doctor-gateway-auth-token.windows.test.ts src/agents/tools/media-tool-file-url.windows.test.ts src/media/local-media-path.windows.test.ts src/infra/sqlite-snapshot.test.ts src/infra/ssh-client.windows.test.ts src/infra/ports.test.ts src/infra/advertised-lan-host.windows.test.ts src/infra/update-managed-service-handoff-command.test.ts src/infra/update-managed-service-handoff-lifecycle.test.ts src/infra/exec-allowlist-pattern.test.ts src/infra/executable-path.test.ts src/infra/process-env.test.ts src/infra/fs-safe-remove.test.ts src/snapshot/local-repository.windows.test.ts src/state/openclaw-database-paths.windows.test.ts src/commands/backup-verify.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/test-utils/openclaw-test-state.test.ts src/agents/provider-local-service.env-case.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/agents/bash-tools.exec.script-preflight.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/process/terminal-pty.test.ts src/plugin-sdk/node-host.test.ts src/tui/tui.resolve-codex-bin.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/auto-reply/usage-bar/template.windows.test.ts src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts src/media-understanding/attachments.file-url.windows.test.ts src/utils.test.ts src/commands/agents.commands.list.test.ts src/cli/daemon-cli/status.print.test.ts src/cli/mcp-cli.path-case.windows.test.ts extensions/memory-core/src/memory-extra-file-path.windows.test.ts packages/terminal-core/src/display-string.test.ts src/agents/sandbox/fs-paths.test.ts src/agents/sessions/tools/render-utils.test.ts src/agents/agent-tools.read.windows.test.ts src/agents/agent-tools.read.host-operations.test.ts src/agents/sessions/tools/path-utils.test.ts src/daemon/schtasks.startup-fallback.test.ts src/media/web-media.file-url.windows.test.ts extensions/lobster/src/lobster-runner.test.ts extensions/msteams/src/media-helpers.test.ts extensions/msteams/src/messenger.test.ts extensions/mxc/test/mxc-backend.test.ts extensions/mxc/test/sandbox-policy-loader.test.ts test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts test/scripts/direct-run-entrypoints.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"test:windows:ci": "node --import tsx scripts/test-projects.mts src/shared/runtime-import.test.ts src/config/sessions/session-accessor.sqlite-archive.worker.test.ts src/commands/doctor-gateway-auth-token.windows.test.ts src/agents/tools/media-tool-file-url.windows.test.ts src/media/local-media-path.windows.test.ts src/infra/sqlite-snapshot.test.ts src/state/openclaw-state-ownership.test.ts src/infra/ssh-client.windows.test.ts src/infra/ports.test.ts src/infra/advertised-lan-host.windows.test.ts src/infra/update-managed-service-handoff-command.test.ts src/infra/update-managed-service-handoff-lifecycle.test.ts src/infra/exec-allowlist-pattern.test.ts src/infra/executable-path.test.ts src/infra/process-env.test.ts src/infra/fs-safe-remove.test.ts src/snapshot/local-repository.windows.test.ts src/state/openclaw-database-paths.windows.test.ts src/commands/backup-verify.test.ts src/infra/state-migrations.legacy-session-store.test.ts src/test-utils/openclaw-test-state.test.ts src/agents/provider-local-service.env-case.test.ts src/agents/sessions/windows-git-bash-path.test.ts src/agents/bash-tools.exec.script-preflight.test.ts src/process/exec.windows.test.ts src/process/exec.windows.integration.test.ts src/process/windows-command.test.ts src/process/terminal-pty.test.ts src/plugin-sdk/node-host.test.ts src/tui/tui.resolve-codex-bin.test.ts src/infra/windows-install-roots.test.ts src/node-host/invoke-system-run-allowlist.test.ts src/auto-reply/usage-bar/template.windows.test.ts src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts src/media-understanding/attachments.file-url.windows.test.ts src/utils.test.ts src/commands/agents.commands.list.test.ts src/cli/daemon-cli/status.print.test.ts src/cli/mcp-cli.path-case.windows.test.ts extensions/memory-core/src/memory-extra-file-path.windows.test.ts packages/terminal-core/src/display-string.test.ts src/agents/sandbox/fs-paths.test.ts src/agents/sessions/tools/render-utils.test.ts src/agents/agent-tools.read.windows.test.ts src/agents/agent-tools.read.host-operations.test.ts src/agents/sessions/tools/path-utils.test.ts src/daemon/schtasks.startup-fallback.test.ts src/media/web-media.file-url.windows.test.ts extensions/lobster/src/lobster-runner.test.ts extensions/msteams/src/media-helpers.test.ts extensions/msteams/src/messenger.test.ts extensions/mxc/test/mxc-backend.test.ts extensions/mxc/test/sandbox-policy-loader.test.ts test/e2e/qa-lab/runtime/package-openclaw-for-docker.e2e.test.ts test/scripts/direct-run-entrypoints.test.ts test/scripts/format-generated-module.test.ts test/scripts/npm-runner.test.ts test/scripts/openclaw-cross-os-installer.windows.test.ts test/scripts/openclaw-cross-os-release-workflow.test.ts test/scripts/pnpm-runner.test.ts test/scripts/run-with-env.test.ts test/scripts/ts-topology.test.ts test/scripts/ui.test.ts test/scripts/vitest-process-group.test.ts",
"test:windows:schtasks:integration": "node --import tsx scripts/run-with-env.mts CI_WINDOWS_SCHTASKS_INTEGRATION=1 OPENCLAW_E2E_VERBOSE=1 OPENCLAW_VITEST_MAX_WORKERS=1 -- node scripts/run-vitest.mjs src/daemon/schtasks.integration.e2e.test.ts",
"tool-display:check": "node --import tsx scripts/tool-display.ts --check",
"tool-display:write": "node --import tsx scripts/tool-display.ts --write",
@@ -82,7 +82,12 @@ describe("worker environment protocol schemas", () => {
});
it("accepts worker metadata additively across summary and mutation results", () => {
const requested = workerSummary("requested");
const requested = {
...workerSummary("requested"),
platform: "linux",
sessionHost: false,
trust: "disposable",
};
const destroyedBase = workerSummary("destroyed", "unavailable");
const destroyed = {
...destroyedBase,
@@ -140,7 +145,7 @@ describe("worker environment protocol schemas", () => {
expect(
Value.Check(EnvironmentsListResultSchema, {
environments: [],
profiles: [{ id: "aws", providerId: "crabbox" }],
profiles: [{ id: "aws", providerId: "crabbox", trust: "disposable" }],
}),
).toBe(true);
expect(
@@ -149,6 +154,12 @@ describe("worker environment protocol schemas", () => {
profiles: [{ id: "aws", providerId: "crabbox", settings: { token: "hidden" } }],
}),
).toBe(false);
expect(
Value.Check(EnvironmentsListResultSchema, {
environments: [],
profiles: [{ id: "aws", providerId: "crabbox", trust: "temporary" }],
}),
).toBe(false);
});
it("preserves summaries without worker metadata and rejects malformed worker metadata", () => {
@@ -180,5 +191,11 @@ describe("worker environment protocol schemas", () => {
worker: { ...workerSummary("failed").worker, error: "" },
}),
).toBe(false);
expect(
Value.Check(EnvironmentSummarySchema, {
...workerSummary("ready", "available"),
trust: "temporary",
}),
).toBe(false);
});
});
@@ -14,6 +14,10 @@ export const EnvironmentStatusSchema = Type.String({
enum: ["available", "unavailable", "starting", "stopping", "error"],
});
const EnvironmentTrustSchema = Type.String({
enum: ["persistent", "disposable"],
});
/** Durable lifecycle states for plugin-provisioned worker environments. */
export const WorkerEnvironmentStateSchema = Type.Union([
Type.Literal("requested"),
@@ -65,6 +69,9 @@ function createEnvironmentSummarySchema() {
type: NonEmptyString,
label: Type.Optional(NonEmptyString),
status: EnvironmentStatusSchema,
platform: Type.Optional(NonEmptyString),
sessionHost: Type.Optional(Type.Boolean()),
trust: Type.Optional(EnvironmentTrustSchema),
capabilities: Type.Optional(Type.Array(NonEmptyString)),
worker: Type.Optional(WorkerEnvironmentMetadataSchema),
});
@@ -80,6 +87,7 @@ export const EnvironmentsListParamsSchema = closedObject({});
const WorkerEnvironmentProfileSummarySchema = closedObject({
id: NonEmptyString,
providerId: NonEmptyString,
trust: Type.Optional(EnvironmentTrustSchema),
});
/** List response containing all gateway-visible environment summaries. */
@@ -756,6 +756,9 @@ async function proveRealGatewayContracts(): Promise<void> {
type: "local",
label: "Gateway local",
status: "available",
platform: process.platform,
sessionHost: true,
trust: "persistent",
capabilities: ["agent.run", "sessions", "tools", "workspace"],
});
const gatewayEnvironment = await oc.environments.status("gateway");
+4
View File
@@ -79,6 +79,9 @@ export type EnvironmentSummary = {
type: "local" | "gateway" | "node" | "managed" | "ephemeral" | (string & {});
label?: string;
status: "available" | "unavailable" | "starting" | "stopping" | "error";
platform?: string;
sessionHost?: boolean;
trust?: "persistent" | "disposable";
capabilities?: string[];
worker?: WorkerEnvironmentMetadata;
};
@@ -91,6 +94,7 @@ export type EnvironmentCreateParams = {
export type WorkerEnvironmentProfileSummary = {
id: string;
providerId: string;
trust?: "persistent" | "disposable";
};
export type EnvironmentsListResult = {
+102 -114
View File
@@ -168,139 +168,127 @@ const COMPACT_WHOLE_NODE_TEST_TIMEOUT_MINUTES = 120;
const AUTO_REPLY_COMMANDS_STRIPES = 3;
const AGENTS_CORE_RUNNER_CLI_STRIPES = 3;
const UNIT_FAST_NODE_TEST_STRIPES = 2;
// Advisory runtime estimates (seconds) per split shard: [shard:*] begin->end
// wall clock across seven green Blacksmith compact PR runs after the
// cli-runner reliability whale fix (29605136624, 29605203485, 29605983019,
// 29606701461, 29611308972, 29611457693, 29611500865), averaged after
// dropping cache-warm/contention outliers outside [median/1.5, median*1.5].
// Advisory runtime estimates (seconds) per split shard: median [shard:*]
// begin->end wall across nine successful hosted compact runs (31568650453,
// 31569157374, 31569912984, 31570693513, 31571644856, 31572044913,
// 31572489294, 31574210928, 31574367637).
// Packing only: a stale entry skews job balance but never correctness.
// Unknown shards fall back to a per-file estimate.
// Outlier hints were refreshed from child-process walls in runs 31453973052
// and 31455822921.
const COMPACT_GROUP_SECONDS_HINTS = new Map<string, number>([
["agentic-agents-core-auth", 27],
["agentic-agents-core-isolated", 9],
// Model catalog and full UI both cold-load broad graphs; preserve their
// measured separation when striping the expanded groups.
["agentic-agents-core-models", 37],
// Reliability's runtime-free provider check dropped its wall time from
// ~245s to ~5s; the narrow anthropic cli-api artifact removes the same
// full-barrel evaluation for the remaining facade importers (spawn).
// The live-session extraction rebalanced these stripes without changing the
// fleet-scale import wall that dominates each compact group.
["agentic-agents-core-runner-cli-1", 8],
["agentic-agents-core-runner-cli-2", 8],
["agentic-agents-core-runner-cli-3", 8],
["agentic-agents-core-auth", 28],
["agentic-agents-core-isolated", 16],
["agentic-agents-core-models", 39],
["agentic-agents-core-runner-cli-1", 7],
["agentic-agents-core-runner-cli-2", 17],
["agentic-agents-core-runner-cli-3", 13],
["agentic-agents-core-runner-commands", 27],
["agentic-agents-core-runner-embedded", 20],
["agentic-agents-core-runner-sessions", 13],
["agentic-agents-core-runtime", 104],
["agentic-agents-core-subagents", 10],
["agentic-agents-core-tools", 52],
["agentic-agents-core-runner-sessions", 18],
["agentic-agents-core-runtime", 113],
["agentic-agents-core-subagents", 17],
["agentic-agents-core-tools", 45],
// The composite hint sets the job count before its independent configs are
// striped across those jobs. Split hints use the same loaded-fleet run as
// the rest of this map rather than older 2-core measurements.
["agentic-agents-embedded", 150],
["agentic-agents-embedded-base", 88],
["agentic-agents-embedded-incomplete-turn", 14],
["agentic-agents-embedded-overflow-compaction", 12],
["agentic-agents-embedded-run", 30],
["agentic-agents-support", 201],
["agentic-agents-tools", 42],
["agentic-cli", 145],
["agentic-command-support", 65],
["agentic-commands-agent-channel", 74],
["agentic-commands-doctor", 19],
["agentic-commands-doctor-auth", 11],
["agentic-commands-doctor-config-state", 112],
["agentic-commands-doctor-device", 2],
["agentic-commands-doctor-gateway", 4],
["agentic-commands-doctor-platform", 3],
["agentic-commands-doctor-plugins-tools", 11],
["agentic-commands-doctor-sessions-cron", 24],
["agentic-commands-doctor-shared", 16],
// striped across those jobs; its estimate is the sum of the split medians.
["agentic-agents-embedded", 162],
["agentic-agents-embedded-base", 90],
["agentic-agents-embedded-incomplete-turn", 17],
["agentic-agents-embedded-overflow-compaction", 18],
["agentic-agents-embedded-run", 37],
["agentic-agents-support", 144],
["agentic-agents-tools", 76],
["agentic-cli", 111],
["agentic-command-support", 61],
["agentic-commands-agent-channel", 71],
["agentic-commands-doctor", 23],
["agentic-commands-doctor-auth", 19],
["agentic-commands-doctor-config-state", 69],
["agentic-commands-doctor-device", 3],
["agentic-commands-doctor-gateway", 3],
["agentic-commands-doctor-platform", 4],
["agentic-commands-doctor-plugins-tools", 27],
["agentic-commands-doctor-sessions-cron", 21],
["agentic-commands-doctor-shared", 27],
["agentic-commands-doctor-whatsapp", 1],
["agentic-commands-doctor-workspace", 1],
["agentic-commands-models", 16],
["agentic-commands-onboard-config", 11],
["agentic-commands-status-tools", 21],
["agentic-control-plane-agent-chat", 123],
["agentic-control-plane-auth-node", 128],
["agentic-control-plane-http-models", 33],
["agentic-control-plane-http-plugin-ws", 39],
["agentic-control-plane-runtime-config", 14],
["agentic-control-plane-runtime-cron", 15],
["agentic-commands-models", 24],
["agentic-commands-onboard-config", 26],
["agentic-commands-status-tools", 28],
["agentic-control-plane-agent-chat", 140],
["agentic-control-plane-auth-node", 153],
["agentic-control-plane-http-models", 25],
["agentic-control-plane-http-plugin-ws", 49],
["agentic-control-plane-runtime", 20],
["agentic-control-plane-runtime-config", 8],
["agentic-control-plane-runtime-cron", 31],
["agentic-control-plane-runtime-network", 1],
["agentic-control-plane-runtime-server", 29],
["agentic-control-plane-runtime-shared-token", 22],
["agentic-control-plane-runtime-state", 13],
["agentic-control-plane-runtime-ui-tools", 11],
["agentic-control-plane-startup-core", 28],
["agentic-control-plane-startup-health-runtime", 22],
["agentic-control-plane-startup-restart-close", 8],
["agentic-gateway-core", 197],
["agentic-gateway-methods", 136],
["agentic-plugin-sdk", 47],
["auto-reply-core-top-level", 30],
["auto-reply-reply-agent-runner", 40],
["auto-reply-reply-commands-1", 44],
["auto-reply-reply-commands-2", 18],
["auto-reply-reply-commands-3", 36],
["auto-reply-reply-dispatch", 64],
["auto-reply-reply-session", 19],
["auto-reply-reply-state-routing", 54],
["core-runtime-cron-core", 16],
["core-runtime-cron-isolated-agent", 94],
["core-runtime-cron-service", 49],
["core-runtime-hooks", 9],
["core-runtime-infra-approval-exec", 30],
["core-runtime-infra-channel-plugin", 17],
["core-runtime-infra-cli-ui", 1],
["core-runtime-infra-core-utils", 3],
["core-runtime-infra-diagnostics-state", 19],
["core-runtime-infra-events-runtime", 4],
["agentic-control-plane-runtime-server", 25],
["agentic-control-plane-runtime-shared-token", 8],
["agentic-control-plane-runtime-state", 34],
["agentic-control-plane-runtime-ui-tools", 9],
["agentic-control-plane-startup-config", 5],
["agentic-control-plane-startup-core", 27],
["agentic-control-plane-startup-health-runtime", 11],
["agentic-control-plane-startup-restart-close", 16],
["agentic-gateway-core", 214],
["agentic-gateway-methods", 119],
["agentic-plugin-sdk", 44],
["auto-reply-core-top-level", 27],
["auto-reply-reply-agent-runner", 68],
["auto-reply-reply-commands-1", 27],
["auto-reply-reply-commands-2", 16],
["auto-reply-reply-commands-3", 27],
["auto-reply-reply-dispatch", 65],
["auto-reply-reply-session", 40],
["auto-reply-reply-state-routing", 48],
["core-runtime-cron-core", 24],
["core-runtime-cron-isolated-agent", 110],
["core-runtime-cron-service", 51],
["core-runtime-hooks", 18],
["core-runtime-infra-approval-exec", 23],
["core-runtime-infra-channel-plugin", 7],
["core-runtime-infra-cli-ui", 2],
["core-runtime-infra-core-utils", 4],
["core-runtime-infra-device", 8],
["core-runtime-infra-diagnostics-state", 12],
["core-runtime-infra-env-auth", 5],
["core-runtime-infra-events-runtime", 7],
["core-runtime-infra-file-safety", 2],
["core-runtime-infra-files-commands", 5],
["core-runtime-infra-files-commands", 4],
["core-runtime-infra-gateway-lock-argv", 2],
["core-runtime-infra-gateway-processes", 1],
["core-runtime-infra-gateway-watch", 1],
["core-runtime-infra-heartbeat-core", 4],
["core-runtime-infra-heartbeat-runner", 123],
["core-runtime-infra-misc", 9],
["core-runtime-infra-heartbeat-core", 6],
["core-runtime-infra-heartbeat-runner", 54],
["core-runtime-infra-misc", 12],
["core-runtime-infra-misc-dedupe-disk", 1],
["core-runtime-infra-misc-os", 1],
["core-runtime-infra-misc-values", 1],
["core-runtime-infra-net-install", 13],
["core-runtime-infra-network-node", 2],
["core-runtime-infra-net-install", 9],
["core-runtime-infra-network-node", 4],
["core-runtime-infra-network-platform", 4],
["core-runtime-infra-outbound-actions", 19],
["core-runtime-infra-outbound-core", 45],
["core-runtime-infra-process", 118],
["core-runtime-infra-provider-push", 17],
["core-runtime-infra-outbound-actions", 31],
["core-runtime-infra-outbound-core", 57],
["core-runtime-infra-process", 134],
["core-runtime-infra-provider-push", 15],
["core-runtime-infra-repo-tooling", 4],
["core-runtime-infra-storage-state", 96],
["core-runtime-infra-system-runtime", 40],
["core-runtime-media-ui", 174],
["core-runtime-secrets", 37],
["core-runtime-shared", 48],
// PTY timing suites still need a lightly packed lane; the exclusive-bin cap
// leaves only trivial co-groups next to this measured runtime.
["core-runtime-infra-storage-state", 86],
["core-runtime-infra-system-runtime", 35],
["core-runtime-media-ui", 196],
["core-runtime-secrets", 58],
["core-runtime-shared", 52],
// This dist-only group is outside the sampled nondist logs and retains its
// prior measured hint. The exclusive-bin cap keeps its lane lightly packed.
["core-runtime-tui-pty", 116],
["core-tooling-1", 94],
["core-tooling-2", 95],
["core-tooling-3", 108],
["core-tooling-4", 125],
["core-tooling-isolated", 49],
["core-unit-fast-1", 89],
["core-unit-fast-2", 92],
// Fork-per-file isolation parallelizes poorly on 4 vCPU; keep it on the
// 8 vCPU class, where it still runs a measured ~90s under fleet load.
["core-unit-fast-isolated", 90],
// In 35 green main runs on 2026-08-11/12, compact large jobs owned the
// critical tail 15 times and reached p90=457s. This group's former 205s
// hint repeatedly packed another 58s of serial work beside that tail.
["core-unit-src-security", 295],
["core-unit-support", 17],
["core-tooling-1", 112],
["core-tooling-2", 128],
["core-tooling-3", 163],
["core-tooling-4", 123],
["core-tooling-isolated", 34],
["core-unit-fast-1", 54],
["core-unit-fast-2", 60],
["core-unit-fast-isolated", 79],
["core-unit-src-security", 252],
["core-unit-support", 18],
]);
// Advisory per-file wall-clock hints (seconds) for stripe balancing, measured
// from single-file local runs (M4 Max) and static import-graph size. Packing
+7 -41
View File
@@ -1,8 +1,6 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
isSyntheticSourceReplyTurn,
resolveSourceReplyDeliveryMode,
} from "../../auto-reply/reply/source-reply-delivery-mode.js";
import { resolveSessionStableReplyMode } from "../../auto-reply/reply/session-stable-reply-mode.js";
import { isSyntheticSourceReplyTurn } from "../../auto-reply/reply/source-reply-delivery-mode.js";
import {
formatThinkingLevels,
normalizeThinkLevel,
@@ -31,10 +29,6 @@ import {
resolveAgentHarnessSessionContextError,
} from "../../sessions/agent-harness-session-key.js";
import { resolveUserPath } from "../../utils.js";
import {
sessionDeliveryChannel,
sessionDeliveryOrigin,
} from "../../utils/delivery-context.shared.js";
import { isDeliverableMessageChannel, resolveMessageChannel } from "../../utils/message-channel.js";
import { resolveAgentRuntimeConfig } from "../agent-runtime-config.js";
import {
@@ -45,7 +39,6 @@ import {
resolveAgentWorkspaceDir,
} from "../agent-scope.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js";
import { selectAgentHarness } from "../harness/selection.js";
import { AGENT_LANE_SUBAGENT } from "../lanes.js";
import type { ModelManifestNormalizationContext } from "../model-ref-shared.js";
import { buildConfiguredModelCatalog, resolveConfiguredModelRef } from "../model-selection.js";
@@ -345,42 +338,15 @@ export async function prepareAgentCommandExecution(opts: AgentCommandOpts, runti
isHeartbeat: commandOpts.bootstrapContextRunKind === "heartbeat",
})
) {
// Lifecycle turns keep their effective delivery mode, but CLI reuse belongs
// to the existing session's normal source-reply policy.
const stableReplyContext = {
CommandAuthorized: false,
ChatType: sessionEntryRaw.chatType,
Provider: sessionDeliveryOrigin(sessionEntryRaw)?.provider,
Surface: sessionDeliveryChannel(sessionEntryRaw),
InputProvenance: commandOpts.inputProvenance,
};
const stableProvider = sessionEntryRaw.modelProvider ?? configuredModel.provider;
const stableModel = sessionEntryRaw.model ?? configuredModel.model;
const stableRuntime = resolveEffectiveAgentRuntime({
cfg,
provider: stableProvider,
modelId: stableModel,
agentId: sessionAgentId,
sessionKey,
sessionEntry: sessionEntryRaw,
});
const harness = selectAgentHarness({
provider: stableProvider,
modelId: stableModel,
config: cfg,
agentId: sessionAgentId,
sessionKey,
agentHarnessRuntimeOverride: stableRuntime,
});
const defaultVisibleReplies =
harness.deliveryDefaults?.visibleReplies ?? harness.deliveryDefaults?.sourceVisibleReplies;
commandOpts = {
...commandOpts,
cliSessionBindingFacts: {
sourceReplyDeliveryMode: resolveSourceReplyDeliveryMode({
sourceReplyDeliveryMode: resolveSessionStableReplyMode({
cfg,
ctx: stableReplyContext,
defaultVisibleReplies,
ctx: { CommandAuthorized: false },
sessionEntry: sessionEntryRaw,
sessionAgentId,
sessionKey,
}),
},
};
+4 -1
View File
@@ -1164,6 +1164,9 @@ describe("sessions tools", () => {
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as GatewayCall;
calls.push(request);
if (request.method === "sessions.resolve") {
return { key: targetSessionKey };
}
if (request.method === "agent") {
return { runId: "run-scoped", status: "accepted", acceptedAt: 1 };
}
@@ -1193,8 +1196,8 @@ describe("sessions tools", () => {
watched: false,
});
expect(calls.map((call) => call.method)).toEqual([
"sessions.list",
"sessions.resolve",
"sessions.list",
"agent",
]);
} finally {
+75 -1
View File
@@ -146,6 +146,76 @@ describe("sessions_history redaction", () => {
);
});
it("returns not-found for an unknown explicit key without reading history", async () => {
const requests: CallGatewayRequest[] = [];
const sessionKey = "agent:main:missing";
const tool = createSessionsHistoryTool({
config: { tools: { sessions: { visibility: "all" } } },
callGateway: async <T = Record<string, unknown>>(request: CallGatewayRequest): Promise<T> => {
requests.push(request);
if (request.method === "sessions.resolve") {
throw new Error(`No session found: ${sessionKey}`);
}
return { messages: [] } as T;
},
});
const result = await tool.execute("missing-explicit-key", { sessionKey });
expect(result.details).toEqual({
status: "error",
error: `No session found: ${sessionKey}`,
});
expect(requests.map((request) => request.method)).toEqual(["sessions.resolve"]);
});
it("conceals missing explicit keys denied by session visibility", async () => {
const requests: CallGatewayRequest[] = [];
const tool = createSessionsHistoryTool({
agentSessionKey: "agent:main:main",
config: { tools: { sessions: { visibility: "self" } } },
callGateway: async <T = Record<string, unknown>>(request: CallGatewayRequest): Promise<T> => {
requests.push(request);
throw new Error("No session found: agent:main:missing");
},
});
const result = await tool.execute("hidden-missing-key", {
sessionKey: "agent:main:missing",
});
expect(result.details).toMatchObject({ status: "forbidden" });
expect(requests.map((request) => request.method)).toEqual(["sessions.resolve"]);
});
it("returns an empty history for an existing explicit key", async () => {
const requests: CallGatewayRequest[] = [];
const sessionKey = "agent:main:empty";
const tool = createSessionsHistoryTool({
config: { tools: { sessions: { visibility: "all" } } },
callGateway: async <T = Record<string, unknown>>(request: CallGatewayRequest): Promise<T> => {
requests.push(request);
if (request.method === "sessions.resolve") {
return { key: sessionKey } as T;
}
return { messages: [] } as T;
},
});
const result = await tool.execute("existing-empty-key", { sessionKey });
expect(result.details).toMatchObject({
sessionKey,
messages: [],
bytes: 2,
});
expect(requests.map((request) => request.method)).toEqual([
"sessions.resolve",
"sessions.list",
"chat.history",
]);
});
it("redacts recalled session text even when log redaction is disabled", async () => {
// Recalled transcript content is model-visible, so it is always redacted
// even when normal logging redaction is configured off.
@@ -445,7 +515,11 @@ describe("sessions_history redaction", () => {
sessionKey: targetSessionKey,
messages: [{ role: "assistant", content: "visible" }],
});
expect(requests.map((request) => request.method)).toEqual(["sessions.list", "chat.history"]);
expect(requests.map((request) => request.method)).toEqual([
"sessions.resolve",
"sessions.list",
"chat.history",
]);
} finally {
unregister();
}
+14 -5
View File
@@ -33,6 +33,7 @@ import {
import { runWithScopedSessionAccess } from "./scoped-session-access.js";
import {
createSessionVisibilityGuard,
createSessionVisibilityRowChecker,
createAgentToAgentPolicy,
resolveEffectiveSessionToolsVisibility,
resolveSessionReference,
@@ -398,12 +399,25 @@ export function createSessionsHistoryTool(opts?: {
if (!resolvedSession.ok) {
return jsonResult({ status: resolvedSession.status, error: resolvedSession.error });
}
const a2aPolicy = createAgentToAgentPolicy(cfg);
const visibility = resolveEffectiveSessionToolsVisibility({
cfg,
sandboxed: opts?.sandboxed === true,
});
const resolutionAccess = createSessionVisibilityRowChecker({
action: "history",
defaultAgentId: resolveDefaultAgentId(cfg),
requesterSessionKey: effectiveRequesterKey,
visibility,
a2aPolicy,
}).check({ key: resolvedSession.key });
const visibleSession = await resolveVisibleSessionReference({
action: "history",
resolvedSession,
requesterSessionKey: effectiveRequesterKey,
restrictToSpawned,
visibilitySessionKey: sessionKeyParam,
concealResolutionError: resolutionAccess.allowed ? undefined : resolutionAccess.error,
callGateway: gatewayCall,
});
if (!visibleSession.ok) {
@@ -416,11 +430,6 @@ export function createSessionsHistoryTool(opts?: {
const resolvedKey = visibleSession.key;
const displayKey = visibleSession.displayKey;
const a2aPolicy = createAgentToAgentPolicy(cfg);
const visibility = resolveEffectiveSessionToolsVisibility({
cfg,
sandboxed: opts?.sandboxed === true,
});
const visibilityGuard = await createSessionVisibilityGuard({
action: "history",
defaultAgentId: resolveDefaultAgentId(cfg),
+233 -69
View File
@@ -160,7 +160,7 @@ describe("resolved session visibility checks", () => {
await expect(
resolveVisibleSessionReference({
action: "history",
action: "status",
resolvedSession: {
ok: true,
key: sessionKey,
@@ -210,7 +210,7 @@ describe("resolved session visibility checks", () => {
for (const testCase of cases) {
callGatewayMock.mockResolvedValueOnce({ key: testCase.targetSessionKey });
const result = resolveVisibleSessionReference({
action: "history",
action: "status",
resolvedSession: {
ok: true,
key: testCase.targetSessionKey,
@@ -253,7 +253,7 @@ describe("resolved session visibility checks", () => {
await expect(
resolveVisibleSessionReference({
action: "history",
action: "status",
resolvedSession: {
ok: true,
key: "agent:main:subagent:worker-999",
@@ -281,7 +281,7 @@ describe("resolved session visibility checks", () => {
await expect(
resolveVisibleSessionReference({
action: "history",
action: "status",
resolvedSession: {
ok: true,
key: "agent:main:subagent:worker",
@@ -362,71 +362,6 @@ describe("resolveSessionReference", () => {
});
});
it("retries literal current probes without allowMissing for older gateways", async () => {
const unsupportedAllowMissing = () =>
new GatewayClientRequestError({
code: "INVALID_REQUEST",
message: "invalid sessions.resolve params: at root: unexpected property 'allowMissing'",
});
callGatewayMock
.mockRejectedValueOnce(unsupportedAllowMissing())
.mockRejectedValueOnce(
new GatewayClientRequestError({
code: "INVALID_REQUEST",
message: "No session found: current",
}),
)
.mockRejectedValueOnce(unsupportedAllowMissing())
.mockResolvedValueOnce({ key: "agent:ops:main" });
const result = await resolveSessionReference({
sessionKey: "current",
alias: "main",
mainKey: "main",
requesterInternalKey: "agent:main:subagent:child",
restrictToSpawned: false,
});
expectResolvedSessionReference(result, {
key: "agent:ops:main",
displayKey: "agent:ops:main",
resolvedViaSessionId: true,
});
expect(callGatewayMock).toHaveBeenNthCalledWith(1, {
method: "sessions.resolve",
params: {
key: "current",
spawnedBy: undefined,
allowMissing: true,
},
});
expect(callGatewayMock).toHaveBeenNthCalledWith(2, {
method: "sessions.resolve",
params: {
key: "current",
spawnedBy: undefined,
},
});
expect(callGatewayMock).toHaveBeenNthCalledWith(3, {
method: "sessions.resolve",
params: {
sessionId: "current",
spawnedBy: undefined,
includeGlobal: true,
includeUnknown: true,
allowMissing: true,
},
});
expect(callGatewayMock).toHaveBeenNthCalledWith(4, {
method: "sessions.resolve",
params: {
sessionId: "current",
spawnedBy: undefined,
includeGlobal: true,
includeUnknown: true,
},
});
});
it("does not compatibility-retry unrelated gateway failures", async () => {
callGatewayMock.mockRejectedValueOnce(new Error("gateway timeout")).mockResolvedValueOnce({});
@@ -486,4 +421,233 @@ describe("resolveSessionReference", () => {
});
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("preserves the main alias without probing configured-main bootstrap", async () => {
const result = await resolveSessionReference({
sessionKey: "main",
alias: "main",
mainKey: "main",
requesterInternalKey: "agent:main:dashboard:requester",
restrictToSpawned: false,
});
expectResolvedSessionReference(result, {
key: "main",
displayKey: "main",
resolvedViaSessionId: false,
});
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("defers explicit-key lookup to action-aware visibility resolution", async () => {
const result = await resolveSessionReference({
sessionKey: "agent:main:worker",
alias: "main",
mainKey: "main",
requesterInternalKey: "agent:main:main",
restrictToSpawned: false,
});
expect(result).toEqual({
ok: true,
key: "agent:main:worker",
displayKey: "agent:main:worker",
resolvedViaSessionId: false,
});
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("rejects an unknown explicit session key for history", async () => {
callGatewayMock.mockRejectedValueOnce(
new GatewayClientRequestError({
code: "INVALID_REQUEST",
message: "No session found: agent:main:missing",
}),
);
const resolvedSession = await resolveSessionReference({
sessionKey: "agent:main:missing",
alias: "main",
mainKey: "main",
requesterInternalKey: "agent:main:main",
restrictToSpawned: false,
});
if (!resolvedSession.ok) {
throw new Error("Expected session reference");
}
const result = await resolveVisibleSessionReference({
action: "history",
resolvedSession,
requesterSessionKey: "agent:main:main",
restrictToSpawned: false,
visibilitySessionKey: "agent:main:missing",
});
expect(result).toEqual({
ok: false,
status: "error",
error: "No session found: agent:main:missing",
displayKey: "agent:main:missing",
});
expect(callGatewayMock).toHaveBeenCalledWith({
method: "sessions.resolve",
params: {
key: "agent:main:missing",
spawnedBy: undefined,
},
});
});
it("canonicalizes an existing explicit session key", async () => {
callGatewayMock.mockResolvedValueOnce({ key: "agent:ops:main" });
const resolvedSession = await resolveSessionReference({
sessionKey: "agent:OPS:main",
alias: "main",
mainKey: "main",
requesterInternalKey: "agent:main:main",
restrictToSpawned: false,
});
if (!resolvedSession.ok) {
throw new Error("Expected session reference");
}
const result = await resolveVisibleSessionReference({
action: "send",
resolvedSession,
requesterSessionKey: "agent:main:main",
restrictToSpawned: false,
visibilitySessionKey: "agent:OPS:main",
});
expect(result).toEqual({
ok: true,
key: "agent:ops:main",
displayKey: "agent:ops:main",
});
});
it("rejects an explicit key that canonicalizes to an incognito session", async () => {
callGatewayMock.mockResolvedValueOnce({ key: "agent:ops:dashboard:incognito-private" });
const resolvedSession = await resolveSessionReference({
sessionKey: "agent:OPS:dashboard:private",
alias: "main",
mainKey: "main",
requesterInternalKey: "agent:main:main",
restrictToSpawned: false,
});
if (!resolvedSession.ok) {
throw new Error("Expected session reference");
}
const result = await resolveVisibleSessionReference({
action: "history",
resolvedSession,
requesterSessionKey: "agent:main:main",
restrictToSpawned: false,
visibilitySessionKey: "agent:OPS:dashboard:private",
});
expect(result).toEqual({
ok: false,
status: "forbidden",
error: "Session not visible from session tools: agent:OPS:dashboard:private",
displayKey: "agent:ops:dashboard:incognito-private",
});
});
it("conceals a missing explicit key from sandboxed callers", async () => {
callGatewayMock.mockRejectedValueOnce(new Error("No session found: agent:main:missing"));
const resolvedSession = await resolveSessionReference({
sessionKey: "agent:main:missing",
alias: "main",
mainKey: "main",
requesterInternalKey: "agent:main:subagent:child",
restrictToSpawned: true,
});
if (!resolvedSession.ok) {
throw new Error("Expected session reference");
}
const result = await resolveVisibleSessionReference({
action: "history",
resolvedSession,
requesterSessionKey: "agent:main:subagent:child",
restrictToSpawned: true,
visibilitySessionKey: "agent:main:missing",
});
expect(result).toEqual({
ok: false,
status: "forbidden",
error: "Session not visible from this sandboxed agent session: agent:main:missing",
displayKey: "agent:main:missing",
});
});
it("propagates explicit-key gateway failures", async () => {
callGatewayMock.mockRejectedValueOnce(new Error("gateway unavailable"));
const resolvedSession = await resolveSessionReference({
sessionKey: "agent:main:worker",
alias: "main",
mainKey: "main",
requesterInternalKey: "agent:main:main",
restrictToSpawned: false,
});
if (!resolvedSession.ok) {
throw new Error("Expected session reference");
}
const result = await resolveVisibleSessionReference({
action: "send",
resolvedSession,
requesterSessionKey: "agent:main:main",
restrictToSpawned: false,
visibilitySessionKey: "agent:main:worker",
});
expect(result).toEqual({
ok: false,
status: "error",
error: "gateway unavailable",
displayKey: "agent:main:worker",
});
});
it("reports an allowed missing explicit key for deliberate bootstrap", async () => {
callGatewayMock.mockResolvedValueOnce({});
const resolvedSession = await resolveSessionReference({
sessionKey: "agent:main:main",
alias: "main",
mainKey: "main",
requesterInternalKey: "agent:main:dashboard:requester",
restrictToSpawned: false,
});
if (!resolvedSession.ok) {
throw new Error("Expected session reference");
}
const result = await resolveVisibleSessionReference({
action: "send",
resolvedSession,
requesterSessionKey: "agent:main:dashboard:requester",
restrictToSpawned: false,
visibilitySessionKey: "agent:main:main",
allowMissingKey: true,
});
expect(result).toEqual({
ok: true,
key: "agent:main:main",
displayKey: "agent:main:main",
missing: true,
});
expect(callGatewayMock).toHaveBeenCalledWith({
method: "sessions.resolve",
params: {
key: "agent:main:main",
spawnedBy: undefined,
allowMissing: true,
},
});
});
});
+86 -51
View File
@@ -9,7 +9,6 @@ import {
normalizeGatewayClientId,
} from "../../../packages/gateway-protocol/src/client-info.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { GatewayClientRequestError } from "../../gateway/client.js";
import { formatErrorMessage } from "../../infra/errors.js";
import {
createSessionVisibilityChecker,
@@ -164,10 +163,11 @@ type VisibleSessionReferenceResolution =
ok: true;
key: string;
displayKey: string;
missing?: true;
}
| {
ok: false;
status: "forbidden";
status: "error" | "forbidden";
error: string;
displayKey: string;
};
@@ -190,36 +190,35 @@ function buildResolvedSessionReference(params: {
};
}
function buildFailedSessionReference(
error: unknown,
raw: string,
restrictToSpawned: boolean,
): Extract<SessionReferenceResolution, { ok: false }> {
return restrictToSpawned
? {
ok: false,
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${raw}`,
}
: {
ok: false,
status: "error",
error:
formatErrorMessage(error) ||
`Session not found: ${raw} (use the full sessionKey from sessions_list)`,
};
}
async function requestResolvedSessionKey(
params: Record<string, unknown> & { allowMissing?: boolean },
callGateway: GatewayCaller,
): Promise<string | undefined> {
try {
const result = await callGateway<{ key?: unknown }>({
method: "sessions.resolve",
params,
});
return normalizeOptionalString(result?.key);
} catch (error) {
const olderGatewayRejectedProbe =
params.allowMissing === true &&
error instanceof GatewayClientRequestError &&
error.gatewayCode === "INVALID_REQUEST" &&
error.message.includes("invalid sessions.resolve params") &&
error.message.includes("unexpected property 'allowMissing'");
if (!olderGatewayRejectedProbe) {
throw error;
}
// Protocol v4 gateways predating allowMissing reject the additive field.
// Retry without it for mixed-version correctness; remove at the next protocol break.
const legacyParams: Record<string, unknown> = { ...params };
delete legacyParams.allowMissing;
const result = await callGateway<{ key?: unknown }>({
method: "sessions.resolve",
params: legacyParams,
});
return normalizeOptionalString(result?.key);
}
const result = await callGateway<{ key?: unknown }>({
method: "sessions.resolve",
params,
});
return normalizeOptionalString(result?.key);
}
function buildSessionResolveQuery(params: {
@@ -310,20 +309,7 @@ export async function resolveSessionReference(params: {
}
return buildReference(key, true);
} catch (error) {
if (params.restrictToSpawned) {
return {
ok: false,
status: "forbidden",
error: `Session not visible from this sandboxed agent session: ${raw}`,
};
}
return {
ok: false,
status: "error",
error:
formatErrorMessage(error) ||
`Session not found: ${raw} (use the full sessionKey from sessions_list)`,
};
return buildFailedSessionReference(error, raw, params.restrictToSpawned);
}
}
@@ -333,12 +319,7 @@ export async function resolveSessionReference(params: {
mainKey: params.mainKey,
requesterInternalKey: params.requesterInternalKey,
});
const displayKey = resolveDisplaySessionKey({
key: resolvedKey,
alias: params.alias,
mainKey: params.mainKey,
});
return { ok: true, key: resolvedKey, displayKey, resolvedViaSessionId: false };
return buildReference(resolvedKey, false);
}
export async function resolveVisibleSessionReference(params: {
@@ -347,10 +328,13 @@ export async function resolveVisibleSessionReference(params: {
requesterSessionKey: string;
restrictToSpawned: boolean;
visibilitySessionKey: string;
allowMissingKey?: boolean;
concealResolutionError?: string;
callGateway?: GatewayCaller;
}): Promise<VisibleSessionReferenceResolution> {
const resolvedKey = params.resolvedSession.key;
const displayKey = params.resolvedSession.displayKey;
let resolvedKey = params.resolvedSession.key;
let displayKey = params.resolvedSession.displayKey;
let missing = false;
// Cross-session tools persist their results into the caller transcript; an
// incognito target must remain unreachable even from an incognito requester.
if (isIncognitoSessionKey(resolvedKey)) {
@@ -361,6 +345,57 @@ export async function resolveVisibleSessionReference(params: {
displayKey,
};
}
const input = params.visibilitySessionKey.trim();
const isExplicitKey =
!params.resolvedSession.resolvedViaSessionId &&
input !== "current" &&
input !== "main" &&
input !== "global" &&
input !== "unknown" &&
!shouldResolveSessionIdInput(input);
if (isExplicitKey && (params.action === "history" || params.action === "send")) {
try {
const key = await requestResolvedSessionKey(
buildSessionResolveQuery({
input: resolvedKey,
kind: "key",
requesterInternalKey: params.requesterSessionKey,
restrictToSpawned: params.restrictToSpawned,
allowMissing: params.allowMissingKey,
}),
params.callGateway ?? callAgentToolGatewayRequest,
);
if (key) {
resolvedKey = key;
displayKey = key;
} else if (params.allowMissingKey) {
missing = true;
}
} catch (error) {
if (params.concealResolutionError && !params.restrictToSpawned) {
return {
ok: false,
status: "forbidden",
error: params.concealResolutionError,
displayKey,
};
}
const failed = buildFailedSessionReference(
error,
params.visibilitySessionKey,
params.restrictToSpawned,
);
return { ...failed, displayKey };
}
}
if (isIncognitoSessionKey(resolvedKey)) {
return {
ok: false,
status: "forbidden",
error: `Session not visible from session tools: ${params.visibilitySessionKey}`,
displayKey,
};
}
const shouldVerifySpawnedVisibility =
params.restrictToSpawned &&
!params.resolvedSession.resolvedViaSessionId &&
@@ -389,5 +424,5 @@ export async function resolveVisibleSessionReference(params: {
displayKey,
};
}
return { ok: true, key: resolvedKey, displayKey };
return { ok: true, key: resolvedKey, displayKey, ...(missing ? { missing: true } : {}) };
}

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