@vellumai/assistant 0.12.2-staging.2 → 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 (153) 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/apps/app-store.ts +6 -5
  21. package/src/browser/operations.ts +2 -1
  22. package/src/browser/types.ts +7 -0
  23. package/src/browser/virtual-desktop-target.ts +34 -0
  24. package/src/calls/__tests__/progress-narration.test.ts +23 -0
  25. package/src/calls/__tests__/voice-control-protocol.test.ts +7 -0
  26. package/src/calls/__tests__/voice-session-bridge.test.ts +62 -0
  27. package/src/calls/progress-narration.ts +15 -2
  28. package/src/calls/voice-control-protocol.ts +34 -4
  29. package/src/calls/voice-session-bridge.ts +23 -0
  30. package/src/cli/commands/__tests__/browser.test.ts +84 -3
  31. package/src/cli/commands/browser.help.ts +43 -0
  32. package/src/cli/commands/browser.ts +52 -8
  33. package/src/cli/commands/plugins.ts +16 -5
  34. package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
  35. package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
  36. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
  37. package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
  38. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
  39. package/src/cli/lib/bundled-marketplace.json +13 -0
  40. package/src/cli/lib/install-from-github.ts +17 -18
  41. package/src/cli/lib/install-from-platform.ts +14 -9
  42. package/src/cli/lib/list-installed-plugins.ts +21 -35
  43. package/src/cli/lib/plugin-details.ts +21 -8
  44. package/src/cli/lib/uninstall-plugin.ts +59 -1
  45. package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
  46. package/src/config/bundled-skills/computer-use/SKILL.md +30 -0
  47. package/src/config/bundled-skills/computer-use/TOOLS.json +2 -2
  48. package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
  49. package/src/config/feature-flag-registry.json +2 -2
  50. package/src/config/schemas/__tests__/voice.test.ts +2 -2
  51. package/src/config/schemas/mcp.ts +9 -7
  52. package/src/config/schemas/monitoring.ts +9 -0
  53. package/src/config/schemas/voice.ts +2 -2
  54. package/src/config/skills.ts +13 -21
  55. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
  56. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
  57. package/src/daemon/conversation-client-surface.ts +26 -0
  58. package/src/daemon/conversation-runtime-assembly.ts +1 -1
  59. package/src/daemon/conversation-tool-setup.ts +31 -29
  60. package/src/daemon/host-cu-proxy.ts +51 -26
  61. package/src/daemon/host-proxy-preactivation.ts +2 -0
  62. package/src/daemon/mcp-reload-service.ts +7 -0
  63. package/src/daemon/message-types/sync.ts +1 -0
  64. package/src/daemon/providers-setup.ts +4 -0
  65. package/src/desktop/__tests__/fake-desktop.ts +1 -0
  66. package/src/desktop/desktop-automation-lease.test.ts +208 -0
  67. package/src/desktop/desktop-automation-lease.ts +270 -0
  68. package/src/desktop/desktop-browser-client.test.ts +413 -0
  69. package/src/desktop/desktop-browser-client.ts +430 -0
  70. package/src/desktop/desktop-browser-cursor.ts +40 -0
  71. package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
  72. package/src/desktop/desktop-browser-endpoint.ts +145 -0
  73. package/src/desktop/desktop-browser-operations.ts +130 -0
  74. package/src/desktop/desktop-chrome-session.test.ts +48 -0
  75. package/src/desktop/desktop-chrome-session.ts +58 -8
  76. package/src/desktop/desktop-dependencies.test.ts +29 -0
  77. package/src/desktop/desktop-dependencies.ts +26 -5
  78. package/src/desktop/desktop-display.ts +9 -0
  79. package/src/desktop/desktop-panel-config.ts +14 -1
  80. package/src/desktop/desktop-session-manager.test.ts +86 -3
  81. package/src/desktop/desktop-session-manager.ts +144 -24
  82. package/src/desktop/desktop-stream-bridge.ts +1 -1
  83. package/src/desktop/virtual-desktop-feature.ts +18 -0
  84. package/src/desktop/virtual-desktop-platform.test.ts +61 -0
  85. package/src/i18n/__tests__/i18n.test.ts +9 -5
  86. package/src/i18n/messages.ts +13 -4
  87. package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
  88. package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
  89. package/src/ipc/assistant-server.ts +15 -1
  90. package/src/ipc/cli-client.ts +13 -5
  91. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +22 -0
  92. package/src/live-voice/__tests__/live-voice-events.test.ts +3 -2
  93. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
  94. package/src/live-voice/__tests__/protocol.test.ts +11 -3
  95. package/src/live-voice/__tests__/session-controls.test.ts +34 -0
  96. package/src/live-voice/live-voice-metrics.ts +9 -0
  97. package/src/live-voice/live-voice-session.ts +3 -0
  98. package/src/live-voice/protocol.ts +10 -1
  99. package/src/live-voice/session-controls.ts +28 -0
  100. package/src/mcp/__tests__/credential-target.test.ts +59 -0
  101. package/src/mcp/__tests__/effective-config.test.ts +11 -0
  102. package/src/mcp/__tests__/manager-state.test.ts +128 -0
  103. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
  104. package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
  105. package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
  106. package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
  107. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
  108. package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
  109. package/src/mcp/client.ts +64 -16
  110. package/src/mcp/credential-target.ts +105 -0
  111. package/src/mcp/effective-config.ts +42 -15
  112. package/src/mcp/manager.ts +61 -8
  113. package/src/mcp/mcp-auth-orchestrator.ts +17 -4
  114. package/src/mcp/mcp-auth-state.ts +38 -6
  115. package/src/mcp/mcp-oauth-provider.ts +101 -33
  116. package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
  117. package/src/monitoring/mount-watch.ts +348 -0
  118. package/src/monitoring/worker.ts +9 -0
  119. package/src/oauth/seed-providers.ts +11 -0
  120. package/src/onboarding/checkin-event.ts +2 -1
  121. package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
  122. package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
  123. package/src/plugins/external-plugin-loader.ts +49 -86
  124. package/src/plugins/installed-plugin-dirs.ts +6 -4
  125. package/src/plugins/mcp-servers.ts +9 -15
  126. package/src/plugins/mtime-cache.ts +2 -1
  127. package/src/plugins/user-loader.ts +2 -1
  128. package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
  129. package/src/providers/inference/endpoint-probe.ts +27 -12
  130. package/src/providers/openai/responses-provider.ts +9 -2
  131. package/src/providers/opencode/client.test.ts +167 -0
  132. package/src/providers/opencode/client.ts +73 -4
  133. package/src/runtime/AGENTS.md +4 -0
  134. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
  135. package/src/runtime/http-server.ts +6 -6
  136. package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
  137. package/src/runtime/routes/browser-context.ts +39 -0
  138. package/src/runtime/routes/browser-routes.ts +16 -47
  139. package/src/runtime/routes/browser-tabs-routes.ts +72 -29
  140. package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
  141. package/src/runtime/routes/desktop-setup-routes.ts +7 -5
  142. package/src/runtime/routes/mcp-auth-routes.ts +106 -94
  143. package/src/runtime/routes/plugins-routes.ts +14 -1
  144. package/src/runtime/sync/resource-sync-events.ts +4 -0
  145. package/src/tools/browser/browser-execution.ts +44 -13
  146. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
  147. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
  148. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
  149. package/src/tools/computer-use/definitions.ts +2 -3
  150. package/src/util/abort-reasons.ts +12 -1
  151. package/src/util/plugin-manifest.ts +203 -0
  152. package/src/desktop/desktop-feature.test.ts +0 -18
  153. package/src/desktop/desktop-feature.ts +0 -19
@@ -9,18 +9,72 @@ import {
9
9
  EMPTY_ASSISTANT_TURN_PLACEHOLDER,
10
10
  OpenAIChatCompletionsProvider,
11
11
  } from "../openai/chat-completions-provider.js";
12
+ import { OpenAIResponsesProvider } from "../openai/responses-provider.js";
13
+ import type { Message } from "../types.js";
12
14
  import {
13
15
  buildOpenCodeRequestHeaders,
14
16
  OPENCODE_GO_BASE_URL,
15
17
  OPENCODE_REQUEST_HEADER,
18
+ OPENCODE_RESPONSES_ONLY_MODELS,
16
19
  OPENCODE_SESSION_HEADER,
17
20
  OPENCODE_ZEN_BASE_URL,
18
21
  OpenCodeProvider,
22
+ OpenCodeResponsesProvider,
19
23
  resetOpenCodeFallbackSessionForTests,
20
24
  resolveOpenCodeBaseURL,
21
25
  resolveOpenCodeRequestHeaders,
22
26
  } from "./client.js";
23
27
 
28
+ const USER_TURN: Message[] = [
29
+ { role: "user", content: [{ type: "text", text: "question" }] },
30
+ ];
31
+
32
+ /** Stub both wires on an OpenCodeProvider and record which one was called. */
33
+ function stubTransports(provider: OpenCodeProvider) {
34
+ const chat: unknown[] = [];
35
+ const responses: Array<{ params: unknown; options: unknown }> = [];
36
+ (provider as unknown as { client: unknown }).client = {
37
+ chat: {
38
+ completions: {
39
+ create: async (params: unknown) => {
40
+ chat.push(params);
41
+ return {
42
+ async *[Symbol.asyncIterator]() {
43
+ yield {
44
+ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }],
45
+ usage: { prompt_tokens: 2, completion_tokens: 1 },
46
+ };
47
+ },
48
+ };
49
+ },
50
+ },
51
+ },
52
+ };
53
+ const inner = (
54
+ provider as unknown as { getResponsesInner(): OpenCodeResponsesProvider }
55
+ ).getResponsesInner();
56
+ (inner as unknown as { client: unknown }).client = {
57
+ responses: {
58
+ create: async (params: unknown, options: unknown) => {
59
+ responses.push({ params, options });
60
+ return {
61
+ async *[Symbol.asyncIterator]() {
62
+ yield { type: "response.output_text.delta", delta: "ok" };
63
+ yield {
64
+ type: "response.completed",
65
+ response: {
66
+ status: "completed",
67
+ usage: { input_tokens: 2, output_tokens: 1 },
68
+ },
69
+ };
70
+ },
71
+ };
72
+ },
73
+ },
74
+ };
75
+ return { chat, responses };
76
+ }
77
+
24
78
  describe("resolveOpenCodeBaseURL", () => {
25
79
  test("defaults to OpenCode Zen", () => {
26
80
  expect(resolveOpenCodeBaseURL()).toBe(OPENCODE_ZEN_BASE_URL);
@@ -241,3 +295,116 @@ describe("OpenCodeProvider", () => {
241
295
  expect(assistantMsg.content).toBe(EMPTY_ASSISTANT_TURN_PLACEHOLDER);
242
296
  });
243
297
  });
298
+
299
+ describe("OPENCODE_RESPONSES_ONLY_MODELS", () => {
300
+ test("lists the verified responses-only models and nothing else", () => {
301
+ expect([...OPENCODE_RESPONSES_ONLY_MODELS].sort()).toEqual([
302
+ "muse-spark-1.2-contributor-free",
303
+ "muse-spark-1.3-contributor-free",
304
+ ]);
305
+ });
306
+ });
307
+
308
+ describe("OpenCodeResponsesProvider", () => {
309
+ test("is the Responses transport under the opencode name on OpenCode Zen", () => {
310
+ const provider = new OpenCodeResponsesProvider(
311
+ "sk-test",
312
+ "muse-spark-1.2-contributor-free",
313
+ );
314
+ expect(provider).toBeInstanceOf(OpenAIResponsesProvider);
315
+ expect(provider.name).toBe("opencode");
316
+ expect(
317
+ (provider as unknown as { client: { baseURL?: string } }).client.baseURL,
318
+ ).toBe(OPENCODE_ZEN_BASE_URL);
319
+ });
320
+
321
+ test("keeps a configured OpenCode Go origin", () => {
322
+ const provider = new OpenCodeResponsesProvider(
323
+ "sk-test",
324
+ "muse-spark-1.2-contributor-free",
325
+ { baseURL: OPENCODE_GO_BASE_URL },
326
+ );
327
+ expect(
328
+ (provider as unknown as { client: { baseURL?: string } }).client.baseURL,
329
+ ).toBe(OPENCODE_GO_BASE_URL);
330
+ });
331
+ });
332
+
333
+ describe("OpenCodeProvider transport selection", () => {
334
+ beforeEach(() => {
335
+ mockDeviceId = null;
336
+ resetOpenCodeFallbackSessionForTests();
337
+ });
338
+
339
+ test("sends a responses-only model through the Responses API with the caller's OpenCode headers", async () => {
340
+ const provider = new OpenCodeProvider(
341
+ "sk-test",
342
+ "muse-spark-1.2-contributor-free",
343
+ );
344
+ const { chat, responses } = stubTransports(provider);
345
+
346
+ const result = await provider.sendMessage(USER_TURN, {
347
+ config: {
348
+ requestHeaders: {
349
+ [OPENCODE_SESSION_HEADER]: "conv-xyz",
350
+ [OPENCODE_REQUEST_HEADER]: "req-123",
351
+ },
352
+ },
353
+ });
354
+
355
+ expect(chat).toHaveLength(0);
356
+ expect(responses).toHaveLength(1);
357
+ expect((responses[0]!.params as { model: string }).model).toBe(
358
+ "muse-spark-1.2-contributor-free",
359
+ );
360
+ const options = responses[0]!.options as {
361
+ headers?: Record<string, string>;
362
+ };
363
+ expect(options.headers?.[OPENCODE_SESSION_HEADER]).toBe("conv-xyz");
364
+ expect(options.headers?.[OPENCODE_REQUEST_HEADER]).toBe("req-123");
365
+ expect(options.headers).not.toHaveProperty("session_id");
366
+ expect(result.content).toEqual([{ type: "text", text: "ok" }]);
367
+ });
368
+
369
+ test("stamps the session backstop on a configless Responses call", async () => {
370
+ mockDeviceId = "device-abc";
371
+ const provider = new OpenCodeProvider(
372
+ "sk-test",
373
+ "muse-spark-1.2-contributor-free",
374
+ );
375
+ const { responses } = stubTransports(provider);
376
+
377
+ await provider.sendMessage(USER_TURN);
378
+
379
+ const options = responses[0]!.options as {
380
+ headers?: Record<string, string>;
381
+ };
382
+ expect(options.headers?.[OPENCODE_SESSION_HEADER]).toBe("device-abc");
383
+ expect(options.headers?.[OPENCODE_REQUEST_HEADER]).toBeTruthy();
384
+ });
385
+
386
+ test("keeps a chat-completions model on the chat wire", async () => {
387
+ const provider = new OpenCodeProvider("sk-test", "mimo-v2.5-free");
388
+ const { chat, responses } = stubTransports(provider);
389
+
390
+ await provider.sendMessage(USER_TURN);
391
+
392
+ expect(chat).toHaveLength(1);
393
+ expect(responses).toHaveLength(0);
394
+ });
395
+
396
+ test("a per-call model override picks the transport that model needs", async () => {
397
+ const provider = new OpenCodeProvider("sk-test", "mimo-v2.5-free");
398
+ const { chat, responses } = stubTransports(provider);
399
+
400
+ await provider.sendMessage(USER_TURN, {
401
+ config: { model: "muse-spark-1.2-contributor-free" },
402
+ });
403
+
404
+ expect(chat).toHaveLength(0);
405
+ expect(responses).toHaveLength(1);
406
+ expect((responses[0]!.params as { model: string }).model).toBe(
407
+ "muse-spark-1.2-contributor-free",
408
+ );
409
+ });
410
+ });
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
 
3
3
  import { getExistingDeviceId } from "../../util/device-id.js";
4
4
  import { OpenAIChatCompletionsProvider } from "../openai/chat-completions-provider.js";
5
+ import { OpenAIResponsesProvider } from "../openai/responses-provider.js";
5
6
  import type {
6
7
  Message,
7
8
  ProviderResponse,
@@ -18,6 +19,18 @@ export interface OpenCodeProviderOptions {
18
19
  streamTimeoutMs?: number;
19
20
  }
20
21
 
22
+ /**
23
+ * OpenCode Zen models served only by the Responses API: Zen answers
24
+ * `/chat/completions` for them with HTTP 500 and completes the same request
25
+ * on `/responses`. OpenCode's own client picks the transport per model from
26
+ * its catalog's `api.endpoint`; the assistant has no such feed, so the
27
+ * verified ids live here. Everything else stays on chat completions.
28
+ */
29
+ export const OPENCODE_RESPONSES_ONLY_MODELS: ReadonlySet<string> = new Set([
30
+ "muse-spark-1.2-contributor-free",
31
+ "muse-spark-1.3-contributor-free",
32
+ ]);
33
+
21
34
  /**
22
35
  * Resolve the OpenCode chat-completions origin. A stored connection URL
23
36
  * wins; otherwise the request goes to OpenCode Zen.
@@ -82,7 +95,38 @@ export function resolveOpenCodeRequestHeaders(
82
95
  });
83
96
  }
84
97
 
98
+ /**
99
+ * Responses-API transport for OpenCode. Shares the chat client's origin
100
+ * resolution and provider name, so the same `x-opencode-session` /
101
+ * `x-opencode-request` headers reach its requests.
102
+ */
103
+ export class OpenCodeResponsesProvider extends OpenAIResponsesProvider {
104
+ constructor(
105
+ apiKey: string,
106
+ model: string,
107
+ options: OpenCodeProviderOptions = {},
108
+ ) {
109
+ super(apiKey, model, {
110
+ baseURL: resolveOpenCodeBaseURL(options.baseURL),
111
+ providerName: "opencode",
112
+ providerLabel: "OpenCode",
113
+ streamTimeoutMs: options.streamTimeoutMs,
114
+ });
115
+ }
116
+ }
117
+
118
+ /**
119
+ * OpenCode adapter. Chat completions is the default wire; models in
120
+ * {@link OPENCODE_RESPONSES_ONLY_MODELS} are delegated to an
121
+ * {@link OpenCodeResponsesProvider} on the same origin. The choice is made
122
+ * per send from the effective model, so a per-call model override lands on
123
+ * the transport that model needs.
124
+ */
85
125
  export class OpenCodeProvider extends OpenAIChatCompletionsProvider {
126
+ private readonly openCodeApiKey: string;
127
+ private readonly openCodeOptions: OpenCodeProviderOptions;
128
+ private responsesInner: OpenCodeResponsesProvider | undefined;
129
+
86
130
  constructor(
87
131
  apiKey: string,
88
132
  model: string,
@@ -96,20 +140,22 @@ export class OpenCodeProvider extends OpenAIChatCompletionsProvider {
96
140
  assistantReasoningField: "reasoning_content",
97
141
  omitToolChoiceWhenReasoning: true,
98
142
  });
143
+ this.openCodeApiKey = apiKey;
144
+ this.openCodeOptions = options;
99
145
  }
100
146
 
101
147
  /**
102
148
  * Transport-level backstop so a call that reaches this provider without
103
149
  * going through `RetryProvider` (or with no config at all) still carries
104
- * the session and request headers zen/go requires. Headers the caller
105
- * already resolved (conversation-derived) win.
150
+ * the session and request headers zen/go requires on either wire. Headers
151
+ * the caller already resolved (conversation-derived) win.
106
152
  */
107
153
  override async sendMessage(
108
154
  messages: Message[],
109
155
  options?: SendMessageOptions,
110
156
  ): Promise<ProviderResponse> {
111
157
  const config = options?.config ?? {};
112
- return super.sendMessage(messages, {
158
+ const stamped: SendMessageOptions = {
113
159
  ...options,
114
160
  config: {
115
161
  ...config,
@@ -118,6 +164,29 @@ export class OpenCodeProvider extends OpenAIChatCompletionsProvider {
118
164
  ...config.requestHeaders,
119
165
  },
120
166
  },
121
- });
167
+ };
168
+ if (
169
+ OPENCODE_RESPONSES_ONLY_MODELS.has(this.resolveEffectiveModel(config))
170
+ ) {
171
+ return this.getResponsesInner().sendMessage(messages, stamped);
172
+ }
173
+ return super.sendMessage(messages, stamped);
174
+ }
175
+
176
+ private resolveEffectiveModel(config: Record<string, unknown>): string {
177
+ const override =
178
+ typeof config.model === "string" && config.model.trim().length > 0
179
+ ? config.model.trim()
180
+ : undefined;
181
+ return override ?? this.defaultModel;
182
+ }
183
+
184
+ private getResponsesInner(): OpenCodeResponsesProvider {
185
+ this.responsesInner ??= new OpenCodeResponsesProvider(
186
+ this.openCodeApiKey,
187
+ this.defaultModel,
188
+ this.openCodeOptions,
189
+ );
190
+ return this.responsesInner;
122
191
  }
123
192
  }
@@ -161,6 +161,10 @@ In the CDP factory the bridge is the internal `"host-bridge"` candidate kind (`I
161
161
 
162
162
  **After the first successful CDP command**, the selected backend becomes **sticky** for the remainder of the tool invocation. Subsequent commands always route through the same backend so multi-command tool flows do not hop transports mid-step.
163
163
 
164
+ ### Browser CLI surface defaults
165
+
166
+ The browser execute and tab routes share `browser/virtual-desktop-target.ts`. Platform-hosted web guardian conversations use installed, enabled virtual desktop Chrome by default. The shared `isVirtualDesktopEnabled` gate requires both `IS_PLATFORM` and `IS_CONTAINERIZED` alongside the `assistant-desktop` flag. Native renderer turns also use the `web` transport, so the frozen turn `clientOs` excludes native apps from automatic streamed-browser selection. Native apps retain their existing backend selection and fallback behavior. Explicit desktop/backend/client targets and existing personal-browser sessions override the surface default. Disabled or uninstalled desktop support retains the existing browser path; selection never triggers installation.
167
+
164
168
  ### Per-tool `browser_mode` override
165
169
 
166
170
  All CDP-backed browser tools (`browser_navigate`, `browser_snapshot`, `browser_screenshot`, `browser_click`, `browser_type`, `browser_hover`, `browser_scroll`, `browser_press_key`, `browser_select_option`, `browser_wait_for`, `browser_extract`, `browser_fill_credential`, `browser_attach`, `browser_detach`, `browser_close`, `browser_status`) accept an optional `browser_mode` input parameter that overrides the automatic backend selection for that invocation.
@@ -15,6 +15,7 @@ describe("RuntimeHttpServer /v1/desktop/stream upgrade", () => {
15
15
  let baseUrl: string;
16
16
  let restoreAuthEnv: () => void;
17
17
  const originalContainerized = process.env.IS_CONTAINERIZED;
18
+ const originalPlatform = process.env.IS_PLATFORM;
18
19
 
19
20
  beforeEach(async () => {
20
21
  restoreAuthEnv = requireHttpAuth();
@@ -22,6 +23,7 @@ describe("RuntimeHttpServer /v1/desktop/stream upgrade", () => {
22
23
  server = new RuntimeHttpServer({ port, hostname: "127.0.0.1" });
23
24
  await server.start();
24
25
  baseUrl = `127.0.0.1:${server.actualPort}`;
26
+ process.env.IS_PLATFORM = "true";
25
27
  });
26
28
 
27
29
  afterEach(async () => {
@@ -32,6 +34,11 @@ describe("RuntimeHttpServer /v1/desktop/stream upgrade", () => {
32
34
  } else {
33
35
  process.env.IS_CONTAINERIZED = originalContainerized;
34
36
  }
37
+ if (originalPlatform === undefined) {
38
+ delete process.env.IS_PLATFORM;
39
+ } else {
40
+ process.env.IS_PLATFORM = originalPlatform;
41
+ }
35
42
  setOverridesForTesting({});
36
43
  });
37
44
 
@@ -70,7 +77,9 @@ describe("RuntimeHttpServer /v1/desktop/stream upgrade", () => {
70
77
  );
71
78
  const closed = await waitForClose(ws);
72
79
  expect(closed.code).toBe(4008);
73
- expect(closed.reason).toBe("Desktop is not available on this assistant");
80
+ expect(closed.reason).toBe(
81
+ "Virtual desktop is available only on enabled platform-hosted assistants",
82
+ );
74
83
  });
75
84
  for (const flag of [false, undefined]) {
76
85
  test(`refuses a containerized stream with a disabled or missing flag (${flag})`, async () => {
@@ -83,7 +92,41 @@ describe("RuntimeHttpServer /v1/desktop/stream upgrade", () => {
83
92
  );
84
93
  const closed = await waitForClose(ws);
85
94
  expect(closed.code).toBe(4008);
86
- expect(closed.reason).toBe("Desktop is not available on this assistant");
95
+ expect(closed.reason).toBe(
96
+ "Virtual desktop is available only on enabled platform-hosted assistants",
97
+ );
87
98
  });
88
99
  }
89
100
  });
101
+
102
+ test("self-hosted containers cannot stream with the feature flag enabled", async () => {
103
+ const originalPlatform = process.env.IS_PLATFORM;
104
+ const originalContainerized = process.env.IS_CONTAINERIZED;
105
+ const restoreAuth = requireHttpAuth();
106
+ const server = new RuntimeHttpServer({ port: 0, hostname: "127.0.0.1" });
107
+ try {
108
+ await server.start();
109
+ process.env.IS_PLATFORM = "false";
110
+ process.env.IS_CONTAINERIZED = "true";
111
+ setOverridesForTesting({ "assistant-desktop": true });
112
+ const baseUrl = `127.0.0.1:${server.actualPort}`;
113
+ const ws = new WebSocket(
114
+ `ws://${baseUrl}/v1/desktop/stream?token=${encodeURIComponent(mintGatewayToken())}`,
115
+ );
116
+ expect((await waitForClose(ws)).code).toBe(4008);
117
+ } finally {
118
+ await server.stop();
119
+ restoreAuth();
120
+ if (originalPlatform === undefined) {
121
+ delete process.env.IS_PLATFORM;
122
+ } else {
123
+ process.env.IS_PLATFORM = originalPlatform;
124
+ }
125
+ if (originalContainerized === undefined) {
126
+ delete process.env.IS_CONTAINERIZED;
127
+ } else {
128
+ process.env.IS_CONTAINERIZED = originalContainerized;
129
+ }
130
+ setOverridesForTesting({});
131
+ }
132
+ });
@@ -32,12 +32,12 @@ import {
32
32
  } from "../daemon/daemon-readiness.js";
33
33
  import { processMessage } from "../daemon/process-message.js";
34
34
  import { makeAddrInUseError } from "../daemon/startup-error.js";
35
- import { isAssistantDesktopEnabled } from "../desktop/desktop-feature.js";
36
35
  import {
37
36
  DESKTOP_CLOSE,
38
37
  destroyDesktopSessionManager,
39
38
  } from "../desktop/desktop-session-manager.js";
40
39
  import { DesktopStreamBridge } from "../desktop/desktop-stream-bridge.js";
40
+ import { isVirtualDesktopEnabled } from "../desktop/virtual-desktop-feature.js";
41
41
  import {
42
42
  createLiveVoiceConnection,
43
43
  type LiveVoiceConnection,
@@ -252,9 +252,9 @@ type AllWebSocketData =
252
252
  | WatchStreamWebSocketData
253
253
  | DesktopStreamWebSocketData;
254
254
 
255
- function assistantDesktopEnabled(): boolean {
255
+ function virtualDesktopEnabled(): boolean {
256
256
  try {
257
- return isAssistantDesktopEnabled(getConfig());
257
+ return isVirtualDesktopEnabled(getConfig());
258
258
  } catch (err) {
259
259
  log.warn({ err }, "Failed to read config for desktop stream gate");
260
260
  return false;
@@ -453,15 +453,15 @@ export class RuntimeHttpServer {
453
453
  }
454
454
  if (data.wsType === "desktop-stream") {
455
455
  log.info("Desktop stream WebSocket opened");
456
- if (!assistantDesktopEnabled()) {
456
+ if (!virtualDesktopEnabled()) {
457
457
  ws.close(
458
458
  DESKTOP_CLOSE.unavailable,
459
- "Desktop is not available on this assistant",
459
+ "Virtual desktop is available only on enabled platform-hosted assistants",
460
460
  );
461
461
  return;
462
462
  }
463
463
  const bridge = new DesktopStreamBridge(ws, {
464
- isEnabled: assistantDesktopEnabled,
464
+ isEnabled: virtualDesktopEnabled,
465
465
  });
466
466
  data.bridge = bridge;
467
467
  void bridge.start();
@@ -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");
@@ -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