UNPKG

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.

368 lines (327 loc) • 14.1 kB
/** * Opt-in diagnostic log for pi-mcp. * * Two related responsibilities: * * 1. `DebugLog` — a redacting, size-capped JSON Lines log file written to * `<agentDir>/logs/pi-mcp.log`, enabled with `PI_MCP_DEBUG=1`. Writes are * best-effort and never block or break a connection or tool call. * 2. `reportDiagnostic` — the process-wide sink that replaces the extension's * `console.error` calls. While a UI is active nothing is written to Pi's * stderr (the TUI owns that stream); user-actionable events are surfaced * through `ctx.ui.notify(...)` instead. Without a UI (bootstrap before * `session_start`, headless runs) warn/error events (and info events * flagged `stderr: true`, such as child-server output) use stderr; other * info-level telemetry is log-file only, so headless stderr keeps exactly * the output it had before this sink existed. * * Redaction is allow-list first — only known-safe field names are serialized — * and pattern-based second, as a safety net for values that were passed by * mistake. */ import { appendFile, chmod, mkdir, rename, stat } from "node:fs/promises"; import { join } from "node:path"; import { buildLogEntry, formatLogLine, sanitizeLogText, type BuildLogEntryParams, type DiagnosticLevel, type LogFields, } from "./debug-log-format.js"; export { buildLogEntry, DEBUG_LOG_MAX_SERVER_LENGTH, DEBUG_LOG_MAX_TEXT_LENGTH, describeError, formatLogLine, SAFE_FIELD_NAMES, sanitizeLogFields, sanitizeLogText, type BuildLogEntryParams, type DebugLogEntry, type DiagnosticLevel, type LogFields, type LogFieldValue, } from "./debug-log-format.js"; export { redactSecrets } from "./secret-redaction.js"; // ─── Constants ──────────────────────────────────────────────────────────────── /** Environment variable that turns the debug log on. */ export const DEBUG_LOG_ENV_VAR = "PI_MCP_DEBUG"; /** Log file name inside the log directory. */ export const DEBUG_LOG_FILE_NAME = "pi-mcp.log"; /** Name of the single rotated backup kept next to the log file. */ export const DEBUG_LOG_ROTATED_FILE_NAME = "pi-mcp.log.1"; /** Maximum size of the log file before it is rotated (~2 MB). */ export const DEBUG_LOG_MAX_BYTES = 2 * 1024 * 1024; /** Permissions of the log directory — same as the credential directories. */ export const DEBUG_LOG_DIR_MODE = 0o700; /** Permissions of the log file — same as the credential files. */ export const DEBUG_LOG_FILE_MODE = 0o600; // ─── File Logger ────────────────────────────────────────────────────────────── export interface DebugLogFs { mkdir(path: string, options?: { recursive?: boolean; mode?: number }): Promise<unknown>; appendFile(path: string, data: string, options?: { mode?: number }): Promise<void>; stat(path: string): Promise<{ size: number }>; rename(oldPath: string, newPath: string): Promise<void>; chmod(path: string, mode: number): Promise<void>; } export interface DebugLogDeps { fs: DebugLogFs; now: () => Date; } export function createDefaultDebugLogDeps(): DebugLogDeps { return { fs: { mkdir: (path, options) => mkdir(path, options), appendFile: (path, data, options) => appendFile(path, data, options), stat: (path) => stat(path), rename: (oldPath, newPath) => rename(oldPath, newPath), chmod: (path, mode) => chmod(path, mode), }, now: () => new Date(), }; } /** True when `PI_MCP_DEBUG` asks for the debug log. */ export function isDebugLogEnabled(env: NodeJS.ProcessEnv = process.env): boolean { const raw = env[DEBUG_LOG_ENV_VAR]; if (typeof raw !== "string") return false; const value = raw.trim().toLowerCase(); return value === "1" || value === "true" || value === "yes" || value === "on"; } /** * A size-capped JSON Lines log file. * * Writes are queued and executed in order, but never awaited by the caller: * a logging failure must not break a connection or a tool call. */ export class DebugLog { readonly dir: string; readonly filePath: string; readonly rotatedFilePath: string; private readonly deps: DebugLogDeps; private queue: Promise<void> = Promise.resolve(); private dirReady = false; private fileModeEnsured = false; constructor(dir: string, deps?: Partial<DebugLogDeps>) { this.dir = dir; this.filePath = join(dir, DEBUG_LOG_FILE_NAME); this.rotatedFilePath = join(dir, DEBUG_LOG_ROTATED_FILE_NAME); this.deps = { ...createDefaultDebugLogDeps(), ...deps }; } /** Queue one event. Never throws, never blocks the caller. */ write(params: Omit<BuildLogEntryParams, "ts"> & { ts?: Date | undefined }): void { const line = formatLogLine( buildLogEntry({ ...params, ts: params.ts ?? this.deps.now() }), ); this.queue = this.queue .then(() => this._append(line)) .catch((err: unknown) => { reportLoggerFailure(err); }); } /** Wait for all queued writes to settle. Used by tests and on shutdown. */ async flush(): Promise<void> { await this.queue; } private async _append(line: string): Promise<void> { await this._ensureDir(); await this._rotateIfNeeded(Buffer.byteLength(line, "utf8")); try { await this.deps.fs.appendFile(this.filePath, line, { mode: DEBUG_LOG_FILE_MODE }); } catch (err) { // WHY: `ENOENT` on `appendFile` means `<agentDir>/logs` disappeared after // it was created. Recreating the directory and retrying once keeps that // from dropping the entry or tripping the once-per-process failure latch; // a second failure is a real one. if (!isErrnoException(err, "ENOENT")) throw err; this.dirReady = false; this.fileModeEnsured = false; await this._ensureDir(); await this.deps.fs.appendFile(this.filePath, line, { mode: DEBUG_LOG_FILE_MODE }); } await this._ensureFileMode(); } private async _ensureDir(): Promise<void> { if (this.dirReady) return; try { await this._createDir(); } catch (err) { // WHY: Something removed `<agentDir>/logs` between the mkdir and the chmod // (a peer Pi process pruning an agent dir, a sandbox reset). Recreating it // keeps that from dropping the entry; a second failure is a real one. if (!isErrnoException(err, "ENOENT")) throw err; await this._createDir(); } this.dirReady = true; } private async _createDir(): Promise<void> { const fs = this.deps.fs; await fs.mkdir(this.dir, { recursive: true, mode: DEBUG_LOG_DIR_MODE }); await fs.chmod(this.dir, DEBUG_LOG_DIR_MODE); } private async _ensureFileMode(): Promise<void> { if (this.fileModeEnsured) return; try { // WHY: `mode` only applies when the file is created; an existing file may // have looser permissions, so the mode is enforced once per logger. await this.deps.fs.chmod(this.filePath, DEBUG_LOG_FILE_MODE); this.fileModeEnsured = true; } catch (err) { // WHY: A peer Pi process sharing this log rotated `pi-mcp.log` between the // append and this chmod, so it already holds the line. Leaving the flag // false re-applies the mode on the file the next write creates instead of // reporting a logger failure for a race that lost nothing. if (!isErrnoException(err, "ENOENT")) throw err; } } private async _rotateIfNeeded(incomingBytes: number): Promise<void> { const size = await this._logSize(); if (size === null || size <= 0) return; if (size + incomingBytes <= DEBUG_LOG_MAX_BYTES) return; // WHY: Re-read immediately before renaming to narrow the lock-free race // window: if the log vanished or shrank (`currentSize < size`), a peer // process already rotated it (and possibly recreated a smaller one), so // renaming again would overwrite the backup that peer just produced. A plain // peer append (`currentSize >= size`) leaves the file over the cap and still // rotates. Either way, `pi-mcp.log` is no longer the pre-rotation file, so // `0o600` must be re-enforced after the append. const currentSize = await this._logSize(); if (currentSize === null) return; this.fileModeEnsured = false; if (currentSize < size) return; await this._rotate(); } /** Size of the log file: `0` when it is absent, `null` when it is unknown. */ private async _logSize(): Promise<number | null> { try { return (await this.deps.fs.stat(this.filePath)).size; } catch (err) { if (isErrnoException(err, "ENOENT")) { // WHY: An absent file means the initial write has not run yet or a peer // rotated `pi-mcp.log` away since this logger last ensured `0o600`. this.fileModeEnsured = false; return 0; } // WHY: Any other stat failure leaves the size unknown. Rotation is only an // optimization — the caller appends anyway rather than dropping the line, // and the cap is re-checked on the next write that can read the size. return null; } } private async _rotate(): Promise<void> { this.fileModeEnsured = false; try { // WHY: One rename, no unlink: `rename` replaces the previous backup // directly (POSIX `rename(2)`, Windows `MoveFileExW`), so there is no // unlink-before-rename gap in which a peer process that rotated in the // meantime loses the 2 MB of history its backup just preserved. await this.deps.fs.rename(this.filePath, this.rotatedFilePath); } catch (err) { // WHY: A peer Pi process sharing this log rotated `pi-mcp.log` first, so // there is nothing to move — its backup is the one that stays. The append // below recreates the file and writes the incoming line into it. if (!isErrnoException(err, "ENOENT")) throw err; } } } /** True when `err` is a Node.js errno exception carrying `code`. */ function isErrnoException(err: unknown, code: string): boolean { return (err as NodeJS.ErrnoException | null)?.code === code; } // ─── Diagnostic Sink ────────────────────────────────────────────────────────── export interface Diagnostic { level: DiagnosticLevel; /** Machine-readable event id, e.g. `server.state`. */ event: string; /** Human-readable summary. */ message: string; server?: string | undefined; fields?: LogFields | undefined; /** Surface the event in the Pi UI when no log file is available. */ notify?: boolean | undefined; /** * Also write to stderr in headless mode (no UI) even for info-level events — * for output that historically went to stderr, such as child-server logs. * warn/error events always reach stderr when no UI is active. */ stderr?: boolean | undefined; } export interface DiagnosticNotifier { notify(message: string, level: "info" | "warning" | "error"): void; } let activeLogger: DebugLog | null = null; let activeNotifier: DiagnosticNotifier | null = null; let uiActive = false; let loggerFailureReported = false; /** Install (or clear) the debug log file writer. */ export function setDebugLog(logger: DebugLog | null): void { activeLogger = logger; loggerFailureReported = false; } export function getDebugLog(): DebugLog | null { return activeLogger; } /** * Tell the sink whether a UI is active. * * `hasUI` mirrors `ExtensionContext.hasUI`: while it is true, nothing is ever * written to Pi's stderr. */ export function setDiagnosticUi(notifier: DiagnosticNotifier | null, hasUI: boolean): void { activeNotifier = notifier; uiActive = hasUI && notifier !== null; } export function isUiActive(): boolean { return uiActive; } /** * Report a diagnostic event. * * - With a debug log: the event is written to the log file. * - Without a debug log and with an active UI: the event is dropped, or * surfaced through `ctx.ui.notify(...)` when it is user-actionable. * - Without a UI: warn/error events (and info events flagged `stderr`) go to * stderr. Other info-level telemetry is log-file only, so headless stderr * keeps exactly the output it had before this sink existed. */ export function reportDiagnostic(diagnostic: Diagnostic): void { const message = sanitizeLogText(diagnostic.message, 500); if (activeLogger) { activeLogger.write({ level: diagnostic.level, event: diagnostic.event, server: diagnostic.server, message, fields: diagnostic.fields, }); } if (uiActive) { if (diagnostic.notify && activeNotifier) { const level = diagnostic.level === "warn" ? "warning" : diagnostic.level; activeNotifier.notify(`pi-mcp: ${message}`, level); } return; } // WHY: info-level telemetry (state transitions, token refresh, session // lifecycle) would be new noise on headless stderr — before the sink these // events did not exist. Only warn/error events and info events flagged // `stderr` (child-server output, the headless OAuth URL fallback) reach it. if (diagnostic.level === "info" && diagnostic.stderr !== true) return; console.error(`[pi-mcp] ${message}`); } /** * Report a failure of the log writer itself. A broken log must never break a * connection, so the failure is reported once and then ignored. */ function reportLoggerFailure(err: unknown): void { if (loggerFailureReported) return; loggerFailureReported = true; if (uiActive) { // WHY: While a UI is active, stderr belongs to the TUI. The failed log write // is silently ignored — logging must never break a connection or a tool call. return; } const detail = err instanceof Error ? err.message : String(err); console.error(`[pi-mcp] Debug log write failed: ${sanitizeLogText(detail, 200)}`); }