@vellumai/cli 0.11.2 → 0.11.3-staging.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/knip.json +1 -0
- package/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
- package/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
- package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
- package/node_modules/@vellumai/environments/src/index.ts +17 -6
- package/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
- package/node_modules/@vellumai/environments/src/seeds.ts +29 -0
- package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
- package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
- package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
- package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
- package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
- package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
- package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/index.ts +17 -6
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
- package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/seeds.ts +29 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/LICENSE +20 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/README.md +38 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/bin/nanoid.js +55 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.browser.js +29 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.d.ts +106 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.js +47 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/nanoid.js +1 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.d.ts +48 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.js +21 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/package.json +46 -0
- package/node_modules/@vellumai/local-mode/node_modules/nanoid/url-alphabet/index.js +2 -0
- package/node_modules/@vellumai/local-mode/package.json +1 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +63 -1
- package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +503 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token.test.ts +203 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +46 -1
- package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +6 -4
- package/node_modules/@vellumai/local-mode/src/__tests__/pair.test.ts +621 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +5 -0
- package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +232 -0
- package/node_modules/@vellumai/local-mode/src/config.ts +126 -20
- package/node_modules/@vellumai/local-mode/src/environment.ts +36 -15
- package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +313 -25
- package/node_modules/@vellumai/local-mode/src/guardian-token.ts +222 -13
- package/node_modules/@vellumai/local-mode/src/index.ts +35 -3
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +18 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +72 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +130 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +134 -45
- package/node_modules/@vellumai/local-mode/src/pair.ts +354 -0
- package/node_modules/@vellumai/local-mode/src/paths.ts +49 -0
- package/node_modules/@vellumai/local-mode/src/status.ts +10 -8
- package/node_modules/@vellumai/local-mode/src/unpair.ts +101 -0
- package/node_modules/@vellumai/local-mode/src/util.ts +34 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
- package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
- package/package.json +3 -1
- package/src/__tests__/client-token.test.ts +26 -1
- package/src/__tests__/guardian-token.test.ts +4 -0
- package/src/__tests__/nginx-ingress-command.test.ts +176 -16
- package/src/__tests__/nginx-ingress.test.ts +1821 -101
- package/src/__tests__/pair.test.ts +104 -2
- package/src/__tests__/sleep.test.ts +7 -3
- package/src/__tests__/tailscale-tunnel.test.ts +0 -1
- package/src/__tests__/tunnel.test.ts +1099 -55
- package/src/__tests__/wake.test.ts +166 -99
- package/src/commands/client.ts +182 -27
- package/src/commands/connect/import.ts +35 -156
- package/src/commands/nginx-ingress.ts +87 -110
- package/src/commands/pair.ts +84 -21
- package/src/commands/tunnel.ts +196 -85
- package/src/commands/upgrade.ts +3 -2
- package/src/commands/wake.ts +5 -163
- package/src/index.ts +1 -1
- package/src/lib/__tests__/local-ces.test.ts +10 -1
- package/src/lib/cloudflare-tunnel.ts +3 -16
- package/src/lib/environments/__tests__/paths.test.ts +20 -1
- package/src/lib/environments/paths.ts +29 -41
- package/src/lib/environments/resolve.ts +7 -5
- package/src/lib/guardian-token.ts +22 -62
- package/src/lib/ingress-config.ts +25 -0
- package/src/lib/local.ts +77 -20
- package/src/lib/nginx-ingress.ts +523 -76
- package/src/lib/ngrok.ts +154 -61
- package/src/lib/orphan-detection.test.ts +167 -0
- package/src/lib/orphan-detection.ts +40 -0
- package/src/lib/pair.test.ts +87 -0
- package/src/lib/pair.ts +55 -0
- package/src/lib/platform-client.ts +8 -5
- package/src/lib/tailscale-tunnel.ts +7 -18
- package/src/lib/tunnel-edge.ts +114 -0
- package/src/lib/xdg-log.ts +2 -2
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { nanoid } from "nanoid";
|
|
5
|
+
|
|
6
|
+
import { guardianTokenPath } from "./config";
|
|
7
|
+
import {
|
|
8
|
+
isConfidentialRefreshUrl,
|
|
9
|
+
isLoopbackUrl,
|
|
10
|
+
saveGuardianToken,
|
|
11
|
+
} from "./guardian-token";
|
|
12
|
+
import { readRawLockfile, upsertLockfileAssistant } from "./lockfile";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A decoded `vellum pair` bundle: base64(JSON.stringify({ gatewayUrl, token,
|
|
16
|
+
* ... })) printed on the host machine and imported on this one.
|
|
17
|
+
*/
|
|
18
|
+
export interface PairBundle {
|
|
19
|
+
gatewayUrl: string;
|
|
20
|
+
token: string;
|
|
21
|
+
assistantId?: string;
|
|
22
|
+
deviceId?: string;
|
|
23
|
+
// Optional refresh credential. Present when the host's gateway issued a
|
|
24
|
+
// device-bound token pair; absent for older access-only bundles (which remain
|
|
25
|
+
// importable, just without auto-renewal). `refreshTokenExpiresAt` mirrors
|
|
26
|
+
// GuardianTokenData (ISO string OR epoch-ms number) so a numeric expiry isn't
|
|
27
|
+
// silently dropped on import.
|
|
28
|
+
refreshToken?: string;
|
|
29
|
+
refreshTokenExpiresAt?: string | number;
|
|
30
|
+
refreshAfter?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type DecodePairBundleResult =
|
|
34
|
+
| { ok: true; bundle: PairBundle }
|
|
35
|
+
| { ok: false; error: string };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Decode and validate a base64 pairing bundle. Total and non-throwing:
|
|
39
|
+
* malformed input yields a typed failure. `gatewayUrl` is persisted as
|
|
40
|
+
* `runtimeUrl` and used to build fetch URLs, so it must be an absolute http(s)
|
|
41
|
+
* URL rather than letting an invalid string through (which would crash
|
|
42
|
+
* `new URL(...)` or break later client calls).
|
|
43
|
+
*/
|
|
44
|
+
export function decodePairBundle(encoded: string): DecodePairBundleResult {
|
|
45
|
+
let json: unknown;
|
|
46
|
+
try {
|
|
47
|
+
json = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
|
|
48
|
+
} catch {
|
|
49
|
+
return { ok: false, error: "Bundle is not base64-encoded JSON" };
|
|
50
|
+
}
|
|
51
|
+
if (typeof json !== "object" || json === null) {
|
|
52
|
+
return { ok: false, error: "Bundle is not a JSON object" };
|
|
53
|
+
}
|
|
54
|
+
const b = json as Record<string, unknown>;
|
|
55
|
+
if (typeof b.gatewayUrl !== "string" || typeof b.token !== "string") {
|
|
56
|
+
return { ok: false, error: "Bundle is missing gatewayUrl or token" };
|
|
57
|
+
}
|
|
58
|
+
let parsed: URL;
|
|
59
|
+
try {
|
|
60
|
+
parsed = new URL(b.gatewayUrl);
|
|
61
|
+
} catch {
|
|
62
|
+
return { ok: false, error: "Bundle gatewayUrl is not an absolute URL" };
|
|
63
|
+
}
|
|
64
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
65
|
+
return { ok: false, error: "Bundle gatewayUrl is not an http(s) URL" };
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
bundle: {
|
|
70
|
+
gatewayUrl: b.gatewayUrl,
|
|
71
|
+
token: b.token,
|
|
72
|
+
assistantId:
|
|
73
|
+
typeof b.assistantId === "string" ? b.assistantId : undefined,
|
|
74
|
+
deviceId: typeof b.deviceId === "string" ? b.deviceId : undefined,
|
|
75
|
+
refreshToken:
|
|
76
|
+
typeof b.refreshToken === "string" ? b.refreshToken : undefined,
|
|
77
|
+
refreshTokenExpiresAt:
|
|
78
|
+
typeof b.refreshTokenExpiresAt === "string" ||
|
|
79
|
+
typeof b.refreshTokenExpiresAt === "number"
|
|
80
|
+
? b.refreshTokenExpiresAt
|
|
81
|
+
: undefined,
|
|
82
|
+
refreshAfter:
|
|
83
|
+
typeof b.refreshAfter === "string" ? b.refreshAfter : undefined,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Lowercase, collapse non-alphanumerics to single dashes, trim dashes. */
|
|
89
|
+
function slugify(name: string): string {
|
|
90
|
+
return name
|
|
91
|
+
.toLowerCase()
|
|
92
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
93
|
+
.replace(/^-+|-+$/g, "");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Best-effort JWT `exp` (epoch seconds) → epoch ms; null if undecodable. */
|
|
97
|
+
function jwtExpiryMs(token: string): number | null {
|
|
98
|
+
const parts = token.split(".");
|
|
99
|
+
if (parts.length !== 3) return null;
|
|
100
|
+
try {
|
|
101
|
+
const payload = JSON.parse(
|
|
102
|
+
Buffer.from(parts[1]!, "base64").toString("utf8"),
|
|
103
|
+
) as Record<string, unknown>;
|
|
104
|
+
if (typeof payload.exp === "number") return payload.exp * 1000;
|
|
105
|
+
} catch {
|
|
106
|
+
/* fall through */
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface PairOptions {
|
|
112
|
+
/** A bundle validated by {@link decodePairBundle}. */
|
|
113
|
+
bundle: PairBundle;
|
|
114
|
+
/** Optional local name; its slug becomes the entry's assistantId. */
|
|
115
|
+
name?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Failure statuses: 400 the name slugifies to nothing, 409 the id names an
|
|
120
|
+
* existing non-paired assistant (`assistantId` carries the refused id),
|
|
121
|
+
* 422 the bundle's gatewayUrl is unparseable, 500 a write failed.
|
|
122
|
+
*/
|
|
123
|
+
export type PairResult =
|
|
124
|
+
| {
|
|
125
|
+
ok: true;
|
|
126
|
+
/** The unique local id the pairing was registered under. */
|
|
127
|
+
assistantId: string;
|
|
128
|
+
/** True when an existing paired entry was updated in place. */
|
|
129
|
+
updated: boolean;
|
|
130
|
+
/** True when the bundle carried no refresh credential. */
|
|
131
|
+
accessOnly: boolean;
|
|
132
|
+
}
|
|
133
|
+
| { ok: false; status: number; error: string; assistantId?: string };
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Register a pairing bundle on this machine: persist the guardian token and
|
|
137
|
+
* upsert a `cloud: "paired"` lockfile entry, the write counterpart of
|
|
138
|
+
* `unpairAssistant`.
|
|
139
|
+
*
|
|
140
|
+
* The entry is stored under a UNIQUE LOCAL id (not the bundle's assistantId,
|
|
141
|
+
* which is typically "self" and would collide across hosts). This is safe
|
|
142
|
+
* because the gateway's runtime proxy strips the `/v1/assistants/<id>/` segment
|
|
143
|
+
* before forwarding, so the local id never has to match the remote one: the
|
|
144
|
+
* token (validated by signature/audience) is what authorizes requests.
|
|
145
|
+
*/
|
|
146
|
+
export function pairAssistant(
|
|
147
|
+
lockfilePaths: string[],
|
|
148
|
+
configDir: string,
|
|
149
|
+
{ bundle, name }: PairOptions,
|
|
150
|
+
): PairResult {
|
|
151
|
+
let gatewayHost: string;
|
|
152
|
+
try {
|
|
153
|
+
gatewayHost = new URL(bundle.gatewayUrl).host;
|
|
154
|
+
} catch {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
status: 422,
|
|
158
|
+
error: "Bundle gatewayUrl is not an absolute URL",
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A paired entry is remote by definition (`vellum pair` refuses to advertise
|
|
163
|
+
// loopback URLs), and a loopback runtimeUrl in the lockfile would otherwise
|
|
164
|
+
// read as a local gateway to loopback-port consumers. Refuse it outright so
|
|
165
|
+
// a crafted bundle cannot point a pairing at this machine's own services.
|
|
166
|
+
if (isLoopbackUrl(bundle.gatewayUrl)) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
status: 422,
|
|
170
|
+
error:
|
|
171
|
+
"A paired assistant needs a non-loopback gateway URL; run `vellum pair` " +
|
|
172
|
+
"with --url pointing at a tunnel or LAN address.",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Unique local id: a name slug, or paired-<deviceId> (deviceId is unique per
|
|
177
|
+
// pairing). Never the bundle's "self" assistantId, which would collide. The
|
|
178
|
+
// deviceId comes from an untrusted bundle and is used as a path component by
|
|
179
|
+
// saveGuardianToken, so it MUST be slugified (no `../` traversal); fall back
|
|
180
|
+
// to a random id if it sanitizes to empty.
|
|
181
|
+
const localId = name
|
|
182
|
+
? slugify(name)
|
|
183
|
+
: `paired-${slugify(bundle.deviceId ?? "") || nanoid()}`;
|
|
184
|
+
if (!localId) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
status: 400,
|
|
188
|
+
error: "Name must contain at least one alphanumeric character",
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Don't clobber an existing assistant. Only update in place when the prior
|
|
193
|
+
// entry is itself a paired import (marked `paired: true`); otherwise the id
|
|
194
|
+
// collides with a real local/remote assistant and overwriting would drop its
|
|
195
|
+
// resources/runtime metadata. Reject and let the caller pick a fresh name.
|
|
196
|
+
const rawAssistants = readRawLockfile(lockfilePaths).assistants;
|
|
197
|
+
const existing = (
|
|
198
|
+
Array.isArray(rawAssistants)
|
|
199
|
+
? (rawAssistants as Array<Record<string, unknown>>)
|
|
200
|
+
: []
|
|
201
|
+
).find((a) => a?.assistantId === localId);
|
|
202
|
+
if (existing && existing.paired !== true) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
status: 409,
|
|
206
|
+
error: `An assistant named '${localId}' already exists`,
|
|
207
|
+
assistantId: localId,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Write the token BEFORE committing the lockfile entry, the mirror of
|
|
212
|
+
// unpair's ordering. A lockfile entry must never exist without its
|
|
213
|
+
// credential (every read path would 404 on the token), while a token
|
|
214
|
+
// without an entry is inert and overwritten by the next import. The prior
|
|
215
|
+
// token contents are kept in memory so a failed lockfile write below can
|
|
216
|
+
// roll the file back.
|
|
217
|
+
const tokenPath = guardianTokenPath(configDir, localId);
|
|
218
|
+
let priorToken: string | null;
|
|
219
|
+
try {
|
|
220
|
+
priorToken = fs.readFileSync(tokenPath, "utf-8");
|
|
221
|
+
} catch {
|
|
222
|
+
priorToken = null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const now = Date.now();
|
|
226
|
+
try {
|
|
227
|
+
saveGuardianToken(configDir, localId, {
|
|
228
|
+
guardianPrincipalId: "imported",
|
|
229
|
+
accessToken: bundle.token,
|
|
230
|
+
accessTokenExpiresAt:
|
|
231
|
+
jwtExpiryMs(bundle.token) ?? now + 24 * 60 * 60 * 1000,
|
|
232
|
+
refreshToken: bundle.refreshToken ?? "",
|
|
233
|
+
refreshTokenExpiresAt: bundle.refreshTokenExpiresAt ?? 0,
|
|
234
|
+
refreshAfter: bundle.refreshAfter ?? "",
|
|
235
|
+
isNew: false,
|
|
236
|
+
deviceId: bundle.deviceId ?? "",
|
|
237
|
+
leasedAt: new Date(now).toISOString(),
|
|
238
|
+
pairedGatewayUrl: bundle.gatewayUrl,
|
|
239
|
+
});
|
|
240
|
+
} catch (err) {
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
status: 500,
|
|
244
|
+
error: `Failed to write the guardian token: ${
|
|
245
|
+
err instanceof Error ? err.message : String(err)
|
|
246
|
+
}`,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const writeResult = upsertLockfileAssistant(
|
|
251
|
+
lockfilePaths,
|
|
252
|
+
{
|
|
253
|
+
assistantId: localId,
|
|
254
|
+
name: name ?? `paired (${gatewayHost})`,
|
|
255
|
+
runtimeUrl: bundle.gatewayUrl,
|
|
256
|
+
// Paired entries are reached by bearer token at the remote runtimeUrl
|
|
257
|
+
// (a non-"vellum" cloud selects the bearer-token auth path in client.ts).
|
|
258
|
+
// The "paired" topology lets lifecycle/status commands (ps/wake/sleep)
|
|
259
|
+
// recognize this as a remote pairing rather than an on-machine process.
|
|
260
|
+
cloud: "paired",
|
|
261
|
+
// Marks this entry as a connect-import so re-imports update in place
|
|
262
|
+
// while imports never silently overwrite a non-paired assistant (see
|
|
263
|
+
// guard above).
|
|
264
|
+
paired: true,
|
|
265
|
+
species: "vellum",
|
|
266
|
+
},
|
|
267
|
+
undefined,
|
|
268
|
+
);
|
|
269
|
+
if (!writeResult.ok) {
|
|
270
|
+
// The entry was not registered, so undo the token write (best-effort;
|
|
271
|
+
// the write failure itself is what gets reported): restore the prior
|
|
272
|
+
// token of a re-import, or delete the freshly written one.
|
|
273
|
+
try {
|
|
274
|
+
if (priorToken !== null) {
|
|
275
|
+
fs.writeFileSync(tokenPath, priorToken, { mode: 0o600 });
|
|
276
|
+
} else {
|
|
277
|
+
fs.rmSync(tokenPath, { force: true });
|
|
278
|
+
fs.rmdirSync(path.dirname(tokenPath));
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
// Rollback failed; the reported write error already covers the outcome.
|
|
282
|
+
}
|
|
283
|
+
return writeResult;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
ok: true,
|
|
288
|
+
assistantId: localId,
|
|
289
|
+
updated: existing !== undefined,
|
|
290
|
+
// A refresh token is only usable over a confidential channel; with
|
|
291
|
+
// loopback refused above, a plaintext http gateway can never renew, so it
|
|
292
|
+
// is reported access-only and gets the expiry warning.
|
|
293
|
+
accessOnly:
|
|
294
|
+
!bundle.refreshToken || !isConfidentialRefreshUrl(bundle.gatewayUrl),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Bounds what an untrusted connect-import request can make a host buffer and
|
|
300
|
+
* decode. Enforced by {@link connectImport} for every host.
|
|
301
|
+
*/
|
|
302
|
+
export const MAX_PAIR_BUNDLE_LENGTH = 64 * 1024;
|
|
303
|
+
|
|
304
|
+
export interface ConnectImportOptions {
|
|
305
|
+
/** The encoded bundle exactly as received from the transport. */
|
|
306
|
+
bundle: unknown;
|
|
307
|
+
/** Optional local name; non-string or empty values are ignored. */
|
|
308
|
+
name?: unknown;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Wire-shaped result of a connect-import: the success members are the response
|
|
313
|
+
* body fields, and `status` on failure is the HTTP status the loopback hosts
|
|
314
|
+
* respond with (the IPC host ignores it).
|
|
315
|
+
*/
|
|
316
|
+
export type ConnectImportResult =
|
|
317
|
+
| { ok: true; assistantId: string; accessOnly: boolean }
|
|
318
|
+
| { ok: false; status: number; error: string };
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The complete connect-import host operation: validate the raw bundle value
|
|
322
|
+
* (present, non-empty, within {@link MAX_PAIR_BUNDLE_LENGTH}), decode it, and
|
|
323
|
+
* register the pairing via {@link pairAssistant}. Hosts keep only
|
|
324
|
+
* transport-specific parsing and pass the untrusted values straight through,
|
|
325
|
+
* so bundle limits, error strings, and the result shape are defined once.
|
|
326
|
+
*/
|
|
327
|
+
export function connectImport(
|
|
328
|
+
lockfilePaths: string[],
|
|
329
|
+
configDir: string,
|
|
330
|
+
{ bundle, name }: ConnectImportOptions,
|
|
331
|
+
): ConnectImportResult {
|
|
332
|
+
if (typeof bundle !== "string" || !bundle) {
|
|
333
|
+
return { ok: false, status: 400, error: "Missing pairing bundle" };
|
|
334
|
+
}
|
|
335
|
+
if (bundle.length > MAX_PAIR_BUNDLE_LENGTH) {
|
|
336
|
+
return { ok: false, status: 400, error: "Pairing bundle is too large" };
|
|
337
|
+
}
|
|
338
|
+
const decoded = decodePairBundle(bundle.trim());
|
|
339
|
+
if (!decoded.ok) {
|
|
340
|
+
return { ok: false, status: 400, error: decoded.error };
|
|
341
|
+
}
|
|
342
|
+
const result = pairAssistant(lockfilePaths, configDir, {
|
|
343
|
+
bundle: decoded.bundle,
|
|
344
|
+
name: typeof name === "string" && name ? name : undefined,
|
|
345
|
+
});
|
|
346
|
+
if (!result.ok) {
|
|
347
|
+
return { ok: false, status: result.status, error: result.error };
|
|
348
|
+
}
|
|
349
|
+
return {
|
|
350
|
+
ok: true,
|
|
351
|
+
assistantId: result.assistantId,
|
|
352
|
+
accessOnly: result.accessOnly,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export interface LocalPathOptions {
|
|
4
|
+
platform?: NodeJS.Platform;
|
|
5
|
+
homeDir?: string;
|
|
6
|
+
environmentName?: string;
|
|
7
|
+
configDirOverride?: string;
|
|
8
|
+
lockfileDirOverride?: string;
|
|
9
|
+
}
|
|
10
|
+
const isWindows = (options: LocalPathOptions) => (options.platform ?? process.platform) === "win32";
|
|
11
|
+
const paths = (options: LocalPathOptions) =>
|
|
12
|
+
isWindows(options) ? path.win32 : path.posix;
|
|
13
|
+
const home = (options: LocalPathOptions) => options.homeDir ?? os.homedir();
|
|
14
|
+
export function resolveConfigHome(env: Record<string, string | undefined>, options: LocalPathOptions = {}): string {
|
|
15
|
+
if (isWindows(options)) {
|
|
16
|
+
return env.APPDATA?.trim() || paths(options).join(home(options), "AppData", "Roaming");
|
|
17
|
+
}
|
|
18
|
+
return env.XDG_CONFIG_HOME?.trim() || paths(options).join(home(options), ".config");
|
|
19
|
+
}
|
|
20
|
+
export function resolveConfigHomes(env: Record<string, string | undefined>, options: LocalPathOptions = {}): string[] {
|
|
21
|
+
const canonical = resolveConfigHome(env, options);
|
|
22
|
+
if (!isWindows(options)) {
|
|
23
|
+
return [canonical];
|
|
24
|
+
}
|
|
25
|
+
const legacy =
|
|
26
|
+
env.XDG_CONFIG_HOME?.trim() || paths(options).join(home(options), ".config");
|
|
27
|
+
return canonical === legacy ? [canonical] : [canonical, legacy];
|
|
28
|
+
}
|
|
29
|
+
export function resolveDataHome(env: Record<string, string | undefined>, options: LocalPathOptions = {}): string {
|
|
30
|
+
if (isWindows(options)) {
|
|
31
|
+
return env.LOCALAPPDATA?.trim() || paths(options).join(home(options), "AppData", "Local");
|
|
32
|
+
}
|
|
33
|
+
return env.XDG_DATA_HOME?.trim() || paths(options).join(home(options), ".local", "share");
|
|
34
|
+
}
|
|
35
|
+
export const joinLocalPath = (options: LocalPathOptions, ...segments: string[]) =>
|
|
36
|
+
paths(options).join(...segments);
|
|
37
|
+
export function assertSafePathSegment(value: string, label: string, options: LocalPathOptions = {}): void {
|
|
38
|
+
const unsafe =
|
|
39
|
+
!value ||
|
|
40
|
+
/[/\\\0]/.test(value) ||
|
|
41
|
+
/^\.{1,2}$/.test(value) ||
|
|
42
|
+
(isWindows(options) &&
|
|
43
|
+
(/[<>:"|?*\u0000-\u001f]/.test(value) ||
|
|
44
|
+
/[. ]$/.test(value) ||
|
|
45
|
+
/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(value)));
|
|
46
|
+
if (unsafe) {
|
|
47
|
+
throw new Error(`Invalid ${label}: ${value}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
-
import os from "node:os";
|
|
3
2
|
import http from "node:http";
|
|
4
3
|
import path from "node:path";
|
|
5
4
|
|
|
6
5
|
import { SEEDS } from "@vellumai/environments";
|
|
7
6
|
|
|
7
|
+
import { resolveInstanceDir } from "./config";
|
|
8
|
+
import { resolveEnvironmentName } from "./environment";
|
|
8
9
|
import type { LockfileAssistant } from "./lockfile-contract";
|
|
9
10
|
import { getLockfileData } from "./lockfile";
|
|
11
|
+
import { assertSafePathSegment } from "./paths";
|
|
10
12
|
|
|
11
13
|
const HEALTH_TIMEOUT_MS = 1_500;
|
|
12
14
|
const STARTING_GRACE_MS = 60_000;
|
|
@@ -209,7 +211,7 @@ function defaultPorts(env: Record<string, string | undefined>): {
|
|
|
209
211
|
daemon: number;
|
|
210
212
|
gateway: number;
|
|
211
213
|
} {
|
|
212
|
-
const envName = env
|
|
214
|
+
const envName = resolveEnvironmentName(env);
|
|
213
215
|
const seed = SEEDS[envName] ?? SEEDS[PRODUCTION_ENVIRONMENT_NAME];
|
|
214
216
|
return {
|
|
215
217
|
daemon: seed?.portsOverride?.daemon ?? DEFAULT_PORTS.daemon,
|
|
@@ -221,12 +223,7 @@ function defaultInstanceDir(
|
|
|
221
223
|
env: Record<string, string | undefined>,
|
|
222
224
|
assistantId: string,
|
|
223
225
|
): string {
|
|
224
|
-
|
|
225
|
-
const xdgDataHome =
|
|
226
|
-
env.XDG_DATA_HOME?.trim() || path.join(os.homedir(), ".local", "share");
|
|
227
|
-
const dataRoot =
|
|
228
|
-
envName === PRODUCTION_ENVIRONMENT_NAME ? "vellum" : `vellum-${envName}`;
|
|
229
|
-
return path.join(xdgDataHome, dataRoot, "assistants", assistantId);
|
|
226
|
+
return resolveInstanceDir(env, assistantId);
|
|
230
227
|
}
|
|
231
228
|
|
|
232
229
|
function firstString(...values: unknown[]): string | undefined {
|
|
@@ -394,6 +391,11 @@ export async function getLocalAssistantStatus(
|
|
|
394
391
|
assistantId: string,
|
|
395
392
|
env: Record<string, string | undefined> = process.env,
|
|
396
393
|
): Promise<LocalAssistantStatusResult> {
|
|
394
|
+
try {
|
|
395
|
+
assertSafePathSegment(assistantId, "assistant ID");
|
|
396
|
+
} catch {
|
|
397
|
+
return { ok: false, status: 400, error: "Invalid assistant ID" };
|
|
398
|
+
}
|
|
397
399
|
const result = getLockfileData(lockfilePaths);
|
|
398
400
|
if (!result.ok) {
|
|
399
401
|
return {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { guardianTokenPath } from "./config";
|
|
5
|
+
import {
|
|
6
|
+
readRawLockfile,
|
|
7
|
+
writeRawLockfile,
|
|
8
|
+
type WriteResult,
|
|
9
|
+
} from "./lockfile";
|
|
10
|
+
import { resolveCloud } from "./lockfile-contract";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Forget a paired assistant on this machine: remove its lockfile entry and
|
|
14
|
+
* delete its stored guardian token. Client-side only, mirroring the CLI's
|
|
15
|
+
* `vellum unpair`: the remote assistant itself is never touched (host-side
|
|
16
|
+
* device revocation is `vellum devices`). Only pairing records
|
|
17
|
+
* (`cloud: "paired"`, created by `vellum connect import`) are forgettable this
|
|
18
|
+
* way; local and managed assistants go through retire.
|
|
19
|
+
*/
|
|
20
|
+
export function unpairAssistant(
|
|
21
|
+
lockfilePaths: string[],
|
|
22
|
+
configDir: string,
|
|
23
|
+
assistantId: string,
|
|
24
|
+
): WriteResult {
|
|
25
|
+
const lockfile = readRawLockfile(lockfilePaths);
|
|
26
|
+
const assistants = Array.isArray(lockfile.assistants)
|
|
27
|
+
? (lockfile.assistants as Array<Record<string, unknown>>)
|
|
28
|
+
: [];
|
|
29
|
+
const entry = assistants.find((a) => a?.assistantId === assistantId);
|
|
30
|
+
if (!entry) {
|
|
31
|
+
return { ok: false, status: 404, error: "No such assistant" };
|
|
32
|
+
}
|
|
33
|
+
if (resolveCloud(entry) !== "paired") {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
status: 400,
|
|
37
|
+
error:
|
|
38
|
+
"Only paired assistants can be unpaired. Use retire for local or managed assistants.",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Delete the token BEFORE committing the lockfile removal. A failed delete
|
|
43
|
+
// aborts with the entry intact, so unpair stays retryable; the reverse order
|
|
44
|
+
// would strand the credential on disk forever (the retry 404s on the
|
|
45
|
+
// already-removed entry and never reaches cleanup). The token contents are
|
|
46
|
+
// kept in memory so a failed lockfile write below can restore them.
|
|
47
|
+
const tokenPath = guardianTokenPath(configDir, assistantId);
|
|
48
|
+
let savedToken: string | null = null;
|
|
49
|
+
try {
|
|
50
|
+
savedToken = fs.readFileSync(tokenPath, "utf-8");
|
|
51
|
+
} catch {
|
|
52
|
+
savedToken = null;
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
fs.rmSync(tokenPath, { force: true });
|
|
56
|
+
} catch (err) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
status: 500,
|
|
60
|
+
error: `Failed to delete the stored guardian token: ${
|
|
61
|
+
err instanceof Error ? err.message : String(err)
|
|
62
|
+
}`,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
fs.rmdirSync(path.dirname(tokenPath));
|
|
67
|
+
} catch {
|
|
68
|
+
// Directory not empty or absent.
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const remaining = assistants.filter((a) => a?.assistantId !== assistantId);
|
|
72
|
+
lockfile.assistants = remaining;
|
|
73
|
+
// Reassign the active assistant like the CLI's removeAssistantEntry does, so
|
|
74
|
+
// unpairing through the bridge and through `vellum unpair` leave the same
|
|
75
|
+
// active state. Skip tolerated malformed entries (no string assistantId):
|
|
76
|
+
// parseLockfile drops them from the returned lockfile, so pointing at one
|
|
77
|
+
// would report no active assistant while valid entries remain.
|
|
78
|
+
if (lockfile.activeAssistant === assistantId) {
|
|
79
|
+
const next = remaining.find(
|
|
80
|
+
(a) => typeof a?.assistantId === "string",
|
|
81
|
+
)?.assistantId;
|
|
82
|
+
if (typeof next === "string") {
|
|
83
|
+
lockfile.activeAssistant = next;
|
|
84
|
+
} else {
|
|
85
|
+
delete lockfile.activeAssistant;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const result = writeRawLockfile(lockfilePaths, lockfile);
|
|
90
|
+
if (!result.ok && savedToken !== null) {
|
|
91
|
+
// The entry is still listed, so put its credential back (best-effort;
|
|
92
|
+
// the write failure itself is what gets reported).
|
|
93
|
+
try {
|
|
94
|
+
fs.mkdirSync(path.dirname(tokenPath), { recursive: true, mode: 0o700 });
|
|
95
|
+
fs.writeFileSync(tokenPath, savedToken, { mode: 0o600 });
|
|
96
|
+
} catch {
|
|
97
|
+
// Restore failed; the reported write error already covers the outcome.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
@@ -71,6 +71,40 @@ export function originIsAllowed(originHeader: string | undefined): boolean {
|
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Require browser-controlled proof that a request came from the loopback HTTP
|
|
76
|
+
* origin named by its Host header. Conflicting Origin or Fetch Metadata values
|
|
77
|
+
* are rejected even when the other signal is valid.
|
|
78
|
+
*/
|
|
79
|
+
export function hasSameOriginCredentialProof(
|
|
80
|
+
hostHeader: string | undefined,
|
|
81
|
+
originHeader: string | undefined,
|
|
82
|
+
secFetchSiteHeader: string | undefined,
|
|
83
|
+
): boolean {
|
|
84
|
+
if (!hostHeader || !headerHostIsLoopback(hostHeader)) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let hasMatchingOrigin = false;
|
|
89
|
+
if (originHeader) {
|
|
90
|
+
try {
|
|
91
|
+
hasMatchingOrigin =
|
|
92
|
+
new URL(originHeader).origin === new URL(`http://${hostHeader}`).origin;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
if (!hasMatchingOrigin) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (secFetchSiteHeader && secFetchSiteHeader !== "same-origin") {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return hasMatchingOrigin || secFetchSiteHeader === "same-origin";
|
|
106
|
+
}
|
|
107
|
+
|
|
74
108
|
export function isLoopbackAddr(addr: string): boolean {
|
|
75
109
|
const v4Mapped = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
|
|
76
110
|
const normalized = v4Mapped ? v4Mapped[1]! : addr;
|
|
@@ -40,3 +40,42 @@ export function isChannelId(value: unknown): value is ChannelId {
|
|
|
40
40
|
(CHANNEL_IDS as readonly string[]).includes(value)
|
|
41
41
|
);
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The provider key holding each channel's bot credential.
|
|
46
|
+
*
|
|
47
|
+
* Two senses of "connected" share the provider registry: a provider the user
|
|
48
|
+
* authorized so the assistant can act **as them** (`slack`, `discord`,
|
|
49
|
+
* `google`), and a bot credential letting people reach the assistant **as
|
|
50
|
+
* itself**. Nothing in a provider key says which, and the naming actively
|
|
51
|
+
* misleads: `slack` and `discord` name the user integration while their bots
|
|
52
|
+
* take a `_channel` suffix, yet `telegram` *is* the bot, because Telegram has
|
|
53
|
+
* no user-identity integration.
|
|
54
|
+
*
|
|
55
|
+
* That irregularity is why this is stated rather than derived from the key,
|
|
56
|
+
* and it is stated here because this file already owns what a channel is.
|
|
57
|
+
*
|
|
58
|
+
* Deliberately only the key. What fields each credential requires is declared
|
|
59
|
+
* once already, per service, in the gateway's credential specs; restating it
|
|
60
|
+
* here would be a second copy of a different fact.
|
|
61
|
+
*
|
|
62
|
+
* Channels absent from this map reach the assistant without a bot credential
|
|
63
|
+
* of their own: `phone` through the voice provider, `vellum` and `platform`
|
|
64
|
+
* internally.
|
|
65
|
+
*/
|
|
66
|
+
export const CHANNEL_BOT_PROVIDER = {
|
|
67
|
+
slack: "slack_channel",
|
|
68
|
+
discord: "discord_channel",
|
|
69
|
+
telegram: "telegram",
|
|
70
|
+
} as const satisfies Partial<Record<ChannelId, string>>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether a provider key names a bot the assistant is reached through, rather
|
|
74
|
+
* than a grant letting it act as the user. This is the "which sense of
|
|
75
|
+
* connected" question, for any provider key.
|
|
76
|
+
*/
|
|
77
|
+
export function isChannelBotProvider(providerKey: string): boolean {
|
|
78
|
+
return (Object.values(CHANNEL_BOT_PROVIDER) as readonly string[]).includes(
|
|
79
|
+
providerKey,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -20,6 +20,16 @@ export function normalizePublicBaseUrl(value: unknown): string | undefined {
|
|
|
20
20
|
return normalized.length > 0 ? normalized : undefined;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Application close code the gateway's velay bridge sends to proxied
|
|
25
|
+
* WebSockets when the tunnel itself is lost (velay disconnect, gateway
|
|
26
|
+
* shutdown). A dedicated code because the natural 1001 (going away) cannot
|
|
27
|
+
* be sent through the JS `close()` API (the bridge would remap it to a
|
|
28
|
+
* misleading 4001), and Bun's WebSocket client drops close reasons, so the
|
|
29
|
+
* code is the only signal that survives the relay to the daemon.
|
|
30
|
+
*/
|
|
31
|
+
export const GATEWAY_TUNNEL_LOST_WS_CLOSE_CODE = 4801;
|
|
32
|
+
|
|
23
33
|
export function normalizeHttpPublicBaseUrl(value: unknown): string | undefined {
|
|
24
34
|
if (typeof value !== "string") return undefined;
|
|
25
35
|
const trimmed = value.trim();
|
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
* `Date#toISOString()`.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Pairing-challenge TTL in milliseconds (10 minutes): the gateway's challenge
|
|
26
|
+
* store enforces it and the `vellum pair` CLI renders it in user-facing copy.
|
|
27
|
+
*/
|
|
28
|
+
export const REMOTE_WEB_PAIRING_CODE_TTL_MS = 10 * 60 * 1000;
|
|
29
|
+
|
|
24
30
|
/** `POST /v1/remote-web/pairing-challenge` request body. */
|
|
25
31
|
export interface RemoteWebPairingChallengeRequest {
|
|
26
32
|
/** Public https base URL the scanning device can reach the assistant at. */
|