UNPKG

akm-cli

Version:

akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cur

239 lines (238 loc) • 10.1 kB
// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. /** * Task-run log persistence: log path resolution, the flat per-run log file, * structured logs.db rows, and the shared redaction pass both sinks go * through before either is written. * * Moved body-intact from src/tasks/runner.ts (spec * docs/plans/specs/p1b-model-extraction.md §5.1, runner.ts:779-968). * * A DAG leaf with respect to the rest of src/tasks/run/**: nothing here * imports ./task-result, ./task-history, or ./attempt-lifecycle, so every * other run/** module can depend on this one without risking an import * cycle (tests/architecture/import-cycle-ratchet.test.ts counts type-only * imports as real edges too). */ import fs from "node:fs"; import path from "node:path"; import { loadConfig } from "../../core/config/config.js"; import { rethrowIfTestIsolationError } from "../../core/errors.js"; import { buildTaskRunId, insertTaskLogLines, openLogsDatabase, } from "../../core/logs-db.js"; import { getTaskLogDir } from "../../core/paths.js"; import { redactCredentialPatterns, redactSensitiveText } from "../../core/redaction.js"; import { collectTaskLogSensitiveValues } from "../log-redaction.js"; function taskLogPath(logDir, taskId, startedAtIso) { const tsSlug = startedAtIso.replace(/[:.]/g, "-"); return path.join(logDir, taskId, `${tsSlug}.log`); } export function resolveTaskLogPath(logDir, taskId, startedAtIso) { try { return taskLogPath(logDir ?? getTaskLogDir(), taskId, startedAtIso); } catch (error) { rethrowIfTestIsolationError(error); return ""; } } /** * Delete per-run flat log files (`<logDir>/<taskId>/<timestamp>.log`) older * than `retentionDays` (#951). These files are a transitional human-readable * tail only — the durable record lives in logs.db, already purged by * {@link purgeOldTaskLogs} in ../../core/logs-db — so deleting an old file * here loses nothing that isn't already retained (and separately purged) * there. * * Bounded by construction: only ever reads/deletes inside `logDir` (defaults * to {@link getTaskLogDir}), one level of `<taskId>` subdirectories, `.log` * files only. A missing/unreadable directory is a no-op, not an error — * mirrors the best-effort contract the rest of this module holds for log * persistence. */ export function purgeOldTaskLogFiles(logDir, retentionDays = 90) { if (!Number.isFinite(retentionDays) || retentionDays <= 0) return 0; const dir = logDir ?? getTaskLogDir(); const cutoffMs = Date.now() - retentionDays * 86_400_000; let deleted = 0; let taskDirs; try { taskDirs = fs.readdirSync(dir, { withFileTypes: true }); } catch (error) { rethrowIfTestIsolationError(error); return 0; } for (const taskDirEntry of taskDirs) { if (!taskDirEntry.isDirectory()) continue; const taskDirPath = path.join(dir, taskDirEntry.name); let logFiles; try { logFiles = fs.readdirSync(taskDirPath, { withFileTypes: true }); } catch (error) { rethrowIfTestIsolationError(error); continue; } for (const logFile of logFiles) { if (!logFile.isFile() || !logFile.name.endsWith(".log")) continue; const logFilePath = path.join(taskDirPath, logFile.name); try { const stat = fs.statSync(logFilePath); if (stat.mtimeMs < cutoffMs) { fs.unlinkSync(logFilePath); deleted++; } } catch (error) { rethrowIfTestIsolationError(error); // Best-effort: a file that vanished or can't be stat'd/removed is skipped. } } } return deleted; } /** * Redact logs.db rows against the SAME contiguous text the file sink sees. * * The rows arrive already split on "\n" (see {@link streamLines}), but the * redaction needles are whole env values — and a needle containing a newline * can never match inside a single line. Scrubbing row-by-row therefore left * multi-line secrets (PEM keys, multi-line service-account credentials) intact * in logs.db while the flat .log was correctly scrubbed, defeating all three * tiers including the explicit `redact:` opt-in. * * Consecutive rows sharing a stream and level are rejoined, scrubbed as one * string, and re-split, so a needle spanning lines matches. Collapsing a * multi-line secret into a single [REDACTED] row is the intended outcome. */ export function scrubDbLines(dbLines, scrub) { const out = []; for (let i = 0; i < dbLines.length;) { const { stream, level } = dbLines[i]; let end = i; while (end < dbLines.length && dbLines[end].stream === stream && dbLines[end].level === level) end++; const joined = dbLines .slice(i, end) .map((entry) => entry.line) .join("\n"); for (const line of scrub(joined).split("\n")) { if (line.length > 0) out.push({ stream, level, line }); } i = end; } return out; } /** Split captured pipe output into per-line logs.db rows (blank lines dropped). */ export function streamLines(text, stream, level) { return (text .split("\n") // Windows child output is CRLF-terminated. Splitting on "\n" alone left a // trailing "\r" on every row and turned blank CRLF lines into phantom // rows containing just "\r" (length 1 passes the filter below). .map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)) .filter((line) => line.length > 0) .map((line) => ({ stream, level, line }))); } /** * Exact secret values to scrub from this run's persisted output (#755). * * Best-effort by construction: this runs on the persistence path of a run that * has already finished, so a config that will not load must degrade to * "pattern-based redaction only" rather than fail the run. It does NOT degrade * to "log it anyway with no redaction at all" — `redactCredentialPatterns` * still runs unconditionally in the caller. */ function taskLogSensitiveValues(redactNames, environment) { const env = { ...process.env, ...environment }; try { return collectTaskLogSensitiveValues({ env, config: loadConfig(), declaredNames: redactNames, }); } catch (error) { rethrowIfTestIsolationError(error); // No config — the name heuristic and the task's own `redact:` list still apply. try { return collectTaskLogSensitiveValues({ env, declaredNames: redactNames }); } catch (fallbackError) { rethrowIfTestIsolationError(fallbackError); return []; } } } /** Scrub one piece of durable-result text against a prepared task's own secrets. */ export function scrubTaskOutput(task, text) { const patterned = redactCredentialPatterns(text); const sensitive = taskLogSensitiveValues(task.redact, task.environment); return sensitive.length > 0 ? redactSensitiveText(patterned, sensitive) : patterned; } /** * Persist a finished run's log: the flat text file (so `log_path` in * task_history keeps resolving for humans and older consumers) plus * structured rows in logs.db keyed by `buildTaskRunId(taskId, startedAt)`. * * Both sinks are pattern-redacted (`redactCredentialPatterns`) before being * written — task output is raw command/agent/LLM text that can echo a * credential-bearing URL (e.g. a Discord webhook) nothing upstream expects to * scrub. * * The DB write is best-effort, mirroring history recording: an unwritable * logs.db must never fail a task run. */ export function persistRunLog(input) { // Two arms, and both are needed. `redactCredentialPatterns` catches // credential SHAPES nobody listed; the exact-value pass catches configured // secrets whose value is shaped like nothing in particular (#755). The // command target had only the first, so a scheduled command that echoed an // ordinary-looking secret persisted it verbatim to both sinks. Applying the // exact pass here — the one sink all three target kinds funnel through — // covers every arm once rather than per-arm; prompt/workflow runs already // scrub upstream, and redaction is idempotent, so the overlap is free. const sensitive = taskLogSensitiveValues(input.redactNames, input.environment); const scrub = (text) => sensitive.length > 0 ? redactSensitiveText(redactCredentialPatterns(text), sensitive) : redactCredentialPatterns(text); const fileText = scrub(input.fileText); const dbLines = scrubDbLines(input.dbLines, scrub); if (input.logPath) { try { // Written at the process umask. #756 pinned 0600/0700 here; that went out // with the rest of akm's permission enforcement (#791) — the operator owns // the mode of their own data directory, and akm neither sets nor reports // on it. fs.mkdirSync(path.dirname(input.logPath), { recursive: true }); fs.writeFileSync(input.logPath, fileText); } catch (error) { rethrowIfTestIsolationError(error); // Transitional file logging is fully best-effort. } } try { const db = openLogsDatabase(); try { insertTaskLogLines(db, { taskId: input.taskId, runId: buildTaskRunId(input.taskId, input.startedAtIso), ts: input.finishedAtIso, lines: dbLines, }); } finally { db.close(); } } catch (error) { rethrowIfTestIsolationError(error); // Structured logging is fully best-effort and must not alter CLI output. } }