@vellumai/cli 0.11.3 → 0.11.4-dev.202608190019.b94dbf2
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/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +33 -0
- package/node_modules/@vellumai/local-mode/src/index.ts +3 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-lock.test.ts +165 -0
- package/node_modules/@vellumai/local-mode/src/lockfile-lock.ts +156 -0
- package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +249 -8
- package/node_modules/@vellumai/local-mode/src/lockfile.ts +186 -68
- package/node_modules/@vellumai/local-mode/src/unpair.ts +18 -0
- package/node_modules/@vellumai/service-contracts/package.json +1 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
- package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
- package/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
- package/package.json +1 -1
- package/src/__tests__/assistant-config.test.ts +35 -0
- package/src/__tests__/nginx-ingress-command.test.ts +4 -23
- package/src/__tests__/nginx-ingress.test.ts +59 -215
- package/src/__tests__/pair.test.ts +11 -197
- package/src/__tests__/retire-archive.test.ts +13 -1
- package/src/__tests__/retire-local.test.ts +58 -4
- package/src/__tests__/tunnel.test.ts +0 -28
- package/src/__tests__/wake.test.ts +91 -69
- package/src/__tests__/windows-lifecycle.test.ts +157 -0
- package/src/commands/client.ts +9 -31
- package/src/commands/nginx-ingress.ts +0 -15
- package/src/commands/pair.ts +0 -39
- package/src/commands/wake.ts +39 -12
- package/src/lib/__tests__/web-dist.test.ts +86 -0
- package/src/lib/assistant-config.ts +64 -27
- package/src/lib/local.ts +60 -20
- package/src/lib/nginx-ingress.ts +35 -108
- package/src/lib/orphan-detection.test.ts +3 -0
- package/src/lib/orphan-detection.ts +33 -11
- package/src/lib/pgrep.ts +20 -2
- package/src/lib/process.ts +191 -18
- package/src/lib/retire-archive.ts +38 -8
- package/src/lib/retire-local.ts +75 -9
- package/src/lib/tunnel-edge.ts +14 -22
- package/src/lib/web-dist.ts +48 -0
- package/src/lib/feature-flags.test.ts +0 -157
- package/src/lib/feature-flags.ts +0 -38
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { findWebDistDir } from "../web-dist.js";
|
|
7
|
+
|
|
8
|
+
const tempDirs: string[] = [];
|
|
9
|
+
|
|
10
|
+
function makeTempDir(): string {
|
|
11
|
+
const dir = mkdtempSync(join(tmpdir(), "vellum-web-dist-"));
|
|
12
|
+
tempDirs.push(dir);
|
|
13
|
+
return dir;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function writeWebDist(dir: string): void {
|
|
17
|
+
mkdirSync(dir, { recursive: true });
|
|
18
|
+
writeFileSync(join(dir, "index.html"), "<!doctype html>", "utf8");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const missingPackage = (): string => {
|
|
22
|
+
throw new Error("package missing");
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
for (const dir of tempDirs.splice(0)) {
|
|
27
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("finds web assets beside a packaged Windows CLI", () => {
|
|
32
|
+
const root = makeTempDir();
|
|
33
|
+
const runtimeDir = join(root, "runtime");
|
|
34
|
+
const webDistDir = join(runtimeDir, "web-dist");
|
|
35
|
+
writeWebDist(webDistDir);
|
|
36
|
+
|
|
37
|
+
expect(
|
|
38
|
+
findWebDistDir({
|
|
39
|
+
execPath: join(runtimeDir, "vellum.exe"),
|
|
40
|
+
platform: "win32",
|
|
41
|
+
resolvePackage: missingPackage,
|
|
42
|
+
startDir: join(root, "source"),
|
|
43
|
+
}),
|
|
44
|
+
).toBe(webDistDir);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("does not use the Windows runtime path on macOS", () => {
|
|
48
|
+
const root = makeTempDir();
|
|
49
|
+
const runtimeDir = join(root, "runtime");
|
|
50
|
+
writeWebDist(join(runtimeDir, "web-dist"));
|
|
51
|
+
|
|
52
|
+
expect(
|
|
53
|
+
findWebDistDir({
|
|
54
|
+
execPath: join(runtimeDir, "vellum"),
|
|
55
|
+
platform: "darwin",
|
|
56
|
+
resolvePackage: missingPackage,
|
|
57
|
+
startDir: join(root, "source"),
|
|
58
|
+
}),
|
|
59
|
+
).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("preserves installed package and source checkout resolution", () => {
|
|
63
|
+
const root = makeTempDir();
|
|
64
|
+
const packageDir = join(root, "package");
|
|
65
|
+
const packageDist = join(packageDir, "dist");
|
|
66
|
+
writeWebDist(packageDist);
|
|
67
|
+
writeFileSync(join(packageDir, "package.json"), "{}", "utf8");
|
|
68
|
+
|
|
69
|
+
expect(
|
|
70
|
+
findWebDistDir({
|
|
71
|
+
platform: "linux",
|
|
72
|
+
resolvePackage: () => join(packageDir, "package.json"),
|
|
73
|
+
startDir: join(root, "missing-source"),
|
|
74
|
+
}),
|
|
75
|
+
).toBe(packageDist);
|
|
76
|
+
|
|
77
|
+
const sourceDist = join(root, "repo", "clients", "web", "dist");
|
|
78
|
+
writeWebDist(sourceDist);
|
|
79
|
+
expect(
|
|
80
|
+
findWebDistDir({
|
|
81
|
+
platform: "linux",
|
|
82
|
+
resolvePackage: missingPackage,
|
|
83
|
+
startDir: join(root, "repo", "cli", "src"),
|
|
84
|
+
}),
|
|
85
|
+
).toBe(sourceDist);
|
|
86
|
+
});
|
|
@@ -11,6 +11,7 @@ import { homedir } from "os";
|
|
|
11
11
|
import { dirname, join } from "path";
|
|
12
12
|
|
|
13
13
|
import { SEEDS, type EnvironmentDefinition } from "@vellumai/environments";
|
|
14
|
+
import { withLockfileLock } from "@vellumai/local-mode";
|
|
14
15
|
import {
|
|
15
16
|
resolveCloud,
|
|
16
17
|
type LocalAssistantResources,
|
|
@@ -186,7 +187,7 @@ function readLockfile(): LockfileData {
|
|
|
186
187
|
return {};
|
|
187
188
|
}
|
|
188
189
|
|
|
189
|
-
function
|
|
190
|
+
function writeLockfileUnlocked(data: LockfileData): void {
|
|
190
191
|
const lockfilePath = getLockfilePath(getCurrentEnvironment());
|
|
191
192
|
mkdirSync(dirname(lockfilePath), { recursive: true });
|
|
192
193
|
const tmpPath = `${lockfilePath}.${randomBytes(4).toString("hex")}.tmp`;
|
|
@@ -201,6 +202,23 @@ function writeLockfile(data: LockfileData): void {
|
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Run `fn` under the same cross-process advisory lock the local-mode hosts
|
|
207
|
+
* use, keyed to this environment's write-path lockfile, so CLI writers and
|
|
208
|
+
* package-side writers (Electron main, Vite plugin, `vellum client`) never
|
|
209
|
+
* interleave read-modify-write cycles. Reentrant, so wrapped units may call
|
|
210
|
+
* the locked write helpers. Throws on lock timeout, matching the fs-error
|
|
211
|
+
* contract of the write path.
|
|
212
|
+
*/
|
|
213
|
+
function withCliLockfileLock<T>(fn: () => T): T {
|
|
214
|
+
const locked = withLockfileLock(
|
|
215
|
+
[getLockfilePath(getCurrentEnvironment())],
|
|
216
|
+
fn,
|
|
217
|
+
);
|
|
218
|
+
if (!locked.ok) throw new Error(locked.error);
|
|
219
|
+
return locked.value;
|
|
220
|
+
}
|
|
221
|
+
|
|
204
222
|
/**
|
|
205
223
|
* Try to extract a port number from a URL string (e.g. `http://localhost:7830`).
|
|
206
224
|
* Returns undefined if the URL is malformed or has no explicit port.
|
|
@@ -322,7 +340,18 @@ function readAssistants(): AssistantEntry[] {
|
|
|
322
340
|
}
|
|
323
341
|
|
|
324
342
|
if (migrated) {
|
|
325
|
-
|
|
343
|
+
// Persist the backfill against a fresh read under the lock so this
|
|
344
|
+
// snapshot cannot clobber a concurrent writer; skip on contention (the
|
|
345
|
+
// in-memory result below is already migrated).
|
|
346
|
+
withLockfileLock([getLockfilePath(getCurrentEnvironment())], () => {
|
|
347
|
+
const fresh = readLockfile();
|
|
348
|
+
if (!Array.isArray(fresh.assistants)) return;
|
|
349
|
+
let freshMigrated = false;
|
|
350
|
+
for (const entry of fresh.assistants) {
|
|
351
|
+
if (migrateLegacyEntry(entry)) freshMigrated = true;
|
|
352
|
+
}
|
|
353
|
+
if (freshMigrated) writeLockfileUnlocked(fresh);
|
|
354
|
+
});
|
|
326
355
|
}
|
|
327
356
|
|
|
328
357
|
const result: AssistantEntry[] = [];
|
|
@@ -342,9 +371,11 @@ function readAssistants(): AssistantEntry[] {
|
|
|
342
371
|
}
|
|
343
372
|
|
|
344
373
|
function writeAssistants(entries: AssistantEntry[]): void {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
374
|
+
withCliLockfileLock(() => {
|
|
375
|
+
const data = readLockfile();
|
|
376
|
+
data.assistants = entries;
|
|
377
|
+
writeLockfileUnlocked(data);
|
|
378
|
+
});
|
|
348
379
|
}
|
|
349
380
|
|
|
350
381
|
export function loadLatestAssistant(): AssistantEntry | null {
|
|
@@ -428,21 +459,23 @@ export function formatAssistantLookupError(
|
|
|
428
459
|
}
|
|
429
460
|
|
|
430
461
|
export function removeAssistantEntry(assistantId: string): void {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
462
|
+
withCliLockfileLock(() => {
|
|
463
|
+
const data = readLockfile();
|
|
464
|
+
const entries = (data.assistants ?? []).filter(
|
|
465
|
+
(e) => e.assistantId !== assistantId,
|
|
466
|
+
);
|
|
467
|
+
data.assistants = entries;
|
|
468
|
+
// Reassign active assistant if it matches the removed entry
|
|
469
|
+
if (data.activeAssistant === assistantId) {
|
|
470
|
+
const remaining = entries[0];
|
|
471
|
+
if (remaining) {
|
|
472
|
+
data.activeAssistant = String(remaining.assistantId);
|
|
473
|
+
} else {
|
|
474
|
+
delete data.activeAssistant;
|
|
475
|
+
}
|
|
443
476
|
}
|
|
444
|
-
|
|
445
|
-
|
|
477
|
+
writeLockfileUnlocked(data);
|
|
478
|
+
});
|
|
446
479
|
}
|
|
447
480
|
|
|
448
481
|
export function loadAllAssistants(): AssistantEntry[] {
|
|
@@ -519,9 +552,11 @@ export function getActiveAssistant(): string | null {
|
|
|
519
552
|
}
|
|
520
553
|
|
|
521
554
|
export function setActiveAssistant(assistantId: string): void {
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
555
|
+
withCliLockfileLock(() => {
|
|
556
|
+
const data = readLockfile();
|
|
557
|
+
data.activeAssistant = assistantId;
|
|
558
|
+
writeLockfileUnlocked(data);
|
|
559
|
+
});
|
|
525
560
|
}
|
|
526
561
|
|
|
527
562
|
/**
|
|
@@ -607,11 +642,13 @@ export function extractHostFromUrl(url: string): string {
|
|
|
607
642
|
}
|
|
608
643
|
|
|
609
644
|
export function saveAssistantEntry(entry: AssistantEntry): void {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
645
|
+
withCliLockfileLock(() => {
|
|
646
|
+
const entries = readAssistants().filter(
|
|
647
|
+
(e) => e.assistantId !== entry.assistantId,
|
|
648
|
+
);
|
|
649
|
+
entries.unshift(entry);
|
|
650
|
+
writeAssistants(entries);
|
|
651
|
+
});
|
|
615
652
|
}
|
|
616
653
|
|
|
617
654
|
/**
|
package/src/lib/local.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
import { createRequire } from "module";
|
|
11
11
|
import { Socket } from "net";
|
|
12
12
|
import { homedir, networkInterfaces, platform, tmpdir } from "os";
|
|
13
|
-
import { basename, dirname, join } from "path";
|
|
13
|
+
import { basename, dirname, isAbsolute, join } from "path";
|
|
14
14
|
|
|
15
15
|
import {
|
|
16
16
|
findAssistantCommand,
|
|
@@ -39,6 +39,8 @@ import {
|
|
|
39
39
|
import { stopIngressNginx } from "./nginx-ingress.js";
|
|
40
40
|
import {
|
|
41
41
|
type ProcessState,
|
|
42
|
+
executableName,
|
|
43
|
+
pathListDelimiter,
|
|
42
44
|
resolveProcessState,
|
|
43
45
|
stopProcess,
|
|
44
46
|
stopProcessByPidFile,
|
|
@@ -55,6 +57,11 @@ const DARWIN_UNIX_SOCKET_MAX_PATH_BYTES = 103;
|
|
|
55
57
|
// assistant.sock = 14 chars, plus 1 for the "/" separator = 15 overhead.
|
|
56
58
|
const LONGEST_SOCKET_FILENAME = "assistant.sock";
|
|
57
59
|
const LOCAL_RUNTIME_PACKAGE = "vellum";
|
|
60
|
+
const PATH_DELIMITER = pathListDelimiter(platform());
|
|
61
|
+
const DEFAULT_EXECUTABLE_PATH =
|
|
62
|
+
platform() === "win32"
|
|
63
|
+
? "C:\\Windows\\System32;C:\\Windows"
|
|
64
|
+
: "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
|
58
65
|
|
|
59
66
|
export interface LocalRuntimeInstall {
|
|
60
67
|
version: string;
|
|
@@ -116,12 +123,16 @@ function hasLocalRuntimeComponents(installDir: string): boolean {
|
|
|
116
123
|
* and point at whatever version happens to be installed globally.
|
|
117
124
|
*/
|
|
118
125
|
export function isCompiledCli(): boolean {
|
|
119
|
-
const execBase = basename(process.execPath);
|
|
126
|
+
const execBase = basename(process.execPath).replace(/\.exe$/i, "");
|
|
120
127
|
return (
|
|
121
128
|
execBase !== "bun" && execBase !== "bunx" && !execBase.startsWith("bun-")
|
|
122
129
|
);
|
|
123
130
|
}
|
|
124
131
|
|
|
132
|
+
function compiledSibling(name: string): string {
|
|
133
|
+
return join(dirname(process.execPath), executableName(name, platform()));
|
|
134
|
+
}
|
|
135
|
+
|
|
125
136
|
function resolveBunExecutable(): string {
|
|
126
137
|
if (!isCompiledCli()) {
|
|
127
138
|
return process.execPath;
|
|
@@ -130,16 +141,22 @@ function resolveBunExecutable(): string {
|
|
|
130
141
|
const envBun = process.env.VELLUM_BUN;
|
|
131
142
|
if (envBun && existsSync(envBun)) return envBun;
|
|
132
143
|
|
|
133
|
-
const
|
|
144
|
+
const bunName = executableName("bun", platform());
|
|
145
|
+
const siblingBun = compiledSibling("bun");
|
|
134
146
|
if (existsSync(siblingBun)) return siblingBun;
|
|
135
147
|
|
|
136
|
-
const bundledBun = join(
|
|
148
|
+
const bundledBun = join(
|
|
149
|
+
dirname(process.execPath),
|
|
150
|
+
"..",
|
|
151
|
+
"Resources",
|
|
152
|
+
bunName,
|
|
153
|
+
);
|
|
137
154
|
if (existsSync(bundledBun)) return bundledBun;
|
|
138
155
|
|
|
139
|
-
const homeBun = join(homedir(), ".bun", "bin",
|
|
156
|
+
const homeBun = join(homedir(), ".bun", "bin", bunName);
|
|
140
157
|
if (existsSync(homeBun)) return homeBun;
|
|
141
158
|
|
|
142
|
-
return
|
|
159
|
+
return bunName;
|
|
143
160
|
}
|
|
144
161
|
|
|
145
162
|
function envWithBunPath(
|
|
@@ -147,16 +164,16 @@ function envWithBunPath(
|
|
|
147
164
|
commandDirs: string[] = [],
|
|
148
165
|
): Record<string, string | undefined> {
|
|
149
166
|
const bunPath = resolveBunExecutable();
|
|
150
|
-
const basePath = env.PATH ||
|
|
167
|
+
const basePath = env.PATH || DEFAULT_EXECUTABLE_PATH;
|
|
151
168
|
const extraDirs = [
|
|
152
|
-
bunPath
|
|
169
|
+
isAbsolute(bunPath) ? dirname(bunPath) : "",
|
|
153
170
|
...commandDirs,
|
|
154
171
|
join(homedir(), ".bun", "bin"),
|
|
155
172
|
join(homedir(), ".local", "bin"),
|
|
156
|
-
].filter((dir) => dir && !basePath.split(
|
|
173
|
+
].filter((dir) => dir && !basePath.split(PATH_DELIMITER).includes(dir));
|
|
157
174
|
return {
|
|
158
175
|
...env,
|
|
159
|
-
PATH: [...extraDirs, basePath].filter(Boolean).join(
|
|
176
|
+
PATH: [...extraDirs, basePath].filter(Boolean).join(PATH_DELIMITER),
|
|
160
177
|
};
|
|
161
178
|
}
|
|
162
179
|
|
|
@@ -649,7 +666,9 @@ function logAssistantAlreadyRunning(
|
|
|
649
666
|
? " but its database migrations failed — restart to recover"
|
|
650
667
|
: status === "unready"
|
|
651
668
|
? " — database migrations still running"
|
|
652
|
-
: ""
|
|
669
|
+
: status === "stuck"
|
|
670
|
+
? " but is not responding and could not be stopped"
|
|
671
|
+
: "";
|
|
653
672
|
console.log(` Assistant already running (pid ${pid})${suffix}\n`);
|
|
654
673
|
}
|
|
655
674
|
|
|
@@ -968,7 +987,7 @@ export async function startCes(
|
|
|
968
987
|
|
|
969
988
|
let ces;
|
|
970
989
|
const runtimeCesDir = !watch ? localRuntimeCesDir(resources) : undefined;
|
|
971
|
-
const cesBinary =
|
|
990
|
+
const cesBinary = compiledSibling("credential-executor");
|
|
972
991
|
if (!runtimeCesDir && isCompiledCli() && existsSync(cesBinary) && !watch) {
|
|
973
992
|
// Compiled binary alongside the CLI (desktop app / compiled CLI).
|
|
974
993
|
const cesLogFd = openLogFile("hatch.log");
|
|
@@ -1328,7 +1347,10 @@ export function isGatewayWatchModeAvailable(): boolean {
|
|
|
1328
1347
|
* The wrapper is idempotent: safe to call on every daemon wake.
|
|
1329
1348
|
*/
|
|
1330
1349
|
function writeAssistantWrapper(resources: LocalInstanceResources): void {
|
|
1331
|
-
|
|
1350
|
+
if (platform() === "win32") {
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
const assistantBinary = compiledSibling("assistant");
|
|
1332
1354
|
if (!isCompiledCli() || !existsSync(assistantBinary)) return;
|
|
1333
1355
|
|
|
1334
1356
|
const workspaceDir = join(resources.instanceDir, ".vellum", "workspace");
|
|
@@ -1391,7 +1413,7 @@ export async function startLocalDaemon(
|
|
|
1391
1413
|
// This covers both the desktop app (VELLUM_DESKTOP_APP) and the case where
|
|
1392
1414
|
// the user runs the compiled CLI directly from the terminal (e.g. via a
|
|
1393
1415
|
// /usr/local/bin/vellum symlink into the app bundle).
|
|
1394
|
-
const daemonBinary =
|
|
1416
|
+
const daemonBinary = compiledSibling("vellum-daemon");
|
|
1395
1417
|
if (isCompiledCli() && existsSync(daemonBinary) && !watch) {
|
|
1396
1418
|
// In watch mode, skip the bundled binary and use source (bun --watch
|
|
1397
1419
|
// only works with source files, not compiled binaries).
|
|
@@ -1443,21 +1465,39 @@ export async function startLocalDaemon(
|
|
|
1443
1465
|
const home = homedir();
|
|
1444
1466
|
const bunBinDir = join(home, ".bun", "bin");
|
|
1445
1467
|
const localBinDir = join(home, ".local", "bin");
|
|
1446
|
-
const basePath =
|
|
1447
|
-
process.env.PATH || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
|
|
1468
|
+
const basePath = process.env.PATH || DEFAULT_EXECUTABLE_PATH;
|
|
1448
1469
|
// The compiled `assistant` ships beside the daemon binary, so its
|
|
1449
1470
|
// directory is what puts `assistant …` on PATH for agent-run commands.
|
|
1450
1471
|
const daemonBinaryDir = dirname(daemonBinary);
|
|
1451
|
-
const assistantBinaryDir = existsSync(
|
|
1472
|
+
const assistantBinaryDir = existsSync(
|
|
1473
|
+
join(daemonBinaryDir, executableName("assistant", platform())),
|
|
1474
|
+
)
|
|
1452
1475
|
? [daemonBinaryDir]
|
|
1453
1476
|
: [];
|
|
1454
1477
|
const extraDirs = [...assistantBinaryDir, bunBinDir, localBinDir].filter(
|
|
1455
|
-
(d) => !basePath.split(
|
|
1478
|
+
(d) => !basePath.split(PATH_DELIMITER).includes(d),
|
|
1456
1479
|
);
|
|
1457
1480
|
const daemonEnv: Record<string, string> = {
|
|
1458
1481
|
HOME: process.env.HOME || home,
|
|
1459
|
-
PATH: [...extraDirs, basePath].filter(Boolean).join(
|
|
1482
|
+
PATH: [...extraDirs, basePath].filter(Boolean).join(PATH_DELIMITER),
|
|
1460
1483
|
};
|
|
1484
|
+
if (platform() === "win32") {
|
|
1485
|
+
for (const key of [
|
|
1486
|
+
"APPDATA",
|
|
1487
|
+
"COMSPEC",
|
|
1488
|
+
"LOCALAPPDATA",
|
|
1489
|
+
"PATHEXT",
|
|
1490
|
+
"SystemDrive",
|
|
1491
|
+
"SystemRoot",
|
|
1492
|
+
"TEMP",
|
|
1493
|
+
"TMP",
|
|
1494
|
+
"USERPROFILE",
|
|
1495
|
+
]) {
|
|
1496
|
+
if (process.env[key]) {
|
|
1497
|
+
daemonEnv[key] = process.env[key];
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1461
1501
|
// Forward optional config env vars the daemon may need.
|
|
1462
1502
|
// `VELLUM_ENVIRONMENT` must be forwarded so the daemon resolves
|
|
1463
1503
|
// env-scoped paths (device ID, platform/guardian tokens, XDG
|
|
@@ -1692,7 +1732,7 @@ export async function startGateway(
|
|
|
1692
1732
|
const runtimeGatewayDir = !watch
|
|
1693
1733
|
? localRuntimeGatewayDir(resources)
|
|
1694
1734
|
: undefined;
|
|
1695
|
-
const gatewayBinary =
|
|
1735
|
+
const gatewayBinary = compiledSibling("vellum-gateway");
|
|
1696
1736
|
if (
|
|
1697
1737
|
!runtimeGatewayDir &&
|
|
1698
1738
|
isCompiledCli() &&
|
package/src/lib/nginx-ingress.ts
CHANGED
|
@@ -7,16 +7,14 @@ import {
|
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
import {
|
|
9
9
|
closeSync,
|
|
10
|
-
existsSync,
|
|
11
10
|
mkdirSync,
|
|
12
11
|
openSync,
|
|
13
12
|
readFileSync,
|
|
14
13
|
rmSync,
|
|
15
14
|
writeFileSync,
|
|
16
15
|
} from "node:fs";
|
|
17
|
-
import { createRequire } from "node:module";
|
|
18
16
|
import { networkInterfaces } from "node:os";
|
|
19
|
-
import {
|
|
17
|
+
import { join } from "node:path";
|
|
20
18
|
|
|
21
19
|
import { cloudAssistantHubUrl } from "@vellumai/environments";
|
|
22
20
|
|
|
@@ -25,12 +23,11 @@ import {
|
|
|
25
23
|
lookupAssistantByIdentifier,
|
|
26
24
|
} from "./assistant-config.js";
|
|
27
25
|
import { getCurrentEnvironment } from "./environments/resolve.js";
|
|
28
|
-
import {
|
|
29
|
-
isAssistantFeatureFlagEnabled,
|
|
30
|
-
WEB_REMOTE_INGRESS_FLAG,
|
|
31
|
-
} from "./feature-flags.js";
|
|
32
26
|
import { waitForDaemonReady } from "./http-client.js";
|
|
33
27
|
import { loadRawConfig, saveRawConfig } from "./ingress-config.js";
|
|
28
|
+
import { findWebDistDir } from "./web-dist.js";
|
|
29
|
+
|
|
30
|
+
export { findWebDistDir } from "./web-dist.js";
|
|
34
31
|
|
|
35
32
|
/**
|
|
36
33
|
* CLI-managed nginx reverse proxy that fronts the gateway as the canonical
|
|
@@ -41,7 +38,6 @@ import { loadRawConfig, saveRawConfig } from "./ingress-config.js";
|
|
|
41
38
|
*/
|
|
42
39
|
|
|
43
40
|
export const DEFAULT_NGINX_INGRESS_PORT = 7840;
|
|
44
|
-
const _require = createRequire(import.meta.url);
|
|
45
41
|
|
|
46
42
|
/** Listen port for nginx ingress, from VELLUM_NGINX_INGRESS_PORT. */
|
|
47
43
|
export function getNginxIngressPort(): number {
|
|
@@ -72,37 +68,6 @@ export function getIngressPaths(workspaceDir: string): IngressPaths {
|
|
|
72
68
|
};
|
|
73
69
|
}
|
|
74
70
|
|
|
75
|
-
/**
|
|
76
|
-
* Locate the pre-built @vellumai/web dist directory.
|
|
77
|
-
*
|
|
78
|
-
* Resolution order:
|
|
79
|
-
* 1. npm-installed package — require.resolve('@vellumai/web/package.json')
|
|
80
|
-
* 2. Source checkout — walk up from cli/ to find clients/web/dist/
|
|
81
|
-
*/
|
|
82
|
-
export function findWebDistDir(): string | null {
|
|
83
|
-
try {
|
|
84
|
-
const pkgPath = _require.resolve("@vellumai/web/package.json");
|
|
85
|
-
const distDir = join(dirname(pkgPath), "dist");
|
|
86
|
-
if (existsSync(join(distDir, "index.html"))) {
|
|
87
|
-
return distDir;
|
|
88
|
-
}
|
|
89
|
-
} catch {
|
|
90
|
-
// Package not installed; try source checkout.
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
let dir = import.meta.dir;
|
|
94
|
-
for (let depth = 0; depth < 8; depth++) {
|
|
95
|
-
const candidate = join(dir, "clients", "web", "dist", "index.html");
|
|
96
|
-
if (existsSync(candidate)) {
|
|
97
|
-
return dirname(candidate);
|
|
98
|
-
}
|
|
99
|
-
const parent = dirname(dir);
|
|
100
|
-
if (parent === dir) break;
|
|
101
|
-
dir = parent;
|
|
102
|
-
}
|
|
103
|
-
return null;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
71
|
function nginxQuoted(value: string, label: string): string {
|
|
107
72
|
if (/[\u0000-\u001f\u007f]/.test(value)) {
|
|
108
73
|
throw new Error(`${label} contains a control character`);
|
|
@@ -125,6 +90,7 @@ function gatewayProxyBlock(gatewayPort: number): string {
|
|
|
125
90
|
proxy_read_timeout 1h;
|
|
126
91
|
proxy_set_header Host $host;
|
|
127
92
|
proxy_set_header X-Vellum-Edge-Forwarded "1";
|
|
93
|
+
proxy_set_header X-Vellum-Client-Ip $vellum_edge_client_ip;
|
|
128
94
|
proxy_set_header Upgrade $http_upgrade;
|
|
129
95
|
proxy_set_header Connection $connection_upgrade;`;
|
|
130
96
|
}
|
|
@@ -147,6 +113,12 @@ const DENYLIST_LOCATIONS = ` location = /auth/token { return 404; }
|
|
|
147
113
|
location = /v1/guardian/init/ { return 404; }
|
|
148
114
|
location = /v1/guardian/reset-bootstrap { return 404; }
|
|
149
115
|
location = /v1/guardian/reset-bootstrap/ { return 404; }
|
|
116
|
+
location = /v1/remote-web/pairing-requests { return 404; }
|
|
117
|
+
location = /v1/remote-web/pairing-requests/ { return 404; }
|
|
118
|
+
location = /v1/remote-web/pairing-requests/approve { return 404; }
|
|
119
|
+
location = /v1/remote-web/pairing-requests/approve/ { return 404; }
|
|
120
|
+
location = /v1/remote-web/pairing-requests/deny { return 404; }
|
|
121
|
+
location = /v1/remote-web/pairing-requests/deny/ { return 404; }
|
|
150
122
|
location = /v1/remote-web/pairing-verification { return 404; }
|
|
151
123
|
location = /v1/remote-web/pairing-verification/ { return 404; }
|
|
152
124
|
location ^~ /assistant/__local/ { return 404; }
|
|
@@ -193,7 +165,7 @@ function remoteWebIngressConfig(
|
|
|
193
165
|
* fingerprint matches, so this must change whenever the generated index or
|
|
194
166
|
* nginx template does.
|
|
195
167
|
*/
|
|
196
|
-
const EDGE_TEMPLATE_VERSION =
|
|
168
|
+
const EDGE_TEMPLATE_VERSION = 3;
|
|
197
169
|
|
|
198
170
|
/**
|
|
199
171
|
* Stable fingerprint of the SPA config injected into the served index and
|
|
@@ -309,6 +281,19 @@ http {
|
|
|
309
281
|
"" close;
|
|
310
282
|
}
|
|
311
283
|
|
|
284
|
+
# Edge-observed client address, stamped onto every proxied request as
|
|
285
|
+
# X-Vellum-Client-Ip. proxy_set_header overwrites any inbound value, so a
|
|
286
|
+
# remote client cannot smuggle one. Every caller reaches this loopback-only
|
|
287
|
+
# listener through the TLS-terminating front (tunnel agent), so the raw peer
|
|
288
|
+
# is always 127.0.0.1; the front records the real client as the RIGHTMOST
|
|
289
|
+
# X-Forwarded-For entry (ngrok/cloudflared append, tailscale serve sets it),
|
|
290
|
+
# which the remote client cannot control. Fall back to the raw peer when the
|
|
291
|
+
# front sets no X-Forwarded-For.
|
|
292
|
+
map $http_x_forwarded_for $vellum_edge_client_ip {
|
|
293
|
+
default $remote_addr;
|
|
294
|
+
"~,?\\s*(?<vellum_last_xff>[^,\\s]+)\\s*$" $vellum_last_xff;
|
|
295
|
+
}
|
|
296
|
+
|
|
312
297
|
server {
|
|
313
298
|
listen 127.0.0.1:${opts.listenPort};
|
|
314
299
|
${ipv6Listen} client_max_body_size 512m;
|
|
@@ -910,50 +895,6 @@ export async function startRemoteWebIngress(opts: {
|
|
|
910
895
|
return rollback("port-conflict");
|
|
911
896
|
}
|
|
912
897
|
|
|
913
|
-
/** Retry policy for the `web-remote-ingress` flag lookup. */
|
|
914
|
-
export interface FlagRetryPolicy {
|
|
915
|
-
attempts: number;
|
|
916
|
-
intervalMs: number;
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
/**
|
|
920
|
-
* Resolve the edge mode for an assistant: the `web-remote-ingress` flag selects
|
|
921
|
-
* the SPA edge when enabled and the webhooks-only edge when disabled. The
|
|
922
|
-
* lookup requires a reachable assistant; `flagRetry` rides out a gateway that
|
|
923
|
-
* is still starting by retrying thrown lookups (a resolved `false` is a real
|
|
924
|
-
* answer, not a retry). When the budget is spent the last error throws with a
|
|
925
|
-
* wake hint.
|
|
926
|
-
*/
|
|
927
|
-
async function resolveEdgeIncludesWebApp(
|
|
928
|
-
assistantId: string,
|
|
929
|
-
gatewayPort: number,
|
|
930
|
-
flagRetry?: FlagRetryPolicy,
|
|
931
|
-
): Promise<boolean> {
|
|
932
|
-
const attempts = Math.max(1, flagRetry?.attempts ?? 1);
|
|
933
|
-
let lastError: unknown;
|
|
934
|
-
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
935
|
-
try {
|
|
936
|
-
return await isAssistantFeatureFlagEnabled(
|
|
937
|
-
assistantId,
|
|
938
|
-
WEB_REMOTE_INGRESS_FLAG,
|
|
939
|
-
{ runtimeUrl: `http://127.0.0.1:${gatewayPort}` },
|
|
940
|
-
);
|
|
941
|
-
} catch (err) {
|
|
942
|
-
lastError = err;
|
|
943
|
-
if (attempt < attempts) {
|
|
944
|
-
await new Promise((resolve) =>
|
|
945
|
-
setTimeout(resolve, flagRetry?.intervalMs ?? 0),
|
|
946
|
-
);
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
throw new Error(
|
|
951
|
-
`Could not verify the \`${WEB_REMOTE_INGRESS_FLAG}\` feature flag before starting the edge. Is the assistant running? Try \`vellum wake\` and retry. ${
|
|
952
|
-
lastError instanceof Error ? lastError.message : String(lastError)
|
|
953
|
-
}`,
|
|
954
|
-
);
|
|
955
|
-
}
|
|
956
|
-
|
|
957
898
|
/**
|
|
958
899
|
* Display name recorded for the assistant in the CLI lockfile; undefined when
|
|
959
900
|
* no entry matches, so the served config omits the label rather than guessing.
|
|
@@ -983,13 +924,11 @@ export interface TunnelEdge {
|
|
|
983
924
|
* Bring up the nginx edge as the canonical tunnel target and return the listen
|
|
984
925
|
* port a tunnel should front.
|
|
985
926
|
*
|
|
986
|
-
* The
|
|
987
|
-
* proxy, disabled: webhooks-only proxy); an entry without an assistant id
|
|
988
|
-
* cannot have the flag verified and gets the webhooks-only edge. The resolved
|
|
927
|
+
* The edge always serves the SPA alongside the gateway proxy. The requested
|
|
989
928
|
* mode is always delegated to `startRemoteWebIngress`, which reuses a running
|
|
990
929
|
* edge that already serves that mode, gateway port, and injected SPA config
|
|
991
930
|
* and restarts one that drifted in any respect, so the returned port always
|
|
992
|
-
* fronts the
|
|
931
|
+
* fronts the requested config. `started` is false when a matching edge was
|
|
993
932
|
* reused; a drifted edge that survives the restart attempt throws rather than
|
|
994
933
|
* reporting the wrong config. Failures throw with actionable install or
|
|
995
934
|
* diagnostic text.
|
|
@@ -998,8 +937,6 @@ export async function ensureTunnelEdge(opts: {
|
|
|
998
937
|
assistantId: string | undefined;
|
|
999
938
|
workspaceDir: string;
|
|
1000
939
|
gatewayPort: number;
|
|
1001
|
-
/** Retries thrown flag lookups (e.g. a still-starting gateway); default one attempt. */
|
|
1002
|
-
flagRetry?: FlagRetryPolicy;
|
|
1003
940
|
/** Forwarded to `startRemoteWebIngress` for caller progress output. */
|
|
1004
941
|
onStarting?: (info: {
|
|
1005
942
|
version: string;
|
|
@@ -1007,23 +944,14 @@ export async function ensureTunnelEdge(opts: {
|
|
|
1007
944
|
listenPort: number;
|
|
1008
945
|
}) => void;
|
|
1009
946
|
}): Promise<TunnelEdge> {
|
|
1010
|
-
const
|
|
1011
|
-
?
|
|
1012
|
-
|
|
1013
|
-
opts.gatewayPort,
|
|
1014
|
-
opts.flagRetry,
|
|
1015
|
-
)
|
|
1016
|
-
: false;
|
|
1017
|
-
|
|
1018
|
-
const assistantName =
|
|
1019
|
-
includeWebApp && opts.assistantId
|
|
1020
|
-
? lockfileAssistantName(opts.assistantId)
|
|
1021
|
-
: undefined;
|
|
947
|
+
const assistantName = opts.assistantId
|
|
948
|
+
? lockfileAssistantName(opts.assistantId)
|
|
949
|
+
: undefined;
|
|
1022
950
|
|
|
1023
951
|
const result = await startRemoteWebIngress({
|
|
1024
952
|
workspaceDir: opts.workspaceDir,
|
|
1025
953
|
gatewayPort: opts.gatewayPort,
|
|
1026
|
-
includeWebApp,
|
|
954
|
+
includeWebApp: true,
|
|
1027
955
|
...(assistantName ? { assistantName } : {}),
|
|
1028
956
|
...(opts.onStarting ? { onStarting: opts.onStarting } : {}),
|
|
1029
957
|
});
|
|
@@ -1033,16 +961,15 @@ export async function ensureTunnelEdge(opts: {
|
|
|
1033
961
|
return {
|
|
1034
962
|
port: result.listenPort,
|
|
1035
963
|
started: true,
|
|
1036
|
-
includesWebApp:
|
|
964
|
+
includesWebApp: true,
|
|
1037
965
|
};
|
|
1038
966
|
case "already-running": {
|
|
1039
967
|
// `already-running` also covers a drifted edge whose restart failed, so
|
|
1040
968
|
// trust the recorded state it carries over the requested config.
|
|
1041
|
-
if (result.includeWebApp
|
|
1042
|
-
const describe = (spa: boolean) => (spa ? "web app" : "webhooks-only");
|
|
969
|
+
if (!result.includeWebApp) {
|
|
1043
970
|
throw new Error(
|
|
1044
|
-
|
|
1045
|
-
|
|
971
|
+
"The nginx edge is still running in webhooks-only mode " +
|
|
972
|
+
"and could not be restarted in web app mode. " +
|
|
1046
973
|
"Run `vellum nginx-ingress down` and retry.",
|
|
1047
974
|
);
|
|
1048
975
|
}
|
|
@@ -150,6 +150,9 @@ describe("detectOrphanedProcesses", () => {
|
|
|
150
150
|
"103 1 vellum --plain logs foo",
|
|
151
151
|
"104 1 bun /x/bin/vellum-gateway --port 7830",
|
|
152
152
|
"105 1 vellum hatch",
|
|
153
|
+
"106 1 node /opt/unrelated-service/daemon/main.ts",
|
|
154
|
+
"107 1 node ./tools daemon start",
|
|
155
|
+
"108 1 node /opt/VELLUM/daemon/main.ts",
|
|
153
156
|
].join("\n");
|
|
154
157
|
mock.module("./step-runner", () => ({
|
|
155
158
|
...realStepRunner,
|