@vellumai/cli 0.7.3 → 0.8.1

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.
@@ -1,4 +1,6 @@
1
- import { hostname } from "os";
1
+ import { existsSync } from "node:fs";
2
+ import { hostname } from "node:os";
3
+ import path from "node:path";
2
4
 
3
5
  import {
4
6
  findAssistantByName,
@@ -14,6 +16,7 @@ import { loadGuardianToken } from "../lib/guardian-token";
14
16
  import { getLocalLanIPv4 } from "../lib/local";
15
17
  import {
16
18
  CLI_INTERFACE_ID,
19
+ WEB_INTERFACE_ID,
17
20
  getClientRegistrationHeaders,
18
21
  } from "../lib/client-identity";
19
22
  import {
@@ -22,7 +25,7 @@ import {
22
25
  } from "../lib/platform-client";
23
26
  import { tuiLog } from "../lib/tui-log";
24
27
 
25
- const SUPPORTED_INTERFACES = ["cli"] as const;
28
+ const SUPPORTED_INTERFACES = ["cli", "web"] as const;
26
29
  type SupportedInterface = (typeof SUPPORTED_INTERFACES)[number];
27
30
 
28
31
  const ANSI = {
@@ -133,12 +136,6 @@ function parseArgs(): ParsedArgs {
133
136
  assistantId = flagArgs[++i];
134
137
  } else if ((flag === "--interface" || flag === "-i") && flagArgs[i + 1]) {
135
138
  const value = flagArgs[++i];
136
- if (value === "web") {
137
- console.error(
138
- `--interface web is not yet supported. Coming soon.`,
139
- );
140
- process.exit(1);
141
- }
142
139
  if (!(SUPPORTED_INTERFACES as readonly string[]).includes(value)) {
143
140
  console.error(
144
141
  `Unknown interface '${value}'. Supported: ${SUPPORTED_INTERFACES.join(", ")}.`,
@@ -213,7 +210,7 @@ ${ANSI.bold}ARGUMENTS:${ANSI.reset}
213
210
  ${ANSI.bold}OPTIONS:${ANSI.reset}
214
211
  -u, --url <url> Runtime URL
215
212
  -a, --assistant-id <id> Assistant ID
216
- -i, --interface <id> Interface identifier (default: cli)
213
+ -i, --interface <id> Interface identifier: cli (default) or web
217
214
  -h, --help Show this help message
218
215
 
219
216
  ${ANSI.bold}DEFAULTS:${ANSI.reset}
@@ -228,6 +225,66 @@ ${ANSI.bold}EXAMPLES:${ANSI.reset}
228
225
  `);
229
226
  }
230
227
 
228
+ /**
229
+ * Walk up from this file's location to find a sibling `clients/web` package.
230
+ *
231
+ * Returns the absolute path to its directory, or null when not found —
232
+ * e.g. when the CLI is installed via npm/bunx, where the `clients/web`
233
+ * source isn't shipped alongside `@vellumai/cli`. For now we treat the
234
+ * `--interface web` path as source-checkout-only.
235
+ */
236
+ function findClientsWebDir(): string | null {
237
+ let dir = import.meta.dir;
238
+ for (let depth = 0; depth < 8; depth++) {
239
+ const candidate = path.join(dir, "clients", "web", "package.json");
240
+ if (existsSync(candidate)) {
241
+ return path.dirname(candidate);
242
+ }
243
+ const parent = path.dirname(dir);
244
+ if (parent === dir) break;
245
+ dir = parent;
246
+ }
247
+ return null;
248
+ }
249
+
250
+ /**
251
+ * Spawn the `clients/web` package's `local` script and proxy its lifecycle.
252
+ *
253
+ * The web client is deliberately not declared as a dependency of `@vellumai/cli`:
254
+ * the CLI is published, the web package is not. Locating it on disk and
255
+ * shelling out keeps the two packages independent.
256
+ */
257
+ async function runWebInterface(): Promise<void> {
258
+ const webDir = findClientsWebDir();
259
+ if (!webDir) {
260
+ console.error(
261
+ `${ANSI.bold}--interface web${ANSI.reset}: unable to locate ` +
262
+ `clients/web. This interface currently requires running ` +
263
+ `vellum from a source checkout of vellum-assistant.`,
264
+ );
265
+ process.exit(1);
266
+ }
267
+
268
+ const child = Bun.spawn({
269
+ cmd: ["bun", "run", "local"],
270
+ cwd: webDir,
271
+ stdio: ["inherit", "inherit", "inherit"],
272
+ });
273
+
274
+ const forward = (signal: "SIGINT" | "SIGTERM"): void => {
275
+ try {
276
+ child.kill(signal);
277
+ } catch {
278
+ // Child already exited; nothing to forward.
279
+ }
280
+ };
281
+ process.on("SIGINT", () => forward("SIGINT"));
282
+ process.on("SIGTERM", () => forward("SIGTERM"));
283
+
284
+ const exitCode = await child.exited;
285
+ process.exit(typeof exitCode === "number" ? exitCode : 0);
286
+ }
287
+
231
288
  export async function client(): Promise<void> {
232
289
  const {
233
290
  runtimeUrl,
@@ -241,6 +298,11 @@ export async function client(): Promise<void> {
241
298
  zone,
242
299
  } = parseArgs();
243
300
 
301
+ if (interfaceId === WEB_INTERFACE_ID) {
302
+ await runWebInterface();
303
+ return;
304
+ }
305
+
244
306
  tuiLog.init();
245
307
  tuiLog.info("session start", {
246
308
  runtimeUrl,
@@ -49,13 +49,14 @@ interface AssistantEvent {
49
49
  content?: string;
50
50
  message?: string;
51
51
  chunk?: string;
52
+ tags?: unknown;
52
53
  conversationId?: string;
53
54
  [key: string]: unknown;
54
55
  };
55
56
  }
56
57
 
57
58
  /** Render an event as human-readable markdown to stdout. */
58
- function renderMarkdown(event: AssistantEvent): void {
59
+ export function renderMarkdown(event: AssistantEvent): void {
59
60
  const msg = event.message;
60
61
  switch (msg.type) {
61
62
  case "assistant_text_delta":
@@ -94,6 +95,17 @@ function renderMarkdown(event: AssistantEvent): void {
94
95
  case "user_message_echo":
95
96
  console.log(`\n**You:** ${msg.text}`);
96
97
  break;
98
+ case "sync_changed": {
99
+ const tags = Array.isArray(msg.tags)
100
+ ? msg.tags.filter((tag): tag is string => typeof tag === "string")
101
+ : [];
102
+ const renderedTags =
103
+ tags.length > 0
104
+ ? tags.map((tag) => `\`${tag}\``).join(", ")
105
+ : "(no tags)";
106
+ console.log(`\n> **Sync changed:** ${renderedTags}`);
107
+ break;
108
+ }
97
109
  default:
98
110
  // Silently skip events that don't have a markdown representation
99
111
  // (e.g. heartbeat comments, activity states, etc.)
@@ -287,9 +287,10 @@ export async function login(): Promise<void> {
287
287
 
288
288
  // Sync cloud assistants from the platform into the local lockfile.
289
289
  // This ensures `vellum ps` shows managed assistants immediately
290
- // after login (e.g. after a retire-and-rehatch cycle).
290
+ // after login (e.g. after a retire-and-rehatch cycle). We've just
291
+ // saved this token, so it's guaranteed non-empty here.
291
292
  try {
292
- const result = await syncCloudAssistants();
293
+ const result = await syncCloudAssistants(token);
293
294
  if (result) {
294
295
  const total = result.added + result.removed;
295
296
  if (total > 0) {
@@ -15,6 +15,7 @@ import {
15
15
  fetchManagedPs,
16
16
  type ManagedProcessEntry,
17
17
  } from "../lib/health-check";
18
+ import { readPlatformToken } from "../lib/platform-client";
18
19
  import { dockerResourceNames } from "../lib/docker";
19
20
  import { existsSync } from "fs";
20
21
  import {
@@ -472,7 +473,7 @@ async function showAssistantProcesses(entry: AssistantEntry): Promise<void> {
472
473
 
473
474
  // ── List all assistants (no arg) ────────────────────────────────
474
475
 
475
- async function listAllAssistants(verbose: boolean): Promise<void> {
476
+ export async function listAllAssistants(verbose: boolean): Promise<void> {
476
477
  const { name: envName, source: envSource } = resolveEnvironmentSource();
477
478
  const sourceLabels: Record<typeof envSource, string> = {
478
479
  flag: "--environment flag",
@@ -486,23 +487,33 @@ async function listAllAssistants(verbose: boolean): Promise<void> {
486
487
  ? (msg) => console.log(` [verbose] ${msg}`)
487
488
  : undefined;
488
489
 
489
- // Refresh cloud assistants from the platform before listing.
490
- const syncResult = await syncCloudAssistants({ log });
491
-
492
- // Show platform login status
493
- if (syncResult) {
494
- const parts = [`Platform: logged in`];
495
- if (syncResult.email) parts[0] += ` as ${syncResult.email}`;
496
- if (syncResult.added > 0 || syncResult.removed > 0) {
497
- const changes: string[] = [];
498
- if (syncResult.added > 0) changes.push(`${syncResult.added} added`);
499
- if (syncResult.removed > 0)
500
- changes.push(`${syncResult.removed} removed`);
501
- parts.push(`(${changes.join(", ")})`);
502
- }
503
- console.log(parts.join(" "));
504
- } else {
490
+ // Decide platform login status FIRST, before touching the network. With no
491
+ // local token we never enter the platform fetch path — so unreachable-host
492
+ // errors from the org-ID/user lookups can't leak onto stderr ahead of the
493
+ // "Platform: not logged in" line.
494
+ const platformToken = readPlatformToken();
495
+ if (!platformToken) {
496
+ log?.("No platform token found skipping cloud sync");
505
497
  console.log("Platform: not logged in");
498
+ } else {
499
+ const syncResult = await syncCloudAssistants(platformToken, { log });
500
+ if (syncResult) {
501
+ const parts = [`Platform: logged in`];
502
+ if (syncResult.email) parts[0] += ` as ${syncResult.email}`;
503
+ if (syncResult.added > 0 || syncResult.removed > 0) {
504
+ const changes: string[] = [];
505
+ if (syncResult.added > 0) changes.push(`${syncResult.added} added`);
506
+ if (syncResult.removed > 0)
507
+ changes.push(`${syncResult.removed} removed`);
508
+ parts.push(`(${changes.join(", ")})`);
509
+ }
510
+ console.log(parts.join(" "));
511
+ } else {
512
+ // We had a token but the platform fetch failed (offline, expired, etc.).
513
+ // Treat it the same as "not logged in" from a UX perspective — the user
514
+ // can't reach cloud-managed assistants right now either way.
515
+ console.log("Platform: not logged in");
516
+ }
506
517
  }
507
518
  console.log("");
508
519
 
@@ -333,6 +333,8 @@ interface SseEvent {
333
333
  allowedDomains?: string[];
334
334
  // message_complete fields
335
335
  source?: "main" | "aux";
336
+ // sync_changed fields
337
+ tags?: string[];
336
338
  [key: string]: unknown;
337
339
  }
338
340
 
@@ -1856,6 +1858,11 @@ function ChatApp({
1856
1858
  hRef.setBusy(false);
1857
1859
  break;
1858
1860
 
1861
+ case "sync_changed":
1862
+ // The interactive CLI does not currently keep any sync-tagged
1863
+ // caches, so generic invalidations are intentionally ignored.
1864
+ break;
1865
+
1859
1866
  default:
1860
1867
  // Ignore events we don't handle (activity state, traces, etc.)
1861
1868
  break;
@@ -2265,15 +2272,7 @@ function ChatApp({
2265
2272
  // racing with SSE events that may arrive during the sendMessage await.
2266
2273
  h.showSpinner("Working...");
2267
2274
  },
2268
- [
2269
- runtimeUrl,
2270
- assistantId,
2271
- auth,
2272
- project,
2273
- zone,
2274
- cleanup,
2275
- ensureConnected,
2276
- ],
2275
+ [runtimeUrl, assistantId, auth, project, zone, cleanup, ensureConnected],
2277
2276
  );
2278
2277
 
2279
2278
  const handleSubmit = useCallback(
@@ -128,6 +128,17 @@ describe("buildServiceRunArgs — gateway", () => {
128
128
  buildGatewayArgs().some((arg) => arg.startsWith("VELAY_BASE_URL=")),
129
129
  ).toBe(false);
130
130
  });
131
+
132
+ test("forces gateway to run as uid 0 so it can connect to the assistant's root-owned IPC socket (mirrors K8s securityContext.runAsUser=0)", () => {
133
+ const args = buildGatewayArgs();
134
+ const userIdx = args.indexOf("--user");
135
+ expect(userIdx).toBeGreaterThan(-1);
136
+ expect(args[userIdx + 1]).toBe("0");
137
+ });
138
+
139
+ test("assistant container does NOT get a --user override (image USER root wins)", () => {
140
+ expect(buildAssistantArgs().includes("--user")).toBe(false);
141
+ });
131
142
  });
132
143
 
133
144
  describe("VELLUM_AVATAR_DEVICE passthrough", () => {
@@ -18,6 +18,8 @@ import {
18
18
  getMultiInstanceDir,
19
19
  } from "./environments/paths.js";
20
20
  import { getCurrentEnvironment } from "./environments/resolve.js";
21
+ import { SEEDS } from "./environments/seeds.js";
22
+ import type { EnvironmentDefinition } from "./environments/types.js";
21
23
  import { probePort } from "./port-probe.js";
22
24
 
23
25
  /**
@@ -327,6 +329,69 @@ export function loadAllAssistants(): AssistantEntry[] {
327
329
  return readAssistants();
328
330
  }
329
331
 
332
+ /**
333
+ * Read the first existing lockfile for an explicitly-provided environment,
334
+ * without applying legacy migrations. This is the cross-env read path used by
335
+ * {@link loadAllAssistantsAcrossEnvs}: it deliberately bypasses
336
+ * {@link readLockfile} (which always resolves the *current* env) so callers
337
+ * can enumerate state from every env without flipping `process.env` or the
338
+ * persisted default. Migrations are skipped because we never want to write
339
+ * to another env's lockfile from the current env's process.
340
+ */
341
+ function readLockfileForEnv(env: EnvironmentDefinition): LockfileData {
342
+ for (const lockfilePath of getLockfilePaths(env)) {
343
+ if (!existsSync(lockfilePath)) continue;
344
+ try {
345
+ const raw = readFileSync(lockfilePath, "utf-8");
346
+ const parsed = JSON.parse(raw) as unknown;
347
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
348
+ return parsed as LockfileData;
349
+ }
350
+ } catch {
351
+ // Malformed; try next candidate
352
+ }
353
+ }
354
+ return {};
355
+ }
356
+
357
+ /**
358
+ * Load assistant entries from every known environment's lockfile.
359
+ *
360
+ * Each {@link SEEDS} entry has its own on-host data layout (config dir,
361
+ * lockfile path, data dir). A running assistant from `dev` is invisible to
362
+ * `loadAllAssistants()` when the current env is `local`, but its host
363
+ * processes (daemon/gateway/qdrant) still show up in `ps ax`. The orphan
364
+ * detector and `vellum clean` need the union of all envs' entries to avoid
365
+ * misclassifying — or worse, killing — another env's running services.
366
+ *
367
+ * Optional `envs` override is provided for testability so call sites can
368
+ * inject a curated env list with `lockfileDirOverride` set, without having
369
+ * to manipulate the global SEEDS table or process.env.
370
+ */
371
+ export function loadAllAssistantsAcrossEnvs(
372
+ envs?: EnvironmentDefinition[],
373
+ ): AssistantEntry[] {
374
+ const envList = envs ?? Object.values(SEEDS).map((env) => ({ ...env }));
375
+ const all: AssistantEntry[] = [];
376
+ for (const env of envList) {
377
+ const data = readLockfileForEnv(env);
378
+ const entries = data.assistants;
379
+ if (!Array.isArray(entries)) continue;
380
+ for (const raw of entries) {
381
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
382
+ const entry = raw as AssistantEntry;
383
+ if (
384
+ typeof entry.assistantId !== "string" ||
385
+ typeof entry.runtimeUrl !== "string"
386
+ ) {
387
+ continue;
388
+ }
389
+ all.push(entry);
390
+ }
391
+ }
392
+ return all;
393
+ }
394
+
330
395
  export function getActiveAssistant(): string | null {
331
396
  const data = readLockfile();
332
397
  return data.activeAssistant ?? null;
@@ -12,6 +12,7 @@ import { homedir } from "os";
12
12
  import { join } from "path";
13
13
 
14
14
  export const CLI_INTERFACE_ID = "cli";
15
+ export const WEB_INTERFACE_ID = "web";
15
16
 
16
17
  let cached: string | null = null;
17
18
 
package/src/lib/local.ts CHANGED
@@ -840,6 +840,42 @@ export function isGatewayWatchModeAvailable(): boolean {
840
840
  }
841
841
  }
842
842
 
843
+ /**
844
+ * Write (or overwrite) a shell wrapper at `<workspace>/bin/assistant` that
845
+ * pre-injects the three instance-specific env vars before exec-ing the real
846
+ * assistant binary from the app bundle.
847
+ *
848
+ * This lets developers invoke `<workspace>/bin/assistant <command>` directly
849
+ * from the terminal without manually setting env vars. Only created when a
850
+ * compiled `assistant` binary is present adjacent to the CLI executable (i.e.
851
+ * inside a desktop app bundle) — a no-op in source/watch mode.
852
+ *
853
+ * The wrapper is idempotent: safe to call on every daemon wake.
854
+ */
855
+ function writeAssistantWrapper(resources: LocalInstanceResources): void {
856
+ const assistantBinary = join(dirname(process.execPath), "assistant");
857
+ if (!existsSync(assistantBinary)) return;
858
+
859
+ const workspaceDir = join(resources.instanceDir, ".vellum", "workspace");
860
+ const protectedDir = join(resources.instanceDir, ".vellum", "protected");
861
+ const binDir = join(workspaceDir, "bin");
862
+
863
+ mkdirSync(binDir, { recursive: true });
864
+ const wrapperPath = join(binDir, "assistant");
865
+ writeFileSync(
866
+ wrapperPath,
867
+ [
868
+ "#!/bin/sh",
869
+ `export VELLUM_WORKSPACE_DIR="${workspaceDir}"`,
870
+ `export CREDENTIAL_SECURITY_DIR="${protectedDir}"`,
871
+ `export GATEWAY_SECURITY_DIR="${protectedDir}"`,
872
+ `exec "${assistantBinary}" "$@"`,
873
+ "",
874
+ ].join("\n"),
875
+ { mode: 0o755 },
876
+ );
877
+ }
878
+
843
879
  // NOTE: startLocalDaemon() is the CLI-side daemon lifecycle manager.
844
880
  // It should eventually converge with
845
881
  // assistant/src/daemon/daemon-control.ts::startDaemon which is the
@@ -850,6 +886,7 @@ export async function startLocalDaemon(
850
886
  options?: DaemonStartOptions,
851
887
  ): Promise<void> {
852
888
  warnIfLegacyWorkspaceFallbackDetected(resources);
889
+ writeAssistantWrapper(resources);
853
890
 
854
891
  const foreground = options?.foreground ?? false;
855
892
  // Check for a compiled daemon binary adjacent to the CLI executable.
@@ -1,5 +1,11 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
2
3
 
4
+ import {
5
+ getDaemonPidPath,
6
+ loadAllAssistantsAcrossEnvs,
7
+ type AssistantEntry,
8
+ } from "./assistant-config.js";
3
9
  import { execOutput } from "./step-runner";
4
10
 
5
11
  export interface RemoteProcess {
@@ -67,10 +73,68 @@ export interface OrphanedProcess {
67
73
  source: string;
68
74
  }
69
75
 
70
- export async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
76
+ /**
77
+ * Collect PIDs that belong to a known assistant in any environment.
78
+ *
79
+ * For local entries this reads the daemon/gateway/qdrant/embed-worker PID
80
+ * files under each entry's `instanceDir`. For docker entries we include the
81
+ * `watcherPid` field when present (the file watcher runs as a host process,
82
+ * unlike the containers themselves). Other cloud topologies don't have
83
+ * host-side processes that show up in `ps ax`.
84
+ *
85
+ * This set is the basis for filtering the orphan list: if a running process
86
+ * matches a recorded PID for *any* env's assistant, it's not an orphan.
87
+ */
88
+ export function getKnownPidsFromAssistants(
89
+ entries: AssistantEntry[],
90
+ ): Set<string> {
91
+ const pids = new Set<string>();
92
+ for (const entry of entries) {
93
+ if (entry.cloud === "local" && entry.resources) {
94
+ const vellumDir = join(entry.resources.instanceDir, ".vellum");
95
+ const candidates = [
96
+ getDaemonPidPath(entry.resources),
97
+ join(vellumDir, "gateway.pid"),
98
+ join(vellumDir, "workspace", "data", "qdrant", "qdrant.pid"),
99
+ join(vellumDir, "workspace", "embed-worker.pid"),
100
+ ];
101
+ for (const file of candidates) {
102
+ const pid = readPidFile(file);
103
+ if (pid) pids.add(pid);
104
+ }
105
+ }
106
+ if (typeof entry.watcherPid === "number") {
107
+ pids.add(String(entry.watcherPid));
108
+ }
109
+ }
110
+ return pids;
111
+ }
112
+
113
+ export interface DetectOrphansOptions {
114
+ /**
115
+ * Set of PIDs to treat as known and exclude from the orphan list. When
116
+ * omitted, defaults to the union of every env's recorded assistant PIDs
117
+ * via {@link loadAllAssistantsAcrossEnvs} +
118
+ * {@link getKnownPidsFromAssistants}. Tests can inject an explicit set to
119
+ * avoid touching the real on-host lockfiles.
120
+ */
121
+ excludePids?: Set<string>;
122
+ }
123
+
124
+ export async function detectOrphanedProcesses(
125
+ options: DetectOrphansOptions = {},
126
+ ): Promise<OrphanedProcess[]> {
71
127
  const results: OrphanedProcess[] = [];
72
128
  const seenPids = new Set<string>();
73
129
 
130
+ // PIDs that belong to a known assistant in *any* environment are not
131
+ // orphans. Without this filter, running `vellum ps` from an env that has
132
+ // no assistants — or `vellum clean` from any env — would flag (or kill)
133
+ // another env's healthy services as orphans.
134
+ const knownPids =
135
+ options.excludePids ??
136
+ getKnownPidsFromAssistants(loadAllAssistantsAcrossEnvs());
137
+
74
138
  // Process table scan — discover orphaned processes by scanning the OS
75
139
  // process table rather than reading PID files from the workspace.
76
140
  try {
@@ -83,6 +147,7 @@ export async function detectOrphanedProcesses(): Promise<OrphanedProcess[]> {
83
147
 
84
148
  for (const p of procs) {
85
149
  if (p.pid === ownPid || seenPids.has(p.pid)) continue;
150
+ if (knownPids.has(p.pid)) continue;
86
151
  const type = classifyProcess(p.command);
87
152
  if (type === "unknown") continue;
88
153
  results.push({ name: type, pid: p.pid, source: "process table" });
@@ -129,9 +129,12 @@ export function invalidateOrgIdCache(
129
129
  * The org ID is cached per (token, platformUrl) for 60 seconds to avoid
130
130
  * redundant HTTP requests in tight polling loops.
131
131
  *
132
- * Auth errors (401 / 403) from the org-ID fetch are logged with a
133
- * user-friendly message before re-throwing, so callers don't need to
134
- * repeat that logic.
132
+ * Auth errors (401 / 403) from the org-ID fetch are wrapped in a
133
+ * user-friendly Error message before re-throwing, so callers can surface
134
+ * a useful message without doing their own classification. Callers that
135
+ * handle the throw (e.g. `syncCloudAssistants`) stay silent on stderr;
136
+ * callers that let it bubble get a single clean line from the top-level
137
+ * runner.
135
138
  */
136
139
  export async function authHeaders(
137
140
  token: string,
@@ -163,11 +166,9 @@ export async function authHeaders(
163
166
  } catch (err) {
164
167
  const msg = err instanceof Error ? err.message : String(err);
165
168
  if (msg.includes("401") || msg.includes("403")) {
166
- console.error("Authentication failed. Run 'vellum login' to refresh.");
167
- } else {
168
- console.error(`Failed to fetch organization: ${msg}`);
169
+ throw new Error("Authentication failed. Run 'vellum login' to refresh.");
169
170
  }
170
- throw err;
171
+ throw new Error(`Failed to fetch organization: ${msg}`);
171
172
  }
172
173
  }
173
174
 
@@ -99,6 +99,11 @@ export interface DockerContainerSpec {
99
99
  ports?: PortSpec[];
100
100
  env: EnvEntry[];
101
101
  volumeMounts: VolumeMount[];
102
+ /**
103
+ * Optional `--user` override for `docker run`. Mirrors K8s
104
+ * `securityContext.runAsUser`. Omitted ⇒ image's `USER` directive wins.
105
+ */
106
+ user?: string;
102
107
  }
103
108
 
104
109
  export interface DockerVolumeClaimTemplate {
@@ -168,6 +173,7 @@ export const DOCKER_STATEFUL_SET_SPEC: DockerStatefulSetSpec = {
168
173
  { kind: "static", name: "DEBUG_STDOUT_LOGS", value: "1" },
169
174
  { kind: "static", name: "VELLUM_CLOUD", value: "docker" },
170
175
  { kind: "static", name: "RUNTIME_HTTP_HOST", value: "0.0.0.0" },
176
+ { kind: "static", name: "RUNTIME_HTTP_PORT", value: `${ASSISTANT_INTERNAL_PORT}` },
171
177
  { kind: "static", name: "VELLUM_WORKSPACE_DIR", value: "/workspace" },
172
178
  { kind: "static", name: "VELLUM_BACKUP_DIR", value: "/workspace/.backups" },
173
179
  { kind: "static", name: "VELLUM_BACKUP_KEY_PATH", value: "/workspace/.backup.key" },
@@ -196,6 +202,7 @@ export const DOCKER_STATEFUL_SET_SPEC: DockerStatefulSetSpec = {
196
202
  name: "gateway-sidecar",
197
203
  internalName: "gateway",
198
204
  network: "container",
205
+ user: "0",
199
206
  env: [
200
207
  { kind: "static", name: "VELLUM_WORKSPACE_DIR", value: "/workspace" },
201
208
  { kind: "static", name: "GATEWAY_SECURITY_DIR", value: "/gateway-security" },
@@ -300,6 +307,11 @@ export function buildServiceRunArgs(
300
307
  : res.cesContainer;
301
308
  args.push("--name", containerName);
302
309
 
310
+ // User override (mirrors K8s securityContext.runAsUser)
311
+ if (container.user !== undefined) {
312
+ args.push("--user", container.user);
313
+ }
314
+
303
315
  // Network
304
316
  if (container.network === "bridge") {
305
317
  args.push(`--network=${res.network}`);
@@ -6,6 +6,11 @@
6
6
  * (e.g. retired assistants).
7
7
  *
8
8
  * Used by both `vellum login` and `vellum ps` to keep the lockfile fresh.
9
+ *
10
+ * **Contract:** callers must verify the user is logged in (i.e. a non-empty
11
+ * platform token exists) before invoking this helper. The "is there a token?"
12
+ * decision belongs at the command level so commands can render the right
13
+ * "Platform: …" status without ever entering the platform fetch path.
9
14
  */
10
15
 
11
16
  import {
@@ -17,7 +22,6 @@ import {
17
22
  fetchCurrentUser,
18
23
  fetchPlatformAssistants,
19
24
  getPlatformUrl,
20
- readPlatformToken,
21
25
  } from "./platform-client.js";
22
26
 
23
27
  export type SyncLogger = (message: string) => void;
@@ -34,21 +38,24 @@ export interface SyncOptions {
34
38
 
35
39
  /**
36
40
  * Fetch platform assistants and reconcile against the lockfile.
37
- * Returns the number of entries added/removed, or `null` if the user
38
- * is not logged in or the fetch fails.
41
+ *
42
+ * Returns the number of entries added/removed, or `null` if the fetch fails
43
+ * (e.g. platform unreachable, invalid token). Callers must pre-verify a
44
+ * non-empty token; this function assumes one is present and will throw if
45
+ * called with an empty string.
39
46
  */
40
47
  export async function syncCloudAssistants(
48
+ token: string,
41
49
  options?: SyncOptions,
42
50
  ): Promise<SyncResult | null> {
51
+ if (!token) {
52
+ throw new Error(
53
+ "syncCloudAssistants called without a token. Callers must check `readPlatformToken()` first.",
54
+ );
55
+ }
43
56
  const log = options?.log;
44
57
  const platformUrl = getPlatformUrl();
45
58
  log?.(`Platform URL: ${platformUrl}`);
46
-
47
- const token = readPlatformToken();
48
- if (!token) {
49
- log?.("No platform token found — skipping cloud sync");
50
- return null;
51
- }
52
59
  log?.(
53
60
  `Token found (${token.length} chars, prefix: ${token.slice(0, 6)}…)`,
54
61
  );