mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-23 07:19:52 +00:00
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:
parent
65e9935ce0
commit
3fc2dc3e10
6 changed files with 354 additions and 8 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
49
packages/browser-use/src/bridge/transport/framing.test.ts
Normal file
49
packages/browser-use/src/bridge/transport/framing.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue