@vellumai/assistant 0.12.2-staging.1 → 0.12.2-staging.3

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 (194) hide show
  1. package/docs/desktop-browser-cli.md +33 -0
  2. package/node_modules/@vellumai/slack-text/src/index.ts +13 -8
  3. package/node_modules/@vellumai/slack-text/src/label-resolution-entities.test.ts +95 -0
  4. package/openapi.yaml +50 -4
  5. package/package.json +1 -1
  6. package/scripts/smoke-desktop-browser-cli.ts +279 -0
  7. package/src/__tests__/always-loaded-tools-guard.test.ts +5 -5
  8. package/src/__tests__/config-schema.test.ts +1 -1
  9. package/src/__tests__/config-sounds-sync.test.ts +23 -0
  10. package/src/__tests__/conversation-error.test.ts +4 -1
  11. package/src/__tests__/conversation-runtime-assembly.test.ts +33 -3
  12. package/src/__tests__/conversation-surfaces-point-at-budget.test.ts +1 -0
  13. package/src/__tests__/conversation-surfaces-point-at-capability.test.ts +1 -0
  14. package/src/__tests__/credential-routes.test.ts +38 -0
  15. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  16. package/src/__tests__/cu-unified-flow.test.ts +6 -2
  17. package/src/__tests__/external-plugin-loader.test.ts +52 -0
  18. package/src/__tests__/headless-browser-mode.test.ts +48 -0
  19. package/src/__tests__/host-cu-proxy.test.ts +185 -30
  20. package/src/__tests__/list-all-apps.test.ts +40 -8
  21. package/src/__tests__/mcp-auth-routes.test.ts +99 -0
  22. package/src/__tests__/mcp-client-auth.test.ts +54 -0
  23. package/src/__tests__/mcp-health-check.test.ts +32 -32
  24. package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
  25. package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
  26. package/src/__tests__/oauth-commands-routes.test.ts +55 -0
  27. package/src/__tests__/oauth-provider-serializer.test.ts +22 -0
  28. package/src/__tests__/oauth-providers-routes.test.ts +1 -0
  29. package/src/__tests__/secret-routes-acp-guard.test.ts +59 -1
  30. package/src/__tests__/skills.test.ts +10 -0
  31. package/src/__tests__/subagent-tool-gate-mode.test.ts +51 -0
  32. package/src/__tests__/ui-channel-variants.test.ts +1 -56
  33. package/src/__tests__/user-plugin-loader.test.ts +21 -0
  34. package/src/acp/__tests__/acp-claude-oauth.test.ts +257 -6
  35. package/src/acp/__tests__/acp-credentials.test.ts +13 -0
  36. package/src/acp/__tests__/claude-token-refresh.test.ts +257 -0
  37. package/src/acp/__tests__/prepare-agent-env.test.ts +60 -1
  38. package/src/acp/acp-claude-oauth.ts +328 -14
  39. package/src/acp/acp-credentials.ts +19 -0
  40. package/src/acp/claude-token-refresh.ts +150 -0
  41. package/src/acp/prepare-agent-env.ts +21 -6
  42. package/src/apps/app-store.ts +6 -5
  43. package/src/browser/operations.ts +2 -1
  44. package/src/browser/types.ts +7 -0
  45. package/src/browser/virtual-desktop-target.ts +34 -0
  46. package/src/calls/__tests__/progress-narration.test.ts +23 -0
  47. package/src/calls/__tests__/voice-control-protocol.test.ts +69 -0
  48. package/src/calls/__tests__/voice-session-bridge.test.ts +81 -0
  49. package/src/calls/progress-narration.ts +15 -2
  50. package/src/calls/voice-control-protocol.ts +132 -0
  51. package/src/calls/voice-session-bridge.ts +56 -26
  52. package/src/calls/voice-triage-escalate.ts +3 -3
  53. package/src/cli/commands/__tests__/browser.test.ts +84 -3
  54. package/src/cli/commands/browser.help.ts +43 -0
  55. package/src/cli/commands/browser.ts +52 -8
  56. package/src/cli/commands/oauth/status.ts +5 -0
  57. package/src/cli/commands/plugins.ts +16 -5
  58. package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
  59. package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
  60. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
  61. package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
  62. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
  63. package/src/cli/lib/bundled-marketplace.json +13 -0
  64. package/src/cli/lib/install-from-github.ts +17 -18
  65. package/src/cli/lib/install-from-platform.ts +14 -9
  66. package/src/cli/lib/list-installed-plugins.ts +21 -35
  67. package/src/cli/lib/plugin-details.ts +21 -8
  68. package/src/cli/lib/uninstall-plugin.ts +59 -1
  69. package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
  70. package/src/config/bundled-skills/computer-use/SKILL.md +43 -4
  71. package/src/config/bundled-skills/computer-use/TOOLS.json +3 -3
  72. package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
  73. package/src/config/feature-flag-registry.json +11 -3
  74. package/src/config/loader.ts +1 -0
  75. package/src/config/schemas/__tests__/voice.test.ts +2 -2
  76. package/src/config/schemas/mcp.ts +9 -7
  77. package/src/config/schemas/monitoring.ts +9 -0
  78. package/src/config/schemas/services.ts +10 -0
  79. package/src/config/schemas/voice.ts +2 -2
  80. package/src/config/skills.ts +13 -21
  81. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
  82. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
  83. package/src/daemon/conversation-client-surface.ts +26 -0
  84. package/src/daemon/conversation-runtime-assembly.ts +19 -7
  85. package/src/daemon/conversation-surfaces.ts +6 -1
  86. package/src/daemon/conversation-tool-setup.ts +37 -43
  87. package/src/daemon/host-cu-proxy.ts +94 -31
  88. package/src/daemon/host-proxy-preactivation.ts +2 -0
  89. package/src/daemon/mcp-reload-service.ts +7 -0
  90. package/src/daemon/message-types/sync.ts +1 -0
  91. package/src/daemon/providers-setup.ts +4 -0
  92. package/src/desktop/__tests__/fake-desktop.ts +1 -0
  93. package/src/desktop/desktop-automation-lease.test.ts +208 -0
  94. package/src/desktop/desktop-automation-lease.ts +270 -0
  95. package/src/desktop/desktop-browser-client.test.ts +413 -0
  96. package/src/desktop/desktop-browser-client.ts +430 -0
  97. package/src/desktop/desktop-browser-cursor.ts +40 -0
  98. package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
  99. package/src/desktop/desktop-browser-endpoint.ts +145 -0
  100. package/src/desktop/desktop-browser-operations.ts +130 -0
  101. package/src/desktop/desktop-chrome-session.test.ts +48 -0
  102. package/src/desktop/desktop-chrome-session.ts +58 -8
  103. package/src/desktop/desktop-dependencies.test.ts +29 -0
  104. package/src/desktop/desktop-dependencies.ts +26 -5
  105. package/src/desktop/desktop-display.ts +9 -0
  106. package/src/desktop/desktop-panel-config.ts +14 -1
  107. package/src/desktop/desktop-session-manager.test.ts +86 -3
  108. package/src/desktop/desktop-session-manager.ts +144 -24
  109. package/src/desktop/desktop-stream-bridge.ts +1 -1
  110. package/src/desktop/virtual-desktop-feature.ts +18 -0
  111. package/src/desktop/virtual-desktop-platform.test.ts +61 -0
  112. package/src/i18n/__tests__/i18n.test.ts +9 -5
  113. package/src/i18n/messages.ts +13 -4
  114. package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
  115. package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
  116. package/src/ipc/assistant-server.ts +15 -1
  117. package/src/ipc/cli-client.ts +13 -5
  118. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +143 -0
  119. package/src/live-voice/__tests__/live-voice-events.test.ts +9 -7
  120. package/src/live-voice/__tests__/live-voice-progress.test.ts +79 -0
  121. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
  122. package/src/live-voice/__tests__/protocol.test.ts +47 -0
  123. package/src/live-voice/__tests__/session-controls.test.ts +113 -0
  124. package/src/live-voice/live-voice-metrics.ts +9 -0
  125. package/src/live-voice/live-voice-session.ts +88 -7
  126. package/src/live-voice/protocol.ts +69 -0
  127. package/src/live-voice/session-controls.ts +139 -0
  128. package/src/mcp/__tests__/credential-target.test.ts +59 -0
  129. package/src/mcp/__tests__/effective-config.test.ts +11 -0
  130. package/src/mcp/__tests__/manager-state.test.ts +128 -0
  131. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
  132. package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
  133. package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
  134. package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
  135. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
  136. package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
  137. package/src/mcp/client.ts +64 -16
  138. package/src/mcp/credential-target.ts +105 -0
  139. package/src/mcp/effective-config.ts +42 -15
  140. package/src/mcp/manager.ts +61 -8
  141. package/src/mcp/mcp-auth-orchestrator.ts +17 -4
  142. package/src/mcp/mcp-auth-state.ts +38 -6
  143. package/src/mcp/mcp-oauth-provider.ts +101 -33
  144. package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
  145. package/src/monitoring/mount-watch.ts +348 -0
  146. package/src/monitoring/worker.ts +9 -0
  147. package/src/oauth/__tests__/seed-providers-managed.test.ts +95 -0
  148. package/src/oauth/connection-resolver.test.ts +27 -0
  149. package/src/oauth/connection-resolver.ts +25 -1
  150. package/src/oauth/provider-serializer.ts +9 -0
  151. package/src/oauth/seed-providers.ts +121 -2
  152. package/src/onboarding/checkin-event.ts +2 -1
  153. package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
  154. package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
  155. package/src/plugins/external-plugin-loader.ts +49 -86
  156. package/src/plugins/installed-plugin-dirs.ts +6 -4
  157. package/src/plugins/mcp-servers.ts +9 -15
  158. package/src/plugins/mtime-cache.ts +2 -1
  159. package/src/plugins/user-loader.ts +2 -1
  160. package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
  161. package/src/providers/inference/endpoint-probe.ts +27 -12
  162. package/src/providers/openai/responses-provider.ts +9 -2
  163. package/src/providers/opencode/client.test.ts +167 -0
  164. package/src/providers/opencode/client.ts +73 -4
  165. package/src/runtime/AGENTS.md +4 -0
  166. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
  167. package/src/runtime/http-server.ts +6 -6
  168. package/src/runtime/routes/__tests__/acp-claude-auth-routes.test.ts +16 -5
  169. package/src/runtime/routes/__tests__/apps-refresh-route.test.ts +74 -4
  170. package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
  171. package/src/runtime/routes/acp-claude-auth-routes.ts +11 -3
  172. package/src/runtime/routes/app-management-routes.ts +17 -2
  173. package/src/runtime/routes/browser-context.ts +39 -0
  174. package/src/runtime/routes/browser-routes.ts +16 -47
  175. package/src/runtime/routes/browser-tabs-routes.ts +72 -29
  176. package/src/runtime/routes/credential-routes.ts +1 -4
  177. package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
  178. package/src/runtime/routes/desktop-setup-routes.ts +7 -5
  179. package/src/runtime/routes/mcp-auth-routes.ts +106 -94
  180. package/src/runtime/routes/oauth-commands-routes.ts +14 -0
  181. package/src/runtime/routes/oauth-providers.ts +7 -0
  182. package/src/runtime/routes/plugins-routes.ts +14 -1
  183. package/src/runtime/sync/resource-sync-events.ts +4 -0
  184. package/src/tools/browser/browser-execution.ts +44 -13
  185. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
  186. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
  187. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
  188. package/src/tools/computer-use/definitions.ts +3 -4
  189. package/src/tools/ui-surface/channel-variants.ts +10 -59
  190. package/src/util/abort-reasons.ts +12 -1
  191. package/src/util/plugin-manifest.ts +203 -0
  192. package/src/watch/watch-retro.ts +6 -8
  193. package/src/desktop/desktop-feature.test.ts +0 -18
  194. package/src/desktop/desktop-feature.ts +0 -19
@@ -5,7 +5,7 @@
5
5
  * implementations unless this file's tests are running.
6
6
  */
7
7
 
8
- import { mkdirSync, rmSync, writeFileSync } from "node:fs";
8
+ import { mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import {
@@ -33,6 +33,9 @@ const realCompiler = { ...(await import("../../../bundler/app-compiler.js")) };
33
33
  const realNotify = {
34
34
  ...(await import("../../../daemon/app-change-notify.js")),
35
35
  };
36
+ const realPinStore = {
37
+ ...(await import("../../../apps/app-pin-store.js")),
38
+ };
36
39
 
37
40
  const compileApp = mock(
38
41
  async (
@@ -70,6 +73,16 @@ mock.module("../../../daemon/app-change-notify.js", () => ({
70
73
  },
71
74
  }));
72
75
 
76
+ mock.module("../../../apps/app-pin-store.js", () => ({
77
+ ...realPinStore,
78
+ listAppPins: () => {
79
+ if (!mockActive) {
80
+ return realPinStore.listAppPins();
81
+ }
82
+ return [];
83
+ },
84
+ }));
85
+
73
86
  const { createApp } = await import("../../../apps/app-store.js");
74
87
  const { getWorkspacePluginsDir } = await import("../../../util/platform.js");
75
88
  const { ROUTES } = await import("../app-management-routes.js");
@@ -83,6 +96,7 @@ function findHandler(operationId: string) {
83
96
  }
84
97
 
85
98
  const handleRefreshApp = findHandler("apps_refresh");
99
+ const handleListApps = findHandler("apps_list");
86
100
 
87
101
  let workspaceDir: string;
88
102
  let previousWorkspaceDir: string | undefined;
@@ -142,6 +156,30 @@ describe("apps_refresh route", () => {
142
156
  });
143
157
  });
144
158
 
159
+ test("advances the workspace app list revision when compiled output changes", async () => {
160
+ const created = createApp({
161
+ name: "Budget",
162
+ schemaJson: "{}",
163
+ htmlDefinition: "<h1>Budget</h1>",
164
+ });
165
+ const builtAt = Math.ceil(created.updatedAt) + 1_000;
166
+ compileApp.mockImplementationOnce(async (appDir) => {
167
+ const distDir = join(appDir, "dist");
168
+ mkdirSync(distDir, { recursive: true });
169
+ utimesSync(distDir, new Date(builtAt), new Date(builtAt));
170
+ return { ok: true, errors: [], warnings: [], durationMs: 11 };
171
+ });
172
+
173
+ await handleRefreshApp({ pathParams: { id: created.id } });
174
+ const listed = (await handleListApps({})) as {
175
+ apps: Array<{ id: string; updatedAt: number }>;
176
+ };
177
+
178
+ expect(listed.apps.find((app) => app.id === created.id)?.updatedAt).toBe(
179
+ builtAt,
180
+ );
181
+ });
182
+
145
183
  test("compiles a plugin app in place", async () => {
146
184
  const pluginName = `charts-${Math.random().toString(36).slice(2, 8)}`;
147
185
  const pluginDir = join(getWorkspacePluginsDir(), pluginName);
@@ -157,9 +195,7 @@ describe("apps_refresh route", () => {
157
195
  pathParams: { id: pluginAppId },
158
196
  });
159
197
 
160
- expect(compileApp).toHaveBeenCalledWith(
161
- join(pluginDir, "apps", "viewer"),
162
- );
198
+ expect(compileApp).toHaveBeenCalledWith(join(pluginDir, "apps", "viewer"));
163
199
  expect(notifyAppSurfacesChanged).toHaveBeenCalledWith(pluginAppId, {
164
200
  fileChange: true,
165
201
  });
@@ -171,6 +207,40 @@ describe("apps_refresh route", () => {
171
207
  });
172
208
  });
173
209
 
210
+ test("advances the plugin app list revision when compiled output changes", async () => {
211
+ const pluginName = `charts-${Math.random().toString(36).slice(2, 8)}`;
212
+ const appDir = join(getWorkspacePluginsDir(), pluginName, "apps", "viewer");
213
+ mkdirSync(appDir, { recursive: true });
214
+ writeFileSync(
215
+ join(getWorkspacePluginsDir(), pluginName, "package.json"),
216
+ JSON.stringify({ name: pluginName, version: "1.0.0" }),
217
+ );
218
+ const pluginAppId = `plugins~${pluginName}~viewer`;
219
+ const before = (await handleListApps({})) as {
220
+ apps: Array<{ id: string; updatedAt: number }>;
221
+ };
222
+ const previousRevision = before.apps.find(
223
+ (app) => app.id === pluginAppId,
224
+ )?.updatedAt;
225
+ expect(previousRevision).toBeDefined();
226
+ const builtAt = Math.ceil(previousRevision!) + 1_000;
227
+ compileApp.mockImplementationOnce(async (sourceDir) => {
228
+ const distDir = join(sourceDir, "dist");
229
+ mkdirSync(distDir, { recursive: true });
230
+ utimesSync(distDir, new Date(builtAt), new Date(builtAt));
231
+ return { ok: true, errors: [], warnings: [], durationMs: 11 };
232
+ });
233
+
234
+ await handleRefreshApp({ pathParams: { id: pluginAppId } });
235
+ const after = (await handleListApps({})) as {
236
+ apps: Array<{ id: string; updatedAt: number }>;
237
+ };
238
+
239
+ expect(after.apps.find((app) => app.id === pluginAppId)?.updatedAt).toBe(
240
+ builtAt,
241
+ );
242
+ });
243
+
174
244
  test("returns compile errors without throwing", async () => {
175
245
  const created = createApp({
176
246
  name: "Budget",
@@ -1239,8 +1239,12 @@ describe("GET /v1/plugins/search", () => {
1239
1239
 
1240
1240
  async function invokeUninstall(
1241
1241
  args: RouteHandlerArgs = {},
1242
- ): Promise<{ name: string; target: string }> {
1243
- return (await uninstallHandler(args)) as { name: string; target: string };
1242
+ ): Promise<{ name: string; target: string; warnings?: string[] }> {
1243
+ return (await uninstallHandler(args)) as {
1244
+ name: string;
1245
+ target: string;
1246
+ warnings?: string[];
1247
+ };
1244
1248
  }
1245
1249
 
1246
1250
  describe("DELETE /v1/plugins/:name", () => {
@@ -1278,6 +1282,22 @@ describe("DELETE /v1/plugins/:name", () => {
1278
1282
  expectPluginsListBroadcast();
1279
1283
  });
1280
1284
 
1285
+ test("returns non-fatal cleanup warnings from the shared uninstall", async () => {
1286
+ uninstallSpy.mockImplementation(async (opts) => ({
1287
+ name: opts.name,
1288
+ target: `/workspace/.vellum/plugins/${opts.name}`,
1289
+ warnings: ["plugin.uninstall.mcp_oauth_credentials_unchecked"],
1290
+ }));
1291
+
1292
+ const result = await invokeUninstall({
1293
+ pathParams: { name: "simple-memory" },
1294
+ });
1295
+
1296
+ expect(result.warnings).toEqual([
1297
+ "plugin.uninstall.mcp_oauth_credentials_unchecked",
1298
+ ]);
1299
+ });
1300
+
1281
1301
  test("threads x-vellum-client-id into the published event's originClientId", async () => {
1282
1302
  uninstallSpy.mockImplementation(async (opts) => ({
1283
1303
  name: opts.name,
@@ -2759,7 +2779,9 @@ describe("POST /v1/plugins/:name/disable", () => {
2759
2779
  toggleResult(name, "disable"),
2760
2780
  );
2761
2781
 
2762
- const result = await invokeDisable({ pathParams: { name: "simple-memory" } });
2782
+ const result = await invokeDisable({
2783
+ pathParams: { name: "simple-memory" },
2784
+ });
2763
2785
 
2764
2786
  expect(result).toEqual({ ok: true });
2765
2787
  expect(disablePluginSpy.mock.calls[0]?.[0]).toBe("simple-memory");
@@ -18,7 +18,7 @@
18
18
  * has landed.
19
19
  * POST /v1/acp/claude/auth/exchange — complete a manual/cloud flow: accept the
20
20
  * pasted `code#state` (or a raw code + state), exchange it, and store the
21
- * Claude OAuth token.
21
+ * Claude OAuth token set (access token plus any refresh token and expiry).
22
22
  *
23
23
  * `handleStartAuth` picks loopback vs. manual: a containerized host, or a client
24
24
  * that sets `preferManual` because its browser can't reach the daemon's
@@ -126,7 +126,11 @@ async function handleStartLocalAuth(): Promise<StartResponse> {
126
126
  // can react. Runs in the background — the web client opens `authorize_url`.
127
127
  void flow.completion
128
128
  .then(async (result) => {
129
- await storeAcpClaudeToken(result.tokens.accessToken);
129
+ await storeAcpClaudeToken({
130
+ accessToken: result.tokens.accessToken,
131
+ refreshToken: result.tokens.refreshToken,
132
+ expiresIn: result.tokens.expiresIn,
133
+ });
130
134
  pendingFlows.mark(flow.state, "connected");
131
135
  log.info("ACP Claude local OAuth flow connected");
132
136
  })
@@ -216,7 +220,11 @@ async function handleExchange(args: RouteHandlerArgs): Promise<{ ok: true }> {
216
220
  pending.codeVerifier,
217
221
  state,
218
222
  );
219
- await storeAcpClaudeToken(result.tokens.accessToken);
223
+ await storeAcpClaudeToken({
224
+ accessToken: result.tokens.accessToken,
225
+ refreshToken: result.tokens.refreshToken,
226
+ expiresIn: result.tokens.expiresIn,
227
+ });
220
228
  } catch (err) {
221
229
  const message = err instanceof Error ? err.message : String(err);
222
230
  pendingFlows.mark(state, "error", { error: message });
@@ -181,7 +181,7 @@ function workspaceAppItem(a: AppDefinition): AppListItem {
181
181
  description: a.description,
182
182
  icon: a.icon,
183
183
  createdAt: a.createdAt,
184
- updatedAt: a.updatedAt,
184
+ updatedAt: appContentUpdatedAt(getAppDirPath(a.id), a.updatedAt),
185
185
  version: a.version ?? "1.0.0",
186
186
  contentId: computeContentId(a.name),
187
187
  origin: "workspace",
@@ -203,13 +203,28 @@ function pluginAppItem(app: EnumeratedApp): AppListItem {
203
203
  id: app.id,
204
204
  name: app.name,
205
205
  createdAt: mtime,
206
- updatedAt: mtime,
206
+ updatedAt: appContentUpdatedAt(app.sourcePath, mtime),
207
207
  version: "1.0.0",
208
208
  contentId: computeContentId(app.name),
209
209
  origin: `plugin:${pluginName}`,
210
210
  };
211
211
  }
212
212
 
213
+ /** The newest persisted metadata or compiled-output revision for an app. */
214
+ function appContentUpdatedAt(
215
+ sourcePath: string,
216
+ metadataUpdatedAt: number,
217
+ ): number {
218
+ try {
219
+ return Math.max(
220
+ metadataUpdatedAt,
221
+ statSync(join(sourcePath, "dist")).mtimeMs,
222
+ );
223
+ } catch {
224
+ return metadataUpdatedAt;
225
+ }
226
+ }
227
+
213
228
  function getAppDataResult(
214
229
  method: string,
215
230
  appId: string,
@@ -0,0 +1,39 @@
1
+ import { resolveTurnClientOs } from "../../daemon/conversation-client-surface.js";
2
+ import { findConversation } from "../../daemon/conversation-registry.js";
3
+ import type { ToolContext } from "../../tools/types.js";
4
+ import { resolveActorPrincipalIdForLocalGuardian } from "../local-actor-identity.js";
5
+
6
+ export function browserCliConversationKey(sessionId: string): string {
7
+ return `browser-cli:${sessionId}`;
8
+ }
9
+
10
+ export async function resolveBrowserContext(
11
+ conversationId: string | undefined,
12
+ sessionId: string,
13
+ headers: Record<string, string>,
14
+ abortSignal?: AbortSignal,
15
+ ): Promise<ToolContext> {
16
+ const conversation = conversationId
17
+ ? findConversation(conversationId)
18
+ : undefined;
19
+ const actor = await resolveActorPrincipalIdForLocalGuardian(
20
+ conversation?.getTurnActorPrincipalId() ??
21
+ headers["x-vellum-actor-principal-id"]?.trim(),
22
+ );
23
+ const signals = [abortSignal, conversation?.abortController?.signal].filter(
24
+ (signal): signal is AbortSignal => signal !== undefined,
25
+ );
26
+ return {
27
+ workingDir: process.cwd(),
28
+ conversationId: conversation
29
+ ? conversationId!
30
+ : browserCliConversationKey(sessionId),
31
+ trustClass: conversation?.trustContext?.trustClass ?? "unknown",
32
+ transportInterface: conversation?.transportInterface,
33
+ clientOs: conversation
34
+ ? resolveTurnClientOs(conversation).clientOs
35
+ : undefined,
36
+ sourceActorPrincipalId: actor,
37
+ signal: signals.length ? AbortSignal.any(signals) : undefined,
38
+ };
39
+ }
@@ -16,10 +16,12 @@ import {
16
16
  BROWSER_OPERATIONS,
17
17
  type BrowserOperation,
18
18
  } from "../../browser/types.js";
19
- import { findConversation } from "../../daemon/conversation-registry.js";
19
+ import { shouldUseVirtualDesktopBrowser } from "../../browser/virtual-desktop-target.js";
20
+ import { executeDesktopBrowserOperation } from "../../desktop/desktop-browser-operations.js";
20
21
  import type { ContentBlock } from "../../providers/types.js";
21
22
  import { LOCAL_PRINCIPALS } from "../auth/route-policy.js";
22
- import { resolveActorPrincipalIdForLocalGuardian } from "../local-actor-identity.js";
23
+ import { resolveBrowserContext } from "./browser-context.js";
24
+ export { browserCliConversationKey } from "./browser-context.js";
23
25
  import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
24
26
 
25
27
  // ── Param validation ─────────────────────────────────────────────────
@@ -29,18 +31,9 @@ const BrowserExecuteParams = z.object({
29
31
  input: z.record(z.string(), z.unknown()).default({}),
30
32
  sessionId: z.string().min(1).default("default"),
31
33
  conversationId: z.string().min(1).optional(),
34
+ desktop: z.boolean().optional(),
32
35
  });
33
36
 
34
- // ── Conversation key ─────────────────────────────────────────────────
35
-
36
- /**
37
- * Build a deterministic conversation key from a session ID.
38
- * All CLI browser calls with the same session share browser state.
39
- */
40
- export function browserCliConversationKey(sessionId: string): string {
41
- return `browser-cli:${sessionId}`;
42
- }
43
-
44
37
  // ── Screenshot extraction ────────────────────────────────────────────
45
38
 
46
39
  /**
@@ -70,45 +63,21 @@ function extractScreenshots(
70
63
  async function handleBrowserExecute({
71
64
  body = {},
72
65
  headers = {},
66
+ abortSignal,
73
67
  }: RouteHandlerArgs) {
74
- const { operation, input, sessionId, conversationId } =
68
+ const { operation, input, sessionId, conversationId, desktop } =
75
69
  BrowserExecuteParams.parse(body);
76
70
 
77
- // When the caller passes a live conversation ID (e.g. from
78
- // __CONVERSATION_ID in a nested bash invocation), reuse that
79
- // conversation's trust context and transport interface.
80
- const conversation = conversationId
81
- ? findConversation(conversationId)
82
- : undefined;
83
-
84
- const resolvedConversationId = conversation
85
- ? conversationId!
86
- : browserCliConversationKey(sessionId);
87
-
88
- // Actor principal lets host-browser routing enforce same-actor ownership.
89
- // A live conversation's turn actor owns the request (e.g. a nested-bash
90
- // browser call inherits the conversation's actor), so it wins over the
91
- // request header — which over IPC may carry a synthetic local-guardian id
92
- // injected for header-less local callers. Resolve through the local-guardian
93
- // translation so the value matches the actorPrincipalId host_browser clients
94
- // register with (dev-bypass otherwise mismatches).
95
- const headerActor =
96
- headers["x-vellum-actor-principal-id"]?.trim() || undefined;
97
- const sourceActorPrincipalId = await resolveActorPrincipalIdForLocalGuardian(
98
- conversation?.getTurnActorPrincipalId() ?? headerActor,
99
- );
100
-
101
- const result = await executeBrowserOperation(
102
- operation as BrowserOperation,
103
- input,
104
- {
105
- workingDir: process.cwd(),
106
- conversationId: resolvedConversationId,
107
- trustClass: conversation?.trustContext?.trustClass ?? "unknown",
108
- transportInterface: conversation?.transportInterface,
109
- sourceActorPrincipalId,
110
- },
71
+ const context = await resolveBrowserContext(
72
+ conversationId,
73
+ sessionId,
74
+ headers,
75
+ abortSignal,
111
76
  );
77
+ const execute = shouldUseVirtualDesktopBrowser(desktop, input, context)
78
+ ? executeDesktopBrowserOperation
79
+ : executeBrowserOperation;
80
+ const result = await execute(operation as BrowserOperation, input, context);
112
81
 
113
82
  const screenshots = extractScreenshots(result.contentBlocks);
114
83
 
@@ -2,22 +2,23 @@
2
2
  * Routes for browser tab management commands.
3
3
  *
4
4
  * Exposes `browser_tabs` so CLI commands can list, create, select, and
5
- * close browser tabs via the Chrome extension backend.
5
+ * close browser tabs in the Chrome extension or streamed desktop browser.
6
6
  */
7
7
 
8
8
  import { z } from "zod";
9
9
 
10
- import { findConversation } from "../../daemon/conversation-registry.js";
10
+ import { shouldUseVirtualDesktopBrowser } from "../../browser/virtual-desktop-target.js";
11
11
  import { HostBrowserProxy } from "../../daemon/host-browser-proxy.js";
12
+ import { executeDesktopBrowserTabs } from "../../desktop/desktop-browser-operations.js";
13
+ import { normalizeBrowserMode } from "../../tools/browser/browser-mode.js";
12
14
  import { getCdpClient } from "../../tools/browser/cdp-client/factory.js";
13
15
  import {
14
16
  clearPinnedTab,
15
17
  clearPinnedTabByTabId,
16
18
  setPinnedTab,
17
19
  } from "../../tools/browser/pinned-tabs.js";
18
- import type { ToolContext } from "../../tools/types.js";
19
20
  import { LOCAL_PRINCIPALS } from "../auth/route-policy.js";
20
- import { browserCliConversationKey } from "./browser-routes.js";
21
+ import { resolveBrowserContext } from "./browser-context.js";
21
22
  import { BadRequestError } from "./errors.js";
22
23
  import { parseBody } from "./parse-body.js";
23
24
  import type { RouteDefinition, RouteHandlerArgs } from "./types.js";
@@ -26,6 +27,8 @@ const BrowserTabsParams = z.object({
26
27
  command: z.enum(["list", "select", "new", "close"]),
27
28
  sessionId: z.string().min(1).default("default"),
28
29
  conversationId: z.string().min(1).optional(),
30
+ desktop: z.boolean().optional(),
31
+ browserMode: z.string().optional(),
29
32
  tabId: z.number().optional(),
30
33
  url: z.string().optional(),
31
34
  // Route tab operations to a specific extension client in multi-client
@@ -33,33 +36,73 @@ const BrowserTabsParams = z.object({
33
36
  targetClientId: z.string().min(1).optional(),
34
37
  });
35
38
 
36
- async function handleBrowserTabs({ body = {} }: RouteHandlerArgs) {
37
- const { command, sessionId, conversationId, tabId, url, targetClientId } =
38
- parseBody(BrowserTabsParams, body);
39
-
40
- const conversation = conversationId
41
- ? findConversation(conversationId)
42
- : undefined;
43
- const resolvedConversationId = conversation
44
- ? conversationId!
45
- : browserCliConversationKey(sessionId);
46
-
47
- const context = {
48
- workingDir: process.cwd(),
49
- conversationId: resolvedConversationId,
50
- trustClass: conversation?.trustContext?.trustClass ?? "unknown",
51
- transportInterface: conversation?.transportInterface,
52
- } as unknown as ToolContext;
39
+ async function handleBrowserTabs({
40
+ body = {},
41
+ headers = {},
42
+ abortSignal,
43
+ }: RouteHandlerArgs) {
44
+ const {
45
+ command,
46
+ sessionId,
47
+ conversationId,
48
+ tabId,
49
+ url,
50
+ targetClientId,
51
+ desktop,
52
+ browserMode,
53
+ } = parseBody(BrowserTabsParams, body);
53
54
 
54
- const cdpOptions = { mode: "extension" as const, targetClientId };
55
+ const context = await resolveBrowserContext(
56
+ conversationId,
57
+ sessionId,
58
+ headers,
59
+ abortSignal,
60
+ );
61
+ const resolvedConversationId = context.conversationId;
62
+ const mode = normalizeBrowserMode(browserMode);
63
+ if ("error" in mode) {
64
+ throw new BadRequestError(mode.error);
65
+ }
66
+ if (
67
+ shouldUseVirtualDesktopBrowser(
68
+ desktop,
69
+ { browser_mode: browserMode, target_client_id: targetClientId },
70
+ context,
71
+ )
72
+ ) {
73
+ if (
74
+ targetClientId ||
75
+ (mode.mode !== "auto" && mode.mode !== "cdp-inspect")
76
+ ) {
77
+ throw new BadRequestError(
78
+ "--virtual-desktop cannot target a personal browser client",
79
+ );
80
+ }
81
+ const result = await executeDesktopBrowserTabs(
82
+ { command, tabId, url },
83
+ context,
84
+ );
85
+ if (result.isError) {
86
+ throw new BadRequestError(result.content);
87
+ }
88
+ return JSON.parse(result.content);
89
+ }
55
90
 
56
- // Every tabs command pins extension mode. Absorb a brief extension SSE
57
- // reconnect blip so a flapping connection doesn't surface as a hard
58
- // "no Chrome Extension connected" error.
59
- await HostBrowserProxy.instance.waitForExtensionClient(
60
- context.sourceActorPrincipalId,
91
+ const cdpOptions = {
92
+ mode:
93
+ targetClientId || mode.mode === "auto"
94
+ ? ("extension" as const)
95
+ : mode.mode,
61
96
  targetClientId,
62
- );
97
+ };
98
+
99
+ // Absorb brief reconnects before dispatching to the extension.
100
+ if (cdpOptions.mode === "extension") {
101
+ await HostBrowserProxy.instance.waitForExtensionClient(
102
+ context.sourceActorPrincipalId,
103
+ targetClientId,
104
+ );
105
+ }
63
106
 
64
107
  if (command === "list") {
65
108
  const cdp = getCdpClient(context, cdpOptions);
@@ -185,7 +228,7 @@ export const ROUTES: RouteDefinition[] = [
185
228
  handler: handleBrowserTabs,
186
229
  summary: "Manage browser tabs",
187
230
  description:
188
- "List, create, select, or close browser tabs via the Chrome extension backend.",
231
+ "List, create, select, or close browser tabs in the Chrome extension or streamed desktop browser.",
189
232
  tags: ["browser"],
190
233
  requestBody: BrowserTabsParams,
191
234
  responseBody: z.object({
@@ -34,7 +34,6 @@ import { credentialKey } from "../../security/credential-key.js";
34
34
  import {
35
35
  deleteSecureKeyAsync,
36
36
  getActiveBackendInfoAsync,
37
- getSecureKeyAsync,
38
37
  getSecureKeyResultAsync,
39
38
  } from "../../security/secure-keys.js";
40
39
  import {
@@ -561,9 +560,7 @@ async function handleCredentialsDelete({ body }: RouteHandlerArgs) {
561
560
 
562
561
  const key = credentialKey(service, field);
563
562
  const affectedConnections = assertCredentialNotInUse(key, force === true);
564
- const existing = await getSecureKeyAsync(key);
565
- const deleteResult =
566
- existing != null ? await deleteSecureKeyAsync(key) : "not-found";
563
+ const deleteResult = await deleteSecureKeyAsync(key);
567
564
 
568
565
  if (deleteResult === "error") {
569
566
  throw new InternalError(
@@ -14,6 +14,7 @@ import { ROUTES } from "./desktop-setup-routes.js";
14
14
  import { NotFoundError } from "./errors.js";
15
15
 
16
16
  const originalContainerized = process.env.IS_CONTAINERIZED;
17
+ const originalPlatform = process.env.IS_PLATFORM;
17
18
  const status = spyOn(desktopDependencyInstaller, "getStatus");
18
19
  const start = spyOn(desktopDependencyInstaller, "start");
19
20
 
@@ -24,6 +25,7 @@ afterAll(() => {
24
25
 
25
26
  beforeEach(() => {
26
27
  process.env.IS_CONTAINERIZED = "true";
28
+ process.env.IS_PLATFORM = "true";
27
29
  status.mockReturnValue({ state: "required" });
28
30
  start.mockReturnValue({ state: "installing", stage: "packages" });
29
31
  });
@@ -34,6 +36,11 @@ afterEach(() => {
34
36
  } else {
35
37
  process.env.IS_CONTAINERIZED = originalContainerized;
36
38
  }
39
+ if (originalPlatform === undefined) {
40
+ delete process.env.IS_PLATFORM;
41
+ } else {
42
+ process.env.IS_PLATFORM = originalPlatform;
43
+ }
37
44
  setOverridesForTesting({});
38
45
  status.mockClear();
39
46
  start.mockClear();
@@ -60,7 +67,15 @@ describe("desktop setup route feature gate", () => {
60
67
  expect(start).not.toHaveBeenCalled();
61
68
  });
62
69
 
63
- test(`${route.method} serves setup only with the flag enabled in a container`, () => {
70
+ test(`${route.method} rejects self-hosted Docker assistants without inspecting or installing dependencies`, () => {
71
+ process.env.IS_PLATFORM = "false";
72
+ setOverridesForTesting({ "assistant-desktop": true });
73
+ expect(() => route.handler({})).toThrow(NotFoundError);
74
+ expect(status).not.toHaveBeenCalled();
75
+ expect(start).not.toHaveBeenCalled();
76
+ });
77
+
78
+ test(`${route.method} serves setup only for enabled platform containers`, () => {
64
79
  setOverridesForTesting({ "assistant-desktop": true });
65
80
  expect(route.handler({})).toEqual(
66
81
  route.method === "GET"
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
 
3
3
  import { getConfig } from "../../config/loader.js";
4
4
  import { desktopDependencyInstaller } from "../../desktop/desktop-dependencies.js";
5
- import { isAssistantDesktopEnabled } from "../../desktop/desktop-feature.js";
5
+ import { isVirtualDesktopEnabled } from "../../desktop/virtual-desktop-feature.js";
6
6
  import { GATEWAY_PRINCIPALS } from "../auth/route-policy.js";
7
7
  import { NotFoundError } from "./errors.js";
8
8
  import type { RouteDefinition } from "./types.js";
@@ -19,8 +19,10 @@ export const ROUTES: RouteDefinition[] = ["GET", "POST"].map((method) => ({
19
19
  method,
20
20
  policy: { requiredScopes: [], allowedPrincipalTypes: GATEWAY_PRINCIPALS },
21
21
  handler: () => {
22
- if (!isAssistantDesktopEnabled(getConfig())) {
23
- throw new NotFoundError("Desktop is not available on this assistant");
22
+ if (!isVirtualDesktopEnabled(getConfig())) {
23
+ throw new NotFoundError(
24
+ "Virtual desktop is available only on enabled platform-hosted assistants",
25
+ );
24
26
  }
25
27
  return method === "GET"
26
28
  ? desktopDependencyInstaller.getStatus()
@@ -28,8 +30,8 @@ export const ROUTES: RouteDefinition[] = ["GET", "POST"].map((method) => ({
28
30
  },
29
31
  summary:
30
32
  method === "GET"
31
- ? "Get desktop setup status"
32
- : "Install desktop components",
33
+ ? "Get virtual desktop setup status"
34
+ : "Install virtual desktop components",
33
35
  tags: ["desktop"],
34
36
  responseBody: statusSchema,
35
37
  }));