@indigoai-us/hq-cloud 6.12.2 → 6.12.4
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.
- package/dist/bin/sync-runner-events.d.ts +18 -0
- package/dist/bin/sync-runner-events.d.ts.map +1 -1
- package/dist/bin/sync-runner-events.js +26 -0
- package/dist/bin/sync-runner-events.js.map +1 -1
- package/dist/bin/sync-runner-events.test.d.ts +2 -0
- package/dist/bin/sync-runner-events.test.d.ts.map +1 -0
- package/dist/bin/sync-runner-events.test.js +90 -0
- package/dist/bin/sync-runner-events.test.js.map +1 -0
- package/dist/bin/sync-runner.d.ts.map +1 -1
- package/dist/bin/sync-runner.js +10 -2
- package/dist/bin/sync-runner.js.map +1 -1
- package/dist/cli/reindex.d.ts.map +1 -1
- package/dist/cli/reindex.js +25 -2
- package/dist/cli/reindex.js.map +1 -1
- package/dist/cli/reindex.test.js +51 -1
- package/dist/cli/reindex.test.js.map +1 -1
- package/dist/cognito-auth.d.ts +17 -2
- package/dist/cognito-auth.d.ts.map +1 -1
- package/dist/cognito-auth.js +131 -44
- package/dist/cognito-auth.js.map +1 -1
- package/dist/machine-auth.test.js +95 -0
- package/dist/machine-auth.test.js.map +1 -1
- package/package.json +1 -1
- package/src/bin/sync-runner-events.test.ts +112 -0
- package/src/bin/sync-runner-events.ts +30 -0
- package/src/bin/sync-runner.ts +13 -4
- package/src/cli/reindex.test.ts +68 -1
- package/src/cli/reindex.ts +27 -2
- package/src/cognito-auth.ts +160 -49
- package/src/machine-auth.test.ts +137 -0
|
@@ -23,3 +23,33 @@ export function createRunnerEmitter(
|
|
|
23
23
|
): (event: RunnerEvent) => void {
|
|
24
24
|
return (event) => routeEvents(event, streams);
|
|
25
25
|
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Emit a CLASSIFIED `error` event for a top-level uncaught runner rejection,
|
|
29
|
+
* BEFORE the process exits non-zero.
|
|
30
|
+
*
|
|
31
|
+
* The menubar (hq-sync) treats a non-zero runner exit with NO error event seen
|
|
32
|
+
* at all as an "unexplained crash" and ALWAYS alerts on it. That is correct for
|
|
33
|
+
* a real panic/OOM, but the historical top-level catch wrote only a plain
|
|
34
|
+
* stderr line (not a protocol event) for EVERY uncaught rejection — so even a
|
|
35
|
+
* benign cause that bubbled to the top (transient network blip, a
|
|
36
|
+
* not-yet-provisioned 404, an expected ACL-scope skip) drove a false alert
|
|
37
|
+
* (HQ-SYNC-WEB-6). Emitting a structured `{type:"error"}` event here routes the
|
|
38
|
+
* failure through the menubar's `is_alertable_error` classifier, so the benign
|
|
39
|
+
* cases are suppressed while genuine defects still surface.
|
|
40
|
+
*
|
|
41
|
+
* Best-effort: if routing itself throws, fall back to a plain stderr line so the
|
|
42
|
+
* failure is never fully silent in the logs.
|
|
43
|
+
*/
|
|
44
|
+
export function emitUncaughtRunnerError(
|
|
45
|
+
err: unknown,
|
|
46
|
+
streams: RunnerEventStreams,
|
|
47
|
+
): void {
|
|
48
|
+
const message =
|
|
49
|
+
err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
50
|
+
try {
|
|
51
|
+
routeEvents({ type: "error", message, path: "(runner)" }, streams);
|
|
52
|
+
} catch {
|
|
53
|
+
streams.stderr.write(`hq-sync-runner: uncaught error — ${message}\n`);
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/bin/sync-runner.ts
CHANGED
|
@@ -115,7 +115,10 @@ import {
|
|
|
115
115
|
type StartEventSyncOptions,
|
|
116
116
|
} from "../sync/event-sync.js";
|
|
117
117
|
import { migratePersonalVaultJournal } from "../journal.js";
|
|
118
|
-
import {
|
|
118
|
+
import {
|
|
119
|
+
createRunnerEmitter,
|
|
120
|
+
emitUncaughtRunnerError,
|
|
121
|
+
} from "./sync-runner-events.js";
|
|
119
122
|
import {
|
|
120
123
|
buildFanoutPlan,
|
|
121
124
|
emitFanoutPlan,
|
|
@@ -1473,9 +1476,15 @@ if (isDirectInvocation) {
|
|
|
1473
1476
|
runRunnerWithLoop(process.argv.slice(2))
|
|
1474
1477
|
.then((code) => process.exit(code))
|
|
1475
1478
|
.catch((err) => {
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
+
// A top-level rejection means the runner died before (or instead of)
|
|
1480
|
+
// emitting its protocol stream. Emit a CLASSIFIED `error` event first so
|
|
1481
|
+
// the menubar runs it through `is_alertable_error` and suppresses the
|
|
1482
|
+
// benign cases, instead of treating every uncaught exit as an unexplained
|
|
1483
|
+
// "silent death" crash (HQ-SYNC-WEB-6).
|
|
1484
|
+
emitUncaughtRunnerError(err, {
|
|
1485
|
+
stdout: process.stdout,
|
|
1486
|
+
stderr: process.stderr,
|
|
1487
|
+
});
|
|
1479
1488
|
process.exit(1);
|
|
1480
1489
|
});
|
|
1481
1490
|
}
|
package/src/cli/reindex.test.ts
CHANGED
|
@@ -24,6 +24,11 @@ const hoisted = vi.hoisted(() => ({
|
|
|
24
24
|
// lets a test simulate a mkdir failure at a NON-wrapper site (e.g. the
|
|
25
25
|
// `.claude/skills` parent or a `core/<type>` mirror dir).
|
|
26
26
|
failMkdirContaining: "" as string,
|
|
27
|
+
// HQ-CLI-4: when set, any symlink whose LINK path contains this substring
|
|
28
|
+
// throws EPERM — simulates Windows WITHOUT Developer Mode, where symlink
|
|
29
|
+
// creation is a privileged operation. Off by default so other tests see the
|
|
30
|
+
// real `symlinkSync`.
|
|
31
|
+
failSymlinkContaining: "" as string,
|
|
27
32
|
}));
|
|
28
33
|
vi.mock("fs", async (importOriginal) => {
|
|
29
34
|
const actual = await importOriginal<typeof import("fs")>();
|
|
@@ -40,7 +45,24 @@ vi.mock("fs", async (importOriginal) => {
|
|
|
40
45
|
}
|
|
41
46
|
return (actual.mkdirSync as (p: unknown, opts: unknown) => unknown)(p, opts);
|
|
42
47
|
}) as typeof actual.mkdirSync;
|
|
43
|
-
const
|
|
48
|
+
const symlinkSync = ((target: unknown, linkPath: unknown, type?: unknown) => {
|
|
49
|
+
if (
|
|
50
|
+
hoisted.failSymlinkContaining !== "" &&
|
|
51
|
+
typeof linkPath === "string" &&
|
|
52
|
+
linkPath.includes(hoisted.failSymlinkContaining)
|
|
53
|
+
) {
|
|
54
|
+
throw Object.assign(
|
|
55
|
+
new Error(`EPERM: operation not permitted, symlink '${target}' -> '${linkPath}'`),
|
|
56
|
+
{ code: "EPERM" },
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return (actual.symlinkSync as (t: unknown, l: unknown, ty?: unknown) => unknown)(
|
|
60
|
+
target,
|
|
61
|
+
linkPath,
|
|
62
|
+
type,
|
|
63
|
+
);
|
|
64
|
+
}) as typeof actual.symlinkSync;
|
|
65
|
+
const patched = { ...actual, mkdirSync, symlinkSync };
|
|
44
66
|
return { ...patched, default: patched };
|
|
45
67
|
});
|
|
46
68
|
|
|
@@ -307,4 +329,49 @@ describe("reindex", () => {
|
|
|
307
329
|
// The mirror for the failed type is skipped; skill surfacing still happened.
|
|
308
330
|
expect(fs.existsSync(path.join(root, ".claude/skills/core:demo"))).toBe(true);
|
|
309
331
|
});
|
|
332
|
+
|
|
333
|
+
// ── HQ-CLI-4: symlink creation is privileged on Windows. Without Developer
|
|
334
|
+
// Mode, fs.symlinkSync throws EPERM. The HQ-B0 work guarded every mkdir
|
|
335
|
+
// site but left the symlink sites unguarded, so `hq reindex` still aborted
|
|
336
|
+
// on Windows. safeSymlink must degrade gracefully like safeMkdir.
|
|
337
|
+
it("does not abort reindex when a skill-wrapper symlink fails with EPERM (Windows no Developer Mode)", () => {
|
|
338
|
+
writeSkill("core/skills/demo");
|
|
339
|
+
|
|
340
|
+
hoisted.failSymlinkContaining = path.join(".claude", "skills");
|
|
341
|
+
try {
|
|
342
|
+
// Every per-file wrapper symlink throws EPERM, but reindex must still
|
|
343
|
+
// complete cleanly (exit 0) rather than crash + flood Sentry.
|
|
344
|
+
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
345
|
+
} finally {
|
|
346
|
+
hoisted.failSymlinkContaining = "";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// The wrapper DIR was still created; the symlink inside was just skipped.
|
|
350
|
+
expect(fs.existsSync(path.join(root, ".claude/skills/core:demo"))).toBe(true);
|
|
351
|
+
expect(
|
|
352
|
+
fs.existsSync(path.join(root, ".claude/skills/core:demo/SKILL.md")),
|
|
353
|
+
).toBe(false);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("does not abort reindex when a personal-mirror symlink fails with EPERM, and other work still runs", () => {
|
|
357
|
+
writeSkill("core/skills/demo");
|
|
358
|
+
fs.mkdirSync(path.join(root, "personal/knowledge/my-kb"), { recursive: true });
|
|
359
|
+
fs.writeFileSync(path.join(root, "personal/knowledge/my-kb/note.md"), "n\n");
|
|
360
|
+
|
|
361
|
+
// Fail ONLY the core/<type> personal-mirror symlinks; wrapper symlinks
|
|
362
|
+
// succeed, proving the skip is scoped and the reindex completes.
|
|
363
|
+
hoisted.failSymlinkContaining = path.join("core", "knowledge");
|
|
364
|
+
try {
|
|
365
|
+
expect(reindex({ repoRoot: root }).status).toBe(0);
|
|
366
|
+
} finally {
|
|
367
|
+
hoisted.failSymlinkContaining = "";
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// The personal mirror was skipped (EPERM)...
|
|
371
|
+
expect(fs.existsSync(path.join(root, "core/knowledge/my-kb"))).toBe(false);
|
|
372
|
+
// ...but unaffected work still happened: the skill wrapper + its symlink.
|
|
373
|
+
expect(
|
|
374
|
+
fs.lstatSync(path.join(root, ".claude/skills/core:demo/SKILL.md")).isSymbolicLink(),
|
|
375
|
+
).toBe(true);
|
|
376
|
+
});
|
|
310
377
|
});
|
package/src/cli/reindex.ts
CHANGED
|
@@ -154,6 +154,31 @@ function safeMkdir(dir: string, label: string): boolean {
|
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
// Create a symlink, tolerating platform-specific failures instead of aborting
|
|
158
|
+
// the whole reindex — the symlink sibling of safeMkdir. The classic case
|
|
159
|
+
// (HQ-CLI-4) is Windows WITHOUT Developer Mode (or an elevated shell), where
|
|
160
|
+
// symlink creation is a privileged operation and `fs.symlinkSync` throws EPERM
|
|
161
|
+
// (Win32 ERROR_PRIVILEGE_NOT_HELD). A single symlink we can't create should
|
|
162
|
+
// degrade gracefully — warn with an actionable hint and skip just that link so
|
|
163
|
+
// the rest of the reindex (other wrappers, mirrors, registry regen) still runs.
|
|
164
|
+
// reindex is idempotent and re-run on hooks, so a later run re-creates the link
|
|
165
|
+
// once the privilege is granted. Returns true on success, false on skip.
|
|
166
|
+
function safeSymlink(target: string, linkPath: string, label: string): boolean {
|
|
167
|
+
try {
|
|
168
|
+
fs.symlinkSync(target, linkPath);
|
|
169
|
+
return true;
|
|
170
|
+
} catch (err) {
|
|
171
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
172
|
+
warn(
|
|
173
|
+
`reindex: could not create ${label} '${linkPath}' -> '${target}' ` +
|
|
174
|
+
`(${code ?? "error"}: ${(err as Error).message}); skipping. On Windows, ` +
|
|
175
|
+
`creating symlinks needs Developer Mode (Settings -> Privacy & security ` +
|
|
176
|
+
`-> For developers) or an elevated shell.`,
|
|
177
|
+
);
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
157
182
|
// --- legacy `.claude/commands/<ns>/<skill>.md` symlink matcher --------------
|
|
158
183
|
// Mirrors the bash `case` patterns; `*` matches any chars (including `/`).
|
|
159
184
|
function isLegacyCommandTarget(t: string): boolean {
|
|
@@ -325,7 +350,7 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
325
350
|
continue;
|
|
326
351
|
}
|
|
327
352
|
|
|
328
|
-
|
|
353
|
+
safeSymlink(relativeTarget, linkPath, `.claude/skills/${wrapperName}/${entry}`);
|
|
329
354
|
}
|
|
330
355
|
|
|
331
356
|
// Prune symlinks in the wrapper whose source entry no longer exists.
|
|
@@ -424,7 +449,7 @@ export function reindex(opts: ReindexOptions = {}): ReindexResult {
|
|
|
424
449
|
continue;
|
|
425
450
|
}
|
|
426
451
|
|
|
427
|
-
|
|
452
|
+
safeSymlink(relativeTarget, linkPath, `core/${type}/${entry}`);
|
|
428
453
|
}
|
|
429
454
|
}
|
|
430
455
|
|
package/src/cognito-auth.ts
CHANGED
|
@@ -299,15 +299,63 @@ interface InitiateAuthResponse {
|
|
|
299
299
|
message?: string;
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
+
/** Tunables for the machine-mint retry-on-throttle loop. Defaults are the
|
|
303
|
+
* production values; tests pass `baseDelayMs: 0` to retry without waiting. */
|
|
304
|
+
export interface MintRetryOptions {
|
|
305
|
+
/** Retries AFTER the first attempt (so total attempts = maxRetries + 1). */
|
|
306
|
+
maxRetries?: number;
|
|
307
|
+
/** Backoff for the first retry in ms; doubles each subsequent attempt. */
|
|
308
|
+
baseDelayMs?: number;
|
|
309
|
+
/** Ceiling for any single backoff sleep. */
|
|
310
|
+
maxDelayMs?: number;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const DEFAULT_MINT_RETRY: Required<MintRetryOptions> = {
|
|
314
|
+
maxRetries: 5,
|
|
315
|
+
baseDelayMs: 250,
|
|
316
|
+
maxDelayMs: 5000,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Cognito signals a per-user mint throttle as `TooManyRequestsException` — and
|
|
321
|
+
* (observed in prod on the Infraredi agent box, 2026-06-26) returns it under
|
|
322
|
+
* HTTP 400, not 429. Match the typed error first and the status second so both
|
|
323
|
+
* shapes are treated as transient throttles to retry, not hard auth failures.
|
|
324
|
+
*/
|
|
325
|
+
function isCognitoThrottle(status: number, type: string | undefined): boolean {
|
|
326
|
+
if (typeof type === "string" && type.includes("TooManyRequests")) return true;
|
|
327
|
+
return status === 429;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function sleep(ms: number): Promise<void> {
|
|
331
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Full-jitter exponential backoff: a random delay in
|
|
335
|
+
* [0, min(maxDelayMs, baseDelayMs * 2^attempt)]. Jitter de-syncs a concurrent
|
|
336
|
+
* burst of retries so they don't re-collide on the same per-user limit. */
|
|
337
|
+
function backoffDelayMs(
|
|
338
|
+
attempt: number,
|
|
339
|
+
opts: Required<MintRetryOptions>,
|
|
340
|
+
): number {
|
|
341
|
+
const ceiling = Math.min(opts.maxDelayMs, opts.baseDelayMs * 2 ** attempt);
|
|
342
|
+
return Math.floor(Math.random() * (ceiling + 1));
|
|
343
|
+
}
|
|
344
|
+
|
|
302
345
|
/**
|
|
303
346
|
* Mint a fresh session for the machine identity via USER_PASSWORD_AUTH
|
|
304
347
|
* against the Cognito IDP endpoint (plain unsigned HTTP — no AWS SDK
|
|
305
348
|
* dependency). Caches BOTH tokens with correct field semantics and returns
|
|
306
349
|
* them.
|
|
350
|
+
*
|
|
351
|
+
* Retries on a `TooManyRequestsException` throttle with exponential backoff +
|
|
352
|
+
* full jitter (transient, per-user rate limit); every other non-OK response
|
|
353
|
+
* (bad creds, disabled flow) is a hard error surfaced immediately.
|
|
307
354
|
*/
|
|
308
355
|
export async function mintMachineTokens(
|
|
309
356
|
config: CognitoAuthConfig,
|
|
310
357
|
creds?: MachineCreds,
|
|
358
|
+
retry?: MintRetryOptions,
|
|
311
359
|
): Promise<CognitoTokens> {
|
|
312
360
|
const machineCreds = creds ?? loadMachineCreds();
|
|
313
361
|
if (!machineCreds) {
|
|
@@ -320,68 +368,131 @@ export async function mintMachineTokens(
|
|
|
320
368
|
// which the CLI's default (browser) client may not.
|
|
321
369
|
const region = machineCreds.region ?? config.region;
|
|
322
370
|
const clientId = machineCreds.clientId ?? config.clientId;
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
AuthParameters: {
|
|
335
|
-
USERNAME: machineCreds.username,
|
|
336
|
-
PASSWORD: machineCreds.secret,
|
|
371
|
+
const retryOpts: Required<MintRetryOptions> = { ...DEFAULT_MINT_RETRY, ...retry };
|
|
372
|
+
|
|
373
|
+
let lastError: CognitoAuthError | null = null;
|
|
374
|
+
for (let attempt = 0; attempt <= retryOpts.maxRetries; attempt++) {
|
|
375
|
+
const res = await fetch(
|
|
376
|
+
`https://cognito-idp.${region}.amazonaws.com/`,
|
|
377
|
+
{
|
|
378
|
+
method: "POST",
|
|
379
|
+
headers: {
|
|
380
|
+
"Content-Type": "application/x-amz-json-1.1",
|
|
381
|
+
"X-Amz-Target": "AWSCognitoIdentityProviderService.InitiateAuth",
|
|
337
382
|
},
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const result = data.AuthenticationResult;
|
|
348
|
-
if (!result?.AccessToken || !result?.IdToken) {
|
|
349
|
-
throw new CognitoAuthError(
|
|
350
|
-
`Machine token mint returned no tokens${data.ChallengeName ? ` (challenge: ${data.ChallengeName})` : ""}`,
|
|
383
|
+
body: JSON.stringify({
|
|
384
|
+
AuthFlow: "USER_PASSWORD_AUTH",
|
|
385
|
+
ClientId: clientId,
|
|
386
|
+
AuthParameters: {
|
|
387
|
+
USERNAME: machineCreds.username,
|
|
388
|
+
PASSWORD: machineCreds.secret,
|
|
389
|
+
},
|
|
390
|
+
}),
|
|
391
|
+
},
|
|
351
392
|
);
|
|
393
|
+
const data = (await res.json().catch(() => ({}))) as InitiateAuthResponse;
|
|
394
|
+
if (!res.ok) {
|
|
395
|
+
const err = new CognitoAuthError(
|
|
396
|
+
`Machine token mint failed (${res.status}): ${data.__type ?? ""} ${data.message ?? ""}`.trim(),
|
|
397
|
+
);
|
|
398
|
+
// Back off and retry a transient per-user throttle rather than failing
|
|
399
|
+
// the whole sync run; surface every other auth error immediately.
|
|
400
|
+
if (
|
|
401
|
+
isCognitoThrottle(res.status, data.__type) &&
|
|
402
|
+
attempt < retryOpts.maxRetries
|
|
403
|
+
) {
|
|
404
|
+
lastError = err;
|
|
405
|
+
await sleep(backoffDelayMs(attempt, retryOpts));
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
throw err;
|
|
409
|
+
}
|
|
410
|
+
const result = data.AuthenticationResult;
|
|
411
|
+
if (!result?.AccessToken || !result?.IdToken) {
|
|
412
|
+
throw new CognitoAuthError(
|
|
413
|
+
`Machine token mint returned no tokens${data.ChallengeName ? ` (challenge: ${data.ChallengeName})` : ""}`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
const tokens: CognitoTokens = {
|
|
417
|
+
accessToken: result.AccessToken,
|
|
418
|
+
idToken: result.IdToken,
|
|
419
|
+
// 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 ?? "",
|
|
422
|
+
expiresAt: Date.now() + (result.ExpiresIn ?? 3600) * 1000,
|
|
423
|
+
tokenType: "Bearer",
|
|
424
|
+
};
|
|
425
|
+
saveCachedTokens(tokens);
|
|
426
|
+
return tokens;
|
|
352
427
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
// Machine creds never expire — expiry is handled by re-minting, so the
|
|
357
|
-
// refresh token (when Cognito returns one at all) is never exercised.
|
|
358
|
-
refreshToken: result.RefreshToken ?? "",
|
|
359
|
-
expiresAt: Date.now() + (result.ExpiresIn ?? 3600) * 1000,
|
|
360
|
-
tokenType: "Bearer",
|
|
361
|
-
};
|
|
362
|
-
saveCachedTokens(tokens);
|
|
363
|
-
return tokens;
|
|
428
|
+
// Unreachable in practice — the final throttled attempt throws inside the
|
|
429
|
+
// loop — but keeps the function total for the type checker.
|
|
430
|
+
throw lastError ?? new CognitoAuthError("Machine token mint failed: throttled");
|
|
364
431
|
}
|
|
365
432
|
|
|
433
|
+
// In-process machine-token memo (sync-runner mint-storm fix).
|
|
434
|
+
//
|
|
435
|
+
// `getValidAccessToken` runs on EVERY vault request, and a busy sync fans out
|
|
436
|
+
// many requests concurrently (presign primes, STS vend, the per-file tombstone
|
|
437
|
+
// HEAD verifies). The on-disk cache alone does NOT bound mints: at run start
|
|
438
|
+
// the disk token is often within its expiry buffer, so N concurrent callers
|
|
439
|
+
// each independently observe "expiring" and each fire USER_PASSWORD_AUTH —
|
|
440
|
+
// bursting Cognito's per-user limit (TooManyRequestsException) on large vaults
|
|
441
|
+
// (observed: 56 throttled mints over 314 files on the Infraredi box, 2026-06-26).
|
|
442
|
+
//
|
|
443
|
+
// This module-level memo makes mints O(1) per process:
|
|
444
|
+
// - hot path: a live in-memory token serves every later request with no disk
|
|
445
|
+
// read and no network — a 300-file sync does ONE mint, not one-per-request;
|
|
446
|
+
// - single-flight: a concurrent burst of cache-misses (the run-start fan-out)
|
|
447
|
+
// 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;
|
|
452
|
+
|
|
366
453
|
/**
|
|
367
454
|
* Return a valid (non-expiring) machine session, re-minting on demand.
|
|
368
|
-
* Cache-hit path never touches the network
|
|
455
|
+
* Cache-hit path never touches the network — and after the first mint, the
|
|
456
|
+
* in-process memo serves every subsequent request for the life of the token.
|
|
369
457
|
*/
|
|
370
458
|
export async function getValidMachineTokens(
|
|
371
459
|
config: CognitoAuthConfig,
|
|
372
460
|
): Promise<CognitoTokens> {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
461
|
+
// Hot path: a live in-memory token serves every request after the first.
|
|
462
|
+
if (inProcessMachineTokens && !isExpiring(inProcessMachineTokens, 120)) {
|
|
463
|
+
return inProcessMachineTokens;
|
|
464
|
+
}
|
|
465
|
+
// Single-flight: collapse a concurrent burst of misses onto one resolution.
|
|
466
|
+
// The assignment below completes before the first `await` yields, so sibling
|
|
467
|
+
// callers in the same tick see the in-flight promise and reuse it.
|
|
468
|
+
if (inFlightMachineMint) return inFlightMachineMint;
|
|
469
|
+
|
|
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;
|
|
482
|
+
}
|
|
382
483
|
}
|
|
484
|
+
return mintMachineTokens(config, machineCreds ?? undefined);
|
|
485
|
+
})();
|
|
486
|
+
|
|
487
|
+
try {
|
|
488
|
+
const tokens = await inFlightMachineMint;
|
|
489
|
+
inProcessMachineTokens = tokens;
|
|
490
|
+
return tokens;
|
|
491
|
+
} finally {
|
|
492
|
+
// Clear on both success and failure: a rejected mint must not wedge later
|
|
493
|
+
// calls onto a permanently-failed promise.
|
|
494
|
+
inFlightMachineMint = null;
|
|
383
495
|
}
|
|
384
|
-
return mintMachineTokens(config, machineCreds ?? undefined);
|
|
385
496
|
}
|
|
386
497
|
|
|
387
498
|
// ---------------------------------------------------------------------------
|
package/src/machine-auth.test.ts
CHANGED
|
@@ -355,6 +355,143 @@ describe("getValidMachineTokens", () => {
|
|
|
355
355
|
});
|
|
356
356
|
});
|
|
357
357
|
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// Regression: constant token mints over N files (sync-runner mint storm)
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
//
|
|
362
|
+
// `getValidAccessToken` (→ getValidMachineTokens) is invoked on EVERY vault
|
|
363
|
+
// request a sync makes, including the per-file tombstone HEAD verifies. The
|
|
364
|
+
// 2026-06-26 prod incident was 56 throttled USER_PASSWORD_AUTH mints over 314
|
|
365
|
+
// files on one box: with no in-process memo + single-flight, mints scaled with
|
|
366
|
+
// request count and tripped Cognito's per-user limit. These lock the invariant
|
|
367
|
+
// that mints are O(1) per process, independent of N.
|
|
368
|
+
|
|
369
|
+
describe("regression: constant mints regardless of file count", () => {
|
|
370
|
+
it.each([10, 300])(
|
|
371
|
+
"performs exactly ONE token mint across %i sequential file-verify requests",
|
|
372
|
+
async (n) => {
|
|
373
|
+
writeCreds();
|
|
374
|
+
const { fetchMock } = stubMintFetch();
|
|
375
|
+
const { getValidMachineTokens } = await importModule();
|
|
376
|
+
|
|
377
|
+
for (let i = 0; i < n; i++) {
|
|
378
|
+
await getValidMachineTokens(CONFIG);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// One mint for N requests — NOT proportional to N. The first call mints;
|
|
382
|
+
// every later call is served by the in-process memo (no network).
|
|
383
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
384
|
+
},
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
it.each([10, 300])(
|
|
388
|
+
"collapses a concurrent burst of %i requests onto a SINGLE mint (single-flight)",
|
|
389
|
+
async (n) => {
|
|
390
|
+
writeCreds();
|
|
391
|
+
const { fetchMock } = stubMintFetch();
|
|
392
|
+
const { getValidMachineTokens } = await importModule();
|
|
393
|
+
|
|
394
|
+
// Fire all N at once, as the run-start fan-out does. Without single-flight
|
|
395
|
+
// every concurrent miss would mint and burst the per-user limit.
|
|
396
|
+
const results = await Promise.all(
|
|
397
|
+
Array.from({ length: n }, () => getValidMachineTokens(CONFIG)),
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
401
|
+
// All callers observe the same token.
|
|
402
|
+
for (const r of results) expect(r).toEqual(results[0]);
|
|
403
|
+
|
|
404
|
+
// Follow-up sequential requests stay on the memo — still one mint total.
|
|
405
|
+
await getValidMachineTokens(CONFIG);
|
|
406
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
407
|
+
},
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Throttle handling: retry TooManyRequestsException with backoff
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
describe("mintMachineTokens throttle retry", () => {
|
|
416
|
+
/** Fetch mock that throttles (HTTP 400 + TooManyRequestsException, the shape
|
|
417
|
+
* Cognito actually returns) for the first `throttleCount` calls, then 200s. */
|
|
418
|
+
function stubThrottleThenSuccess(throttleCount: number) {
|
|
419
|
+
let call = 0;
|
|
420
|
+
const fetchMock = vi.fn(async () => {
|
|
421
|
+
call++;
|
|
422
|
+
if (call <= throttleCount) {
|
|
423
|
+
return new Response(
|
|
424
|
+
JSON.stringify({
|
|
425
|
+
__type: "TooManyRequestsException",
|
|
426
|
+
message: "Too many requests",
|
|
427
|
+
}),
|
|
428
|
+
{ status: 400 },
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
return new Response(
|
|
432
|
+
JSON.stringify({
|
|
433
|
+
AuthenticationResult: {
|
|
434
|
+
AccessToken: fakeJwt({ token_use: "access", client_id: CONFIG.clientId }),
|
|
435
|
+
IdToken: fakeJwt({ token_use: "id" }),
|
|
436
|
+
ExpiresIn: 3600,
|
|
437
|
+
},
|
|
438
|
+
}),
|
|
439
|
+
{ status: 200 },
|
|
440
|
+
);
|
|
441
|
+
});
|
|
442
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
443
|
+
return fetchMock;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
it("retries a throttled mint with backoff and eventually succeeds", async () => {
|
|
447
|
+
writeCreds();
|
|
448
|
+
const fetchMock = stubThrottleThenSuccess(2);
|
|
449
|
+
const { mintMachineTokens } = await importModule();
|
|
450
|
+
|
|
451
|
+
// baseDelayMs: 0 keeps the test instant — jitter * 0 == 0.
|
|
452
|
+
const tokens = await mintMachineTokens(CONFIG, undefined, {
|
|
453
|
+
baseDelayMs: 0,
|
|
454
|
+
maxRetries: 5,
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
expect(fetchMock).toHaveBeenCalledTimes(3); // 2 throttles + 1 success
|
|
458
|
+
const idClaims = JSON.parse(
|
|
459
|
+
Buffer.from(tokens.idToken.split(".")[1], "base64url").toString(),
|
|
460
|
+
);
|
|
461
|
+
expect(idClaims.token_use).toBe("id");
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("gives up after maxRetries and throws when throttling never clears", async () => {
|
|
465
|
+
writeCreds();
|
|
466
|
+
const fetchMock = stubThrottleThenSuccess(Number.POSITIVE_INFINITY);
|
|
467
|
+
const { mintMachineTokens, CognitoAuthError } = await importModule();
|
|
468
|
+
|
|
469
|
+
await expect(
|
|
470
|
+
mintMachineTokens(CONFIG, undefined, { baseDelayMs: 0, maxRetries: 2 }),
|
|
471
|
+
).rejects.toBeInstanceOf(CognitoAuthError);
|
|
472
|
+
|
|
473
|
+
// maxRetries: 2 → 3 total attempts (initial + 2 retries).
|
|
474
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("does NOT retry a non-throttle auth failure (bad creds fail fast)", async () => {
|
|
478
|
+
writeCreds();
|
|
479
|
+
const fetchMock = vi.fn(async () =>
|
|
480
|
+
new Response(
|
|
481
|
+
JSON.stringify({ __type: "NotAuthorizedException", message: "nope" }),
|
|
482
|
+
{ status: 400 },
|
|
483
|
+
),
|
|
484
|
+
);
|
|
485
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
486
|
+
const { mintMachineTokens, CognitoAuthError } = await importModule();
|
|
487
|
+
|
|
488
|
+
await expect(
|
|
489
|
+
mintMachineTokens(CONFIG, undefined, { baseDelayMs: 0, maxRetries: 5 }),
|
|
490
|
+
).rejects.toBeInstanceOf(CognitoAuthError);
|
|
491
|
+
expect(fetchMock).toHaveBeenCalledTimes(1); // no retries
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
|
|
358
495
|
// ---------------------------------------------------------------------------
|
|
359
496
|
// getValidAccessToken — machine-mode short circuit
|
|
360
497
|
// ---------------------------------------------------------------------------
|