Files
wehub-resource-sync e76d0ad892
CI / Test (macos-latest, Node 18.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (ubuntu-latest, Node 18.x, bun) (push) Has been cancelled
CI / Test (ubuntu-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (ubuntu-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (ubuntu-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (ubuntu-latest, Node 20.x, bun) (push) Has been cancelled
CI / Test (ubuntu-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (ubuntu-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (ubuntu-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (ubuntu-latest, Node 22.x, bun) (push) Has been cancelled
CI / Test (ubuntu-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (ubuntu-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (ubuntu-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Validate Components (push) Has been cancelled
CI / Python Tests (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / Lint (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:15:35 +08:00

149 lines
4.8 KiB
JavaScript

'use strict';
/**
* Shared session bridge utilities for ECC hooks.
*
* The bridge file is a small JSON aggregate in /tmp that allows
* statusline, metrics-bridge, and context-monitor to share state
* without scanning large JSONL logs on every invocation.
*/
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
const MAX_SESSION_ID_LENGTH = 64;
/**
* Sanitize a session ID for safe use in file paths.
* Rejects path traversal, strips unsafe chars, limits length.
* @param {string} raw
* @returns {string|null} Safe session ID or null if invalid
*/
function sanitizeSessionId(raw) {
if (!raw || typeof raw !== 'string') return null;
if (/[/\\]|\.\./.test(raw)) return null;
const safe = raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, MAX_SESSION_ID_LENGTH);
return safe || null;
}
/**
* Get the bridge file path for a session.
* @param {string} sessionId - Already-sanitized session ID
* @returns {string}
*/
function getBridgePath(sessionId) {
return path.join(os.tmpdir(), `ecc-metrics-${sessionId}.json`);
}
/**
* Read bridge data. Returns null on any error.
* @param {string} sessionId - Already-sanitized session ID
* @returns {object|null}
*/
function readBridge(sessionId) {
try {
const raw = fs.readFileSync(getBridgePath(sessionId), 'utf8');
return JSON.parse(raw);
} catch {
return null;
}
}
/**
* Write bridge data atomically (write unique-suffix tmp then rename).
*
* The tmp path includes `process.pid` plus a random nonce so concurrent
* writers (e.g. PostToolUse `ecc-metrics-bridge` and the background
* `ecc-statusline`, both writing to the same session bridge) do not
* clobber each other's tmp file mid-write. With a fixed `.tmp` suffix
* two writers could both call `writeFileSync` against the same path
* before either reaches `renameSync`, causing one writer's payload to
* silently overwrite the other and the second `renameSync` to throw
* ENOENT once the rename consumes the file.
*
* Same pattern already used by `writeCostWarningIfChanged` in
* `scripts/hooks/ecc-metrics-bridge.js` (commit 9b1d8918) for the
* cost-warning cache; this commit applies it to the session-bridge
* primitive too.
*
* @param {string} sessionId - Already-sanitized session ID
* @param {object} data
*/
function writeBridgeAtomic(sessionId, data) {
const target = getBridgePath(sessionId);
const tmp = `${target}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(data), 'utf8');
try {
renameWithRetry(tmp, target);
} catch (err) {
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
throw err;
}
}
/**
* Replace a file via rename, retrying briefly on transient OS-level errors.
*
* POSIX `rename(2)` is atomic between source and destination, so concurrent
* writers each rename onto the same target without conflict. Windows
* `MoveFileExW` is different: it fails with EPERM/EACCES/EBUSY if the
* target is currently being renamed by *another* process — a short race
* window that fires reliably under our PostToolUse + statusline concurrency.
*
* To stay portable, retry up to 5 times with exponential backoff (20 ms,
* 40, 80, 160, 320) on the Windows-only transient codes. POSIX runs hit
* the first try and exit immediately. Other error codes (ENOENT, ENOSPC,
* EROFS, …) re-throw without retry — they are not transient.
*
* Sleep uses `Atomics.wait` on a throwaway SharedArrayBuffer so the
* retry path does not busy-spin the CPU. This works on the main thread
* in Node ≥ 17 (and on workers in earlier versions).
*
* @param {string} tmp
* @param {string} target
*/
function renameWithRetry(tmp, target) {
const RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
const MAX_ATTEMPTS = 5;
for (let attempt = 0; ; attempt++) {
try {
fs.renameSync(tmp, target);
return;
} catch (err) {
if (attempt + 1 >= MAX_ATTEMPTS || !RETRY_CODES.has(err.code)) {
throw err;
}
const delayMs = 20 << attempt;
try {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs);
} catch {
// Atomics.wait throws on the main thread in some older runtimes;
// fall back to a brief busy-wait so the retry path still has a delay.
const until = Date.now() + delayMs;
while (Date.now() < until) { /* spin */ }
}
}
}
}
/**
* Resolve session ID from environment variables.
* @returns {string|null} Sanitized session ID or null
*/
function resolveSessionId() {
const raw = process.env.ECC_SESSION_ID || process.env.CLAUDE_SESSION_ID || '';
return sanitizeSessionId(raw);
}
module.exports = {
sanitizeSessionId,
getBridgePath,
readBridge,
writeBridgeAtomic,
renameWithRetry,
resolveSessionId,
MAX_SESSION_ID_LENGTH
};