@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
@@ -1,7 +1,13 @@
1
1
  import fs from "node:fs";
2
2
 
3
+ import type { TokenResult } from "./guardian-token";
4
+ import { isUsableRuntimeUrl, resolveCloud } from "./lockfile-contract";
5
+
3
6
  const GATEWAY_PATTERN = /^(?:\/assistant)?\/__gateway\/(\d+)(\/.*)?$/;
4
7
 
8
+ const PAIRED_GATEWAY_PATTERN =
9
+ /^(?:\/assistant)?\/__gateway-paired\/([^/?#]+)(\/.*)?$/;
10
+
5
11
  export interface GatewayTarget {
6
12
  port: number;
7
13
  path: string;
@@ -64,6 +70,191 @@ export function resolveGatewayProxyTarget(
64
70
  return { kind: "forward", target: parsed.target };
65
71
  }
66
72
 
73
+ export interface PairedGatewayTarget {
74
+ assistantId: string;
75
+ /** Pathname tail forwarded onto the runtimeUrl, without the query. */
76
+ path: string;
77
+ /** Query string including the leading `?`, or empty. */
78
+ search: string;
79
+ }
80
+
81
+ export type PairedGatewayParseResult =
82
+ | { match: true; valid: true; target: PairedGatewayTarget }
83
+ | { match: true; valid: false }
84
+ | { match: false };
85
+
86
+ /**
87
+ * A tail containing a `.` or `..` path segment (raw or percent-encoded) could
88
+ * escape the runtimeUrl's recorded path prefix on hosts that forward the tail
89
+ * un-normalized, so it is an invalid parse. A segment whose percent-encoding
90
+ * doesn't decode is rejected for the same reason: it can't be verified.
91
+ */
92
+ function hasDotSegment(path: string): boolean {
93
+ for (const segment of path.split("/")) {
94
+ let decoded: string;
95
+ try {
96
+ decoded = decodeURIComponent(segment);
97
+ } catch {
98
+ return true;
99
+ }
100
+ if (decoded === "." || decoded === "..") {
101
+ return true;
102
+ }
103
+ }
104
+ return false;
105
+ }
106
+
107
+ /**
108
+ * Parse a paired-gateway URL given as `pathname + search` (a `#fragment`
109
+ * suffix is tolerated and dropped), so every host hands over the same shape
110
+ * and the decision is host-independent.
111
+ */
112
+ export function parsePairedGatewayUrl(url: string): PairedGatewayParseResult {
113
+ const delimiter = url.search(/[?#]/);
114
+ const pathname = delimiter === -1 ? url : url.slice(0, delimiter);
115
+ const suffix = delimiter === -1 ? "" : url.slice(delimiter);
116
+ const search = suffix.startsWith("?") ? (suffix.split("#")[0] ?? "") : "";
117
+
118
+ const match = pathname.match(PAIRED_GATEWAY_PATTERN);
119
+ if (!match) {
120
+ return { match: false };
121
+ }
122
+
123
+ let assistantId: string;
124
+ try {
125
+ assistantId = decodeURIComponent(match[1]!);
126
+ } catch {
127
+ // Malformed percent-encoding is an invalid parse, not a crash.
128
+ return { match: true, valid: false };
129
+ }
130
+
131
+ const path = match[2] || "/";
132
+ if (hasDotSegment(path)) {
133
+ return { match: true, valid: false };
134
+ }
135
+
136
+ return {
137
+ match: true,
138
+ valid: true,
139
+ target: { assistantId, path, search },
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Verdict for a paired-gateway proxy URL (`/__gateway-paired/{assistantId}/*`),
145
+ * combining the URL parse with the lockfile pairing-allowlist check into one
146
+ * decision a host can act on without re-deriving the rules.
147
+ *
148
+ * - `pass`: not a paired-gateway URL; the host serves it normally.
149
+ * - `reject`: a paired-gateway URL whose id is malformed, whose tail is a
150
+ * traversal attempt, or whose id is not paired in the lockfile (the
151
+ * security boundary: the proxy only reaches gateways of entries the user
152
+ * actually imported, never arbitrary URLs). Carries the status and
153
+ * message the host answers with, so hosts don't restate them.
154
+ * - `forward`: forward to `url` (the entry's recorded runtimeUrl with the
155
+ * request path and query appended). Carries the paired assistant id so
156
+ * the trusted host can resolve its guardian bearer.
157
+ */
158
+ export type PairedGatewayProxyDecision =
159
+ | { kind: "pass" }
160
+ | { kind: "reject"; status: number; message: string }
161
+ | {
162
+ kind: "forward";
163
+ url: string;
164
+ runtimeUrl: string;
165
+ assistantId: string;
166
+ };
167
+
168
+ const PAIRED_GATEWAY_REJECTION: PairedGatewayProxyDecision = {
169
+ kind: "reject",
170
+ status: 403,
171
+ message: "Assistant is not paired in lockfile",
172
+ };
173
+
174
+ /**
175
+ * Resolve a request URL, given as `pathname + search`, to a paired-gateway
176
+ * proxy verdict. Identical across every host that proxies the data plane,
177
+ * mirroring {@link resolveGatewayProxyTarget}.
178
+ *
179
+ * `getTargets` is a thunk (typically `() => readPairedGatewayTargets(...)`) so
180
+ * the lockfile is read only once a paired-gateway URL is matched.
181
+ */
182
+ export function resolvePairedGatewayProxyTarget(
183
+ url: string,
184
+ getTargets: () => Map<string, string>,
185
+ ): PairedGatewayProxyDecision {
186
+ const parsed = parsePairedGatewayUrl(url);
187
+ if (!parsed.match) {
188
+ return { kind: "pass" };
189
+ }
190
+ if (!parsed.valid) {
191
+ return PAIRED_GATEWAY_REJECTION;
192
+ }
193
+ const runtimeUrl = getTargets().get(parsed.target.assistantId);
194
+ if (!runtimeUrl) {
195
+ return PAIRED_GATEWAY_REJECTION;
196
+ }
197
+ // Preserve the runtimeUrl's own path prefix (minus trailing slashes), then
198
+ // append the request path and query.
199
+ return {
200
+ kind: "forward",
201
+ url: `${runtimeUrl.replace(/\/+$/, "")}${parsed.target.path}${parsed.target.search}`,
202
+ runtimeUrl,
203
+ assistantId: parsed.target.assistantId,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Strip renderer-controlled and browser-ambient headers from a paired forward
209
+ * before the remote hop. The trusted host installs its own guardian bearer
210
+ * after this function returns, so a renderer-provided `Authorization` header
211
+ * can never select or override the credential sent upstream.
212
+ */
213
+ export function sanitizePairedForwardHeaders(headers: Headers): void {
214
+ const secFetchNames: string[] = [];
215
+ headers.forEach((_value, name) => {
216
+ if (name.toLowerCase().startsWith("sec-fetch-")) {
217
+ secFetchNames.push(name);
218
+ }
219
+ });
220
+ for (const name of secFetchNames) {
221
+ headers.delete(name);
222
+ }
223
+ headers.delete("origin");
224
+ headers.delete("referer");
225
+ headers.delete("cookie");
226
+ headers.delete("authorization");
227
+ // Paired runtimeUrls are commonly ngrok tunnels; free-tier ngrok answers
228
+ // browser User-Agents with an interstitial page (ERR_NGROK_6024) unless
229
+ // this header is present. Harmless for every other target.
230
+ headers.set("ngrok-skip-browser-warning", "true");
231
+ }
232
+
233
+ export type PairedGuardianTokenProvider = (
234
+ assistantId: string,
235
+ runtimeUrl: string,
236
+ ) => Promise<TokenResult>;
237
+
238
+ export type PairedForwardAuthorizationResult =
239
+ | { ok: true }
240
+ | { ok: false; status: number; error: string };
241
+
242
+ /** Replace renderer authorization with the paired credential owned by the host. */
243
+ export async function authorizePairedForwardHeaders(
244
+ assistantId: string,
245
+ runtimeUrl: string,
246
+ headers: Headers,
247
+ getGuardianToken: PairedGuardianTokenProvider,
248
+ ): Promise<PairedForwardAuthorizationResult> {
249
+ sanitizePairedForwardHeaders(headers);
250
+ const result = await getGuardianToken(assistantId, runtimeUrl);
251
+ if (!result.ok) {
252
+ return result;
253
+ }
254
+ headers.set("authorization", `Bearer ${result.accessToken}`);
255
+ return { ok: true };
256
+ }
257
+
67
258
  function addPortFromUrl(url: unknown, ports: Set<number>): void {
68
259
  if (typeof url !== "string") return;
69
260
  try {
@@ -78,37 +269,134 @@ function addPortFromUrl(url: unknown, ports: Set<number>): void {
78
269
  }
79
270
  }
80
271
 
272
+ type LockfileEntriesRead =
273
+ | { kind: "ok"; assistants: unknown[] }
274
+ | { kind: "missing" }
275
+ | { kind: "unreadable" };
276
+
277
+ /**
278
+ * Read one lockfile candidate's raw `assistants` array. `missing` is an
279
+ * absent file; `unreadable` is any other read failure or malformed JSON. The
280
+ * one read-loop body both gateway-proxy readers share; how a caller walks the
281
+ * candidate list on `missing`/`unreadable` is its own posture.
282
+ */
283
+ function readLockfileAssistantEntries(candidate: string): LockfileEntriesRead {
284
+ let raw: string;
285
+ try {
286
+ raw = fs.readFileSync(candidate, "utf-8");
287
+ } catch (err: unknown) {
288
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
289
+ return { kind: "missing" };
290
+ }
291
+ return { kind: "unreadable" };
292
+ }
293
+ try {
294
+ const data = JSON.parse(raw) as { assistants?: unknown };
295
+ return {
296
+ kind: "ok",
297
+ assistants: Array.isArray(data.assistants) ? data.assistants : [],
298
+ };
299
+ } catch {
300
+ return { kind: "unreadable" };
301
+ }
302
+ }
303
+
81
304
  export function readAllowedGatewayPorts(lockfilePaths: string[]): Set<number> {
82
305
  const ports = new Set<number>();
83
306
  for (const candidate of lockfilePaths) {
84
- try {
85
- const raw = fs.readFileSync(candidate, "utf-8");
86
- const data = JSON.parse(raw) as {
87
- assistants?: Array<{
88
- gatewayUrl?: unknown;
89
- localUrl?: unknown;
90
- runtimeUrl?: unknown;
91
- resources?: { gatewayPort?: unknown };
92
- }>;
307
+ const read = readLockfileAssistantEntries(candidate);
308
+ if (read.kind === "missing") {
309
+ continue;
310
+ }
311
+ if (read.kind === "unreadable") {
312
+ return new Set<number>();
313
+ }
314
+ for (const entry of read.assistants) {
315
+ if (!entry || typeof entry !== "object") {
316
+ continue;
317
+ }
318
+ const assistant = entry as {
319
+ cloud?: unknown;
320
+ project?: unknown;
321
+ sshUser?: unknown;
322
+ gatewayUrl?: unknown;
323
+ localUrl?: unknown;
324
+ runtimeUrl?: unknown;
325
+ resources?: { gatewayPort?: unknown };
93
326
  };
94
- const assistants = Array.isArray(data.assistants) ? data.assistants : [];
95
- for (const assistant of assistants) {
96
- if (!assistant) continue;
97
- addPortFromUrl(assistant.gatewayUrl, ports);
98
- addPortFromUrl(assistant.localUrl, ports);
99
- // Docker entries record their published gateway as a loopback
100
- // `runtimeUrl` with no `resources` block; the loopback-hostname filter
101
- // in addPortFromUrl keeps remote runtimeUrls out of the allowlist.
102
- addPortFromUrl(assistant.runtimeUrl, ports);
103
- const gp = assistant.resources?.gatewayPort;
104
- if (typeof gp === "number" && Number.isInteger(gp) && gp >= 1024 && gp <= 65535) {
105
- ports.add(gp);
106
- }
327
+ // A paired entry's gateway is remote by contract and reached through the
328
+ // paired proxy; even a (rejected-on-import, but possibly pre-existing)
329
+ // loopback runtimeUrl must never open the generic loopback proxy.
330
+ if (resolveCloud(assistant) === "paired") {
331
+ continue;
107
332
  }
108
- if (ports.size > 0) return ports;
109
- } catch (err: unknown) {
110
- if ((err as NodeJS.ErrnoException).code !== "ENOENT") return new Set<number>();
333
+ addPortFromUrl(assistant.gatewayUrl, ports);
334
+ addPortFromUrl(assistant.localUrl, ports);
335
+ // Docker entries record their published gateway as a loopback
336
+ // `runtimeUrl` with no `resources` block; the loopback-hostname filter
337
+ // in addPortFromUrl keeps remote runtimeUrls out of the allowlist.
338
+ addPortFromUrl(assistant.runtimeUrl, ports);
339
+ const gp = assistant.resources?.gatewayPort;
340
+ if (typeof gp === "number" && Number.isInteger(gp) && gp >= 1024 && gp <= 65535) {
341
+ ports.add(gp);
342
+ }
343
+ }
344
+ if (ports.size > 0) {
345
+ return ports;
111
346
  }
112
347
  }
113
348
  return ports;
114
349
  }
350
+
351
+ /**
352
+ * Compute the paired-gateway allowlist from a lockfile's assistant entries:
353
+ * assistantId to the recorded remote `runtimeUrl`, for entries whose resolved
354
+ * cloud is "paired" and whose runtimeUrl is usable
355
+ * ({@link isUsableRuntimeUrl}). Tolerant of malformed entries. Pure, so a host
356
+ * holding an in-memory lockfile snapshot (e.g. the Electron lockfile watcher)
357
+ * derives the allowlist without touching disk on the request path.
358
+ */
359
+ export function pairedGatewayTargetsFromLockfile(lockfile: {
360
+ assistants: readonly unknown[];
361
+ }): Map<string, string> {
362
+ const targets = new Map<string, string>();
363
+ for (const entry of lockfile.assistants) {
364
+ if (!entry || typeof entry !== "object") {
365
+ continue;
366
+ }
367
+ const assistant = entry as Record<string, unknown>;
368
+ if (resolveCloud(assistant) !== "paired") {
369
+ continue;
370
+ }
371
+ const { assistantId, runtimeUrl } = assistant;
372
+ if (typeof assistantId !== "string" || assistantId === "") {
373
+ continue;
374
+ }
375
+ if (typeof runtimeUrl !== "string" || !isUsableRuntimeUrl(runtimeUrl)) {
376
+ continue;
377
+ }
378
+ targets.set(assistantId, runtimeUrl);
379
+ }
380
+ return targets;
381
+ }
382
+
383
+ /**
384
+ * Read the paired-gateway allowlist from the lockfile on disk
385
+ * ({@link pairedGatewayTargetsFromLockfile} over the first readable
386
+ * candidate). Path selection matches the unpair write path (`readRawLockfile`
387
+ * in lockfile.ts): the first readable, parseable lockfile is authoritative
388
+ * even when it yields no targets, so a pairing removed by an unpair write can
389
+ * never survive in a stale fallback file's allowlist.
390
+ */
391
+ export function readPairedGatewayTargets(
392
+ lockfilePaths: string[],
393
+ ): Map<string, string> {
394
+ for (const candidate of lockfilePaths) {
395
+ const read = readLockfileAssistantEntries(candidate);
396
+ if (read.kind !== "ok") {
397
+ continue;
398
+ }
399
+ return pairedGatewayTargetsFromLockfile({ assistants: read.assistants });
400
+ }
401
+ return new Map<string, string>();
402
+ }
@@ -1,16 +1,104 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import fs from "node:fs";
3
+ import path from "node:path";
3
4
 
4
- import { guardianTokenPath } from "./config";
5
+ import { guardianTokenPath, resolveConfigDirPaths } from "./config";
5
6
  import type { CliInvocation } from "./util";
6
7
 
7
8
  const GUARDIAN_TOKEN_REFRESH_TIMEOUT_MS = 15_000;
9
+ const guardianTokenRefreshes = new Map<string, Promise<TokenResult>>();
8
10
 
9
- interface GuardianTokenData {
11
+ export const PAIRED_GUARDIAN_TOKEN_HOST_ONLY_ERROR =
12
+ "Paired assistant credentials are available only through the paired gateway proxy";
13
+ export const PAIRED_GUARDIAN_TARGET_MISMATCH_ERROR =
14
+ "Paired assistant target does not match the stored pairing";
15
+
16
+ /** The persisted shape of an assistant's guardian token file. */
17
+ export interface GuardianTokenData {
18
+ guardianPrincipalId: string;
10
19
  accessToken: string;
20
+ /** ISO date string or epoch-ms number as returned by the gateway. */
11
21
  accessTokenExpiresAt: string | number;
12
22
  refreshToken: string;
23
+ /** ISO date string or epoch-ms number as returned by the gateway. */
13
24
  refreshTokenExpiresAt: string | number;
25
+ refreshAfter: string;
26
+ isNew: boolean;
27
+ deviceId: string;
28
+ leasedAt: string;
29
+ /** Remote gateway bound to a credential imported through the pairing flow. */
30
+ pairedGatewayUrl?: string;
31
+ }
32
+
33
+ /**
34
+ * Persist an assistant's guardian token where every host-seam reader resolves
35
+ * it (`guardianTokenPath`). The per-assistant directory is created 0700 and the
36
+ * file written 0600; chmod after the write covers a pre-existing file whose
37
+ * mode drifted.
38
+ */
39
+ export function saveGuardianToken(
40
+ configDir: string,
41
+ assistantId: string,
42
+ data: GuardianTokenData,
43
+ ): void {
44
+ const tokenPath = guardianTokenPath(configDir, assistantId);
45
+ fs.mkdirSync(path.dirname(tokenPath), { recursive: true, mode: 0o700 });
46
+ fs.writeFileSync(tokenPath, JSON.stringify(data, null, 2) + "\n", {
47
+ mode: 0o600,
48
+ });
49
+ fs.chmodSync(tokenPath, 0o600);
50
+ }
51
+
52
+ /**
53
+ * The guardian refresh token is long-lived and replayable, so it is only
54
+ * transmitted over a confidential channel: HTTPS, or a loopback host (local
55
+ * dev, or a same-host reverse proxy / tunnel agent). Refreshing against a
56
+ * non-loopback plaintext `http://` URL is refused; an on-path attacker could
57
+ * otherwise capture the refresh token and rotate it into fresh credentials.
58
+ *
59
+ * A user-chosen malicious `https://` destination is intentionally out of
60
+ * scope: HTTPS protects the channel, and the access token already goes
61
+ * wherever the configured URL points. This guard targets the
62
+ * plaintext-interception vector.
63
+ */
64
+ function isLoopbackHostname(hostname: string): boolean {
65
+ // Strip URL brackets so IPv6 forms compare on the bare address.
66
+ const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
67
+ return (
68
+ h === "localhost" ||
69
+ h === "::1" ||
70
+ h === "0:0:0:0:0:0:0:1" ||
71
+ /^127(?:\.\d{1,3}){3}$/.test(h) ||
72
+ // Wildcard hosts reach a local listener when dialed (0.0.0.0 / ::), so
73
+ // they count as local for both the refresh-channel and pairing guards.
74
+ h === "0.0.0.0" ||
75
+ h === "0" ||
76
+ h === "::" ||
77
+ h === "0:0:0:0:0:0:0:0" ||
78
+ // IPv4-mapped loopback and wildcard, in dotted and hex encodings.
79
+ /^(?:0:0:0:0:0|:):ffff:127(?:\.\d{1,3}){3}$/.test(h) ||
80
+ /^(?:0:0:0:0:0|:):ffff:7f[0-9a-f]{2}:[0-9a-f]{1,4}$/.test(h) ||
81
+ /^(?:0:0:0:0:0|:):ffff:0\.0\.0\.0$/.test(h) ||
82
+ /^(?:0:0:0:0:0|:):ffff:0:0$/.test(h)
83
+ );
84
+ }
85
+
86
+ export function isConfidentialRefreshUrl(gatewayUrl: string): boolean {
87
+ try {
88
+ const url = new URL(gatewayUrl);
89
+ return url.protocol === "https:" || isLoopbackHostname(url.hostname);
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ /** Whether a URL's host is loopback; false for unparseable URLs. */
96
+ export function isLoopbackUrl(url: string): boolean {
97
+ try {
98
+ return isLoopbackHostname(new URL(url).hostname);
99
+ } catch {
100
+ return false;
101
+ }
14
102
  }
15
103
 
16
104
  function isAccessTokenExpired(data: GuardianTokenData): boolean {
@@ -29,31 +117,109 @@ export type TokenResult =
29
117
  | { ok: true; accessToken: string }
30
118
  | { ok: false; status: number; error: string };
31
119
 
120
+ export interface GuardianTokenOptions {
121
+ /**
122
+ * True when the entry was imported from another machine via `vellum pair`.
123
+ * A paired entry has no local daemon, so expired-refresh guidance points at
124
+ * re-pairing instead of `vellum hatch`/`vellum wake`.
125
+ */
126
+ paired?: boolean;
127
+ /** Gateway URL resolved for the paired proxy request. */
128
+ pairedGatewayUrl?: string;
129
+ }
130
+
32
131
  export function getGuardianAccessToken(
33
132
  assistantId: string,
34
133
  configDir: string,
35
134
  invocation: CliInvocation,
36
135
  isLoopback: boolean,
37
136
  env?: Record<string, string>,
137
+ options?: GuardianTokenOptions,
38
138
  ): Promise<TokenResult> {
39
139
  if (!isLoopback) {
40
140
  return Promise.resolve({ ok: false, status: 403, error: "Forbidden" });
41
141
  }
42
142
 
43
- const tokenPath = guardianTokenPath(configDir, assistantId);
44
-
45
- let raw: string;
143
+ let tokenPaths: string[];
46
144
  try {
47
- raw = fs.readFileSync(tokenPath, "utf-8");
145
+ const configDirs = [
146
+ configDir,
147
+ ...resolveConfigDirPaths({ ...process.env, ...env }),
148
+ ];
149
+ tokenPaths = [...new Set(configDirs)].map((dir) =>
150
+ guardianTokenPath(dir, assistantId),
151
+ );
48
152
  } catch {
49
- return Promise.resolve({ ok: false, status: 404, error: "Guardian token not found" });
153
+ return Promise.resolve({
154
+ ok: false,
155
+ status: 400,
156
+ error: "Invalid assistant ID",
157
+ });
158
+ }
159
+
160
+ let raw: string | undefined;
161
+ let resolvedTokenPath: string | undefined;
162
+ for (const tokenPath of tokenPaths) {
163
+ try {
164
+ raw = fs.readFileSync(tokenPath, "utf-8");
165
+ resolvedTokenPath = tokenPath;
166
+ break;
167
+ } catch {
168
+ // Try the next compatible location.
169
+ }
170
+ }
171
+ if (raw === undefined || resolvedTokenPath === undefined) {
172
+ return Promise.resolve({
173
+ ok: false,
174
+ status: 404,
175
+ error: "Guardian token not found",
176
+ });
50
177
  }
51
178
 
52
179
  let data: GuardianTokenData;
53
180
  try {
54
181
  data = JSON.parse(raw) as GuardianTokenData;
55
182
  } catch {
56
- return Promise.resolve({ ok: false, status: 500, error: "Malformed guardian token file" });
183
+ return Promise.resolve({
184
+ ok: false,
185
+ status: 500,
186
+ error: "Malformed guardian token file",
187
+ });
188
+ }
189
+
190
+ if (data.pairedGatewayUrl) {
191
+ if (!options?.paired) {
192
+ return Promise.resolve({
193
+ ok: false,
194
+ status: 403,
195
+ error: PAIRED_GUARDIAN_TOKEN_HOST_ONLY_ERROR,
196
+ });
197
+ }
198
+ if (options.pairedGatewayUrl !== data.pairedGatewayUrl) {
199
+ return Promise.resolve({
200
+ ok: false,
201
+ status: 403,
202
+ error: PAIRED_GUARDIAN_TARGET_MISMATCH_ERROR,
203
+ });
204
+ }
205
+ } else if (options?.paired) {
206
+ if (!options.pairedGatewayUrl) {
207
+ return Promise.resolve({
208
+ ok: false,
209
+ status: 403,
210
+ error: PAIRED_GUARDIAN_TARGET_MISMATCH_ERROR,
211
+ });
212
+ }
213
+ data = { ...data, pairedGatewayUrl: options.pairedGatewayUrl };
214
+ try {
215
+ saveGuardianToken(configDir, assistantId, data);
216
+ } catch {
217
+ return Promise.resolve({
218
+ ok: false,
219
+ status: 500,
220
+ error: "Failed to bind paired assistant credential",
221
+ });
222
+ }
57
223
  }
58
224
 
59
225
  if (!isAccessTokenExpired(data)) {
@@ -64,11 +230,42 @@ export function getGuardianAccessToken(
64
230
  return Promise.resolve({
65
231
  ok: false,
66
232
  status: 401,
67
- error: "Guardian token expired — re-run `vellum hatch` or `vellum wake`",
233
+ error: options?.paired
234
+ ? "Guardian token expired. Run `vellum pair` on the assistant's machine, then re-import it from the app's connect flow or with `vellum connect import`."
235
+ : "Guardian token expired. Re-run `vellum hatch` or `vellum wake`.",
68
236
  });
69
237
  }
70
238
 
71
- return refreshToken(assistantId, invocation, env);
239
+ const existingRefresh = guardianTokenRefreshes.get(resolvedTokenPath);
240
+ if (existingRefresh) {
241
+ return existingRefresh;
242
+ }
243
+ const refresh = refreshToken(assistantId, invocation, env).finally(() => {
244
+ if (guardianTokenRefreshes.get(resolvedTokenPath) === refresh) {
245
+ guardianTokenRefreshes.delete(resolvedTokenPath);
246
+ }
247
+ });
248
+ guardianTokenRefreshes.set(resolvedTokenPath, refresh);
249
+ return refresh;
250
+ }
251
+
252
+ /** Resolve a paired bearer only when the proxy target matches its binding. */
253
+ export function getPairedGuardianAccessToken(
254
+ assistantId: string,
255
+ pairedGatewayUrl: string,
256
+ configDir: string,
257
+ invocation: CliInvocation,
258
+ isLoopback: boolean,
259
+ env?: Record<string, string>,
260
+ ): Promise<TokenResult> {
261
+ return getGuardianAccessToken(
262
+ assistantId,
263
+ configDir,
264
+ invocation,
265
+ isLoopback,
266
+ env,
267
+ { paired: true, pairedGatewayUrl },
268
+ );
72
269
  }
73
270
 
74
271
  function refreshToken(
@@ -95,7 +292,11 @@ function refreshToken(
95
292
 
96
293
  const timeout = setTimeout(() => {
97
294
  child.kill("SIGTERM");
98
- finish({ ok: false, status: 500, error: "Guardian token refresh timed out" });
295
+ finish({
296
+ ok: false,
297
+ status: 500,
298
+ error: "Guardian token refresh timed out",
299
+ });
99
300
  }, GUARDIAN_TOKEN_REFRESH_TIMEOUT_MS);
100
301
 
101
302
  child.stdout.on("data", (chunk: Buffer) => {
@@ -111,12 +312,20 @@ function refreshToken(
111
312
  finish({ ok: false, status: 500, error: "CLI returned empty token" });
112
313
  }
113
314
  } else {
114
- finish({ ok: false, status: 401, error: "Failed to refresh guardian token" });
315
+ finish({
316
+ ok: false,
317
+ status: 401,
318
+ error: "Failed to refresh guardian token",
319
+ });
115
320
  }
116
321
  });
117
322
 
118
323
  child.on("error", (err) => {
119
- finish({ ok: false, status: 500, error: `Failed to spawn CLI: ${err.message}` });
324
+ finish({
325
+ ok: false,
326
+ status: 500,
327
+ error: `Failed to spawn CLI: ${err.message}`,
328
+ });
120
329
  });
121
330
  });
122
331
  }