@vellumai/cli 0.7.0 → 0.7.2

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 (54) hide show
  1. package/AGENTS.md +3 -11
  2. package/README.md +49 -0
  3. package/bun.lock +0 -15
  4. package/package.json +1 -6
  5. package/src/__tests__/backup.test.ts +591 -0
  6. package/src/__tests__/config-utils.test.ts +35 -48
  7. package/src/__tests__/teleport.test.ts +597 -37
  8. package/src/commands/backup.ts +149 -70
  9. package/src/commands/client.ts +56 -14
  10. package/src/commands/events.ts +3 -0
  11. package/src/commands/exec.ts +34 -12
  12. package/src/commands/hatch.ts +3 -7
  13. package/src/commands/login.ts +15 -33
  14. package/src/commands/logs.ts +2 -7
  15. package/src/commands/ps.ts +41 -6
  16. package/src/commands/restore.ts +32 -47
  17. package/src/commands/setup.ts +38 -73
  18. package/src/commands/ssh.ts +2 -5
  19. package/src/commands/teleport.ts +148 -34
  20. package/src/commands/tunnel.ts +2 -7
  21. package/src/commands/upgrade.ts +114 -7
  22. package/src/commands/wake.ts +5 -16
  23. package/src/components/DefaultMainScreen.tsx +65 -129
  24. package/src/index.ts +2 -13
  25. package/src/lib/__tests__/docker.test.ts +50 -32
  26. package/src/lib/__tests__/local-runtime-client.test.ts +308 -25
  27. package/src/lib/__tests__/platform-client-signed-url.test.ts +237 -2
  28. package/src/lib/__tests__/runtime-url.test.ts +125 -0
  29. package/src/lib/__tests__/terminal-session.test.ts +202 -0
  30. package/src/lib/assistant-client.ts +18 -26
  31. package/src/lib/assistant-config.ts +34 -41
  32. package/src/lib/backup-ops.ts +43 -17
  33. package/src/lib/cli-error.ts +1 -0
  34. package/src/lib/client-identity.ts +1 -1
  35. package/src/lib/config-utils.ts +1 -97
  36. package/src/lib/docker-statefulset.ts +381 -0
  37. package/src/lib/docker.ts +8 -247
  38. package/src/lib/guardian-token.ts +56 -6
  39. package/src/lib/hatch-local.ts +3 -26
  40. package/src/lib/job-polling.ts +1 -1
  41. package/src/lib/local-runtime-client.ts +162 -28
  42. package/src/lib/local.ts +35 -64
  43. package/src/lib/ngrok.ts +36 -26
  44. package/src/lib/platform-client.ts +97 -221
  45. package/src/lib/platform-releases.ts +23 -0
  46. package/src/lib/retire-local.ts +2 -2
  47. package/src/lib/runtime-url.ts +52 -0
  48. package/src/lib/sync-cloud-assistants.ts +126 -0
  49. package/src/lib/terminal-client.ts +6 -1
  50. package/src/lib/terminal-session.ts +127 -48
  51. package/src/lib/tui-log.ts +60 -0
  52. package/src/lib/upgrade-lifecycle.ts +65 -0
  53. package/src/lib/xdg-log.ts +10 -4
  54. package/src/commands/pair.ts +0 -212
@@ -1,8 +1,6 @@
1
1
  import { spawn } from "child_process";
2
- import { createHash, randomBytes, randomUUID } from "crypto";
3
- import { hostname, userInfo } from "os";
2
+ import { randomUUID } from "crypto";
4
3
  import { basename } from "path";
5
- import qrcode from "qrcode-terminal";
6
4
  import {
7
5
  useCallback,
8
6
  useEffect,
@@ -19,6 +17,7 @@ import { SPECIES_CONFIG, type Species } from "../lib/constants";
19
17
  import { callDoctorDaemon, type ChatLogEntry } from "../lib/doctor-client";
20
18
  import { checkHealth } from "../lib/health-check";
21
19
  import { appendHistory, loadHistory } from "../lib/input-history";
20
+ import { tuiLog } from "../lib/tui-log";
22
21
  import { statusEmoji, withStatusEmoji } from "../lib/status-emoji";
23
22
  import {
24
23
  getTerminalCapabilities,
@@ -45,7 +44,6 @@ export const SLASH_COMMANDS = [
45
44
  "/doctor",
46
45
  "/exit",
47
46
  "/help",
48
- "/pair",
49
47
  "/q",
50
48
  "/quit",
51
49
  "/retire",
@@ -172,14 +170,14 @@ async function runtimeRequest<T>(
172
170
  assistantId: string,
173
171
  path: string,
174
172
  init?: RequestInit,
175
- bearerToken?: string,
173
+ auth?: Record<string, string>,
176
174
  ): Promise<T> {
177
175
  const url = `${baseUrl}/v1/assistants/${assistantId}${path}`;
178
176
  const response = await fetch(url, {
179
177
  ...init,
180
178
  headers: {
181
179
  "Content-Type": "application/json",
182
- ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
180
+ ...auth,
183
181
  ...(init?.headers as Record<string, string> | undefined),
184
182
  },
185
183
  });
@@ -218,7 +216,7 @@ async function checkHealthRuntime(baseUrl: string): Promise<HealthResponse> {
218
216
  async function pollMessages(
219
217
  baseUrl: string,
220
218
  assistantId: string,
221
- bearerToken?: string,
219
+ auth?: Record<string, string>,
222
220
  ): Promise<ListMessagesResponse> {
223
221
  const params = new URLSearchParams({ conversationKey: assistantId });
224
222
  return runtimeRequest<ListMessagesResponse>(
@@ -226,7 +224,7 @@ async function pollMessages(
226
224
  assistantId,
227
225
  `/messages?${params.toString()}`,
228
226
  undefined,
229
- bearerToken,
227
+ auth,
230
228
  );
231
229
  }
232
230
 
@@ -235,7 +233,7 @@ async function sendMessage(
235
233
  assistantId: string,
236
234
  content: string,
237
235
  signal?: AbortSignal,
238
- bearerToken?: string,
236
+ auth?: Record<string, string>,
239
237
  ): Promise<SendMessageResponse> {
240
238
  return runtimeRequest<SendMessageResponse>(
241
239
  baseUrl,
@@ -251,7 +249,7 @@ async function sendMessage(
251
249
  }),
252
250
  signal,
253
251
  },
254
- bearerToken,
252
+ auth,
255
253
  );
256
254
  }
257
255
 
@@ -260,7 +258,7 @@ async function submitDecision(
260
258
  assistantId: string,
261
259
  requestId: string,
262
260
  decision: "allow" | "deny",
263
- bearerToken?: string,
261
+ auth?: Record<string, string>,
264
262
  ): Promise<SubmitDecisionResponse> {
265
263
  return runtimeRequest<SubmitDecisionResponse>(
266
264
  baseUrl,
@@ -270,7 +268,7 @@ async function submitDecision(
270
268
  method: "POST",
271
269
  body: JSON.stringify({ requestId, decision }),
272
270
  },
273
- bearerToken,
271
+ auth,
274
272
  );
275
273
  }
276
274
 
@@ -281,7 +279,7 @@ async function addTrustRule(
281
279
  pattern: string,
282
280
  scope: string,
283
281
  decision: "allow" | "deny",
284
- bearerToken?: string,
282
+ auth?: Record<string, string>,
285
283
  ): Promise<AddTrustRuleResponse> {
286
284
  return runtimeRequest<AddTrustRuleResponse>(
287
285
  baseUrl,
@@ -291,7 +289,7 @@ async function addTrustRule(
291
289
  method: "POST",
292
290
  body: JSON.stringify({ requestId, pattern, scope, decision }),
293
291
  },
294
- bearerToken,
292
+ auth,
295
293
  );
296
294
  }
297
295
 
@@ -350,26 +348,39 @@ async function* streamEvents(
350
348
  assistantId: string,
351
349
  conversationKey: string,
352
350
  signal: AbortSignal,
353
- bearerToken?: string,
351
+ auth?: Record<string, string>,
354
352
  ): AsyncGenerator<SseEvent> {
355
353
  const params = new URLSearchParams({ conversationKey });
356
354
  const url = `${baseUrl}/v1/assistants/${assistantId}/events?${params.toString()}`;
355
+ const clientHeaders = getClientRegistrationHeaders();
356
+ tuiLog.info("sse connect", { url, clientHeaders });
357
357
  const response = await fetch(url, {
358
358
  headers: {
359
359
  Accept: "text/event-stream",
360
- ...(bearerToken ? { Authorization: `Bearer ${bearerToken}` } : {}),
361
- ...getClientRegistrationHeaders(),
360
+ ...auth,
361
+ ...clientHeaders,
362
362
  },
363
363
  signal,
364
364
  });
365
365
 
366
+ tuiLog.info("sse response", {
367
+ status: response.status,
368
+ statusText: response.statusText,
369
+ contentType: response.headers.get("content-type"),
370
+ });
371
+
366
372
  if (!response.ok) {
367
373
  const body = await response.text().catch(() => "");
374
+ tuiLog.error("sse connection failed", {
375
+ status: response.status,
376
+ body: body.slice(0, 500),
377
+ });
368
378
  throw new Error(
369
379
  `SSE connection failed (${response.status}): ${body || response.statusText}`,
370
380
  );
371
381
  }
372
382
  if (!response.body) {
383
+ tuiLog.error("sse response has no body");
373
384
  throw new Error("No response body from SSE endpoint");
374
385
  }
375
386
 
@@ -443,7 +454,7 @@ async function handleConfirmationPrompt(
443
454
  requestId: string,
444
455
  confirmation: PendingConfirmation,
445
456
  chatApp: ChatAppHandle,
446
- bearerToken?: string,
457
+ auth?: Record<string, string>,
447
458
  ): Promise<void> {
448
459
  const preview = formatConfirmationPreview(
449
460
  confirmation.toolName,
@@ -469,7 +480,7 @@ async function handleConfirmationPrompt(
469
480
  const index = await chatApp.showSelection("Tool Approval", options);
470
481
 
471
482
  if (index === 0) {
472
- await submitDecision(baseUrl, assistantId, requestId, "allow", bearerToken);
483
+ await submitDecision(baseUrl, assistantId, requestId, "allow", auth);
473
484
  chatApp.addStatus("\u2714 Allowed", "green");
474
485
  return;
475
486
  }
@@ -481,7 +492,7 @@ async function handleConfirmationPrompt(
481
492
  confirmation,
482
493
  chatApp,
483
494
  "always_allow",
484
- bearerToken,
495
+ auth,
485
496
  );
486
497
  return;
487
498
  }
@@ -493,12 +504,12 @@ async function handleConfirmationPrompt(
493
504
  confirmation,
494
505
  chatApp,
495
506
  "always_deny",
496
- bearerToken,
507
+ auth,
497
508
  );
498
509
  return;
499
510
  }
500
511
 
501
- await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken);
512
+ await submitDecision(baseUrl, assistantId, requestId, "deny", auth);
502
513
  chatApp.addStatus("\u2718 Denied", "yellow");
503
514
  }
504
515
 
@@ -509,7 +520,7 @@ async function handlePatternSelection(
509
520
  confirmation: PendingConfirmation,
510
521
  chatApp: ChatAppHandle,
511
522
  trustDecision: TrustDecision,
512
- bearerToken?: string,
523
+ auth?: Record<string, string>,
513
524
  ): Promise<void> {
514
525
  const allowlistOptions = confirmation.allowlistOptions ?? [];
515
526
  const label = trustDecision === "always_deny" ? "Denylist" : "Allowlist";
@@ -530,12 +541,12 @@ async function handlePatternSelection(
530
541
  chatApp,
531
542
  selectedPattern,
532
543
  trustDecision,
533
- bearerToken,
544
+ auth,
534
545
  );
535
546
  return;
536
547
  }
537
548
 
538
- await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken);
549
+ await submitDecision(baseUrl, assistantId, requestId, "deny", auth);
539
550
  chatApp.addStatus("\u2718 Denied", "yellow");
540
551
  }
541
552
 
@@ -547,7 +558,7 @@ async function handleScopeSelection(
547
558
  chatApp: ChatAppHandle,
548
559
  selectedPattern: string,
549
560
  trustDecision: TrustDecision,
550
- bearerToken?: string,
561
+ auth?: Record<string, string>,
551
562
  ): Promise<void> {
552
563
  const scopeOptions = confirmation.scopeOptions ?? [];
553
564
  const label = trustDecision === "always_deny" ? "Denylist" : "Allowlist";
@@ -564,14 +575,14 @@ async function handleScopeSelection(
564
575
  selectedPattern,
565
576
  scopeOptions[index].scope,
566
577
  ruleDecision,
567
- bearerToken,
578
+ auth,
568
579
  );
569
580
  await submitDecision(
570
581
  baseUrl,
571
582
  assistantId,
572
583
  requestId,
573
584
  ruleDecision === "deny" ? "deny" : "allow",
574
- bearerToken,
585
+ auth,
575
586
  );
576
587
  const ruleLabel =
577
588
  trustDecision === "always_deny" ? "Denylisted" : "Allowlisted";
@@ -583,7 +594,7 @@ async function handleScopeSelection(
583
594
  return;
584
595
  }
585
596
 
586
- await submitDecision(baseUrl, assistantId, requestId, "deny", bearerToken);
597
+ await submitDecision(baseUrl, assistantId, requestId, "deny", auth);
587
598
  chatApp.addStatus("\u2718 Denied", "yellow");
588
599
  }
589
600
 
@@ -739,10 +750,6 @@ function HelpDisplay(): ReactElement {
739
750
  {" /clear "}
740
751
  <Text dimColor>Clear the screen</Text>
741
752
  </Text>
742
- <Text>
743
- {" /pair "}
744
- <Text dimColor>Generate a QR code for mobile device pairing</Text>
745
- </Text>
746
753
  <Text>
747
754
  {" /help, ? "}
748
755
  <Text dimColor>Show this help</Text>
@@ -1354,7 +1361,9 @@ interface ChatAppProps {
1354
1361
  runtimeUrl: string;
1355
1362
  assistantId: string;
1356
1363
  species: Species;
1357
- bearerToken?: string;
1364
+ /** Pre-built auth headers (e.g. { Authorization: "Bearer ..." } for local,
1365
+ * { "X-Session-Token": "...", "Vellum-Organization-Id": "..." } for platform). */
1366
+ auth?: Record<string, string>;
1358
1367
  project?: string;
1359
1368
  zone?: string;
1360
1369
  onExit: () => void;
@@ -1365,7 +1374,7 @@ function ChatApp({
1365
1374
  runtimeUrl,
1366
1375
  assistantId,
1367
1376
  species,
1368
- bearerToken,
1377
+ auth,
1369
1378
  project,
1370
1379
  zone,
1371
1380
  onExit,
@@ -1653,6 +1662,10 @@ function ChatApp({
1653
1662
 
1654
1663
  try {
1655
1664
  const health = await checkHealthRuntime(runtimeUrl);
1665
+ tuiLog.info("health check", {
1666
+ status: health.status,
1667
+ message: health.message,
1668
+ });
1656
1669
  h.hideSpinner();
1657
1670
  h.updateHealthStatus(health.status);
1658
1671
  if (health.status === "healthy" || health.status === "ok") {
@@ -1674,7 +1687,7 @@ function ChatApp({
1674
1687
  const historyResponse = await pollMessages(
1675
1688
  runtimeUrl,
1676
1689
  assistantId,
1677
- bearerToken,
1690
+ auth,
1678
1691
  );
1679
1692
  h.hideSpinner();
1680
1693
  if (historyResponse.messages.length > 0) {
@@ -1699,7 +1712,7 @@ function ChatApp({
1699
1712
  assistantId,
1700
1713
  assistantId,
1701
1714
  sseAc.signal,
1702
- bearerToken,
1715
+ auth,
1703
1716
  )) {
1704
1717
  const hRef = handleRef_.current;
1705
1718
  if (!hRef) continue;
@@ -1770,7 +1783,7 @@ function ChatApp({
1770
1783
  event.persistentDecisionsAllowed,
1771
1784
  },
1772
1785
  hRef,
1773
- bearerToken,
1786
+ auth,
1774
1787
  );
1775
1788
  hRef.showSpinner("Working...");
1776
1789
  break;
@@ -1801,7 +1814,7 @@ function ChatApp({
1801
1814
  delivery,
1802
1815
  }),
1803
1816
  },
1804
- bearerToken,
1817
+ auth,
1805
1818
  );
1806
1819
  },
1807
1820
  );
@@ -1850,9 +1863,12 @@ function ChatApp({
1850
1863
  break;
1851
1864
  }
1852
1865
  }
1853
- } catch {
1866
+ } catch (sseErr) {
1854
1867
  // Stream ended — only report if not intentionally aborted
1855
1868
  if (!sseAc.signal.aborted) {
1869
+ tuiLog.warn("sse stream disconnected", {
1870
+ error: String(sseErr),
1871
+ });
1856
1872
  handleRef_.current?.addStatus(
1857
1873
  "SSE stream disconnected — will reconnect on next message",
1858
1874
  "yellow",
@@ -1869,10 +1885,11 @@ function ChatApp({
1869
1885
  setConnectionState("connected");
1870
1886
  return true;
1871
1887
  } catch (err) {
1888
+ const msg = err instanceof Error ? err.message : String(err);
1889
+ tuiLog.error("connection failed", { error: msg });
1872
1890
  h.hideSpinner();
1873
1891
  connectingRef.current = false;
1874
1892
  h.updateHealthStatus("unreachable");
1875
- const msg = err instanceof Error ? err.message : String(err);
1876
1893
  setConnectionState("error");
1877
1894
  setConnectionError(msg);
1878
1895
  h.addStatus(
@@ -1881,7 +1898,7 @@ function ChatApp({
1881
1898
  );
1882
1899
  return false;
1883
1900
  }
1884
- }, [runtimeUrl, assistantId, bearerToken]);
1901
+ }, [runtimeUrl, assistantId, auth]);
1885
1902
 
1886
1903
  const handleInput = useCallback(
1887
1904
  async (input: string): Promise<void> => {
@@ -2088,9 +2105,7 @@ function ChatApp({
2088
2105
  method: "POST",
2089
2106
  headers: {
2090
2107
  "Content-Type": "application/json",
2091
- ...(bearerToken
2092
- ? { Authorization: `Bearer ${bearerToken}` }
2093
- : {}),
2108
+ ...auth,
2094
2109
  },
2095
2110
  body: JSON.stringify({
2096
2111
  conversationKey: assistantId,
@@ -2149,85 +2164,6 @@ function ChatApp({
2149
2164
  return;
2150
2165
  }
2151
2166
 
2152
- if (trimmed === "/pair") {
2153
- h.showSpinner("Generating pairing credentials...");
2154
-
2155
- const isConnected = await ensureConnected();
2156
- if (!isConnected) {
2157
- h.hideSpinner();
2158
- h.showError("Cannot pair — not connected to the assistant runtime.");
2159
- return;
2160
- }
2161
-
2162
- try {
2163
- const pairingRequestId = randomUUID();
2164
- const pairingSecret = randomBytes(32).toString("hex");
2165
- const gatewayUrl = runtimeUrl;
2166
-
2167
- // Call /pairing/register on the gateway (dedicated pairing proxy route)
2168
- const registerUrl = `${runtimeUrl}/pairing/register`;
2169
- const registerRes = await fetch(registerUrl, {
2170
- method: "POST",
2171
- headers: {
2172
- "Content-Type": "application/json",
2173
- ...(bearerToken
2174
- ? { Authorization: `Bearer ${bearerToken}` }
2175
- : {}),
2176
- },
2177
- body: JSON.stringify({
2178
- pairingRequestId,
2179
- pairingSecret,
2180
- gatewayUrl,
2181
- }),
2182
- });
2183
-
2184
- if (!registerRes.ok) {
2185
- const body = await registerRes.text().catch(() => "");
2186
- throw new Error(
2187
- `HTTP ${registerRes.status}: ${body || registerRes.statusText}`,
2188
- );
2189
- }
2190
-
2191
- let username: string;
2192
- try {
2193
- username = userInfo().username;
2194
- } catch {
2195
- username = "";
2196
- }
2197
- const hostId = createHash("sha256")
2198
- .update(hostname() + username)
2199
- .digest("hex");
2200
- const payload = JSON.stringify({
2201
- type: "vellum-assistant",
2202
- v: 4,
2203
- id: hostId,
2204
- g: gatewayUrl,
2205
- pairingRequestId,
2206
- pairingSecret,
2207
- });
2208
-
2209
- const qrString = await new Promise<string>((resolve) => {
2210
- qrcode.generate(payload, { small: true }, (code: string) => {
2211
- resolve(code);
2212
- });
2213
- });
2214
-
2215
- h.hideSpinner();
2216
- h.addStatus(
2217
- `Pairing Ready\n\n` +
2218
- `Scan this QR code with the Vellum iOS app:\n\n` +
2219
- `${qrString}\n` +
2220
- `This pairing request expires in 5 minutes. Run /pair again to generate a new one.`,
2221
- );
2222
- } catch (err) {
2223
- h.hideSpinner();
2224
- h.showError(
2225
- `Pairing failed: ${err instanceof Error ? err.message : err}`,
2226
- );
2227
- }
2228
- return;
2229
- }
2230
-
2231
2167
  if (busyRef.current) {
2232
2168
  // /btw is already handled above this block
2233
2169
  if (!trimmed.startsWith("/")) {
@@ -2256,7 +2192,7 @@ function ChatApp({
2256
2192
  assistantId,
2257
2193
  trimmed,
2258
2194
  controller.signal,
2259
- bearerToken,
2195
+ auth,
2260
2196
  );
2261
2197
  clearTimeout(timeoutId);
2262
2198
  if (sendResult.accepted) {
@@ -2307,7 +2243,7 @@ function ChatApp({
2307
2243
  assistantId,
2308
2244
  trimmed,
2309
2245
  controller.signal,
2310
- bearerToken,
2246
+ auth,
2311
2247
  );
2312
2248
  clearTimeout(timeoutId);
2313
2249
  if (!sendResult.accepted) {
@@ -2334,7 +2270,7 @@ function ChatApp({
2334
2270
  [
2335
2271
  runtimeUrl,
2336
2272
  assistantId,
2337
- bearerToken,
2273
+ auth,
2338
2274
  project,
2339
2275
  zone,
2340
2276
  cleanup,
@@ -2648,7 +2584,7 @@ export function renderChatApp(
2648
2584
  assistantId: string,
2649
2585
  species: Species,
2650
2586
  onExit: () => void,
2651
- options?: { bearerToken?: string; project?: string; zone?: string },
2587
+ options?: { auth?: Record<string, string>; project?: string; zone?: string },
2652
2588
  ): ChatAppInstance {
2653
2589
  let chatHandle: ChatAppHandle | null = null;
2654
2590
 
@@ -2657,7 +2593,7 @@ export function renderChatApp(
2657
2593
  runtimeUrl={runtimeUrl}
2658
2594
  assistantId={assistantId}
2659
2595
  species={species}
2660
- bearerToken={options?.bearerToken}
2596
+ auth={options?.auth}
2661
2597
  project={options?.project}
2662
2598
  zone={options?.zone}
2663
2599
  onExit={onExit}
package/src/index.ts CHANGED
@@ -11,7 +11,6 @@ import { hatch } from "./commands/hatch";
11
11
  import { login, logout, whoami } from "./commands/login";
12
12
  import { logs } from "./commands/logs";
13
13
  import { message } from "./commands/message";
14
- import { pair } from "./commands/pair";
15
14
  import { ps } from "./commands/ps";
16
15
  import { recover } from "./commands/recover";
17
16
  import { restore } from "./commands/restore";
@@ -26,12 +25,7 @@ import { tunnel } from "./commands/tunnel";
26
25
  import { upgrade } from "./commands/upgrade";
27
26
  import { use } from "./commands/use";
28
27
  import { wake } from "./commands/wake";
29
- import {
30
- getActiveAssistant,
31
- findAssistantByName,
32
- loadLatestAssistant,
33
- setActiveAssistant,
34
- } from "./lib/assistant-config";
28
+ import { resolveAssistant, setActiveAssistant } from "./lib/assistant-config";
35
29
  import { loadGuardianToken } from "./lib/guardian-token";
36
30
  import { checkHealth } from "./lib/health-check";
37
31
 
@@ -47,7 +41,6 @@ const commands = {
47
41
  logout,
48
42
  logs,
49
43
  message,
50
- pair,
51
44
  ps,
52
45
  recover,
53
46
  restore,
@@ -82,7 +75,6 @@ function printHelp(): void {
82
75
  console.log(" login Log in to the Vellum platform");
83
76
  console.log(" logout Log out of the Vellum platform");
84
77
  console.log(" message Send a message to a running assistant");
85
- console.log(" pair Pair with a remote assistant via QR code");
86
78
  console.log(
87
79
  " ps List assistants (or processes for a specific assistant)",
88
80
  );
@@ -129,10 +121,7 @@ function applyNoColorFlags(argv: string[]): void {
129
121
  * Otherwise return false so the caller can fall back to help text.
130
122
  */
131
123
  async function tryLaunchClient(): Promise<boolean> {
132
- const activeName = getActiveAssistant();
133
- const entry = activeName
134
- ? findAssistantByName(activeName)
135
- : loadLatestAssistant();
124
+ const entry = resolveAssistant();
136
125
 
137
126
  if (!entry) return false;
138
127
 
@@ -29,50 +29,41 @@ function buildAssistantArgs(
29
29
  return builders.assistant();
30
30
  }
31
31
 
32
- describe("serviceDockerRunArgs — assistant", () => {
33
- test("grants the minimum capability set needed for DinD (SYS_ADMIN + NET_ADMIN) rather than --privileged", () => {
34
- const args = buildAssistantArgs();
35
- expect(args).not.toContain("--privileged");
36
- // --cap-add SYS_ADMIN and --cap-add NET_ADMIN are each passed as two
37
- // adjacent args: "--cap-add" followed by the capability name.
38
- const sysAdminIdx = args.indexOf("SYS_ADMIN");
39
- expect(sysAdminIdx).toBeGreaterThan(0);
40
- expect(args[sysAdminIdx - 1]).toBe("--cap-add");
41
- const netAdminIdx = args.indexOf("NET_ADMIN");
42
- expect(netAdminIdx).toBeGreaterThan(0);
43
- expect(args[netAdminIdx - 1]).toBe("--cap-add");
32
+ function buildGatewayArgs(
33
+ overrides: Partial<Parameters<typeof serviceDockerRunArgs>[0]> = {},
34
+ ): string[] {
35
+ const res = dockerResourceNames(instanceName);
36
+ const builders = serviceDockerRunArgs({
37
+ gatewayPort: 7830,
38
+ imageTags,
39
+ instanceName,
40
+ res,
41
+ ...overrides,
44
42
  });
43
+ return builders.gateway();
44
+ }
45
45
 
46
- test("disables the default seccomp and AppArmor profiles so the inner dockerd can mount overlayfs and run pivot_root", () => {
46
+ describe("serviceDockerRunArgs assistant", () => {
47
+ test("does not grant elevated capabilities or disable security profiles", () => {
47
48
  const args = buildAssistantArgs();
48
- const seccompIdx = args.indexOf("seccomp=unconfined");
49
- expect(seccompIdx).toBeGreaterThan(0);
50
- expect(args[seccompIdx - 1]).toBe("--security-opt");
51
- const apparmorIdx = args.indexOf("apparmor=unconfined");
52
- expect(apparmorIdx).toBeGreaterThan(0);
53
- expect(args[apparmorIdx - 1]).toBe("--security-opt");
49
+ expect(args).not.toContain("--privileged");
50
+ expect(args).not.toContain("--cap-add");
51
+ expect(args).not.toContain("SYS_ADMIN");
52
+ expect(args).not.toContain("NET_ADMIN");
53
+ expect(args).not.toContain("seccomp=unconfined");
54
+ expect(args).not.toContain("apparmor=unconfined");
54
55
  });
55
56
 
56
- test("mounts a dedicated named volume at /var/lib/docker for the inner dockerd data store", () => {
57
+ test("does not mount a dockerd data volume", () => {
57
58
  const args = buildAssistantArgs();
58
- const spec = `${instanceName}-dockerd-data:/var/lib/docker`;
59
- const mountIndex = args.indexOf(spec);
60
- expect(mountIndex).toBeGreaterThan(0);
61
- expect(args[mountIndex - 1]).toBe("-v");
59
+ expect(args.some((a) => a.includes("/var/lib/docker"))).toBe(false);
62
60
  });
63
61
 
64
- test("does NOT bind-mount the host Docker socket (DinD replaces host-socket access)", () => {
62
+ test("does NOT bind-mount the host Docker socket", () => {
65
63
  const args = buildAssistantArgs();
66
64
  expect(args).not.toContain("/var/run/docker.sock:/var/run/docker.sock");
67
65
  });
68
66
 
69
- test("does NOT set VELLUM_WORKSPACE_VOLUME_NAME (legacy Phase 1.8 hint, no longer needed in DinD)", () => {
70
- const args = buildAssistantArgs();
71
- expect(
72
- args.some((a) => a.startsWith("VELLUM_WORKSPACE_VOLUME_NAME=")),
73
- ).toBe(false);
74
- });
75
-
76
67
  test("keeps existing workspace and socket volume mounts intact", () => {
77
68
  const args = buildAssistantArgs();
78
69
  expect(args).toContain(`${instanceName}-workspace:/workspace`);
@@ -112,6 +103,33 @@ describe("serviceDockerRunArgs — assistant", () => {
112
103
  });
113
104
  });
114
105
 
106
+ describe("serviceDockerRunArgs — gateway", () => {
107
+ const savedVelayBaseUrl = process.env.VELAY_BASE_URL;
108
+
109
+ beforeEach(() => {
110
+ delete process.env.VELAY_BASE_URL;
111
+ });
112
+
113
+ afterEach(() => {
114
+ if (savedVelayBaseUrl === undefined) delete process.env.VELAY_BASE_URL;
115
+ else process.env.VELAY_BASE_URL = savedVelayBaseUrl;
116
+ });
117
+
118
+ test("passes VELAY_BASE_URL into the gateway container when set", () => {
119
+ process.env.VELAY_BASE_URL = "http://host.docker.internal:8501";
120
+
121
+ expect(buildGatewayArgs()).toContain(
122
+ "VELAY_BASE_URL=http://host.docker.internal:8501",
123
+ );
124
+ });
125
+
126
+ test("omits VELAY_BASE_URL from gateway args when unset", () => {
127
+ expect(
128
+ buildGatewayArgs().some((arg) => arg.startsWith("VELAY_BASE_URL=")),
129
+ ).toBe(false);
130
+ });
131
+ });
132
+
115
133
  describe("VELLUM_AVATAR_DEVICE passthrough", () => {
116
134
  const savedValue = process.env[AVATAR_DEVICE_ENV_VAR];
117
135