@vellumai/cli 0.11.2 → 0.11.3-staging.2
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/knip.json +1 -0
- package/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
- package/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
- package/node_modules/@vellumai/environments/src/index.ts +17 -6
- package/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
- package/node_modules/@vellumai/environments/src/seeds.ts +29 -0
- package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
- package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
- package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
- package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/index.ts +17 -6
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/seeds.ts +29 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/LICENSE +20 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/README.md +38 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/bin/nanoid.js +55 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.browser.js +29 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.d.ts +106 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.js +47 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/nanoid.js +1 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.d.ts +48 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.js +21 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/package.json +46 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/url-alphabet/index.js +2 -0
- package/node_modules/@vellumai/local-mode/package.json +1 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +63 -1
- package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +503 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token.test.ts +203 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +46 -1
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +6 -4
- package/node_modules/@vellumai/local-mode/src/__tests__/pair.test.ts +621 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +5 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +232 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +126 -20
- package/node_modules/@vellumai/local-mode/src/environment.ts +36 -15
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +313 -25
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +222 -13
- package/node_modules/@vellumai/local-mode/src/index.ts +35 -3
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +18 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +72 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +130 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +134 -45
- package/node_modules/@vellumai/local-mode/src/pair.ts +354 -0
- package/node_modules/@vellumai/local-mode/src/paths.ts +49 -0
- package/node_modules/@vellumai/local-mode/src/status.ts +10 -8
- package/node_modules/@vellumai/local-mode/src/unpair.ts +101 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +34 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
- package/package.json +3 -1
- package/src/__tests__/client-token.test.ts +26 -1
- package/src/__tests__/guardian-token.test.ts +4 -0
- package/src/__tests__/nginx-ingress-command.test.ts +176 -16
- package/src/__tests__/nginx-ingress.test.ts +1821 -101
- package/src/__tests__/pair.test.ts +104 -2
- package/src/__tests__/sleep.test.ts +7 -3
- package/src/__tests__/tailscale-tunnel.test.ts +0 -1
- package/src/__tests__/tunnel.test.ts +1099 -55
- package/src/__tests__/wake.test.ts +166 -99
- package/src/commands/client.ts +182 -27
- package/src/commands/connect/import.ts +35 -156
- package/src/commands/nginx-ingress.ts +87 -110
- package/src/commands/pair.ts +84 -21
- package/src/commands/tunnel.ts +196 -85
- package/src/commands/upgrade.ts +3 -2
- package/src/commands/wake.ts +5 -163
- package/src/index.ts +1 -1
- package/src/lib/__tests__/local-ces.test.ts +10 -1
- package/src/lib/cloudflare-tunnel.ts +3 -16
- package/src/lib/environments/__tests__/paths.test.ts +20 -1
- package/src/lib/environments/paths.ts +29 -41
- package/src/lib/environments/resolve.ts +7 -5
- package/src/lib/guardian-token.ts +22 -62
- package/src/lib/ingress-config.ts +25 -0
- package/src/lib/local.ts +77 -20
- package/src/lib/nginx-ingress.ts +523 -76
- package/src/lib/ngrok.ts +154 -61
- package/src/lib/orphan-detection.test.ts +167 -0
- package/src/lib/orphan-detection.ts +40 -0
- package/src/lib/pair.test.ts +87 -0
- package/src/lib/pair.ts +55 -0
- package/src/lib/platform-client.ts +8 -5
- package/src/lib/tailscale-tunnel.ts +7 -18
- package/src/lib/tunnel-edge.ts +114 -0
- package/src/lib/xdg-log.ts +2 -2
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { guardianTokenPath } from "../config";
|
|
7
|
+
import {
|
|
8
|
+
connectImport,
|
|
9
|
+
decodePairBundle,
|
|
10
|
+
MAX_PAIR_BUNDLE_LENGTH,
|
|
11
|
+
pairAssistant,
|
|
12
|
+
type PairBundle,
|
|
13
|
+
} from "../pair";
|
|
14
|
+
|
|
15
|
+
let tmpDir: string;
|
|
16
|
+
let lockfilePath: string;
|
|
17
|
+
let configDir: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vellum-pair-"));
|
|
21
|
+
lockfilePath = path.join(tmpDir, "lockfile.json");
|
|
22
|
+
configDir = path.join(tmpDir, "config");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const writeLockfile = (data: Record<string, unknown>): void => {
|
|
30
|
+
fs.writeFileSync(lockfilePath, JSON.stringify(data));
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const readLockfileFromDisk = (): Record<string, unknown> =>
|
|
34
|
+
JSON.parse(fs.readFileSync(lockfilePath, "utf-8")) as Record<string, unknown>;
|
|
35
|
+
|
|
36
|
+
const encodeBundle = (obj: Record<string, unknown>): string =>
|
|
37
|
+
Buffer.from(JSON.stringify(obj)).toString("base64");
|
|
38
|
+
|
|
39
|
+
const bundle = (overrides: Partial<PairBundle> = {}): PairBundle => ({
|
|
40
|
+
gatewayUrl: "http://10.0.0.5:7830",
|
|
41
|
+
token: "test-token",
|
|
42
|
+
assistantId: "self",
|
|
43
|
+
deviceId: "dev-aaa",
|
|
44
|
+
...overrides,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** A syntactically valid JWT whose `exp` is 2030-01-01T00:00:00Z. */
|
|
48
|
+
const JWT_EXP_S = 1893456000;
|
|
49
|
+
const jwtWithExp = `h.${Buffer.from(JSON.stringify({ exp: JWT_EXP_S })).toString("base64")}.s`;
|
|
50
|
+
|
|
51
|
+
describe("decodePairBundle", () => {
|
|
52
|
+
test("decodes a full bundle, preserving a numeric refreshTokenExpiresAt", () => {
|
|
53
|
+
const result = decodePairBundle(
|
|
54
|
+
encodeBundle({
|
|
55
|
+
gatewayUrl: "https://tunnel.example.com",
|
|
56
|
+
token: "tok",
|
|
57
|
+
assistantId: "self",
|
|
58
|
+
deviceId: "dev-1",
|
|
59
|
+
refreshToken: "refresh",
|
|
60
|
+
refreshTokenExpiresAt: 1893456000000,
|
|
61
|
+
refreshAfter: "2026-07-01T00:00:00.000Z",
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
expect(result).toEqual({
|
|
66
|
+
ok: true,
|
|
67
|
+
bundle: {
|
|
68
|
+
gatewayUrl: "https://tunnel.example.com",
|
|
69
|
+
token: "tok",
|
|
70
|
+
assistantId: "self",
|
|
71
|
+
deviceId: "dev-1",
|
|
72
|
+
refreshToken: "refresh",
|
|
73
|
+
refreshTokenExpiresAt: 1893456000000,
|
|
74
|
+
refreshAfter: "2026-07-01T00:00:00.000Z",
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("drops malformed optional fields instead of failing", () => {
|
|
80
|
+
const result = decodePairBundle(
|
|
81
|
+
encodeBundle({
|
|
82
|
+
gatewayUrl: "http://10.0.0.5:7830",
|
|
83
|
+
token: "tok",
|
|
84
|
+
deviceId: 42,
|
|
85
|
+
refreshToken: { nope: true },
|
|
86
|
+
refreshAfter: 7,
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
expect(result).toEqual({
|
|
91
|
+
ok: true,
|
|
92
|
+
bundle: {
|
|
93
|
+
gatewayUrl: "http://10.0.0.5:7830",
|
|
94
|
+
token: "tok",
|
|
95
|
+
assistantId: undefined,
|
|
96
|
+
deviceId: undefined,
|
|
97
|
+
refreshToken: undefined,
|
|
98
|
+
refreshTokenExpiresAt: undefined,
|
|
99
|
+
refreshAfter: undefined,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("rejects garbage base64 and JSON non-objects without throwing", () => {
|
|
105
|
+
for (const encoded of [
|
|
106
|
+
"not-valid-base64!!",
|
|
107
|
+
Buffer.from(JSON.stringify("just a string")).toString("base64"),
|
|
108
|
+
Buffer.from("{truncated").toString("base64"),
|
|
109
|
+
]) {
|
|
110
|
+
const result = decodePairBundle(encoded);
|
|
111
|
+
expect(result.ok).toBe(false);
|
|
112
|
+
if (result.ok) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
expect(typeof result.error).toBe("string");
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("rejects a bundle missing token or gatewayUrl", () => {
|
|
120
|
+
expect(decodePairBundle(encodeBundle({ token: "tok" })).ok).toBe(false);
|
|
121
|
+
expect(
|
|
122
|
+
decodePairBundle(encodeBundle({ gatewayUrl: "http://h" })).ok,
|
|
123
|
+
).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("rejects a non-http(s) or relative gatewayUrl", () => {
|
|
127
|
+
expect(
|
|
128
|
+
decodePairBundle(encodeBundle({ gatewayUrl: "ftp://nope", token: "t" }))
|
|
129
|
+
.ok,
|
|
130
|
+
).toBe(false);
|
|
131
|
+
expect(
|
|
132
|
+
decodePairBundle(encodeBundle({ gatewayUrl: "/relative", token: "t" }))
|
|
133
|
+
.ok,
|
|
134
|
+
).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("pairAssistant", () => {
|
|
139
|
+
test("writes the lockfile entry and guardian token with the exact CLI shapes and modes", () => {
|
|
140
|
+
const result = pairAssistant([lockfilePath], configDir, {
|
|
141
|
+
bundle: bundle({ token: jwtWithExp, deviceId: "dev-aaa" }),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(result.ok).toBe(true);
|
|
145
|
+
if (!result.ok) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
expect(result.assistantId).toBe("paired-dev-aaa");
|
|
149
|
+
expect(result.updated).toBe(false);
|
|
150
|
+
expect(result.accessOnly).toBe(true);
|
|
151
|
+
|
|
152
|
+
// The on-disk entry matches what `vellum connect import` writes, field for
|
|
153
|
+
// field and in the same key order.
|
|
154
|
+
const onDisk = readLockfileFromDisk();
|
|
155
|
+
const entry = (onDisk.assistants as Array<Record<string, unknown>>)[0]!;
|
|
156
|
+
expect(entry).toEqual({
|
|
157
|
+
assistantId: "paired-dev-aaa",
|
|
158
|
+
name: "paired (10.0.0.5:7830)",
|
|
159
|
+
runtimeUrl: "http://10.0.0.5:7830",
|
|
160
|
+
cloud: "paired",
|
|
161
|
+
paired: true,
|
|
162
|
+
species: "vellum",
|
|
163
|
+
});
|
|
164
|
+
expect(Object.keys(entry)).toEqual([
|
|
165
|
+
"assistantId",
|
|
166
|
+
"name",
|
|
167
|
+
"runtimeUrl",
|
|
168
|
+
"cloud",
|
|
169
|
+
"paired",
|
|
170
|
+
"species",
|
|
171
|
+
]);
|
|
172
|
+
// Importing never reassigns the active assistant.
|
|
173
|
+
expect(onDisk.activeAssistant ?? null).toBeNull();
|
|
174
|
+
|
|
175
|
+
const tokenPath = guardianTokenPath(configDir, "paired-dev-aaa");
|
|
176
|
+
const raw = fs.readFileSync(tokenPath, "utf-8");
|
|
177
|
+
expect(raw.endsWith("\n")).toBe(true);
|
|
178
|
+
const token = JSON.parse(raw) as Record<string, unknown>;
|
|
179
|
+
const { leasedAt, ...rest } = token;
|
|
180
|
+
expect(rest).toEqual({
|
|
181
|
+
guardianPrincipalId: "imported",
|
|
182
|
+
accessToken: jwtWithExp,
|
|
183
|
+
accessTokenExpiresAt: JWT_EXP_S * 1000,
|
|
184
|
+
refreshToken: "",
|
|
185
|
+
refreshTokenExpiresAt: 0,
|
|
186
|
+
refreshAfter: "",
|
|
187
|
+
isNew: false,
|
|
188
|
+
deviceId: "dev-aaa",
|
|
189
|
+
pairedGatewayUrl: "http://10.0.0.5:7830",
|
|
190
|
+
});
|
|
191
|
+
expect(new Date(leasedAt as string).toISOString()).toBe(
|
|
192
|
+
leasedAt as string,
|
|
193
|
+
);
|
|
194
|
+
expect(Object.keys(token)).toEqual([
|
|
195
|
+
"guardianPrincipalId",
|
|
196
|
+
"accessToken",
|
|
197
|
+
"accessTokenExpiresAt",
|
|
198
|
+
"refreshToken",
|
|
199
|
+
"refreshTokenExpiresAt",
|
|
200
|
+
"refreshAfter",
|
|
201
|
+
"isNew",
|
|
202
|
+
"deviceId",
|
|
203
|
+
"leasedAt",
|
|
204
|
+
"pairedGatewayUrl",
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
expect(fs.statSync(tokenPath).mode & 0o777).toBe(0o600);
|
|
208
|
+
expect(fs.statSync(path.dirname(tokenPath)).mode & 0o777).toBe(0o700);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("falls back to now+24h expiry for a non-JWT access token", () => {
|
|
212
|
+
const before = Date.now();
|
|
213
|
+
const result = pairAssistant([lockfilePath], configDir, {
|
|
214
|
+
bundle: bundle({ token: "opaque-token", deviceId: "dev-exp" }),
|
|
215
|
+
});
|
|
216
|
+
expect(result.ok).toBe(true);
|
|
217
|
+
|
|
218
|
+
const token = JSON.parse(
|
|
219
|
+
fs.readFileSync(guardianTokenPath(configDir, "paired-dev-exp"), "utf-8"),
|
|
220
|
+
) as Record<string, unknown>;
|
|
221
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
222
|
+
expect(token.accessTokenExpiresAt as number).toBeGreaterThanOrEqual(
|
|
223
|
+
before + dayMs,
|
|
224
|
+
);
|
|
225
|
+
expect(token.accessTokenExpiresAt as number).toBeLessThanOrEqual(
|
|
226
|
+
Date.now() + dayMs,
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("persists the refresh credential and reports accessOnly: false", () => {
|
|
231
|
+
const result = pairAssistant([lockfilePath], configDir, {
|
|
232
|
+
bundle: bundle({
|
|
233
|
+
gatewayUrl: "https://gw.example.com",
|
|
234
|
+
deviceId: "dev-refresh",
|
|
235
|
+
refreshToken: "refresh-tok",
|
|
236
|
+
refreshTokenExpiresAt: "2027-01-01T00:00:00.000Z",
|
|
237
|
+
refreshAfter: "2026-07-01T00:00:00.000Z",
|
|
238
|
+
}),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
expect(result.ok).toBe(true);
|
|
242
|
+
if (!result.ok) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
expect(result.accessOnly).toBe(false);
|
|
246
|
+
const token = JSON.parse(
|
|
247
|
+
fs.readFileSync(
|
|
248
|
+
guardianTokenPath(configDir, "paired-dev-refresh"),
|
|
249
|
+
"utf-8",
|
|
250
|
+
),
|
|
251
|
+
) as Record<string, unknown>;
|
|
252
|
+
expect(token.refreshToken).toBe("refresh-tok");
|
|
253
|
+
expect(token.refreshTokenExpiresAt).toBe("2027-01-01T00:00:00.000Z");
|
|
254
|
+
expect(token.refreshAfter).toBe("2026-07-01T00:00:00.000Z");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("refuses loopback gateway URLs so a bundle can't alias local services", () => {
|
|
258
|
+
// A stored loopback runtimeUrl would otherwise read as a local gateway
|
|
259
|
+
// (e.g. to the loopback proxy allowlist), aliasing arbitrary local ports.
|
|
260
|
+
for (const gatewayUrl of [
|
|
261
|
+
"http://127.0.0.1:5432",
|
|
262
|
+
"http://localhost:7830",
|
|
263
|
+
"http://[::1]:7830",
|
|
264
|
+
"https://127.0.0.1:7830",
|
|
265
|
+
// Wildcard and IPv4-mapped aliases reach local listeners when dialed.
|
|
266
|
+
"http://0.0.0.0:7830",
|
|
267
|
+
"http://0:7830",
|
|
268
|
+
"http://[::]:7830",
|
|
269
|
+
"http://[::ffff:127.0.0.1]:7830",
|
|
270
|
+
"http://[0:0:0:0:0:ffff:127.0.0.1]:7830",
|
|
271
|
+
"http://[::ffff:0.0.0.0]:7830",
|
|
272
|
+
]) {
|
|
273
|
+
const result = pairAssistant([lockfilePath], configDir, {
|
|
274
|
+
bundle: bundle({ gatewayUrl, deviceId: "dev-loop" }),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
expect(result.ok).toBe(false);
|
|
278
|
+
if (result.ok) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
expect(result.status).toBe(422);
|
|
282
|
+
expect(result.error).toContain("non-loopback");
|
|
283
|
+
}
|
|
284
|
+
// Nothing was written for any refused bundle.
|
|
285
|
+
expect(fs.existsSync(lockfilePath)).toBe(false);
|
|
286
|
+
expect(
|
|
287
|
+
fs.existsSync(guardianTokenPath(configDir, "paired-dev-loop")),
|
|
288
|
+
).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("a non-loopback plaintext http gateway is access-only even with a refresh token", () => {
|
|
292
|
+
// refreshGuardianToken refuses to send the refresh token over plaintext
|
|
293
|
+
// LAN URLs, so the pairing can never renew; report it access-only so the
|
|
294
|
+
// expiry warning shows.
|
|
295
|
+
const result = pairAssistant([lockfilePath], configDir, {
|
|
296
|
+
bundle: bundle({
|
|
297
|
+
gatewayUrl: "http://10.0.0.5:7830",
|
|
298
|
+
deviceId: "dev-lan",
|
|
299
|
+
refreshToken: "refresh-tok",
|
|
300
|
+
refreshTokenExpiresAt: "2027-01-01T00:00:00.000Z",
|
|
301
|
+
}),
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
expect(result.ok).toBe(true);
|
|
305
|
+
if (!result.ok) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
expect(result.accessOnly).toBe(true);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("a name is slugified into the id while the entry keeps the raw name", () => {
|
|
312
|
+
const result = pairAssistant([lockfilePath], configDir, {
|
|
313
|
+
bundle: bundle(),
|
|
314
|
+
name: "Desk Box",
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
expect(result.ok).toBe(true);
|
|
318
|
+
if (!result.ok) {
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
expect(result.assistantId).toBe("desk-box");
|
|
322
|
+
const entry = (
|
|
323
|
+
readLockfileFromDisk().assistants as Array<Record<string, unknown>>
|
|
324
|
+
)[0]!;
|
|
325
|
+
expect(entry.name).toBe("Desk Box");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("a name with no alphanumerics is refused with a 400", () => {
|
|
329
|
+
const result = pairAssistant([lockfilePath], configDir, {
|
|
330
|
+
bundle: bundle(),
|
|
331
|
+
name: "!!!",
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(result.ok).toBe(false);
|
|
335
|
+
if (result.ok) {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
expect(result.status).toBe(400);
|
|
339
|
+
expect(fs.existsSync(lockfilePath)).toBe(false);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("a malicious deviceId is slugified out of the path", () => {
|
|
343
|
+
const result = pairAssistant([lockfilePath], configDir, {
|
|
344
|
+
bundle: bundle({ deviceId: "-/../../tmp/x" }),
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
expect(result.ok).toBe(true);
|
|
348
|
+
if (!result.ok) {
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
expect(result.assistantId).not.toContain("/");
|
|
352
|
+
expect(result.assistantId).not.toContain("..");
|
|
353
|
+
expect(
|
|
354
|
+
fs.existsSync(guardianTokenPath(configDir, result.assistantId)),
|
|
355
|
+
).toBe(true);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("a missing deviceId falls back to a random path-safe id", () => {
|
|
359
|
+
const result = pairAssistant([lockfilePath], configDir, {
|
|
360
|
+
bundle: bundle({ deviceId: undefined }),
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
expect(result.ok).toBe(true);
|
|
364
|
+
if (!result.ok) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
expect(result.assistantId).toMatch(/^paired-[A-Za-z0-9_-]{21}$/);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("refuses to clobber an existing non-paired assistant", () => {
|
|
371
|
+
writeLockfile({
|
|
372
|
+
assistants: [
|
|
373
|
+
{
|
|
374
|
+
assistantId: "desk",
|
|
375
|
+
cloud: "local",
|
|
376
|
+
runtimeUrl: "http://127.0.0.1:7830",
|
|
377
|
+
},
|
|
378
|
+
],
|
|
379
|
+
activeAssistant: "desk",
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const result = pairAssistant([lockfilePath], configDir, {
|
|
383
|
+
bundle: bundle(),
|
|
384
|
+
name: "desk",
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
expect(result.ok).toBe(false);
|
|
388
|
+
if (result.ok) {
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
expect(result.status).toBe(409);
|
|
392
|
+
expect(result.assistantId).toBe("desk");
|
|
393
|
+
// Nothing was written: the entry is untouched and no token exists.
|
|
394
|
+
expect(readLockfileFromDisk().assistants).toEqual([
|
|
395
|
+
{
|
|
396
|
+
assistantId: "desk",
|
|
397
|
+
cloud: "local",
|
|
398
|
+
runtimeUrl: "http://127.0.0.1:7830",
|
|
399
|
+
},
|
|
400
|
+
]);
|
|
401
|
+
expect(fs.existsSync(guardianTokenPath(configDir, "desk"))).toBe(false);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("re-importing over an existing paired entry updates it in place", () => {
|
|
405
|
+
const first = pairAssistant([lockfilePath], configDir, {
|
|
406
|
+
bundle: bundle({ deviceId: "dev-re", token: "t1" }),
|
|
407
|
+
});
|
|
408
|
+
expect(first.ok).toBe(true);
|
|
409
|
+
|
|
410
|
+
const second = pairAssistant([lockfilePath], configDir, {
|
|
411
|
+
bundle: bundle({
|
|
412
|
+
deviceId: "dev-re",
|
|
413
|
+
token: "t2",
|
|
414
|
+
gatewayUrl: "https://new.example.com",
|
|
415
|
+
}),
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
expect(second.ok).toBe(true);
|
|
419
|
+
if (!second.ok) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
expect(second.updated).toBe(true);
|
|
423
|
+
const assistants = readLockfileFromDisk().assistants as Array<
|
|
424
|
+
Record<string, unknown>
|
|
425
|
+
>;
|
|
426
|
+
expect(assistants).toHaveLength(1);
|
|
427
|
+
expect(assistants[0]!.runtimeUrl).toBe("https://new.example.com");
|
|
428
|
+
const token = JSON.parse(
|
|
429
|
+
fs.readFileSync(guardianTokenPath(configDir, "paired-dev-re"), "utf-8"),
|
|
430
|
+
) as Record<string, unknown>;
|
|
431
|
+
expect(token.accessToken).toBe("t2");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("deletes the just-written token when the lockfile write fails", () => {
|
|
435
|
+
// A lockfile path whose parent is a regular file makes the write fail
|
|
436
|
+
// deterministically (unlike chmod, which root ignores) after the token
|
|
437
|
+
// has already been written.
|
|
438
|
+
const notADir = path.join(tmpDir, "not-a-dir");
|
|
439
|
+
fs.writeFileSync(notADir, "");
|
|
440
|
+
const tokenPath = guardianTokenPath(configDir, "paired-dev-aaa");
|
|
441
|
+
|
|
442
|
+
const result = pairAssistant(
|
|
443
|
+
[path.join(notADir, "lockfile.json")],
|
|
444
|
+
configDir,
|
|
445
|
+
{ bundle: bundle() },
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
expect(result.ok).toBe(false);
|
|
449
|
+
expect(fs.existsSync(tokenPath)).toBe(false);
|
|
450
|
+
// The per-assistant directory is cleaned up too.
|
|
451
|
+
expect(fs.existsSync(path.dirname(tokenPath))).toBe(false);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("restores the prior token when a re-import's lockfile write fails", () => {
|
|
455
|
+
const first = pairAssistant([lockfilePath], configDir, {
|
|
456
|
+
bundle: bundle({ deviceId: "dev-re", token: "t1" }),
|
|
457
|
+
});
|
|
458
|
+
expect(first.ok).toBe(true);
|
|
459
|
+
const tokenPath = guardianTokenPath(configDir, "paired-dev-re");
|
|
460
|
+
const priorContents = fs.readFileSync(tokenPath, "utf-8");
|
|
461
|
+
|
|
462
|
+
// Reads fall back to the real lockfile (so the existing entry is found
|
|
463
|
+
// and this is a genuine re-import) while the write targets a path whose
|
|
464
|
+
// parent is a regular file and fails deterministically (unlike chmod,
|
|
465
|
+
// which root ignores).
|
|
466
|
+
const notADir = path.join(tmpDir, "not-a-dir");
|
|
467
|
+
fs.writeFileSync(notADir, "");
|
|
468
|
+
const result = pairAssistant(
|
|
469
|
+
[path.join(notADir, "lockfile.json"), lockfilePath],
|
|
470
|
+
configDir,
|
|
471
|
+
{ bundle: bundle({ deviceId: "dev-re", token: "t2" }) },
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
expect(result.ok).toBe(false);
|
|
475
|
+
expect(fs.readFileSync(tokenPath, "utf-8")).toBe(priorContents);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("an unparseable gatewayUrl on an unvalidated bundle is refused, not thrown", () => {
|
|
479
|
+
const result = pairAssistant([lockfilePath], configDir, {
|
|
480
|
+
bundle: bundle({ gatewayUrl: "not a url" }),
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
expect(result.ok).toBe(false);
|
|
484
|
+
if (result.ok) {
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
expect(result.status).toBe(422);
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
describe("connectImport", () => {
|
|
492
|
+
test("registers a valid bundle and returns the wire-shaped success", () => {
|
|
493
|
+
const result = connectImport([lockfilePath], configDir, {
|
|
494
|
+
bundle: ` ${encodeBundle({
|
|
495
|
+
gatewayUrl: "https://gw.example.com",
|
|
496
|
+
token: "tok",
|
|
497
|
+
deviceId: "dev-ci",
|
|
498
|
+
})} `,
|
|
499
|
+
name: "Desk Box",
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
expect(result).toEqual({
|
|
503
|
+
ok: true,
|
|
504
|
+
assistantId: "desk-box",
|
|
505
|
+
accessOnly: true,
|
|
506
|
+
});
|
|
507
|
+
expect(fs.existsSync(guardianTokenPath(configDir, "desk-box"))).toBe(true);
|
|
508
|
+
const entry = (
|
|
509
|
+
readLockfileFromDisk().assistants as Array<Record<string, unknown>>
|
|
510
|
+
)[0]!;
|
|
511
|
+
expect(entry).toMatchObject({
|
|
512
|
+
assistantId: "desk-box",
|
|
513
|
+
cloud: "paired",
|
|
514
|
+
paired: true,
|
|
515
|
+
runtimeUrl: "https://gw.example.com",
|
|
516
|
+
});
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("a missing, empty, or non-string bundle is a 400", () => {
|
|
520
|
+
for (const value of [undefined, "", 42]) {
|
|
521
|
+
expect(
|
|
522
|
+
connectImport([lockfilePath], configDir, { bundle: value }),
|
|
523
|
+
).toEqual({
|
|
524
|
+
ok: false,
|
|
525
|
+
status: 400,
|
|
526
|
+
error: "Missing pairing bundle",
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
expect(fs.existsSync(lockfilePath)).toBe(false);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("an oversized bundle is refused before decoding", () => {
|
|
533
|
+
expect(
|
|
534
|
+
connectImport([lockfilePath], configDir, {
|
|
535
|
+
bundle: "a".repeat(MAX_PAIR_BUNDLE_LENGTH + 1),
|
|
536
|
+
}),
|
|
537
|
+
).toEqual({
|
|
538
|
+
ok: false,
|
|
539
|
+
status: 400,
|
|
540
|
+
error: "Pairing bundle is too large",
|
|
541
|
+
});
|
|
542
|
+
expect(fs.existsSync(lockfilePath)).toBe(false);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test("a loopback gatewayUrl keeps pairAssistant's 422 and refusal copy", () => {
|
|
546
|
+
const result = connectImport([lockfilePath], configDir, {
|
|
547
|
+
bundle: encodeBundle({
|
|
548
|
+
gatewayUrl: "http://127.0.0.1:5432",
|
|
549
|
+
token: "tok",
|
|
550
|
+
deviceId: "dev-loop",
|
|
551
|
+
}),
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
expect(result.ok).toBe(false);
|
|
555
|
+
if (result.ok) {
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
expect(result.status).toBe(422);
|
|
559
|
+
expect(result.error).toContain("non-loopback");
|
|
560
|
+
expect(fs.existsSync(lockfilePath)).toBe(false);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("a malformed bundle maps the decode error to a 400", () => {
|
|
564
|
+
const result = connectImport([lockfilePath], configDir, {
|
|
565
|
+
bundle: "not-base64-json!!!",
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
expect(result.ok).toBe(false);
|
|
569
|
+
if (result.ok) {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
expect(result.status).toBe(400);
|
|
573
|
+
expect(result.error).toContain("base64");
|
|
574
|
+
expect(fs.existsSync(lockfilePath)).toBe(false);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("an overwrite refusal keeps pairAssistant's 409 and error string", () => {
|
|
578
|
+
writeLockfile({
|
|
579
|
+
assistants: [
|
|
580
|
+
{
|
|
581
|
+
assistantId: "desk",
|
|
582
|
+
cloud: "local",
|
|
583
|
+
runtimeUrl: "http://127.0.0.1:7830",
|
|
584
|
+
},
|
|
585
|
+
],
|
|
586
|
+
activeAssistant: "desk",
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
const result = connectImport([lockfilePath], configDir, {
|
|
590
|
+
bundle: encodeBundle({
|
|
591
|
+
gatewayUrl: "https://gw.example.com",
|
|
592
|
+
token: "tok",
|
|
593
|
+
}),
|
|
594
|
+
name: "desk",
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
expect(result).toEqual({
|
|
598
|
+
ok: false,
|
|
599
|
+
status: 409,
|
|
600
|
+
error: "An assistant named 'desk' already exists",
|
|
601
|
+
});
|
|
602
|
+
expect(fs.existsSync(guardianTokenPath(configDir, "desk"))).toBe(false);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test("a non-string name is ignored rather than slugified", () => {
|
|
606
|
+
const result = connectImport([lockfilePath], configDir, {
|
|
607
|
+
bundle: encodeBundle({
|
|
608
|
+
gatewayUrl: "https://gw.example.com",
|
|
609
|
+
token: "tok",
|
|
610
|
+
deviceId: "dev-nn",
|
|
611
|
+
}),
|
|
612
|
+
name: 7,
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
expect(result).toEqual({
|
|
616
|
+
ok: true,
|
|
617
|
+
assistantId: "paired-dev-nn",
|
|
618
|
+
accessOnly: true,
|
|
619
|
+
});
|
|
620
|
+
});
|
|
621
|
+
});
|
|
@@ -87,6 +87,11 @@ afterEach(() => {
|
|
|
87
87
|
});
|
|
88
88
|
|
|
89
89
|
describe("getLocalAssistantStatus", () => {
|
|
90
|
+
test("returns a structured error for unsafe assistant IDs", async () => {
|
|
91
|
+
const result = await getLocalAssistantStatus([lockfilePath], "../other");
|
|
92
|
+
expect(result.ok).toBe(false);
|
|
93
|
+
expect(result.ok ? 200 : result.status).toBe(400);
|
|
94
|
+
});
|
|
90
95
|
test("returns sleeping when the assistant PID file is absent", async () => {
|
|
91
96
|
writeLocalLockfile();
|
|
92
97
|
|