mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-10-08 09:11:13 +02:00
feat: Shannon 3.0 Agentic SAST (#433)
* feat(worker): add agentic static analysis Add the ten-stage Agentic SAST pipeline, confined repository tools, model runtime, prompt templates, and SARIF export. Make retries, repair sessions, reduced coverage, usage accounting, and model-output drift durable across Temporal replay and resume. Keep retry diagnostics in their actionable closed vocabulary. Package the Mantis-derived license material with the prompts that require it. * feat(worker): deduplicate static and runtime findings before exploitation Parse Agentic SAST SARIF into typed observations, enrich and route those observations, and reconcile them with pentest findings before exploitation. Publish deterministic exploitation queues with stable lineage, exact-path Git commits, retry-safe manifests, named drop reasons, and confined task formation. Reject duplicate producer IDs before commit and adopt either legal provenance shape after a lost acknowledgement. * feat(config)!: replace vuln_classes with agentic_sast Wire Agentic SAST and reconciliation into the main pipeline, persist their durable state, and add the Miscellaneous finding and exploitation lane. Make scan completion, cancellation, partial outcomes, resume identity, and report recovery use the integrated final workflow contract. Introduce the atomic finalization, ordering, renumbering, compaction, and output services that workflow calls. Keep completed Miscellaneous work and report drafts idempotent across resume, preserve public main's default-on exploit SARIF behavior, and describe stage-fallback candidates without claiming they were exported. BREAKING CHANGE: `vuln_classes` has been removed. Configs containing it now fail validation, and all five core pentest classes run on every scan. Workspaces created by Shannon 2.x cannot be resumed. Finish or discard in-flight scans before upgrading, then start a new workspace name. * perf: overlap static analysis and the Miscellaneous lane with the pentest Run Agentic SAST alongside vulnerability analysis and run Miscellaneous exploitation alongside the specialist exploitation lanes. Keep reconciliation dependent on the completed static-analysis result while preserving parallel work everywhere that has no data dependency. * feat(cli)!: default the scan target and add a JSON error contract List local scans, resolve the active or most recent workspace automatically, and make logs, status, and stop use one canonical scan identity. Add stable machine-readable failures, richer status output, explicit help errors, and seven-day Temporal retention. Treat absent Temporal pending-activity failures as absent whether the decoder represents them as `null` or missing. BREAKING CHANGE: `status --json` now returns a fixed `failureMessage`. Read `partialReasons`, `agenticSast`, and `workflow.log` for diagnostic detail. * feat(logging): trace tool calls and write a log per agent Record complete tool-call arguments in the workflow log and project each agent's events into its own durable log. Add agent listing and agent-specific log tailing while preserving byte-exact output and draining log handles before activities return. * feat(worker): standardize severity and reporting guidance in exploit prompts Give every exploit agent the same status, confidence, severity-reasoning, report-writing, credential-handling, and scope contract. Apply the same task-formation and SAST-enrichment procedure to the Miscellaneous lane. * feat(worker): disclose scan coverage and make reporting auditable Build on the retry-safe finalization foundation to preserve correct identities, source locations, scan dates, partial-coverage limitations, and consistent report JSON, Markdown, SARIF, and PDF output. Report Agentic SAST, reconciliation wall-clock time, stage usage, retry spend, and background work without duplicate or hardcoded totals. Keep report findings canonical, drop cross-class restatements, name enrichment losses, and render the executive-summary narrative in the PDF. * chore(license): attribute Mantis and Pi and refresh the docs Add the final Mantis and Pi notices, license copies, acknowledgements, and residual copyright updates. Update the README, maintained documentation, contributor guidance, and hand-maintained mirrors to describe Agentic SAST, reconciliation, the Miscellaneous lane, current CLI behavior, and the final release contract. Correct stale workspace and container guidance and annotate long-standing internals for maintainers. * fix(logging): treat a slash as a word separator in agent labels * feat(cli)!: rebuild scan status around model work - show Capella stages beneath the concurrent Agentic SAST phase - attach reconciliation time to the class row it feeds - hide completed bookkeeping and the duplicate miscellaneous wrapper - carry validated child-workflow progress into durable parent state - derive the terminal tree and status JSON from the same phase shape BREAKING CHANGE: `status --json` replaces phase `parallel` with `children` and `meta`, adds phase summaries and notes plus agent attachment fields, and removes the `analysis-engines` and `operational-work` phases. * fix(report): drop the empty Critical Findings section from the PDF summary * fix(sast): align Capella export with the submit-time code-path contract The export gate required every code_paths entry to be file:line, but submit only requires the primary sink to be file:line and accepts bare trace steps. A single malformed trace step therefore dropped an otherwise-valid finding at export. - add isValidPrimaryCodePath as the one shared primary-sink contract - validate only the primary at export; buildResult already drops unusable steps - route the submit-time validator through the same helper so the two cannot drift * feat(sast): tolerate hygiene-only Capella reductions instead of going partial A reduction only makes a run partial when it loses real coverage or a whole finding. Malformed model output, salvaged turn-limit work, and rejected duplicate verdicts are recorded as evidence but no longer flip the run to partial. - add reductionIsTolerable: partial only when genuine-loss counts are nonzero - drive runCapella's partial reasons and display coverage off non-tolerable ones - keep every reduction in agenticSast.reductions so nothing is lost as evidence * feat(logging): record the provider reason for a failed agent turn A failed provider turn collapsed to AGENT_EXECUTION_FAILED/unknown with the underlying reason discarded, so a model-side rejection or safeguard was indistinguishable from a transport fault in the error log. - add safeProviderTurnDetails: write bounded, non-sensitive fields (provider, model, responseId, stop reason, tool-in-flight, category, retryable) to error.log - gate a sanitized errorMessage snippet behind SHANNON_DEBUG_PROVIDER_ERRORS, off by default - forward SHANNON_DEBUG_PROVIDER_ERRORS from the CLI into the worker container * fix(cli): keep shannon logs tailing through a Temporal blip - End the interactive tail on the log's own terminal marker or Ctrl-C, so a transient Temporal outage no longer aborts the command with exit 1. - Rebuild the memoized Temporal client after a failed poll: a wedged gRPC channel was cached forever, so "retrying…" could never reconnect. - Keep start --follow (CI) bounded — a genuinely dead Temporal still fails the run instead of hanging. * fix(worker): correct PDF finding reporting - Render OWASP category, authentication state, and remediation - Omit the redundant per-finding exploited status - Preserve canonical category and field ordering across report modes - Continue Proof of Impact numbering across embedded code blocks - Wrap long PDF code lines without changing canonical report content * fix: attribute a reconciliation failure to exploitation only - Stop marking a class's vulnerability-analysis agent failed when that agent succeeded and only reconciliation failed; the status tree now renders the analysis row completed and the exploitation row failed - Consume the worker's failedReconciliations signal in the CLI, which the mirrored PipelineState already declared but never read - Correct the class_reconciliation_failed message, which claimed the class's analysis results were still in the report when the class is excluded from it * fix(pi): give each task sub-session its own resource loader to prevent stale extension ctx * fix(prompts): scope exploit agents to in-band proof, mark OOB-only findings blocked * fix(cli): reject a shell credential that shadows a gateway config.toml key * fix(cli): make scan shutdown verifiable - preselect and persist workflow identity before worker launch - cancel first, then verify bounded Temporal termination - reconcile Docker workers with Temporal open workflows - fail closed on stale images and unavailable lifecycle state - mark cancellation only after confirmed shutdown * feat(cli): prompt for setup on a bare npx invocation with no credentials * fix(cli): don't blame anthropic when no credentials are configured at all * chore(release): bump beta base version to 3.0.0 * feat(cli): show a 'start your first scan' box in help on a TTY * docs: refresh README and platform overview for Shannon 3.0 - lead with the 3.0 launch note and rewrite key capabilities around security code analysis, the rebuilt terminal experience, native CI/CD, and PDF/SARIF - recast the editions table as Shannon Open Source against the Keygraph Enterprise Platform, stating open source is not a trial edition - rewrite the platform overview around exhaustive agentic SAST, canonical findings, automated remediation, targeted verification, and governance - add five product screenshots under assets/keygraph-platform/, referenced relative to docs/ * docs: add the Shannon naming section and swap in the 3.0 demo GIF - explain the Claude Shannon information-theory origin under "What is Shannon?" - point "Shannon in Action" at the 3.0 recording in assets/Shannon3GIF.gif Both taken from the README half of #438. * docs: document CI/CD integrations and the reconciled analysis pipeline - add a CI/CD Integrations section covering the official GitHub Action and GitLab component, pipeline artifacts, and exploit-only severity gates - redraw the architecture section as a Mermaid flow: agentic code analysis and recon feed finding reconciliation, then exploitation and reporting - describe open-source code analysis as a multi-stage agentic workflow and reserve parsed-code CPGs and exhaustive verification for Enterprise - sharpen the privacy wording: results stay local, but model requests carry source context to whichever endpoint you configure - drop the "not recommended" framing on local models and add a section on why Shannon complements rather than replaces human pentesters - regenerate llms-full.txt from the updated README and docs * docs: add the Photoview benchmark across three models - Add a "Shannon in Action" table for Photoview 2.4.0 runs on DeepSeek v4 Flash, Grok 4.6, and Claude Opus 5, each linking its PDF report and SARIF output - Store the per-model reports under benchmark/ - Link the (forthcoming) benchmark writeup from the section intro * docs: add the Shannon vs XBOW/Aikido Photoview benchmark writeup - Add docs/shannon-xbow-aikido-benchmark.md with methodology, per-model cost/coverage tables, and links to each model's report and SARIF - Link the writeup from the README "Shannon in Action" section * docs: link the benchmark announcement discussion from the README * fix(readme): restore theme-aware banner, badge, and buttons * feat!: trigger the Shannon 3.0 major release --------- Co-authored-by: ezl-keygraph <ezhil@keygraph.io>
This commit is contained in:
1 parent
6108de3cfc
commit
9767ebe633
267 files changed
+127389
-3268
No files matched your search
+277
-63
@@ -12,18 +12,20 @@ import { setTimeout as sleep } from 'node:timers/promises';
|
||||
import * as p from '@clack/prompts';
|
||||
import { ensureDocker, ensureImage, ensureInfra, randomSuffix, spawnWorker } from '../docker.js';
|
||||
import { buildEnvFlags, loadEnv, resolveHostPiAuthPath, shouldUsePiAuth, validateCredentials } from '../env.js';
|
||||
import { fail } from '../errors.js';
|
||||
import { fail, warn } from '../errors.js';
|
||||
import { getWorkspacesDir, initHome } from '../home.js';
|
||||
import { commandPrefix, isLocal } from '../mode.js';
|
||||
import { resolveModelSpec } from '../model-spec.js';
|
||||
import {
|
||||
expandHome,
|
||||
FINAL_REPORT_MD_FILENAME,
|
||||
FINAL_REPORT_PDF_FILENAME,
|
||||
INTERNAL_DIR,
|
||||
resolveConfig,
|
||||
resolveRepo,
|
||||
resolveRunFile,
|
||||
} from '../paths.js';
|
||||
import { clearPendingWorkflowIdentity, writePendingWorkflowIdentity } from '../pending-workflow.js';
|
||||
import { indentFailureSegments } from '../scan/failure.js';
|
||||
import { resolveWorkflowId } from '../session.js';
|
||||
import { displayPlainBanner, displaySplash } from '../splash.js';
|
||||
@@ -43,83 +45,226 @@ export interface StartArgs {
|
||||
version: string;
|
||||
}
|
||||
|
||||
const LAUNCH_STATE_SCHEMA_VERSION = 1 as const;
|
||||
const LAUNCH_STATE_FILENAME = 'launch.json';
|
||||
const FIXED_CLASSES = ['injection', 'xss', 'auth', 'authz', 'ssrf'] as const;
|
||||
|
||||
/**
|
||||
* Upgrade a pre-restructure workspace (flat layout, no INTERNAL_DIR) before it is mounted,
|
||||
* so resume finds the old deliverables and their git checkpoints instead of re-running every
|
||||
* agent. For a legacy run every top-level entry is internal, so move them all into INTERNAL_DIR
|
||||
* (a same-filesystem rename carries the deliverables .git along).
|
||||
* CLI-owned launch record at INTERNAL_DIR/launch.json, written once when a workspace is
|
||||
* created and never rewritten. It pins the customer output destination so a resume with a
|
||||
* different -o cannot silently redirect the final report. The worker does not read it.
|
||||
*/
|
||||
function migrateLegacyWorkspaceLayout(workspacePath: string): void {
|
||||
const legacySessionJson = path.join(workspacePath, 'session.json');
|
||||
const internalPath = path.join(workspacePath, INTERNAL_DIR);
|
||||
if (!fs.existsSync(legacySessionJson) || fs.existsSync(internalPath)) {
|
||||
return;
|
||||
interface LaunchState {
|
||||
readonly schema_version: typeof LAUNCH_STATE_SCHEMA_VERSION;
|
||||
readonly customer_output_path?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceLaunchDecision {
|
||||
readonly isResume: boolean;
|
||||
readonly outputDir?: string;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function arraysEqual(left: readonly unknown[], right: readonly unknown[]): boolean {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand-rolled twin of the worker's durable-state validator in
|
||||
* apps/worker/src/types/run-state.ts, which owns the session.json.durableScanState shape.
|
||||
* Each array check accepts two variants because the worker appends 'miscellaneous' and
|
||||
* 'miscellaneous-exploit' only after the miscellaneous pipeline admits findings. If the worker's shape
|
||||
* changes and this twin lags, resume fails fast as incompatible instead of launching a
|
||||
* worker against state it would misread.
|
||||
*/
|
||||
function isCurrentDurableState(value: unknown): boolean {
|
||||
if (!isRecord(value) || value.schema_version !== 1 || typeof value.exploit !== 'boolean') return false;
|
||||
if (!Array.isArray(value.participating_classes) || !Array.isArray(value.expected_agents)) return false;
|
||||
|
||||
const participating = value.participating_classes;
|
||||
const validParticipation =
|
||||
arraysEqual(participating, FIXED_CLASSES) || arraysEqual(participating, [...FIXED_CLASSES, 'miscellaneous']);
|
||||
if (!validParticipation) return false;
|
||||
|
||||
const baselineAgents = ['pre-recon', 'recon', ...FIXED_CLASSES.map((name) => `${name}-vuln`)];
|
||||
if (value.exploit) baselineAgents.push(...FIXED_CLASSES.map((name) => `${name}-exploit`));
|
||||
baselineAgents.push('report');
|
||||
const expected = value.expected_agents;
|
||||
return arraysEqual(expected, baselineAgents) || arraysEqual(expected, [...baselineAgents, 'miscellaneous-exploit']);
|
||||
}
|
||||
|
||||
/** One refusal for damaged CLI-owned or worker-owned workspace records, whichever reads first. */
|
||||
const DAMAGED_RECORDS_MESSAGE =
|
||||
"This workspace's internal records are damaged and it cannot be resumed. Its report files are untouched. Start a new scan with a different -w name.";
|
||||
|
||||
const NEWER_RELEASE_MESSAGE =
|
||||
'This workspace was created by a newer version of Shannon. Upgrade Shannon, or start a new scan with a different -w name.';
|
||||
|
||||
function readJsonFile(filePath: string): unknown {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
fail(DAMAGED_RECORDS_MESSAGE);
|
||||
}
|
||||
}
|
||||
|
||||
function readLaunchState(filePath: string): LaunchState {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
fail(
|
||||
'This workspace was created by an earlier version of Shannon and cannot be resumed. Its files and report are untouched. Start a new scan with a different -w name.',
|
||||
);
|
||||
}
|
||||
const value = readJsonFile(filePath);
|
||||
if (!isRecord(value)) fail(NEWER_RELEASE_MESSAGE);
|
||||
// Unknown keys mean a newer release wrote this workspace; refuse rather than half-read it.
|
||||
const keys = Object.keys(value).sort();
|
||||
const keysAreValid = keys.every((key) => key === 'customer_output_path' || key === 'schema_version');
|
||||
const customerPath = value.customer_output_path;
|
||||
const pathIsValid =
|
||||
customerPath === undefined ||
|
||||
(typeof customerPath === 'string' && path.isAbsolute(customerPath) && path.resolve(customerPath) === customerPath);
|
||||
if (value.schema_version !== LAUNCH_STATE_SCHEMA_VERSION || !keysAreValid || !pathIsValid) {
|
||||
fail(NEWER_RELEASE_MESSAGE);
|
||||
}
|
||||
return {
|
||||
schema_version: LAUNCH_STATE_SCHEMA_VERSION,
|
||||
...(typeof customerPath === 'string' && { customer_output_path: customerPath }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide fresh-versus-resume from on-disk state alone, before start() mutates anything.
|
||||
* A fresh launch requires the workspace directory to be absent or empty; a resume requires
|
||||
* current-release session state, a matching target URL, and a customer output path that
|
||||
* agrees with the recorded one. Every other combination fails the launch, so a typo in
|
||||
* -w or -o stops here instead of spawning a worker into the wrong workspace.
|
||||
*/
|
||||
export function classifyWorkspaceLaunch(
|
||||
workspacePath: string,
|
||||
expectedUrl: string,
|
||||
requestedOutputDir: string | undefined,
|
||||
): WorkspaceLaunchDecision {
|
||||
const sessionPath = resolveRunFile(workspacePath, 'session.json');
|
||||
const sessionExists = fs.existsSync(sessionPath);
|
||||
if (!sessionExists) {
|
||||
if (fs.existsSync(workspacePath) && fs.readdirSync(workspacePath).length > 0) {
|
||||
fail(
|
||||
'This directory is not a Shannon workspace, or its scan state is missing. Start a new scan with a different -w name.',
|
||||
);
|
||||
}
|
||||
return { isResume: false, ...(requestedOutputDir !== undefined && { outputDir: requestedOutputDir }) };
|
||||
}
|
||||
|
||||
fs.mkdirSync(internalPath, { recursive: true });
|
||||
for (const entry of fs.readdirSync(workspacePath)) {
|
||||
if (entry === INTERNAL_DIR) {
|
||||
continue;
|
||||
}
|
||||
fs.renameSync(path.join(workspacePath, entry), path.join(internalPath, entry));
|
||||
const launchPath = path.join(workspacePath, INTERNAL_DIR, LAUNCH_STATE_FILENAME);
|
||||
const launch = readLaunchState(launchPath);
|
||||
const session = readJsonFile(sessionPath);
|
||||
if (!isRecord(session) || !isRecord(session.session) || session.session.webUrl !== expectedUrl) {
|
||||
fail(
|
||||
'This workspace was created for a different target URL, so it cannot be resumed against this one. Check -u, or start a new scan with a different -w name.',
|
||||
);
|
||||
}
|
||||
console.log(`Migrated workspace to ${INTERNAL_DIR}/ layout: ${workspacePath}`);
|
||||
if (!isCurrentDurableState(session.durableScanState)) {
|
||||
fail(
|
||||
"This workspace's scan state cannot be read by this version. Its files are untouched. Start a new scan with a different -w name.",
|
||||
);
|
||||
}
|
||||
|
||||
const storedOutputDir = launch.customer_output_path;
|
||||
if (requestedOutputDir !== undefined && requestedOutputDir !== storedOutputDir) {
|
||||
fail(
|
||||
'This workspace already copies its report to a different location than the -o path you passed. Re-run without -o to keep the original location, or start a new scan with a different -w name.',
|
||||
);
|
||||
}
|
||||
return { isResume: true, ...(storedOutputDir !== undefined && { outputDir: storedOutputDir }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Crash-safe single write: exclusive temp file (pid plus random suffix keeps concurrent
|
||||
* starts apart), fsync, rename into place, then directory fsync so the entry survives a
|
||||
* host crash. Callers invoke this only for a fresh workspace; an existing launch.json is
|
||||
* the resume contract and must never be replaced.
|
||||
*/
|
||||
export function writeLaunchStateAtomically(internalPath: string, outputDir: string | undefined): void {
|
||||
const finalPath = path.join(internalPath, LAUNCH_STATE_FILENAME);
|
||||
const temporaryPath = path.join(internalPath, `${LAUNCH_STATE_FILENAME}.tmp-${process.pid}-${randomSuffix()}`);
|
||||
const launchState: LaunchState = {
|
||||
schema_version: LAUNCH_STATE_SCHEMA_VERSION,
|
||||
...(outputDir !== undefined && { customer_output_path: outputDir }),
|
||||
};
|
||||
const descriptor = fs.openSync(temporaryPath, 'wx', 0o600);
|
||||
try {
|
||||
fs.writeFileSync(descriptor, `${JSON.stringify(launchState, null, 2)}\n`, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
try {
|
||||
fs.renameSync(temporaryPath, finalPath);
|
||||
const directory = fs.openSync(internalPath, 'r');
|
||||
try {
|
||||
fs.fsyncSync(directory);
|
||||
} finally {
|
||||
fs.closeSync(directory);
|
||||
}
|
||||
} catch (error) {
|
||||
fs.rmSync(temporaryPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Select the workflow ID before Docker starts so the container can carry it as immutable identity. */
|
||||
export function createWorkflowId(workspace: string, isResume: boolean, timestamp: number = Date.now()): string {
|
||||
if (isResume) return `${workspace}_resume_${timestamp}`;
|
||||
return /_shannon-\d+$/.test(workspace) ? workspace : `${workspace}_shannon-${timestamp}`;
|
||||
}
|
||||
|
||||
export async function start(args: StartArgs): Promise<void> {
|
||||
// 1. Initialize state directories and load env
|
||||
// 1. Resolve non-mutating inputs and classify the workspace before changing it.
|
||||
initHome();
|
||||
loadEnv();
|
||||
|
||||
// 2. Validate credentials
|
||||
const creds = validateCredentials();
|
||||
if (!creds.valid) {
|
||||
fail(creds.error ?? 'Invalid credentials');
|
||||
}
|
||||
|
||||
// 3. Resolve paths
|
||||
const repo = resolveRepo(args.repo);
|
||||
const config = args.config ? resolveConfig(args.config) : undefined;
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
const workspace =
|
||||
args.workspace ?? `${new URL(args.url).hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`;
|
||||
const workspacePath = path.join(workspacesDir, workspace);
|
||||
const requestedOutputDir = args.output ? path.resolve(expandHome(args.output)) : undefined;
|
||||
const launchDecision = classifyWorkspaceLaunch(workspacePath, args.url, requestedOutputDir);
|
||||
|
||||
// Inputs are valid — identify the run before the Docker/Temporal setup work.
|
||||
// 2. Inputs are valid; identify the run before initializing shared infrastructure.
|
||||
const bannerVersion = isLocal() ? undefined : args.version;
|
||||
if (stdoutIsTerminal()) {
|
||||
displaySplash(bannerVersion);
|
||||
} else {
|
||||
displayPlainBanner(bannerVersion);
|
||||
}
|
||||
|
||||
// 4. Ensure workspaces dir is writable by container user (UID 1001)
|
||||
const workspacesDir = getWorkspacesDir();
|
||||
fs.mkdirSync(workspacesDir, { recursive: true });
|
||||
fs.chmodSync(workspacesDir, 0o777);
|
||||
|
||||
// 5. Ensure Docker and the worker image are available (pull/build prints its own progress).
|
||||
ensureDocker();
|
||||
ensureImage(args.version);
|
||||
|
||||
// One spinner spans the whole launch: bringing up Temporal and registering the worker.
|
||||
const spinner = p.spinner();
|
||||
spinner.start('Starting scan');
|
||||
await ensureInfra(spinner);
|
||||
|
||||
// 6. Generate unique task queue and container name
|
||||
// 3. Generate the invocation identity.
|
||||
const suffix = randomSuffix();
|
||||
const taskQueue = `shannon-${suffix}`;
|
||||
const containerName = `shannon-worker-${suffix}`;
|
||||
const workflowId = createWorkflowId(workspace, launchDecision.isResume);
|
||||
|
||||
// 7. Generate workspace name if not provided
|
||||
const workspace =
|
||||
args.workspace ?? `${new URL(args.url).hostname.replace(/[^a-zA-Z0-9-]/g, '-')}_shannon-${Date.now()}`;
|
||||
|
||||
// 8. Create writable overlay directories (mounted over :ro repo paths inside container)
|
||||
// 4. Create writable overlay directories after resume validation has succeeded.
|
||||
// The run dir and its INTERNAL_DIR must be 0o777 so the container user can create audit
|
||||
// subdirs and the overlay backing dirs.
|
||||
const workspacePath = path.join(workspacesDir, workspace);
|
||||
const internalPath = path.join(workspacePath, INTERNAL_DIR);
|
||||
fs.mkdirSync(workspacePath, { recursive: true });
|
||||
fs.chmodSync(workspacePath, 0o777);
|
||||
migrateLegacyWorkspaceLayout(workspacePath);
|
||||
fs.mkdirSync(internalPath, { recursive: true });
|
||||
fs.chmodSync(internalPath, 0o777);
|
||||
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli', '.playwright']) {
|
||||
@@ -127,30 +272,53 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.chmodSync(dirPath, 0o777);
|
||||
}
|
||||
if (!launchDecision.isResume) {
|
||||
writeLaunchStateAtomically(internalPath, launchDecision.outputDir);
|
||||
}
|
||||
|
||||
// 9. Pre-create overlay mount points (:ro mounts can't auto-create them)
|
||||
// 5. Pre-create overlay mount points (:ro mounts cannot create them).
|
||||
const shannonDir = path.join(repo.hostPath, '.shannon');
|
||||
for (const dir of ['deliverables', 'scratchpad', '.playwright-cli']) {
|
||||
fs.mkdirSync(path.join(shannonDir, dir), { recursive: true });
|
||||
}
|
||||
fs.mkdirSync(path.join(repo.hostPath, '.playwright'), { recursive: true });
|
||||
|
||||
// 10. Resolve output directory
|
||||
const outputDir = args.output ? path.resolve(expandHome(args.output)) : undefined;
|
||||
// 6. Create the validated customer-copy destination, if configured.
|
||||
const outputDir = launchDecision.outputDir;
|
||||
if (outputDir) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 11. Resolve prompts directory (local mode only)
|
||||
// 7. Resolve prompts and capture the pre-launch resume counter.
|
||||
const promptsDir = isLocal() ? path.resolve('apps/worker/prompts') : undefined;
|
||||
const sessionJson = resolveRunFile(workspacePath, 'session.json');
|
||||
const isResume = launchDecision.isResume;
|
||||
let initialResumeCount = 0;
|
||||
if (isResume) {
|
||||
// Docker and Temporal startup sit between this read and the classification that validated the
|
||||
// same file, so a file that changed in between is a workspace-state failure, not a CLI bug.
|
||||
const session = readJsonFile(sessionJson);
|
||||
const attempts = isRecord(session) && isRecord(session.session) ? session.session.resumeAttempts : undefined;
|
||||
initialResumeCount = Array.isArray(attempts) ? attempts.length : 0;
|
||||
}
|
||||
|
||||
// 12. Spawn worker container
|
||||
// 8. Persist the exact launch candidate before Docker can start the worker. Session
|
||||
// registration later replaces this bridge as the durable workflow identity.
|
||||
try {
|
||||
writePendingWorkflowIdentity(workspacePath, workflowId, taskQueue);
|
||||
} catch {
|
||||
spinner.error('Could not record the scan workflow identity');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 9. Spawn the worker container.
|
||||
const proc = spawnWorker({
|
||||
version: args.version,
|
||||
url: args.url,
|
||||
repo,
|
||||
workspacesDir,
|
||||
taskQueue,
|
||||
workflowId,
|
||||
containerName,
|
||||
envFlags: buildEnvFlags(),
|
||||
...(config && { config }),
|
||||
@@ -173,24 +341,16 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Detect whether this is a fresh workspace or a resume by checking session.json existence
|
||||
const sessionJson = resolveRunFile(path.join(workspacesDir, workspace), 'session.json');
|
||||
const isResume = fs.existsSync(sessionJson);
|
||||
let initialResumeCount = 0;
|
||||
if (isResume) {
|
||||
try {
|
||||
const session = JSON.parse(fs.readFileSync(sessionJson, 'utf-8'));
|
||||
initialResumeCount = session.session?.resumeAttempts?.length ?? 0;
|
||||
} catch {
|
||||
// Corrupted file — worker will handle validation
|
||||
}
|
||||
}
|
||||
|
||||
let started = false;
|
||||
|
||||
// Set when the startup poll times out but session.json already holds durable state this
|
||||
// release understands: the workflow is executing, so the exit handler must not stop its
|
||||
// worker. An operator abort is a different intent and still stops it.
|
||||
let scanRunningUnconfirmed = false;
|
||||
|
||||
// Stop the worker only if the scan hasn't registered yet (e.g. Ctrl-C mid-startup).
|
||||
let cleaned = false;
|
||||
const cleanup = (): void => {
|
||||
const stopWorker = (): void => {
|
||||
if (cleaned || started) return;
|
||||
cleaned = true;
|
||||
spinner.stop('Stopping scan');
|
||||
@@ -204,14 +364,17 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
}
|
||||
};
|
||||
process.on('SIGINT', () => {
|
||||
cleanup();
|
||||
stopWorker();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('SIGTERM', () => {
|
||||
cleanup();
|
||||
stopWorker();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on('exit', cleanup);
|
||||
process.on('exit', () => {
|
||||
if (scanRunningUnconfirmed) return;
|
||||
stopWorker();
|
||||
});
|
||||
|
||||
// Poll for the workflow to register in session.json; the spinner resolves once it does.
|
||||
spinner.message('Waiting for the scan to start');
|
||||
@@ -221,10 +384,17 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
const resumeAttempts: { workflowId: string }[] = session.session?.resumeAttempts ?? [];
|
||||
|
||||
// Fresh: session.json appears with originalWorkflowId. Resume: new resumeAttempts entry.
|
||||
const ready = isResume ? resumeAttempts.length > initialResumeCount : !!session.session?.originalWorkflowId;
|
||||
const ready = isResume
|
||||
? resumeAttempts.slice(initialResumeCount).some((attempt) => attempt.workflowId === workflowId)
|
||||
: session.session?.originalWorkflowId === workflowId;
|
||||
|
||||
if (ready) {
|
||||
started = true;
|
||||
try {
|
||||
clearPendingWorkflowIdentity(workspacePath, taskQueue);
|
||||
} catch {
|
||||
warn(`Scan ${workspace} started, but its launch record could not be removed.`);
|
||||
}
|
||||
spinner.stop(`Scan started — ${workspace}`);
|
||||
printInfo(args, workspace, repo.hostPath, workspacesDir);
|
||||
if (args.follow) {
|
||||
@@ -238,10 +408,52 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
if (classifyStartupTimeout(sessionJson) === 'scan-running') {
|
||||
scanRunningUnconfirmed = true;
|
||||
spinner.error('The scan started, but this CLI could not confirm it');
|
||||
printUnconfirmedScanHint(workspace, taskQueue, containerName);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
spinner.error('Timed out waiting for the scan to start');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the startup timeout: 'scan-running' when session.json already holds durable state this
|
||||
* release understands, which only the worker writes and only after Temporal began executing the
|
||||
* workflow; 'unregistered' when nothing proves the scan started. The distinction decides whether
|
||||
* timing out may stop the worker container.
|
||||
*/
|
||||
export function classifyStartupTimeout(sessionJsonPath: string): 'unregistered' | 'scan-running' {
|
||||
let session: unknown;
|
||||
try {
|
||||
session = JSON.parse(fs.readFileSync(sessionJsonPath, 'utf-8'));
|
||||
} catch {
|
||||
return 'unregistered';
|
||||
}
|
||||
if (!isRecord(session) || !isCurrentDurableState(session.durableScanState)) {
|
||||
return 'unregistered';
|
||||
}
|
||||
return 'scan-running';
|
||||
}
|
||||
|
||||
/** Point the operator at a scan that is running but whose startup this CLI could not confirm. */
|
||||
function printUnconfirmedScanHint(workspace: string, taskQueue: string, containerName: string): void {
|
||||
console.log('');
|
||||
console.log(' The scan is running and was left alone; only its startup confirmation is missing.');
|
||||
console.log('');
|
||||
console.log(` Workspace: ${workspace}`);
|
||||
console.log(` Task queue: ${taskQueue}`);
|
||||
console.log(` Container: ${containerName}`);
|
||||
console.log('');
|
||||
console.log(' Inspect it:');
|
||||
console.log(` Live logs: ${commandPrefix()} logs ${workspace}`);
|
||||
console.log(` Worker logs: docker logs ${containerName}`);
|
||||
console.log(' Dashboard: http://localhost:8233');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow a just-started scan (for `--follow`, aimed at CI): stream its log while Temporal drives
|
||||
* completion, then exit on the workflow outcome — 0 if the assessment ran, 1 if the scan failed.
|
||||
@@ -331,7 +543,7 @@ function printInfo(args: StartArgs, workspace: string, repoPath: string, workspa
|
||||
return;
|
||||
}
|
||||
|
||||
const reportPath = path.join(workspacesDir, workspace, FINAL_REPORT_PDF_FILENAME);
|
||||
const reportDir = path.join(workspacesDir, workspace);
|
||||
|
||||
// When following, the scan log streams inline next, so the "run these to watch it" hints
|
||||
// would only contradict that.
|
||||
@@ -345,6 +557,8 @@ function printInfo(args: StartArgs, workspace: string, repoPath: string, workspa
|
||||
|
||||
console.log('');
|
||||
console.log(' Report (when the scan finishes):');
|
||||
console.log(` ${reportPath}`);
|
||||
console.log(` ${reportDir}${path.sep}`);
|
||||
console.log(` ${FINAL_REPORT_PDF_FILENAME}`);
|
||||
console.log(` ${FINAL_REPORT_MD_FILENAME}`);
|
||||
console.log('');
|
||||
}
|
||||
Reference in new issue
Block a user