@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 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, on Paseo 0.11 and newer, the sidebar footer.
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.9, 0.10 or 0.11, and Node.js 24 or newer on the daemon host.
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. On Paseo 0.11 or newer, a **Shared
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 from Paseo 0.11, so new OMP agents receive the adapter there.
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 the version range in `paseo-plugin.json`.
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
 
@@ -123,9 +123,8 @@ function createSidebarItem(client: PluginClientContext) {
123
123
  };
124
124
  }
125
125
 
126
- /** Sidebar footer row listing open sessions. Hosts before 0.11 lack the API and get no row. */
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.240.1",
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)
@@ -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.runtime.targets()).map(({ targetId, title, url }) => ({
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.runtime.targets()).length >= MAX_BROWSER_TABS)
209
+ if ((await orderedTargets(owned)).length >= MAX_BROWSER_TABS)
190
210
  throw new Error("Browser tab limit reached");
191
- return { targetId: await owned.runtime.createTarget() };
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.runtime.targets();
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;
@@ -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
- const current = await this.browserPolicy.status(binding.viewerToken);
832
- this.assertAgentBindingCurrent(binding);
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
+ });