@vellumai/cli 0.8.10 → 0.8.11-dev.202606112057.e4bc22e
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.
- package/AGENTS.md +2 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +13 -0
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +2 -2
- package/node_modules/@vellumai/local-mode/src/index.ts +1 -1
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +20 -1
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +3 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +169 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +9 -4
- package/package.json +1 -1
- package/src/__tests__/confirm.test.ts +85 -0
- package/src/__tests__/device-id.test.ts +167 -0
- package/src/__tests__/guardian-token.test.ts +79 -0
- package/src/__tests__/helpers/env.ts +19 -0
- package/src/__tests__/statefulset.test.ts +149 -0
- package/src/__tests__/upgrade-replay-env.test.ts +165 -0
- package/src/__tests__/wake.test.ts +68 -0
- package/src/commands/backup.ts +3 -2
- package/src/commands/client.ts +22 -5
- package/src/commands/confirm.ts +144 -0
- package/src/commands/connect.ts +1 -1
- package/src/commands/devices.ts +4 -3
- package/src/commands/hatch.ts +16 -1
- package/src/commands/pair.ts +3 -2
- package/src/commands/restore.ts +3 -2
- package/src/commands/retire.ts +2 -1
- package/src/commands/roadmap.ts +2 -1
- package/src/commands/rollback.ts +9 -37
- package/src/commands/unpair.ts +1 -1
- package/src/commands/upgrade.ts +13 -44
- package/src/commands/wake.ts +49 -1
- package/src/index.ts +11 -4
- package/src/lib/assistant-client.ts +3 -2
- package/src/lib/backup-ops.ts +5 -4
- package/src/lib/device-id.ts +85 -0
- package/src/lib/docker.ts +19 -3
- package/src/lib/guardian-token.ts +44 -8
- package/src/lib/hatch-local.ts +2 -1
- package/src/lib/health-check.ts +6 -4
- package/src/lib/http-client.ts +3 -1
- package/src/lib/local-runtime-client.ts +5 -4
- package/src/lib/local.ts +1 -0
- package/src/lib/loopback-fetch.ts +28 -0
- package/src/lib/ngrok.ts +2 -1
- package/src/lib/platform-client.ts +28 -21
- package/src/lib/platform-releases.ts +3 -2
- package/src/lib/statefulset.ts +43 -0
- package/src/lib/terminal-client.ts +6 -5
- 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 =
|
|
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: "
|
|
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
|
|
122
|
-
|
|
123
|
-
)
|
|
124
|
-
|
|
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
|
@@ -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
|
+
});
|