@flrande/browserctl 0.1.0-dev.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README-CN.md +66 -0
- package/README.md +66 -0
- package/apps/browserctl/src/commands/a11y-snapshot.ts +20 -0
- package/apps/browserctl/src/commands/act.ts +20 -0
- package/apps/browserctl/src/commands/common.test.ts +87 -0
- package/apps/browserctl/src/commands/common.ts +191 -0
- package/apps/browserctl/src/commands/console-list.ts +20 -0
- package/apps/browserctl/src/commands/cookie-clear.ts +18 -0
- package/apps/browserctl/src/commands/cookie-get.ts +18 -0
- package/apps/browserctl/src/commands/cookie-set.ts +22 -0
- package/apps/browserctl/src/commands/dialog-arm.ts +20 -0
- package/apps/browserctl/src/commands/dom-query-all.ts +18 -0
- package/apps/browserctl/src/commands/dom-query.ts +18 -0
- package/apps/browserctl/src/commands/download-trigger.ts +22 -0
- package/apps/browserctl/src/commands/download-wait.test.ts +67 -0
- package/apps/browserctl/src/commands/download-wait.ts +27 -0
- package/apps/browserctl/src/commands/element-screenshot.ts +20 -0
- package/apps/browserctl/src/commands/frame-list.ts +16 -0
- package/apps/browserctl/src/commands/frame-snapshot.ts +18 -0
- package/apps/browserctl/src/commands/network-wait-for.ts +100 -0
- package/apps/browserctl/src/commands/profile-list.ts +16 -0
- package/apps/browserctl/src/commands/profile-use.ts +18 -0
- package/apps/browserctl/src/commands/response-body.ts +24 -0
- package/apps/browserctl/src/commands/screenshot.ts +16 -0
- package/apps/browserctl/src/commands/snapshot.ts +16 -0
- package/apps/browserctl/src/commands/status.ts +10 -0
- package/apps/browserctl/src/commands/storage-get.ts +20 -0
- package/apps/browserctl/src/commands/storage-set.ts +22 -0
- package/apps/browserctl/src/commands/tab-close.ts +20 -0
- package/apps/browserctl/src/commands/tab-focus.ts +20 -0
- package/apps/browserctl/src/commands/tab-open.ts +19 -0
- package/apps/browserctl/src/commands/tabs.ts +13 -0
- package/apps/browserctl/src/commands/upload-arm.ts +26 -0
- package/apps/browserctl/src/daemon-client.test.ts +253 -0
- package/apps/browserctl/src/daemon-client.ts +632 -0
- package/apps/browserctl/src/e2e.test.ts +99 -0
- package/apps/browserctl/src/main.test.ts +215 -0
- package/apps/browserctl/src/main.ts +372 -0
- package/apps/browserctl/src/smoke.test.ts +16 -0
- package/apps/browserctl/src/smoke.ts +5 -0
- package/apps/browserd/src/bootstrap.ts +432 -0
- package/apps/browserd/src/chrome-relay-extension-bridge.test.ts +275 -0
- package/apps/browserd/src/chrome-relay-extension-bridge.ts +506 -0
- package/apps/browserd/src/container.ts +1531 -0
- package/apps/browserd/src/main.test.ts +864 -0
- package/apps/browserd/src/main.ts +7 -0
- package/bin/browserctl.cjs +21 -0
- package/bin/browserd.cjs +21 -0
- package/extensions/chrome-relay/README-CN.md +38 -0
- package/extensions/chrome-relay/README.md +38 -0
- package/extensions/chrome-relay/background.js +1687 -0
- package/extensions/chrome-relay/manifest.json +15 -0
- package/extensions/chrome-relay/popup.html +369 -0
- package/extensions/chrome-relay/popup.js +972 -0
- package/package.json +51 -0
- package/packages/core/src/bootstrap.test.ts +10 -0
- package/packages/core/src/driver-registry.test.ts +45 -0
- package/packages/core/src/driver-registry.ts +22 -0
- package/packages/core/src/driver.ts +47 -0
- package/packages/core/src/index.ts +5 -0
- package/packages/core/src/ref-cache.test.ts +61 -0
- package/packages/core/src/ref-cache.ts +28 -0
- package/packages/core/src/session-store.test.ts +49 -0
- package/packages/core/src/session-store.ts +33 -0
- package/packages/core/src/types.ts +9 -0
- package/packages/driver-chrome-relay/src/chrome-relay-driver.test.ts +634 -0
- package/packages/driver-chrome-relay/src/chrome-relay-driver.ts +2206 -0
- package/packages/driver-chrome-relay/src/chrome-relay-extension-runtime.test.ts +264 -0
- package/packages/driver-chrome-relay/src/chrome-relay-extension-runtime.ts +521 -0
- package/packages/driver-chrome-relay/src/index.ts +26 -0
- package/packages/driver-managed/src/index.ts +22 -0
- package/packages/driver-managed/src/managed-driver.test.ts +59 -0
- package/packages/driver-managed/src/managed-driver.ts +125 -0
- package/packages/driver-managed/src/managed-local-driver.test.ts +506 -0
- package/packages/driver-managed/src/managed-local-driver.ts +2021 -0
- package/packages/driver-remote-cdp/src/index.ts +19 -0
- package/packages/driver-remote-cdp/src/remote-cdp-driver.test.ts +617 -0
- package/packages/driver-remote-cdp/src/remote-cdp-driver.ts +2042 -0
- package/packages/protocol/src/envelope.test.ts +25 -0
- package/packages/protocol/src/envelope.ts +31 -0
- package/packages/protocol/src/errors.test.ts +17 -0
- package/packages/protocol/src/errors.ts +11 -0
- package/packages/protocol/src/index.ts +3 -0
- package/packages/protocol/src/tools.ts +3 -0
- package/packages/transport-mcp-stdio/src/index.ts +3 -0
- package/packages/transport-mcp-stdio/src/sdk-server.ts +139 -0
- package/packages/transport-mcp-stdio/src/server.test.ts +281 -0
- package/packages/transport-mcp-stdio/src/server.ts +183 -0
- package/packages/transport-mcp-stdio/src/tool-map.ts +67 -0
- package/scripts/smoke.ps1 +127 -0
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { PassThrough } from "node:stream";
|
|
3
|
+
import { createConnection } from "node:net";
|
|
4
|
+
|
|
5
|
+
import { createContainer, loadBrowserdConfig } from "./container";
|
|
6
|
+
import { bootstrapBrowserd } from "./bootstrap";
|
|
7
|
+
|
|
8
|
+
function waitForNextJsonLine(stream: PassThrough): Promise<Record<string, unknown>> {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
let buffer = "";
|
|
11
|
+
const timeout = setTimeout(() => {
|
|
12
|
+
stream.off("data", onData);
|
|
13
|
+
reject(new Error("Timed out waiting for JSON line response."));
|
|
14
|
+
}, 1000);
|
|
15
|
+
|
|
16
|
+
const onData = (chunk: string | Buffer) => {
|
|
17
|
+
buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
18
|
+
const newlineIndex = buffer.indexOf("\n");
|
|
19
|
+
if (newlineIndex < 0) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
clearTimeout(timeout);
|
|
24
|
+
stream.off("data", onData);
|
|
25
|
+
const line = buffer.slice(0, newlineIndex);
|
|
26
|
+
|
|
27
|
+
resolve(JSON.parse(line) as Record<string, unknown>);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
stream.on("data", onData);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sendToolRequest(
|
|
35
|
+
input: PassThrough,
|
|
36
|
+
output: PassThrough,
|
|
37
|
+
request: Record<string, unknown>
|
|
38
|
+
): Promise<Record<string, unknown>> {
|
|
39
|
+
const responsePromise = waitForNextJsonLine(output);
|
|
40
|
+
input.write(`${JSON.stringify(request)}\n`);
|
|
41
|
+
return responsePromise;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sendTcpToolRequest(
|
|
45
|
+
port: number,
|
|
46
|
+
request: Record<string, unknown>
|
|
47
|
+
): Promise<Record<string, unknown>> {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const socket = createConnection({ host: "127.0.0.1", port });
|
|
50
|
+
socket.setEncoding("utf8");
|
|
51
|
+
|
|
52
|
+
let buffer = "";
|
|
53
|
+
const timeout = setTimeout(() => {
|
|
54
|
+
socket.destroy();
|
|
55
|
+
reject(new Error("Timed out waiting for TCP response."));
|
|
56
|
+
}, 1000);
|
|
57
|
+
|
|
58
|
+
socket.on("error", (error) => {
|
|
59
|
+
clearTimeout(timeout);
|
|
60
|
+
reject(error);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
socket.on("connect", () => {
|
|
64
|
+
socket.write(`${JSON.stringify(request)}\n`);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
socket.on("data", (chunk: string) => {
|
|
68
|
+
buffer += chunk;
|
|
69
|
+
const lineBreakIndex = buffer.indexOf("\n");
|
|
70
|
+
if (lineBreakIndex < 0) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
clearTimeout(timeout);
|
|
75
|
+
socket.end();
|
|
76
|
+
resolve(JSON.parse(buffer.slice(0, lineBreakIndex)) as Record<string, unknown>);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("browserd container", () => {
|
|
82
|
+
it("uses secure defaults for new security config fields", () => {
|
|
83
|
+
const config = loadBrowserdConfig({});
|
|
84
|
+
|
|
85
|
+
expect(config.defaultDriver).toBe("managed-local");
|
|
86
|
+
expect(config.managedLocalEnabled).toBe(true);
|
|
87
|
+
expect(config.uploadRoot).toBeUndefined();
|
|
88
|
+
expect(config.downloadRoot).toBeUndefined();
|
|
89
|
+
expect(config.authToken).toBeUndefined();
|
|
90
|
+
expect(config.authScopes).toEqual(["read", "act", "upload", "download"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("registers managed-local, managed, chrome-relay, and remote-cdp drivers by default", () => {
|
|
94
|
+
const c = createContainer();
|
|
95
|
+
|
|
96
|
+
expect(c.drivers.has("managed-local")).toBe(true);
|
|
97
|
+
expect(c.drivers.has("managed")).toBe(true);
|
|
98
|
+
expect(c.drivers.has("chrome-relay")).toBe(true);
|
|
99
|
+
expect(c.drivers.has("remote-cdp")).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("uses env override in loadBrowserdConfig", () => {
|
|
103
|
+
const config = loadBrowserdConfig({
|
|
104
|
+
BROWSERD_REMOTE_CDP_URL: "http://127.0.0.1:9333/devtools/browser/override",
|
|
105
|
+
BROWSERD_UPLOAD_ROOT: "C:\\safe\\uploads",
|
|
106
|
+
BROWSERD_DOWNLOAD_ROOT: "C:\\safe\\downloads",
|
|
107
|
+
BROWSERD_AUTH_TOKEN: "test-token",
|
|
108
|
+
BROWSERD_AUTH_SCOPES: " read, download "
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(config.remoteCdpUrl).toBe("http://127.0.0.1:9333/devtools/browser/override");
|
|
112
|
+
expect(config.chromeRelayUrl).toBe("http://127.0.0.1:9223");
|
|
113
|
+
expect(config.defaultDriver).toBe("managed-local");
|
|
114
|
+
expect(config.managedLocalEnabled).toBe(true);
|
|
115
|
+
expect(config.uploadRoot).toBe("C:\\safe\\uploads");
|
|
116
|
+
expect(config.downloadRoot).toBe("C:\\safe\\downloads");
|
|
117
|
+
expect(config.authToken).toBe("test-token");
|
|
118
|
+
expect(config.authScopes).toEqual(["read", "download"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("parses chrome relay extension mode env values", () => {
|
|
122
|
+
const config = loadBrowserdConfig({
|
|
123
|
+
BROWSERD_CHROME_RELAY_MODE: "extension",
|
|
124
|
+
BROWSERD_CHROME_RELAY_URL: "http://127.0.0.1:9555",
|
|
125
|
+
BROWSERD_CHROME_RELAY_EXTENSION_TOKEN: "relay-secret",
|
|
126
|
+
BROWSERD_CHROME_RELAY_EXTENSION_REQUEST_TIMEOUT_MS: "7000"
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(config.chromeRelayMode).toBe("extension");
|
|
130
|
+
expect(config.chromeRelayUrl).toBe("http://127.0.0.1:9555");
|
|
131
|
+
expect(config.chromeRelayExtensionToken).toBe("relay-secret");
|
|
132
|
+
expect(config.chromeRelayExtensionRequestTimeoutMs).toBe(7000);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("requires extension relay token when extension mode is enabled", () => {
|
|
136
|
+
expect(() =>
|
|
137
|
+
loadBrowserdConfig({
|
|
138
|
+
BROWSERD_CHROME_RELAY_MODE: "extension"
|
|
139
|
+
})
|
|
140
|
+
).toThrow("BROWSERD_CHROME_RELAY_EXTENSION_TOKEN");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("defaults to managed when managed-local is explicitly disabled", () => {
|
|
144
|
+
const config = loadBrowserdConfig({
|
|
145
|
+
BROWSERD_MANAGED_LOCAL_ENABLED: "false"
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(config.managedLocalEnabled).toBe(false);
|
|
149
|
+
expect(config.defaultDriver).toBe("managed");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("does not register managed-local driver when explicitly disabled", () => {
|
|
153
|
+
const c = createContainer(
|
|
154
|
+
loadBrowserdConfig({
|
|
155
|
+
BROWSERD_MANAGED_LOCAL_ENABLED: "false"
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
expect(c.drivers.has("managed-local")).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("browserd bootstrap", () => {
|
|
164
|
+
it("starts mcp stdio runtime with container wiring", () => {
|
|
165
|
+
const input = new PassThrough();
|
|
166
|
+
const output = new PassThrough();
|
|
167
|
+
|
|
168
|
+
const runtime = bootstrapBrowserd({ input, output });
|
|
169
|
+
|
|
170
|
+
expect(runtime.mcpStdioStarted).toBe(true);
|
|
171
|
+
expect(runtime.container.drivers.has("managed")).toBe(true);
|
|
172
|
+
expect(runtime.container.drivers.has("chrome-relay")).toBe(true);
|
|
173
|
+
expect(runtime.container.drivers.has("remote-cdp")).toBe(true);
|
|
174
|
+
expect(runtime.container.drivers.has("managed-local")).toBe(true);
|
|
175
|
+
|
|
176
|
+
runtime.close();
|
|
177
|
+
input.end();
|
|
178
|
+
output.end();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("supports tcp transport mode for persistent daemon use", async () => {
|
|
182
|
+
const port = 41419;
|
|
183
|
+
const runtime = bootstrapBrowserd({
|
|
184
|
+
env: {
|
|
185
|
+
BROWSERD_TRANSPORT: "tcp",
|
|
186
|
+
BROWSERD_PORT: String(port),
|
|
187
|
+
BROWSERD_AUTH_TOKEN: "tcp-token"
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const response = await sendTcpToolRequest(port, {
|
|
192
|
+
id: "request-tcp-status",
|
|
193
|
+
name: "browser.status",
|
|
194
|
+
traceId: "trace:tcp",
|
|
195
|
+
arguments: {
|
|
196
|
+
sessionId: "session:tcp",
|
|
197
|
+
authToken: "tcp-token"
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(response.ok).toBe(true);
|
|
202
|
+
expect(response.id).toBe("request-tcp-status");
|
|
203
|
+
expect(response.traceId).toBe("trace:tcp");
|
|
204
|
+
expect(response.sessionId).toBe("session:tcp");
|
|
205
|
+
expect(response.data).toMatchObject({
|
|
206
|
+
kind: "browserd",
|
|
207
|
+
ready: true
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
runtime.close();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("rejects tcp transport mode when auth token is not configured", () => {
|
|
214
|
+
let runtime: ReturnType<typeof bootstrapBrowserd> | undefined;
|
|
215
|
+
try {
|
|
216
|
+
expect(() => {
|
|
217
|
+
runtime = bootstrapBrowserd({
|
|
218
|
+
env: {
|
|
219
|
+
BROWSERD_TRANSPORT: "tcp",
|
|
220
|
+
BROWSERD_PORT: "41420"
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}).toThrow("BROWSERD_AUTH_TOKEN");
|
|
224
|
+
} finally {
|
|
225
|
+
runtime?.close();
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("processes line-delimited requests and writes response with id", async () => {
|
|
230
|
+
const input = new PassThrough();
|
|
231
|
+
const output = new PassThrough();
|
|
232
|
+
const runtime = bootstrapBrowserd({ input, output, stdioProtocol: "legacy" });
|
|
233
|
+
|
|
234
|
+
const response = await sendToolRequest(input, output, {
|
|
235
|
+
id: "request-1",
|
|
236
|
+
name: "browser.status",
|
|
237
|
+
traceId: "trace:test-1",
|
|
238
|
+
arguments: {
|
|
239
|
+
sessionId: "session:test-1"
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(response.id).toBe("request-1");
|
|
244
|
+
expect(response.ok).toBe(true);
|
|
245
|
+
expect(response.traceId).toBe("trace:test-1");
|
|
246
|
+
expect(response.sessionId).toBe("session:test-1");
|
|
247
|
+
expect(response.data).toMatchObject({
|
|
248
|
+
kind: "browserd",
|
|
249
|
+
ready: true,
|
|
250
|
+
driver: "managed-local"
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
runtime.close();
|
|
254
|
+
input.end();
|
|
255
|
+
output.end();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("supports managed-local as default driver when enabled", async () => {
|
|
259
|
+
const input = new PassThrough();
|
|
260
|
+
const output = new PassThrough();
|
|
261
|
+
const runtime = bootstrapBrowserd({
|
|
262
|
+
env: {
|
|
263
|
+
BROWSERD_MANAGED_LOCAL_ENABLED: "true",
|
|
264
|
+
BROWSERD_DEFAULT_DRIVER: "managed-local"
|
|
265
|
+
},
|
|
266
|
+
input,
|
|
267
|
+
output,
|
|
268
|
+
stdioProtocol: "legacy"
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
const response = await sendToolRequest(input, output, {
|
|
272
|
+
id: "request-local-default",
|
|
273
|
+
name: "browser.status",
|
|
274
|
+
traceId: "trace:local-default",
|
|
275
|
+
arguments: {
|
|
276
|
+
sessionId: "session:local-default"
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
expect(response.ok).toBe(true);
|
|
281
|
+
expect(response.data).toMatchObject({
|
|
282
|
+
kind: "browserd",
|
|
283
|
+
ready: true,
|
|
284
|
+
driver: "managed-local"
|
|
285
|
+
});
|
|
286
|
+
expect((response.data as { status: { kind: string; launched: boolean } }).status).toMatchObject({
|
|
287
|
+
kind: "managed-local",
|
|
288
|
+
launched: false
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
runtime.close();
|
|
292
|
+
input.end();
|
|
293
|
+
output.end();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("falls back to managed when configured default is unavailable", async () => {
|
|
297
|
+
const input = new PassThrough();
|
|
298
|
+
const output = new PassThrough();
|
|
299
|
+
const runtime = bootstrapBrowserd({
|
|
300
|
+
env: {
|
|
301
|
+
BROWSERD_DEFAULT_DRIVER: "managed-local",
|
|
302
|
+
BROWSERD_MANAGED_LOCAL_ENABLED: "false"
|
|
303
|
+
},
|
|
304
|
+
input,
|
|
305
|
+
output,
|
|
306
|
+
stdioProtocol: "legacy"
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
const response = await sendToolRequest(input, output, {
|
|
310
|
+
id: "request-default-fallback",
|
|
311
|
+
name: "browser.status",
|
|
312
|
+
traceId: "trace:default-fallback",
|
|
313
|
+
arguments: {
|
|
314
|
+
sessionId: "session:default-fallback"
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
expect(response.ok).toBe(true);
|
|
319
|
+
expect(response.data).toMatchObject({
|
|
320
|
+
kind: "browserd",
|
|
321
|
+
ready: true,
|
|
322
|
+
driver: "managed"
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
runtime.close();
|
|
326
|
+
input.end();
|
|
327
|
+
output.end();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("requires auth token when configured", async () => {
|
|
331
|
+
const input = new PassThrough();
|
|
332
|
+
const output = new PassThrough();
|
|
333
|
+
const runtime = bootstrapBrowserd({
|
|
334
|
+
env: {
|
|
335
|
+
BROWSERD_AUTH_TOKEN: "secret-token"
|
|
336
|
+
},
|
|
337
|
+
input,
|
|
338
|
+
output,
|
|
339
|
+
stdioProtocol: "legacy"
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const response = await sendToolRequest(input, output, {
|
|
343
|
+
id: "request-auth-missing",
|
|
344
|
+
name: "browser.status",
|
|
345
|
+
traceId: "trace:auth-missing",
|
|
346
|
+
arguments: {
|
|
347
|
+
sessionId: "session:auth-missing"
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
expect(response.ok).toBe(false);
|
|
352
|
+
expect(response.error).toMatchObject({
|
|
353
|
+
code: "E_PERMISSION"
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
runtime.close();
|
|
357
|
+
input.end();
|
|
358
|
+
output.end();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("rejects invalid auth token", async () => {
|
|
362
|
+
const input = new PassThrough();
|
|
363
|
+
const output = new PassThrough();
|
|
364
|
+
const runtime = bootstrapBrowserd({
|
|
365
|
+
env: {
|
|
366
|
+
BROWSERD_AUTH_TOKEN: "secret-token"
|
|
367
|
+
},
|
|
368
|
+
input,
|
|
369
|
+
output,
|
|
370
|
+
stdioProtocol: "legacy"
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const response = await sendToolRequest(input, output, {
|
|
374
|
+
id: "request-auth-invalid",
|
|
375
|
+
name: "browser.status",
|
|
376
|
+
traceId: "trace:auth-invalid",
|
|
377
|
+
arguments: {
|
|
378
|
+
sessionId: "session:auth-invalid",
|
|
379
|
+
authToken: "wrong-token"
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
expect(response.ok).toBe(false);
|
|
384
|
+
expect(response.error).toMatchObject({
|
|
385
|
+
code: "E_PERMISSION"
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
runtime.close();
|
|
389
|
+
input.end();
|
|
390
|
+
output.end();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("denies calls when requested tool scope is not allowed", async () => {
|
|
394
|
+
const input = new PassThrough();
|
|
395
|
+
const output = new PassThrough();
|
|
396
|
+
const runtime = bootstrapBrowserd({
|
|
397
|
+
env: {
|
|
398
|
+
BROWSERD_DEFAULT_DRIVER: "managed",
|
|
399
|
+
BROWSERD_AUTH_TOKEN: "secret-token",
|
|
400
|
+
BROWSERD_AUTH_SCOPES: "read"
|
|
401
|
+
},
|
|
402
|
+
input,
|
|
403
|
+
output,
|
|
404
|
+
stdioProtocol: "legacy"
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
const response = await sendToolRequest(input, output, {
|
|
408
|
+
id: "request-scope-deny",
|
|
409
|
+
name: "browser.tab.open",
|
|
410
|
+
traceId: "trace:scope-deny",
|
|
411
|
+
arguments: {
|
|
412
|
+
sessionId: "session:scope-deny",
|
|
413
|
+
authToken: "secret-token",
|
|
414
|
+
url: "https://example.com"
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
expect(response.ok).toBe(false);
|
|
419
|
+
expect(response.error).toMatchObject({
|
|
420
|
+
code: "E_PERMISSION"
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
runtime.close();
|
|
424
|
+
input.end();
|
|
425
|
+
output.end();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("denies upload and download tools when scopes exclude them", async () => {
|
|
429
|
+
const input = new PassThrough();
|
|
430
|
+
const output = new PassThrough();
|
|
431
|
+
const runtime = bootstrapBrowserd({
|
|
432
|
+
env: {
|
|
433
|
+
BROWSERD_DEFAULT_DRIVER: "managed",
|
|
434
|
+
BROWSERD_AUTH_TOKEN: "secret-token",
|
|
435
|
+
BROWSERD_AUTH_SCOPES: "read,act"
|
|
436
|
+
},
|
|
437
|
+
input,
|
|
438
|
+
output,
|
|
439
|
+
stdioProtocol: "legacy"
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const openResponse = await sendToolRequest(input, output, {
|
|
443
|
+
id: "request-scope-open",
|
|
444
|
+
name: "browser.tab.open",
|
|
445
|
+
traceId: "trace:scope:open",
|
|
446
|
+
arguments: {
|
|
447
|
+
sessionId: "session:scope:file-transfer",
|
|
448
|
+
authToken: "secret-token",
|
|
449
|
+
url: "https://example.com/scopes"
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
const targetId = (openResponse.data as { targetId: string }).targetId;
|
|
454
|
+
|
|
455
|
+
const uploadResponse = await sendToolRequest(input, output, {
|
|
456
|
+
id: "request-scope-upload",
|
|
457
|
+
name: "browser.upload.arm",
|
|
458
|
+
traceId: "trace:scope:upload",
|
|
459
|
+
arguments: {
|
|
460
|
+
sessionId: "session:scope:file-transfer",
|
|
461
|
+
authToken: "secret-token",
|
|
462
|
+
targetId,
|
|
463
|
+
files: ["C:\\allowed\\uploads\\file.txt"]
|
|
464
|
+
}
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
expect(uploadResponse.ok).toBe(false);
|
|
468
|
+
expect(uploadResponse.error).toMatchObject({
|
|
469
|
+
code: "E_PERMISSION"
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
const downloadResponse = await sendToolRequest(input, output, {
|
|
473
|
+
id: "request-scope-download",
|
|
474
|
+
name: "browser.download.wait",
|
|
475
|
+
traceId: "trace:scope:download",
|
|
476
|
+
arguments: {
|
|
477
|
+
sessionId: "session:scope:file-transfer",
|
|
478
|
+
authToken: "secret-token",
|
|
479
|
+
targetId
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
expect(downloadResponse.ok).toBe(false);
|
|
484
|
+
expect(downloadResponse.error).toMatchObject({
|
|
485
|
+
code: "E_PERMISSION"
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
runtime.close();
|
|
489
|
+
input.end();
|
|
490
|
+
output.end();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("rejects upload paths that escape configured upload root", async () => {
|
|
494
|
+
const input = new PassThrough();
|
|
495
|
+
const output = new PassThrough();
|
|
496
|
+
const runtime = bootstrapBrowserd({
|
|
497
|
+
env: {
|
|
498
|
+
BROWSERD_DEFAULT_DRIVER: "managed",
|
|
499
|
+
BROWSERD_UPLOAD_ROOT: "C:\\allowed\\uploads"
|
|
500
|
+
},
|
|
501
|
+
input,
|
|
502
|
+
output,
|
|
503
|
+
stdioProtocol: "legacy"
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const openResponse = await sendToolRequest(input, output, {
|
|
507
|
+
id: "request-upload-open",
|
|
508
|
+
name: "browser.tab.open",
|
|
509
|
+
traceId: "trace:upload:open",
|
|
510
|
+
arguments: {
|
|
511
|
+
sessionId: "session:upload-root",
|
|
512
|
+
url: "https://example.com/upload"
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const targetId = (openResponse.data as { targetId: string }).targetId;
|
|
517
|
+
const uploadResponse = await sendToolRequest(input, output, {
|
|
518
|
+
id: "request-upload-arm",
|
|
519
|
+
name: "browser.upload.arm",
|
|
520
|
+
traceId: "trace:upload:arm",
|
|
521
|
+
arguments: {
|
|
522
|
+
sessionId: "session:upload-root",
|
|
523
|
+
targetId,
|
|
524
|
+
files: ["..\\..\\escape.txt"]
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
expect(uploadResponse.ok).toBe(false);
|
|
529
|
+
expect(uploadResponse.error).toMatchObject({
|
|
530
|
+
code: "E_PERMISSION"
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
runtime.close();
|
|
534
|
+
input.end();
|
|
535
|
+
output.end();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("rejects upload-arm when upload root is not configured", async () => {
|
|
539
|
+
const input = new PassThrough();
|
|
540
|
+
const output = new PassThrough();
|
|
541
|
+
const runtime = bootstrapBrowserd({
|
|
542
|
+
env: {
|
|
543
|
+
BROWSERD_DEFAULT_DRIVER: "managed"
|
|
544
|
+
},
|
|
545
|
+
input,
|
|
546
|
+
output,
|
|
547
|
+
stdioProtocol: "legacy"
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
const openResponse = await sendToolRequest(input, output, {
|
|
551
|
+
id: "request-upload-root-open",
|
|
552
|
+
name: "browser.tab.open",
|
|
553
|
+
traceId: "trace:upload-root:open",
|
|
554
|
+
arguments: {
|
|
555
|
+
sessionId: "session:upload-root:required",
|
|
556
|
+
url: "https://example.com/upload-root"
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
const targetId = (openResponse.data as { targetId: string }).targetId;
|
|
561
|
+
const uploadResponse = await sendToolRequest(input, output, {
|
|
562
|
+
id: "request-upload-root-required",
|
|
563
|
+
name: "browser.upload.arm",
|
|
564
|
+
traceId: "trace:upload-root:required",
|
|
565
|
+
arguments: {
|
|
566
|
+
sessionId: "session:upload-root:required",
|
|
567
|
+
targetId,
|
|
568
|
+
files: ["C:\\temp\\upload.txt"]
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
expect(uploadResponse.ok).toBe(false);
|
|
573
|
+
expect(uploadResponse.error).toMatchObject({
|
|
574
|
+
code: "E_PERMISSION"
|
|
575
|
+
});
|
|
576
|
+
expect(String(uploadResponse.error?.message ?? "")).toContain("BROWSERD_UPLOAD_ROOT");
|
|
577
|
+
|
|
578
|
+
runtime.close();
|
|
579
|
+
input.end();
|
|
580
|
+
output.end();
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it("rejects download paths outside configured download root", async () => {
|
|
584
|
+
const input = new PassThrough();
|
|
585
|
+
const output = new PassThrough();
|
|
586
|
+
const runtime = bootstrapBrowserd({
|
|
587
|
+
env: {
|
|
588
|
+
BROWSERD_DEFAULT_DRIVER: "managed",
|
|
589
|
+
BROWSERD_DOWNLOAD_ROOT: "C:\\allowed\\downloads"
|
|
590
|
+
},
|
|
591
|
+
input,
|
|
592
|
+
output,
|
|
593
|
+
stdioProtocol: "legacy"
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
const openResponse = await sendToolRequest(input, output, {
|
|
597
|
+
id: "request-download-open",
|
|
598
|
+
name: "browser.tab.open",
|
|
599
|
+
traceId: "trace:download:open",
|
|
600
|
+
arguments: {
|
|
601
|
+
sessionId: "session:download-root",
|
|
602
|
+
url: "https://example.com/download"
|
|
603
|
+
}
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
const targetId = (openResponse.data as { targetId: string }).targetId;
|
|
607
|
+
|
|
608
|
+
const traversalResponse = await sendToolRequest(input, output, {
|
|
609
|
+
id: "request-download-wait-traversal",
|
|
610
|
+
name: "browser.download.wait",
|
|
611
|
+
traceId: "trace:download:wait:traversal",
|
|
612
|
+
arguments: {
|
|
613
|
+
sessionId: "session:download-root",
|
|
614
|
+
targetId,
|
|
615
|
+
path: "..\\escape.bin"
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
expect(traversalResponse.ok).toBe(false);
|
|
620
|
+
expect(traversalResponse.error).toMatchObject({
|
|
621
|
+
code: "E_PERMISSION"
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
const outsideResponse = await sendToolRequest(input, output, {
|
|
625
|
+
id: "request-download-wait-outside",
|
|
626
|
+
name: "browser.download.wait",
|
|
627
|
+
traceId: "trace:download:wait:outside",
|
|
628
|
+
arguments: {
|
|
629
|
+
sessionId: "session:download-root",
|
|
630
|
+
targetId,
|
|
631
|
+
path: "D:\\outside\\escape.bin"
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
expect(outsideResponse.ok).toBe(false);
|
|
636
|
+
expect(outsideResponse.error).toMatchObject({
|
|
637
|
+
code: "E_PERMISSION"
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
runtime.close();
|
|
641
|
+
input.end();
|
|
642
|
+
output.end();
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("rejects download-wait when download root is not configured", async () => {
|
|
646
|
+
const input = new PassThrough();
|
|
647
|
+
const output = new PassThrough();
|
|
648
|
+
const runtime = bootstrapBrowserd({
|
|
649
|
+
env: {
|
|
650
|
+
BROWSERD_DEFAULT_DRIVER: "managed"
|
|
651
|
+
},
|
|
652
|
+
input,
|
|
653
|
+
output,
|
|
654
|
+
stdioProtocol: "legacy"
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
const openResponse = await sendToolRequest(input, output, {
|
|
658
|
+
id: "request-download-root-open",
|
|
659
|
+
name: "browser.tab.open",
|
|
660
|
+
traceId: "trace:download-root:open",
|
|
661
|
+
arguments: {
|
|
662
|
+
sessionId: "session:download-root:required",
|
|
663
|
+
url: "https://example.com/download-root"
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
const targetId = (openResponse.data as { targetId: string }).targetId;
|
|
668
|
+
const downloadResponse = await sendToolRequest(input, output, {
|
|
669
|
+
id: "request-download-root-required",
|
|
670
|
+
name: "browser.download.wait",
|
|
671
|
+
traceId: "trace:download-root:required",
|
|
672
|
+
arguments: {
|
|
673
|
+
sessionId: "session:download-root:required",
|
|
674
|
+
targetId
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
expect(downloadResponse.ok).toBe(false);
|
|
679
|
+
expect(downloadResponse.error).toMatchObject({
|
|
680
|
+
code: "E_PERMISSION"
|
|
681
|
+
});
|
|
682
|
+
expect(String(downloadResponse.error?.message ?? "")).toContain("BROWSERD_DOWNLOAD_ROOT");
|
|
683
|
+
|
|
684
|
+
runtime.close();
|
|
685
|
+
input.end();
|
|
686
|
+
output.end();
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("routes tab.open and tab.list through driver-backed tool handlers", async () => {
|
|
690
|
+
const input = new PassThrough();
|
|
691
|
+
const output = new PassThrough();
|
|
692
|
+
const runtime = bootstrapBrowserd({
|
|
693
|
+
env: {
|
|
694
|
+
BROWSERD_DEFAULT_DRIVER: "managed"
|
|
695
|
+
},
|
|
696
|
+
input,
|
|
697
|
+
output,
|
|
698
|
+
stdioProtocol: "legacy"
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
const openResponse = await sendToolRequest(input, output, {
|
|
702
|
+
id: "request-open",
|
|
703
|
+
name: "browser.tab.open",
|
|
704
|
+
traceId: "trace:open",
|
|
705
|
+
arguments: {
|
|
706
|
+
sessionId: "session:flow",
|
|
707
|
+
url: "https://example.com"
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
expect(openResponse.ok).toBe(true);
|
|
712
|
+
expect(openResponse.data).toMatchObject({
|
|
713
|
+
driver: "managed"
|
|
714
|
+
});
|
|
715
|
+
const openedTargetId = (openResponse.data as Record<string, unknown>).targetId;
|
|
716
|
+
expect(typeof openedTargetId).toBe("string");
|
|
717
|
+
|
|
718
|
+
const listResponse = await sendToolRequest(input, output, {
|
|
719
|
+
id: "request-list",
|
|
720
|
+
name: "browser.tab.list",
|
|
721
|
+
traceId: "trace:list",
|
|
722
|
+
arguments: {
|
|
723
|
+
sessionId: "session:flow"
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
expect(listResponse.ok).toBe(true);
|
|
728
|
+
expect(listResponse.data).toMatchObject({
|
|
729
|
+
driver: "managed"
|
|
730
|
+
});
|
|
731
|
+
expect((listResponse.data as { tabs: string[] }).tabs).toContain(openedTargetId);
|
|
732
|
+
|
|
733
|
+
runtime.close();
|
|
734
|
+
input.end();
|
|
735
|
+
output.end();
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("routes console and network tools for known targets", async () => {
|
|
739
|
+
const input = new PassThrough();
|
|
740
|
+
const output = new PassThrough();
|
|
741
|
+
const runtime = bootstrapBrowserd({
|
|
742
|
+
env: {
|
|
743
|
+
BROWSERD_DEFAULT_DRIVER: "managed"
|
|
744
|
+
},
|
|
745
|
+
input,
|
|
746
|
+
output,
|
|
747
|
+
stdioProtocol: "legacy"
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
const openResponse = await sendToolRequest(input, output, {
|
|
751
|
+
id: "request-open-tools",
|
|
752
|
+
name: "browser.tab.open",
|
|
753
|
+
traceId: "trace:tools:open",
|
|
754
|
+
arguments: {
|
|
755
|
+
sessionId: "session:tools",
|
|
756
|
+
url: "https://example.com/tools"
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
const targetId = (openResponse.data as { targetId: string }).targetId;
|
|
761
|
+
|
|
762
|
+
const consoleResponse = await sendToolRequest(input, output, {
|
|
763
|
+
id: "request-console",
|
|
764
|
+
name: "browser.console.list",
|
|
765
|
+
traceId: "trace:tools:console",
|
|
766
|
+
arguments: {
|
|
767
|
+
sessionId: "session:tools",
|
|
768
|
+
targetId
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
expect(consoleResponse.ok).toBe(true);
|
|
773
|
+
expect(consoleResponse.data).toEqual({
|
|
774
|
+
driver: "managed",
|
|
775
|
+
targetId,
|
|
776
|
+
entries: []
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
const networkResponse = await sendToolRequest(input, output, {
|
|
780
|
+
id: "request-network",
|
|
781
|
+
name: "browser.network.responseBody",
|
|
782
|
+
traceId: "trace:tools:network",
|
|
783
|
+
arguments: {
|
|
784
|
+
sessionId: "session:tools",
|
|
785
|
+
targetId,
|
|
786
|
+
requestId: "request-42"
|
|
787
|
+
}
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
expect(networkResponse.ok).toBe(true);
|
|
791
|
+
expect(networkResponse.data).toEqual({
|
|
792
|
+
driver: "managed",
|
|
793
|
+
targetId,
|
|
794
|
+
requestId: "request-42",
|
|
795
|
+
body: "",
|
|
796
|
+
encoding: "utf8"
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
const screenshotResponse = await sendToolRequest(input, output, {
|
|
800
|
+
id: "request-screenshot",
|
|
801
|
+
name: "browser.screenshot",
|
|
802
|
+
traceId: "trace:tools:screenshot",
|
|
803
|
+
arguments: {
|
|
804
|
+
sessionId: "session:tools",
|
|
805
|
+
targetId
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
expect(screenshotResponse.ok).toBe(false);
|
|
810
|
+
expect(screenshotResponse.error).toMatchObject({
|
|
811
|
+
code: "E_DRIVER_UNAVAILABLE"
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
runtime.close();
|
|
815
|
+
input.end();
|
|
816
|
+
output.end();
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
it("preserves id and trace/session metadata when queue-level error handling runs", async () => {
|
|
820
|
+
const input = new PassThrough();
|
|
821
|
+
const output = new PassThrough();
|
|
822
|
+
const runtime = bootstrapBrowserd({ input, output, stdioProtocol: "legacy" });
|
|
823
|
+
|
|
824
|
+
const originalWrite = output.write.bind(output);
|
|
825
|
+
let shouldThrow = true;
|
|
826
|
+
(output as unknown as { write: (chunk: string | Buffer) => boolean }).write = (
|
|
827
|
+
chunk: string | Buffer
|
|
828
|
+
) => {
|
|
829
|
+
if (shouldThrow) {
|
|
830
|
+
shouldThrow = false;
|
|
831
|
+
throw new Error("simulated output failure");
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
return originalWrite(chunk);
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
const responsePromise = waitForNextJsonLine(output);
|
|
838
|
+
input.write(
|
|
839
|
+
`${JSON.stringify({
|
|
840
|
+
id: "request-failure",
|
|
841
|
+
name: "browser.status",
|
|
842
|
+
traceId: "trace:failure",
|
|
843
|
+
arguments: {
|
|
844
|
+
sessionId: "session:failure"
|
|
845
|
+
}
|
|
846
|
+
})}\n`
|
|
847
|
+
);
|
|
848
|
+
|
|
849
|
+
const response = await responsePromise;
|
|
850
|
+
|
|
851
|
+
expect(response.id).toBe("request-failure");
|
|
852
|
+
expect(response.ok).toBe(false);
|
|
853
|
+
expect(response.traceId).toBe("trace:failure");
|
|
854
|
+
expect(response.sessionId).toBe("session:failure");
|
|
855
|
+
expect(response.error).toEqual({
|
|
856
|
+
code: "E_INVALID_ARG",
|
|
857
|
+
message: "simulated output failure"
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
runtime.close();
|
|
861
|
+
input.end();
|
|
862
|
+
output.end();
|
|
863
|
+
});
|
|
864
|
+
});
|