@cortexkit/antigravity-auth-core 3.0.16 → 3.0.18

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.
@@ -1,4 +1,17 @@
1
1
  export declare function isLostMarkerRaceError(error: unknown): boolean;
2
+ /** Test-only fault injection between marker mkdir and owner.json write. */
3
+ export interface EvictionMarkerTestHooks {
4
+ /** Runs after the exclusive mkdir (and identity capture) before owner write. */
5
+ beforeOwnerWrite?: (evictPath: string, ownerPath: string) => Promise<void> | void;
6
+ /** Throw to fail the owner write; return an error to throw it. */
7
+ failOwnerWrite?: (evictPath: string, ownerPath: string) => Promise<unknown> | unknown;
8
+ }
9
+ /**
10
+ * Tests only: inject a failure or a directory replacement between the
11
+ * eviction-marker mkdir and its owner.json write. Production never sets this.
12
+ * Not exported from the fs entry point, so it is not part of the public API.
13
+ */
14
+ export declare function setEvictionMarkerTestHooksForTest(hooks?: EvictionMarkerTestHooks): void;
2
15
  export interface LockLoss {
3
16
  readonly reason: 'taken-over' | 'expired' | 'unreadable' | 'renewal-failed' | 'marker-lost';
4
17
  readonly expectedOwnerId: string;
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises';
2
+ import { lstat, mkdir, open, readFile, rename, rm, stat, } from 'node:fs/promises';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { createPrivateBytes, createPrivateDirectoryExclusive, ensurePrivateDirectory, PrivateFileError, privateOperation, readPrivateText, verifyPrivate, windowsPrivateFiles, writePrivateAtomic, } from './private-files.js';
5
5
  import { LockOwnershipError, lockPathFor } from './with-lock.js';
@@ -13,9 +13,35 @@ import { LockOwnershipError, lockPathFor } from './with-lock.js';
13
13
  */
14
14
  async function createExclusiveText(path, text) {
15
15
  const handle = await open(path, 'wx', 0o600);
16
+ let createdStat;
17
+ try {
18
+ createdStat = await handle.stat();
19
+ }
20
+ catch {
21
+ createdStat = undefined;
22
+ }
16
23
  try {
17
24
  await handle.writeFile(text, 'utf8');
18
25
  }
26
+ catch (error) {
27
+ // A failed write after an exclusive create leaves a partial file behind.
28
+ // Remove only the file just created: when a contender has meanwhile
29
+ // replaced the path, the identities differ and the successor is left
30
+ // alone. Removal failures must never mask the original write error.
31
+ try {
32
+ const current = await lstat(path).catch(() => undefined);
33
+ if (createdStat &&
34
+ current &&
35
+ current.dev === createdStat.dev &&
36
+ current.ino === createdStat.ino) {
37
+ await rm(path, { force: true }).catch(() => { });
38
+ }
39
+ }
40
+ catch {
41
+ // Best effort only; the original error below explains the failure.
42
+ }
43
+ throw error;
44
+ }
19
45
  finally {
20
46
  await handle.close();
21
47
  }
@@ -31,6 +57,31 @@ export function isLostMarkerRaceError(error) {
31
57
  return (code === 'ENOENT' ||
32
58
  (!windowsPrivateFiles && (code === 'EINVAL' || code === 'ENOTDIR')));
33
59
  }
60
+ let evictionMarkerTestHooks;
61
+ /**
62
+ * Tests only: inject a failure or a directory replacement between the
63
+ * eviction-marker mkdir and its owner.json write. Production never sets this.
64
+ * Not exported from the fs entry point, so it is not part of the public API.
65
+ */
66
+ export function setEvictionMarkerTestHooksForTest(hooks) {
67
+ evictionMarkerTestHooks = hooks;
68
+ }
69
+ function sameMarkerDirectory(created, current) {
70
+ if (created.dev !== current.dev || created.ino !== current.ino)
71
+ return false;
72
+ // Windows reports the directory identity as a file id that can be 0; fall
73
+ // back to creation time so a reused id does not look like the same directory.
74
+ if (created.ino === 0 && created.birthtimeMs !== current.birthtimeMs)
75
+ return false;
76
+ return true;
77
+ }
78
+ /**
79
+ * An ownerless marker older than this is recoverable without waiting for the
80
+ * full eviction TTL. Honest holders write owner.json within tens of
81
+ * milliseconds of the mkdir, so one second leaves wide margin for slow Windows
82
+ * filesystems while still bounding the stall from a crashed holder.
83
+ */
84
+ const OWNERLESS_MARKER_GRACE_MS = 1_000;
34
85
  /** Filesystem leases share wall time across all callers and processes. */
35
86
  export function acquireRefreshFileLock(options) {
36
87
  return acquireRefreshFileLockWithClock({ ...options, now: Date.now });
@@ -321,12 +372,66 @@ export async function acquireRefreshFileLockWithClock(options) {
321
372
  await removeLeaseObject(evictPath);
322
373
  }
323
374
  }
375
+ // Returns whether the directory at the marker path was still the one this
376
+ // process created when its owner write failed. Only then is the failure ours
377
+ // to report: a gone or replaced directory is a lost race.
378
+ async function removeOwnMarkerAfterFailedOwnerWrite(created) {
379
+ // Best effort only: never throws, so the original owner-write error below
380
+ // keeps explaining the failure. Removes the directory this process just
381
+ // created when its owner write failed, and leaves a contender's
382
+ // replacement alone when the identities differ.
383
+ if (!created)
384
+ return false;
385
+ let current;
386
+ try {
387
+ const st = await lstat(evictPath);
388
+ current = { dev: st.dev, ino: st.ino, birthtimeMs: st.birthtimeMs };
389
+ }
390
+ catch {
391
+ // Renamed away by a contender's steal: nothing of ours left to clean.
392
+ return false;
393
+ }
394
+ if (!sameMarkerDirectory(created, current))
395
+ return false;
396
+ // Remove only an ownerless directory. An owner file that appeared after
397
+ // our failed write belongs to someone: ours goes through
398
+ // releaseEvictionMarker in the caller, and a contender's must stay, since
399
+ // it may be holding the marker right now.
400
+ try {
401
+ await stat(evictOwnerPath);
402
+ return true;
403
+ }
404
+ catch (error) {
405
+ if (error.code !== 'ENOENT')
406
+ return true;
407
+ }
408
+ try {
409
+ await removeLeaseObject(evictPath);
410
+ }
411
+ catch {
412
+ // A removal failure keeps the stale marker recoverable later; it must
413
+ // never mask the original owner-write error.
414
+ }
415
+ return true;
416
+ }
324
417
  async function tryAcquireEvictionMarker() {
325
418
  if (windowsPrivateFiles)
326
419
  await createPrivateDirectoryExclusive(evictPath);
327
420
  else
328
421
  await mkdir(evictPath);
422
+ let created;
423
+ try {
424
+ const st = await lstat(evictPath);
425
+ created = { dev: st.dev, ino: st.ino, birthtimeMs: st.birthtimeMs };
426
+ }
427
+ catch {
428
+ created = undefined;
429
+ }
329
430
  try {
431
+ await evictionMarkerTestHooks?.beforeOwnerWrite?.(evictPath, evictOwnerPath);
432
+ const injected = await evictionMarkerTestHooks?.failOwnerWrite?.(evictPath, evictOwnerPath);
433
+ if (injected !== undefined)
434
+ throw injected;
330
435
  const bytes = `${JSON.stringify({ ownerId: evictOwnerId, createdAt: now() })}\n`;
331
436
  if (windowsPrivateFiles)
332
437
  await createPrivateBytes(evictOwnerPath, bytes);
@@ -339,9 +444,26 @@ export async function acquireRefreshFileLockWithClock(options) {
339
444
  // path below does exactly that). That is a lost race, not a failure, so
340
445
  // report it as such and let the caller back off and retry rather than
341
446
  // failing the whole lock acquisition.
342
- if (isLostMarkerRaceError(error))
447
+ //
448
+ // The race codes alone cannot prove the directory is gone: the same
449
+ // codes can surface while our directory is still in place (for example
450
+ // a permission failure while writing the owner file, which used to
451
+ // leave an ownerless marker behind). The identity fence
452
+ // above removes only the directory this process created, and is a no-op
453
+ // when the contender renamed it away or replaced it, so run it on both
454
+ // branches before deciding between a retry and a failure.
455
+ // A race code while our own directory is still in place is not a lost
456
+ // race but a write that fails here every time: report it, rather than
457
+ // retrying the same failure until a deadline hides the cause.
458
+ const stillOurs = await removeOwnMarkerAfterFailedOwnerWrite(created);
459
+ if (isLostMarkerRaceError(error) && !stillOurs)
343
460
  return false;
344
- await releaseEvictionMarker();
461
+ try {
462
+ await releaseEvictionMarker();
463
+ }
464
+ catch {
465
+ // Removal must never mask the original owner-write error.
466
+ }
345
467
  throw error;
346
468
  }
347
469
  if (options.onStep)
@@ -371,8 +493,29 @@ export async function acquireRefreshFileLockWithClock(options) {
371
493
  throw error;
372
494
  }
373
495
  }
374
- if (evictStat.mtimeMs + EVICT_TTL > now())
375
- return 'fresh';
496
+ if (evictStat.mtimeMs + EVICT_TTL > now()) {
497
+ // A fresh marker with no owner is either this process's orphan (its
498
+ // owner write just failed) or a holder that crashed between mkdir and
499
+ // owner write. Honest holders publish owner.json within tens of
500
+ // milliseconds, so recover ownerless markers after a short grace instead
501
+ // of stalling for the full TTL. Stealing one is safe: a live holder
502
+ // stalled past the grace sees its pending owner write fail with ENOENT
503
+ // (a lost race) and retries, rather than acting on a stolen marker.
504
+ let hasOwner = true;
505
+ try {
506
+ await stat(evictOwnerPath);
507
+ }
508
+ catch (error) {
509
+ if (error.code === 'ENOENT')
510
+ hasOwner = false;
511
+ else
512
+ return 'fresh';
513
+ }
514
+ if (hasOwner)
515
+ return 'fresh';
516
+ if (evictStat.mtimeMs + OWNERLESS_MARKER_GRACE_MS > now())
517
+ return 'fresh';
518
+ }
376
519
  if (options.onStep)
377
520
  await options.onStep('stale-marker-stat');
378
521
  const claimedPath = `${evictPath}.${randomUUID()}`;
@@ -699,7 +842,12 @@ export async function acquireRefreshFileLockWithClock(options) {
699
842
  released = true;
700
843
  cancelRenewalTimer();
701
844
  await renewalInFlight;
702
- for (let attempt = 0; attempt < MAX_STEAL_ATTEMPTS; attempt++) {
845
+ // A fresh marker may outlive a few quick retries, especially on Windows.
846
+ // Allow its full TTL plus recovery time, using a monotonic deadline so
847
+ // wall-clock adjustments cannot extend the retry deadline.
848
+ const deadline = performance.now() + EVICT_TTL + 1_000;
849
+ let delayMs = 5;
850
+ for (;;) {
703
851
  try {
704
852
  let removalConfirmed = false;
705
853
  const markerAcquired = await withEvictionMarker(async () => {
@@ -741,7 +889,11 @@ export async function acquireRefreshFileLockWithClock(options) {
741
889
  throw error;
742
890
  return;
743
891
  }
744
- await backoff();
892
+ const remainingMs = deadline - performance.now();
893
+ if (remainingMs <= 0)
894
+ break;
895
+ await new Promise((resolve) => setTimeout(resolve, Math.min(delayMs, remainingMs)));
896
+ delayMs = Math.min(delayMs * 2, 50);
745
897
  }
746
898
  // Do not delete by pathname without the marker: bounded retries leave the
747
899
  // lease to expire rather than risking removal of a successor's lock.
@@ -75,6 +75,16 @@ async function acquire(path, stale) {
75
75
  await remove(path);
76
76
  return acquire(path, 0);
77
77
  }
78
+ // The exclusive mkdir above succeeded: capture this directory's identity
79
+ // before any further step, so cleanup below removes only what we created.
80
+ let created;
81
+ try {
82
+ const st = await stat(path);
83
+ created = { dev: st.dev, ino: st.ino };
84
+ }
85
+ catch {
86
+ created = undefined;
87
+ }
78
88
  try {
79
89
  if (!precision) {
80
90
  const probe = new Date(Math.ceil(Date.now() / 1_000) * 1_000 + 5);
@@ -86,7 +96,22 @@ async function acquire(path, stale) {
86
96
  return { dev: observed.dev, ino: observed.ino, mtime };
87
97
  }
88
98
  catch (error) {
89
- await remove(path).catch(() => { });
99
+ // Only remove the directory this call created. A contender can rename it
100
+ // away and publish its own lock between the mkdir above and this failure;
101
+ // an unfenced rmdir would then delete the successor. The directory holds
102
+ // no owner file (mtime is the lease), so identity alone is the fence.
103
+ try {
104
+ if (created) {
105
+ const current = await stat(path).catch(() => undefined);
106
+ if (current &&
107
+ current.dev === created.dev &&
108
+ current.ino === created.ino)
109
+ await remove(path).catch(() => { });
110
+ }
111
+ }
112
+ catch {
113
+ // Best effort only; the original error below explains the failure.
114
+ }
90
115
  throw error;
91
116
  }
92
117
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema": 1,
3
3
  "package": "@cortexkit/common-auth",
4
- "version": "0.28.0",
4
+ "version": "0.29.0",
5
5
  "artifactStatus": "published",
6
6
  "publicRoots": {
7
7
  "./store": {
@@ -37,15 +37,15 @@
37
37
  "import": "./dist/pi-slot/index.js"
38
38
  }
39
39
  },
40
- "input": "tools/common-auth-build/inputs/cortexkit-common-auth-0.28.0.tgz",
41
- "tarballSha256": "8070e3c1882238d78ac5e955fb140484e08d9f97ed0f2d5d40f35928c60fbbc5",
42
- "sri": "sha512-PRyppMdE3y8dTzF3iQHcFruo5ksPPhgSQExe7g4Ij/oPynDLbalSQqBaInKdSV/v5qyrY0iJs/D0Yu7fbWh3NQ==",
40
+ "input": "tools/common-auth-build/inputs/cortexkit-common-auth-0.29.0.tgz",
41
+ "tarballSha256": "6d8795d0672353ab897f938cad86c63556ceab7704bd60498a8c8e1a62dbc207",
42
+ "sri": "sha512-XnNFY5ppNQRDP09lUTDDONGHwiJ8Z93w8/NP16pmWpC6lzBPkC7XJcHhld+B6iLATaNvbcenfhecitB8fS9nbQ==",
43
43
  "canonicalFiles": 147,
44
- "canonicalBytes": 985409,
44
+ "canonicalBytes": 994153,
45
45
  "generator": {
46
46
  "path": "packages/opencode/scripts/embed-common-auth.ts",
47
47
  "version": 3,
48
- "sha256": "32e05acc65fbec2114dce8a43e29e3eca2d6f02594f20e3f6e7bc3b1ec9b8aa3"
48
+ "sha256": "ece06fd4117208e4b276fee17386d3a0f93e641cee49c0d30d985c169c551ad2"
49
49
  },
50
50
  "files": [
51
51
  {
@@ -595,17 +595,17 @@
595
595
  {
596
596
  "source": "package/dist/fs/refresh-file-lock.d.ts",
597
597
  "output": "fs/refresh-file-lock.d.ts",
598
- "bytes": 2157,
599
- "sourceSha256": "cbce15eb313d425501780dd4be657cbbaa7e41064bab4ae64fea91fbd54918e9",
600
- "outputSha256": "cbce15eb313d425501780dd4be657cbbaa7e41064bab4ae64fea91fbd54918e9",
598
+ "bytes": 2946,
599
+ "sourceSha256": "b649e67813eb4086ca5642c99d318f90ccccae9702b23ad8fb0616f8d62f5a06",
600
+ "outputSha256": "b649e67813eb4086ca5642c99d318f90ccccae9702b23ad8fb0616f8d62f5a06",
601
601
  "transform": "verbatim"
602
602
  },
603
603
  {
604
604
  "source": "package/dist/fs/refresh-file-lock.js",
605
605
  "output": "fs/refresh-file-lock.js",
606
- "bytes": 30833,
607
- "sourceSha256": "c0e4de79b0c0bb8fac09b13c5cf861eaa008434b336e87ce761d7f0713203a90",
608
- "outputSha256": "c0e4de79b0c0bb8fac09b13c5cf861eaa008434b336e87ce761d7f0713203a90",
606
+ "bytes": 37753,
607
+ "sourceSha256": "38bc20ae71a195818f9cf7e42954e8305f92a55ff15dd855da33ccdec6b34fa1",
608
+ "outputSha256": "38bc20ae71a195818f9cf7e42954e8305f92a55ff15dd855da33ccdec6b34fa1",
609
609
  "transform": "verbatim"
610
610
  },
611
611
  {
@@ -707,9 +707,9 @@
707
707
  {
708
708
  "source": "package/dist/pi-slot/lock.js",
709
709
  "output": "pi-slot/lock.js",
710
- "bytes": 7385,
711
- "sourceSha256": "68b0c7c764b88391e466f3b9fa172df176bf21529399606bf84475f3b920db8b",
712
- "outputSha256": "68b0c7c764b88391e466f3b9fa172df176bf21529399606bf84475f3b920db8b",
710
+ "bytes": 8420,
711
+ "sourceSha256": "533de03f6b53624301e75565b7d677382acec3c9eda9cfdb7265a0e868aaeb82",
712
+ "outputSha256": "533de03f6b53624301e75565b7d677382acec3c9eda9cfdb7265a0e868aaeb82",
713
713
  "transform": "verbatim"
714
714
  },
715
715
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/antigravity-auth-core",
3
- "version": "3.0.16",
3
+ "version": "3.0.18",
4
4
  "description": "Harness-agnostic core for Google Antigravity OAuth: transport, fingerprint, request transforms, accounts, quota",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -80,7 +80,7 @@
80
80
  "dependencies": {
81
81
  "@cortexkit/claustrum-client": "0.12.0",
82
82
  "@cortexkit/subc-client": "0.22.5",
83
- "@cortexkit/private-files": "0.2.1",
83
+ "@cortexkit/private-files": "0.2.2",
84
84
  "xdg-basedir": "^5.1.0",
85
85
  "zod": "^4.0.0"
86
86
  },