mirror of
https://github.com/KeygraphHQ/shannon.git
synced 2026-09-14 05:59:06 +02:00
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
This commit is contained in:
@@ -99,13 +99,13 @@ apps/worker/ — @shannon/worker (private, Temporal worker + pipeline logic)
|
||||
```
|
||||
|
||||
### CLI Package (`apps/cli/`)
|
||||
Published as `@keygraph/shannon` on npm. Contains Docker orchestration logic plus a read-only `@temporalio/client` reader (for `status`); no worker/pipeline business logic or prompts. Bundled with tsdown for single-file ESM output (deps stay external).
|
||||
Published as `@keygraph/shannon` on npm. Contains Docker orchestration and a direct `@temporalio/client` integration for read-only status plus bounded workflow lifecycle operations; no worker/pipeline business logic or prompts. Bundled with tsdown for single-file ESM output (deps stay external).
|
||||
|
||||
- `apps/cli/src/index.ts` — CLI dispatcher (`setup`, `start`, `stop`, `reset`, `logs`, `status`, `scans`, `build`, `version`)
|
||||
- `apps/cli/src/temporal-client.ts` — `@temporalio/client` reader for `status`: connects to the frontend on `127.0.0.1:7233` (published by compose), `describeScan` (status + `pendingActivities` → running agents), `queryProgress` (live `getProgress` query → `PipelineState`), `getTerminalOutcome` (workflow `result()`). No worker of its own; scans are visible within Temporal's retention window, which `ensureInfra` (`apps/cli/src/docker.ts`) converges to `168h` (7 days) on every successful `shannon start` — override with `SHANNON_TEMPORAL_RETENTION` (a positive whole-hour value like `72h`)
|
||||
- `apps/cli/src/temporal-client.ts` — `@temporalio/client` integration: connects to the frontend on `127.0.0.1:7233` (published by compose), provides `describeScan` (status + `pendingActivities` → running agents), `queryProgress` (live `getProgress` query → `PipelineState`), `getTerminalOutcome` (workflow `result()`), and bounded lifecycle RPCs for `stop`. `stop` requests cancellation first, waits up to 10 seconds, then requests termination only when necessary and verifies closure within a bounded window. No worker of its own; scans are visible within Temporal's retention window, which `ensureInfra` (`apps/cli/src/docker.ts`) converges to `168h` (7 days) on every successful `shannon start`; override with `SHANNON_TEMPORAL_RETENTION` (a positive whole-hour value like `72h`)
|
||||
- `apps/cli/src/scan/` — `status` rendering: `pipeline.ts` (static phase/agent plan + `run*Agent` activity-type→agent map + mirrored `PipelineState`/`AgentMetrics` types; keep in sync with the worker), `derive.ts` (pure phase/agent state derivation shared by the tree and `--json`), `render.ts` (one renderer for both the live query state and the terminal result). The tree shows model work only: every row is an agent, an Agentic SAST stage, or a report step that is currently running or failed. Reconciliation is model work owned by a class, so its wall time renders as a trailing `+ duration` on that class's exploitation row (its analysis row when `exploit: false`) rather than as a row of its own; deterministic bookkeeping stages (`report:*` renumber/assemble/finalize/surface) never appear once they complete. `DerivedPhase.children` (renders sub-rows) and `DerivedPhase.meta` (`duration` vs a `k/N done` tally) are independent — Agentic SAST lists stages under a duration, exploitation lists classes under a tally
|
||||
- `apps/cli/src/mode.ts` — Auto-detection: local mode if `SHANNON_LOCAL=1` env var is set
|
||||
- `apps/cli/src/docker.ts` — Compose lifecycle, image pull/build, ephemeral `docker run` worker spawning
|
||||
- `apps/cli/src/docker.ts` — Compose lifecycle, image pull/build, and ephemeral `docker run` worker spawning. Each worker carries workspace, task-queue, and preselected workflow-ID labels so stop can correlate the local worker with its Temporal execution before `session.json` exists. Before `docker run`, start fsyncs that exact candidate under the workspace's hidden internals and clears it only when `session.json` registers the same ID; stop reconciles any candidate left by an interrupted launch. Start also checks the image's workflow-ID protocol label and refuses a stale worker that would ignore the preselected ID
|
||||
- `apps/cli/src/home.ts` — State directory management (`~/.shannon/` for npx, `./` for local)
|
||||
- `apps/cli/src/env.ts` — `.env` loading, TOML fallback (npx only) via `apps/cli/src/config/resolver.ts`, credential validation, provider-scoped env flag building
|
||||
- `apps/cli/src/model-spec.ts` — `SHANNON_AI_MODEL` (`<provider>:<model-id>`) parsing; mirrors `apps/worker/src/ai/models.ts`
|
||||
@@ -121,7 +121,7 @@ Published as `@keygraph/shannon` on npm. Contains Docker orchestration logic plu
|
||||
- `shannon` — Node.js entry point (`#!/usr/bin/env node`) that delegates to `apps/cli/dist/index.mjs`
|
||||
|
||||
### Docker Architecture
|
||||
Infra (Temporal) runs via `docker-compose.yml`. Workers are ephemeral `docker run --rm` containers, one per scan, each with a unique task queue and isolated volume mounts.
|
||||
Infra (Temporal) runs via `docker-compose.yml`. Workers are ephemeral `docker run --rm` containers, one per scan, each with a unique task queue, preselected workflow ID, matching identity labels, and isolated volume mounts. `shannon stop --all` takes the union of labeled running workers and Temporal-running workflows, so an orphaned workflow is still stopped after its worker has disappeared.
|
||||
|
||||
- `docker-compose.yml` — Infra only: `shannon-temporal` (port 7233/8233). Network: `shannon-net`
|
||||
- `Dockerfile` — 2-stage build (builder + Chainguard Wolfi runtime). Uses pnpm. Entrypoint: `CMD ["node", "apps/worker/dist/temporal/worker.js"]`
|
||||
|
||||
@@ -43,6 +43,9 @@ RUN rm -rf node_modules apps/*/node_modules && pnpm install --frozen-lockfile --
|
||||
# Runtime stage - Minimal production image
|
||||
FROM cgr.dev/chainguard/wolfi-base:latest AS runtime
|
||||
|
||||
# Lifecycle protocol consumed by the CLI before it trusts a container workflow-id label.
|
||||
LABEL shannon.worker-protocol="workflow-id-v1"
|
||||
|
||||
# Install only runtime dependencies
|
||||
USER root
|
||||
RUN apk update && apk add --no-cache \
|
||||
|
||||
@@ -12,7 +12,7 @@ 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';
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
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';
|
||||
@@ -214,6 +215,12 @@ export function writeLaunchStateAtomically(internalPath: string, outputDir: stri
|
||||
}
|
||||
}
|
||||
|
||||
/** 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. Resolve non-mutating inputs and classify the workspace before changing it.
|
||||
initHome();
|
||||
@@ -250,6 +257,7 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
const suffix = randomSuffix();
|
||||
const taskQueue = `shannon-${suffix}`;
|
||||
const containerName = `shannon-worker-${suffix}`;
|
||||
const workflowId = createWorkflowId(workspace, launchDecision.isResume);
|
||||
|
||||
// 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
|
||||
@@ -294,13 +302,23 @@ export async function start(args: StartArgs): Promise<void> {
|
||||
initialResumeCount = Array.isArray(attempts) ? attempts.length : 0;
|
||||
}
|
||||
|
||||
// 8. Spawn the 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 }),
|
||||
@@ -366,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) {
|
||||
|
||||
+786
-124
File diff suppressed because it is too large
Load Diff
+126
-50
@@ -27,6 +27,16 @@ const DEV_IMAGE = 'shannon-worker';
|
||||
/** Docker label stamped on each worker container, mapping it back to its workspace so a single scan can be stopped by name. */
|
||||
const WORKSPACE_LABEL = 'shannon.workspace';
|
||||
|
||||
/** Docker label that joins a worker container to the Temporal workflow polling its unique task queue. */
|
||||
const TASK_QUEUE_LABEL = 'shannon.task-queue';
|
||||
|
||||
/** Docker label carrying the workflow ID selected before the worker starts. */
|
||||
const WORKFLOW_ID_LABEL = 'shannon.workflow-id';
|
||||
|
||||
/** Image/container protocol proving that the worker honors the preselected workflow ID. */
|
||||
const WORKER_PROTOCOL_LABEL = 'shannon.worker-protocol';
|
||||
export const WORKFLOW_ID_PROTOCOL = 'workflow-id-v1';
|
||||
|
||||
export function getWorkerImage(version: string): string {
|
||||
return getMode() === 'local' ? DEV_IMAGE : `${NPX_IMAGE_REPO}:${version}`;
|
||||
}
|
||||
@@ -84,9 +94,6 @@ function spawnQuiet(cmd: string, args: string[]): Promise<boolean> {
|
||||
const TEMPORAL_CONTAINER = 'shannon-temporal';
|
||||
const TEMPORAL_ADDRESS = 'localhost:7233';
|
||||
|
||||
/** Query matching every running pentest scan workflow. */
|
||||
const RUNNING_SCAN_QUERY = "ExecutionStatus = 'Running' AND WorkflowType = 'pentestPipelineWorkflow'";
|
||||
|
||||
/** Build `docker exec` args for a `temporal` CLI command run inside the Temporal container. */
|
||||
function temporalCmd(...args: string[]): string[] {
|
||||
return ['exec', TEMPORAL_CONTAINER, 'temporal', ...args, '--address', TEMPORAL_ADDRESS];
|
||||
@@ -256,7 +263,10 @@ export function buildImage(noCache: boolean, version: string): void {
|
||||
export function ensureImage(version: string): void {
|
||||
const image = getWorkerImage(version);
|
||||
const exists = runQuiet('docker', ['image', 'inspect', image]);
|
||||
if (exists) return;
|
||||
if (exists) {
|
||||
ensureWorkerImageProtocol(image);
|
||||
return;
|
||||
}
|
||||
|
||||
if (canBuildImage()) {
|
||||
console.log('Shannon image not found, building...');
|
||||
@@ -274,6 +284,22 @@ export function ensureImage(version: string): void {
|
||||
}
|
||||
pruneOldImages(version);
|
||||
}
|
||||
ensureWorkerImageProtocol(image);
|
||||
}
|
||||
|
||||
/** Refuse a stale worker image that would ignore the CLI-selected workflow ID. */
|
||||
function ensureWorkerImageProtocol(image: string): void {
|
||||
const protocol = runOutput('docker', [
|
||||
'image',
|
||||
'inspect',
|
||||
image,
|
||||
'--format',
|
||||
`{{ index .Config.Labels "${WORKER_PROTOCOL_LABEL}" }}`,
|
||||
]);
|
||||
if (protocol === WORKFLOW_ID_PROTOCOL) return;
|
||||
|
||||
const hint = canBuildImage() ? 'Run ./shannon build, then retry.' : 'Reinstall this Shannon version, then retry.';
|
||||
fail('The Shannon worker image is incompatible with this CLI.', hint);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -377,6 +403,7 @@ export interface WorkerOptions {
|
||||
repo: { hostPath: string; containerPath: string };
|
||||
workspacesDir: string;
|
||||
taskQueue: string;
|
||||
workflowId: string;
|
||||
containerName: string;
|
||||
envFlags: string[];
|
||||
config?: { hostPath: string; containerPath: string };
|
||||
@@ -399,8 +426,16 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
}
|
||||
args.push('--name', opts.containerName, '--network', 'shannon-net');
|
||||
|
||||
// Tag with the workspace so `stop <workspace>` can target this scan's container
|
||||
args.push('--label', `${WORKSPACE_LABEL}=${opts.workspace}`);
|
||||
// Keep the launch identity on the container before session.json exists. The fixed workflow
|
||||
// ID lets stop verify the pre-registration window without trusting visibility timing.
|
||||
args.push(
|
||||
'--label',
|
||||
`${WORKSPACE_LABEL}=${opts.workspace}`,
|
||||
'--label',
|
||||
`${TASK_QUEUE_LABEL}=${opts.taskQueue}`,
|
||||
'--label',
|
||||
`${WORKFLOW_ID_LABEL}=${opts.workflowId}`,
|
||||
);
|
||||
|
||||
// Add host flag for Linux
|
||||
args.push(...addHostFlag());
|
||||
@@ -459,6 +494,7 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
// Worker command
|
||||
args.push('node', 'apps/worker/dist/temporal/worker.js', opts.url, opts.repo.containerPath);
|
||||
args.push('--task-queue', opts.taskQueue);
|
||||
args.push('--workflow-id', opts.workflowId);
|
||||
if (opts.config) {
|
||||
args.push('--config', opts.config.containerPath);
|
||||
}
|
||||
@@ -482,6 +518,18 @@ export function spawnWorker(opts: WorkerOptions): ChildProcess {
|
||||
/** `docker ps --filter` args matching every running worker container. */
|
||||
export const WORKER_FILTER: readonly string[] = ['--filter', 'name=shannon-worker-'];
|
||||
|
||||
/** Result of a command-backed query whose unavailable state must not be mistaken for an empty result. */
|
||||
export type CommandQueryResult<T> = { kind: 'ok'; value: T } | { kind: 'unavailable' };
|
||||
|
||||
/** Identity carried by a running scan worker container. Older workers may lack the newer labels. */
|
||||
export interface RunningScanContainer {
|
||||
readonly id: string;
|
||||
readonly workspace?: string;
|
||||
readonly taskQueue?: string;
|
||||
readonly workflowId?: string;
|
||||
readonly workerProtocol?: string;
|
||||
}
|
||||
|
||||
/** `docker ps --filter` args matching one scan's worker container(s), by workspace label. */
|
||||
export function scanFilter(workspace: string): readonly string[] {
|
||||
return ['--filter', `label=${WORKSPACE_LABEL}=${workspace}`];
|
||||
@@ -492,23 +540,85 @@ export function scanFilter(workspace: string): readonly string[] {
|
||||
* the authoritative check for whether containers actually stopped — `docker stop`'s
|
||||
* exit code can't distinguish "already gone" from "failed to stop".
|
||||
*/
|
||||
export function runningContainersChecked(filter: readonly string[]): CommandQueryResult<string[]> {
|
||||
try {
|
||||
const output = execFileSync('docker', ['ps', '-q', ...filter], { stdio: 'pipe', encoding: 'utf-8' }).trim();
|
||||
return { kind: 'ok', value: output.split('\n').filter(Boolean) };
|
||||
} catch {
|
||||
return { kind: 'unavailable' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort counterpart for callers where Docker unavailability is intentionally
|
||||
* presented as no local running containers.
|
||||
*/
|
||||
export function runningContainers(filter: readonly string[]): string[] {
|
||||
const output = runOutput('docker', ['ps', '-q', ...filter]);
|
||||
return output.split('\n').filter(Boolean);
|
||||
const result = runningContainersChecked(filter);
|
||||
return result.kind === 'ok' ? result.value : [];
|
||||
}
|
||||
|
||||
function normalizedLabel(value: string | undefined): string | undefined {
|
||||
const normalized = value?.trim();
|
||||
return normalized && normalized !== '<no value>' ? normalized : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Running scan containers with the labels needed to correlate a worker to its Temporal
|
||||
* workflow. A successful query keeps unlabeled legacy workers in the result by ID.
|
||||
*/
|
||||
export function runningScanContainersChecked(
|
||||
filter: readonly string[] = WORKER_FILTER,
|
||||
): CommandQueryResult<RunningScanContainer[]> {
|
||||
try {
|
||||
const format = `{{.ID}}\t{{ index .Labels "${WORKSPACE_LABEL}" }}\t{{ index .Labels "${TASK_QUEUE_LABEL}" }}\t{{ index .Labels "${WORKFLOW_ID_LABEL}" }}\t{{ index .Labels "${WORKER_PROTOCOL_LABEL}" }}`;
|
||||
const output = execFileSync('docker', ['ps', ...filter, '--format', format], {
|
||||
stdio: 'pipe',
|
||||
encoding: 'utf-8',
|
||||
}).trim();
|
||||
if (!output) return { kind: 'ok', value: [] };
|
||||
|
||||
const containers: RunningScanContainer[] = [];
|
||||
for (const line of output.split('\n')) {
|
||||
const [rawId, rawWorkspace, rawTaskQueue, rawWorkflowId, rawWorkerProtocol] = line.split('\t');
|
||||
const id = rawId?.trim();
|
||||
if (!id) return { kind: 'unavailable' };
|
||||
const workspace = normalizedLabel(rawWorkspace);
|
||||
const taskQueue = normalizedLabel(rawTaskQueue);
|
||||
const workflowId = normalizedLabel(rawWorkflowId);
|
||||
const workerProtocol = normalizedLabel(rawWorkerProtocol);
|
||||
containers.push({
|
||||
id,
|
||||
...(workspace !== undefined && { workspace }),
|
||||
...(taskQueue !== undefined && { taskQueue }),
|
||||
...(workflowId !== undefined && { workflowId }),
|
||||
...(workerProtocol !== undefined && { workerProtocol }),
|
||||
});
|
||||
}
|
||||
return { kind: 'ok', value: containers };
|
||||
} catch {
|
||||
return { kind: 'unavailable' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace names of every running worker container, read from the shannon.workspace
|
||||
* label each scan is stamped with at spawn. This is the authoritative running-scan →
|
||||
* workspace-name map. Best-effort: empty when Docker is unreachable, which is the
|
||||
* correct answer anyway (no scan can be running without the daemon).
|
||||
* label each scan is stamped with at spawn. The checked form preserves Docker query
|
||||
* failures so lifecycle commands do not mistake an unavailable daemon for an empty list.
|
||||
*/
|
||||
export function runningScanWorkspacesChecked(): CommandQueryResult<string[]> {
|
||||
const result = runningScanContainersChecked();
|
||||
if (result.kind === 'unavailable') return result;
|
||||
return {
|
||||
kind: 'ok',
|
||||
value: result.value.flatMap((container) => (container.workspace === undefined ? [] : [container.workspace])),
|
||||
};
|
||||
}
|
||||
|
||||
/** Best-effort counterpart for callers that only need the local scan list. */
|
||||
export function runningScanWorkspaces(): string[] {
|
||||
const output = runOutput('docker', ['ps', ...WORKER_FILTER, '--format', `{{ index .Labels "${WORKSPACE_LABEL}" }}`]);
|
||||
return output
|
||||
.split('\n')
|
||||
.map((name) => name.trim())
|
||||
.filter(Boolean);
|
||||
const result = runningScanWorkspacesChecked();
|
||||
return result.kind === 'ok' ? result.value : [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -520,40 +630,6 @@ export async function stopContainers(ids: string[]): Promise<void> {
|
||||
await Promise.all(ids.map((id) => spawnQuiet('docker', ['stop', id])));
|
||||
}
|
||||
|
||||
/** Request cooperative cancellation so the workflow can run its terminal finalizer. */
|
||||
export function cancelWorkflow(workflowId: string): boolean {
|
||||
return runQuiet('docker', temporalCmd('workflow', 'cancel', '--workflow-id', workflowId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate a Temporal workflow so a stopped scan doesn't linger as a running
|
||||
* workflow with no worker. Best-effort: returns false if Temporal is unreachable
|
||||
* or the workflow already closed. Requires Temporal to be up (guard with isTemporalReady).
|
||||
*/
|
||||
export function terminateWorkflow(workflowId: string, reason: string): boolean {
|
||||
return runQuiet('docker', temporalCmd('workflow', 'terminate', '--workflow-id', workflowId, '--reason', reason));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a specific workflow is still in the Running state. Re-querying this after
|
||||
* a terminate verifies it actually took effect, rather than trusting the terminate
|
||||
* command's exit code. Requires Temporal to be up (guard with isTemporalReady).
|
||||
*/
|
||||
export function isWorkflowRunning(workflowId: string): boolean {
|
||||
const query = `WorkflowId = '${workflowId}' AND ExecutionStatus = 'Running'`;
|
||||
const output = runOutput('docker', temporalCmd('workflow', 'list', '--query', query));
|
||||
return output.includes(workflowId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any pentest scan workflow is still Running — the `stop --all` counterpart
|
||||
* to isWorkflowRunning. Requires Temporal to be up (guard with isTemporalReady).
|
||||
*/
|
||||
export function anyRunningScanWorkflow(): boolean {
|
||||
const output = runOutput('docker', temporalCmd('workflow', 'list', '--query', RUNNING_SCAN_QUERY));
|
||||
return output.includes('pentestPipelineWorkflow');
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down the compose stack. When `clean` is set, volumes are removed too.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/** Durable CLI-owned workflow candidates that bridge Docker launch and session registration. */
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { INTERNAL_DIR } from './paths.js';
|
||||
|
||||
const SCHEMA_VERSION = 1 as const;
|
||||
const PENDING_DIR = 'pending-workflows';
|
||||
|
||||
export interface PendingWorkflowIdentity {
|
||||
readonly schema_version: typeof SCHEMA_VERSION;
|
||||
readonly workflow_id: string;
|
||||
readonly task_queue: string;
|
||||
readonly created_at: string;
|
||||
}
|
||||
|
||||
export interface PendingWorkflowReadResult {
|
||||
readonly identities: readonly PendingWorkflowIdentity[];
|
||||
readonly unreadableCount: number;
|
||||
}
|
||||
|
||||
function pendingDir(workspacePath: string): string {
|
||||
return path.join(workspacePath, INTERNAL_DIR, PENDING_DIR);
|
||||
}
|
||||
|
||||
function pendingFile(workspacePath: string, taskQueue: string): string {
|
||||
return path.join(pendingDir(workspacePath), `launch-${encodeURIComponent(taskQueue)}.json`);
|
||||
}
|
||||
|
||||
function syncDirectory(directory: string): void {
|
||||
const descriptor = fs.openSync(directory, 'r');
|
||||
try {
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist the candidate before docker run, so a vanished pre-registration worker remains addressable. */
|
||||
export function writePendingWorkflowIdentity(workspacePath: string, workflowId: string, taskQueue: string): void {
|
||||
const directory = pendingDir(workspacePath);
|
||||
const directoryAlreadyExisted = fs.existsSync(directory);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
if (!directoryAlreadyExisted) syncDirectory(path.dirname(directory));
|
||||
const destination = pendingFile(workspacePath, taskQueue);
|
||||
const temporary = `${destination}.tmp-${process.pid}-${Date.now()}`;
|
||||
const identity: PendingWorkflowIdentity = {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
workflow_id: workflowId,
|
||||
task_queue: taskQueue,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const descriptor = fs.openSync(temporary, 'wx', 0o600);
|
||||
try {
|
||||
fs.writeFileSync(descriptor, `${JSON.stringify(identity, null, 2)}\n`, 'utf8');
|
||||
fs.fsyncSync(descriptor);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
try {
|
||||
// Link installs the fully-fsynced inode without replacing an existing task-queue record.
|
||||
fs.linkSync(temporary, destination);
|
||||
fs.unlinkSync(temporary);
|
||||
syncDirectory(directory);
|
||||
} catch (error) {
|
||||
fs.rmSync(temporary, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove one candidate only after session registration or a fully verified stop. */
|
||||
export function clearPendingWorkflowIdentity(workspacePath: string, taskQueue: string): void {
|
||||
const directory = pendingDir(workspacePath);
|
||||
fs.rmSync(pendingFile(workspacePath, taskQueue), { force: true });
|
||||
if (fs.existsSync(directory)) syncDirectory(directory);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function isPendingWorkflowIdentity(
|
||||
value: unknown,
|
||||
workspace: string,
|
||||
expectedFilename: string,
|
||||
): value is PendingWorkflowIdentity {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const keys = Object.keys(candidate).sort();
|
||||
const workflowId = candidate.workflow_id;
|
||||
const workflowPattern = new RegExp(`^${escapeRegExp(workspace)}_(?:shannon-|resume_)\\d+$`);
|
||||
const workspaceIsWorkflowId = workflowId === workspace && /_shannon-\d+$/.test(workspace);
|
||||
return (
|
||||
keys.length === 4 &&
|
||||
keys[0] === 'created_at' &&
|
||||
keys[1] === 'schema_version' &&
|
||||
keys[2] === 'task_queue' &&
|
||||
keys[3] === 'workflow_id' &&
|
||||
candidate.schema_version === SCHEMA_VERSION &&
|
||||
typeof workflowId === 'string' &&
|
||||
(workspaceIsWorkflowId || workflowPattern.test(workflowId)) &&
|
||||
typeof candidate.task_queue === 'string' &&
|
||||
/^shannon-[0-9a-f]{8}$/.test(candidate.task_queue) &&
|
||||
expectedFilename === `launch-${encodeURIComponent(candidate.task_queue)}.json` &&
|
||||
typeof candidate.created_at === 'string' &&
|
||||
!Number.isNaN(Date.parse(candidate.created_at)) &&
|
||||
new Date(candidate.created_at).toISOString() === candidate.created_at
|
||||
);
|
||||
}
|
||||
|
||||
/** Read every outstanding launch candidate, preserving corrupt records as an explicit failure count. */
|
||||
export function readPendingWorkflowIdentities(workspacePath: string): PendingWorkflowReadResult {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(pendingDir(workspacePath)).filter((entry) => entry.endsWith('.json'));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { identities: [], unreadableCount: 0 };
|
||||
return { identities: [], unreadableCount: 1 };
|
||||
}
|
||||
|
||||
const identities: PendingWorkflowIdentity[] = [];
|
||||
let unreadableCount = 0;
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
const value: unknown = JSON.parse(fs.readFileSync(path.join(pendingDir(workspacePath), entry), 'utf8'));
|
||||
if (!isPendingWorkflowIdentity(value, path.basename(workspacePath), entry)) {
|
||||
unreadableCount++;
|
||||
continue;
|
||||
}
|
||||
identities.push(value);
|
||||
} catch {
|
||||
unreadableCount++;
|
||||
}
|
||||
}
|
||||
// Atomic-write temp files are intentionally ignored: start cannot spawn Docker until the
|
||||
// final .json rename and fsync above have both completed.
|
||||
return { identities, unreadableCount };
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Thin Temporal client for reading one scan's state.
|
||||
* Thin Temporal client for reading scan state and controlling scan workflow lifecycle.
|
||||
*
|
||||
* A running scan is queried live (getProgress) and read via pendingActivities for
|
||||
* the in-flight agents; a closed scan is read once from its result. Everything goes
|
||||
@@ -13,10 +13,20 @@ import { ACTIVITY_TO_PROGRESS, type PipelineState } from './scan/pipeline.js';
|
||||
|
||||
const ADDRESS = '127.0.0.1:7233';
|
||||
const NAMESPACE = 'default';
|
||||
const LIFECYCLE_RPC_DEADLINE_MS = 3_000;
|
||||
const OPEN_SCAN_WORKFLOW_QUERY =
|
||||
"WorkflowType = 'pentestPipelineWorkflow' AND (ExecutionStatus = 'Running' OR ExecutionStatus = 'Paused')";
|
||||
|
||||
// WorkflowExecutionStatusName values that mean the scan has closed. RUNNING (and the unused
|
||||
// CONTINUED_AS_NEW) are the only non-terminal states.
|
||||
const TERMINAL_STATUSES: ReadonlySet<string> = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'TERMINATED', 'TIMED_OUT']);
|
||||
// WorkflowExecutionStatusName values that positively prove this execution has closed.
|
||||
// PAUSED is open; UNSPECIFIED and UNKNOWN are not safe closure evidence.
|
||||
const TERMINAL_STATUSES: ReadonlySet<string> = new Set([
|
||||
'COMPLETED',
|
||||
'FAILED',
|
||||
'CANCELLED',
|
||||
'TERMINATED',
|
||||
'CONTINUED_AS_NEW',
|
||||
'TIMED_OUT',
|
||||
]);
|
||||
|
||||
export interface RunningAgent {
|
||||
readonly agent: string;
|
||||
@@ -66,11 +76,28 @@ export type TerminalOutcome =
|
||||
| { readonly kind: 'success'; readonly state: PipelineState }
|
||||
| { readonly kind: 'failed'; readonly message: string };
|
||||
|
||||
/**
|
||||
* The authoritative Temporal state used by lifecycle commands. Transport failures deliberately
|
||||
* remain errors instead of being represented as a closed workflow: callers must not report a
|
||||
* scan stopped unless Temporal has positively confirmed it.
|
||||
*/
|
||||
export type WorkflowLifecycleState =
|
||||
| { readonly kind: 'open'; readonly status: 'RUNNING' | 'PAUSED' }
|
||||
| { readonly kind: 'terminal'; readonly status: string }
|
||||
| { readonly kind: 'unknown'; readonly status: string }
|
||||
| { readonly kind: 'not-found' };
|
||||
|
||||
/** A scan workflow returned by Temporal's eventually consistent open-workflow visibility query. */
|
||||
export interface RunningScanWorkflow {
|
||||
readonly workflowId: string;
|
||||
readonly taskQueue: string;
|
||||
}
|
||||
|
||||
let clientPromise: Promise<Client> | null = null;
|
||||
|
||||
function getClient(): Promise<Client> {
|
||||
if (!clientPromise) {
|
||||
const pending = Connection.connect({ address: ADDRESS }).then(
|
||||
const pending = Connection.connect({ address: ADDRESS, connectTimeout: LIFECYCLE_RPC_DEADLINE_MS }).then(
|
||||
(connection) => new Client({ connection, namespace: NAMESPACE }),
|
||||
);
|
||||
// A rejected connect must not be cached forever: clear the memo so the next call rebuilds
|
||||
@@ -95,6 +122,95 @@ function resetClient(only?: Promise<Client>): void {
|
||||
previous?.then((client) => client.connection.close()).catch(() => {});
|
||||
}
|
||||
|
||||
/** Close the current channel and establish another before a termination retry. */
|
||||
export async function refreshWorkflowLifecycleConnection(): Promise<void> {
|
||||
const previous = clientPromise;
|
||||
if (previous !== null) {
|
||||
if (clientPromise === previous) clientPromise = null;
|
||||
try {
|
||||
const client = await previous;
|
||||
await client.connection.close();
|
||||
} catch {
|
||||
// A failed prior connection is already detached. The new connection below is authoritative.
|
||||
}
|
||||
}
|
||||
await getClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a bounded lifecycle RPC and discard the connection when Temporal did not positively say
|
||||
* that the workflow is absent. A fresh connection is important after a gRPC timeout or transport
|
||||
* failure: reusing a wedged channel can turn a recoverable stop into an indefinitely ambiguous one.
|
||||
*/
|
||||
async function runLifecycleRpc<T>(operation: (client: Client) => Promise<T>): Promise<T> {
|
||||
const pending = getClient();
|
||||
try {
|
||||
const client = await pending;
|
||||
return await client.withDeadline(Date.now() + LIFECYCLE_RPC_DEADLINE_MS, () => operation(client));
|
||||
} catch (err) {
|
||||
if (!(err instanceof WorkflowNotFoundError)) resetClient(pending);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Describe a workflow for lifecycle control without reading its progress or pending activities. */
|
||||
export async function describeWorkflowLifecycle(workflowId: string): Promise<WorkflowLifecycleState> {
|
||||
try {
|
||||
const desc = await runLifecycleRpc((client) => client.workflow.getHandle(workflowId).describe());
|
||||
if (desc.status.name === 'RUNNING' || desc.status.name === 'PAUSED') {
|
||||
return { kind: 'open', status: desc.status.name };
|
||||
}
|
||||
if (TERMINAL_STATUSES.has(desc.status.name)) return { kind: 'terminal', status: desc.status.name };
|
||||
return { kind: 'unknown', status: desc.status.name };
|
||||
} catch (err) {
|
||||
if (err instanceof WorkflowNotFoundError) return { kind: 'not-found' };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Request cooperative cancellation. This confirms request acceptance, not workflow closure. */
|
||||
export async function requestWorkflowCancellation(workflowId: string): Promise<'requested' | 'not-found'> {
|
||||
try {
|
||||
await runLifecycleRpc((client) => client.workflow.getHandle(workflowId).cancel());
|
||||
return 'requested';
|
||||
} catch (err) {
|
||||
if (err instanceof WorkflowNotFoundError) return 'not-found';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Request forced termination. This confirms request acceptance, not workflow closure. */
|
||||
export async function requestWorkflowTermination(
|
||||
workflowId: string,
|
||||
reason: string,
|
||||
): Promise<'requested' | 'not-found'> {
|
||||
try {
|
||||
await runLifecycleRpc((client) => client.workflow.getHandle(workflowId).terminate(reason));
|
||||
return 'requested';
|
||||
} catch (err) {
|
||||
if (err instanceof WorkflowNotFoundError) return 'not-found';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** List currently open Shannon scan workflows through Temporal visibility. */
|
||||
export async function listRunningScanWorkflows(): Promise<readonly RunningScanWorkflow[]> {
|
||||
return runLifecycleRpc(async (client) => {
|
||||
const workflows: RunningScanWorkflow[] = [];
|
||||
for await (const execution of client.workflow.list({ query: OPEN_SCAN_WORKFLOW_QUERY })) {
|
||||
// Visibility is eventually consistent. Keep only the open scan rows returned by this page;
|
||||
// each discovered workflow is described directly before `stop` accepts its closure.
|
||||
if (
|
||||
(execution.status.name === 'RUNNING' || execution.status.name === 'PAUSED') &&
|
||||
execution.type === 'pentestPipelineWorkflow'
|
||||
) {
|
||||
workflows.push({ workflowId: execution.workflowId, taskQueue: execution.taskQueue });
|
||||
}
|
||||
}
|
||||
return workflows;
|
||||
});
|
||||
}
|
||||
|
||||
/** Describe a scan: status, timing, and the agents currently running (from pendingActivities). Null if not found. */
|
||||
export async function describeScan(workflowId: string): Promise<ScanDescription | null> {
|
||||
const client = await getClient();
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
* identity proof are separate steps: `resolveScanIdentity` turns a selected or explicit
|
||||
* string into the one canonical (workspace, workflowId) pair the session records prove.
|
||||
*
|
||||
* Running scans are identified by Docker label (the authoritative source, shared with
|
||||
* `stop`); recency for finished scans comes from each run's session.json createdAt,
|
||||
* Running workers are identified by Docker workspace label for default-target selection.
|
||||
* `stop` supplements that local discovery with Temporal lifecycle state. Recency for
|
||||
* finished scans comes from each run's session.json createdAt,
|
||||
* with the workspace directory mtime as the fallback for runs that predate it.
|
||||
*/
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*
|
||||
* Options:
|
||||
* --task-queue <name> Task queue name (required, unique per scan)
|
||||
* --workflow-id <id> Workflow ID selected by the Shannon CLI
|
||||
* --config <path> Configuration file path
|
||||
* --output <path> Stable mounted path for final customer report copies
|
||||
* --workspace <name> Resume from existing workspace
|
||||
@@ -240,6 +241,7 @@ interface CliArgs {
|
||||
webUrl: string;
|
||||
repoPath: string;
|
||||
taskQueue: string;
|
||||
workflowId?: string;
|
||||
configPath?: string;
|
||||
customerOutputPath?: string;
|
||||
pipelineTestingMode: boolean;
|
||||
@@ -253,6 +255,7 @@ function showUsage(): void {
|
||||
console.log(' node dist/temporal/worker.js <webUrl> <repoPath> --task-queue <name> [options]\n');
|
||||
console.log('Options:');
|
||||
console.log(' --task-queue <name> Task queue name (required)');
|
||||
console.log(' --workflow-id <id> Workflow ID selected by the Shannon CLI');
|
||||
console.log(' --config <path> Configuration file path');
|
||||
console.log(' --workspace <name> Resume from existing workspace');
|
||||
console.log(' --output <path> Stable mounted path for final customer report copies');
|
||||
@@ -268,6 +271,7 @@ function parseCliArgs(argv: string[]): CliArgs {
|
||||
let webUrl: string | undefined;
|
||||
let repoPath: string | undefined;
|
||||
let taskQueue: string | undefined;
|
||||
let workflowId: string | undefined;
|
||||
let configPath: string | undefined;
|
||||
let customerOutputPath: string | undefined;
|
||||
let pipelineTestingMode = false;
|
||||
@@ -281,6 +285,12 @@ function parseCliArgs(argv: string[]): CliArgs {
|
||||
taskQueue = nextArg;
|
||||
i++;
|
||||
}
|
||||
} else if (arg === '--workflow-id') {
|
||||
const nextArg = argv[i + 1];
|
||||
if (nextArg && !nextArg.startsWith('-')) {
|
||||
workflowId = nextArg;
|
||||
i++;
|
||||
}
|
||||
} else if (arg === '--config') {
|
||||
const nextArg = argv[i + 1];
|
||||
if (nextArg && !nextArg.startsWith('-')) {
|
||||
@@ -326,6 +336,7 @@ function parseCliArgs(argv: string[]): CliArgs {
|
||||
webUrl,
|
||||
repoPath,
|
||||
taskQueue,
|
||||
...(workflowId && { workflowId }),
|
||||
pipelineTestingMode,
|
||||
...(configPath && { configPath }),
|
||||
...(customerOutputPath && { customerOutputPath }),
|
||||
@@ -356,6 +367,17 @@ function isValidWorkspaceName(name: string): boolean {
|
||||
return /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(name);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** Accept a CLI-owned ID only when it preserves this launch branch's public naming contract. */
|
||||
function selectWorkflowId(requested: string | undefined, fallback: string, expected: RegExp): string {
|
||||
if (requested === undefined) return fallback;
|
||||
if (!expected.test(requested)) throw new Error('Invalid workflow identity supplied by the Shannon CLI');
|
||||
return requested;
|
||||
}
|
||||
|
||||
interface WorkspaceResolution {
|
||||
workflowId: string;
|
||||
sessionId: string;
|
||||
@@ -407,7 +429,12 @@ async function terminateExistingWorkflows(client: Client, workspaceName: string)
|
||||
async function resolveWorkspace(client: Client, args: CliArgs, expectedExploit: boolean): Promise<WorkspaceResolution> {
|
||||
if (!args.resumeFromWorkspace) {
|
||||
const hostname = sanitizeHostname(args.webUrl);
|
||||
const workflowId = `${hostname}_shannon-${Date.now()}`;
|
||||
const fallback = `${hostname}_shannon-${Date.now()}`;
|
||||
const workflowId = selectWorkflowId(
|
||||
args.workflowId,
|
||||
fallback,
|
||||
new RegExp(`^${escapeRegExp(hostname)}_shannon-\\d+$`),
|
||||
);
|
||||
return {
|
||||
workflowId,
|
||||
sessionId: workflowId,
|
||||
@@ -442,8 +469,9 @@ async function resolveWorkspace(client: Client, args: CliArgs, expectedExploit:
|
||||
console.log(`Terminated ${terminatedWorkflows.length} previous scan(s)\n`);
|
||||
}
|
||||
|
||||
const fallback = `${workspace}_resume_${Date.now()}`;
|
||||
return {
|
||||
workflowId: `${workspace}_resume_${Date.now()}`,
|
||||
workflowId: selectWorkflowId(args.workflowId, fallback, new RegExp(`^${escapeRegExp(workspace)}_resume_\\d+$`)),
|
||||
sessionId: workspace,
|
||||
isResume: true,
|
||||
terminatedWorkflows,
|
||||
@@ -461,7 +489,12 @@ async function resolveWorkspace(client: Client, args: CliArgs, expectedExploit:
|
||||
|
||||
// If the workspace name already looks like a CLI-generated ID
|
||||
// (ends with _shannon-<digits>), use it directly to avoid double _shannon- suffixes
|
||||
const workflowId = /_shannon-\d+$/.test(workspace) ? workspace : `${workspace}_shannon-${Date.now()}`;
|
||||
const fallback = /_shannon-\d+$/.test(workspace) ? workspace : `${workspace}_shannon-${Date.now()}`;
|
||||
const expected =
|
||||
fallback === workspace
|
||||
? new RegExp(`^${escapeRegExp(workspace)}$`)
|
||||
: new RegExp(`^${escapeRegExp(workspace)}_shannon-\\d+$`);
|
||||
const workflowId = selectWorkflowId(args.workflowId, fallback, expected);
|
||||
|
||||
return {
|
||||
workflowId,
|
||||
|
||||
Reference in New Issue
Block a user