@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.
Files changed (41) hide show
  1. package/node_modules/@vellumai/local-mode/src/__tests__/unpair.test.ts +33 -0
  2. package/node_modules/@vellumai/local-mode/src/index.ts +3 -0
  3. package/node_modules/@vellumai/local-mode/src/lockfile-lock.test.ts +165 -0
  4. package/node_modules/@vellumai/local-mode/src/lockfile-lock.ts +156 -0
  5. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +249 -8
  6. package/node_modules/@vellumai/local-mode/src/lockfile.ts +186 -68
  7. package/node_modules/@vellumai/local-mode/src/unpair.ts +18 -0
  8. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  9. package/node_modules/@vellumai/service-contracts/src/__tests__/url-normalization.test.ts +135 -0
  10. package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  11. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  12. package/node_modules/@vellumai/service-contracts/src/remote-web-pairing.ts +60 -0
  13. package/node_modules/@vellumai/service-contracts/src/url-normalization.ts +107 -0
  14. package/package.json +1 -1
  15. package/src/__tests__/assistant-config.test.ts +35 -0
  16. package/src/__tests__/nginx-ingress-command.test.ts +4 -23
  17. package/src/__tests__/nginx-ingress.test.ts +59 -215
  18. package/src/__tests__/pair.test.ts +11 -197
  19. package/src/__tests__/retire-archive.test.ts +13 -1
  20. package/src/__tests__/retire-local.test.ts +58 -4
  21. package/src/__tests__/tunnel.test.ts +0 -28
  22. package/src/__tests__/wake.test.ts +91 -69
  23. package/src/__tests__/windows-lifecycle.test.ts +157 -0
  24. package/src/commands/client.ts +9 -31
  25. package/src/commands/nginx-ingress.ts +0 -15
  26. package/src/commands/pair.ts +0 -39
  27. package/src/commands/wake.ts +39 -12
  28. package/src/lib/__tests__/web-dist.test.ts +86 -0
  29. package/src/lib/assistant-config.ts +64 -27
  30. package/src/lib/local.ts +60 -20
  31. package/src/lib/nginx-ingress.ts +35 -108
  32. package/src/lib/orphan-detection.test.ts +3 -0
  33. package/src/lib/orphan-detection.ts +33 -11
  34. package/src/lib/pgrep.ts +20 -2
  35. package/src/lib/process.ts +191 -18
  36. package/src/lib/retire-archive.ts +38 -8
  37. package/src/lib/retire-local.ts +75 -9
  38. package/src/lib/tunnel-edge.ts +14 -22
  39. package/src/lib/web-dist.ts +48 -0
  40. package/src/lib/feature-flags.test.ts +0 -157
  41. 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 writeLockfile(data: LockfileData): void {
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
- writeLockfile(data);
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
- const data = readLockfile();
346
- data.assistants = entries;
347
- writeLockfile(data);
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
- const data = readLockfile();
432
- const entries = (data.assistants ?? []).filter(
433
- (e) => e.assistantId !== assistantId,
434
- );
435
- data.assistants = entries;
436
- // Reassign active assistant if it matches the removed entry
437
- if (data.activeAssistant === assistantId) {
438
- const remaining = entries[0];
439
- if (remaining) {
440
- data.activeAssistant = String(remaining.assistantId);
441
- } else {
442
- delete data.activeAssistant;
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
- writeLockfile(data);
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
- const data = readLockfile();
523
- data.activeAssistant = assistantId;
524
- writeLockfile(data);
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
- const entries = readAssistants().filter(
611
- (e) => e.assistantId !== entry.assistantId,
612
- );
613
- entries.unshift(entry);
614
- writeAssistants(entries);
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 siblingBun = join(dirname(process.execPath), "bun");
144
+ const bunName = executableName("bun", platform());
145
+ const siblingBun = compiledSibling("bun");
134
146
  if (existsSync(siblingBun)) return siblingBun;
135
147
 
136
- const bundledBun = join(dirname(process.execPath), "..", "Resources", "bun");
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", "bun");
156
+ const homeBun = join(homedir(), ".bun", "bin", bunName);
140
157
  if (existsSync(homeBun)) return homeBun;
141
158
 
142
- return "bun";
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 || "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
167
+ const basePath = env.PATH || DEFAULT_EXECUTABLE_PATH;
151
168
  const extraDirs = [
152
- bunPath.includes("/") ? dirname(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(":").includes(dir));
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 = join(dirname(process.execPath), "credential-executor");
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
- const assistantBinary = join(dirname(process.execPath), "assistant");
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 = join(dirname(process.execPath), "vellum-daemon");
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(join(daemonBinaryDir, "assistant"))
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(":").includes(d),
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 = join(dirname(process.execPath), "vellum-gateway");
1735
+ const gatewayBinary = compiledSibling("vellum-gateway");
1696
1736
  if (
1697
1737
  !runtimeGatewayDir &&
1698
1738
  isCompiledCli() &&
@@ -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 { dirname, join } from "node:path";
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 = 2;
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 `web-remote-ingress` flag picks the edge mode (enabled: SPA + gateway
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 flag-resolved config. `started` is false when a matching edge was
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 includeWebApp = opts.assistantId
1011
- ? await resolveEdgeIncludesWebApp(
1012
- opts.assistantId,
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: includeWebApp,
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 !== includeWebApp) {
1042
- const describe = (spa: boolean) => (spa ? "web app" : "webhooks-only");
969
+ if (!result.includeWebApp) {
1043
970
  throw new Error(
1044
- `The nginx edge is still running in ${describe(result.includeWebApp)} mode ` +
1045
- `and could not be restarted in ${describe(includeWebApp)} mode. ` +
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,