pi-mcp-extension
Version:
Full-featured, lightweight MCP extension for the Pi coding agent: stdio, SSE & Streamable HTTP transports, OAuth 2.1 (PKCE + DCR), lazy/eager lifecycles — minimal dependencies, zero bloat.
627 lines (569 loc) • 20.9 kB
text/typescript
/**
* Cross-process per-server interactive OAuth authorization lock.
*
* Uses an atomic lock file (<hash>.json.lock, mode 0600) containing a
* versioned JSON record (PID, hostname, random nonce, creation timestamp)
* plus a periodic mtime heartbeat. Automatically reclaims locks left behind
* by crashed or SIGKILLed processes using a nonce-verified rename protocol.
*/
import {
open,
readFile,
stat,
rename,
unlink,
link,
utimes,
mkdir,
chmod,
} from "node:fs/promises";
import { readFileSync, unlinkSync } from "node:fs";
import { hostname as osHostname } from "node:os";
import { join } from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { agentDir } from "./config.js";
import { describeError } from "./debug-log-format.js";
import { reportDiagnostic } from "./debug-log.js";
import { AuthLockError, AuthRequiredError } from "./errors.js";
import {
formatHolderDetails,
HEARTBEAT_MS,
isLockStale,
parseLockRecord,
type LockRecord,
} from "./auth-lock-policy.js";
export { AuthLockError, AuthRequiredError };
// ─── Dependency Injection Types ───────────────────────────────────────────────
export interface AuthLockFileHandle {
writeFile(data: string): Promise<void>;
sync(): Promise<void>;
close(): Promise<void>;
}
export interface AuthLockFs {
open(path: string, flags: string, mode?: number): Promise<AuthLockFileHandle>;
readFile(path: string, encoding: BufferEncoding): Promise<string>;
stat(path: string): Promise<{ mtimeMs: number }>;
rename(oldPath: string, newPath: string): Promise<void>;
unlink(path: string): Promise<void>;
link(existingPath: string, newPath: string): Promise<void>;
utimes(path: string, atime: number | string | Date, mtime: number | string | Date): Promise<void>;
mkdir(path: string, options?: { recursive?: boolean; mode?: number }): Promise<string | undefined | void>;
chmod(path: string, mode: number): Promise<void>;
}
export interface AuthLockTimer {
unref?: () => void;
}
export interface AuthLockClock {
now(): number;
setInterval(callback: () => void, ms: number): ReturnType<typeof setInterval> | AuthLockTimer;
clearInterval(timer: ReturnType<typeof setInterval> | AuthLockTimer): void;
}
export interface AuthLockDeps {
fs: AuthLockFs;
clock: AuthLockClock;
pid: number;
hostname: string;
isPidAlive: (pid: number) => boolean;
}
export interface AcquireAuthLockOptions {
onLost?: (() => void) | undefined;
deps?: Partial<AuthLockDeps> | undefined;
}
export type AuthLockHandle = (() => Promise<void>) & {
unlock: () => Promise<void>;
assertHeld: () => Promise<void>;
isLost: () => boolean;
};
// ─── Path & Default Dependency Helpers ────────────────────────────────────────
export function authDir(storageDir?: string): string {
return storageDir ?? join(agentDir(), "mcp-auth");
}
export function statePath(serverName: string, storageDir?: string): string {
// Hash the server name to avoid filesystem issues with special chars
const hash = createHash("sha256").update(serverName).digest("hex").slice(0, 16);
return join(authDir(storageDir), `${hash}.json`);
}
export function lockPath(serverName: string, storageDir?: string): string {
return `${statePath(serverName, storageDir)}.lock`;
}
export function defaultIsPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err) {
return (err as NodeJS.ErrnoException).code === "EPERM";
}
}
export function createDefaultAuthLockDeps(): AuthLockDeps {
return {
fs: {
open: (path, flags, mode) => open(path, flags, mode),
readFile: (path, encoding) => readFile(path, encoding),
stat: (path) => stat(path),
rename: (oldPath, newPath) => rename(oldPath, newPath),
unlink: (path) => unlink(path),
link: (existingPath, newPath) => link(existingPath, newPath),
utimes: (path, atime, mtime) => utimes(path, atime, mtime),
mkdir: (path, options) => mkdir(path, options),
chmod: (path, mode) => chmod(path, mode),
},
clock: {
now: () => Date.now(),
setInterval: (cb, ms) => setInterval(cb, ms),
clearInterval: (timer) => clearInterval(timer as ReturnType<typeof setInterval>),
},
pid: process.pid,
hostname: osHostname(),
isPidAlive: defaultIsPidAlive,
};
}
// ─── Lock Acquisition, Heartbeat & Release ────────────────────────────────────
const MAX_ACQUIRE_ATTEMPTS = 3;
/** Yield across microtasks (and briefly on the real fs) while a peer finishes its restore. */
async function yieldForPeerRestore(usingRealFs: boolean, attempt: number): Promise<void> {
await Promise.resolve();
await Promise.resolve();
if (usingRealFs && attempt === 1) {
await new Promise<void>((resolve) => setTimeout(resolve, 5));
}
}
/** Read the lock record, retrying while a peer may have the file briefly out of place. */
async function readLockRecordWithRetry(
fs: AuthLockFs,
path: string,
usingRealFs = false,
): Promise<LockRecord | null> {
for (let i = 0; i < 3; i++) {
let record: LockRecord | null = null;
try {
record = parseLockRecord(await fs.readFile(path, "utf8"));
} catch {
// WHY: A missing or unreadable file is indistinguishable from a peer's in-flight restore
// on a single read; the retry below decides whether the lock is really gone.
}
if (record !== null) return record;
if (i === 2) return null;
// WHY: During stale-lock inspection a contender briefly renames `path` to `movedPath` and
// restores it via `link()` — or, where hard links are unsupported, re-creates it with an
// exclusive write that publishes the file empty for an instant. Both read as "no lock"
// here, so retry across microtasks (and a brief macrotask on the real fs) before
// concluding the lock is genuinely gone.
await yieldForPeerRestore(usingRealFs, i);
}
return null;
}
interface LockContext {
serverName: string;
path: string;
myRecord: LockRecord;
deps: AuthLockDeps;
usingRealFs: boolean;
onLost?: (() => void) | undefined;
}
async function runHeartbeatTick(
ctx: LockContext,
isStateActive: () => boolean,
markLost: () => void,
): Promise<void> {
const { path, myRecord, deps, usingRealFs } = ctx;
if (!isStateActive()) return;
const current = await readLockRecordWithRetry(deps.fs, path, usingRealFs);
if (!isStateActive() || !current || current.nonce !== myRecord.nonce) {
if (isStateActive()) markLost();
return;
}
const nowSec = deps.clock.now() / 1000;
try {
await deps.fs.utimes(path, nowSec, nowSec);
} catch {
if (!isStateActive()) return;
const after = await readLockRecordWithRetry(deps.fs, path, usingRealFs);
if (!after || after.nonce !== myRecord.nonce) {
markLost();
return;
}
await deps.fs.utimes(path, nowSec, nowSec).catch(() => {
// WHY: If utimes still fails after verifying the restored lock nonce, mark lock lost.
if (isStateActive()) markLost();
});
}
}
interface LockLifecycleState {
released: boolean;
lost: boolean;
heartbeatTimer: ReturnType<typeof setInterval> | AuthLockTimer | null;
}
function stopHeartbeat(ctx: LockContext, state: LockLifecycleState): void {
if (state.heartbeatTimer === null) return;
ctx.deps.clock.clearInterval(state.heartbeatTimer);
state.heartbeatTimer = null;
}
function unlinkOwnLockFileSync(ctx: LockContext, state: LockLifecycleState): void {
// WHY: Do not skip cleanup when `lost` is true — if a contender restored our
// lock file via `link()` after a transient rename, we must still unlink our own nonce.
if (state.released || !ctx.usingRealFs) return;
try {
const raw = readFileSync(ctx.path, "utf8");
const current = parseLockRecord(raw);
if (current && current.nonce === ctx.myRecord.nonce) {
unlinkSync(ctx.path);
}
} catch {
// WHY: Best-effort synchronous cleanup on process exit; lock file may already be unlinked.
}
}
function markLockLost(ctx: LockContext, state: LockLifecycleState): void {
if (state.lost || state.released) return;
state.lost = true;
stopHeartbeat(ctx, state);
ctx.onLost?.();
}
function startHeartbeat(ctx: LockContext, state: LockLifecycleState): void {
state.heartbeatTimer = ctx.deps.clock.setInterval(() => {
void runHeartbeatTick(
ctx,
() => !state.released && !state.lost,
() => markLockLost(ctx, state),
);
}, HEARTBEAT_MS);
state.heartbeatTimer.unref?.();
}
function lostLockError(serverName: string): AuthLockError {
return new AuthLockError(serverName, `Authorization lock lost for ${serverName}; retry`);
}
async function assertLockHeldFor(ctx: LockContext, state: LockLifecycleState): Promise<void> {
if (state.lost || state.released) throw lostLockError(ctx.serverName);
const current = await readLockRecordWithRetry(ctx.deps.fs, ctx.path, ctx.usingRealFs);
if (current && current.nonce === ctx.myRecord.nonce) return;
markLockLost(ctx, state);
throw lostLockError(ctx.serverName);
}
async function releaseLock(
ctx: LockContext,
state: LockLifecycleState,
onExit: () => void,
): Promise<void> {
if (state.released) return;
state.released = true;
stopHeartbeat(ctx, state);
process.removeListener("exit", onExit);
// WHY: Always check and unlink if the file on disk still carries our nonce, even if
// `lost` was set during a contender's temporary stale-check rename+link window.
const current = await readLockRecordWithRetry(ctx.deps.fs, ctx.path, ctx.usingRealFs);
if (!current || current.nonce !== ctx.myRecord.nonce) return;
await ctx.deps.fs.unlink(ctx.path).catch(() => {
// WHY: Best-effort lock removal on unlock; ignore ENOENT if concurrently removed.
});
}
function createLockHandle(ctx: LockContext): AuthLockHandle {
const state: LockLifecycleState = { released: false, lost: false, heartbeatTimer: null };
const onExit = () => unlinkOwnLockFileSync(ctx, state);
process.once("exit", onExit);
startHeartbeat(ctx, state);
const unlock = async (): Promise<void> => releaseLock(ctx, state, onExit);
return Object.assign(unlock, {
unlock,
assertHeld: () => assertLockHeldFor(ctx, state),
isLost: () => state.lost,
});
}
function errnoCode(err: unknown): string | undefined {
return (err as NodeJS.ErrnoException).code;
}
/** Stat the existing lock file; `undefined` when it disappeared (ENOENT) mid-race. */
async function statExistingLockFile(
path: string,
deps: AuthLockDeps,
): Promise<{ mtimeMs: number } | undefined> {
try {
return await deps.fs.stat(path);
} catch (statErr) {
if (errnoCode(statErr) === "ENOENT") {
return undefined;
}
throw statErr;
}
}
/**
* Read and parse the lock record at `path`.
* `undefined` means the file vanished (ENOENT); `null` means it is unreadable or corrupt.
*/
async function readExistingLockRecord(
path: string,
deps: AuthLockDeps,
): Promise<LockRecord | null | undefined> {
try {
return parseLockRecord(await deps.fs.readFile(path, "utf8"));
} catch (readErr) {
if (errnoCode(readErr) === "ENOENT") {
return undefined;
}
return null;
}
}
/** Read the moved stale candidate; a missing or corrupt candidate is treated as unparsable. */
async function readMovedLockRecord(path: string, deps: AuthLockDeps): Promise<LockRecord | null> {
try {
return parseLockRecord(await deps.fs.readFile(path, "utf8"));
} catch {
// WHY: Best-effort inspection of a renamed candidate; an unreadable file simply fails the
// identity check below and the contender's lock is restored instead of reclaimed.
return null;
}
}
/** Move the stale candidate aside; `undefined` when it vanished (ENOENT) before the rename. */
async function moveStaleCandidate(path: string, deps: AuthLockDeps): Promise<string | undefined> {
const movedPath = `${path}.stale-${randomUUID()}`;
try {
await deps.fs.rename(path, movedPath);
return movedPath;
} catch (renameErr) {
if (errnoCode(renameErr) === "ENOENT") {
return undefined;
}
throw renameErr;
}
}
/**
* Confirm the file moved aside is the same lock we measured as stale: either the same nonce, or,
* when the stale lock was unparsable, an unparsable file with the same mtime.
*/
async function isSameStaleCandidate(
record: LockRecord | null,
movedRecord: LockRecord | null,
movedPath: string,
statResult: { mtimeMs: number },
deps: AuthLockDeps,
): Promise<boolean> {
if (record !== null) {
return movedRecord !== null && movedRecord.nonce === record.nonce;
}
try {
const movedStat = await deps.fs.stat(movedPath);
return movedRecord === null && movedStat.mtimeMs === statResult.mtimeMs;
} catch {
// WHY: A vanished moved file cannot be the stale lock we measured; fall back to restore.
return false;
}
}
/** Remove the temporary stale-check file; an already-removed candidate is not a failure. */
async function unlinkCandidate(movedPath: string, deps: AuthLockDeps): Promise<void> {
await deps.fs.unlink(movedPath).catch(() => {
// WHY: Best-effort cleanup of temporary stale-check file.
});
}
/**
* Give up on a candidate that cannot be put back, and say so.
*
* The displaced holder finds out on its next heartbeat and aborts with `Authorization lock
* lost`; nothing in this module ever reads a `<path>.stale-*` file again, so leaving the
* candidate behind would only accumulate orphans in the auth directory.
*/
async function abandonCandidate(
serverName: string,
movedPath: string,
err: unknown,
deps: AuthLockDeps,
): Promise<void> {
const { kind, message } = describeError(err);
reportDiagnostic({
level: "warn",
event: "auth.lock_restore_failed",
server: serverName,
message: `Could not restore the authorization lock file for "${serverName}": ${message}`,
fields: { kind },
});
await unlinkCandidate(movedPath, deps);
}
/**
* Re-create `path` from the candidate when hard links are unavailable.
*
* Uses the same exclusive-create write as lock acquisition, so a lock another process acquired
* while `path` was free is never overwritten; `EEXIST` means that process now holds the lock.
*/
async function restoreCandidateByWrite(
serverName: string,
movedPath: string,
path: string,
deps: AuthLockDeps,
): Promise<void> {
let data: string;
try {
data = await deps.fs.readFile(movedPath, "utf8");
} catch (readErr) {
await abandonCandidate(serverName, movedPath, readErr, deps);
return;
}
try {
await writeExclusiveLockFile(deps.fs, path, data);
} catch (writeErr) {
if (errnoCode(writeErr) === "EEXIST") {
// WHY: Another process acquired the lock while `path` was free; its record wins and the
// displaced holder detects the loss itself.
await unlinkCandidate(movedPath, deps);
return;
}
await abandonCandidate(serverName, movedPath, writeErr, deps);
return;
}
await unlinkCandidate(movedPath, deps);
}
/**
* Restore the contender's fresh lock file, then clean up the temporary candidate path.
*
* Reached only when `isSameStaleCandidate` found the moved file carries a *different* nonce:
* the reclaim race was lost and this record is that process's only copy of its lock. Both
* restore primitives refuse to replace an existing lock — `link` fails `EEXIST`, and the
* fallback write is exclusive-create — because a filesystem that refuses hard links
* (`ENOTSUP`/`EOPNOTSUPP` on FAT/exFAT/SMB/SSHFS/FUSE, `EPERM` under `fs.protected_hardlinks`
* or seccomp) says nothing about whether `path` is free. Overwriting there would destroy the
* live lock of whichever process acquired it in the meantime, which is the very failure this
* path exists to prevent.
*
* At most one holder survives: if `path` is taken, the displaced holder's record is dropped
* and it aborts on its next heartbeat, exactly as if the reclaim had never restored it.
*/
async function restoreCandidate(
serverName: string,
movedPath: string,
path: string,
deps: AuthLockDeps,
): Promise<void> {
try {
await deps.fs.link(movedPath, path);
} catch (linkErr) {
const code = errnoCode(linkErr);
if (code === "EEXIST") {
// WHY: `path` holds a lock another process acquired while it was free, so restoring this
// record would delete a live lock. Keep that lock; the displaced holder detects the loss.
await unlinkCandidate(movedPath, deps);
return;
}
if (code === "ENOENT") {
await abandonCandidate(serverName, movedPath, linkErr, deps);
return;
}
await restoreCandidateByWrite(serverName, movedPath, path, deps);
return;
}
await unlinkCandidate(movedPath, deps);
}
async function inspectAndReclaimStaleLock(
serverName: string,
path: string,
deps: AuthLockDeps,
): Promise<void> {
// Lock file already exists — inspect and check staleness
const statResult = await statExistingLockFile(path, deps);
if (!statResult) {
return;
}
const record = await readExistingLockRecord(path, deps);
if (record === undefined) {
return;
}
const stale = isLockStale(record, statResult.mtimeMs, deps.clock.now(), {
hostname: deps.hostname,
isPidAlive: deps.isPidAlive,
});
if (!stale) {
throw new AuthLockError(serverName, formatHolderDetails(record));
}
// Reclaim protocol: move stale candidate aside, verify identity, then delete or restore
const movedPath = await moveStaleCandidate(path, deps);
if (!movedPath) {
return;
}
const movedRecord = await readMovedLockRecord(movedPath, deps);
if (await isSameStaleCandidate(record, movedRecord, movedPath, statResult, deps)) {
await deps.fs.unlink(movedPath).catch(() => {
// WHY: Best-effort removal of reclaimed stale lock file.
});
return;
}
await restoreCandidate(serverName, movedPath, path, deps);
throw new AuthLockError(serverName, formatHolderDetails(movedRecord));
}
function resolveAuthLockDeps(options?: AcquireAuthLockOptions): AuthLockDeps {
const overrides = options?.deps ?? {};
const defaults = createDefaultAuthLockDeps();
return {
fs: overrides.fs ?? defaults.fs,
clock: overrides.clock ?? defaults.clock,
pid: overrides.pid ?? defaults.pid,
hostname: overrides.hostname ?? defaults.hostname,
isPidAlive: overrides.isPidAlive ?? defaults.isPidAlive,
};
}
async function prepareAuthDirectory(
deps: AuthLockDeps,
serverName: string,
storageDir?: string,
): Promise<string> {
const directory = authDir(storageDir);
await deps.fs.mkdir(directory, { recursive: true, mode: 0o700 });
await deps.fs.chmod(directory, 0o700);
return lockPath(serverName, storageDir);
}
async function writeExclusiveLockFile(
fs: AuthLockFs,
path: string,
data: string,
): Promise<void> {
const file = await fs.open(path, "wx", 0o600);
try {
await file.writeFile(data);
await file.sync();
} catch (writeErr) {
await file.close().catch(() => {
// WHY: Best-effort file handle close after write failure.
});
await fs.unlink(path).catch(() => {
// WHY: Best-effort cleanup of partially written lock file before rethrowing writeErr.
});
throw writeErr;
}
await file.close();
}
function createLockRecord(deps: AuthLockDeps): LockRecord {
return {
v: 1,
pid: deps.pid,
hostname: deps.hostname,
nonce: randomUUID(),
createdAt: deps.clock.now(),
};
}
type LockAcquisitionContext = Omit<LockContext, "myRecord">;
async function tryCreateLock(ctx: LockAcquisitionContext): Promise<AuthLockHandle | null> {
const myRecord = createLockRecord(ctx.deps);
try {
await writeExclusiveLockFile(ctx.deps.fs, ctx.path, JSON.stringify(myRecord));
return createLockHandle({ ...ctx, myRecord });
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
await inspectAndReclaimStaleLock(ctx.serverName, ctx.path, ctx.deps);
return null;
}
}
export async function acquireAuthLock(
serverName: string,
storageDir?: string,
options?: AcquireAuthLockOptions,
): Promise<AuthLockHandle> {
const deps = resolveAuthLockDeps(options);
const path = await prepareAuthDirectory(deps, serverName, storageDir);
const ctx: LockAcquisitionContext = {
serverName,
path,
deps,
usingRealFs: options?.deps?.fs === undefined,
onLost: options?.onLost,
};
for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
const handle = await tryCreateLock(ctx);
if (handle) return handle;
}
throw new AuthLockError(serverName);
}
export { acquireAuthLock as acquireLock };