@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
@@ -17,7 +17,12 @@ import { z } from "zod";
17
17
  import type { McpServerConfig } from "../../config/schemas/mcp.js";
18
18
  import { estimateToolDefinitionTokens } from "../../context/token-estimator.js";
19
19
  import { reloadMcpServers } from "../../daemon/mcp-reload-service.js";
20
- import { McpClient } from "../../mcp/client.js";
20
+ import {
21
+ mcpOAuthCredentialKey,
22
+ resolveMcpOAuthCredentialTarget,
23
+ workspaceMcpOAuthCredentialTarget,
24
+ } from "../../mcp/credential-target.js";
25
+ import { readEffectiveMcpConfig } from "../../mcp/effective-config.js";
21
26
  import { getMcpServerManager } from "../../mcp/manager.js";
22
27
  import { orchestrateMcpOAuthConnect } from "../../mcp/mcp-auth-orchestrator.js";
23
28
  import { getMcpAuthState } from "../../mcp/mcp-auth-state.js";
@@ -74,9 +79,7 @@ function persistWorkspaceMcpConfig(config: {
74
79
  try {
75
80
  saveWorkspaceMcpConfig(config);
76
81
  } catch (err) {
77
- throw new InternalError(
78
- err instanceof Error ? err.message : String(err),
79
- );
82
+ throw new InternalError(err instanceof Error ? err.message : String(err));
80
83
  }
81
84
  }
82
85
 
@@ -91,8 +94,9 @@ async function handleMcpAuthStart({
91
94
  }> {
92
95
  const { serverId } = parseBody(McpServerIdParams, body);
93
96
 
94
- const servers = loadWorkspaceMcpConfig().servers;
95
- const serverConfig = servers[serverId];
97
+ const serverConfig = readEffectiveMcpConfig(loadWorkspaceMcpConfig()).servers[
98
+ serverId
99
+ ];
96
100
 
97
101
  if (!serverConfig) {
98
102
  throw new BadRequestError(`MCP server "${serverId}" not configured`);
@@ -105,10 +109,29 @@ async function handleMcpAuthStart({
105
109
  );
106
110
  }
107
111
 
112
+ const credentialTarget = resolveMcpOAuthCredentialTarget(
113
+ serverId,
114
+ serverConfig,
115
+ );
116
+ if (!credentialTarget) {
117
+ throw new BadRequestError(
118
+ `OAuth only supported for sse/streamable-http transports (server "${serverId}" uses ${transport.type})`,
119
+ );
120
+ }
121
+
122
+ try {
123
+ mcpOAuthCredentialKey(credentialTarget, "tokens");
124
+ } catch {
125
+ throw new BadRequestError(
126
+ `MCP server "${serverId}" has an invalid transport URL`,
127
+ );
128
+ }
129
+
108
130
  let result: { auth_url: string; already_authenticated?: boolean };
109
131
  try {
110
132
  result = await orchestrateMcpOAuthConnect({
111
133
  serverId,
134
+ credentialTarget,
112
135
  transport: {
113
136
  url: transport.url,
114
137
  type: transport.type,
@@ -135,7 +158,19 @@ function handleMcpAuthStatus({
135
158
  | { status: "complete" }
136
159
  | { status: "error"; error: string } {
137
160
  const { serverId } = pathParams as { serverId: string };
138
- const state = getMcpAuthState(serverId);
161
+ const serverConfig = readEffectiveMcpConfig(loadWorkspaceMcpConfig()).servers[
162
+ serverId
163
+ ];
164
+ const target = serverConfig
165
+ ? resolveMcpOAuthCredentialTarget(serverId, serverConfig)
166
+ : null;
167
+ let targetKey: string | undefined;
168
+ try {
169
+ targetKey = target ? mcpOAuthCredentialKey(target, "tokens") : undefined;
170
+ } catch {
171
+ targetKey = undefined;
172
+ }
173
+ const state = targetKey ? getMcpAuthState(serverId, targetKey) : null;
139
174
 
140
175
  if (state === null) {
141
176
  throw new NotFoundError(`No active OAuth flow for server "${serverId}"`);
@@ -170,53 +205,6 @@ function handleMcpReload(_args: { body?: Record<string, unknown> }): {
170
205
  return { ok: true };
171
206
  }
172
207
 
173
- // ---------------------------------------------------------------------------
174
- // Health check helper
175
- // ---------------------------------------------------------------------------
176
-
177
- const HEALTH_CHECK_TIMEOUT_MS = 10_000;
178
-
179
- async function checkMachineReadableHealth(
180
- serverId: string,
181
- config: McpServerConfig,
182
- timeoutMs = HEALTH_CHECK_TIMEOUT_MS,
183
- ): Promise<string> {
184
- const client = new McpClient(serverId);
185
- try {
186
- await Promise.race([
187
- client.connect(config.transport),
188
- new Promise<never>((_, reject) => {
189
- const t = setTimeout(() => reject(new Error("timeout")), timeoutMs);
190
- if (typeof t === "object" && "unref" in t) {
191
- t.unref();
192
- }
193
- }),
194
- ]);
195
-
196
- if (client.isConnected) {
197
- await client.disconnect();
198
- return "connected";
199
- }
200
-
201
- const err = client.lastError;
202
- if (err) {
203
- if (err.message.includes("timeout")) {
204
- return "error";
205
- }
206
- return "error";
207
- }
208
-
209
- return "needs-auth";
210
- } catch {
211
- try {
212
- await client.disconnect();
213
- } catch {
214
- /* ignore */
215
- }
216
- return "error";
217
- }
218
- }
219
-
220
208
  // ---------------------------------------------------------------------------
221
209
  // List
222
210
  // ---------------------------------------------------------------------------
@@ -271,12 +259,19 @@ async function handleMcpList(_args: {
271
259
  const configEntries = (
272
260
  Object.entries(servers) as [string, McpServerConfig][]
273
261
  ).filter(([, config]) => config && typeof config === "object");
262
+ const manager = getMcpServerManager();
274
263
 
275
264
  const workspaceEntries: McpServerEntry[] = await Promise.all(
276
265
  configEntries.map(async ([id, config]) => {
277
- const status = await checkMachineReadableHealth(id, config);
266
+ const runtimeState = manager.getServerState(id, "workspace");
267
+ const status =
268
+ runtimeState === "connected" || runtimeState === "needs-auth"
269
+ ? runtimeState
270
+ : "error";
278
271
  const hasOAuth =
279
- config.transport.type !== "stdio" ? await hasMcpOAuthTokens(id) : false;
272
+ config.transport.type !== "stdio"
273
+ ? await hasMcpOAuthTokens(workspaceMcpOAuthCredentialTarget(id))
274
+ : false;
280
275
 
281
276
  // Check credential store for stored static auth headers
282
277
  const storedHeaders = await getMcpHeaders(id);
@@ -316,21 +311,23 @@ async function handleMcpList(_args: {
316
311
  );
317
312
 
318
313
  return {
319
- servers: [...workspaceEntries, ...listPluginServerEntries(configEntries)],
314
+ servers: [
315
+ ...workspaceEntries,
316
+ ...(await listPluginServerEntries(configEntries)),
317
+ ],
320
318
  };
321
319
  }
322
320
 
323
321
  /**
324
322
  * Project plugin-declared servers onto listing entries.
325
323
  *
326
- * Nothing here touches the credential store or the network: a plugin
327
- * server carries no auth state the assistant owns, and its status comes
328
- * from the manager's in-memory client rather than a probe (see
329
- * {@link PLUGIN_SERVER_STATUS}).
324
+ * OAuth presence is read from the plugin's scoped credential identity. Static
325
+ * workspace headers are never consulted, no network probe runs, and status
326
+ * comes from the manager's in-memory client (see {@link PLUGIN_SERVER_STATUS}).
330
327
  */
331
- function listPluginServerEntries(
328
+ async function listPluginServerEntries(
332
329
  configEntries: [string, McpServerConfig][],
333
- ): McpServerEntry[] {
330
+ ): Promise<McpServerEntry[]> {
334
331
  const { servers, issues } = readPluginMcpServers();
335
332
  for (const issue of issues) {
336
333
  log.warn(
@@ -349,36 +346,51 @@ function listPluginServerEntries(
349
346
  const workspaceIds = new Set(configEntries.map(([id]) => id));
350
347
  const manager = getMcpServerManager();
351
348
 
352
- return servers
353
- .filter((server) => {
354
- if (!workspaceIds.has(server.id)) {
355
- return true;
356
- }
357
- log.warn(
358
- { plugin: server.pluginName, serverId: server.id },
359
- "Plugin MCP server shadowed by a workspace server of the same id; skipping",
360
- );
361
- return false;
362
- })
363
- .map((server) => {
364
- const { headers: _stripped, ...safeTransport } = server.config
365
- .transport as Record<string, unknown>;
366
- return {
367
- id: server.id,
368
- status: manager.getClient(server.id)?.isConnected
369
- ? "connected"
370
- : PLUGIN_SERVER_STATUS,
371
- transport: safeTransport as McpServerEntry["transport"],
372
- // A plugin server has no assistant-owned credentials. Resolving
373
- // these against `mcp:<id>:*` would report, and could disclose, a
374
- // workspace credential that happens to share the id.
375
- hasOAuth: false,
376
- hasStaticAuth: false,
377
- authType: "none" as const,
378
- source: "plugin" as const,
379
- pluginName: server.pluginName,
380
- };
381
- });
349
+ return Promise.all(
350
+ servers
351
+ .filter((server) => {
352
+ if (!workspaceIds.has(server.id)) {
353
+ return true;
354
+ }
355
+ log.warn(
356
+ { plugin: server.pluginName, serverId: server.id },
357
+ "Plugin MCP server shadowed by a workspace server of the same id; skipping",
358
+ );
359
+ return false;
360
+ })
361
+ .map(async (server) => {
362
+ const { headers: _stripped, ...safeTransport } = server.config
363
+ .transport as Record<string, unknown>;
364
+ let hasOAuth = false;
365
+ const credentialTarget = resolveMcpOAuthCredentialTarget(
366
+ server.id,
367
+ server.config,
368
+ );
369
+ if (credentialTarget) {
370
+ try {
371
+ hasOAuth = await hasMcpOAuthTokens(credentialTarget);
372
+ } catch (err) {
373
+ log.warn(
374
+ { err, plugin: server.pluginName, serverId: server.id },
375
+ "Plugin MCP OAuth credential identity could not be resolved",
376
+ );
377
+ }
378
+ }
379
+ return {
380
+ id: server.id,
381
+ status:
382
+ manager.getServerState(server.id, "plugin") === "connected"
383
+ ? "connected"
384
+ : PLUGIN_SERVER_STATUS,
385
+ transport: safeTransport as McpServerEntry["transport"],
386
+ hasOAuth,
387
+ hasStaticAuth: false,
388
+ authType: "none" as const,
389
+ source: "plugin" as const,
390
+ pluginName: server.pluginName,
391
+ };
392
+ }),
393
+ );
382
394
  }
383
395
 
384
396
  // ---------------------------------------------------------------------------
@@ -687,7 +699,7 @@ export const ROUTES: RouteDefinition[] = [
687
699
  },
688
700
  summary: "List MCP servers with health status",
689
701
  description:
690
- "Returns configured MCP servers with live health-check results (connected, needs auth, error).",
702
+ "Returns configured MCP servers with recorded runtime status without opening new connections.",
691
703
  tags: ["internal"],
692
704
  responseBody: z.object({
693
705
  servers: z.array(
@@ -66,6 +66,7 @@ interface PlatformConnectionEntry {
66
66
  id: string;
67
67
  account_label?: string;
68
68
  scopes_granted?: string[];
69
+ provider_params?: Record<string, string> | null;
69
70
  status?: string;
70
71
  }
71
72
 
@@ -574,6 +575,9 @@ async function handleStatus({ queryParams = {} }: RouteHandlerArgs) {
574
575
  account: c.account_label ?? null,
575
576
  grantedScopes: c.scopes_granted ?? [],
576
577
  status: c.status ?? "ACTIVE",
578
+ // Values the provider scopes the connection by (QuickBooks' realm id),
579
+ // so a caller can address resources the proxy's base URL does not.
580
+ providerParams: c.provider_params ?? {},
577
581
  }));
578
582
 
579
583
  return {
@@ -1060,6 +1064,8 @@ async function handleManagedConnect({ body = {} }: RouteHandlerArgs) {
1060
1064
  provider: string;
1061
1065
  scopes?: string[];
1062
1066
  redirect_after_connect?: string;
1067
+ /** Per-tenant providers (Shopify): the customer's own host. */
1068
+ tenant_host?: string;
1063
1069
  };
1064
1070
 
1065
1071
  if (!b.provider) {
@@ -1076,6 +1082,13 @@ async function handleManagedConnect({ body = {} }: RouteHandlerArgs) {
1076
1082
  }
1077
1083
  reqBody.redirect_after_connect =
1078
1084
  b.redirect_after_connect ?? "/account/oauth/complete";
1085
+ // Only forwarded when present: the platform validates it against the
1086
+ // provider's pattern and rejects per-tenant providers that omit it.
1087
+ const tenantHost =
1088
+ typeof b.tenant_host === "string" ? b.tenant_host.trim() : "";
1089
+ if (tenantHost) {
1090
+ reqBody.tenant_host = tenantHost;
1091
+ }
1079
1092
 
1080
1093
  const response = await client.fetch(startPath, {
1081
1094
  method: "POST",
@@ -1122,6 +1135,7 @@ async function handleManagedConnectPoll({
1122
1135
  id: e.id,
1123
1136
  account_label: e.account_label ?? null,
1124
1137
  scopes_granted: e.scopes_granted ?? [],
1138
+ provider_params: e.provider_params ?? {},
1125
1139
  })),
1126
1140
  };
1127
1141
  }
@@ -397,6 +397,13 @@ export const oauthProviderSummarySchema = z.object({
397
397
  supports_managed_mode: z.boolean(),
398
398
  managed_service_is_paid: z.boolean(),
399
399
  feature_flag: z.string().nullable(),
400
+ tenant_host: z
401
+ .object({
402
+ pattern: z.string(),
403
+ label: z.string(),
404
+ placeholder: z.string(),
405
+ })
406
+ .nullable(),
400
407
  acts_as: z.enum(["user", "assistant"]),
401
408
  });
402
409
 
@@ -80,6 +80,7 @@ import {
80
80
  PluginDirectoryNotFoundError,
81
81
  } from "../../cli/lib/toggle-plugin.js";
82
82
  import {
83
+ PLUGIN_UNINSTALL_WARNING_KEYS,
83
84
  PluginNotInstalledError,
84
85
  uninstallPlugin,
85
86
  } from "../../cli/lib/uninstall-plugin.js";
@@ -255,6 +256,14 @@ const pluginUninstallResponseSchema = z.object({
255
256
  .describe(
256
257
  "Absolute path that was removed on the assistant host. Useful for audit logs and confirmation toasts.",
257
258
  ),
259
+ warnings: z
260
+ .array(
261
+ z.literal(PLUGIN_UNINSTALL_WARNING_KEYS.MCP_OAUTH_CREDENTIALS_UNCHECKED),
262
+ )
263
+ .optional()
264
+ .describe(
265
+ "Stable keys for non-fatal cleanup limitations that should be localized at the presentation edge.",
266
+ ),
258
267
  });
259
268
 
260
269
  const pluginDetailsResponseSchema = z.object({
@@ -1134,7 +1143,11 @@ async function handleUninstallPlugin({
1134
1143
  const result = await uninstallPlugin({ name: rawName });
1135
1144
  await reconcilePluginSourcesNow();
1136
1145
  publishPluginsChanged(getOriginClientId(headers));
1137
- return { name: result.name, target: result.target };
1146
+ return {
1147
+ name: result.name,
1148
+ target: result.target,
1149
+ ...(result.warnings && { warnings: result.warnings }),
1150
+ };
1138
1151
  } catch (err) {
1139
1152
  if (err instanceof InvalidPluginNameError) {
1140
1153
  throw new BadRequestError(err.message);
@@ -152,6 +152,10 @@ export function publishPluginsChanged(originClientId?: string): void {
152
152
  void publishSyncInvalidation([SYNC_TAGS.pluginsList], originClientId);
153
153
  }
154
154
 
155
+ export function publishMcpChanged(): void {
156
+ void publishSyncInvalidation([SYNC_TAGS.mcpList]);
157
+ }
158
+
155
159
  /**
156
160
  * Invalidate the activation-checklist progress resource on every client.
157
161
  *
@@ -1,4 +1,5 @@
1
1
  import { optimizeImageForTransport } from "../../agent/image-optimize.js";
2
+ import type { BrowserOperationContext as ToolContext } from "../../browser/types.js";
2
3
  import { getConfig } from "../../config/loader.js";
3
4
  import { HostBrowserProxy } from "../../daemon/host-browser-proxy.js";
4
5
  import type { ImageContent } from "../../providers/types.js";
@@ -21,7 +22,7 @@ import {
21
22
  sanitizeUrlForOutput,
22
23
  sanitizeUrlStringForOutput,
23
24
  } from "../network/url-safety.js";
24
- import type { ToolContext, ToolExecutionResult } from "../types.js";
25
+ import type { ToolExecutionResult } from "../types.js";
25
26
  import {
26
27
  type AuthChallenge,
27
28
  detectAuthChallenge,
@@ -515,6 +516,9 @@ async function acquireCdpClientWithMode(
515
516
  }
516
517
  | { cdp?: never; browserMode?: never; errorResult: ToolExecutionResult }
517
518
  > {
519
+ if (context.cdpClient) {
520
+ return { cdp: context.cdpClient, browserMode: "cdp-inspect" };
521
+ }
518
522
  const modeResult = parseBrowserMode(input);
519
523
  if (!modeResult.ok) {
520
524
  return {
@@ -746,6 +750,17 @@ export type ResolvedElement =
746
750
  | { kind: "backend"; backendNodeId: number; eid: string }
747
751
  | { kind: "selector"; selector: string };
748
752
 
753
+ function snapshotConversationId(context: ToolContext): string {
754
+ return context.cdpClient?.conversationId ?? context.conversationId;
755
+ }
756
+
757
+ function clearBrowserSessionState(context: ToolContext): void {
758
+ browserManager.clearSnapshotBackendNodeMap(snapshotConversationId(context));
759
+ if (!context.cdpClient) {
760
+ browserManager.clearPreferredBackendKind(context.conversationId);
761
+ }
762
+ }
763
+
749
764
  /**
750
765
  * Resolve an element reference (either `element_id` from a prior
751
766
  * snapshot or a raw `selector`) for CDP-native tools. Returns a
@@ -872,6 +887,9 @@ export async function executeBrowserNavigate(
872
887
  typeof input.target_client_id === "string" && input.target_client_id !== ""
873
888
  ? input.target_client_id
874
889
  : undefined;
890
+ if (context.cdpClient && forceNewTab) {
891
+ await cdp.send("Vellum.createTab", {}, context.signal);
892
+ }
875
893
  if (cdp.kind === "extension" && useActiveTab) {
876
894
  // Explicit opt-out: target the currently-active tab. Clear any
877
895
  // conversation pin and reset the live session so this navigate is
@@ -1188,7 +1206,7 @@ export async function executeBrowserNavigate(
1188
1206
  // Navigation changed the page content, so clear stale snapshot
1189
1207
  // mappings regardless of backend. The backendNodeId map is shared
1190
1208
  // per-conversation state that needs to be invalidated on any nav.
1191
- browserManager.clearSnapshotBackendNodeMap(context.conversationId);
1209
+ browserManager.clearSnapshotBackendNodeMap(snapshotConversationId(context));
1192
1210
 
1193
1211
  // Auto-dismiss common blocker modals (regulatory notices, cookie
1194
1212
  // banners) that aren't exposed in the accessibility tree. Runs
@@ -1398,7 +1416,7 @@ export async function executeBrowserSnapshot(
1398
1416
  const { elements, selectorMap: backendNodeMap } = transformAxTree(rawTree);
1399
1417
 
1400
1418
  browserManager.storeSnapshotBackendNodeMap(
1401
- context.conversationId,
1419
+ snapshotConversationId(context),
1402
1420
  backendNodeMap,
1403
1421
  );
1404
1422
 
@@ -1600,8 +1618,7 @@ export async function executeBrowserDetach(
1600
1618
  // Vellum.detach round-trip failed (target gone, transport dropped).
1601
1619
  // browser_detach is the user's recovery path — leaving a stale
1602
1620
  // sticky backend or snapshot map behind would defeat its purpose.
1603
- browserManager.clearSnapshotBackendNodeMap(context.conversationId);
1604
- browserManager.clearPreferredBackendKind(context.conversationId);
1621
+ clearBrowserSessionState(context);
1605
1622
  cdp.dispose();
1606
1623
  }
1607
1624
  }
@@ -1653,8 +1670,7 @@ export async function executeBrowserClose(
1653
1670
  // Tolerate detach failures (already detached, tab closed, etc.)
1654
1671
  }
1655
1672
  }
1656
- browserManager.clearSnapshotBackendNodeMap(context.conversationId);
1657
- browserManager.clearPreferredBackendKind(context.conversationId);
1673
+ clearBrowserSessionState(context);
1658
1674
  return {
1659
1675
  content:
1660
1676
  "Browser session cleared. (Your Chrome tab was not closed — close it yourself if desired.)",
@@ -1683,7 +1699,10 @@ export async function executeBrowserClick(
1683
1699
  input: Record<string, unknown>,
1684
1700
  context: ToolContext,
1685
1701
  ): Promise<ToolExecutionResult> {
1686
- const { resolved, error } = resolveElement(context.conversationId, input);
1702
+ const { resolved, error } = resolveElement(
1703
+ snapshotConversationId(context),
1704
+ input,
1705
+ );
1687
1706
  if (error) {
1688
1707
  return { content: error, isError: true };
1689
1708
  }
@@ -1796,7 +1815,10 @@ export async function executeBrowserType(
1796
1815
  input: Record<string, unknown>,
1797
1816
  context: ToolContext,
1798
1817
  ): Promise<ToolExecutionResult> {
1799
- const { resolved, error } = resolveElement(context.conversationId, input);
1818
+ const { resolved, error } = resolveElement(
1819
+ snapshotConversationId(context),
1820
+ input,
1821
+ );
1800
1822
  if (error) {
1801
1823
  return { content: error, isError: true };
1802
1824
  }
@@ -1887,7 +1909,7 @@ export async function executeBrowserPressKey(
1887
1909
  let targetDescription: string | null = null;
1888
1910
  let resolved: ResolvedElement | null = null;
1889
1911
  if (hasTarget) {
1890
- const res = resolveElement(context.conversationId, input);
1912
+ const res = resolveElement(snapshotConversationId(context), input);
1891
1913
  if (res.error) {
1892
1914
  return { content: res.error, isError: true };
1893
1915
  }
@@ -2025,7 +2047,10 @@ export async function executeBrowserSelectOption(
2025
2047
  input: Record<string, unknown>,
2026
2048
  context: ToolContext,
2027
2049
  ): Promise<ToolExecutionResult> {
2028
- const { resolved, error } = resolveElement(context.conversationId, input);
2050
+ const { resolved, error } = resolveElement(
2051
+ snapshotConversationId(context),
2052
+ input,
2053
+ );
2029
2054
  if (error) {
2030
2055
  return { content: error, isError: true };
2031
2056
  }
@@ -2162,7 +2187,10 @@ export async function executeBrowserHover(
2162
2187
  input: Record<string, unknown>,
2163
2188
  context: ToolContext,
2164
2189
  ): Promise<ToolExecutionResult> {
2165
- const { resolved, error } = resolveElement(context.conversationId, input);
2190
+ const { resolved, error } = resolveElement(
2191
+ snapshotConversationId(context),
2192
+ input,
2193
+ );
2166
2194
  if (error) {
2167
2195
  return { content: error, isError: true };
2168
2196
  }
@@ -2410,7 +2438,10 @@ export async function executeBrowserFillCredential(
2410
2438
  return { content: "Error: field is required.", isError: true };
2411
2439
  }
2412
2440
 
2413
- const { resolved, error } = resolveElement(context.conversationId, input);
2441
+ const { resolved, error } = resolveElement(
2442
+ snapshotConversationId(context),
2443
+ input,
2444
+ );
2414
2445
  if (error) {
2415
2446
  return { content: error, isError: true };
2416
2447
  }
@@ -2804,3 +2804,72 @@ describe("macOS host-browser proxy without extension registry", () => {
2804
2804
  expect(result).toEqual({ ok: true, via: "extension" });
2805
2805
  });
2806
2806
  });
2807
+
2808
+ describe("native renderer browser compatibility", () => {
2809
+ beforeEach(() => {
2810
+ setCdpInspectEnabled(false);
2811
+ setDesktopAutoConfig({ enabled: true, cooldownMs: 30_000 });
2812
+ _resetDesktopAutoCooldown();
2813
+ _resetHostBridgeCooldown();
2814
+ mockSingletonProxy = null;
2815
+ });
2816
+ test.each(["macos", "windows"] as const)(
2817
+ "%s web renderer sends CDP to the connected host bridge",
2818
+ async (clientOs) => {
2819
+ mockSingletonProxy = makeMacosBridgeOnlyProxy();
2820
+ const client = getCdpClient(
2821
+ makeContext({
2822
+ conversationId: "conv-native",
2823
+ transportInterface: "web",
2824
+ clientOs,
2825
+ }),
2826
+ );
2827
+ try {
2828
+ expect(await client.send("Page.getFrameTree", {})).toMatchObject({
2829
+ via: "host-bridge",
2830
+ });
2831
+ } finally {
2832
+ client.dispose();
2833
+ }
2834
+ },
2835
+ );
2836
+
2837
+ test.each(["macos", "windows"] as const)(
2838
+ "%s web renderer keeps the existing Playwright fallback without a bridge",
2839
+ async (clientOs) => {
2840
+ const client = getCdpClient(
2841
+ makeContext({
2842
+ conversationId: "conv-native",
2843
+ transportInterface: "web",
2844
+ clientOs,
2845
+ }),
2846
+ );
2847
+ try {
2848
+ expect(await client.send("Page.getFrameTree", {})).toMatchObject({
2849
+ via: "local",
2850
+ });
2851
+ } finally {
2852
+ client.dispose();
2853
+ }
2854
+ },
2855
+ );
2856
+
2857
+ test("explicit Playwright choice overrides the native browser default", async () => {
2858
+ mockSingletonProxy = makeMacosBridgeOnlyProxy();
2859
+ const client = getCdpClient(
2860
+ makeContext({
2861
+ conversationId: "conv-native",
2862
+ transportInterface: "web",
2863
+ clientOs: "windows",
2864
+ }),
2865
+ { mode: "local" },
2866
+ );
2867
+ try {
2868
+ expect(await client.send("Page.getFrameTree", {})).toMatchObject({
2869
+ via: "local",
2870
+ });
2871
+ } finally {
2872
+ client.dispose();
2873
+ }
2874
+ });
2875
+ });
@@ -417,6 +417,7 @@ describe("connectCdpWsTransport", () => {
417
417
  }
418
418
  expect(caught).toBeInstanceOf(CdpWsTransportError);
419
419
  expect((caught as CdpWsTransportError).code).toBe("closed");
420
+ expect(transport.closed).toBe(true);
420
421
 
421
422
  // After close, subsequent sends also reject with closed.
422
423
  let caught2: unknown;
@@ -99,6 +99,8 @@ export interface CdpTransportEvent {
99
99
  * CDP error mapping to the shared `CdpError` taxonomy.
100
100
  */
101
101
  export interface CdpWsTransport {
102
+ readonly closed?: boolean;
103
+
102
104
  /**
103
105
  * Send a CDP method call over the socket and await its response.
104
106
  *
@@ -464,6 +466,9 @@ function createTransport(ws: WsLike): CdpWsTransport {
464
466
  ws.addEventListener("error", handleError);
465
467
 
466
468
  const transport: CdpWsTransport = {
469
+ get closed() {
470
+ return disposed || closed;
471
+ },
467
472
  send<T = unknown>(
468
473
  method: string,
469
474
  params?: Record<string, unknown>,
@@ -352,7 +352,7 @@ export const computerUseOpenAppTool = {
352
352
  export const computerUseRunAppleScriptTool = {
353
353
  name: "computer_use_run_applescript",
354
354
  description:
355
- "Run an AppleScript command. Prefer this over click/type when possible - it doesn't move the cursor or interrupt foreground activity. Never use 'do shell script' inside AppleScript (blocked for security).",
355
+ "Run an AppleScript on the Mac. Try the target app's own scripting dictionary first, asking it for the state you want rather than for the clicks that would produce it; fall back to System Events menu clicking for apps with no dictionary entry for what you need. Prefer this over click and type when the app supports it; it does not move the cursor. The result is the script's return value, and the accessibility tree may not change even when the script worked. Never use 'do shell script' inside AppleScript (blocked for security).",
356
356
  category: "computer-use",
357
357
  defaultRiskLevel: RiskLevel.Low,
358
358
  executionTarget: "host",
@@ -367,8 +367,7 @@ export const computerUseRunAppleScriptTool = {
367
367
  },
368
368
  reasoning: {
369
369
  type: "string",
370
- description:
371
- "Explanation of what this script does and why AppleScript is better than UI interaction for this step",
370
+ description: "What this script does",
372
371
  },
373
372
  target_client_id: {
374
373
  type: "string",
@@ -569,7 +568,7 @@ export const computerUseObserveTool = {
569
568
  include_screenshot: {
570
569
  type: "boolean",
571
570
  description:
572
- "Force a screenshot with this observation. The accessibility tree is returned every step; ask for pixels whenever the tree is not enough to act on, such as a canvas, a game, a custom-drawn view, a window with few or unlabeled controls, or a question about layout.",
571
+ "Force a screenshot with this observation. The accessibility tree is returned every step and actions always return a screenshot of their result; ask for pixels on an observation whenever the tree is not enough to act on, such as a canvas, a game, a custom-drawn view, a window with few or unlabeled controls, or a question about layout.",
573
572
  },
574
573
  full_tree: {
575
574
  type: "boolean",