@indigoai-us/hq-cloud 6.12.3 → 6.12.5
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/cli/share.d.ts.map +1 -1
- package/dist/cli/share.js +11 -0
- package/dist/cli/share.js.map +1 -1
- package/dist/cli/sync.d.ts.map +1 -1
- package/dist/cli/sync.js +13 -0
- package/dist/cli/sync.js.map +1 -1
- package/dist/cli/sync.test.js +42 -0
- package/dist/cli/sync.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/lib/cloud-authoritative.d.ts +45 -0
- package/dist/lib/cloud-authoritative.d.ts.map +1 -0
- package/dist/lib/cloud-authoritative.js +58 -0
- package/dist/lib/cloud-authoritative.js.map +1 -0
- package/dist/lib/cloud-authoritative.test.d.ts +2 -0
- package/dist/lib/cloud-authoritative.test.d.ts.map +1 -0
- package/dist/lib/cloud-authoritative.test.js +42 -0
- package/dist/lib/cloud-authoritative.test.js.map +1 -0
- package/dist/machine-auth.test.js +95 -0
- package/dist/machine-auth.test.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/share.ts +11 -0
- package/src/cli/sync.test.ts +52 -0
- package/src/cli/sync.ts +14 -0
- package/src/cognito-auth.ts +160 -49
- package/src/lib/cloud-authoritative.test.ts +45 -0
- package/src/lib/cloud-authoritative.ts +59 -0
- package/src/machine-auth.test.ts +137 -0
package/src/cli/share.ts
CHANGED
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
readShortMachineId,
|
|
65
65
|
} from "../lib/conflict-file.js";
|
|
66
66
|
import { appendConflictEntry } from "../lib/conflict-index.js";
|
|
67
|
+
import { isCloudAuthoritative } from "../lib/cloud-authoritative.js";
|
|
67
68
|
|
|
68
69
|
/**
|
|
69
70
|
* Push-side fresh-collision convergence probe.
|
|
@@ -1260,6 +1261,16 @@ async function executeUploads(
|
|
|
1260
1261
|
}
|
|
1261
1262
|
|
|
1262
1263
|
if ((localChanged && remoteChanged) || isFreshCollision) {
|
|
1264
|
+
// Cloud-authoritative paths (server-regenerated: company-brief.md,
|
|
1265
|
+
// board.json, ontology/ signals/ sources/) are PULL-WINS. Never push the
|
|
1266
|
+
// local copy over the server-owned version and never mint a `.conflict-`
|
|
1267
|
+
// mirror — the pull leg overwrites local from cloud. Declining here is
|
|
1268
|
+
// what stops the per-sync conflict-mirror loop on these files.
|
|
1269
|
+
if (isCloudAuthoritative(relativePath)) {
|
|
1270
|
+
run.emit({ type: "reconciled", path: relativePath, direction: "push" });
|
|
1271
|
+
counters.filesSkipped++;
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1263
1274
|
conflictPaths.push(relativePath);
|
|
1264
1275
|
|
|
1265
1276
|
const resolution = await resolveConflictSerialized({
|
package/src/cli/sync.test.ts
CHANGED
|
@@ -451,6 +451,58 @@ describe("sync", () => {
|
|
|
451
451
|
expect(litter).toHaveLength(1);
|
|
452
452
|
});
|
|
453
453
|
|
|
454
|
+
it("cloud-authoritative file is PULL-WINS on conflict: overwrites local, no mirror, no false-stamp", async () => {
|
|
455
|
+
// company-brief.md is server-regenerated (gardener), so a divergence is not
|
|
456
|
+
// a real conflict — the cloud copy wins. Even under --on-conflict keep, the
|
|
457
|
+
// conflict path must short-circuit to a plain overwrite: NO `.conflict-`
|
|
458
|
+
// mirror (the loop) and NO journal stamp of the remote etag over divergent
|
|
459
|
+
// local content (Finding B). Contrast the docs/handoff.md test above, which
|
|
460
|
+
// (correctly) keeps local + mirrors for a client-owned file.
|
|
461
|
+
const companyRoot = path.join(tmpDir, "companies", "acme");
|
|
462
|
+
fs.mkdirSync(companyRoot, { recursive: true });
|
|
463
|
+
fs.writeFileSync(path.join(companyRoot, "company-brief.md"), "local richer brief");
|
|
464
|
+
|
|
465
|
+
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
466
|
+
{ key: "company-brief.md", size: 17, lastModified: new Date(), etag: '"newbrief"' },
|
|
467
|
+
]);
|
|
468
|
+
// Default downloadFile mock writes "mock file content" — the cloud copy.
|
|
469
|
+
|
|
470
|
+
fs.writeFileSync(
|
|
471
|
+
journalPath,
|
|
472
|
+
JSON.stringify({
|
|
473
|
+
version: "1",
|
|
474
|
+
lastSync: new Date().toISOString(),
|
|
475
|
+
files: {
|
|
476
|
+
"company-brief.md": {
|
|
477
|
+
hash: "stale-hash",
|
|
478
|
+
size: 18,
|
|
479
|
+
syncedAt: new Date(Date.now() - 3600000).toISOString(),
|
|
480
|
+
direction: "down",
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
}),
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
const result = await sync({
|
|
487
|
+
company: "acme",
|
|
488
|
+
onConflict: "keep",
|
|
489
|
+
vaultConfig: mockConfig,
|
|
490
|
+
hqRoot: tmpDir,
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// Cloud wins: not counted as a conflict; local overwritten with the cloud copy.
|
|
494
|
+
expect(result.conflicts).toBe(0);
|
|
495
|
+
expect(result.conflictPaths).toEqual([]);
|
|
496
|
+
expect(
|
|
497
|
+
fs.readFileSync(path.join(companyRoot, "company-brief.md"), "utf-8"),
|
|
498
|
+
).toBe("mock file content");
|
|
499
|
+
// No conflict mirror littered.
|
|
500
|
+
const litter = fs
|
|
501
|
+
.readdirSync(companyRoot)
|
|
502
|
+
.filter((f) => f.includes(".conflict-"));
|
|
503
|
+
expect(litter).toHaveLength(0);
|
|
504
|
+
});
|
|
505
|
+
|
|
454
506
|
it("pre-primes new-file GET presigns before per-file created-by HEADs (avoids the presign-burst breaker trip)", async () => {
|
|
455
507
|
vi.mocked(s3Module.listRemoteFiles).mockResolvedValueOnce([
|
|
456
508
|
{ key: "alpha.md", size: 10, lastModified: new Date(), etag: '"a1"' },
|
package/src/cli/sync.ts
CHANGED
|
@@ -69,6 +69,7 @@ import {
|
|
|
69
69
|
readShortMachineId,
|
|
70
70
|
} from "../lib/conflict-file.js";
|
|
71
71
|
import { appendConflictEntry } from "../lib/conflict-index.js";
|
|
72
|
+
import { isCloudAuthoritative } from "../lib/cloud-authoritative.js";
|
|
72
73
|
import { reindex } from "./reindex.js";
|
|
73
74
|
import { withOperationLock } from "../operation-lock.js";
|
|
74
75
|
import {
|
|
@@ -1048,6 +1049,19 @@ async function executeConflictItem(
|
|
|
1048
1049
|
): Promise<SyncResult | null> {
|
|
1049
1050
|
const { remoteFile, localPath } = item;
|
|
1050
1051
|
|
|
1052
|
+
// Cloud-authoritative paths (server-regenerated: company-brief.md, board.json,
|
|
1053
|
+
// ontology/ signals/ sources/) are PULL-WINS: the cloud copy is the source of
|
|
1054
|
+
// truth, so a divergence here is not a real conflict. Resolve it as a normal
|
|
1055
|
+
// overwrite-from-cloud — queue a plain download (which stamps the journal with
|
|
1056
|
+
// the REMOTE hash/etag) and skip the conflict path entirely. This removes both
|
|
1057
|
+
// the `.conflict-` mirror loop AND the journal false-stamp (remote etag over
|
|
1058
|
+
// divergent local content) that the keep path produced for these files.
|
|
1059
|
+
if (isCloudAuthoritative(path.relative(run.hqRoot, localPath))) {
|
|
1060
|
+
downloadItems.push({ action: "download", remoteFile, localPath, isNew: false });
|
|
1061
|
+
run.emit({ type: "reconciled", path: remoteFile.key, direction: "pull" });
|
|
1062
|
+
return null;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1051
1065
|
await refreshRunContextIfExpiring(run);
|
|
1052
1066
|
|
|
1053
1067
|
const detectedAt = new Date().toISOString();
|
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
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { isCloudAuthoritative } from "./cloud-authoritative.js";
|
|
3
|
+
|
|
4
|
+
describe("isCloudAuthoritative", () => {
|
|
5
|
+
it("matches server-regenerated root files (both path forms)", () => {
|
|
6
|
+
for (const p of [
|
|
7
|
+
"companies/indigo/company-brief.md",
|
|
8
|
+
"company-brief.md",
|
|
9
|
+
"companies/acme/board.json",
|
|
10
|
+
"board.json",
|
|
11
|
+
]) {
|
|
12
|
+
expect(isCloudAuthoritative(p)).toBe(true);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("matches the pipeline-authored dirs", () => {
|
|
17
|
+
for (const p of [
|
|
18
|
+
"companies/indigo/ontology/entities/concept/grok.md",
|
|
19
|
+
"ontology/entities/person/x.md",
|
|
20
|
+
"companies/indigo/signals/slack-dm/action_item/a.md",
|
|
21
|
+
"signals/decision/b.md",
|
|
22
|
+
"companies/indigo/sources/slack/T1-2.md",
|
|
23
|
+
"sources/meetings/m.md",
|
|
24
|
+
]) {
|
|
25
|
+
expect(isCloudAuthoritative(p)).toBe(true);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("does NOT match client-owned files", () => {
|
|
30
|
+
for (const p of [
|
|
31
|
+
"companies/indigo/skills/work-mesh/SKILL.md",
|
|
32
|
+
"companies/indigo/projects/company-agents/release/index.html",
|
|
33
|
+
"companies/indigo/workers/hq-notify/emails/notice.template.html",
|
|
34
|
+
"companies/indigo/knowledge/readme.md",
|
|
35
|
+
"company-brief.md.conflict-2026-06-18T00-00-00Z-501bc8.md", // a mirror, not the brief
|
|
36
|
+
"docs/handoff.md",
|
|
37
|
+
]) {
|
|
38
|
+
expect(isCloudAuthoritative(p)).toBe(false);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("normalizes windows separators", () => {
|
|
43
|
+
expect(isCloudAuthoritative("companies\\indigo\\ontology\\entities\\x.md")).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud-authoritative vault paths — files the SERVER regenerates and owns, so
|
|
3
|
+
* on a sync conflict the cloud version always wins (pull-wins) and NO
|
|
4
|
+
* `.conflict-` mirror is minted.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists
|
|
7
|
+
* ---------------
|
|
8
|
+
* Some vault files are written/regenerated server-side on a schedule, not by
|
|
9
|
+
* the client:
|
|
10
|
+
* - `company-brief.md` — rewritten by the ontology gardener's brief-generator
|
|
11
|
+
* on every run.
|
|
12
|
+
* - `board.json` — rewritten by board automation.
|
|
13
|
+
* - `ontology/**`, `signals/**`, `sources/**` — the gardener / sources+signals
|
|
14
|
+
* pipeline are the sole authors; clients only consume.
|
|
15
|
+
*
|
|
16
|
+
* A client that also holds these locally diverges from the server copy on
|
|
17
|
+
* essentially every sync. Under `--on-conflict keep` (what outposts run) that
|
|
18
|
+
* produced two compounding bugs:
|
|
19
|
+
* 1. CONFLICT LOOP — each sync minted a fresh `<file>.conflict-<ts>-<machine>`
|
|
20
|
+
* mirror that was never cleaned up (e.g. ~50 `company-brief.md.conflict-*`
|
|
21
|
+
* piled up on an outpost over a few days).
|
|
22
|
+
* 2. SILENT DRIFT — the keep path stamped the journal with the REMOTE etag
|
|
23
|
+
* while leaving the divergent LOCAL content in place, so the journal
|
|
24
|
+
* reported "in sync" forever and the file never reconciled.
|
|
25
|
+
*
|
|
26
|
+
* Treating these paths as cloud-authoritative resolves both: the conflict path
|
|
27
|
+
* short-circuits to a normal overwrite-from-cloud (correct journal stamp, no
|
|
28
|
+
* mirror), because for a server-owned file the cloud copy is the truth.
|
|
29
|
+
*
|
|
30
|
+
* Extending
|
|
31
|
+
* ---------
|
|
32
|
+
* Add a pattern to {@link CLOUD_AUTHORITATIVE_PATTERNS}. Patterns are matched
|
|
33
|
+
* against the VAULT-RELATIVE path (a leading `companies/<slug>/` is stripped
|
|
34
|
+
* first), so both call sites — the pull leg (hqRoot-relative
|
|
35
|
+
* `companies/<slug>/…`) and the push leg (company-relative `…`) — work.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** Vault-relative patterns for server-owned, cloud-wins files. */
|
|
39
|
+
export const CLOUD_AUTHORITATIVE_PATTERNS: readonly RegExp[] = [
|
|
40
|
+
/^company-brief\.md$/,
|
|
41
|
+
/^board\.json$/,
|
|
42
|
+
/^ontology\//,
|
|
43
|
+
/^signals\//,
|
|
44
|
+
/^sources\//,
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* True iff `relPath` is a server-regenerated, cloud-authoritative vault file.
|
|
49
|
+
* Accepts either an hqRoot-relative path (`companies/<slug>/…`) or a
|
|
50
|
+
* vault-relative path (`…`); Windows separators are normalized.
|
|
51
|
+
*/
|
|
52
|
+
export function isCloudAuthoritative(relPath: string): boolean {
|
|
53
|
+
const vaultRel = relPath
|
|
54
|
+
.split("\\")
|
|
55
|
+
.join("/")
|
|
56
|
+
.replace(/^\.?\//, "")
|
|
57
|
+
.replace(/^companies\/[^/]+\//, "");
|
|
58
|
+
return CLOUD_AUTHORITATIVE_PATTERNS.some((re) => re.test(vaultRel));
|
|
59
|
+
}
|
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
|
// ---------------------------------------------------------------------------
|