From fdc46b734dc03e367f6c3d27cd76d9c5ad72f2b7 Mon Sep 17 00:00:00 2001 From: Jithin Date: Wed, 22 Jul 2026 18:19:21 +0530 Subject: [PATCH 1/3] feat: unify report artifacts into one folder, add graceful ctrl+c cancellation --- core/src/autonomous/index.ts | 2 +- core/src/autonomous/orchestrator/run.ts | 70 ++++++++++-- core/src/autonomous/report/mapRunLog.ts | 1 + core/src/autonomous/report/types.ts | 1 + core/src/autonomous/report/writeReport.ts | 41 +++++-- docs/hunt.md | 8 ++ runners/cli/src/commands/hunt.ts | 133 ++++++++++++++++------ runners/cli/src/commands/setup.ts | 1 + runners/cli/src/ui/server.ts | 76 +++++++++---- 9 files changed, 255 insertions(+), 78 deletions(-) diff --git a/core/src/autonomous/index.ts b/core/src/autonomous/index.ts index ecce928a..8e8eaefd 100644 --- a/core/src/autonomous/index.ts +++ b/core/src/autonomous/index.ts @@ -18,7 +18,7 @@ export type { BudgetGuardOptions } from "./lib/budget.js"; export type { AutonomousReport } from "./report/types.js"; export { mapRunLogToReport } from "./report/mapRunLog.js"; export { renderReportHtml } from "./report/html.js"; -export { writeAutonomousReport } from "./report/writeReport.js"; +export { writeAutonomousReport, reportDirFor } from "./report/writeReport.js"; export { loadKnowledge } from "./knowledge/load.js"; export type { diff --git a/core/src/autonomous/orchestrator/run.ts b/core/src/autonomous/orchestrator/run.ts index 76b89587..bc0f1a22 100644 --- a/core/src/autonomous/orchestrator/run.ts +++ b/core/src/autonomous/orchestrator/run.ts @@ -64,8 +64,17 @@ export interface RunHooks { progress?: ProgressReporter; /** Called once the in-memory RunLog is created — used by the live UI for snapshots. */ onRunLog?: (runLog: import("../state/runLog.js").RunLog) => void; + /** + * Cancellation signal. When aborted the run interrupts the agent, keeps every + * finding gathered so far, and finalizes a partial (truncated) report instead of + * throwing. Callers (CLI) wire this to SIGINT / Ctrl+C. + */ + signal?: AbortSignal; } +/** Truncation reason recorded when a run is stopped by the user rather than a ceiling/error. */ +const USER_INTERRUPT_REASON = "interrupted by user (Ctrl+C)"; + export async function runAutonomous( options: HuntOptions, runHooks?: RunHooks @@ -79,6 +88,10 @@ export async function runAutonomous( ); } + // Handed off via `onRunLog` so callers can react — e.g. create the report folder and open + // live-log files inside it — before any progress event has a chance to fire. Deliberately + // created only after the checks above: a bad config (missing seed dir) should fail with zero + // artifacts on disk, not leave behind an empty report folder. const runLog = createRunLog({ runId: randomUUID(), objective: options.objective, @@ -173,14 +186,38 @@ export async function runAutonomous( const q = query({ prompt: kickoff, options: queryOptions }); const reporter = runHooks?.progress; + const signal = runHooks?.signal; reporter?.onLine("Autonomous assessment started — commander initializing…"); + // Cancellation: interrupt the SDK query the moment the caller aborts, rather than waiting + // for the next stream message. An in-flight operator attack (a long multi-turn exchange + // with the target) could otherwise keep the run alive for many seconds after Ctrl+C — the + // loop only regains control between messages. The truncation flags are set from the loop / + // catch below; here we just stop the agent promptly. `interrupt` is idempotent and guarded. + const onAbort = (): void => { + reporter?.onLine("⏹ interrupt received — stopping agents, finalizing a partial report…"); + void q.interrupt().catch(() => {}); + }; + if (signal) { + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + // Tracks last reported cost threshold so we only emit a cost line every $0.10. let lastReportedCostUsd = 0; try { for await (const message of q) { + // Stop promptly on cancellation while messages are still flowing (the abort listener + // above covers the idle-between-messages case). + if (signal?.aborted && !runLog.completed) { + runLog.truncated = true; + if (!runLog.truncationReason) runLog.truncationReason = USER_INTERRUPT_REASON; + await q.interrupt().catch(() => {}); + break; + } + if (message.type === "assistant") { const text = message.message.content .map((b) => (b.type === "text" ? b.text : "")) @@ -252,20 +289,37 @@ export async function runAutonomous( } } } catch (err) { - // A mid-run failure (e.g. provider usage-policy block, network error) must - // NOT lose the findings already captured in the RunLog. Mark the run - // truncated and fall through to build a partial report. + // A mid-run failure (e.g. provider usage-policy block, network error) — or the SDK + // child being torn down by the same Ctrl+C the caller aborted on — must NOT lose the + // findings already captured in the RunLog. Mark the run truncated and fall through to + // build a partial report. When the user aborted, prefer the friendly interrupt reason + // over the raw SDK error. const message = err instanceof Error ? err.message : String(err); runLog.truncated = true; - runLog.truncationReason = `run interrupted: ${message.slice(0, 300)}`; - reporter?.onLine( - `⚠️ run interrupted — finalizing partial report from ${runLog.findings.length} finding(s)` - ); - reporter?.onLine(` reason: ${message.slice(0, 200)}`); + if (signal?.aborted) { + runLog.truncationReason = USER_INTERRUPT_REASON; + reporter?.onLine( + `⏹ run interrupted — finalizing partial report from ${runLog.findings.length} finding(s)` + ); + } else { + runLog.truncationReason = `run interrupted: ${message.slice(0, 300)}`; + reporter?.onLine( + `⚠️ run interrupted — finalizing partial report from ${runLog.findings.length} finding(s)` + ); + reporter?.onLine(` reason: ${message.slice(0, 200)}`); + } } finally { + if (signal) signal.removeEventListener("abort", onAbort); q.close(); } + // Safety net: if the caller aborted but neither the loop nor the catch labelled it (e.g. the + // stream ended cleanly right as the interrupt landed), still mark it a user interrupt. + if (signal?.aborted && !runLog.completed && !runLog.truncated) { + runLog.truncated = true; + runLog.truncationReason = USER_INTERRUPT_REASON; + } + if (!runLog.completed && !runLog.truncated) { // Stream ended without a submit_report (e.g. agent stopped early). runLog.truncated = runLog.findings.length === 0 && runLog.threads.size === 0; diff --git a/core/src/autonomous/report/mapRunLog.ts b/core/src/autonomous/report/mapRunLog.ts index 66e94d19..7f18a9af 100644 --- a/core/src/autonomous/report/mapRunLog.ts +++ b/core/src/autonomous/report/mapRunLog.ts @@ -191,6 +191,7 @@ export function mapRunLogToReport(log: RunLog): AutonomousReport { return { reportId: log.runId, + startedAt: log.startedAt, generatedAt: new Date().toISOString(), target: { name: log.targetName, endpoint: log.targetEndpoint }, objective: log.objective, diff --git a/core/src/autonomous/report/types.ts b/core/src/autonomous/report/types.ts index a4c8b1f7..c07ac7ce 100644 --- a/core/src/autonomous/report/types.ts +++ b/core/src/autonomous/report/types.ts @@ -85,6 +85,7 @@ export interface PersonaTimelineEntry { export interface AutonomousReport { reportId: string; + startedAt: string; generatedAt: string; target: { name: string; endpoint: string }; objective: string; diff --git a/core/src/autonomous/report/writeReport.ts b/core/src/autonomous/report/writeReport.ts index fc8a4de3..d0fa7aba 100644 --- a/core/src/autonomous/report/writeReport.ts +++ b/core/src/autonomous/report/writeReport.ts @@ -11,21 +11,44 @@ export interface ReportFiles { dir: string; } +function slugify(name: string): string { + return ( + name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 40) || "target" + ); +} + +/** + * The report subfolder name is derived entirely from values known at run start (target name, + * run id, start time) — not from anything only available once the run finishes. That lets + * callers create this folder up front and write live logs directly into it, instead of writing + * elsewhere and relocating them once the report exists. + */ +export function reportDirFor( + outputDir: string, + params: { targetName: string; runId: string; startedAt: string } +): string { + const compactTs = params.startedAt.replace(/[-:T.Z]/g, "").slice(0, 14); + const slug = slugify(params.targetName); + const shortId = params.runId.replace(/-/g, "").slice(0, 8); + return path.resolve(outputDir, `hunt-report-${compactTs}-${slug}-${shortId}`); +} + export async function writeAutonomousReport( report: AutonomousReport, outputDir: string ): Promise { - const compactTs = report.generatedAt.replace(/[-:T.Z]/g, "").slice(0, 14); - const slug = - report.target.name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 40) || "target"; - const shortId = report.reportId.replace(/-/g, "").slice(0, 8); - const dir = path.resolve(outputDir, `hunt-report-${compactTs}-${slug}-${shortId}`); + const dir = reportDirFor(outputDir, { + targetName: report.target.name, + runId: report.reportId, + startedAt: report.startedAt, + }); await mkdir(dir, { recursive: true }); + const slug = slugify(report.target.name); const htmlPath = path.join(dir, `${slug}-report.html`); const jsonPath = path.join(dir, `${slug}-report.json`); diff --git a/docs/hunt.md b/docs/hunt.md index cb8c4478..8fccd4d3 100644 --- a/docs/hunt.md +++ b/docs/hunt.md @@ -132,6 +132,14 @@ respond before it's killed. | `--ui` | Launch live dashboard | | `--ui-port ` | `3847` | +Each run writes everything into one folder — `hunt-report---/` — containing the live log (`hunt-live.log`), the structured event trail (`run-events.jsonl`), and the final `*-report.html` / `*-report.json`. + +## Stopping a run + +Press **Ctrl+C** once to stop early: the agent is interrupted, and a report is still written from the findings gathered so far (with the live log and event trail already on disk). The report is marked as truncated so it's clear the assessment was cut short. Press **Ctrl+C** a second time to force-quit without writing a report. + +The same applies if a run errors out mid-flight (provider block, network failure) — findings captured up to that point are preserved in a partial report rather than lost. + ## Authentication Credentials are resolved in order: diff --git a/runners/cli/src/commands/hunt.ts b/runners/cli/src/commands/hunt.ts index b24e74ab..3e5903cb 100644 --- a/runners/cli/src/commands/hunt.ts +++ b/runners/cli/src/commands/hunt.ts @@ -1,7 +1,7 @@ import type { Command } from "commander"; import path from "node:path"; -import { readFile, mkdir } from "node:fs/promises"; -import { createWriteStream, existsSync, type WriteStream } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { createWriteStream, existsSync, mkdirSync, type WriteStream } from "node:fs"; import { homedir } from "node:os"; import { consola } from "consola"; import type { @@ -11,7 +11,10 @@ import type { import type { RunEvent } from "@keyvaluesystems/agent-opfor-core/autonomous/state/observe.js"; import { parseAgentTarget } from "@keyvaluesystems/agent-opfor-core/config/schema.js"; import { runAutonomous } from "@keyvaluesystems/agent-opfor-core/autonomous/orchestrator/run.js"; -import { writeAutonomousReport } from "@keyvaluesystems/agent-opfor-core/autonomous/report/writeReport.js"; +import { + writeAutonomousReport, + reportDirFor, +} from "@keyvaluesystems/agent-opfor-core/autonomous/report/writeReport.js"; import { startUiServer } from "../ui/server.js"; import { mergeReporters } from "../ui/bridge.js"; @@ -295,6 +298,27 @@ export function registerHuntCommand(program: Command): void { }, }); + // Graceful shutdown for the browser-launched assessment. First Ctrl+C aborts a + // running assessment so it finalizes a partial report (onComplete → cleanup); if + // nothing is running yet (still on the setup form) we just exit. Second Ctrl+C + // force-quits. + let setupSigintCount = 0; + const onSetupSigint = (): void => { + setupSigintCount++; + if (setupSigintCount >= 2) { + void cleanup(130); + return; + } + if (serverHandle?.abortAssessment()) { + consola.warn( + "\nCaught interrupt — finalizing a partial report… (Ctrl+C again to force quit)" + ); + } else { + void cleanup(0); + } + }; + process.on("SIGINT", onSetupSigint); + // Keep process alive until onComplete is called await new Promise(() => {}); return; @@ -420,25 +444,38 @@ export function registerHuntCommand(program: Command): void { outputDir: path.resolve(opts.output), }; - // Live log file the user can `tail -f` while the run is in progress. - await mkdir(huntOptions.outputDir, { recursive: true }); - const startedAt = new Date() - .toISOString() - .replace(/[-:T.Z]/g, "") - .slice(0, 14); - const liveLogPath = path.join(huntOptions.outputDir, `hunt-live-${startedAt}.log`); - const liveLog: WriteStream = createWriteStream(liveLogPath, { flags: "a" }); + const header = [ + "════════════════════════════════════════════════════════════════", + ` AUTONOMOUS RED-TEAM`, + ` target : ${target.name} (${target.mode}) ${target.endpoint}`, + ` objective : ${objective}`, + ` models : commander=${huntOptions.commanderModel} operator=${huntOptions.operatorModel} scout=${huntOptions.scoutModel}`, + ` limits : operators≤${huntOptions.maxOperators} turns≤${huntOptions.maxTurns} thread-turns≤${huntOptions.maxThreadTurns}${huntOptions.budgetUsd ? ` budget=$${huntOptions.budgetUsd}` : ""}`, + ` verifier : ${huntOptions.verify ? "on" : "off"}`, + "════════════════════════════════════════════════════════════════", + ].join("\n"); + process.stdout.write(header + "\n"); + + // Report folder + live-log files are created from `onRunLog` below, as soon as the + // orchestrator hands back the RunLog (runId/startedAt/targetName are all it takes to name + // the folder — see `reportDirFor`). That fires before any progress event, so by the time + // `emit`/`emitEvent` are ever called the streams already exist. + let reportDir = ""; + let liveLogPath = ""; + let eventLogPath = ""; + let liveLog: WriteStream | undefined; + let eventLog: WriteStream | undefined; + const emit = (line: string): void => { const stamped = `[${clock()}] ${line}`; process.stdout.write(stamped + "\n"); - liveLog.write(stamped + "\n"); + liveLog?.write(stamped + "\n"); }; // Structured event trail (one JSON object per line) — machine-readable for debugging and // the foundation a future "opfor view" web UI consumes. Stamped with a wall-clock time. - const eventLogPath = path.join(huntOptions.outputDir, `run-${startedAt}.jsonl`); - const eventLog: WriteStream = createWriteStream(eventLogPath, { flags: "a" }); const emitEvent = (event: RunEvent): void => { + if (!eventLog) return; // An observability sink must never crash the run. Guard JSON.stringify (data is // Record — a future event could carry a circular ref / BigInt). try { @@ -455,20 +492,6 @@ export function registerHuntCommand(program: Command): void { } }; - const header = [ - "════════════════════════════════════════════════════════════════", - ` AUTONOMOUS RED-TEAM`, - ` target : ${target.name} (${target.mode}) ${target.endpoint}`, - ` objective : ${objective}`, - ` models : commander=${huntOptions.commanderModel} operator=${huntOptions.operatorModel} scout=${huntOptions.scoutModel}`, - ` limits : operators≤${huntOptions.maxOperators} turns≤${huntOptions.maxTurns} thread-turns≤${huntOptions.maxThreadTurns}${huntOptions.budgetUsd ? ` budget=$${huntOptions.budgetUsd}` : ""}`, - ` verifier : ${huntOptions.verify ? "on" : "off"}`, - "════════════════════════════════════════════════════════════════", - ].join("\n"); - process.stdout.write(header + "\n"); - liveLog.write(header + "\n"); - consola.box(`Live log (tail it):\n tail -f ${liveLogPath}`); - let uiHandle: Awaited> | undefined; if (opts.ui) { const uiPort = intOr(opts.uiPort, 3847); @@ -490,8 +513,6 @@ export function registerHuntCommand(program: Command): void { } catch (err) { consola.error(`Failed to start UI server: ${String(err)}`); process.exitCode = 1; - liveLog.end(); - eventLog.end(); return; } } @@ -499,32 +520,74 @@ export function registerHuntCommand(program: Command): void { const fileReporter = { onLine: emit, onEvent: emitEvent }; const progress = uiHandle ? mergeReporters(fileReporter, uiHandle.bridge) : fileReporter; + // Graceful shutdown: first Ctrl+C aborts so the commander stops dispatching, the + // in-flight agent turn is interrupted, and we still write a report from whatever was + // found up to that point (plus the live logs already on disk). Second Ctrl+C force-quits. + const ac = new AbortController(); + let sigintCount = 0; + const onSigint = (): void => { + sigintCount++; + if (sigintCount === 1) { + consola.warn("\nCaught interrupt — stopping agents and finalizing a partial report…"); + consola.warn("Press Ctrl+C again to force quit (no report will be written).\n"); + ac.abort(); + } else { + process.exit(130); + } + }; + process.on("SIGINT", onSigint); + let report; try { report = await runAutonomous(huntOptions, { progress, - onRunLog: uiHandle ? (log) => uiHandle!.attachRunLog(log) : undefined, + signal: ac.signal, + onRunLog: (log) => { + reportDir = reportDirFor(huntOptions.outputDir, { + targetName: log.targetName, + runId: log.runId, + startedAt: log.startedAt, + }); + mkdirSync(reportDir, { recursive: true }); + liveLogPath = path.join(reportDir, "hunt-live.log"); + eventLogPath = path.join(reportDir, "run-events.jsonl"); + liveLog = createWriteStream(liveLogPath, { flags: "a" }); + eventLog = createWriteStream(eventLogPath, { flags: "a" }); + liveLog.write(header + "\n"); + consola.box(`Live log (tail it):\n tail -f ${liveLogPath}`); + uiHandle?.attachRunLog(log); + }, }); } finally { - liveLog.end(); - eventLog.end(); + // Stop intercepting SIGINT before the post-run UI wait installs its own handler. + process.off("SIGINT", onSigint); + liveLog?.end(); + eventLog?.end(); } + const interrupted = ac.signal.aborted; + const { html, json, dir } = await writeAutonomousReport(report, huntOptions.outputDir); uiHandle?.markComplete({ reportDir: dir, outcome: report.objectiveOutcome }); consola.info(""); - consola.success(`Assessment complete — outcome: ${report.objectiveOutcome}`); + if (interrupted) { + consola.warn(`Assessment interrupted — partial report written from work completed so far.`); + } else { + consola.success(`Assessment complete — outcome: ${report.objectiveOutcome}`); + } consola.info( `Vulnerabilities: ${report.summary.confirmed} · Defended: ${report.summary.defended} · Errors: ${report.summary.errors}` ); if (report.totalCostUsd !== undefined) consola.info(`Cost: $${report.totalCostUsd.toFixed(4)}`); - if (report.truncated) consola.warn(`Run truncated: ${report.truncationReason}`); + if (report.truncated && !interrupted) + consola.warn(`Run truncated: ${report.truncationReason}`); consola.success(`Report: ${html}`); consola.info(` JSON: ${json}`); consola.info(` Dir : ${dir}`); + consola.info(` Live log: ${liveLogPath}`); consola.info(` Events: ${eventLogPath}`); if (uiHandle) { consola.info(` UI : ${uiHandle.url} (press Ctrl+C to exit)`); diff --git a/runners/cli/src/commands/setup.ts b/runners/cli/src/commands/setup.ts index 2631a576..8331358b 100644 --- a/runners/cli/src/commands/setup.ts +++ b/runners/cli/src/commands/setup.ts @@ -48,6 +48,7 @@ export async function runSetupAndWrite(opts?: { await writeFile(outPath, JSON.stringify(config, null, 2), "utf8"); log.success(`\nConfig written → ${outPath}`); + log.info(`Made a mistake? Edit any field in that JSON file directly before running it.`); return { config, path: outPath }; } diff --git a/runners/cli/src/ui/server.ts b/runners/cli/src/ui/server.ts index 0a889f82..08b25d4f 100644 --- a/runners/cli/src/ui/server.ts +++ b/runners/cli/src/ui/server.ts @@ -2,9 +2,7 @@ import http from "node:http"; import path from "node:path"; -import { existsSync } from "node:fs"; -import { mkdir } from "node:fs/promises"; -import { createWriteStream, type WriteStream } from "node:fs"; +import { existsSync, createWriteStream, mkdirSync, type WriteStream } from "node:fs"; import { fileURLToPath } from "node:url"; import { exec } from "node:child_process"; import express from "express"; @@ -77,6 +75,11 @@ export interface UiServerHandle { url: string; attachRunLog: (runLog: RunLog) => void; markComplete: (payload: { reportDir?: string; outcome?: string }) => void; + /** + * Abort an in-progress setup-mode assessment so it finalizes a partial report. + * Returns true if a run was actually in progress, false if idle (nothing to abort). + */ + abortAssessment: () => boolean; close: () => Promise; } @@ -109,6 +112,10 @@ function openInBrowser(url: string): void { export async function startUiServer(options: UiServerOptions): Promise { const app = express(); const bridge = new UiBridge(); + + // Cancellation for a setup-mode assessment. Created fresh per run in the /api/start handler; + // aborting it lets the in-flight run finalize a partial report instead of dying mid-attack. + let assessmentAbort: AbortController | undefined; bridge.setMeta(options.meta); const staticDir = resolveStaticDir(); @@ -260,8 +267,10 @@ export async function startUiServer(options: UiServerOptions): Promise { options.onComplete?.({ success: true, reportDir }); }) @@ -274,6 +283,7 @@ export async function startUiServer(options: UiServerOptions): Promise { runningAssessment = false; + assessmentAbort = undefined; }); }); } @@ -310,6 +320,11 @@ export async function startUiServer(options: UiServerOptions): Promise { server.close((err) => (err ? reject(err) : resolve())); @@ -322,35 +337,33 @@ export async function startUiServer(options: UiServerOptions): Promise void + onLog?: (line: string) => void, + signal?: AbortSignal ): Promise { const { runAutonomous } = await import("@keyvaluesystems/agent-opfor-core/autonomous/orchestrator/run.js"); - const { writeAutonomousReport } = + const { writeAutonomousReport, reportDirFor } = await import("@keyvaluesystems/agent-opfor-core/autonomous/report/writeReport.js"); - // Set up output directory and log files - await mkdir(autoOptions.outputDir, { recursive: true }); - const startedAt = new Date() - .toISOString() - .replace(/[-:T.Z]/g, "") - .slice(0, 14); - const liveLogPath = path.join(autoOptions.outputDir, `hunt-live-${startedAt}.log`); - const liveLog: WriteStream = createWriteStream(liveLogPath, { flags: "a" }); - const eventLogPath = path.join(autoOptions.outputDir, `run-${startedAt}.jsonl`); - const eventLog: WriteStream = createWriteStream(eventLogPath, { flags: "a" }); - const clock = () => new Date().toISOString().slice(11, 19); + // Report folder + live-log files are created from `onRunLog` below, as soon as the + // orchestrator hands back the RunLog (runId/startedAt/targetName are all it takes to name + // the folder — see `reportDirFor`). That fires before any progress event, so by the time + // `emit`/`emitEvent` are ever called the streams already exist. + let liveLog: WriteStream | undefined; + let eventLog: WriteStream | undefined; + const emit = (line: string): void => { const stamped = `[${clock()}] ${line}`; - liveLog.write(stamped + "\n"); + liveLog?.write(stamped + "\n"); bridge.onLine(line); // Also stream to terminal onLog?.(stamped); }; const emitEvent = (event: RunEvent): void => { + if (!eventLog) return; try { eventLog.write(JSON.stringify({ ...event, wall: clock() }) + "\n"); } catch { @@ -359,15 +372,25 @@ async function runAssessmentInProcess( bridge.onEvent(event); }; - emit(`Starting autonomous assessment against ${autoOptions.target.name}`); - emit(`Objective: ${autoOptions.objective}`); - let reportDir: string | undefined; try { const report = await runAutonomous(autoOptions, { progress: { onLine: emit, onEvent: emitEvent }, + signal, onRunLog: (runLog) => { + reportDir = reportDirFor(autoOptions.outputDir, { + targetName: runLog.targetName, + runId: runLog.runId, + startedAt: runLog.startedAt, + }); + mkdirSync(reportDir, { recursive: true }); + liveLog = createWriteStream(path.join(reportDir, "hunt-live.log"), { flags: "a" }); + eventLog = createWriteStream(path.join(reportDir, "run-events.jsonl"), { flags: "a" }); + + emit(`Starting autonomous assessment against ${autoOptions.target.name}`); + emit(`Objective: ${autoOptions.objective}`); + bridge.attachRunLog(runLog, { objective: autoOptions.objective, targetName: autoOptions.target.name, @@ -380,15 +403,18 @@ async function runAssessmentInProcess( }, }); - emit("Run completed, writing report…"); + const interrupted = signal?.aborted ?? false; + emit( + interrupted ? "Run interrupted, writing partial report…" : "Run completed, writing report…" + ); const reportFiles = await writeAutonomousReport(report, autoOptions.outputDir); reportDir = reportFiles.dir; emit(`Report written to ${reportDir}`); - bridge.markComplete({ reportDir, outcome: "completed" }); + bridge.markComplete({ reportDir, outcome: interrupted ? "interrupted" : "completed" }); } finally { - liveLog.end(); - eventLog.end(); + liveLog?.end(); + eventLog?.end(); } return reportDir; From 6899c81a8070d8815546ee1d553c3ab8ba802fe6 Mon Sep 17 00:00:00 2001 From: Jithin Date: Wed, 22 Jul 2026 18:28:27 +0530 Subject: [PATCH 2/3] chore: trim down comments --- core/src/autonomous/orchestrator/run.ts | 26 ++++++----------------- core/src/autonomous/report/writeReport.ts | 7 +----- runners/cli/src/commands/hunt.ts | 15 ++++--------- runners/cli/src/ui/server.ts | 8 ++----- 4 files changed, 14 insertions(+), 42 deletions(-) diff --git a/core/src/autonomous/orchestrator/run.ts b/core/src/autonomous/orchestrator/run.ts index bc0f1a22..141d1763 100644 --- a/core/src/autonomous/orchestrator/run.ts +++ b/core/src/autonomous/orchestrator/run.ts @@ -64,11 +64,7 @@ export interface RunHooks { progress?: ProgressReporter; /** Called once the in-memory RunLog is created — used by the live UI for snapshots. */ onRunLog?: (runLog: import("../state/runLog.js").RunLog) => void; - /** - * Cancellation signal. When aborted the run interrupts the agent, keeps every - * finding gathered so far, and finalizes a partial (truncated) report instead of - * throwing. Callers (CLI) wire this to SIGINT / Ctrl+C. - */ + /** Abort to stop the run early and finalize a partial (truncated) report instead of throwing. */ signal?: AbortSignal; } @@ -88,10 +84,8 @@ export async function runAutonomous( ); } - // Handed off via `onRunLog` so callers can react — e.g. create the report folder and open - // live-log files inside it — before any progress event has a chance to fire. Deliberately - // created only after the checks above: a bad config (missing seed dir) should fail with zero - // artifacts on disk, not leave behind an empty report folder. + // Created only after the checks above — a bad config should fail with zero artifacts, not an + // empty report folder. Handed off via `onRunLog` before any progress event can fire. const runLog = createRunLog({ runId: randomUUID(), objective: options.objective, @@ -190,11 +184,8 @@ export async function runAutonomous( reporter?.onLine("Autonomous assessment started — commander initializing…"); - // Cancellation: interrupt the SDK query the moment the caller aborts, rather than waiting - // for the next stream message. An in-flight operator attack (a long multi-turn exchange - // with the target) could otherwise keep the run alive for many seconds after Ctrl+C — the - // loop only regains control between messages. The truncation flags are set from the loop / - // catch below; here we just stop the agent promptly. `interrupt` is idempotent and guarded. + // Interrupt immediately so an in-flight multi-turn attack doesn't keep the run alive after + // Ctrl+C — truncation flags are set below, this just stops the agent promptly. const onAbort = (): void => { reporter?.onLine("⏹ interrupt received — stopping agents, finalizing a partial report…"); void q.interrupt().catch(() => {}); @@ -289,11 +280,8 @@ export async function runAutonomous( } } } catch (err) { - // A mid-run failure (e.g. provider usage-policy block, network error) — or the SDK - // child being torn down by the same Ctrl+C the caller aborted on — must NOT lose the - // findings already captured in the RunLog. Mark the run truncated and fall through to - // build a partial report. When the user aborted, prefer the friendly interrupt reason - // over the raw SDK error. + // A mid-run failure or the abort itself must not lose captured findings — mark truncated + // and fall through to a partial report. const message = err instanceof Error ? err.message : String(err); runLog.truncated = true; if (signal?.aborted) { diff --git a/core/src/autonomous/report/writeReport.ts b/core/src/autonomous/report/writeReport.ts index d0fa7aba..2f152502 100644 --- a/core/src/autonomous/report/writeReport.ts +++ b/core/src/autonomous/report/writeReport.ts @@ -21,12 +21,7 @@ function slugify(name: string): string { ); } -/** - * The report subfolder name is derived entirely from values known at run start (target name, - * run id, start time) — not from anything only available once the run finishes. That lets - * callers create this folder up front and write live logs directly into it, instead of writing - * elsewhere and relocating them once the report exists. - */ +/** Report folder name — derivable at run start, so callers can create it upfront instead of relocating files later. */ export function reportDirFor( outputDir: string, params: { targetName: string; runId: string; startedAt: string } diff --git a/runners/cli/src/commands/hunt.ts b/runners/cli/src/commands/hunt.ts index 3e5903cb..99bdbdcc 100644 --- a/runners/cli/src/commands/hunt.ts +++ b/runners/cli/src/commands/hunt.ts @@ -298,10 +298,8 @@ export function registerHuntCommand(program: Command): void { }, }); - // Graceful shutdown for the browser-launched assessment. First Ctrl+C aborts a - // running assessment so it finalizes a partial report (onComplete → cleanup); if - // nothing is running yet (still on the setup form) we just exit. Second Ctrl+C - // force-quits. + // First Ctrl+C aborts a running assessment (partial report via onComplete), or exits if + // idle on the setup form; second Ctrl+C force-quits. let setupSigintCount = 0; const onSetupSigint = (): void => { setupSigintCount++; @@ -456,10 +454,7 @@ export function registerHuntCommand(program: Command): void { ].join("\n"); process.stdout.write(header + "\n"); - // Report folder + live-log files are created from `onRunLog` below, as soon as the - // orchestrator hands back the RunLog (runId/startedAt/targetName are all it takes to name - // the folder — see `reportDirFor`). That fires before any progress event, so by the time - // `emit`/`emitEvent` are ever called the streams already exist. + // Folder + streams are created from `onRunLog` below, before any progress event can fire. let reportDir = ""; let liveLogPath = ""; let eventLogPath = ""; @@ -520,9 +515,7 @@ export function registerHuntCommand(program: Command): void { const fileReporter = { onLine: emit, onEvent: emitEvent }; const progress = uiHandle ? mergeReporters(fileReporter, uiHandle.bridge) : fileReporter; - // Graceful shutdown: first Ctrl+C aborts so the commander stops dispatching, the - // in-flight agent turn is interrupted, and we still write a report from whatever was - // found up to that point (plus the live logs already on disk). Second Ctrl+C force-quits. + // First Ctrl+C aborts and finalizes a partial report; second Ctrl+C force-quits. const ac = new AbortController(); let sigintCount = 0; const onSigint = (): void => { diff --git a/runners/cli/src/ui/server.ts b/runners/cli/src/ui/server.ts index 08b25d4f..a81f2e18 100644 --- a/runners/cli/src/ui/server.ts +++ b/runners/cli/src/ui/server.ts @@ -113,8 +113,7 @@ export async function startUiServer(options: UiServerOptions): Promise new Date().toISOString().slice(11, 19); - // Report folder + live-log files are created from `onRunLog` below, as soon as the - // orchestrator hands back the RunLog (runId/startedAt/targetName are all it takes to name - // the folder — see `reportDirFor`). That fires before any progress event, so by the time - // `emit`/`emitEvent` are ever called the streams already exist. + // Folder + streams are created from `onRunLog` below, before any progress event can fire. let liveLog: WriteStream | undefined; let eventLog: WriteStream | undefined; From 611acd2b4aaf9021b21b26d3be57126c7cd86492 Mon Sep 17 00:00:00 2001 From: Jithin Date: Thu, 23 Jul 2026 16:16:28 +0530 Subject: [PATCH 3/3] fix: show "interrupted" status on cancelled hunt --ui run The live dashboard renders markComplete's `outcome` field verbatim as its final status badge. It was fed `report.objectiveOutcome` (a verdict), so a Ctrl+C-cancelled run showed a normal-looking verdict instead of signalling the cancellation. Send "interrupted" when aborted, otherwise keep the verdict (more useful than a generic "completed"). Co-Authored-By: Claude Opus 4.8 --- runners/cli/src/commands/hunt.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/runners/cli/src/commands/hunt.ts b/runners/cli/src/commands/hunt.ts index 99bdbdcc..362556aa 100644 --- a/runners/cli/src/commands/hunt.ts +++ b/runners/cli/src/commands/hunt.ts @@ -562,7 +562,13 @@ export function registerHuntCommand(program: Command): void { const { html, json, dir } = await writeAutonomousReport(report, huntOptions.outputDir); - uiHandle?.markComplete({ reportDir: dir, outcome: report.objectiveOutcome }); + // `outcome` is the dashboard's final status label (rendered verbatim). On interrupt show + // "interrupted" so a cancelled run isn't mistaken for a normal finish; otherwise show the + // assessment verdict, which is more useful than a generic "completed". + uiHandle?.markComplete({ + reportDir: dir, + outcome: interrupted ? "interrupted" : report.objectiveOutcome, + }); consola.info(""); if (interrupted) {