/** * Cross-process lock protocol for the machine credential store (unified-machine-auth 04 §2) — * the TypeScript twin of the C# implementation in MCP-Plugin-dotnet (b2), with IDENTICAL * semantics and constants so mixed C#/TS processes on one machine serialize correctly against * the same `~/.ai-game-dev/credentials.lock` file. * * Protocol invariants (04 §2): * * - **Exclusive-create only.** Acquisition is `open(…, 'wx')` (C#: `FileMode.CreateNew`) of the * lock file, writing `{pid, startedAt, hostId}` and closing the handle immediately so the * mtime is visible to peers. NO advisory locks (`flock`/`FileShare.None`) anywhere — .NET * skips advisory locks on NFS/SMB and `File.Delete` ignores them on Linux (O6), so * exclusive-create is the only primitive both languages can rely on identically. * * - **Ordered constants.** {@link REFRESH_HTTP_TIMEOUT} < {@link LOCK_STALE_MS} < * {@link ACQUIRE_BUDGET}. The ordering IS the invariant: a live holder inside one HTTP call * can never be declared stale, and a waiter always outlives the stale threshold so takeover * is reachable. Exported (and pinned by `test/golden-vectors/LockProtocol.GoldenVectors.json`) * so the cross-language parity suite (x1) can assert equality with the C# values. * * - **Stale takeover by compare-and-delete, SERIALIZED through a takeover-intent file.** A * candidate whose last-WRITE time (never atime) is older than its class's threshold may be * taken over ({@link classifyLockDocument}): same-host (case-insensitive `hostId`) and * unparseable/zero-byte documents at {@link LOCK_STALE_MS}; parseable foreign-`hostId` * documents (network home) only after {@link FOREIGN_LOCK_STALE_MS} (24 h). The remover must * first win an * exclusive-create of the sibling intent file ({@link CREDENTIALS_LOCK_TAKEOVER_FILE_NAME}), * re-validate that the candidate is the SAME artifact it judged stale (stat + byte-identical * content), and only then unlink it — then release the intent and race for exclusive-create * of the lock itself, verifying the new lock's content is its own before entering. * * The intent serialization is LOAD-BEARING, not defensive garnish: with removal gated only by * a read → re-stat → re-read → unlink sequence (whether the removal is an `unlink` or an * atomic rename-claim), two lockstep waiters both validate the SAME unchanged stale file, and * the slower one's removal lands after the faster one has already re-created and verified a * LIVE lock — stranding a verified holder and letting a third waiter in. Both raced variants * were MEASURED double-entering under a 4-process hammer (see * `lock-protocol.subprocess.test.ts`, plant 3, which fails against either). Exclusive-create * of the intent is the atomic arbiter that path-based removal cannot provide. * * Crash recovery: a claimant that dies between intent-create and intent-release leaves the * intent file behind; it is itself recovered by the same staleness rules (compare-and-delete, * unserialized — safe because it requires the rare crashed-intent precondition on top of the * tight race window, multiplying two small probabilities; the C# twin shares this residual by * design). * * - **Budget exhaustion ⇒ "busy", never lock-free.** When {@link ACQUIRE_BUDGET} elapses the * attempt fails with {@link CredentialLockBusyError} (D9 REVISED — the removed "kill-switch" * fallback would have reintroduced the refresh-token reuse race). There is deliberately NO * code path that proceeds without the lock. * * - **Release = delete the lock file.** {@link MachineCredentialLock.release} additionally * verifies the file still carries OUR content before unlinking, so a holder that overstayed * {@link LOCK_STALE_MS} and was legitimately taken over never deletes the new holder's lock * (a strict safety refinement of "delete the lock file"; unobservable on the happy path). * * - **Logout delete path (F6).** {@link MachineCredentialLock.deleteStoreUnderLock}: * acquire → unlink store → release (which unlinks the lock). */ /** File name of the cross-process lock, a sibling of `credentials.json` — NEVER the data file itself (04 §2). */ export declare const CREDENTIALS_LOCK_FILE_NAME = "credentials.lock"; /** * File name of the takeover-intent file (sibling of the lock). Removing a stale * {@link CREDENTIALS_LOCK_FILE_NAME} requires FIRST winning an exclusive-create of this file — * the atomic arbiter that serializes concurrent stale-takeover claimants (see the module doc). * Part of the shared cross-language protocol surface: the C# twin (b2) must honor the same * intent file, or a mixed C#/TS fleet degrades to the measured double-acquire race. */ export declare const CREDENTIALS_LOCK_TAKEOVER_FILE_NAME = "credentials.lock.takeover"; /** * Network timeout (ms) for the token-refresh HTTP call performed INSIDE the lock's critical * section — 15 s, explicitly set in both languages (.NET's default would be 100 s, which would * break the ordering invariant). Must stay `<` {@link LOCK_STALE_MS}: a live holder inside one * HTTP call can never be declared stale. Shared contract with C# (04 §2); consumed by the * refresher wiring (c3). */ export declare const REFRESH_HTTP_TIMEOUT = 15000; /** * Age (ms of last-WRITE time, never atime) beyond which a same-host lock is a stale-takeover * candidate — 60 s. Must stay `>` {@link REFRESH_HTTP_TIMEOUT} and `<` {@link ACQUIRE_BUDGET} * (04 §2). Shared contract with C#. */ export declare const LOCK_STALE_MS = 60000; /** * Total time (ms) an acquisition attempt may spend before failing as "busy" — 75 s. Must stay * `>` {@link LOCK_STALE_MS} so a waiter always outlives the stale threshold and takeover is * reachable (04 §2). Shared contract with C#. */ export declare const ACQUIRE_BUDGET = 75000; /** * Age (ms) beyond which a lock with a FOREIGN `hostId` may be taken over — 24 h (04 §2). On a * network home directory another machine's live process may hold the lock; its clock skew and * our inability to probe its pid make the short threshold unsafe, hence the long bar. */ export declare const FOREIGN_LOCK_STALE_MS: number; /** The JSON document written into the lock file at acquisition (04 §2 + fix-round amendment). */ export interface CredentialLockContent { /** Process id of the holder (diagnostic; staleness is judged by mtime, never by pid probing). */ pid: number; /** ISO-8601 instant the holder acquired the lock (diagnostic). */ startedAt: string; /** Stable machine identifier of the holder — hostname is acceptable (04 §2). Compared CASE-INSENSITIVELY. */ hostId: string; /** * Fresh random 128-bit hex per acquisition attempt (fix-round amendment): `startedAt` has only * millisecond granularity, so two same-process attempts inside one ms would otherwise produce * byte-identical documents and defeat every content-identity comparison in the protocol. * Optional on READ — both twins tolerate documents from writers that predate the field. */ nonce?: string; } /** * Staleness class of a lock/intent document (fix-round contract amendment): * - `local` — parseable, `hostId` equal to ours case-insensitively ⇒ {@link LOCK_STALE_MS}. * - `foreign` — parseable, `hostId` missing/empty/different ⇒ {@link FOREIGN_LOCK_STALE_MS}. * - `unparseable` — zero-byte or corrupted ⇒ {@link LOCK_STALE_MS}: such an artifact's writer * NEVER entered the critical section (the full document write precedes the handle return, * which precedes entry), so the 24 h foreign bar would protect nothing while wedging * same-host recovery for a day. Takeover of this class emits one diagnostic warning. */ export type LockDocumentClass = "local" | "foreign" | "unparseable"; /** * Classify a lock/intent document for staleness-threshold selection (see * {@link LockDocumentClass}). `hostId` is compared case-insensitively (hostnames are * case-insensitive on every platform we ship to, and Windows reports them inconsistently); * an empty or missing `hostId` in an otherwise-parseable document classifies as FOREIGN — * a foreign implementation's document proves nothing about which machine wrote it. */ export declare function classifyLockDocument(bytes: Buffer, localHostId: string): LockDocumentClass; /** * Thrown by {@link MachineCredentialLock.acquire} when {@link ACQUIRE_BUDGET} elapses without * acquiring the lock. The operation MUST be surfaced to the caller as "busy" — it never * proceeds lock-free (04 §2, D9 REVISED). */ export declare class CredentialLockBusyError extends Error { /** Absolute path of the lock file that stayed contended. */ readonly lockPath: string; /** How long the attempt waited before giving up (ms). */ readonly waitedMs: number; constructor(lockPath: string, waitedMs: number); } /** * Options for {@link MachineCredentialLock}. The timing overrides exist EXCLUSIVELY so tests can * exercise the protocol without minute-scale waits — production consumers MUST use the defaults, * which are the 04 §2 cross-language contract ({@link LOCK_STALE_MS} / {@link ACQUIRE_BUDGET} / * {@link FOREIGN_LOCK_STALE_MS}). Overriding them in shipping code breaks the ordering invariant * shared with the C# twin. */ export interface MachineCredentialLockOptions { /** Stable machine identifier written into the lock content. Default: `os.hostname()`. */ hostId?: string; /** TEST-ONLY override of {@link LOCK_STALE_MS}. */ staleMs?: number; /** TEST-ONLY override of {@link FOREIGN_LOCK_STALE_MS}. */ foreignStaleMs?: number; /** TEST-ONLY override of {@link ACQUIRE_BUDGET}. */ acquireBudgetMs?: number; /** TEST-ONLY cap of the jittered retry backoff (ms). Default 500. */ maxBackoffMs?: number; /** * Structured diagnostic-warning sink (never receives token material — lock documents carry * none). Default: `console.warn`, so the one mandated diagnostic — taking over an * unparseable/zero-byte lock artifact — surfaces even before a consumer wires a sink. */ onWarning?: (message: string) => void; } /** * The cross-process credential-store lock (04 §2). One instance guards one store directory; * non-reentrant — a second {@link acquire} on a held instance throws instead of deadlocking. * * Intended use (c3 refresher / F6 logout): * ```ts * const lock = new MachineCredentialLock(); // ~/.ai-game-dev/credentials.lock * await lock.withLock(() => { // acquire → fn → release * // re-read store → decide → network refresh (≤ REFRESH_HTTP_TIMEOUT) → write * }); * ``` */ export declare class MachineCredentialLock { private readonly _lockPath; private readonly _intentPath; private readonly _hostId; private readonly _staleMs; private readonly _foreignStaleMs; private readonly _acquireBudgetMs; private readonly _maxBackoffMs; private readonly _onWarning; /** The exact bytes we wrote into the lock file while held; undefined when not held. */ private _ownContent; constructor(baseDirectory?: string, options?: MachineCredentialLockOptions); /** Absolute path of the lock file (`/credentials.lock`). */ get lockPath(): string; /** Absolute path of the takeover-intent file (`/credentials.lock.takeover`). */ get takeoverIntentPath(): string; /** True while this instance holds the lock. */ get isHeld(): boolean; /** * Acquire the lock, waiting up to the budget ({@link ACQUIRE_BUDGET}) with jittered backoff and * stale takeover (04 §2). Throws {@link CredentialLockBusyError} when the budget is exhausted — * the caller must surface "busy" and MUST NOT proceed lock-free (D9 REVISED). */ acquire(): Promise; /** * Release the lock: delete the lock file (04 §2) — but only when it still carries OUR content. * If a peer legitimately took the lock over (we overstayed {@link LOCK_STALE_MS}), the file is * theirs now and is left untouched. Safe to call when the file is already gone. Always clears * the held state. */ release(): void; /** * Run `fn` inside the lock's critical section: acquire → fn → release (release runs even when * `fn` throws). The refresher's critical section (04 §2): re-read store → decide → network * refresh (≤ {@link REFRESH_HTTP_TIMEOUT}) → write → release. */ withLock(fn: () => T | Promise): Promise; /** * The F6 logout delete path (04 §2): acquire → `unlinkStore()` (the server-side revoke has * already been done by the caller) → release, which unlinks the lock file. Exposed so logout * consumers never hand-roll the ordering. Throws {@link CredentialLockBusyError} when the lock * cannot be acquired — logout must not delete the store while a refresher is mid-rotation. */ deleteStoreUnderLock(unlinkStore: () => void): Promise; /** * One `open(…,'wx')` exclusive-create attempt (04 §2). On success the content is written, the * handle is closed immediately (so the mtime is visible to peers), and the file is re-read to * verify it still holds OUR content before we consider ourselves the holder. */ private tryExclusiveCreate; /** * Stale-takeover attempt (04 §2), serialized through the takeover-intent file. Returns true * when the stale lock was removed (the caller then races for exclusive-create immediately); * false when there is nothing stale to take over or we lost a race along the way. * * Staleness is judged on the LAST-WRITE time (mtime — never atime: reads must not extend a * lock's life) against {@link LOCK_STALE_MS} for a same-host (`local`) or `unparseable` document * and {@link FOREIGN_LOCK_STALE_MS} (24 h) only for a genuinely `foreign` one — see * {@link classifyLockDocument} and {@link LockDocumentClass}. Unparseable content is judged at * the SHORT bar on purpose (B2 amendment): such an artifact's writer can never have entered the * critical section (a well-formed document is written before that point), so nothing live can be * disturbed by treating it as stale quickly — the 24 h foreign bar would protect nothing here * while wedging every future acquirer behind a corrupt file for a day. * * Sequence: judge stale → win exclusive-create of the INTENT file (losers back off; a live * lock is never touched by a claimant that did not win the intent) → re-validate that the * candidate is the SAME artifact (stat + byte-identical content; a live replacement always * differs — its `startedAt`/`pid` are newer than the dead holder's) → unlink it → release the * intent. Only then does the caller race `open('wx')` for the lock itself. */ private tryStaleTakeover; /** * Try to win the takeover-intent file by exclusive-create, recovering a CRASHED claimant's * intent by the same staleness + compare-and-delete rules. Returns the intent content bytes on * success (used to verify ownership at release) or undefined when another claimant holds it. * * The crashed-intent recovery path is deliberately unserialized (there is no third lock): it * is reachable only when a claimant died between intent-create and intent-release, and the * damage of its residual race is another claimant pair racing the MAIN takeover — two small * probabilities multiplied, accepted by design in both languages. */ private tryAcquireTakeoverIntent; /** * Fresh protocol document bytes for one acquisition attempt: `{pid, startedAt, hostId, nonce}` * with a fresh random 128-bit hex `nonce` (fix-round amendment) — `startedAt` alone has ms * granularity, so same-process attempts inside one ms would otherwise be byte-identical and * defeat the protocol's content-identity comparisons. */ private newDocumentBytes; /** Release the takeover-intent file — only when it still carries OUR content. */ private releaseTakeoverIntent; /** Jittered exponential backoff: base 25 ms doubled per attempt, capped, ×[0.5, 1.5). */ private nextBackoffMs; } /** Parse lock-file bytes into {@link CredentialLockContent}; undefined when unparseable/mis-shaped. */ export declare function parseLockContent(bytes: Buffer): CredentialLockContent | undefined; //# sourceMappingURL=credential-lock.d.ts.map