mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(browser): add relay authentication v2 (#120526)
* feat(browser): add relay authentication v2 * fix(browser): cap relay test WebSocket payload * fix(browser): keep relay E2E inside extension boundary * fix(browser): isolate relay admission and cleanup auth * fix(browser): finish relay auth migration hardening * fix(browser): keep preauth transport bounded through teardown
This commit is contained in:
committed by
GitHub
parent
4eec51a276
commit
83900e4683
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"core": 2321,
|
||||
"channel": 3692,
|
||||
"core": 2324,
|
||||
"channel": 3694,
|
||||
"plugin": 4056
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
1315dd0d8e904da70f5e26f6f055e493c1ddbc4b2ca0ad02bc8608f694e0d89f config-baseline.json
|
||||
a99b96985ee0409f06f356e538eb3a68c0d0e2c7862a242e00ee919681582165 config-baseline.core.json
|
||||
292a907b48f9fd7273f12947c8001077ab69978d29985cb287a71a33e837339a config-baseline.channel.json
|
||||
5d5fe4c95346cb1e7555395d40a6244967e6595b30f13912ae7754209944b734 config-baseline.plugin.json
|
||||
b5631ecbdb64894b82f48cbf0a82d74e396529619c83e942065778f119f9d496 config-baseline.json
|
||||
208150d5d64a45d9e1ca3c72764e85f5cc7ecfcee110cae6aeabe8ca84c3ca52 config-baseline.core.json
|
||||
d752cf8a7ecd2d31684557aeae4d2c08f2ec4ce5f091bce32d54a4685eefdf45 config-baseline.channel.json
|
||||
6df755fceeafe28ded568b6f4bb1bd0bd49050a601732e52aacf4ab56e398b19 config-baseline.plugin.json
|
||||
|
||||
@@ -118,6 +118,34 @@ When the macOS app uses a local Gateway, it can offer this import once and make
|
||||
|
||||
System-profile import is enabled by default. Set `browser.allowSystemProfileImport=false` to disable both CLI and agent-triggered imports. Import is host-local and cannot run through the browser node proxy.
|
||||
|
||||
## Chrome extension relay
|
||||
|
||||
```bash
|
||||
openclaw browser extension path
|
||||
openclaw browser extension pair
|
||||
openclaw browser extension pair --gateway-url wss://gateway.example.com
|
||||
openclaw browser extension cdp
|
||||
openclaw browser extension cdp --json
|
||||
```
|
||||
|
||||
- `extension path` prints the unpacked extension directory for Chrome's **Load
|
||||
unpacked** flow.
|
||||
- `extension pair` creates the host-local relay key when needed and prints the
|
||||
pairing string. `--gateway-url` creates a direct remote-Gateway pairing URL;
|
||||
non-loopback URLs must use `wss://`.
|
||||
- `extension cdp` prints non-secret Browser Relay Authentication v2 metadata:
|
||||
the loopback browser/CDP endpoints, protocol version, key ID, and fixed
|
||||
challenge/complete binding. It never prints the relay key or an authorization
|
||||
header by default.
|
||||
|
||||
`extension cdp --legacy-bearer` is a temporary migration escape hatch. It
|
||||
prints the old Bearer header with a warning only while
|
||||
`browser.extensionRelay.allowLegacyAuth=true`; otherwise it exits with an error
|
||||
without printing a credential. Use `--json` for machine output; warnings remain
|
||||
on stderr so stdout stays valid JSON.
|
||||
|
||||
Setup, security model, and migration steps: [Chrome extension](/tools/chrome-extension).
|
||||
|
||||
## Tabs
|
||||
|
||||
```bash
|
||||
|
||||
@@ -426,27 +426,25 @@ See [Plugins](/tools/plugin).
|
||||
},
|
||||
tabCleanup: {
|
||||
enabled: true,
|
||||
idleMinutes: 120,
|
||||
maxTabsPerSession: 8,
|
||||
sweepMinutes: 5,
|
||||
},
|
||||
extensionRelay: {
|
||||
allowLegacyAuth: true,
|
||||
},
|
||||
profiles: {
|
||||
openclaw: { cdpPort: 18800, color: "#FF4500" },
|
||||
openclaw: { cdpPort: 18800 },
|
||||
work: {
|
||||
cdpPort: 18801,
|
||||
color: "#0066CC",
|
||||
executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
},
|
||||
user: { driver: "existing-session", attachOnly: true, color: "#00AA00" },
|
||||
chrome: { driver: "extension" },
|
||||
user: { driver: "existing-session", attachOnly: true },
|
||||
brave: {
|
||||
driver: "existing-session",
|
||||
attachOnly: true,
|
||||
userDataDir: "~/Library/Application Support/BraveSoftware/Brave-Browser",
|
||||
color: "#FB542B",
|
||||
},
|
||||
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" },
|
||||
remote: { cdpUrl: "http://10.0.0.42:9222" },
|
||||
},
|
||||
color: "#FF4500",
|
||||
// headless: false,
|
||||
// noSandbox: false,
|
||||
// extraArgs: [],
|
||||
@@ -457,6 +455,10 @@ See [Plugins](/tools/plugin).
|
||||
```
|
||||
|
||||
- `evaluateEnabled: false` disables `act:evaluate` and `wait --fn`.
|
||||
- `extensionRelay.allowLegacyAuth` defaults to `true` for one Browser Relay
|
||||
Authentication migration window. It permits old extension and external CDP
|
||||
Bearer, Basic, and token-subprotocol clients. Set it to `false`
|
||||
after all relay clients use auth v2; v2 clients never downgrade.
|
||||
- `tabCleanup` controls best-effort periodic cleanup for tracked primary-agent
|
||||
tabs after idle time or when a session exceeds its cap. Tracking applies only
|
||||
to tabs created by browser tool `action: "open"`; tabs opened by the user or
|
||||
@@ -487,6 +489,9 @@ See [Plugins](/tools/plugin).
|
||||
local managed browser profile and may report local port ownership errors.
|
||||
- `existing-session` profiles use Chrome MCP instead of CDP and can attach on
|
||||
the selected host or through a connected browser node.
|
||||
- `extension` profiles use the authenticated OpenClaw Chrome extension relay.
|
||||
The relay owns its loopback endpoint, so these profiles do not accept
|
||||
`cdpUrl`. See [Chrome extension](/tools/chrome-extension).
|
||||
- `existing-session` profiles can set `userDataDir` to target a specific
|
||||
Chromium-based browser profile such as Brave or Edge.
|
||||
- `existing-session` profiles can set `cdpUrl` when Chrome is already running
|
||||
|
||||
@@ -287,7 +287,7 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
| `plugins.entries.voice-call.config.streaming.openaiApiKey`/`sttModel`/`silenceDurationMs`/`vadThreshold` | `plugins.entries.voice-call.config.streaming.providers.openai.*` |
|
||||
| `models.providers.*.api: "openai"` | `"openai-completions"` (gateway startup also skips providers whose `api` is a future/unknown enum value rather than failing closed) |
|
||||
| `browser.ssrfPolicy.allowPrivateNetwork` | `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` |
|
||||
| `browser.profiles.*.driver: "extension"` | `"existing-session"` |
|
||||
| `browser.profiles.*.driver: "extension"` with a stale `cdpUrl` | driver preserved; stale relay URL removed |
|
||||
| `browser.relayBindHost` | removed (legacy Chrome extension relay setting) |
|
||||
| `mcp.servers.*.type` (CLI-native aliases) | `mcp.servers.*.transport` |
|
||||
| `mcp.servers.*.disabled` | inverse `mcp.servers.*.enabled` |
|
||||
@@ -355,7 +355,9 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
If you have added `models.providers.opencode`, `opencode-zen`, or `opencode-go` manually while the matching official external plugin is installed and enabled, it overrides that plugin-provided catalog. That can force models onto the wrong API or zero out costs. Doctor warns so you can remove the override and restore per-model API routing + costs. Without the matching plugin, the entry remains a valid standalone custom provider.
|
||||
</Accordion>
|
||||
<Accordion title="2c. Browser migration and Chrome MCP readiness">
|
||||
If your browser config still points at the removed Chrome extension path, doctor normalizes it to the current host-local Chrome MCP attach model (`browser.profiles.*.driver: "extension"` → `"existing-session"`; `browser.relayBindHost` removed).
|
||||
If an extension-driver profile still carries a retired relay `cdpUrl`, doctor removes that URL while preserving `driver: "extension"`; the current extension relay owns its endpoint. Doctor also removes the retired `browser.relayBindHost` setting.
|
||||
|
||||
Doctor warns while `browser.extensionRelay.allowLegacyAuth` is enabled. Upgrade paired Chrome extensions and external CDP clients to Browser Relay Authentication v2, then set the flag to `false`. V2 clients do not downgrade to legacy authentication.
|
||||
|
||||
Doctor also audits the host-local Chrome MCP path when you use `defaultProfile: "user"` or a configured `existing-session` profile:
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ exhaustive):
|
||||
| `browser.control_no_auth` | critical | Browser control exposed without token/password auth | `gateway.auth.*` | no |
|
||||
| `browser.remote_cdp_http` | warn | Remote CDP over plain HTTP lacks transport encryption | browser profile `cdpUrl` | no |
|
||||
| `browser.remote_cdp_private_host` | warn | Remote CDP targets a private/internal host | browser profile `cdpUrl`, `browser.ssrfPolicy.*` | no |
|
||||
| `browser.extension_relay_legacy_auth` | warn | Legacy extension relay bearer/Basic/token authentication remains enabled | `browser.extensionRelay.allowLegacyAuth` | no |
|
||||
| `sandbox.docker_config_mode_off` | warn | Sandbox Docker config present but inactive | `agents.*.sandbox.mode` | no |
|
||||
| `sandbox.bind_mount_non_absolute` | warn | Relative bind mounts can resolve unpredictably | `agents.*.sandbox.docker.binds[]` | no |
|
||||
| `sandbox.dangerous_bind_mount` | critical | Sandbox bind mount targets blocked system, credential, or Docker socket paths | `agents.*.sandbox.docker.binds[]` | no |
|
||||
|
||||
@@ -483,6 +483,15 @@ Enabling browser control gives the model a real browser. If that profile already
|
||||
- Keep Gateway and node hosts tailnet-only; avoid exposing browser control ports to LAN or public internet.
|
||||
- Disable browser proxy routing when not needed (`gateway.nodes.browser.mode="off"`).
|
||||
- Chrome MCP existing-session mode is not "safer" - it can act as you in whatever that host Chrome profile can reach.
|
||||
- Browser Relay Authentication v2 never sends the persistent extension relay
|
||||
key. The extension and external CDP clients verify a signed server challenge
|
||||
before returning a short-lived, one-time, connection-bound HMAC proof. Proofs
|
||||
bind the protocol version, role, transport, method, resource, flow, profile,
|
||||
and relay instance; replay on the same or another socket fails.
|
||||
- `browser.extensionRelay.allowLegacyAuth` defaults to `true` for one migration
|
||||
window. This temporarily accepts old Bearer, Basic, and token-subprotocol
|
||||
relay clients. Update every relay client, then set it to `false`. V2 clients
|
||||
never downgrade after a failed proof or unsupported response.
|
||||
- Run a **node host** on the browser machine and let the Gateway proxy browser actions when the Gateway is remote from the browser (see [Browser tool](/tools/browser)); treat node pairing like admin access, keep Gateway and node host on the same tailnet, and avoid exposing relay/control ports over LAN, public internet, or Tailscale Funnel.
|
||||
|
||||
### Browser SSRF policy (strict by default)
|
||||
|
||||
@@ -63,16 +63,18 @@ has stale tab state.
|
||||
4. Click the OpenClaw toolbar icon and paste the pairing string into the popup.
|
||||
The badge turns **ON** when the extension connects to the relay.
|
||||
|
||||
The pairing token is a **per-host secret** created on first use and stored
|
||||
The pairing key is a **per-host secret** created on first use and stored
|
||||
under `credentials/` in the state directory (mode `0600`). Each machine that
|
||||
runs a browser — the Gateway host and every browser node host — owns its own
|
||||
token, so no credential has to travel between machines. To rotate it, delete the
|
||||
key, so no credential has to travel between machines. To rotate it, delete the
|
||||
`browser-extension-relay.secret` file and pair again.
|
||||
|
||||
The secret stays in the pairing string fragment rather than the WebSocket URL
|
||||
sent to the server. During connection, the extension presents it as a
|
||||
WebSocket subprotocol credential. This keeps it out of normal proxy request
|
||||
URLs and access logs; still treat the complete pairing string as a password.
|
||||
The key stays in the pairing string fragment rather than the WebSocket URL sent
|
||||
to the server. Browser Relay Authentication v2 uses it only as an HMAC key: the
|
||||
extension first verifies the relay's signed, connection-bound challenge, then
|
||||
sends a one-time proof. The key is never sent in a URL, header, WebSocket
|
||||
subprotocol, or application frame. Still treat the complete pairing string as a
|
||||
password.
|
||||
|
||||
## Use it
|
||||
|
||||
@@ -101,38 +103,71 @@ openclaw config set browser.defaultProfile chrome
|
||||
|
||||
### Authenticated external CDP clients
|
||||
|
||||
The relay supports authenticated external CDP clients such as mcporter,
|
||||
chrome-devtools-mcp, and Puppeteer. They use the same paired Chrome and the same
|
||||
tab-group consent boundary, without Chrome's "Allow remote debugging?" prompt.
|
||||
Print the endpoint and bearer-auth header:
|
||||
The relay supports Browser Relay Authentication v2 clients such as mcporter.
|
||||
They use the same paired Chrome and the same tab-group consent boundary,
|
||||
without Chrome's "Allow remote debugging?" prompt. Print the non-secret v2
|
||||
endpoint metadata:
|
||||
|
||||
```bash
|
||||
openclaw browser extension cdp
|
||||
```
|
||||
|
||||
For example, Google's [chrome-devtools-mcp](https://github.com/ChromeDevTools/chrome-devtools-mcp)
|
||||
connects with:
|
||||
`openclaw browser extension cdp --json` emits the loopback endpoint, protocol
|
||||
version, key ID, and fixed challenge/complete resource metadata. It never emits
|
||||
the relay key or an authorization header. A v2 client must keep one raw
|
||||
loopback TCP connection from challenge through `/json/version` and the `/cdp`
|
||||
WebSocket upgrade; redirects, reconnects, and a second upstream socket are not
|
||||
valid relay authentication.
|
||||
|
||||
During the migration window, an old external client can request the legacy
|
||||
Bearer header explicitly:
|
||||
|
||||
```bash
|
||||
npx chrome-devtools-mcp --wsEndpoint ws://127.0.0.1:18799/cdp \
|
||||
--wsHeaders '{"Authorization":"Bearer <token>"}'
|
||||
openclaw browser extension cdp --legacy-bearer
|
||||
```
|
||||
|
||||
`openclaw browser extension cdp --json` emits `{ browserUrl, wsEndpoint,
|
||||
headers }` for scripting. External CDP clients authenticate to the local CDP
|
||||
endpoint with this header; the extension authenticates its separate relay
|
||||
WebSocket with the subprotocol credential described above. Both derive from
|
||||
the same per-host relay secret. Treat it as private, and rotate it by deleting
|
||||
`credentials/browser-extension-relay.secret` and pairing again.
|
||||
This command warns because it reveals the relay key in a credential header. It
|
||||
works only while `browser.extensionRelay.allowLegacyAuth` is `true`; when legacy
|
||||
auth is disabled, the command fails without printing a credential.
|
||||
|
||||
[mcporter](https://github.com/openclaw/mcporter) is a supported external CDP
|
||||
client and needs no extension-side wiring: when a paired relay answers on this
|
||||
host, it transparently rewrites
|
||||
[mcporter](https://github.com/openclaw/mcporter) is the supported external CDP
|
||||
adapter. Use a release that supports Browser Relay Authentication v2; the
|
||||
OpenClaw-side upgrade does not update mcporter. When a paired relay answers on
|
||||
this host, a compatible mcporter release transparently rewrites
|
||||
`chrome-devtools-mcp --autoConnect` server commands to the relay endpoint, so
|
||||
agents calling Chrome DevTools through mcporter skip the remote-debugging
|
||||
prompt automatically (set `MCPORTER_DISABLE_CHROME_DEVTOOLS_RELAY=1` there to
|
||||
opt out).
|
||||
|
||||
## Migrate relay authentication
|
||||
|
||||
New OpenClaw extensions use Browser Relay Authentication v2 and never retry
|
||||
legacy authentication after a bad proof, timeout, unsupported response, or
|
||||
connection failure.
|
||||
|
||||
- Existing valid pairing strings migrate locally to `authVersion: 2`; you do
|
||||
not need to pair again for the protocol upgrade.
|
||||
- Stored direct-Gateway pairings behind a path-prefix proxy are cleared during
|
||||
migration. Re-run `openclaw browser extension pair` with a Gateway URL that
|
||||
has no path prefix; v2 supports the exact `/browser/extension` route only.
|
||||
- Upgrade OpenClaw before upgrading the extension. A v2 extension reports an
|
||||
old server as needing an upgrade instead of sending the old token.
|
||||
- Old extensions and external CDP clients continue to work for one migration
|
||||
window while `browser.extensionRelay.allowLegacyAuth` keeps its default value
|
||||
of `true`.
|
||||
- After every extension and external CDP client uses v2, set
|
||||
`browser.extensionRelay.allowLegacyAuth` to `false` and restart the Gateway or
|
||||
browser node host.
|
||||
- Rotating `credentials/browser-extension-relay.secret` changes the key ID,
|
||||
closes authenticated relay sessions, clears pending and replay state, and
|
||||
requires extension re-pairing.
|
||||
|
||||
V2 external CDP access requires a client that implements the same-socket HTTP
|
||||
challenge, completion, discovery, and WebSocket-upgrade sequence. Generic
|
||||
Puppeteer or chrome-devtools-mcp clients do not implement that sequence by
|
||||
themselves; use a v2-capable adapter, or the explicitly warned legacy escape
|
||||
hatch only during the migration window.
|
||||
|
||||
### Tab copilot side panel
|
||||
|
||||
After pairing the extension, click **Open tab copilot** in its toolbar popup.
|
||||
@@ -221,18 +256,20 @@ Chrome does not have to run on the Gateway host. Three topologies work:
|
||||
It prints a `wss://…/browser/extension#<secret>` string; load and pair the
|
||||
extension on the laptop. The extension connects **straight to the Gateway**
|
||||
over `wss://` — no OpenClaw install, Node, CLI, or open inbound port on the
|
||||
laptop. This is the managed-hosting path.
|
||||
laptop. This is the managed-hosting path. The Gateway URL must expose
|
||||
`/browser/extension` without a path-rewriting proxy prefix because v2 binds
|
||||
the exact request path into every proof.
|
||||
- **Via a browser node host** (Chrome on a machine already running an OpenClaw
|
||||
node): run `pair` on the node and pair locally; the Gateway proxies browser
|
||||
actions to the node over its existing authenticated node link.
|
||||
|
||||
The pairing secret is per host (the Gateway's, in the direct case), validated by
|
||||
the Gateway's `/browser/extension` route. For the direct path, serve the Gateway
|
||||
over TLS (`wss://`) so the pairing secret and CDP traffic are encrypted.
|
||||
The secret remains in the pairing string's URL fragment and is presented during
|
||||
the WebSocket handshake as a subprotocol credential, so normal proxy access
|
||||
logs do not receive it in the request URL. Ensure any reverse proxy preserves
|
||||
the standard `Sec-WebSocket-Protocol` header.
|
||||
over TLS (`wss://`) so the proof exchange and CDP traffic are encrypted. The
|
||||
secret remains in the pairing string's URL fragment and is never presented to
|
||||
the server. The extension offers only the non-secret
|
||||
`openclaw-extension-relay.v2` WebSocket subprotocol. Ensure any reverse proxy
|
||||
preserves the standard `Sec-WebSocket-Protocol` header.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
@@ -242,16 +279,22 @@ openclaw browser doctor --browser-profile chrome
|
||||
```
|
||||
|
||||
`doctor` reports the **Chrome extension relay** check as failing until the
|
||||
extension popup shows **Connected**.
|
||||
extension popup shows **Connected**. `openclaw doctor` also warns while legacy
|
||||
relay authentication remains enabled and tells you when to set
|
||||
`browser.extensionRelay.allowLegacyAuth=false`.
|
||||
|
||||
## Security model
|
||||
|
||||
- Same-host and browser-node relays bind loopback; direct remote pairing uses
|
||||
the Gateway's `wss://` route. Both WebSocket sides authenticate with the
|
||||
per-host secret, and the extension side is origin-checked to
|
||||
the Gateway's `wss://` route. Both use connection-bound HMAC proofs derived
|
||||
from the per-host key, and the extension side is origin-checked to
|
||||
`chrome-extension://`.
|
||||
- Direct Gateway pairing does not accept the relay token in the request URL;
|
||||
the bundled extension carries it in the WebSocket subprotocol list instead.
|
||||
- Before verifying the relay's server proof, the client sends only the
|
||||
non-secret key ID and a fresh nonce; it never sends an HMAC proof. Client
|
||||
proofs are short-lived, one-time, and bound to the exact socket, protocol
|
||||
version, role, transport, method, resource, flow, profile, and relay instance.
|
||||
- In v2, the per-host key is never transmitted. Failed proof validation does
|
||||
not fall back to legacy Bearer, Basic, or token-subprotocol auth.
|
||||
- The relay exposes only tabs in the **OpenClaw tab group**, and the extension
|
||||
independently rechecks group membership before each authority-bearing
|
||||
existing-tab command. Your other tabs stay private.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const getBrowserControlState = vi.hoisted(() => vi.fn());
|
||||
const closeTrackedBrowserTabs = vi.hoisted(() =>
|
||||
vi.fn(async (_params: { getResolvedBrowserConfig: () => unknown; sessionKeys: string[] }) => 0),
|
||||
);
|
||||
|
||||
vi.mock("./src/browser-control-state.js", () => ({ getBrowserControlState }));
|
||||
vi.mock("./src/browser/session-tab-registry.js", () => ({
|
||||
closeTrackedBrowserTabsForSessions: closeTrackedBrowserTabs,
|
||||
}));
|
||||
|
||||
import { closeTrackedBrowserTabsForSessions } from "./browser-maintenance.js";
|
||||
|
||||
describe("browser maintenance cleanup ownership", () => {
|
||||
it("injects the current live Browser runtime config without retaining a stale snapshot", async () => {
|
||||
const firstResolved = { marker: "first" };
|
||||
const secondResolved = { marker: "second" };
|
||||
getBrowserControlState.mockReturnValue({ resolved: firstResolved });
|
||||
|
||||
await closeTrackedBrowserTabsForSessions({ sessionKeys: ["agent:main:main"] });
|
||||
const cleanupParams = closeTrackedBrowserTabs.mock.calls[0]?.[0];
|
||||
expect(cleanupParams?.getResolvedBrowserConfig()).toBe(firstResolved);
|
||||
|
||||
getBrowserControlState.mockReturnValue({ resolved: secondResolved });
|
||||
expect(cleanupParams?.getResolvedBrowserConfig()).toBe(secondResolved);
|
||||
getBrowserControlState.mockReturnValue(null);
|
||||
expect(cleanupParams?.getResolvedBrowserConfig()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,5 +2,19 @@
|
||||
* Browser maintenance API barrel. It exposes tab cleanup and trash helpers for
|
||||
* runtime and doctor flows.
|
||||
*/
|
||||
export { closeTrackedBrowserTabsForSessions } from "./src/browser/session-tab-registry.js";
|
||||
import { closeTrackedBrowserTabsForSessions as closeTrackedBrowserTabs } from "./src/browser/session-tab-registry.js";
|
||||
|
||||
type CloseTrackedBrowserTabsParams = Parameters<typeof closeTrackedBrowserTabs>[0];
|
||||
|
||||
/** Route lifecycle cleanup through the currently running Browser runtime when available. */
|
||||
export async function closeTrackedBrowserTabsForSessions(
|
||||
params: CloseTrackedBrowserTabsParams,
|
||||
): Promise<number> {
|
||||
const { getBrowserControlState } = await import("./src/browser-control-state.js");
|
||||
return await closeTrackedBrowserTabs({
|
||||
...params,
|
||||
getResolvedBrowserConfig: () => getBrowserControlState()?.resolved ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export { movePathToTrash } from "./src/browser/trash.js";
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
waitForCondition,
|
||||
} from "./modules/page-share-core.js";
|
||||
import { createPageShareRelay } from "./modules/page-share-relay.js";
|
||||
import { createRelayCommandHandler } from "./modules/relay-command-handler.js";
|
||||
import { openAuthenticatedRelaySocket } from "./modules/relay-connection.js";
|
||||
// OpenClaw extension service worker.
|
||||
//
|
||||
// Thin transport between the OpenClaw extension relay (loopback WebSocket) and
|
||||
@@ -14,7 +16,6 @@ import { createPageShareRelay } from "./modules/page-share-relay.js";
|
||||
// consent boundary: only grouped tabs are reported to (and driven by) OpenClaw.
|
||||
import {
|
||||
OPENCLAW_TAB_GROUP_TITLE,
|
||||
buildRelayWsProtocols,
|
||||
createPairingConfigStore,
|
||||
nearestGroupColor,
|
||||
parsePairingString,
|
||||
@@ -42,7 +43,7 @@ const COPILOT_RELAY_LABEL = {
|
||||
};
|
||||
const RELAY_WATCHDOG_ALARM = "openclaw-relay-watchdog";
|
||||
const RELAY_OPENING_DEADLINE_ALARM = "openclaw-relay-opening-deadline";
|
||||
const RELAY_OPENING_TIMEOUT_MS = 30_000;
|
||||
const RELAY_AUTH_TIMEOUT_MS = 10_000;
|
||||
|
||||
/** @type {WebSocket|null} */
|
||||
let relayWs = null;
|
||||
@@ -51,6 +52,9 @@ let copilot = null;
|
||||
let reconnectAttempt = 0;
|
||||
let reconnectTimer = null;
|
||||
let relayOpeningDeadlineAt = 0;
|
||||
let relayOpeningDeadlineTimer = null;
|
||||
let relayAuthenticatedSocket = null;
|
||||
let relayStatusHint = "";
|
||||
let reconciledPairingInvalidationRevision = 0;
|
||||
/** Tab ids with an active chrome.debugger attachment. */
|
||||
const attachedTabs = new Set();
|
||||
@@ -74,6 +78,9 @@ function closeRelaySocket() {
|
||||
return;
|
||||
}
|
||||
relayWs = null;
|
||||
if (relayAuthenticatedSocket === socket) {
|
||||
relayAuthenticatedSocket = null;
|
||||
}
|
||||
// Chrome completes close asynchronously; fail pending requests before the
|
||||
// handshake so pairing and unpairing never leave a popup stuck on Sending.
|
||||
pageShareRelay.rejectSocket(socket);
|
||||
@@ -118,7 +125,11 @@ function flashPageShareBadge(ok) {
|
||||
}
|
||||
|
||||
async function getConfig() {
|
||||
return await pairingConfigStore.read();
|
||||
const config = await pairingConfigStore.read();
|
||||
if (config.pairingStatusHint) {
|
||||
relayStatusHint = config.pairingStatusHint;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -171,7 +182,7 @@ function scheduleTabsSync() {
|
||||
}
|
||||
|
||||
async function syncTabsToRelay() {
|
||||
if (!relayWs || relayWs.readyState !== WebSocket.OPEN) {
|
||||
if (!relayWs || relayWs.readyState !== WebSocket.OPEN || relayAuthenticatedSocket !== relayWs) {
|
||||
return;
|
||||
}
|
||||
const shared = await listSharedTabs();
|
||||
@@ -332,87 +343,51 @@ chrome.debugger.onDetach.addListener((source, reason) => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function send(message) {
|
||||
if (relayWs && relayWs.readyState === WebSocket.OPEN) {
|
||||
if (relayWs && relayWs.readyState === WebSocket.OPEN && relayAuthenticatedSocket === relayWs) {
|
||||
relayWs.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
function clearRelayOpeningDeadline() {
|
||||
relayOpeningDeadlineAt = 0;
|
||||
if (relayOpeningDeadlineTimer) {
|
||||
clearTimeout(relayOpeningDeadlineTimer);
|
||||
relayOpeningDeadlineTimer = null;
|
||||
}
|
||||
void chrome.alarms.clear(RELAY_OPENING_DEADLINE_ALARM);
|
||||
}
|
||||
|
||||
function armRelayOpeningDeadline() {
|
||||
relayOpeningDeadlineAt = Date.now() + RELAY_OPENING_TIMEOUT_MS;
|
||||
clearRelayOpeningDeadline();
|
||||
relayOpeningDeadlineAt = Date.now() + RELAY_AUTH_TIMEOUT_MS;
|
||||
relayOpeningDeadlineTimer = setTimeout(handleRelayOpeningDeadline, RELAY_AUTH_TIMEOUT_MS);
|
||||
chrome.alarms.create(RELAY_OPENING_DEADLINE_ALARM, { when: relayOpeningDeadlineAt });
|
||||
}
|
||||
|
||||
async function handleRelayCommand(msg) {
|
||||
const { seq } = msg;
|
||||
function failRelayAuthentication(ws, error) {
|
||||
if (relayWs !== ws) {
|
||||
return;
|
||||
}
|
||||
relayStatusHint =
|
||||
"Relay authentication v2 failed. Update OpenClaw, or re-pair after a relay key rotation.";
|
||||
try {
|
||||
switch (msg.type) {
|
||||
case "ping":
|
||||
send({ type: "pong" });
|
||||
return;
|
||||
case "attach": {
|
||||
const result = await attachDebugger(msg.tabId);
|
||||
send({ type: "result", seq, result });
|
||||
return;
|
||||
}
|
||||
case "detach": {
|
||||
// Detach is the cleanup primitive after consent is revoked, so it must
|
||||
// remain available when the tab is no longer shared.
|
||||
await detachDebugger(msg.tabId);
|
||||
send({ type: "result", seq, result: {} });
|
||||
return;
|
||||
}
|
||||
case "cdp": {
|
||||
await requireSharedTab(msg.tabId);
|
||||
const target = msg.sessionId
|
||||
? { tabId: msg.tabId, sessionId: msg.sessionId }
|
||||
: { tabId: msg.tabId };
|
||||
const result = await chrome.debugger.sendCommand(target, msg.method, msg.params ?? {});
|
||||
send({ type: "result", seq, result: result ?? {} });
|
||||
return;
|
||||
}
|
||||
case "createTab": {
|
||||
const tab = await chrome.tabs.create({ url: msg.url, active: msg.background !== true });
|
||||
await addTabToOpenClawGroup(tab.id);
|
||||
if (msg.focus === true) {
|
||||
await focusWindowForTab(tab);
|
||||
}
|
||||
scheduleTabsSync();
|
||||
send({ type: "result", seq, result: { tabId: tab.id } });
|
||||
return;
|
||||
}
|
||||
case "closeTab": {
|
||||
await requireSharedTab(msg.tabId);
|
||||
await detachDebugger(msg.tabId);
|
||||
await requireSharedTab(msg.tabId);
|
||||
await chrome.tabs.remove(msg.tabId);
|
||||
send({ type: "result", seq, result: {} });
|
||||
return;
|
||||
}
|
||||
case "activateTab": {
|
||||
const tab = await requireSharedTab(msg.tabId);
|
||||
await chrome.tabs.update(msg.tabId, { active: true });
|
||||
await requireSharedTab(msg.tabId);
|
||||
await focusWindowForTab(tab);
|
||||
send({ type: "result", seq, result: {} });
|
||||
return;
|
||||
}
|
||||
default:
|
||||
if (typeof seq === "number") {
|
||||
send({ type: "error", seq, message: `unknown relay command: ${msg.type}` });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (typeof seq === "number") {
|
||||
send({ type: "error", seq, message: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
ws.close(4001, error instanceof Error ? error.message.slice(0, 120) : "authentication failed");
|
||||
} catch {
|
||||
closeRelaySocket();
|
||||
setBadge("error");
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
const handleRelayCommand = createRelayCommandHandler({
|
||||
send,
|
||||
attachDebugger,
|
||||
detachDebugger,
|
||||
addTabToOpenClawGroup,
|
||||
focusWindowForTab,
|
||||
scheduleTabsSync,
|
||||
});
|
||||
|
||||
async function sendHello() {
|
||||
const shared = await listSharedTabs();
|
||||
const uaMatch = /Chrom(?:e|ium)\/[\d.]+/.exec(navigator.userAgent);
|
||||
@@ -442,52 +417,57 @@ async function connectRelay() {
|
||||
setBadge("connecting");
|
||||
let ws;
|
||||
try {
|
||||
ws = new WebSocket(relayUrl, buildRelayWsProtocols(token));
|
||||
ws = openAuthenticatedRelaySocket({
|
||||
relayUrl,
|
||||
token,
|
||||
isCurrent: (socket) => relayWs === socket,
|
||||
onAuthenticated: async (socket) => {
|
||||
relayAuthenticatedSocket = socket;
|
||||
relayStatusHint = "";
|
||||
clearRelayOpeningDeadline();
|
||||
reconnectAttempt = 0;
|
||||
setBadge("on");
|
||||
await sendHello();
|
||||
},
|
||||
onApplicationMessage: (socket, msg) => {
|
||||
if (msg?.type === "pageShareResult") {
|
||||
pageShareRelay.settle(socket, msg);
|
||||
return;
|
||||
}
|
||||
void handleRelayCommand(msg);
|
||||
},
|
||||
onAuthenticationFailure: (socket, error) => failRelayAuthentication(socket, error),
|
||||
onClose: (socket, authenticated) => {
|
||||
pageShareRelay.rejectSocket(socket);
|
||||
if (relayWs !== socket) {
|
||||
return;
|
||||
}
|
||||
clearRelayOpeningDeadline();
|
||||
relayWs = null;
|
||||
if (authenticated) {
|
||||
relayAuthenticatedSocket = null;
|
||||
} else if (!relayStatusHint) {
|
||||
relayStatusHint =
|
||||
"Relay authentication v2 failed. Update OpenClaw, or re-pair after a relay key rotation.";
|
||||
}
|
||||
setBadge("error");
|
||||
scheduleReconnect();
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
setBadge("error");
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
relayWs = ws;
|
||||
relayAuthenticatedSocket = null;
|
||||
armRelayOpeningDeadline();
|
||||
ws.addEventListener("open", () => {
|
||||
if (relayWs !== ws) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
clearRelayOpeningDeadline();
|
||||
reconnectAttempt = 0;
|
||||
setBadge("on");
|
||||
void sendHello();
|
||||
});
|
||||
ws.addEventListener("message", (event) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(String(event.data));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (msg?.type === "pageShareResult") {
|
||||
pageShareRelay.settle(ws, msg);
|
||||
return;
|
||||
}
|
||||
void handleRelayCommand(msg);
|
||||
});
|
||||
ws.addEventListener("close", () => {
|
||||
pageShareRelay.rejectSocket(ws);
|
||||
if (relayWs === ws) {
|
||||
clearRelayOpeningDeadline();
|
||||
relayWs = null;
|
||||
setBadge("error");
|
||||
scheduleReconnect();
|
||||
}
|
||||
});
|
||||
// onclose follows onerror and drives the reconnect, so no error handler needed.
|
||||
}
|
||||
|
||||
async function sendPageShareRequest(payload) {
|
||||
const socket = relayWs;
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
||||
if (!socket || socket.readyState !== WebSocket.OPEN || relayAuthenticatedSocket !== socket) {
|
||||
throw new Error("Relay not connected.");
|
||||
}
|
||||
await pageShareRelay.send(socket, payload);
|
||||
@@ -499,9 +479,14 @@ async function ensureRelayReady() {
|
||||
if (!config.relayUrl || !config.token) {
|
||||
throw new Error("Pair the extension first.");
|
||||
}
|
||||
if (!relayWs || relayWs.readyState !== WebSocket.OPEN) {
|
||||
if (!relayWs || relayWs.readyState !== WebSocket.OPEN || relayAuthenticatedSocket !== relayWs) {
|
||||
await connectRelay();
|
||||
if (!(await waitForCondition(() => relayWs?.readyState === WebSocket.OPEN, 3_000))) {
|
||||
if (
|
||||
!(await waitForCondition(
|
||||
() => relayWs?.readyState === WebSocket.OPEN && relayAuthenticatedSocket === relayWs,
|
||||
RELAY_AUTH_TIMEOUT_MS,
|
||||
))
|
||||
) {
|
||||
throw new Error("Relay not connected.");
|
||||
}
|
||||
}
|
||||
@@ -567,34 +552,38 @@ const copilotCustodyReady = copilot.initializeCustody();
|
||||
const copilotReady = copilot.initialize();
|
||||
|
||||
function handleRelayOpeningDeadline() {
|
||||
// Unit-test module isolation can outlive the mocked Chrome global. The real
|
||||
// MV3 worker always has chrome; a detached test timer has no owner to mutate.
|
||||
if (typeof chrome === "undefined") {
|
||||
relayOpeningDeadlineAt = 0;
|
||||
relayOpeningDeadlineTimer = null;
|
||||
return;
|
||||
}
|
||||
const ws = relayWs;
|
||||
if (!ws) {
|
||||
clearRelayOpeningDeadline();
|
||||
void connectRelay();
|
||||
return;
|
||||
}
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
if (relayAuthenticatedSocket === ws) {
|
||||
clearRelayOpeningDeadline();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
ws.readyState !== WebSocket.CONNECTING ||
|
||||
relayOpeningDeadlineAt === 0 ||
|
||||
Date.now() < relayOpeningDeadlineAt
|
||||
) {
|
||||
if (relayOpeningDeadlineAt === 0 || Date.now() < relayOpeningDeadlineAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear ownership before close so a delayed close/open event from this
|
||||
// socket cannot mutate the replacement connection's badge or deadline.
|
||||
relayWs = null;
|
||||
relayAuthenticatedSocket = null;
|
||||
clearRelayOpeningDeadline();
|
||||
try {
|
||||
ws.close();
|
||||
ws.close(4001, "relay authentication timed out");
|
||||
} catch {
|
||||
// The socket may have changed state while the alarm event was queued.
|
||||
}
|
||||
setBadge("error");
|
||||
relayStatusHint = "Relay authentication v2 timed out. Make sure OpenClaw is up to date.";
|
||||
scheduleReconnect();
|
||||
}
|
||||
|
||||
@@ -638,6 +627,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, reply) => {
|
||||
state: relayState,
|
||||
sharedTabCount: shared.length,
|
||||
relayUrl: relayUrl ?? "",
|
||||
...(relayStatusHint ? { hint: relayStatusHint } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -648,6 +638,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, reply) => {
|
||||
return;
|
||||
}
|
||||
await pairingConfigStore.save(parsed, nearestGroupColor(msg.groupColor));
|
||||
relayStatusHint = "";
|
||||
reconnectAttempt = 0;
|
||||
clearRelayOpeningDeadline();
|
||||
closeRelaySocket();
|
||||
@@ -658,6 +649,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, reply) => {
|
||||
}
|
||||
case "unpair": {
|
||||
await pairingConfigStore.clear();
|
||||
relayStatusHint = "";
|
||||
clearRelayOpeningDeadline();
|
||||
closeRelaySocket();
|
||||
setBadge("off");
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export const AUTH_INSTANCE_ID = "ICEiIyQlJicoKSorLC0uLw";
|
||||
export const AUTH_SESSION_ID = "MDEyMzQ1Njc4OTo7PD0-Pw";
|
||||
export const AUTH_SERVER_NONCE = "YGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8";
|
||||
|
||||
type SocketEvent = { data?: unknown };
|
||||
type SocketListener = (event: SocketEvent) => void;
|
||||
|
||||
export type RuntimeMessageListener = (
|
||||
message: { type: string; tabId?: number; note?: string; pairingString?: string },
|
||||
sender: unknown,
|
||||
sendResponse: (response: unknown) => void,
|
||||
) => boolean;
|
||||
|
||||
export type PageCaptureResult = {
|
||||
content: string;
|
||||
selection: string;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
let configuredSockets: FakeWebSocket[] = [];
|
||||
let configuredDeferredClose = false;
|
||||
let configuredProtocol: string | undefined;
|
||||
|
||||
export function configureFakeWebSockets(options: {
|
||||
sockets: FakeWebSocket[];
|
||||
deferSocketClose: boolean;
|
||||
relayNegotiatedProtocol?: string;
|
||||
}): void {
|
||||
configuredSockets = options.sockets;
|
||||
configuredDeferredClose = options.deferSocketClose;
|
||||
configuredProtocol = options.relayNegotiatedProtocol;
|
||||
}
|
||||
|
||||
export class FakeWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
readyState = FakeWebSocket.CONNECTING;
|
||||
readonly protocol: string;
|
||||
readonly send = vi.fn();
|
||||
readonly close = vi.fn(() => {
|
||||
if (configuredDeferredClose) {
|
||||
this.readyState = FakeWebSocket.CLOSING;
|
||||
return;
|
||||
}
|
||||
this.readyState = FakeWebSocket.CLOSED;
|
||||
this.emit("close");
|
||||
});
|
||||
private readonly listeners = new Map<string, SocketListener[]>();
|
||||
|
||||
constructor(
|
||||
readonly url: string,
|
||||
readonly protocols: string[] = [],
|
||||
) {
|
||||
this.protocol = protocols.includes("openclaw-extension-relay.v2")
|
||||
? (configuredProtocol ?? "openclaw-extension-relay.v2")
|
||||
: (protocols[0] ?? "");
|
||||
configuredSockets.push(this);
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: SocketListener): void {
|
||||
const listeners = this.listeners.get(type) ?? [];
|
||||
listeners.push(listener);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.readyState = FakeWebSocket.OPEN;
|
||||
this.emit("open");
|
||||
}
|
||||
|
||||
receive(message: unknown): void {
|
||||
this.emit("message", { data: JSON.stringify(message) });
|
||||
}
|
||||
|
||||
private emit(type: string, event: SocketEvent = {}): void {
|
||||
for (const listener of this.listeners.get(type) ?? []) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +1,32 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AUTH_INSTANCE_ID,
|
||||
AUTH_SERVER_NONCE,
|
||||
AUTH_SESSION_ID,
|
||||
configureFakeWebSockets,
|
||||
FakeWebSocket,
|
||||
} from "./background.test-support.js";
|
||||
import type { PageCaptureResult, RuntimeMessageListener } from "./background.test-support.js";
|
||||
import { computeRelayAuthProof } from "./modules/relay-auth-v2-crypto.js";
|
||||
|
||||
const RELAY_WATCHDOG_ALARM = "openclaw-relay-watchdog";
|
||||
const RELAY_OPENING_DEADLINE_ALARM = "openclaw-relay-opening-deadline";
|
||||
const START_TIME_MS = Date.parse("2026-07-16T08:00:00.000Z");
|
||||
const RELAY_SECRET = "a".repeat(64);
|
||||
const REPLACEMENT_RELAY_SECRET = "b".repeat(64);
|
||||
const PAIRING_CONFIG_KEYS = ["relayUrl", "gatewayUrl", "token", "groupColor"];
|
||||
|
||||
type SocketEvent = { data?: unknown };
|
||||
type SocketListener = (event: SocketEvent) => void;
|
||||
type RuntimeMessageListener = (
|
||||
message: { type: string; tabId?: number; note?: string; pairingString?: string },
|
||||
sender: unknown,
|
||||
sendResponse: (response: unknown) => void,
|
||||
) => boolean;
|
||||
type PageCaptureResult = {
|
||||
content: string;
|
||||
selection: string;
|
||||
title: string;
|
||||
url: string;
|
||||
};
|
||||
const PAIRING_CONFIG_KEYS = ["relayUrl", "token", "pairingStatus"];
|
||||
|
||||
async function loadBackground({
|
||||
deferSocketClose = false,
|
||||
onConsentChanged,
|
||||
rejectStorageRemove = false,
|
||||
relayNegotiatedProtocol,
|
||||
storedConfig,
|
||||
}: {
|
||||
deferSocketClose?: boolean;
|
||||
onConsentChanged?: () => Promise<void>;
|
||||
rejectStorageRemove?: boolean;
|
||||
relayNegotiatedProtocol?: string;
|
||||
storedConfig?: Record<string, unknown>;
|
||||
} = {}) {
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
@@ -42,56 +39,11 @@ async function loadBackground({
|
||||
...(storedConfig ?? {
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
token: RELAY_SECRET,
|
||||
authVersion: 2,
|
||||
groupColor: "orange",
|
||||
}),
|
||||
};
|
||||
|
||||
class FakeWebSocket {
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
readyState = FakeWebSocket.CONNECTING;
|
||||
readonly send = vi.fn();
|
||||
readonly close = vi.fn(() => {
|
||||
if (deferSocketClose) {
|
||||
this.readyState = FakeWebSocket.CLOSING;
|
||||
return;
|
||||
}
|
||||
this.readyState = FakeWebSocket.CLOSED;
|
||||
this.emit("close");
|
||||
});
|
||||
private readonly listeners = new Map<string, SocketListener[]>();
|
||||
|
||||
constructor(
|
||||
readonly url: string,
|
||||
readonly protocols: string[] = [],
|
||||
) {
|
||||
sockets.push(this);
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: SocketListener) {
|
||||
const listeners = this.listeners.get(type) ?? [];
|
||||
listeners.push(listener);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
open() {
|
||||
this.readyState = FakeWebSocket.OPEN;
|
||||
this.emit("open");
|
||||
}
|
||||
|
||||
receive(message: unknown) {
|
||||
this.emit("message", { data: JSON.stringify(message) });
|
||||
}
|
||||
|
||||
private emit(type: string, event: SocketEvent = {}) {
|
||||
for (const listener of this.listeners.get(type) ?? []) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
configureFakeWebSockets({ sockets, deferSocketClose, relayNegotiatedProtocol });
|
||||
|
||||
const addListener = vi.fn();
|
||||
const createAlarm = vi.fn();
|
||||
@@ -230,10 +182,8 @@ async function loadBackground({
|
||||
const backgroundModulePath = "./background.js";
|
||||
await import(backgroundModulePath);
|
||||
await vi.waitFor(() => {
|
||||
const pairingReads = storageGet.mock.calls.filter(
|
||||
([keys]) =>
|
||||
keys.length === PAIRING_CONFIG_KEYS.length &&
|
||||
PAIRING_CONFIG_KEYS.every((key) => keys.includes(key)),
|
||||
const pairingReads = storageGet.mock.calls.filter(([keys]) =>
|
||||
PAIRING_CONFIG_KEYS.every((key) => keys.includes(key)),
|
||||
);
|
||||
expect(pairingReads.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
@@ -263,11 +213,73 @@ async function loadBackground({
|
||||
return release;
|
||||
},
|
||||
get gatewaySockets() {
|
||||
return sockets.filter((socket) => !socket.protocols.includes("openclaw-extension-relay"));
|
||||
return sockets.filter((socket) => !socket.protocols.includes("openclaw-extension-relay.v2"));
|
||||
},
|
||||
messageListener,
|
||||
get relaySockets() {
|
||||
return sockets.filter((socket) => socket.protocols.includes("openclaw-extension-relay"));
|
||||
return sockets.filter((socket) => socket.protocols.includes("openclaw-extension-relay.v2"));
|
||||
},
|
||||
authenticate: async (socket: FakeWebSocket) => {
|
||||
if (socket.readyState !== FakeWebSocket.OPEN) {
|
||||
socket.open();
|
||||
}
|
||||
await vi.waitFor(() => {
|
||||
expect(socket.send).toHaveBeenCalled();
|
||||
});
|
||||
const helloRaw = socket.send.mock.calls.find(
|
||||
([raw]) => JSON.parse(raw).type === "auth.hello",
|
||||
)?.[0];
|
||||
if (typeof helloRaw !== "string") {
|
||||
throw new Error("expected auth.hello");
|
||||
}
|
||||
const hello = JSON.parse(helloRaw) as { keyId: string; clientNonce: string };
|
||||
const issuedAtMs = Date.now();
|
||||
const fields = {
|
||||
keyId: hello.keyId,
|
||||
instanceId: AUTH_INSTANCE_ID,
|
||||
sessionId: AUTH_SESSION_ID,
|
||||
clientNonce: hello.clientNonce,
|
||||
serverNonce: AUTH_SERVER_NONCE,
|
||||
issuedAtMs,
|
||||
expiresAtMs: issuedAtMs + 10_000,
|
||||
role: "extension",
|
||||
transport: "websocket",
|
||||
method: "GET",
|
||||
resource: new URL(socket.url).pathname + new URL(socket.url).search,
|
||||
flow: "extension",
|
||||
};
|
||||
socket.receive({
|
||||
type: "auth.challenge",
|
||||
v: 2,
|
||||
...fields,
|
||||
serverProof: await computeRelayAuthProof(String(storageValues.token), "server", fields),
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(
|
||||
socket.send.mock.calls.some(([raw]) => JSON.parse(raw).type === "auth.response"),
|
||||
).toBe(true);
|
||||
});
|
||||
const responseRaw = socket.send.mock.calls.find(
|
||||
([raw]) => JSON.parse(raw).type === "auth.response",
|
||||
)?.[0];
|
||||
if (typeof responseRaw !== "string") {
|
||||
throw new Error("expected auth.response");
|
||||
}
|
||||
const response = JSON.parse(responseRaw) as { clientProof: string };
|
||||
socket.receive({
|
||||
type: "auth.ok",
|
||||
v: 2,
|
||||
sessionId: AUTH_SESSION_ID,
|
||||
acceptProof: await computeRelayAuthProof(
|
||||
String(storageValues.token),
|
||||
"accept",
|
||||
fields,
|
||||
response.clientProof,
|
||||
),
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(socket.send.mock.calls.some(([raw]) => JSON.parse(raw).type === "hello")).toBe(true);
|
||||
});
|
||||
},
|
||||
setBadgeText,
|
||||
sockets,
|
||||
@@ -303,9 +315,10 @@ describe("persisted relay pairing validation", () => {
|
||||
it("opens the canonical persisted pairing on startup", async () => {
|
||||
const harness = await loadBackground({
|
||||
storedConfig: {
|
||||
relayUrl: "wss://gateway.example.com/base/browser/extension",
|
||||
relayUrl: "wss://gateway.example.com/browser/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "wss://gateway.example.com/base",
|
||||
authVersion: 2,
|
||||
gatewayUrl: "wss://gateway.example.com",
|
||||
groupColor: "blue",
|
||||
},
|
||||
});
|
||||
@@ -315,12 +328,26 @@ describe("persisted relay pairing validation", () => {
|
||||
expect(harness.gatewaySockets).toHaveLength(1);
|
||||
});
|
||||
expect(harness.relaySockets[0]).toMatchObject({
|
||||
url: "wss://gateway.example.com/base/browser/extension",
|
||||
protocols: ["openclaw-extension-relay", `openclaw-extension-token.${RELAY_SECRET}`],
|
||||
url: "wss://gateway.example.com/browser/extension",
|
||||
protocols: ["openclaw-extension-relay.v2"],
|
||||
});
|
||||
expect(harness.storageRemove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("migrates a canonical existing pairing to authVersion 2 before connecting", async () => {
|
||||
const harness = await loadBackground({
|
||||
storedConfig: {
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "",
|
||||
groupColor: "orange",
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(harness.relaySockets).toHaveLength(1));
|
||||
expect(harness.storageSet).toHaveBeenCalledWith({ authVersion: 2 });
|
||||
expect(harness.storageValues.authVersion).toBe(2);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an invalid token", { relayUrl: "ws://127.0.0.1:18797/extension", token: "short" }],
|
||||
[
|
||||
@@ -369,12 +396,16 @@ describe("persisted relay pairing validation", () => {
|
||||
{ relayUrl: "ws://127.0.0.1:18797/extension?unknown=1", token: RELAY_SECRET },
|
||||
],
|
||||
["partial state", { relayUrl: "ws://127.0.0.1:18797/extension", groupColor: "orange" }],
|
||||
[
|
||||
"a proxy-prefixed direct pairing",
|
||||
{ relayUrl: "wss://gateway.example.com/proxy/browser/extension", token: RELAY_SECRET },
|
||||
],
|
||||
[
|
||||
"mismatched direct state",
|
||||
{
|
||||
relayUrl: "wss://gateway.example.com/base/browser/extension",
|
||||
relayUrl: "wss://gateway.example.com/browser/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "wss://other.example.com/base",
|
||||
gatewayUrl: "wss://other.example.com",
|
||||
},
|
||||
],
|
||||
])("clears %s before startup can open a socket", async (_label, storedConfig) => {
|
||||
@@ -382,16 +413,23 @@ describe("persisted relay pairing validation", () => {
|
||||
|
||||
expect(harness.relaySockets).toHaveLength(0);
|
||||
expect(harness.gatewaySockets).toHaveLength(0);
|
||||
expect(harness.storageRemove).toHaveBeenCalledWith(["relayUrl", "gatewayUrl", "token"]);
|
||||
expect(harness.storageRemove).toHaveBeenCalledWith([
|
||||
"relayUrl",
|
||||
"gatewayUrl",
|
||||
"token",
|
||||
"authVersion",
|
||||
]);
|
||||
const response = vi.fn();
|
||||
harness.messageListener({ type: "getStatus" }, {}, response);
|
||||
await vi.waitFor(() => {
|
||||
expect(response).toHaveBeenCalledWith({
|
||||
paired: false,
|
||||
state: "off",
|
||||
sharedTabCount: 0,
|
||||
relayUrl: "",
|
||||
});
|
||||
expect(response).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
paired: false,
|
||||
state: "off",
|
||||
sharedTabCount: 0,
|
||||
relayUrl: "",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -430,7 +468,12 @@ describe("persisted relay pairing validation", () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
|
||||
expect(harness.sockets).toHaveLength(1);
|
||||
expect(harness.storageRemove).toHaveBeenCalledWith(["relayUrl", "gatewayUrl", "token"]);
|
||||
expect(harness.storageRemove).toHaveBeenCalledWith([
|
||||
"relayUrl",
|
||||
"gatewayUrl",
|
||||
"token",
|
||||
"authVersion",
|
||||
]);
|
||||
expect(harness.setBadgeText).toHaveBeenLastCalledWith({ text: "" });
|
||||
});
|
||||
|
||||
@@ -491,6 +534,81 @@ describe("persisted relay pairing validation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("relay authentication v2 transport", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("offers only the non-secret v2 protocol", async () => {
|
||||
const harness = await loadBackground();
|
||||
const socket = harness.relaySockets[0];
|
||||
expect(socket?.protocols).toEqual(["openclaw-extension-relay.v2"]);
|
||||
expect(JSON.stringify(socket?.protocols)).not.toContain(RELAY_SECRET);
|
||||
});
|
||||
|
||||
it("rejects a mismatched negotiated protocol before sending any frame", async () => {
|
||||
const harness = await loadBackground({ relayNegotiatedProtocol: "" });
|
||||
const socket = harness.relaySockets[0];
|
||||
if (!socket) {
|
||||
throw new Error("expected relay socket");
|
||||
}
|
||||
socket.open();
|
||||
await vi.waitFor(() => expect(socket.close).toHaveBeenCalled());
|
||||
expect(socket.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends no client proof or application hello after a bad server proof", async () => {
|
||||
const harness = await loadBackground();
|
||||
const socket = harness.relaySockets[0];
|
||||
if (!socket) {
|
||||
throw new Error("expected relay socket");
|
||||
}
|
||||
socket.open();
|
||||
await vi.waitFor(() => expect(socket.send).toHaveBeenCalled());
|
||||
const helloRaw = socket.send.mock.calls[0]?.[0];
|
||||
const hello = JSON.parse(helloRaw) as { keyId: string; clientNonce: string };
|
||||
const issuedAtMs = Date.now();
|
||||
socket.receive({
|
||||
type: "auth.challenge",
|
||||
v: 2,
|
||||
keyId: hello.keyId,
|
||||
instanceId: AUTH_INSTANCE_ID,
|
||||
sessionId: AUTH_SESSION_ID,
|
||||
clientNonce: hello.clientNonce,
|
||||
serverNonce: AUTH_SERVER_NONCE,
|
||||
issuedAtMs,
|
||||
expiresAtMs: issuedAtMs + 10_000,
|
||||
role: "extension",
|
||||
transport: "websocket",
|
||||
method: "GET",
|
||||
resource: "/extension",
|
||||
flow: "extension",
|
||||
serverProof: "A".repeat(43),
|
||||
});
|
||||
await vi.waitFor(() => expect(socket.close).toHaveBeenCalled());
|
||||
const types = socket.send.mock.calls.map(([raw]) => JSON.parse(raw).type);
|
||||
expect(types).toEqual(["auth.hello"]);
|
||||
expect(harness.setBadgeText).not.toHaveBeenLastCalledWith({ text: "ON" });
|
||||
});
|
||||
|
||||
it("rejects application commands before authentication", async () => {
|
||||
const harness = await loadBackground();
|
||||
const socket = harness.relaySockets[0];
|
||||
if (!socket) {
|
||||
throw new Error("expected relay socket");
|
||||
}
|
||||
socket.open();
|
||||
await vi.waitFor(() => expect(socket.send).toHaveBeenCalled());
|
||||
socket.receive({ type: "attach", seq: 1, tabId: 1 });
|
||||
await vi.waitFor(() => expect(socket.close).toHaveBeenCalled());
|
||||
expect(harness.debuggerAttach).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
async function startPendingPageShare(
|
||||
harness: Awaited<ReturnType<typeof loadBackground>>,
|
||||
socket = harness.sockets.at(-1),
|
||||
@@ -499,7 +617,7 @@ async function startPendingPageShare(
|
||||
throw new Error("expected the page-share relay socket");
|
||||
}
|
||||
if (socket.readyState !== 1) {
|
||||
socket.open();
|
||||
await harness.authenticate(socket);
|
||||
}
|
||||
harness.executeScript.mockResolvedValueOnce([
|
||||
{
|
||||
@@ -544,10 +662,10 @@ describe("relay opening deadline", () => {
|
||||
periodInMinutes: 0.5,
|
||||
});
|
||||
expect(harness.createAlarm).toHaveBeenCalledWith(RELAY_OPENING_DEADLINE_ALARM, {
|
||||
when: START_TIME_MS + 30_000,
|
||||
when: START_TIME_MS + 10_000,
|
||||
});
|
||||
|
||||
vi.setSystemTime(START_TIME_MS + 30_000);
|
||||
vi.setSystemTime(START_TIME_MS + 10_000);
|
||||
harness.alarmListener({ name: RELAY_OPENING_DEADLINE_ALARM });
|
||||
|
||||
expect(harness.sockets[0]?.close).toHaveBeenCalledOnce();
|
||||
@@ -557,16 +675,23 @@ describe("relay opening deadline", () => {
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(harness.sockets).toHaveLength(2);
|
||||
expect(harness.createAlarm).toHaveBeenLastCalledWith(RELAY_OPENING_DEADLINE_ALARM, {
|
||||
when: START_TIME_MS + 61_000,
|
||||
when: START_TIME_MS + 21_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the deadline after the socket opens", async () => {
|
||||
it("clears the deadline only after relay authentication completes", async () => {
|
||||
const harness = await loadBackground();
|
||||
const socket = harness.sockets[0];
|
||||
expect(socket).toBeDefined();
|
||||
|
||||
const clearsBeforeOpen = harness.clearAlarm.mock.calls.length;
|
||||
socket?.open();
|
||||
expect(harness.clearAlarm).toHaveBeenCalledTimes(clearsBeforeOpen);
|
||||
expect(harness.setBadgeText).toHaveBeenLastCalledWith({ text: "…" });
|
||||
|
||||
if (socket) {
|
||||
await harness.authenticate(socket);
|
||||
}
|
||||
expect(harness.clearAlarm).toHaveBeenCalledWith(RELAY_OPENING_DEADLINE_ALARM);
|
||||
expect(harness.setBadgeText).toHaveBeenLastCalledWith({ text: "ON" });
|
||||
|
||||
@@ -875,7 +1000,7 @@ describe("relay command authorization", () => {
|
||||
if (!socket) {
|
||||
throw new Error("expected relay socket");
|
||||
}
|
||||
socket.open();
|
||||
await harness.authenticate(socket);
|
||||
harness.shareTab(41);
|
||||
harness.unshareTab(41);
|
||||
|
||||
@@ -906,7 +1031,7 @@ describe("relay command authorization", () => {
|
||||
if (!socket) {
|
||||
throw new Error("expected relay socket");
|
||||
}
|
||||
socket.open();
|
||||
await harness.authenticate(socket);
|
||||
harness.unshareTab(41);
|
||||
|
||||
socket.receive({ type: "detach", seq: 5, tabId: 41 });
|
||||
@@ -924,7 +1049,7 @@ describe("relay command authorization", () => {
|
||||
if (!socket) {
|
||||
throw new Error("expected relay socket");
|
||||
}
|
||||
socket.open();
|
||||
await harness.authenticate(socket);
|
||||
harness.tabsCreate.mockResolvedValueOnce({ id: 42 });
|
||||
|
||||
socket.receive({ type: "createTab", seq: 6, url: "https://example.com" });
|
||||
@@ -942,7 +1067,7 @@ describe("relay command authorization", () => {
|
||||
if (!socket) {
|
||||
throw new Error("expected relay socket");
|
||||
}
|
||||
socket.open();
|
||||
await harness.authenticate(socket);
|
||||
harness.shareTab(43);
|
||||
let releaseAttach = () => {};
|
||||
harness.debuggerAttach.mockImplementationOnce(
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export const RELAY_AUTH_VERSION: 2;
|
||||
export function requireRelayCrypto(cryptoApi: Crypto): Crypto;
|
||||
export function relayBytesFromBase64Url(
|
||||
value: unknown,
|
||||
expectedLength: number,
|
||||
field: string,
|
||||
): Uint8Array;
|
||||
export function randomRelayBase64Url(cryptoApi: Crypto, byteLength: number): string;
|
||||
export function extensionRelayAuthResource(relayUrl: string): string;
|
||||
export type RelayAuthProofFields = {
|
||||
keyId: string;
|
||||
instanceId: string;
|
||||
sessionId: string;
|
||||
clientNonce: string;
|
||||
serverNonce: string;
|
||||
issuedAtMs: number;
|
||||
expiresAtMs: number;
|
||||
role: string;
|
||||
transport: string;
|
||||
method: string;
|
||||
resource: string;
|
||||
flow: string;
|
||||
};
|
||||
export function canonicalRelayAuthProofBytes(
|
||||
proofKind: "server" | "client" | "accept",
|
||||
fields: RelayAuthProofFields,
|
||||
clientProof?: string,
|
||||
): Uint8Array;
|
||||
export function importRelayHmacKey(token: string, cryptoApi: Crypto): Promise<CryptoKey>;
|
||||
export function deriveRelayAuthKeyId(token: string, cryptoApi?: Crypto): Promise<string>;
|
||||
export function computeRelayAuthProof(
|
||||
token: string,
|
||||
proofKind: "server" | "client" | "accept",
|
||||
fields: RelayAuthProofFields,
|
||||
clientProof?: string,
|
||||
cryptoApi?: Crypto,
|
||||
): Promise<string>;
|
||||
@@ -0,0 +1,134 @@
|
||||
// Browser-native proof primitives shared by the extension auth client and its vectors.
|
||||
|
||||
const RELAY_AUTH_LABEL = "openclaw.browser-relay.auth";
|
||||
export const RELAY_AUTH_VERSION = 2;
|
||||
|
||||
const RELAY_KEY_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
export function requireRelayCrypto(cryptoApi) {
|
||||
if (!cryptoApi?.subtle || typeof cryptoApi.getRandomValues !== "function") {
|
||||
throw new Error("WebCrypto is unavailable");
|
||||
}
|
||||
return cryptoApi;
|
||||
}
|
||||
|
||||
function relayHexToBytes(value) {
|
||||
if (!RELAY_KEY_PATTERN.test(value)) {
|
||||
throw new Error("relay key must be 32 lowercase-hex bytes");
|
||||
}
|
||||
const bytes = new Uint8Array(32);
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
bytes[index] = Number.parseInt(value.slice(index * 2, index * 2 + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function relayBytesToBase64Url(bytes) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
export function relayBytesFromBase64Url(value, expectedLength, field) {
|
||||
if (typeof value !== "string" || !BASE64URL_PATTERN.test(value)) {
|
||||
throw new Error(`${field} must be base64url`);
|
||||
}
|
||||
const padded = value
|
||||
.replace(/-/g, "+")
|
||||
.replace(/_/g, "/")
|
||||
.padEnd(Math.ceil(value.length / 4) * 4, "=");
|
||||
let bytes;
|
||||
try {
|
||||
bytes = Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
|
||||
} catch {
|
||||
throw new Error(`${field} must be base64url`);
|
||||
}
|
||||
if (bytes.length !== expectedLength || relayBytesToBase64Url(bytes) !== value) {
|
||||
throw new Error(`${field} has an invalid length or encoding`);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function randomRelayBase64Url(cryptoApi, byteLength) {
|
||||
return relayBytesToBase64Url(cryptoApi.getRandomValues(new Uint8Array(byteLength)));
|
||||
}
|
||||
|
||||
export function extensionRelayAuthResource(relayUrl) {
|
||||
const url = new URL(relayUrl);
|
||||
const entries = [...url.searchParams];
|
||||
if (
|
||||
entries.some(([key, value]) => key !== "profile" || !/^[a-z0-9-]+$/.test(value)) ||
|
||||
entries.filter(([key]) => key === "profile").length > 1
|
||||
) {
|
||||
throw new Error("relay URL auth resource has unsupported query parameters");
|
||||
}
|
||||
url.searchParams.sort();
|
||||
return `${url.pathname}${url.search}`;
|
||||
}
|
||||
|
||||
export function canonicalRelayAuthProofBytes(proofKind, fields, clientProof) {
|
||||
if (proofKind !== "server" && proofKind !== "client" && proofKind !== "accept") {
|
||||
throw new Error("invalid relay auth proof kind");
|
||||
}
|
||||
const values = [
|
||||
RELAY_AUTH_LABEL,
|
||||
RELAY_AUTH_VERSION,
|
||||
proofKind,
|
||||
fields.keyId,
|
||||
fields.instanceId,
|
||||
fields.sessionId,
|
||||
fields.clientNonce,
|
||||
fields.serverNonce,
|
||||
fields.issuedAtMs,
|
||||
fields.expiresAtMs,
|
||||
fields.role,
|
||||
fields.transport,
|
||||
fields.method,
|
||||
fields.resource,
|
||||
fields.flow,
|
||||
];
|
||||
if (proofKind === "accept") {
|
||||
if (typeof clientProof !== "string") {
|
||||
throw new Error("accept proof requires clientProof");
|
||||
}
|
||||
values.push(clientProof);
|
||||
}
|
||||
return textEncoder.encode(JSON.stringify(values));
|
||||
}
|
||||
|
||||
export async function importRelayHmacKey(token, cryptoApi) {
|
||||
return await cryptoApi.subtle.importKey(
|
||||
"raw",
|
||||
relayHexToBytes(token),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"],
|
||||
);
|
||||
}
|
||||
|
||||
export async function deriveRelayAuthKeyId(token, cryptoApi = globalThis.crypto) {
|
||||
const runtime = requireRelayCrypto(cryptoApi);
|
||||
const digest = await runtime.subtle.digest("SHA-256", relayHexToBytes(token));
|
||||
return relayBytesToBase64Url(new Uint8Array(digest)).slice(0, 22);
|
||||
}
|
||||
|
||||
export async function computeRelayAuthProof(
|
||||
token,
|
||||
proofKind,
|
||||
fields,
|
||||
clientProof,
|
||||
cryptoApi = globalThis.crypto,
|
||||
) {
|
||||
const runtime = requireRelayCrypto(cryptoApi);
|
||||
const key = await importRelayHmacKey(token, runtime);
|
||||
const signature = await runtime.subtle.sign(
|
||||
"HMAC",
|
||||
key,
|
||||
canonicalRelayAuthProofBytes(proofKind, fields, clientProof),
|
||||
);
|
||||
return relayBytesToBase64Url(new Uint8Array(signature));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const EXTENSION_RELAY_V2_PROTOCOL: string;
|
||||
export function parseRelayAuthJson(raw: string): Record<string, unknown> | null;
|
||||
export function createExtensionRelayAuthClient(params: {
|
||||
token: string;
|
||||
relayUrl: string;
|
||||
cryptoApi?: Crypto;
|
||||
now?: () => number;
|
||||
clientNonce?: string;
|
||||
}): Promise<{
|
||||
readonly keyId: string;
|
||||
readonly clientNonce: string;
|
||||
readonly authenticated: boolean;
|
||||
start(): { type: "auth.hello"; v: 2; keyId: string; clientNonce: string };
|
||||
acceptChallenge(message: unknown): Promise<{
|
||||
type: "auth.response";
|
||||
v: 2;
|
||||
sessionId: string;
|
||||
clientProof: string;
|
||||
}>;
|
||||
acceptOk(message: unknown): Promise<void>;
|
||||
}>;
|
||||
@@ -0,0 +1,282 @@
|
||||
// Browser Relay Authentication v2 for the unpacked MV3 extension.
|
||||
// This module is intentionally browser-native ESM: no Node globals or bundling.
|
||||
|
||||
import {
|
||||
RELAY_AUTH_VERSION,
|
||||
canonicalRelayAuthProofBytes,
|
||||
computeRelayAuthProof,
|
||||
deriveRelayAuthKeyId,
|
||||
extensionRelayAuthResource,
|
||||
importRelayHmacKey,
|
||||
randomRelayBase64Url,
|
||||
relayBytesFromBase64Url,
|
||||
requireRelayCrypto,
|
||||
} from "./relay-auth-v2-crypto.js";
|
||||
|
||||
export const EXTENSION_RELAY_V2_PROTOCOL = "openclaw-extension-relay.v2";
|
||||
|
||||
const CHALLENGE_LIFETIME_MS = 10_000;
|
||||
const MAX_CLOCK_SKEW_MS = 30_000;
|
||||
const MAX_AUTH_JSON_BYTES = 16 * 1024;
|
||||
const authTextEncoder = new TextEncoder();
|
||||
|
||||
const CHALLENGE_KEYS = [
|
||||
"type",
|
||||
"v",
|
||||
"keyId",
|
||||
"instanceId",
|
||||
"sessionId",
|
||||
"clientNonce",
|
||||
"serverNonce",
|
||||
"issuedAtMs",
|
||||
"expiresAtMs",
|
||||
"role",
|
||||
"transport",
|
||||
"method",
|
||||
"resource",
|
||||
"flow",
|
||||
"serverProof",
|
||||
];
|
||||
const OK_KEYS = ["type", "v", "sessionId", "acceptProof"];
|
||||
|
||||
function hasExactKeys(value, expected) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const actual = Object.keys(value).toSorted((a, b) => a.localeCompare(b));
|
||||
const wanted = [...expected].toSorted((a, b) => a.localeCompare(b));
|
||||
return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
|
||||
}
|
||||
|
||||
function hasDuplicateJsonObjectKeys(text) {
|
||||
const stack = [];
|
||||
let expectingKey = false;
|
||||
let index = 0;
|
||||
const skipWhitespace = () => {
|
||||
while (/\s/.test(text[index] ?? "")) {
|
||||
index += 1;
|
||||
}
|
||||
};
|
||||
while (index < text.length) {
|
||||
const char = text[index];
|
||||
if (char === '"') {
|
||||
const start = index;
|
||||
index += 1;
|
||||
let escaped = false;
|
||||
while (index < text.length) {
|
||||
const next = text[index++];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (next === "\\") {
|
||||
escaped = true;
|
||||
} else if (next === '"') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (expectingKey && stack.at(-1)) {
|
||||
let key;
|
||||
try {
|
||||
key = JSON.parse(text.slice(start, index));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
skipWhitespace();
|
||||
if (text[index] === ":" && typeof key === "string") {
|
||||
const keys = stack.at(-1);
|
||||
if (keys.has(key)) {
|
||||
return true;
|
||||
}
|
||||
keys.add(key);
|
||||
expectingKey = false;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === "{") {
|
||||
stack.push(new Set());
|
||||
expectingKey = true;
|
||||
} else if (char === "[") {
|
||||
stack.push(null);
|
||||
expectingKey = false;
|
||||
} else if (char === "}") {
|
||||
stack.pop();
|
||||
expectingKey = false;
|
||||
} else if (char === "]") {
|
||||
stack.pop();
|
||||
expectingKey = false;
|
||||
} else if (char === ",") {
|
||||
expectingKey = stack.at(-1) instanceof Set;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Parse an authentication frame without allowing JSON duplicate-key shadowing. */
|
||||
export function parseRelayAuthJson(raw) {
|
||||
if (
|
||||
typeof raw !== "string" ||
|
||||
raw.length > MAX_AUTH_JSON_BYTES ||
|
||||
authTextEncoder.encode(raw).byteLength > MAX_AUTH_JSON_BYTES ||
|
||||
hasDuplicateJsonObjectKeys(raw)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function assertSafeTimestamp(value, field) {
|
||||
if (!Number.isSafeInteger(value) || value < 0) {
|
||||
throw new Error(`${field} must be a non-negative safe integer`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One connection-bound client state machine. Any invalid or out-of-order
|
||||
* security frame permanently fails this instance; callers must close the socket.
|
||||
*/
|
||||
export async function createExtensionRelayAuthClient({
|
||||
token,
|
||||
relayUrl,
|
||||
cryptoApi = globalThis.crypto,
|
||||
now = () => Date.now(),
|
||||
clientNonce,
|
||||
}) {
|
||||
const runtime = requireRelayCrypto(cryptoApi);
|
||||
const keyId = await deriveRelayAuthKeyId(token, runtime);
|
||||
const key = await importRelayHmacKey(token, runtime);
|
||||
const nonce = clientNonce ?? randomRelayBase64Url(runtime, 32);
|
||||
relayBytesFromBase64Url(nonce, 32, "clientNonce");
|
||||
const resource = extensionRelayAuthResource(relayUrl);
|
||||
let state = "new";
|
||||
let challenge = null;
|
||||
let clientProof = null;
|
||||
|
||||
const fail = (error) => {
|
||||
state = "failed";
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
};
|
||||
|
||||
return {
|
||||
keyId,
|
||||
clientNonce: nonce,
|
||||
get authenticated() {
|
||||
return state === "authenticated";
|
||||
},
|
||||
start() {
|
||||
if (state !== "new") {
|
||||
return fail(new Error("relay auth hello is out of sequence"));
|
||||
}
|
||||
state = "waiting-challenge";
|
||||
return { type: "auth.hello", v: RELAY_AUTH_VERSION, keyId, clientNonce: nonce };
|
||||
},
|
||||
async acceptChallenge(message) {
|
||||
if (state !== "waiting-challenge") {
|
||||
return fail(new Error("relay auth challenge is out of sequence"));
|
||||
}
|
||||
state = "verifying-challenge";
|
||||
try {
|
||||
if (!hasExactKeys(message, CHALLENGE_KEYS) || message.type !== "auth.challenge") {
|
||||
throw new Error("invalid relay auth challenge shape");
|
||||
}
|
||||
if (
|
||||
message.v !== RELAY_AUTH_VERSION ||
|
||||
message.keyId !== keyId ||
|
||||
message.clientNonce !== nonce ||
|
||||
message.role !== "extension" ||
|
||||
message.transport !== "websocket" ||
|
||||
message.method !== "GET" ||
|
||||
message.resource !== resource ||
|
||||
message.flow !== "extension"
|
||||
) {
|
||||
throw new Error("relay auth challenge binding mismatch");
|
||||
}
|
||||
relayBytesFromBase64Url(message.instanceId, 16, "instanceId");
|
||||
relayBytesFromBase64Url(message.sessionId, 16, "sessionId");
|
||||
relayBytesFromBase64Url(message.serverNonce, 32, "serverNonce");
|
||||
const serverProofBytes = relayBytesFromBase64Url(message.serverProof, 32, "serverProof");
|
||||
assertSafeTimestamp(message.issuedAtMs, "issuedAtMs");
|
||||
assertSafeTimestamp(message.expiresAtMs, "expiresAtMs");
|
||||
const currentTime = now();
|
||||
assertSafeTimestamp(currentTime, "current time");
|
||||
if (
|
||||
message.expiresAtMs <= message.issuedAtMs ||
|
||||
message.expiresAtMs - message.issuedAtMs > CHALLENGE_LIFETIME_MS ||
|
||||
Math.abs(currentTime - message.issuedAtMs) > MAX_CLOCK_SKEW_MS ||
|
||||
currentTime > message.expiresAtMs
|
||||
) {
|
||||
throw new Error("relay auth challenge is expired or outside the allowed clock skew");
|
||||
}
|
||||
const fields = {
|
||||
keyId: message.keyId,
|
||||
instanceId: message.instanceId,
|
||||
sessionId: message.sessionId,
|
||||
clientNonce: message.clientNonce,
|
||||
serverNonce: message.serverNonce,
|
||||
issuedAtMs: message.issuedAtMs,
|
||||
expiresAtMs: message.expiresAtMs,
|
||||
role: message.role,
|
||||
transport: message.transport,
|
||||
method: message.method,
|
||||
resource: message.resource,
|
||||
flow: message.flow,
|
||||
};
|
||||
const valid = await runtime.subtle.verify(
|
||||
"HMAC",
|
||||
key,
|
||||
serverProofBytes,
|
||||
canonicalRelayAuthProofBytes("server", fields),
|
||||
);
|
||||
if (!valid) {
|
||||
throw new Error("relay auth server proof is invalid");
|
||||
}
|
||||
clientProof = await computeRelayAuthProof(token, "client", fields, undefined, runtime);
|
||||
challenge = fields;
|
||||
state = "waiting-ok";
|
||||
return {
|
||||
type: "auth.response",
|
||||
v: RELAY_AUTH_VERSION,
|
||||
sessionId: fields.sessionId,
|
||||
clientProof,
|
||||
};
|
||||
} catch (error) {
|
||||
return fail(error);
|
||||
}
|
||||
},
|
||||
async acceptOk(message) {
|
||||
if (state !== "waiting-ok" || !challenge || !clientProof) {
|
||||
return fail(new Error("relay auth ok is out of sequence"));
|
||||
}
|
||||
state = "verifying-ok";
|
||||
try {
|
||||
if (
|
||||
!hasExactKeys(message, OK_KEYS) ||
|
||||
message.type !== "auth.ok" ||
|
||||
message.v !== RELAY_AUTH_VERSION ||
|
||||
message.sessionId !== challenge.sessionId
|
||||
) {
|
||||
throw new Error("invalid relay auth ok binding");
|
||||
}
|
||||
const acceptProofBytes = relayBytesFromBase64Url(message.acceptProof, 32, "acceptProof");
|
||||
const valid = await runtime.subtle.verify(
|
||||
"HMAC",
|
||||
key,
|
||||
acceptProofBytes,
|
||||
canonicalRelayAuthProofBytes("accept", challenge, clientProof),
|
||||
);
|
||||
if (!valid) {
|
||||
throw new Error("relay auth accept proof is invalid");
|
||||
}
|
||||
state = "authenticated";
|
||||
return undefined;
|
||||
} catch (error) {
|
||||
return fail(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
computeRelayAuthProof,
|
||||
deriveRelayAuthKeyId,
|
||||
extensionRelayAuthResource,
|
||||
type RelayAuthProofFields,
|
||||
} from "./relay-auth-v2-crypto.js";
|
||||
import { createExtensionRelayAuthClient, parseRelayAuthJson } from "./relay-auth-v2.js";
|
||||
|
||||
const VECTOR = {
|
||||
token: Array.from({ length: 32 }, (_, index) => index.toString(16).padStart(2, "0")).join(""),
|
||||
fields: {
|
||||
keyId: "Yw3NKWbEM2aRElRIu7JbT_",
|
||||
instanceId: "EREREREREREREREREREREQ",
|
||||
sessionId: "IiIiIiIiIiIiIiIiIiIiIg",
|
||||
clientNonce: "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM",
|
||||
serverNonce: "REREREREREREREREREREREREREREREREREREREREREQ",
|
||||
issuedAtMs: 1_786_123_456_000,
|
||||
expiresAtMs: 1_786_123_466_000,
|
||||
role: "extension",
|
||||
transport: "websocket",
|
||||
method: "GET",
|
||||
resource: "/extension?profile=chrome",
|
||||
flow: "extension",
|
||||
} satisfies RelayAuthProofFields,
|
||||
serverProof: "ynhaAA_l2HkOGXQ8DvIWfzWwwGjDcV93aumHNe_NM-Q",
|
||||
clientProof: "Rl8TStMYlPLxJPDYwSe__mtEjgMf1C4TM-ZN6sUipZ4",
|
||||
acceptProof: "1R5MpHs6qnAdc0_X6vKBwj91tlRoWfNuGXaNfSD7VnI",
|
||||
};
|
||||
|
||||
function challenge(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "auth.challenge",
|
||||
v: 2,
|
||||
...VECTOR.fields,
|
||||
serverProof: VECTOR.serverProof,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function client() {
|
||||
return await createExtensionRelayAuthClient({
|
||||
token: VECTOR.token,
|
||||
relayUrl: "ws://127.0.0.1:18797/extension?profile=chrome",
|
||||
clientNonce: VECTOR.fields.clientNonce,
|
||||
now: () => VECTOR.fields.issuedAtMs + 1,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Browser Relay Authentication v2 WebCrypto vectors", () => {
|
||||
it("matches the fixed Node HMAC vector", async () => {
|
||||
await expect(deriveRelayAuthKeyId(VECTOR.token)).resolves.toBe(VECTOR.fields.keyId);
|
||||
await expect(computeRelayAuthProof(VECTOR.token, "server", VECTOR.fields)).resolves.toBe(
|
||||
VECTOR.serverProof,
|
||||
);
|
||||
await expect(computeRelayAuthProof(VECTOR.token, "client", VECTOR.fields)).resolves.toBe(
|
||||
VECTOR.clientProof,
|
||||
);
|
||||
await expect(
|
||||
computeRelayAuthProof(VECTOR.token, "accept", VECTOR.fields, VECTOR.clientProof),
|
||||
).resolves.toBe(VECTOR.acceptProof);
|
||||
});
|
||||
|
||||
it("verifies server proof before producing client proof and verifies accept proof", async () => {
|
||||
const auth = await client();
|
||||
expect(auth.start()).toEqual({
|
||||
type: "auth.hello",
|
||||
v: 2,
|
||||
keyId: VECTOR.fields.keyId,
|
||||
clientNonce: VECTOR.fields.clientNonce,
|
||||
});
|
||||
await expect(auth.acceptChallenge(challenge())).resolves.toEqual({
|
||||
type: "auth.response",
|
||||
v: 2,
|
||||
sessionId: VECTOR.fields.sessionId,
|
||||
clientProof: VECTOR.clientProof,
|
||||
});
|
||||
expect(auth.authenticated).toBe(false);
|
||||
await expect(
|
||||
auth.acceptOk({
|
||||
type: "auth.ok",
|
||||
v: 2,
|
||||
sessionId: VECTOR.fields.sessionId,
|
||||
acceptProof: VECTOR.acceptProof,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(auth.authenticated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Browser Relay Authentication v2 client validation", () => {
|
||||
it("rejects a bad server proof without producing a client proof", async () => {
|
||||
const auth = await client();
|
||||
auth.start();
|
||||
await expect(auth.acceptChallenge(challenge({ serverProof: "A".repeat(43) }))).rejects.toThrow(
|
||||
"server proof is invalid",
|
||||
);
|
||||
expect(auth.authenticated).toBe(false);
|
||||
await expect(auth.acceptChallenge(challenge())).rejects.toThrow("out of sequence");
|
||||
});
|
||||
|
||||
it("rejects every substituted challenge binding", async () => {
|
||||
const substitutions: Array<[string, unknown]> = [
|
||||
["v", 1],
|
||||
["keyId", "A".repeat(22)],
|
||||
["instanceId", "A".repeat(22)],
|
||||
["sessionId", "A".repeat(22)],
|
||||
["clientNonce", "A".repeat(43)],
|
||||
["serverNonce", "A".repeat(43)],
|
||||
["issuedAtMs", VECTOR.fields.issuedAtMs - 1],
|
||||
["expiresAtMs", VECTOR.fields.expiresAtMs - 1],
|
||||
["role", "cdp"],
|
||||
["transport", "connection"],
|
||||
["method", "POST"],
|
||||
["resource", "/other"],
|
||||
["flow", "json-list"],
|
||||
];
|
||||
for (const [field, value] of substitutions) {
|
||||
const auth = await client();
|
||||
auth.start();
|
||||
await expect(auth.acceptChallenge(challenge({ [field]: value }))).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects expired, overlong, and extra-field challenges", async () => {
|
||||
for (const mutation of [
|
||||
{
|
||||
issuedAtMs: VECTOR.fields.issuedAtMs - 20_000,
|
||||
expiresAtMs: VECTOR.fields.issuedAtMs - 10_000,
|
||||
},
|
||||
{ expiresAtMs: VECTOR.fields.issuedAtMs + 10_001 },
|
||||
{ unexpected: true },
|
||||
]) {
|
||||
const auth = await client();
|
||||
auth.start();
|
||||
await expect(auth.acceptChallenge(challenge(mutation))).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects bad accept proof and exact-sequence violations", async () => {
|
||||
const early = await client();
|
||||
await expect(
|
||||
early.acceptOk({
|
||||
type: "auth.ok",
|
||||
v: 2,
|
||||
sessionId: VECTOR.fields.sessionId,
|
||||
acceptProof: VECTOR.acceptProof,
|
||||
}),
|
||||
).rejects.toThrow("out of sequence");
|
||||
|
||||
const auth = await client();
|
||||
auth.start();
|
||||
await auth.acceptChallenge(challenge());
|
||||
await expect(
|
||||
auth.acceptOk({
|
||||
type: "auth.ok",
|
||||
v: 2,
|
||||
sessionId: VECTOR.fields.sessionId,
|
||||
acceptProof: "A".repeat(43),
|
||||
}),
|
||||
).rejects.toThrow("accept proof is invalid");
|
||||
expect(auth.authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it("canonicalizes only the allowed profile query", () => {
|
||||
expect(
|
||||
extensionRelayAuthResource("wss://gateway.example/base/browser/extension?profile=work"),
|
||||
).toBe("/base/browser/extension?profile=work");
|
||||
expect(() =>
|
||||
extensionRelayAuthResource("ws://127.0.0.1/extension?profile=a&profile=b"),
|
||||
).toThrow("unsupported query");
|
||||
expect(() => extensionRelayAuthResource("ws://127.0.0.1/extension?token=nope")).toThrow(
|
||||
"unsupported query",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects duplicate security fields before JSON parsing", () => {
|
||||
expect(parseRelayAuthJson('{"type":"auth.ok","v":2,"v":1}')).toBeNull();
|
||||
expect(parseRelayAuthJson('{"type":"auth.ok","v":2}')).toEqual({
|
||||
type: "auth.ok",
|
||||
v: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects oversized authentication frames before JSON parsing", () => {
|
||||
expect(parseRelayAuthJson(`{"padding":"${"a".repeat(16 * 1024)}"}`)).toBeNull();
|
||||
expect(parseRelayAuthJson(`{"padding":"${"é".repeat(9 * 1024)}"}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export function createRelayCommandHandler(params: {
|
||||
send: (message: Record<string, unknown>) => void;
|
||||
attachDebugger: (tabId: number) => Promise<unknown>;
|
||||
detachDebugger: (tabId: number) => Promise<void>;
|
||||
addTabToOpenClawGroup: (tabId: number) => Promise<void>;
|
||||
focusWindowForTab: (tab: chrome.tabs.Tab) => Promise<void>;
|
||||
scheduleTabsSync: () => void;
|
||||
}): (message: Record<string, unknown>) => Promise<void>;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { requireSharedTab } from "./relay-tab-groups.js";
|
||||
|
||||
/** Build the authenticated application-command dispatcher for the relay socket. */
|
||||
export function createRelayCommandHandler({
|
||||
send,
|
||||
attachDebugger,
|
||||
detachDebugger,
|
||||
addTabToOpenClawGroup,
|
||||
focusWindowForTab,
|
||||
scheduleTabsSync,
|
||||
}) {
|
||||
return async (message) => {
|
||||
const { seq } = message;
|
||||
try {
|
||||
switch (message.type) {
|
||||
case "ping":
|
||||
send({ type: "pong" });
|
||||
return;
|
||||
case "attach":
|
||||
send({ type: "result", seq, result: await attachDebugger(message.tabId) });
|
||||
return;
|
||||
case "detach":
|
||||
await detachDebugger(message.tabId);
|
||||
send({ type: "result", seq, result: {} });
|
||||
return;
|
||||
case "cdp": {
|
||||
await requireSharedTab(message.tabId);
|
||||
const target = message.sessionId
|
||||
? { tabId: message.tabId, sessionId: message.sessionId }
|
||||
: { tabId: message.tabId };
|
||||
const result = await chrome.debugger.sendCommand(
|
||||
target,
|
||||
message.method,
|
||||
message.params ?? {},
|
||||
);
|
||||
send({ type: "result", seq, result: result ?? {} });
|
||||
return;
|
||||
}
|
||||
case "createTab": {
|
||||
const tab = await chrome.tabs.create({
|
||||
url: message.url,
|
||||
active: message.background !== true,
|
||||
});
|
||||
await addTabToOpenClawGroup(tab.id);
|
||||
if (message.focus === true) {
|
||||
await focusWindowForTab(tab);
|
||||
}
|
||||
scheduleTabsSync();
|
||||
send({ type: "result", seq, result: { tabId: tab.id } });
|
||||
return;
|
||||
}
|
||||
case "closeTab":
|
||||
await requireSharedTab(message.tabId);
|
||||
await detachDebugger(message.tabId);
|
||||
await requireSharedTab(message.tabId);
|
||||
await chrome.tabs.remove(message.tabId);
|
||||
send({ type: "result", seq, result: {} });
|
||||
return;
|
||||
case "activateTab": {
|
||||
const tab = await requireSharedTab(message.tabId);
|
||||
await chrome.tabs.update(message.tabId, { active: true });
|
||||
await requireSharedTab(message.tabId);
|
||||
await focusWindowForTab(tab);
|
||||
send({ type: "result", seq, result: {} });
|
||||
return;
|
||||
}
|
||||
default:
|
||||
if (typeof seq === "number") {
|
||||
send({ type: "error", seq, message: `unknown relay command: ${message.type}` });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof seq === "number") {
|
||||
send({
|
||||
type: "error",
|
||||
seq,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function openAuthenticatedRelaySocket(params: {
|
||||
relayUrl: string;
|
||||
token: string;
|
||||
isCurrent: (socket: WebSocket) => boolean;
|
||||
onAuthenticated: (socket: WebSocket) => void | Promise<void>;
|
||||
onApplicationMessage: (socket: WebSocket, message: Record<string, unknown>) => void;
|
||||
onAuthenticationFailure: (socket: WebSocket, error: unknown) => void;
|
||||
onClose: (socket: WebSocket, authenticated: boolean) => void;
|
||||
}): WebSocket;
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
createExtensionRelayAuthClient,
|
||||
EXTENSION_RELAY_V2_PROTOCOL,
|
||||
parseRelayAuthJson,
|
||||
} from "./relay-auth-v2.js";
|
||||
import { buildRelayWsProtocols } from "./relay-core.js";
|
||||
|
||||
/** Open one v2-only relay socket and expose application frames only after auth.ok. */
|
||||
export function openAuthenticatedRelaySocket({
|
||||
relayUrl,
|
||||
token,
|
||||
isCurrent,
|
||||
onAuthenticated,
|
||||
onApplicationMessage,
|
||||
onAuthenticationFailure,
|
||||
onClose,
|
||||
}) {
|
||||
const authClientPromise = createExtensionRelayAuthClient({ token, relayUrl });
|
||||
const ws = new WebSocket(relayUrl, buildRelayWsProtocols());
|
||||
let authenticated = false;
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (!isCurrent(ws)) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
if (ws.protocol !== EXTENSION_RELAY_V2_PROTOCOL) {
|
||||
throw new Error("relay did not negotiate Browser Relay Authentication v2");
|
||||
}
|
||||
const authClient = await authClientPromise;
|
||||
ws.send(JSON.stringify(authClient.start()));
|
||||
} catch (error) {
|
||||
onAuthenticationFailure(ws, error);
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
let messageChain = Promise.resolve();
|
||||
ws.addEventListener("message", (event) => {
|
||||
messageChain = messageChain.then(async () => {
|
||||
try {
|
||||
if (!isCurrent(ws)) {
|
||||
return;
|
||||
}
|
||||
const raw = String(event.data);
|
||||
if (authenticated) {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (typeof message?.type === "string" && message.type.startsWith("auth.")) {
|
||||
throw new Error("relay sent an authentication frame after completion");
|
||||
}
|
||||
onApplicationMessage(ws, message);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = parseRelayAuthJson(raw);
|
||||
if (!message) {
|
||||
throw new Error("relay sent malformed authentication JSON");
|
||||
}
|
||||
const authClient = await authClientPromise;
|
||||
if (message.type === "auth.challenge") {
|
||||
const response = await authClient.acceptChallenge(message);
|
||||
if (isCurrent(ws) && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(response));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message.type !== "auth.ok") {
|
||||
throw new Error("relay authentication frame is out of sequence");
|
||||
}
|
||||
await authClient.acceptOk(message);
|
||||
if (!isCurrent(ws) || ws.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
authenticated = true;
|
||||
await onAuthenticated(ws);
|
||||
} catch (error) {
|
||||
onAuthenticationFailure(ws, error);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ws.addEventListener("close", () => onClose(ws, authenticated));
|
||||
return ws;
|
||||
}
|
||||
@@ -17,7 +17,9 @@ export function createPairingConfigStore(storage: {
|
||||
relayUrl: string;
|
||||
token: string;
|
||||
gatewayUrl: string;
|
||||
authVersion?: 2;
|
||||
groupColor: string;
|
||||
pairingStatusHint: string;
|
||||
}>;
|
||||
save(
|
||||
pairing: { relayUrl: string; token: string; gatewayUrl?: string },
|
||||
@@ -26,7 +28,7 @@ export function createPairingConfigStore(storage: {
|
||||
clear(): Promise<void>;
|
||||
};
|
||||
|
||||
export function buildRelayWsProtocols(token: string): string[];
|
||||
export function buildRelayWsProtocols(): string[];
|
||||
|
||||
export function reconnectDelayMs(attempt: number): number;
|
||||
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
|
||||
/** Tab group shown to the user; membership == what the agent may touch. */
|
||||
export const OPENCLAW_TAB_GROUP_TITLE = "OpenClaw";
|
||||
const EXTENSION_RELAY_PROTOCOL = "openclaw-extension-relay";
|
||||
const EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX = "openclaw-extension-token.";
|
||||
const EXTENSION_RELAY_PROTOCOL = "openclaw-extension-relay.v2";
|
||||
const RELAY_SECRET_PATTERN = /^[0-9a-f]{64}$/;
|
||||
const PAIRING_STORAGE_KEYS = ["relayUrl", "gatewayUrl", "token"];
|
||||
const PAIRING_STORAGE_KEYS = ["relayUrl", "gatewayUrl", "token", "authVersion"];
|
||||
const PAIRING_STATUS_KEY = "pairingStatus";
|
||||
const UNSUPPORTED_PROXY_PREFIX_STATUS = "proxy-prefix-unsupported";
|
||||
const UNSUPPORTED_PROXY_PREFIX_HINT =
|
||||
"Stored proxy-prefixed browser relay pairing is no longer supported. Re-run `openclaw browser extension pair` with a Gateway URL that has no path prefix.";
|
||||
|
||||
const CHROME_GROUP_COLORS = {
|
||||
grey: [128, 128, 128],
|
||||
@@ -66,15 +69,43 @@ function parseGatewayHint(raw) {
|
||||
}
|
||||
|
||||
function directGatewayUrlFromRelay(relay) {
|
||||
const suffix = "/browser/extension";
|
||||
if (!relay.pathname.endsWith(suffix)) {
|
||||
if (relay.pathname !== "/browser/extension") {
|
||||
return null;
|
||||
}
|
||||
const gateway = new URL(relay.toString());
|
||||
gateway.pathname = gateway.pathname.slice(0, -suffix.length) || "/";
|
||||
gateway.pathname = "/";
|
||||
gateway.search = "";
|
||||
return gateway.toString();
|
||||
}
|
||||
|
||||
function isUnsupportedProxyPrefix(raw) {
|
||||
if (typeof raw !== "string") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const relay = new URL(raw);
|
||||
return (
|
||||
isAllowedWebSocketUrl(relay) &&
|
||||
relay.pathname !== "/browser/extension" &&
|
||||
relay.pathname.endsWith("/browser/extension")
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRelayQuery(relay) {
|
||||
const query = [...relay.searchParams];
|
||||
if (
|
||||
query.some(([key, value]) => key !== "profile" || !/^[a-z0-9-]+$/.test(value)) ||
|
||||
query.filter(([key]) => key === "profile").length > 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
relay.searchParams.sort();
|
||||
return true;
|
||||
}
|
||||
|
||||
function validatePairingFields(relayUrl, token, gatewayUrl) {
|
||||
if (typeof relayUrl !== "string" || typeof token !== "string") {
|
||||
return null;
|
||||
@@ -88,10 +119,13 @@ function validatePairingFields(relayUrl, token, gatewayUrl) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const supportedPath =
|
||||
(isLoopbackHost(relay.hostname) && relay.pathname === "/extension") ||
|
||||
relay.pathname === "/browser/extension";
|
||||
if (
|
||||
!isAllowedWebSocketUrl(relay) ||
|
||||
!relay.pathname.endsWith("/extension") ||
|
||||
relay.search ||
|
||||
!supportedPath ||
|
||||
!normalizeRelayQuery(relay) ||
|
||||
relay.hash
|
||||
) {
|
||||
return null;
|
||||
@@ -137,14 +171,21 @@ export function parsePairingString(raw) {
|
||||
return null;
|
||||
}
|
||||
const query = [...parsed.searchParams];
|
||||
if (query.length > 1 || (query.length === 1 && query[0]?.[0] !== "gateway")) {
|
||||
const gatewayEntries = query.filter(([key]) => key === "gateway");
|
||||
const profileEntries = query.filter(([key]) => key === "profile");
|
||||
if (
|
||||
gatewayEntries.length > 1 ||
|
||||
profileEntries.length > 1 ||
|
||||
query.some(([key]) => key !== "gateway" && key !== "profile")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const gatewayUrl = query.length === 1 ? query[0]?.[1] : undefined;
|
||||
if (query.length === 1 && !gatewayUrl?.trim()) {
|
||||
const gatewayUrl = gatewayEntries[0]?.[1];
|
||||
if ((gatewayEntries.length === 1 && !gatewayUrl?.trim()) || profileEntries[0]?.[1] === "") {
|
||||
return null;
|
||||
}
|
||||
parsed.search = "";
|
||||
parsed.searchParams.delete("gateway");
|
||||
parsed.searchParams.sort();
|
||||
return validatePairingFields(parsed.toString(), token, gatewayUrl);
|
||||
}
|
||||
|
||||
@@ -156,6 +197,7 @@ function parseStoredPairing(stored) {
|
||||
const parsed = validatePairingFields(stored.relayUrl, stored.token, stored.gatewayUrl);
|
||||
if (
|
||||
!parsed ||
|
||||
(stored.authVersion !== undefined && stored.authVersion !== 2) ||
|
||||
parsed.relayUrl !== stored.relayUrl ||
|
||||
parsed.token !== stored.token ||
|
||||
(parsed.gatewayUrl ?? "") !== (stored.gatewayUrl ?? "")
|
||||
@@ -181,41 +223,69 @@ export function createPairingConfigStore(storage) {
|
||||
},
|
||||
read: () =>
|
||||
run(async () => {
|
||||
const stored = await storage.get([...PAIRING_STORAGE_KEYS, "groupColor"]);
|
||||
const stored = await storage.get([
|
||||
...PAIRING_STORAGE_KEYS,
|
||||
PAIRING_STATUS_KEY,
|
||||
"groupColor",
|
||||
]);
|
||||
const hasPairing = PAIRING_STORAGE_KEYS.some((key) => Object.hasOwn(stored, key));
|
||||
const pairing = hasPairing ? parseStoredPairing(stored) : null;
|
||||
let pairingStatus =
|
||||
stored[PAIRING_STATUS_KEY] === UNSUPPORTED_PROXY_PREFIX_STATUS
|
||||
? UNSUPPORTED_PROXY_PREFIX_STATUS
|
||||
: "";
|
||||
if (hasPairing && !pairing) {
|
||||
if (!invalidObserved) {
|
||||
invalidationRevision += 1;
|
||||
}
|
||||
invalidObserved = true;
|
||||
pairingStatus = isUnsupportedProxyPrefix(stored.relayUrl)
|
||||
? UNSUPPORTED_PROXY_PREFIX_STATUS
|
||||
: "";
|
||||
await storage.remove(PAIRING_STORAGE_KEYS).catch(() => undefined);
|
||||
if (pairingStatus) {
|
||||
await storage.set({ [PAIRING_STATUS_KEY]: pairingStatus }).catch(() => undefined);
|
||||
} else if (Object.hasOwn(stored, PAIRING_STATUS_KEY)) {
|
||||
await storage.remove([PAIRING_STATUS_KEY]).catch(() => undefined);
|
||||
}
|
||||
} else {
|
||||
invalidObserved = false;
|
||||
if (pairing && stored.authVersion === undefined) {
|
||||
await storage.set({ authVersion: 2 });
|
||||
}
|
||||
if (pairing && pairingStatus) {
|
||||
pairingStatus = "";
|
||||
await storage.remove([PAIRING_STATUS_KEY]).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
return {
|
||||
relayUrl: pairing?.relayUrl ?? "",
|
||||
token: pairing?.token ?? "",
|
||||
gatewayUrl: pairing?.gatewayUrl ?? "",
|
||||
authVersion: pairing ? 2 : undefined,
|
||||
groupColor: typeof stored.groupColor === "string" ? stored.groupColor : "orange",
|
||||
pairingStatusHint:
|
||||
pairingStatus === UNSUPPORTED_PROXY_PREFIX_STATUS ? UNSUPPORTED_PROXY_PREFIX_HINT : "",
|
||||
};
|
||||
}),
|
||||
save: (pairing, groupColor) =>
|
||||
run(() =>
|
||||
storage.set({
|
||||
run(async () => {
|
||||
await storage.set({
|
||||
relayUrl: pairing.relayUrl,
|
||||
token: pairing.token,
|
||||
gatewayUrl: pairing.gatewayUrl ?? "",
|
||||
authVersion: 2,
|
||||
groupColor,
|
||||
}),
|
||||
),
|
||||
clear: () => run(() => storage.remove(PAIRING_STORAGE_KEYS)),
|
||||
});
|
||||
await storage.remove([PAIRING_STATUS_KEY]);
|
||||
}),
|
||||
clear: () => run(() => storage.remove([...PAIRING_STORAGE_KEYS, PAIRING_STATUS_KEY])),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build WebSocket subprotocols without putting the relay secret in the request URL. */
|
||||
export function buildRelayWsProtocols(token) {
|
||||
return [EXTENSION_RELAY_PROTOCOL, `${EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX}${token}`];
|
||||
/** Build the v2 WebSocket subprotocol list; credentials stay in WebCrypto only. */
|
||||
export function buildRelayWsProtocols() {
|
||||
return [EXTENSION_RELAY_PROTOCOL];
|
||||
}
|
||||
|
||||
/** Exponential reconnect backoff: 1s, 2s, 4s ... capped at 30s. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Pure-logic tests for the OpenClaw Chrome extension. Runs under the
|
||||
// extension-browser vitest glob (extensions/browser/**/*.test.ts).
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildRelayWsProtocols,
|
||||
createPairingConfigStore,
|
||||
@@ -29,10 +29,7 @@ describe("parsePairingString", () => {
|
||||
throw new Error("expected pairing string to parse");
|
||||
}
|
||||
expect(parsed.relayUrl).toBe(`ws://127.0.0.1:${port}/extension`);
|
||||
expect(buildRelayWsProtocols(parsed.token)).toEqual([
|
||||
"openclaw-extension-relay",
|
||||
`openclaw-extension-token.${token}`,
|
||||
]);
|
||||
expect(buildRelayWsProtocols()).toEqual(["openclaw-extension-relay.v2"]);
|
||||
});
|
||||
|
||||
it("extracts the additive direct Gateway hint without passing it to the relay", () => {
|
||||
@@ -45,12 +42,23 @@ describe("parsePairingString", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retains and canonicalizes the profile auth binding while stripping the Gateway hint", () => {
|
||||
const pairing = `ws://127.0.0.1:18797/extension?profile=work&gateway=${encodeURIComponent("wss://gateway.example.com")}#${RELAY_SECRET}`;
|
||||
expect(parsePairingString(pairing)).toEqual({
|
||||
relayUrl: "ws://127.0.0.1:18797/extension?profile=work",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "wss://gateway.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"ws://localhost.:18797/extension",
|
||||
"ws://127.25.0.1:18797/extension",
|
||||
"ws://[::1]:18797/extension",
|
||||
"ws://[::ffff:127.0.0.1]:18797/extension",
|
||||
"ws://127.0.0.1:18789/browser/extension",
|
||||
"wss://gateway.example.com/browser/extension",
|
||||
"wss://gateway.example.com/browser/extension?profile=work",
|
||||
])("accepts the supported relay transport %s", (relayUrl) => {
|
||||
expect(parsePairingString(`${relayUrl}#${RELAY_SECRET}`)?.token).toBe(RELAY_SECRET);
|
||||
});
|
||||
@@ -59,12 +67,26 @@ describe("parsePairingString", () => {
|
||||
["an empty string", ""],
|
||||
["an HTTP URL", `http://127.0.0.1/extension#${RELAY_SECRET}`],
|
||||
["a non-loopback plaintext URL", `ws://gateway.example.com/extension#${RELAY_SECRET}`],
|
||||
["a remote relay path", `wss://gateway.example.com/extension#${RELAY_SECRET}`],
|
||||
[
|
||||
"a proxy-prefixed remote relay path",
|
||||
`wss://gateway.example.com/proxy/browser/extension#${RELAY_SECRET}`,
|
||||
],
|
||||
[
|
||||
"a proxy-prefixed loopback direct-Gateway path",
|
||||
`ws://127.0.0.1:18789/proxy/browser/extension#${RELAY_SECRET}`,
|
||||
],
|
||||
[
|
||||
"a suffixed remote relay path",
|
||||
`wss://gateway.example.com/browser/extension/extra#${RELAY_SECRET}`,
|
||||
],
|
||||
["relay credentials", `wss://user:pass@gateway.example.com/extension#${RELAY_SECRET}`],
|
||||
["the wrong path", `ws://127.0.0.1/other#${RELAY_SECRET}`],
|
||||
["a missing secret", "ws://127.0.0.1/extension#"],
|
||||
["a short secret", "ws://127.0.0.1/extension#abc123"],
|
||||
["an uppercase secret", `ws://127.0.0.1/extension#${"A".repeat(64)}`],
|
||||
["an unknown query parameter", `ws://127.0.0.1/extension?token=nope#${RELAY_SECRET}`],
|
||||
["duplicate profiles", `ws://127.0.0.1/extension?profile=one&profile=two#${RELAY_SECRET}`],
|
||||
[
|
||||
"duplicate Gateway hints",
|
||||
`ws://127.0.0.1/extension?gateway=wss%3A%2F%2Fone.example&gateway=wss%3A%2F%2Ftwo.example#${RELAY_SECRET}`,
|
||||
@@ -117,17 +139,98 @@ describe("persisted pairing storage", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "a direct relay with its matching trailing-slash Gateway hint",
|
||||
label: "an exact direct relay with its matching trailing-slash Gateway hint",
|
||||
stored: {
|
||||
relayUrl: "wss://gateway.example.com/base/browser/extension",
|
||||
relayUrl: "wss://gateway.example.com/browser/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "wss://gateway.example.com/base/",
|
||||
gatewayUrl: "wss://gateway.example.com/",
|
||||
},
|
||||
},
|
||||
])("accepts $label", async ({ stored }) => {
|
||||
expect(await readStoredPairing(stored)).toEqual(stored);
|
||||
});
|
||||
|
||||
it("migrates a canonical existing pairing to authVersion 2 without re-pairing", async () => {
|
||||
const stored = {
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "",
|
||||
groupColor: "orange",
|
||||
};
|
||||
const set = vi.fn(async (values: Record<string, unknown>) => {
|
||||
Object.assign(stored, values);
|
||||
});
|
||||
const config = await createPairingConfigStore({
|
||||
get: async () => stored,
|
||||
set,
|
||||
remove: async () => undefined,
|
||||
}).read();
|
||||
expect(set).toHaveBeenCalledWith({ authVersion: 2 });
|
||||
expect(config).toMatchObject({
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
token: RELAY_SECRET,
|
||||
authVersion: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects and clears an unsupported stored auth version", async () => {
|
||||
const remove = vi.fn(async () => undefined);
|
||||
const config = await createPairingConfigStore({
|
||||
get: async () => ({
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "",
|
||||
authVersion: 1,
|
||||
}),
|
||||
set: async () => undefined,
|
||||
remove,
|
||||
}).read();
|
||||
expect(config.relayUrl).toBe("");
|
||||
expect(remove).toHaveBeenCalledWith(["relayUrl", "gatewayUrl", "token", "authVersion"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
relayUrl: "wss://gateway.example.com/proxy/browser/extension",
|
||||
gatewayUrl: "wss://gateway.example.com/proxy",
|
||||
},
|
||||
{
|
||||
relayUrl: "ws://127.0.0.1:18789/proxy/browser/extension",
|
||||
gatewayUrl: "ws://127.0.0.1:18789/proxy",
|
||||
},
|
||||
])("clears stored proxy-prefixed direct pairing $relayUrl with safe guidance", async (route) => {
|
||||
const stored: Record<string, unknown> = {
|
||||
relayUrl: route.relayUrl,
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: route.gatewayUrl,
|
||||
authVersion: 2,
|
||||
};
|
||||
const set = vi.fn(async (values: Record<string, unknown>) => {
|
||||
Object.assign(stored, values);
|
||||
});
|
||||
const remove = vi.fn(async (keys: string[]) => {
|
||||
for (const key of keys) {
|
||||
delete stored[key];
|
||||
}
|
||||
});
|
||||
const store = createPairingConfigStore({ get: async () => stored, set, remove });
|
||||
|
||||
const config = await store.read();
|
||||
|
||||
expect(config).toMatchObject({ relayUrl: "", token: "", authVersion: undefined });
|
||||
expect(config.pairingStatusHint).toContain("no path prefix");
|
||||
expect(config.pairingStatusHint).not.toContain(RELAY_SECRET);
|
||||
expect(remove).toHaveBeenCalledWith(["relayUrl", "gatewayUrl", "token", "authVersion"]);
|
||||
expect(set).toHaveBeenCalledWith({ pairingStatus: "proxy-prefix-unsupported" });
|
||||
|
||||
const afterWorkerRestart = await createPairingConfigStore({
|
||||
get: async () => stored,
|
||||
set,
|
||||
remove,
|
||||
}).read();
|
||||
expect(afterWorkerRestart.pairingStatusHint).toContain("openclaw browser extension pair");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["an invalid token", { relayUrl: "ws://127.0.0.1:18797/extension", token: "short" }],
|
||||
[
|
||||
@@ -186,9 +289,9 @@ describe("persisted pairing storage", () => {
|
||||
[
|
||||
"a mismatched direct Gateway hint",
|
||||
{
|
||||
relayUrl: "wss://gateway.example.com/base/browser/extension",
|
||||
relayUrl: "wss://gateway.example.com/browser/extension",
|
||||
token: RELAY_SECRET,
|
||||
gatewayUrl: "wss://other.example.com/base",
|
||||
gatewayUrl: "wss://other.example.com",
|
||||
},
|
||||
],
|
||||
])("rejects %s", async (_label, stored) => {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import path from "node:path";
|
||||
import { chromium, type CDPSession } from "playwright-core";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { WebSocketServer } from "ws";
|
||||
import {
|
||||
EXTENSION_RELAY_MAX_PAYLOAD_BYTES,
|
||||
startExtensionRelayServer,
|
||||
type ExtensionRelayHandle,
|
||||
} from "../src/browser/extension-relay/relay-server.js";
|
||||
@@ -9,6 +14,7 @@ import { useAutoCleanupTempDirTracker } from "../test-support.js";
|
||||
import {
|
||||
copyCopilotSidepanelExtension,
|
||||
createRelayHarness,
|
||||
rawDataText,
|
||||
waitForContextExtensionId,
|
||||
waitForLoadedExtensionId,
|
||||
} from "./sidepanel.e2e-support.js";
|
||||
@@ -73,6 +79,24 @@ async function listen(server: Server): Promise<number> {
|
||||
return address.port;
|
||||
}
|
||||
|
||||
async function configureRelayCredential(token: string): Promise<void> {
|
||||
const priorStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const stateDir = tempDirs.make("openclaw-extension-relay-state-");
|
||||
const credentialsDir = path.join(stateDir, "credentials");
|
||||
await fs.mkdir(credentialsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(credentialsDir, "browser-extension-relay.secret"), `${token}\n`, {
|
||||
mode: 0o600,
|
||||
});
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
cleanups.push(async () => {
|
||||
if (priorStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = priorStateDir;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function evaluateToolbarPopup<T>(
|
||||
browserCdp: CDPSession,
|
||||
sessionId: string,
|
||||
@@ -125,8 +149,102 @@ async function evaluateToolbarPopup<T>(
|
||||
}
|
||||
|
||||
describe.runIf(runE2E)("Chrome extension relay authorization", () => {
|
||||
it("sends no client proof or raw key to a malicious loopback listener", async () => {
|
||||
const server = createServer();
|
||||
const port = await listen(server);
|
||||
const wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: EXTENSION_RELAY_MAX_PAYLOAD_BYTES,
|
||||
handleProtocols: (protocols) =>
|
||||
protocols.has("openclaw-extension-relay.v2") ? "openclaw-extension-relay.v2" : false,
|
||||
});
|
||||
const protocolHeaders: string[] = [];
|
||||
const receivedTypes: string[] = [];
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
const protocolHeader = request.headers["sec-websocket-protocol"];
|
||||
protocolHeaders.push(
|
||||
Array.isArray(protocolHeader) ? protocolHeader.join(", ") : (protocolHeader ?? ""),
|
||||
);
|
||||
wss.handleUpgrade(request, socket, head, (client) => wss.emit("connection", client, request));
|
||||
});
|
||||
wss.on("connection", (socket) => {
|
||||
socket.on("message", (data) => {
|
||||
const message = JSON.parse(rawDataText(data)) as Record<string, unknown>;
|
||||
receivedTypes.push(String(message.type));
|
||||
if (message.type !== "auth.hello") {
|
||||
return;
|
||||
}
|
||||
const issuedAtMs = Date.now();
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "auth.challenge",
|
||||
v: 2,
|
||||
keyId: createHash("sha256")
|
||||
.update(Buffer.from(PAGE_SHARE_RELAY_SECRET, "hex"))
|
||||
.digest("base64url")
|
||||
.slice(0, 22),
|
||||
instanceId: "ICEiIyQlJicoKSorLC0uLw",
|
||||
sessionId: "MDEyMzQ1Njc4OTo7PD0-Pw",
|
||||
clientNonce: message.clientNonce,
|
||||
serverNonce: "YGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn8",
|
||||
issuedAtMs,
|
||||
expiresAtMs: issuedAtMs + 10_000,
|
||||
role: "extension",
|
||||
transport: "websocket",
|
||||
method: "GET",
|
||||
resource: "/extension",
|
||||
flow: "extension",
|
||||
serverProof: "A".repeat(43),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
cleanups.push(async () => {
|
||||
for (const client of wss.clients) {
|
||||
client.terminate();
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
});
|
||||
|
||||
const unpackedExtension = await copyCopilotSidepanelExtension(tempDirs);
|
||||
const context = await chromium.launchPersistentContext(
|
||||
tempDirs.make("openclaw-extension-malicious-relay-profile-"),
|
||||
{
|
||||
channel: "chromium",
|
||||
headless: true,
|
||||
ignoreDefaultArgs: ["--disable-extensions"],
|
||||
args: [
|
||||
"--enable-unsafe-extension-debugging",
|
||||
`--disable-extensions-except=${unpackedExtension}`,
|
||||
`--load-extension=${unpackedExtension}`,
|
||||
],
|
||||
},
|
||||
);
|
||||
cleanups.push(async () => await context.close());
|
||||
const extensionId = await waitForContextExtensionId(context, unpackedExtension);
|
||||
const launcher = context.pages()[0] ?? (await context.newPage());
|
||||
await launcher.goto(`chrome-extension://${extensionId}/e2e-launcher.html`);
|
||||
await launcher.evaluate(
|
||||
async (pairingString) => await chrome.runtime.sendMessage({ type: "pair", pairingString }),
|
||||
`ws://127.0.0.1:${port}/extension#${PAGE_SHARE_RELAY_SECRET}`,
|
||||
);
|
||||
|
||||
await expect.poll(() => receivedTypes.length, { timeout: 10_000 }).toBeGreaterThan(0);
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 750);
|
||||
});
|
||||
expect(new Set(receivedTypes)).toEqual(new Set(["auth.hello"]));
|
||||
expect(new Set(protocolHeaders)).toEqual(new Set(["openclaw-extension-relay.v2"]));
|
||||
expect(protocolHeaders.join("\n")).not.toContain(PAGE_SHARE_RELAY_SECRET);
|
||||
}, 60_000);
|
||||
|
||||
it("clears an invalid persisted pairing before reconnecting after restart", async () => {
|
||||
const relay = await createRelayHarness();
|
||||
const relay = await createRelayHarness(PAGE_SHARE_RELAY_SECRET);
|
||||
cleanups.push(relay.close);
|
||||
const unpackedExtension = await copyCopilotSidepanelExtension(tempDirs);
|
||||
const userDataDir = tempDirs.make("openclaw-extension-persisted-auth-profile-");
|
||||
@@ -168,7 +286,8 @@ describe.runIf(runE2E)("Chrome extension relay authorization", () => {
|
||||
.poll(
|
||||
async () =>
|
||||
await launcher.evaluate(
|
||||
async () => await chrome.storage.local.get(["relayUrl", "gatewayUrl", "token"]),
|
||||
async () =>
|
||||
await chrome.storage.local.get(["relayUrl", "gatewayUrl", "token", "authVersion"]),
|
||||
),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
@@ -183,7 +302,7 @@ describe.runIf(runE2E)("Chrome extension relay authorization", () => {
|
||||
}, 60_000);
|
||||
|
||||
it("enforces pairing and current tab-group consent at the extension edge", async () => {
|
||||
const relay = await createRelayHarness();
|
||||
const relay = await createRelayHarness(PAGE_SHARE_RELAY_SECRET);
|
||||
cleanups.push(relay.close);
|
||||
const fixture = createServer((_request, response) => {
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
@@ -276,6 +395,7 @@ describe.runIf(runE2E)("Chrome page sharing with a real Gateway extension relay"
|
||||
const delivery = new Promise<void>((resolve) => {
|
||||
releaseDelivery = resolve;
|
||||
});
|
||||
await configureRelayCredential(PAGE_SHARE_RELAY_SECRET);
|
||||
const relay = await startExtensionRelayServer({
|
||||
port: 0,
|
||||
token: PAGE_SHARE_RELAY_SECRET,
|
||||
@@ -500,6 +620,7 @@ describe.runIf(runE2E)("Chrome page sharing with a real Gateway extension relay"
|
||||
});
|
||||
|
||||
it("keeps a real stale-tab sharing error visible across the popup status poll", async () => {
|
||||
await configureRelayCredential(PAGE_SHARE_RELAY_SECRET);
|
||||
const relay = await startExtensionRelayServer({
|
||||
port: 0,
|
||||
token: PAGE_SHARE_RELAY_SECRET,
|
||||
|
||||
@@ -13,6 +13,7 @@ type PopupMessage = {
|
||||
type PopupState = {
|
||||
paired?: boolean;
|
||||
shared?: boolean;
|
||||
statusHint?: string;
|
||||
failures: Partial<Record<"getStatus" | "pair" | "unpair" | "toggleShareTab", string>>;
|
||||
onFailure?: (message: PopupMessage) => void;
|
||||
};
|
||||
@@ -39,6 +40,7 @@ async function loadPopup(params: PopupState) {
|
||||
state: "on",
|
||||
sharedTabCount: params.shared ? 1 : 0,
|
||||
relayUrl: "ws://127.0.0.1:18797/extension",
|
||||
...(params.statusHint ? { hint: params.statusHint } : {}),
|
||||
};
|
||||
case "prepareCopilotPanel":
|
||||
return { ok: true, path: "sidepanel.html?binding=fixture" };
|
||||
@@ -106,6 +108,15 @@ describe("Chrome extension popup action errors", () => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it("shows persisted re-pair guidance after an unsupported pairing is cleared", async () => {
|
||||
const hint =
|
||||
"Stored proxy-prefixed browser relay pairing is no longer supported. Re-run openclaw browser extension pair with a Gateway URL that has no path prefix.";
|
||||
await loadPopup({ paired: false, statusHint: hint, failures: {} });
|
||||
|
||||
expect(popupElement("statusLine").textContent).toBe(hint);
|
||||
expect(popupElement("pairSection").classList.contains("hidden")).toBe(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -322,7 +322,7 @@
|
||||
</section>
|
||||
<section id="connectedSection" class="hidden">
|
||||
<p id="statusHint" class="lede hidden" style="margin: 0 2px 4px">
|
||||
Relay unreachable — is the OpenClaw gateway running?
|
||||
Relay unreachable — is the OpenClaw gateway running and up to date?
|
||||
</p>
|
||||
<button id="copilotButton" class="action primary" type="button" disabled>
|
||||
Open tab copilot
|
||||
|
||||
@@ -61,13 +61,15 @@ async function refresh() {
|
||||
unpairButton.classList.toggle("hidden", !status.paired);
|
||||
unpairNote.classList.toggle("hidden", !status.paired);
|
||||
if (!status.paired) {
|
||||
statusLine.textContent = actionError ?? "Not paired with a gateway";
|
||||
statusLine.textContent = actionError ?? status.hint ?? "Not paired with a gateway";
|
||||
return;
|
||||
}
|
||||
const label = STATE_LABEL[status.state] ?? STATE_LABEL.off;
|
||||
statusLine.textContent =
|
||||
actionError ??
|
||||
`${label} · ${status.sharedTabCount} tab${status.sharedTabCount === 1 ? "" : "s"} shared`;
|
||||
statusHint.textContent =
|
||||
status.hint || "Relay unreachable — is the OpenClaw gateway running and up to date?";
|
||||
statusHint.classList.toggle("hidden", status.state !== "error");
|
||||
const tab = await activeTab();
|
||||
if (tab?.id === undefined) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import path from "node:path";
|
||||
@@ -5,6 +6,11 @@ import { fileURLToPath } from "node:url";
|
||||
import type { BrowserContext, CDPSession, Page } from "playwright-core";
|
||||
import type { expect as VitestExpect } from "vitest";
|
||||
import { WebSocketServer, type RawData } from "ws";
|
||||
import {
|
||||
computeRelayAuthProof,
|
||||
deriveRelayAuthKeyId,
|
||||
type RelayAuthProofFields,
|
||||
} from "./modules/relay-auth-v2-crypto.js";
|
||||
|
||||
type CopilotTurnIsolationGateway = {
|
||||
chatSends: Array<Record<string, unknown>>;
|
||||
@@ -67,7 +73,7 @@ type RelayHarness = {
|
||||
setAvailable: (available: boolean) => void;
|
||||
};
|
||||
|
||||
export async function createRelayHarness(): Promise<RelayHarness> {
|
||||
export async function createRelayHarness(token = "a".repeat(64)): Promise<RelayHarness> {
|
||||
const server = createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
@@ -80,7 +86,8 @@ export async function createRelayHarness(): Promise<RelayHarness> {
|
||||
const wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: 1_000_000,
|
||||
handleProtocols: (protocols) => protocols.values().next().value ?? false,
|
||||
handleProtocols: (protocols) =>
|
||||
protocols.has("openclaw-extension-relay.v2") ? "openclaw-extension-relay.v2" : false,
|
||||
});
|
||||
const hellos: Array<Record<string, unknown>> = [];
|
||||
const pendingCommands = new Map<
|
||||
@@ -90,6 +97,7 @@ export async function createRelayHarness(): Promise<RelayHarness> {
|
||||
let available = true;
|
||||
let connectionCount = 0;
|
||||
let nextCommandSeq = 0;
|
||||
const authenticated = new Set<import("ws").WebSocket>();
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
if (!available) {
|
||||
socket.destroy();
|
||||
@@ -100,10 +108,87 @@ export async function createRelayHarness(): Promise<RelayHarness> {
|
||||
});
|
||||
});
|
||||
wss.on("connection", (socket) => {
|
||||
connectionCount += 1;
|
||||
socket.on("message", (data) => {
|
||||
let authState:
|
||||
| { kind: "hello" }
|
||||
| {
|
||||
kind: "response";
|
||||
fields: RelayAuthProofFields;
|
||||
clientProof?: string;
|
||||
}
|
||||
| { kind: "authenticated" } = { kind: "hello" };
|
||||
const handleMessage = async (data: RawData) => {
|
||||
const message = JSON.parse(rawDataText(data)) as Record<string, unknown>;
|
||||
if (authState.kind === "hello") {
|
||||
if (
|
||||
message.type !== "auth.hello" ||
|
||||
message.v !== 2 ||
|
||||
typeof message.keyId !== "string" ||
|
||||
typeof message.clientNonce !== "string"
|
||||
) {
|
||||
socket.close(4001, "expected auth.hello");
|
||||
return;
|
||||
}
|
||||
const keyId = await deriveRelayAuthKeyId(token);
|
||||
if (message.keyId !== keyId) {
|
||||
socket.close(4001, "keyId mismatch");
|
||||
return;
|
||||
}
|
||||
const issuedAtMs = Date.now();
|
||||
const fields: RelayAuthProofFields = {
|
||||
keyId,
|
||||
instanceId: randomBytes(16).toString("base64url"),
|
||||
sessionId: randomBytes(16).toString("base64url"),
|
||||
clientNonce: message.clientNonce,
|
||||
serverNonce: randomBytes(32).toString("base64url"),
|
||||
issuedAtMs,
|
||||
expiresAtMs: issuedAtMs + 10_000,
|
||||
role: "extension",
|
||||
transport: "websocket",
|
||||
method: "GET",
|
||||
resource: "/extension",
|
||||
flow: "extension",
|
||||
};
|
||||
authState = { kind: "response", fields };
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "auth.challenge",
|
||||
v: 2,
|
||||
...fields,
|
||||
serverProof: await computeRelayAuthProof(token, "server", fields),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (authState.kind === "response") {
|
||||
if (
|
||||
message.type !== "auth.response" ||
|
||||
message.v !== 2 ||
|
||||
message.sessionId !== authState.fields.sessionId ||
|
||||
typeof message.clientProof !== "string"
|
||||
) {
|
||||
socket.close(4001, "expected auth.response");
|
||||
return;
|
||||
}
|
||||
const fields = authState.fields;
|
||||
const expectedClientProof = await computeRelayAuthProof(token, "client", fields);
|
||||
if (message.clientProof !== expectedClientProof) {
|
||||
socket.close(4001, "clientProof mismatch");
|
||||
return;
|
||||
}
|
||||
authState = { kind: "authenticated" };
|
||||
authenticated.add(socket);
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "auth.ok",
|
||||
v: 2,
|
||||
sessionId: fields.sessionId,
|
||||
acceptProof: await computeRelayAuthProof(token, "accept", fields, message.clientProof),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (message.type === "hello") {
|
||||
connectionCount += 1;
|
||||
hellos.push(message);
|
||||
return;
|
||||
}
|
||||
@@ -121,7 +206,11 @@ export async function createRelayHarness(): Promise<RelayHarness> {
|
||||
} else {
|
||||
pending.resolve(message.result);
|
||||
}
|
||||
};
|
||||
socket.on("message", (data) => {
|
||||
void handleMessage(data);
|
||||
});
|
||||
socket.on("close", () => authenticated.delete(socket));
|
||||
});
|
||||
return {
|
||||
get connectionCount() {
|
||||
@@ -130,7 +219,7 @@ export async function createRelayHarness(): Promise<RelayHarness> {
|
||||
hellos,
|
||||
port: address.port,
|
||||
command: async (body) => {
|
||||
const client = [...wss.clients].find((candidate) => candidate.readyState === 1);
|
||||
const client = [...authenticated].find((candidate) => candidate.readyState === 1);
|
||||
if (!client) {
|
||||
throw new Error("extension relay client is not connected");
|
||||
}
|
||||
|
||||
@@ -191,17 +191,32 @@ describe("browser config", () => {
|
||||
expect(() => resolveBrowserConfig({ profiles })).toThrow(/extension.*relay.*port/i);
|
||||
});
|
||||
|
||||
it("embeds the host-local relay secret as Basic auth in the extension cdpUrl", () => {
|
||||
const token = "a".repeat(64);
|
||||
writeRelaySecret(token);
|
||||
it("keeps the host-local relay key out of the extension cdpUrl", () => {
|
||||
const relayKey = "a".repeat(64);
|
||||
writeRelaySecret(relayKey);
|
||||
const resolved = resolveBrowserConfig(undefined);
|
||||
expect(resolved.extensionRelayToken).toBe(token);
|
||||
expect(resolved.extensionRelayToken).toBe(relayKey);
|
||||
const chrome = resolveProfile(resolved, "chrome");
|
||||
expect(chrome?.cdpUrl).toBe(
|
||||
`http://openclaw:${token}@127.0.0.1:${resolved.extensionRelayDefaultPort}`,
|
||||
expect(chrome?.cdpUrl).toBe(`http://127.0.0.1:${resolved.extensionRelayDefaultPort}`);
|
||||
|
||||
resolved.extensionRelayInternalTokens.chrome = "process-only-token";
|
||||
expect(resolveProfile(resolved, "chrome")?.cdpUrl).toBe(
|
||||
[
|
||||
"http://openclaw-internal:",
|
||||
"process-only-token",
|
||||
`@127.0.0.1:${resolved.extensionRelayDefaultPort}`,
|
||||
].join(""),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows legacy extension relay auth for one migration window by default", () => {
|
||||
expect(resolveBrowserConfig(undefined).extensionRelay.allowLegacyAuth).toBe(true);
|
||||
expect(
|
||||
resolveBrowserConfig({ extensionRelay: { allowLegacyAuth: false } }).extensionRelay
|
||||
.allowLegacyAuth,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("derives default ports from OPENCLAW_GATEWAY_PORT when unset", () => {
|
||||
withEnv({ OPENCLAW_GATEWAY_PORT: "19001" }, () => {
|
||||
const resolved = resolveBrowserConfig(undefined);
|
||||
|
||||
@@ -91,7 +91,13 @@ export type ResolvedBrowserConfig = {
|
||||
extensionRelayDefaultPort: number;
|
||||
/** Assigned loopback relay port per extension-driver profile (no explicit cdpPort). */
|
||||
extensionRelayPorts: Record<string, number>;
|
||||
/** Derived bearer token for extension relay auth (absent until gateway auth exists). */
|
||||
/** Extension relay authentication compatibility policy. */
|
||||
extensionRelay: {
|
||||
allowLegacyAuth: boolean;
|
||||
};
|
||||
/** Per-profile process-only Basic credentials for internal browser clients. */
|
||||
extensionRelayInternalTokens: Record<string, string>;
|
||||
/** Persistent relay HMAC key (absent until pairing or relay startup creates it). */
|
||||
extensionRelayToken?: string;
|
||||
};
|
||||
|
||||
@@ -138,8 +144,8 @@ const DEFAULT_BROWSER_REMOTE_CDP_HANDSHAKE_TIMEOUT_MS = 3_000;
|
||||
* can never hand this port to a managed profile.
|
||||
*/
|
||||
const EXTENSION_RELAY_PORT_OFFSET = 8;
|
||||
/** Username half of the relay's Basic credential; the password is the derived token. */
|
||||
const EXTENSION_RELAY_CDP_USER = "openclaw";
|
||||
/** Username half of the process-only internal relay credential. */
|
||||
const EXTENSION_RELAY_CDP_USER = "openclaw-internal";
|
||||
/** Environment variable that overrides managed Chrome headless mode. */
|
||||
const BROWSER_HEADLESS_ENV_KEY = "OPENCLAW_BROWSER_HEADLESS";
|
||||
|
||||
@@ -411,7 +417,7 @@ export function resolveBrowserConfig(
|
||||
|
||||
const headless = cfg?.headless === true;
|
||||
const headlessSource = typeof cfg?.headless === "boolean" ? "config" : "default";
|
||||
// Host-local relay secret (created lazily by relay startup / pairing). Null
|
||||
// Host-local HMAC key (created lazily by relay startup / pairing). Null
|
||||
// here just means the extension driver has not been used on this host yet.
|
||||
const extensionRelayToken = resolveExtensionRelayToken() ?? undefined;
|
||||
const noSandbox = cfg?.noSandbox === true;
|
||||
@@ -478,6 +484,10 @@ export function resolveBrowserConfig(
|
||||
profiles,
|
||||
controlPort + EXTENSION_RELAY_PORT_OFFSET,
|
||||
),
|
||||
extensionRelay: {
|
||||
allowLegacyAuth: cfg?.extensionRelay?.allowLegacyAuth ?? true,
|
||||
},
|
||||
extensionRelayInternalTokens: {},
|
||||
...(extensionRelayToken ? { extensionRelayToken } : {}),
|
||||
};
|
||||
}
|
||||
@@ -514,9 +524,9 @@ export function resolveProfile(
|
||||
profile.cdpPort ??
|
||||
resolved.extensionRelayPorts[profileName] ??
|
||||
resolved.extensionRelayDefaultPort;
|
||||
const token = resolved.extensionRelayToken;
|
||||
// Userinfo credentials flow through getHeadersWithAuth into /json/version
|
||||
// and /cdp requests, so the relay is authenticated with zero extra plumbing.
|
||||
const token = resolved.extensionRelayInternalTokens[profileName];
|
||||
// Internal browser clients use a process-only credential. The persistent
|
||||
// relay key is reserved for HMAC proofs and never enters a URL or header.
|
||||
const relayCdpUrl = token
|
||||
? `http://${EXTENSION_RELAY_CDP_USER}:${encodeURIComponent(token)}@127.0.0.1:${relayPort}`
|
||||
: `http://127.0.0.1:${relayPort}`;
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export const BROWSER_RELAY_AUTH_LABEL = "openclaw.browser-relay.auth" as const;
|
||||
export const BROWSER_RELAY_AUTH_VERSION = 2 as const;
|
||||
|
||||
const KEY_HEX_PATTERN = /^[0-9a-f]{64}$/u;
|
||||
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u;
|
||||
|
||||
type BrowserRelayProofKind = "server" | "client" | "accept";
|
||||
type BrowserRelayRole = "extension" | "cdp";
|
||||
type BrowserRelayTransport = "websocket" | "connection";
|
||||
type BrowserRelayMethod = "GET" | "SEQUENCE";
|
||||
type BrowserRelayFlow = "extension" | "cdp" | "json-list";
|
||||
|
||||
export type BrowserRelayProofFields = {
|
||||
keyId: string;
|
||||
instanceId: string;
|
||||
sessionId: string;
|
||||
clientNonce: string;
|
||||
serverNonce: string;
|
||||
issuedAtMs: number;
|
||||
expiresAtMs: number;
|
||||
role: BrowserRelayRole;
|
||||
transport: BrowserRelayTransport;
|
||||
method: BrowserRelayMethod;
|
||||
resource: string;
|
||||
flow: BrowserRelayFlow;
|
||||
};
|
||||
|
||||
export type BrowserRelayAuthChallenge = BrowserRelayProofFields & {
|
||||
type: "auth.challenge";
|
||||
v: 2;
|
||||
serverProof: string;
|
||||
};
|
||||
|
||||
export type BrowserRelayAuthOk = {
|
||||
type: "auth.ok";
|
||||
v: 2;
|
||||
sessionId: string;
|
||||
acceptProof: string;
|
||||
};
|
||||
|
||||
function decodeRelayKey(keyHex: string): Buffer {
|
||||
if (!KEY_HEX_PATTERN.test(keyHex)) {
|
||||
throw new Error("browser relay key must be 32 lowercase-hex bytes");
|
||||
}
|
||||
return Buffer.from(keyHex, "hex");
|
||||
}
|
||||
|
||||
export function isCanonicalBase64UrlBytes(value: unknown, bytes: number): value is string {
|
||||
if (typeof value !== "string" || !BASE64URL_PATTERN.test(value)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const decoded = Buffer.from(value, "base64url");
|
||||
return decoded.length === bytes && decoded.toString("base64url") === value;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isBase64UrlText(value: string): boolean {
|
||||
return BASE64URL_PATTERN.test(value);
|
||||
}
|
||||
|
||||
export function relayKeyIdFromHex(keyHex: string): string {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(decodeRelayKey(keyHex))
|
||||
.digest("base64url")
|
||||
.slice(0, 22);
|
||||
}
|
||||
|
||||
export function randomRelayNonce(): string {
|
||||
return crypto.randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
export function randomRelayId(): string {
|
||||
return crypto.randomBytes(16).toString("base64url");
|
||||
}
|
||||
|
||||
function canonicalRelayProofBytes(
|
||||
proofKind: BrowserRelayProofKind,
|
||||
fields: BrowserRelayProofFields,
|
||||
clientProof?: string,
|
||||
): Buffer {
|
||||
const values: unknown[] = [
|
||||
BROWSER_RELAY_AUTH_LABEL,
|
||||
BROWSER_RELAY_AUTH_VERSION,
|
||||
proofKind,
|
||||
fields.keyId,
|
||||
fields.instanceId,
|
||||
fields.sessionId,
|
||||
fields.clientNonce,
|
||||
fields.serverNonce,
|
||||
fields.issuedAtMs,
|
||||
fields.expiresAtMs,
|
||||
fields.role,
|
||||
fields.transport,
|
||||
fields.method,
|
||||
fields.resource,
|
||||
fields.flow,
|
||||
];
|
||||
if (proofKind === "accept") {
|
||||
if (!isCanonicalBase64UrlBytes(clientProof, 32)) {
|
||||
throw new Error("accept proof requires a 32-byte client proof");
|
||||
}
|
||||
values.push(clientProof);
|
||||
}
|
||||
return Buffer.from(JSON.stringify(values), "utf8");
|
||||
}
|
||||
|
||||
export function createRelayProof(
|
||||
keyHex: string,
|
||||
proofKind: BrowserRelayProofKind,
|
||||
fields: BrowserRelayProofFields,
|
||||
clientProof?: string,
|
||||
): string {
|
||||
return crypto
|
||||
.createHmac("sha256", decodeRelayKey(keyHex))
|
||||
.update(canonicalRelayProofBytes(proofKind, fields, clientProof))
|
||||
.digest("base64url");
|
||||
}
|
||||
|
||||
export function verifyRelayProof(
|
||||
keyHex: string,
|
||||
proofKind: BrowserRelayProofKind,
|
||||
fields: BrowserRelayProofFields,
|
||||
candidate: unknown,
|
||||
clientProof?: string,
|
||||
): boolean {
|
||||
if (!isCanonicalBase64UrlBytes(candidate, 32)) {
|
||||
return false;
|
||||
}
|
||||
const expected = Buffer.from(
|
||||
createRelayProof(keyHex, proofKind, fields, clientProof),
|
||||
"base64url",
|
||||
);
|
||||
const actual = Buffer.from(candidate, "base64url");
|
||||
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createRelayProof,
|
||||
relayKeyIdFromHex,
|
||||
verifyRelayProof,
|
||||
type BrowserRelayProofFields,
|
||||
} from "./auth-v2-crypto.js";
|
||||
import {
|
||||
BrowserRelayAuthV2Authority,
|
||||
getBrowserRelayAuthV2Authority,
|
||||
invalidateBrowserRelayAuthV2Authority,
|
||||
parseExtensionRelayResource,
|
||||
parseRelayAuthHello,
|
||||
parseRelayAuthResponse,
|
||||
parseStrictJsonObject,
|
||||
} from "./auth-v2.js";
|
||||
|
||||
const KEY = Array.from({ length: 32 }, (_, index) => index.toString(16).padStart(2, "0")).join("");
|
||||
const VECTOR_FIELDS: BrowserRelayProofFields = {
|
||||
keyId: "Yw3NKWbEM2aRElRIu7JbT_",
|
||||
instanceId: "EREREREREREREREREREREQ",
|
||||
sessionId: "IiIiIiIiIiIiIiIiIiIiIg",
|
||||
clientNonce: "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM",
|
||||
serverNonce: "REREREREREREREREREREREREREREREREREREREREREQ",
|
||||
issuedAtMs: 1_786_123_456_000,
|
||||
expiresAtMs: 1_786_123_466_000,
|
||||
role: "extension",
|
||||
transport: "websocket",
|
||||
method: "GET",
|
||||
resource: "/extension?profile=chrome",
|
||||
flow: "extension",
|
||||
};
|
||||
|
||||
const BINDING = {
|
||||
role: "extension",
|
||||
transport: "websocket",
|
||||
method: "GET",
|
||||
resource: "/extension",
|
||||
flow: "extension",
|
||||
} as const;
|
||||
|
||||
function hello(clientNonce = "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM") {
|
||||
return {
|
||||
type: "auth.hello" as const,
|
||||
v: 2 as const,
|
||||
keyId: relayKeyIdFromHex(KEY),
|
||||
clientNonce,
|
||||
};
|
||||
}
|
||||
|
||||
describe("browser relay auth v2 proofs", () => {
|
||||
it("matches the frozen Node/WebCrypto test vector", () => {
|
||||
expect(relayKeyIdFromHex(KEY)).toBe(VECTOR_FIELDS.keyId);
|
||||
expect(createRelayProof(KEY, "server", VECTOR_FIELDS)).toBe(
|
||||
"ynhaAA_l2HkOGXQ8DvIWfzWwwGjDcV93aumHNe_NM-Q",
|
||||
);
|
||||
const clientProof = createRelayProof(KEY, "client", VECTOR_FIELDS);
|
||||
expect(clientProof).toBe("Rl8TStMYlPLxJPDYwSe__mtEjgMf1C4TM-ZN6sUipZ4");
|
||||
expect(createRelayProof(KEY, "accept", VECTOR_FIELDS, clientProof)).toBe(
|
||||
"1R5MpHs6qnAdc0_X6vKBwj91tlRoWfNuGXaNfSD7VnI",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["keyId", "EREREREREREREREREREREQ"],
|
||||
["instanceId", "IiIiIiIiIiIiIiIiIiIiIg"],
|
||||
["sessionId", "EREREREREREREREREREREQ"],
|
||||
["clientNonce", "REREREREREREREREREREREREREREREREREREREREREQ"],
|
||||
["serverNonce", "MzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzM"],
|
||||
["issuedAtMs", VECTOR_FIELDS.issuedAtMs + 1],
|
||||
["expiresAtMs", VECTOR_FIELDS.expiresAtMs + 1],
|
||||
["role", "cdp"],
|
||||
["transport", "connection"],
|
||||
["method", "SEQUENCE"],
|
||||
["resource", "/extension"],
|
||||
["flow", "cdp"],
|
||||
] as const)("binds %s", (field, replacement) => {
|
||||
const proof = createRelayProof(KEY, "server", VECTOR_FIELDS);
|
||||
expect(verifyRelayProof(KEY, "server", { ...VECTOR_FIELDS, [field]: replacement }, proof)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed or wrong-length proofs before constant-time comparison", () => {
|
||||
expect(verifyRelayProof(KEY, "server", VECTOR_FIELDS, "short")).toBe(false);
|
||||
expect(verifyRelayProof(KEY, "server", VECTOR_FIELDS, "!".repeat(43))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BrowserRelayAuthV2Authority", () => {
|
||||
it("binds completion to the exact connection and consumes it atomically", () => {
|
||||
const authority = new BrowserRelayAuthV2Authority(KEY);
|
||||
const socket = {};
|
||||
const otherSocket = {};
|
||||
expect(authority.registerPendingConnection(socket, vi.fn())).toBe(true);
|
||||
expect(authority.registerPendingConnection(otherSocket, vi.fn())).toBe(true);
|
||||
const challenge = authority.issueChallenge(socket, hello(), BINDING, 1_000);
|
||||
expect(challenge).not.toBeNull();
|
||||
const fields = challenge as BrowserRelayProofFields;
|
||||
const response = {
|
||||
type: "auth.response",
|
||||
v: 2,
|
||||
sessionId: fields.sessionId,
|
||||
clientProof: createRelayProof(KEY, "client", fields),
|
||||
} as const;
|
||||
expect(authority.completeChallenge(otherSocket, response, 1_001)).toBeNull();
|
||||
expect(authority.completeChallenge(socket, response, 1_001)?.ok.type).toBe("auth.ok");
|
||||
expect(authority.completeChallenge(socket, response, 1_001)).toBeNull();
|
||||
expect(authority.issueChallenge(socket, hello(), BINDING, 1_002)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects replayed hello across the same or another socket until expiry", () => {
|
||||
const authority = new BrowserRelayAuthV2Authority(KEY);
|
||||
const first = {};
|
||||
const second = {};
|
||||
authority.registerPendingConnection(first, vi.fn());
|
||||
authority.registerPendingConnection(second, vi.fn());
|
||||
expect(authority.issueChallenge(first, hello(), BINDING, 1_000)).not.toBeNull();
|
||||
expect(authority.issueChallenge(first, hello(), BINDING, 1_001)).toBeNull();
|
||||
expect(authority.issueChallenge(second, hello(), BINDING, 1_001)).toBeNull();
|
||||
expect(authority.issueChallenge(second, hello(), BINDING, 11_001)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects expired challenges and wrong client proofs", () => {
|
||||
const authority = new BrowserRelayAuthV2Authority(KEY);
|
||||
const first = {};
|
||||
authority.registerPendingConnection(first, vi.fn());
|
||||
const expired = authority.issueChallenge(first, hello(), BINDING, 1_000);
|
||||
expect(expired).not.toBeNull();
|
||||
expect(
|
||||
authority.completeChallenge(
|
||||
first,
|
||||
{
|
||||
type: "auth.response",
|
||||
v: 2,
|
||||
sessionId: expired!.sessionId,
|
||||
clientProof: createRelayProof(KEY, "client", expired!),
|
||||
},
|
||||
11_001,
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
const second = {};
|
||||
authority.registerPendingConnection(second, vi.fn());
|
||||
const challenge = authority.issueChallenge(
|
||||
second,
|
||||
hello("REREREREREREREREREREREREREREREREREREREREREQ"),
|
||||
BINDING,
|
||||
20_000,
|
||||
);
|
||||
expect(challenge).not.toBeNull();
|
||||
expect(
|
||||
authority.completeChallenge(
|
||||
second,
|
||||
{
|
||||
type: "auth.response",
|
||||
v: 2,
|
||||
sessionId: challenge!.sessionId,
|
||||
clientProof: "A".repeat(43),
|
||||
},
|
||||
20_001,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("invalidates pending and authenticated connections exactly once on rotation", () => {
|
||||
invalidateBrowserRelayAuthV2Authority();
|
||||
const pendingInvalidated = vi.fn();
|
||||
const authenticatedInvalidated = vi.fn();
|
||||
const pending = {};
|
||||
const authenticated = {};
|
||||
const first = getBrowserRelayAuthV2Authority(KEY);
|
||||
first.registerPendingConnection(pending, pendingInvalidated);
|
||||
first.registerAuthenticatedConnection(authenticated, authenticatedInvalidated);
|
||||
expect(first.issueChallenge(pending, hello(), BINDING, 1_000)).not.toBeNull();
|
||||
const rotated = getBrowserRelayAuthV2Authority("f".repeat(64));
|
||||
expect(rotated).not.toBe(first);
|
||||
expect(pendingInvalidated).toHaveBeenCalledOnce();
|
||||
expect(authenticatedInvalidated).toHaveBeenCalledOnce();
|
||||
expect(first.issueChallenge(pending, hello(), BINDING, 1_001)).toBeNull();
|
||||
first.dispose();
|
||||
expect(pendingInvalidated).toHaveBeenCalledOnce();
|
||||
expect(authenticatedInvalidated).toHaveBeenCalledOnce();
|
||||
invalidateBrowserRelayAuthV2Authority();
|
||||
});
|
||||
|
||||
it("keeps pending admission independent from authenticated capacity", () => {
|
||||
const authority = new BrowserRelayAuthV2Authority(KEY);
|
||||
const pendingInvalidators = Array.from({ length: 128 }, () => vi.fn());
|
||||
for (const invalidate of pendingInvalidators) {
|
||||
expect(authority.registerPendingConnection({}, invalidate)).toBe(true);
|
||||
}
|
||||
expect(authority.registerPendingConnection({}, vi.fn())).toBe(false);
|
||||
|
||||
const active = {};
|
||||
const activeInvalidated = vi.fn();
|
||||
expect(authority.registerAuthenticatedConnection(active, activeInvalidated)).toBe(true);
|
||||
expect(activeInvalidated).not.toHaveBeenCalled();
|
||||
expect(pendingInvalidators.every((invalidate) => !invalidate.mock.calls.length)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects promotion at active capacity without disturbing active connections", () => {
|
||||
const authority = new BrowserRelayAuthV2Authority(KEY);
|
||||
const pending = {};
|
||||
const pendingInvalidated = vi.fn();
|
||||
expect(authority.registerPendingConnection(pending, pendingInvalidated)).toBe(true);
|
||||
const challenge = authority.issueChallenge(pending, hello(), BINDING, 1_000);
|
||||
expect(challenge).not.toBeNull();
|
||||
|
||||
const activeInvalidators = Array.from({ length: 128 }, () => vi.fn());
|
||||
const activeBindings = activeInvalidators.map(() => ({}));
|
||||
for (const [index, binding] of activeBindings.entries()) {
|
||||
expect(authority.registerAuthenticatedConnection(binding, activeInvalidators[index]!)).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
authority.completeChallenge(
|
||||
pending,
|
||||
{
|
||||
type: "auth.response",
|
||||
v: 2,
|
||||
sessionId: challenge!.sessionId,
|
||||
clientProof: createRelayProof(KEY, "client", challenge!),
|
||||
},
|
||||
1_001,
|
||||
),
|
||||
).toBeNull();
|
||||
expect(pendingInvalidated).not.toHaveBeenCalled();
|
||||
expect(activeInvalidators.every((invalidate) => !invalidate.mock.calls.length)).toBe(true);
|
||||
|
||||
authority.releaseConnection(activeBindings[0]!);
|
||||
expect(authority.registerAuthenticatedConnection({}, vi.fn())).toBe(true);
|
||||
authority.releaseConnection(pending);
|
||||
expect(authority.issueChallenge(pending, hello(), BINDING, 1_002)).toBeNull();
|
||||
});
|
||||
|
||||
it("cleans failed and expired pending proofs without affecting active connections", () => {
|
||||
const authority = new BrowserRelayAuthV2Authority(KEY);
|
||||
const active = {};
|
||||
const activeInvalidated = vi.fn();
|
||||
authority.registerAuthenticatedConnection(active, activeInvalidated);
|
||||
|
||||
const failed = {};
|
||||
authority.registerPendingConnection(failed, vi.fn());
|
||||
const failedChallenge = authority.issueChallenge(failed, hello(), BINDING, 1_000)!;
|
||||
expect(
|
||||
authority.completeChallenge(
|
||||
failed,
|
||||
{
|
||||
type: "auth.response",
|
||||
v: 2,
|
||||
sessionId: failedChallenge.sessionId,
|
||||
clientProof: "A".repeat(43),
|
||||
},
|
||||
1_001,
|
||||
),
|
||||
).toBeNull();
|
||||
authority.releaseConnection(failed);
|
||||
|
||||
const expired = {};
|
||||
authority.registerPendingConnection(expired, vi.fn());
|
||||
const expiredChallenge = authority.issueChallenge(
|
||||
expired,
|
||||
hello("REREREREREREREREREREREREREREREREREREREREREQ"),
|
||||
BINDING,
|
||||
2_000,
|
||||
)!;
|
||||
expect(
|
||||
authority.completeChallenge(
|
||||
expired,
|
||||
{
|
||||
type: "auth.response",
|
||||
v: 2,
|
||||
sessionId: expiredChallenge.sessionId,
|
||||
clientProof: createRelayProof(KEY, "client", expiredChallenge),
|
||||
},
|
||||
12_001,
|
||||
),
|
||||
).toBeNull();
|
||||
authority.releaseConnection(expired);
|
||||
expect(activeInvalidated).not.toHaveBeenCalled();
|
||||
expect(authority.registerPendingConnection({}, vi.fn())).toBe(true);
|
||||
});
|
||||
|
||||
it("fails closed at the bounded replay-cache limit", () => {
|
||||
const authority = new BrowserRelayAuthV2Authority(KEY);
|
||||
for (let index = 0; index < 1_024; index += 1) {
|
||||
const connection = {};
|
||||
authority.registerPendingConnection(connection, vi.fn());
|
||||
const nonce = Buffer.alloc(32);
|
||||
nonce.writeUInt32BE(index, 28);
|
||||
expect(
|
||||
authority.issueChallenge(connection, hello(nonce.toString("base64url")), BINDING, 1_000),
|
||||
).not.toBeNull();
|
||||
authority.releaseConnection(connection);
|
||||
}
|
||||
const overflow = {};
|
||||
authority.registerPendingConnection(overflow, vi.fn());
|
||||
expect(
|
||||
authority.issueChallenge(
|
||||
overflow,
|
||||
hello(Buffer.alloc(32, 0xff).toString("base64url")),
|
||||
BINDING,
|
||||
1_000,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser relay auth v2 wire validation", () => {
|
||||
it("accepts only exact hello and response shapes", () => {
|
||||
expect(parseRelayAuthHello(hello())).toEqual(hello());
|
||||
expect(parseRelayAuthHello({ ...hello(), extra: true })).toBeNull();
|
||||
expect(parseRelayAuthHello({ ...hello(), v: 1 })).toBeNull();
|
||||
expect(
|
||||
parseRelayAuthResponse({
|
||||
type: "auth.response",
|
||||
v: 2,
|
||||
sessionId: "EREREREREREREREREREREQ",
|
||||
clientProof: "A".repeat(43),
|
||||
}),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("canonicalizes only the exact path and optional profile query", () => {
|
||||
expect(parseExtensionRelayResource("/extension", "/extension")).toBe("/extension");
|
||||
expect(parseExtensionRelayResource("/extension?profile=chrome", "/extension")).toBe(
|
||||
"/extension?profile=chrome",
|
||||
);
|
||||
expect(parseExtensionRelayResource("/extension?x=1", "/extension")).toBeNull();
|
||||
expect(parseExtensionRelayResource("/extension?profile=a&profile=b", "/extension")).toBeNull();
|
||||
expect(parseExtensionRelayResource("/other", "/extension")).toBeNull();
|
||||
});
|
||||
|
||||
it("detects duplicate security fields before JSON parsing", () => {
|
||||
expect(parseStrictJsonObject('{"v":2,"v":1}')).toBeNull();
|
||||
expect(parseStrictJsonObject('{"outer":{"v":2,"v":1}}')).toBeNull();
|
||||
expect(parseStrictJsonObject('{"a":1,"nested":{"a":2}}')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,446 @@
|
||||
import {
|
||||
BROWSER_RELAY_AUTH_VERSION,
|
||||
createRelayProof,
|
||||
isBase64UrlText,
|
||||
isCanonicalBase64UrlBytes,
|
||||
randomRelayId,
|
||||
randomRelayNonce,
|
||||
relayKeyIdFromHex,
|
||||
verifyRelayProof,
|
||||
type BrowserRelayAuthChallenge,
|
||||
type BrowserRelayAuthOk,
|
||||
type BrowserRelayProofFields,
|
||||
} from "./auth-v2-crypto.js";
|
||||
|
||||
export const BROWSER_RELAY_EXTENSION_SUBPROTOCOL = "openclaw-extension-relay.v2";
|
||||
export const BROWSER_RELAY_AUTH_CHALLENGE_PATH = "/_openclaw/relay/auth/v2/challenge";
|
||||
export const BROWSER_RELAY_AUTH_COMPLETE_PATH = "/_openclaw/relay/auth/v2/complete";
|
||||
export const BROWSER_RELAY_CHALLENGE_TTL_MS = 10_000;
|
||||
|
||||
const MAX_PENDING_AUTH_CONNECTIONS = 128;
|
||||
const MAX_AUTHENTICATED_CONNECTIONS = 128;
|
||||
const MAX_REPLAY_ENTRIES = 1_024;
|
||||
|
||||
type BrowserRelayAuthHello = {
|
||||
type: "auth.hello";
|
||||
v: 2;
|
||||
keyId: string;
|
||||
clientNonce: string;
|
||||
};
|
||||
|
||||
type BrowserRelayAuthResponse = {
|
||||
type: "auth.response";
|
||||
v: 2;
|
||||
sessionId: string;
|
||||
clientProof: string;
|
||||
};
|
||||
|
||||
type BrowserRelayHttpChallengeRequest = {
|
||||
v: 2;
|
||||
keyId: string;
|
||||
clientNonce: string;
|
||||
role: "cdp";
|
||||
transport: "connection";
|
||||
method: "SEQUENCE" | "GET";
|
||||
resource: "/json/version -> /cdp" | "/json/list";
|
||||
flow: "cdp" | "json-list";
|
||||
};
|
||||
|
||||
type BrowserRelayHttpCompleteRequest = {
|
||||
v: 2;
|
||||
sessionId: string;
|
||||
clientProof: string;
|
||||
};
|
||||
|
||||
type BrowserRelayBinding = Pick<
|
||||
BrowserRelayProofFields,
|
||||
"role" | "transport" | "method" | "resource" | "flow"
|
||||
>;
|
||||
|
||||
type ChallengeState = {
|
||||
binding: object;
|
||||
fields: BrowserRelayProofFields;
|
||||
};
|
||||
|
||||
function hasExactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
|
||||
const actual = Object.keys(value).toSorted();
|
||||
const expected = [...keys].toSorted();
|
||||
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
||||
}
|
||||
|
||||
export function parseRelayAuthHello(value: unknown): BrowserRelayAuthHello | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
!hasExactKeys(record, ["type", "v", "keyId", "clientNonce"]) ||
|
||||
record.type !== "auth.hello" ||
|
||||
record.v !== BROWSER_RELAY_AUTH_VERSION ||
|
||||
typeof record.keyId !== "string" ||
|
||||
record.keyId.length !== 22 ||
|
||||
!isBase64UrlText(record.keyId) ||
|
||||
!isCanonicalBase64UrlBytes(record.clientNonce, 32)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return record as BrowserRelayAuthHello;
|
||||
}
|
||||
|
||||
export function parseRelayAuthResponse(value: unknown): BrowserRelayAuthResponse | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
!hasExactKeys(record, ["type", "v", "sessionId", "clientProof"]) ||
|
||||
record.type !== "auth.response" ||
|
||||
record.v !== BROWSER_RELAY_AUTH_VERSION ||
|
||||
!isCanonicalBase64UrlBytes(record.sessionId, 16) ||
|
||||
!isCanonicalBase64UrlBytes(record.clientProof, 32)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return record as BrowserRelayAuthResponse;
|
||||
}
|
||||
|
||||
export function parseRelayHttpChallengeRequest(
|
||||
value: unknown,
|
||||
): BrowserRelayHttpChallengeRequest | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
!hasExactKeys(record, [
|
||||
"v",
|
||||
"keyId",
|
||||
"clientNonce",
|
||||
"role",
|
||||
"transport",
|
||||
"method",
|
||||
"resource",
|
||||
"flow",
|
||||
]) ||
|
||||
record.v !== BROWSER_RELAY_AUTH_VERSION ||
|
||||
typeof record.keyId !== "string" ||
|
||||
record.keyId.length !== 22 ||
|
||||
!isBase64UrlText(record.keyId) ||
|
||||
!isCanonicalBase64UrlBytes(record.clientNonce, 32) ||
|
||||
record.role !== "cdp" ||
|
||||
record.transport !== "connection" ||
|
||||
!(
|
||||
(record.flow === "cdp" &&
|
||||
record.method === "SEQUENCE" &&
|
||||
record.resource === "/json/version -> /cdp") ||
|
||||
(record.flow === "json-list" && record.method === "GET" && record.resource === "/json/list")
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return record as BrowserRelayHttpChallengeRequest;
|
||||
}
|
||||
|
||||
export function parseRelayHttpCompleteRequest(
|
||||
value: unknown,
|
||||
): BrowserRelayHttpCompleteRequest | null {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
!hasExactKeys(record, ["v", "sessionId", "clientProof"]) ||
|
||||
record.v !== BROWSER_RELAY_AUTH_VERSION ||
|
||||
!isCanonicalBase64UrlBytes(record.sessionId, 16) ||
|
||||
!isCanonicalBase64UrlBytes(record.clientProof, 32)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return record as BrowserRelayHttpCompleteRequest;
|
||||
}
|
||||
|
||||
export function parseExtensionRelayResource(rawUrl: string, expectedPath: string): string | null {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl, "http://127.0.0.1");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.pathname !== expectedPath || url.hash) {
|
||||
return null;
|
||||
}
|
||||
const entries = [...url.searchParams.entries()];
|
||||
if (entries.some(([key]) => key !== "profile") || url.searchParams.getAll("profile").length > 1) {
|
||||
return null;
|
||||
}
|
||||
const profile = url.searchParams.get("profile");
|
||||
if (profile !== null && !/^[a-z0-9-]+$/u.test(profile)) {
|
||||
return null;
|
||||
}
|
||||
return profile === null ? expectedPath : `${expectedPath}?profile=${encodeURIComponent(profile)}`;
|
||||
}
|
||||
|
||||
/** Reject duplicate object keys before JSON.parse can silently keep the last value. */
|
||||
function hasDuplicateJsonObjectKeys(text: string): boolean {
|
||||
const stack: Array<Set<string> | null> = [];
|
||||
let expectingKey = false;
|
||||
let index = 0;
|
||||
const skipWhitespace = () => {
|
||||
while (/\s/u.test(text[index] ?? "")) {
|
||||
index += 1;
|
||||
}
|
||||
};
|
||||
while (index < text.length) {
|
||||
const char = text[index];
|
||||
if (char === '"') {
|
||||
const start = index;
|
||||
index += 1;
|
||||
let escaped = false;
|
||||
while (index < text.length) {
|
||||
const next = text[index++];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (next === "\\") {
|
||||
escaped = true;
|
||||
} else if (next === '"') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (expectingKey && stack.at(-1)) {
|
||||
let key: unknown;
|
||||
try {
|
||||
key = JSON.parse(text.slice(start, index));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
skipWhitespace();
|
||||
if (text[index] === ":" && typeof key === "string") {
|
||||
const keys = stack.at(-1) as Set<string>;
|
||||
if (keys.has(key)) {
|
||||
return true;
|
||||
}
|
||||
keys.add(key);
|
||||
expectingKey = false;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === "{") {
|
||||
stack.push(new Set());
|
||||
expectingKey = true;
|
||||
} else if (char === "[") {
|
||||
stack.push(null);
|
||||
expectingKey = false;
|
||||
} else if (char === "}") {
|
||||
stack.pop();
|
||||
expectingKey = false;
|
||||
} else if (char === "]") {
|
||||
stack.pop();
|
||||
expectingKey = false;
|
||||
} else if (char === ",") {
|
||||
expectingKey = stack.at(-1) instanceof Set;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function parseStrictJsonObject(text: string): Record<string, unknown> | null {
|
||||
if (hasDuplicateJsonObjectKeys(text)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class BoundedReplayCache {
|
||||
private readonly entries = new Map<string, number>();
|
||||
|
||||
reserve(key: string, expiresAtMs: number, nowMs: number): boolean {
|
||||
for (const [candidate, expiry] of this.entries) {
|
||||
if (expiry < nowMs) {
|
||||
this.entries.delete(candidate);
|
||||
}
|
||||
}
|
||||
if (this.entries.has(key) || this.entries.size >= MAX_REPLAY_ENTRIES) {
|
||||
return false;
|
||||
}
|
||||
this.entries.set(key, expiresAtMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.entries.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowserRelayAuthV2Authority {
|
||||
readonly keyId: string;
|
||||
readonly instanceId = randomRelayId();
|
||||
private readonly challenges = new Map<string, ChallengeState>();
|
||||
private readonly pendingConnections = new Map<object, () => void>();
|
||||
private readonly authenticatedConnections = new Map<object, () => void>();
|
||||
private readonly replay = new BoundedReplayCache();
|
||||
private disposed = false;
|
||||
|
||||
constructor(private readonly keyHex: string) {
|
||||
this.keyId = relayKeyIdFromHex(keyHex);
|
||||
}
|
||||
|
||||
registerPendingConnection(binding: object, onInvalidate: () => void): boolean {
|
||||
if (
|
||||
this.disposed ||
|
||||
this.pendingConnections.has(binding) ||
|
||||
this.authenticatedConnections.has(binding) ||
|
||||
this.pendingConnections.size >= MAX_PENDING_AUTH_CONNECTIONS
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.pendingConnections.set(binding, onInvalidate);
|
||||
return true;
|
||||
}
|
||||
|
||||
registerAuthenticatedConnection(binding: object, onInvalidate: () => void): boolean {
|
||||
if (
|
||||
this.disposed ||
|
||||
this.pendingConnections.has(binding) ||
|
||||
this.authenticatedConnections.has(binding) ||
|
||||
this.authenticatedConnections.size >= MAX_AUTHENTICATED_CONNECTIONS
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
this.authenticatedConnections.set(binding, onInvalidate);
|
||||
return true;
|
||||
}
|
||||
|
||||
releaseConnection(binding: object): void {
|
||||
this.pendingConnections.delete(binding);
|
||||
this.authenticatedConnections.delete(binding);
|
||||
for (const [sessionId, challenge] of this.challenges) {
|
||||
if (challenge.binding === binding) {
|
||||
this.challenges.delete(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
issueChallenge(
|
||||
binding: object,
|
||||
hello: BrowserRelayAuthHello,
|
||||
expected: BrowserRelayBinding,
|
||||
nowMs = Date.now(),
|
||||
): BrowserRelayAuthChallenge | null {
|
||||
if (
|
||||
this.disposed ||
|
||||
!this.pendingConnections.has(binding) ||
|
||||
hello.keyId !== this.keyId ||
|
||||
this.challenges.size >= MAX_PENDING_AUTH_CONNECTIONS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const expiresAtMs = nowMs + BROWSER_RELAY_CHALLENGE_TTL_MS;
|
||||
if (!this.replay.reserve(`${this.keyId}:${hello.clientNonce}`, expiresAtMs, nowMs)) {
|
||||
return null;
|
||||
}
|
||||
const fields: BrowserRelayProofFields = {
|
||||
keyId: this.keyId,
|
||||
instanceId: this.instanceId,
|
||||
sessionId: randomRelayId(),
|
||||
clientNonce: hello.clientNonce,
|
||||
serverNonce: randomRelayNonce(),
|
||||
issuedAtMs: nowMs,
|
||||
expiresAtMs,
|
||||
...expected,
|
||||
};
|
||||
this.challenges.set(fields.sessionId, { binding, fields });
|
||||
return {
|
||||
type: "auth.challenge",
|
||||
v: BROWSER_RELAY_AUTH_VERSION,
|
||||
...fields,
|
||||
serverProof: createRelayProof(this.keyHex, "server", fields),
|
||||
};
|
||||
}
|
||||
|
||||
completeChallenge(
|
||||
binding: object,
|
||||
response: BrowserRelayAuthResponse,
|
||||
nowMs = Date.now(),
|
||||
): { ok: BrowserRelayAuthOk; fields: BrowserRelayProofFields } | null {
|
||||
const challenge = this.challenges.get(response.sessionId);
|
||||
if (!challenge || challenge.binding !== binding || this.disposed) {
|
||||
return null;
|
||||
}
|
||||
// The exact socket owns one attempt. Consume before proof verification so
|
||||
// duplicate completion cannot race a successful verification.
|
||||
this.challenges.delete(response.sessionId);
|
||||
if (
|
||||
nowMs > challenge.fields.expiresAtMs ||
|
||||
!verifyRelayProof(this.keyHex, "client", challenge.fields, response.clientProof)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const invalidate = this.pendingConnections.get(binding);
|
||||
if (!invalidate || this.authenticatedConnections.size >= MAX_AUTHENTICATED_CONNECTIONS) {
|
||||
return null;
|
||||
}
|
||||
// Promotion is synchronous and moves the exact binding between disjoint
|
||||
// registries, so pending admission can never consume active capacity.
|
||||
this.pendingConnections.delete(binding);
|
||||
this.authenticatedConnections.set(binding, invalidate);
|
||||
return {
|
||||
fields: challenge.fields,
|
||||
ok: {
|
||||
type: "auth.ok",
|
||||
v: BROWSER_RELAY_AUTH_VERSION,
|
||||
sessionId: challenge.fields.sessionId,
|
||||
acceptProof: createRelayProof(
|
||||
this.keyHex,
|
||||
"accept",
|
||||
challenge.fields,
|
||||
response.clientProof,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.disposed = true;
|
||||
const invalidators = [
|
||||
...this.pendingConnections.values(),
|
||||
...this.authenticatedConnections.values(),
|
||||
];
|
||||
this.pendingConnections.clear();
|
||||
this.authenticatedConnections.clear();
|
||||
this.challenges.clear();
|
||||
this.replay.clear();
|
||||
for (const invalidate of invalidators) {
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let activeAuthority: { keyHex: string; authority: BrowserRelayAuthV2Authority } | null = null;
|
||||
|
||||
/** One process-wide replay/session authority for the current host key. */
|
||||
export function getBrowserRelayAuthV2Authority(keyHex: string): BrowserRelayAuthV2Authority {
|
||||
if (activeAuthority?.keyHex === keyHex) {
|
||||
return activeAuthority.authority;
|
||||
}
|
||||
activeAuthority?.authority.dispose();
|
||||
const authority = new BrowserRelayAuthV2Authority(keyHex);
|
||||
activeAuthority = { keyHex, authority };
|
||||
return authority;
|
||||
}
|
||||
|
||||
export function invalidateBrowserRelayAuthV2Authority(): void {
|
||||
activeAuthority?.authority.dispose();
|
||||
activeAuthority = null;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { EventEmitter, once } from "node:events";
|
||||
// Gateway extension relay upgrade handler: auth + routing decisions.
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import http, { type IncomingMessage } from "node:http";
|
||||
import net from "node:net";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -21,12 +23,22 @@ vi.mock("../config.js", () => ({
|
||||
resolveProfile: (...args: unknown[]) => resolveProfileMock(...args),
|
||||
}));
|
||||
|
||||
const configState = vi.hoisted(() => ({ allowLegacyAuth: true }));
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
getRuntimeConfig: () => ({
|
||||
browser: { extensionRelay: { allowLegacyAuth: configState.allowLegacyAuth } },
|
||||
}),
|
||||
}));
|
||||
|
||||
const attachExtensionWebSocketMock = vi.fn();
|
||||
const authenticateExtensionWebSocketMock = vi.fn();
|
||||
vi.mock("./relay-server.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./relay-server.js")>();
|
||||
return {
|
||||
...actual,
|
||||
attachExtensionWebSocket: (...args: unknown[]) => attachExtensionWebSocketMock(...args),
|
||||
authenticateExtensionWebSocket: (...args: unknown[]) =>
|
||||
authenticateExtensionWebSocketMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -39,7 +51,11 @@ vi.mock("./relay-auth.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { handleGatewayExtensionUpgrade } from "./gateway-relay-route.js";
|
||||
import { invalidateBrowserRelayAuthV2Authority } from "./auth-v2.js";
|
||||
import {
|
||||
disposeGatewayExtensionRelay,
|
||||
handleGatewayExtensionUpgrade,
|
||||
} from "./gateway-relay-route.js";
|
||||
|
||||
const TOKEN = "a".repeat(64);
|
||||
const ROTATED_TOKEN = "b".repeat(64);
|
||||
@@ -47,7 +63,7 @@ const ROTATED_TOKEN = "b".repeat(64);
|
||||
function fakeSocket() {
|
||||
const writes: string[] = [];
|
||||
let destroyed = false;
|
||||
const socket = {
|
||||
const socket = Object.assign(new EventEmitter(), {
|
||||
write: (chunk: string) => {
|
||||
writes.push(chunk);
|
||||
return true;
|
||||
@@ -55,7 +71,7 @@ function fakeSocket() {
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
},
|
||||
} as unknown as Duplex;
|
||||
}) as unknown as Duplex;
|
||||
return { socket, writes, isDestroyed: () => destroyed };
|
||||
}
|
||||
|
||||
@@ -74,6 +90,10 @@ function relayReq(
|
||||
});
|
||||
}
|
||||
|
||||
function v2Req(url = "/browser/extension"): IncomingMessage {
|
||||
return req(url, { "sec-websocket-protocol": "openclaw-extension-relay.v2" });
|
||||
}
|
||||
|
||||
function stateWithExtensionProfile() {
|
||||
return {
|
||||
resolved: {
|
||||
@@ -84,14 +104,31 @@ function stateWithExtensionProfile() {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
configState.allowLegacyAuth = true;
|
||||
readExtensionRelayTokenMock.mockReturnValue(TOKEN);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
disposeGatewayExtensionRelay();
|
||||
invalidateBrowserRelayAuthV2Authority();
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function oversizedMaskedTextFrame(): Buffer {
|
||||
const payload = Buffer.alloc(18 * 1024, 0x20);
|
||||
const header = Buffer.alloc(8);
|
||||
header[0] = 0x81;
|
||||
header[1] = 0x80 | 126;
|
||||
header.writeUInt16BE(payload.length, 2);
|
||||
const mask = Buffer.from([0x12, 0x34, 0x56, 0x78]);
|
||||
mask.copy(header, 4);
|
||||
for (let index = 0; index < payload.length; index += 1) {
|
||||
payload[index] = payload[index]! ^ mask[index % 4]!;
|
||||
}
|
||||
return Buffer.concat([header, payload]);
|
||||
}
|
||||
|
||||
// Default: the requested profile resolves to a valid extension profile.
|
||||
function primeProfile() {
|
||||
resolveProfileMock.mockReturnValue({ name: "chrome", driver: "extension" });
|
||||
@@ -99,11 +136,18 @@ function primeProfile() {
|
||||
|
||||
async function mockSuccessfulUpgrade() {
|
||||
const wsMod = await import("ws");
|
||||
const ws = Object.assign(new EventEmitter(), {
|
||||
readyState: 1,
|
||||
close: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
send: vi.fn(),
|
||||
});
|
||||
vi.spyOn(wsMod.WebSocketServer.prototype, "handleUpgrade").mockImplementation(
|
||||
(_req, _socket, _head, cb) => {
|
||||
(cb as (ws: unknown) => void)({ readyState: 1 });
|
||||
(cb as (socket: unknown) => void)(ws);
|
||||
},
|
||||
);
|
||||
return ws;
|
||||
}
|
||||
|
||||
describe("handleGatewayExtensionUpgrade", () => {
|
||||
@@ -173,7 +217,7 @@ describe("handleGatewayExtensionUpgrade", () => {
|
||||
socket,
|
||||
Buffer.alloc(0),
|
||||
);
|
||||
expect(writes.join("")).toContain("401");
|
||||
expect(writes.join("")).toContain("400");
|
||||
expect(ensureExtensionRelayForProfileMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -196,7 +240,135 @@ describe("handleGatewayExtensionUpgrade", () => {
|
||||
expect(handled).toBe(true);
|
||||
expect(readExtensionRelayTokenMock).toHaveBeenCalledOnce();
|
||||
expect(startBrowserControlServiceFromConfigMock).toHaveBeenCalledOnce();
|
||||
expect(attachExtensionWebSocketMock).toHaveBeenCalledWith(bridge, { readyState: 1 });
|
||||
expect(attachExtensionWebSocketMock).toHaveBeenCalledWith(
|
||||
bridge,
|
||||
expect.objectContaining({ readyState: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not lazy-start or attach v2 before the in-band client proof succeeds", async () => {
|
||||
getBrowserControlStateMock.mockReturnValue(null);
|
||||
startBrowserControlServiceFromConfigMock.mockResolvedValue(stateWithExtensionProfile());
|
||||
primeProfile();
|
||||
const bridge = { id: "v2-bridge" };
|
||||
ensureExtensionRelayForProfileMock.mockResolvedValue({ bridge });
|
||||
await mockSuccessfulUpgrade();
|
||||
|
||||
const handled = await handleGatewayExtensionUpgrade(
|
||||
v2Req(),
|
||||
fakeSocket().socket,
|
||||
Buffer.alloc(0),
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(authenticateExtensionWebSocketMock).toHaveBeenCalledOnce();
|
||||
expect(startBrowserControlServiceFromConfigMock).not.toHaveBeenCalled();
|
||||
expect(ensureExtensionRelayForProfileMock).not.toHaveBeenCalled();
|
||||
expect(attachExtensionWebSocketMock).not.toHaveBeenCalled();
|
||||
|
||||
const authParams = authenticateExtensionWebSocketMock.mock.calls[0]?.[0] as {
|
||||
prepareAuthenticated: () => Promise<() => void>;
|
||||
resource: string;
|
||||
};
|
||||
expect(authParams.resource).toBe("/browser/extension");
|
||||
const attach = await authParams.prepareAuthenticated();
|
||||
expect(startBrowserControlServiceFromConfigMock).toHaveBeenCalledOnce();
|
||||
expect(ensureExtensionRelayForProfileMock).toHaveBeenCalledOnce();
|
||||
expect(attachExtensionWebSocketMock).not.toHaveBeenCalled();
|
||||
attach();
|
||||
expect(attachExtensionWebSocketMock).toHaveBeenCalledWith(
|
||||
bridge,
|
||||
expect.objectContaining({ readyState: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects oversized direct-Gateway upgrade-head data before ws auth or lazy startup", async () => {
|
||||
const server = http.createServer();
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
void handleGatewayExtensionUpgrade(request, socket, head);
|
||||
});
|
||||
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("expected direct-Gateway test port");
|
||||
}
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port: address.port });
|
||||
socket.on("error", () => {});
|
||||
await once(socket, "connect");
|
||||
const response: Buffer[] = [];
|
||||
socket.on("data", (chunk) => response.push(Buffer.from(chunk)));
|
||||
const closed = once(socket, "close");
|
||||
const request = Buffer.from(
|
||||
[
|
||||
"GET /browser/extension HTTP/1.1",
|
||||
"Host: 127.0.0.1",
|
||||
"Connection: Upgrade",
|
||||
"Upgrade: websocket",
|
||||
"Sec-WebSocket-Version: 13",
|
||||
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
|
||||
"Sec-WebSocket-Protocol: openclaw-extension-relay.v2",
|
||||
"Origin: chrome-extension://relay-auth-v2-test",
|
||||
"",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
);
|
||||
socket.write(Buffer.concat([request, oversizedMaskedTextFrame()]));
|
||||
await closed;
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
|
||||
const text = Buffer.concat(response).toString("utf8");
|
||||
expect(text).not.toContain("auth.challenge");
|
||||
expect(text).not.toContain("auth.ok");
|
||||
expect(authenticateExtensionWebSocketMock).not.toHaveBeenCalled();
|
||||
expect(startBrowserControlServiceFromConfigMock).not.toHaveBeenCalled();
|
||||
expect(ensureExtensionRelayForProfileMock).not.toHaveBeenCalled();
|
||||
expect(attachExtensionWebSocketMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("binds v2 to the exact profile resource and refuses mixed-protocol downgrade", async () => {
|
||||
await mockSuccessfulUpgrade();
|
||||
const valid = fakeSocket();
|
||||
await handleGatewayExtensionUpgrade(
|
||||
v2Req("/browser/extension?profile=chrome"),
|
||||
valid.socket,
|
||||
Buffer.alloc(0),
|
||||
);
|
||||
expect(authenticateExtensionWebSocketMock.mock.calls[0]?.[0]).toMatchObject({
|
||||
resource: "/browser/extension?profile=chrome",
|
||||
});
|
||||
|
||||
const duplicate = fakeSocket();
|
||||
await handleGatewayExtensionUpgrade(
|
||||
v2Req("/browser/extension?profile=chrome&profile=other"),
|
||||
duplicate.socket,
|
||||
Buffer.alloc(0),
|
||||
);
|
||||
expect(duplicate.writes.join("")).toContain("400");
|
||||
|
||||
const mixed = fakeSocket();
|
||||
await handleGatewayExtensionUpgrade(
|
||||
req("/browser/extension", {
|
||||
"sec-websocket-protocol": `openclaw-extension-relay.v2, openclaw-extension-relay, openclaw-extension-token.${TOKEN}`,
|
||||
}),
|
||||
mixed.socket,
|
||||
Buffer.alloc(0),
|
||||
);
|
||||
expect(mixed.writes.join("")).toContain("400");
|
||||
});
|
||||
|
||||
it("accepts legacy only while the explicit migration gate is enabled", async () => {
|
||||
configState.allowLegacyAuth = false;
|
||||
const denied = fakeSocket();
|
||||
await handleGatewayExtensionUpgrade(
|
||||
relayReq("/browser/extension"),
|
||||
denied.socket,
|
||||
Buffer.alloc(0),
|
||||
);
|
||||
expect(denied.writes.join("")).toContain("401");
|
||||
expect(getBrowserControlStateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches the socket to the bridge on a valid token", async () => {
|
||||
@@ -214,7 +386,10 @@ describe("handleGatewayExtensionUpgrade", () => {
|
||||
);
|
||||
expect(handled).toBe(true);
|
||||
expect(ensureExtensionRelayForProfileMock).toHaveBeenCalledOnce();
|
||||
expect(attachExtensionWebSocketMock).toHaveBeenCalledWith(bridge, { readyState: 1 });
|
||||
expect(attachExtensionWebSocketMock).toHaveBeenCalledWith(
|
||||
bridge,
|
||||
expect.objectContaining({ readyState: 1 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("authenticates against the live relay secret when Browser state is stale", async () => {
|
||||
@@ -243,6 +418,9 @@ describe("handleGatewayExtensionUpgrade", () => {
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(ensureExtensionRelayForProfileMock).toHaveBeenCalledOnce();
|
||||
expect(attachExtensionWebSocketMock).toHaveBeenCalledWith(bridge, { readyState: 1 });
|
||||
expect(attachExtensionWebSocketMock).toHaveBeenCalledWith(
|
||||
bridge,
|
||||
expect.objectContaining({ readyState: 1 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,44 +1,39 @@
|
||||
/**
|
||||
* Gateway-hosted extension relay upgrade handler.
|
||||
*
|
||||
* Lets the OpenClaw Chrome extension connect DIRECTLY to a remote gateway over
|
||||
* `wss://` — no OpenClaw node host on the browser machine. This is the
|
||||
* cross-machine path for #53599: a user installs only the extension and pastes
|
||||
* a `wss://gateway/browser/extension#<secret>` pairing string.
|
||||
*
|
||||
* The gateway route is registered with `auth: "plugin"` and no nodeCapability,
|
||||
* so the gateway does NOT pre-enforce gateway-token auth (browser WebSockets
|
||||
* cannot send an Authorization header anyway). This handler self-validates the
|
||||
* host-local relay secret from the WebSocket subprotocol list, then attaches
|
||||
* the socket to the same ExtensionRelayBridge the loopback relay uses — so all
|
||||
* CDP synthesis, tab-group scoping, and the in-process Playwright /cdp client
|
||||
* are unchanged.
|
||||
*/
|
||||
/** Direct Gateway extension relay with in-band Browser Relay Authentication v2. */
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { getRuntimeConfig } from "../../config/config.js";
|
||||
import {
|
||||
getBrowserControlState,
|
||||
startBrowserControlServiceFromConfig,
|
||||
} from "../../control-service.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { resolveProfile } from "../config.js";
|
||||
import {
|
||||
BROWSER_RELAY_EXTENSION_SUBPROTOCOL,
|
||||
getBrowserRelayAuthV2Authority,
|
||||
invalidateBrowserRelayAuthV2Authority,
|
||||
parseExtensionRelayResource,
|
||||
} from "./auth-v2.js";
|
||||
import { handlePreAuthWebSocketUpgrade } from "./preauth-websocket-guard.js";
|
||||
import { readExtensionRelayToken } from "./relay-auth.js";
|
||||
import { ensureExtensionRelayForProfile } from "./relay-lifecycle.js";
|
||||
import {
|
||||
attachExtensionWebSocket,
|
||||
EXTENSION_RELAY_MAX_PAYLOAD_BYTES,
|
||||
isAllowedExtensionOrigin,
|
||||
LEGACY_EXTENSION_RELAY_PROTOCOL,
|
||||
requestExtensionProtocolToken,
|
||||
requestProtocols,
|
||||
} from "./relay-request.js";
|
||||
import {
|
||||
attachExtensionWebSocket,
|
||||
authenticateExtensionWebSocket,
|
||||
EXTENSION_RELAY_MAX_PAYLOAD_BYTES,
|
||||
} from "./relay-server.js";
|
||||
|
||||
const log = createSubsystemLogger("browser").child("extension-relay-gateway");
|
||||
|
||||
/** Path the browser plugin registers on the gateway (ends in /extension so the pairing parser accepts it). */
|
||||
const GATEWAY_EXTENSION_RELAY_PATH = "/browser/extension";
|
||||
|
||||
// Single noServer WebSocketServer for all gateway-hosted extension upgrades.
|
||||
let wss: WebSocketServer | null = null;
|
||||
function getWss(): WebSocketServer {
|
||||
wss ??= new WebSocketServer({ noServer: true, maxPayload: EXTENSION_RELAY_MAX_PAYLOAD_BYTES });
|
||||
@@ -48,22 +43,15 @@ function getWss(): WebSocketServer {
|
||||
function destroy(socket: Duplex, statusLine: string): void {
|
||||
try {
|
||||
socket.write(`HTTP/1.1 ${statusLine}\r\nConnection: close\r\n\r\n`);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
} catch {
|
||||
// socket already gone
|
||||
}
|
||||
}
|
||||
|
||||
function requestedProfileName(req: IncomingMessage, fallback: string): string {
|
||||
try {
|
||||
const value = new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("profile");
|
||||
return value?.trim() || fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
function requestedProfileName(resource: string, fallback: string): string {
|
||||
return new URL(resource, "http://127.0.0.1").searchParams.get("profile") ?? fallback;
|
||||
}
|
||||
|
||||
/** First extension-driver profile name, defaulting to the built-in `chrome`. */
|
||||
function defaultExtensionProfileName(profiles: Record<string, { driver?: string }>): string {
|
||||
for (const [name, profile] of Object.entries(profiles)) {
|
||||
if (profile.driver === "extension") {
|
||||
@@ -73,77 +61,138 @@ function defaultExtensionProfileName(profiles: Record<string, { driver?: string
|
||||
return "chrome";
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a gateway upgrade for the extension relay path. Returns true when the
|
||||
* request was claimed (handled or rejected), false to let the gateway continue.
|
||||
*/
|
||||
async function resolveGatewayBridge(resource: string) {
|
||||
let state = getBrowserControlState();
|
||||
if (!state) {
|
||||
state = await startBrowserControlServiceFromConfig();
|
||||
if (!state) {
|
||||
throw new Error("Browser control is disabled");
|
||||
}
|
||||
}
|
||||
const profileName = requestedProfileName(
|
||||
resource,
|
||||
defaultExtensionProfileName(state.resolved.profiles),
|
||||
);
|
||||
const resolved = resolveProfile(state.resolved, profileName);
|
||||
if (!resolved || resolved.driver !== "extension") {
|
||||
throw new Error(`Extension browser profile "${profileName}" was not found`);
|
||||
}
|
||||
return {
|
||||
bridge: (await ensureExtensionRelayForProfile(state, resolved)).bridge,
|
||||
profileName,
|
||||
};
|
||||
}
|
||||
|
||||
/** Handle the plugin-owned Gateway upgrade path. */
|
||||
export async function handleGatewayExtensionUpgrade(
|
||||
req: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
): Promise<boolean> {
|
||||
const path = (req.url ?? "/").split("?")[0];
|
||||
if (path !== GATEWAY_EXTENSION_RELAY_PATH) {
|
||||
return false;
|
||||
const resource = parseExtensionRelayResource(req.url ?? "/", GATEWAY_EXTENSION_RELAY_PATH);
|
||||
if (!resource) {
|
||||
return (req.url ?? "/").split("?")[0] === GATEWAY_EXTENSION_RELAY_PATH
|
||||
? (destroy(socket, "400 Bad Request"), true)
|
||||
: false;
|
||||
}
|
||||
|
||||
// chrome-extension:// origin hygiene (not a security boundary on its own —
|
||||
// the relay secret is the gate — but rejects obvious cross-site sockets).
|
||||
if (!isAllowedExtensionOrigin(req)) {
|
||||
destroy(socket, "403 Forbidden");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Authenticate before lazy-starting Browser control. A valid pairing secret
|
||||
// may start the service; an arbitrary public WebSocket request may not.
|
||||
let state = getBrowserControlState();
|
||||
const expectedToken = readExtensionRelayToken();
|
||||
const candidate = requestExtensionProtocolToken(req);
|
||||
if (!expectedToken || candidate.length === 0 || !safeEqualSecret(expectedToken, candidate)) {
|
||||
const protocols = requestProtocols(req);
|
||||
const token = readExtensionRelayToken();
|
||||
if (!token) {
|
||||
invalidateBrowserRelayAuthV2Authority();
|
||||
destroy(socket, "401 Unauthorized");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
try {
|
||||
state = await startBrowserControlServiceFromConfig();
|
||||
} catch (err) {
|
||||
log.warn(`failed to start Browser control for extension relay: ${String(err)}`);
|
||||
if (protocols.length === 1 && protocols[0] === BROWSER_RELAY_EXTENSION_SUBPROTOCOL) {
|
||||
const authority = getBrowserRelayAuthV2Authority(token);
|
||||
if (
|
||||
!handlePreAuthWebSocketUpgrade({
|
||||
wss: getWss(),
|
||||
req,
|
||||
socket,
|
||||
head,
|
||||
onUpgrade: (ws, removePreAuthGuard) => {
|
||||
authenticateExtensionWebSocket({
|
||||
ws,
|
||||
authority,
|
||||
resource,
|
||||
removePreAuthGuard,
|
||||
prepareAuthenticated: async () => {
|
||||
// The proof may finish while an operator rotates the host key. Never
|
||||
// let an old authenticated socket lazy-start or claim a new bridge.
|
||||
if (readExtensionRelayToken() !== token) {
|
||||
throw new Error("browser relay key rotated during authentication");
|
||||
}
|
||||
const { bridge, profileName } = await resolveGatewayBridge(resource);
|
||||
return () => {
|
||||
attachExtensionWebSocket(bridge, ws);
|
||||
log.info(`extension authenticated over gateway for profile "${profileName}"`);
|
||||
};
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
) {
|
||||
destroy(socket, "400 Bad Request");
|
||||
}
|
||||
if (!state) {
|
||||
destroy(socket, "503 Service Unavailable");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const profileName = requestedProfileName(
|
||||
req,
|
||||
defaultExtensionProfileName(state.resolved.profiles),
|
||||
);
|
||||
const resolved = resolveProfile(state.resolved, profileName);
|
||||
if (!resolved || resolved.driver !== "extension") {
|
||||
destroy(socket, "404 Not Found");
|
||||
return true;
|
||||
}
|
||||
|
||||
let bridge;
|
||||
if (protocols.includes(BROWSER_RELAY_EXTENSION_SUBPROTOCOL)) {
|
||||
destroy(socket, "400 Bad Request");
|
||||
return true;
|
||||
}
|
||||
|
||||
const config = getRuntimeConfig();
|
||||
const allowLegacyAuth = config.browser?.extensionRelay?.allowLegacyAuth !== false;
|
||||
const legacyToken = requestExtensionProtocolToken(req);
|
||||
if (
|
||||
!allowLegacyAuth ||
|
||||
!protocols.includes(LEGACY_EXTENSION_RELAY_PROTOCOL) ||
|
||||
legacyToken.length === 0 ||
|
||||
!safeEqualSecret(token, legacyToken)
|
||||
) {
|
||||
destroy(socket, "401 Unauthorized");
|
||||
return true;
|
||||
}
|
||||
|
||||
let resolved;
|
||||
try {
|
||||
bridge = (await ensureExtensionRelayForProfile(state, resolved)).bridge;
|
||||
resolved = await resolveGatewayBridge(resource);
|
||||
} catch (err) {
|
||||
log.warn(`failed to start relay for profile "${profileName}": ${String(err)}`);
|
||||
log.warn(`failed to start Browser control for legacy extension relay: ${String(err)}`);
|
||||
destroy(socket, "503 Service Unavailable");
|
||||
return true;
|
||||
}
|
||||
|
||||
const authority = getBrowserRelayAuthV2Authority(token);
|
||||
getWss().handleUpgrade(req, socket, head, (ws) => {
|
||||
attachExtensionWebSocket(bridge, ws);
|
||||
log.info(`extension connected over gateway for profile "${profileName}"`);
|
||||
if (
|
||||
!authority.registerAuthenticatedConnection(ws, () =>
|
||||
ws.close(4003, "browser relay key rotated"),
|
||||
)
|
||||
) {
|
||||
ws.terminate();
|
||||
return;
|
||||
}
|
||||
ws.once("close", () => authority.releaseConnection(ws));
|
||||
attachExtensionWebSocket(resolved.bridge, ws);
|
||||
log.warn(`legacy extension authentication accepted for profile "${resolved.profileName}"`);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Release the shared WebSocketServer (runtime shutdown / tests). */
|
||||
export function disposeGatewayExtensionRelay(): void {
|
||||
wss?.close();
|
||||
if (!wss) {
|
||||
return;
|
||||
}
|
||||
for (const client of wss.clients) {
|
||||
client.terminate();
|
||||
}
|
||||
wss.close();
|
||||
wss = null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { Duplex } from "node:stream";
|
||||
import type { RawData, WebSocket, WebSocketServer } from "ws";
|
||||
|
||||
export const MAX_WEBSOCKET_AUTH_MESSAGE_BYTES = 16 * 1024;
|
||||
// A masked 16 KiB frame needs at most 8 bytes of framing. Keep a small bounded
|
||||
// allowance for a few fragments without approaching the 64 MiB app limit.
|
||||
const MAX_WEBSOCKET_PREAUTH_WIRE_BYTES = MAX_WEBSOCKET_AUTH_MESSAGE_BYTES + 1024;
|
||||
|
||||
export function boundedRawDataByteLength(data: RawData, limit: number): number {
|
||||
if (!Array.isArray(data)) {
|
||||
return data.byteLength;
|
||||
}
|
||||
let length = 0;
|
||||
for (const chunk of data) {
|
||||
length += chunk.byteLength;
|
||||
if (length > limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
class PreAuthWebSocketTransport extends Duplex {
|
||||
private guardActive = true;
|
||||
private wireBytes: number;
|
||||
|
||||
constructor(
|
||||
private readonly rawSocket: Duplex,
|
||||
headBytes: number,
|
||||
) {
|
||||
super();
|
||||
this.wireBytes = headBytes;
|
||||
rawSocket.on("data", this.onRawData);
|
||||
rawSocket.once("end", this.onRawEnd);
|
||||
rawSocket.once("close", this.onRawClose);
|
||||
rawSocket.once("error", this.onRawError);
|
||||
}
|
||||
|
||||
removeGuard = () => {
|
||||
this.guardActive = false;
|
||||
};
|
||||
|
||||
override _read(): void {
|
||||
this.rawSocket.resume();
|
||||
}
|
||||
|
||||
override _write(
|
||||
chunk: Buffer,
|
||||
encoding: BufferEncoding,
|
||||
callback: (error?: Error | null) => void,
|
||||
): void {
|
||||
this.rawSocket.write(chunk, encoding, callback);
|
||||
}
|
||||
|
||||
override _final(callback: (error?: Error | null) => void): void {
|
||||
this.rawSocket.end(callback);
|
||||
}
|
||||
|
||||
override _destroy(error: Error | null, callback: (error?: Error | null) => void): void {
|
||||
this.rawSocket.off("data", this.onRawData);
|
||||
this.rawSocket.off("end", this.onRawEnd);
|
||||
this.rawSocket.off("close", this.onRawClose);
|
||||
this.rawSocket.off("error", this.onRawError);
|
||||
if (!this.rawSocket.destroyed) {
|
||||
this.rawSocket.destroy();
|
||||
}
|
||||
callback(error);
|
||||
}
|
||||
|
||||
private readonly onRawData = (chunk: Buffer) => {
|
||||
if (this.guardActive) {
|
||||
this.wireBytes += chunk.byteLength;
|
||||
if (this.wireBytes > MAX_WEBSOCKET_PREAUTH_WIRE_BYTES) {
|
||||
this.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!this.push(chunk)) {
|
||||
this.rawSocket.pause();
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onRawEnd = () => this.push(null);
|
||||
private readonly onRawClose = () => this.destroy();
|
||||
private readonly onRawError = (error: Error) => this.destroy(error);
|
||||
}
|
||||
|
||||
/** Withhold pre-auth bytes from `ws`; successful proof makes the transport transparent. */
|
||||
export function handlePreAuthWebSocketUpgrade(params: {
|
||||
wss: WebSocketServer;
|
||||
req: IncomingMessage;
|
||||
socket: Duplex;
|
||||
head: Buffer;
|
||||
onUpgrade: (ws: WebSocket, removePreAuthGuard: () => void) => void;
|
||||
}): boolean {
|
||||
if (params.head.byteLength > MAX_WEBSOCKET_PREAUTH_WIRE_BYTES) {
|
||||
return false;
|
||||
}
|
||||
const transport = new PreAuthWebSocketTransport(params.socket, params.head.byteLength);
|
||||
try {
|
||||
params.wss.handleUpgrade(params.req, transport, params.head, (ws) => {
|
||||
params.onUpgrade(ws, transport.removeGuard);
|
||||
});
|
||||
} catch (err) {
|
||||
transport.destroy();
|
||||
throw err;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -8,12 +8,14 @@ class FakeSocket {
|
||||
readonly sent: unknown[] = [];
|
||||
closed = false;
|
||||
closeCode?: number;
|
||||
closeReason?: string;
|
||||
send(data: string): void {
|
||||
this.sent.push(JSON.parse(data));
|
||||
}
|
||||
close(code?: number): void {
|
||||
close(code?: number, reason?: string): void {
|
||||
this.closed = true;
|
||||
this.closeCode = code;
|
||||
this.closeReason = reason;
|
||||
}
|
||||
/** Frames of a given method (client CDP responses/events). */
|
||||
frames(): Array<Record<string, unknown>> {
|
||||
@@ -574,6 +576,113 @@ describe("ExtensionRelayBridge", () => {
|
||||
expect(bridge.extensionConnected).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the active extension while a candidate is pending, malformed, or closed", () => {
|
||||
const bridge = new ExtensionRelayBridge();
|
||||
const active = wireExtension(bridge);
|
||||
sendHello(active.handlers);
|
||||
|
||||
const pendingSocket = new FakeSocket();
|
||||
const pending = bridge.attachExtensionSocket(pendingSocket);
|
||||
expect(active.socket.closed).toBe(false);
|
||||
expect(bridge.identity?.browserVersion).toBe("Chrome/144.0.0.0");
|
||||
|
||||
pending.onClose();
|
||||
sendHello(pending);
|
||||
expect(bridge.extensionConnected).toBe(true);
|
||||
expect(active.socket.closed).toBe(false);
|
||||
|
||||
const malformedSocket = new FakeSocket();
|
||||
const malformed = bridge.attachExtensionSocket(malformedSocket);
|
||||
malformed.onMessage(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
userAgent: "candidate",
|
||||
browserVersion: "Chrome/145.0.0.0",
|
||||
extensionVersion: "2.0.0",
|
||||
}),
|
||||
);
|
||||
expect(malformedSocket).toMatchObject({
|
||||
closed: true,
|
||||
closeCode: 4001,
|
||||
closeReason: "expected valid hello",
|
||||
});
|
||||
expect(bridge.identity?.browserVersion).toBe("Chrome/144.0.0.0");
|
||||
expect(active.socket.closed).toBe(false);
|
||||
});
|
||||
|
||||
it("replaces the active extension only after the candidate sends a valid hello", () => {
|
||||
const bridge = new ExtensionRelayBridge();
|
||||
const active = wireExtension(bridge);
|
||||
sendHello(active.handlers);
|
||||
|
||||
const candidateSocket = new FakeSocket();
|
||||
const candidate = bridge.attachExtensionSocket(candidateSocket);
|
||||
sendHello(candidate, [
|
||||
{ tabId: 2, url: "https://candidate.example", title: "Candidate", active: true },
|
||||
]);
|
||||
|
||||
expect(active.socket).toMatchObject({
|
||||
closed: true,
|
||||
closeCode: 4000,
|
||||
closeReason: "replaced by newer extension connection",
|
||||
});
|
||||
expect(bridge.identity?.browserVersion).toBe("Chrome/144.0.0.0");
|
||||
expect(bridge.sharedTabs()).toEqual([
|
||||
{ tabId: 2, url: "https://candidate.example", title: "Candidate", active: true },
|
||||
]);
|
||||
|
||||
active.handlers.onClose();
|
||||
expect(bridge.extensionConnected).toBe(true);
|
||||
expect(bridge.sharedTabs()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects an older candidate when a newer candidate promotes first", () => {
|
||||
const bridge = new ExtensionRelayBridge();
|
||||
const active = wireExtension(bridge);
|
||||
sendHello(active.handlers);
|
||||
|
||||
const firstSocket = new FakeSocket();
|
||||
const first = bridge.attachExtensionSocket(firstSocket);
|
||||
const secondSocket = new FakeSocket();
|
||||
const second = bridge.attachExtensionSocket(secondSocket);
|
||||
expect(active.socket.closed).toBe(false);
|
||||
|
||||
second.onMessage(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
userAgent: "second",
|
||||
browserVersion: "Chrome/146.0.0.0",
|
||||
extensionVersion: "2.0.0",
|
||||
tabs: [],
|
||||
}),
|
||||
);
|
||||
expect(active.socket.closed).toBe(true);
|
||||
expect(firstSocket.closed).toBe(false);
|
||||
expect(secondSocket.closed).toBe(false);
|
||||
expect(bridge.identity?.browserVersion).toBe("Chrome/146.0.0.0");
|
||||
|
||||
first.onMessage(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
userAgent: "first",
|
||||
browserVersion: "Chrome/145.0.0.0",
|
||||
extensionVersion: "2.0.0",
|
||||
tabs: [],
|
||||
}),
|
||||
);
|
||||
expect(firstSocket).toMatchObject({
|
||||
closed: true,
|
||||
closeCode: 4000,
|
||||
closeReason: "superseded by newer extension connection",
|
||||
});
|
||||
expect(bridge.identity?.browserVersion).toBe("Chrome/146.0.0.0");
|
||||
|
||||
first.onClose();
|
||||
active.handlers.onClose();
|
||||
expect(bridge.extensionConnected).toBe(true);
|
||||
expect(secondSocket.closed).toBe(false);
|
||||
});
|
||||
|
||||
it("answers the Puppeteer connect bootstrap without protocol errors", async () => {
|
||||
// The exact browser-scoped sequence puppeteer.connect() issues before any
|
||||
// page work (chrome-devtools-mcp --browserUrl/--wsEndpoint rides this).
|
||||
|
||||
@@ -98,6 +98,7 @@ function toErrorPayload(
|
||||
*/
|
||||
export class ExtensionRelayBridge {
|
||||
private extension: { socket: BridgeSocket; identity: ExtensionIdentity } | null = null;
|
||||
private readonly extensionCandidates = new Set<BridgeSocket>();
|
||||
private readonly clients = new Set<CdpClientState>();
|
||||
private readonly tabs = new Map<number, TabState>();
|
||||
/** Browser-level sessions created by Playwright for page-scoped CDP access. */
|
||||
@@ -109,6 +110,8 @@ export class ExtensionRelayBridge {
|
||||
private readonly pendingExtension = new Map<number, PendingExtensionCommand>();
|
||||
private nextSeq = 1;
|
||||
private nextSessionOrdinal = 1;
|
||||
private nextExtensionCandidateOrdinal = 1;
|
||||
private latestPromotedCandidateOrdinal = 0;
|
||||
private pingTimer: NodeJS.Timeout | null = null;
|
||||
private readonly onStateChange?: () => void;
|
||||
private readonly onPageShare?: (payload: PageSharePayload) => Promise<void>;
|
||||
@@ -171,26 +174,41 @@ export class ExtensionRelayBridge {
|
||||
onMessage: (raw: string) => void;
|
||||
onClose: () => void;
|
||||
} {
|
||||
if (this.extension) {
|
||||
// Replace the previous connection: MV3 service workers restart and the
|
||||
// stale socket may linger half-open. Newest connection wins.
|
||||
log.info("extension reconnected; replacing previous relay connection");
|
||||
this.extension.socket.close(4000, "replaced by newer extension connection");
|
||||
this.handleExtensionGone();
|
||||
}
|
||||
let helloSeen = false;
|
||||
const candidateOrdinal = this.nextExtensionCandidateOrdinal++;
|
||||
let candidateState: "awaiting-hello" | "active" | "rejected" = "awaiting-hello";
|
||||
this.extensionCandidates.add(socket);
|
||||
const rejectCandidate = (code: number, reason: string) => {
|
||||
candidateState = "rejected";
|
||||
this.extensionCandidates.delete(socket);
|
||||
socket.close(code, reason);
|
||||
};
|
||||
const onMessage = (raw: string) => {
|
||||
const msg = parseExtensionMessage(raw);
|
||||
if (!msg) {
|
||||
log.warn("dropping malformed extension relay frame");
|
||||
if (candidateState === "rejected") {
|
||||
return;
|
||||
}
|
||||
if (!helloSeen) {
|
||||
if (msg.type !== "hello") {
|
||||
socket.close(4001, "expected hello");
|
||||
const msg = parseExtensionMessage(raw);
|
||||
if (candidateState === "awaiting-hello") {
|
||||
if (msg?.type !== "hello") {
|
||||
rejectCandidate(4001, "expected valid hello");
|
||||
return;
|
||||
}
|
||||
helloSeen = true;
|
||||
if (candidateOrdinal < this.latestPromotedCandidateOrdinal) {
|
||||
rejectCandidate(4000, "superseded by newer extension connection");
|
||||
return;
|
||||
}
|
||||
candidateState = "active";
|
||||
this.extensionCandidates.delete(socket);
|
||||
this.latestPromotedCandidateOrdinal = candidateOrdinal;
|
||||
if (this.extension) {
|
||||
// Authentication happens before bridge attachment. Keep the active
|
||||
// socket until its replacement also proves it can speak the relay protocol.
|
||||
log.info("extension reconnected; replacing previous relay connection");
|
||||
const previous = this.extension;
|
||||
previous.socket.close(4000, "replaced by newer extension connection");
|
||||
if (this.extension === previous) {
|
||||
this.handleExtensionGone();
|
||||
}
|
||||
}
|
||||
this.extension = {
|
||||
socket,
|
||||
identity: {
|
||||
@@ -204,9 +222,18 @@ export class ExtensionRelayBridge {
|
||||
this.onStateChange?.();
|
||||
return;
|
||||
}
|
||||
if (this.extension?.socket !== socket) {
|
||||
return;
|
||||
}
|
||||
if (!msg) {
|
||||
log.warn("dropping malformed extension relay frame");
|
||||
return;
|
||||
}
|
||||
this.handleExtensionMessage(msg);
|
||||
};
|
||||
const onClose = () => {
|
||||
candidateState = "rejected";
|
||||
this.extensionCandidates.delete(socket);
|
||||
if (this.extension?.socket === socket) {
|
||||
this.handleExtensionGone();
|
||||
this.onStateChange?.();
|
||||
@@ -977,6 +1004,10 @@ export class ExtensionRelayBridge {
|
||||
pending.reject(new Error("extension relay stopped"));
|
||||
}
|
||||
this.pendingExtension.clear();
|
||||
for (const candidate of this.extensionCandidates) {
|
||||
candidate.close(1001, "relay stopped");
|
||||
}
|
||||
this.extensionCandidates.clear();
|
||||
this.extension?.socket.close(1001, "relay stopped");
|
||||
this.extension = null;
|
||||
for (const client of this.clients) {
|
||||
|
||||
@@ -30,6 +30,8 @@ function createState(token: string, existing?: ExtensionRelayHandle) {
|
||||
extensionRelayToken: token,
|
||||
extensionRelayDefaultPort: 18_799,
|
||||
extensionRelayPorts: { [PROFILE_NAME]: RELAY_PORT },
|
||||
extensionRelay: { allowLegacyAuth: true },
|
||||
extensionRelayInternalTokens: existing ? { [PROFILE_NAME]: existing.internalToken } : {},
|
||||
profiles: {
|
||||
[PROFILE_NAME]: {
|
||||
cdpPort: RELAY_PORT,
|
||||
@@ -56,6 +58,8 @@ function createHandle(token: string, port = RELAY_PORT): ExtensionRelayHandle {
|
||||
return {
|
||||
port,
|
||||
token,
|
||||
allowLegacyAuth: true,
|
||||
internalToken: `${token.slice(0, 8)}-internal`,
|
||||
bridge: {} as ExtensionRelayHandle["bridge"],
|
||||
close: vi.fn(async () => {}),
|
||||
};
|
||||
@@ -74,9 +78,11 @@ describe("extension relay lifecycle", () => {
|
||||
vi.clearAllMocks();
|
||||
readExtensionRelayTokenMock.mockReturnValue(ROTATED_TOKEN);
|
||||
ensureExtensionRelayTokenMock.mockReturnValue(ROTATED_TOKEN);
|
||||
startExtensionRelayServerMock.mockImplementation(async ({ port, token }) => ({
|
||||
startExtensionRelayServerMock.mockImplementation(async ({ port, token, allowLegacyAuth }) => ({
|
||||
port,
|
||||
token,
|
||||
allowLegacyAuth,
|
||||
internalToken: "replacement-internal",
|
||||
bridge: {},
|
||||
close: vi.fn(async () => {}),
|
||||
}));
|
||||
@@ -85,7 +91,8 @@ describe("extension relay lifecycle", () => {
|
||||
it("rebounds an existing relay when the host-local token rotates", async () => {
|
||||
const oldRelay = createHandle(OLD_TOKEN);
|
||||
const { profile, state } = createState(OLD_TOKEN, oldRelay);
|
||||
expect(profile.cdpUrl).toContain(OLD_TOKEN);
|
||||
expect(profile.cdpUrl).toContain(encodeURIComponent(oldRelay.internalToken));
|
||||
expect(profile.cdpUrl).not.toContain(OLD_TOKEN);
|
||||
|
||||
const handle = await ensureExtensionRelayForProfile(state, profile);
|
||||
|
||||
@@ -93,12 +100,14 @@ describe("extension relay lifecycle", () => {
|
||||
expect(startExtensionRelayServerMock).toHaveBeenCalledWith({
|
||||
port: RELAY_PORT,
|
||||
token: ROTATED_TOKEN,
|
||||
allowLegacyAuth: true,
|
||||
onPageShare: expect.any(Function),
|
||||
});
|
||||
expect(handle.token).toBe(ROTATED_TOKEN);
|
||||
expect(state.resolved.extensionRelayToken).toBe(ROTATED_TOKEN);
|
||||
expect(profile.cdpUrl).toContain(ROTATED_TOKEN);
|
||||
expect(resolveProfile(state.resolved, PROFILE_NAME)?.cdpUrl).toContain(ROTATED_TOKEN);
|
||||
expect(profile.cdpUrl).toContain("replacement-internal");
|
||||
expect(profile.cdpUrl).not.toContain(ROTATED_TOKEN);
|
||||
expect(resolveProfile(state.resolved, PROFILE_NAME)?.cdpUrl).toContain("replacement-internal");
|
||||
expect(state.extensionRelays?.get(PROFILE_NAME)).toBe(handle);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const log = createSubsystemLogger("browser").child("extension-relay");
|
||||
type PendingRelayEnsure = {
|
||||
port: number;
|
||||
token: string;
|
||||
allowLegacyAuth: boolean;
|
||||
promise: Promise<ExtensionRelayHandle>;
|
||||
};
|
||||
|
||||
@@ -35,6 +36,26 @@ function relays(state: BrowserServerState): Map<string, ExtensionRelayHandle> {
|
||||
return state.extensionRelays;
|
||||
}
|
||||
|
||||
function applyInternalRelayToken(
|
||||
state: BrowserServerState,
|
||||
profileName: string,
|
||||
internalToken: string | null,
|
||||
): ResolvedBrowserProfile | null {
|
||||
const tokens = { ...state.resolved.extensionRelayInternalTokens };
|
||||
if (internalToken) {
|
||||
tokens[profileName] = internalToken;
|
||||
} else {
|
||||
delete tokens[profileName];
|
||||
}
|
||||
state.resolved = { ...state.resolved, extensionRelayInternalTokens: tokens };
|
||||
const resolved = resolveProfile(state.resolved, profileName);
|
||||
const runtime = state.profiles.get(profileName);
|
||||
if (resolved?.driver === "extension" && runtime?.profile.driver === "extension") {
|
||||
Object.assign(runtime.profile, resolved);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the relay server for one extension-driver profile, reconciling any
|
||||
* existing one. Idempotency is keyed on profile name, but the desired (port,
|
||||
@@ -51,9 +72,8 @@ export async function ensureExtensionRelayForProfile(
|
||||
if (!isBrowserRuntimeRunning(state)) {
|
||||
throw new Error("Browser runtime is stopping");
|
||||
}
|
||||
// The host-local relay secret can rotate while Browser control stays up.
|
||||
// Resolve one canonical desired profile after applying that token so the
|
||||
// intentional auth-derived cdpUrl change is not mistaken for config drift.
|
||||
// The host-local HMAC key can rotate while Browser control stays up.
|
||||
// Resolve one canonical desired profile after adopting the live key.
|
||||
const { ensureExtensionRelayToken, readExtensionRelayToken } = await import("./relay-auth.js");
|
||||
const token = readExtensionRelayToken() ?? (await ensureExtensionRelayToken());
|
||||
if (state.resolved.extensionRelayToken !== token) {
|
||||
@@ -67,8 +87,7 @@ export async function ensureExtensionRelayForProfile(
|
||||
) {
|
||||
throw new Error(`Extension relay profile "${profile.name}" changed during startup.`);
|
||||
}
|
||||
// Token rotation changes only the auth-derived CDP URL. Keep the active
|
||||
// request's shared profile object aligned with the relay it will use.
|
||||
// Keep the active request's shared profile object aligned with the relay.
|
||||
Object.assign(profile, desiredProfile);
|
||||
|
||||
const runtime = getOrCreateProfileRuntime(state, desiredProfile);
|
||||
@@ -81,8 +100,17 @@ export async function ensureExtensionRelayForProfile(
|
||||
}
|
||||
const pending = pendingRelayEnsures.get(runtime);
|
||||
if (pending) {
|
||||
if (pending.port === desiredProfile.cdpPort && pending.token === token) {
|
||||
return await pending.promise;
|
||||
if (
|
||||
pending.port === desiredProfile.cdpPort &&
|
||||
pending.token === token &&
|
||||
pending.allowLegacyAuth === state.resolved.extensionRelay.allowLegacyAuth
|
||||
) {
|
||||
const handle = await pending.promise;
|
||||
const current = resolveProfile(state.resolved, profile.name);
|
||||
if (current) {
|
||||
Object.assign(profile, current);
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
try {
|
||||
await pending.promise;
|
||||
@@ -95,10 +123,20 @@ export async function ensureExtensionRelayForProfile(
|
||||
}
|
||||
|
||||
const promise = ensureDesiredRelay({ state, runtime, profile: desiredProfile, token });
|
||||
const owned = { port: desiredProfile.cdpPort, token, promise };
|
||||
const owned = {
|
||||
port: desiredProfile.cdpPort,
|
||||
token,
|
||||
allowLegacyAuth: state.resolved.extensionRelay.allowLegacyAuth,
|
||||
promise,
|
||||
};
|
||||
pendingRelayEnsures.set(runtime, owned);
|
||||
try {
|
||||
return await promise;
|
||||
const handle = await promise;
|
||||
const current = resolveProfile(state.resolved, profile.name);
|
||||
if (current) {
|
||||
Object.assign(profile, current);
|
||||
}
|
||||
return handle;
|
||||
} finally {
|
||||
if (pendingRelayEnsures.get(runtime) === owned) {
|
||||
pendingRelayEnsures.delete(runtime);
|
||||
@@ -123,7 +161,15 @@ async function ensureDesiredRelay(params: {
|
||||
const actor = getProfileLifecycle(runtime);
|
||||
const existing = map.get(profile.name);
|
||||
if (existing) {
|
||||
if (existing.port === profile.cdpPort && existing.token === token) {
|
||||
if (
|
||||
existing.port === profile.cdpPort &&
|
||||
existing.token === token &&
|
||||
existing.allowLegacyAuth === state.resolved.extensionRelay.allowLegacyAuth
|
||||
) {
|
||||
const current = applyInternalRelayToken(state, profile.name, existing.internalToken);
|
||||
if (current) {
|
||||
Object.assign(profile, current);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
// Never drop the exact old handle until close succeeds; shutdown can retry it.
|
||||
@@ -133,12 +179,14 @@ async function ensureDesiredRelay(params: {
|
||||
if (map.get(profile.name) === existing) {
|
||||
map.delete(profile.name);
|
||||
}
|
||||
applyInternalRelayToken(state, profile.name, null);
|
||||
}
|
||||
let handle: ExtensionRelayHandle | undefined;
|
||||
try {
|
||||
handle = await startExtensionRelayServer({
|
||||
port: profile.cdpPort,
|
||||
token,
|
||||
allowLegacyAuth: state.resolved.extensionRelay.allowLegacyAuth,
|
||||
onPageShare: (payload) => deliverPageShare(payload),
|
||||
});
|
||||
actor.cleanupRelays.add(handle);
|
||||
@@ -147,12 +195,21 @@ async function ensureDesiredRelay(params: {
|
||||
if (
|
||||
state.profiles.get(profile.name) !== runtime ||
|
||||
currentProfile?.driver !== "extension" ||
|
||||
currentProfile.cdpUrl !== profile.cdpUrl ||
|
||||
currentProfile.cdpPort !== profile.cdpPort ||
|
||||
state.resolved.extensionRelayToken !== token
|
||||
) {
|
||||
throw new Error(`Extension relay profile "${profile.name}" changed during startup.`);
|
||||
}
|
||||
map.set(profile.name, handle);
|
||||
const currentWithInternalAuth = applyInternalRelayToken(
|
||||
state,
|
||||
profile.name,
|
||||
handle.internalToken,
|
||||
);
|
||||
if (!currentWithInternalAuth) {
|
||||
throw new Error(`Extension relay profile "${profile.name}" disappeared during startup.`);
|
||||
}
|
||||
Object.assign(profile, currentWithInternalAuth);
|
||||
actor.cleanupRelays.delete(handle);
|
||||
log.info(
|
||||
`extension relay for profile "${profile.name}" listening on 127.0.0.1:${handle.port}`,
|
||||
@@ -209,6 +266,7 @@ export async function stopExtensionRelays(state: BrowserServerState): Promise<vo
|
||||
if (map.get(name) === handle) {
|
||||
map.delete(name);
|
||||
}
|
||||
applyInternalRelayToken(state, name, null);
|
||||
} catch (err) {
|
||||
log.warn(`extension relay for profile "${name}" failed to stop: ${String(err)}`);
|
||||
firstError ??=
|
||||
|
||||
@@ -3,7 +3,16 @@ import { describe, expect, it } from "vitest";
|
||||
import { parseExtensionMessage } from "./relay-protocol.js";
|
||||
|
||||
describe("parseExtensionMessage", () => {
|
||||
const validHello = {
|
||||
type: "hello",
|
||||
userAgent: "Mozilla/5.0 Chrome/144.0.0.0",
|
||||
browserVersion: "Chrome/144.0.0.0",
|
||||
extensionVersion: "2.0.0",
|
||||
tabs: [{ tabId: 1, url: "https://example.com", title: "Example", active: true }],
|
||||
};
|
||||
|
||||
it("accepts known frame types", () => {
|
||||
expect(parseExtensionMessage(JSON.stringify(validHello))).toEqual(validHello);
|
||||
expect(parseExtensionMessage(JSON.stringify({ type: "pong" }))).toEqual({ type: "pong" });
|
||||
expect(
|
||||
parseExtensionMessage(JSON.stringify({ type: "result", seq: 3, result: { ok: true } })),
|
||||
@@ -24,6 +33,37 @@ describe("parseExtensionMessage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing identity", { ...validHello, userAgent: undefined }],
|
||||
["empty browser version", { ...validHello, browserVersion: "" }],
|
||||
["oversized user agent", { ...validHello, userAgent: "x".repeat(2_049) }],
|
||||
["an extra hello field", { ...validHello, extra: true }],
|
||||
["non-array tabs", { ...validHello, tabs: {} }],
|
||||
[
|
||||
"a fractional tab id",
|
||||
{ ...validHello, tabs: [{ tabId: 1.5, url: "", title: "", active: true }] },
|
||||
],
|
||||
[
|
||||
"an extra tab field",
|
||||
{
|
||||
...validHello,
|
||||
tabs: [{ tabId: 1, url: "", title: "", active: true, incognito: false }],
|
||||
},
|
||||
],
|
||||
[
|
||||
"duplicate tab ids",
|
||||
{
|
||||
...validHello,
|
||||
tabs: [
|
||||
{ tabId: 1, url: "https://one.example", title: "One", active: true },
|
||||
{ tabId: 1, url: "https://two.example", title: "Two", active: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
])("rejects a hello with %s", (_label, hello) => {
|
||||
expect(parseExtensionMessage(JSON.stringify(hello))).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed or unknown frames", () => {
|
||||
expect(parseExtensionMessage("not json")).toBeNull();
|
||||
expect(parseExtensionMessage(JSON.stringify({ type: "evil" }))).toBeNull();
|
||||
|
||||
@@ -131,6 +131,58 @@ export type RelayToExtensionMessage =
|
||||
| RelayPingMessage
|
||||
| RelayPageShareResultMessage;
|
||||
|
||||
function hasExactOwnKeys(value: object, keys: readonly string[]): boolean {
|
||||
const actual = Object.keys(value);
|
||||
return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key));
|
||||
}
|
||||
|
||||
function isRelayTabInfo(value: unknown): value is RelayTabInfo {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
if (!hasExactOwnKeys(value, ["tabId", "url", "title", "active"])) {
|
||||
return false;
|
||||
}
|
||||
const tab = value as Record<string, unknown>;
|
||||
return (
|
||||
Number.isSafeInteger(tab.tabId) &&
|
||||
(tab.tabId as number) >= 0 &&
|
||||
typeof tab.url === "string" &&
|
||||
tab.url.length <= 16_384 &&
|
||||
typeof tab.title === "string" &&
|
||||
tab.title.length <= 4_096 &&
|
||||
typeof tab.active === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
function isExtensionHelloMessage(value: object): value is ExtensionHelloMessage {
|
||||
if (
|
||||
!hasExactOwnKeys(value, ["type", "userAgent", "browserVersion", "extensionVersion", "tabs"])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const hello = value as Record<string, unknown>;
|
||||
if (
|
||||
hello.type !== "hello" ||
|
||||
typeof hello.userAgent !== "string" ||
|
||||
hello.userAgent.length === 0 ||
|
||||
hello.userAgent.length > 2_048 ||
|
||||
typeof hello.browserVersion !== "string" ||
|
||||
hello.browserVersion.length === 0 ||
|
||||
hello.browserVersion.length > 512 ||
|
||||
typeof hello.extensionVersion !== "string" ||
|
||||
hello.extensionVersion.length === 0 ||
|
||||
hello.extensionVersion.length > 128 ||
|
||||
!Array.isArray(hello.tabs) ||
|
||||
hello.tabs.length > 1_000 ||
|
||||
!hello.tabs.every(isRelayTabInfo)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const tabIds = new Set(hello.tabs.map((tab) => tab.tabId));
|
||||
return tabIds.size === hello.tabs.length;
|
||||
}
|
||||
|
||||
/** Parse one extension frame; returns null for malformed input. */
|
||||
export function parseExtensionMessage(raw: string): ExtensionToRelayMessage | null {
|
||||
let parsed: unknown;
|
||||
@@ -148,6 +200,7 @@ export function parseExtensionMessage(raw: string): ExtensionToRelayMessage | nu
|
||||
}
|
||||
switch (type) {
|
||||
case "hello":
|
||||
return isExtensionHelloMessage(parsed) ? parsed : null;
|
||||
case "tabs":
|
||||
case "cdpEvent":
|
||||
case "result":
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
|
||||
export const LEGACY_EXTENSION_RELAY_PROTOCOL = "openclaw-extension-relay";
|
||||
const LEGACY_EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX = "openclaw-extension-token.";
|
||||
|
||||
export function firstHeader(value: string | string[] | undefined): string {
|
||||
return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
|
||||
}
|
||||
|
||||
export function requestProtocols(req: IncomingMessage): string[] {
|
||||
return firstHeader(req.headers["sec-websocket-protocol"])
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function requestExtensionProtocolToken(req: IncomingMessage): string {
|
||||
const protocols = requestProtocols(req);
|
||||
if (!protocols.includes(LEGACY_EXTENSION_RELAY_PROTOCOL)) {
|
||||
return "";
|
||||
}
|
||||
const tokenProtocol = protocols.find((value) =>
|
||||
value.startsWith(LEGACY_EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX),
|
||||
);
|
||||
return tokenProtocol?.slice(LEGACY_EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX.length) ?? "";
|
||||
}
|
||||
|
||||
export function isAllowedExtensionOrigin(req: IncomingMessage): boolean {
|
||||
const origin = firstHeader(req.headers.origin);
|
||||
return origin === "" || origin.startsWith("chrome-extension://");
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
import { EventEmitter, once } from "node:events";
|
||||
import fs from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocket, type RawData } from "ws";
|
||||
import {
|
||||
createRelayProof,
|
||||
randomRelayNonce,
|
||||
relayKeyIdFromHex,
|
||||
type BrowserRelayAuthChallenge,
|
||||
} from "./auth-v2-crypto.js";
|
||||
import {
|
||||
BROWSER_RELAY_AUTH_CHALLENGE_PATH,
|
||||
BROWSER_RELAY_AUTH_COMPLETE_PATH,
|
||||
BROWSER_RELAY_CHALLENGE_TTL_MS,
|
||||
BROWSER_RELAY_EXTENSION_SUBPROTOCOL,
|
||||
BrowserRelayAuthV2Authority,
|
||||
getBrowserRelayAuthV2Authority,
|
||||
invalidateBrowserRelayAuthV2Authority,
|
||||
} from "./auth-v2.js";
|
||||
import {
|
||||
authenticateExtensionWebSocket,
|
||||
startExtensionRelayServer,
|
||||
type ExtensionRelayHandle,
|
||||
} from "./relay-server.js";
|
||||
|
||||
const KEY = "0123456789abcdef".repeat(4);
|
||||
|
||||
type RawResponse = {
|
||||
status: number;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
};
|
||||
|
||||
class RawHttpConnection {
|
||||
private buffer = Buffer.alloc(0);
|
||||
private readonly waiters: Array<() => void> = [];
|
||||
|
||||
private constructor(readonly socket: net.Socket) {
|
||||
socket.on("data", (chunk) => {
|
||||
this.buffer = Buffer.concat([
|
||||
this.buffer,
|
||||
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
|
||||
]);
|
||||
this.waiters.splice(0).forEach((resolve) => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(port: number): Promise<RawHttpConnection> {
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
socket.once("connect", resolve);
|
||||
socket.once("error", reject);
|
||||
});
|
||||
return new RawHttpConnection(socket);
|
||||
}
|
||||
|
||||
async request(
|
||||
method: string,
|
||||
requestPath: string,
|
||||
body = "",
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<RawResponse> {
|
||||
this.socket.write(
|
||||
[
|
||||
`${method} ${requestPath} HTTP/1.1`,
|
||||
"Host: 127.0.0.1",
|
||||
"Connection: keep-alive",
|
||||
`Content-Length: ${Buffer.byteLength(body)}`,
|
||||
...Object.entries(headers).map(([key, value]) => `${key}: ${value}`),
|
||||
"",
|
||||
body,
|
||||
].join("\r\n"),
|
||||
);
|
||||
return await this.readResponse();
|
||||
}
|
||||
|
||||
async upgrade(requestPath: string): Promise<RawResponse> {
|
||||
this.socket.write(
|
||||
[
|
||||
`GET ${requestPath} HTTP/1.1`,
|
||||
"Host: 127.0.0.1",
|
||||
"Connection: Upgrade",
|
||||
"Upgrade: websocket",
|
||||
"Sec-WebSocket-Version: 13",
|
||||
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
|
||||
"",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
);
|
||||
return await this.readResponse({ headersOnly: true });
|
||||
}
|
||||
|
||||
private async waitForData(): Promise<void> {
|
||||
if (this.socket.destroyed) {
|
||||
throw new Error("socket closed before response completed");
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
private async readResponse(options: { headersOnly?: boolean } = {}): Promise<RawResponse> {
|
||||
let headerEnd = this.buffer.indexOf("\r\n\r\n");
|
||||
while (headerEnd < 0) {
|
||||
await this.waitForData();
|
||||
headerEnd = this.buffer.indexOf("\r\n\r\n");
|
||||
}
|
||||
const headerText = this.buffer.subarray(0, headerEnd).toString("utf8");
|
||||
const [statusLine, ...headerLines] = headerText.split("\r\n");
|
||||
const headers = Object.fromEntries(
|
||||
headerLines.map((line) => {
|
||||
const separator = line.indexOf(":");
|
||||
return [line.slice(0, separator).toLowerCase(), line.slice(separator + 1).trim()];
|
||||
}),
|
||||
);
|
||||
const contentLength = options.headersOnly ? 0 : Number(headers["content-length"] ?? 0);
|
||||
const responseLength = headerEnd + 4 + contentLength;
|
||||
while (this.buffer.length < responseLength) {
|
||||
await this.waitForData();
|
||||
}
|
||||
const body = this.buffer.subarray(headerEnd + 4, responseLength).toString("utf8");
|
||||
this.buffer = this.buffer.subarray(responseLength);
|
||||
return {
|
||||
status: Number(/^HTTP\/1\.1 (\d+)/u.exec(statusLine ?? "")?.[1] ?? 0),
|
||||
headers,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function authenticate(
|
||||
connection: RawHttpConnection,
|
||||
flow: "cdp" | "json-list",
|
||||
clientNonce = randomRelayNonce(),
|
||||
): Promise<BrowserRelayAuthChallenge> {
|
||||
const binding =
|
||||
flow === "cdp"
|
||||
? { method: "SEQUENCE", resource: "/json/version -> /cdp" }
|
||||
: { method: "GET", resource: "/json/list" };
|
||||
const challengeResponse = await connection.request(
|
||||
"POST",
|
||||
BROWSER_RELAY_AUTH_CHALLENGE_PATH,
|
||||
JSON.stringify({
|
||||
v: 2,
|
||||
keyId: relayKeyIdFromHex(KEY),
|
||||
clientNonce,
|
||||
role: "cdp",
|
||||
transport: "connection",
|
||||
...binding,
|
||||
flow,
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
expect(challengeResponse.status).toBe(200);
|
||||
const challenge = JSON.parse(challengeResponse.body) as BrowserRelayAuthChallenge;
|
||||
const completeResponse = await connection.request(
|
||||
"POST",
|
||||
BROWSER_RELAY_AUTH_COMPLETE_PATH,
|
||||
JSON.stringify({
|
||||
v: 2,
|
||||
sessionId: challenge.sessionId,
|
||||
clientProof: createRelayProof(KEY, "client", challenge),
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
expect(completeResponse.status).toBe(200);
|
||||
expect(JSON.parse(completeResponse.body)).toMatchObject({
|
||||
type: "auth.ok",
|
||||
v: 2,
|
||||
sessionId: challenge.sessionId,
|
||||
});
|
||||
return challenge;
|
||||
}
|
||||
|
||||
function attachTestExtension(handle: ExtensionRelayHandle): void {
|
||||
const handlers = handle.bridge.attachExtensionSocket({ send: () => {}, close: () => {} });
|
||||
handlers.onMessage(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
userAgent: "test",
|
||||
browserVersion: "Chrome/test",
|
||||
extensionVersion: "2",
|
||||
tabs: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function rawDataText(data: RawData): string {
|
||||
if (Array.isArray(data)) {
|
||||
return Buffer.concat(data).toString("utf8");
|
||||
}
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return Buffer.from(data).toString("utf8");
|
||||
}
|
||||
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8");
|
||||
}
|
||||
|
||||
async function openExtensionSocket(
|
||||
handle: ExtensionRelayHandle,
|
||||
protocols: string | string[],
|
||||
): Promise<WebSocket> {
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${handle.port}/extension`, protocols, {
|
||||
origin: "chrome-extension://relay-auth-v2-test",
|
||||
});
|
||||
ws.on("error", () => {});
|
||||
await once(ws, "open");
|
||||
return ws;
|
||||
}
|
||||
|
||||
async function authenticateV2Extension(handle: ExtensionRelayHandle): Promise<WebSocket> {
|
||||
const ws = await openExtensionSocket(handle, BROWSER_RELAY_EXTENSION_SUBPROTOCOL);
|
||||
const challengeMessage = once(ws, "message");
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "auth.hello",
|
||||
v: 2,
|
||||
keyId: relayKeyIdFromHex(KEY),
|
||||
clientNonce: randomRelayNonce(),
|
||||
}),
|
||||
);
|
||||
const [challengeData] = (await challengeMessage) as [RawData];
|
||||
const challenge = JSON.parse(rawDataText(challengeData)) as BrowserRelayAuthChallenge;
|
||||
const okMessage = once(ws, "message");
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "auth.response",
|
||||
v: 2,
|
||||
sessionId: challenge.sessionId,
|
||||
clientProof: createRelayProof(KEY, "client", challenge),
|
||||
}),
|
||||
);
|
||||
const [okData] = (await okMessage) as [RawData];
|
||||
expect(JSON.parse(rawDataText(okData))).toMatchObject({
|
||||
type: "auth.ok",
|
||||
v: 2,
|
||||
sessionId: challenge.sessionId,
|
||||
});
|
||||
return ws;
|
||||
}
|
||||
|
||||
function createWebSocketAuthHarness(
|
||||
options: {
|
||||
prepareAuthenticated?: () => Promise<() => void>;
|
||||
removePreAuthGuard?: () => void;
|
||||
} = {},
|
||||
) {
|
||||
const close = vi.fn();
|
||||
const send = vi.fn();
|
||||
const socket = Object.assign(new EventEmitter(), {
|
||||
close,
|
||||
readyState: 1,
|
||||
send,
|
||||
terminate: vi.fn(),
|
||||
}) as unknown as WebSocket;
|
||||
const authority = new BrowserRelayAuthV2Authority(KEY);
|
||||
const issueChallenge = vi.spyOn(authority, "issueChallenge");
|
||||
const prepareAuthenticated = vi.fn(options.prepareAuthenticated ?? (async () => vi.fn()));
|
||||
authenticateExtensionWebSocket({
|
||||
ws: socket,
|
||||
authority,
|
||||
resource: "/extension",
|
||||
prepareAuthenticated,
|
||||
removePreAuthGuard: options.removePreAuthGuard,
|
||||
});
|
||||
return { authority, close, issueChallenge, prepareAuthenticated, send, socket };
|
||||
}
|
||||
|
||||
function maskedFrame(payload: Buffer, options: { fin: boolean; opcode: number }): Buffer {
|
||||
const lengthBytes = payload.length < 126 ? 0 : 2;
|
||||
const header = Buffer.alloc(2 + lengthBytes + 4);
|
||||
header[0] = (options.fin ? 0x80 : 0) | options.opcode;
|
||||
header[1] = 0x80 | (lengthBytes === 0 ? payload.length : 126);
|
||||
if (lengthBytes === 2) {
|
||||
header.writeUInt16BE(payload.length, 2);
|
||||
}
|
||||
const maskOffset = 2 + lengthBytes;
|
||||
const mask = Buffer.from([0x12, 0x34, 0x56, 0x78]);
|
||||
mask.copy(header, maskOffset);
|
||||
const masked = Buffer.allocUnsafe(payload.length);
|
||||
for (let index = 0; index < payload.length; index += 1) {
|
||||
masked[index] = payload[index]! ^ mask[index % 4]!;
|
||||
}
|
||||
return Buffer.concat([header, masked]);
|
||||
}
|
||||
|
||||
async function sendRawV2Frames(params: {
|
||||
port: number;
|
||||
initialFrames?: Buffer[];
|
||||
subsequentFrames?: Buffer[];
|
||||
}): Promise<string> {
|
||||
const socket = net.createConnection({ host: "127.0.0.1", port: params.port });
|
||||
socket.on("error", () => {});
|
||||
await once(socket, "connect");
|
||||
const received: Buffer[] = [];
|
||||
socket.on("data", (chunk) => received.push(Buffer.from(chunk)));
|
||||
const closed = once(socket, "close");
|
||||
const request = Buffer.from(
|
||||
[
|
||||
"GET /extension HTTP/1.1",
|
||||
"Host: 127.0.0.1",
|
||||
"Connection: Upgrade",
|
||||
"Upgrade: websocket",
|
||||
"Sec-WebSocket-Version: 13",
|
||||
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
|
||||
`Sec-WebSocket-Protocol: ${BROWSER_RELAY_EXTENSION_SUBPROTOCOL}`,
|
||||
"Origin: chrome-extension://relay-auth-v2-test",
|
||||
"",
|
||||
"",
|
||||
].join("\r\n"),
|
||||
);
|
||||
socket.write(Buffer.concat([request, ...(params.initialFrames ?? [])]));
|
||||
if (params.subsequentFrames?.length) {
|
||||
await vi.waitFor(() => {
|
||||
expect(Buffer.concat(received).toString("utf8")).toContain("101 Switching Protocols");
|
||||
});
|
||||
socket.write(Buffer.concat(params.subsequentFrames));
|
||||
}
|
||||
await closed;
|
||||
return Buffer.concat(received).toString("utf8");
|
||||
}
|
||||
|
||||
describe("extension relay WebSocket auth v2 frame boundary", () => {
|
||||
it.each([
|
||||
["Buffer", Buffer.alloc(16 * 1024 + 1, 0x20)],
|
||||
["ArrayBuffer", new Uint8Array(16 * 1024 + 1).buffer],
|
||||
["Buffer[]", [Buffer.alloc(8 * 1024), Buffer.alloc(8 * 1024 + 1)]],
|
||||
] satisfies Array<[string, RawData]>)(
|
||||
"rejects an oversized text auth frame backed by %s before issuing a challenge",
|
||||
(_kind, data) => {
|
||||
const harness = createWebSocketAuthHarness();
|
||||
|
||||
harness.socket.emit("message", data, false);
|
||||
|
||||
expect(harness.close).toHaveBeenCalledWith(4003, "browser relay auth frame is too large");
|
||||
expect(harness.issueChallenge).not.toHaveBeenCalled();
|
||||
expect(harness.send).not.toHaveBeenCalled();
|
||||
expect(harness.prepareAuthenticated).not.toHaveBeenCalled();
|
||||
harness.socket.emit("close");
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a binary auth frame without issuing a challenge or promoting the bridge", () => {
|
||||
const harness = createWebSocketAuthHarness();
|
||||
|
||||
harness.socket.emit("message", Buffer.from("{}"), true);
|
||||
|
||||
expect(harness.close).toHaveBeenCalledWith(
|
||||
4003,
|
||||
"binary browser relay auth frames are not allowed",
|
||||
);
|
||||
expect(harness.issueChallenge).not.toHaveBeenCalled();
|
||||
expect(harness.send).not.toHaveBeenCalled();
|
||||
expect(harness.prepareAuthenticated).not.toHaveBeenCalled();
|
||||
harness.socket.emit("close");
|
||||
});
|
||||
|
||||
it("releases a timed-out pending socket without disturbing active capacity", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const removePreAuthGuard = vi.fn();
|
||||
const harness = createWebSocketAuthHarness({ removePreAuthGuard });
|
||||
const activeInvalidated = vi.fn();
|
||||
expect(harness.authority.registerAuthenticatedConnection({}, activeInvalidated)).toBe(true);
|
||||
for (let index = 0; index < 127; index += 1) {
|
||||
expect(harness.authority.registerPendingConnection({}, vi.fn())).toBe(true);
|
||||
}
|
||||
expect(harness.authority.registerPendingConnection({}, vi.fn())).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(harness.close).toHaveBeenCalledWith(4008, "browser relay auth timeout");
|
||||
expect(removePreAuthGuard).not.toHaveBeenCalled();
|
||||
harness.socket.emit("close");
|
||||
expect(removePreAuthGuard).toHaveBeenCalledOnce();
|
||||
expect(harness.authority.registerPendingConnection({}, vi.fn())).toBe(true);
|
||||
expect(activeInvalidated).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("ends the proof deadline at promotion while authenticated preparation remains pending", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let finishPreparation = (_attach: () => void) => {};
|
||||
const attach = vi.fn();
|
||||
const preparation = new Promise<() => void>((resolve) => {
|
||||
finishPreparation = resolve;
|
||||
});
|
||||
const removePreAuthGuard = vi.fn();
|
||||
const harness = createWebSocketAuthHarness({
|
||||
prepareAuthenticated: async () => await preparation,
|
||||
removePreAuthGuard,
|
||||
});
|
||||
const clientNonce = randomRelayNonce();
|
||||
harness.socket.emit(
|
||||
"message",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
type: "auth.hello",
|
||||
v: 2,
|
||||
keyId: relayKeyIdFromHex(KEY),
|
||||
clientNonce,
|
||||
}),
|
||||
),
|
||||
false,
|
||||
);
|
||||
const challenge = JSON.parse(harness.send.mock.calls[0]?.[0]) as BrowserRelayAuthChallenge;
|
||||
harness.socket.emit(
|
||||
"message",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
type: "auth.response",
|
||||
v: 2,
|
||||
sessionId: challenge.sessionId,
|
||||
clientProof: createRelayProof(KEY, "client", challenge),
|
||||
}),
|
||||
),
|
||||
false,
|
||||
);
|
||||
expect(removePreAuthGuard).toHaveBeenCalledOnce();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(BROWSER_RELAY_CHALLENGE_TTL_MS + 1);
|
||||
expect(harness.close).not.toHaveBeenCalled();
|
||||
expect(harness.socket.readyState).toBe(WebSocket.OPEN);
|
||||
|
||||
finishPreparation(attach);
|
||||
await vi.waitFor(() => expect(attach).toHaveBeenCalledOnce());
|
||||
expect(harness.send.mock.calls.some(([raw]) => JSON.parse(raw).type === "auth.ok")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(harness.close).not.toHaveBeenCalled();
|
||||
harness.socket.emit("close");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.sequential("extension relay HTTP auth v2", () => {
|
||||
let stateDir: string;
|
||||
let previousStateDir: string | undefined;
|
||||
let handle: ExtensionRelayHandle | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-relay-auth-v2-"));
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
await fs.mkdir(path.join(stateDir, "credentials"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(stateDir, "credentials", "browser-extension-relay.secret"),
|
||||
`${KEY}\n`,
|
||||
{
|
||||
mode: 0o600,
|
||||
},
|
||||
);
|
||||
invalidateBrowserRelayAuthV2Authority();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle?.close();
|
||||
handle = null;
|
||||
invalidateBrowserRelayAuthV2Authority();
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
await fs.rm(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("keeps the same-socket CDP upgrade active and rotation-bound", async () => {
|
||||
handle = await startExtensionRelayServer({ port: 0, token: KEY, allowLegacyAuth: false });
|
||||
attachTestExtension(handle);
|
||||
const connection = await RawHttpConnection.connect(handle.port);
|
||||
await authenticate(connection, "cdp");
|
||||
const version = await connection.request("GET", "/json/version");
|
||||
expect(version.status).toBe(200);
|
||||
expect(JSON.parse(version.body).webSocketDebuggerUrl).toBe(`ws://127.0.0.1:${handle.port}/cdp`);
|
||||
const upgraded = await connection.upgrade("/cdp");
|
||||
expect(upgraded.status).toBe(101);
|
||||
expect(handle.bridge.cdpClientCount).toBe(1);
|
||||
const closed = once(connection.socket, "close");
|
||||
getBrowserRelayAuthV2Authority("f".repeat(64));
|
||||
await closed;
|
||||
await vi.waitFor(() => expect(handle?.bridge.cdpClientCount).toBe(0));
|
||||
connection.close();
|
||||
});
|
||||
|
||||
it("rejects oversized upgrade-head auth data before challenge or bridge promotion", async () => {
|
||||
handle = await startExtensionRelayServer({ port: 0, token: KEY, allowLegacyAuth: false });
|
||||
const authority = getBrowserRelayAuthV2Authority(KEY);
|
||||
const issueChallenge = vi.spyOn(authority, "issueChallenge");
|
||||
const validHello = maskedFrame(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
type: "auth.hello",
|
||||
v: 2,
|
||||
keyId: relayKeyIdFromHex(KEY),
|
||||
clientNonce: randomRelayNonce(),
|
||||
}),
|
||||
),
|
||||
{ fin: true, opcode: 0x1 },
|
||||
);
|
||||
const response = await sendRawV2Frames({
|
||||
port: handle.port,
|
||||
initialFrames: [
|
||||
validHello,
|
||||
maskedFrame(Buffer.alloc(18 * 1024, 0x20), { fin: true, opcode: 0x1 }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(response).not.toContain("auth.challenge");
|
||||
expect(response).not.toContain("auth.ok");
|
||||
expect(issueChallenge).not.toHaveBeenCalled();
|
||||
expect(handle.bridge.extensionConnected).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects fragmented masked pre-auth wire overhead before challenge or bridge promotion", async () => {
|
||||
handle = await startExtensionRelayServer({ port: 0, token: KEY, allowLegacyAuth: false });
|
||||
const authority = getBrowserRelayAuthV2Authority(KEY);
|
||||
const issueChallenge = vi.spyOn(authority, "issueChallenge");
|
||||
const payload = Buffer.alloc(16 * 1024, 0x20);
|
||||
const fragments: Buffer[] = [];
|
||||
for (let offset = 0; offset < payload.length; offset += 91) {
|
||||
const end = Math.min(offset + 91, payload.length);
|
||||
fragments.push(
|
||||
maskedFrame(payload.subarray(offset, end), {
|
||||
fin: end === payload.length,
|
||||
opcode: offset === 0 ? 0x1 : 0x0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const response = await sendRawV2Frames({ port: handle.port, subsequentFrames: fragments });
|
||||
|
||||
expect(response).not.toContain("auth.challenge");
|
||||
expect(response).not.toContain("auth.ok");
|
||||
expect(issueChallenge).not.toHaveBeenCalled();
|
||||
expect(handle.bridge.extensionConnected).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the 64 MiB application receiver after v2 authentication", async () => {
|
||||
handle = await startExtensionRelayServer({ port: 0, token: KEY, allowLegacyAuth: false });
|
||||
const socket = await authenticateV2Extension(handle);
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
userAgent: "test",
|
||||
browserVersion: "Chrome/test",
|
||||
extensionVersion: "2",
|
||||
tabs: [
|
||||
{
|
||||
tabId: 1,
|
||||
url: `https://example.test/${"a".repeat(16_000)}`,
|
||||
title: "one",
|
||||
active: true,
|
||||
},
|
||||
{
|
||||
tabId: 2,
|
||||
url: `https://example.test/${"b".repeat(16_000)}`,
|
||||
title: "two",
|
||||
active: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(handle?.bridge.extensionConnected).toBe(true));
|
||||
expect(socket.readyState).toBe(WebSocket.OPEN);
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("keeps an active extension while pending admission is full and recovers after release", async () => {
|
||||
handle = await startExtensionRelayServer({ port: 0, token: KEY, allowLegacyAuth: true });
|
||||
const active = await openExtensionSocket(handle, [
|
||||
"openclaw-extension-relay",
|
||||
`openclaw-extension-token.${KEY}`,
|
||||
]);
|
||||
active.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
userAgent: "test",
|
||||
browserVersion: "Chrome/test",
|
||||
extensionVersion: "2",
|
||||
tabs: [],
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(handle?.bridge.extensionConnected).toBe(true));
|
||||
|
||||
const pending = await Promise.all(
|
||||
Array.from({ length: 128 }, () =>
|
||||
openExtensionSocket(handle!, BROWSER_RELAY_EXTENSION_SUBPROTOCOL),
|
||||
),
|
||||
);
|
||||
expect(active.readyState).toBe(WebSocket.OPEN);
|
||||
expect(handle.bridge.extensionConnected).toBe(true);
|
||||
|
||||
const overflow = new WebSocket(
|
||||
`ws://127.0.0.1:${handle.port}/extension`,
|
||||
BROWSER_RELAY_EXTENSION_SUBPROTOCOL,
|
||||
{ origin: "chrome-extension://relay-auth-v2-test" },
|
||||
);
|
||||
overflow.on("error", () => {});
|
||||
const overflowClosed = once(overflow, "close");
|
||||
await once(overflow, "open");
|
||||
const [overflowCode] = (await overflowClosed) as [number, Buffer];
|
||||
expect(overflowCode).toBe(4013);
|
||||
expect(active.readyState).toBe(WebSocket.OPEN);
|
||||
expect(handle.bridge.extensionConnected).toBe(true);
|
||||
|
||||
const released = once(pending[0]!, "close");
|
||||
pending[0]!.close();
|
||||
await released;
|
||||
const promoted = await authenticateV2Extension(handle);
|
||||
promoted.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
userAgent: "test-v2",
|
||||
browserVersion: "Chrome/test-v2",
|
||||
extensionVersion: "2",
|
||||
tabs: [],
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(handle?.bridge.extensionConnected).toBe(true));
|
||||
|
||||
promoted.close();
|
||||
active.close();
|
||||
for (const socket of pending.slice(1)) {
|
||||
socket.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("rejects completion on another socket without consuming the original challenge", async () => {
|
||||
handle = await startExtensionRelayServer({ port: 0, token: KEY });
|
||||
const original = await RawHttpConnection.connect(handle.port);
|
||||
const other = await RawHttpConnection.connect(handle.port);
|
||||
const challengeResponse = await original.request(
|
||||
"POST",
|
||||
BROWSER_RELAY_AUTH_CHALLENGE_PATH,
|
||||
JSON.stringify({
|
||||
v: 2,
|
||||
keyId: relayKeyIdFromHex(KEY),
|
||||
clientNonce: randomRelayNonce(),
|
||||
role: "cdp",
|
||||
transport: "connection",
|
||||
method: "GET",
|
||||
resource: "/json/list",
|
||||
flow: "json-list",
|
||||
}),
|
||||
);
|
||||
const challenge = JSON.parse(challengeResponse.body) as BrowserRelayAuthChallenge;
|
||||
const completion = JSON.stringify({
|
||||
v: 2,
|
||||
sessionId: challenge.sessionId,
|
||||
clientProof: createRelayProof(KEY, "client", challenge),
|
||||
});
|
||||
expect((await other.request("POST", BROWSER_RELAY_AUTH_COMPLETE_PATH, completion)).status).toBe(
|
||||
409,
|
||||
);
|
||||
expect(
|
||||
(await original.request("POST", BROWSER_RELAY_AUTH_COMPLETE_PATH, completion)).status,
|
||||
).toBe(200);
|
||||
original.close();
|
||||
other.close();
|
||||
});
|
||||
|
||||
it("rejects replayed client nonces across sockets", async () => {
|
||||
handle = await startExtensionRelayServer({ port: 0, token: KEY });
|
||||
const first = await RawHttpConnection.connect(handle.port);
|
||||
const second = await RawHttpConnection.connect(handle.port);
|
||||
const nonce = randomRelayNonce();
|
||||
const body = JSON.stringify({
|
||||
v: 2,
|
||||
keyId: relayKeyIdFromHex(KEY),
|
||||
clientNonce: nonce,
|
||||
role: "cdp",
|
||||
transport: "connection",
|
||||
method: "GET",
|
||||
resource: "/json/list",
|
||||
flow: "json-list",
|
||||
});
|
||||
expect((await first.request("POST", BROWSER_RELAY_AUTH_CHALLENGE_PATH, body)).status).toBe(200);
|
||||
expect((await second.request("POST", BROWSER_RELAY_AUTH_CHALLENGE_PATH, body)).status).toBe(
|
||||
401,
|
||||
);
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
|
||||
it("uses a separate one-GET json-list flow and closes it", async () => {
|
||||
handle = await startExtensionRelayServer({ port: 0, token: KEY, allowLegacyAuth: false });
|
||||
attachTestExtension(handle);
|
||||
const connection = await RawHttpConnection.connect(handle.port);
|
||||
await authenticate(connection, "json-list");
|
||||
const list = await connection.request("GET", "/json/list");
|
||||
expect(list.status).toBe(200);
|
||||
expect(JSON.parse(list.body)).toEqual([]);
|
||||
expect(list.headers.connection).toBe("close");
|
||||
connection.close();
|
||||
});
|
||||
|
||||
it("gates K-bearing legacy auth but preserves process-ephemeral internal Basic auth", async () => {
|
||||
handle = await startExtensionRelayServer({ port: 0, token: KEY, allowLegacyAuth: false });
|
||||
attachTestExtension(handle);
|
||||
const bearer = await RawHttpConnection.connect(handle.port);
|
||||
expect(
|
||||
(await bearer.request("GET", "/json/version", "", { Authorization: `Bearer ${KEY}` })).status,
|
||||
).toBe(401);
|
||||
bearer.close();
|
||||
|
||||
const query = await RawHttpConnection.connect(handle.port);
|
||||
expect((await query.request("GET", `/json/version?token=${KEY}`)).status).toBe(401);
|
||||
query.close();
|
||||
|
||||
const internal = await RawHttpConnection.connect(handle.port);
|
||||
const credential = Buffer.from(`openclaw-internal:${handle.internalToken}`).toString("base64");
|
||||
expect(
|
||||
(await internal.request("GET", "/json/version", "", { Authorization: `Basic ${credential}` }))
|
||||
.status,
|
||||
).toBe(200);
|
||||
internal.close();
|
||||
});
|
||||
|
||||
it("rejects query substitutions and duplicate security fields", async () => {
|
||||
handle = await startExtensionRelayServer({ port: 0, token: KEY });
|
||||
const query = await RawHttpConnection.connect(handle.port);
|
||||
expect(
|
||||
(await query.request("POST", `${BROWSER_RELAY_AUTH_CHALLENGE_PATH}?x=1`, JSON.stringify({})))
|
||||
.status,
|
||||
).toBe(400);
|
||||
query.close();
|
||||
|
||||
const duplicate = await RawHttpConnection.connect(handle.port);
|
||||
const nonce = randomRelayNonce();
|
||||
const body = `{"v":2,"v":1,"keyId":"${relayKeyIdFromHex(KEY)}","clientNonce":"${nonce}","role":"cdp","transport":"connection","method":"GET","resource":"/json/list","flow":"json-list"}`;
|
||||
expect((await duplicate.request("POST", BROWSER_RELAY_AUTH_CHALLENGE_PATH, body)).status).toBe(
|
||||
400,
|
||||
);
|
||||
duplicate.close();
|
||||
});
|
||||
});
|
||||
@@ -1,104 +1,121 @@
|
||||
/**
|
||||
* Extension relay HTTP/WebSocket server.
|
||||
*
|
||||
* Loopback-only endpoint that pairs the OpenClaw Chrome extension with the
|
||||
* browser control service:
|
||||
* GET /json/version -> CDP discovery for pw-session (503 until paired)
|
||||
* WS /cdp -> CDP browser endpoint (Playwright connectOverCDP)
|
||||
* WS /extension -> the Chrome extension's relay transport
|
||||
* Both sides authenticate with the derived relay token: CDP clients send it as
|
||||
* Basic auth (flows from the profile cdpUrl userinfo via getHeadersWithAuth),
|
||||
* the extension sends the token in its WebSocket subprotocol list.
|
||||
*/
|
||||
import http, { type IncomingMessage, type Server } from "node:http";
|
||||
/** Loopback extension relay with connection-bound Browser Relay Authentication v2. */
|
||||
import crypto from "node:crypto";
|
||||
import http, { type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import { WebSocketServer, type RawData, type WebSocket } from "ws";
|
||||
import { isLoopbackHost } from "../../gateway/net.js";
|
||||
import { rawDataToString } from "../../infra/ws.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import {
|
||||
BROWSER_RELAY_AUTH_CHALLENGE_PATH,
|
||||
BROWSER_RELAY_AUTH_COMPLETE_PATH,
|
||||
BROWSER_RELAY_CHALLENGE_TTL_MS,
|
||||
BROWSER_RELAY_EXTENSION_SUBPROTOCOL,
|
||||
getBrowserRelayAuthV2Authority,
|
||||
invalidateBrowserRelayAuthV2Authority,
|
||||
parseExtensionRelayResource,
|
||||
parseRelayAuthHello,
|
||||
parseRelayAuthResponse,
|
||||
parseRelayHttpChallengeRequest,
|
||||
parseRelayHttpCompleteRequest,
|
||||
parseStrictJsonObject,
|
||||
type BrowserRelayAuthV2Authority,
|
||||
} from "./auth-v2.js";
|
||||
import {
|
||||
boundedRawDataByteLength,
|
||||
handlePreAuthWebSocketUpgrade,
|
||||
MAX_WEBSOCKET_AUTH_MESSAGE_BYTES,
|
||||
} from "./preauth-websocket-guard.js";
|
||||
import { readExtensionRelayToken } from "./relay-auth.js";
|
||||
import { ExtensionRelayBridge } from "./relay-bridge.js";
|
||||
import type { PageSharePayload } from "./relay-protocol.js";
|
||||
import { parseExtensionMessage, type PageSharePayload } from "./relay-protocol.js";
|
||||
import {
|
||||
firstHeader,
|
||||
isAllowedExtensionOrigin,
|
||||
requestExtensionProtocolToken,
|
||||
requestProtocols,
|
||||
} from "./relay-request.js";
|
||||
|
||||
const log = createSubsystemLogger("browser").child("extension-relay");
|
||||
const EXTENSION_RELAY_PROTOCOL = "openclaw-extension-relay";
|
||||
const EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX = "openclaw-extension-token.";
|
||||
const INTERNAL_CDP_USERNAME = "openclaw-internal";
|
||||
const MAX_AUTH_BODY_BYTES = 8 * 1024;
|
||||
|
||||
/**
|
||||
* Cap relay frame size to bound memory from a hostile/buggy peer while leaving
|
||||
* headroom for CDP payloads (base64 screenshots, DOM snapshots, network bodies).
|
||||
*/
|
||||
export const EXTENSION_RELAY_MAX_PAYLOAD_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** Wire an accepted extension WebSocket to a bridge (shared by loopback + gateway paths). */
|
||||
export function attachExtensionWebSocket(bridge: ExtensionRelayBridge, ws: WebSocket): void {
|
||||
bindSocket(ws, bridge.attachExtensionSocket(ws));
|
||||
}
|
||||
type HttpAuthState =
|
||||
| { stage: "busy" }
|
||||
| {
|
||||
stage: "challenged";
|
||||
flow: "cdp" | "json-list";
|
||||
authority: BrowserRelayAuthV2Authority;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
| {
|
||||
stage: "authenticated";
|
||||
flow: "cdp" | "json-list";
|
||||
authority: BrowserRelayAuthV2Authority;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
| {
|
||||
stage: "awaiting-upgrade";
|
||||
authority: BrowserRelayAuthV2Authority;
|
||||
timer: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
/** Running relay server handle owned by the profile runtime state. */
|
||||
export type ExtensionRelayHandle = {
|
||||
port: number;
|
||||
/** Auth token this relay validates against; used to detect auth rotation. */
|
||||
token: string;
|
||||
allowLegacyAuth: boolean;
|
||||
/** Process-only Basic credential for OpenClaw's own CDP client. Never persisted or printed. */
|
||||
internalToken: string;
|
||||
bridge: ExtensionRelayBridge;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
function firstHeader(value: string | string[] | undefined): string {
|
||||
return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
|
||||
}
|
||||
|
||||
/** Extract a relay token carried by the extension's WebSocket subprotocol list. */
|
||||
export function requestExtensionProtocolToken(req: IncomingMessage): string {
|
||||
const protocols = firstHeader(req.headers["sec-websocket-protocol"])
|
||||
.split(",")
|
||||
.map((value) => value.trim());
|
||||
if (!protocols.includes(EXTENSION_RELAY_PROTOCOL)) {
|
||||
return "";
|
||||
}
|
||||
const tokenProtocol = protocols.find((value) =>
|
||||
value.startsWith(EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX),
|
||||
);
|
||||
return tokenProtocol?.slice(EXTENSION_RELAY_TOKEN_PROTOCOL_PREFIX.length) ?? "";
|
||||
}
|
||||
|
||||
/** Extract relay auth from a CDP header, extension subprotocol, or legacy query. */
|
||||
function requestToken(req: IncomingMessage): string {
|
||||
function decodeBasic(req: IncomingMessage): { username: string; password: string } | null {
|
||||
const auth = firstHeader(req.headers.authorization);
|
||||
if (auth.startsWith("Bearer ")) {
|
||||
return auth.slice("Bearer ".length).trim();
|
||||
}
|
||||
if (auth.startsWith("Basic ")) {
|
||||
const decoded = Buffer.from(auth.slice("Basic ".length), "base64").toString("utf8");
|
||||
const separator = decoded.indexOf(":");
|
||||
return separator >= 0 ? decoded.slice(separator + 1) : decoded;
|
||||
}
|
||||
const protocolToken = requestExtensionProtocolToken(req);
|
||||
if (protocolToken) {
|
||||
return protocolToken;
|
||||
if (!auth.startsWith("Basic ")) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
return url.searchParams.get("token") ?? "";
|
||||
const decoded = Buffer.from(auth.slice("Basic ".length), "base64").toString("utf8");
|
||||
const separator = decoded.indexOf(":");
|
||||
return separator < 0
|
||||
? { username: "", password: decoded }
|
||||
: { username: decoded.slice(0, separator), password: decoded.slice(separator + 1) };
|
||||
} catch {
|
||||
return "";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthorized(req: IncomingMessage, token: string): boolean {
|
||||
const candidate = requestToken(req);
|
||||
return candidate.length > 0 && safeEqualSecret(token, candidate);
|
||||
function isAuthorizedInternal(req: IncomingMessage, internalToken: string): boolean {
|
||||
const basic = decodeBasic(req);
|
||||
return (
|
||||
basic?.username === INTERNAL_CDP_USERNAME && safeEqualSecret(internalToken, basic.password)
|
||||
);
|
||||
}
|
||||
|
||||
/** Reject cross-origin websocket upgrades; the extension side must come from Chrome. */
|
||||
export function isAllowedExtensionOrigin(req: IncomingMessage): boolean {
|
||||
const origin = firstHeader(req.headers.origin);
|
||||
// Chrome MV3 service workers send their chrome-extension:// origin. Absent
|
||||
// origin is allowed for non-browser clients such as tests and diagnostics.
|
||||
return origin === "" || origin.startsWith("chrome-extension://");
|
||||
function isAuthorizedLegacy(
|
||||
req: IncomingMessage,
|
||||
token: string,
|
||||
allowLegacyAuth: boolean,
|
||||
): boolean {
|
||||
if (!allowLegacyAuth) {
|
||||
return false;
|
||||
}
|
||||
const auth = firstHeader(req.headers.authorization);
|
||||
if (auth.startsWith("Bearer ") && safeEqualSecret(token, auth.slice("Bearer ".length).trim())) {
|
||||
return true;
|
||||
}
|
||||
const basic = decodeBasic(req);
|
||||
if (basic && safeEqualSecret(token, basic.password)) {
|
||||
return true;
|
||||
}
|
||||
const protocolToken = requestExtensionProtocolToken(req);
|
||||
return protocolToken.length > 0 && safeEqualSecret(token, protocolToken);
|
||||
}
|
||||
|
||||
/** Reject DNS-rebinding style requests that reach loopback with a foreign Host. */
|
||||
function hasLoopbackHostHeader(req: IncomingMessage): boolean {
|
||||
const host = firstHeader(req.headers.host);
|
||||
if (!host) {
|
||||
@@ -112,17 +129,228 @@ function hasLoopbackHostHeader(req: IncomingMessage): boolean {
|
||||
}
|
||||
|
||||
function destroySocket(socket: Duplex, response: string): void {
|
||||
socket.write(response);
|
||||
socket.destroy();
|
||||
try {
|
||||
socket.write(response);
|
||||
} finally {
|
||||
socket.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(
|
||||
res: ServerResponse,
|
||||
status: number,
|
||||
value: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
): void {
|
||||
const body = JSON.stringify(value);
|
||||
res.writeHead(status, {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": String(Buffer.byteLength(body)),
|
||||
...headers,
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function rejectHttp(res: ServerResponse, status: number, message: string): void {
|
||||
res.once("finish", () => res.socket?.destroy());
|
||||
writeJson(res, status, { error: message }, { Connection: "close" });
|
||||
}
|
||||
|
||||
async function readAuthBody(req: IncomingMessage): Promise<string | null> {
|
||||
let body = "";
|
||||
for await (const chunk of req) {
|
||||
body += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
|
||||
if (Buffer.byteLength(body) > MAX_AUTH_BODY_BYTES) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function bindSocket(
|
||||
ws: WebSocket,
|
||||
handlers: { onMessage: (raw: string) => void; onClose: () => void },
|
||||
): void {
|
||||
ws.on("message", (data) => handlers.onMessage(rawDataToString(data)));
|
||||
ws.on("close", handlers.onClose);
|
||||
ws.on("error", (err) => log.warn(`relay socket error: ${String(err)}`));
|
||||
}
|
||||
|
||||
function trackAuthenticatedSocket(authority: BrowserRelayAuthV2Authority, ws: WebSocket): boolean {
|
||||
if (
|
||||
!authority.registerAuthenticatedConnection(ws, () =>
|
||||
ws.close(4003, "browser relay key rotated"),
|
||||
)
|
||||
) {
|
||||
ws.terminate();
|
||||
return false;
|
||||
}
|
||||
ws.once("close", () => authority.releaseConnection(ws));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Wire an already-v2-authenticated extension socket to the bridge. */
|
||||
export function attachExtensionWebSocket(bridge: ExtensionRelayBridge, ws: WebSocket): void {
|
||||
const handlers = bridge.attachExtensionSocket(ws);
|
||||
let helloSeen = false;
|
||||
const helloTimer = setTimeout(() => {
|
||||
ws.close(4008, "extension hello timeout");
|
||||
ws.terminate();
|
||||
}, BROWSER_RELAY_CHALLENGE_TTL_MS);
|
||||
helloTimer.unref?.();
|
||||
bindSocket(ws, {
|
||||
onMessage: (raw) => {
|
||||
if (!helloSeen && parseExtensionMessage(raw)?.type === "hello") {
|
||||
helloSeen = true;
|
||||
clearTimeout(helloTimer);
|
||||
}
|
||||
handlers.onMessage(raw);
|
||||
},
|
||||
onClose: () => {
|
||||
clearTimeout(helloTimer);
|
||||
handlers.onClose();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function authenticateExtensionWebSocket(params: {
|
||||
ws: WebSocket;
|
||||
authority: BrowserRelayAuthV2Authority;
|
||||
resource: string;
|
||||
prepareAuthenticated: () => Promise<() => void>;
|
||||
removePreAuthGuard?: () => void;
|
||||
}): void {
|
||||
const { ws, authority } = params;
|
||||
let stage: "hello" | "response" | "authenticated" | "failed" = "hello";
|
||||
let preAuthGuardActive = true;
|
||||
const removePreAuthGuard = () => {
|
||||
if (!preAuthGuardActive) {
|
||||
return;
|
||||
}
|
||||
preAuthGuardActive = false;
|
||||
params.removePreAuthGuard?.();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
stage = "failed";
|
||||
ws.off("message", onMessage);
|
||||
ws.close(4008, "browser relay auth timeout");
|
||||
ws.terminate();
|
||||
}, BROWSER_RELAY_CHALLENGE_TTL_MS);
|
||||
timer.unref?.();
|
||||
const release = () => {
|
||||
clearTimeout(timer);
|
||||
removePreAuthGuard();
|
||||
authority.releaseConnection(ws);
|
||||
};
|
||||
if (
|
||||
!authority.registerPendingConnection(ws, () => {
|
||||
ws.close(4003, "browser relay key rotated");
|
||||
})
|
||||
) {
|
||||
clearTimeout(timer);
|
||||
ws.close(4013, "browser relay auth capacity reached");
|
||||
return;
|
||||
}
|
||||
ws.once("close", release);
|
||||
const fail = (code: number, reason: string) => {
|
||||
if (stage === "failed") {
|
||||
return;
|
||||
}
|
||||
stage = "failed";
|
||||
clearTimeout(timer);
|
||||
ws.off("message", onMessage);
|
||||
ws.close(code, reason);
|
||||
const terminateTimer = setTimeout(() => ws.terminate(), 100);
|
||||
terminateTimer.unref?.();
|
||||
};
|
||||
const onMessage = (data: RawData, isBinary: boolean) => {
|
||||
if (isBinary) {
|
||||
fail(4003, "binary browser relay auth frames are not allowed");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
boundedRawDataByteLength(data, MAX_WEBSOCKET_AUTH_MESSAGE_BYTES) >
|
||||
MAX_WEBSOCKET_AUTH_MESSAGE_BYTES
|
||||
) {
|
||||
fail(4003, "browser relay auth frame is too large");
|
||||
return;
|
||||
}
|
||||
const raw = rawDataToString(data);
|
||||
const parsed = parseStrictJsonObject(raw);
|
||||
if (stage === "hello") {
|
||||
const hello = parseRelayAuthHello(parsed);
|
||||
if (!hello) {
|
||||
fail(4003, "invalid browser relay auth hello");
|
||||
return;
|
||||
}
|
||||
const challenge = authority.issueChallenge(ws, hello, {
|
||||
role: "extension",
|
||||
transport: "websocket",
|
||||
method: "GET",
|
||||
resource: params.resource,
|
||||
flow: "extension",
|
||||
});
|
||||
if (!challenge) {
|
||||
fail(4003, "browser relay auth rejected");
|
||||
return;
|
||||
}
|
||||
stage = "response";
|
||||
ws.send(JSON.stringify(challenge));
|
||||
return;
|
||||
}
|
||||
if (stage === "response") {
|
||||
const response = parseRelayAuthResponse(parsed);
|
||||
if (!response) {
|
||||
fail(4003, "invalid browser relay auth response");
|
||||
return;
|
||||
}
|
||||
const completed = authority.completeChallenge(ws, response);
|
||||
if (!completed) {
|
||||
fail(4003, "browser relay auth proof failed");
|
||||
return;
|
||||
}
|
||||
stage = "authenticated";
|
||||
// The proof deadline owns only challenge completion. Promotion is now
|
||||
// authoritative, so cold Browser/Gateway preparation must not race it.
|
||||
clearTimeout(timer);
|
||||
removePreAuthGuard();
|
||||
void params
|
||||
.prepareAuthenticated()
|
||||
.then((attach) => {
|
||||
if (ws.readyState !== 1) {
|
||||
return;
|
||||
}
|
||||
ws.off("message", onMessage);
|
||||
attach();
|
||||
ws.send(JSON.stringify(completed.ok), (err) => {
|
||||
if (err) {
|
||||
ws.close(1011, "browser relay auth acknowledgement failed");
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
log.warn(`browser relay post-auth preparation failed: ${String(err)}`);
|
||||
fail(1011, "browser relay unavailable after authentication");
|
||||
});
|
||||
return;
|
||||
}
|
||||
fail(4003, "unexpected browser relay auth frame");
|
||||
};
|
||||
ws.on("message", onMessage);
|
||||
}
|
||||
|
||||
/** Start the relay server for one extension-driver profile. */
|
||||
export async function startExtensionRelayServer(params: {
|
||||
port: number;
|
||||
token: string;
|
||||
allowLegacyAuth?: boolean;
|
||||
onStateChange?: () => void;
|
||||
onPageShare?: (payload: PageSharePayload) => Promise<void>;
|
||||
}): Promise<ExtensionRelayHandle> {
|
||||
const allowLegacyAuth = params.allowLegacyAuth ?? true;
|
||||
const internalToken = crypto.randomBytes(32).toString("base64url");
|
||||
if (readExtensionRelayToken() === params.token) {
|
||||
getBrowserRelayAuthV2Authority(params.token);
|
||||
}
|
||||
const bridge = new ExtensionRelayBridge({
|
||||
onStateChange: params.onStateChange,
|
||||
onPageShare: params.onPageShare,
|
||||
@@ -131,77 +359,330 @@ export async function startExtensionRelayServer(params: {
|
||||
noServer: true,
|
||||
maxPayload: EXTENSION_RELAY_MAX_PAYLOAD_BYTES,
|
||||
});
|
||||
const httpStates = new WeakMap<Duplex, HttpAuthState>();
|
||||
const socketAuthorities = new WeakMap<Duplex, BrowserRelayAuthV2Authority>();
|
||||
const authSockets = new Set<Duplex>();
|
||||
|
||||
const currentAuthority = (): BrowserRelayAuthV2Authority | null => {
|
||||
const liveToken = readExtensionRelayToken();
|
||||
if (!liveToken) {
|
||||
invalidateBrowserRelayAuthV2Authority();
|
||||
return null;
|
||||
}
|
||||
return getBrowserRelayAuthV2Authority(liveToken);
|
||||
};
|
||||
|
||||
const clearSocketState = (socket: Duplex) => {
|
||||
const state = httpStates.get(socket);
|
||||
if (state && "timer" in state) {
|
||||
clearTimeout(state.timer);
|
||||
}
|
||||
httpStates.delete(socket);
|
||||
authSockets.delete(socket);
|
||||
const authority = socketAuthorities.get(socket);
|
||||
socketAuthorities.delete(socket);
|
||||
authority?.releaseConnection(socket);
|
||||
};
|
||||
const armSocketTimer = (socket: Duplex): NodeJS.Timeout => {
|
||||
const timer = setTimeout(() => socket.destroy(), BROWSER_RELAY_CHALLENGE_TTL_MS);
|
||||
timer.unref?.();
|
||||
return timer;
|
||||
};
|
||||
const registerHttpSocket = (socket: Duplex, authority: BrowserRelayAuthV2Authority): boolean => {
|
||||
if (authSockets.has(socket)) {
|
||||
return true;
|
||||
}
|
||||
if (!authority.registerPendingConnection(socket, () => socket.destroy())) {
|
||||
return false;
|
||||
}
|
||||
authSockets.add(socket);
|
||||
socketAuthorities.set(socket, authority);
|
||||
socket.once("close", () => clearSocketState(socket));
|
||||
return true;
|
||||
};
|
||||
|
||||
const versionPayload = () => ({
|
||||
Browser: bridge.identity?.browserVersion ?? "Chrome/unknown",
|
||||
"Protocol-Version": "1.3",
|
||||
"User-Agent": bridge.identity?.userAgent ?? "unknown",
|
||||
webSocketDebuggerUrl: `ws://127.0.0.1:${resolvedPort()}/cdp`,
|
||||
});
|
||||
|
||||
const server: Server = http.createServer((req, res) => {
|
||||
if (!hasLoopbackHostHeader(req)) {
|
||||
res.writeHead(403).end("Forbidden");
|
||||
return;
|
||||
}
|
||||
if (!isAuthorized(req, params.token)) {
|
||||
res.writeHead(401, { "WWW-Authenticate": 'Basic realm="openclaw-extension-relay"' });
|
||||
res.end("Unauthorized");
|
||||
return;
|
||||
}
|
||||
const path = (req.url ?? "/").split("?")[0];
|
||||
if (req.method === "GET" && (path === "/json/version" || path === "/json/version/")) {
|
||||
if (!bridge.extensionConnected) {
|
||||
res.writeHead(503, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error:
|
||||
"OpenClaw Chrome extension is not connected. Install the extension and pair it with `openclaw browser extension pair`.",
|
||||
}),
|
||||
);
|
||||
void (async () => {
|
||||
if (!hasLoopbackHostHeader(req)) {
|
||||
rejectHttp(res, 403, "Forbidden");
|
||||
return;
|
||||
}
|
||||
const identity = bridge.identity;
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
Browser: identity?.browserVersion ?? "Chrome/unknown",
|
||||
"Protocol-Version": "1.3",
|
||||
"User-Agent": identity?.userAgent ?? "unknown",
|
||||
webSocketDebuggerUrl: `ws://127.0.0.1:${resolvedPort()}/cdp`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && (path === "/json" || path === "/json/list")) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(bridge.devtoolsTargetDescriptors()));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end("Not found");
|
||||
const path = (req.url ?? "/").split("?")[0];
|
||||
const socket = req.socket;
|
||||
const existingState = httpStates.get(socket);
|
||||
const authority = currentAuthority();
|
||||
|
||||
if (path === BROWSER_RELAY_AUTH_CHALLENGE_PATH) {
|
||||
if (
|
||||
req.url !== BROWSER_RELAY_AUTH_CHALLENGE_PATH ||
|
||||
req.method !== "POST" ||
|
||||
existingState ||
|
||||
!authority ||
|
||||
!registerHttpSocket(socket, authority)
|
||||
) {
|
||||
rejectHttp(res, existingState ? 409 : 400, "Invalid relay auth sequence");
|
||||
return;
|
||||
}
|
||||
const pending: HttpAuthState = { stage: "busy" };
|
||||
httpStates.set(socket, pending);
|
||||
const raw = await readAuthBody(req);
|
||||
const request =
|
||||
raw === null ? null : parseRelayHttpChallengeRequest(parseStrictJsonObject(raw));
|
||||
if (!request || request.keyId !== authority.keyId) {
|
||||
clearSocketState(socket);
|
||||
rejectHttp(res, 400, "Invalid relay auth challenge request");
|
||||
return;
|
||||
}
|
||||
const challenge = authority.issueChallenge(
|
||||
socket,
|
||||
{ type: "auth.hello", v: 2, keyId: request.keyId, clientNonce: request.clientNonce },
|
||||
{
|
||||
role: request.role,
|
||||
transport: request.transport,
|
||||
method: request.method,
|
||||
resource: request.resource,
|
||||
flow: request.flow,
|
||||
},
|
||||
);
|
||||
if (!challenge) {
|
||||
clearSocketState(socket);
|
||||
rejectHttp(res, 401, "Relay auth challenge rejected");
|
||||
return;
|
||||
}
|
||||
res.once("finish", () => {
|
||||
if (!socket.destroyed && httpStates.get(socket) === pending) {
|
||||
httpStates.set(socket, {
|
||||
stage: "challenged",
|
||||
flow: request.flow,
|
||||
authority,
|
||||
timer: armSocketTimer(socket),
|
||||
});
|
||||
}
|
||||
});
|
||||
writeJson(res, 200, challenge);
|
||||
return;
|
||||
}
|
||||
|
||||
if (path === BROWSER_RELAY_AUTH_COMPLETE_PATH) {
|
||||
if (
|
||||
req.url !== BROWSER_RELAY_AUTH_COMPLETE_PATH ||
|
||||
req.method !== "POST" ||
|
||||
existingState?.stage !== "challenged"
|
||||
) {
|
||||
rejectHttp(res, 409, "Invalid relay auth sequence");
|
||||
return;
|
||||
}
|
||||
clearTimeout(existingState.timer);
|
||||
const pending: HttpAuthState = { stage: "busy" };
|
||||
httpStates.set(socket, pending);
|
||||
const raw = await readAuthBody(req);
|
||||
const request =
|
||||
raw === null ? null : parseRelayHttpCompleteRequest(parseStrictJsonObject(raw));
|
||||
const completed = request
|
||||
? existingState.authority.completeChallenge(socket, {
|
||||
type: "auth.response",
|
||||
...request,
|
||||
})
|
||||
: null;
|
||||
if (!completed) {
|
||||
clearSocketState(socket);
|
||||
rejectHttp(res, 401, "Relay auth proof failed");
|
||||
return;
|
||||
}
|
||||
res.once("finish", () => {
|
||||
if (!socket.destroyed && httpStates.get(socket) === pending) {
|
||||
httpStates.set(socket, {
|
||||
stage: "authenticated",
|
||||
flow: existingState.flow,
|
||||
authority: existingState.authority,
|
||||
timer: armSocketTimer(socket),
|
||||
});
|
||||
}
|
||||
});
|
||||
writeJson(res, 200, completed.ok);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingState?.stage === "authenticated") {
|
||||
clearTimeout(existingState.timer);
|
||||
const pending: HttpAuthState = { stage: "busy" };
|
||||
httpStates.set(socket, pending);
|
||||
if (existingState.flow === "cdp" && req.method === "GET" && req.url === "/json/version") {
|
||||
if (!bridge.extensionConnected) {
|
||||
clearSocketState(socket);
|
||||
rejectHttp(res, 503, "OpenClaw Chrome extension is not connected");
|
||||
return;
|
||||
}
|
||||
res.once("finish", () => {
|
||||
if (!socket.destroyed && httpStates.get(socket) === pending) {
|
||||
httpStates.set(socket, {
|
||||
stage: "awaiting-upgrade",
|
||||
authority: existingState.authority,
|
||||
timer: armSocketTimer(socket),
|
||||
});
|
||||
}
|
||||
});
|
||||
writeJson(res, 200, versionPayload());
|
||||
return;
|
||||
}
|
||||
if (
|
||||
existingState.flow === "json-list" &&
|
||||
req.method === "GET" &&
|
||||
req.url === "/json/list"
|
||||
) {
|
||||
clearSocketState(socket);
|
||||
res.once("finish", () => socket.destroy());
|
||||
writeJson(res, 200, bridge.devtoolsTargetDescriptors(), { Connection: "close" });
|
||||
return;
|
||||
}
|
||||
clearSocketState(socket);
|
||||
rejectHttp(res, 409, "Invalid relay auth sequence");
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingState) {
|
||||
clearSocketState(socket);
|
||||
rejectHttp(res, 409, "Invalid relay auth sequence");
|
||||
return;
|
||||
}
|
||||
|
||||
const legacyOrInternal =
|
||||
isAuthorizedInternal(req, internalToken) ||
|
||||
(authority !== null &&
|
||||
isAuthorizedLegacy(req, readExtensionRelayToken() ?? "", allowLegacyAuth));
|
||||
if (!legacyOrInternal) {
|
||||
rejectHttp(res, 401, "Unauthorized");
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && (path === "/json/version" || path === "/json/version/")) {
|
||||
if (!bridge.extensionConnected) {
|
||||
writeJson(res, 503, {
|
||||
error:
|
||||
"OpenClaw Chrome extension is not connected. Install the extension and pair it with `openclaw browser extension pair`.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
writeJson(res, 200, versionPayload());
|
||||
return;
|
||||
}
|
||||
if (req.method === "GET" && (path === "/json" || path === "/json/list")) {
|
||||
writeJson(res, 200, bridge.devtoolsTargetDescriptors());
|
||||
return;
|
||||
}
|
||||
rejectHttp(res, 404, "Not found");
|
||||
})().catch((err: unknown) => {
|
||||
log.warn(`relay HTTP request failed: ${String(err)}`);
|
||||
if (!res.headersSent) {
|
||||
rejectHttp(res, 500, "Relay request failed");
|
||||
} else {
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
const path = (req.url ?? "/").split("?")[0];
|
||||
if (!hasLoopbackHostHeader(req)) {
|
||||
destroySocket(socket, "HTTP/1.1 403 Forbidden\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
if (!isAuthorized(req, params.token)) {
|
||||
destroySocket(socket, "HTTP/1.1 401 Unauthorized\r\n\r\n");
|
||||
destroySocket(socket, "HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
if (path === "/extension") {
|
||||
if (!isAllowedExtensionOrigin(req)) {
|
||||
destroySocket(socket, "HTTP/1.1 403 Forbidden\r\n\r\n");
|
||||
destroySocket(socket, "HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
const protocols = requestProtocols(req);
|
||||
const resource = parseExtensionRelayResource(req.url ?? "/", "/extension");
|
||||
if (
|
||||
protocols.length === 1 &&
|
||||
protocols[0] === BROWSER_RELAY_EXTENSION_SUBPROTOCOL &&
|
||||
resource
|
||||
) {
|
||||
const authority = currentAuthority();
|
||||
if (!authority) {
|
||||
destroySocket(socket, "HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!handlePreAuthWebSocketUpgrade({
|
||||
wss,
|
||||
req,
|
||||
socket,
|
||||
head,
|
||||
onUpgrade: (ws, removePreAuthGuard) => {
|
||||
authenticateExtensionWebSocket({
|
||||
ws,
|
||||
authority,
|
||||
resource,
|
||||
removePreAuthGuard,
|
||||
prepareAuthenticated: async () => () => {
|
||||
attachExtensionWebSocket(bridge, ws);
|
||||
log.info("extension authenticated and connected to relay");
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
) {
|
||||
destroySocket(socket, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (protocols.includes(BROWSER_RELAY_EXTENSION_SUBPROTOCOL)) {
|
||||
destroySocket(socket, "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
const liveToken = readExtensionRelayToken();
|
||||
if (!liveToken || !isAuthorizedLegacy(req, liveToken, allowLegacyAuth)) {
|
||||
destroySocket(socket, "HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
const authority = getBrowserRelayAuthV2Authority(liveToken);
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
if (!trackAuthenticatedSocket(authority, ws)) {
|
||||
return;
|
||||
}
|
||||
attachExtensionWebSocket(bridge, ws);
|
||||
log.info("extension connected to relay");
|
||||
log.warn("legacy extension relay authentication accepted");
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (path === "/cdp") {
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
bindSocket(ws, bridge.attachCdpClientSocket(ws));
|
||||
});
|
||||
const state = httpStates.get(socket);
|
||||
if (req.url === "/cdp" && state?.stage === "awaiting-upgrade") {
|
||||
clearTimeout(state.timer);
|
||||
httpStates.delete(socket);
|
||||
wss.handleUpgrade(req, socket, head, (ws) =>
|
||||
bindSocket(ws, bridge.attachCdpClientSocket(ws)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!isAuthorizedInternal(req, internalToken) &&
|
||||
!isAuthorizedLegacy(req, readExtensionRelayToken() ?? "", allowLegacyAuth)
|
||||
) {
|
||||
destroySocket(socket, "HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
const authority = currentAuthority();
|
||||
if (!authority) {
|
||||
destroySocket(socket, "HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) =>
|
||||
trackAuthenticatedSocket(authority, ws)
|
||||
? bindSocket(ws, bridge.attachCdpClientSocket(ws))
|
||||
: undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
destroySocket(socket, "HTTP/1.1 404 Not Found\r\n\r\n");
|
||||
destroySocket(socket, "HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -217,8 +698,17 @@ export async function startExtensionRelayServer(params: {
|
||||
return {
|
||||
port: resolvedPort(),
|
||||
token: params.token,
|
||||
allowLegacyAuth,
|
||||
internalToken,
|
||||
bridge,
|
||||
close: async () => {
|
||||
for (const socket of authSockets) {
|
||||
clearSocketState(socket);
|
||||
socket.destroy();
|
||||
}
|
||||
for (const client of wss.clients) {
|
||||
client.terminate();
|
||||
}
|
||||
bridge.dispose();
|
||||
wss.close();
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -227,16 +717,3 @@ export async function startExtensionRelayServer(params: {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function bindSocket(
|
||||
ws: WebSocket,
|
||||
handlers: { onMessage: (raw: string) => void; onClose: () => void },
|
||||
): void {
|
||||
ws.on("message", (data) => {
|
||||
handlers.onMessage(rawDataToString(data));
|
||||
});
|
||||
ws.on("close", handlers.onClose);
|
||||
ws.on("error", (err) => {
|
||||
log.warn(`relay socket error: ${String(err)}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export async function createBrowserRuntimeState(
|
||||
profiles: new Map(),
|
||||
};
|
||||
const stopTrackedTabCleanup = startTrackedBrowserTabCleanupTimer({
|
||||
getResolvedBrowserConfig: () => state.resolved,
|
||||
onWarn: params.onWarn,
|
||||
});
|
||||
trackedTabCleanupDisposers.set(state, stopTrackedTabCleanup);
|
||||
|
||||
@@ -25,7 +25,10 @@ const {
|
||||
} = vi.hoisted(() => {
|
||||
const trackedTabCleanupMockLocal = vi.fn();
|
||||
return {
|
||||
startTrackedBrowserTabCleanupTimerMock: vi.fn(() => trackedTabCleanupMockLocal),
|
||||
startTrackedBrowserTabCleanupTimerMock: vi.fn(
|
||||
(_params: { getResolvedBrowserConfig?: () => unknown; onWarn: (message: string) => void }) =>
|
||||
trackedTabCleanupMockLocal,
|
||||
),
|
||||
stopKnownBrowserProfilesMock: vi.fn(async () => {}),
|
||||
trackedTabCleanupMock: trackedTabCleanupMockLocal,
|
||||
};
|
||||
@@ -54,6 +57,26 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("browser unhandled rejection lifecycle", () => {
|
||||
it("binds periodic cleanup to the current runtime config", async () => {
|
||||
const firstResolved = { profiles: {}, marker: "first" } as never;
|
||||
const secondResolved = { profiles: {}, marker: "second" } as never;
|
||||
const state = await createBrowserRuntimeState({
|
||||
resolved: firstResolved,
|
||||
port: 18791,
|
||||
onWarn: vi.fn(),
|
||||
});
|
||||
const cleanupParams = startTrackedBrowserTabCleanupTimerMock.mock.calls[0]?.[0];
|
||||
expect(cleanupParams?.getResolvedBrowserConfig?.()).toBe(firstResolved);
|
||||
state.resolved = secondResolved;
|
||||
expect(cleanupParams?.getResolvedBrowserConfig?.()).toBe(secondResolved);
|
||||
await stopBrowserRuntime({
|
||||
current: state,
|
||||
getState: () => state,
|
||||
clearState: vi.fn(),
|
||||
onWarn: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("matches direct and nested Playwright dialog-race protocol errors", async () => {
|
||||
const state = await createBrowserRuntimeState({
|
||||
resolved: { profiles: {} } as never,
|
||||
|
||||
@@ -61,6 +61,8 @@ function makeState(): BrowserServerState {
|
||||
cdpPortRangeEnd: 18899,
|
||||
extensionRelayDefaultPort: 18799,
|
||||
extensionRelayPorts: {},
|
||||
extensionRelay: { allowLegacyAuth: true },
|
||||
extensionRelayInternalTokens: {},
|
||||
cdpProtocol: "http",
|
||||
cdpHost: "127.0.0.1",
|
||||
cdpIsLoopback: true,
|
||||
|
||||
@@ -27,6 +27,8 @@ export function makeState(
|
||||
cdpPortRangeEnd: 18899,
|
||||
extensionRelayDefaultPort: 18799,
|
||||
extensionRelayPorts: {},
|
||||
extensionRelay: { allowLegacyAuth: true },
|
||||
extensionRelayInternalTokens: {},
|
||||
cdpProtocol: profile === "remote" ? "https" : "http",
|
||||
cdpHost: profile === "remote" ? "1.1.1.1" : "127.0.0.1",
|
||||
cdpIsLoopback: profile !== "remote",
|
||||
|
||||
@@ -41,6 +41,8 @@ export function makeBrowserServerState(params?: {
|
||||
cdpPortRangeEnd: 18810,
|
||||
extensionRelayDefaultPort: 18808,
|
||||
extensionRelayPorts: {},
|
||||
extensionRelay: { allowLegacyAuth: true },
|
||||
extensionRelayInternalTokens: {},
|
||||
evaluateEnabled: false,
|
||||
remoteCdpTimeoutMs: 1500,
|
||||
remoteCdpHandshakeTimeoutMs: 3000,
|
||||
|
||||
@@ -54,4 +54,21 @@ describe("session tab cleanup timer", () => {
|
||||
|
||||
await stop();
|
||||
});
|
||||
|
||||
it("forwards the live runtime config resolver to every periodic sweep", async () => {
|
||||
registryMocks.sweepTrackedBrowserTabs.mockResolvedValue(0);
|
||||
const resolved = { profiles: {} } as never;
|
||||
const getResolvedBrowserConfig = vi.fn(() => resolved);
|
||||
const stop = startTrackedBrowserTabCleanupTimer({
|
||||
getResolvedBrowserConfig,
|
||||
onWarn: vi.fn(),
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(300_000);
|
||||
await vi.waitFor(() => expect(registryMocks.sweepTrackedBrowserTabs).toHaveBeenCalledOnce());
|
||||
expect(registryMocks.sweepTrackedBrowserTabs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ getResolvedBrowserConfig }),
|
||||
);
|
||||
await stop();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,11 @@ import {
|
||||
isSubagentSessionKey,
|
||||
} from "openclaw/plugin-sdk/routing";
|
||||
import { getRuntimeConfig } from "../config/config.js";
|
||||
import { resolveBrowserConfig, type ResolvedBrowserTabCleanupConfig } from "./config.js";
|
||||
import {
|
||||
resolveBrowserConfig,
|
||||
type ResolvedBrowserConfig,
|
||||
type ResolvedBrowserTabCleanupConfig,
|
||||
} from "./config.js";
|
||||
import { sweepTrackedBrowserTabs } from "./session-tab-registry.js";
|
||||
|
||||
const MIN_SWEEP_INTERVAL_MS = 60_000;
|
||||
@@ -35,6 +39,7 @@ async function runTrackedBrowserTabCleanupOnce(params?: {
|
||||
now?: number;
|
||||
cleanup?: ResolvedBrowserTabCleanupConfig;
|
||||
closeTab?: (tab: { targetId: string; baseUrl?: string; profile?: string }) => Promise<void>;
|
||||
getResolvedBrowserConfig?: () => ResolvedBrowserConfig | null;
|
||||
onWarn?: (message: string) => void;
|
||||
}): Promise<number> {
|
||||
const cleanup = params?.cleanup ?? resolveBrowserTabCleanupRuntimeConfig();
|
||||
@@ -47,12 +52,14 @@ async function runTrackedBrowserTabCleanupOnce(params?: {
|
||||
maxTabsPerSession: cleanup.maxTabsPerSession,
|
||||
sessionFilter: isPrimaryTrackedBrowserSessionKey,
|
||||
closeTab: params?.closeTab,
|
||||
getResolvedBrowserConfig: params?.getResolvedBrowserConfig,
|
||||
onWarn: params?.onWarn,
|
||||
});
|
||||
}
|
||||
|
||||
/** Starts the recurring Browser tab cleanup timer and returns its disposer. */
|
||||
export function startTrackedBrowserTabCleanupTimer(params: {
|
||||
getResolvedBrowserConfig?: () => ResolvedBrowserConfig | null;
|
||||
onWarn: (message: string) => void;
|
||||
}): () => Promise<void> {
|
||||
let stopped = false;
|
||||
@@ -78,7 +85,10 @@ export function startTrackedBrowserTabCleanupTimer(params: {
|
||||
return;
|
||||
}
|
||||
if (!running) {
|
||||
running = runTrackedBrowserTabCleanupOnce({ onWarn: params.onWarn })
|
||||
running = runTrackedBrowserTabCleanupOnce({
|
||||
getResolvedBrowserConfig: params.getResolvedBrowserConfig,
|
||||
onWarn: params.onWarn,
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
params.onWarn(`failed to sweep tracked browser tabs: ${String(error)}`);
|
||||
})
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// Browser tests cover extension-tab cleanup through live runtime-owned credentials.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type {
|
||||
OpenKeyedStoreOptions,
|
||||
PluginStateSyncKeyedStore,
|
||||
} from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import {
|
||||
createPluginStateKeyedStoreForTests,
|
||||
createPluginStateSyncKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import {
|
||||
clearRuntimeConfigSnapshot,
|
||||
setRuntimeConfigSnapshot,
|
||||
} from "openclaw/plugin-sdk/runtime-config-snapshot";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerBrowserPlugin } from "../../plugin-registration.js";
|
||||
import type { OpenClawPluginApi } from "../../runtime-api.js";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test-support.js";
|
||||
import type { CloseTrackedCdpTargetResult } from "./cdp.helpers.js";
|
||||
import { resolveBrowserConfig, type ResolvedBrowserConfig } from "./config.js";
|
||||
import { BROWSER_TAB_UNREACHABLE_RETIRE_MS } from "./constants.js";
|
||||
import { durableOwnership } from "./session-tab-registry.sqlite.test-helpers.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
const cdpMocks = vi.hoisted(() => ({
|
||||
closeTrackedCdpTarget: vi.fn<() => Promise<CloseTrackedCdpTargetResult>>(),
|
||||
}));
|
||||
|
||||
vi.mock("./cdp.helpers.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./cdp.helpers.js")>()),
|
||||
closeTrackedCdpTarget: cdpMocks.closeTrackedCdpTarget,
|
||||
}));
|
||||
|
||||
import {
|
||||
closeTrackedBrowserTabsForSessions,
|
||||
sweepTrackedBrowserTabs,
|
||||
trackSessionBrowserTab,
|
||||
} from "./session-tab-registry.js";
|
||||
|
||||
const config = {
|
||||
browser: {
|
||||
defaultProfile: "chrome",
|
||||
profiles: {
|
||||
chrome: {
|
||||
driver: "extension",
|
||||
cdpPort: 18_799,
|
||||
color: "#123456",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
function clearProcessLocalTabState(): void {
|
||||
const state = globalThis as Record<symbol, unknown>;
|
||||
for (const name of [
|
||||
"openclaw.browser.session-tabs.volatile",
|
||||
"openclaw.browser.session-tabs.active-durable-keys",
|
||||
"openclaw.browser.session-tabs.cold-native-activity",
|
||||
"openclaw.browser.session-tabs.interaction-storage-keys",
|
||||
"openclaw.browser.session-tabs.exact-interaction-storage-keys",
|
||||
"openclaw.browser.session-tabs.volatile-aliases",
|
||||
"openclaw.browser.session-tabs.exact-volatile-aliases",
|
||||
]) {
|
||||
delete state[Symbol.for(name)];
|
||||
}
|
||||
}
|
||||
|
||||
function installRuntime(): void {
|
||||
registerBrowserPlugin(
|
||||
createTestPluginApi({
|
||||
id: "browser",
|
||||
name: "Browser",
|
||||
source: "test",
|
||||
rootDir: "/plugins/browser",
|
||||
config: {},
|
||||
runtime: {
|
||||
state: {
|
||||
openKeyedStore: (options: OpenKeyedStoreOptions) =>
|
||||
createPluginStateKeyedStoreForTests("browser", options),
|
||||
openSyncKeyedStore: (options: OpenKeyedStoreOptions) =>
|
||||
createPluginStateSyncKeyedStoreForTests("browser", options),
|
||||
},
|
||||
} as unknown as OpenClawPluginApi["runtime"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function openStore(): PluginStateSyncKeyedStore<unknown> {
|
||||
return createPluginStateSyncKeyedStoreForTests("browser", {
|
||||
namespace: "browser.session-tabs",
|
||||
maxEntries: 5_000,
|
||||
overflowPolicy: "reject-new",
|
||||
});
|
||||
}
|
||||
|
||||
describe("durable extension session tab cleanup", () => {
|
||||
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
let resolved: ResolvedBrowserConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearProcessLocalTabState();
|
||||
process.env.OPENCLAW_STATE_DIR = tempDirs.make("openclaw-browser-extension-tabs-");
|
||||
resetPluginStateStoreForTests();
|
||||
installRuntime();
|
||||
openStore().clear();
|
||||
cdpMocks.closeTrackedCdpTarget.mockReset().mockResolvedValue({ status: "closed" });
|
||||
setRuntimeConfigSnapshot(config, config);
|
||||
resolved = resolveBrowserConfig(config.browser, config);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearProcessLocalTabState();
|
||||
resetPluginStateStoreForTests();
|
||||
if (originalStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = originalStateDir;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the live process-only extension credential for lifecycle cleanup", async () => {
|
||||
expect(resolved.extensionRelayInternalTokens).toEqual({});
|
||||
trackSessionBrowserTab({
|
||||
sessionKey: "agent:main:main",
|
||||
targetId: "extension-tab",
|
||||
profile: "chrome",
|
||||
ownership: durableOwnership("NATIVE-EXTENSION"),
|
||||
now: 1_000,
|
||||
});
|
||||
const internalToken = "process-only-test-credential";
|
||||
const liveResolved: ResolvedBrowserConfig = {
|
||||
...resolved,
|
||||
extensionRelayInternalTokens: { chrome: internalToken },
|
||||
};
|
||||
expect(JSON.stringify(openStore().entries())).not.toContain(internalToken);
|
||||
|
||||
await expect(
|
||||
closeTrackedBrowserTabsForSessions({
|
||||
sessionKeys: ["agent:main:main"],
|
||||
getResolvedBrowserConfig: () => liveResolved,
|
||||
}),
|
||||
).resolves.toBe(1);
|
||||
expect(cdpMocks.closeTrackedCdpTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
profileName: "chrome",
|
||||
cdpUrl: `http://openclaw-internal:${internalToken}@127.0.0.1:18799`,
|
||||
nativeTargetId: "NATIVE-EXTENSION",
|
||||
}),
|
||||
);
|
||||
expect(openStore().entries()).toEqual([]);
|
||||
});
|
||||
|
||||
it("retains cleanup without a runtime and closes it after reconnect", async () => {
|
||||
trackSessionBrowserTab({
|
||||
sessionKey: "agent:main:main",
|
||||
targetId: "extension-tab",
|
||||
profile: "chrome",
|
||||
ownership: durableOwnership("NATIVE-EXTENSION"),
|
||||
now: 1_000,
|
||||
});
|
||||
let liveResolved: ResolvedBrowserConfig | null = null;
|
||||
const warnings: string[] = [];
|
||||
const getResolvedBrowserConfig = () => liveResolved;
|
||||
const afterRetireAge = 1_000 + BROWSER_TAB_UNREACHABLE_RETIRE_MS;
|
||||
|
||||
await expect(
|
||||
sweepTrackedBrowserTabs({
|
||||
now: afterRetireAge,
|
||||
idleMs: 1,
|
||||
getResolvedBrowserConfig,
|
||||
onWarn: (message) => warnings.push(message),
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
expect(cdpMocks.closeTrackedCdpTarget).not.toHaveBeenCalled();
|
||||
expect(openStore().entries()).toHaveLength(1);
|
||||
expect(warnings).toContain(
|
||||
"deferred tracked browser tab NATIVE-EXTENSION: extension relay runtime unavailable",
|
||||
);
|
||||
|
||||
const internalToken = "reconnected-process-only-credential";
|
||||
liveResolved = {
|
||||
...resolved,
|
||||
extensionRelayInternalTokens: { chrome: internalToken },
|
||||
};
|
||||
await expect(
|
||||
sweepTrackedBrowserTabs({
|
||||
now: afterRetireAge + 1,
|
||||
idleMs: 1,
|
||||
getResolvedBrowserConfig,
|
||||
}),
|
||||
).resolves.toBe(1);
|
||||
expect(cdpMocks.closeTrackedCdpTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cdpUrl: `http://openclaw-internal:${internalToken}@127.0.0.1:18799`,
|
||||
}),
|
||||
);
|
||||
expect(openStore().entries()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
// exported from production code.
|
||||
import type { CloseTrackedCdpTargetResult } from "./cdp.helpers.js";
|
||||
import type { BrowserTabOwnership } from "./client.types.js";
|
||||
import type { ResolvedBrowserConfig } from "./config.js";
|
||||
|
||||
type TabIdentity = {
|
||||
sessionKey?: string;
|
||||
@@ -45,6 +46,7 @@ type CleanupParams = {
|
||||
tab: DurableTab,
|
||||
options: { shouldClose: () => boolean },
|
||||
) => Promise<CloseTrackedCdpTargetResult>;
|
||||
getResolvedBrowserConfig?: () => ResolvedBrowserConfig | null;
|
||||
onWarn?: (message: string) => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { resolveCdpControlPolicy } from "./cdp-reachability-policy.js";
|
||||
import { closeTrackedCdpTarget, type CloseTrackedCdpTargetResult } from "./cdp.helpers.js";
|
||||
import { browserCloseTabByRawTargetId } from "./client.js";
|
||||
import type { BrowserTabOwnership } from "./client.types.js";
|
||||
import { resolveBrowserConfig, resolveProfile } from "./config.js";
|
||||
import { resolveBrowserConfig, resolveProfile, type ResolvedBrowserConfig } from "./config.js";
|
||||
import { BROWSER_TAB_UNREACHABLE_RETIRE_MS } from "./constants.js";
|
||||
import {
|
||||
type CleanupKind,
|
||||
@@ -78,6 +78,9 @@ type DurableTab = DurableRecord & {
|
||||
|
||||
type TrackedTab = VolatileTab | DurableTab;
|
||||
type DurableOwnership = Extract<BrowserTabOwnership, { status: "durable" }>;
|
||||
type DurableCleanupResult =
|
||||
| CloseTrackedCdpTargetResult
|
||||
| { status: "unavailable"; reason: "extension-relay-unavailable" };
|
||||
type CloseTab = (tab: {
|
||||
targetId: string;
|
||||
nativeTargetId?: string;
|
||||
@@ -90,6 +93,7 @@ type CloseParams = {
|
||||
tab: DurableTab,
|
||||
options: { shouldClose: () => boolean },
|
||||
) => Promise<CloseTrackedCdpTargetResult>;
|
||||
getResolvedBrowserConfig?: () => ResolvedBrowserConfig | null;
|
||||
onWarn?: (message: string) => void;
|
||||
};
|
||||
|
||||
@@ -473,13 +477,20 @@ export function untrackSessionBrowserTab(params: SessionTabParams): void {
|
||||
async function closeCurrentDurableTab(
|
||||
tab: DurableTab,
|
||||
shouldClose: () => boolean,
|
||||
): Promise<CloseTrackedCdpTargetResult> {
|
||||
const cfg = getRuntimeConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
getResolvedBrowserConfig?: () => ResolvedBrowserConfig | null,
|
||||
): Promise<DurableCleanupResult> {
|
||||
let resolved = getResolvedBrowserConfig?.();
|
||||
if (!resolved) {
|
||||
const cfg = getRuntimeConfig();
|
||||
resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
}
|
||||
const profile = resolveProfile(resolved, tab.profile);
|
||||
if (!profile?.cdpUrl) {
|
||||
return { status: "ownership-mismatch" };
|
||||
}
|
||||
if (profile.driver === "extension" && !resolved.extensionRelayInternalTokens[profile.name]) {
|
||||
return { status: "unavailable", reason: "extension-relay-unavailable" };
|
||||
}
|
||||
const cdpControlPolicy = resolveCdpControlPolicy(profile, resolved.ssrfPolicy);
|
||||
return await closeTrackedCdpTarget({
|
||||
profileName: profile.name,
|
||||
@@ -504,7 +515,7 @@ async function performDurableCleanup(
|
||||
return 0;
|
||||
}
|
||||
const shouldClose = () => ownsCleanupAttempt(tab);
|
||||
let outcome: CloseTrackedCdpTargetResult;
|
||||
let outcome: DurableCleanupResult;
|
||||
try {
|
||||
if (params.closeDurableTab) {
|
||||
outcome = await params.closeDurableTab(tab, { shouldClose });
|
||||
@@ -519,7 +530,7 @@ async function performDurableCleanup(
|
||||
});
|
||||
outcome = { status: "closed" };
|
||||
} else {
|
||||
outcome = await closeCurrentDurableTab(tab, shouldClose);
|
||||
outcome = await closeCurrentDurableTab(tab, shouldClose, params.getResolvedBrowserConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isIgnorableTabCloseError(error)) {
|
||||
@@ -533,6 +544,12 @@ async function performDurableCleanup(
|
||||
return 0;
|
||||
}
|
||||
if (outcome.status === "unavailable") {
|
||||
if (outcome.reason === "extension-relay-unavailable") {
|
||||
params.onWarn?.(
|
||||
`deferred tracked browser tab ${tab.nativeTargetId}: extension relay runtime unavailable`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
// A browser that never comes back leaves its rows unreachable forever: the
|
||||
// sweep re-claims them, fails ownership lookup, and defers again. Without an
|
||||
// age bound the namespace fills to its reject-new cap and every later
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Command } from "commander";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createCliRuntimeCapture } from "../../test-support.js";
|
||||
import { relayKeyIdFromHex } from "../browser/extension-relay/auth-v2-crypto.js";
|
||||
import { resolveLocalPairingGatewayUrl } from "./browser-cli-extension-pairing.js";
|
||||
import * as cliCoreApiModule from "./core-api.js";
|
||||
|
||||
@@ -38,6 +39,27 @@ describe("browser extension pairing Gateway URL", () => {
|
||||
).toBe("wss://gateway.example");
|
||||
});
|
||||
|
||||
it("rejects path-rewriting proxy prefixes for strict v2 resource binding", async () => {
|
||||
vi.spyOn(cliCoreApiModule, "getRuntimeConfig").mockReturnValue({});
|
||||
const errorSpy = vi
|
||||
.spyOn(cliCoreApiModule.defaultRuntime, "error")
|
||||
.mockImplementation(runtime.error);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "exit").mockImplementation(runtime.exit);
|
||||
const { registerBrowserExtensionCommands } = await import("./browser-cli-extension.js");
|
||||
const program = new Command();
|
||||
registerBrowserExtensionCommands(program.command("browser"), () => ({}));
|
||||
|
||||
await expect(
|
||||
program.parseAsync(
|
||||
["browser", "extension", "pair", "--gateway-url", "wss://gateway.example/proxy-prefix"],
|
||||
{ from: "user" },
|
||||
),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("must not include a path prefix"),
|
||||
);
|
||||
});
|
||||
|
||||
it("writes explicit JSON output through the raw machine-output sink", async () => {
|
||||
vi.spyOn(cliCoreApiModule, "getRuntimeConfig").mockReturnValue({});
|
||||
const logSpy = vi.spyOn(cliCoreApiModule.defaultRuntime, "log").mockImplementation(runtime.log);
|
||||
@@ -84,7 +106,7 @@ describe("browser extension pairing Gateway URL", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("prints the relay CDP endpoint for external clients via cdp --json", async () => {
|
||||
it("prints only safe v2 relay metadata via cdp --json", async () => {
|
||||
vi.spyOn(cliCoreApiModule, "getRuntimeConfig").mockReturnValue({});
|
||||
const logSpy = vi.spyOn(cliCoreApiModule.defaultRuntime, "log").mockImplementation(runtime.log);
|
||||
const writeJsonSpy = vi
|
||||
@@ -100,8 +122,75 @@ describe("browser extension pairing Gateway URL", () => {
|
||||
expect(writeJsonSpy).toHaveBeenCalledWith({
|
||||
browserUrl: "http://127.0.0.1:18799",
|
||||
wsEndpoint: "ws://127.0.0.1:18799/cdp",
|
||||
headers: { Authorization: `Bearer ${"a".repeat(64)}` },
|
||||
auth: {
|
||||
label: "openclaw.browser-relay.auth",
|
||||
version: 2,
|
||||
keyId: relayKeyIdFromHex("a".repeat(64)),
|
||||
challengeUrl: "http://127.0.0.1:18799/_openclaw/relay/auth/v2/challenge",
|
||||
completeUrl: "http://127.0.0.1:18799/_openclaw/relay/auth/v2/complete",
|
||||
role: "cdp",
|
||||
transport: "connection",
|
||||
method: "SEQUENCE",
|
||||
resource: "/json/version -> /cdp",
|
||||
flow: "cdp",
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(writeJsonSpy.mock.calls[0]?.[0])).not.toContain("Bearer");
|
||||
expect(JSON.stringify(writeJsonSpy.mock.calls[0]?.[0])).not.toContain("a".repeat(64));
|
||||
expect(logSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prints an explicit warned legacy bearer only while legacy auth is enabled", async () => {
|
||||
vi.spyOn(cliCoreApiModule, "getRuntimeConfig").mockReturnValue({});
|
||||
const errorSpy = vi
|
||||
.spyOn(cliCoreApiModule.defaultRuntime, "error")
|
||||
.mockImplementation(runtime.error);
|
||||
const writeJsonSpy = vi
|
||||
.spyOn(cliCoreApiModule.defaultRuntime, "writeJson")
|
||||
.mockImplementation(runtime.writeJson);
|
||||
const { registerBrowserExtensionCommands } = await import("./browser-cli-extension.js");
|
||||
const program = new Command();
|
||||
const browser = program.command("browser");
|
||||
registerBrowserExtensionCommands(browser, () => ({}));
|
||||
|
||||
await program.parseAsync(["browser", "extension", "cdp", "--legacy-bearer", "--json"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(writeJsonSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: `Bearer ${"a".repeat(64)}` },
|
||||
}),
|
||||
);
|
||||
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("reveals the relay key"));
|
||||
});
|
||||
|
||||
it("refuses --legacy-bearer when legacy auth is disabled", async () => {
|
||||
vi.spyOn(cliCoreApiModule, "getRuntimeConfig").mockReturnValue({
|
||||
browser: { extensionRelay: { allowLegacyAuth: false } },
|
||||
});
|
||||
const errorSpy = vi
|
||||
.spyOn(cliCoreApiModule.defaultRuntime, "error")
|
||||
.mockImplementation(runtime.error);
|
||||
vi.spyOn(cliCoreApiModule.defaultRuntime, "exit").mockImplementation(runtime.exit);
|
||||
const writeJsonSpy = vi
|
||||
.spyOn(cliCoreApiModule.defaultRuntime, "writeJson")
|
||||
.mockImplementation(runtime.writeJson);
|
||||
const { registerBrowserExtensionCommands } = await import("./browser-cli-extension.js");
|
||||
const program = new Command();
|
||||
const browser = program.command("browser");
|
||||
registerBrowserExtensionCommands(browser, () => ({}));
|
||||
|
||||
await expect(
|
||||
program.parseAsync(["browser", "extension", "cdp", "--legacy-bearer", "--json"], {
|
||||
from: "user",
|
||||
}),
|
||||
).rejects.toThrow("__exit__:1");
|
||||
|
||||
expect(writeJsonSpy).not.toHaveBeenCalled();
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Legacy browser relay auth is disabled"),
|
||||
);
|
||||
expect(errorSpy.mock.calls.flat().join("\n")).not.toContain("a".repeat(64));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Command } from "commander";
|
||||
import {
|
||||
BROWSER_RELAY_AUTH_LABEL,
|
||||
BROWSER_RELAY_AUTH_VERSION,
|
||||
relayKeyIdFromHex,
|
||||
} from "../browser/extension-relay/auth-v2-crypto.js";
|
||||
import {
|
||||
BROWSER_RELAY_AUTH_CHALLENGE_PATH,
|
||||
BROWSER_RELAY_AUTH_COMPLETE_PATH,
|
||||
} from "../browser/extension-relay/auth-v2.js";
|
||||
import { ensureExtensionRelayToken } from "../browser/extension-relay/relay-auth.js";
|
||||
import { isLoopbackHost } from "../gateway/net.js";
|
||||
import { resolveGatewayPort } from "../sdk-config.js";
|
||||
@@ -50,7 +59,7 @@ function firstExtensionProfile(
|
||||
/** Gateway route path for the remote extension relay (see gateway-relay-route.ts). */
|
||||
const GATEWAY_EXTENSION_RELAY_PATH = "/browser/extension";
|
||||
|
||||
/** Resolve a safe direct-Gateway relay URL, preserving an optional proxy base path. */
|
||||
/** Resolve a safe direct-Gateway relay URL with an exact v2-bound route path. */
|
||||
function buildRemoteGatewayRelayUrl(raw: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
@@ -66,8 +75,12 @@ function buildRemoteGatewayRelayUrl(raw: string): string {
|
||||
if (url.username || url.password || url.search || url.hash) {
|
||||
throw new Error("--gateway-url must not include credentials, a query, or a fragment");
|
||||
}
|
||||
const basePath = url.pathname.replace(/\/+$/, "");
|
||||
url.pathname = `${basePath}${GATEWAY_EXTENSION_RELAY_PATH}`;
|
||||
if (url.pathname !== "/") {
|
||||
throw new Error(
|
||||
"--gateway-url must not include a path prefix; Browser Relay Authentication v2 binds the exact /browser/extension path",
|
||||
);
|
||||
}
|
||||
url.pathname = GATEWAY_EXTENSION_RELAY_PATH;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -113,24 +126,60 @@ async function buildPairingString(gatewayUrl?: string): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the local relay CDP endpoint for third-party CDP clients
|
||||
* (Puppeteer, chrome-devtools-mcp, raw WebSocket). Creates the host-local
|
||||
* relay secret on first use, mirroring `pair`.
|
||||
*/
|
||||
async function buildCdpEndpoint(): Promise<{
|
||||
type BrowserRelayCdpEndpoint = {
|
||||
browserUrl: string;
|
||||
wsEndpoint: string;
|
||||
headers: { Authorization: string };
|
||||
}> {
|
||||
auth: {
|
||||
label: typeof BROWSER_RELAY_AUTH_LABEL;
|
||||
version: typeof BROWSER_RELAY_AUTH_VERSION;
|
||||
keyId: string;
|
||||
challengeUrl: string;
|
||||
completeUrl: string;
|
||||
role: "cdp";
|
||||
transport: "connection";
|
||||
method: "SEQUENCE";
|
||||
resource: "/json/version -> /cdp";
|
||||
flow: "cdp";
|
||||
};
|
||||
headers?: { Authorization: string };
|
||||
};
|
||||
|
||||
/** Resolve safe v2 metadata, with an explicit gated legacy credential escape hatch. */
|
||||
async function buildCdpEndpoint(options: {
|
||||
legacyBearer: boolean;
|
||||
}): Promise<BrowserRelayCdpEndpoint> {
|
||||
const cfg = getRuntimeConfig();
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
const token = await ensureExtensionRelayToken();
|
||||
const profile = firstExtensionProfile(resolved);
|
||||
const relayPort = profile?.relayPort ?? resolved.extensionRelayDefaultPort;
|
||||
return {
|
||||
browserUrl: `http://127.0.0.1:${relayPort}`,
|
||||
const browserUrl = `http://127.0.0.1:${relayPort}`;
|
||||
const metadata = {
|
||||
browserUrl,
|
||||
wsEndpoint: `ws://127.0.0.1:${relayPort}/cdp`,
|
||||
auth: {
|
||||
label: BROWSER_RELAY_AUTH_LABEL,
|
||||
version: BROWSER_RELAY_AUTH_VERSION,
|
||||
keyId: relayKeyIdFromHex(token),
|
||||
challengeUrl: new URL(BROWSER_RELAY_AUTH_CHALLENGE_PATH, browserUrl).toString(),
|
||||
completeUrl: new URL(BROWSER_RELAY_AUTH_COMPLETE_PATH, browserUrl).toString(),
|
||||
role: "cdp" as const,
|
||||
transport: "connection" as const,
|
||||
method: "SEQUENCE" as const,
|
||||
resource: "/json/version -> /cdp" as const,
|
||||
flow: "cdp" as const,
|
||||
},
|
||||
};
|
||||
if (!options.legacyBearer) {
|
||||
return metadata;
|
||||
}
|
||||
if (!resolved.extensionRelay.allowLegacyAuth) {
|
||||
throw new Error(
|
||||
"Legacy browser relay auth is disabled; remove --legacy-bearer and use Browser Relay Authentication v2.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
...metadata,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
};
|
||||
}
|
||||
@@ -189,7 +238,7 @@ export function registerBrowserExtensionCommands(
|
||||
"",
|
||||
theme.heading(result.pairing),
|
||||
"",
|
||||
info("The token is a host-local secret; keep it private."),
|
||||
info("The relay key is a host-local secret; keep it private."),
|
||||
].join("\n"),
|
||||
);
|
||||
},
|
||||
@@ -202,31 +251,45 @@ export function registerBrowserExtensionCommands(
|
||||
|
||||
extension
|
||||
.command("cdp")
|
||||
.description("Print the relay CDP endpoint and auth header for external CDP clients")
|
||||
.description("Print non-secret Browser Relay Authentication v2 CDP metadata")
|
||||
.option("--json", "Print the endpoint as JSON")
|
||||
.option(
|
||||
"--legacy-bearer",
|
||||
"Print the legacy Bearer header while browser.extensionRelay.allowLegacyAuth is enabled",
|
||||
)
|
||||
.action(async (opts) => {
|
||||
await runCommandWithRuntime(
|
||||
defaultRuntime,
|
||||
async () => {
|
||||
const endpoint = await buildCdpEndpoint();
|
||||
const legacyBearer = opts.legacyBearer === true;
|
||||
const endpoint = await buildCdpEndpoint({ legacyBearer });
|
||||
if (legacyBearer) {
|
||||
defaultRuntime.error(
|
||||
theme.warn(
|
||||
"Warning: --legacy-bearer reveals the relay key in an authorization header. Migrate this client to Browser Relay Authentication v2.",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (opts.json === true) {
|
||||
defaultRuntime.writeJson(endpoint);
|
||||
return;
|
||||
}
|
||||
defaultRuntime.log(
|
||||
[
|
||||
info("Relay CDP endpoint (pair the extension first):"),
|
||||
`browserUrl: ${endpoint.browserUrl}`,
|
||||
`wsEndpoint: ${endpoint.wsEndpoint}`,
|
||||
`header: Authorization: ${endpoint.headers.Authorization}`,
|
||||
"",
|
||||
info("Example (chrome-devtools-mcp):"),
|
||||
` npx chrome-devtools-mcp --wsEndpoint ${endpoint.wsEndpoint} \\`,
|
||||
` --wsHeaders '${JSON.stringify(endpoint.headers)}'`,
|
||||
"",
|
||||
info("The token is a host-local secret; keep it private."),
|
||||
].join("\n"),
|
||||
);
|
||||
const lines = [
|
||||
info("Relay CDP endpoint (pair the extension first):"),
|
||||
`browserUrl: ${endpoint.browserUrl}`,
|
||||
`wsEndpoint: ${endpoint.wsEndpoint}`,
|
||||
`auth: ${endpoint.auth.label} v${endpoint.auth.version}`,
|
||||
`keyId: ${endpoint.auth.keyId}`,
|
||||
`challenge: POST ${endpoint.auth.challengeUrl}`,
|
||||
`complete: POST ${endpoint.auth.completeUrl}`,
|
||||
`sequence: ${endpoint.auth.resource}`,
|
||||
];
|
||||
if (endpoint.headers) {
|
||||
lines.push(`legacy: Authorization: ${endpoint.headers.Authorization}`);
|
||||
} else {
|
||||
lines.push("", info("No relay key or authorization header is printed."));
|
||||
}
|
||||
defaultRuntime.log(lines.join("\n"));
|
||||
},
|
||||
(err: unknown) => {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
|
||||
@@ -44,9 +44,8 @@ async function startBrowserControlServiceUnlocked(): Promise<BrowserServerState
|
||||
logService.warn(`failed to auto-configure browser auth: ${String(err)}`);
|
||||
}
|
||||
|
||||
// Ensure the host-local relay secret exists before profiles are consumed so
|
||||
// the extension cdpUrl carries auth. Works identically on the gateway host
|
||||
// and on a browser node host — each owns its own secret.
|
||||
// Ensure the host-local HMAC key exists before relay startup. Gateway hosts
|
||||
// and browser node hosts each own an independent key.
|
||||
const hasExtensionProfiles = Object.values(resolved.profiles).some(
|
||||
(profile) => profile.driver === "extension",
|
||||
);
|
||||
@@ -89,10 +88,18 @@ export async function startBrowserControlServiceFromConfig(): Promise<BrowserSer
|
||||
|
||||
/** Stops the in-process Browser control service runtime. */
|
||||
export async function stopBrowserControlService(): Promise<void> {
|
||||
await stopBrowserControlRuntime({
|
||||
requestedBy: "service",
|
||||
onWarn: (message) => logService.warn(message),
|
||||
});
|
||||
try {
|
||||
await stopBrowserControlRuntime({
|
||||
requestedBy: "service",
|
||||
onWarn: (message) => logService.warn(message),
|
||||
});
|
||||
} finally {
|
||||
// Direct Gateway auth sockets can exist before Browser control lazy-starts,
|
||||
// so plugin shutdown must close them even when there is no runtime state.
|
||||
const { disposeGatewayExtensionRelay } =
|
||||
await import("./browser/extension-relay/gateway-relay-route.js");
|
||||
disposeGatewayExtensionRelay();
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-export Browser control context accessors for gateway-local dispatch. */
|
||||
|
||||
@@ -28,6 +28,7 @@ describe("browser doctor readiness", () => {
|
||||
await noteChromeMcpBrowserReadiness(
|
||||
{
|
||||
browser: {
|
||||
extensionRelay: { allowLegacyAuth: false },
|
||||
profiles: {
|
||||
openclaw: { color: "#FF4500" },
|
||||
},
|
||||
@@ -44,11 +45,38 @@ describe("browser doctor readiness", () => {
|
||||
expect(noteFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns while legacy Browser Relay Authentication remains enabled", async () => {
|
||||
const noteFn = vi.fn();
|
||||
await noteChromeMcpBrowserReadiness(
|
||||
{
|
||||
browser: {
|
||||
extensionRelay: { allowLegacyAuth: true },
|
||||
profiles: {
|
||||
openclaw: { color: "#FF4500" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
noteFn,
|
||||
platform: "linux",
|
||||
env: { DISPLAY: ":99" },
|
||||
getUid: () => 1000,
|
||||
resolveManagedExecutable: () => ({ kind: "chrome", path: "/usr/bin/google-chrome" }),
|
||||
},
|
||||
);
|
||||
|
||||
expect(noteFn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("browser.extensionRelay.allowLegacyAuth=true"),
|
||||
"Browser relay authentication",
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when managed browser profiles have no local executable", async () => {
|
||||
const noteFn = vi.fn();
|
||||
await noteChromeMcpBrowserReadiness(
|
||||
{
|
||||
browser: {
|
||||
extensionRelay: { allowLegacyAuth: false },
|
||||
profiles: {
|
||||
openclaw: { color: "#FF4500" },
|
||||
},
|
||||
@@ -78,6 +106,7 @@ describe("browser doctor readiness", () => {
|
||||
await noteChromeMcpBrowserReadiness(
|
||||
{
|
||||
browser: {
|
||||
extensionRelay: { allowLegacyAuth: false },
|
||||
headless: false,
|
||||
noSandbox: false,
|
||||
profiles: {
|
||||
@@ -111,6 +140,7 @@ describe("browser doctor readiness", () => {
|
||||
await noteChromeMcpBrowserReadiness(
|
||||
{
|
||||
browser: {
|
||||
extensionRelay: { allowLegacyAuth: false },
|
||||
profiles: {
|
||||
openclaw: { color: "#FF4500" },
|
||||
},
|
||||
@@ -141,6 +171,7 @@ describe("browser doctor readiness", () => {
|
||||
await noteChromeMcpBrowserReadiness(
|
||||
{
|
||||
browser: {
|
||||
extensionRelay: { allowLegacyAuth: false },
|
||||
profiles: {
|
||||
clawd: { color: "#FF4500" },
|
||||
openclaw: { color: "#00AA00" },
|
||||
@@ -166,6 +197,7 @@ describe("browser doctor readiness", () => {
|
||||
await noteChromeMcpBrowserReadiness(
|
||||
{
|
||||
browser: {
|
||||
extensionRelay: { allowLegacyAuth: false },
|
||||
defaultProfile: "user",
|
||||
},
|
||||
},
|
||||
@@ -189,6 +221,7 @@ describe("browser doctor readiness", () => {
|
||||
await noteChromeMcpBrowserReadiness(
|
||||
{
|
||||
browser: {
|
||||
extensionRelay: { allowLegacyAuth: false },
|
||||
profiles: {
|
||||
chromeLive: {
|
||||
driver: "existing-session",
|
||||
@@ -216,6 +249,7 @@ describe("browser doctor readiness", () => {
|
||||
await noteChromeMcpBrowserReadiness(
|
||||
{
|
||||
browser: {
|
||||
extensionRelay: { allowLegacyAuth: false },
|
||||
profiles: {
|
||||
chromeLive: {
|
||||
driver: "existing-session",
|
||||
@@ -243,6 +277,7 @@ describe("browser doctor readiness", () => {
|
||||
await noteChromeMcpBrowserReadiness(
|
||||
{
|
||||
browser: {
|
||||
extensionRelay: { allowLegacyAuth: false },
|
||||
profiles: {
|
||||
braveLive: {
|
||||
driver: "existing-session",
|
||||
|
||||
@@ -233,6 +233,16 @@ export async function noteChromeMcpBrowserReadiness(
|
||||
const managedProfiles = collectManagedProfiles(cfg);
|
||||
const managedProfileLabel = managedProfiles.map((profile) => profile.name).join(", ");
|
||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
||||
if (resolved.enabled && resolved.extensionRelay.allowLegacyAuth) {
|
||||
noteFn(
|
||||
[
|
||||
"- Legacy Browser Relay Authentication is enabled (browser.extensionRelay.allowLegacyAuth=true).",
|
||||
"- Update paired Chrome extensions and external CDP clients to Browser Relay Authentication v2, then set browser.extensionRelay.allowLegacyAuth=false.",
|
||||
"- V2 clients never downgrade to legacy authentication.",
|
||||
].join("\n"),
|
||||
"Browser relay authentication",
|
||||
);
|
||||
}
|
||||
const legacyClawdResidue = detectLegacyClawdBrowserProfileResidue(cfg, {
|
||||
configDir: deps?.configDir,
|
||||
pathExists: deps?.pathExists,
|
||||
|
||||
@@ -26,6 +26,30 @@ function findingByCheckId(
|
||||
}
|
||||
|
||||
describe("browser security audit collector", () => {
|
||||
it("warns while legacy extension relay auth remains enabled", () => {
|
||||
const findings = collectFindings({
|
||||
browser: {
|
||||
extensionRelay: { allowLegacyAuth: true },
|
||||
},
|
||||
});
|
||||
|
||||
const finding = findingByCheckId(findings, "browser.extension_relay_legacy_auth");
|
||||
expect(finding.severity).toBe("warn");
|
||||
expect(finding.remediation).toContain("allowLegacyAuth=false");
|
||||
});
|
||||
|
||||
it("does not warn when legacy extension relay auth is disabled", () => {
|
||||
const findings = collectFindings({
|
||||
browser: {
|
||||
extensionRelay: { allowLegacyAuth: false },
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
findings.some((finding) => finding.checkId === "browser.extension_relay_legacy_auth"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("flags browser control without auth", () => {
|
||||
const findings = collectFindings({
|
||||
gateway: {
|
||||
|
||||
@@ -49,6 +49,18 @@ export function collectBrowserSecurityAuditFindings(ctx: OpenClawPluginSecurityA
|
||||
return findings;
|
||||
}
|
||||
|
||||
if (resolved.extensionRelay.allowLegacyAuth) {
|
||||
findings.push({
|
||||
checkId: "browser.extension_relay_legacy_auth",
|
||||
severity: "warn" as const,
|
||||
title: "Legacy browser extension relay authentication is enabled",
|
||||
detail:
|
||||
"browser.extensionRelay.allowLegacyAuth defaults to true for one migration window, so old relay Bearer, Basic, and token-subprotocol clients can still authenticate.",
|
||||
remediation:
|
||||
"Update paired Chrome extensions and external CDP clients to Browser Relay Authentication v2, then set browser.extensionRelay.allowLegacyAuth=false.",
|
||||
});
|
||||
}
|
||||
|
||||
const browserAuth = resolveBrowserControlAuth(ctx.config, ctx.env);
|
||||
const explicitAuthMode = ctx.config.gateway?.auth?.mode;
|
||||
const tokenConfigured =
|
||||
|
||||
@@ -395,6 +395,31 @@ describe("config schema regressions", () => {
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts the browser extension relay legacy-auth migration gate", () => {
|
||||
const res = validateConfigObject({
|
||||
browser: {
|
||||
extensionRelay: {
|
||||
allowLegacyAuth: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown keys under browser.extensionRelay", () => {
|
||||
const res = validateConfigObject({
|
||||
browser: {
|
||||
extensionRelay: {
|
||||
allowLegacyAuth: true,
|
||||
unknownKey: true as unknown,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts discovery.wideArea.domain for unicast DNS-SD", () => {
|
||||
const res = validateConfigObject({
|
||||
discovery: {
|
||||
|
||||
@@ -250,6 +250,7 @@ export const TARGET_KEYS = [
|
||||
"browser.profiles.*.userDataDir",
|
||||
"browser.profiles.*.driver",
|
||||
"browser.profiles.*.attachOnly",
|
||||
"browser.extensionRelay.allowLegacyAuth",
|
||||
"tools",
|
||||
"tools.allow",
|
||||
"tools.deny",
|
||||
@@ -377,7 +378,7 @@ export const ENUM_EXPECTATIONS: Record<string, string[]> = {
|
||||
"gateway.bind": ['"auto"', '"lan"', '"loopback"', '"custom"', '"tailnet"'],
|
||||
"gateway.auth.mode": ['"none"', '"token"', '"password"', '"trusted-proxy"'],
|
||||
"gateway.tailscale.mode": ['"off"', '"serve"', '"funnel"'],
|
||||
"browser.profiles.*.driver": ['"openclaw"', '"clawd"', '"existing-session"'],
|
||||
"browser.profiles.*.driver": ['"openclaw"', '"clawd"', '"existing-session"', '"extension"'],
|
||||
"discovery.mdns.mode": ['"off"', '"minimal"', '"full"'],
|
||||
"diagnostics.otel.protocol": ['"http/protobuf"'],
|
||||
"diagnostics.otel.logsExporter": ['"otlp"', '"stdout"', '"both"'],
|
||||
|
||||
@@ -34,7 +34,7 @@ export const RUNTIME_FIELD_HELP: Record<string, string> = {
|
||||
"browser.profiles.*.mcpArgs":
|
||||
"Extra per-profile Chrome DevTools MCP arguments for existing-session attachment, such as --no-usage-statistics. Endpoint arguments here override the built-in auto-connect or browser URL selection.",
|
||||
"browser.profiles.*.driver":
|
||||
'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, or use "existing-session" for Chrome DevTools MCP attachment on the selected host or browser node.',
|
||||
'Per-profile browser driver mode. Use "openclaw" (or legacy "clawd") for CDP-based profiles, "existing-session" for Chrome DevTools MCP attachment, or "extension" for the authenticated Chrome extension relay.',
|
||||
"browser.profiles.*.executablePath":
|
||||
"Per-profile browser executable path for locally launched managed browser profiles. Overrides browser.executablePath and accepts paths starting with ~ for the OS home directory.",
|
||||
"browser.profiles.*.headless":
|
||||
@@ -51,6 +51,10 @@ export const RUNTIME_FIELD_HELP: Record<string, string> = {
|
||||
"Best-effort cleanup policy for browser tabs opened by primary-agent sessions. Keep enabled to avoid stale sandbox or managed-browser tabs accumulating across long-lived gateways.",
|
||||
"browser.tabCleanup.enabled":
|
||||
"Enables cleanup of idle tracked browser tabs for primary-agent sessions. Disable only when external tooling owns tab lifecycle completely.",
|
||||
"browser.extensionRelay":
|
||||
"Chrome extension relay authentication compatibility settings. Keep the legacy window only while older paired extensions or external CDP clients still need it.",
|
||||
"browser.extensionRelay.allowLegacyAuth":
|
||||
"Temporarily accepts legacy Bearer, Basic, and token-subprotocol relay authentication. Default: true for one migration window. Set false after every extension and external CDP client uses Browser Relay Authentication v2.",
|
||||
"browser.ssrfPolicy":
|
||||
"Server-side request forgery guardrail settings for browser/network fetch paths that could reach internal hosts. Keep restrictive defaults in production and open only explicitly approved targets.",
|
||||
"browser.ssrfPolicy.dangerouslyAllowPrivateNetwork":
|
||||
|
||||
@@ -173,6 +173,8 @@ export const FIELD_LABELS: Record<string, string> = {
|
||||
"browser.profiles.*.executablePath": "Browser Profile Executable Path",
|
||||
"browser.profiles.*.headless": "Browser Profile Headless Mode",
|
||||
"browser.profiles.*.attachOnly": "Browser Profile Attach-only Mode",
|
||||
"browser.extensionRelay": "Browser Extension Relay",
|
||||
"browser.extensionRelay.allowLegacyAuth": "Allow Legacy Browser Relay Auth",
|
||||
tools: "Tools",
|
||||
"tools.allow": "Tool Allowlist",
|
||||
"tools.deny": "Tool Denylist",
|
||||
|
||||
@@ -33,6 +33,10 @@ export type BrowserTabCleanupConfig = {
|
||||
/** Enable best-effort cleanup for tracked primary-agent browser tabs. Default: true */
|
||||
enabled?: boolean;
|
||||
};
|
||||
export type BrowserExtensionRelayConfig = {
|
||||
/** Temporarily accept legacy relay bearer/basic/subprotocol auth. Default: true. */
|
||||
allowLegacyAuth?: boolean;
|
||||
};
|
||||
export type BrowserSsrFPolicyConfig = SsrFPolicyConfig;
|
||||
export type BrowserConfig = {
|
||||
/** @deprecated Doctor-only legacy input; canonical schema rejects this field. */
|
||||
@@ -52,7 +56,7 @@ export type BrowserConfig = {
|
||||
noSandbox?: boolean;
|
||||
/** If true: never launch; only attach to an existing browser. Default: false */
|
||||
attachOnly?: boolean;
|
||||
/** Default profile to use when profile param is omitted. Default: "chrome" */
|
||||
/** Default profile to use when profile param is omitted. Default: "openclaw" */
|
||||
defaultProfile?: string;
|
||||
/** Named browser profiles with explicit CDP ports or URLs. */
|
||||
profiles?: Record<string, BrowserProfileConfig>;
|
||||
@@ -60,6 +64,8 @@ export type BrowserConfig = {
|
||||
snapshotDefaults?: BrowserSnapshotDefaults;
|
||||
/** Best-effort cleanup policy for tabs opened by primary-agent browser sessions. */
|
||||
tabCleanup?: BrowserTabCleanupConfig;
|
||||
/** Chrome extension relay authentication compatibility settings. */
|
||||
extensionRelay?: BrowserExtensionRelayConfig;
|
||||
/** SSRF policy for browser navigation/open-tab operations. */
|
||||
ssrfPolicy?: BrowserSsrFPolicyConfig;
|
||||
/**
|
||||
|
||||
@@ -201,6 +201,11 @@ export const OpenClawSchemaShape = {
|
||||
enabled: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
extensionRelay: z
|
||||
.strictObject({
|
||||
allowLegacyAuth: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
ui: z
|
||||
|
||||
Reference in New Issue
Block a user