@vellumai/cli 0.8.5 → 0.8.7-dev.202606052118.34cd356

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 (102) hide show
  1. package/AGENTS.md +6 -0
  2. package/bun.lock +8 -0
  3. package/knip.json +6 -1
  4. package/node_modules/@vellumai/environments/bun.lock +24 -0
  5. package/node_modules/@vellumai/environments/package.json +18 -0
  6. package/node_modules/@vellumai/environments/src/__tests__/package-boundary.test.ts +95 -0
  7. package/node_modules/@vellumai/environments/src/index.ts +11 -0
  8. package/{src/lib/environments → node_modules/@vellumai/environments/src}/seeds.ts +5 -9
  9. package/node_modules/@vellumai/environments/tsconfig.json +20 -0
  10. package/node_modules/@vellumai/local-mode/bun.lock +29 -0
  11. package/node_modules/@vellumai/local-mode/package.json +22 -0
  12. package/node_modules/@vellumai/local-mode/src/__tests__/environment.test.ts +116 -0
  13. package/node_modules/@vellumai/local-mode/src/__tests__/gateway-proxy.test.ts +79 -0
  14. package/node_modules/@vellumai/local-mode/src/__tests__/hatch.test.ts +108 -0
  15. package/node_modules/@vellumai/local-mode/src/__tests__/package-boundary.test.ts +104 -0
  16. package/node_modules/@vellumai/local-mode/src/__tests__/wake.test.ts +66 -0
  17. package/node_modules/@vellumai/local-mode/src/config.ts +66 -0
  18. package/node_modules/@vellumai/local-mode/src/environment.ts +62 -0
  19. package/node_modules/@vellumai/local-mode/src/gateway-proxy.ts +109 -0
  20. package/node_modules/@vellumai/local-mode/src/guardian-token.ts +122 -0
  21. package/node_modules/@vellumai/local-mode/src/hatch.ts +92 -0
  22. package/node_modules/@vellumai/local-mode/src/index.ts +48 -0
  23. package/node_modules/@vellumai/local-mode/src/lockfile-contract.test.ts +173 -0
  24. package/node_modules/@vellumai/local-mode/src/lockfile-contract.ts +114 -0
  25. package/node_modules/@vellumai/local-mode/src/lockfile.test.ts +235 -0
  26. package/node_modules/@vellumai/local-mode/src/lockfile.ts +133 -0
  27. package/node_modules/@vellumai/local-mode/src/retire.ts +58 -0
  28. package/node_modules/@vellumai/local-mode/src/util.ts +102 -0
  29. package/node_modules/@vellumai/local-mode/src/wake.ts +78 -0
  30. package/node_modules/@vellumai/local-mode/tsconfig.json +16 -0
  31. package/package.json +12 -1
  32. package/src/__tests__/assistant-client-refresh.test.ts +182 -0
  33. package/src/__tests__/backup.test.ts +38 -0
  34. package/src/__tests__/clean.test.ts +179 -0
  35. package/src/__tests__/client-token.test.ts +87 -0
  36. package/src/__tests__/client-tui-refresh.test.ts +170 -0
  37. package/src/__tests__/cloudflare-tunnel.test.ts +137 -0
  38. package/src/__tests__/connect-import.test.ts +317 -0
  39. package/src/__tests__/devices.test.ts +272 -0
  40. package/src/__tests__/env-drift.test.ts +32 -44
  41. package/src/__tests__/flags.test.ts +248 -0
  42. package/src/__tests__/guardian-token.test.ts +126 -2
  43. package/src/__tests__/multi-local.test.ts +1 -1
  44. package/src/__tests__/orphan-detection.test.ts +8 -6
  45. package/src/__tests__/pair.test.ts +271 -0
  46. package/src/__tests__/paired-lifecycle.test.ts +116 -0
  47. package/src/__tests__/recover.test.ts +307 -0
  48. package/src/__tests__/segments-to-plain-text.test.ts +37 -0
  49. package/src/__tests__/tui-midsession-refresh.test.ts +166 -0
  50. package/src/__tests__/unpair.test.ts +163 -0
  51. package/src/__tests__/wake.test.ts +215 -0
  52. package/src/commands/backup.ts +2 -0
  53. package/src/commands/client.ts +569 -39
  54. package/src/commands/connect/import.ts +217 -0
  55. package/src/commands/connect.ts +31 -0
  56. package/src/commands/devices.ts +247 -0
  57. package/src/commands/env.ts +1 -1
  58. package/src/commands/flags.ts +269 -0
  59. package/src/commands/gateway/token.ts +73 -0
  60. package/src/commands/gateway.ts +29 -0
  61. package/src/commands/logs.ts +6 -18
  62. package/src/commands/pair.ts +222 -0
  63. package/src/commands/ps.ts +57 -41
  64. package/src/commands/recover.ts +47 -9
  65. package/src/commands/restore.ts +8 -1
  66. package/src/commands/retire.ts +23 -70
  67. package/src/commands/rollback.ts +2 -14
  68. package/src/commands/sleep.ts +7 -0
  69. package/src/commands/ssh.ts +5 -24
  70. package/src/commands/teleport.ts +34 -26
  71. package/src/commands/tunnel.ts +46 -2
  72. package/src/commands/unpair.ts +118 -0
  73. package/src/commands/upgrade.ts +8 -16
  74. package/src/commands/wake.ts +75 -45
  75. package/src/components/DefaultMainScreen.tsx +100 -14
  76. package/src/index.ts +22 -0
  77. package/src/lib/__tests__/lifecycle-reporter.test.ts +59 -0
  78. package/src/lib/__tests__/step-runner.test.ts +49 -1
  79. package/src/lib/assistant-client.ts +58 -37
  80. package/src/lib/assistant-config.ts +28 -3
  81. package/src/lib/cloudflare-tunnel.ts +276 -0
  82. package/src/lib/config-utils.ts +24 -3
  83. package/src/lib/confirm-action.ts +57 -0
  84. package/src/lib/docker.ts +82 -8
  85. package/src/lib/environments/__tests__/paths.test.ts +2 -1
  86. package/src/lib/environments/__tests__/seeds.test.ts +2 -1
  87. package/src/lib/environments/paths.ts +1 -1
  88. package/src/lib/environments/resolve.ts +11 -35
  89. package/src/lib/guardian-token.ts +132 -9
  90. package/src/lib/hatch-local.ts +75 -33
  91. package/src/lib/http-client.ts +1 -3
  92. package/src/lib/lifecycle-reporter.ts +31 -0
  93. package/src/lib/local.ts +193 -298
  94. package/src/lib/orphan-detection.ts +9 -5
  95. package/src/lib/pgrep.ts +5 -1
  96. package/src/lib/platform-client.ts +97 -49
  97. package/src/lib/process.ts +109 -39
  98. package/src/lib/retire-local.ts +28 -14
  99. package/src/lib/segments-to-plain-text.ts +35 -0
  100. package/src/lib/step-runner.ts +67 -7
  101. package/src/lib/sync-cloud-assistants.ts +17 -0
  102. /package/{src/lib/environments → node_modules/@vellumai/environments/src}/types.ts +0 -0
package/src/lib/docker.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { randomBytes } from "crypto";
2
- import { chmodSync, existsSync, mkdirSync, watch as fsWatch } from "fs";
2
+ import {
3
+ chmodSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ watch as fsWatch,
8
+ } from "fs";
3
9
  import { arch, platform } from "os";
4
10
  import { dirname, join, resolve } from "path";
5
11
 
@@ -12,6 +18,7 @@ import {
12
18
  setActiveAssistant,
13
19
  } from "./assistant-config";
14
20
  import type { AssistantEntry } from "./assistant-config";
21
+ import { buildHatchConfigValues, writeInitialConfig } from "./config-utils";
15
22
  import { buildServiceRunArgs } from "./statefulset.js";
16
23
  import type { Species } from "./constants";
17
24
  import { getDefaultPorts } from "./environments/paths.js";
@@ -35,7 +42,7 @@ import {
35
42
  resolveHatchProvider,
36
43
  } from "./provider-secrets.js";
37
44
  import { findOpenPort } from "./port-allocator.js";
38
- import { exec, execOutput } from "./step-runner";
45
+ import { exec, execOutput, execWithStdin } from "./step-runner";
39
46
  import {
40
47
  closeLogFile,
41
48
  openLogFile,
@@ -471,6 +478,17 @@ export async function retireDocker(name: string): Promise<void> {
471
478
  }
472
479
  }
473
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
+
474
492
  console.log(`\u2705 Docker instance retired.`);
475
493
  }
476
494
 
@@ -1130,7 +1148,20 @@ export async function hatchDocker(
1130
1148
  await loadImageViaHost(HOST_IMAGE_LOADER_URL, ref, log);
1131
1149
  } else {
1132
1150
  log(` ↪ pulling ${ref}`);
1133
- 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
+ }
1134
1165
  }
1135
1166
  }
1136
1167
  log("✅ Docker images acquired");
@@ -1164,11 +1195,53 @@ export async function hatchDocker(
1164
1195
  "chown 1001:1001 /workspace /run/assistant-ipc /run/gateway-ipc",
1165
1196
  ]);
1166
1197
 
1167
- // BYOK setup (API key, custom profiles, active-profile selection) is
1168
- // driven post-boot by the CLI calling the Assistant's public APIs
1169
- // (`POST /v1/secrets`, etc.) via `configureHatchProviderApiKey` below.
1170
- // The Assistant container comes up clean — no overlay file, no
1171
- // client-side workspace-config injection.
1198
+ // Stage the BYOK default-workspace-config overlay *inside* the workspace
1199
+ // volume so the daemon's startup loader can consume it. The loader
1200
+ // (`mergeDefaultWorkspaceConfig` in assistant/src/config/loader.ts) does:
1201
+ // 1. JSON.parse + deep-merge into the workspace's config.json.
1202
+ // 2. `renameSync(defaultConfigPath, <workspace>/default-config.json)`
1203
+ // to mark the overlay consumed so subsequent restarts skip it.
1204
+ //
1205
+ // The rename must be same-filesystem. Bind-mounting the host file into
1206
+ // `/tmp/...` (the pre-#32025 design) crossed filesystems and silently
1207
+ // failed with EXDEV, so every `docker start` re-applied the overlay.
1208
+ // Staging into the workspace volume keeps the rename in-place.
1209
+ //
1210
+ // Streaming the JSON via stdin (instead of bind-mounting the host file
1211
+ // into the staging container) sidesteps macOS Colima's virtiofs share,
1212
+ // which doesn't expose `/var/folders/...` (where `os.tmpdir()` resolves
1213
+ // on macOS) and would otherwise materialize an empty directory at the
1214
+ // bind-mount target.
1215
+ //
1216
+ // @deprecated stopgap. Replacement direction is one of:
1217
+ // 1. Post-hatch API calls (POST /v1/secrets + a small endpoint that
1218
+ // returns canonical inference-profile templates).
1219
+ // 2. Move inference-profile seeds out of workspace config and into
1220
+ // Assistant code, eliminating the overlay entirely.
1221
+ // See `cli/src/lib/config-utils.ts` JSDoc for context.
1222
+ const hatchConfigValues = buildHatchConfigValues(configValues, provider);
1223
+ const hostOverlayPath = writeInitialConfig(hatchConfigValues);
1224
+ const stagedOverlayInContainer = "/workspace/.default-config-overlay.json";
1225
+ const extraAssistantEnv: Record<string, string> = {};
1226
+ if (hostOverlayPath) {
1227
+ await execWithStdin(
1228
+ "docker",
1229
+ [
1230
+ "run",
1231
+ "--rm",
1232
+ "-i",
1233
+ "-v",
1234
+ `${res.workspaceVolume}:/workspace`,
1235
+ "busybox",
1236
+ "sh",
1237
+ "-c",
1238
+ `cat > ${stagedOverlayInContainer} && chown 1001:1001 ${stagedOverlayInContainer}`,
1239
+ ],
1240
+ readFileSync(hostOverlayPath, "utf-8"),
1241
+ );
1242
+ extraAssistantEnv.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH =
1243
+ stagedOverlayInContainer;
1244
+ }
1172
1245
 
1173
1246
  const cesServiceToken = randomBytes(32).toString("hex");
1174
1247
  const signingKey = randomBytes(32).toString("hex");
@@ -1190,6 +1263,7 @@ export async function hatchDocker(
1190
1263
  signingKey,
1191
1264
  bootstrapSecret,
1192
1265
  cesServiceToken,
1266
+ extraAssistantEnv,
1193
1267
  gatewayPort,
1194
1268
  imageTags,
1195
1269
  instanceName,
@@ -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