@vellumai/cli 0.11.2 → 0.11.3-staging.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/knip.json +1 -0
  2. package/node_modules/@vellumai/environments/package.json +2 -1
  3. package/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
  4. package/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
  5. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
  6. package/node_modules/@vellumai/environments/src/index.ts +17 -6
  7. package/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
  8. package/node_modules/@vellumai/environments/src/seeds.ts +29 -0
  9. package/node_modules/@vellumai/ipc-server-utils/package.json +18 -0
  10. package/node_modules/@vellumai/ipc-server-utils/src/endpoint.test.ts +36 -0
  11. package/node_modules/@vellumai/ipc-server-utils/src/endpoint.ts +142 -0
  12. package/node_modules/@vellumai/ipc-server-utils/src/index.ts +18 -0
  13. package/node_modules/@vellumai/ipc-server-utils/src/ipc-framing.ts +295 -0
  14. package/node_modules/@vellumai/ipc-server-utils/src/listen-options.ts +3 -0
  15. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.test.ts +444 -0
  16. package/node_modules/@vellumai/ipc-server-utils/src/socket-watchdog.ts +236 -0
  17. package/node_modules/@vellumai/ipc-server-utils/tsconfig.json +20 -0
  18. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/package.json +2 -1
  19. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/cloud-assistant-hub-url.test.ts +38 -0
  20. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/install-layout.test.ts +98 -0
  21. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +5 -5
  22. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/index.ts +17 -6
  23. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/install-layout.ts +49 -0
  24. package/node_modules/@vellumai/local-mode/node_modules/@vellumai/environments/src/seeds.ts +29 -0
  25. package/node_modules/@vellumai/local-mode/node_modules/nanoid/LICENSE +20 -0
  26. package/node_modules/@vellumai/local-mode/node_modules/nanoid/README.md +38 -0
  27. package/node_modules/@vellumai/local-mode/node_modules/nanoid/bin/nanoid.js +55 -0
  28. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.browser.js +29 -0
  29. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.d.ts +106 -0
  30. package/node_modules/@vellumai/local-mode/node_modules/nanoid/index.js +47 -0
  31. package/node_modules/@vellumai/local-mode/node_modules/nanoid/nanoid.js +1 -0
  32. package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.d.ts +48 -0
  33. package/node_modules/@vellumai/local-mode/node_modules/nanoid/non-secure/index.js +21 -0
  34. package/node_modules/@vellumai/local-mode/node_modules/nanoid/package.json +46 -0
  35. package/node_modules/@vellumai/local-mode/node_modules/nanoid/url-alphabet/index.js +2 -0
  36. package/node_modules/@vellumai/local-mode/package.json +1 -0
  37. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +63 -1
  38. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +503 -0
  39. package/node_modules/@vellumai/local-mode/src/__tests__/guardian-token.test.ts +203 -0
  40. package/node_modules/@vellumai/local-mode/src/__tests__/loopback-auth.test.ts +46 -1
  41. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +6 -4
  42. package/node_modules/@vellumai/local-mode/src/__tests__/pair.test.ts +621 -0
  43. package/node_modules/@vellumai/local-mode/src/__tests__/status.test.ts +5 -0
  44. package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +232 -0
  45. package/node_modules/@vellumai/local-mode/src/config.ts +126 -20
  46. package/node_modules/@vellumai/local-mode/src/environment.ts +36 -15
  47. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +313 -25
  48. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +222 -13
  49. package/node_modules/@vellumai/local-mode/src/index.ts +35 -3
  50. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +18 -0
  51. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +72 -0
  52. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +130 -0
  53. package/node_modules/@vellumai/local-mode/src/lockfile.ts +134 -45
  54. package/node_modules/@vellumai/local-mode/src/pair.ts +354 -0
  55. package/node_modules/@vellumai/local-mode/src/paths.ts +49 -0
  56. package/node_modules/@vellumai/local-mode/src/status.ts +10 -8
  57. package/node_modules/@vellumai/local-mode/src/unpair.ts +101 -0
  58. package/node_modules/@vellumai/local-mode/src/util.ts +34 -0
  59. package/node_modules/@vellumai/service-contracts/src/channels.ts +39 -0
  60. package/node_modules/@vellumai/service-contracts/src/ingress.ts +10 -0
  61. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +6 -0
  62. package/package.json +3 -1
  63. package/src/__tests__/client-token.test.ts +26 -1
  64. package/src/__tests__/guardian-token.test.ts +4 -0
  65. package/src/__tests__/nginx-ingress-command.test.ts +176 -16
  66. package/src/__tests__/nginx-ingress.test.ts +1821 -101
  67. package/src/__tests__/pair.test.ts +104 -2
  68. package/src/__tests__/sleep.test.ts +7 -3
  69. package/src/__tests__/tailscale-tunnel.test.ts +0 -1
  70. package/src/__tests__/tunnel.test.ts +1099 -55
  71. package/src/__tests__/wake.test.ts +166 -99
  72. package/src/commands/client.ts +182 -27
  73. package/src/commands/connect/import.ts +35 -156
  74. package/src/commands/nginx-ingress.ts +87 -110
  75. package/src/commands/pair.ts +84 -21
  76. package/src/commands/tunnel.ts +196 -85
  77. package/src/commands/upgrade.ts +3 -2
  78. package/src/commands/wake.ts +5 -163
  79. package/src/index.ts +1 -1
  80. package/src/lib/__tests__/local-ces.test.ts +10 -1
  81. package/src/lib/cloudflare-tunnel.ts +3 -16
  82. package/src/lib/environments/__tests__/paths.test.ts +20 -1
  83. package/src/lib/environments/paths.ts +29 -41
  84. package/src/lib/environments/resolve.ts +7 -5
  85. package/src/lib/guardian-token.ts +22 -62
  86. package/src/lib/ingress-config.ts +25 -0
  87. package/src/lib/local.ts +77 -20
  88. package/src/lib/nginx-ingress.ts +523 -76
  89. package/src/lib/ngrok.ts +154 -61
  90. package/src/lib/orphan-detection.test.ts +167 -0
  91. package/src/lib/orphan-detection.ts +40 -0
  92. package/src/lib/pair.test.ts +87 -0
  93. package/src/lib/pair.ts +55 -0
  94. package/src/lib/platform-client.ts +8 -5
  95. package/src/lib/tailscale-tunnel.ts +7 -18
  96. package/src/lib/tunnel-edge.ts +114 -0
  97. package/src/lib/xdg-log.ts +2 -2
@@ -0,0 +1,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.VELLUM_ENVIRONMENT?.trim() || PRODUCTION_ENVIRONMENT_NAME;
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
- const envName = env.VELLUM_ENVIRONMENT?.trim() || PRODUCTION_ENVIRONMENT_NAME;
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. */