@vellumai/cli 0.11.4-dev.202608190416.3aa24ff → 0.11.4-dev.202608191511.29bfca9

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.
@@ -0,0 +1,290 @@
1
+ import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test";
2
+ import { EventEmitter } from "node:events";
3
+
4
+ import type { CliInvocation } from "./util";
5
+
6
+ class FakeChild extends EventEmitter {
7
+ stdout = new EventEmitter();
8
+ stderr = new EventEmitter();
9
+ kill = mock(() => true);
10
+ }
11
+
12
+ let lastChild: FakeChild;
13
+ const spawnArgs: Array<[string, string[], { stdio?: unknown }]> = [];
14
+ const spawnMock = mock(
15
+ (command: string, args: string[], options: { stdio?: unknown }) => {
16
+ spawnArgs.push([command, args, options]);
17
+ lastChild = new FakeChild();
18
+ return lastChild;
19
+ },
20
+ );
21
+
22
+ mock.module("node:child_process", () => ({ spawn: spawnMock }));
23
+
24
+ let runDevicesList: typeof import("./devices").runDevicesList;
25
+ let runDevicesRevoke: typeof import("./devices").runDevicesRevoke;
26
+
27
+ beforeAll(async () => {
28
+ ({ runDevicesList, runDevicesRevoke } = await import("./devices"));
29
+ });
30
+
31
+ afterEach(() => {
32
+ spawnArgs.length = 0;
33
+ spawnMock.mockClear();
34
+ });
35
+
36
+ const invocation: CliInvocation = { command: "bun", baseArgs: ["run", "cli"] };
37
+
38
+ describe("runDevicesList", () => {
39
+ test("spawns the CLI devices command and parses the JSON document", async () => {
40
+ const pending = runDevicesList(invocation, "asst-42");
41
+ lastChild.stdout.emit(
42
+ "data",
43
+ Buffer.from(
44
+ JSON.stringify({
45
+ devices: [
46
+ {
47
+ hashedDeviceId: "hash-a",
48
+ platform: "ios",
49
+ issuedAt: 1000,
50
+ expiresAt: 2000,
51
+ lastUsedAt: 1500,
52
+ },
53
+ ],
54
+ }),
55
+ ),
56
+ );
57
+ lastChild.emit("close", 0);
58
+
59
+ expect(await pending).toEqual({
60
+ ok: true,
61
+ devices: [
62
+ {
63
+ hashedDeviceId: "hash-a",
64
+ platform: "ios",
65
+ issuedAt: 1000,
66
+ expiresAt: 2000,
67
+ lastUsedAt: 1500,
68
+ },
69
+ ],
70
+ });
71
+ expect(spawnArgs[0]).toEqual([
72
+ "bun",
73
+ ["run", "cli", "devices", "asst-42", "--json"],
74
+ { stdio: ["ignore", "pipe", "pipe"] },
75
+ ]);
76
+ });
77
+
78
+ test("tolerates null and non-numeric timestamps", async () => {
79
+ const pending = runDevicesList(invocation, "asst-42");
80
+ lastChild.stdout.emit(
81
+ "data",
82
+ Buffer.from(
83
+ JSON.stringify({
84
+ devices: [
85
+ {
86
+ hashedDeviceId: "hash-b",
87
+ platform: "web",
88
+ issuedAt: null,
89
+ expiresAt: "soon",
90
+ },
91
+ ],
92
+ }),
93
+ ),
94
+ );
95
+ lastChild.emit("close", 0);
96
+
97
+ expect(await pending).toEqual({
98
+ ok: true,
99
+ devices: [
100
+ {
101
+ hashedDeviceId: "hash-b",
102
+ platform: "web",
103
+ issuedAt: null,
104
+ expiresAt: null,
105
+ lastUsedAt: null,
106
+ },
107
+ ],
108
+ });
109
+ });
110
+
111
+ test("passes isCurrentHost through only when literally true", async () => {
112
+ const pending = runDevicesList(invocation, "asst-42");
113
+ lastChild.stdout.emit(
114
+ "data",
115
+ Buffer.from(
116
+ JSON.stringify({
117
+ devices: [
118
+ {
119
+ hashedDeviceId: "hash-host",
120
+ platform: "cli",
121
+ issuedAt: 1000,
122
+ expiresAt: null,
123
+ lastUsedAt: null,
124
+ isCurrentHost: true,
125
+ },
126
+ {
127
+ hashedDeviceId: "hash-truthy",
128
+ platform: "ios",
129
+ issuedAt: 1000,
130
+ expiresAt: null,
131
+ lastUsedAt: null,
132
+ isCurrentHost: "yes",
133
+ },
134
+ {
135
+ hashedDeviceId: "hash-false",
136
+ platform: "ios",
137
+ issuedAt: 1000,
138
+ expiresAt: null,
139
+ lastUsedAt: null,
140
+ isCurrentHost: false,
141
+ },
142
+ ],
143
+ }),
144
+ ),
145
+ );
146
+ lastChild.emit("close", 0);
147
+
148
+ const result = await pending;
149
+ expect(result.ok).toBe(true);
150
+ if (result.ok) {
151
+ expect(result.devices.map((d) => d.isCurrentHost)).toEqual([
152
+ true,
153
+ undefined,
154
+ undefined,
155
+ ]);
156
+ expect("isCurrentHost" in result.devices[1]!).toBe(false);
157
+ }
158
+ });
159
+
160
+ test("an empty device list parses to no records", async () => {
161
+ const pending = runDevicesList(invocation, "asst-42");
162
+ lastChild.stdout.emit("data", Buffer.from('{ "devices": [] }'));
163
+ lastChild.emit("close", 0);
164
+
165
+ expect(await pending).toEqual({ ok: true, devices: [] });
166
+ });
167
+
168
+ test("malformed stdout resolves to a failure with a snippet", async () => {
169
+ const pending = runDevicesList(invocation, "asst-42");
170
+ lastChild.stdout.emit("data", Buffer.from("not json"));
171
+ lastChild.emit("close", 0);
172
+
173
+ expect(await pending).toEqual({
174
+ ok: false,
175
+ error: "CLI returned unparseable devices output: not json",
176
+ });
177
+ });
178
+
179
+ test("a record missing hashedDeviceId resolves to a failure", async () => {
180
+ const pending = runDevicesList(invocation, "asst-42");
181
+ lastChild.stdout.emit(
182
+ "data",
183
+ Buffer.from(JSON.stringify({ devices: [{ platform: "ios" }] })),
184
+ );
185
+ lastChild.emit("close", 0);
186
+
187
+ const result = await pending;
188
+ expect(result.ok).toBe(false);
189
+ if (!result.ok) {
190
+ expect(result.error).toStartWith("CLI returned unparseable devices output");
191
+ }
192
+ });
193
+
194
+ test("a non-zero exit resolves to a failure carrying stderr", async () => {
195
+ const pending = runDevicesList(invocation, "asst-42");
196
+ lastChild.stderr.emit("data", Buffer.from("devices failed"));
197
+ lastChild.emit("close", 1);
198
+
199
+ expect(await pending).toEqual({
200
+ ok: false,
201
+ error: "devices failed",
202
+ });
203
+ });
204
+
205
+ test("a spawn error resolves to a failure", async () => {
206
+ const pending = runDevicesList(invocation, "asst-42");
207
+ lastChild.emit("error", new Error("ENOENT"));
208
+
209
+ expect(await pending).toEqual({
210
+ ok: false,
211
+ error: "Failed to spawn CLI: ENOENT",
212
+ });
213
+ });
214
+ });
215
+
216
+ describe("runDevicesRevoke", () => {
217
+ test("spawns the CLI revoke command and succeeds on exit 0", async () => {
218
+ const pending = runDevicesRevoke(invocation, "asst-42", "hash-a");
219
+ lastChild.stdout.emit(
220
+ "data",
221
+ Buffer.from('{ "ok": true, "hashedDeviceId": "hash-a" }'),
222
+ );
223
+ lastChild.emit("close", 0);
224
+
225
+ expect(await pending).toEqual({ ok: true });
226
+ expect(spawnArgs[0]).toEqual([
227
+ "bun",
228
+ ["run", "cli", "devices", "revoke", "hash-a", "asst-42", "--yes", "--json"],
229
+ { stdio: ["ignore", "pipe", "pipe"] },
230
+ ]);
231
+ });
232
+
233
+ test("a non-zero exit resolves to a failure carrying stderr", async () => {
234
+ const pending = runDevicesRevoke(invocation, "asst-42", "hash-a");
235
+ lastChild.stderr.emit("data", Buffer.from("revoke failed"));
236
+ lastChild.emit("close", 1);
237
+
238
+ expect(await pending).toEqual({
239
+ ok: false,
240
+ error: "revoke failed",
241
+ });
242
+ });
243
+
244
+ test("a spawn error resolves to a failure", async () => {
245
+ const pending = runDevicesRevoke(invocation, "asst-42", "hash-a");
246
+ lastChild.emit("error", new Error("EACCES"));
247
+
248
+ expect(await pending).toEqual({
249
+ ok: false,
250
+ error: "Failed to spawn CLI: EACCES",
251
+ });
252
+ });
253
+
254
+ test("multi-line stderr with a trailing Error: line surfaces just that message", async () => {
255
+ const pending = runDevicesRevoke(invocation, "asst-42", "hash-a");
256
+ lastChild.stderr.emit(
257
+ "data",
258
+ Buffer.from(
259
+ [
260
+ "Device to revoke:",
261
+ " Platform: ios",
262
+ " Issued: 2026-01-01",
263
+ "",
264
+ "Error: Gateway is unreachable",
265
+ "",
266
+ ].join("\n"),
267
+ ),
268
+ );
269
+ lastChild.emit("close", 1);
270
+
271
+ expect(await pending).toEqual({
272
+ ok: false,
273
+ error: "Gateway is unreachable",
274
+ });
275
+ });
276
+
277
+ test("stderr without an Error: line falls back to the whole stderr", async () => {
278
+ const pending = runDevicesRevoke(invocation, "asst-42", "hash-a");
279
+ lastChild.stderr.emit(
280
+ "data",
281
+ Buffer.from("Device to revoke:\n Platform: ios\n"),
282
+ );
283
+ lastChild.emit("close", 1);
284
+
285
+ expect(await pending).toEqual({
286
+ ok: false,
287
+ error: "Device to revoke:\n Platform: ios",
288
+ });
289
+ });
290
+ });
@@ -0,0 +1,188 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ import type { CliInvocation } from "./util";
4
+
5
+ // Fast loopback HTTP calls; well under the lifecycle-op timeouts.
6
+ const DEVICES_TIMEOUT_MS = 30_000;
7
+
8
+ /**
9
+ * Mirrors one row of the gateway's `GET /v1/devices` response. The gateway
10
+ * stores only the HASHED device id, so `hashedDeviceId` is both the display
11
+ * identifier and the revocation key.
12
+ */
13
+ export interface DeviceRecord {
14
+ hashedDeviceId: string;
15
+ platform: string;
16
+ issuedAt: number | null;
17
+ expiresAt: number | null;
18
+ lastUsedAt: number | null;
19
+ /** True when this row is the hosting machine's own guardian credential. */
20
+ isCurrentHost?: boolean;
21
+ }
22
+
23
+ export type DevicesListResult =
24
+ | { ok: true; devices: DeviceRecord[] }
25
+ | { ok: false; error: string };
26
+
27
+ export type DevicesRevokeResult =
28
+ { ok: true } | { ok: false; error: string };
29
+
30
+ type CliRunResult =
31
+ | { ok: true; stdout: string }
32
+ | { ok: false; error: string };
33
+
34
+ // The CLI prints an identity preamble before acting, so a failure transcript
35
+ // is multi-line; surface only the final "Error:" line when one exists.
36
+ function extractCliError(stderr: string, stdout: string): string {
37
+ const errorLines = stderr
38
+ .split("\n")
39
+ .map((line) => line.trim())
40
+ .filter((line) => line.startsWith("Error:"));
41
+ const last = errorLines[errorLines.length - 1];
42
+ if (last) {
43
+ return last.slice("Error:".length).trim();
44
+ }
45
+ return (stderr || stdout).trim();
46
+ }
47
+
48
+ function runDevicesCli(
49
+ invocation: CliInvocation,
50
+ args: string[],
51
+ ): Promise<CliRunResult> {
52
+ return new Promise((resolve) => {
53
+ const child = spawn(
54
+ invocation.command,
55
+ [...invocation.baseArgs, ...args],
56
+ { stdio: ["ignore", "pipe", "pipe"] },
57
+ );
58
+
59
+ let stdout = "";
60
+ let stderr = "";
61
+ let done = false;
62
+
63
+ const finish = (result: CliRunResult) => {
64
+ if (done) return;
65
+ done = true;
66
+ clearTimeout(timeout);
67
+ resolve(result);
68
+ };
69
+
70
+ const timeout = setTimeout(() => {
71
+ child.kill("SIGTERM");
72
+ finish({
73
+ ok: false,
74
+ error: `Devices command timed out after ${DEVICES_TIMEOUT_MS / 1000} seconds`,
75
+ });
76
+ }, DEVICES_TIMEOUT_MS);
77
+
78
+ child.stdout.on("data", (data: Buffer) => {
79
+ stdout += data.toString();
80
+ });
81
+
82
+ child.stderr.on("data", (data: Buffer) => {
83
+ stderr += data.toString();
84
+ });
85
+
86
+ child.on("close", (code) => {
87
+ if (code === 0) {
88
+ finish({ ok: true, stdout });
89
+ } else {
90
+ finish({ ok: false, error: extractCliError(stderr, stdout) });
91
+ }
92
+ });
93
+
94
+ child.on("error", (err) => {
95
+ finish({
96
+ ok: false,
97
+ error: `Failed to spawn CLI: ${err.message}`,
98
+ });
99
+ });
100
+ });
101
+ }
102
+
103
+ function toTimestamp(value: unknown): number | null {
104
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
105
+ }
106
+
107
+ function parseDeviceRecords(stdout: string): DeviceRecord[] | null {
108
+ let parsed: unknown;
109
+ try {
110
+ parsed = JSON.parse(stdout);
111
+ } catch {
112
+ return null;
113
+ }
114
+ if (typeof parsed !== "object" || parsed === null) {
115
+ return null;
116
+ }
117
+ const devices = (parsed as { devices?: unknown }).devices;
118
+ if (!Array.isArray(devices)) {
119
+ return null;
120
+ }
121
+
122
+ const records: DeviceRecord[] = [];
123
+ for (const entry of devices) {
124
+ if (typeof entry !== "object" || entry === null) {
125
+ return null;
126
+ }
127
+ const {
128
+ hashedDeviceId,
129
+ platform,
130
+ issuedAt,
131
+ expiresAt,
132
+ lastUsedAt,
133
+ isCurrentHost,
134
+ } = entry as Record<string, unknown>;
135
+ if (typeof hashedDeviceId !== "string" || typeof platform !== "string") {
136
+ return null;
137
+ }
138
+ records.push({
139
+ hashedDeviceId,
140
+ platform,
141
+ issuedAt: toTimestamp(issuedAt),
142
+ expiresAt: toTimestamp(expiresAt),
143
+ lastUsedAt: toTimestamp(lastUsedAt),
144
+ // Tolerant passthrough: only a literal `true` survives.
145
+ ...(isCurrentHost === true ? { isCurrentHost: true } : {}),
146
+ });
147
+ }
148
+ return records;
149
+ }
150
+
151
+ export async function runDevicesList(
152
+ invocation: CliInvocation,
153
+ assistantId: string,
154
+ ): Promise<DevicesListResult> {
155
+ const result = await runDevicesCli(invocation, [
156
+ "devices",
157
+ assistantId,
158
+ "--json",
159
+ ]);
160
+ if (!result.ok) {
161
+ return result;
162
+ }
163
+ const devices = parseDeviceRecords(result.stdout);
164
+ if (!devices) {
165
+ const snippet = result.stdout.trim().slice(0, 200);
166
+ return {
167
+ ok: false,
168
+ error: `CLI returned unparseable devices output: ${snippet}`,
169
+ };
170
+ }
171
+ return { ok: true, devices };
172
+ }
173
+
174
+ export async function runDevicesRevoke(
175
+ invocation: CliInvocation,
176
+ assistantId: string,
177
+ hashedDeviceId: string,
178
+ ): Promise<DevicesRevokeResult> {
179
+ const result = await runDevicesCli(invocation, [
180
+ "devices",
181
+ "revoke",
182
+ hashedDeviceId,
183
+ assistantId,
184
+ "--yes",
185
+ "--json",
186
+ ]);
187
+ return result.ok ? { ok: true } : result;
188
+ }
@@ -64,6 +64,12 @@ export { runSleep } from "./sleep";
64
64
  export type { SleepResult } from "./sleep";
65
65
  export { runWake } from "./wake";
66
66
  export type { WakeOptions, WakeResult } from "./wake";
67
+ export { runDevicesList, runDevicesRevoke } from "./devices";
68
+ export type {
69
+ DeviceRecord,
70
+ DevicesListResult,
71
+ DevicesRevokeResult,
72
+ } from "./devices";
67
73
  export { runUpgrade, isValidReleaseVersion } from "./upgrade";
68
74
  export type { UpgradeOptions, UpgradeResult } from "./upgrade";
69
75
  export { getLocalAssistantStatus } from "./status";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/cli",
3
- "version": "0.11.4-dev.202608190416.3aa24ff",
3
+ "version": "0.11.4-dev.202608191511.29bfca9",
4
4
  "description": "CLI tools for vellum-assistant",
5
5
  "type": "module",
6
6
  "exports": {
@@ -24,8 +24,11 @@ const ORIGINAL_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
24
24
  const ORIGINAL_ARGV = [...process.argv];
25
25
  const ORIGINAL_FETCH = globalThis.fetch;
26
26
 
27
+ import { createHash } from "node:crypto";
28
+
27
29
  import { devices } from "../commands/devices.js";
28
30
  import { saveAssistantEntry } from "../lib/assistant-config.js";
31
+ import { computeDeviceId } from "../lib/guardian-token.js";
29
32
 
30
33
  interface FetchCall {
31
34
  url: string;
@@ -178,6 +181,112 @@ describe("vellum devices", () => {
178
181
  expect(keys).not.toContain("x-forwarded-for");
179
182
  });
180
183
 
184
+ // Matches currentHostHashedDeviceId in the command: sha256 of the same
185
+ // stable device id the host's guardian lease registered with the gateway.
186
+ function hostHash(): string {
187
+ return createHash("sha256").update(computeDeviceId()).digest("hex");
188
+ }
189
+
190
+ test("labels this machine's own host credential in the human list", async () => {
191
+ seedLocal("host-label", "http://127.0.0.1:7837");
192
+ stubFetch((url) =>
193
+ url.endsWith("/v1/devices")
194
+ ? jsonResponse({
195
+ devices: [
196
+ {
197
+ hashedDeviceId: hostHash(),
198
+ platform: "cli",
199
+ issuedAt: 1_700_000_000_000,
200
+ expiresAt: null,
201
+ lastUsedAt: null,
202
+ },
203
+ {
204
+ hashedDeviceId: "hashBBB222",
205
+ platform: "ios",
206
+ issuedAt: 1_700_000_000_000,
207
+ expiresAt: null,
208
+ lastUsedAt: null,
209
+ },
210
+ ],
211
+ })
212
+ : jsonResponse({ error: "unexpected" }, 500),
213
+ );
214
+
215
+ process.argv = ["bun", "vellum", "devices", "host-label"];
216
+ const { exited, logs } = await runDevices();
217
+
218
+ expect(exited).toBe(false);
219
+ const label = "this machine's host credential";
220
+ expect(logs).toContain(label);
221
+ // Only the host record carries the label.
222
+ expect(logs.split(label)).toHaveLength(2);
223
+ const hostIdx = logs.indexOf(hostHash());
224
+ const otherIdx = logs.indexOf("hashBBB222");
225
+ const labelIdx = logs.indexOf(label);
226
+ expect(labelIdx).toBeGreaterThan(hostIdx);
227
+ expect(labelIdx).toBeLessThan(otherIdx);
228
+ });
229
+
230
+ test("list --json marks only the host credential with isCurrentHost", async () => {
231
+ seedLocal("host-json", "http://127.0.0.1:7838");
232
+ const hostRecord = {
233
+ hashedDeviceId: hostHash(),
234
+ platform: "cli",
235
+ issuedAt: 1_700_000_000_000,
236
+ expiresAt: null,
237
+ lastUsedAt: null,
238
+ };
239
+ const otherRecord = {
240
+ hashedDeviceId: "hashBBB222",
241
+ platform: "ios",
242
+ issuedAt: 1_700_000_000_000,
243
+ expiresAt: null,
244
+ lastUsedAt: null,
245
+ };
246
+ stubFetch((url) =>
247
+ url.endsWith("/v1/devices")
248
+ ? jsonResponse({ devices: [hostRecord, otherRecord] })
249
+ : jsonResponse({ error: "unexpected" }, 500),
250
+ );
251
+
252
+ process.argv = ["bun", "vellum", "devices", "host-json", "--json"];
253
+ const { exited, logs } = await runDevices();
254
+
255
+ expect(exited).toBe(false);
256
+ const parsed = JSON.parse(logs) as {
257
+ devices: Array<Record<string, unknown>>;
258
+ };
259
+ expect(parsed.devices[0]).toEqual({ ...hostRecord, isCurrentHost: true });
260
+ // Non-host records omit the field entirely (lean wire).
261
+ expect(parsed.devices[1]).toEqual(otherRecord);
262
+ expect("isCurrentHost" in parsed.devices[1]!).toBe(false);
263
+ });
264
+
265
+ test("revoking the host credential warns on stderr but proceeds", async () => {
266
+ seedLocal("host-revoke", "http://127.0.0.1:7839");
267
+ stubFetch((url) =>
268
+ url.endsWith("/v1/devices/revoke")
269
+ ? jsonResponse({ revoked: true })
270
+ : jsonResponse({ error: "unexpected" }, 500),
271
+ );
272
+
273
+ process.argv = [
274
+ "bun",
275
+ "vellum",
276
+ "devices",
277
+ "revoke",
278
+ hostHash(),
279
+ "host-revoke",
280
+ "--yes",
281
+ ];
282
+ const { exited, logs, errors } = await runDevices();
283
+
284
+ expect(exited).toBe(false);
285
+ expect(errors).toContain("own host credential");
286
+ expect(logs).toContain(`Revoked device ${hostHash()}`);
287
+ expect(fetchCalls).toHaveLength(1);
288
+ });
289
+
181
290
  test("prints a clear message when no devices are paired", async () => {
182
291
  seedLocal("empty-host");
183
292
  stubFetch(() => jsonResponse({ devices: [] }));
@@ -259,6 +368,111 @@ describe("vellum devices", () => {
259
368
  expect(fetchCalls).toHaveLength(0);
260
369
  });
261
370
 
371
+ test("list --json emits a single JSON document and no human text", async () => {
372
+ seedLocal("json-host", "http://127.0.0.1:7835");
373
+ const records = [
374
+ {
375
+ hashedDeviceId: "hashAAA111",
376
+ platform: "cli",
377
+ issuedAt: 1_700_000_000_000,
378
+ expiresAt: 1_800_000_000_000,
379
+ lastUsedAt: null,
380
+ },
381
+ ];
382
+ stubFetch((url) =>
383
+ url.endsWith("/v1/devices")
384
+ ? jsonResponse({ devices: records })
385
+ : jsonResponse({ error: "unexpected" }, 500),
386
+ );
387
+
388
+ process.argv = ["bun", "vellum", "devices", "json-host", "--json"];
389
+ const { exited, logs, errors } = await runDevices();
390
+
391
+ expect(exited).toBe(false);
392
+ expect(errors).toBe("");
393
+ // Exactly one line on stdout, parseable, and zero prose.
394
+ expect(logs).not.toContain("\n");
395
+ expect(JSON.parse(logs)).toEqual({ devices: records });
396
+ expect(logs).not.toContain("Devices paired to");
397
+ });
398
+
399
+ test('list --json with zero devices emits {"devices":[]}', async () => {
400
+ seedLocal("json-empty-host");
401
+ stubFetch(() => jsonResponse({ devices: [] }));
402
+
403
+ process.argv = ["bun", "vellum", "devices", "json-empty-host", "--json"];
404
+ const { exited, logs } = await runDevices();
405
+
406
+ expect(exited).toBe(false);
407
+ expect(logs).toBe('{"devices":[]}');
408
+ });
409
+
410
+ test("revoke --json --yes emits one JSON line; identity preamble on stderr", async () => {
411
+ seedLocal("json-revoke-host", "http://127.0.0.1:7836");
412
+ stubFetch((url) =>
413
+ url.endsWith("/v1/devices/revoke")
414
+ ? jsonResponse({ revoked: true, hashedDeviceId: "hashAAA111" })
415
+ : jsonResponse({ error: "unexpected" }, 500),
416
+ );
417
+
418
+ process.argv = [
419
+ "bun",
420
+ "vellum",
421
+ "devices",
422
+ "revoke",
423
+ "hashAAA111",
424
+ "json-revoke-host",
425
+ "--yes",
426
+ "--json",
427
+ ];
428
+ const { exited, logs, errors } = await runDevices();
429
+
430
+ expect(exited).toBe(false);
431
+ // Stdout is exactly one JSON document, no prose.
432
+ expect(logs).not.toContain("\n");
433
+ expect(JSON.parse(logs)).toEqual({
434
+ ok: true,
435
+ hashedDeviceId: "hashAAA111",
436
+ assistantId: "json-revoke-host",
437
+ });
438
+ expect(logs).not.toContain("Device to revoke");
439
+ // Destructive-identity preamble still printed, on stderr (cli/AGENTS.md).
440
+ expect(errors).toContain("Device to revoke:");
441
+ expect(errors).toContain("hashAAA111");
442
+ });
443
+
444
+ test("revoke --json without --yes exits 1 with empty stdout", async () => {
445
+ seedLocal("json-noyes-host");
446
+
447
+ process.argv = [
448
+ "bun",
449
+ "vellum",
450
+ "devices",
451
+ "revoke",
452
+ "hashAAA111",
453
+ "json-noyes-host",
454
+ "--json",
455
+ ];
456
+ const { exited, logs, errors } = await runDevices();
457
+
458
+ expect(exited).toBe(true);
459
+ expect(logs).toBe("");
460
+ expect(errors).toContain("--json requires --yes");
461
+ expect(fetchCalls).toHaveLength(0);
462
+ });
463
+
464
+ test("list --json still exits 1 with stderr on a non-2xx gateway response", async () => {
465
+ seedLocal("json-err-host");
466
+ stubFetch(() => jsonResponse({ error: { code: "FORBIDDEN" } }, 403));
467
+
468
+ process.argv = ["bun", "vellum", "devices", "json-err-host", "--json"];
469
+ const { exited, logs, errors } = await runDevices();
470
+
471
+ expect(exited).toBe(true);
472
+ expect(logs).toBe("");
473
+ expect(errors).toContain("403");
474
+ });
475
+
262
476
  test("surfaces a non-2xx gateway response on list", async () => {
263
477
  seedLocal("err-host");
264
478
  stubFetch(() => jsonResponse({ error: { code: "FORBIDDEN" } }, 403));
@@ -35,6 +35,8 @@ import {
35
35
  isActiveAssistant,
36
36
  isPairedLockfileEntry,
37
37
  PAIRED_GUARDIAN_TOKEN_HOST_ONLY_ERROR,
38
+ runDevicesList,
39
+ runDevicesRevoke,
38
40
  runHatch,
39
41
  runRetire,
40
42
  connectImport,
@@ -427,6 +429,8 @@ const LOCKFILE_PATTERN = /^(?:\/assistant)?\/__local\/lockfile$/;
427
429
  const HATCH_PATTERN = /^(?:\/assistant)?\/__local\/hatch$/;
428
430
  const RETIRE_PATTERN = /^(?:\/assistant)?\/__local\/retire$/;
429
431
  const UNPAIR_PATTERN = /^(?:\/assistant)?\/__local\/unpair$/;
432
+ const DEVICES_PATTERN = /^(?:\/assistant)?\/__local\/devices$/;
433
+ const DEVICES_REVOKE_PATTERN = /^(?:\/assistant)?\/__local\/devices-revoke$/;
430
434
  const CONNECT_IMPORT_PATTERN = /^(?:\/assistant)?\/__local\/connect-import$/;
431
435
  const GUARDIAN_TOKEN_PATTERN =
432
436
  /^(?:\/assistant)?\/__local\/guardian-token\/([^/]+)$/;
@@ -483,6 +487,8 @@ async function handleLocalEndpoints(
483
487
  HATCH_PATTERN.test(pathname) ||
484
488
  RETIRE_PATTERN.test(pathname) ||
485
489
  UNPAIR_PATTERN.test(pathname) ||
490
+ DEVICES_PATTERN.test(pathname) ||
491
+ DEVICES_REVOKE_PATTERN.test(pathname) ||
486
492
  CONNECT_IMPORT_PATTERN.test(pathname) ||
487
493
  GUARDIAN_TOKEN_PATTERN.test(pathname) ||
488
494
  PLATFORM_SESSION_PATTERN.test(pathname) ||
@@ -706,6 +712,60 @@ async function handleLocalEndpoints(
706
712
  );
707
713
  }
708
714
 
715
+ // Paired devices: list and revoke via the CLI (`vellum devices … --json`).
716
+ // Always 200 with `ok` discriminating success, matching the other hosts.
717
+ const isDevicesRevoke = DEVICES_REVOKE_PATTERN.test(pathname);
718
+ if (DEVICES_PATTERN.test(pathname) || isDevicesRevoke) {
719
+ if (req.method !== "POST") {
720
+ return new Response(null, { status: 405 });
721
+ }
722
+
723
+ let body: { assistantId?: unknown; hashedDeviceId?: unknown };
724
+ try {
725
+ body = (await req.json()) as typeof body;
726
+ } catch {
727
+ return Response.json(
728
+ { ok: false, error: "Invalid JSON body" },
729
+ { status: 400 },
730
+ );
731
+ }
732
+
733
+ const { assistantId, hashedDeviceId } = body;
734
+ if (typeof assistantId !== "string" || !assistantId) {
735
+ return Response.json(
736
+ { ok: false, error: "Missing assistantId" },
737
+ { status: 400 },
738
+ );
739
+ }
740
+ let revokeHash: string | null = null;
741
+ if (isDevicesRevoke) {
742
+ if (typeof hashedDeviceId !== "string" || !hashedDeviceId) {
743
+ return Response.json(
744
+ { ok: false, error: "Missing hashedDeviceId" },
745
+ { status: 400 },
746
+ );
747
+ }
748
+ revokeHash = hashedDeviceId;
749
+ }
750
+
751
+ let invocation: CliInvocation;
752
+ try {
753
+ invocation = resolveDevCliInvocation(_baseDir);
754
+ } catch (err) {
755
+ return Response.json(
756
+ { ok: false, error: err instanceof Error ? err.message : String(err) },
757
+ { status: 500 },
758
+ );
759
+ }
760
+
761
+ if (revokeHash !== null) {
762
+ return Response.json(
763
+ await runDevicesRevoke(invocation, assistantId, revokeHash),
764
+ );
765
+ }
766
+ return Response.json(await runDevicesList(invocation, assistantId));
767
+ }
768
+
709
769
  // Connect-import: register a pairing bundle from another machine (guardian
710
770
  // token + paired lockfile entry), the write counterpart of unpair.
711
771
  if (CONNECT_IMPORT_PATTERN.test(pathname)) {
@@ -13,12 +13,17 @@
13
13
  * that runs the assistant (server side).
14
14
  */
15
15
 
16
+ import { createHash } from "node:crypto";
17
+
18
+ import type { DeviceRecord } from "@vellumai/local-mode";
19
+
16
20
  import {
17
21
  type AssistantEntry,
18
22
  formatAssistantReference,
19
23
  getAssistantDisplayName,
20
24
  resolveTargetAssistant,
21
25
  } from "../lib/assistant-config";
26
+ import { computeDeviceId } from "../lib/guardian-token.js";
22
27
  import { parseAssistantTargetArg } from "../lib/assistant-target-args.js";
23
28
  import {
24
29
  CLI_INTERFACE_ID,
@@ -30,14 +35,6 @@ import {
30
35
  } from "../lib/confirm-action.js";
31
36
  import { loopbackSafeFetch } from "../lib/loopback-fetch.js";
32
37
 
33
- interface DeviceRecord {
34
- hashedDeviceId: string;
35
- platform: string;
36
- issuedAt: number | null;
37
- expiresAt: number | null;
38
- lastUsedAt: number | null;
39
- }
40
-
41
38
  function printUsage(): void {
42
39
  console.log(`vellum devices [beta] - List and revoke devices paired to a local assistant
43
40
 
@@ -51,6 +48,7 @@ ARGUMENTS:
51
48
 
52
49
  OPTIONS:
53
50
  --yes Skip the interactive confirmation prompt when revoking (for automation)
51
+ --json Print machine-readable JSON on stdout (revoke requires --yes)
54
52
 
55
53
  Lists the devices paired to a local (host-side) assistant, or revokes one by its
56
54
  hashed id. Runs on the machine that hosts the assistant — paired connections
@@ -98,13 +96,28 @@ function resolveLoopbackBase(entry: AssistantEntry): string {
98
96
  return base.replace(/\/+$/, "");
99
97
  }
100
98
 
99
+ /**
100
+ * The hashed device id the gateway stores for THIS machine's own guardian
101
+ * credential (`leaseGuardianToken` registers it device-bound as
102
+ * sha256(deviceId)). It shows up in `/v1/devices` next to remotely paired
103
+ * devices; revoking it locks this host out of its own assistant until
104
+ * guardian repair.
105
+ */
106
+ function currentHostHashedDeviceId(): string {
107
+ return createHash("sha256").update(computeDeviceId()).digest("hex");
108
+ }
109
+
101
110
  /** Format an epoch-ms timestamp as ISO, or a placeholder when absent. */
102
111
  function formatTimestamp(ms: number | null, absent: string): string {
103
112
  if (typeof ms !== "number" || !Number.isFinite(ms)) return absent;
104
113
  return new Date(ms).toISOString();
105
114
  }
106
115
 
107
- async function listDevices(entry: AssistantEntry, base: string): Promise<void> {
116
+ async function listDevices(
117
+ entry: AssistantEntry,
118
+ base: string,
119
+ jsonOutput: boolean,
120
+ ): Promise<void> {
108
121
  const displayName = getAssistantDisplayName(entry);
109
122
 
110
123
  let response: Response;
@@ -130,7 +143,17 @@ async function listDevices(entry: AssistantEntry, base: string): Promise<void> {
130
143
  }
131
144
 
132
145
  const body = (await response.json()) as { devices?: DeviceRecord[] };
133
- const devices = body.devices ?? [];
146
+ const hostHash = currentHostHashedDeviceId();
147
+ const devices = (body.devices ?? []).map((device) =>
148
+ device.hashedDeviceId === hostHash
149
+ ? { ...device, isCurrentHost: true as const }
150
+ : device,
151
+ );
152
+
153
+ if (jsonOutput) {
154
+ console.log(JSON.stringify({ devices }));
155
+ return;
156
+ }
134
157
 
135
158
  if (devices.length === 0) {
136
159
  console.log(`No devices are paired to ${displayName}.`);
@@ -147,6 +170,11 @@ async function listDevices(entry: AssistantEntry, base: string): Promise<void> {
147
170
  console.log(
148
171
  ` last used: ${formatTimestamp(device.lastUsedAt, "never")}`,
149
172
  );
173
+ if (device.isCurrentHost) {
174
+ console.log(
175
+ " this machine's host credential; revoking locks this host out until guardian repair",
176
+ );
177
+ }
150
178
  console.log("");
151
179
  }
152
180
  console.log(
@@ -159,14 +187,34 @@ async function revokeDevice(
159
187
  base: string,
160
188
  hashedDeviceId: string,
161
189
  yes: boolean,
190
+ jsonOutput: boolean,
162
191
  ): Promise<void> {
163
192
  const displayName = getAssistantDisplayName(entry);
164
193
 
165
- // Print the resolved identity before acting (cli/AGENTS.md).
166
- console.log("Device to revoke:");
167
- console.log(` Assistant: ${formatAssistantReference(entry)}`);
168
- console.log(` Device: ${hashedDeviceId}`);
169
- console.log("");
194
+ if (jsonOutput && !yes) {
195
+ console.error(
196
+ "Error: --json requires --yes; the revoke confirmation prompt would corrupt JSON stdout.",
197
+ );
198
+ process.exit(1);
199
+ }
200
+
201
+ // Print the resolved identity before acting (cli/AGENTS.md). In --json mode
202
+ // it goes to stderr so stdout stays a single JSON document.
203
+ const printIdentity = jsonOutput ? console.error : console.log;
204
+ printIdentity("Device to revoke:");
205
+ printIdentity(` Assistant: ${formatAssistantReference(entry)}`);
206
+ printIdentity(` Device: ${hashedDeviceId}`);
207
+ printIdentity("");
208
+
209
+ // Warn (don't block; recoverable via guardian repair) when the target is
210
+ // this machine's own host credential. --json is app-driven; the UI disables
211
+ // the host row, so the warning stays out of automation transcripts.
212
+ if (!jsonOutput && hashedDeviceId === currentHostHashedDeviceId()) {
213
+ console.error(
214
+ "Warning: this is this machine's own host credential. Revoking it locks this host out of the assistant until guardian repair.",
215
+ );
216
+ console.error("");
217
+ }
170
218
 
171
219
  if (!yes) {
172
220
  if (!canPromptForConfirmation()) {
@@ -211,19 +259,33 @@ async function revokeDevice(
211
259
  process.exit(1);
212
260
  }
213
261
 
262
+ if (jsonOutput) {
263
+ console.log(
264
+ JSON.stringify({
265
+ ok: true,
266
+ hashedDeviceId,
267
+ assistantId: entry.assistantId,
268
+ }),
269
+ );
270
+ return;
271
+ }
272
+
214
273
  console.log(
215
274
  `Revoked device ${hashedDeviceId} from ${displayName}. Its tokens are invalidated; that machine must re-pair to reconnect.`,
216
275
  );
217
276
  }
218
277
 
219
278
  export async function devices(): Promise<void> {
220
- const args = process.argv.slice(3);
279
+ const rawArgs = process.argv.slice(3);
221
280
 
222
- if (args.includes("--help") || args.includes("-h")) {
281
+ if (rawArgs.includes("--help") || rawArgs.includes("-h")) {
223
282
  printUsage();
224
283
  return;
225
284
  }
226
285
 
286
+ const jsonOutput = rawArgs.includes("--json");
287
+ const args = rawArgs.filter((a) => a !== "--json");
288
+
227
289
  if (args[0] === "revoke") {
228
290
  const rest = args.slice(1);
229
291
  const yes = rest.includes("--yes");
@@ -237,12 +299,12 @@ export async function devices(): Promise<void> {
237
299
  const nameArg = positionals.slice(1).join(" ") || undefined;
238
300
  const entry = resolveTargetAssistant(nameArg);
239
301
  const base = resolveLoopbackBase(entry);
240
- await revokeDevice(entry, base, hashedDeviceId, yes);
302
+ await revokeDevice(entry, base, hashedDeviceId, yes, jsonOutput);
241
303
  return;
242
304
  }
243
305
 
244
306
  const nameArg = parseAssistantTargetArg(args, []);
245
307
  const entry = resolveTargetAssistant(nameArg);
246
308
  const base = resolveLoopbackBase(entry);
247
- await listDevices(entry, base);
309
+ await listDevices(entry, base, jsonOutput);
248
310
  }