@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
@@ -29,16 +29,7 @@ import {
29
29
  startLocalDaemon,
30
30
  startGateway,
31
31
  } from "../lib/local";
32
- import { maybeStartNgrokTunnel } from "../lib/ngrok";
33
- import {
34
- isAssistantFeatureFlagEnabled,
35
- WEB_REMOTE_INGRESS_FLAG,
36
- } from "../lib/feature-flags.js";
37
- import { loadRawConfig } from "../lib/ingress-config.js";
38
- import {
39
- isIngressRunning,
40
- startRemoteWebIngress,
41
- } from "../lib/nginx-ingress.js";
32
+ import { restoreTunnelEdgeAndAutoTunnel } from "../lib/tunnel-edge.js";
42
33
 
43
34
  export async function wake(): Promise<void> {
44
35
  const args = process.argv.slice(3);
@@ -377,9 +368,11 @@ export async function wake(): Promise<void> {
377
368
  }
378
369
  }
379
370
 
380
- // Auto-start ngrok if webhook integrations (e.g. Telegram) are configured.
371
+ // Restore the nginx edge and point the webhook auto-tunnel at it. Non-fatal:
372
+ // a down edge is a degraded remote-web/webhook path, not a broken assistant.
381
373
  const workspaceDir = join(resources.instanceDir, ".vellum", "workspace");
382
- const ngrokChild = await maybeStartNgrokTunnel(
374
+ const ngrokChild = await restoreTunnelEdgeAndAutoTunnel(
375
+ entry.assistantId,
383
376
  resources.gatewayPort,
384
377
  workspaceDir,
385
378
  );
@@ -388,17 +381,6 @@ export async function wake(): Promise<void> {
388
381
  writeFileSync(ngrokPidFile, String(ngrokChild.pid));
389
382
  }
390
383
 
391
- // Restore the nginx web ingress edge when the workspace config still wants
392
- // it. A TLS-terminating front (tailscale serve / tunnel) persists across
393
- // restarts and keeps proxying to the edge's loopback port, but the edge has
394
- // a manual lifecycle — so a routine restart otherwise leaves the self-hosted
395
- // remote-web path dead (502 / blank page) until someone runs it back up.
396
- await restoreWebIngressIfEnabled(
397
- entry.assistantId,
398
- resources.gatewayPort,
399
- workspaceDir,
400
- );
401
-
402
384
  if (daemonMigrationsFailed) {
403
385
  console.log(
404
386
  "Assistant database migrations FAILED — DB-backed routes will return 503 until the assistant is restarted. Check the daemon logs.",
@@ -424,143 +406,3 @@ export async function wake(): Promise<void> {
424
406
  });
425
407
  }
426
408
  }
427
-
428
- /**
429
- * Retry policy for the flag probe that gates the web-ingress restore. The
430
- * gateway has typically been up for milliseconds at this point and answers
431
- * `503 {"status":"starting"}` (or refuses connections) until its startup
432
- * completes, so a single probe races it. Mutable so tests can shrink the
433
- * window.
434
- */
435
- export const WEB_INGRESS_FLAG_RETRY = {
436
- attempts: 15,
437
- intervalMs: 2_000,
438
- };
439
-
440
- /**
441
- * Probe the `web-remote-ingress` flag, riding out the gateway's startup
442
- * window: transient failures retry on an interval until the attempt budget is
443
- * spent, then the last error propagates to the caller's warn path.
444
- */
445
- async function verifyWebIngressFlagWithRetry(
446
- assistantId: string,
447
- gatewayPort: number,
448
- ): Promise<boolean> {
449
- let lastError: unknown;
450
- for (let attempt = 1; attempt <= WEB_INGRESS_FLAG_RETRY.attempts; attempt++) {
451
- try {
452
- return await isAssistantFeatureFlagEnabled(
453
- assistantId,
454
- WEB_REMOTE_INGRESS_FLAG,
455
- { runtimeUrl: `http://127.0.0.1:${gatewayPort}` },
456
- );
457
- } catch (err) {
458
- lastError = err;
459
- if (attempt === 1) {
460
- console.log(
461
- " Waiting for the gateway before restoring the web ingress edge...",
462
- );
463
- }
464
- if (attempt < WEB_INGRESS_FLAG_RETRY.attempts) {
465
- await new Promise((resolve) =>
466
- setTimeout(resolve, WEB_INGRESS_FLAG_RETRY.intervalMs),
467
- );
468
- }
469
- }
470
- }
471
- throw lastError;
472
- }
473
-
474
- /**
475
- * Bring the nginx web ingress edge back up after a wake when the workspace
476
- * config still wants it. Only restores when ingress is explicitly enabled with
477
- * a saved public URL and the `web-remote-ingress` flag is on — the edge is
478
- * pointless without the flag, so a disabled flag skips quietly with a hint.
479
- *
480
- * Reads the same workspace config the edge serves. Any failure to restore
481
- * warns with the manual `vellum nginx-ingress up` command and never fails the
482
- * wake — a down edge is a degraded remote-web path, not a broken assistant.
483
- */
484
- async function restoreWebIngressIfEnabled(
485
- assistantId: string,
486
- gatewayPort: number,
487
- workspaceDir: string,
488
- ): Promise<void> {
489
- const config = loadRawConfig(workspaceDir);
490
- const ingress = config.ingress as
491
- | { enabled?: unknown; publicBaseUrl?: unknown }
492
- | undefined;
493
- const enabled = ingress?.enabled === true;
494
- const publicBaseUrl =
495
- typeof ingress?.publicBaseUrl === "string"
496
- ? ingress.publicBaseUrl.trim()
497
- : "";
498
- if (!enabled || !publicBaseUrl) {
499
- return;
500
- }
501
-
502
- // The edge already survived (or was manually brought back) — nothing to do.
503
- if (isIngressRunning(workspaceDir)) {
504
- return;
505
- }
506
-
507
- let flagEnabled: boolean;
508
- try {
509
- flagEnabled = await verifyWebIngressFlagWithRetry(assistantId, gatewayPort);
510
- } catch (err) {
511
- console.warn(
512
- ` Could not verify the \`${WEB_REMOTE_INGRESS_FLAG}\` flag to restore the web ingress edge; leaving it down. Bring it up manually with \`vellum nginx-ingress up\`. ${
513
- err instanceof Error ? err.message : String(err)
514
- }`,
515
- );
516
- return;
517
- }
518
- if (!flagEnabled) {
519
- console.log(
520
- ` Web ingress edge not restored: the \`${WEB_REMOTE_INGRESS_FLAG}\` flag is off. Enable it and run \`vellum nginx-ingress up\` to serve remote web access.`,
521
- );
522
- return;
523
- }
524
-
525
- try {
526
- const result = await startRemoteWebIngress({
527
- workspaceDir,
528
- gatewayPort,
529
- onStarting: ({ listenPort }) => {
530
- console.log(
531
- `Restoring web ingress edge on 127.0.0.1:${listenPort} (ingress.enabled)...`,
532
- );
533
- },
534
- });
535
- switch (result.status) {
536
- case "started":
537
- console.log(
538
- ` Web ingress edge running: http://127.0.0.1:${result.listenPort}`,
539
- );
540
- break;
541
- case "already-running":
542
- break;
543
- case "nginx-missing":
544
- console.warn(
545
- " Could not restore the web ingress edge: nginx is not installed. Bring it up manually with `vellum nginx-ingress up`.",
546
- );
547
- break;
548
- case "web-dist-missing":
549
- console.warn(
550
- " Could not restore the web ingress edge: built web assets were not found. Bring it up manually with `vellum nginx-ingress up`.",
551
- );
552
- break;
553
- case "unreachable":
554
- console.warn(
555
- ` Web ingress edge did not become reachable on 127.0.0.1:${result.listenPort}; check ${result.logPath}. Bring it up manually with \`vellum nginx-ingress up\`.`,
556
- );
557
- break;
558
- }
559
- } catch (err) {
560
- console.warn(
561
- ` Failed to restore the web ingress edge: ${
562
- err instanceof Error ? err.message : String(err)
563
- }. Bring it up manually with \`vellum nginx-ingress up\`.`,
564
- );
565
- }
566
- }
package/src/index.ts CHANGED
@@ -106,7 +106,7 @@ function printHelp(): void {
106
106
  console.log(" gateway Gateway management commands");
107
107
  console.log(" hatch Create a new assistant instance");
108
108
  console.log(
109
- " nginx-ingress Manage the nginx proxy fronting the gateway for web access [beta]",
109
+ " nginx-ingress Manage the nginx edge that `vellum tunnel` fronts [beta]",
110
110
  );
111
111
  console.log(" logs View logs from an assistant instance");
112
112
  console.log(" login Log in to the Vellum platform");
@@ -9,7 +9,7 @@ import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
10
  import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
11
11
 
12
- import { startCes } from "../local.js";
12
+ import { resolveCesSocketPath, startCes } from "../local.js";
13
13
 
14
14
  // ---------------------------------------------------------------------------
15
15
  // Mocks
@@ -134,4 +134,13 @@ describe("startCes", () => {
134
134
  const cesPidFile = join(vellumDir, "ces.pid");
135
135
  expect(existsSync(cesPidFile)).toBe(true);
136
136
  }, 15_000);
137
+ test("uses a Windows named pipe for the local CES seam", () => {
138
+ const resources = {
139
+ instanceDir: "C:\\Users\\Example\\Vellum\\assistant-123",
140
+ } as Parameters<typeof resolveCesSocketPath>[0];
141
+
142
+ const socketPath = resolveCesSocketPath(resources, "win32");
143
+ expect(socketPath.startsWith("\\\\.\\pipe\\vellum-ces-")).toBe(true);
144
+ expect(socketPath).not.toContain("Example");
145
+ });
137
146
  });
@@ -6,7 +6,6 @@ import {
6
6
  getDefaultWorkspaceDir,
7
7
  saveIngressUrl,
8
8
  } from "./ingress-config.js";
9
- import { resolveTunnelTargetPort } from "./nginx-ingress.js";
10
9
 
11
10
  // ── Cloudflare Tunnel ─────────────────────────────────────────────────────────
12
11
 
@@ -116,7 +115,7 @@ export function waitForCloudflareTunnelUrl(
116
115
  /**
117
116
  * Run the cloudflared quick-tunnel workflow:
118
117
  * 1. Verify cloudflared is installed.
119
- * 2. Start a quick tunnel pointing at the gateway port.
118
+ * 2. Start a quick tunnel pointing at the local edge port.
120
119
  * 3. Parse the public URL from cloudflared output.
121
120
  * 4. Persist the URL to the workspace config as the ingress base URL.
122
121
  * 5. Block until the process exits or the user presses Ctrl+C.
@@ -125,12 +124,10 @@ export function waitForCloudflareTunnelUrl(
125
124
  * No Cloudflare account is required — quick tunnels are free and ephemeral.
126
125
  */
127
126
  export interface RunCloudflareTunnelOptions {
128
- /** Gateway port to forward. Defaults to the global GATEWAY_PORT. */
127
+ /** Local edge port to forward. Defaults to the global GATEWAY_PORT. */
129
128
  port?: number;
130
129
  /** Workspace directory for config read/write. Defaults to ~/.vellum/workspace. */
131
130
  workspaceDir?: string;
132
- /** Prefer nginx ingress over the gateway port when it is running. */
133
- preferNginxIngress?: boolean;
134
131
  /** Lockfile entry to mirror the ingress URL onto (`ingressUrl`). */
135
132
  assistantId?: string;
136
133
  }
@@ -156,17 +153,7 @@ export async function runCloudflareTunnel(
156
153
  console.log(`Using ${version}`);
157
154
 
158
155
  const workspaceDir = opts.workspaceDir ?? getDefaultWorkspaceDir();
159
- const gatewayPort = opts.port ?? GATEWAY_PORT;
160
- const { port, viaIngress } = resolveTunnelTargetPort(
161
- workspaceDir,
162
- gatewayPort,
163
- { preferNginxIngress: opts.preferNginxIngress === true },
164
- );
165
- if (viaIngress) {
166
- console.log(
167
- `nginx ingress detected — tunneling to it on 127.0.0.1:${port}.`,
168
- );
169
- }
156
+ const port = opts.port ?? GATEWAY_PORT;
170
157
 
171
158
  console.log(`Starting cloudflared quick tunnel to localhost:${port}...`);
172
159
  console.log("No Cloudflare account required — quick tunnels are free.");
@@ -37,6 +37,7 @@ afterAll(() => {
37
37
  // mock.module() calls above.
38
38
  const {
39
39
  getConfigDir,
40
+ getConfigDirs,
40
41
  getDefaultPorts,
41
42
  getLockfilePath,
42
43
  getLockfilePaths,
@@ -57,7 +58,7 @@ const dev: EnvironmentDefinition = {
57
58
  webUrl: "https://dev-assistant.vellum.ai",
58
59
  };
59
60
 
60
- const XDG_ENV_VARS = ["XDG_DATA_HOME", "XDG_CONFIG_HOME"] as const;
61
+ const XDG_ENV_VARS = ["XDG_DATA_HOME", "XDG_CONFIG_HOME", "APPDATA"] as const;
61
62
 
62
63
  describe("path helpers", () => {
63
64
  let savedEnv: Record<string, string | undefined>;
@@ -81,6 +82,24 @@ describe("path helpers", () => {
81
82
  });
82
83
 
83
84
  describe("getConfigDir", () => {
85
+ test("Windows keeps XDG config as a legacy read location", () => {
86
+ const descriptor = Object.getOwnPropertyDescriptor(process, "platform")!;
87
+ try {
88
+ Object.defineProperty(process, "platform", {
89
+ ...descriptor,
90
+ value: "win32",
91
+ });
92
+ process.env.APPDATA = "C:\\AppData";
93
+ process.env.XDG_CONFIG_HOME = "D:\\Legacy";
94
+ expect(getConfigDirs(dev)).toEqual([
95
+ "C:\\AppData\\vellum-dev",
96
+ "D:\\Legacy\\vellum-dev",
97
+ ]);
98
+ } finally {
99
+ Object.defineProperty(process, "platform", descriptor);
100
+ }
101
+ });
102
+
84
103
  test("production returns ~/.config/vellum/", () => {
85
104
  expect(getConfigDir(prod)).toBe(join(TEST_HOME, ".config", "vellum"));
86
105
  });
@@ -2,18 +2,14 @@ import { homedir } from "os";
2
2
  import { join } from "path";
3
3
 
4
4
  import type { EnvironmentDefinition, PortMap } from "@vellumai/environments";
5
-
6
- const PRODUCTION_ENVIRONMENT_NAME = "production";
7
-
8
- /**
9
- * Production lockfile filenames in priority order. The current name is
10
- * `.vellum.lock.json`; `.vellum.lockfile.json` is the legacy name kept for
11
- * backward compatibility with installs that predate the rename.
12
- */
13
- const PRODUCTION_LOCKFILE_NAMES = [
14
- ".vellum.lock.json",
15
- ".vellum.lockfile.json",
16
- ] as const;
5
+ import {
6
+ resolveAssistantsDir,
7
+ resolveConfigDirPaths,
8
+ resolveLockfilePaths,
9
+ resolveLogDir,
10
+ resolveRuntimeDir,
11
+ type LocalPathOptions,
12
+ } from "@vellumai/local-mode";
17
13
 
18
14
  const DEFAULT_PORTS: Readonly<PortMap> = {
19
15
  daemon: 7821,
@@ -30,11 +26,11 @@ const DEFAULT_PORTS: Readonly<PortMap> = {
30
26
  * non-production environments use `$XDG_CONFIG_HOME/vellum-<env>/`.
31
27
  */
32
28
  export function getConfigDir(env: EnvironmentDefinition): string {
33
- if (env.configDirOverride) return env.configDirOverride;
34
- if (env.name === PRODUCTION_ENVIRONMENT_NAME) {
35
- return join(xdgConfigHome(), "vellum");
36
- }
37
- return join(xdgConfigHome(), `vellum-${env.name}`);
29
+ return getConfigDirs(env)[0]!;
30
+ }
31
+
32
+ export function getConfigDirs(env: EnvironmentDefinition): string[] {
33
+ return resolveConfigDirPaths(process.env, localPathOptions(env));
38
34
  }
39
35
 
40
36
  /**
@@ -56,12 +52,7 @@ export function getConfigDir(env: EnvironmentDefinition): string {
56
52
  * both production and non-production environments.
57
53
  */
58
54
  export function getLockfilePaths(env: EnvironmentDefinition): string[] {
59
- if (env.name === PRODUCTION_ENVIRONMENT_NAME) {
60
- const dir = env.lockfileDirOverride ?? homedir();
61
- return PRODUCTION_LOCKFILE_NAMES.map((name) => join(dir, name));
62
- }
63
- const dir = env.lockfileDirOverride ?? getConfigDir(env);
64
- return [join(dir, "lockfile.json")];
55
+ return resolveLockfilePaths(process.env, localPathOptions(env));
65
56
  }
66
57
 
67
58
  /**
@@ -79,10 +70,7 @@ export function getLockfilePath(env: EnvironmentDefinition): string {
79
70
  * `~/.local/share/vellum-<env>/assistants/`.
80
71
  */
81
72
  export function getMultiInstanceDir(env: EnvironmentDefinition): string {
82
- if (env.name === PRODUCTION_ENVIRONMENT_NAME) {
83
- return join(xdgDataHome(), "vellum", "assistants");
84
- }
85
- return join(xdgDataHome(), `vellum-${env.name}`, "assistants");
73
+ return resolveAssistantsDir(process.env, localPathOptions(env));
86
74
  }
87
75
 
88
76
  /**
@@ -104,10 +92,11 @@ export function getDefaultPorts(env: EnvironmentDefinition): PortMap {
104
92
  * use `~/.local/share/vellum-<env>/`.
105
93
  */
106
94
  export function getStateDir(env: EnvironmentDefinition): string {
107
- if (env.name === PRODUCTION_ENVIRONMENT_NAME) {
108
- return join(xdgDataHome(), "vellum");
109
- }
110
- return join(xdgDataHome(), `vellum-${env.name}`);
95
+ return resolveRuntimeDir(process.env, localPathOptions(env));
96
+ }
97
+
98
+ export function getLogDir(env: EnvironmentDefinition): string {
99
+ return resolveLogDir(process.env, localPathOptions(env));
111
100
  }
112
101
 
113
102
  /**
@@ -133,18 +122,17 @@ export function getInputHistoryPath(): string {
133
122
  export const ASSISTANT_INTERNAL_PORT = DEFAULT_PORTS.daemon;
134
123
  export const GATEWAY_INTERNAL_PORT = DEFAULT_PORTS.gateway;
135
124
 
136
- function xdgDataHome(): string {
137
- return (
138
- process.env.XDG_DATA_HOME?.trim() || join(homedir(), ".local", "share")
139
- );
140
- }
141
-
142
- function xdgConfigHome(): string {
143
- return process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
144
- }
145
-
146
125
  function xdgStateHome(): string {
147
126
  return (
148
127
  process.env.XDG_STATE_HOME?.trim() || join(homedir(), ".local", "state")
149
128
  );
150
129
  }
130
+
131
+ function localPathOptions(env: EnvironmentDefinition): LocalPathOptions {
132
+ return {
133
+ homeDir: homedir(),
134
+ environmentName: env.name,
135
+ configDirOverride: env.configDirOverride,
136
+ lockfileDirOverride: env.lockfileDirOverride,
137
+ };
138
+ }
@@ -4,6 +4,7 @@ import { dirname } from "path";
4
4
  import { SEEDS, type EnvironmentDefinition } from "@vellumai/environments";
5
5
  import {
6
6
  defaultEnvironmentFilePath,
7
+ defaultEnvironmentFilePaths,
7
8
  readDefaultEnvironment as readPersistedDefaultEnvironment,
8
9
  } from "@vellumai/local-mode";
9
10
 
@@ -30,11 +31,12 @@ export function writeDefaultEnvironment(name: string): void {
30
31
  * Remove the persisted default environment file, falling back to production.
31
32
  */
32
33
  export function clearDefaultEnvironment(): void {
33
- const filePath = defaultEnvironmentFilePath(process.env);
34
- try {
35
- unlinkSync(filePath);
36
- } catch {
37
- // Already absent — nothing to do.
34
+ for (const filePath of defaultEnvironmentFilePaths(process.env)) {
35
+ try {
36
+ unlinkSync(filePath);
37
+ } catch {
38
+ // Already absent.
39
+ }
38
40
  }
39
41
  }
40
42
 
@@ -17,27 +17,21 @@ import { platform } from "os";
17
17
  import { dirname, join } from "path";
18
18
 
19
19
  import { SEEDS } from "@vellumai/environments";
20
- import { guardianTokenPath, resolveConfigDir } from "@vellumai/local-mode";
21
-
22
- import { getConfigDir } from "./environments/paths.js";
20
+ import {
21
+ guardianTokenPath,
22
+ isConfidentialRefreshUrl,
23
+ resolveConfigDir,
24
+ saveGuardianToken as writeGuardianToken,
25
+ type GuardianTokenData,
26
+ } from "@vellumai/local-mode";
27
+
28
+ import { getConfigDir, getConfigDirs } from "./environments/paths.js";
23
29
  import { getCurrentEnvironment } from "./environments/resolve.js";
24
30
  import { loopbackSafeFetch } from "./loopback-fetch.js";
25
31
 
26
32
  const DEVICE_ID_SALT = "vellum-assistant-host-id";
27
33
 
28
- export interface GuardianTokenData {
29
- guardianPrincipalId: string;
30
- accessToken: string;
31
- /** ISO date string or epoch-ms number as returned by the gateway. */
32
- accessTokenExpiresAt: string | number;
33
- refreshToken: string;
34
- /** ISO date string or epoch-ms number as returned by the gateway. */
35
- refreshTokenExpiresAt: string | number;
36
- refreshAfter: string;
37
- isNew: boolean;
38
- deviceId: string;
39
- leasedAt: string;
40
- }
34
+ export type { GuardianTokenData };
41
35
 
42
36
  function getGuardianTokenPath(assistantId: string): string {
43
37
  // Resolve via the shared @vellumai/local-mode resolver — the same one every
@@ -165,28 +159,24 @@ export function computeDeviceId(): string {
165
159
  export function loadGuardianToken(
166
160
  assistantId: string,
167
161
  ): GuardianTokenData | null {
168
- const tokenPath = getGuardianTokenPath(assistantId);
169
- try {
170
- const raw = readFileSync(tokenPath, "utf-8");
171
- return JSON.parse(raw) as GuardianTokenData;
172
- } catch {
173
- return null;
162
+ for (const dir of getConfigDirs(getCurrentEnvironment())) {
163
+ try {
164
+ const raw = readFileSync(guardianTokenPath(dir, assistantId), "utf-8");
165
+ return JSON.parse(raw) as GuardianTokenData;
166
+ } catch {
167
+ // Try the next compatible location.
168
+ }
174
169
  }
170
+ return null;
175
171
  }
176
172
 
177
173
  export function saveGuardianToken(
178
174
  assistantId: string,
179
175
  data: GuardianTokenData,
180
176
  ): void {
181
- const tokenPath = getGuardianTokenPath(assistantId);
182
- const dir = dirname(tokenPath);
183
- if (!existsSync(dir)) {
184
- mkdirSync(dir, { recursive: true, mode: 0o700 });
185
- }
186
- writeFileSync(tokenPath, JSON.stringify(data, null, 2) + "\n", {
187
- mode: 0o600,
188
- });
189
- chmodSync(tokenPath, 0o600);
177
+ // Delegates to the shared @vellumai/local-mode writer (0700 dir, 0600 file)
178
+ // with the same env-resolved config dir the path resolver above uses.
179
+ writeGuardianToken(resolveConfigDir(process.env), assistantId, data);
190
180
  }
191
181
 
192
182
  /** Abort the refresh POST if the gateway is slow/unreachable (it's now on the
@@ -256,37 +246,6 @@ function releaseRefreshLock(lockPath: string): void {
256
246
  * process already rotated it while we waited, we return that fresh token
257
247
  * instead of replaying our now-stale refresh token.
258
248
  */
259
- /**
260
- * The guardian refresh token is long-lived and replayable, so we only transmit
261
- * it over a confidential channel: HTTPS, or a loopback host (local dev, or a
262
- * same-host reverse proxy / tunnel agent). Refreshing against a non-loopback
263
- * plaintext `http://` URL is refused — an on-path attacker could otherwise
264
- * capture the refresh token and rotate it into fresh credentials.
265
- *
266
- * A user-chosen malicious `https://` destination is intentionally out of scope:
267
- * HTTPS protects the channel, and the access token already goes wherever the
268
- * configured URL points. This guard targets the plaintext-interception vector.
269
- */
270
- function isLoopbackHostname(hostname: string): boolean {
271
- const h = hostname.toLowerCase();
272
- return (
273
- h === "localhost" ||
274
- h === "::1" ||
275
- h === "[::1]" ||
276
- h === "0:0:0:0:0:0:0:1" ||
277
- /^127(?:\.\d{1,3}){3}$/.test(h)
278
- );
279
- }
280
-
281
- function isConfidentialRefreshUrl(gatewayUrl: string): boolean {
282
- try {
283
- const url = new URL(gatewayUrl);
284
- return url.protocol === "https:" || isLoopbackHostname(url.hostname);
285
- } catch {
286
- return false;
287
- }
288
- }
289
-
290
249
  /**
291
250
  * True when a stored guardian token has reached its renewal point — now is
292
251
  * at/after `refreshAfter` (preferred) or `accessTokenExpiresAt`. Used to gate
@@ -380,6 +339,7 @@ export async function refreshGuardianToken(
380
339
  isNew: false,
381
340
  deviceId: tokenData.deviceId,
382
341
  leasedAt: new Date().toISOString(),
342
+ pairedGatewayUrl: tokenData.pairedGatewayUrl,
383
343
  };
384
344
  saveGuardianToken(assistantId, refreshed);
385
345
  return refreshed;
@@ -85,6 +85,31 @@ export function saveIngressUrl(
85
85
  }
86
86
  }
87
87
 
88
+ /** Persist a reserved ngrok domain under `ingress.ngrok.domain`; null clears it. */
89
+ export function saveNgrokDomain(
90
+ workspaceDir: string,
91
+ domain: string | null,
92
+ ): void {
93
+ const config = loadRawConfig(workspaceDir);
94
+ const ingress = (config.ingress ?? {}) as Record<string, unknown>;
95
+ if (domain) {
96
+ ingress.ngrok = { domain };
97
+ } else {
98
+ delete ingress.ngrok;
99
+ }
100
+ config.ingress = ingress;
101
+ saveRawConfig(workspaceDir, config);
102
+ }
103
+
104
+ /** Read the reserved ngrok domain from the workspace config, if saved. */
105
+ export function loadNgrokDomain(workspaceDir: string): string | null {
106
+ const config = loadRawConfig(workspaceDir);
107
+ const ingress = config.ingress as Record<string, unknown> | undefined;
108
+ const ngrok = ingress?.ngrok as Record<string, unknown> | undefined;
109
+ const domain = ngrok?.domain;
110
+ return typeof domain === "string" && domain.trim() ? domain : null;
111
+ }
112
+
88
113
  /** Clear the ingress public base URL from the workspace config. */
89
114
  export function clearIngressUrl(
90
115
  workspaceDir: string,