fix(browser-use): tear down accepted peers on start failure and pin the transport's untested paths

The esbuild banner declared require/__filename/__dirname/process bindings
the bundle never uses, so any bundled module declaring one of them built
green but failed to load; drop it (and the unused .wasm loader) and import
dist/index.js after bundling so an unloadable bundle fails the build.

When start() fails after listen (chmod, handshake), a peer validated in
between kept server.close() waiting forever; destroy and clear the
accepted sockets first, mirroring stop(). net.Server has no
closeAllConnections(), so that is the whole fix.

Tests now cover the QWEN_BROWSER_USE_SOCKET_PATH override, a hello with a
mismatched protocol version or extension id, onConnectionChange on the
real transport, an in-flight request when the extension disconnects and
the configured request budget, the ok:false error-code mapping,
FrameDecoder split-frame reassembly and its length ceiling, the
start-failure teardown, and all five schema refine predicates.
This commit is contained in:
Shaojin Wen 2026-09-13 08:35:21 +08:00
parent 65e9935ce0
commit 3fc2dc3e10
6 changed files with 354 additions and 8 deletions

View file

@ -6,7 +6,7 @@
import { rmSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { build } from 'esbuild';
const root = path.dirname(fileURLToPath(import.meta.url));
@ -24,10 +24,11 @@ await build({
platform: 'node',
target: 'node22',
format: 'esm',
banner: {
js: "import { createRequire as __qwenCreateRequire } from 'node:module'; import { fileURLToPath as __qwenFileURLToPath } from 'node:url'; import { dirname as __qwenDirname } from 'node:path'; const require = __qwenCreateRequire(import.meta.url); const __filename = __qwenFileURLToPath(import.meta.url); const __dirname = __qwenDirname(__filename); const process = require('node:process');",
},
packages: 'bundle',
external: ['playwright-core', 'playwright-core/*'],
loader: { '.wasm': 'binary' },
});
// esbuild only proves the bundle parses; a load-time failure (a duplicate
// top-level binding, an unresolved external) would otherwise surface first in
// the Node kernel that imports the published artifact.
await import(pathToFileURL(path.join(dist, 'index.js')).href);

View file

@ -26,6 +26,7 @@ import {
ChromeExtensionTransport,
ensureSocketDirectory,
isAddressInUse,
type ChromeExtensionTransportOptions,
} from './chrome-extension-transport.js';
import { encodeFrame, FrameDecoder } from './framing.js';
@ -317,6 +318,17 @@ describe('ChromeExtensionTransport', () => {
expect(CHROME_BRIDGE_PROTOCOL_VERSION).toBe(1);
});
it('honours an explicit QWEN_BROWSER_USE_SOCKET_PATH and ignores a blank one', () => {
expect(
defaultChromeBridgeSocketPath({
QWEN_BROWSER_USE_SOCKET_PATH: '/run/qbu/x.sock',
}),
).toBe('/run/qbu/x.sock');
expect(
defaultChromeBridgeSocketPath({ QWEN_BROWSER_USE_SOCKET_PATH: ' ' }),
).toBe(defaultChromeBridgeSocketPath({}));
});
it('prefers an owned per-user runtime directory over world-writable /tmp', () => {
const owned = () => ({
isDirectory: () => true,
@ -653,13 +665,187 @@ describe('ChromeExtensionTransport', () => {
expect(fs.statSync(transport.socketPath).isSocket()).toBe(true);
},
);
it.skipIf(process.platform === 'win32').each([
[
'protocolVersion',
{
protocolVersion: CHROME_BRIDGE_PROTOCOL_VERSION + 1,
extensionId: CHROME_EXTENSION_ID,
},
],
[
'extensionId',
{
protocolVersion: CHROME_BRIDGE_PROTOCOL_VERSION,
extensionId: 'wrong-extension-id',
},
],
] as const)(
'rejects a hello with a mismatched %s',
async (_field, identity) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'qbu-transport-'));
roots.push(root);
const transport = new ChromeExtensionTransport({
socketPath: path.join(root, 'bridge.sock'),
});
transports.push(transport);
await transport.start();
const impostor = connect(transport.socketPath);
impostor.on('error', () => undefined);
await new Promise<void>((resolve) => impostor.once('connect', resolve));
impostor.write(encodeFrame({ type: 'hello', ...identity }));
await new Promise<void>((resolve) => impostor.once('close', resolve));
expect(transport.isConnected()).toBe(false);
// The same server still promotes a matching hello afterwards.
const extension = connect(transport.socketPath);
await new Promise<void>((resolve) => extension.once('connect', resolve));
extension.write(
encodeFrame({
type: 'hello',
protocolVersion: CHROME_BRIDGE_PROTOCOL_VERSION,
extensionId: CHROME_EXTENSION_ID,
}),
);
await vi.waitFor(() => expect(transport.isConnected()).toBe(true));
extension.destroy();
},
);
it.skipIf(process.platform === 'win32')(
'notifies validated connection changes and honours unsubscribe',
async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'qbu-transport-'));
roots.push(root);
const transport = new ChromeExtensionTransport({
socketPath: path.join(root, 'bridge.sock'),
});
transports.push(transport);
await transport.start();
const states: boolean[] = [];
const unsubscribe = transport.onConnectionChange((connected) => {
states.push(connected);
});
const first = connect(transport.socketPath);
await new Promise<void>((resolve) => first.once('connect', resolve));
first.write(
encodeFrame({
type: 'hello',
protocolVersion: CHROME_BRIDGE_PROTOCOL_VERSION,
extensionId: CHROME_EXTENSION_ID,
}),
);
await vi.waitFor(() => expect(states).toEqual([true]));
first.destroy();
await vi.waitFor(() => expect(states).toEqual([true, false]));
unsubscribe();
const second = connect(transport.socketPath);
await new Promise<void>((resolve) => second.once('connect', resolve));
second.write(
encodeFrame({
type: 'hello',
protocolVersion: CHROME_BRIDGE_PROTOCOL_VERSION,
extensionId: CHROME_EXTENSION_ID,
}),
);
await vi.waitFor(() => expect(transport.isConnected()).toBe(true));
expect(states).toEqual([true, false]);
second.destroy();
},
);
it.skipIf(process.platform === 'win32')(
'fails an in-flight request closed when the extension disconnects',
async () => {
const { transport, socket, requests } = await connectedTransport();
const slow = transport.request('slow');
await vi.waitFor(() => expect(requests).toHaveLength(1));
socket.destroy();
await expect(slow).rejects.toMatchObject({
code: 'BROWSER_DISCONNECTED',
});
expect(transport.isConnected()).toBe(false);
},
);
it.skipIf(process.platform === 'win32')(
'times out an unanswered request with the configured budget',
async () => {
const { transport, socket, requests } = await connectedTransport({
requestTimeoutMs: 10,
});
const stalled = transport
.request('stalled')
.catch((error: unknown) => error);
await vi.waitFor(() => expect(requests).toHaveLength(1));
await expect(stalled).resolves.toMatchObject({
code: 'OPERATION_TIMEOUT',
message: expect.stringContaining('stalled'),
});
expect(transport.isConnected()).toBe(true);
socket.destroy();
},
);
it.skipIf(process.platform === 'win32').each([
['NOT_GRANTED', 'TAB_NOT_GRANTED'],
['STALE_TAB', 'STALE_TAB'],
['UNSUPPORTED_TAB', 'UNSUPPORTED_TAB'],
['PERMISSION_REQUIRED', 'PERMISSION_REQUIRED'],
['SOMETHING_NEW', 'OPERATION_FAILED'],
[undefined, 'OPERATION_FAILED'],
] as const)('maps extension error code %s to %s', async (code, expected) => {
const { transport, socket, requests } = await connectedTransport();
const failing = transport.request('tabs.attach');
await vi.waitFor(() => expect(requests).toHaveLength(1));
socket.write(
encodeFrame({
type: 'response',
id: requests[0].id,
ok: false,
error: { code, message: 'tab not granted' },
}),
);
await expect(failing).rejects.toMatchObject({
code: expected,
message: 'tab not granted',
});
socket.destroy();
});
it.skipIf(process.platform === 'win32')(
'falls back to a generic message for an extension error without one',
async () => {
const { transport, socket, requests } = await connectedTransport();
const failing = transport.request('tabs.attach');
await vi.waitFor(() => expect(requests).toHaveLength(1));
socket.write(
encodeFrame({
type: 'response',
id: requests[0].id,
ok: false,
error: {},
}),
);
await expect(failing).rejects.toMatchObject({
code: 'OPERATION_FAILED',
message: 'Chrome extension operation failed',
});
socket.destroy();
},
);
});
async function connectedTransport() {
async function connectedTransport(
options: Omit<ChromeExtensionTransportOptions, 'socketPath'> = {},
) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'qbu-transport-'));
roots.push(root);
const transport = new ChromeExtensionTransport({
socketPath: path.join(root, 'bridge.sock'),
...options,
});
transports.push(transport);
await transport.start();

View file

@ -243,6 +243,10 @@ export class ChromeExtensionTransport implements ChromeBridge {
}
} catch (error) {
if (this.server === server) this.server = undefined;
// A peer validated between listen and a failing post-listen check
// would otherwise keep server.close() waiting on it indefinitely.
for (const socket of this.acceptedSockets) socket.destroy();
this.acceptedSockets.clear();
await closeServer(server);
await unlinkOwnedSocket(this.socketPath, this.socketIdentity);
this.socketIdentity = undefined;

View file

@ -5,7 +5,7 @@
*/
import fs from 'node:fs';
import { connect } from 'node:net';
import { connect, type Socket } from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@ -14,9 +14,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// between the existence check and the create, owned by someone else or with
// permissive bits.
const lstatMock = vi.hoisted(() => vi.fn());
// A scripted chmod fails the post-listen step of start() while the socket is
// already bound and accepting.
const chmodMock = vi.hoisted(() => vi.fn());
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
return { ...actual, lstat: lstatMock };
return { ...actual, lstat: lstatMock, chmod: chmodMock };
});
// The transport deliberately imports its timers from node:timers (the global
@ -85,6 +88,10 @@ beforeEach(() => {
lstatMock.mockImplementation(
async (target: string) => await fs.promises.lstat(target),
);
chmodMock.mockImplementation(
async (target: string, mode: number) =>
await fs.promises.chmod(target, mode),
);
});
afterEach(async () => {
@ -92,6 +99,7 @@ afterEach(async () => {
for (const root of roots.splice(0))
fs.rmSync(root, { recursive: true, force: true });
lstatMock.mockReset();
chmodMock.mockReset();
requestTimers.reset();
});
@ -153,6 +161,41 @@ describe('ensureSocketDirectory create race', () => {
);
});
describe('start failure after listen', () => {
it.skipIf(process.platform === 'win32')(
'tears down a validated peer when start fails after listen',
async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'qbu-transport-'));
roots.push(root);
const socketPath = path.join(root, 'bridge.sock');
const transport = new ChromeExtensionTransport({ socketPath });
transports.push(transport);
let peer: Socket | undefined;
chmodMock.mockImplementationOnce(async () => {
peer = connect(socketPath);
peer.on('error', () => undefined);
await new Promise<void>((resolve) => peer!.once('connect', resolve));
peer.write(
encodeFrame({
type: 'hello',
protocolVersion: CHROME_BRIDGE_PROTOCOL_VERSION,
extensionId: CHROME_EXTENSION_ID,
}),
);
await vi.waitFor(() => expect(transport.isConnected()).toBe(true));
throw Object.assign(new Error('read-only socket'), { code: 'EPERM' });
});
await expect(transport.start()).rejects.toMatchObject({
code: 'TRANSPORT_UNAVAILABLE',
});
expect(fs.existsSync(socketPath)).toBe(false);
expect(transport.isConnected()).toBe(false);
await vi.waitFor(() => expect(peer?.destroyed).toBe(true));
},
);
});
describe('request deadlines', () => {
it.skipIf(process.platform === 'win32')(
'lets a CDP command outlive the 120s operation ceiling',

View file

@ -0,0 +1,49 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { MAX_BRIDGE_FRAME_BYTES } from '../protocol.js';
import { encodeFrame, FrameDecoder } from './framing.js';
describe('FrameDecoder', () => {
it('reassembles a frame split across chunks', () => {
const message = { type: 'event', tabId: 7, method: 'Page.loadEventFired' };
const frame = encodeFrame(message);
const decoder = new FrameDecoder();
// Cut inside the length header and again inside the payload.
expect(decoder.push(frame.subarray(0, 2))).toEqual([]);
expect(decoder.push(frame.subarray(2, 6))).toEqual([]);
expect(decoder.push(frame.subarray(6))).toEqual([message]);
});
it('yields every frame packed into one chunk', () => {
const decoder = new FrameDecoder();
const third = encodeFrame({ id: 'c' });
const packed = Buffer.concat([
encodeFrame({ id: 'a' }),
encodeFrame({ id: 'b' }),
third.subarray(0, 3),
]);
expect(decoder.push(packed)).toEqual([{ id: 'a' }, { id: 'b' }]);
expect(decoder.push(third.subarray(3))).toEqual([{ id: 'c' }]);
});
it('rejects a header above the frame ceiling but decodes one exactly at it', () => {
const oversized = Buffer.alloc(4);
oversized.writeUInt32LE(MAX_BRIDGE_FRAME_BYTES + 1, 0);
expect(() => new FrameDecoder().push(oversized)).toThrow(
`Bridge frame declares ${MAX_BRIDGE_FRAME_BYTES + 1} bytes; maximum is ${MAX_BRIDGE_FRAME_BYTES}`,
);
// The JSON string quotes account for the remaining two bytes.
const atCeiling = encodeFrame('x'.repeat(MAX_BRIDGE_FRAME_BYTES - 2));
expect(atCeiling.byteLength).toBe(MAX_BRIDGE_FRAME_BYTES + 4);
const [decoded] = new FrameDecoder().push(atCeiling);
expect(typeof decoded).toBe('string');
expect(decoded).toHaveLength(MAX_BRIDGE_FRAME_BYTES - 2);
});
});

View file

@ -8,6 +8,7 @@ import { spawnSync } from 'node:child_process';
import { describe, expect, it } from 'vitest';
import { commandSchemas, locatorStepsSchema } from './schemas.js';
import { MAX_SCREENSHOT_PIXELS } from './screenshot-budget.js';
describe('recursive locator plans', () => {
it.each(['and', 'or', 'has', 'hasNot'])(
@ -338,4 +339,66 @@ describe('browser command schemas', () => {
}).success,
).toBe(true);
});
it('enforces every refine predicate without over-rejecting legal shapes', () => {
const screenshot = commandSchemas['tab.screenshot'];
const width = 2_048;
const height = MAX_SCREENSHOT_PIXELS / width;
const clip = { x: 0, y: 0, width, height };
expect(
screenshot.safeParse({ tabId: 'tab-1', clip, fullPage: true }).success,
).toBe(false);
expect(
screenshot.safeParse({
tabId: 'tab-1',
clip: { ...clip, height: height + 1 },
}).success,
).toBe(false);
expect(screenshot.safeParse({ tabId: 'tab-1', clip }).success).toBe(true);
expect(
screenshot.safeParse({ tabId: 'tab-1', clip, fullPage: false }).success,
).toBe(true);
expect(
screenshot.safeParse({ tabId: 'tab-1', fullPage: true }).success,
).toBe(true);
const selectOption = commandSchemas['locator.selectOption'];
const steps = [{ kind: 'locator', selector: 'select' }];
for (const value of [{}, [{}]]) {
expect(
selectOption.safeParse({ tabId: 'tab-1', steps, value }).success,
).toBe(false);
}
expect(
selectOption.safeParse({
tabId: 'tab-1',
steps,
value: [{ index: 0 }, 'b'],
}).success,
).toBe(true);
const history = commandSchemas['browser.user.history'];
expect(
history.safeParse({ browserId: 'b-1', options: { queries: [' '] } })
.success,
).toBe(false);
for (const bound of ['from', 'to']) {
expect(
history.safeParse({
browserId: 'b-1',
options: { [bound]: 'not-a-date' },
}).success,
).toBe(false);
}
expect(
history.safeParse({
browserId: 'b-1',
options: {
queries: ['qwen'],
from: '2026-01-01T00:00:00Z',
to: new Date('2026-02-01T00:00:00Z'),
},
}).success,
).toBe(true);
});
});