@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.
- package/node_modules/@vellumai/local-mode/src/devices.test.ts +290 -0
- package/node_modules/@vellumai/local-mode/src/devices.ts +188 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +6 -0
- package/package.json +1 -1
- package/src/__tests__/devices.test.ts +214 -0
- package/src/commands/client.ts +60 -0
- package/src/commands/devices.ts +81 -19
|
@@ -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
|
@@ -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));
|
package/src/commands/client.ts
CHANGED
|
@@ -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)) {
|
package/src/commands/devices.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
|
279
|
+
const rawArgs = process.argv.slice(3);
|
|
221
280
|
|
|
222
|
-
if (
|
|
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
|
}
|