113 lines
4.4 KiB
JavaScript
113 lines
4.4 KiB
JavaScript
|
|
#!/usr/bin/env node
|
|||
|
|
/**
|
|||
|
|
* enforce-parallel-session-lock — PreToolUse wrapper around the pure
|
|||
|
|
* parallel-session-lock module (router-gate v4 Stream H Task 7).
|
|||
|
|
*
|
|||
|
|
* Prevents two Claude sessions on the same workspace from concurrently
|
|||
|
|
* mutating files. When session B tries a mutating tool while session A
|
|||
|
|
* holds a fresh (non-stale) lock, B is blocked with a message naming A's
|
|||
|
|
* pid for human triage.
|
|||
|
|
*
|
|||
|
|
* Activation: settings.json registration is deferred to Phase H-α/H-β
|
|||
|
|
* batch step. main() is a no-op (exit 0) until then.
|
|||
|
|
*/
|
|||
|
|
import { acquire, release, computeWorkspaceHash, isProcessAlive } from './parallel-session-lock.mjs';
|
|||
|
|
import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
|||
|
|
import { join, dirname } from 'node:path';
|
|||
|
|
import { readStdin, parseEventJson, exitDecision, runtimeDir } from './enforce-hook-helpers.mjs';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Pure decision: given an acquire() result, decide block/allow.
|
|||
|
|
*
|
|||
|
|
* @param {object} args
|
|||
|
|
* @param {object|null|undefined} args.acquireResult - from parallel-session-lock.acquire()
|
|||
|
|
* @param {string} args.sessionId - current session id
|
|||
|
|
* @returns {{block: boolean, reason?: string}}
|
|||
|
|
*/
|
|||
|
|
export function decide({ acquireResult, sessionId }) {
|
|||
|
|
// Fail-open if no acquire result (treat as internal error — never lockout).
|
|||
|
|
if (!acquireResult || typeof acquireResult !== 'object') return { block: false };
|
|||
|
|
if (acquireResult.acquired) return { block: false };
|
|||
|
|
const holder = acquireResult.holder || {};
|
|||
|
|
return {
|
|||
|
|
block: true,
|
|||
|
|
reason: `parallel session lock held by ${holder.session_id || 'unknown'} (pid ${holder.pid || '?'}) — wait or close that session first`,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* PreToolUse wiring: acquire (or same-session refresh / stale takeover) the lock,
|
|||
|
|
* then decide block/allow. I/O injected for testability.
|
|||
|
|
*
|
|||
|
|
* @returns {{block: boolean, reason?: string}}
|
|||
|
|
*/
|
|||
|
|
export function runAcquireDecision({ event, now, pid, cwd, readLock, writeLock, isPidAlive = isProcessAlive }) {
|
|||
|
|
const sessionId = event && event.session_id;
|
|||
|
|
const workspaceHash = computeWorkspaceHash(cwd);
|
|||
|
|
// 7.5 (дыра 5): pid-liveness — мёртвый держатель свежего лока перехватывается, не блокирует TTL.
|
|||
|
|
const acquireResult = acquire({ sessionId, pid, workspaceHash, now, readLock, writeLock, isPidAlive });
|
|||
|
|
return decide({ acquireResult, sessionId });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Stop wiring: release the lock if this session owns it (no-op otherwise).
|
|||
|
|
*
|
|||
|
|
* @returns {{released: boolean}}
|
|||
|
|
*/
|
|||
|
|
export function runReleaseAction({ event, cwd, readLock, deleteLock }) {
|
|||
|
|
const sessionId = event && event.session_id;
|
|||
|
|
const workspaceHash = computeWorkspaceHash(cwd);
|
|||
|
|
release({ sessionId, workspaceHash, readLock, deleteLock });
|
|||
|
|
return { released: true };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function lockPathFor(cwd) {
|
|||
|
|
return join(runtimeDir(), `session-lock-${computeWorkspaceHash(cwd)}.json`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function realReadLock(p) {
|
|||
|
|
try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { return null; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function realWriteLock(p, rec) {
|
|||
|
|
try { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, JSON.stringify(rec)); } catch { /* fail-open */ }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function realDeleteLock(p) {
|
|||
|
|
try { unlinkSync(p); } catch { /* already gone */ }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function main() {
|
|||
|
|
// Live wiring (point 2, 2026-05-31). PreToolUse (mutating tool) → acquire/refresh
|
|||
|
|
// the workspace lock; Stop (no tool_name) → release it. Fail-open on any error so
|
|||
|
|
// a lock bug can NEVER wedge the user out of their own session.
|
|||
|
|
try {
|
|||
|
|
const event = parseEventJson(await readStdin());
|
|||
|
|
const cwd = process.cwd();
|
|||
|
|
const p = lockPathFor(cwd);
|
|||
|
|
|
|||
|
|
// Stop event carries no tool_name → release path.
|
|||
|
|
if (!event.tool_name) {
|
|||
|
|
runReleaseAction({ event, cwd, readLock: () => realReadLock(p), deleteLock: () => realDeleteLock(p) });
|
|||
|
|
return exitDecision({ block: false });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// PreToolUse on a mutating tool → acquire/refresh, then block/allow.
|
|||
|
|
const r = runAcquireDecision({
|
|||
|
|
event,
|
|||
|
|
now: Date.now(),
|
|||
|
|
pid: process.pid,
|
|||
|
|
cwd,
|
|||
|
|
readLock: () => realReadLock(p),
|
|||
|
|
writeLock: (rec) => realWriteLock(p, rec),
|
|||
|
|
});
|
|||
|
|
return exitDecision({ block: r.block, message: r.block ? `[parallel-session-lock] ${r.reason}` : undefined });
|
|||
|
|
} catch {
|
|||
|
|
return exitDecision({ block: false }); // fail-open — never lock out
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}` || (process.argv[1] || '').endsWith('enforce-parallel-session-lock.mjs')) {
|
|||
|
|
main().catch(() => process.exit(0));
|
|||
|
|
}
|