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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/docs/desktop-browser-cli.md +33 -0
  2. package/openapi.yaml +17 -5
  3. package/package.json +1 -1
  4. package/scripts/smoke-desktop-browser-cli.ts +279 -0
  5. package/src/__tests__/config-schema.test.ts +1 -1
  6. package/src/__tests__/config-sounds-sync.test.ts +23 -0
  7. package/src/__tests__/conversation-error.test.ts +4 -1
  8. package/src/__tests__/conversation-runtime-assembly.test.ts +6 -4
  9. package/src/__tests__/external-plugin-loader.test.ts +52 -0
  10. package/src/__tests__/headless-browser-mode.test.ts +48 -0
  11. package/src/__tests__/host-cu-proxy.test.ts +116 -18
  12. package/src/__tests__/list-all-apps.test.ts +40 -8
  13. package/src/__tests__/mcp-auth-routes.test.ts +99 -0
  14. package/src/__tests__/mcp-client-auth.test.ts +54 -0
  15. package/src/__tests__/mcp-health-check.test.ts +32 -32
  16. package/src/__tests__/mcp-list-plugin-servers.test.ts +77 -32
  17. package/src/__tests__/mcp-tool-annotations-risk.test.ts +4 -2
  18. package/src/__tests__/skills.test.ts +10 -0
  19. package/src/__tests__/user-plugin-loader.test.ts +21 -0
  20. package/src/apps/app-store.ts +6 -5
  21. package/src/browser/operations.ts +2 -1
  22. package/src/browser/types.ts +7 -0
  23. package/src/browser/virtual-desktop-target.ts +34 -0
  24. package/src/calls/__tests__/progress-narration.test.ts +23 -0
  25. package/src/calls/__tests__/voice-control-protocol.test.ts +7 -0
  26. package/src/calls/__tests__/voice-session-bridge.test.ts +62 -0
  27. package/src/calls/progress-narration.ts +15 -2
  28. package/src/calls/voice-control-protocol.ts +34 -4
  29. package/src/calls/voice-session-bridge.ts +23 -0
  30. package/src/cli/commands/__tests__/browser.test.ts +84 -3
  31. package/src/cli/commands/browser.help.ts +43 -0
  32. package/src/cli/commands/browser.ts +52 -8
  33. package/src/cli/commands/plugins.ts +16 -5
  34. package/src/cli/lib/__tests__/install-from-github.test.ts +111 -0
  35. package/src/cli/lib/__tests__/install-from-platform.test.ts +77 -2
  36. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +45 -3
  37. package/src/cli/lib/__tests__/plugin-details.test.ts +26 -0
  38. package/src/cli/lib/__tests__/uninstall-plugin.test.ts +122 -5
  39. package/src/cli/lib/bundled-marketplace.json +13 -0
  40. package/src/cli/lib/install-from-github.ts +17 -18
  41. package/src/cli/lib/install-from-platform.ts +14 -9
  42. package/src/cli/lib/list-installed-plugins.ts +21 -35
  43. package/src/cli/lib/plugin-details.ts +21 -8
  44. package/src/cli/lib/uninstall-plugin.ts +59 -1
  45. package/src/config/__tests__/plugin-resident-skill-discovery.test.ts +21 -0
  46. package/src/config/bundled-skills/computer-use/SKILL.md +30 -0
  47. package/src/config/bundled-skills/computer-use/TOOLS.json +2 -2
  48. package/src/config/bundled-skills/screen-annotation/SKILL.md +2 -2
  49. package/src/config/feature-flag-registry.json +2 -2
  50. package/src/config/schemas/__tests__/voice.test.ts +2 -2
  51. package/src/config/schemas/mcp.ts +9 -7
  52. package/src/config/schemas/monitoring.ts +9 -0
  53. package/src/config/schemas/voice.ts +2 -2
  54. package/src/config/skills.ts +13 -21
  55. package/src/daemon/__tests__/conversation-tool-setup-exclude.test.ts +47 -0
  56. package/src/daemon/__tests__/plugin-mcp-reconcile.test.ts +1 -0
  57. package/src/daemon/conversation-client-surface.ts +26 -0
  58. package/src/daemon/conversation-runtime-assembly.ts +1 -1
  59. package/src/daemon/conversation-tool-setup.ts +31 -29
  60. package/src/daemon/host-cu-proxy.ts +51 -26
  61. package/src/daemon/host-proxy-preactivation.ts +2 -0
  62. package/src/daemon/mcp-reload-service.ts +7 -0
  63. package/src/daemon/message-types/sync.ts +1 -0
  64. package/src/daemon/providers-setup.ts +4 -0
  65. package/src/desktop/__tests__/fake-desktop.ts +1 -0
  66. package/src/desktop/desktop-automation-lease.test.ts +208 -0
  67. package/src/desktop/desktop-automation-lease.ts +270 -0
  68. package/src/desktop/desktop-browser-client.test.ts +413 -0
  69. package/src/desktop/desktop-browser-client.ts +430 -0
  70. package/src/desktop/desktop-browser-cursor.ts +40 -0
  71. package/src/desktop/desktop-browser-endpoint.test.ts +78 -0
  72. package/src/desktop/desktop-browser-endpoint.ts +145 -0
  73. package/src/desktop/desktop-browser-operations.ts +130 -0
  74. package/src/desktop/desktop-chrome-session.test.ts +48 -0
  75. package/src/desktop/desktop-chrome-session.ts +58 -8
  76. package/src/desktop/desktop-dependencies.test.ts +29 -0
  77. package/src/desktop/desktop-dependencies.ts +26 -5
  78. package/src/desktop/desktop-display.ts +9 -0
  79. package/src/desktop/desktop-panel-config.ts +14 -1
  80. package/src/desktop/desktop-session-manager.test.ts +86 -3
  81. package/src/desktop/desktop-session-manager.ts +144 -24
  82. package/src/desktop/desktop-stream-bridge.ts +1 -1
  83. package/src/desktop/virtual-desktop-feature.ts +18 -0
  84. package/src/desktop/virtual-desktop-platform.test.ts +61 -0
  85. package/src/i18n/__tests__/i18n.test.ts +9 -5
  86. package/src/i18n/messages.ts +13 -4
  87. package/src/ipc/__tests__/browser-ipc.test.ts +230 -2
  88. package/src/ipc/__tests__/cancel-on-disconnect.test.ts +54 -0
  89. package/src/ipc/assistant-server.ts +15 -1
  90. package/src/ipc/cli-client.ts +13 -5
  91. package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +22 -0
  92. package/src/live-voice/__tests__/live-voice-events.test.ts +3 -2
  93. package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +19 -0
  94. package/src/live-voice/__tests__/protocol.test.ts +11 -3
  95. package/src/live-voice/__tests__/session-controls.test.ts +34 -0
  96. package/src/live-voice/live-voice-metrics.ts +9 -0
  97. package/src/live-voice/live-voice-session.ts +3 -0
  98. package/src/live-voice/protocol.ts +10 -1
  99. package/src/live-voice/session-controls.ts +28 -0
  100. package/src/mcp/__tests__/credential-target.test.ts +59 -0
  101. package/src/mcp/__tests__/effective-config.test.ts +11 -0
  102. package/src/mcp/__tests__/manager-state.test.ts +128 -0
  103. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +20 -6
  104. package/src/mcp/__tests__/mcp-auth-state-target.test.ts +49 -0
  105. package/src/mcp/__tests__/plugin-mcp-oauth-cleanup.test.ts +107 -0
  106. package/src/mcp/__tests__/plugin-mcp-oauth-provider-isolation.test.ts +89 -0
  107. package/src/mcp/__tests__/plugin-server-credential-isolation.test.ts +63 -11
  108. package/src/mcp/__tests__/reload-signal-emission.test.ts +39 -2
  109. package/src/mcp/client.ts +64 -16
  110. package/src/mcp/credential-target.ts +105 -0
  111. package/src/mcp/effective-config.ts +42 -15
  112. package/src/mcp/manager.ts +61 -8
  113. package/src/mcp/mcp-auth-orchestrator.ts +17 -4
  114. package/src/mcp/mcp-auth-state.ts +38 -6
  115. package/src/mcp/mcp-oauth-provider.ts +101 -33
  116. package/src/monitoring/__tests__/mount-watch.test.ts +236 -0
  117. package/src/monitoring/mount-watch.ts +348 -0
  118. package/src/monitoring/worker.ts +9 -0
  119. package/src/oauth/seed-providers.ts +11 -0
  120. package/src/onboarding/checkin-event.ts +2 -1
  121. package/src/plugins/__tests__/installed-plugin-dirs.test.ts +14 -1
  122. package/src/plugins/__tests__/mcp-servers.test.ts +38 -8
  123. package/src/plugins/external-plugin-loader.ts +49 -86
  124. package/src/plugins/installed-plugin-dirs.ts +6 -4
  125. package/src/plugins/mcp-servers.ts +9 -15
  126. package/src/plugins/mtime-cache.ts +2 -1
  127. package/src/plugins/user-loader.ts +2 -1
  128. package/src/providers/inference/__tests__/endpoint-probe.test.ts +45 -0
  129. package/src/providers/inference/endpoint-probe.ts +27 -12
  130. package/src/providers/openai/responses-provider.ts +9 -2
  131. package/src/providers/opencode/client.test.ts +167 -0
  132. package/src/providers/opencode/client.ts +73 -4
  133. package/src/runtime/AGENTS.md +4 -0
  134. package/src/runtime/__tests__/desktop-stream-upgrade.test.ts +45 -2
  135. package/src/runtime/http-server.ts +6 -6
  136. package/src/runtime/routes/__tests__/plugins-routes.test.ts +25 -3
  137. package/src/runtime/routes/browser-context.ts +39 -0
  138. package/src/runtime/routes/browser-routes.ts +16 -47
  139. package/src/runtime/routes/browser-tabs-routes.ts +72 -29
  140. package/src/runtime/routes/desktop-setup-routes.test.ts +16 -1
  141. package/src/runtime/routes/desktop-setup-routes.ts +7 -5
  142. package/src/runtime/routes/mcp-auth-routes.ts +106 -94
  143. package/src/runtime/routes/plugins-routes.ts +14 -1
  144. package/src/runtime/sync/resource-sync-events.ts +4 -0
  145. package/src/tools/browser/browser-execution.ts +44 -13
  146. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +69 -0
  147. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +1 -0
  148. package/src/tools/browser/cdp-client/cdp-inspect/ws-transport.ts +5 -0
  149. package/src/tools/computer-use/definitions.ts +2 -3
  150. package/src/util/abort-reasons.ts +12 -1
  151. package/src/util/plugin-manifest.ts +203 -0
  152. package/src/desktop/desktop-feature.test.ts +0 -18
  153. package/src/desktop/desktop-feature.ts +0 -19
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Mount-table watcher for the assistant container, polled by the resource
3
+ * monitor.
4
+ *
5
+ * The monitor process shares the assistant container's mount namespace, so
6
+ * `/proc/self/mountinfo` is exactly that container's mounts (workspace and
7
+ * data PVC binds, plus any virtiofs handles). Gateway and CES sidecar mounts
8
+ * are out of scope. The watcher keeps a baseline and logs only when the
9
+ * watched set actually changes: a virtiofs bind that disappears, a fstype
10
+ * swap, or a statfs size collapse on `/workspace` or `/data`.
11
+ *
12
+ * Windows and macOS have no `/proc/self/mountinfo`; those platforms no-op.
13
+ */
14
+
15
+ import { readFileSync, statfsSync } from "node:fs";
16
+
17
+ import type { MonitoringConfig } from "../config/schemas/monitoring.js";
18
+ import { getLogger } from "../util/logger.js";
19
+
20
+ const log = getLogger("mount-watch");
21
+
22
+ /** Assistant-container PVC bind targets in the managed pod template. */
23
+ export const ASSISTANT_VOLUME_MOUNT_POINTS = ["/workspace", "/data"] as const;
24
+
25
+ const VIRTIOFS_TYPES = new Set(["virtiofs", "fuse.virtiofs"]);
26
+
27
+ export interface MountInfoEntry {
28
+ mountId: string;
29
+ parentId: string;
30
+ majorMinor: string;
31
+ root: string;
32
+ mountPoint: string;
33
+ mountOptions: string;
34
+ fsType: string;
35
+ source: string;
36
+ superOptions: string;
37
+ }
38
+
39
+ export interface PathFsSnapshot {
40
+ path: string;
41
+ /** `statfs.f_type`; null when the path could not be measured. */
42
+ type: number | null;
43
+ blockSize: number | null;
44
+ blocks: number | null;
45
+ files: number | null;
46
+ /** Present when the path exists but `statfs` failed. */
47
+ error?: string;
48
+ }
49
+
50
+ export interface MountSnapshot {
51
+ mounts: MountInfoEntry[];
52
+ paths: PathFsSnapshot[];
53
+ }
54
+
55
+ export interface MountDiff {
56
+ added: MountInfoEntry[];
57
+ removed: MountInfoEntry[];
58
+ changed: Array<{ previous: MountInfoEntry; current: MountInfoEntry }>;
59
+ pathChanges: Array<{ previous: PathFsSnapshot; current: PathFsSnapshot }>;
60
+ }
61
+
62
+ export interface CollectMountSnapshotOptions {
63
+ platform?: NodeJS.Platform;
64
+ readMountinfo?: () => string;
65
+ statPath?: (path: string) => PathFsSnapshot;
66
+ watchedMountPoints?: readonly string[];
67
+ }
68
+
69
+ export interface MountWatchHandle {
70
+ stop: () => void;
71
+ }
72
+
73
+ /**
74
+ * Kernel mountinfo encodes space/tab/newline in paths as octal escapes
75
+ * (`\040`, `\011`, `\012`) and a backslash as `\\`.
76
+ */
77
+ export function unescapeMountField(value: string): string {
78
+ return value
79
+ .replace(/\\([0-7]{3})/g, (_match, oct: string) =>
80
+ String.fromCharCode(parseInt(oct, 8)),
81
+ )
82
+ .replace(/\\\\/g, "\\");
83
+ }
84
+
85
+ /**
86
+ * Parse `/proc/self/mountinfo` into structured entries. Malformed lines are
87
+ * skipped rather than failing the pass.
88
+ */
89
+ export function parseMountinfo(raw: string): MountInfoEntry[] {
90
+ const entries: MountInfoEntry[] = [];
91
+ for (const line of raw.split("\n")) {
92
+ if (line.length === 0) {
93
+ continue;
94
+ }
95
+ const separator = line.indexOf(" - ");
96
+ if (separator < 0) {
97
+ continue;
98
+ }
99
+ const left = line.slice(0, separator).split(" ");
100
+ const right = line.slice(separator + 3).split(" ");
101
+ if (left.length < 6 || right.length < 3) {
102
+ continue;
103
+ }
104
+ entries.push({
105
+ mountId: left[0],
106
+ parentId: left[1],
107
+ majorMinor: left[2],
108
+ root: unescapeMountField(left[3]),
109
+ mountPoint: unescapeMountField(left[4]),
110
+ mountOptions: left[5],
111
+ fsType: unescapeMountField(right[0]),
112
+ source: unescapeMountField(right[1]),
113
+ superOptions: right.slice(2).join(" "),
114
+ });
115
+ }
116
+ return entries;
117
+ }
118
+
119
+ export function isWatchedMount(
120
+ entry: MountInfoEntry,
121
+ watchedMountPoints: readonly string[] = ASSISTANT_VOLUME_MOUNT_POINTS,
122
+ ): boolean {
123
+ if (VIRTIOFS_TYPES.has(entry.fsType)) {
124
+ return true;
125
+ }
126
+ return watchedMountPoints.some(
127
+ (point) => entry.mountPoint === point || entry.mountPoint.startsWith(`${point}/`),
128
+ );
129
+ }
130
+
131
+ function mountIdentity(entry: MountInfoEntry): string {
132
+ return entry.mountPoint;
133
+ }
134
+
135
+ function mountSignature(entry: MountInfoEntry): string {
136
+ return [
137
+ entry.fsType,
138
+ entry.source,
139
+ entry.majorMinor,
140
+ entry.root,
141
+ entry.mountOptions,
142
+ entry.superOptions,
143
+ ].join("\0");
144
+ }
145
+
146
+ function pathSignature(snapshot: PathFsSnapshot): string {
147
+ return [
148
+ String(snapshot.type),
149
+ String(snapshot.blockSize),
150
+ String(snapshot.blocks),
151
+ String(snapshot.files),
152
+ snapshot.error ?? "",
153
+ ].join("\0");
154
+ }
155
+
156
+ export function diffMountSnapshots(
157
+ previous: MountSnapshot,
158
+ current: MountSnapshot,
159
+ ): MountDiff {
160
+ const prevByPoint = new Map(
161
+ previous.mounts.map((entry) => [mountIdentity(entry), entry]),
162
+ );
163
+ const currentByPoint = new Map(
164
+ current.mounts.map((entry) => [mountIdentity(entry), entry]),
165
+ );
166
+
167
+ const added: MountInfoEntry[] = [];
168
+ const removed: MountInfoEntry[] = [];
169
+ const changed: Array<{ previous: MountInfoEntry; current: MountInfoEntry }> =
170
+ [];
171
+
172
+ for (const [point, entry] of currentByPoint) {
173
+ const prior = prevByPoint.get(point);
174
+ if (prior == null) {
175
+ added.push(entry);
176
+ continue;
177
+ }
178
+ if (mountSignature(prior) !== mountSignature(entry)) {
179
+ changed.push({ previous: prior, current: entry });
180
+ }
181
+ }
182
+ for (const [point, entry] of prevByPoint) {
183
+ if (!currentByPoint.has(point)) {
184
+ removed.push(entry);
185
+ }
186
+ }
187
+
188
+ const prevPaths = new Map(previous.paths.map((row) => [row.path, row]));
189
+ const pathChanges: Array<{
190
+ previous: PathFsSnapshot;
191
+ current: PathFsSnapshot;
192
+ }> = [];
193
+ for (const row of current.paths) {
194
+ const prior = prevPaths.get(row.path);
195
+ if (prior != null && pathSignature(prior) !== pathSignature(row)) {
196
+ pathChanges.push({ previous: prior, current: row });
197
+ }
198
+ }
199
+
200
+ return { added, removed, changed, pathChanges };
201
+ }
202
+
203
+ export function mountDiffIsEmpty(diff: MountDiff): boolean {
204
+ return (
205
+ diff.added.length === 0 &&
206
+ diff.removed.length === 0 &&
207
+ diff.changed.length === 0 &&
208
+ diff.pathChanges.length === 0
209
+ );
210
+ }
211
+
212
+ function defaultReadMountinfo(): string {
213
+ return readFileSync("/proc/self/mountinfo", "utf8");
214
+ }
215
+
216
+ function defaultStatPath(path: string): PathFsSnapshot {
217
+ try {
218
+ const stats = statfsSync(path);
219
+ return {
220
+ path,
221
+ type: Number(stats.type),
222
+ blockSize: Number(stats.bsize),
223
+ blocks: Number(stats.blocks),
224
+ files: Number(stats.files),
225
+ };
226
+ } catch {
227
+ return {
228
+ path,
229
+ type: null,
230
+ blockSize: null,
231
+ blocks: null,
232
+ files: null,
233
+ error: "statfs-failed",
234
+ };
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Snapshot the assistant container's watched mounts. Returns null on Windows
240
+ * and macOS, or when `/proc/self/mountinfo` is unreadable.
241
+ */
242
+ export function collectMountSnapshot(
243
+ options: CollectMountSnapshotOptions = {},
244
+ ): MountSnapshot | null {
245
+ const platform = options.platform ?? process.platform;
246
+ if (platform === "win32" || platform === "darwin") {
247
+ return null;
248
+ }
249
+
250
+ const watchedMountPoints =
251
+ options.watchedMountPoints ?? ASSISTANT_VOLUME_MOUNT_POINTS;
252
+ const readMountinfo = options.readMountinfo ?? defaultReadMountinfo;
253
+ const statPath = options.statPath ?? defaultStatPath;
254
+
255
+ let raw: string;
256
+ try {
257
+ raw = readMountinfo();
258
+ } catch {
259
+ return null;
260
+ }
261
+
262
+ const mounts = parseMountinfo(raw)
263
+ .filter((entry) => isWatchedMount(entry, watchedMountPoints))
264
+ .sort((a, b) => a.mountPoint.localeCompare(b.mountPoint));
265
+
266
+ const paths = watchedMountPoints.map((path) => statPath(path));
267
+
268
+ return { mounts, paths };
269
+ }
270
+
271
+ function summarizeEntry(entry: MountInfoEntry): Record<string, string> {
272
+ return {
273
+ mountPoint: entry.mountPoint,
274
+ fsType: entry.fsType,
275
+ source: entry.source,
276
+ majorMinor: entry.majorMinor,
277
+ };
278
+ }
279
+
280
+ function summarizePath(snapshot: PathFsSnapshot): Record<string, unknown> {
281
+ return {
282
+ path: snapshot.path,
283
+ type: snapshot.type,
284
+ blockSize: snapshot.blockSize,
285
+ blocks: snapshot.blocks,
286
+ files: snapshot.files,
287
+ ...(snapshot.error != null ? { error: snapshot.error } : {}),
288
+ };
289
+ }
290
+
291
+ export function logMountDiff(diff: MountDiff): void {
292
+ log.warn(
293
+ {
294
+ added: diff.added.map(summarizeEntry),
295
+ removed: diff.removed.map(summarizeEntry),
296
+ changed: diff.changed.map((row) => ({
297
+ previous: summarizeEntry(row.previous),
298
+ current: summarizeEntry(row.current),
299
+ })),
300
+ pathChanges: diff.pathChanges.map((row) => ({
301
+ previous: summarizePath(row.previous),
302
+ current: summarizePath(row.current),
303
+ })),
304
+ },
305
+ "Assistant container mount table changed",
306
+ );
307
+ }
308
+
309
+ /**
310
+ * Start the mount poller. The first pass establishes a silent baseline so a
311
+ * healthy boot does not log; later passes warn only when the watched mounts
312
+ * or `/workspace`/`/data` statfs values change.
313
+ *
314
+ * The timer is unref'd: the resource sampler is the monitor process's
315
+ * keep-alive, and this loop must not extend its life on its own.
316
+ */
317
+ export function startMountWatch(
318
+ config: MonitoringConfig,
319
+ options: CollectMountSnapshotOptions = {},
320
+ ): MountWatchHandle {
321
+ let previous: MountSnapshot | null = null;
322
+
323
+ const poll = () => {
324
+ try {
325
+ const snapshot = collectMountSnapshot(options);
326
+ if (snapshot == null) {
327
+ return;
328
+ }
329
+ if (previous != null) {
330
+ const diff = diffMountSnapshots(previous, snapshot);
331
+ if (!mountDiffIsEmpty(diff)) {
332
+ logMountDiff(diff);
333
+ }
334
+ }
335
+ previous = snapshot;
336
+ } catch (err) {
337
+ log.warn({ err }, "Mount-watch poll failed");
338
+ }
339
+ };
340
+
341
+ poll();
342
+ const timer = setInterval(poll, config.mountWatchIntervalMs);
343
+ timer.unref?.();
344
+
345
+ return {
346
+ stop: () => clearInterval(timer),
347
+ };
348
+ }
@@ -49,6 +49,7 @@ import {
49
49
  type FileDescriptorMonitorHandle,
50
50
  startFileDescriptorMonitor,
51
51
  } from "./file-descriptors.js";
52
+ import { type MountWatchHandle, startMountWatch } from "./mount-watch.js";
52
53
  import {
53
54
  type PluginAutoUpdateHandle,
54
55
  startPluginAutoUpdate,
@@ -99,6 +100,7 @@ async function main(): Promise<void> {
99
100
 
100
101
  let sampler: ResourceSamplerHandle | null = null;
101
102
  let fdMonitor: FileDescriptorMonitorHandle | null = null;
103
+ let mountWatch: MountWatchHandle | null = null;
102
104
  let sourceWatch: PluginSourceWatchHandle | null = null;
103
105
  let autoUpdate: PluginAutoUpdateHandle | null = null;
104
106
  let recovery: RecoveryHandle | null = null;
@@ -125,6 +127,7 @@ async function main(): Promise<void> {
125
127
  stopDbIntegritySampler();
126
128
  autoUpdate?.stop();
127
129
  sourceWatch?.stop();
130
+ mountWatch?.stop();
128
131
  fdMonitor?.stop();
129
132
  sampler?.stop();
130
133
  // Bounded final telemetry flush, mirroring the daemon's shutdown. This
@@ -170,6 +173,9 @@ async function main(): Promise<void> {
170
173
  // Descriptor exhaustion is per-process and slow-moving, so it polls on its
171
174
  // own timer rather than riding the memory sampler's 250ms tick.
172
175
  fdMonitor = startFileDescriptorMonitor(config.monitoring);
176
+ // Assistant-container mount table only (this process's mount namespace).
177
+ // Warns only when /workspace, /data, or a virtiofs bind actually changes.
178
+ mountWatch = startMountWatch(config.monitoring);
173
179
  sourceWatch = startPluginSourceWatch(
174
180
  config.monitoring.pluginSourceScanIntervalMs,
175
181
  );
@@ -211,6 +217,7 @@ async function main(): Promise<void> {
211
217
  recovery?.stop();
212
218
  autoUpdate?.stop();
213
219
  sourceWatch?.stop();
220
+ mountWatch?.stop();
214
221
  fdMonitor?.stop();
215
222
  sampler?.stop();
216
223
  stopDbIntegritySampler();
@@ -223,6 +230,7 @@ async function main(): Promise<void> {
223
230
  recovery?.stop();
224
231
  autoUpdate?.stop();
225
232
  sourceWatch?.stop();
233
+ mountWatch?.stop();
226
234
  fdMonitor?.stop();
227
235
  sampler?.stop();
228
236
  stopDbIntegritySampler();
@@ -234,6 +242,7 @@ async function main(): Promise<void> {
234
242
  recovery?.stop();
235
243
  autoUpdate?.stop();
236
244
  sourceWatch?.stop();
245
+ mountWatch?.stop();
237
246
  fdMonitor?.stop();
238
247
  sampler?.stop();
239
248
  stopDbIntegritySampler();
@@ -1403,6 +1403,12 @@ export const PROVIDER_SEED_DATA: Record<
1403
1403
  "read_discounts",
1404
1404
  "write_discounts",
1405
1405
  "read_price_rules",
1406
+ // write_themes covers listing, duplicating, and publishing themes.
1407
+ // Writing theme files (settings, JSON templates, Liquid) also requires
1408
+ // Shopify's theme-code exemption on the app itself, which is granted
1409
+ // per app in the Shopify dashboard rather than through a scope.
1410
+ "read_themes",
1411
+ "write_themes",
1406
1412
  ],
1407
1413
  availableScopes: [
1408
1414
  {
@@ -1455,6 +1461,11 @@ export const PROVIDER_SEED_DATA: Record<
1455
1461
  scope: "write_price_rules",
1456
1462
  description: "Create and update price rules",
1457
1463
  },
1464
+ { scope: "read_themes", description: "Read themes and theme files" },
1465
+ {
1466
+ scope: "write_themes",
1467
+ description: "Create, publish, and edit themes and theme files",
1468
+ },
1458
1469
  { scope: "read_files", description: "Read files uploaded to the store" },
1459
1470
  { scope: "write_files", description: "Upload and update files" },
1460
1471
  { scope: "read_gift_cards", description: "Read gift cards" },
@@ -69,7 +69,8 @@ export function buildCheckinTitle({
69
69
  * Google Calendar strips nearly all styling, so the body relies only on the
70
70
  * tags that survive (`<p>`, `<strong>`, `<a>`, emoji) and makes the CTA a bold
71
71
  * link rather than a styled button. The deep link opens a fresh conversation
72
- * (`uuid`) pre-seeded with the first-week prompt.
72
+ * (`uuid`) with the first-week prompt staged in the composer; the user sends
73
+ * it. A clicked link never auto-sends (`clients/web/src/utils/auto-send-prompt.ts`).
73
74
  */
74
75
  export function buildCheckinDescription(uuid: string): string {
75
76
  // `vref` is an app-owned attribution param, NOT a marketing `utm_*`: the
@@ -45,10 +45,23 @@ beforeEach(() => {
45
45
  describe("listInstalledPluginDirs", () => {
46
46
  test("lists real plugin directories and skips dot entries", () => {
47
47
  makePluginDir(pluginsDir, "alpha");
48
+ const standard = join(pluginsDir, "standard");
49
+ mkdirSync(standard, { recursive: true });
50
+ writeFileSync(
51
+ join(standard, "plugin.json"),
52
+ JSON.stringify({
53
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
54
+ name: "standard",
55
+ }),
56
+ );
48
57
  makePluginDir(pluginsDir, ".hidden");
49
58
  mkdirSync(join(pluginsDir, "no-manifest"), { recursive: true });
50
59
 
51
- expect(listInstalledPluginDirs().map((p) => p.name)).toEqual(["alpha"]);
60
+ expect(
61
+ listInstalledPluginDirs()
62
+ .map((p) => p.name)
63
+ .sort(),
64
+ ).toEqual(["alpha", "standard"]);
52
65
  });
53
66
 
54
67
  test("skips a symlinked plugin root that points outside the plugins dir", () => {
@@ -24,17 +24,27 @@ import {
24
24
  function makePluginsDir(
25
25
  plugins: Record<
26
26
  string,
27
- { mcpJson?: string; packageJson?: string; disabled?: boolean }
27
+ {
28
+ mcpJson?: string;
29
+ packageJson?: string | null;
30
+ pluginJson?: string;
31
+ disabled?: boolean;
32
+ }
28
33
  >,
29
34
  ): string {
30
35
  const root = mkdtempSync(join(tmpdir(), "vellum-plugin-mcp-"));
31
36
  for (const [name, spec] of Object.entries(plugins)) {
32
37
  const dir = join(root, name);
33
38
  mkdirSync(dir, { recursive: true });
34
- writeFileSync(
35
- join(dir, "package.json"),
36
- spec.packageJson ?? JSON.stringify({ name, version: "1.0.0" }),
37
- );
39
+ if (spec.packageJson !== null) {
40
+ writeFileSync(
41
+ join(dir, "package.json"),
42
+ spec.packageJson ?? JSON.stringify({ name, version: "1.0.0" }),
43
+ );
44
+ }
45
+ if (spec.pluginJson !== undefined) {
46
+ writeFileSync(join(dir, "plugin.json"), spec.pluginJson);
47
+ }
38
48
  if (spec.mcpJson !== undefined) {
39
49
  writeFileSync(join(dir, "mcp.json"), spec.mcpJson);
40
50
  }
@@ -137,6 +147,26 @@ describe("readPluginMcpServers", () => {
137
147
  });
138
148
  });
139
149
 
150
+ test("reads MCP servers from a standard-only plugin", () => {
151
+ const dir = makePluginsDir({
152
+ unabyss: {
153
+ packageJson: null,
154
+ pluginJson: JSON.stringify({
155
+ $schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
156
+ name: "unabyss",
157
+ }),
158
+ mcpJson: VALID_MANIFEST,
159
+ },
160
+ });
161
+
162
+ const { servers, issues } = readPluginMcpServers({
163
+ workspacePluginsDir: dir,
164
+ });
165
+
166
+ expect(issues).toEqual([]);
167
+ expect(servers.map((server) => server.id)).toEqual(["unabyss"]);
168
+ });
169
+
140
170
  test("defaults an omitted type to streamable-http", () => {
141
171
  const dir = makePluginsDir({
142
172
  openseo: {
@@ -205,7 +235,7 @@ describe("readPluginMcpServers", () => {
205
235
  workspacePluginsDir: root,
206
236
  });
207
237
  expect(servers).toEqual([]);
208
- expect(issues[0].message).toContain("package.json");
238
+ expect(issues[0].message).toContain("selected plugin manifest");
209
239
  });
210
240
 
211
241
  test("ignores a directory whose package.json is unparseable", () => {
@@ -216,7 +246,7 @@ describe("readPluginMcpServers", () => {
216
246
  workspacePluginsDir: dir,
217
247
  });
218
248
  expect(servers).toEqual([]);
219
- expect(issues[0].message).toContain("package.json");
249
+ expect(issues[0].message).toContain("selected plugin manifest");
220
250
  });
221
251
 
222
252
  test("ignores a directory whose package.json has no name", () => {
@@ -230,7 +260,7 @@ describe("readPluginMcpServers", () => {
230
260
  workspacePluginsDir: dir,
231
261
  });
232
262
  expect(servers).toEqual([]);
233
- expect(issues[0].message).toContain("package.json");
263
+ expect(issues[0].message).toContain("selected plugin manifest");
234
264
  });
235
265
 
236
266
  test("skips plugins with no mcp.json without complaining", () => {