@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
@@ -0,0 +1,33 @@
1
+ # Virtual desktop browser CLI
2
+
3
+ `assistant browser --virtual-desktop` uses the browser CLI's shared operation handlers against the Chrome process owned by `DesktopSessionManager`. It requires a platform-hosted container, the `assistant-desktop` flag, completed automatic desktop installation and an identified guardian conversation. Availability is checked before starting or reusing control and after asynchronous startup; loss of the flag or installation readiness cancels active control. The first eligible browser command waits for the shared installation job, starts Chrome, and executes the original action exactly once. Cancellation and feature revocation stop the pending action while shared installation can finish for other viewers. Browser CLI requests allow ten minutes, with an eight-minute setup wait limit. Eligible web conversations receive browser guidance in the bash tool description, including the configured shell timeout; native clients retain their existing tool description. Opening the picture-in-picture panel also starts setup and shows progress automatically; failed installs retain a retry action. The persistent profile remains `data/desktop-profile`; no data migration or extension installation is required.
4
+
5
+ Automatic browser selection in platform-hosted web conversations uses the streamed Chrome when the desktop flag is enabled and the actor is an identified guardian. The macOS and Windows apps keep their existing browser selection and fallback behavior; they use the streamed desktop only with explicit `--virtual-desktop`. Their shared renderer reports a `web` transport, so selection also reads the active turn's frozen `clientOs`; it does not change transport identity or host capability checks. Explicit `--virtual-desktop`, `--browser-mode`, `--target-client-id` and active-tab requests override the default. Existing personal-browser sessions and tab pins remain on their selected browser. Tab commands use the same streamed-browser default as page commands. `--browser-mode local` selects assistant-side Playwright, not the user's Chrome.
6
+
7
+ The legacy `--desktop` option remains an alias. The deployment flag key `assistant-desktop`, API paths, sync tags and persistent profile path are unchanged for compatibility. The shared `isVirtualDesktopEnabled` gate rejects self-hosted assistants, including local Docker, for setup, streaming and control.
8
+
9
+ ```mermaid
10
+ flowchart LR
11
+ CLI[assistant browser --virtual-desktop] --> IPC[Existing browser IPC routes]
12
+ IPC --> Lease[DesktopAutomationLease conversation and actor lease]
13
+ Lease --> Shared[Shared browser operation handlers]
14
+ Shared --> CDP[Scoped desktop CDP client]
15
+ CDP --> Chrome[Managed Chrome on display :99]
16
+ Chrome --> Stream[Existing desktop stream]
17
+ ```
18
+
19
+ Chrome and its dock launcher share the managed profile and loopback debug port. For Chrome reopened from the dock, the profile singleton lock identifies a candidate PID, validated against the installed executable, profile and loopback debug arguments. Discovery checks `/proc` socket ownership against that browser PID, refuses redirects and validates the returned browser WebSocket endpoint. The connection stays within the container. No CDP endpoint or token is exposed to the renderer.
20
+
21
+ The desktop client bypasses personal-browser discovery, extension reconnect waits and backend fallback. It reuses the existing AX snapshot, DOM element resolution, mouse, keyboard, extraction and credential-fill implementations. Operation-scoped clients borrow the lease's connection; disposing one does not release the lease. `detach`, `close`, cancellation, errors and idle expiry release control. Only `tabs close` closes a Chrome tab.
22
+
23
+ `assistant browser --virtual-desktop screenshot --output /tmp/desktop-page.jpg` captures a color page image through CDP `Page.captureScreenshot`. It excludes the browser toolbar and desktop dock. `snapshot` returns semantic page structure. Agents should use these interfaces and let the manager start the desktop, including when older memory notes describe manual Xvnc, `xdotool` or scratch XWD conversion scripts.
24
+
25
+ Tab IDs are ephemeral numeric aliases for this managed browser's CDP target IDs. They are never personal extension tab IDs. Initial attachment selects an existing HTTP(S) page or opens a blank tab. Use `tabs list` and `tabs select --tab-id <id>` to choose explicitly. Navigation, tab selection, document replacement and release clear the desktop snapshot map. Personal-browser snapshots use a separate namespace.
26
+
27
+ CDP mouse events use page viewport CSS coordinates. Before dispatching them, the client animates a purple arrow overlay to the same point. The overlay is excluded from accessibility and hit testing. It appears in the existing desktop stream and is removed on release. It does not move the OS pointer, appear in browser toolbar UI or visualize every programmatic DOM operation. Native dialogs and other applications are outside the browser CLI; users can interact with them directly in the expanded desktop. The picture-in-picture preview is view-only. Direct user interaction does not pause automation.
28
+
29
+ The client records key and mouse presses before dispatch. On release it opens a fresh bounded cleanup connection, attaches to the same live targets, releases uncertain held input and removes overlays. A failed cleanup preserves state and the automation slot for retry. Dispatched actions are never automatically retried. Closed targets need no input cleanup. Browser-process loss disposes the client, and later requests discover the replacement process.
30
+
31
+ `--use-active-tab` and personal browser targeting are rejected with `--virtual-desktop`. Download waiting is unsupported. Browser operations are bounded to two minutes and share the desktop lease's action budget and idle expiry.
32
+
33
+ Validation: focused client tests exercise shared snapshot/click behavior, namespace isolation, stale references, target changes, cancellation and uncertain-input cleanup. Lease tests cover browser ownership, cancellation and cleanup independently of native input. The Linux smoke script exercises real Chrome, the CLI and visible pointer feedback.
@@ -113,7 +113,7 @@ export async function buildSlackUserLabelMap(
113
113
  // that label directly; only queue a lookup when the label is missing,
114
114
  // empty, or ID-shaped. Mirrors the channel builder below.
115
115
  const [, embeddedLabel] = splitSlackLabel(match[0].slice(1, -1));
116
- const sanitizedEmbeddedLabel = sanitizeOptionalLabel(embeddedLabel);
116
+ const sanitizedEmbeddedLabel = sanitizeEmbeddedSlackLabel(embeddedLabel);
117
117
  if (sanitizedEmbeddedLabel && sanitizedEmbeddedLabel !== id) continue;
118
118
  if (seen.has(id)) continue;
119
119
  seen.add(id);
@@ -151,7 +151,7 @@ export async function buildSlackChannelLabelMap(
151
151
  for (const match of text.matchAll(SLACK_CHANNEL_REFERENCE_RE)) {
152
152
  const id = match[1];
153
153
  const [, embeddedLabel] = splitSlackLabel(match[0].slice(1, -1));
154
- const sanitizedEmbeddedLabel = sanitizeOptionalLabel(embeddedLabel);
154
+ const sanitizedEmbeddedLabel = sanitizeEmbeddedSlackLabel(embeddedLabel);
155
155
  if (sanitizedEmbeddedLabel && sanitizedEmbeddedLabel !== id) continue;
156
156
  if (!seen.has(id)) {
157
157
  seen.add(id);
@@ -192,9 +192,7 @@ function renderUserMention(
192
192
  // prefer it over a lookup, mirroring renderChannelReference. The embedded
193
193
  // label is Slack-sourced (part of the token), so entities decode before
194
194
  // sanitization; the caller-resolved label below is not.
195
- const embeddedLabel = sanitizeOptionalLabel(
196
- label === undefined ? undefined : decodeSlackHtmlEntities(label),
197
- );
195
+ const embeddedLabel = sanitizeEmbeddedSlackLabel(label);
198
196
  if (embeddedLabel && embeddedLabel !== id) {
199
197
  return `@${embeddedLabel}`;
200
198
  }
@@ -219,9 +217,7 @@ function renderChannelReference(
219
217
  );
220
218
  // The embedded label is Slack-sourced (part of the token), so entities
221
219
  // decode before sanitization; the caller-resolved label below is not.
222
- const embeddedLabel = sanitizeOptionalLabel(
223
- label === undefined ? undefined : decodeSlackHtmlEntities(label),
224
- );
220
+ const embeddedLabel = sanitizeEmbeddedSlackLabel(label);
225
221
  if (embeddedLabel && embeddedLabel !== channelId) {
226
222
  return `#${embeddedLabel}`;
227
223
  }
@@ -320,6 +316,15 @@ export function sanitizeSlackLabel(
320
316
  return sanitized || undefined;
321
317
  }
322
318
 
319
+ // Slack-sourced labels are decoded before validation in both lookup and render.
320
+ function sanitizeEmbeddedSlackLabel(
321
+ label: string | undefined,
322
+ ): string | undefined {
323
+ return sanitizeOptionalLabel(
324
+ label === undefined ? undefined : decodeSlackHtmlEntities(label),
325
+ );
326
+ }
327
+
323
328
  function sanitizeOptionalLabel(label: string | undefined): string | undefined {
324
329
  return sanitizeSlackLabel(label);
325
330
  }
@@ -0,0 +1,95 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "bun:test";
3
+ import {
4
+ buildSlackChannelLabelMap,
5
+ buildSlackUserLabelMap,
6
+ renderSlackTextForModel,
7
+ } from "./index.js";
8
+
9
+ describe("Slack embedded-label lookup parity", () => {
10
+ const cases = [
11
+ { id: "U123", prefix: "@", build: buildSlackUserLabelMap, mapKey: "userLabels" },
12
+ {
13
+ id: "C123",
14
+ prefix: "#",
15
+ build: buildSlackChannelLabelMap,
16
+ mapKey: "channelLabels",
17
+ },
18
+ ] as const;
19
+
20
+ for (const { id, prefix, build, mapKey } of cases) {
21
+ for (const label of ["&lt;&gt;", `&lt;${id}&gt;`, "&lt; @# &gt;"]) {
22
+ test(`${prefix} resolves label ${label} after Slack entity decoding`, async () => {
23
+ const text = `<${prefix}${id}|${label}>`;
24
+ const requested: string[] = [];
25
+ const labels = await build([text], async (value) => {
26
+ requested.push(value);
27
+ return "Example Name";
28
+ });
29
+ assert.deepEqual(requested, [id]);
30
+ assert.equal(
31
+ renderSlackTextForModel(text, { [mapKey]: labels }),
32
+ `${prefix}Example Name`,
33
+ );
34
+ });
35
+ }
36
+
37
+ for (const label of ["&lt;Example&gt;", "R&amp;D", "&amp;lt;&amp;gt;"]) {
38
+ test(`${prefix} keeps usable embedded label ${label} without lookup`, async () => {
39
+ const requested: string[] = [];
40
+ const text = `<${prefix}${id}|${label}>`;
41
+ const labels = await build([text], async (value) => {
42
+ requested.push(value);
43
+ return "Wrong Label";
44
+ });
45
+ assert.deepEqual(requested, []);
46
+ assert.deepEqual(labels, {});
47
+ const expected =
48
+ label === "&lt;Example&gt;"
49
+ ? "Example"
50
+ : label === "R&amp;D"
51
+ ? "R&D"
52
+ : "&lt;&gt;";
53
+ assert.equal(
54
+ renderSlackTextForModel(text, { [mapKey]: labels }),
55
+ `${prefix}${expected}`,
56
+ );
57
+ });
58
+ }
59
+
60
+ test(`${prefix} deduplicates required lookups across encoded and bare mentions`, async () => {
61
+ const requested: string[] = [];
62
+ const labels = await build(
63
+ [`<${prefix}${id}|&lt;&gt;>`, `<${prefix}${id}>`],
64
+ async (value) => {
65
+ requested.push(value);
66
+ return "Example";
67
+ },
68
+ );
69
+ assert.deepEqual(requested, [id]);
70
+ assert.deepEqual(labels, { [id]: "Example" });
71
+ });
72
+
73
+ test(`${prefix} does not decode caller-resolved entity text`, async () => {
74
+ const text = `<${prefix}${id}|&lt;&gt;>`;
75
+ const labels = await build([text], async () => "&lt;Example&gt;");
76
+ assert.equal(
77
+ renderSlackTextForModel(text, { [mapKey]: labels }),
78
+ `${prefix}&lt;Example&gt;`,
79
+ );
80
+ });
81
+
82
+ test(`${prefix} preserves unknown fallback after a failed required lookup`, async () => {
83
+ let attempts = 0;
84
+ const text = `<${prefix}${id}|&lt;&gt;>`;
85
+ const labels = await build([text], async () => {
86
+ attempts++;
87
+ throw new Error("unavailable");
88
+ });
89
+ assert.equal(attempts, 1);
90
+ assert.deepEqual(labels, {});
91
+ const expected = prefix === "@" ? "@unknown-user" : "#unknown-channel";
92
+ assert.equal(renderSlackTextForModel(text, { [mapKey]: labels }), expected);
93
+ });
94
+ }
95
+ });
package/openapi.yaml CHANGED
@@ -4146,6 +4146,8 @@ paths:
4146
4146
  conversationId:
4147
4147
  type: string
4148
4148
  minLength: 1
4149
+ desktop:
4150
+ type: boolean
4149
4151
  required:
4150
4152
  - operation
4151
4153
  responses:
@@ -4181,7 +4183,7 @@ paths:
4181
4183
  post:
4182
4184
  operationId: browser_tabs_post
4183
4185
  summary: Manage browser tabs
4184
- description: List, create, select, or close browser tabs via the Chrome extension backend.
4186
+ description: List, create, select, or close browser tabs in the Chrome extension or streamed desktop browser.
4185
4187
  tags:
4186
4188
  - browser
4187
4189
  requestBody:
@@ -4205,6 +4207,10 @@ paths:
4205
4207
  conversationId:
4206
4208
  type: string
4207
4209
  minLength: 1
4210
+ desktop:
4211
+ type: boolean
4212
+ browserMode:
4213
+ type: string
4208
4214
  tabId:
4209
4215
  type: number
4210
4216
  url:
@@ -11916,7 +11922,7 @@ paths:
11916
11922
  /v1/desktop/setup:
11917
11923
  get:
11918
11924
  operationId: desktop_setup_get
11919
- summary: Get desktop setup status
11925
+ summary: Get virtual desktop setup status
11920
11926
  tags:
11921
11927
  - desktop
11922
11928
  responses:
@@ -11946,7 +11952,7 @@ paths:
11946
11952
  additionalProperties: false
11947
11953
  post:
11948
11954
  operationId: desktop_setup_post
11949
- summary: Install desktop components
11955
+ summary: Install virtual desktop components
11950
11956
  tags:
11951
11957
  - desktop
11952
11958
  responses:
@@ -18426,7 +18432,7 @@ paths:
18426
18432
  get:
18427
18433
  operationId: internal_mcp_list_get
18428
18434
  summary: List MCP servers with health status
18429
- description: Returns configured MCP servers with live health-check results (connected, needs auth, error).
18435
+ description: Returns configured MCP servers with recorded runtime status without opening new connections.
18430
18436
  tags:
18431
18437
  - internal
18432
18438
  responses:
@@ -24481,6 +24487,22 @@ paths:
24481
24487
  anyOf:
24482
24488
  - type: string
24483
24489
  - type: "null"
24490
+ tenant_host:
24491
+ anyOf:
24492
+ - type: object
24493
+ properties:
24494
+ pattern:
24495
+ type: string
24496
+ label:
24497
+ type: string
24498
+ placeholder:
24499
+ type: string
24500
+ required:
24501
+ - pattern
24502
+ - label
24503
+ - placeholder
24504
+ additionalProperties: false
24505
+ - type: "null"
24484
24506
  acts_as:
24485
24507
  type: string
24486
24508
  enum:
@@ -24497,6 +24519,7 @@ paths:
24497
24519
  - supports_managed_mode
24498
24520
  - managed_service_is_paid
24499
24521
  - feature_flag
24522
+ - tenant_host
24500
24523
  - acts_as
24501
24524
  additionalProperties: false
24502
24525
  - type: "null"
@@ -25015,6 +25038,22 @@ paths:
25015
25038
  anyOf:
25016
25039
  - type: string
25017
25040
  - type: "null"
25041
+ tenant_host:
25042
+ anyOf:
25043
+ - type: object
25044
+ properties:
25045
+ pattern:
25046
+ type: string
25047
+ label:
25048
+ type: string
25049
+ placeholder:
25050
+ type: string
25051
+ required:
25052
+ - pattern
25053
+ - label
25054
+ - placeholder
25055
+ additionalProperties: false
25056
+ - type: "null"
25018
25057
  acts_as:
25019
25058
  type: string
25020
25059
  enum:
@@ -25031,6 +25070,7 @@ paths:
25031
25070
  - supports_managed_mode
25032
25071
  - managed_service_is_paid
25033
25072
  - feature_flag
25073
+ - tenant_host
25034
25074
  - acts_as
25035
25075
  additionalProperties: false
25036
25076
  required:
@@ -26510,6 +26550,12 @@ paths:
26510
26550
  target:
26511
26551
  type: string
26512
26552
  description: Absolute path that was removed on the assistant host. Useful for audit logs and confirmation toasts.
26553
+ warnings:
26554
+ description: Stable keys for non-fatal cleanup limitations that should be localized at the presentation edge.
26555
+ type: array
26556
+ items:
26557
+ type: string
26558
+ const: plugin.uninstall.mcp_oauth_credentials_unchecked
26513
26559
  required:
26514
26560
  - name
26515
26561
  - target
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-staging.1",
3
+ "version": "0.12.2-staging.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,279 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
3
+ import { createServer } from "node:net";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ import { IpcFrameReader, writeMessage } from "@vellumai/ipc-server-utils";
8
+ import { Command } from "commander";
9
+ import sharp from "sharp";
10
+
11
+ import { executeBrowserOperation } from "../src/browser/operations.js";
12
+ import type { BrowserOperation } from "../src/browser/types.js";
13
+ import { registerBrowserCommand } from "../src/cli/commands/browser.js";
14
+ import { DesktopAutomationLease } from "../src/desktop/desktop-automation-lease.js";
15
+ import {
16
+ desktopChromePath,
17
+ desktopDependencyInstaller,
18
+ } from "../src/desktop/desktop-dependencies.js";
19
+ import { DesktopSessionManager } from "../src/desktop/desktop-session-manager.js";
20
+ import { getAssistantSocketPath } from "../src/ipc/socket-path.js";
21
+
22
+ if (
23
+ process.platform !== "linux" ||
24
+ !process.argv[2] ||
25
+ !process.env.ASSISTANT_IPC_SOCKET_DIR
26
+ ) {
27
+ throw new Error(
28
+ "Run in disposable Linux with desktop packages, a temporary ASSISTANT_IPC_SOCKET_DIR and a Chrome executable argument or --install for a cold install.",
29
+ );
30
+ }
31
+ const coldInstall = process.argv[2] === "--install";
32
+ const executable = coldInstall ? desktopChromePath() : process.argv[2];
33
+ if (coldInstall) {
34
+ assert.equal(desktopDependencyInstaller.getStatus().state, "required");
35
+ assert.equal(Bun.which("Xtigervnc"), null);
36
+ }
37
+ const directory = await mkdtemp(join(tmpdir(), "desktop-browser-cli-"));
38
+ const manager = new DesktopSessionManager({
39
+ resolveChromePath: async () => executable,
40
+ profileDir: join(directory, "profile"),
41
+ panelConfigDir: join(directory, "panel"),
42
+ renderWallpaper: async () => null,
43
+ });
44
+ const control = new DesktopAutomationLease({
45
+ enabled: () => true,
46
+ ready: () =>
47
+ !coldInstall || desktopDependencyInstaller.getStatus().state === "ready",
48
+ ensureReady: (signal) => desktopDependencyInstaller.ensureReady(signal),
49
+ manager: () => manager,
50
+ });
51
+ const context = {
52
+ workingDir: directory,
53
+ conversationId: "conv-smoke",
54
+ sourceActorPrincipalId: "user-123",
55
+ trustClass: "guardian" as const,
56
+ };
57
+ let submissions = 0;
58
+ let navigations = 0;
59
+ const page = Bun.serve({
60
+ hostname: "127.0.0.1",
61
+ port: 0,
62
+ fetch(request) {
63
+ if (new URL(request.url).pathname === "/save") {
64
+ submissions++;
65
+ return Response.json({ ok: true });
66
+ }
67
+ if (new URL(request.url).pathname === "/") {
68
+ navigations++;
69
+ }
70
+ return new Response(
71
+ `<!doctype html><html><head><title>Desktop browser CLI</title><style>body{font:24px sans-serif;padding:70px;background:#f3f4f8}input,button{font:inherit;padding:12px;margin:16px}#result{color:#5140bd}#colors{position:fixed;left:0;top:0;display:flex}#colors span{width:100px;height:50px}</style></head><body><div id="colors"><span style="background:#ff0000"></span><span style="background:#00ff00"></span><span style="background:#0000ff"></span></div><h1>Streamed desktop browser</h1><label>Example text<input id="text"></label><button id="save" onclick="fetch('/save');document.getElementById('result').textContent='Saved '+document.getElementById('text').value">Save</button><p id="result"></p><button id="open-dialog" onclick="document.querySelector('dialog').showModal()">Open dialog</button><dialog style="background:white"><button id="inside" onclick="this.textContent='Modal clicked'">Inside dialog</button></dialog></body></html>`,
72
+ {
73
+ headers: {
74
+ "content-type": "text/html",
75
+ "content-security-policy":
76
+ "require-trusted-types-for 'script'; trusted-types 'none'",
77
+ },
78
+ },
79
+ );
80
+ },
81
+ });
82
+ await mkdir(process.env.ASSISTANT_IPC_SOCKET_DIR, { recursive: true });
83
+ let setupNotifications = 0;
84
+ const ipc = createServer((socket) => {
85
+ const reader = new IpcFrameReader((request) => {
86
+ void (async () => {
87
+ try {
88
+ if (request.method === "/events/publish") {
89
+ setupNotifications++;
90
+ writeMessage(socket, { id: request.id, result: { ok: true } });
91
+ return;
92
+ }
93
+ const body = (
94
+ request.params as {
95
+ body: {
96
+ operation: BrowserOperation;
97
+ input: Record<string, unknown>;
98
+ desktop: boolean;
99
+ };
100
+ }
101
+ ).body;
102
+ assert.equal(request.method, "browser_execute");
103
+ assert.equal(body.desktop, true);
104
+ const result = await control.runBrowser(
105
+ context,
106
+ async (signal) => {
107
+ const cdpClient = await manager.browser.client(
108
+ context.conversationId,
109
+ signal,
110
+ );
111
+ return executeBrowserOperation(body.operation, body.input, {
112
+ ...context,
113
+ signal,
114
+ cdpClient,
115
+ });
116
+ },
117
+ body.operation === "detach",
118
+ );
119
+ const screenshots = (result.contentBlocks ?? []).flatMap((block) =>
120
+ block.type === "image" && block.source.type === "base64"
121
+ ? [{ mediaType: block.source.media_type, data: block.source.data }]
122
+ : [],
123
+ );
124
+ writeMessage(socket, {
125
+ id: request.id,
126
+ result: { ...result, screenshots },
127
+ });
128
+ } catch (error) {
129
+ writeMessage(socket, { id: request.id, error: String(error) });
130
+ }
131
+ })();
132
+ });
133
+ socket.on("data", (chunk) => reader.push(chunk));
134
+ });
135
+ await new Promise<void>((resolve, reject) => {
136
+ ipc.once("error", reject);
137
+ ipc.listen(getAssistantSocketPath(), resolve);
138
+ });
139
+ async function cli(...args: string[]) {
140
+ const program = new Command();
141
+ program.exitOverride();
142
+ registerBrowserCommand(program);
143
+ const chunks: string[] = [];
144
+ const original = process.stdout.write;
145
+ process.stdout.write = ((chunk: unknown) => {
146
+ chunks.push(String(chunk));
147
+ return true;
148
+ }) as typeof original;
149
+ try {
150
+ await program.parseAsync([
151
+ "bun",
152
+ "assistant",
153
+ "browser",
154
+ "--virtual-desktop",
155
+ "--json",
156
+ ...args,
157
+ ]);
158
+ } finally {
159
+ process.stdout.write = original;
160
+ }
161
+ const result = JSON.parse(chunks.join("")) as {
162
+ ok: boolean;
163
+ content: string;
164
+ error?: string;
165
+ };
166
+ assert.equal(result.ok, true, JSON.stringify(result));
167
+ return result.content;
168
+ }
169
+ function ref(snapshot: string, name: string): string {
170
+ const line = snapshot
171
+ .split("\n")
172
+ .find((value) => value.includes(name) && /\be\d+\b/.test(value));
173
+ assert(line, `Missing ${name}: ${snapshot}`);
174
+ return /\be\d+\b/.exec(line)![0];
175
+ }
176
+ try {
177
+ await cli(
178
+ "navigate",
179
+ "--url",
180
+ `http://127.0.0.1:${page.port}`,
181
+ "--allow-private-network",
182
+ );
183
+ assert.equal(navigations, 1);
184
+ if (coldInstall) {
185
+ assert.equal(desktopDependencyInstaller.getStatus().state, "ready");
186
+ assert(
187
+ setupNotifications >= 3,
188
+ "Installation progress must reach the viewer",
189
+ );
190
+ console.error(
191
+ "PASS: one CLI navigate installed desktop and Chrome from scratch, then loaded the requested page once",
192
+ );
193
+ }
194
+ const snapshot = await cli("snapshot");
195
+ await cli(
196
+ "type",
197
+ "--element-id",
198
+ ref(snapshot, "Example text"),
199
+ "--text",
200
+ "Hello café 世界",
201
+ "--clear-first",
202
+ );
203
+ await cli("click", "--element-id", ref(snapshot, "Save"));
204
+ const saved = await cli("extract");
205
+ assert.match(saved, /Saved Hello café 世界/);
206
+ assert.equal(submissions, 1);
207
+ const screenshotPath = join(tmpdir(), "desktop-browser-cli-page.jpg");
208
+ await cli("screenshot", "--output", screenshotPath);
209
+ const { data: pixels, info } = await sharp(screenshotPath)
210
+ .removeAlpha()
211
+ .raw()
212
+ .toBuffer({ resolveWithObject: true });
213
+ for (let channel = 0; channel < 3; channel++) {
214
+ const offset = (25 * info.width + 50 + channel * 100) * info.channels;
215
+ for (let component = 0; component < 3; component++) {
216
+ const expected = component === channel ? 255 : 0;
217
+ assert(
218
+ Math.abs(pixels[offset + component] - expected) < 10,
219
+ `Incorrect screenshot color for swatch ${channel}, channel ${component}`,
220
+ );
221
+ }
222
+ }
223
+ async function assertCursorPainted(expected: boolean) {
224
+ await control.runBrowser(context, async (signal) => {
225
+ const cdp = await manager.browser.client(context.conversationId, signal);
226
+ const screenshot = await cdp.send<{ data: string }>(
227
+ "Page.captureScreenshot",
228
+ { format: "png" },
229
+ );
230
+ const { data, info } = await sharp(Buffer.from(screenshot.data, "base64"))
231
+ .removeAlpha()
232
+ .raw()
233
+ .toBuffer({ resolveWithObject: true });
234
+ let purple = 0;
235
+ for (let i = 0; i < data.length; i += info.channels) {
236
+ if (
237
+ Math.abs(data[i]! - 112) < 5 &&
238
+ Math.abs(data[i + 1]! - 87) < 5 &&
239
+ Math.abs(data[i + 2]! - 255) < 5
240
+ ) {
241
+ purple++;
242
+ }
243
+ }
244
+ assert.equal(purple > 40, expected, `Cursor pixels: ${purple}`);
245
+ return { content: "verified", isError: false };
246
+ });
247
+ }
248
+ await assertCursorPainted(true);
249
+ await cli("click", "--selector", "#open-dialog");
250
+ await assertCursorPainted(true);
251
+ await cli("hover", "--selector", "#inside");
252
+ await assertCursorPainted(true);
253
+ await cli("click", "--selector", "#inside");
254
+ assert.match(await cli("extract"), /Modal clicked/);
255
+ for (const key of ["Enter", "Space"]) {
256
+ await cli("press-key", "--key", "Escape");
257
+ await cli("press-key", "--key", key, "--selector", "#open-dialog");
258
+ await assertCursorPainted(true);
259
+ }
260
+ await cli(
261
+ "navigate",
262
+ "--url",
263
+ `http://127.0.0.1:${page.port}/next`,
264
+ "--allow-private-network",
265
+ );
266
+ await assertCursorPainted(true);
267
+ await cli("detach");
268
+ await assertCursorPainted(false);
269
+ await cli("detach");
270
+ console.log(
271
+ "PASS: real browser CLI over IPC, shared AX snapshot, Unicode typing, one click submission, RGB page screenshot, cursor pixels above dialogs and after navigation, detach cleanup",
272
+ );
273
+ } finally {
274
+ await cli("detach");
275
+ await manager.destroy();
276
+ await new Promise<void>((resolve) => ipc.close(() => resolve()));
277
+ page.stop(true);
278
+ await rm(directory, { recursive: true, force: true });
279
+ }
@@ -44,11 +44,11 @@ describe("always-loaded tool count", () => {
44
44
  );
45
45
  const activeNames = activeTools.map((t) => t.name).sort();
46
46
 
47
- // `watch_retro_report` survives this baseline for the same reason the
48
- // ui_* tools do: a watch retrospective runs clientless, so it can only
49
- // report through a tool that survives this baseline. The ui_* tools are
50
- // here because background UI surfaces persist and return instead of
51
- // awaiting action, so they no longer need a connected client.
47
+ // `watch_retro_report` survives this baseline because a watch
48
+ // retrospective runs clientless and can only report through a tool that
49
+ // stays on this baseline. The ui_* tools stay here because background
50
+ // surfaces persist and return instead of awaiting action, so a connected
51
+ // client is not required for the definitions to be present.
52
52
  // Host tool definitions stay on the wire for background turns: schemas
53
53
  // are cache-stable, and the shared execution gate rejects host tools on
54
54
  // explicitly non-interactive turns before any proxy dispatch.
@@ -1021,7 +1021,7 @@ describe("AssistantConfigSchema", () => {
1021
1021
  maxSilenceMs: 35000,
1022
1022
  longOpMs: 15000,
1023
1023
  minGapMs: 6000,
1024
- generationTimeoutMs: 1500,
1024
+ generationTimeoutMs: 5000,
1025
1025
  },
1026
1026
  },
1027
1027
  });
@@ -5,6 +5,7 @@ import { SYNC_TAGS } from "../daemon/message-types/sync.js";
5
5
  import { assistantEventHub } from "../runtime/assistant-event-hub.js";
6
6
  import {
7
7
  publishConfigChanged,
8
+ publishMcpChanged,
8
9
  publishSchedulesChanged,
9
10
  publishSoundsConfigUpdated,
10
11
  } from "../runtime/sync/resource-sync-events.js";
@@ -94,4 +95,26 @@ describe("config and sounds sync events", () => {
94
95
  subscription.dispose();
95
96
  }
96
97
  });
98
+
99
+ test("MCP changes emit a sync tag without a client origin", async () => {
100
+ const received: AssistantEventEnvelope[] = [];
101
+ const subscription = assistantEventHub.subscribe({
102
+ type: "process",
103
+ callback: (event) => {
104
+ received.push(event);
105
+ },
106
+ });
107
+
108
+ try {
109
+ publishMcpChanged();
110
+ await waitFor(() => received.length === 1);
111
+
112
+ expect(received[0].message).toEqual({
113
+ type: "sync_changed",
114
+ tags: [SYNC_TAGS.mcpList],
115
+ });
116
+ } finally {
117
+ subscription.dispose();
118
+ }
119
+ });
97
120
  });