@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
|
@@ -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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
let raw: string;
|
|
143
|
+
let tokenPaths: string[];
|
|
46
144
|
try {
|
|
47
|
-
|
|
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({
|
|
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({
|
|
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:
|
|
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
|
-
|
|
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({
|
|
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({
|
|
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({
|
|
324
|
+
finish({
|
|
325
|
+
ok: false,
|
|
326
|
+
status: 500,
|
|
327
|
+
error: `Failed to spawn CLI: ${err.message}`,
|
|
328
|
+
});
|
|
120
329
|
});
|
|
121
330
|
});
|
|
122
331
|
}
|