@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,130 @@
1
+ import { executeBrowserOperation } from "../browser/operations.js";
2
+ import type { BrowserOperation } from "../browser/types.js";
3
+ import type { ToolContext, ToolExecutionResult } from "../tools/types.js";
4
+ import { desktopAutomationLease } from "./desktop-automation-lease.js";
5
+ import { getDesktopSessionManager } from "./desktop-session-manager.js";
6
+
7
+ export function executeDesktopBrowserOperation(
8
+ operation: BrowserOperation,
9
+ input: Record<string, unknown>,
10
+ context: ToolContext,
11
+ ): Promise<ToolExecutionResult> {
12
+ if (
13
+ input.target_client_id ||
14
+ (input.browser_mode &&
15
+ input.browser_mode !== "auto" &&
16
+ input.browser_mode !== "cdp-inspect")
17
+ ) {
18
+ throw new Error(
19
+ "--virtual-desktop targets the streamed Chrome directly. Omit other browser targets and modes.",
20
+ );
21
+ }
22
+ if (input.use_active_tab) {
23
+ throw new Error(
24
+ "Use assistant browser --virtual-desktop tabs list and tabs select to choose a streamed browser tab.",
25
+ );
26
+ }
27
+ if (operation === "wait_for_download") {
28
+ throw new Error(
29
+ "Download waiting is unavailable for the streamed desktop browser",
30
+ );
31
+ }
32
+ return desktopAutomationLease.runBrowser(
33
+ context,
34
+ async (signal) => {
35
+ const cdpClient = await getDesktopSessionManager().browser.client(
36
+ context.conversationId,
37
+ signal,
38
+ );
39
+ if (operation === "status") {
40
+ await cdpClient.listTabs();
41
+ return {
42
+ isError: false,
43
+ content: JSON.stringify({
44
+ requestedMode: "desktop",
45
+ recommendedMode: "desktop",
46
+ modes: [
47
+ {
48
+ mode: "desktop",
49
+ available: true,
50
+ summary:
51
+ "Virtual desktop Chrome is ready. Use assistant browser --virtual-desktop commands.",
52
+ userActions: [],
53
+ },
54
+ ],
55
+ }),
56
+ };
57
+ }
58
+ const browser = getDesktopSessionManager().browser;
59
+ const generation = browser.generation;
60
+ const result = await executeBrowserOperation(operation, input, {
61
+ ...context,
62
+ signal,
63
+ cdpClient,
64
+ });
65
+ if (operation === "snapshot" && generation !== browser.generation) {
66
+ browser.invalidateSnapshot();
67
+ return {
68
+ isError: true,
69
+ content:
70
+ "The page changed during the snapshot. Take a fresh snapshot before acting.",
71
+ };
72
+ }
73
+ return result;
74
+ },
75
+ operation === "detach" || operation === "close",
76
+ );
77
+ }
78
+
79
+ export function executeDesktopBrowserTabs(
80
+ params: {
81
+ command: "list" | "select" | "new" | "close";
82
+ tabId?: number;
83
+ url?: string;
84
+ },
85
+ context: ToolContext,
86
+ ): Promise<ToolExecutionResult> {
87
+ return desktopAutomationLease.runBrowser(context, async (signal) => {
88
+ const cdpClient = await getDesktopSessionManager().browser.client(
89
+ context.conversationId,
90
+ signal,
91
+ );
92
+ let result: unknown;
93
+ if (params.command === "list") {
94
+ const tabs = await cdpClient.listTabs();
95
+ result = {
96
+ ok: true,
97
+ tabs: [
98
+ ...tabs.filter((tab) => tab.active),
99
+ ...tabs.filter((tab) => !tab.active),
100
+ ].slice(0, 100),
101
+ };
102
+ } else if (params.command === "new") {
103
+ const created = await cdpClient.send<{ tabId: number }>(
104
+ "Vellum.createTab",
105
+ {},
106
+ signal,
107
+ );
108
+ if (params.url) {
109
+ const navigated = await executeBrowserOperation(
110
+ "navigate",
111
+ { url: params.url },
112
+ { ...context, signal, cdpClient },
113
+ );
114
+ if (navigated.isError) {
115
+ return navigated;
116
+ }
117
+ }
118
+ result = { ok: true, ...created };
119
+ } else {
120
+ if (params.tabId === undefined) {
121
+ throw new Error("A tab ID is required");
122
+ }
123
+ result =
124
+ params.command === "select"
125
+ ? { ok: true, tab: await cdpClient.selectTab(params.tabId) }
126
+ : { ok: true, ...(await cdpClient.closeTab(params.tabId)) };
127
+ }
128
+ return { isError: false, content: JSON.stringify(result) };
129
+ });
130
+ }
@@ -0,0 +1,48 @@
1
+ import {
2
+ mkdirSync,
3
+ mkdtempSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { afterEach, expect, test } from "bun:test";
11
+
12
+ import { configureDesktopChromeFrame } from "./desktop-chrome-session.js";
13
+
14
+ const directories: string[] = [];
15
+
16
+ afterEach(() => {
17
+ for (const directory of directories.splice(0)) {
18
+ rmSync(directory, { recursive: true, force: true });
19
+ }
20
+ });
21
+
22
+ test.each(["{truncated", "[]", '{"browser":[]}'])(
23
+ "leaves unreadable or malformed preferences untouched: %s",
24
+ (contents) => {
25
+ const directory = mkdtempSync(join(tmpdir(), "desktop-frame-"));
26
+ directories.push(directory);
27
+ mkdirSync(join(directory, "Default"));
28
+ const path = join(directory, "Default", "Preferences");
29
+ writeFileSync(path, contents);
30
+
31
+ expect(() => configureDesktopChromeFrame(directory)).toThrow();
32
+ expect(readFileSync(path, "utf8")).toBe(contents);
33
+ },
34
+ );
35
+
36
+ test("does not rewrite an already configured profile", () => {
37
+ const directory = mkdtempSync(join(tmpdir(), "desktop-frame-"));
38
+ directories.push(directory);
39
+ mkdirSync(join(directory, "Default"));
40
+ const path = join(directory, "Default", "Preferences");
41
+ const contents =
42
+ '{ "browser": { "custom_chrome_frame": true }, "profile": { "exit_type": "Crashed" } }';
43
+ writeFileSync(path, contents);
44
+
45
+ configureDesktopChromeFrame(directory);
46
+
47
+ expect(readFileSync(path, "utf8")).toBe(contents);
48
+ });
@@ -1,7 +1,60 @@
1
- import { readFileSync } from "node:fs";
2
- import { basename, join } from "node:path";
1
+ import {
2
+ mkdirSync,
3
+ mkdtempSync,
4
+ readFileSync,
5
+ renameSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { basename, dirname, join } from "node:path";
10
+
11
+ import { isPlainObject } from "../util/object.js";
3
12
 
4
13
  export function shouldRestoreDesktopChromeSession(profileDir: string): boolean {
14
+ return (
15
+ readProfileMetadata(chromePreferencesPath(profileDir))?.exit_type ===
16
+ "Crashed"
17
+ );
18
+ }
19
+
20
+ /** Configure Chrome's own title bar before the browser or dock starts. */
21
+ export function configureDesktopChromeFrame(profileDir: string): void {
22
+ const path = chromePreferencesPath(profileDir);
23
+ let preferences: Record<string, unknown> = {};
24
+ try {
25
+ const value: unknown = JSON.parse(readFileSync(path, "utf8"));
26
+ if (!isPlainObject(value)) {
27
+ throw new Error("Desktop Chrome preferences must be a JSON object");
28
+ }
29
+ preferences = value;
30
+ } catch (err) {
31
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
32
+ throw err;
33
+ }
34
+ }
35
+ const browser = preferences.browser ?? {};
36
+ if (!isPlainObject(browser)) {
37
+ throw new Error("Desktop Chrome browser preferences must be a JSON object");
38
+ }
39
+ if (browser.custom_chrome_frame === true) {
40
+ return;
41
+ }
42
+ preferences.browser = { ...browser, custom_chrome_frame: true };
43
+ const directory = dirname(path);
44
+ mkdirSync(directory, { recursive: true });
45
+ const staging = mkdtempSync(join(directory, ".vellum-frame-"));
46
+ try {
47
+ const temporaryPath = join(staging, "Preferences");
48
+ writeFileSync(temporaryPath, JSON.stringify(preferences) + "\n", {
49
+ mode: 0o600,
50
+ });
51
+ renameSync(temporaryPath, path);
52
+ } finally {
53
+ rmSync(staging, { recursive: true, force: true });
54
+ }
55
+ }
56
+
57
+ function chromePreferencesPath(profileDir: string): string {
5
58
  const lastUsed = readProfileMetadata(
6
59
  join(profileDir, "Local State"),
7
60
  )?.last_used;
@@ -13,10 +66,7 @@ export function shouldRestoreDesktopChromeSession(profileDir: string): boolean {
13
66
  basename(lastUsed) === lastUsed
14
67
  ? lastUsed
15
68
  : "Default";
16
- return (
17
- readProfileMetadata(join(profileDir, profileName, "Preferences"))
18
- ?.exit_type === "Crashed"
19
- );
69
+ return join(profileDir, profileName, "Preferences");
20
70
  }
21
71
 
22
72
  function readProfileMetadata(
@@ -24,9 +74,9 @@ function readProfileMetadata(
24
74
  ): { last_used?: unknown; exit_type?: unknown } | undefined {
25
75
  try {
26
76
  const value: unknown = JSON.parse(readFileSync(path, "utf8"));
27
- if (value && typeof value === "object" && "profile" in value) {
77
+ if (isPlainObject(value)) {
28
78
  const profile = value.profile;
29
- if (profile && typeof profile === "object" && !Array.isArray(profile)) {
79
+ if (isPlainObject(profile)) {
30
80
  return profile;
31
81
  }
32
82
  }
@@ -100,3 +100,32 @@ describe("desktop dependency installation", () => {
100
100
  expect(f.install).not.toHaveBeenCalled();
101
101
  });
102
102
  });
103
+
104
+ test("browser and viewer share installation while a cancelled waiter stops immediately", async () => {
105
+ const f = setup();
106
+ const abort = new AbortController();
107
+ const browser = f.installer
108
+ .ensureReady(abort.signal)
109
+ .catch((error: unknown) => error);
110
+ const viewer = f.installer.ensureReady();
111
+ await flush();
112
+ expect(f.install).toHaveBeenCalledTimes(1);
113
+ abort.abort();
114
+ expect(await browser).toBeInstanceOf(Error);
115
+ expect(f.installer.getStatus().state).toBe("installing");
116
+ f.finish();
117
+ await viewer;
118
+ expect(f.installer.getStatus().state).toBe("ready");
119
+ });
120
+
121
+ test("failed setup reports the stage without running an automatic retry", async () => {
122
+ const f = setup();
123
+ const result = f.installer.ensureReady().catch((error: Error) => error);
124
+ await flush();
125
+ f.progress();
126
+ f.fail();
127
+ const error = await result;
128
+ expect(error).toBeInstanceOf(Error);
129
+ expect((error as Error).message).toContain("failed during chrome");
130
+ expect(f.install).toHaveBeenCalledTimes(1);
131
+ });
@@ -156,7 +156,7 @@ export class DesktopDependencyInstaller {
156
156
  })
157
157
  .catch((err: unknown) => {
158
158
  log.warn({ err }, "Desktop installation failed");
159
- this.status = { state: "failed" };
159
+ this.status = { state: "failed", stage: this.status?.stage };
160
160
  })
161
161
  .finally(() => {
162
162
  this.installing = null;
@@ -166,11 +166,32 @@ export class DesktopDependencyInstaller {
166
166
  return this.status;
167
167
  }
168
168
 
169
- async ensureReady(): Promise<void> {
169
+ async ensureReady(signal?: AbortSignal): Promise<void> {
170
+ signal?.throwIfAborted();
170
171
  this.start();
171
- await this.installing;
172
- if (this.getStatus().state !== "ready") {
173
- throw new Error("Desktop setup did not complete");
172
+ let onAbort: (() => void) | undefined;
173
+ try {
174
+ await new Promise<void>((resolve, reject) => {
175
+ onAbort = () => reject(signal?.reason);
176
+ signal?.addEventListener("abort", onAbort, { once: true });
177
+ void Promise.resolve(this.installing).then(() => resolve(), reject);
178
+ });
179
+ signal?.throwIfAborted();
180
+ const status = this.getStatus();
181
+ if (status.state === "unsupported") {
182
+ throw new Error(
183
+ "Virtual desktop installation is unsupported on this assistant.",
184
+ );
185
+ }
186
+ if (status.state !== "ready") {
187
+ throw new Error(
188
+ `Virtual desktop setup failed during ${status.stage ?? "installation"}. Open the Virtual desktop panel to retry. Do not launch Chrome manually.`,
189
+ );
190
+ }
191
+ } finally {
192
+ if (onAbort) {
193
+ signal?.removeEventListener("abort", onAbort);
194
+ }
174
195
  }
175
196
  }
176
197
 
@@ -0,0 +1,9 @@
1
+ export const DESKTOP_DISPLAY = ":99";
2
+ export const DESKTOP_WIDTH = 1600;
3
+ export const DESKTOP_HEIGHT = 900;
4
+ export const DESKTOP_OVERRIDABLE_PARAMETERS = [
5
+ "desktop",
6
+ "SendCutText",
7
+ "SendPrimary",
8
+ "SetPrimary",
9
+ ] as const;
@@ -9,6 +9,8 @@ import {
9
9
  import { dirname, join } from "node:path";
10
10
  import { pathToFileURL } from "node:url";
11
11
 
12
+ import { desktopChromeArguments } from "./desktop-browser-endpoint.js";
13
+
12
14
  // Absolute icon paths work without an installed icon theme.
13
15
  const TERMINAL_ICON_BASE64 = [
14
16
  "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAACBklEQVR42u2bP2vCQBjG/T",
@@ -32,6 +34,7 @@ export interface DesktopPanelConfigRequest {
32
34
  /** Profile the launcher shares with that browser, so it reuses the window. */
33
35
  readonly chromiumProfileDir: string;
34
36
  readonly terminalPath: string;
37
+ readonly debugPort?: number;
35
38
  }
36
39
 
37
40
  /** Generate managed launchers and seed the desktop dock on first use. */
@@ -60,7 +63,17 @@ export function writeDesktopPanelConfig(
60
63
  // Chrome includes its profile path in WM_CLASS.
61
64
  windowClass: `google-chrome (${request.chromiumProfileDir})`,
62
65
  icon: browserIcon,
63
- exec: `"${request.chromiumPath}" --no-sandbox --no-first-run --disable-dev-shm-usage "--user-data-dir=${request.chromiumProfileDir}"`,
66
+ exec: [
67
+ request.chromiumPath,
68
+ ...desktopChromeArguments(
69
+ request.chromiumProfileDir,
70
+ request.debugPort,
71
+ ),
72
+ ]
73
+ .map(
74
+ (arg) => `"${arg.replace(/[\\"`$]/g, "\\$&").replace(/%/g, "%%")}"`,
75
+ )
76
+ .join(" "),
64
77
  }),
65
78
  );
66
79
  writeFileSync(
@@ -7,7 +7,7 @@ import {
7
7
  } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
- import { afterAll, describe, expect, test } from "bun:test";
10
+ import { afterAll, describe, expect, spyOn, test } from "bun:test";
11
11
 
12
12
  import { waitFor } from "../__tests__/helpers/wait-for.js";
13
13
  import { sleep } from "../util/retry.js";
@@ -19,6 +19,7 @@ import {
19
19
  newViewer,
20
20
  settle,
21
21
  } from "./__tests__/fake-desktop.js";
22
+ import * as browserEndpoint from "./desktop-browser-endpoint.js";
22
23
  import {
23
24
  DESKTOP_VNC_PORT,
24
25
  type DesktopChildRole,
@@ -543,7 +544,12 @@ describe("DesktopSessionManager process tree", () => {
543
544
  JSON.stringify({ profile: { last_used: profileName } }),
544
545
  );
545
546
  const preferences = join(dir, profileName, "Preferences");
546
- const crashed = JSON.stringify({ profile: { exit_type: "Crashed" } });
547
+ const original = {
548
+ profile: { exit_type: "Crashed" },
549
+ browser: { custom_chrome_frame: false, show_home_button: true },
550
+ session: { restore_on_startup: 1 },
551
+ };
552
+ const crashed = JSON.stringify(original);
547
553
  writeFileSync(preferences, crashed);
548
554
  h.manager.acquireViewerSlot(newViewer().viewer);
549
555
  await h.manager.ensureDesktopRunning();
@@ -553,7 +559,10 @@ describe("DesktopSessionManager process tree", () => {
553
559
  expect(h.child("browser").request.cmd).toContain(
554
560
  "--hide-crash-restore-bubble",
555
561
  );
556
- expect(readFileSync(preferences, "utf8")).toBe(crashed);
562
+ expect(JSON.parse(readFileSync(preferences, "utf8"))).toEqual({
563
+ ...original,
564
+ browser: { ...original.browser, custom_chrome_frame: true },
565
+ });
557
566
 
558
567
  h.child("browser").exit(1);
559
568
  await settle();
@@ -728,3 +737,77 @@ describe("DesktopSessionManager viewer slot", () => {
728
737
  expect(h.count("x-server")).toBe(1);
729
738
  });
730
739
  });
740
+
741
+ describe("desktop automation lifecycle", () => {
742
+ test("an automation session outlives viewer disconnect and idles after both release", async () => {
743
+ const f = newManager({ exitOnTerm: true });
744
+ const viewer = newViewer();
745
+ const automation = newViewer();
746
+ expect(f.manager.acquireViewerSlot(viewer.viewer)).toEqual({ ok: true });
747
+ expect(f.manager.acquireAutomationSlot(automation.viewer)).toEqual({
748
+ ok: true,
749
+ });
750
+ await f.manager.ensureDesktopRunning();
751
+ f.manager.releaseViewerSlot(viewer.viewer);
752
+ await sleep(LINGER_MS + 20);
753
+ expect(f.terminated()).toHaveLength(0);
754
+ f.manager.releaseAutomationSlot(automation.viewer);
755
+ await waitFor(() => f.terminated().length > 0);
756
+ await f.manager.destroy();
757
+ });
758
+
759
+ test("shutdown notifies both the viewer and automation owner", async () => {
760
+ const f = newManager({ exitOnTerm: true });
761
+ const viewer = newViewer();
762
+ const automation = newViewer();
763
+ f.manager.acquireViewerSlot(viewer.viewer);
764
+ f.manager.acquireAutomationSlot(automation.viewer);
765
+ await f.manager.ensureDesktopRunning();
766
+ await f.manager.destroy();
767
+ expect(viewer.lost[0]?.code).toBe(1001);
768
+ expect(automation.lost[0]?.code).toBe(1001);
769
+ expect(f.manager.acquireAutomationSlot(newViewer().viewer)).toEqual(
770
+ SHUTTING_DOWN,
771
+ );
772
+ });
773
+ });
774
+
775
+ test.each([undefined, 4321])(
776
+ "browser control uses the reopened dock PID %s or launches Chrome when absent",
777
+ async (dockPid) => {
778
+ const h = newManager({ exitOnTerm: true });
779
+ const owner = newViewer().viewer;
780
+ h.manager.acquireViewerSlot(owner);
781
+ await h.manager.ensureDesktopRunning();
782
+ await settle();
783
+ h.child("browser").exit(0);
784
+ await settle();
785
+ h.manager.acquireAutomationSlot(owner);
786
+ const abort = new AbortController();
787
+ const find = spyOn(
788
+ browserEndpoint,
789
+ "findDesktopBrowserPid",
790
+ ).mockResolvedValue(dockPid);
791
+ const discover = spyOn(
792
+ browserEndpoint,
793
+ "discoverDesktopBrowser",
794
+ ).mockImplementation(async () => {
795
+ abort.abort();
796
+ throw new Error("Reached verified discovery");
797
+ });
798
+ try {
799
+ await expect(
800
+ h.manager.browser.client("conv-123", abort.signal),
801
+ ).rejects.toThrow("Reached verified discovery");
802
+ expect(h.count("browser")).toBe(dockPid ? 1 : 2);
803
+ expect(discover.mock.calls[0]?.[0]).toBe(
804
+ dockPid ?? h.child("browser").pid,
805
+ );
806
+ expect(find).toHaveBeenCalledTimes(1);
807
+ } finally {
808
+ find.mockRestore();
809
+ discover.mockRestore();
810
+ await h.manager.destroy();
811
+ }
812
+ },
813
+ );