@omercnet/paseo-shared-browser 1.2.2-next.240.1 → 1.2.2-next.242.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/OVERVIEW.md +2 -2
- package/README.md +8 -6
- package/client/sidebar.tsx +1 -2
- package/index.server.ts +0 -4
- package/package.json +2 -1
- package/paseo-plugin.json +1 -3
- package/server/agent-browser-runtime.ts +3 -0
- package/server/runtime-owner.ts +39 -4
- package/server/supervisor.ts +2 -2
- package/server/tab-close-confirmation.test.ts +97 -0
package/OVERVIEW.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
Shared Browser runs one real Chromium browser per workspace on the daemon host and shows that same live page to every connected Paseo client, so viewers and agents act on one page with one set of cookies and logins. Many viewers can watch, and one human at a time holds control. Open it from the Command Center, the **Shared Browser** composer pill, or
|
|
1
|
+
Shared Browser runs one real Chromium browser per workspace on the daemon host and shows that same live page to every connected Paseo client, so viewers and agents act on one page with one set of cookies and logins. Many viewers can watch, and one human at a time holds control. Open it from the Command Center, the **Shared Browser** composer pill, or the sidebar footer.
|
|
2
2
|
|
|
3
3
|
## How it works
|
|
4
4
|
|
|
@@ -13,7 +13,7 @@ New agents whose provider accepts external MCP servers receive a local MCP serve
|
|
|
13
13
|
|
|
14
14
|
## Setup
|
|
15
15
|
|
|
16
|
-
- Paseo 0.
|
|
16
|
+
- Paseo ^0.11.0, and Node.js 24 or newer on the daemon host.
|
|
17
17
|
- Chromium is downloaded when the plugin is installed and stored in the Paseo data directory. On Linux ARM64 nothing is downloaded, and you install a non-Snap Chromium at `/usr/bin/chromium` first. `PASEO_SHARED_BROWSER_CHROMIUM_EXECUTABLE` points the plugin at a different Chromium.
|
|
18
18
|
|
|
19
19
|
## Limits
|
package/README.md
CHANGED
|
@@ -110,7 +110,7 @@ Before upgrading the plugin on Windows, close active Shared Browser sessions and
|
|
|
110
110
|
daemon; Windows does not allow the installer to replace runtime executables that are still running.
|
|
111
111
|
|
|
112
112
|
Open a workspace, search the Command Center for **Open Shared Browser**, or tap the **Shared
|
|
113
|
-
Browser** composer pill while a workspace session is open.
|
|
113
|
+
Browser** composer pill while a workspace session is open. A **Shared
|
|
114
114
|
Browser (n)** row in the sidebar footer appears while browser sessions are open; it lists them and
|
|
115
115
|
jumps to the chosen workspace's browser panel.
|
|
116
116
|
|
|
@@ -119,7 +119,7 @@ jumps to the chosen workspace's browser panel.
|
|
|
119
119
|
The plugin automatically injects its stdio MCP adapter only when a new, non-internal agent is
|
|
120
120
|
created with a provider that accepts external MCP servers. Agents that already exist, resumed
|
|
121
121
|
sessions, imported sessions, and Paseo's internal agents are not modified. Paseo's built-in OMP
|
|
122
|
-
provider accepts session MCP servers
|
|
122
|
+
provider accepts session MCP servers, so new OMP agents receive the adapter.
|
|
123
123
|
Pi agents also receive the adapter, but require Pi's optional MCP support to
|
|
124
124
|
launch it.
|
|
125
125
|
|
|
@@ -353,6 +353,11 @@ bun run check
|
|
|
353
353
|
bun run test:unit
|
|
354
354
|
```
|
|
355
355
|
|
|
356
|
+
The `test:tabs` native check requires explicit
|
|
357
|
+
`PASEO_SHARED_BROWSER_AGENT_BROWSER_BINARY` and
|
|
358
|
+
`PASEO_SHARED_BROWSER_CHROMIUM_EXECUTABLE` paths. It creates and cleans up its own
|
|
359
|
+
temporary Paseo home and browser profile.
|
|
360
|
+
|
|
356
361
|
`node scripts/test-image-capture-mounted.mjs --tooling-root /path/to/test-workspace`
|
|
357
362
|
opt-in checks the real React/React Query image scheduling and viewing recovery hooks
|
|
358
363
|
in JSDOM. The tooling workspace must already provide compatible React, react-dom,
|
|
@@ -417,10 +422,7 @@ runs the actual Paseo compiler on both plugin entries.
|
|
|
417
422
|
Release Please maintains the version, changelog, component tag, and GitHub release from
|
|
418
423
|
Conventional Commits in the monorepo.
|
|
419
424
|
|
|
420
|
-
Both the Paseo daemon and app must satisfy
|
|
421
|
-
The upstream 0.9, 0.10 and 0.11 ranges remain supported by the manifest;
|
|
422
|
-
the tested `0.11.0-beta.3` prerelease is also allowed. Older daemon/client
|
|
423
|
-
combinations still need runtime qualification. Encoded video is opt-in on the daemon and
|
|
425
|
+
Both the Paseo daemon and app must satisfy `^0.11.0`. Encoded video is opt-in on the daemon and
|
|
424
426
|
requires WebCodecs in a web client; JPEG is always available. The client surface uses React
|
|
425
427
|
Native primitives and works in desktop, web, iOS, and Android Paseo clients.
|
|
426
428
|
|
package/client/sidebar.tsx
CHANGED
|
@@ -123,9 +123,8 @@ function createSidebarItem(client: PluginClientContext) {
|
|
|
123
123
|
};
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
/** Sidebar footer row listing open sessions.
|
|
126
|
+
/** Sidebar footer row listing open sessions. */
|
|
127
127
|
export function contributeSharedBrowserSidebar(client: PluginClientContext): () => void {
|
|
128
|
-
if (typeof client.addSidebarFooterItem !== "function") return () => {};
|
|
129
128
|
return client.addSidebarFooterItem({
|
|
130
129
|
id: "shared-browser-sessions",
|
|
131
130
|
title: "Shared Browser",
|
package/index.server.ts
CHANGED
|
@@ -109,12 +109,8 @@ export default function contribute(server: PluginServerContext) {
|
|
|
109
109
|
server.handle(resizeBrowserRpc, handleResizeBrowser);
|
|
110
110
|
server.handle(applyDevicePresetRpc, handleApplyDevicePreset);
|
|
111
111
|
server.handle(sendBrowserInputRpc, handleSendBrowserInput);
|
|
112
|
-
// OMP accepts session MCP servers from Paseo 0.11.0-beta.1, the release that added
|
|
113
|
-
// registerUsageSource; 0.9/0.10 reject agent creation when an OMP config carries MCP servers.
|
|
114
|
-
const ompAcceptsMcp = typeof server.registerUsageSource === "function";
|
|
115
112
|
server.before("agent.create", async ({ request }) => {
|
|
116
113
|
if (request.config.internal) return request;
|
|
117
|
-
if (request.config.provider === "omp" && !ompAcceptsMcp) return request;
|
|
118
114
|
const ticket = randomBytes(32).toString("base64url");
|
|
119
115
|
if (!(await issueTicketWithinDeadline(ticket))) return request;
|
|
120
116
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omercnet/paseo-shared-browser",
|
|
3
|
-
"version": "1.2.2-next.
|
|
3
|
+
"version": "1.2.2-next.242.1",
|
|
4
4
|
"description": "One real Chromium session per Paseo workspace, shared live across every connected client.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"browser",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"test": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/agent-input-safety.test.ts tests/tabs.test.ts tests/tabs-runtime-loss.test.ts tests/composer-pills.test.ts tests/sidebar.test.ts tests/pointer-mapping.test.ts tests/frame-lifecycle.test.ts tests/viewport-resync.test.ts client server shared",
|
|
51
51
|
"test:unit": "vitest run tests/contracts.test.ts tests/lease.test.ts tests/runtime-lifecycle.test.ts tests/agent-access.test.ts tests/agent-input-safety.test.ts tests/tabs.test.ts tests/tabs-runtime-loss.test.ts tests/composer-pills.test.ts tests/sidebar.test.ts tests/pointer-mapping.test.ts tests/frame-lifecycle.test.ts tests/viewport-resync.test.ts client server shared",
|
|
52
52
|
"test:smoke": "vitest run tests/browser.smoke.test.ts tests/browser.drag-cancel.smoke.test.ts tests/xvfb.smoke.test.ts tests/browser.failed-open.smoke.test.ts tests/browser.background-tab.smoke.test.ts --testTimeout=30000",
|
|
53
|
+
"test:tabs": "vitest run tests/browser-tabs.smoke.test.ts --testTimeout=30000",
|
|
53
54
|
"test:video": "vitest run tests/video.smoke.test.ts --testTimeout=30000"
|
|
54
55
|
},
|
|
55
56
|
"dependencies": {
|
package/paseo-plugin.json
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "shared-browser",
|
|
3
3
|
"description": "One real Chromium session per Paseo workspace, shared live across every connected client.",
|
|
4
|
-
"requirements": {
|
|
5
|
-
"paseo": "^0.9.0 || ^0.10.0 || ^0.11.0 || 0.11.0-beta.3 || 0.11.0-beta.4"
|
|
6
|
-
},
|
|
4
|
+
"requirements": { "paseo": "^0.11.0" },
|
|
7
5
|
"build": [["node", "scripts/prepare-dependencies.mjs"], ["npm", "run", "prepare:runtime"]]
|
|
8
6
|
}
|
|
@@ -368,6 +368,9 @@ export class AgentBrowserRuntime {
|
|
|
368
368
|
const existingArguments = process.env.PASEO_SHARED_BROWSER_CHROMIUM_ARGS;
|
|
369
369
|
const chromiumArguments = [
|
|
370
370
|
existingArguments,
|
|
371
|
+
// agent-browser creates the launch page itself. Chromium's default
|
|
372
|
+
// startup window otherwise leaves an additional chrome://newtab page.
|
|
373
|
+
"--no-startup-window",
|
|
371
374
|
videoExtension ? `--allowlisted-extension-id=${NATIVE_VIDEO_EXTENSION_ID}` : undefined,
|
|
372
375
|
]
|
|
373
376
|
.filter(Boolean)
|
package/server/runtime-owner.ts
CHANGED
|
@@ -7,7 +7,9 @@ import {
|
|
|
7
7
|
AgentBrowserRuntime,
|
|
8
8
|
type AgentBrowserRuntimeOptions,
|
|
9
9
|
type BrowserViewport,
|
|
10
|
+
type RuntimeTarget,
|
|
10
11
|
} from "./agent-browser-runtime";
|
|
12
|
+
import { CdpUnknownOutcomeError } from "./cdp";
|
|
11
13
|
import { resolveBrowserRuntimeRoot } from "./runtime-path";
|
|
12
14
|
import type { JsonValue } from "./runtime-protocol";
|
|
13
15
|
import type { RuntimeOwner } from "./supervisor";
|
|
@@ -25,6 +27,7 @@ interface OwnedRuntime {
|
|
|
25
27
|
rootTargetId: string;
|
|
26
28
|
runtimeOptions: AgentBrowserRuntimeOptions;
|
|
27
29
|
tabs: Map<string, Promise<AgentBrowserRuntime>>;
|
|
30
|
+
tabOrder: string[];
|
|
28
31
|
display: PrivateVirtualDisplay | null;
|
|
29
32
|
/** Bounded, secret-free reason an opted-in private display is not in use. */
|
|
30
33
|
displayNotice: string | null;
|
|
@@ -53,6 +56,22 @@ function optionalMouseModifiers(data: Record<string, JsonValue>): [] | [number]
|
|
|
53
56
|
: [browserGestureKeySchema.shape.modifiers.parse(data.modifiers)];
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
/** Retain known tab positions across unordered CDP snapshots; append newly discovered pages. */
|
|
60
|
+
async function orderedTargets(owned: OwnedRuntime): Promise<RuntimeTarget[]> {
|
|
61
|
+
const targets = await owned.runtime.targets();
|
|
62
|
+
const byId = new Map(targets.map((target) => [target.targetId, target]));
|
|
63
|
+
const surviving = owned.tabOrder.filter((targetId) => byId.has(targetId));
|
|
64
|
+
const known = new Set(surviving);
|
|
65
|
+
for (const target of targets) {
|
|
66
|
+
if (!known.has(target.targetId)) {
|
|
67
|
+
surviving.push(target.targetId);
|
|
68
|
+
known.add(target.targetId);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
owned.tabOrder = surviving;
|
|
72
|
+
return surviving.map((targetId) => byId.get(targetId)!);
|
|
73
|
+
}
|
|
74
|
+
|
|
56
75
|
/** Give each page its own CDP attachment and media state within one Chromium profile. */
|
|
57
76
|
async function tabRuntime(owned: OwnedRuntime, targetId: string): Promise<AgentBrowserRuntime> {
|
|
58
77
|
const existing = owned.tabs.get(targetId);
|
|
@@ -151,6 +170,7 @@ export async function createRuntimeOwner(
|
|
|
151
170
|
rootTargetId: identity.targetId,
|
|
152
171
|
runtimeOptions,
|
|
153
172
|
tabs: new Map(),
|
|
173
|
+
tabOrder: [identity.targetId],
|
|
154
174
|
display,
|
|
155
175
|
displayNotice,
|
|
156
176
|
};
|
|
@@ -179,20 +199,22 @@ export async function createRuntimeOwner(
|
|
|
179
199
|
? (input as Record<string, JsonValue>)
|
|
180
200
|
: {};
|
|
181
201
|
if (operation === "tabs.list") {
|
|
182
|
-
return (await owned
|
|
202
|
+
return (await orderedTargets(owned)).map(({ targetId, title, url }) => ({
|
|
183
203
|
targetId,
|
|
184
204
|
title,
|
|
185
205
|
url,
|
|
186
206
|
}));
|
|
187
207
|
}
|
|
188
208
|
if (operation === "tabs.create") {
|
|
189
|
-
if ((await owned
|
|
209
|
+
if ((await orderedTargets(owned)).length >= MAX_BROWSER_TABS)
|
|
190
210
|
throw new Error("Browser tab limit reached");
|
|
191
|
-
|
|
211
|
+
const targetId = await owned.runtime.createTarget();
|
|
212
|
+
owned.tabOrder.push(targetId);
|
|
213
|
+
return { targetId };
|
|
192
214
|
}
|
|
193
215
|
if (operation === "tabs.close") {
|
|
194
216
|
const targetId = String(data.targetId);
|
|
195
|
-
const targets = await owned
|
|
217
|
+
const targets = await orderedTargets(owned);
|
|
196
218
|
if (!targets.some((target) => target.targetId === targetId)) {
|
|
197
219
|
throw new Error("Browser tab is no longer available");
|
|
198
220
|
}
|
|
@@ -207,7 +229,20 @@ export async function createRuntimeOwner(
|
|
|
207
229
|
owned.rootTargetId = successor.targetId;
|
|
208
230
|
}
|
|
209
231
|
await owned.runtime.closeTarget(targetId);
|
|
232
|
+
// Target.closeTarget can acknowledge before Chromium drops the page from
|
|
233
|
+
// its target list. Success requires observing its absence; the close is
|
|
234
|
+
// never replayed, and exhaustion is reported rather than assumed.
|
|
235
|
+
let gone = false;
|
|
236
|
+
for (let attempt = 0; attempt < 20 && !gone; attempt++) {
|
|
237
|
+
gone = !(await owned.runtime.targets()).some((target) => target.targetId === targetId);
|
|
238
|
+
if (gone) break;
|
|
239
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
240
|
+
setTimeout(resolve, 50);
|
|
241
|
+
await promise;
|
|
242
|
+
}
|
|
210
243
|
owned.tabs.delete(targetId);
|
|
244
|
+
if (!gone) throw new CdpUnknownOutcomeError("Browser tab close could not be confirmed");
|
|
245
|
+
owned.tabOrder = owned.tabOrder.filter((id) => id !== targetId);
|
|
211
246
|
return null;
|
|
212
247
|
}
|
|
213
248
|
const targetId = typeof data.targetId === "string" ? data.targetId : null;
|
package/server/supervisor.ts
CHANGED
|
@@ -828,8 +828,8 @@ export class RuntimeSupervisor<Runtime extends RuntimeInstance = RuntimeInstance
|
|
|
828
828
|
private async selectAgentTab(binding: AgentBinding, tabId: string): Promise<BrowserState> {
|
|
829
829
|
this.assertAgentBindingCurrent(binding);
|
|
830
830
|
if (binding.tabId === tabId && binding.viewerToken) {
|
|
831
|
-
|
|
832
|
-
this.
|
|
831
|
+
// Same-tab selection needs the same expiry recovery as status/capture.
|
|
832
|
+
const current = await this.requestAgentObservation(binding, "status", {});
|
|
833
833
|
if (binding.tabId !== tabId) {
|
|
834
834
|
throw new RuntimeProtocolError("INVALID_REQUEST", "Agent tab changed during selection");
|
|
835
835
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
|
2
|
+
import { createRuntimeOwner } from "./runtime-owner";
|
|
3
|
+
|
|
4
|
+
type Owner = Awaited<ReturnType<typeof createRuntimeOwner>>;
|
|
5
|
+
type Owned = Parameters<Owner["request"]>[0];
|
|
6
|
+
|
|
7
|
+
beforeEach(() => vi.useFakeTimers());
|
|
8
|
+
afterEach(() => vi.useRealTimers());
|
|
9
|
+
|
|
10
|
+
async function fixture(lingerSnapshots: number | null) {
|
|
11
|
+
const owner = await createRuntimeOwner({ virtualDisplay: false, nativeVideo: false });
|
|
12
|
+
const state = { ids: ["root", "closing"], closing: false, polls: 0, next: 0 };
|
|
13
|
+
const native = {
|
|
14
|
+
targets: vi.fn(async () => {
|
|
15
|
+
if (state.closing && lingerSnapshots !== null && state.polls++ >= lingerSnapshots) {
|
|
16
|
+
state.ids = state.ids.filter((id) => id !== "closing");
|
|
17
|
+
} else if (state.closing && lingerSnapshots === null) {
|
|
18
|
+
state.polls++;
|
|
19
|
+
}
|
|
20
|
+
return state.ids.map((targetId) => ({ targetId, title: targetId, url: "about:blank" }));
|
|
21
|
+
}),
|
|
22
|
+
closeTarget: vi.fn(async () => {
|
|
23
|
+
state.closing = true;
|
|
24
|
+
}),
|
|
25
|
+
createTarget: vi.fn(async () => {
|
|
26
|
+
const id = `new-${++state.next}`;
|
|
27
|
+
state.ids.unshift(id);
|
|
28
|
+
return id;
|
|
29
|
+
}),
|
|
30
|
+
selectTarget: vi.fn(async () => {}),
|
|
31
|
+
};
|
|
32
|
+
const owned = {
|
|
33
|
+
runtimeId: "review-runtime",
|
|
34
|
+
runtime: native,
|
|
35
|
+
rootTargetId: "root",
|
|
36
|
+
runtimeOptions: {},
|
|
37
|
+
tabs: new Map(),
|
|
38
|
+
tabOrder: ["root", "closing"],
|
|
39
|
+
display: null,
|
|
40
|
+
displayNotice: null,
|
|
41
|
+
} as unknown as Owned;
|
|
42
|
+
return { owner, owned, native, state };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
it("confirms an acknowledged close only after the target disappears without replay", async () => {
|
|
46
|
+
const f = await fixture(2);
|
|
47
|
+
const pending = f.owner.request(f.owned, "tabs.close", { targetId: "closing" });
|
|
48
|
+
await vi.runAllTimersAsync();
|
|
49
|
+
await expect(pending).resolves.toBeNull();
|
|
50
|
+
expect(f.native.closeTarget).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(f.state.polls).toBe(3);
|
|
52
|
+
expect(
|
|
53
|
+
((await f.owner.request(f.owned, "tabs.list", null)) as { targetId: string }[]).map(
|
|
54
|
+
(x) => x.targetId,
|
|
55
|
+
),
|
|
56
|
+
).toEqual(["root"]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("fails explicitly, without replaying close or reordering, when the target never disappears", async () => {
|
|
60
|
+
const f = await fixture(null);
|
|
61
|
+
const outcome = f.owner.request(f.owned, "tabs.close", { targetId: "closing" }).then(
|
|
62
|
+
() => "ok",
|
|
63
|
+
(error) => String(error),
|
|
64
|
+
);
|
|
65
|
+
await vi.runAllTimersAsync();
|
|
66
|
+
expect(await outcome).toContain(
|
|
67
|
+
"CdpUnknownOutcomeError: Browser tab close could not be confirmed",
|
|
68
|
+
);
|
|
69
|
+
expect(f.native.closeTarget).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(f.state.polls).toBe(20);
|
|
71
|
+
const ids = (await f.owner.request(f.owned, "tabs.list", null)) as { targetId: string }[];
|
|
72
|
+
expect(ids.map((tab) => tab.targetId)).toEqual(["root", "closing"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("retains known ordering across unordered snapshots and appends explicit creations", async () => {
|
|
76
|
+
const f = await fixture(null);
|
|
77
|
+
f.state.ids = ["third", "closing", "root"];
|
|
78
|
+
const ids = async () =>
|
|
79
|
+
((await f.owner.request(f.owned, "tabs.list", null)) as { targetId: string }[]).map(
|
|
80
|
+
(x) => x.targetId,
|
|
81
|
+
);
|
|
82
|
+
expect(await ids()).toEqual(["root", "closing", "third"]);
|
|
83
|
+
f.state.ids = ["closing", "root", "third"];
|
|
84
|
+
expect(await ids()).toEqual(["root", "closing", "third"]);
|
|
85
|
+
await f.owner.request(f.owned, "tabs.create", null);
|
|
86
|
+
await f.owner.request(f.owned, "tabs.create", null);
|
|
87
|
+
expect(await ids()).toEqual(["root", "closing", "third", "new-1", "new-2"]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("preserves the existing last-tab refusal", async () => {
|
|
91
|
+
const f = await fixture(null);
|
|
92
|
+
f.state.ids = ["root"];
|
|
93
|
+
await expect(f.owner.request(f.owned, "tabs.close", { targetId: "root" })).rejects.toThrow(
|
|
94
|
+
"last browser tab",
|
|
95
|
+
);
|
|
96
|
+
expect(f.native.closeTarget).not.toHaveBeenCalled();
|
|
97
|
+
});
|