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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/docs/desktop-browser-cli.md +33 -0
  2. package/openapi.yaml +17 -5
  3. package/package.json +1 -1
  4. package/scripts/smoke-desktop-browser-cli.ts +279 -0
  5. package/src/__tests__/config-schema.test.ts +1 -1
  6. package/src/__tests__/config-sounds-sync.test.ts +23 -0
  7. package/src/__tests__/conversation-error.test.ts +4 -1
  8. package/src/__tests__/conversation-runtime-assembly.test.ts +6 -4
  9. package/src/__tests__/external-plugin-loader.test.ts +52 -0
  10. package/src/__tests__/headless-browser-mode.test.ts +48 -0
  11. package/src/__tests__/host-cu-proxy.test.ts +116 -18
  12. package/src/__tests__/list-all-apps.test.ts +40 -8
  13. package/src/__tests__/mcp-auth-routes.test.ts +99 -0
  14. package/src/__tests__/mcp-client-auth.test.ts +54 -0
  15. package/src/__tests__/mcp-health-check.test.ts +32 -32
  16. package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
  17. package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
  18. package/src/__tests__/skills.test.ts +10 -0
  19. package/src/__tests__/user-plugin-loader.test.ts +21 -0
  20. package/src/acp/session-snapshot.ts +260 -0
  21. package/src/apps/app-store.ts +6 -5
  22. package/src/browser/operations.ts +2 -1
  23. package/src/browser/types.ts +7 -0
  24. package/src/browser/virtual-desktop-target.ts +34 -0
  25. package/src/calls/__tests__/progress-narration.test.ts +23 -0
  26. package/src/calls/__tests__/voice-control-protocol.test.ts +7 -0
  27. package/src/calls/__tests__/voice-session-bridge.test.ts +62 -0
  28. package/src/calls/progress-narration.ts +15 -2
  29. package/src/calls/voice-control-protocol.ts +34 -4
  30. package/src/calls/voice-session-bridge.ts +23 -0
  31. package/src/cli/commands/__tests__/browser.test.ts +84 -3
  32. package/src/cli/commands/browser.help.ts +43 -0
  33. package/src/cli/commands/browser.ts +52 -8
  34. package/src/cli/commands/plugins.ts +16 -5
  35. package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
  36. package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
  37. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
  38. package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
  39. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
  40. package/src/cli/lib/bundled-marketplace.json +13 -0
  41. package/src/cli/lib/install-from-github.ts +17 -18
  42. package/src/cli/lib/install-from-platform.ts +14 -9
  43. package/src/cli/lib/list-installed-plugins.ts +21 -35
  44. package/src/cli/lib/plugin-details.ts +21 -8
  45. package/src/cli/lib/uninstall-plugin.ts +59 -1
  46. package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
  47. package/src/config/bundled-skills/acp/SKILL.md +1 -1
  48. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  49. package/src/config/bundled-skills/computer-use/SKILL.md +30 -0
  50. package/src/config/bundled-skills/computer-use/TOOLS.json +2 -2
  51. package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
  52. package/src/config/feature-flag-registry.json +2 -2
  53. package/src/config/schemas/__tests__/voice.test.ts +2 -2
  54. package/src/config/schemas/mcp.ts +9 -7
  55. package/src/config/schemas/monitoring.ts +9 -0
  56. package/src/config/schemas/voice.ts +2 -2
  57. package/src/config/skills.ts +13 -21
  58. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
  59. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
  60. package/src/daemon/conversation-client-surface.ts +26 -0
  61. package/src/daemon/conversation-runtime-assembly.ts +1 -1
  62. package/src/daemon/conversation-tool-setup.ts +31 -29
  63. package/src/daemon/host-cu-proxy.ts +51 -26
  64. package/src/daemon/host-proxy-preactivation.ts +2 -0
  65. package/src/daemon/mcp-reload-service.ts +7 -0
  66. package/src/daemon/message-types/sync.ts +1 -0
  67. package/src/daemon/providers-setup.ts +4 -0
  68. package/src/desktop/__tests__/fake-desktop.ts +1 -0
  69. package/src/desktop/desktop-automation-lease.test.ts +208 -0
  70. package/src/desktop/desktop-automation-lease.ts +270 -0
  71. package/src/desktop/desktop-browser-client.test.ts +413 -0
  72. package/src/desktop/desktop-browser-client.ts +430 -0
  73. package/src/desktop/desktop-browser-cursor.ts +40 -0
  74. package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
  75. package/src/desktop/desktop-browser-endpoint.ts +145 -0
  76. package/src/desktop/desktop-browser-operations.ts +130 -0
  77. package/src/desktop/desktop-chrome-session.test.ts +48 -0
  78. package/src/desktop/desktop-chrome-session.ts +58 -8
  79. package/src/desktop/desktop-dependencies.test.ts +29 -0
  80. package/src/desktop/desktop-dependencies.ts +26 -5
  81. package/src/desktop/desktop-display.ts +9 -0
  82. package/src/desktop/desktop-panel-config.ts +14 -1
  83. package/src/desktop/desktop-session-manager.test.ts +86 -3
  84. package/src/desktop/desktop-session-manager.ts +144 -24
  85. package/src/desktop/desktop-stream-bridge.ts +1 -1
  86. package/src/desktop/virtual-desktop-feature.ts +18 -0
  87. package/src/desktop/virtual-desktop-platform.test.ts +61 -0
  88. package/src/i18n/__tests__/i18n.test.ts +9 -5
  89. package/src/i18n/messages.ts +13 -4
  90. package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
  91. package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
  92. package/src/ipc/assistant-server.ts +15 -1
  93. package/src/ipc/cli-client.ts +13 -5
  94. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +22 -0
  95. package/src/live-voice/__tests__/live-voice-events.test.ts +3 -2
  96. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
  97. package/src/live-voice/__tests__/protocol.test.ts +11 -3
  98. package/src/live-voice/__tests__/session-controls.test.ts +34 -0
  99. package/src/live-voice/live-voice-metrics.ts +9 -0
  100. package/src/live-voice/live-voice-session.ts +3 -0
  101. package/src/live-voice/protocol.ts +10 -1
  102. package/src/live-voice/session-controls.ts +28 -0
  103. package/src/mcp/__tests__/credential-target.test.ts +59 -0
  104. package/src/mcp/__tests__/effective-config.test.ts +11 -0
  105. package/src/mcp/__tests__/manager-state.test.ts +128 -0
  106. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
  107. package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
  108. package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
  109. package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
  110. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
  111. package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
  112. package/src/mcp/client.ts +64 -16
  113. package/src/mcp/credential-target.ts +105 -0
  114. package/src/mcp/effective-config.ts +42 -15
  115. package/src/mcp/manager.ts +61 -8
  116. package/src/mcp/mcp-auth-orchestrator.ts +17 -4
  117. package/src/mcp/mcp-auth-state.ts +38 -6
  118. package/src/mcp/mcp-oauth-provider.ts +101 -33
  119. package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
  120. package/src/monitoring/mount-watch.ts +348 -0
  121. package/src/monitoring/worker.ts +9 -0
  122. package/src/notifications/__tests__/decision-engine.test.ts +175 -0
  123. package/src/notifications/decision-engine.ts +43 -8
  124. package/src/oauth/seed-providers.ts +11 -0
  125. package/src/onboarding/checkin-event.ts +2 -1
  126. package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
  127. package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
  128. package/src/plugins/external-plugin-loader.ts +49 -86
  129. package/src/plugins/installed-plugin-dirs.ts +6 -4
  130. package/src/plugins/mcp-servers.ts +9 -15
  131. package/src/plugins/mtime-cache.ts +2 -1
  132. package/src/plugins/user-loader.ts +2 -1
  133. package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
  134. package/src/providers/inference/endpoint-probe.ts +27 -12
  135. package/src/providers/openai/responses-provider.ts +9 -2
  136. package/src/providers/opencode/client.test.ts +167 -0
  137. package/src/providers/opencode/client.ts +73 -4
  138. package/src/runtime/AGENTS.md +4 -0
  139. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
  140. package/src/runtime/http-server.ts +6 -6
  141. package/src/runtime/routes/__tests__/acp-routes.test.ts +19 -0
  142. package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
  143. package/src/runtime/routes/acp-routes.ts +17 -161
  144. package/src/runtime/routes/browser-context.ts +39 -0
  145. package/src/runtime/routes/browser-routes.ts +16 -47
  146. package/src/runtime/routes/browser-tabs-routes.ts +72 -29
  147. package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
  148. package/src/runtime/routes/desktop-setup-routes.ts +7 -5
  149. package/src/runtime/routes/mcp-auth-routes.ts +106 -94
  150. package/src/runtime/routes/plugins-routes.ts +14 -1
  151. package/src/runtime/sync/resource-sync-events.ts +4 -0
  152. package/src/tools/acp/status.test.ts +276 -21
  153. package/src/tools/acp/status.ts +98 -32
  154. package/src/tools/browser/browser-execution.ts +44 -13
  155. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
  156. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
  157. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
  158. package/src/tools/computer-use/definitions.ts +2 -3
  159. package/src/util/abort-reasons.ts +12 -1
  160. package/src/util/plugin-manifest.ts +203 -0
  161. package/src/desktop/desktop-feature.test.ts +0 -18
  162. package/src/desktop/desktop-feature.ts +0 -19
@@ -24,7 +24,13 @@ import {
24
24
  AcpResumeError,
25
25
  AcpSessionNotFoundError,
26
26
  } from "../../acp/session-manager.js";
27
- import { type AcpSessionState, isLiveAcpStatus } from "../../acp/types.js";
27
+ import {
28
+ type AcpSessionSnapshot,
29
+ listAcpSessionSnapshots,
30
+ snapshotHistoryRow,
31
+ withCurrentAuthMarkers,
32
+ } from "../../acp/session-snapshot.js";
33
+ import { isLiveAcpStatus } from "../../acp/types.js";
28
34
  import {
29
35
  AcpSessionModelUpdateEventSchema,
30
36
  type AssistantEvent,
@@ -103,11 +109,14 @@ type SessionEntry = z.infer<typeof sessionEntrySchema>;
103
109
  * client has no use for it and no way to resolve the other side of the
104
110
  * comparison, so serving it would only widen what leaves the daemon.
105
111
  */
106
- type MergedSession = SessionEntry & { authErrorCredential?: string };
112
+ type MergedSession = AcpSessionSnapshot;
107
113
 
108
- /** Drop the comparison's own input before the session goes out on the wire. */
114
+ /** Drop internal snapshot metadata before the session goes out on the wire. */
109
115
  function stripMarkerCredential({
110
- authErrorCredential: _dropped,
116
+ authErrorCredential: _credential,
117
+ source: _source,
118
+ resumable: _resumable,
119
+ cwd: _cwd,
111
120
  ...session
112
121
  }: MergedSession): SessionEntry {
113
122
  return session;
@@ -511,7 +520,7 @@ async function listSessions({ queryParams }: RouteHandlerArgs) {
511
520
  return resolvedByAgent.get(agentId);
512
521
  };
513
522
 
514
- const { sessions: merged, sawEveryHistoryRow } = listMergedSessions({
523
+ const { sessions: merged, sawEveryHistoryRow } = listAcpSessionSnapshots({
515
524
  limit,
516
525
  conversationId,
517
526
  });
@@ -552,43 +561,12 @@ async function listSessions({ queryParams }: RouteHandlerArgs) {
552
561
  return { sessions: [...page, stripMarkerCredential(marker)] };
553
562
  }
554
563
 
555
- /**
556
- * Blank the failure code on any session whose marker no longer describes the
557
- * credential its agent would resolve.
558
- *
559
- * This comparison is what retires a Connect card: not a sweep that has to run
560
- * at the right moment, but the marker no longer describing the credential in
561
- * use. Applied after merging rather than inside the query, so live sessions
562
- * and history rows are judged by exactly the same rule.
563
- *
564
- * Resolved per agent, because precedence is per agent: one alias can carry a
565
- * configured token while another falls through to the vault. Memoised across
566
- * the request, since a conversation's marked runs are nearly always one agent
567
- * and each resolution costs a vault read.
568
- */
569
564
  async function withCurrentMarkersOnly(
570
565
  sessions: MergedSession[],
571
566
  resolvedFor: (agentId: string) => Promise<string | undefined>,
572
567
  ): Promise<SessionEntry[]> {
573
- const strip = stripMarkerCredential;
574
- if (!sessions.some((s) => s.authErrorCode !== undefined)) {
575
- return sessions.map(strip);
576
- }
577
- const judged: SessionEntry[] = [];
578
- for (const session of sessions) {
579
- if (session.authErrorCode === undefined) {
580
- judged.push(strip(session));
581
- continue;
582
- }
583
- const current = acpAuthMarkerStillCurrent(
584
- session.authErrorCredential,
585
- await resolvedFor(session.agentId),
586
- );
587
- judged.push(
588
- strip(current ? session : { ...session, authErrorCode: undefined }),
589
- );
590
- }
591
- return judged;
568
+ const judged = await withCurrentAuthMarkers(sessions, resolvedFor);
569
+ return judged.map(stripMarkerCredential);
592
570
  }
593
571
 
594
572
  function bulkDeleteSessions({ queryParams }: RouteHandlerArgs) {
@@ -879,128 +857,6 @@ function parseLimit(raw: string | null | undefined): number {
879
857
  return Math.min(Math.floor(n), MAX_SESSION_LIMIT);
880
858
  }
881
859
 
882
- function listMergedSessions(opts: { limit: number; conversationId?: string }): {
883
- sessions: MergedSession[];
884
- /**
885
- * Whether the history query reached the end of this conversation's rows.
886
- *
887
- * A short read means there is nothing beyond what was returned, which is the
888
- * proof that no marker is hiding outside the page.
889
- */
890
- sawEveryHistoryRow: boolean;
891
- } {
892
- const manager = getAcpSessionManager();
893
- const inMemory = manager.getStatus() as AcpSessionState[];
894
-
895
- const merged = new Map<string, MergedSession>();
896
- for (const s of inMemory) {
897
- if (opts.conversationId && s.parentConversationId !== opts.conversationId) {
898
- continue;
899
- }
900
- merged.set(s.id, {
901
- id: s.id,
902
- agentId: s.agentId,
903
- acpSessionId: s.acpSessionId,
904
- parentConversationId: s.parentConversationId,
905
- status: s.status,
906
- startedAt: s.startedAt,
907
- completedAt: s.completedAt ?? null,
908
- error: s.error ?? null,
909
- stopReason: s.stopReason ?? null,
910
- task: s.task,
911
- parentToolUseId: s.parentToolUseId,
912
- authErrorCode: s.authErrorCode,
913
- authErrorCredential: s.authErrorCredential,
914
- model: s.model,
915
- availableModels: s.availableModels,
916
- modelRevisionEpoch: s.modelRevisionEpoch,
917
- modelRevision: s.modelRevision,
918
- usedTokens: s.latestUsage?.usedTokens,
919
- contextSize: s.latestUsage?.contextSize,
920
- costAmount: s.latestUsage?.costAmount,
921
- costCurrency: s.latestUsage?.costCurrency,
922
- inputTokens: s.latestUsage?.inputTokens,
923
- outputTokens: s.latestUsage?.outputTokens,
924
- eventLog: manager.getBufferedUpdates(s.id),
925
- });
926
- }
927
-
928
- const db = getDb();
929
- const baseQuery = db.select().from(acpSessionHistory);
930
- const filtered = opts.conversationId
931
- ? baseQuery.where(
932
- eq(acpSessionHistory.parentConversationId, opts.conversationId),
933
- )
934
- : baseQuery;
935
- // Fetch only enough rows to fill the requested page after merging with
936
- // in-memory sessions. In-memory entries take precedence on id collision,
937
- // so we pad by the count that survived the conversation filter to
938
- // guarantee we still surface `limit` distinct rows even when every
939
- // in-memory session shadows a DB row — without over-fetching when many
940
- // unrelated sessions are in memory.
941
- const historyLimit = opts.limit + merged.size;
942
- const historyRows = filtered
943
- .orderBy(desc(acpSessionHistory.startedAt))
944
- .limit(historyLimit)
945
- .all();
946
-
947
- for (const row of historyRows) {
948
- if (merged.has(row.id)) {
949
- continue;
950
- }
951
- merged.set(row.id, toMergedSession(row));
952
- }
953
-
954
- return {
955
- sessions: Array.from(merged.values()).sort(
956
- (a, b) => b.startedAt - a.startedAt,
957
- ),
958
- sawEveryHistoryRow: historyRows.length < historyLimit,
959
- };
960
- }
961
-
962
- /** Shape a history row for the response, parsing its stored event log. */
963
- function toMergedSession(
964
- row: typeof acpSessionHistory.$inferSelect,
965
- ): MergedSession {
966
- let eventLog: unknown[] = [];
967
- try {
968
- const parsed = JSON.parse(row.eventLogJson) as unknown;
969
- if (Array.isArray(parsed)) {
970
- eventLog = parsed;
971
- }
972
- } catch (err) {
973
- log.warn(
974
- { id: row.id, err },
975
- "Failed to parse event_log_json for ACP session history row",
976
- );
977
- }
978
- // Rows predating the usage migration carry NULLs for these columns and
979
- // degrade to undefined.
980
- return {
981
- id: row.id,
982
- agentId: row.agentId,
983
- acpSessionId: row.acpSessionId,
984
- parentConversationId: row.parentConversationId,
985
- status: row.status,
986
- startedAt: row.startedAt,
987
- completedAt: row.completedAt,
988
- error: row.error,
989
- stopReason: row.stopReason,
990
- task: row.task ?? undefined,
991
- parentToolUseId: row.parentToolUseId ?? undefined,
992
- authErrorCode: row.authErrorCode ?? undefined,
993
- authErrorCredential: row.authErrorCredential ?? undefined,
994
- usedTokens: row.usedTokens ?? undefined,
995
- contextSize: row.contextSize ?? undefined,
996
- costAmount: row.costAmount ?? undefined,
997
- costCurrency: row.costCurrency ?? undefined,
998
- inputTokens: row.inputTokens ?? undefined,
999
- outputTokens: row.outputTokens ?? undefined,
1000
- eventLog,
1001
- };
1002
- }
1003
-
1004
860
  /**
1005
861
  * Reach past the page for the one marked run a client would restore the card
1006
862
  * from, when the page itself holds none.
@@ -1061,7 +917,7 @@ async function findRecoveryMarker(
1061
917
  .from(acpSessionHistory)
1062
918
  .where(eq(acpSessionHistory.id, marker.id))
1063
919
  .get();
1064
- return row ? toMergedSession(row) : undefined;
920
+ return row ? snapshotHistoryRow(row) : undefined;
1065
921
  }
1066
922
  }
1067
923
  return undefined;
@@ -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({
@@ -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
  }));