@vellumai/cli 0.8.6 → 0.8.7-dev.202606052118.34cd356
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/bun.lock +8 -0
- package/knip.json +5 -1
- package/node_modules/@vellumai/environments/bun.lock +24 -0
- package/node_modules/@vellumai/environments/package.json +18 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
- package/node_modules/@vellumai/environments/src/index.ts +11 -0
- package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
- package/node_modules/@vellumai/environments/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/bun.lock +29 -0
- package/node_modules/@vellumai/local-mode/package.json +22 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
- package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
- package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
- package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
- package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
- package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
- package/package.json +12 -1
- package/src/__tests__/assistant-client-refresh.test.ts +182 -0
- package/src/__tests__/clean.test.ts +179 -0
- package/src/__tests__/client-token.test.ts +87 -0
- package/src/__tests__/client-tui-refresh.test.ts +170 -0
- package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
- package/src/__tests__/connect-import.test.ts +317 -0
- package/src/__tests__/devices.test.ts +272 -0
- package/src/__tests__/env-drift.test.ts +32 -44
- package/src/__tests__/flags.test.ts +248 -0
- package/src/__tests__/guardian-token.test.ts +126 -2
- package/src/__tests__/multi-local.test.ts +1 -1
- package/src/__tests__/orphan-detection.test.ts +8 -6
- package/src/__tests__/pair.test.ts +271 -0
- package/src/__tests__/paired-lifecycle.test.ts +116 -0
- package/src/__tests__/segments-to-plain-text.test.ts +37 -0
- package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
- package/src/__tests__/unpair.test.ts +163 -0
- package/src/commands/client.ts +511 -11
- package/src/commands/connect/import.ts +217 -0
- package/src/commands/connect.ts +31 -0
- package/src/commands/devices.ts +247 -0
- package/src/commands/env.ts +1 -1
- package/src/commands/flags.ts +89 -17
- package/src/commands/pair.ts +222 -0
- package/src/commands/ps.ts +16 -0
- package/src/commands/retire.ts +20 -47
- package/src/commands/sleep.ts +7 -0
- package/src/commands/tunnel.ts +46 -2
- package/src/commands/unpair.ts +118 -0
- package/src/commands/wake.ts +7 -0
- package/src/components/DefaultMainScreen.tsx +100 -14
- package/src/index.ts +16 -0
- package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
- package/src/lib/assistant-client.ts +58 -37
- package/src/lib/assistant-config.ts +15 -3
- package/src/lib/cloudflare-tunnel.ts +276 -0
- package/src/lib/confirm-action.ts +57 -0
- package/src/lib/docker.ts +25 -1
- package/src/lib/environments/__tests__/paths.test.ts +2 -1
- package/src/lib/environments/__tests__/seeds.test.ts +2 -1
- package/src/lib/environments/paths.ts +1 -1
- package/src/lib/environments/resolve.ts +11 -35
- package/src/lib/guardian-token.ts +132 -9
- package/src/lib/hatch-local.ts +73 -33
- package/src/lib/lifecycle-reporter.ts +31 -0
- package/src/lib/local.ts +20 -6
- package/src/lib/retire-local.ts +28 -14
- package/src/lib/segments-to-plain-text.ts +35 -0
- /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `vellum connect import <blob>`: decode a `vellum pair` bundle and
|
|
3
|
+
* persist a lockfile entry + guardian token under a unique local id.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
afterAll,
|
|
7
|
+
afterEach,
|
|
8
|
+
beforeEach,
|
|
9
|
+
describe,
|
|
10
|
+
expect,
|
|
11
|
+
spyOn,
|
|
12
|
+
test,
|
|
13
|
+
} from "bun:test";
|
|
14
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
const testDir = mkdtempSync(join(tmpdir(), "connect-import-test-"));
|
|
19
|
+
const ORIGINAL_LOCKFILE_DIR = process.env.VELLUM_LOCKFILE_DIR;
|
|
20
|
+
const ORIGINAL_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
|
|
21
|
+
const ORIGINAL_ARGV = [...process.argv];
|
|
22
|
+
|
|
23
|
+
import { connectImport } from "../commands/connect/import.js";
|
|
24
|
+
import {
|
|
25
|
+
findAssistantByName,
|
|
26
|
+
saveAssistantEntry,
|
|
27
|
+
} from "../lib/assistant-config.js";
|
|
28
|
+
import { loadGuardianToken } from "../lib/guardian-token.js";
|
|
29
|
+
|
|
30
|
+
function bundleFor(overrides: Record<string, unknown> = {}): string {
|
|
31
|
+
const obj = {
|
|
32
|
+
gatewayUrl: "http://10.0.0.5:7830",
|
|
33
|
+
assistantId: "self",
|
|
34
|
+
token: "test-token",
|
|
35
|
+
deviceId: "dev-aaa",
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
return Buffer.from(JSON.stringify(obj)).toString("base64");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe("connect import", () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
44
|
+
process.env.XDG_CONFIG_HOME = testDir;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
process.argv = [...ORIGINAL_ARGV];
|
|
49
|
+
if (ORIGINAL_LOCKFILE_DIR === undefined)
|
|
50
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
51
|
+
else process.env.VELLUM_LOCKFILE_DIR = ORIGINAL_LOCKFILE_DIR;
|
|
52
|
+
if (ORIGINAL_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
53
|
+
else process.env.XDG_CONFIG_HOME = ORIGINAL_CONFIG_HOME;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterAll(() => {
|
|
57
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("writes a lockfile entry + guardian token from a valid bundle", async () => {
|
|
61
|
+
process.argv = ["bun", "vellum", "connect", "import", bundleFor()];
|
|
62
|
+
const logSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
63
|
+
try {
|
|
64
|
+
await connectImport();
|
|
65
|
+
} finally {
|
|
66
|
+
logSpy.mockRestore();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const entry = findAssistantByName("paired-dev-aaa");
|
|
70
|
+
expect(entry).not.toBeNull();
|
|
71
|
+
expect(entry!.runtimeUrl).toBe("http://10.0.0.5:7830");
|
|
72
|
+
expect(entry!.cloud).toBe("paired");
|
|
73
|
+
expect(loadGuardianToken("paired-dev-aaa")?.accessToken).toBe("test-token");
|
|
74
|
+
// Back-compat: a bundle without refresh fields imports access-only.
|
|
75
|
+
expect(loadGuardianToken("paired-dev-aaa")?.refreshToken).toBe("");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("persists the refresh credential when the bundle carries one", async () => {
|
|
79
|
+
process.argv = [
|
|
80
|
+
"bun",
|
|
81
|
+
"vellum",
|
|
82
|
+
"connect",
|
|
83
|
+
"import",
|
|
84
|
+
bundleFor({
|
|
85
|
+
deviceId: "dev-refresh",
|
|
86
|
+
token: "acc-tok",
|
|
87
|
+
refreshToken: "refresh-tok",
|
|
88
|
+
refreshTokenExpiresAt: "2027-01-01T00:00:00.000Z",
|
|
89
|
+
refreshAfter: "2026-07-01T00:00:00.000Z",
|
|
90
|
+
}),
|
|
91
|
+
];
|
|
92
|
+
const logSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
93
|
+
try {
|
|
94
|
+
await connectImport();
|
|
95
|
+
} finally {
|
|
96
|
+
logSpy.mockRestore();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const tok = loadGuardianToken("paired-dev-refresh");
|
|
100
|
+
expect(tok?.accessToken).toBe("acc-tok");
|
|
101
|
+
expect(tok?.refreshToken).toBe("refresh-tok");
|
|
102
|
+
expect(tok?.refreshTokenExpiresAt).toBe("2027-01-01T00:00:00.000Z");
|
|
103
|
+
expect(tok?.refreshAfter).toBe("2026-07-01T00:00:00.000Z");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("preserves a numeric (epoch-ms) refreshTokenExpiresAt", async () => {
|
|
107
|
+
// GuardianTokenData allows refreshTokenExpiresAt to be an epoch-ms number;
|
|
108
|
+
// a numeric value in the bundle must round-trip, not be dropped to 0.
|
|
109
|
+
const expiresMs = 1893456000000; // 2030-01-01
|
|
110
|
+
process.argv = [
|
|
111
|
+
"bun",
|
|
112
|
+
"vellum",
|
|
113
|
+
"connect",
|
|
114
|
+
"import",
|
|
115
|
+
bundleFor({
|
|
116
|
+
deviceId: "dev-num",
|
|
117
|
+
refreshToken: "refresh-tok",
|
|
118
|
+
refreshTokenExpiresAt: expiresMs,
|
|
119
|
+
}),
|
|
120
|
+
];
|
|
121
|
+
const logSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
122
|
+
try {
|
|
123
|
+
await connectImport();
|
|
124
|
+
} finally {
|
|
125
|
+
logSpy.mockRestore();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
expect(loadGuardianToken("paired-dev-num")?.refreshTokenExpiresAt).toBe(
|
|
129
|
+
expiresMs,
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("two different bundles (both assistantId 'self') do not collide", async () => {
|
|
134
|
+
process.argv = [
|
|
135
|
+
"bun",
|
|
136
|
+
"vellum",
|
|
137
|
+
"connect",
|
|
138
|
+
"import",
|
|
139
|
+
bundleFor({ deviceId: "dev-one", token: "tok1" }),
|
|
140
|
+
];
|
|
141
|
+
const logSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
142
|
+
try {
|
|
143
|
+
await connectImport();
|
|
144
|
+
process.argv = [
|
|
145
|
+
"bun",
|
|
146
|
+
"vellum",
|
|
147
|
+
"connect",
|
|
148
|
+
"import",
|
|
149
|
+
bundleFor({ deviceId: "dev-two", token: "tok2" }),
|
|
150
|
+
];
|
|
151
|
+
await connectImport();
|
|
152
|
+
} finally {
|
|
153
|
+
logSpy.mockRestore();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
expect(findAssistantByName("paired-dev-one")).not.toBeNull();
|
|
157
|
+
expect(findAssistantByName("paired-dev-two")).not.toBeNull();
|
|
158
|
+
expect(loadGuardianToken("paired-dev-one")?.accessToken).toBe("tok1");
|
|
159
|
+
expect(loadGuardianToken("paired-dev-two")?.accessToken).toBe("tok2");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("--name registers the entry under that name", async () => {
|
|
163
|
+
process.argv = [
|
|
164
|
+
"bun",
|
|
165
|
+
"vellum",
|
|
166
|
+
"connect",
|
|
167
|
+
"import",
|
|
168
|
+
bundleFor({ deviceId: "dev-named" }),
|
|
169
|
+
"--name",
|
|
170
|
+
"Desk Box",
|
|
171
|
+
];
|
|
172
|
+
const logSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
173
|
+
try {
|
|
174
|
+
await connectImport();
|
|
175
|
+
} finally {
|
|
176
|
+
logSpy.mockRestore();
|
|
177
|
+
}
|
|
178
|
+
// Slugified to a stable id.
|
|
179
|
+
expect(findAssistantByName("desk-box")).not.toBeNull();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("sanitizes a malicious bundle deviceId (no path traversal in the local id)", async () => {
|
|
183
|
+
process.argv = [
|
|
184
|
+
"bun",
|
|
185
|
+
"vellum",
|
|
186
|
+
"connect",
|
|
187
|
+
"import",
|
|
188
|
+
bundleFor({ deviceId: "-/../../tmp/x", token: "tokX" }),
|
|
189
|
+
];
|
|
190
|
+
const logs: string[] = [];
|
|
191
|
+
const logSpy = spyOn(console, "log").mockImplementation(
|
|
192
|
+
(...a: unknown[]) => {
|
|
193
|
+
logs.push(a.join(" "));
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
try {
|
|
197
|
+
await connectImport();
|
|
198
|
+
} finally {
|
|
199
|
+
logSpy.mockRestore();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The registered id must contain no path separators or `..`.
|
|
203
|
+
const m = logs.join("\n").match(/paired assistant '([^']+)'/);
|
|
204
|
+
expect(m).not.toBeNull();
|
|
205
|
+
const id = m![1];
|
|
206
|
+
expect(id).not.toContain("/");
|
|
207
|
+
expect(id).not.toContain("..");
|
|
208
|
+
expect(loadGuardianToken(id)?.accessToken).toBe("tokX");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("does not overwrite an existing non-paired assistant", async () => {
|
|
212
|
+
saveAssistantEntry({
|
|
213
|
+
assistantId: "desk",
|
|
214
|
+
name: "Desk",
|
|
215
|
+
runtimeUrl: "http://127.0.0.1:7830",
|
|
216
|
+
cloud: "local",
|
|
217
|
+
species: "vellum",
|
|
218
|
+
});
|
|
219
|
+
process.argv = [
|
|
220
|
+
"bun",
|
|
221
|
+
"vellum",
|
|
222
|
+
"connect",
|
|
223
|
+
"import",
|
|
224
|
+
bundleFor({ deviceId: "dx" }),
|
|
225
|
+
"--name",
|
|
226
|
+
"desk",
|
|
227
|
+
];
|
|
228
|
+
const errSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
229
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((c?: number) => {
|
|
230
|
+
throw new Error(`exit:${c}`);
|
|
231
|
+
}) as never);
|
|
232
|
+
let exited = false;
|
|
233
|
+
try {
|
|
234
|
+
await connectImport();
|
|
235
|
+
} catch (e) {
|
|
236
|
+
exited = (e as Error).message === "exit:1";
|
|
237
|
+
} finally {
|
|
238
|
+
errSpy.mockRestore();
|
|
239
|
+
exitSpy.mockRestore();
|
|
240
|
+
}
|
|
241
|
+
expect(exited).toBe(true);
|
|
242
|
+
// The original local assistant is untouched (not overwritten).
|
|
243
|
+
const e = findAssistantByName("desk");
|
|
244
|
+
expect(e!.runtimeUrl).toBe("http://127.0.0.1:7830");
|
|
245
|
+
expect(e!.paired).toBeUndefined();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("re-importing the same pairing updates in place", async () => {
|
|
249
|
+
const logSpy = spyOn(console, "log").mockImplementation(() => {});
|
|
250
|
+
try {
|
|
251
|
+
process.argv = [
|
|
252
|
+
"bun",
|
|
253
|
+
"vellum",
|
|
254
|
+
"connect",
|
|
255
|
+
"import",
|
|
256
|
+
bundleFor({ deviceId: "dev-re", token: "t1" }),
|
|
257
|
+
];
|
|
258
|
+
await connectImport();
|
|
259
|
+
process.argv = [
|
|
260
|
+
"bun",
|
|
261
|
+
"vellum",
|
|
262
|
+
"connect",
|
|
263
|
+
"import",
|
|
264
|
+
bundleFor({ deviceId: "dev-re", token: "t2" }),
|
|
265
|
+
];
|
|
266
|
+
await connectImport();
|
|
267
|
+
} finally {
|
|
268
|
+
logSpy.mockRestore();
|
|
269
|
+
}
|
|
270
|
+
expect(loadGuardianToken("paired-dev-re")?.accessToken).toBe("t2");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("rejects a bundle whose gatewayUrl is not http(s)", async () => {
|
|
274
|
+
process.argv = [
|
|
275
|
+
"bun",
|
|
276
|
+
"vellum",
|
|
277
|
+
"connect",
|
|
278
|
+
"import",
|
|
279
|
+
bundleFor({ gatewayUrl: "ftp://nope", deviceId: "dz" }),
|
|
280
|
+
];
|
|
281
|
+
const errSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
282
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((c?: number) => {
|
|
283
|
+
throw new Error(`exit:${c}`);
|
|
284
|
+
}) as never);
|
|
285
|
+
let exited = false;
|
|
286
|
+
try {
|
|
287
|
+
await connectImport();
|
|
288
|
+
} catch (e) {
|
|
289
|
+
exited = (e as Error).message === "exit:1";
|
|
290
|
+
} finally {
|
|
291
|
+
errSpy.mockRestore();
|
|
292
|
+
exitSpy.mockRestore();
|
|
293
|
+
}
|
|
294
|
+
expect(exited).toBe(true);
|
|
295
|
+
expect(findAssistantByName("paired-dz")).toBeNull();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("a malformed bundle exits 1 and registers nothing", async () => {
|
|
299
|
+
process.argv = ["bun", "vellum", "connect", "import", "not-valid-base64!!"];
|
|
300
|
+
const errSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
301
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((c?: number) => {
|
|
302
|
+
throw new Error(`exit:${c}`);
|
|
303
|
+
}) as never);
|
|
304
|
+
let exited = false;
|
|
305
|
+
try {
|
|
306
|
+
await connectImport();
|
|
307
|
+
} catch (e) {
|
|
308
|
+
exited = (e as Error).message === "exit:1";
|
|
309
|
+
} finally {
|
|
310
|
+
errSpy.mockRestore();
|
|
311
|
+
exitSpy.mockRestore();
|
|
312
|
+
}
|
|
313
|
+
expect(exited).toBe(true);
|
|
314
|
+
// A malformed bundle has no deviceId, so no `paired-*` entry is created.
|
|
315
|
+
expect(findAssistantByName("paired-")).toBeNull();
|
|
316
|
+
});
|
|
317
|
+
});
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `vellum devices` (list) and `vellum devices revoke <hashedDeviceId>`:
|
|
3
|
+
* the host-side CLI that calls the loopback `GET /v1/devices` and
|
|
4
|
+
* `POST /v1/devices/revoke` endpoints. Verifies host-gating (refuses paired
|
|
5
|
+
* connections), the destructive-revoke confirmation, and that requests carry no
|
|
6
|
+
* browser/proxy headers.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
afterAll,
|
|
10
|
+
afterEach,
|
|
11
|
+
beforeEach,
|
|
12
|
+
describe,
|
|
13
|
+
expect,
|
|
14
|
+
spyOn,
|
|
15
|
+
test,
|
|
16
|
+
} from "bun:test";
|
|
17
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
const testDir = mkdtempSync(join(tmpdir(), "devices-test-"));
|
|
22
|
+
const ORIGINAL_LOCKFILE_DIR = process.env.VELLUM_LOCKFILE_DIR;
|
|
23
|
+
const ORIGINAL_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
|
|
24
|
+
const ORIGINAL_ARGV = [...process.argv];
|
|
25
|
+
const ORIGINAL_FETCH = globalThis.fetch;
|
|
26
|
+
|
|
27
|
+
import { devices } from "../commands/devices.js";
|
|
28
|
+
import { saveAssistantEntry } from "../lib/assistant-config.js";
|
|
29
|
+
|
|
30
|
+
interface FetchCall {
|
|
31
|
+
url: string;
|
|
32
|
+
init?: RequestInit;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let fetchCalls: FetchCall[] = [];
|
|
36
|
+
|
|
37
|
+
/** Stub global fetch (spyOn does not intercept fetch in Bun). */
|
|
38
|
+
function stubFetch(
|
|
39
|
+
handler: (url: string, init?: RequestInit) => Response,
|
|
40
|
+
): void {
|
|
41
|
+
globalThis.fetch = (async (url: unknown, init?: RequestInit) => {
|
|
42
|
+
fetchCalls.push({ url: String(url), init });
|
|
43
|
+
return handler(String(url), init);
|
|
44
|
+
}) as typeof fetch;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
48
|
+
return new Response(JSON.stringify(body), {
|
|
49
|
+
status,
|
|
50
|
+
headers: { "Content-Type": "application/json" },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface RunResult {
|
|
55
|
+
exited: boolean;
|
|
56
|
+
logs: string;
|
|
57
|
+
errors: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Run devices() with console + process.exit spied. */
|
|
61
|
+
async function runDevices(): Promise<RunResult> {
|
|
62
|
+
const logs: string[] = [];
|
|
63
|
+
const errors: string[] = [];
|
|
64
|
+
const logSpy = spyOn(console, "log").mockImplementation((...a: unknown[]) => {
|
|
65
|
+
logs.push(a.join(" "));
|
|
66
|
+
});
|
|
67
|
+
const errSpy = spyOn(console, "error").mockImplementation(
|
|
68
|
+
(...a: unknown[]) => {
|
|
69
|
+
errors.push(a.join(" "));
|
|
70
|
+
},
|
|
71
|
+
);
|
|
72
|
+
const exitSpy = spyOn(process, "exit").mockImplementation(((c?: number) => {
|
|
73
|
+
throw new Error(`exit:${c}`);
|
|
74
|
+
}) as never);
|
|
75
|
+
let exited = false;
|
|
76
|
+
try {
|
|
77
|
+
await devices();
|
|
78
|
+
} catch (e) {
|
|
79
|
+
exited = (e as Error).message?.startsWith("exit:") ?? false;
|
|
80
|
+
} finally {
|
|
81
|
+
logSpy.mockRestore();
|
|
82
|
+
errSpy.mockRestore();
|
|
83
|
+
exitSpy.mockRestore();
|
|
84
|
+
}
|
|
85
|
+
return { exited, logs: logs.join("\n"), errors: errors.join("\n") };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function headerKeys(init?: RequestInit): string[] {
|
|
89
|
+
const h = init?.headers as Record<string, string> | undefined;
|
|
90
|
+
return h ? Object.keys(h).map((k) => k.toLowerCase()) : [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe("vellum devices", () => {
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
process.env.VELLUM_LOCKFILE_DIR = testDir;
|
|
96
|
+
process.env.XDG_CONFIG_HOME = testDir;
|
|
97
|
+
fetchCalls = [];
|
|
98
|
+
// Default stub: any unexpected call is recorded and 500s.
|
|
99
|
+
stubFetch(() => jsonResponse({ error: "unexpected" }, 500));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
process.argv = [...ORIGINAL_ARGV];
|
|
104
|
+
globalThis.fetch = ORIGINAL_FETCH;
|
|
105
|
+
if (ORIGINAL_LOCKFILE_DIR === undefined)
|
|
106
|
+
delete process.env.VELLUM_LOCKFILE_DIR;
|
|
107
|
+
else process.env.VELLUM_LOCKFILE_DIR = ORIGINAL_LOCKFILE_DIR;
|
|
108
|
+
if (ORIGINAL_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
109
|
+
else process.env.XDG_CONFIG_HOME = ORIGINAL_CONFIG_HOME;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
afterAll(() => {
|
|
113
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
function seedLocal(id: string, localUrl = "http://127.0.0.1:7830"): void {
|
|
117
|
+
saveAssistantEntry({
|
|
118
|
+
assistantId: id,
|
|
119
|
+
name: id,
|
|
120
|
+
runtimeUrl: "http://127.0.0.1:7830",
|
|
121
|
+
localUrl,
|
|
122
|
+
cloud: "local",
|
|
123
|
+
species: "vellum",
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
test("--help prints usage including Examples", async () => {
|
|
128
|
+
process.argv = ["bun", "vellum", "devices", "--help"];
|
|
129
|
+
const { logs } = await runDevices();
|
|
130
|
+
expect(logs).toContain("USAGE:");
|
|
131
|
+
expect(logs).toContain("EXAMPLES:");
|
|
132
|
+
expect(logs).toContain("vellum devices revoke");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("lists active devices over loopback with no browser/proxy headers", async () => {
|
|
136
|
+
seedLocal("list-host", "http://127.0.0.1:7833");
|
|
137
|
+
stubFetch((url) => {
|
|
138
|
+
if (url.endsWith("/v1/devices")) {
|
|
139
|
+
return jsonResponse({
|
|
140
|
+
devices: [
|
|
141
|
+
{
|
|
142
|
+
hashedDeviceId: "hashAAA111",
|
|
143
|
+
platform: "cli",
|
|
144
|
+
issuedAt: 1_700_000_000_000,
|
|
145
|
+
expiresAt: 1_800_000_000_000,
|
|
146
|
+
lastUsedAt: 1_750_000_000_000,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
hashedDeviceId: "hashBBB222",
|
|
150
|
+
platform: "webview",
|
|
151
|
+
issuedAt: 1_700_000_000_000,
|
|
152
|
+
expiresAt: null,
|
|
153
|
+
lastUsedAt: null,
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return jsonResponse({ error: "unexpected" }, 500);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
process.argv = ["bun", "vellum", "devices", "list-host"];
|
|
162
|
+
const { exited, logs } = await runDevices();
|
|
163
|
+
|
|
164
|
+
expect(exited).toBe(false);
|
|
165
|
+
// Both full hashes + platforms surfaced; null lastUsedAt → "never".
|
|
166
|
+
expect(logs).toContain("hashAAA111");
|
|
167
|
+
expect(logs).toContain("hashBBB222");
|
|
168
|
+
expect(logs).toContain("cli");
|
|
169
|
+
expect(logs).toContain("webview");
|
|
170
|
+
expect(logs).toContain("never");
|
|
171
|
+
|
|
172
|
+
expect(fetchCalls).toHaveLength(1);
|
|
173
|
+
const call = fetchCalls[0];
|
|
174
|
+
expect(call.url).toBe("http://127.0.0.1:7833/v1/devices");
|
|
175
|
+
expect(call.init?.method).toBe("GET");
|
|
176
|
+
const keys = headerKeys(call.init);
|
|
177
|
+
expect(keys).not.toContain("origin");
|
|
178
|
+
expect(keys).not.toContain("x-forwarded-for");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("prints a clear message when no devices are paired", async () => {
|
|
182
|
+
seedLocal("empty-host");
|
|
183
|
+
stubFetch(() => jsonResponse({ devices: [] }));
|
|
184
|
+
|
|
185
|
+
process.argv = ["bun", "vellum", "devices", "empty-host"];
|
|
186
|
+
const { exited, logs } = await runDevices();
|
|
187
|
+
|
|
188
|
+
expect(exited).toBe(false);
|
|
189
|
+
expect(logs).toContain("No devices are paired to empty-host");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("revoke posts the hashedDeviceId with --yes (no prompt)", async () => {
|
|
193
|
+
seedLocal("revoke-host", "http://127.0.0.1:7834");
|
|
194
|
+
stubFetch((url) => {
|
|
195
|
+
if (url.endsWith("/v1/devices/revoke")) {
|
|
196
|
+
return jsonResponse({ revoked: true, hashedDeviceId: "hashAAA111" });
|
|
197
|
+
}
|
|
198
|
+
return jsonResponse({ error: "unexpected" }, 500);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
process.argv = [
|
|
202
|
+
"bun",
|
|
203
|
+
"vellum",
|
|
204
|
+
"devices",
|
|
205
|
+
"revoke",
|
|
206
|
+
"hashAAA111",
|
|
207
|
+
"revoke-host",
|
|
208
|
+
"--yes",
|
|
209
|
+
];
|
|
210
|
+
const { exited, logs } = await runDevices();
|
|
211
|
+
|
|
212
|
+
expect(exited).toBe(false);
|
|
213
|
+
expect(logs).toContain("Revoked device hashAAA111");
|
|
214
|
+
|
|
215
|
+
expect(fetchCalls).toHaveLength(1);
|
|
216
|
+
const call = fetchCalls[0];
|
|
217
|
+
expect(call.url).toBe("http://127.0.0.1:7834/v1/devices/revoke");
|
|
218
|
+
expect(call.init?.method).toBe("POST");
|
|
219
|
+
expect(JSON.parse(String(call.init?.body))).toEqual({
|
|
220
|
+
hashedDeviceId: "hashAAA111",
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("revoke without a hashedDeviceId errors and makes no request", async () => {
|
|
225
|
+
process.argv = ["bun", "vellum", "devices", "revoke", "--yes"];
|
|
226
|
+
const { exited, errors } = await runDevices();
|
|
227
|
+
|
|
228
|
+
expect(exited).toBe(true);
|
|
229
|
+
expect(errors).toContain("hashedDeviceId is required");
|
|
230
|
+
expect(fetchCalls).toHaveLength(0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("revoke refuses without --yes in a non-interactive terminal", async () => {
|
|
234
|
+
seedLocal("rh3");
|
|
235
|
+
// process.stdin.isTTY is falsy under the test runner → not promptable.
|
|
236
|
+
process.argv = ["bun", "vellum", "devices", "revoke", "hashZZZ", "rh3"];
|
|
237
|
+
const { exited, errors } = await runDevices();
|
|
238
|
+
|
|
239
|
+
expect(exited).toBe(true);
|
|
240
|
+
expect(errors).toContain("--yes");
|
|
241
|
+
expect(fetchCalls).toHaveLength(0);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("host-gates a paired connection (points to the host / unpair)", async () => {
|
|
245
|
+
saveAssistantEntry({
|
|
246
|
+
assistantId: "paired-box",
|
|
247
|
+
name: "Paired Box",
|
|
248
|
+
runtimeUrl: "http://10.0.0.9:7830",
|
|
249
|
+
cloud: "paired",
|
|
250
|
+
paired: true,
|
|
251
|
+
species: "vellum",
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
process.argv = ["bun", "vellum", "devices", "paired-box"];
|
|
255
|
+
const { exited, errors } = await runDevices();
|
|
256
|
+
|
|
257
|
+
expect(exited).toBe(true);
|
|
258
|
+
expect(errors).toContain("vellum unpair");
|
|
259
|
+
expect(fetchCalls).toHaveLength(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("surfaces a non-2xx gateway response on list", async () => {
|
|
263
|
+
seedLocal("err-host");
|
|
264
|
+
stubFetch(() => jsonResponse({ error: { code: "FORBIDDEN" } }, 403));
|
|
265
|
+
|
|
266
|
+
process.argv = ["bun", "vellum", "devices", "err-host"];
|
|
267
|
+
const { exited, errors } = await runDevices();
|
|
268
|
+
|
|
269
|
+
expect(exited).toBe(true);
|
|
270
|
+
expect(errors).toContain("403");
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -2,64 +2,52 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
|
-
import { SEEDS } from "
|
|
5
|
+
import { SEEDS } from "@vellumai/environments";
|
|
6
6
|
|
|
7
|
-
// Drift guard
|
|
8
|
-
// environment names:
|
|
7
|
+
// Drift guard between the two language-level sources of truth for the set of
|
|
8
|
+
// known environment names:
|
|
9
9
|
//
|
|
10
|
-
// 1.
|
|
11
|
-
// 2.
|
|
10
|
+
// 1. packages/environments/src/seeds.ts — SEEDS record (TS source of truth)
|
|
11
|
+
// 2. clients/shared/App/VellumEnvironment.swift — Swift `VellumEnvironment` enum
|
|
12
12
|
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// FOLLOW-UP: split the env name list into a shared `packages/environments`
|
|
18
|
-
// package (mirroring `packages/service-contracts`, `credential-storage`) so
|
|
19
|
-
// both sites can `import { KNOWN_ENVIRONMENTS }` from one place and this
|
|
20
|
-
// drift guard becomes a compile-time check. Planned alongside CLI-driven
|
|
21
|
-
// context support — see the "Environments" design doc.
|
|
13
|
+
// The Swift client can't import the TypeScript package, so the two lists are
|
|
14
|
+
// maintained independently and must be kept in lockstep by hand. This test
|
|
15
|
+
// parses the enum cases out of the Swift source and asserts they agree with
|
|
16
|
+
// SEEDS. Adding an environment means updating both sites.
|
|
22
17
|
|
|
23
18
|
const REPO_ROOT = join(import.meta.dir, "..", "..", "..");
|
|
24
|
-
const
|
|
19
|
+
const SWIFT_ENVIRONMENT = join(
|
|
25
20
|
REPO_ROOT,
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
21
|
+
"clients",
|
|
22
|
+
"shared",
|
|
23
|
+
"App",
|
|
24
|
+
"VellumEnvironment.swift",
|
|
30
25
|
);
|
|
31
26
|
|
|
32
27
|
/**
|
|
33
|
-
* Extract the
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* the
|
|
28
|
+
* Extract the case names declared in the `VellumEnvironment` enum. Matches
|
|
29
|
+
* standalone `case <name>` declaration lines (one identifier, nothing else),
|
|
30
|
+
* which is the enum's own declaration syntax. Switch-statement arms like
|
|
31
|
+
* `case .local:` carry a leading dot and a trailing colon, so they're
|
|
32
|
+
* excluded — the match is anchored to a bare identifier at end of line.
|
|
38
33
|
*/
|
|
39
|
-
function
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const match = source.match(pattern);
|
|
45
|
-
if (!match) {
|
|
46
|
-
throw new Error(
|
|
47
|
-
`Could not find Set literal for ${setName}. Update the drift-guard regex in env-drift.test.ts.`,
|
|
48
|
-
);
|
|
34
|
+
function extractSwiftEnumCases(source: string): string[] {
|
|
35
|
+
const names: string[] = [];
|
|
36
|
+
for (const line of source.split("\n")) {
|
|
37
|
+
const match = line.match(/^\s*case\s+([a-zA-Z][a-zA-Z0-9]*)\s*$/);
|
|
38
|
+
if (match) names.push(match[1]!);
|
|
49
39
|
}
|
|
50
|
-
|
|
51
|
-
const literals = body.match(/"([^"]+)"/g) ?? [];
|
|
52
|
-
return literals.map((lit) => lit.slice(1, -1));
|
|
40
|
+
return names;
|
|
53
41
|
}
|
|
54
42
|
|
|
55
|
-
describe("
|
|
43
|
+
describe("environment name drift guard (TS ↔ Swift)", () => {
|
|
56
44
|
const seedNames = new Set(Object.keys(SEEDS));
|
|
57
45
|
|
|
58
|
-
test("
|
|
59
|
-
const source = readFileSync(
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
);
|
|
63
|
-
expect([...
|
|
46
|
+
test("clients/shared/App/VellumEnvironment.swift matches SEEDS", () => {
|
|
47
|
+
const source = readFileSync(SWIFT_ENVIRONMENT, "utf8");
|
|
48
|
+
const swiftNames = new Set(extractSwiftEnumCases(source));
|
|
49
|
+
|
|
50
|
+
expect(swiftNames.size).toBeGreaterThan(0);
|
|
51
|
+
expect([...swiftNames].sort()).toEqual([...seedNames].sort());
|
|
64
52
|
});
|
|
65
53
|
});
|