@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.
Files changed (97) hide show
  1. package/knip.json +1 -0
  2. package/node_modules/@vellumai/environments/package.json +2 -1
  3. package/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
  4. package/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
  5. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
  6. package/node_modules/@vellumai/environments/src/index.ts +17 -6
  7. package/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
  8. package/node_modules/@vellumai/environments/src/seeds.ts +29 -0
  9. package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
  10. package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
  11. package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
  12. package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
  13. package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
  14. package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
  15. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
  16. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
  17. package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
  18. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
  19. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
  20. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
  21. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
  22. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/index.ts +17 -6
  23. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
  24. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/seeds.ts +29 -0
  25. package/node_modules/@vellumai/local-mode/node_modules/nanoid/LICENSE +20 -0
  26. package/node_modules/@vellumai/local-mode/node_modules/nanoid/README.md +38 -0
  27. package/node_modules/@vellumai/local-mode/node_modules/nanoid/bin/nanoid.js +55 -0
  28. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.browser.js +29 -0
  29. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.d.ts +106 -0
  30. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.js +47 -0
  31. package/node_modules/@vellumai/local-mode/node_modules/nanoid/nanoid.js +1 -0
  32. package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.d.ts +48 -0
  33. package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.js +21 -0
  34. package/node_modules/@vellumai/local-mode/node_modules/nanoid/package.json +46 -0
  35. package/node_modules/@vellumai/local-mode/node_modules/nanoid/url-alphabet/index.js +2 -0
  36. package/node_modules/@vellumai/local-mode/package.json +1 -0
  37. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +63 -1
  38. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +503 -0
  39. package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token.test.ts +203 -0
  40. package/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +46 -1
  41. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +6 -4
  42. package/node_modules/@vellumai/local-mode/src/__tests__/pair.test.ts +621 -0
  43. package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +5 -0
  44. package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +232 -0
  45. package/node_modules/@vellumai/local-mode/src/config.ts +126 -20
  46. package/node_modules/@vellumai/local-mode/src/environment.ts +36 -15
  47. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +313 -25
  48. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +222 -13
  49. package/node_modules/@vellumai/local-mode/src/index.ts +35 -3
  50. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +18 -0
  51. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +72 -0
  52. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +130 -0
  53. package/node_modules/@vellumai/local-mode/src/lockfile.ts +134 -45
  54. package/node_modules/@vellumai/local-mode/src/pair.ts +354 -0
  55. package/node_modules/@vellumai/local-mode/src/paths.ts +49 -0
  56. package/node_modules/@vellumai/local-mode/src/status.ts +10 -8
  57. package/node_modules/@vellumai/local-mode/src/unpair.ts +101 -0
  58. package/node_modules/@vellumai/local-mode/src/util.ts +34 -0
  59. package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
  60. package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
  61. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
  62. package/package.json +3 -1
  63. package/src/__tests__/client-token.test.ts +26 -1
  64. package/src/__tests__/guardian-token.test.ts +4 -0
  65. package/src/__tests__/nginx-ingress-command.test.ts +176 -16
  66. package/src/__tests__/nginx-ingress.test.ts +1821 -101
  67. package/src/__tests__/pair.test.ts +104 -2
  68. package/src/__tests__/sleep.test.ts +7 -3
  69. package/src/__tests__/tailscale-tunnel.test.ts +0 -1
  70. package/src/__tests__/tunnel.test.ts +1099 -55
  71. package/src/__tests__/wake.test.ts +166 -99
  72. package/src/commands/client.ts +182 -27
  73. package/src/commands/connect/import.ts +35 -156
  74. package/src/commands/nginx-ingress.ts +87 -110
  75. package/src/commands/pair.ts +84 -21
  76. package/src/commands/tunnel.ts +196 -85
  77. package/src/commands/upgrade.ts +3 -2
  78. package/src/commands/wake.ts +5 -163
  79. package/src/index.ts +1 -1
  80. package/src/lib/__tests__/local-ces.test.ts +10 -1
  81. package/src/lib/cloudflare-tunnel.ts +3 -16
  82. package/src/lib/environments/__tests__/paths.test.ts +20 -1
  83. package/src/lib/environments/paths.ts +29 -41
  84. package/src/lib/environments/resolve.ts +7 -5
  85. package/src/lib/guardian-token.ts +22 -62
  86. package/src/lib/ingress-config.ts +25 -0
  87. package/src/lib/local.ts +77 -20
  88. package/src/lib/nginx-ingress.ts +523 -76
  89. package/src/lib/ngrok.ts +154 -61
  90. package/src/lib/orphan-detection.test.ts +167 -0
  91. package/src/lib/orphan-detection.ts +40 -0
  92. package/src/lib/pair.test.ts +87 -0
  93. package/src/lib/pair.ts +55 -0
  94. package/src/lib/platform-client.ts +8 -5
  95. package/src/lib/tailscale-tunnel.ts +7 -18
  96. package/src/lib/tunnel-edge.ts +114 -0
  97. package/src/lib/xdg-log.ts +2 -2
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "nanoid",
3
+ "version": "5.1.7",
4
+ "description": "A tiny (118 bytes), secure URL-friendly unique string ID generator",
5
+ "keywords": [
6
+ "uuid",
7
+ "random",
8
+ "id",
9
+ "url"
10
+ ],
11
+ "type": "module",
12
+ "engines": {
13
+ "node": "^18 || >=20"
14
+ },
15
+ "funding": [
16
+ {
17
+ "type": "github",
18
+ "url": "https://github.com/sponsors/ai"
19
+ }
20
+ ],
21
+ "author": "Andrey Sitnik <andrey@sitnik.ru>",
22
+ "license": "MIT",
23
+ "repository": "ai/nanoid",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./index.d.ts",
27
+ "browser": "./index.browser.js",
28
+ "react-native": "./index.browser.js",
29
+ "default": "./index.js"
30
+ },
31
+ "./non-secure": {
32
+ "types": "./non-secure/index.d.ts",
33
+ "default": "./non-secure/index.js"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "browser": {
38
+ "./index.js": "./index.browser.js"
39
+ },
40
+ "react-native": {
41
+ "./index.js": "./index.browser.js"
42
+ },
43
+ "bin": "./bin/nanoid.js",
44
+ "sideEffects": false,
45
+ "types": "./index.d.ts"
46
+ }
@@ -0,0 +1,2 @@
1
+ export let urlAlphabet =
2
+ 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict'
@@ -14,6 +14,7 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@vellumai/environments": "file:../environments",
17
+ "nanoid": "5.1.7",
17
18
  "zod": "4.3.6"
18
19
  },
19
20
  "devDependencies": {
@@ -5,10 +5,18 @@ import path from "node:path";
5
5
 
6
6
  import {
7
7
  defaultEnvironmentFilePath,
8
+ defaultEnvironmentFilePaths,
8
9
  readDefaultEnvironment,
9
10
  resolveEnvironmentName,
10
11
  } from "../environment";
11
- import { resolveLockfilePaths, resolveConfigDir } from "../config";
12
+ import {
13
+ guardianTokenPath,
14
+ resolveConfigDir,
15
+ resolveInstanceDir,
16
+ resolveLockfilePaths,
17
+ resolveLogDir,
18
+ resolveRuntimeDir,
19
+ } from "../config";
12
20
 
13
21
  let configHome: string;
14
22
 
@@ -114,3 +122,57 @@ describe("path resolvers honor the persisted default", () => {
114
122
  ]);
115
123
  });
116
124
  });
125
+
126
+ describe("Windows path resolution", () => {
127
+ const options = {
128
+ platform: "win32" as const,
129
+ homeDir: "C:\\Users\\Example",
130
+ };
131
+ const env = {
132
+ APPDATA: "C:\\Users\\Example\\AppData\\Roaming",
133
+ LOCALAPPDATA: "C:\\Users\\Example\\AppData\\Local",
134
+ XDG_CONFIG_HOME: "D:\\LegacyConfig",
135
+ VELLUM_ENVIRONMENT: "dev",
136
+ };
137
+
138
+ test("uses AppData with XDG read compatibility", () => {
139
+ const paths = [
140
+ [defaultEnvironmentFilePath(env, options), "AppData\\Roaming\\vellum\\environment"],
141
+ [resolveConfigDir(env, options), "AppData\\Roaming\\vellum-dev"],
142
+ [resolveRuntimeDir(env, options), "AppData\\Local\\vellum-dev"],
143
+ [resolveLogDir(env, options), "AppData\\Local\\vellum-dev\\logs"],
144
+ [resolveInstanceDir(env, "assistant-123", options), "AppData\\Local\\vellum-dev\\assistants\\assistant-123"],
145
+ ];
146
+ for (const [actual, suffix] of paths) {
147
+ expect(actual).toBe(`C:\\Users\\Example\\${suffix}`);
148
+ }
149
+ expect(resolveLockfilePaths(env, options)).toEqual([
150
+ "C:\\Users\\Example\\AppData\\Roaming\\vellum-dev\\lockfile.json",
151
+ "D:\\LegacyConfig\\vellum-dev\\lockfile.json",
152
+ ]);
153
+ });
154
+
155
+ test("falls back to conventional AppData directories", () => {
156
+ const productionEnv = { VELLUM_ENVIRONMENT: "production" };
157
+ expect(resolveLockfilePaths(productionEnv, options)).toEqual([
158
+ "C:\\Users\\Example\\AppData\\Roaming\\vellum\\lockfile.json",
159
+ "C:\\Users\\Example\\.vellum.lock.json",
160
+ "C:\\Users\\Example\\.vellum.lockfile.json",
161
+ ]);
162
+ expect(defaultEnvironmentFilePaths(env, options)).toEqual([
163
+ "C:\\Users\\Example\\AppData\\Roaming\\vellum\\environment",
164
+ "D:\\LegacyConfig\\vellum\\environment",
165
+ ]);
166
+ });
167
+
168
+ test("rejects unsafe path segments", () => {
169
+ for (const assistantId of ["../other", "nested/other", "CON", "bad\\id"]) {
170
+ expect(() => resolveInstanceDir(env, assistantId, options)).toThrow(
171
+ "Invalid assistant ID",
172
+ );
173
+ expect(() =>
174
+ guardianTokenPath("C:\\Vellum", assistantId, options),
175
+ ).toThrow("Invalid assistant ID");
176
+ }
177
+ });
178
+ });
@@ -4,10 +4,22 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
6
  import {
7
+ authorizePairedForwardHeaders,
8
+ pairedGatewayTargetsFromLockfile,
9
+ parsePairedGatewayUrl,
7
10
  readAllowedGatewayPorts,
11
+ readPairedGatewayTargets,
8
12
  resolveGatewayProxyTarget,
13
+ resolvePairedGatewayProxyTarget,
14
+ sanitizePairedForwardHeaders,
9
15
  } from "../gateway-proxy";
10
16
 
17
+ const pairedRejection = {
18
+ kind: "reject",
19
+ status: 403,
20
+ message: "Assistant is not paired in lockfile",
21
+ } as const;
22
+
11
23
  const allow =
12
24
  (...ports: number[]) =>
13
25
  () =>
@@ -94,6 +106,15 @@ describe("resolveGatewayProxyTarget", () => {
94
106
  cloud: "gcp",
95
107
  runtimeUrl: "https://assistant.example.com:8443",
96
108
  },
109
+ // A paired entry is skipped wholesale: even a loopback runtimeUrl
110
+ // (rejected on import, but possibly pre-existing) must not open
111
+ // the loopback proxy to arbitrary local services.
112
+ {
113
+ assistantId: "paired-loopback",
114
+ cloud: "paired",
115
+ paired: true,
116
+ runtimeUrl: "http://127.0.0.1:5432",
117
+ },
97
118
  ],
98
119
  }),
99
120
  );
@@ -118,3 +139,485 @@ describe("resolveGatewayProxyTarget", () => {
118
139
  expect(reads).toBe(1);
119
140
  });
120
141
  });
142
+
143
+ const pair =
144
+ (entries: Record<string, string> = {}) =>
145
+ () =>
146
+ new Map<string, string>(Object.entries(entries));
147
+
148
+ describe("parsePairedGatewayUrl", () => {
149
+ test("matches with and without the renderer's `/assistant` mount prefix", () => {
150
+ expect(parsePairedGatewayUrl("/__gateway-paired/abc/v1/foo")).toEqual({
151
+ match: true,
152
+ valid: true,
153
+ target: { assistantId: "abc", path: "/v1/foo", search: "" },
154
+ });
155
+ expect(
156
+ parsePairedGatewayUrl("/assistant/__gateway-paired/abc/v1/foo"),
157
+ ).toEqual({
158
+ match: true,
159
+ valid: true,
160
+ target: { assistantId: "abc", path: "/v1/foo", search: "" },
161
+ });
162
+ });
163
+
164
+ test("defaults a pathless tail to the gateway root", () => {
165
+ expect(parsePairedGatewayUrl("/__gateway-paired/abc")).toEqual({
166
+ match: true,
167
+ valid: true,
168
+ target: { assistantId: "abc", path: "/", search: "" },
169
+ });
170
+ });
171
+
172
+ test("splits the query off the id instead of swallowing it", () => {
173
+ expect(parsePairedGatewayUrl("/__gateway-paired/abc?x=1")).toEqual({
174
+ match: true,
175
+ valid: true,
176
+ target: { assistantId: "abc", path: "/", search: "?x=1" },
177
+ });
178
+ });
179
+
180
+ test("carries the query through on a pathful tail and drops a fragment", () => {
181
+ expect(parsePairedGatewayUrl("/__gateway-paired/abc/v1/foo?x=1#frag")).toEqual({
182
+ match: true,
183
+ valid: true,
184
+ target: { assistantId: "abc", path: "/v1/foo", search: "?x=1" },
185
+ });
186
+ expect(parsePairedGatewayUrl("/__gateway-paired/abc/v1#frag")).toEqual({
187
+ match: true,
188
+ valid: true,
189
+ target: { assistantId: "abc", path: "/v1", search: "" },
190
+ });
191
+ });
192
+
193
+ test("percent-decodes the assistant id", () => {
194
+ expect(parsePairedGatewayUrl("/__gateway-paired/a%20b/v1")).toEqual({
195
+ match: true,
196
+ valid: true,
197
+ target: { assistantId: "a b", path: "/v1", search: "" },
198
+ });
199
+ });
200
+
201
+ test("treats malformed percent-encoding as invalid, not a crash", () => {
202
+ expect(parsePairedGatewayUrl("/__gateway-paired/%zz/v1")).toEqual({
203
+ match: true,
204
+ valid: false,
205
+ });
206
+ });
207
+
208
+ test("rejects dot segments in the tail, raw or percent-encoded", () => {
209
+ for (const url of [
210
+ "/__gateway-paired/abc/../secrets",
211
+ "/__gateway-paired/abc/v1/../../secrets",
212
+ "/__gateway-paired/abc/./v1",
213
+ "/__gateway-paired/abc/%2e%2e/secrets",
214
+ "/__gateway-paired/abc/%2E%2E/secrets",
215
+ "/__gateway-paired/abc/%2e/v1",
216
+ "/__gateway-paired/abc/v1/%zz",
217
+ ]) {
218
+ expect(parsePairedGatewayUrl(url)).toEqual({
219
+ match: true,
220
+ valid: false,
221
+ });
222
+ }
223
+ });
224
+
225
+ test("does not treat dot-containing filenames as traversal", () => {
226
+ expect(parsePairedGatewayUrl("/__gateway-paired/abc/v1/file.json")).toEqual(
227
+ {
228
+ match: true,
229
+ valid: true,
230
+ target: { assistantId: "abc", path: "/v1/file.json", search: "" },
231
+ },
232
+ );
233
+ });
234
+
235
+ test("does not match non-paired pathnames", () => {
236
+ expect(parsePairedGatewayUrl("/__gateway/8080/v1")).toEqual({
237
+ match: false,
238
+ });
239
+ expect(parsePairedGatewayUrl("/index.html")).toEqual({ match: false });
240
+ });
241
+ });
242
+
243
+ describe("resolvePairedGatewayProxyTarget", () => {
244
+ test("passes non-paired pathnames through untouched", () => {
245
+ expect(
246
+ resolvePairedGatewayProxyTarget("/index.html", pair({ abc: "https://gw.example.com" })),
247
+ ).toEqual({ kind: "pass" });
248
+ expect(
249
+ resolvePairedGatewayProxyTarget("/__gateway/8080/v1", pair()),
250
+ ).toEqual({ kind: "pass" });
251
+ });
252
+
253
+ test("forwards a paired id to its runtimeUrl, preserving the query", () => {
254
+ expect(
255
+ resolvePairedGatewayProxyTarget(
256
+ "/assistant/__gateway-paired/abc/v1/foo?x=1",
257
+ pair({ abc: "https://gw.example.com" }),
258
+ ),
259
+ ).toEqual({
260
+ kind: "forward",
261
+ url: "https://gw.example.com/v1/foo?x=1",
262
+ runtimeUrl: "https://gw.example.com",
263
+ assistantId: "abc",
264
+ });
265
+ });
266
+
267
+ test("forwards a query on a pathless tail instead of treating it as part of the id", () => {
268
+ expect(
269
+ resolvePairedGatewayProxyTarget(
270
+ "/__gateway-paired/abc?x=1",
271
+ pair({ abc: "https://gw.example.com" }),
272
+ ),
273
+ ).toEqual({
274
+ kind: "forward",
275
+ url: "https://gw.example.com/?x=1",
276
+ runtimeUrl: "https://gw.example.com",
277
+ assistantId: "abc",
278
+ });
279
+ });
280
+
281
+ test("rejects a dot-segment traversal tail", () => {
282
+ expect(
283
+ resolvePairedGatewayProxyTarget(
284
+ "/__gateway-paired/abc/../../secrets",
285
+ pair({ abc: "https://gw.example.com/edge" }),
286
+ ),
287
+ ).toEqual(pairedRejection);
288
+ expect(
289
+ resolvePairedGatewayProxyTarget(
290
+ "/__gateway-paired/abc/%2e%2e/secrets",
291
+ pair({ abc: "https://gw.example.com/edge" }),
292
+ ),
293
+ ).toEqual(pairedRejection);
294
+ });
295
+
296
+ test("strips the runtimeUrl's trailing slashes and keeps its path prefix", () => {
297
+ expect(
298
+ resolvePairedGatewayProxyTarget(
299
+ "/__gateway-paired/abc/v1/foo",
300
+ pair({ abc: "https://gw.example.com/" }),
301
+ ),
302
+ ).toEqual({
303
+ kind: "forward",
304
+ url: "https://gw.example.com/v1/foo",
305
+ runtimeUrl: "https://gw.example.com/",
306
+ assistantId: "abc",
307
+ });
308
+ expect(
309
+ resolvePairedGatewayProxyTarget(
310
+ "/__gateway-paired/abc/v1/foo",
311
+ pair({ abc: "https://gw.example.com/edge/" }),
312
+ ),
313
+ ).toEqual({
314
+ kind: "forward",
315
+ url: "https://gw.example.com/edge/v1/foo",
316
+ runtimeUrl: "https://gw.example.com/edge/",
317
+ assistantId: "abc",
318
+ });
319
+ });
320
+
321
+ test("resolves a percent-encoded id against the decoded allowlist key", () => {
322
+ expect(
323
+ resolvePairedGatewayProxyTarget(
324
+ "/__gateway-paired/a%20b/v1",
325
+ pair({ "a b": "https://gw.example.com" }),
326
+ ),
327
+ ).toEqual({
328
+ kind: "forward",
329
+ url: "https://gw.example.com/v1",
330
+ runtimeUrl: "https://gw.example.com",
331
+ assistantId: "a b",
332
+ });
333
+ });
334
+
335
+ test("rejects an id that isn't paired in the lockfile", () => {
336
+ expect(
337
+ resolvePairedGatewayProxyTarget(
338
+ "/__gateway-paired/unknown/v1",
339
+ pair({ abc: "https://gw.example.com" }),
340
+ ),
341
+ ).toEqual(pairedRejection);
342
+ });
343
+
344
+ test("rejects a malformed percent-encoded id", () => {
345
+ expect(
346
+ resolvePairedGatewayProxyTarget(
347
+ "/__gateway-paired/%zz/v1",
348
+ pair({ "%zz": "https://gw.example.com" }),
349
+ ),
350
+ ).toEqual(pairedRejection);
351
+ });
352
+
353
+ test("never reads the allowlist for non-paired paths", () => {
354
+ let reads = 0;
355
+ const counting = () => {
356
+ reads += 1;
357
+ return new Map<string, string>([["abc", "https://gw.example.com"]]);
358
+ };
359
+ resolvePairedGatewayProxyTarget("/index.html", counting);
360
+ resolvePairedGatewayProxyTarget("/__gateway/8080/v1", counting);
361
+ expect(reads).toBe(0);
362
+ resolvePairedGatewayProxyTarget("/__gateway-paired/abc/v1", counting);
363
+ expect(reads).toBe(1);
364
+ });
365
+ });
366
+
367
+ describe("pairedGatewayTargetsFromLockfile", () => {
368
+ test("maps paired entries with usable runtimeUrls, excluding everything else", () => {
369
+ expect(
370
+ pairedGatewayTargetsFromLockfile({
371
+ assistants: [
372
+ {
373
+ assistantId: "paired-a",
374
+ cloud: "paired",
375
+ runtimeUrl: "https://gw.example.com",
376
+ },
377
+ {
378
+ assistantId: "paired-b",
379
+ cloud: "paired",
380
+ runtimeUrl: "http://192.0.2.10:8443/edge",
381
+ },
382
+ // Non-paired entries never become forwardable.
383
+ { assistantId: "local-a", cloud: "local" },
384
+ {
385
+ assistantId: "docker-a",
386
+ cloud: "docker",
387
+ runtimeUrl: "http://localhost:7930",
388
+ },
389
+ {
390
+ assistantId: "remote-a",
391
+ cloud: "gcp",
392
+ runtimeUrl: "https://assistant.example.com:8443",
393
+ },
394
+ // Paired entries without a usable absolute http(s) runtimeUrl are
395
+ // excluded rather than forwarded blind.
396
+ { assistantId: "paired-no-url", cloud: "paired" },
397
+ {
398
+ assistantId: "paired-bad-scheme",
399
+ cloud: "paired",
400
+ runtimeUrl: "ssh://gw.example.com",
401
+ },
402
+ {
403
+ assistantId: "paired-relative",
404
+ cloud: "paired",
405
+ runtimeUrl: "/not-absolute",
406
+ },
407
+ // Malformed entries are tolerated, not fatal.
408
+ null,
409
+ "not-an-object",
410
+ { cloud: "paired", runtimeUrl: "https://gw.example.com" },
411
+ {
412
+ assistantId: "",
413
+ cloud: "paired",
414
+ runtimeUrl: "https://gw.example.com",
415
+ },
416
+ ],
417
+ }),
418
+ ).toEqual(
419
+ new Map([
420
+ ["paired-a", "https://gw.example.com"],
421
+ ["paired-b", "http://192.0.2.10:8443/edge"],
422
+ ]),
423
+ );
424
+ });
425
+
426
+ test("returns an empty map for a lockfile with no assistants", () => {
427
+ expect(pairedGatewayTargetsFromLockfile({ assistants: [] })).toEqual(
428
+ new Map(),
429
+ );
430
+ });
431
+ });
432
+
433
+ describe("readPairedGatewayTargets", () => {
434
+ const withLockfile = (
435
+ contents: string,
436
+ run: (lockfilePath: string) => void,
437
+ ) => {
438
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paired-gateway-test-"));
439
+ const lockfilePath = path.join(dir, "assistants.json");
440
+ try {
441
+ fs.writeFileSync(lockfilePath, contents);
442
+ run(lockfilePath);
443
+ } finally {
444
+ fs.rmSync(dir, { recursive: true, force: true });
445
+ }
446
+ };
447
+
448
+ test("maps paired entries to their http(s) runtimeUrls, excluding everything else", () => {
449
+ withLockfile(
450
+ JSON.stringify({
451
+ assistants: [
452
+ {
453
+ assistantId: "paired-a",
454
+ cloud: "paired",
455
+ runtimeUrl: "https://gw.example.com",
456
+ },
457
+ {
458
+ assistantId: "paired-b",
459
+ cloud: "paired",
460
+ runtimeUrl: "http://192.0.2.10:8443/edge",
461
+ },
462
+ // Non-paired entries never become forwardable, loopback or not.
463
+ { assistantId: "local-a", resources: { gatewayPort: 7830 } },
464
+ {
465
+ assistantId: "docker-a",
466
+ cloud: "docker",
467
+ runtimeUrl: "http://localhost:7930",
468
+ },
469
+ {
470
+ assistantId: "remote-a",
471
+ cloud: "gcp",
472
+ runtimeUrl: "https://assistant.example.com:8443",
473
+ },
474
+ // Paired entries without a usable absolute http(s) runtimeUrl are
475
+ // excluded rather than forwarded blind.
476
+ { assistantId: "paired-no-url", cloud: "paired" },
477
+ {
478
+ assistantId: "paired-bad-scheme",
479
+ cloud: "paired",
480
+ runtimeUrl: "ssh://gw.example.com",
481
+ },
482
+ {
483
+ assistantId: "paired-relative",
484
+ cloud: "paired",
485
+ runtimeUrl: "/not-absolute",
486
+ },
487
+ // Malformed entries are tolerated, not fatal.
488
+ null,
489
+ { cloud: "paired", runtimeUrl: "https://gw.example.com" },
490
+ ],
491
+ }),
492
+ (lockfilePath) => {
493
+ expect(readPairedGatewayTargets([lockfilePath])).toEqual(
494
+ new Map([
495
+ ["paired-a", "https://gw.example.com"],
496
+ ["paired-b", "http://192.0.2.10:8443/edge"],
497
+ ]),
498
+ );
499
+ },
500
+ );
501
+ });
502
+
503
+ test("returns an empty map for malformed JSON", () => {
504
+ withLockfile("not json", (lockfilePath) => {
505
+ expect(readPairedGatewayTargets([lockfilePath])).toEqual(new Map());
506
+ });
507
+ });
508
+
509
+ test("returns an empty map when no lockfile path exists", () => {
510
+ expect(
511
+ readPairedGatewayTargets(["/nonexistent/assistants.json"]),
512
+ ).toEqual(new Map());
513
+ });
514
+
515
+ test("stops at the first parseable lockfile even when it yields no targets", () => {
516
+ // The unpair write path targets the first lockfile path
517
+ // (writeRawLockfile), so the allowlist must not fall through to a stale
518
+ // legacy file just because the written file has no paired entries left.
519
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "paired-gateway-test-"));
520
+ const primary = path.join(dir, "assistants.json");
521
+ const legacy = path.join(dir, "legacy-assistants.json");
522
+ try {
523
+ fs.writeFileSync(primary, JSON.stringify({ assistants: [] }));
524
+ fs.writeFileSync(
525
+ legacy,
526
+ JSON.stringify({
527
+ assistants: [
528
+ {
529
+ assistantId: "paired-a",
530
+ cloud: "paired",
531
+ runtimeUrl: "https://gw.example.com",
532
+ },
533
+ ],
534
+ }),
535
+ );
536
+ expect(readPairedGatewayTargets([primary, legacy])).toEqual(new Map());
537
+ // A missing primary still falls through to the legacy file.
538
+ fs.rmSync(primary);
539
+ expect(readPairedGatewayTargets([primary, legacy])).toEqual(
540
+ new Map([["paired-a", "https://gw.example.com"]]),
541
+ );
542
+ } finally {
543
+ fs.rmSync(dir, { recursive: true, force: true });
544
+ }
545
+ });
546
+ });
547
+
548
+ describe("sanitizePairedForwardHeaders", () => {
549
+ test("strips renderer authorization and browser-ambient headers", () => {
550
+ const headers = new Headers({
551
+ origin: "http://localhost:5173",
552
+ referer: "http://localhost:5173/assistant",
553
+ cookie: "sessionid=abc",
554
+ "sec-fetch-site": "same-origin",
555
+ "sec-fetch-mode": "cors",
556
+ "sec-fetch-dest": "empty",
557
+ authorization: "Bearer guardian-token",
558
+ accept: "text/event-stream",
559
+ "content-type": "application/json",
560
+ });
561
+ sanitizePairedForwardHeaders(headers);
562
+ expect(headers.has("origin")).toBe(false);
563
+ expect(headers.has("referer")).toBe(false);
564
+ expect(headers.has("cookie")).toBe(false);
565
+ expect(headers.has("sec-fetch-site")).toBe(false);
566
+ expect(headers.has("sec-fetch-mode")).toBe(false);
567
+ expect(headers.has("sec-fetch-dest")).toBe(false);
568
+ expect(headers.has("authorization")).toBe(false);
569
+ expect(headers.get("accept")).toBe("text/event-stream");
570
+ expect(headers.get("content-type")).toBe("application/json");
571
+ });
572
+
573
+ test("adds the ngrok interstitial bypass header", () => {
574
+ const headers = new Headers({ authorization: "Bearer guardian-token" });
575
+ sanitizePairedForwardHeaders(headers);
576
+ expect(headers.get("ngrok-skip-browser-warning")).toBe("true");
577
+ });
578
+ });
579
+
580
+ describe("authorizePairedForwardHeaders", () => {
581
+ test("sanitizes before installing the host-owned bearer", async () => {
582
+ const headers = new Headers({
583
+ authorization: "Bearer renderer-token",
584
+ cookie: "renderer-cookie=1",
585
+ accept: "application/json",
586
+ });
587
+
588
+ const result = await authorizePairedForwardHeaders(
589
+ "paired-a",
590
+ "https://gw.example.com",
591
+ headers,
592
+ async (assistantId, runtimeUrl) => {
593
+ expect(assistantId).toBe("paired-a");
594
+ expect(runtimeUrl).toBe("https://gw.example.com");
595
+ expect(headers.has("authorization")).toBe(false);
596
+ expect(headers.has("cookie")).toBe(false);
597
+ return { ok: true, accessToken: "host-token" };
598
+ },
599
+ );
600
+
601
+ expect(result).toEqual({ ok: true });
602
+ expect(headers.get("authorization")).toBe("Bearer host-token");
603
+ expect(headers.get("accept")).toBe("application/json");
604
+ });
605
+
606
+ test("leaves renderer authorization stripped when token loading fails", async () => {
607
+ const headers = new Headers({ authorization: "Bearer renderer-token" });
608
+
609
+ const result = await authorizePairedForwardHeaders(
610
+ "paired-a",
611
+ "https://gw.example.com",
612
+ headers,
613
+ async () => ({ ok: false, status: 404, error: "Token not found" }),
614
+ );
615
+
616
+ expect(result).toEqual({
617
+ ok: false,
618
+ status: 404,
619
+ error: "Token not found",
620
+ });
621
+ expect(headers.has("authorization")).toBe(false);
622
+ });
623
+ });