@indigoai-us/hq-cloud 6.14.12 → 6.14.14

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 (78) hide show
  1. package/.claude/policies/hq-cloud-strip-types-no-parameter-properties.md +22 -0
  2. package/dist/bin/sync-runner-company.d.ts.map +1 -1
  3. package/dist/bin/sync-runner-company.js +6 -0
  4. package/dist/bin/sync-runner-company.js.map +1 -1
  5. package/dist/bin/sync-runner-planning.d.ts.map +1 -1
  6. package/dist/bin/sync-runner-planning.js +3 -1
  7. package/dist/bin/sync-runner-planning.js.map +1 -1
  8. package/dist/bin/sync-runner-watch-loop.d.ts +10 -0
  9. package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
  10. package/dist/bin/sync-runner-watch-loop.js +42 -3
  11. package/dist/bin/sync-runner-watch-loop.js.map +1 -1
  12. package/dist/bin/sync-runner-watch-routes.d.ts +1 -0
  13. package/dist/bin/sync-runner-watch-routes.d.ts.map +1 -1
  14. package/dist/bin/sync-runner-watch-routes.js +3 -0
  15. package/dist/bin/sync-runner-watch-routes.js.map +1 -1
  16. package/dist/bin/sync-runner.d.ts +17 -0
  17. package/dist/bin/sync-runner.d.ts.map +1 -1
  18. package/dist/bin/sync-runner.js +83 -33
  19. package/dist/bin/sync-runner.js.map +1 -1
  20. package/dist/bin/sync-runner.test.js +193 -5
  21. package/dist/bin/sync-runner.test.js.map +1 -1
  22. package/dist/cli/reindex.d.ts.map +1 -1
  23. package/dist/cli/reindex.js +23 -60
  24. package/dist/cli/reindex.js.map +1 -1
  25. package/dist/cli/reindex.test.d.ts +2 -2
  26. package/dist/cli/reindex.test.js +60 -109
  27. package/dist/cli/reindex.test.js.map +1 -1
  28. package/dist/cli/rescue.reindex.test.js +3 -2
  29. package/dist/cli/rescue.reindex.test.js.map +1 -1
  30. package/dist/cli/share.d.ts.map +1 -1
  31. package/dist/cli/share.js +12 -1
  32. package/dist/cli/share.js.map +1 -1
  33. package/dist/cli/share.test.js +14 -0
  34. package/dist/cli/share.test.js.map +1 -1
  35. package/dist/cli/sync.d.ts.map +1 -1
  36. package/dist/cli/sync.js +9 -1
  37. package/dist/cli/sync.js.map +1 -1
  38. package/dist/cli/sync.test.js +8 -0
  39. package/dist/cli/sync.test.js.map +1 -1
  40. package/dist/cognito-auth.d.ts +44 -12
  41. package/dist/cognito-auth.d.ts.map +1 -1
  42. package/dist/cognito-auth.js +440 -76
  43. package/dist/cognito-auth.js.map +1 -1
  44. package/dist/cognito-auth.test.js +174 -0
  45. package/dist/cognito-auth.test.js.map +1 -1
  46. package/dist/index.d.ts +1 -1
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +3 -2
  49. package/dist/index.js.map +1 -1
  50. package/dist/machine-auth.test.js +683 -5
  51. package/dist/machine-auth.test.js.map +1 -1
  52. package/dist/vault-client.d.ts +2 -1
  53. package/dist/vault-client.d.ts.map +1 -1
  54. package/dist/vault-client.js +45 -9
  55. package/dist/vault-client.js.map +1 -1
  56. package/dist/vault-client.test.js +77 -7
  57. package/dist/vault-client.test.js.map +1 -1
  58. package/package.json +2 -1
  59. package/src/bin/sync-runner-company.ts +5 -0
  60. package/src/bin/sync-runner-planning.ts +2 -1
  61. package/src/bin/sync-runner-watch-loop.ts +51 -4
  62. package/src/bin/sync-runner-watch-routes.ts +2 -0
  63. package/src/bin/sync-runner.test.ts +244 -4
  64. package/src/bin/sync-runner.ts +99 -32
  65. package/src/cli/reindex.test.ts +63 -120
  66. package/src/cli/reindex.ts +23 -62
  67. package/src/cli/rescue.reindex.test.ts +3 -2
  68. package/src/cli/share.test.ts +20 -0
  69. package/src/cli/share.ts +7 -1
  70. package/src/cli/sync.test.ts +14 -0
  71. package/src/cli/sync.ts +5 -1
  72. package/src/cognito-auth.test.ts +227 -0
  73. package/src/cognito-auth.ts +533 -71
  74. package/src/index.ts +5 -1
  75. package/src/machine-auth.test.ts +808 -5
  76. package/src/vault-client.test.ts +100 -11
  77. package/src/vault-client.ts +55 -10
  78. package/test/share-sync.integration.test.ts +12 -9
@@ -68,18 +68,89 @@ export class CognitoAuthError extends Error {
68
68
  }
69
69
  }
70
70
 
71
+ /**
72
+ * A failed refresh attempt with enough classification for callers to decide
73
+ * whether the cached session is still worth preserving. Network/5xx/429
74
+ * failures remain recoverable; an invalid/expired refresh token means the
75
+ * stored session can no longer make progress and should be cleared.
76
+ */
77
+ export class CognitoRefreshError extends CognitoAuthError {
78
+ public readonly requiresReauth: boolean;
79
+ public readonly statusCode?: number;
80
+ public readonly sessionFingerprint?: string;
81
+
82
+ constructor(
83
+ message: string,
84
+ requiresReauth: boolean,
85
+ statusCode?: number,
86
+ sessionFingerprint?: string,
87
+ ) {
88
+ super(message);
89
+ this.name = "CognitoRefreshError";
90
+ this.requiresReauth = requiresReauth;
91
+ this.statusCode = statusCode;
92
+ this.sessionFingerprint = sessionFingerprint;
93
+ }
94
+ }
95
+
71
96
  // ---------------------------------------------------------------------------
72
97
  // Token cache (~/.hq/cognito-tokens.json)
73
98
  // ---------------------------------------------------------------------------
74
99
 
75
100
  const HQ_DIR = path.join(os.homedir(), ".hq");
76
101
  const TOKEN_FILE = path.join(HQ_DIR, "cognito-tokens.json");
102
+ const TOKEN_INVALIDATION_PREFIX = `${TOKEN_FILE}.invalid.`;
103
+
104
+ /** Stable, non-secret identity for one access-token generation. */
105
+ export function accessTokenFingerprint(accessToken: string): string {
106
+ return crypto.createHash("sha256").update(accessToken).digest("hex");
107
+ }
108
+
109
+ function invalidationPathForFingerprint(fingerprint: string): string | null {
110
+ return /^[a-f0-9]{64}$/i.test(fingerprint)
111
+ ? `${TOKEN_INVALIDATION_PREFIX}${fingerprint.toLowerCase()}`
112
+ : null;
113
+ }
114
+
115
+ /**
116
+ * Mark only the rejected token generation unusable. Automatic auth failures
117
+ * must not unlink the shared cache: another process may have completed a
118
+ * refresh or login after the failing request started.
119
+ */
120
+ export function invalidateCachedTokensByFingerprint(
121
+ fingerprint: string | undefined,
122
+ ): void {
123
+ if (!fingerprint) return;
124
+ const invalidationPath = invalidationPathForFingerprint(fingerprint);
125
+ if (!invalidationPath) return;
126
+ if (!fs.existsSync(HQ_DIR)) {
127
+ fs.mkdirSync(HQ_DIR, { recursive: true, mode: 0o700 });
128
+ }
129
+ try {
130
+ fs.writeFileSync(invalidationPath, "", { mode: 0o600, flag: "wx" });
131
+ } catch (err) {
132
+ const code =
133
+ err && typeof err === "object" && "code" in err
134
+ ? (err as { code?: string }).code
135
+ : undefined;
136
+ if (code !== "EEXIST") throw err;
137
+ }
138
+ }
139
+
140
+ function tokenIsInvalidated(accessToken: string): boolean {
141
+ const marker = invalidationPathForFingerprint(
142
+ accessTokenFingerprint(accessToken),
143
+ );
144
+ return marker !== null && fs.existsSync(marker);
145
+ }
77
146
 
78
147
  export function loadCachedTokens(): CognitoTokens | null {
79
148
  if (!fs.existsSync(TOKEN_FILE)) return null;
80
149
  try {
81
150
  const raw = fs.readFileSync(TOKEN_FILE, "utf-8");
82
- return JSON.parse(raw) as CognitoTokens;
151
+ const tokens = JSON.parse(raw) as CognitoTokens;
152
+ if (!tokens.accessToken || tokenIsInvalidated(tokens.accessToken)) return null;
153
+ return tokens;
83
154
  } catch {
84
155
  return null;
85
156
  }
@@ -89,6 +160,12 @@ export function saveCachedTokens(tokens: CognitoTokens): void {
89
160
  if (!fs.existsSync(HQ_DIR)) {
90
161
  fs.mkdirSync(HQ_DIR, { recursive: true, mode: 0o700 });
91
162
  }
163
+ const invalidationPath = invalidationPathForFingerprint(
164
+ accessTokenFingerprint(tokens.accessToken),
165
+ );
166
+ if (invalidationPath && fs.existsSync(invalidationPath)) {
167
+ fs.unlinkSync(invalidationPath);
168
+ }
92
169
  const tmpPath = path.join(HQ_DIR, `.cognito-tokens.json.tmp.${process.pid}`);
93
170
  fs.writeFileSync(tmpPath, JSON.stringify(tokens, null, 2), { mode: 0o600 });
94
171
  fs.renameSync(tmpPath, TOKEN_FILE);
@@ -96,6 +173,128 @@ export function saveCachedTokens(tokens: CognitoTokens): void {
96
173
 
97
174
  export function clearCachedTokens(): void {
98
175
  if (fs.existsSync(TOKEN_FILE)) fs.unlinkSync(TOKEN_FILE);
176
+ if (!fs.existsSync(HQ_DIR)) return;
177
+ const markerPrefix = `${path.basename(TOKEN_FILE)}.invalid.`;
178
+ for (const entry of fs.readdirSync(HQ_DIR)) {
179
+ if (entry.startsWith(markerPrefix)) {
180
+ fs.unlinkSync(path.join(HQ_DIR, entry));
181
+ }
182
+ }
183
+ }
184
+
185
+ /** Advisory lockfile guarding the read→refresh→save cycle (see withRefreshLock). */
186
+ const REFRESH_LOCK_FILE = `${TOKEN_FILE}.lock`;
187
+
188
+ /** Thrown when the refresh lock can't be acquired within the deadline. */
189
+ export class RefreshLockTimeoutError extends Error {
190
+ constructor() {
191
+ super("timed out acquiring the token refresh lock");
192
+ this.name = "RefreshLockTimeoutError";
193
+ }
194
+ }
195
+
196
+ /** The pid recorded in the lockfile, or null if absent/empty/malformed. */
197
+ function readLockOwnerPid(): number | null {
198
+ try {
199
+ const pid = Number.parseInt(fs.readFileSync(REFRESH_LOCK_FILE, "utf-8").trim(), 10);
200
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
201
+ } catch {
202
+ return null;
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Whether `pid` is a live process. All contenders for this lock share one host
208
+ * (the cache is `~/.hq`), so pid liveness is authoritative. `EPERM` means the
209
+ * process exists but is owned by another user — still alive; any other error
210
+ * (`ESRCH`, bad pid) means it's gone.
211
+ */
212
+ function processAlive(pid: number): boolean {
213
+ try {
214
+ process.kill(pid, 0);
215
+ return true;
216
+ } catch (err) {
217
+ return (err as NodeJS.ErrnoException).code === "EPERM";
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Serialize the token refresh/mint cycle across every process sharing this
223
+ * cache (~/.hq/cognito-tokens.json). Boxes run several hq processes (the
224
+ * auth-refresh timer, the sync runner, replica-sync) that refresh or mint the
225
+ * same session concurrently; without coordination they race on the shared
226
+ * cache. And if the app client ever issues single-use tokens (refresh-token
227
+ * rotation), two unsynchronized refreshers each send the same token — the first
228
+ * consumes it, the second gets `invalid_grant`. An advisory lockfile (atomic
229
+ * hard-link publication) makes them take turns; the caller MUST re-read the cache
230
+ * inside `fn` so a waiter reuses the winner's fresh token, never a consumed one.
231
+ * This is also the lock the per-box machine-identity mint path reuses (ADR-0009).
232
+ *
233
+ * Correctness over availability at the edges:
234
+ * - A held lock is reclaimed ONLY when its recorded holder pid is dead (a real
235
+ * crash) or the lockfile is malformed — NEVER because a live holder is merely
236
+ * slow (a slow Cognito response must not let a second refresher race in).
237
+ * - Release unlinks ONLY our own lock (pid re-checked), so we can't delete a
238
+ * successor's lock.
239
+ * - If we can't acquire within `timeoutMs` (a live holder never released — a
240
+ * hung network), we THROW rather than refresh unlocked; the caller prefers a
241
+ * still-valid cached token and otherwise retries next cycle. Proceeding
242
+ * unlocked would reintroduce the exact rotation race this guards.
243
+ */
244
+ async function withRefreshLock<T>(fn: () => Promise<T>): Promise<T> {
245
+ const timeoutMs = 20_000;
246
+ const pollMs = 50;
247
+ if (!fs.existsSync(HQ_DIR)) {
248
+ fs.mkdirSync(HQ_DIR, { recursive: true, mode: 0o700 });
249
+ }
250
+ // Publish a fully-written inode with an atomic hard link. Creating the lock
251
+ // itself and then writing the pid leaves an observable empty-file window in
252
+ // which a contender can misclassify and unlink a live holder's lock.
253
+ const candidate = `${REFRESH_LOCK_FILE}.candidate.${process.pid}.${crypto.randomBytes(8).toString("hex")}`;
254
+ fs.writeFileSync(candidate, String(process.pid), {
255
+ flag: "wx",
256
+ mode: 0o600,
257
+ });
258
+ const deadline = Date.now() + timeoutMs;
259
+ let held = false;
260
+ try {
261
+ for (;;) {
262
+ try {
263
+ fs.linkSync(candidate, REFRESH_LOCK_FILE);
264
+ held = true;
265
+ break;
266
+ } catch (err) {
267
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
268
+ const owner = readLockOwnerPid();
269
+ if (owner === null || (owner !== process.pid && !processAlive(owner))) {
270
+ try {
271
+ fs.unlinkSync(REFRESH_LOCK_FILE); // crashed/malformed holder — reclaim
272
+ } catch {
273
+ /* raced with another reclaimer */
274
+ }
275
+ continue;
276
+ }
277
+ if (Date.now() >= deadline) throw new RefreshLockTimeoutError();
278
+ await sleep(pollMs);
279
+ }
280
+ }
281
+ return await fn();
282
+ } finally {
283
+ // Release ONLY our own lock — never clobber a successor's.
284
+ if (held && readLockOwnerPid() === process.pid) {
285
+ try {
286
+ fs.unlinkSync(REFRESH_LOCK_FILE);
287
+ } catch {
288
+ /* already released */
289
+ }
290
+ }
291
+ // Remove the private proposal inode name on success or acquisition failure.
292
+ try {
293
+ fs.unlinkSync(candidate);
294
+ } catch {
295
+ /* already removed */
296
+ }
297
+ }
99
298
  }
100
299
 
101
300
  /**
@@ -155,7 +354,7 @@ function decodeJwtClaims(token: string): Record<string, unknown> | null {
155
354
  }
156
355
  }
157
356
 
158
- function cachedTokensMatchMachineIdentity(
357
+ function machineTokenPairMatchesIdentity(
159
358
  tokens: CognitoTokens,
160
359
  creds: MachineCreds,
161
360
  expectedClientId: string,
@@ -181,8 +380,25 @@ function cachedTokensMatchMachineIdentity(
181
380
  const idUsername =
182
381
  stringClaim(idClaims, "cognito:username") ?? stringClaim(idClaims, "username");
183
382
  const idSub = stringClaim(idClaims, "sub");
184
- const entityType = idClaims["custom:entityType"];
185
- const entityUid = idClaims["custom:entityUid"];
383
+ const entityType = stringClaim(idClaims, "custom:entityType");
384
+ const entityUid = stringClaim(idClaims, "custom:entityUid");
385
+ const delegatedSub = stringClaim(idClaims, "custom:delegatedSub");
386
+ const delegatedEmail = stringClaim(idClaims, "custom:delegatedEmail");
387
+ const hasValidMachineClaimShape =
388
+ (entityType === "agent" &&
389
+ entityUid !== null &&
390
+ entityUid.startsWith("agt_")) ||
391
+ (entityType === "outpost" &&
392
+ entityUid !== null &&
393
+ entityUid.startsWith("out_") &&
394
+ delegatedSub !== null &&
395
+ delegatedEmail !== null);
396
+ // Legacy agent credentials did not persist entityType. If a newer creds
397
+ // file adds only its agt_* uid, infer the only valid type for that uid.
398
+ const expectedEntityType = creds.entityType ?? "agent";
399
+ const entityMatchesCreds =
400
+ entityType === expectedEntityType &&
401
+ (creds.entityUid === undefined || entityUid === creds.entityUid);
186
402
  const idTokenMatchesCreds =
187
403
  idUsername === creds.username || idSub === creds.username;
188
404
  const subjectBindings: boolean[] = [];
@@ -200,33 +416,32 @@ function cachedTokensMatchMachineIdentity(
200
416
  accessClientId === expectedClientId &&
201
417
  idClaims.token_use === "id" &&
202
418
  idAudience === expectedClientId &&
203
- entityType === "agent" &&
204
- typeof entityUid === "string" &&
205
- entityUid.startsWith("agt_") &&
419
+ hasValidMachineClaimShape &&
420
+ entityMatchesCreds &&
206
421
  idTokenMatchesCreds &&
207
422
  tokensShareSubject
208
423
  );
209
424
  }
210
425
 
211
426
  // ---------------------------------------------------------------------------
212
- // Machine identity (company agents)
427
+ // Machine identity (company agents and Outpost boxes)
213
428
  // ---------------------------------------------------------------------------
214
429
  //
215
- // HQ company agents run headless on their own boxes with long-lived Cognito
430
+ // HQ agents and Outposts run headless on their own boxes with long-lived Cognito
216
431
  // MACHINE credentials ({username: "machine-agt_<ulid>", secret}) provisioned
217
432
  // by hq-pro's agent bootstrap and stored at ~/.hq-agent/machine-creds.json.
218
433
  // There is no browser, no Hosted UI, and no refresh-token dance: the creds
219
434
  // never expire, so the CLI simply re-mints a session via USER_PASSWORD_AUTH
220
435
  // whenever the cached tokens are missing or expiring.
221
436
  //
222
- // Token semantics matter here. The agent's identity claims
223
- // (custom:entityType=agent, custom:entityUid=agt_*) ride the ID token only;
224
- // APIs that verify token_use=access (e.g. hq-deploy) need the real access
225
- // token. Both are cached with correct field semantics callers pick the
226
- // token type each API actually validates.
437
+ // Token semantics matter here. Machine identity claims ride the ID token only:
438
+ // agents carry custom:entityType=agent/custom:entityUid=agt_*, while Outposts
439
+ // carry outpost/out_* plus delegatedSub and delegatedEmail. APIs that verify
440
+ // token_use=access (e.g. hq-deploy) need the real access token. Both are cached
441
+ // with correct field semantics — callers pick the token type each API validates.
227
442
 
228
443
  export interface MachineCreds {
229
- /** Cognito username (live boxes: "agt-<ulid>@agents.getindigo.ai"). */
444
+ /** Cognito username for the machine user. */
230
445
  username: string;
231
446
  /** Long-lived machine secret (USER_PASSWORD_AUTH password). */
232
447
  secret: string;
@@ -235,6 +450,10 @@ export interface MachineCreds {
235
450
  clientId?: string;
236
451
  /** Cognito region for the mint endpoint; falls back to config. */
237
452
  region?: string;
453
+ /** Expected machine kind carried in the ID token's custom:entityType claim. */
454
+ entityType?: "agent" | "outpost";
455
+ /** Exact expected machine uid carried in custom:entityUid. */
456
+ entityUid?: string;
238
457
  }
239
458
 
240
459
  /** Resolve the machine-creds file path (HQ_MACHINE_CREDS_FILE overrides). */
@@ -258,22 +477,45 @@ export function loadMachineCreds(): MachineCreds | null {
258
477
  secret?: unknown;
259
478
  clientId?: unknown;
260
479
  region?: unknown;
480
+ entityType?: unknown;
481
+ entityUid?: unknown;
261
482
  };
262
483
  // The creds FILE is the machine-identity signal — no username-shape
263
484
  // gate. (6.7.0 required a "machine-" prefix, but live boxes are
264
485
  // provisioned with "agt-<ulid>@agents.getindigo.ai", so detection
265
486
  // never engaged on the first real agent box, 2026-06-12.)
487
+ const validEntityIdentity =
488
+ (raw.entityType === undefined && raw.entityUid === undefined) ||
489
+ (raw.entityType === undefined &&
490
+ typeof raw.entityUid === "string" &&
491
+ raw.entityUid.startsWith("agt_") &&
492
+ raw.entityUid.length > "agt_".length) ||
493
+ (raw.entityType === "agent" &&
494
+ typeof raw.entityUid === "string" &&
495
+ raw.entityUid.startsWith("agt_") &&
496
+ raw.entityUid.length > "agt_".length) ||
497
+ (raw.entityType === "outpost" &&
498
+ typeof raw.entityUid === "string" &&
499
+ raw.entityUid.startsWith("out_") &&
500
+ raw.entityUid.length > "out_".length);
266
501
  if (
267
502
  typeof raw.username === "string" &&
268
503
  raw.username.length > 0 &&
269
504
  typeof raw.secret === "string" &&
270
- raw.secret.length > 0
505
+ raw.secret.length > 0 &&
506
+ validEntityIdentity
271
507
  ) {
272
508
  return {
273
509
  username: raw.username,
274
510
  secret: raw.secret,
275
511
  clientId: typeof raw.clientId === "string" ? raw.clientId : undefined,
276
512
  region: typeof raw.region === "string" ? raw.region : undefined,
513
+ ...(raw.entityType === "agent" || raw.entityType === "outpost"
514
+ ? { entityType: raw.entityType }
515
+ : {}),
516
+ ...(typeof raw.entityUid === "string"
517
+ ? { entityUid: raw.entityUid }
518
+ : {}),
277
519
  };
278
520
  }
279
521
  return null;
@@ -352,7 +594,7 @@ function backoffDelayMs(
352
594
  * full jitter (transient, per-user rate limit); every other non-OK response
353
595
  * (bad creds, disabled flow) is a hard error surfaced immediately.
354
596
  */
355
- export async function mintMachineTokens(
597
+ async function mintMachineTokensUnlocked(
356
598
  config: CognitoAuthConfig,
357
599
  creds?: MachineCreds,
358
600
  retry?: MintRetryOptions,
@@ -417,11 +659,16 @@ export async function mintMachineTokens(
417
659
  accessToken: result.AccessToken,
418
660
  idToken: result.IdToken,
419
661
  // Machine creds never expire — expiry is handled by re-minting, so the
420
- // refresh token (when Cognito returns one at all) is never exercised.
421
- refreshToken: result.RefreshToken ?? "",
662
+ // refresh token (when Cognito returns one at all) is never retained.
663
+ refreshToken: "",
422
664
  expiresAt: Date.now() + (result.ExpiresIn ?? 3600) * 1000,
423
665
  tokenType: "Bearer",
424
666
  };
667
+ if (!machineTokenPairMatchesIdentity(tokens, machineCreds, clientId)) {
668
+ throw new CognitoAuthError(
669
+ "Machine token mint returned tokens that do not match the requested machine identity",
670
+ );
671
+ }
425
672
  saveCachedTokens(tokens);
426
673
  return tokens;
427
674
  }
@@ -430,6 +677,19 @@ export async function mintMachineTokens(
430
677
  throw lastError ?? new CognitoAuthError("Machine token mint failed: throttled");
431
678
  }
432
679
 
680
+ /**
681
+ * Mint and persist a fresh machine session while holding the process-shared
682
+ * token lock. The unlocked implementation is private so public callers cannot
683
+ * race another process's cache write.
684
+ */
685
+ export async function mintMachineTokens(
686
+ config: CognitoAuthConfig,
687
+ creds?: MachineCreds,
688
+ retry?: MintRetryOptions,
689
+ ): Promise<CognitoTokens> {
690
+ return withRefreshLock(() => mintMachineTokensUnlocked(config, creds, retry));
691
+ }
692
+
433
693
  // In-process machine-token memo (sync-runner mint-storm fix).
434
694
  //
435
695
  // `getValidAccessToken` runs on EVERY vault request, and a busy sync fans out
@@ -445,10 +705,37 @@ export async function mintMachineTokens(
445
705
  // read and no network — a 300-file sync does ONE mint, not one-per-request;
446
706
  // - single-flight: a concurrent burst of cache-misses (the run-start fan-out)
447
707
  // collapses onto a single in-flight mint instead of one mint per caller.
448
- // One machine identity per process is assumed (true for agent boxes), so a
449
- // single slot suffices.
450
- let inProcessMachineTokens: CognitoTokens | null = null;
451
- let inFlightMachineMint: Promise<CognitoTokens> | null = null;
708
+ // The memo carries an exact credential/client key, and in-flight work is keyed,
709
+ // so reprovisioning a long-running process cannot reuse the prior identity.
710
+ interface MachineTokenMemo {
711
+ identityKey: string;
712
+ tokens: CognitoTokens;
713
+ }
714
+
715
+ let inProcessMachineTokens: MachineTokenMemo | null = null;
716
+ const inFlightMachineMints = new Map<string, Promise<CognitoTokens>>();
717
+
718
+ function machineIdentityKey(
719
+ config: CognitoAuthConfig,
720
+ creds: MachineCreds,
721
+ ): string {
722
+ // Include every credential value that can change the effective mint. Hashing
723
+ // keeps the long-lived secret out of diagnostic heap strings while retaining
724
+ // exact-key behavior when a creds file is reprovisioned in place.
725
+ return crypto
726
+ .createHash("sha256")
727
+ .update(
728
+ JSON.stringify({
729
+ username: creds.username,
730
+ secret: creds.secret,
731
+ clientId: creds.clientId ?? config.clientId,
732
+ region: creds.region ?? config.region,
733
+ entityType: creds.entityType ?? "agent",
734
+ entityUid: creds.entityUid ?? null,
735
+ }),
736
+ )
737
+ .digest("hex");
738
+ }
452
739
 
453
740
  /**
454
741
  * Return a valid (non-expiring) machine session, re-minting on demand.
@@ -458,40 +745,99 @@ let inFlightMachineMint: Promise<CognitoTokens> | null = null;
458
745
  export async function getValidMachineTokens(
459
746
  config: CognitoAuthConfig,
460
747
  ): Promise<CognitoTokens> {
748
+ const machineCreds = loadMachineCreds();
749
+ if (!machineCreds) {
750
+ throw new CognitoAuthError(
751
+ `No machine credentials found at ${machineCredsFilePath()}`,
752
+ );
753
+ }
754
+ const expectedClientId = machineCreds.clientId ?? config.clientId;
755
+ const identityKey = machineIdentityKey(config, machineCreds);
461
756
  // Hot path: a live in-memory token serves every request after the first.
462
- if (inProcessMachineTokens && !isExpiring(inProcessMachineTokens, 120)) {
463
- return inProcessMachineTokens;
757
+ if (
758
+ inProcessMachineTokens?.identityKey === identityKey &&
759
+ !isExpiring(inProcessMachineTokens.tokens, 120) &&
760
+ machineTokenPairMatchesIdentity(
761
+ inProcessMachineTokens.tokens,
762
+ machineCreds,
763
+ expectedClientId,
764
+ )
765
+ ) {
766
+ return inProcessMachineTokens.tokens;
464
767
  }
465
768
  // Single-flight: collapse a concurrent burst of misses onto one resolution.
466
769
  // The assignment below completes before the first `await` yields, so sibling
467
770
  // callers in the same tick see the in-flight promise and reuse it.
468
- if (inFlightMachineMint) return inFlightMachineMint;
771
+ const existingResolution = inFlightMachineMints.get(identityKey);
772
+ if (existingResolution) return existingResolution;
773
+
774
+ const resolution = (async () => {
775
+ const resolveUnderLock = async (): Promise<CognitoTokens> => {
776
+ // Re-read only after acquiring the process-shared lock. A different hq
777
+ // process may have minted and saved this machine's session while this
778
+ // caller was waiting.
779
+ const cached = loadCachedTokens();
780
+ if (cached && !isExpiring(cached, 120)) {
781
+ // Compare against the client we'd actually mint with (creds-file
782
+ // clientId wins over config), and require the cached ID token to prove
783
+ // the exact machine identity. Opaque/missing/human-shaped claims are
784
+ // treated as stale.
785
+ if (
786
+ machineTokenPairMatchesIdentity(
787
+ cached,
788
+ machineCreds,
789
+ expectedClientId,
790
+ )
791
+ ) {
792
+ if (cached.refreshToken === "") return cached;
793
+ const normalized = { ...cached, refreshToken: "" };
794
+ saveCachedTokens(normalized);
795
+ return normalized;
796
+ }
797
+ }
798
+ // mintMachineTokens persists before returning, so the complete shared
799
+ // cache read -> USER_PASSWORD_AUTH mint -> save sequence is serialized.
800
+ return mintMachineTokensUnlocked(config, machineCreds);
801
+ };
469
802
 
470
- inFlightMachineMint = (async () => {
471
- const machineCreds = loadMachineCreds();
472
- const cached = loadCachedTokens();
473
- if (machineCreds && cached && !isExpiring(cached, 120)) {
474
- // Compare against the client we'd actually mint with (creds-file clientId
475
- // wins over config), and require the cached ID token to prove this exact
476
- // agent identity. Opaque/missing/human-shaped claims are treated as stale.
477
- const expectedClientId = machineCreds.clientId ?? config.clientId;
478
- if (
479
- cachedTokensMatchMachineIdentity(cached, machineCreds, expectedClientId)
480
- ) {
481
- return cached;
803
+ try {
804
+ return await withRefreshLock(resolveUnderLock);
805
+ } catch (err) {
806
+ if (!(err instanceof RefreshLockTimeoutError)) throw err;
807
+ // Never mint unlocked. If the holder managed to persist a matching fresh
808
+ // session, reuse it; otherwise surface a retryable auth failure.
809
+ const cached = loadCachedTokens();
810
+ if (cached && !isExpiring(cached, 120)) {
811
+ if (
812
+ machineTokenPairMatchesIdentity(
813
+ cached,
814
+ machineCreds,
815
+ expectedClientId,
816
+ )
817
+ ) {
818
+ // The successful path always leaves a machine cache with no refresh
819
+ // token. If this is legacy state, only normalize it while holding the
820
+ // lock; after a timeout we must not race the live holder's write.
821
+ if (cached.refreshToken === "") return cached;
822
+ }
482
823
  }
824
+ throw new CognitoAuthError(
825
+ "Machine token mint is held by another process and no fresh matching session is available; retry shortly.",
826
+ );
483
827
  }
484
- return mintMachineTokens(config, machineCreds ?? undefined);
485
828
  })();
829
+ inFlightMachineMints.set(identityKey, resolution);
486
830
 
487
831
  try {
488
- const tokens = await inFlightMachineMint;
489
- inProcessMachineTokens = tokens;
832
+ const tokens = await resolution;
833
+ inProcessMachineTokens = { identityKey, tokens };
490
834
  return tokens;
491
835
  } finally {
492
836
  // Clear on both success and failure: a rejected mint must not wedge later
493
837
  // calls onto a permanently-failed promise.
494
- inFlightMachineMint = null;
838
+ if (inFlightMachineMints.get(identityKey) === resolution) {
839
+ inFlightMachineMints.delete(identityKey);
840
+ }
495
841
  }
496
842
  }
497
843
 
@@ -700,42 +1046,146 @@ async function exchangeCodeForTokens(
700
1046
  };
701
1047
  }
702
1048
 
1049
+ /** Whether the cached session was minted by `config`'s app client. */
1050
+ function cachedIsForClient(
1051
+ cached: CognitoTokens,
1052
+ config: CognitoAuthConfig,
1053
+ ): boolean {
1054
+ const clientId = decodeAccessTokenClientId(cached.accessToken);
1055
+ return clientId !== null && clientId === config.clientId;
1056
+ }
1057
+
703
1058
  /**
704
1059
  * Use the refresh token to obtain a fresh access token without user interaction.
705
- * The refresh token itself is NOT rotated by Cognito on the refresh grant, so
706
- * we preserve the existing one in the result.
1060
+ *
1061
+ * Concurrency-safe. Several hq processes on a box refresh the same cached
1062
+ * session at once, so the whole read→refresh→save cycle runs under a
1063
+ * cross-process lock, and inside the lock we (a) reuse a token another refresher
1064
+ * just produced if it's still fresh, and (b) otherwise refresh with the FRESHEST
1065
+ * token on disk rather than the caller's snapshot. This keeps the shared cache
1066
+ * consistent, and stays correct if the app client uses single-use (rotated)
1067
+ * tokens — a stale snapshot would otherwise be an already-consumed token
1068
+ * (`invalid_grant`). NOTE: rotation is NOT how boxes stay signed in — a rotated
1069
+ * token inherits the original's remaining lifetime, so it does not extend the
1070
+ * session (AWS). Durable never-log-out is per-box machine identity (ADR-0009).
707
1071
  */
708
1072
  export async function refreshTokens(
709
1073
  config: CognitoAuthConfig,
710
1074
  currentRefreshToken: string,
1075
+ currentAccessToken?: string,
711
1076
  ): Promise<CognitoTokens> {
712
- const body = new URLSearchParams({
713
- grant_type: "refresh_token",
714
- client_id: config.clientId,
715
- refresh_token: currentRefreshToken,
716
- });
1077
+ const doRefresh = async (): Promise<CognitoTokens> => {
1078
+ const cached = loadCachedTokens();
1079
+ // Reuse a token another refresher just produced — but ONLY when it belongs
1080
+ // to THIS app client (a direct caller may target a different pool than the
1081
+ // shared cache holds) and is still fresh (a fresh access token has ~1h, so
1082
+ // >300s of life means "someone already refreshed", not the caller's own
1083
+ // expiring token).
1084
+ if (cached && cachedIsForClient(cached, config) && !isExpiring(cached, 300)) {
1085
+ return cached;
1086
+ }
717
1087
 
718
- const res = await fetch(`${authBaseUrl(config)}/oauth2/token`, {
719
- method: "POST",
720
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
721
- body: body.toString(),
722
- });
723
- if (!res.ok) {
724
- const text = await res.text();
725
- throw new CognitoAuthError(
726
- `Refresh failed (${res.status}): ${text}`,
1088
+ // Prefer the freshest refresh token on disk over the caller's snapshot when
1089
+ // it's for this client — a concurrent rotation may have consumed the
1090
+ // snapshot. Otherwise (different client / no cache) use the supplied token.
1091
+ const refreshToken =
1092
+ cached && cachedIsForClient(cached, config)
1093
+ ? cached.refreshToken
1094
+ : currentRefreshToken;
1095
+ const sessionFingerprint = cached && cachedIsForClient(cached, config)
1096
+ ? accessTokenFingerprint(cached.accessToken)
1097
+ : currentAccessToken
1098
+ ? accessTokenFingerprint(currentAccessToken)
1099
+ : undefined;
1100
+
1101
+ const body = new URLSearchParams({
1102
+ grant_type: "refresh_token",
1103
+ client_id: config.clientId,
1104
+ refresh_token: refreshToken,
1105
+ });
1106
+
1107
+ let lastFailure: CognitoRefreshError | undefined;
1108
+ for (let attempt = 0; attempt < 2; attempt++) {
1109
+ let res: Response;
1110
+ try {
1111
+ res = await fetch(`${authBaseUrl(config)}/oauth2/token`, {
1112
+ method: "POST",
1113
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1114
+ body: body.toString(),
1115
+ });
1116
+ } catch (err) {
1117
+ lastFailure = new CognitoRefreshError(
1118
+ `Cognito refresh request failed: ${err instanceof Error ? err.message : String(err)}`,
1119
+ false,
1120
+ undefined,
1121
+ sessionFingerprint,
1122
+ );
1123
+ if (attempt === 0) continue;
1124
+ throw lastFailure;
1125
+ }
1126
+
1127
+ if (!res.ok) {
1128
+ const text = await res.text();
1129
+ const retryable =
1130
+ res.status === 401 ||
1131
+ res.status === 408 ||
1132
+ res.status === 429 ||
1133
+ res.status >= 500;
1134
+ const requiresReauth =
1135
+ res.status === 401 ||
1136
+ (res.status >= 400 &&
1137
+ res.status < 500 &&
1138
+ ![408, 429].includes(res.status));
1139
+ lastFailure = new CognitoRefreshError(
1140
+ requiresReauth
1141
+ ? "Your HQ session needs a quick refresh. Sign in again to keep sync moving."
1142
+ : `Cognito refresh failed (${res.status}): ${text}`,
1143
+ requiresReauth,
1144
+ res.status,
1145
+ sessionFingerprint,
1146
+ );
1147
+ if (retryable && attempt === 0) continue;
1148
+ throw lastFailure;
1149
+ }
1150
+
1151
+ const data = (await res.json()) as CognitoTokenResponse;
1152
+ const tokens: CognitoTokens = {
1153
+ accessToken: data.access_token,
1154
+ idToken: data.id_token,
1155
+ // Persist a rotated refresh token if the endpoint returns one (single-use
1156
+ // clients); otherwise keep the freshest token we sent. This is for cache
1157
+ // correctness, not longevity; rotation does not extend session lifetime.
1158
+ refreshToken: data.refresh_token ?? refreshToken,
1159
+ expiresAt: Date.now() + data.expires_in * 1000,
1160
+ tokenType: "Bearer",
1161
+ };
1162
+ saveCachedTokens(tokens);
1163
+ return tokens;
1164
+ }
1165
+
1166
+ throw (
1167
+ lastFailure ??
1168
+ new CognitoRefreshError("Cognito refresh failed after retry", false)
727
1169
  );
728
- }
729
- const data = (await res.json()) as CognitoTokenResponse;
730
- const tokens: CognitoTokens = {
731
- accessToken: data.access_token,
732
- idToken: data.id_token,
733
- refreshToken: data.refresh_token ?? currentRefreshToken,
734
- expiresAt: Date.now() + data.expires_in * 1000,
735
- tokenType: "Bearer",
736
1170
  };
737
- saveCachedTokens(tokens);
738
- return tokens;
1171
+
1172
+ try {
1173
+ return await withRefreshLock(doRefresh);
1174
+ } catch (err) {
1175
+ if (!(err instanceof RefreshLockTimeoutError)) throw err;
1176
+ // A live holder never released within the window (pathological — e.g. a
1177
+ // hung network). Prefer whatever it may have persisted for this client; do
1178
+ // NOT refresh unlocked (that reintroduces the rotation race). If nothing
1179
+ // usable is cached, surface the failure so the caller retries next cycle.
1180
+ const cached = loadCachedTokens();
1181
+ if (cached && cachedIsForClient(cached, config) && !isExpiring(cached)) {
1182
+ return cached;
1183
+ }
1184
+ throw new CognitoRefreshError(
1185
+ "Token refresh is held by another process and no fresh cached session is available; retry shortly.",
1186
+ false,
1187
+ );
1188
+ }
739
1189
  }
740
1190
 
741
1191
  /**
@@ -778,7 +1228,9 @@ export async function getValidAccessToken(
778
1228
  if (cached) {
779
1229
  const cachedClientId = decodeAccessTokenClientId(cached.accessToken);
780
1230
  if (cachedClientId !== null && cachedClientId !== config.clientId) {
781
- clearCachedTokens();
1231
+ invalidateCachedTokensByFingerprint(
1232
+ accessTokenFingerprint(cached.accessToken),
1233
+ );
782
1234
  cached = null;
783
1235
  }
784
1236
  }
@@ -787,9 +1239,19 @@ export async function getValidAccessToken(
787
1239
 
788
1240
  if (cached) {
789
1241
  try {
790
- const refreshed = await refreshTokens(config, cached.refreshToken);
1242
+ const refreshed = await refreshTokens(
1243
+ config,
1244
+ cached.refreshToken,
1245
+ cached.accessToken,
1246
+ );
791
1247
  return refreshed.accessToken;
792
- } catch {
1248
+ } catch (err) {
1249
+ if (err instanceof CognitoRefreshError && err.requiresReauth) {
1250
+ invalidateCachedTokensByFingerprint(
1251
+ err.sessionFingerprint ?? accessTokenFingerprint(cached.accessToken),
1252
+ );
1253
+ }
1254
+ if (!interactive) throw err;
793
1255
  // fall through to interactive login
794
1256
  }
795
1257
  }