@vellumai/cli 0.8.10 → 0.8.11-dev.202606112147.04177ac

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 (48) hide show
  1. package/AGENTS.md +2 -0
  2. package/node_modules/@vellumai/local-mode/src/config.ts +13 -0
  3. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +2 -2
  4. package/node_modules/@vellumai/local-mode/src/index.ts +1 -1
  5. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +20 -1
  6. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +3 -0
  7. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +169 -0
  8. package/node_modules/@vellumai/local-mode/src/lockfile.ts +9 -4
  9. package/package.json +1 -1
  10. package/src/__tests__/confirm.test.ts +85 -0
  11. package/src/__tests__/device-id.test.ts +167 -0
  12. package/src/__tests__/guardian-token.test.ts +79 -0
  13. package/src/__tests__/helpers/env.ts +19 -0
  14. package/src/__tests__/statefulset.test.ts +149 -0
  15. package/src/__tests__/upgrade-replay-env.test.ts +165 -0
  16. package/src/__tests__/wake.test.ts +68 -0
  17. package/src/commands/backup.ts +3 -2
  18. package/src/commands/client.ts +22 -5
  19. package/src/commands/confirm.ts +144 -0
  20. package/src/commands/connect.ts +1 -1
  21. package/src/commands/devices.ts +4 -3
  22. package/src/commands/hatch.ts +16 -1
  23. package/src/commands/pair.ts +3 -2
  24. package/src/commands/restore.ts +3 -2
  25. package/src/commands/retire.ts +2 -1
  26. package/src/commands/roadmap.ts +2 -1
  27. package/src/commands/rollback.ts +9 -37
  28. package/src/commands/unpair.ts +1 -1
  29. package/src/commands/upgrade.ts +13 -44
  30. package/src/commands/wake.ts +49 -1
  31. package/src/index.ts +11 -4
  32. package/src/lib/assistant-client.ts +3 -2
  33. package/src/lib/backup-ops.ts +5 -4
  34. package/src/lib/device-id.ts +85 -0
  35. package/src/lib/docker.ts +19 -3
  36. package/src/lib/guardian-token.ts +44 -8
  37. package/src/lib/hatch-local.ts +2 -1
  38. package/src/lib/health-check.ts +6 -4
  39. package/src/lib/http-client.ts +3 -1
  40. package/src/lib/local-runtime-client.ts +5 -4
  41. package/src/lib/local.ts +1 -0
  42. package/src/lib/loopback-fetch.ts +28 -0
  43. package/src/lib/ngrok.ts +2 -1
  44. package/src/lib/platform-client.ts +28 -21
  45. package/src/lib/platform-releases.ts +3 -2
  46. package/src/lib/statefulset.ts +43 -0
  47. package/src/lib/terminal-client.ts +6 -5
  48. package/src/lib/upgrade-lifecycle.ts +114 -53
package/AGENTS.md CHANGED
@@ -63,6 +63,8 @@ The CLI must **never** read from or write to the `.vellum/` directory (e.g. `~/.
63
63
 
64
64
  For example, the signing key used for JWT auth between the daemon and gateway is persisted in the lockfile (`resources.signingKey`) so that client actor tokens survive daemon/gateway restarts. On first start (or when the key is missing), the CLI generates a new key via `generateLocalSigningKey()` in `lib/local.ts`, saves it to the lockfile entry, and passes it to both `startLocalDaemon` and `startGateway` as the `ACTOR_TOKEN_SIGNING_KEY` env var. The CLI does **not** read or write to the `.vellum/` directory for signing keys — it uses the lockfile instead.
65
65
 
66
+ **Exception: `~/.vellum/device.json`.** That file is the machine-wide shared device-identity file, co-owned by the Swift clients, the Electron main process, the host-mode assistant, and the CLI (see `clients/shared/App/Auth/DeviceIdStore.swift` and `apps/macos/src/main/device-id.ts`). The boundary rule covers daemon/gateway-internal state (e.g. `~/.vellum/protected/`, instance dirs), not this file.
67
+
66
68
  ## Process liveness
67
69
 
68
70
  Use `resolveProcessState()` from `lib/process.ts` when checking whether a daemon or gateway should be (re)started. It combines PID existence with an HTTP `/healthz` probe, a readiness grace period, and a [`isVellumProcess()`](https://man7.org/linux/man-pages/man1/ps.1.html) guard against PID reuse — see the function's JSDoc for the full flow.
@@ -64,3 +64,16 @@ export function resolveConfigDir(
64
64
  }
65
65
  return path.join(xdgConfigHome, `vellum-${vellumEnv}`);
66
66
  }
67
+
68
+ /**
69
+ * The on-disk location of an assistant's guardian token, given an already
70
+ * resolved config dir. The single source of truth for this path so the CLI
71
+ * writer and every host-seam reader agree — a divergence here is what leaves a
72
+ * freshly leased token unreadable and bricks the connect.
73
+ */
74
+ export function guardianTokenPath(
75
+ configDir: string,
76
+ assistantId: string,
77
+ ): string {
78
+ return path.join(configDir, "assistants", assistantId, "guardian-token.json");
79
+ }
@@ -1,7 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
- import path from "node:path";
4
3
 
4
+ import { guardianTokenPath } from "./config";
5
5
  import type { CliInvocation } from "./util";
6
6
 
7
7
  const GUARDIAN_TOKEN_REFRESH_TIMEOUT_MS = 15_000;
@@ -40,7 +40,7 @@ export function getGuardianAccessToken(
40
40
  return Promise.resolve({ ok: false, status: 403, error: "Forbidden" });
41
41
  }
42
42
 
43
- const tokenPath = path.join(configDir, "assistants", assistantId, "guardian-token.json");
43
+ const tokenPath = guardianTokenPath(configDir, assistantId);
44
44
 
45
45
  let raw: string;
46
46
  try {
@@ -14,7 +14,7 @@ export {
14
14
  resolveDevCliInvocation,
15
15
  } from "./util";
16
16
  export type { CliInvocation } from "./util";
17
- export { resolveLocalConfigFromEnv, resolveLockfilePaths, resolveConfigDir } from "./config";
17
+ export { resolveLocalConfigFromEnv, resolveLockfilePaths, resolveConfigDir, guardianTokenPath } from "./config";
18
18
  export type { LocalEndpointConfig } from "./config";
19
19
  export { defaultEnvironmentFilePath, readDefaultEnvironment, resolveEnvironmentName } from "./environment";
20
20
  export {
@@ -10,10 +10,11 @@ describe("parseLockfile", () => {
10
10
  {
11
11
  assistantId: "asst_1",
12
12
  name: "Alice",
13
- cloud: "local",
13
+ cloud: "vellum",
14
14
  runtimeUrl: "http://127.0.0.1:7777",
15
15
  species: "vellum",
16
16
  hatchedAt: "2026-01-01T00:00:00.000Z",
17
+ organizationId: "org_1",
17
18
  resources: { gatewayPort: 7777, daemonPort: 7778 },
18
19
  },
19
20
  ],
@@ -150,6 +151,24 @@ describe("parseLockfile", () => {
150
151
  expect(parseLockfile(raw).assistants).toEqual([]);
151
152
  });
152
153
 
154
+ test("keeps organizationId on platform entries and drops it when mistyped", () => {
155
+ // Platform assistants carry their owning org so the host proxy can scope
156
+ // requests without guessing; local entries simply omit it.
157
+ const raw = {
158
+ assistants: [
159
+ { assistantId: "asst_1", cloud: "vellum", organizationId: "org_1" },
160
+ { assistantId: "asst_2", cloud: "vellum", organizationId: 7 }, // mistyped
161
+ { assistantId: "asst_3", cloud: "local" }, // local, no org
162
+ ],
163
+ activeAssistant: null,
164
+ };
165
+ expect(parseLockfile(raw).assistants).toEqual([
166
+ { assistantId: "asst_1", cloud: "vellum", organizationId: "org_1" },
167
+ { assistantId: "asst_2", cloud: "vellum" },
168
+ { assistantId: "asst_3", cloud: "local" },
169
+ ]);
170
+ });
171
+
153
172
  test("drops a resources object missing its numeric ports", () => {
154
173
  const raw = {
155
174
  assistants: [
@@ -42,6 +42,8 @@ export interface LockfileAssistant {
42
42
  runtimeUrl?: string;
43
43
  species?: string;
44
44
  hatchedAt?: string;
45
+ /** Owning org for platform assistants; absent for local ones. */
46
+ organizationId?: string;
45
47
  resources?: LocalAssistantResources;
46
48
  }
47
49
 
@@ -87,6 +89,7 @@ function parseAssistant(value: unknown): LockfileAssistant | null {
87
89
  if (typeof value.runtimeUrl === "string") assistant.runtimeUrl = value.runtimeUrl;
88
90
  if (typeof value.species === "string") assistant.species = value.species;
89
91
  if (typeof value.hatchedAt === "string") assistant.hatchedAt = value.hatchedAt;
92
+ if (typeof value.organizationId === "string") assistant.organizationId = value.organizationId;
90
93
  const resources = parseResources(value.resources);
91
94
  if (resources) assistant.resources = resources;
92
95
  return assistant;
@@ -207,6 +207,175 @@ describe("replacePlatformAssistants", () => {
207
207
  expect(ids).toEqual(["asst_local", "asst_new_platform"]);
208
208
  });
209
209
 
210
+ test("a sync scoped to one org preserves another org's platform entries", () => {
211
+ writeOnDisk({
212
+ activeAssistant: null,
213
+ assistants: [
214
+ {
215
+ assistantId: "asst_org_a",
216
+ cloud: "vellum",
217
+ organizationId: "org_a",
218
+ runtimeUrl: "http://a",
219
+ },
220
+ {
221
+ assistantId: "asst_org_b_old",
222
+ cloud: "vellum",
223
+ organizationId: "org_b",
224
+ runtimeUrl: "http://bo",
225
+ },
226
+ ],
227
+ });
228
+
229
+ replacePlatformAssistants(
230
+ [lockfilePath],
231
+ [
232
+ {
233
+ assistantId: "asst_org_b_new",
234
+ cloud: "vellum",
235
+ organizationId: "org_b",
236
+ runtimeUrl: "http://bn",
237
+ },
238
+ ],
239
+ "org_b",
240
+ );
241
+
242
+ const ids = (readOnDisk().assistants as Array<Record<string, unknown>>).map(
243
+ (a) => a.assistantId,
244
+ );
245
+ // Org A survives; Org B's stale entry is replaced by the new one.
246
+ expect(ids).toEqual(["asst_org_a", "asst_org_b_new"]);
247
+ });
248
+
249
+ test("de-duplicates a legacy no-org entry that shares an id with the new list", () => {
250
+ writeOnDisk({
251
+ activeAssistant: null,
252
+ assistants: [
253
+ // Legacy platform entry with no organizationId, same id as the sync.
254
+ { assistantId: "asst_dup", cloud: "vellum", runtimeUrl: "http://old" },
255
+ ],
256
+ });
257
+
258
+ replacePlatformAssistants(
259
+ [lockfilePath],
260
+ [
261
+ {
262
+ assistantId: "asst_dup",
263
+ cloud: "vellum",
264
+ organizationId: "org_a",
265
+ runtimeUrl: "http://new",
266
+ },
267
+ ],
268
+ "org_a",
269
+ );
270
+
271
+ const assistants = readOnDisk().assistants as Array<Record<string, unknown>>;
272
+ expect(assistants).toHaveLength(1);
273
+ expect(assistants[0]).toMatchObject({
274
+ assistantId: "asst_dup",
275
+ organizationId: "org_a",
276
+ runtimeUrl: "http://new",
277
+ });
278
+ });
279
+
280
+ test("full-replaces all platform entries when no org is given (legacy)", () => {
281
+ writeOnDisk({
282
+ activeAssistant: null,
283
+ assistants: [
284
+ {
285
+ assistantId: "asst_org_a",
286
+ cloud: "vellum",
287
+ organizationId: "org_a",
288
+ runtimeUrl: "http://a",
289
+ },
290
+ {
291
+ assistantId: "asst_org_b",
292
+ cloud: "vellum",
293
+ organizationId: "org_b",
294
+ runtimeUrl: "http://b",
295
+ },
296
+ ],
297
+ });
298
+
299
+ replacePlatformAssistants(
300
+ [lockfilePath],
301
+ [
302
+ {
303
+ assistantId: "asst_new",
304
+ cloud: "vellum",
305
+ runtimeUrl: "http://np",
306
+ },
307
+ ],
308
+ );
309
+
310
+ const ids = (readOnDisk().assistants as Array<Record<string, unknown>>).map(
311
+ (a) => a.assistantId,
312
+ );
313
+ expect(ids).toEqual(["asst_new"]);
314
+ });
315
+
316
+ test("local entries always survive an org-scoped sync", () => {
317
+ writeOnDisk({
318
+ activeAssistant: null,
319
+ assistants: [
320
+ { assistantId: "asst_local", cloud: "local", runtimeUrl: "http://l" },
321
+ {
322
+ assistantId: "asst_org_a",
323
+ cloud: "vellum",
324
+ organizationId: "org_a",
325
+ runtimeUrl: "http://a",
326
+ },
327
+ ],
328
+ });
329
+
330
+ replacePlatformAssistants(
331
+ [lockfilePath],
332
+ [
333
+ {
334
+ assistantId: "asst_org_a_new",
335
+ cloud: "vellum",
336
+ organizationId: "org_a",
337
+ runtimeUrl: "http://an",
338
+ },
339
+ ],
340
+ "org_a",
341
+ );
342
+
343
+ const ids = (readOnDisk().assistants as Array<Record<string, unknown>>).map(
344
+ (a) => a.assistantId,
345
+ );
346
+ expect(ids).toEqual(["asst_local", "asst_org_a_new"]);
347
+ });
348
+
349
+ test("keeps activeAssistant when it still resolves after an org-scoped sync", () => {
350
+ writeOnDisk({
351
+ activeAssistant: "asst_org_b_old",
352
+ assistants: [
353
+ {
354
+ assistantId: "asst_org_b_old",
355
+ cloud: "vellum",
356
+ organizationId: "org_b",
357
+ runtimeUrl: "http://bo",
358
+ },
359
+ ],
360
+ });
361
+
362
+ replacePlatformAssistants(
363
+ [lockfilePath],
364
+ [
365
+ {
366
+ assistantId: "asst_org_a",
367
+ cloud: "vellum",
368
+ organizationId: "org_a",
369
+ runtimeUrl: "http://a",
370
+ },
371
+ ],
372
+ "org_a",
373
+ );
374
+
375
+ // Org B's entry (and the active id pointing at it) survives the org-A sync.
376
+ expect(readOnDisk().activeAssistant).toBe("asst_org_b_old");
377
+ });
378
+
210
379
  test("clears activeAssistant when the active id no longer exists", () => {
211
380
  writeOnDisk({
212
381
  activeAssistant: "asst_old_platform",
@@ -106,6 +106,7 @@ export function isActiveAssistant(
106
106
  export function replacePlatformAssistants(
107
107
  lockfilePaths: string[],
108
108
  platformAssistants: Array<Record<string, unknown>>,
109
+ organizationId?: string,
109
110
  ): WriteResult {
110
111
  let lockfile: Record<string, unknown> = { assistants: [], activeAssistant: null };
111
112
  for (const candidate of lockfilePaths) {
@@ -118,10 +119,14 @@ export function replacePlatformAssistants(
118
119
  }
119
120
 
120
121
  const existing = Array.isArray(lockfile.assistants) ? lockfile.assistants : [];
121
- const local = existing.filter(
122
- (a: Record<string, unknown>) => a?.cloud !== "vellum",
123
- );
124
- lockfile.assistants = [...local, ...platformAssistants];
122
+ const syncedIds = new Set(platformAssistants.map((a) => a.assistantId));
123
+ // Org-scoped sync preserves other orgs' platform entries; no org full-replaces.
124
+ const preserved = existing.filter((a: Record<string, unknown>) => {
125
+ if (a?.cloud !== "vellum") return true;
126
+ if (syncedIds.has(a.assistantId)) return false;
127
+ return organizationId != null && a.organizationId !== organizationId;
128
+ });
129
+ lockfile.assistants = [...preserved, ...platformAssistants];
125
130
 
126
131
  const active = lockfile.activeAssistant as string | null;
127
132
  if (active) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.8.10",
3
+ "version": "0.8.11-dev.202606112147.04177ac",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,85 @@
1
+ import { describe, test, expect } from "bun:test";
2
+
3
+ import { parseConfirmArgs } from "../commands/confirm.js";
4
+
5
+ describe("parseConfirmArgs", () => {
6
+ test("parses a request id with the active assistant and defaults to allow", () => {
7
+ const r = parseConfirmArgs(["--request-id", "req-1"]);
8
+ expect(r).toEqual({
9
+ ok: true,
10
+ value: {
11
+ assistantId: undefined,
12
+ requestId: "req-1",
13
+ decision: "allow",
14
+ jsonOutput: false,
15
+ },
16
+ });
17
+ });
18
+
19
+ test("parses an explicit assistant plus request id", () => {
20
+ const r = parseConfirmArgs(["my-assistant", "--request-id", "req-1"]);
21
+ expect(r.ok).toBe(true);
22
+ if (!r.ok) return;
23
+ expect(r.value.assistantId).toBe("my-assistant");
24
+ expect(r.value.requestId).toBe("req-1");
25
+ });
26
+
27
+ test("honors an explicit deny decision", () => {
28
+ const r = parseConfirmArgs(["--request-id", "req-1", "--decision", "deny"]);
29
+ expect(r.ok).toBe(true);
30
+ if (!r.ok) return;
31
+ expect(r.value.decision).toBe("deny");
32
+ });
33
+
34
+ test("requires a request id", () => {
35
+ const r = parseConfirmArgs([]);
36
+ expect(r).toEqual({ ok: false, error: "--request-id is required." });
37
+ });
38
+
39
+ test("rejects an unknown decision", () => {
40
+ const r = parseConfirmArgs([
41
+ "--request-id",
42
+ "req-1",
43
+ "--decision",
44
+ "maybe",
45
+ ]);
46
+ expect(r).toEqual({
47
+ ok: false,
48
+ error: '--decision must be "allow" or "deny" (got "maybe").',
49
+ });
50
+ });
51
+
52
+ test("rejects a value-less --decision instead of defaulting to allow", () => {
53
+ // A trailing `--decision` with no value (e.g. an empty shell expansion)
54
+ // must not silently approve via the "allow" default.
55
+ const r = parseConfirmArgs(["--request-id", "req-1", "--decision"]);
56
+ expect(r).toEqual({
57
+ ok: false,
58
+ error: '--decision requires a value ("allow" or "deny").',
59
+ });
60
+ });
61
+
62
+ test("rejects an empty-string --decision value", () => {
63
+ const r = parseConfirmArgs(["--request-id", "req-1", "--decision", ""]);
64
+ expect(r).toEqual({
65
+ ok: false,
66
+ error: '--decision must be "allow" or "deny" (got "").',
67
+ });
68
+ });
69
+
70
+ test("rejects a value-less --request-id", () => {
71
+ const r = parseConfirmArgs(["--request-id"]);
72
+ expect(r).toEqual({
73
+ ok: false,
74
+ error: "--request-id requires a value.",
75
+ });
76
+ });
77
+
78
+ test("preserves --json alongside the request id", () => {
79
+ const r = parseConfirmArgs(["--json", "--request-id", "req-1"]);
80
+ expect(r.ok).toBe(true);
81
+ if (!r.ok) return;
82
+ expect(r.value.jsonOutput).toBe(true);
83
+ expect(r.value.requestId).toBe("req-1");
84
+ });
85
+ });
@@ -0,0 +1,167 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ rmSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from "fs";
11
+ import { tmpdir } from "os";
12
+ import { join } from "path";
13
+
14
+ // Bun's os.homedir() ignores runtime HOME changes, so mock it (same pattern
15
+ // as multi-local.test.ts) to keep production-path tests off the real ~/.vellum.
16
+ let fakeHome: string | undefined;
17
+ const realOs = await import("node:os");
18
+ const osMock = () => ({
19
+ ...realOs,
20
+ homedir: () => fakeHome ?? realOs.homedir(),
21
+ });
22
+ mock.module("node:os", osMock);
23
+ mock.module("os", osMock);
24
+
25
+ import {
26
+ getOrCreateHostDeviceId,
27
+ resetHostDeviceIdCache,
28
+ } from "../lib/device-id.js";
29
+ import { snapshotEnv } from "./helpers/env.js";
30
+
31
+ const UUID_RE =
32
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
33
+
34
+ const restoreEnv = snapshotEnv([
35
+ "XDG_CONFIG_HOME",
36
+ "VELLUM_ENVIRONMENT",
37
+ "VELLUM_DEVICE_ID",
38
+ ]);
39
+
40
+ describe("getOrCreateHostDeviceId", () => {
41
+ let tempHome: string;
42
+ let deviceFile: string;
43
+
44
+ beforeEach(() => {
45
+ delete process.env.VELLUM_DEVICE_ID;
46
+ tempHome = mkdtempSync(join(tmpdir(), "cli-device-id-test-"));
47
+ process.env.XDG_CONFIG_HOME = tempHome;
48
+ // Non-prod so the resolver targets $XDG_CONFIG_HOME/vellum-dev/
49
+ // instead of the real ~/.config/vellum/.
50
+ process.env.VELLUM_ENVIRONMENT = "dev";
51
+ deviceFile = join(tempHome, "vellum-dev", "device.json");
52
+ resetHostDeviceIdCache();
53
+ });
54
+
55
+ afterEach(() => {
56
+ restoreEnv();
57
+ rmSync(tempHome, { recursive: true, force: true });
58
+ resetHostDeviceIdCache();
59
+ });
60
+
61
+ test("creates device.json with a UUID when missing", () => {
62
+ const id = getOrCreateHostDeviceId();
63
+
64
+ expect(id).toMatch(UUID_RE);
65
+ expect(existsSync(deviceFile)).toBe(true);
66
+ const parsed = JSON.parse(readFileSync(deviceFile, "utf-8"));
67
+ expect(parsed.deviceId).toBe(id);
68
+ expect(readFileSync(deviceFile, "utf-8").endsWith("\n")).toBe(true);
69
+ });
70
+
71
+ test("returns the existing deviceId without rewriting the file", () => {
72
+ mkdirSync(join(tempHome, "vellum-dev"), { recursive: true });
73
+ writeFileSync(deviceFile, JSON.stringify({ deviceId: "existing-id" }));
74
+ const before = statSync(deviceFile).mtimeMs;
75
+
76
+ expect(getOrCreateHostDeviceId()).toBe("existing-id");
77
+ expect(statSync(deviceFile).mtimeMs).toBe(before);
78
+ expect(readFileSync(deviceFile, "utf-8")).toBe(
79
+ JSON.stringify({ deviceId: "existing-id" }),
80
+ );
81
+ });
82
+
83
+ test("caches the resolved id until reset", () => {
84
+ const first = getOrCreateHostDeviceId();
85
+ rmSync(deviceFile);
86
+
87
+ expect(getOrCreateHostDeviceId()).toBe(first);
88
+
89
+ resetHostDeviceIdCache();
90
+ const second = getOrCreateHostDeviceId();
91
+ expect(second).toMatch(UUID_RE);
92
+ expect(second).not.toBe(first);
93
+ });
94
+
95
+ test("preserves unrelated fields when adding deviceId", () => {
96
+ mkdirSync(join(tempHome, "vellum-dev"), { recursive: true });
97
+ writeFileSync(deviceFile, JSON.stringify({ other: "kept", deviceId: "" }));
98
+
99
+ const id = getOrCreateHostDeviceId();
100
+
101
+ expect(id).toMatch(UUID_RE);
102
+ const parsed = JSON.parse(readFileSync(deviceFile, "utf-8"));
103
+ expect(parsed.other).toBe("kept");
104
+ expect(parsed.deviceId).toBe(id);
105
+ });
106
+
107
+ test("VELLUM_DEVICE_ID env var wins and skips file access", () => {
108
+ process.env.VELLUM_DEVICE_ID = "env-device-id";
109
+
110
+ expect(getOrCreateHostDeviceId()).toBe("env-device-id");
111
+ expect(existsSync(deviceFile)).toBe(false);
112
+ });
113
+
114
+ test("malformed JSON regenerates without throwing", () => {
115
+ mkdirSync(join(tempHome, "vellum-dev"), { recursive: true });
116
+ writeFileSync(deviceFile, "{not json");
117
+
118
+ const id = getOrCreateHostDeviceId();
119
+
120
+ expect(id).toMatch(UUID_RE);
121
+ const parsed = JSON.parse(readFileSync(deviceFile, "utf-8"));
122
+ expect(parsed).toEqual({ deviceId: id });
123
+ });
124
+ });
125
+
126
+ describe("getOrCreateHostDeviceId (production)", () => {
127
+ let tempHome: string;
128
+ let deviceFile: string;
129
+
130
+ beforeEach(() => {
131
+ delete process.env.VELLUM_DEVICE_ID;
132
+ tempHome = mkdtempSync(join(tmpdir(), "cli-device-id-prod-test-"));
133
+ fakeHome = tempHome;
134
+ process.env.XDG_CONFIG_HOME = join(tempHome, ".config");
135
+ process.env.VELLUM_ENVIRONMENT = "production";
136
+ deviceFile = join(tempHome, ".vellum", "device.json");
137
+ resetHostDeviceIdCache();
138
+ });
139
+
140
+ afterEach(() => {
141
+ fakeHome = undefined;
142
+ restoreEnv();
143
+ rmSync(tempHome, { recursive: true, force: true });
144
+ resetHostDeviceIdCache();
145
+ });
146
+
147
+ test("creates device.json in the shared ~/.vellum dir", () => {
148
+ const id = getOrCreateHostDeviceId();
149
+
150
+ expect(id).toMatch(UUID_RE);
151
+ expect(existsSync(deviceFile)).toBe(true);
152
+ expect(JSON.parse(readFileSync(deviceFile, "utf-8")).deviceId).toBe(id);
153
+ });
154
+
155
+ test("reuses an existing ~/.vellum/device.json", () => {
156
+ mkdirSync(join(tempHome, ".vellum"), { recursive: true });
157
+ writeFileSync(
158
+ deviceFile,
159
+ JSON.stringify({ deviceId: "shared-prod-id" }),
160
+ );
161
+
162
+ expect(getOrCreateHostDeviceId()).toBe("shared-prod-id");
163
+ expect(readFileSync(deviceFile, "utf-8")).toBe(
164
+ JSON.stringify({ deviceId: "shared-prod-id" }),
165
+ );
166
+ });
167
+ });
@@ -11,6 +11,8 @@ import {
11
11
  import { tmpdir } from "node:os";
12
12
  import { dirname, join } from "node:path";
13
13
 
14
+ import { guardianTokenPath, resolveConfigDir } from "@vellumai/local-mode";
15
+
14
16
  import {
15
17
  getOrCreatePersistedDeviceId,
16
18
  guardianTokenDueForRenewal,
@@ -20,6 +22,8 @@ import {
20
22
  seedGuardianTokenFromSiblingEnv,
21
23
  type GuardianTokenData,
22
24
  } from "../lib/guardian-token.js";
25
+ import { getConfigDir } from "../lib/environments/paths.js";
26
+ import { getCurrentEnvironment } from "../lib/environments/resolve.js";
23
27
 
24
28
  function makeTokenData(suffix: string): GuardianTokenData {
25
29
  const now = new Date().toISOString();
@@ -473,3 +477,78 @@ describe("guardianTokenDueForRenewal", () => {
473
477
  ).toBe(false);
474
478
  });
475
479
  });
480
+
481
+ // Drift guard between the guardian-token WRITE path (CLI: getGuardianTokenPath
482
+ // → getConfigDir(getCurrentEnvironment())) and the READ path used by every
483
+ // host-seam reader (getGuardianAccessToken → resolveConfigDir(env) from
484
+ // @vellumai/local-mode). A divergence here writes a freshly leased token where
485
+ // the connect can't read it, bricking local-assistant connect. saveGuardianToken
486
+ // already resolves through the shared resolver; this asserts the two resolvers
487
+ // stay in lockstep so a future change to either can't silently relocate tokens.
488
+ describe("guardian-token path resolver parity (CLI ↔ shared)", () => {
489
+ let tempHome: string;
490
+ let savedXdg: string | undefined;
491
+ let savedEnv: string | undefined;
492
+
493
+ beforeEach(() => {
494
+ savedXdg = process.env.XDG_CONFIG_HOME;
495
+ savedEnv = process.env.VELLUM_ENVIRONMENT;
496
+ tempHome = mkdtempSync(join(tmpdir(), "cli-guardian-parity-test-"));
497
+ process.env.XDG_CONFIG_HOME = tempHome;
498
+ delete process.env.VELLUM_ENVIRONMENT;
499
+ });
500
+
501
+ afterEach(() => {
502
+ if (savedXdg === undefined) delete process.env.XDG_CONFIG_HOME;
503
+ else process.env.XDG_CONFIG_HOME = savedXdg;
504
+ if (savedEnv === undefined) delete process.env.VELLUM_ENVIRONMENT;
505
+ else process.env.VELLUM_ENVIRONMENT = savedEnv;
506
+ rmSync(tempHome, { recursive: true, force: true });
507
+ });
508
+
509
+ // The CLI's own resolver and the shared @vellumai/local-mode resolver must
510
+ // agree on the config dir for every environment source.
511
+ const expectResolversAgree = () => {
512
+ expect(getConfigDir(getCurrentEnvironment())).toBe(
513
+ resolveConfigDir(process.env),
514
+ );
515
+ };
516
+
517
+ test("unset → production: resolvers agree and saveGuardianToken lands there", () => {
518
+ expectResolversAgree();
519
+ saveGuardianToken("alpha", makeTokenData("prod"));
520
+ expect(
521
+ existsSync(guardianTokenPath(resolveConfigDir(process.env), "alpha")),
522
+ ).toBe(true);
523
+ });
524
+
525
+ test("VELLUM_ENVIRONMENT=dev: resolvers agree and token lands there", () => {
526
+ process.env.VELLUM_ENVIRONMENT = "dev";
527
+ expectResolversAgree();
528
+ saveGuardianToken("alpha", makeTokenData("dev"));
529
+ expect(
530
+ existsSync(guardianTokenPath(resolveConfigDir(process.env), "alpha")),
531
+ ).toBe(true);
532
+ });
533
+
534
+ test("VELLUM_ENVIRONMENT=local: resolvers agree and token lands there", () => {
535
+ process.env.VELLUM_ENVIRONMENT = "local";
536
+ expectResolversAgree();
537
+ saveGuardianToken("alpha", makeTokenData("local"));
538
+ expect(
539
+ existsSync(guardianTokenPath(resolveConfigDir(process.env), "alpha")),
540
+ ).toBe(true);
541
+ });
542
+
543
+ test("persisted default env file (no VELLUM_ENVIRONMENT): resolvers agree", () => {
544
+ // Mirror `vellum env set dev`: the default-env file lives at the fixed,
545
+ // env-agnostic path $XDG_CONFIG_HOME/vellum/environment.
546
+ mkdirSync(join(tempHome, "vellum"), { recursive: true });
547
+ writeFileSync(join(tempHome, "vellum", "environment"), "dev\n");
548
+ expectResolversAgree();
549
+ saveGuardianToken("alpha", makeTokenData("default-dev"));
550
+ expect(
551
+ existsSync(guardianTokenPath(resolveConfigDir(process.env), "alpha")),
552
+ ).toBe(true);
553
+ });
554
+ });