@vellumai/assistant 0.12.2-staging.2 → 0.12.2-staging.4

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.
Files changed (162) hide show
  1. package/docs/desktop-browser-cli.md +33 -0
  2. package/openapi.yaml +17 -5
  3. package/package.json +1 -1
  4. package/scripts/smoke-desktop-browser-cli.ts +279 -0
  5. package/src/__tests__/config-schema.test.ts +1 -1
  6. package/src/__tests__/config-sounds-sync.test.ts +23 -0
  7. package/src/__tests__/conversation-error.test.ts +4 -1
  8. package/src/__tests__/conversation-runtime-assembly.test.ts +6 -4
  9. package/src/__tests__/external-plugin-loader.test.ts +52 -0
  10. package/src/__tests__/headless-browser-mode.test.ts +48 -0
  11. package/src/__tests__/host-cu-proxy.test.ts +116 -18
  12. package/src/__tests__/list-all-apps.test.ts +40 -8
  13. package/src/__tests__/mcp-auth-routes.test.ts +99 -0
  14. package/src/__tests__/mcp-client-auth.test.ts +54 -0
  15. package/src/__tests__/mcp-health-check.test.ts +32 -32
  16. package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
  17. package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
  18. package/src/__tests__/skills.test.ts +10 -0
  19. package/src/__tests__/user-plugin-loader.test.ts +21 -0
  20. package/src/acp/session-snapshot.ts +260 -0
  21. package/src/apps/app-store.ts +6 -5
  22. package/src/browser/operations.ts +2 -1
  23. package/src/browser/types.ts +7 -0
  24. package/src/browser/virtual-desktop-target.ts +34 -0
  25. package/src/calls/__tests__/progress-narration.test.ts +23 -0
  26. package/src/calls/__tests__/voice-control-protocol.test.ts +7 -0
  27. package/src/calls/__tests__/voice-session-bridge.test.ts +62 -0
  28. package/src/calls/progress-narration.ts +15 -2
  29. package/src/calls/voice-control-protocol.ts +34 -4
  30. package/src/calls/voice-session-bridge.ts +23 -0
  31. package/src/cli/commands/__tests__/browser.test.ts +84 -3
  32. package/src/cli/commands/browser.help.ts +43 -0
  33. package/src/cli/commands/browser.ts +52 -8
  34. package/src/cli/commands/plugins.ts +16 -5
  35. package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
  36. package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
  37. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
  38. package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
  39. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
  40. package/src/cli/lib/bundled-marketplace.json +13 -0
  41. package/src/cli/lib/install-from-github.ts +17 -18
  42. package/src/cli/lib/install-from-platform.ts +14 -9
  43. package/src/cli/lib/list-installed-plugins.ts +21 -35
  44. package/src/cli/lib/plugin-details.ts +21 -8
  45. package/src/cli/lib/uninstall-plugin.ts +59 -1
  46. package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
  47. package/src/config/bundled-skills/acp/SKILL.md +1 -1
  48. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  49. package/src/config/bundled-skills/computer-use/SKILL.md +30 -0
  50. package/src/config/bundled-skills/computer-use/TOOLS.json +2 -2
  51. package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
  52. package/src/config/feature-flag-registry.json +2 -2
  53. package/src/config/schemas/__tests__/voice.test.ts +2 -2
  54. package/src/config/schemas/mcp.ts +9 -7
  55. package/src/config/schemas/monitoring.ts +9 -0
  56. package/src/config/schemas/voice.ts +2 -2
  57. package/src/config/skills.ts +13 -21
  58. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
  59. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
  60. package/src/daemon/conversation-client-surface.ts +26 -0
  61. package/src/daemon/conversation-runtime-assembly.ts +1 -1
  62. package/src/daemon/conversation-tool-setup.ts +31 -29
  63. package/src/daemon/host-cu-proxy.ts +51 -26
  64. package/src/daemon/host-proxy-preactivation.ts +2 -0
  65. package/src/daemon/mcp-reload-service.ts +7 -0
  66. package/src/daemon/message-types/sync.ts +1 -0
  67. package/src/daemon/providers-setup.ts +4 -0
  68. package/src/desktop/__tests__/fake-desktop.ts +1 -0
  69. package/src/desktop/desktop-automation-lease.test.ts +208 -0
  70. package/src/desktop/desktop-automation-lease.ts +270 -0
  71. package/src/desktop/desktop-browser-client.test.ts +413 -0
  72. package/src/desktop/desktop-browser-client.ts +430 -0
  73. package/src/desktop/desktop-browser-cursor.ts +40 -0
  74. package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
  75. package/src/desktop/desktop-browser-endpoint.ts +145 -0
  76. package/src/desktop/desktop-browser-operations.ts +130 -0
  77. package/src/desktop/desktop-chrome-session.test.ts +48 -0
  78. package/src/desktop/desktop-chrome-session.ts +58 -8
  79. package/src/desktop/desktop-dependencies.test.ts +29 -0
  80. package/src/desktop/desktop-dependencies.ts +26 -5
  81. package/src/desktop/desktop-display.ts +9 -0
  82. package/src/desktop/desktop-panel-config.ts +14 -1
  83. package/src/desktop/desktop-session-manager.test.ts +86 -3
  84. package/src/desktop/desktop-session-manager.ts +144 -24
  85. package/src/desktop/desktop-stream-bridge.ts +1 -1
  86. package/src/desktop/virtual-desktop-feature.ts +18 -0
  87. package/src/desktop/virtual-desktop-platform.test.ts +61 -0
  88. package/src/i18n/__tests__/i18n.test.ts +9 -5
  89. package/src/i18n/messages.ts +13 -4
  90. package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
  91. package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
  92. package/src/ipc/assistant-server.ts +15 -1
  93. package/src/ipc/cli-client.ts +13 -5
  94. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +22 -0
  95. package/src/live-voice/__tests__/live-voice-events.test.ts +3 -2
  96. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
  97. package/src/live-voice/__tests__/protocol.test.ts +11 -3
  98. package/src/live-voice/__tests__/session-controls.test.ts +34 -0
  99. package/src/live-voice/live-voice-metrics.ts +9 -0
  100. package/src/live-voice/live-voice-session.ts +3 -0
  101. package/src/live-voice/protocol.ts +10 -1
  102. package/src/live-voice/session-controls.ts +28 -0
  103. package/src/mcp/__tests__/credential-target.test.ts +59 -0
  104. package/src/mcp/__tests__/effective-config.test.ts +11 -0
  105. package/src/mcp/__tests__/manager-state.test.ts +128 -0
  106. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
  107. package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
  108. package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
  109. package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
  110. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
  111. package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
  112. package/src/mcp/client.ts +64 -16
  113. package/src/mcp/credential-target.ts +105 -0
  114. package/src/mcp/effective-config.ts +42 -15
  115. package/src/mcp/manager.ts +61 -8
  116. package/src/mcp/mcp-auth-orchestrator.ts +17 -4
  117. package/src/mcp/mcp-auth-state.ts +38 -6
  118. package/src/mcp/mcp-oauth-provider.ts +101 -33
  119. package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
  120. package/src/monitoring/mount-watch.ts +348 -0
  121. package/src/monitoring/worker.ts +9 -0
  122. package/src/notifications/__tests__/decision-engine.test.ts +175 -0
  123. package/src/notifications/decision-engine.ts +43 -8
  124. package/src/oauth/seed-providers.ts +11 -0
  125. package/src/onboarding/checkin-event.ts +2 -1
  126. package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
  127. package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
  128. package/src/plugins/external-plugin-loader.ts +49 -86
  129. package/src/plugins/installed-plugin-dirs.ts +6 -4
  130. package/src/plugins/mcp-servers.ts +9 -15
  131. package/src/plugins/mtime-cache.ts +2 -1
  132. package/src/plugins/user-loader.ts +2 -1
  133. package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
  134. package/src/providers/inference/endpoint-probe.ts +27 -12
  135. package/src/providers/openai/responses-provider.ts +9 -2
  136. package/src/providers/opencode/client.test.ts +167 -0
  137. package/src/providers/opencode/client.ts +73 -4
  138. package/src/runtime/AGENTS.md +4 -0
  139. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
  140. package/src/runtime/http-server.ts +6 -6
  141. package/src/runtime/routes/__tests__/acp-routes.test.ts +19 -0
  142. package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
  143. package/src/runtime/routes/acp-routes.ts +17 -161
  144. package/src/runtime/routes/browser-context.ts +39 -0
  145. package/src/runtime/routes/browser-routes.ts +16 -47
  146. package/src/runtime/routes/browser-tabs-routes.ts +72 -29
  147. package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
  148. package/src/runtime/routes/desktop-setup-routes.ts +7 -5
  149. package/src/runtime/routes/mcp-auth-routes.ts +106 -94
  150. package/src/runtime/routes/plugins-routes.ts +14 -1
  151. package/src/runtime/sync/resource-sync-events.ts +4 -0
  152. package/src/tools/acp/status.test.ts +276 -21
  153. package/src/tools/acp/status.ts +98 -32
  154. package/src/tools/browser/browser-execution.ts +44 -13
  155. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
  156. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
  157. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
  158. package/src/tools/computer-use/definitions.ts +2 -3
  159. package/src/util/abort-reasons.ts +12 -1
  160. package/src/util/plugin-manifest.ts +203 -0
  161. package/src/desktop/desktop-feature.test.ts +0 -18
  162. package/src/desktop/desktop-feature.ts +0 -19
@@ -0,0 +1,430 @@
1
+ import { browserManager } from "../tools/browser/browser-manager.js";
2
+ import {
3
+ type CdpWsTransport,
4
+ CdpWsTransportError,
5
+ } from "../tools/browser/cdp-client/cdp-inspect/ws-transport.js";
6
+ import { CdpError } from "../tools/browser/cdp-client/errors.js";
7
+ import type {
8
+ ScopedCdpClient,
9
+ TabInfo,
10
+ } from "../tools/browser/cdp-client/types.js";
11
+ import {
12
+ desktopCursorExpression,
13
+ REMOVE_DESKTOP_CURSOR,
14
+ } from "./desktop-browser-cursor.js";
15
+
16
+ type Target = { targetId: string; type: string; url: string; title: string };
17
+ type HeldInput = {
18
+ targetId: string;
19
+ method: string;
20
+ params: Record<string, unknown>;
21
+ };
22
+
23
+ export class DesktopBrowserClient {
24
+ private transport?: CdpWsTransport;
25
+ private targets = new Map<number, string>();
26
+ private sessions = new Map<string, string>();
27
+ private selected?: number;
28
+ private nextTab = 1;
29
+ private held = new Map<string, HeldInput>();
30
+ private conversation?: string;
31
+ private cursorPositions = new Map<string, { x: number; y: number }>();
32
+ private cursorRestored: Promise<void> = Promise.resolve();
33
+ generation = 0;
34
+
35
+ constructor(
36
+ private readonly connect: (signal: AbortSignal) => Promise<CdpWsTransport>,
37
+ ) {}
38
+
39
+ invalidateSnapshot(): void {
40
+ this.generation++;
41
+ if (this.conversation) {
42
+ browserManager.clearSnapshotBackendNodeMap(this.conversation);
43
+ }
44
+ }
45
+
46
+ async client(
47
+ conversationId: string,
48
+ signal: AbortSignal,
49
+ ): Promise<ScopedCdpClient> {
50
+ signal = AbortSignal.any([signal, AbortSignal.timeout(120_000)]);
51
+ signal.throwIfAborted();
52
+ if (this.transport?.closed) {
53
+ await this.release();
54
+ signal.throwIfAborted();
55
+ }
56
+ if (!this.transport) {
57
+ const transport = await this.connect(signal);
58
+ if (signal.aborted) {
59
+ transport.dispose();
60
+ signal.throwIfAborted();
61
+ }
62
+ this.transport = transport;
63
+ transport.addEventListener((event) => {
64
+ if (event.method === "Page.domContentEventFired" && event.sessionId) {
65
+ const sessionId = event.sessionId;
66
+ this.cursorRestored = this.cursorRestored.then(async () => {
67
+ const position = this.cursorPositions.get(sessionId);
68
+ if (this.transport === transport && position) {
69
+ await this.drawCursor(
70
+ transport,
71
+ sessionId,
72
+ position.x,
73
+ position.y,
74
+ AbortSignal.timeout(3_000),
75
+ );
76
+ }
77
+ });
78
+ }
79
+ if (
80
+ [
81
+ "Page.frameNavigated",
82
+ "Page.navigatedWithinDocument",
83
+ "DOM.documentUpdated",
84
+ "Target.detachedFromTarget",
85
+ "Target.targetDestroyed",
86
+ ].includes(event.method)
87
+ ) {
88
+ this.invalidateSnapshot();
89
+ }
90
+ if (event.method === "Target.detachedFromTarget") {
91
+ const detached = (event.params as { sessionId?: string })?.sessionId;
92
+ if (detached) {
93
+ this.cursorPositions.delete(detached);
94
+ }
95
+ for (const [target, session] of this.sessions) {
96
+ if (session === detached) {
97
+ this.sessions.delete(target);
98
+ }
99
+ }
100
+ }
101
+ });
102
+ }
103
+ this.conversation = `desktop-browser:${conversationId}`;
104
+ const transport = this.transport;
105
+ await this.session(transport, signal);
106
+ const generation = this.generation;
107
+ const activeSignal = (child?: AbortSignal) =>
108
+ child ? AbortSignal.any([signal, child]) : signal;
109
+ return {
110
+ kind: "cdp-inspect",
111
+ conversationId: this.conversation,
112
+ send: <T>(
113
+ method: string,
114
+ params?: Record<string, unknown>,
115
+ child?: AbortSignal,
116
+ ) =>
117
+ this.send<T>(
118
+ transport,
119
+ method,
120
+ params ?? {},
121
+ activeSignal(child),
122
+ generation,
123
+ ),
124
+ listTabs: () => this.listTabs(transport, signal),
125
+ selectTab: async (tabId) => {
126
+ const tabs = await this.listTabs(transport, signal);
127
+ const tab = tabs.find((item) => item.tabId === tabId);
128
+ const targetId = this.targets.get(tabId);
129
+ if (!tab || !targetId) {
130
+ throw new Error("Desktop browser tab is unavailable");
131
+ }
132
+ await transport.send("Target.activateTarget", { targetId }, { signal });
133
+ this.selected = tabId;
134
+ this.invalidateSnapshot();
135
+ return { ...tab, clientId: "managed-desktop" };
136
+ },
137
+ closeTab: async (tabId) => {
138
+ const targetId = this.targets.get(tabId);
139
+ if (!targetId) {
140
+ throw new Error("Desktop browser tab is unavailable");
141
+ }
142
+ await transport.send("Target.closeTarget", { targetId }, { signal });
143
+ if (this.selected === tabId) {
144
+ this.selected = undefined;
145
+ }
146
+ this.invalidateSnapshot();
147
+ return { closed: true, tabId, clientId: "managed-desktop" };
148
+ },
149
+ setCdpSessionId: (tabId) => {
150
+ signal.throwIfAborted();
151
+ if (this.transport !== transport) {
152
+ throw new Error("Desktop browser session expired");
153
+ }
154
+ this.selected = tabId === undefined ? undefined : Number(tabId);
155
+ this.invalidateSnapshot();
156
+ },
157
+ dispose: () => {},
158
+ };
159
+ }
160
+
161
+ private async listTabs(
162
+ transport: CdpWsTransport,
163
+ signal: AbortSignal,
164
+ ): Promise<TabInfo[]> {
165
+ signal.throwIfAborted();
166
+ if (this.transport !== transport) {
167
+ throw new Error("Desktop browser session expired");
168
+ }
169
+ const { targetInfos } = await transport.send<{ targetInfos: Target[] }>(
170
+ "Target.getTargets",
171
+ {},
172
+ { signal },
173
+ );
174
+ const pages = targetInfos.filter(
175
+ (target) => target.type === "page" && !target.url.startsWith("devtools:"),
176
+ );
177
+ const liveTargets = new Set(pages.map((page) => page.targetId));
178
+ for (const [id, target] of this.targets) {
179
+ if (!liveTargets.has(target)) {
180
+ this.targets.delete(id);
181
+ }
182
+ }
183
+ const aliases = new Map(
184
+ [...this.targets].map(([id, target]) => [target, id]),
185
+ );
186
+ return pages.map((target) => {
187
+ let id = aliases.get(target.targetId);
188
+ if (id === undefined) {
189
+ id = this.nextTab++;
190
+ this.targets.set(id, target.targetId);
191
+ }
192
+ return {
193
+ tabId: id,
194
+ url: target.url,
195
+ title: target.title,
196
+ active: id === this.selected,
197
+ pinned: false,
198
+ };
199
+ });
200
+ }
201
+
202
+ private async createTab(
203
+ transport: CdpWsTransport,
204
+ signal: AbortSignal,
205
+ ): Promise<{ tabId: number }> {
206
+ const { targetId } = await transport.send<{ targetId: string }>(
207
+ "Target.createTarget",
208
+ { url: "about:blank" },
209
+ { signal },
210
+ );
211
+ const tabId = this.nextTab++;
212
+ this.targets.set(tabId, targetId);
213
+ this.selected = tabId;
214
+ this.invalidateSnapshot();
215
+ return { tabId };
216
+ }
217
+
218
+ private async session(
219
+ transport: CdpWsTransport,
220
+ signal: AbortSignal,
221
+ ): Promise<string> {
222
+ const tabs = await this.listTabs(transport, signal);
223
+ if (this.selected !== undefined && !this.targets.has(this.selected)) {
224
+ this.selected = undefined;
225
+ this.invalidateSnapshot();
226
+ throw new Error(
227
+ "Desktop browser tab closed. Take a fresh snapshot before continuing.",
228
+ );
229
+ }
230
+ if (this.selected === undefined) {
231
+ this.selected = tabs.find((tab) => /^https?:/.test(tab.url ?? ""))?.tabId;
232
+ if (this.selected === undefined) {
233
+ await this.createTab(transport, signal);
234
+ }
235
+ }
236
+ const targetId = this.targets.get(this.selected!)!;
237
+ let sessionId = this.sessions.get(targetId);
238
+ if (!sessionId) {
239
+ const attached = await transport.send<{ sessionId: string }>(
240
+ "Target.attachToTarget",
241
+ { targetId, flatten: true },
242
+ { signal },
243
+ );
244
+ sessionId = attached.sessionId;
245
+ this.sessions.set(targetId, sessionId);
246
+ await transport.send("Page.enable", {}, { sessionId, signal });
247
+ await transport.send("DOM.enable", {}, { sessionId, signal });
248
+ }
249
+ await transport.send("Target.activateTarget", { targetId }, { signal });
250
+ return sessionId;
251
+ }
252
+
253
+ private async send<T>(
254
+ transport: CdpWsTransport,
255
+ method: string,
256
+ params: Record<string, unknown>,
257
+ signal: AbortSignal,
258
+ generation: number,
259
+ ): Promise<T> {
260
+ signal.throwIfAborted();
261
+ if (this.transport !== transport) {
262
+ throw new Error("Desktop browser session expired");
263
+ }
264
+ if (method === "Vellum.createTab") {
265
+ return (await this.createTab(transport, signal)) as T;
266
+ }
267
+ const targetId =
268
+ this.selected === undefined ? undefined : this.targets.get(this.selected);
269
+ const sessionId =
270
+ (targetId && this.sessions.get(targetId)) ||
271
+ (await this.session(transport, signal));
272
+ const pointer =
273
+ method === "Input.dispatchMouseEvent" &&
274
+ typeof params.x === "number" &&
275
+ typeof params.y === "number"
276
+ ? { x: params.x, y: params.y }
277
+ : undefined;
278
+ if (pointer) {
279
+ await this.drawCursor(transport, sessionId, pointer.x, pointer.y, signal);
280
+ this.cursorPositions.set(sessionId, pointer);
281
+ signal.throwIfAborted();
282
+ }
283
+ if (method.startsWith("Input.") && generation !== this.generation) {
284
+ throw new Error(
285
+ "The page changed before input. Take a fresh snapshot before continuing.",
286
+ );
287
+ }
288
+ const key = JSON.stringify([
289
+ sessionId,
290
+ method,
291
+ params.code ?? params.key ?? params.button,
292
+ ]);
293
+ const pressed =
294
+ params.type === "keyDown" ||
295
+ params.type === "rawKeyDown" ||
296
+ params.type === "mousePressed";
297
+ const previous = this.held.get(key);
298
+ const heldInput = {
299
+ targetId: this.targets.get(this.selected!)!,
300
+ method,
301
+ params: {
302
+ ...params,
303
+ type: method === "Input.dispatchKeyEvent" ? "keyUp" : "mouseReleased",
304
+ },
305
+ };
306
+ if (pressed) {
307
+ this.held.set(key, heldInput);
308
+ }
309
+ try {
310
+ const result = await transport.send<T>(method, params, {
311
+ sessionId,
312
+ signal,
313
+ });
314
+ if (params.type === "keyUp" || params.type === "mouseReleased") {
315
+ this.held.delete(key);
316
+ }
317
+ const cursor = this.cursorPositions.get(sessionId);
318
+ if (
319
+ cursor &&
320
+ (params.type === "mouseReleased" || params.type === "keyUp") &&
321
+ generation === this.generation
322
+ ) {
323
+ await this.drawCursor(transport, sessionId, cursor.x, cursor.y, signal);
324
+ }
325
+ return result;
326
+ } catch (error) {
327
+ if (
328
+ pressed &&
329
+ error instanceof CdpWsTransportError &&
330
+ error.code === "cdp_error" &&
331
+ this.held.get(key) === heldInput
332
+ ) {
333
+ if (previous) {
334
+ this.held.set(key, previous);
335
+ } else {
336
+ this.held.delete(key);
337
+ }
338
+ }
339
+ throw new CdpError(
340
+ error instanceof CdpWsTransportError && error.code === "aborted"
341
+ ? "aborted"
342
+ : "cdp_error",
343
+ "Desktop browser command failed. A dispatched action may have completed; do not retry it automatically.",
344
+ { underlying: error, cdpMethod: method },
345
+ );
346
+ }
347
+ }
348
+
349
+ private async drawCursor(
350
+ transport: CdpWsTransport,
351
+ sessionId: string,
352
+ x: number,
353
+ y: number,
354
+ signal: AbortSignal,
355
+ ): Promise<void> {
356
+ await transport
357
+ .send(
358
+ "Runtime.evaluate",
359
+ {
360
+ expression: desktopCursorExpression(x, y),
361
+ awaitPromise: true,
362
+ },
363
+ { sessionId, signal },
364
+ )
365
+ .catch(() => {});
366
+ }
367
+
368
+ async release(): Promise<void> {
369
+ this.cursorPositions.clear();
370
+ await this.cursorRestored;
371
+ if (!this.transport) {
372
+ return;
373
+ }
374
+ const signal = AbortSignal.timeout(3000);
375
+ const cleanup = await this.connect(signal);
376
+ try {
377
+ const { targetInfos } = await cleanup.send<{ targetInfos: Target[] }>(
378
+ "Target.getTargets",
379
+ {},
380
+ { signal },
381
+ );
382
+ const targets = new Set([
383
+ ...this.sessions.keys(),
384
+ ...[...this.held.values()].map((input) => input.targetId),
385
+ ]);
386
+ for (const targetId of targets) {
387
+ if (!targetInfos.some((target) => target.targetId === targetId)) {
388
+ for (const [key, input] of this.held) {
389
+ if (input.targetId === targetId) {
390
+ this.held.delete(key);
391
+ }
392
+ }
393
+ continue;
394
+ }
395
+ const { sessionId } = await cleanup.send<{ sessionId: string }>(
396
+ "Target.attachToTarget",
397
+ { targetId, flatten: true },
398
+ { signal },
399
+ );
400
+ for (const [key, input] of this.held) {
401
+ if (input.targetId !== targetId) {
402
+ continue;
403
+ }
404
+ await cleanup.send(input.method, input.params, { sessionId, signal });
405
+ this.held.delete(key);
406
+ }
407
+ await cleanup.send(
408
+ "Runtime.evaluate",
409
+ { expression: REMOVE_DESKTOP_CURSOR },
410
+ { sessionId, signal },
411
+ );
412
+ }
413
+ } finally {
414
+ cleanup.dispose();
415
+ }
416
+ this.dispose();
417
+ }
418
+
419
+ dispose(): void {
420
+ this.invalidateSnapshot();
421
+ this.transport?.dispose();
422
+ this.transport = undefined;
423
+ this.sessions.clear();
424
+ this.targets.clear();
425
+ this.selected = undefined;
426
+ this.held.clear();
427
+ this.conversation = undefined;
428
+ this.cursorPositions.clear();
429
+ }
430
+ }
@@ -0,0 +1,40 @@
1
+ export const REMOVE_DESKTOP_CURSOR = `document.querySelector('[data-vellum-desktop-cursor]')?.remove()`;
2
+
3
+ export function desktopCursorExpression(x: number, y: number): string {
4
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
5
+ throw new Error("Invalid desktop cursor coordinates");
6
+ }
7
+ return `(async () => {
8
+ let cursor = document.querySelector('[data-vellum-desktop-cursor]');
9
+ if (!cursor) {
10
+ cursor = document.createElement('div');
11
+ cursor.setAttribute('data-vellum-desktop-cursor', '');
12
+ cursor.setAttribute('aria-hidden', 'true');
13
+ cursor.setAttribute('popover', 'manual');
14
+ cursor.style.cssText = 'all:initial;position:fixed;inset:auto;left:0;top:0;margin:0;padding:0;border:0;width:24px;height:30px;pointer-events:none;z-index:2147483647;';
15
+ const shadow = cursor.attachShadow({mode:'closed'});
16
+ const style = document.createElement('style');
17
+ style.textContent = ':host::backdrop{pointer-events:none;background:transparent}';
18
+ shadow.append(style);
19
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
20
+ svg.setAttribute('width', '24');
21
+ svg.setAttribute('height', '30');
22
+ svg.setAttribute('viewBox', '0 0 24 30');
23
+ const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'path');
24
+ arrow.setAttribute('d', 'M2 2 L2 23 L8 18 L13 28 L17 26 L12 16 L21 16 Z');
25
+ arrow.setAttribute('fill', '#7057ff');
26
+ arrow.setAttribute('stroke', 'white');
27
+ arrow.setAttribute('stroke-width', '2');
28
+ svg.append(arrow);
29
+ shadow.append(svg);
30
+ document.documentElement.append(cursor);
31
+ }
32
+ if (cursor.matches(':popover-open')) { cursor.hidePopover(); }
33
+ cursor.showPopover();
34
+ const destination = 'translate(${x}px, ${y}px)';
35
+ if (cursor.style.transform === destination) { return; }
36
+ const animation = cursor.animate([{transform:cursor.style.transform || destination},{transform:destination}], {duration:100,easing:'ease-out'});
37
+ cursor.style.transform = destination;
38
+ await animation.finished;
39
+ })()`;
40
+ }
@@ -0,0 +1,78 @@
1
+ import {
2
+ mkdir,
3
+ mkdtemp,
4
+ realpath,
5
+ rm,
6
+ symlink,
7
+ writeFile,
8
+ } from "node:fs/promises";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { expect, test } from "bun:test";
12
+
13
+ import {
14
+ findDesktopBrowserPid,
15
+ validateDesktopWebSocket,
16
+ } from "./desktop-browser-endpoint.js";
17
+
18
+ test("discovery only accepts the allocated loopback browser endpoint", () => {
19
+ expect(
20
+ validateDesktopWebSocket(
21
+ "ws://127.0.0.1:9222/devtools/browser/abc-123",
22
+ 9222,
23
+ ),
24
+ ).toBe("ws://127.0.0.1:9222/devtools/browser/abc-123");
25
+ for (const endpoint of [
26
+ "ws://example.com:9222/devtools/browser/abc",
27
+ "ws://127.0.0.1:9223/devtools/browser/abc",
28
+ "ws://user:password@127.0.0.1:9222/devtools/browser/abc",
29
+ "ws://127.0.0.1:9222/devtools/page/abc",
30
+ "ws://127.0.0.1:9222/devtools/browser/abc?redirect=1",
31
+ "ws://127.0.0.1:9222/devtools/browser/abc#fragment",
32
+ undefined,
33
+ ]) {
34
+ expect(() => validateDesktopWebSocket(endpoint, 9222)).toThrow();
35
+ }
36
+ });
37
+
38
+ test("dock browser discovery validates the profile lock against the live executable and arguments", async () => {
39
+ const directory = await mkdtemp(join(tmpdir(), "desktop-pid-"));
40
+ const executable = join(directory, "chrome");
41
+ const profile = join(directory, "profile");
42
+ const proc = join(directory, "proc");
43
+ const processDir = join(proc, "1234");
44
+ try {
45
+ await mkdir(profile);
46
+ await mkdir(processDir, { recursive: true });
47
+ await writeFile(executable, "test executable");
48
+ await symlink("desktop-1234", join(profile, "SingletonLock"));
49
+ await symlink(await realpath(executable), join(processDir, "exe"));
50
+ const args = [
51
+ executable,
52
+ `--user-data-dir=${profile}`,
53
+ "--remote-debugging-port=9222",
54
+ "--remote-debugging-address=127.0.0.1",
55
+ ];
56
+ const discover = () =>
57
+ findDesktopBrowserPid(executable, profile, 9222, proc);
58
+ await writeFile(join(processDir, "cmdline"), args.join("\0"));
59
+ expect(await discover()).toBe(1234);
60
+ for (const changed of [
61
+ args.map((arg) => arg.replace("port=9222", "port=9223")),
62
+ args.map((arg) => arg.replace(profile, join(directory, "other-profile"))),
63
+ args.map((arg) => arg.replace("address=127.0.0.1", "address=0.0.0.0")),
64
+ [...args, "--type=renderer"],
65
+ ]) {
66
+ await writeFile(join(processDir, "cmdline"), changed.join("\0"));
67
+ expect(await discover()).toBeUndefined();
68
+ }
69
+ await writeFile(join(processDir, "cmdline"), args.join("\0"));
70
+ await rm(join(processDir, "exe"));
71
+ await symlink(join(directory, "other-executable"), join(processDir, "exe"));
72
+ expect(await discover()).toBeUndefined();
73
+ await rm(processDir, { recursive: true });
74
+ expect(await discover()).toBeUndefined();
75
+ } finally {
76
+ await rm(directory, { recursive: true, force: true });
77
+ }
78
+ });
@@ -0,0 +1,145 @@
1
+ import { readdir, readFile, readlink, realpath } from "node:fs/promises";
2
+ import { createServer } from "node:net";
3
+ import { join } from "node:path";
4
+
5
+ import { shouldRestoreDesktopChromeSession } from "./desktop-chrome-session.js";
6
+
7
+ export async function allocateDesktopDebugPort(): Promise<number> {
8
+ const server = createServer();
9
+ await new Promise<void>((resolve, reject) => {
10
+ server.once("error", reject);
11
+ server.listen(0, "127.0.0.1", resolve);
12
+ });
13
+ const address = server.address();
14
+ await new Promise<void>((resolve, reject) =>
15
+ server.close((error) => (error ? reject(error) : resolve())),
16
+ );
17
+ if (!address || typeof address === "string") {
18
+ throw new Error("Cannot allocate desktop browser port");
19
+ }
20
+ return address.port;
21
+ }
22
+
23
+ export function validateDesktopWebSocket(value: unknown, port: number): string {
24
+ if (typeof value !== "string") {
25
+ throw new Error("Desktop browser discovery returned no endpoint");
26
+ }
27
+ const url = new URL(value);
28
+ if (
29
+ url.protocol !== "ws:" ||
30
+ url.hostname !== "127.0.0.1" ||
31
+ url.port !== String(port) ||
32
+ url.username ||
33
+ url.password ||
34
+ url.search ||
35
+ url.hash ||
36
+ !/^\/devtools\/browser\/[a-zA-Z0-9-]+$/.test(url.pathname)
37
+ ) {
38
+ throw new Error("Desktop browser discovery returned an unsafe endpoint");
39
+ }
40
+ return url.href;
41
+ }
42
+
43
+ // Match the listener to the managed Chrome process, including occupied-port races.
44
+ export async function assertDesktopListener(
45
+ pid: number,
46
+ port: number,
47
+ ): Promise<void> {
48
+ const table = await readFile(`/proc/${pid}/net/tcp`, "utf8");
49
+ const address = `0100007F:${port.toString(16).toUpperCase().padStart(4, "0")}`;
50
+ const inodes = new Set(
51
+ table.split("\n").flatMap((line) => {
52
+ const fields = line.trim().split(/\s+/);
53
+ return fields[1] === address && fields[3] === "0A" ? [fields[9]] : [];
54
+ }),
55
+ );
56
+ const fds = await readdir(`/proc/${pid}/fd`);
57
+ for (const fd of fds) {
58
+ const link = await readlink(`/proc/${pid}/fd/${fd}`).catch(() => "");
59
+ if (inodes.has(/^socket:\[(\d+)\]$/.exec(link)?.[1] ?? "")) {
60
+ return;
61
+ }
62
+ }
63
+ throw new Error(
64
+ "Debug listener does not belong to the managed desktop Chrome",
65
+ );
66
+ }
67
+
68
+ export async function findDesktopBrowserPid(
69
+ executable: string,
70
+ profileDir: string,
71
+ port: number,
72
+ procDir = "/proc",
73
+ ): Promise<number | undefined> {
74
+ try {
75
+ const lock = await readlink(join(profileDir, "SingletonLock"));
76
+ const pid = Number(/-(\d+)$/.exec(lock)?.[1]);
77
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
78
+ return undefined;
79
+ }
80
+ const [expectedExecutable, actualExecutable, commandLine] =
81
+ await Promise.all([
82
+ realpath(executable),
83
+ readlink(join(procDir, String(pid), "exe")),
84
+ readFile(join(procDir, String(pid), "cmdline"), "utf8"),
85
+ ]);
86
+ const args = commandLine.split("\0");
87
+ if (
88
+ actualExecutable !== expectedExecutable ||
89
+ !args.includes(`--user-data-dir=${profileDir}`) ||
90
+ !args.includes(`--remote-debugging-port=${port}`) ||
91
+ !args.includes("--remote-debugging-address=127.0.0.1") ||
92
+ args.some((arg) => arg.startsWith("--type="))
93
+ ) {
94
+ return undefined;
95
+ }
96
+ return pid;
97
+ } catch {
98
+ return undefined;
99
+ }
100
+ }
101
+
102
+ export async function discoverDesktopBrowser(
103
+ pid: number,
104
+ port: number,
105
+ signal: AbortSignal,
106
+ ): Promise<string> {
107
+ await assertDesktopListener(pid, port);
108
+ signal.throwIfAborted();
109
+ const response = await fetch(`http://127.0.0.1:${port}/json/version`, {
110
+ signal,
111
+ redirect: "error",
112
+ });
113
+ if (!response.ok) {
114
+ throw new Error("Desktop browser discovery failed");
115
+ }
116
+ const body = await response.text();
117
+ if (body.length > 16_384) {
118
+ throw new Error("Desktop browser discovery exceeded its size limit");
119
+ }
120
+ const data = JSON.parse(body) as { webSocketDebuggerUrl?: unknown };
121
+ await assertDesktopListener(pid, port);
122
+ return validateDesktopWebSocket(data.webSocketDebuggerUrl, port);
123
+ }
124
+
125
+ export function desktopChromeArguments(
126
+ profileDir: string,
127
+ debugPort?: number,
128
+ ): string[] {
129
+ return [
130
+ "--no-sandbox",
131
+ "--no-first-run",
132
+ "--disable-dev-shm-usage",
133
+ ...(shouldRestoreDesktopChromeSession(profileDir)
134
+ ? ["--restore-last-session", "--hide-crash-restore-bubble"]
135
+ : []),
136
+ "--start-maximized",
137
+ `--user-data-dir=${profileDir}`,
138
+ ...(debugPort
139
+ ? [
140
+ "--remote-debugging-address=127.0.0.1",
141
+ `--remote-debugging-port=${debugPort}`,
142
+ ]
143
+ : []),
144
+ ];
145
+ }