@omercnet/paseo-shared-browser 4.1.2 → 4.1.3-next.367.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/npm-shrinkwrap.json +12 -12
- package/package.json +2 -2
- package/server/agent-browser-runtime.ts +57 -24
- package/server/daemon-shutdown.test.ts +110 -0
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omercnet/paseo-shared-browser",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.3",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@omercnet/paseo-shared-browser",
|
|
9
|
-
"version": "4.1.
|
|
9
|
+
"version": "4.1.3",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@getpaseo/plugin": "0.14.0",
|
|
12
|
-
"@modelcontextprotocol/sdk": "1.
|
|
12
|
+
"@modelcontextprotocol/sdk": "1.32.2",
|
|
13
13
|
"agent-browser": "0.40.0",
|
|
14
14
|
"esbuild": "0.28.2",
|
|
15
15
|
"zod": "4.6.7"
|
|
@@ -511,9 +511,9 @@
|
|
|
511
511
|
}
|
|
512
512
|
},
|
|
513
513
|
"node_modules/@modelcontextprotocol/sdk": {
|
|
514
|
-
"version": "1.
|
|
515
|
-
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.
|
|
516
|
-
"integrity": "sha512-
|
|
514
|
+
"version": "1.32.2",
|
|
515
|
+
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.2.tgz",
|
|
516
|
+
"integrity": "sha512-SWsVtH/0vULSQStoZ6xxH2K09Li8fXYTMC64ZeogCdpZ3XSi1eCPOByV1OpuJxuI3OcmHhosDWYyii07tpPsLw==",
|
|
517
517
|
"license": "MIT",
|
|
518
518
|
"dependencies": {
|
|
519
519
|
"@hono/node-server": "^1.19.9 || ^2.0.5",
|
|
@@ -1204,9 +1204,9 @@
|
|
|
1204
1204
|
}
|
|
1205
1205
|
},
|
|
1206
1206
|
"node_modules/hono": {
|
|
1207
|
-
"version": "4.13.
|
|
1208
|
-
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.
|
|
1209
|
-
"integrity": "sha512-
|
|
1207
|
+
"version": "4.13.16",
|
|
1208
|
+
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.16.tgz",
|
|
1209
|
+
"integrity": "sha512-AOLOEhvrV+iH92xXCnFr/5+3KjPKcI3xP63XJtX5spAnkznPdHijlIsUTpi7/9EhWqrTCekw5c5zh1K+YsVw7A==",
|
|
1210
1210
|
"license": "MIT",
|
|
1211
1211
|
"engines": {
|
|
1212
1212
|
"node": ">=16.9.0"
|
|
@@ -1233,9 +1233,9 @@
|
|
|
1233
1233
|
}
|
|
1234
1234
|
},
|
|
1235
1235
|
"node_modules/i18next": {
|
|
1236
|
-
"version": "26.
|
|
1237
|
-
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.
|
|
1238
|
-
"integrity": "sha512-
|
|
1236
|
+
"version": "26.6.0",
|
|
1237
|
+
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.6.0.tgz",
|
|
1238
|
+
"integrity": "sha512-jpb1n5386s9mbBLNF6tSNIVhbBUUNiNeDyjWte+uxHP/Wr+CEy7WBM8q29TkfQ46VbwKeJFYdQJK3pSUhLp4CQ==",
|
|
1239
1239
|
"funding": [
|
|
1240
1240
|
{
|
|
1241
1241
|
"type": "individual",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omercnet/paseo-shared-browser",
|
|
3
|
-
"version": "4.1.
|
|
3
|
+
"version": "4.1.3-next.367.1",
|
|
4
4
|
"description": "One real Chromium session per Paseo workspace, shared live across every connected client.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"browser",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
58
|
"@getpaseo/plugin": "0.14.0",
|
|
59
|
-
"@modelcontextprotocol/sdk": "1.
|
|
59
|
+
"@modelcontextprotocol/sdk": "1.32.2",
|
|
60
60
|
"agent-browser": "0.40.0",
|
|
61
61
|
"esbuild": "0.28.2",
|
|
62
62
|
"zod": "4.6.7"
|
|
@@ -317,6 +317,8 @@ export class AgentBrowserRuntime {
|
|
|
317
317
|
private shutDown = false;
|
|
318
318
|
/** A daemon was launched for this session, so absence of evidence is not proof of exit. */
|
|
319
319
|
private daemonLaunched = false;
|
|
320
|
+
/** The in-flight initial `open`, settled only once the daemon it started was captured. */
|
|
321
|
+
private opening: Promise<unknown> | null = null;
|
|
320
322
|
private readonly nativeVideo: boolean;
|
|
321
323
|
private video: NativeVideoCapture | null = null;
|
|
322
324
|
private videoRetirement: Promise<void> | null = null;
|
|
@@ -379,36 +381,53 @@ export class AgentBrowserRuntime {
|
|
|
379
381
|
// From here a nonce-owned daemon may exist even if open rejects (e.g. navigation failure).
|
|
380
382
|
this.daemonLaunched = true;
|
|
381
383
|
this.daemon = null;
|
|
384
|
+
const opening = this.openDaemon([
|
|
385
|
+
"--session",
|
|
386
|
+
this.session,
|
|
387
|
+
"--profile",
|
|
388
|
+
this.profilePath,
|
|
389
|
+
"--executable-path",
|
|
390
|
+
this.executablePath,
|
|
391
|
+
...(videoExtension ? ["--extension", videoExtension] : []),
|
|
392
|
+
"--hide-scrollbars",
|
|
393
|
+
"false",
|
|
394
|
+
...(this.headed ? ["--headed"] : []),
|
|
395
|
+
...(chromiumArguments ? ["--args", chromiumArguments] : []),
|
|
396
|
+
"--json",
|
|
397
|
+
"open",
|
|
398
|
+
this.initialUrl,
|
|
399
|
+
]);
|
|
400
|
+
this.opening = opening;
|
|
382
401
|
let opened: unknown;
|
|
383
402
|
try {
|
|
384
|
-
opened = await
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
"--profile",
|
|
388
|
-
this.profilePath,
|
|
389
|
-
"--executable-path",
|
|
390
|
-
this.executablePath,
|
|
391
|
-
...(videoExtension ? ["--extension", videoExtension] : []),
|
|
392
|
-
"--hide-scrollbars",
|
|
393
|
-
"false",
|
|
394
|
-
...(this.headed ? ["--headed"] : []),
|
|
395
|
-
...(chromiumArguments ? ["--args", chromiumArguments] : []),
|
|
396
|
-
"--json",
|
|
397
|
-
"open",
|
|
398
|
-
this.initialUrl,
|
|
399
|
-
]);
|
|
400
|
-
} catch (error) {
|
|
401
|
-
// Record the daemon a failed open left behind: since agent-browser 0.40.0 `close`
|
|
402
|
-
// removes the PID file before returning, after which its exit is unobservable.
|
|
403
|
-
await this.captureDaemon().catch(() => undefined);
|
|
404
|
-
throw error;
|
|
403
|
+
opened = await opening;
|
|
404
|
+
} finally {
|
|
405
|
+
if (this.opening === opening) this.opening = null;
|
|
405
406
|
}
|
|
406
407
|
this.assertRuntimeOpen();
|
|
407
408
|
const targetId = findString(opened, ["targetId", "target_id"]);
|
|
409
|
+
await this.connectCdp(targetId);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Runs the initial `open` and records the daemon it started before settling. Since
|
|
414
|
+
* agent-browser 0.40.0 `close` removes the PID file before returning, after which the
|
|
415
|
+
* daemon's exit is unobservable, so shutdown awaits this before issuing `close`.
|
|
416
|
+
*/
|
|
417
|
+
private async openDaemon(args: string[]): Promise<unknown> {
|
|
418
|
+
let opened: unknown;
|
|
419
|
+
try {
|
|
420
|
+
opened = await this.invoke(args);
|
|
421
|
+
} catch (error) {
|
|
422
|
+
await this.protectIpcMetadata();
|
|
423
|
+
// The PID file may be a stale record of an earlier daemon, written before this one
|
|
424
|
+
// recorded its own: drop it when disproven and leave it to shutdown's lazy lookup.
|
|
425
|
+
await this.captureDaemon(true).catch(() => undefined);
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
408
428
|
await this.protectIpcMetadata();
|
|
409
429
|
await this.captureDaemon();
|
|
410
|
-
|
|
411
|
-
await this.connectCdp(targetId);
|
|
430
|
+
return opened;
|
|
412
431
|
}
|
|
413
432
|
|
|
414
433
|
/** Attach an independent page controller to an existing browser session. */
|
|
@@ -1777,6 +1796,11 @@ export class AgentBrowserRuntime {
|
|
|
1777
1796
|
|
|
1778
1797
|
/** Close, then require observed exit; escalate to a verified-owned SIGKILL and observe that too. */
|
|
1779
1798
|
private async stopDaemon(force: boolean): Promise<void> {
|
|
1799
|
+
// A launch still awaiting `open` must capture its daemon before `close` removes the PID file.
|
|
1800
|
+
await this.opening?.catch(() => undefined);
|
|
1801
|
+
// A failed open may have found only a stale record, its daemon writing the PID file later:
|
|
1802
|
+
// record that before `close` removes it, still skipping a record disproven as ours.
|
|
1803
|
+
if (this.daemonLaunched && !this.daemon) await this.captureDaemon(true).catch(() => undefined);
|
|
1780
1804
|
let forced = force;
|
|
1781
1805
|
if (!forced) {
|
|
1782
1806
|
try {
|
|
@@ -1797,11 +1821,20 @@ export class AgentBrowserRuntime {
|
|
|
1797
1821
|
/**
|
|
1798
1822
|
* Records the PID-file daemon. Only a PID whose environment carries this runtime's own
|
|
1799
1823
|
* nonce gets a verified identity; anything else is observable but never signalled.
|
|
1824
|
+
* `possiblyStale` drops a PID proven not this runtime's live daemon (foreign or gone), so
|
|
1825
|
+
* a later lookup re-reads the file; an unknown probe keeps a live PID as exit evidence.
|
|
1800
1826
|
*/
|
|
1801
|
-
private async captureDaemon(): Promise<void> {
|
|
1827
|
+
private async captureDaemon(possiblyStale = false): Promise<void> {
|
|
1802
1828
|
const pid = await this.daemonPid();
|
|
1803
1829
|
if (pid === null) return;
|
|
1804
1830
|
const probe = await probeProcess(pid, this.ownerNonce);
|
|
1831
|
+
if (
|
|
1832
|
+
possiblyStale &&
|
|
1833
|
+
(probe.status === "foreign" ||
|
|
1834
|
+
probe.status === "absent" ||
|
|
1835
|
+
(probe.status === "unknown" && !processExists(pid)))
|
|
1836
|
+
)
|
|
1837
|
+
return;
|
|
1805
1838
|
this.daemon = { pid, identity: probe.status === "owned" ? probe.identity : null };
|
|
1806
1839
|
}
|
|
1807
1840
|
|
|
@@ -38,6 +38,9 @@ const cli = vi.hoisted(() => ({
|
|
|
38
38
|
closes: 0,
|
|
39
39
|
/** Set to make `open` fail after its side effect, like a navigation failure. */
|
|
40
40
|
onOpen: null as null | (() => void),
|
|
41
|
+
/** Set to hold the `open` reply until it resolves, like a slow initial navigation. */
|
|
42
|
+
openReply: null as null | Promise<void>,
|
|
43
|
+
opens: 0,
|
|
41
44
|
version: "0.37.1",
|
|
42
45
|
}));
|
|
43
46
|
vi.mock("node:child_process", async (importOriginal) => ({
|
|
@@ -52,6 +55,11 @@ vi.mock("node:child_process", async (importOriginal) => ({
|
|
|
52
55
|
cli.closes += 1;
|
|
53
56
|
cli.onClose?.();
|
|
54
57
|
}
|
|
58
|
+
if (args.includes("open")) cli.opens += 1;
|
|
59
|
+
if (args.includes("open") && cli.openReply) {
|
|
60
|
+
void cli.openReply.then(() => callback(null, { stdout: "{}\n" }));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
55
63
|
if (args.includes("open") && cli.onOpen) {
|
|
56
64
|
cli.onOpen();
|
|
57
65
|
callback(new Error("Navigation failed: net::ERR_CONNECTION_REFUSED"));
|
|
@@ -71,6 +79,8 @@ afterEach(async () => {
|
|
|
71
79
|
cli.onClose = null;
|
|
72
80
|
cli.closes = 0;
|
|
73
81
|
cli.onOpen = null;
|
|
82
|
+
cli.openReply = null;
|
|
83
|
+
cli.opens = 0;
|
|
74
84
|
cli.version = "0.37.1";
|
|
75
85
|
probe.unreadable = false;
|
|
76
86
|
probe.simulated = null;
|
|
@@ -223,6 +233,27 @@ describe("unverifiable daemon identity (platforms without /proc, unowned PIDs)",
|
|
|
223
233
|
expect(kill).not.toHaveBeenCalledWith(daemon.pid, "SIGKILL");
|
|
224
234
|
});
|
|
225
235
|
|
|
236
|
+
it("confirms the exit of a daemon left by a failed open when its identity probe is unknown", async () => {
|
|
237
|
+
const f = await fixture();
|
|
238
|
+
// The normal probe result where /proc is missing or unreadable (macOS, Windows).
|
|
239
|
+
probe.unreadable = true;
|
|
240
|
+
const daemon = await ownChild(f.native.ownerNonce);
|
|
241
|
+
const pidFile = join(f.ipc, "owned.pid");
|
|
242
|
+
cli.version = AGENT_BROWSER_VERSION;
|
|
243
|
+
cli.onOpen = () => writeFileSync(pidFile, String(daemon.pid));
|
|
244
|
+
// agent-browser >= 0.40.0: close returns only after the daemon removed its PID file.
|
|
245
|
+
cli.onClose = () => {
|
|
246
|
+
daemon.kill("SIGTERM");
|
|
247
|
+
rmSync(pidFile, { force: true });
|
|
248
|
+
};
|
|
249
|
+
await expect(f.runtime.launch()).rejects.toThrow("agent-browser command failed");
|
|
250
|
+
expect(f.native.daemon).toEqual({ pid: daemon.pid, identity: null });
|
|
251
|
+
const kill = vi.spyOn(process, "kill");
|
|
252
|
+
await f.runtime.shutdown();
|
|
253
|
+
expect(kill).not.toHaveBeenCalledWith(daemon.pid, "SIGKILL");
|
|
254
|
+
await exited(daemon);
|
|
255
|
+
});
|
|
256
|
+
|
|
226
257
|
it("has nothing to observe or signal for a runtime that never launched", async () => {
|
|
227
258
|
// Never launched: ordinary cleanup has nothing to observe.
|
|
228
259
|
const f = await fixture(150);
|
|
@@ -308,6 +339,85 @@ describe.each(ownershipModes)(
|
|
|
308
339
|
expect(daemon.pid && alive(daemon.pid)).toBe(false);
|
|
309
340
|
});
|
|
310
341
|
|
|
342
|
+
it("does not take a stale PID file left before a failed open as the exit of a later daemon", async () => {
|
|
343
|
+
const f = await fixture(200);
|
|
344
|
+
const stale = await ownChild();
|
|
345
|
+
stale.kill("SIGKILL");
|
|
346
|
+
await exited(stale);
|
|
347
|
+
const pidFile = join(f.ipc, "owned.pid");
|
|
348
|
+
// A crashed earlier daemon left its PID file; the new one has not recorded itself yet.
|
|
349
|
+
await writeFile(pidFile, String(stale.pid));
|
|
350
|
+
cli.version = AGENT_BROWSER_VERSION;
|
|
351
|
+
cli.onOpen = () => undefined;
|
|
352
|
+
await expect(f.runtime.launch()).rejects.toThrow("agent-browser command failed");
|
|
353
|
+
// The new daemon records itself only after the open already failed.
|
|
354
|
+
const daemon = await ownChild(f.native.ownerNonce);
|
|
355
|
+
own(daemon);
|
|
356
|
+
await writeFile(pidFile, String(daemon.pid));
|
|
357
|
+
const kill = vi.spyOn(process, "kill");
|
|
358
|
+
await f.runtime.shutdown();
|
|
359
|
+
expect(kill).toHaveBeenCalledWith(daemon.pid, "SIGKILL");
|
|
360
|
+
await exited(daemon);
|
|
361
|
+
expect(daemon.pid && alive(daemon.pid)).toBe(false);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("confirms the exit of a daemon that records its PID after a failed open, before close removes it", async () => {
|
|
365
|
+
const f = await fixture();
|
|
366
|
+
const stale = await ownChild();
|
|
367
|
+
stale.kill("SIGKILL");
|
|
368
|
+
await exited(stale);
|
|
369
|
+
const pidFile = join(f.ipc, "owned.pid");
|
|
370
|
+
// A crashed earlier daemon left its PID file; the new one has not recorded itself yet.
|
|
371
|
+
await writeFile(pidFile, String(stale.pid));
|
|
372
|
+
cli.version = AGENT_BROWSER_VERSION;
|
|
373
|
+
cli.onOpen = () => undefined;
|
|
374
|
+
await expect(f.runtime.launch()).rejects.toThrow("agent-browser command failed");
|
|
375
|
+
expect(f.native.daemon).toBeNull();
|
|
376
|
+
// The new daemon records itself only after the open already failed.
|
|
377
|
+
const daemon = await ownChild(f.native.ownerNonce);
|
|
378
|
+
own(daemon);
|
|
379
|
+
await writeFile(pidFile, String(daemon.pid));
|
|
380
|
+
// agent-browser >= 0.40.0: close returns only after the daemon removed its PID file.
|
|
381
|
+
cli.onClose = () => {
|
|
382
|
+
daemon.kill("SIGTERM");
|
|
383
|
+
rmSync(pidFile, { force: true });
|
|
384
|
+
};
|
|
385
|
+
const kill = vi.spyOn(process, "kill");
|
|
386
|
+
await f.runtime.shutdown();
|
|
387
|
+
expect(kill).not.toHaveBeenCalledWith(daemon.pid, "SIGKILL");
|
|
388
|
+
await exited(daemon);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("captures the daemon of a launch still awaiting open before a racing shutdown closes it", async () => {
|
|
392
|
+
const f = await fixture();
|
|
393
|
+
const daemon = await ownChild(f.native.ownerNonce);
|
|
394
|
+
own(daemon);
|
|
395
|
+
const pidFile = join(f.ipc, "owned.pid");
|
|
396
|
+
cli.version = AGENT_BROWSER_VERSION;
|
|
397
|
+
// The daemon recorded its PID, but the initial open replies only later.
|
|
398
|
+
await writeFile(pidFile, String(daemon.pid));
|
|
399
|
+
const { promise: openReply, resolve: reply } = Promise.withResolvers<void>();
|
|
400
|
+
cli.openReply = openReply;
|
|
401
|
+
// agent-browser >= 0.40.0: close returns only after the daemon removed its PID file;
|
|
402
|
+
// the aborted open then replies with the PID file already gone.
|
|
403
|
+
cli.onClose = () => {
|
|
404
|
+
daemon.kill("SIGTERM");
|
|
405
|
+
rmSync(pidFile, { force: true });
|
|
406
|
+
reply();
|
|
407
|
+
};
|
|
408
|
+
const launching = f.runtime.launch();
|
|
409
|
+
await vi.waitFor(() => expect(cli.opens).toBe(1));
|
|
410
|
+
const kill = vi.spyOn(process, "kill");
|
|
411
|
+
const stopping = f.runtime.shutdown();
|
|
412
|
+
// A real-time race: the open replies on its own a little later unless close ends it
|
|
413
|
+
// first; awaiting the launch from shutdown is exactly what is under test.
|
|
414
|
+
setTimeout(reply, 100);
|
|
415
|
+
await expect(launching).rejects.toThrow("shutting down");
|
|
416
|
+
await stopping;
|
|
417
|
+
expect(kill).not.toHaveBeenCalledWith(daemon.pid, "SIGKILL");
|
|
418
|
+
await exited(daemon);
|
|
419
|
+
});
|
|
420
|
+
|
|
311
421
|
it("shares one in-flight shutdown and never reports success after a failed one", async () => {
|
|
312
422
|
const f = await fixture(150);
|
|
313
423
|
const daemon = await ownChild();
|