@vellumai/cli 0.8.6 → 0.8.7-dev.202606052135.3e62c5a

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 (79) hide show
  1. package/bun.lock +8 -0
  2. package/knip.json +5 -1
  3. package/node_modules/@vellumai/environments/bun.lock +24 -0
  4. package/node_modules/@vellumai/environments/package.json +18 -0
  5. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  6. package/node_modules/@vellumai/environments/src/index.ts +11 -0
  7. package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
  8. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  9. package/node_modules/@vellumai/local-mode/bun.lock +29 -0
  10. package/node_modules/@vellumai/local-mode/package.json +22 -0
  11. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  13. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
  14. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  15. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  16. package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
  17. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  18. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
  19. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  20. package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
  21. package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
  22. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  23. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  24. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  25. package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
  26. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  27. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  28. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  29. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  30. package/package.json +12 -1
  31. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  32. package/src/__tests__/clean.test.ts +179 -0
  33. package/src/__tests__/client-token.test.ts +87 -0
  34. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  35. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  36. package/src/__tests__/connect-import.test.ts +317 -0
  37. package/src/__tests__/devices.test.ts +272 -0
  38. package/src/__tests__/env-drift.test.ts +32 -44
  39. package/src/__tests__/flags.test.ts +248 -0
  40. package/src/__tests__/guardian-token.test.ts +126 -2
  41. package/src/__tests__/multi-local.test.ts +1 -1
  42. package/src/__tests__/orphan-detection.test.ts +8 -6
  43. package/src/__tests__/pair.test.ts +271 -0
  44. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  45. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  46. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  47. package/src/__tests__/unpair.test.ts +163 -0
  48. package/src/commands/client.ts +511 -11
  49. package/src/commands/connect/import.ts +217 -0
  50. package/src/commands/connect.ts +31 -0
  51. package/src/commands/devices.ts +247 -0
  52. package/src/commands/env.ts +1 -1
  53. package/src/commands/flags.ts +89 -17
  54. package/src/commands/pair.ts +222 -0
  55. package/src/commands/ps.ts +16 -0
  56. package/src/commands/retire.ts +20 -47
  57. package/src/commands/sleep.ts +7 -0
  58. package/src/commands/tunnel.ts +46 -2
  59. package/src/commands/unpair.ts +118 -0
  60. package/src/commands/wake.ts +7 -0
  61. package/src/components/DefaultMainScreen.tsx +100 -14
  62. package/src/index.ts +16 -0
  63. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  64. package/src/lib/assistant-client.ts +58 -37
  65. package/src/lib/assistant-config.ts +15 -3
  66. package/src/lib/cloudflare-tunnel.ts +276 -0
  67. package/src/lib/confirm-action.ts +57 -0
  68. package/src/lib/docker.ts +25 -1
  69. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  70. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  71. package/src/lib/environments/paths.ts +1 -1
  72. package/src/lib/environments/resolve.ts +11 -35
  73. package/src/lib/guardian-token.ts +132 -9
  74. package/src/lib/hatch-local.ts +73 -33
  75. package/src/lib/lifecycle-reporter.ts +31 -0
  76. package/src/lib/local.ts +20 -6
  77. package/src/lib/retire-local.ts +28 -14
  78. package/src/lib/segments-to-plain-text.ts +35 -0
  79. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
@@ -0,0 +1,276 @@
1
+ import { execFileSync, spawn, type ChildProcess } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+
6
+ import { GATEWAY_PORT } from "./constants.js";
7
+
8
+ // ── Workspace config helpers (mirrors the pattern in ngrok.ts) ───────────────
9
+
10
+ function getDefaultWorkspaceDir(): string {
11
+ return (
12
+ process.env.VELLUM_WORKSPACE_DIR?.trim() ||
13
+ join(homedir(), ".vellum", "workspace")
14
+ );
15
+ }
16
+
17
+ function getConfigPath(workspaceDir: string): string {
18
+ return join(workspaceDir, "config.json");
19
+ }
20
+
21
+ function loadRawConfig(workspaceDir: string): Record<string, unknown> {
22
+ const configPath = getConfigPath(workspaceDir);
23
+ if (!existsSync(configPath)) return {};
24
+ return JSON.parse(readFileSync(configPath, "utf-8")) as Record<
25
+ string,
26
+ unknown
27
+ >;
28
+ }
29
+
30
+ function saveRawConfig(
31
+ workspaceDir: string,
32
+ config: Record<string, unknown>,
33
+ ): void {
34
+ const configPath = getConfigPath(workspaceDir);
35
+ const dir = dirname(configPath);
36
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
37
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
38
+ }
39
+
40
+ function saveIngressUrl(workspaceDir: string, publicUrl: string): void {
41
+ const config = loadRawConfig(workspaceDir);
42
+ const ingress = (config.ingress ?? {}) as Record<string, unknown>;
43
+ ingress.publicBaseUrl = publicUrl;
44
+ ingress.enabled = true;
45
+ config.ingress = ingress;
46
+ saveRawConfig(workspaceDir, config);
47
+ }
48
+
49
+ function clearIngressUrl(workspaceDir: string): void {
50
+ const config = loadRawConfig(workspaceDir);
51
+ const ingress = (config.ingress ?? {}) as Record<string, unknown>;
52
+ delete ingress.publicBaseUrl;
53
+ config.ingress = ingress;
54
+ saveRawConfig(workspaceDir, config);
55
+ }
56
+
57
+ // ── Cloudflare Tunnel ─────────────────────────────────────────────────────────
58
+
59
+ const CLOUDFLARED_TIMEOUT_MS = 30_000;
60
+
61
+ // Quick-tunnel hostnames follow the pattern <word>-<word>-<word>.trycloudflare.com
62
+ const QUICK_TUNNEL_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
63
+
64
+ /**
65
+ * Check whether cloudflared is installed and on PATH.
66
+ * Returns the version string if found, null otherwise.
67
+ */
68
+ export function getCloudflareTunnelVersion(): string | null {
69
+ try {
70
+ const output = execFileSync("cloudflared", ["version"], {
71
+ encoding: "utf-8",
72
+ timeout: 5_000,
73
+ stdio: ["ignore", "pipe", "ignore"],
74
+ });
75
+ return output.trim();
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Spawn a cloudflared quick-tunnel process forwarding HTTP traffic to
83
+ * `targetPort`. The child process writes its public URL to stderr during
84
+ * startup — use {@link waitForCloudflareTunnelUrl} to extract it.
85
+ */
86
+ export function startCloudflareTunnelProcess(targetPort: number): ChildProcess {
87
+ return spawn(
88
+ "cloudflared",
89
+ ["tunnel", "--url", `http://localhost:${targetPort}`, "--no-autoupdate"],
90
+ // Keep stdio as pipes so we can parse the URL from output.
91
+ { stdio: ["ignore", "pipe", "pipe"] },
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Listen to a running cloudflared process's stdout/stderr and resolve with
97
+ * the public quick-tunnel URL once cloudflared prints it.
98
+ *
99
+ * cloudflared emits a line containing the trycloudflare.com URL during
100
+ * startup — typically within 5–15 seconds on a normal internet connection.
101
+ *
102
+ * Rejects when:
103
+ * - The URL does not appear within `timeoutMs`.
104
+ * - The child process exits before the URL is found.
105
+ */
106
+ export function waitForCloudflareTunnelUrl(
107
+ child: ChildProcess,
108
+ timeoutMs: number = CLOUDFLARED_TIMEOUT_MS,
109
+ ): Promise<string> {
110
+ return new Promise((resolve, reject) => {
111
+ const timer = setTimeout(() => {
112
+ reject(
113
+ new Error(
114
+ `cloudflared tunnel URL did not appear within ${timeoutMs / 1000}s. ` +
115
+ `Ensure cloudflared is working: try running 'cloudflared tunnel --url http://localhost:8080' manually.`,
116
+ ),
117
+ );
118
+ }, timeoutMs);
119
+
120
+ let resolved = false;
121
+
122
+ function scanLine(line: string): void {
123
+ if (resolved) return;
124
+ const match = QUICK_TUNNEL_URL_RE.exec(line);
125
+ if (match) {
126
+ resolved = true;
127
+ clearTimeout(timer);
128
+ resolve(match[0]);
129
+ }
130
+ }
131
+
132
+ // Buffer incomplete lines across chunks
133
+ let stdoutBuf = "";
134
+ let stderrBuf = "";
135
+
136
+ child.stdout?.on("data", (chunk: Buffer) => {
137
+ stdoutBuf += chunk.toString();
138
+ const lines = stdoutBuf.split("\n");
139
+ stdoutBuf = lines.pop() ?? "";
140
+ for (const line of lines) scanLine(line);
141
+ });
142
+
143
+ child.stderr?.on("data", (chunk: Buffer) => {
144
+ stderrBuf += chunk.toString();
145
+ const lines = stderrBuf.split("\n");
146
+ stderrBuf = lines.pop() ?? "";
147
+ for (const line of lines) scanLine(line);
148
+ });
149
+
150
+ child.on("exit", (code) => {
151
+ if (resolved) return;
152
+ clearTimeout(timer);
153
+ reject(
154
+ new Error(
155
+ `cloudflared exited with code ${code ?? "unknown"} before the tunnel URL appeared.`,
156
+ ),
157
+ );
158
+ });
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Run the cloudflared quick-tunnel workflow:
164
+ * 1. Verify cloudflared is installed.
165
+ * 2. Start a quick tunnel pointing at the gateway port.
166
+ * 3. Parse the public URL from cloudflared output.
167
+ * 4. Persist the URL to the workspace config as the ingress base URL.
168
+ * 5. Block until the process exits or the user presses Ctrl+C.
169
+ * 6. Clear the ingress URL from config on exit.
170
+ *
171
+ * No Cloudflare account is required — quick tunnels are free and ephemeral.
172
+ */
173
+ export interface RunCloudflareTunnelOptions {
174
+ /** Gateway port to forward. Defaults to the global GATEWAY_PORT. */
175
+ port?: number;
176
+ /** Workspace directory for config read/write. Defaults to ~/.vellum/workspace. */
177
+ workspaceDir?: string;
178
+ }
179
+
180
+ export async function runCloudflareTunnel(
181
+ opts: RunCloudflareTunnelOptions = {},
182
+ ): Promise<void> {
183
+ const version = getCloudflareTunnelVersion();
184
+ if (!version) {
185
+ console.error("Error: cloudflared is not installed.");
186
+ console.error("");
187
+ console.error("Install cloudflared:");
188
+ console.error(" macOS: brew install cloudflare/cloudflare/cloudflared");
189
+ console.error(" Linux: https://pkg.cloudflare.com/index.html");
190
+ console.error(
191
+ " Windows: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/",
192
+ );
193
+ console.error("");
194
+ console.error("No Cloudflare account is required for quick tunnels.");
195
+ process.exit(1);
196
+ }
197
+
198
+ console.log(`Using ${version}`);
199
+
200
+ const port = opts.port ?? GATEWAY_PORT;
201
+ const workspaceDir = opts.workspaceDir ?? getDefaultWorkspaceDir();
202
+
203
+ console.log(`Starting cloudflared quick tunnel to localhost:${port}...`);
204
+ console.log("No Cloudflare account required — quick tunnels are free.");
205
+ console.log("");
206
+
207
+ let publicUrl: string | undefined;
208
+ const child = startCloudflareTunnelProcess(port);
209
+
210
+ const cleanup = (): void => {
211
+ if (!child.killed) child.kill("SIGTERM");
212
+ if (publicUrl) {
213
+ console.log("\nClearing ingress URL from config...");
214
+ clearIngressUrl(workspaceDir);
215
+ }
216
+ };
217
+
218
+ process.on("SIGINT", () => {
219
+ cleanup();
220
+ process.exit(0);
221
+ });
222
+ process.on("SIGTERM", () => {
223
+ cleanup();
224
+ process.exit(0);
225
+ });
226
+
227
+ child.on("error", (err: Error) => {
228
+ console.error(`cloudflared process error: ${err.message}`);
229
+ process.exit(1);
230
+ });
231
+
232
+ child.on("exit", (code) => {
233
+ // Always clear the saved ingress URL when the tunnel process ends so
234
+ // webhook integrations don't keep hitting a dead endpoint.
235
+ if (publicUrl !== undefined) {
236
+ clearIngressUrl(workspaceDir);
237
+ }
238
+ if (code !== null && code !== 0) {
239
+ console.error(`\ncloudflared exited with code ${code}.`);
240
+ process.exit(1);
241
+ }
242
+ });
243
+
244
+ // Forward cloudflared output to the console so the user can see startup
245
+ // progress and any authentication errors.
246
+ child.stdout?.on("data", (data: Buffer) => {
247
+ const line = data.toString().trim();
248
+ if (line) console.log(`[cloudflared] ${line}`);
249
+ });
250
+ child.stderr?.on("data", (data: Buffer) => {
251
+ const line = data.toString().trim();
252
+ if (line) console.log(`[cloudflared] ${line}`);
253
+ });
254
+
255
+ try {
256
+ publicUrl = await waitForCloudflareTunnelUrl(child);
257
+ } catch (err) {
258
+ cleanup();
259
+ throw err;
260
+ }
261
+
262
+ console.log("");
263
+ console.log(`Tunnel established: ${publicUrl}`);
264
+ console.log(`Forwarding to: localhost:${port}`);
265
+ console.log("");
266
+
267
+ saveIngressUrl(workspaceDir, publicUrl);
268
+ console.log("Ingress URL saved to config.");
269
+ console.log("");
270
+ console.log("Press Ctrl+C to stop the tunnel and clear the ingress URL.");
271
+
272
+ // Keep running until cloudflared exits (e.g., network error or user Ctrl+C)
273
+ await new Promise<void>((resolve) => {
274
+ child.on("exit", () => resolve());
275
+ });
276
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Shared interactive confirmation for destructive CLI commands (retire, unpair,
3
+ * …). Per cli/AGENTS.md, a command that removes assistant state must print the
4
+ * resolved identity and require confirmation, with a `--yes` bypass for
5
+ * automation.
6
+ */
7
+
8
+ /** True only when we can run an interactive raw-mode confirmation prompt. */
9
+ export function canPromptForConfirmation(): boolean {
10
+ return (
11
+ process.stdin.isTTY === true &&
12
+ process.stdout.isTTY === true &&
13
+ typeof process.stdin.setRawMode === "function"
14
+ );
15
+ }
16
+
17
+ /**
18
+ * Show `prompt` and resolve true on Enter, false on Esc/q/Ctrl-C. Restores the
19
+ * prior stdin raw/paused state on exit. Caller must gate on
20
+ * {@link canPromptForConfirmation} first.
21
+ */
22
+ export async function confirmAction(prompt: string): Promise<boolean> {
23
+ const stdin = process.stdin;
24
+ const stdout = process.stdout;
25
+ const wasRaw = stdin.isRaw === true;
26
+ const wasPaused = stdin.isPaused();
27
+
28
+ stdout.write(prompt);
29
+ stdin.setRawMode(true);
30
+ stdin.resume();
31
+
32
+ return await new Promise<boolean>((resolve) => {
33
+ const cleanup = () => {
34
+ stdin.off("data", onData);
35
+ stdin.setRawMode(wasRaw);
36
+ if (wasPaused) {
37
+ stdin.pause();
38
+ }
39
+ stdout.write("\n");
40
+ };
41
+
42
+ const onData = (chunk: Buffer) => {
43
+ const byte = chunk[0];
44
+ if (byte === 13 || byte === 10) {
45
+ cleanup();
46
+ resolve(true);
47
+ return;
48
+ }
49
+ if (byte === 27 || byte === 3 || byte === 113 || byte === 81) {
50
+ cleanup();
51
+ resolve(false);
52
+ }
53
+ };
54
+
55
+ stdin.on("data", onData);
56
+ });
57
+ }
package/src/lib/docker.ts CHANGED
@@ -478,6 +478,17 @@ export async function retireDocker(name: string): Promise<void> {
478
478
  }
479
479
  }
480
480
 
481
+ // Future: consider stopping Colima VM when no Docker instances remain.
482
+ // Considerations:
483
+ // - Use loadAllAssistantsAcrossEnvs() instead of loadAllAssistants() to
484
+ // avoid stopping Colima while another VELLUM_ENVIRONMENT still has a
485
+ // running Docker instance.
486
+ // - Track whether Vellum started Colima (vs. the user already had it
487
+ // running for non-Vellum workloads) \u2014 e.g. via a dedicated Colima
488
+ // profile (`colima start --profile vellum`) or a sentinel file.
489
+ // - Only stop if both conditions are met: no cross-env Docker instances
490
+ // AND Vellum owns the Colima lifecycle.
491
+
481
492
  console.log(`\u2705 Docker instance retired.`);
482
493
  }
483
494
 
@@ -1137,7 +1148,20 @@ export async function hatchDocker(
1137
1148
  await loadImageViaHost(HOST_IMAGE_LOADER_URL, ref, log);
1138
1149
  } else {
1139
1150
  log(` ↪ pulling ${ref}`);
1140
- await exec("docker", ["pull", ref]);
1151
+ const MAX_PULL_RETRIES = 3;
1152
+ for (let attempt = 1; attempt <= MAX_PULL_RETRIES; attempt++) {
1153
+ try {
1154
+ await exec("docker", ["pull", ref]);
1155
+ break;
1156
+ } catch (err) {
1157
+ if (attempt === MAX_PULL_RETRIES) throw err;
1158
+ const delaySec = 2 ** attempt;
1159
+ log(
1160
+ ` ⚠ pull failed (attempt ${attempt}/${MAX_PULL_RETRIES}), retrying in ${delaySec}s...`,
1161
+ );
1162
+ await new Promise((r) => setTimeout(r, delaySec * 1000));
1163
+ }
1164
+ }
1141
1165
  }
1142
1166
  }
1143
1167
  log("✅ Docker images acquired");
@@ -27,7 +27,8 @@ const {
27
27
  getLockfilePaths,
28
28
  getMultiInstanceDir,
29
29
  } = await import("../paths.js");
30
- type EnvironmentDefinition = import("../types.js").EnvironmentDefinition;
30
+ type EnvironmentDefinition =
31
+ import("@vellumai/environments").EnvironmentDefinition;
31
32
 
32
33
  const prod: EnvironmentDefinition = {
33
34
  name: "production",
@@ -1,7 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
+ import { SEEDS } from "@vellumai/environments";
4
+
3
5
  import { getDefaultPorts } from "../paths.js";
4
- import { SEEDS } from "../seeds.js";
5
6
 
6
7
  describe("SEEDS port blocks", () => {
7
8
  test("production uses the legacy (pre-MVP) port layout", () => {
@@ -1,7 +1,7 @@
1
1
  import { homedir } from "os";
2
2
  import { join } from "path";
3
3
 
4
- import type { EnvironmentDefinition, PortMap } from "./types.js";
4
+ import type { EnvironmentDefinition, PortMap } from "@vellumai/environments";
5
5
 
6
6
  const PRODUCTION_ENVIRONMENT_NAME = "production";
7
7
 
@@ -1,49 +1,27 @@
1
+ import { mkdirSync, unlinkSync, writeFileSync } from "fs";
2
+ import { dirname } from "path";
3
+
4
+ import { SEEDS, type EnvironmentDefinition } from "@vellumai/environments";
1
5
  import {
2
- existsSync,
3
- mkdirSync,
4
- readFileSync,
5
- unlinkSync,
6
- writeFileSync,
7
- } from "fs";
8
- import { homedir } from "os";
9
- import { dirname, join } from "path";
10
-
11
- import { SEEDS } from "./seeds.js";
12
- import type { EnvironmentDefinition } from "./types.js";
6
+ defaultEnvironmentFilePath,
7
+ readDefaultEnvironment as readPersistedDefaultEnvironment,
8
+ } from "@vellumai/local-mode";
13
9
 
14
10
  const DEFAULT_ENVIRONMENT_NAME = "production";
15
11
 
16
- /**
17
- * Path to the user's persisted default environment file.
18
- * Lives at `~/.config/vellum/environment` — a fixed, environment-agnostic
19
- * location so it can be read before the environment is resolved.
20
- */
21
- function getDefaultEnvironmentPath(): string {
22
- const xdgConfig =
23
- process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
24
- return join(xdgConfig, "vellum", "environment");
25
- }
26
-
27
12
  /**
28
13
  * Read the persisted default environment name, if any.
29
14
  * Returns `undefined` if no file exists or the file is empty.
30
15
  */
31
16
  export function readDefaultEnvironment(): string | undefined {
32
- const filePath = getDefaultEnvironmentPath();
33
- try {
34
- if (!existsSync(filePath)) return undefined;
35
- const content = readFileSync(filePath, "utf-8").trim();
36
- return content.length > 0 ? content : undefined;
37
- } catch {
38
- return undefined;
39
- }
17
+ return readPersistedDefaultEnvironment(process.env);
40
18
  }
41
19
 
42
20
  /**
43
21
  * Persist a default environment name to the user config file.
44
22
  */
45
23
  export function writeDefaultEnvironment(name: string): void {
46
- const filePath = getDefaultEnvironmentPath();
24
+ const filePath = defaultEnvironmentFilePath(process.env);
47
25
  mkdirSync(dirname(filePath), { recursive: true });
48
26
  writeFileSync(filePath, name + "\n", "utf-8");
49
27
  }
@@ -52,7 +30,7 @@ export function writeDefaultEnvironment(name: string): void {
52
30
  * Remove the persisted default environment file, falling back to production.
53
31
  */
54
32
  export function clearDefaultEnvironment(): void {
55
- const filePath = getDefaultEnvironmentPath();
33
+ const filePath = defaultEnvironmentFilePath(process.env);
56
34
  try {
57
35
  unlinkSync(filePath);
58
36
  } catch {
@@ -115,7 +93,7 @@ export function getCurrentEnvironment(
115
93
  // writers don't end up in disjoint states on a typo.
116
94
  process.stderr.write(
117
95
  `warning: unknown environment "${name}"; falling back to "${DEFAULT_ENVIRONMENT_NAME}". ` +
118
- `Add it to cli/src/lib/environments/seeds.ts and rebuild if this was intentional.\n`,
96
+ `Add it to packages/environments/src/seeds.ts and rebuild if this was intentional.\n`,
119
97
  );
120
98
  }
121
99
  const fallback = SEEDS[DEFAULT_ENVIRONMENT_NAME];
@@ -174,5 +152,3 @@ export function resolveEnvironmentSource(override?: string): {
174
152
  }
175
153
  return { name: DEFAULT_ENVIRONMENT_NAME, source: "default" };
176
154
  }
177
-
178
-
@@ -2,18 +2,24 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { execSync } from "node:child_process";
3
3
  import {
4
4
  chmodSync,
5
+ closeSync,
5
6
  existsSync,
6
7
  mkdirSync,
8
+ openSync,
7
9
  readFileSync,
10
+ rmdirSync,
8
11
  statSync,
12
+ unlinkSync,
9
13
  writeFileSync,
14
+ writeSync,
10
15
  } from "fs";
11
16
  import { platform } from "os";
12
17
  import { dirname, join } from "path";
13
18
 
19
+ import { SEEDS } from "@vellumai/environments";
20
+
14
21
  import { getConfigDir } from "./environments/paths.js";
15
22
  import { getCurrentEnvironment } from "./environments/resolve.js";
16
- import { SEEDS } from "./environments/seeds.js";
17
23
 
18
24
  const DEVICE_ID_SALT = "vellum-assistant-host-id";
19
25
 
@@ -40,6 +46,27 @@ function getGuardianTokenPath(assistantId: string): string {
40
46
  );
41
47
  }
42
48
 
49
+ /**
50
+ * Best-effort removal of an assistant's stored guardian token (used by
51
+ * `vellum unpair` to forget a paired connection). Never throws if the token
52
+ * file or its per-assistant directory is already absent.
53
+ */
54
+ export function deleteGuardianToken(assistantId: string): void {
55
+ const tokenPath = getGuardianTokenPath(assistantId);
56
+ try {
57
+ unlinkSync(tokenPath);
58
+ } catch {
59
+ /* already gone */
60
+ }
61
+ // Clean up the now-empty per-assistant directory; rmdir throws if it still
62
+ // holds other files, in which case we leave it.
63
+ try {
64
+ rmdirSync(dirname(tokenPath));
65
+ } catch {
66
+ /* not empty or absent */
67
+ }
68
+ }
69
+
43
70
  function getPersistedDeviceIdPath(): string {
44
71
  return join(getConfigDir(getCurrentEnvironment()), "device-id");
45
72
  }
@@ -160,42 +187,136 @@ export function saveGuardianToken(
160
187
  chmodSync(tokenPath, 0o600);
161
188
  }
162
189
 
190
+ /** Abort the refresh POST if the gateway is slow/unreachable (it's now on the
191
+ * hot request path, so it must never hang indefinitely). */
192
+ const REFRESH_FETCH_TIMEOUT_MS = 15_000;
193
+ /** Max time to wait for the per-assistant refresh lock before proceeding. */
194
+ const REFRESH_LOCK_WAIT_MS = 10_000;
195
+ /** A lock older than this is treated as stale (holder crashed) and stolen. */
196
+ const REFRESH_LOCK_STALE_MS = 30_000;
197
+ const REFRESH_LOCK_POLL_MS = 100;
198
+
199
+ function getRefreshLockPath(assistantId: string): string {
200
+ return join(dirname(getGuardianTokenPath(assistantId)), "refresh.lock");
201
+ }
202
+
203
+ const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
204
+
205
+ /**
206
+ * Best-effort exclusive cross-process lock for a per-assistant token refresh.
207
+ * Created atomically with `wx`; a stale lock (crashed holder) is stolen.
208
+ * Returns true if acquired, false if it timed out (caller proceeds degraded).
209
+ */
210
+ async function acquireRefreshLock(lockPath: string): Promise<boolean> {
211
+ mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
212
+ const deadline = Date.now() + REFRESH_LOCK_WAIT_MS;
213
+ for (;;) {
214
+ try {
215
+ const fd = openSync(lockPath, "wx", 0o600);
216
+ writeSync(fd, String(process.pid));
217
+ closeSync(fd);
218
+ return true;
219
+ } catch (err) {
220
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") return false;
221
+ try {
222
+ if (Date.now() - statSync(lockPath).mtimeMs > REFRESH_LOCK_STALE_MS) {
223
+ unlinkSync(lockPath); // steal a stale lock, then retry
224
+ continue;
225
+ }
226
+ } catch {
227
+ continue; // lock vanished between open and stat — retry
228
+ }
229
+ if (Date.now() >= deadline) return false;
230
+ await delay(REFRESH_LOCK_POLL_MS);
231
+ }
232
+ }
233
+ }
234
+
235
+ function releaseRefreshLock(lockPath: string): void {
236
+ try {
237
+ unlinkSync(lockPath);
238
+ } catch {
239
+ /* already released/stolen */
240
+ }
241
+ }
242
+
163
243
  /**
164
244
  * Call POST /v1/guardian/refresh on the remote gateway to obtain a new
165
245
  * access token using an existing (possibly expired) access token for auth.
166
246
  * Returns the refreshed token data (persisted locally), or null if the
167
247
  * refresh fails (e.g. no stored token, or refresh token itself is expired).
248
+ *
249
+ * Concurrency-safe: the gateway rotates refresh tokens and treats reuse of an
250
+ * already-rotated token as replay (revoking the whole token family), so two
251
+ * processes (e.g. `vellum message` + `vellum events`) refreshing the same
252
+ * stored token at once would self-revoke and force re-pairing. We serialize on
253
+ * a per-assistant lock and, once held, re-read the stored token: if another
254
+ * process already rotated it while we waited, we return that fresh token
255
+ * instead of replaying our now-stale refresh token.
168
256
  */
169
257
  export async function refreshGuardianToken(
170
258
  gatewayUrl: string,
171
259
  assistantId: string,
172
260
  ): Promise<GuardianTokenData | null> {
173
- const tokenData = loadGuardianToken(assistantId);
174
- if (!tokenData) return null;
261
+ const before = loadGuardianToken(assistantId);
262
+ if (!before) return null;
175
263
 
176
264
  // Gateway persists expiresAt as epoch-ms numbers; Date.parse("1234567890000")
177
265
  // returns NaN. new Date() accepts both ISO strings and epoch-ms numbers.
178
- const refreshExpiry = new Date(tokenData.refreshTokenExpiresAt).getTime();
179
- if (!Number.isFinite(refreshExpiry) || refreshExpiry <= Date.now()) return null;
266
+ const refreshExpiry = new Date(before.refreshTokenExpiresAt).getTime();
267
+ if (!Number.isFinite(refreshExpiry) || refreshExpiry <= Date.now())
268
+ return null;
180
269
 
270
+ const lockPath = getRefreshLockPath(assistantId);
271
+ const locked = await acquireRefreshLock(lockPath);
181
272
  try {
273
+ // Re-read under the lock: a concurrent process may have rotated the token
274
+ // while we waited. If the stored refresh token changed, ours is now stale
275
+ // (replaying it would trip reuse-detection) — use the fresh token instead.
276
+ const current = loadGuardianToken(assistantId);
277
+ if (current && current.refreshToken !== before.refreshToken) {
278
+ return current;
279
+ }
280
+
281
+ // We did NOT acquire the lock (another process is likely mid-refresh) and
282
+ // the stored token hasn't been rotated yet. Do NOT call the gateway: our
283
+ // refresh token may be the one the winner is rotating right now, and
284
+ // replaying a rotated token revokes the whole family (forcing re-pair).
285
+ // Give up — the caller surfaces the original 401, and the next attempt
286
+ // picks up the winner's persisted token.
287
+ if (!locked) return null;
288
+
289
+ const tokenData = current ?? before;
290
+
182
291
  const response = await fetch(`${gatewayUrl}/v1/guardian/refresh`, {
183
292
  method: "POST",
184
293
  headers: {
185
294
  "Content-Type": "application/json",
186
295
  Authorization: `Bearer ${tokenData.accessToken}`,
187
296
  },
188
- body: JSON.stringify({ refreshToken: tokenData.refreshToken }),
297
+ body: JSON.stringify({
298
+ refreshToken: tokenData.refreshToken,
299
+ // The refresh token is device-bound; send the device id used at init
300
+ // (falling back to a fresh computation for tokens persisted before the
301
+ // field was stored) so the gateway can verify the binding.
302
+ deviceId: tokenData.deviceId || computeDeviceId(),
303
+ }),
304
+ signal: AbortSignal.timeout(REFRESH_FETCH_TIMEOUT_MS),
189
305
  });
190
306
  if (!response.ok) return null;
191
307
 
192
308
  const json = (await response.json()) as Record<string, unknown>;
193
309
  const refreshed: GuardianTokenData = {
194
- guardianPrincipalId: (json.guardianPrincipalId as string) ?? tokenData.guardianPrincipalId,
310
+ guardianPrincipalId:
311
+ (json.guardianPrincipalId as string) ?? tokenData.guardianPrincipalId,
195
312
  accessToken: json.accessToken as string,
196
- accessTokenExpiresAt: (json.accessTokenExpiresAt as string | number) ?? tokenData.accessTokenExpiresAt,
313
+ accessTokenExpiresAt:
314
+ (json.accessTokenExpiresAt as string | number) ??
315
+ tokenData.accessTokenExpiresAt,
197
316
  refreshToken: (json.refreshToken as string) ?? tokenData.refreshToken,
198
- refreshTokenExpiresAt: (json.refreshTokenExpiresAt as string | number) ?? tokenData.refreshTokenExpiresAt,
317
+ refreshTokenExpiresAt:
318
+ (json.refreshTokenExpiresAt as string | number) ??
319
+ tokenData.refreshTokenExpiresAt,
199
320
  refreshAfter: (json.refreshAfter as string) ?? tokenData.refreshAfter,
200
321
  isNew: false,
201
322
  deviceId: tokenData.deviceId,
@@ -205,6 +326,8 @@ export async function refreshGuardianToken(
205
326
  return refreshed;
206
327
  } catch {
207
328
  return null;
329
+ } finally {
330
+ if (locked) releaseRefreshLock(lockPath);
208
331
  }
209
332
  }
210
333