diff --git a/.env.1password.example b/.env.1password.example index 1c52eb5..b9bfd8d 100644 --- a/.env.1password.example +++ b/.env.1password.example @@ -1,6 +1,13 @@ GITHUB_APP_ID=op://Private/CovenCat/App ID GITHUB_WEBHOOK_SECRET=op://Private/CovenCat/Webhook Secret GITHUB_APP_PRIVATE_KEY=op://Private/CovenCat/Private Key +COVEN_PUBLICATION_SIGNING_SECRET=op://Private/CovenCat/Publication Signing Secret COVEN_GITHUB_POLICY_PATH=./coven-github-policy.json COVEN_GITHUB_STATE_DIR=./coven-github-state -COVEN_CODE_BIN=coven-code +COVEN_RUNTIME_ISOLATION=bwrap +COVEN_RUNTIME_EXTERNAL_ISOLATION=verify-host-controls-before-setting +COVEN_GITHUB_REVOCATION_EVENTS=verify-live-app-before-setting +COVEN_BWRAP_BIN=/usr/bin/bwrap +COVEN_RUNTIME_ROOTFS=/opt/coven-runtime/rootfs +COVEN_RUNTIME_NETWORK=shared +COVEN_CODE_BIN=/usr/local/bin/coven-code diff --git a/AGENTS.md b/AGENTS.md index 166fd6f..75ae3dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,8 +32,11 @@ pull requests against this repo. This is the agent-specific layer; read ## Repo-specific invariants (don't break these) -- This is a **thin hosted forwarder**, not the app logic. Familiar/authority and - GitHub-App behavior lives in `coven-github` — don't reimplement it here. +- This is the **hosted deployment adapter**, not the canonical product worker. + Familiar/authority rules, result contracts, and GitHub App product behavior + live in `coven-github`; keep only deployment-specific webhook verification, + evidence capture, runtime invocation, and safe GitHub transport here. Do not + fork or redefine the canonical contracts in this repository. - **Never commit webhook secrets, signing secrets, App private keys, or tokens.** Configuration comes from the deploy environment, not the repo. - **Always verify the GitHub webhook signature** (`X-Hub-Signature-256`) before diff --git a/README.md b/README.md index a5fcd3e..2392931 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,17 @@ The adapter is deployment-specific. It is not the canonical Rust worker implementation; it exists so hosted webhook behavior can be reviewed, reproduced, and changed through PRs instead of server-only edits. +Long-running tasks execute outside the HTTP thread so GitHub deliveries are +acknowledged promptly and health endpoints stay responsive. Queued or +interrupted tasks are discovered again when the service starts. Native review +deployments must subscribe the GitHub App to both `pull_request` and `push`, and +must enable `pull_request.synchronize`, `pull_request.edited`, +`pull_request.reopened`, and the actionless `push` trigger. These deliveries +reconcile stale decisive reviews after head changes, base retargeting, reopening, +or new commits on a PR's base branch. A `publication.mode=comment` route that +lacks any of these safety triggers fails closed instead of publishing native PR +reviews. + ## Files - `src/adapter.ts` - webhook handler, task router, task runner, PR evidence @@ -32,10 +43,27 @@ The deployment expects secrets and mutable state to be supplied outside git: - `GITHUB_APP_ID` - `GITHUB_WEBHOOK_SECRET` or `WEBHOOK_SECRET` +- `COVEN_PUBLICATION_SIGNING_SECRET` - optional dedicated HMAC key for review + identity markers; defaults to the webhook secret for compatibility +- `COVEN_PUBLICATION_PREVIOUS_SIGNING_SECRETS` - comma-separated prior marker + keys retained only during rotation/reconciliation - `GITHUB_APP_PRIVATE_KEY_PATH` or `.coven-github-private-key.pem` - `COVEN_GITHUB_STATE_DIR` - `COVEN_GITHUB_POLICY_PATH` -- `COVEN_CODE_BIN` +- `COVEN_RUNTIME_ISOLATION=bwrap` - required for every non-demo task; unset is + fail-closed and never falls back to direct execution +- `COVEN_RUNTIME_EXTERNAL_ISOLATION=network-egress-and-resource-limits-verified` + - mandatory declaration that the deployment independently enforces egress, + CPU, memory, PID, and disk/scratch limits +- `COVEN_GITHUB_REVOCATION_EVENTS=pull-request-and-push-verified` - set only + after verifying the installed App's live `pull_request` and `push` + subscriptions; native PR publication fails closed without it +- `COVEN_BWRAP_BIN` - absolute host path to bubblewrap (defaults to + `/usr/bin/bwrap`) +- `COVEN_RUNTIME_ROOTFS` - dedicated credential-free runtime rootfs +- `COVEN_CODE_BIN` - absolute coven-code path inside that rootfs +- `COVEN_RUNTIME_NETWORK=shared` - explicit opt-in required when the Codex + provider needs network access; the default is `none` - `COVEN_REVIEW_FIX_LOOPS` - optional bounded review-fix loop count, clamped between `0` and `5`; defaults to `0` so hosted repair loops are opt-in - Codex OAuth tokens under the deployed account's `.coven-code` directory @@ -43,6 +71,32 @@ The deployment expects secrets and mutable state to be supplied outside git: Do not commit private keys, webhook secrets, OAuth tokens, generated task state, workspaces, or attempt artifacts. +Real tasks never execute directly as the webhook account. Before minting a +GitHub token, the adapter runs a bubblewrap probe and verifies read-only input, +writable workspace/output mounts, a private PID namespace, no network for +validation, and a dedicated rootfs that does not contain adapter state or +credentials. A missing binary, rootfs, executable, or usable user namespace +records the task as `runtime_isolation_unavailable` with no direct fallback. +`publication.mode=record_only` is not an isolation control. + +The runtime rootfs must contain the configured `coven-code`, `git`, and shell +executables plus their libraries, CA/DNS files, and approved runtime assets. It +must not contain the GitHub App key, webhook state, policy, parent home, or Codex +token store. The runtime receives only its dedicated model credential; it never +receives a GitHub token or Git askpass helper. Shared networking is not an +egress-confidentiality boundary, so use a dedicated, revocable model credential +and an externally enforced allowlist that blocks loopback, LAN, and metadata +services. The adapter refuses real tasks unless the external network/resource +isolation declaration is present. + +The current runtime passes that model credential to `coven-code`; an untrusted +checkout can therefore try to consume or encode it through the model channel +even when ordinary egress is filtered. Treat this release as trusted-repository +only. Public/untrusted pull requests require a separately constrained worker and +a quota-limited credential broker that never exposes a reusable model token to +the repository process. Do not set the external-isolation declaration for that +use case until those controls exist. + ## Local runtime Install dependencies, build TypeScript, and start the webhook service: @@ -95,6 +149,16 @@ Deployments should provide `coven-github-policy.json` through `COVEN_GITHUB_POLICY_PATH`. That file is intentionally ignored because it is environment-specific. +Every route with `publication.mode=comment` must include all native-review +safety triggers: `pull_request.synchronize`, `pull_request.edited`, +`pull_request.reopened`, and `push`. The `push` key is actionless; do not write +`push.`. Both the `pull_request` and `push` webhook events must also be enabled +on the installed GitHub App. Missing policy coverage is a configuration error +and native publication remains fail closed. After verifying the live App +registration, set +`COVEN_GITHUB_REVOCATION_EVENTS=pull-request-and-push-verified`; this declaration +is required in addition to the policy trigger list. + Start from [`config/example-policy.json`](config/example-policy.json) and the connection guide in [`docs/coven-github-connection.md`](docs/coven-github-connection.md). @@ -106,10 +170,34 @@ connection guide in signed delivery -> policy route -> delivery/task/result state without calling GitHub or `coven-code`. - Captures PR checkout metadata and changed-file patches before invoking - `coven-code`. -- Publishes visible structured review evidence, including `reviewed_files`, - `supporting_files`, findings, test evidence, no-findings rationale, and - limitations. + `coven-code`, including paginated file lists and patch-completeness checks. +- Publishes PR results as native GitHub reviews: complete no-finding evidence + approves, actionable findings request changes, and incomplete or + contradictory evidence is published as a comment review. Findings are made + inline only when their captured diff location is valid; all other findings + remain in the review body. Decisive reviews are bound to the captured commit + and require full changed-file coverage, a clean matching checkout, and + verified passing test evidence. They are created pending, then submitted only + after fresh head and base checks; a concurrent revision change causes a + COMMENT downgrade or automatic dismissal. Passing claims are decisive only + when they match successful host-captured validation receipts. Validation and + post-run Git checks execute in a second credential-free, network-disabled + sandbox. +- Uses repository-scoped installation tokens: parent Git gets only + `contents:read`, PR evidence gets read authority, and publication write + authority is minted only after isolated execution has finished. +- Persists `publication_pending` before GitHub writes and resumes interrupted + publication on startup or duplicate webhook delivery without rerunning the + agent. +- Persists publication identities and review/comment IDs in the configured + state directory, reconciles HMAC-signed App-authored identities with GitHub, + and serializes publication per PR so retries and concurrent runs do not + duplicate output. + Newer reviews link to superseded covencat output and dismiss its prior + decisive state when GitHub permits it. +- Publishes non-PR task results and operational notices as issue comments, + including structured `reviewed_files`, `supporting_files`, findings, test + evidence, no-findings rationale, and limitations. - When `COVEN_REVIEW_FIX_LOOPS` is greater than `0`, reruns `coven-code` with prior structured review findings as explicit repair instructions until no findings remain or the configured loop count is exhausted. diff --git a/config/example-policy.json b/config/example-policy.json index f5c5e3e..d5e7893 100644 --- a/config/example-policy.json +++ b/config/example-policy.json @@ -7,7 +7,11 @@ "enabled_triggers": [ "issues.labeled", "issue_comment.created", - "pull_request_review_comment.created" + "pull_request_review_comment.created", + "pull_request.synchronize", + "pull_request.edited", + "pull_request.reopened", + "push" ], "trigger_labels": [ "coven:fix", diff --git a/docs/coven-github-connection.md b/docs/coven-github-connection.md index 906664c..c71c144 100644 --- a/docs/coven-github-connection.md +++ b/docs/coven-github-connection.md @@ -21,14 +21,40 @@ Set the manifest webhook URL to this service: https://your-host/webhook ``` -The manifest subscribes to the events this adapter can route: +The adapter can route these GitHub App webhook events: - `issues` - `issue_comment` +- `pull_request` - `pull_request_review` - `pull_request_review_comment` - `check_suite` - `check_run` +- `push` + +For every route using `publication.mode: comment`, the installed GitHub App must +subscribe to both **Pull request** (`pull_request`) and **Push** (`push`) events. +The route's `enabled_triggers` must contain all four safety triggers: + +- `pull_request.synchronize` for new commits or force-pushes on the PR head. +- `pull_request.edited` for base-branch retargeting. +- `pull_request.reopened` to reconcile a PR when it becomes active again. +- `push` to reconcile open PRs when new commits land on their base branch. Push + deliveries have no `action`, so the policy key is exactly `push`, not `push.`. + +These deliveries let the adapter dismiss signed, decisive covencat reviews +whose reviewed head/base pair is no longer current. Native publication fails +closed if any required policy trigger is absent. `doctor:app` reports each +missing trigger as an error. Keep the route on `record_only` until both the +policy and App subscriptions are complete. After checking the live App settings, +set `COVEN_GITHUB_REVOCATION_EVENTS=pull-request-and-push-verified`; this explicit +deployment declaration is also required for native PR publication. + +Treat the manifest file and the live App registration as separate checks: +ensure `docs/app-manifest.json` in `OpenCoven/coven-github` lists both events, +then verify the installed App's settings also subscribe to **Pull request** and +**Push**. Updating a manifest file does not retroactively update an existing +GitHub App registration. After creating the App, keep these values outside git: @@ -68,6 +94,11 @@ Then replace: - `bot_usernames` with the GitHub App bot login, for example `coven-cody[bot]`. - `trigger_labels` with labels that should start tasks. +- `enabled_triggers` with the exact event/action pairs allowed to spend compute, + plus actionless `push` where needed. Events not listed are acknowledged and + ignored. A `publication.mode: comment` route must include all four + native-review safety triggers listed above; otherwise publication fails + closed. - `familiar` with the familiar id, display name, model, and skills to pass to `coven-code`. @@ -79,7 +110,14 @@ export COVEN_GITHUB_POLICY_PATH="$PWD/coven-github-policy.json" Keep `publication.mode` as `record_only` for first smoke runs. Switch it to `comment` only after you have verified the App installation, `coven-code` -runtime, Codex token, and workspace permissions. +runtime, Codex token, workspace permissions, both required GitHub App webhook +subscriptions, and all four required safety triggers. + +`record_only` controls publication, not process isolation. Non-demo work stays +blocked until the mandatory runtime sandbox below passes its executable probe. +For decisive native reviews, also configure a bounded list of trusted +validation commands under `publication.validation_commands`; a runtime-authored +claim without a matching successful sandbox receipt is published as COMMENT. ## Runtime Checklist @@ -92,12 +130,51 @@ export GITHUB_WEBHOOK_SECRET="replace-with-github-secret" export GITHUB_APP_PRIVATE_KEY_PATH="$PWD/keys/coven-github.private-key.pem" export COVEN_GITHUB_POLICY_PATH="$PWD/coven-github-policy.json" export COVEN_GITHUB_STATE_DIR="$PWD/coven-github-state" -export COVEN_CODE_BIN="$(command -v coven-code)" +install -d -m 700 "$COVEN_GITHUB_STATE_DIR" +export COVEN_RUNTIME_ISOLATION="bwrap" +export COVEN_RUNTIME_EXTERNAL_ISOLATION="network-egress-and-resource-limits-verified" +export COVEN_GITHUB_REVOCATION_EVENTS="pull-request-and-push-verified" +export COVEN_BWRAP_BIN="/usr/bin/bwrap" +export COVEN_RUNTIME_ROOTFS="/opt/coven-runtime/rootfs" +export COVEN_CODE_BIN="/usr/local/bin/coven-code" # path inside the rootfs +export COVEN_RUNTIME_NETWORK="shared" # explicit Codex egress opt-in npm run doctor:app npm start ``` +Build the dedicated rootfs as an administrator-controlled deployment artifact. +It must contain the configured `coven-code`, `/usr/bin/git`, `/bin/sh`, +`/bin/true`, their libraries, CA/DNS files, and required runtime assets. Do not +copy the webhook checkout, state directory, policy, App key, hosting-user home, +or `.coven-code` token store into it. Bubblewrap must be able to create user +namespaces on the target host; `doctor:app` runs a real read/write isolation +probe and fails when it cannot. + +The external-isolation declaration is intentionally mandatory. Set it only +after the worker host or container enforces allowlisted egress (including no +loopback, LAN, or metadata access) and CPU, memory, PID, workspace/disk, and +scratch limits. Bubblewrap mount namespaces and timeouts do not provide those +controls. A state directory from an older deployment must be owned by the +service user and made inaccessible to group/other (for example, `chmod -R go-rwx +"$COVEN_GITHUB_STATE_DIR"`) before this version starts. + +This release passes the model credential to `coven-code`, so an untrusted +checkout can still try to consume or encode it through the allowed model +channel. Limit real execution to trusted repositories. Supporting public or +otherwise untrusted pull requests requires a separately constrained worker and +a quota-limited credential broker that does not expose a reusable model token +to repository code; the declaration above must remain unset until that boundary +is actually deployed. + +The adapter mounts only per-task input (read-only), the checkout and result +directory (writable), and the checkout `.git` directory again as read-only. It +passes no GitHub token, askpass helper, App secret, SSH agent, or parent home to +`coven-code`. Publication authority is minted only after the sandbox exits. +Validation commands run again without credentials or network access. If the +host cannot satisfy this boundary, leave real execution disabled and use demo +mode or an externally isolated worker; there is no unsafe direct fallback. + In another shell: ```bash diff --git a/scripts/doctor-app-config.mjs b/scripts/doctor-app-config.mjs index b991608..0c74b63 100644 --- a/scripts/doctor-app-config.mjs +++ b/scripts/doctor-app-config.mjs @@ -1,8 +1,16 @@ #!/usr/bin/env node -import {existsSync, readFileSync} from "node:fs"; +import {existsSync, mkdtempSync, readFileSync, rmSync} from "node:fs"; +import {tmpdir} from "node:os"; import {join} from "node:path"; -import {createConfig} from "../dist/src/adapter.js"; +import {createConfig, probeRuntimeIsolation, runtimeIsolationIssue} from "../dist/src/adapter.js"; + +const NATIVE_REVIEW_SAFETY_TRIGGERS = [ + "pull_request.synchronize", + "pull_request.edited", + "pull_request.reopened", + "push", +]; function finding(level, field, message, next) { return {level, field, message, next}; @@ -45,6 +53,7 @@ if (!config.privateKeyPem && !existsSync(config.privateKeyPath)) { } const policy = loadJson(config.policyPath); +let requiresRevocationEvents = false; if (!policy) { findings.push(finding("error", "COVEN_GITHUB_POLICY_PATH", "policy file is missing or invalid JSON", "Copy config/example-policy.json and replace the installation/repository IDs.")); } else { @@ -64,11 +73,49 @@ if (!policy) { if (Object.keys(repos).includes("987654321")) { findings.push(finding("error", "policy.repositories", "policy still uses placeholder repository ID 987654321", "Replace it with the real repository ID.")); } + for (const [repositoryId, route] of Object.entries(repos)) { + if (route?.publication?.mode !== "comment") continue; + requiresRevocationEvents = true; + const enabledTriggers = new Set(Array.isArray(route.enabled_triggers) ? route.enabled_triggers.map(String) : []); + for (const trigger of NATIVE_REVIEW_SAFETY_TRIGGERS) { + if (!enabledTriggers.has(trigger)) { + const field = `policy.installations.${installationId}.repositories.${repositoryId}.enabled_triggers`; + findings.push(finding( + "error", + field, + `native review publication requires ${trigger}`, + `Add ${trigger} to enabled_triggers and subscribe the GitHub App to the corresponding webhook event, or use publication.mode=record_only.`, + )); + } + } + } } } -if (!process.env.COVEN_CODE_BIN && !config.demoMode) { - findings.push(finding("warning", "COVEN_CODE_BIN", "COVEN_CODE_BIN is not set", "Set it to the coven-code binary path before running real tasks.")); +if (requiresRevocationEvents && !config.revocationEventsVerified) { + findings.push(finding( + "error", + "COVEN_GITHUB_REVOCATION_EVENTS", + "native review publication requires verified pull_request and push delivery subscriptions on the installed GitHub App", + "Verify the live App subscriptions, then set COVEN_GITHUB_REVOCATION_EVENTS=pull-request-and-push-verified; otherwise use publication.mode=record_only.", + )); +} + +if (!config.demoMode) { + const isolationIssue = runtimeIsolationIssue(config); + if (isolationIssue) { + findings.push(finding("error", "COVEN_RUNTIME_ISOLATION", isolationIssue, "Configure COVEN_RUNTIME_ISOLATION=bwrap with a dedicated rootfs and absolute runtime binary paths. There is no direct-execution fallback.")); + } else { + const probeDir = mkdtempSync(join(tmpdir(), "coven-runtime-doctor-")); + try { + const probeIssue = probeRuntimeIsolation(config, probeDir); + if (probeIssue) { + findings.push(finding("error", "COVEN_RUNTIME_ISOLATION", probeIssue, "Fix bubblewrap/user-namespace support or keep real task execution disabled.")); + } + } finally { + rmSync(probeDir, {recursive: true, force: true}); + } + } } const errors = findings.filter((item) => item.level === "error"); @@ -81,6 +128,10 @@ const output = { private_key: Boolean(config.privateKeyPem || existsSync(config.privateKeyPath)), policy_path: config.policyPath, state_dir: config.stateDir, + runtime_isolation: config.runtimeIsolation, + runtime_external_isolation: config.runtimeExternalIsolationVerified, + revocation_events: config.revocationEventsVerified, + runtime_rootfs: config.runtimeRootfs || null, }, findings, }; diff --git a/src/adapter.ts b/src/adapter.ts index 503bd2e..c41ca5c 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -1,7 +1,7 @@ -import {createHash, createHmac, createSign, randomUUID} from "node:crypto"; -import {existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync} from "node:fs"; -import {homedir} from "node:os"; -import {basename, dirname, join, resolve} from "node:path"; +import {createHash, createHmac, createSign, randomUUID, timingSafeEqual} from "node:crypto"; +import {closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync} from "node:fs"; +import {homedir, hostname} from "node:os"; +import {basename, dirname, isAbsolute, join, relative, resolve, sep} from "node:path"; import {spawnSync} from "node:child_process"; export type JsonValue = null | boolean | number | string | JsonValue[] | JsonObject; @@ -12,6 +12,7 @@ export interface AdapterConfig { stateDir: string; deliveriesDir: string; tasksDir: string; + publicationsDir: string; workspacesDir: string; attemptsDir: string; policyPath: string; @@ -19,10 +20,21 @@ export interface AdapterConfig { privateKeyPem: string; appId: string; webhookSecret: string; + publicationSigningSecret: string; + publicationVerificationSecrets: string[]; covenCodeBin: string; covenCodeModel: string; maxReviewFixLoops: number; codexTokensPath: string; + runtimeIsolation: string; + bwrapBin: string; + runtimeRootfs: string; + runtimeNetwork: "none" | "shared"; + runtimeExternalIsolationVerified: boolean; + revocationEventsVerified: boolean; + hostGitBin: string; + runtimeGitBin: string; + runtimeShellBin: string; maxWebhookBodyBytes: number; demoMode: boolean; } @@ -44,6 +56,9 @@ interface CommandResult extends JsonObject { returncode: number; stdout: string; stderr: string; + signal: string | null; + timed_out: boolean; + spawn_error: string; } interface CycleResult { @@ -55,37 +70,171 @@ interface CycleResult { result: JsonObject | null; } +class GithubApiError extends Error { + constructor( + readonly status: number, + readonly responseBody: string, + readonly retryAfterMs: number, + method: string, + url: string, + ) { + super(`GitHub API ${method} ${url} failed (${status}): ${responseBody}`); + this.name = "GithubApiError"; + } +} + const DEFAULT_POLICY: JsonObject = { version: 1, installations: {}, }; const MAX_WEBHOOK_BODY_BYTES = 10 * 1024 * 1024; +const REQUIRED_NATIVE_REVIEW_TRIGGERS = [ + "pull_request.synchronize", + "pull_request.edited", + "pull_request.reopened", + "push", +] as const; +const MAX_GITHUB_RETRY_DELAY_MS = 24 * 60 * 60 * 1000; +const MAX_GENERIC_RETRY_DELAY_MS = 60 * 60 * 1000; + +function lstatIfPresent(path: string): ReturnType | null { + try { + return lstatSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } +} + +function assertTrustedDirectory(path: string, label: string, requirePrivateOwnership = true): void { + const entry = lstatIfPresent(path); + if (!entry) throw new Error(`${label} does not exist`); + if (entry.isSymbolicLink() || !entry.isDirectory()) { + throw new Error(`${label} must be a real directory, not a symbolic link or another file type`); + } + if (realpathSync(path) !== resolve(path)) { + throw new Error(`${label} must not traverse a symbolic-link ancestor`); + } + if (requirePrivateOwnership && typeof process.getuid === "function") { + if (Number(entry.uid) !== process.getuid()) throw new Error(`${label} must be owned by the service user`); + if ((Number(entry.mode) & 0o077) !== 0) throw new Error(`${label} must not grant group or world access`); + } +} + +function assertSafeDirectoryAncestors(path: string, label: string): void { + let cursor = resolve(path); + while (true) { + const entry = lstatSync(cursor); + if (entry.isSymbolicLink() || !entry.isDirectory()) throw new Error(`${label} ancestor must be a real directory: ${cursor}`); + const writableByOthers = Number(entry.mode) & 0o022; + const sticky = Number(entry.mode) & 0o1000; + if (writableByOthers && !sticky) { + throw new Error(`${label} must not be beneath a group- or world-writable non-sticky directory: ${cursor}`); + } + const parent = dirname(cursor); + if (parent === cursor) break; + cursor = parent; + } +} + +function ensureManagedDirectory(path: string, label: string): void { + const target = resolve(path); + const missing: string[] = []; + let cursor = target; + while (!lstatIfPresent(cursor)) { + missing.push(cursor); + const parent = dirname(cursor); + if (parent === cursor) throw new Error(`Cannot locate an existing parent for ${label}`); + cursor = parent; + } + assertTrustedDirectory(cursor, `${label} existing ancestor`, false); + for (const directory of missing.reverse()) { + try { + mkdirSync(directory, {mode: 0o700}); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + assertTrustedDirectory(directory, label); + } + assertTrustedDirectory(target, label); + assertSafeDirectoryAncestors(target, label); +} + +function ensureManagedChildDirectory(parent: string, name: string, label: string): string { + assertTrustedDirectory(parent, `${label} parent`); + const path = join(parent, name); + const entry = lstatIfPresent(path); + if (!entry) mkdirSync(path, {mode: 0o700}); + assertTrustedDirectory(path, label); + return path; +} + +function createFreshChildDirectory(parent: string, name: string, label: string): string { + assertTrustedDirectory(parent, `${label} parent`); + const path = join(parent, name); + if (lstatIfPresent(path)) throw new Error(`${label} already exists; refusing to reuse it`); + mkdirSync(path, {mode: 0o700}); + assertTrustedDirectory(path, label); + return path; +} + +function createFreshTaskAttemptDirectory(root: string, taskId: string, attempt: number, label: string): string { + if (!validRecordId(taskId)) throw new Error(`Invalid task ID for ${label}`); + if (!Number.isSafeInteger(attempt) || attempt <= 0) throw new Error(`Invalid attempt number for ${label}`); + const taskRoot = ensureManagedChildDirectory(root, taskId, `${label} task directory`); + return createFreshChildDirectory(taskRoot, String(attempt), `${label} attempt directory`); +} export function createConfig(env: NodeJS.ProcessEnv = process.env, rootDir = process.cwd()): AdapterConfig { const stateDir = resolve(env.COVEN_GITHUB_STATE_DIR || join(rootDir, "coven-github-state")); + const webhookSecret = (env.GITHUB_WEBHOOK_SECRET || env.WEBHOOK_SECRET || "").trim(); + const publicationSigningSecret = (env.COVEN_PUBLICATION_SIGNING_SECRET || webhookSecret).trim(); + const previousPublicationSecrets = (env.COVEN_PUBLICATION_PREVIOUS_SIGNING_SECRETS || "") + .split(",") + .map((secret) => secret.trim()) + .filter(Boolean); const config: AdapterConfig = { rootDir, stateDir, deliveriesDir: join(stateDir, "deliveries"), tasksDir: join(stateDir, "tasks"), + publicationsDir: join(stateDir, "publications"), workspacesDir: join(stateDir, "workspaces"), attemptsDir: join(stateDir, "attempts"), policyPath: resolve(env.COVEN_GITHUB_POLICY_PATH || join(rootDir, "coven-github-policy.json")), privateKeyPath: resolve(env.GITHUB_APP_PRIVATE_KEY_PATH || join(rootDir, ".coven-github-private-key.pem")), privateKeyPem: (env.GITHUB_APP_PRIVATE_KEY || "").trim(), appId: (env.GITHUB_APP_ID || "").trim(), - webhookSecret: (env.GITHUB_WEBHOOK_SECRET || env.WEBHOOK_SECRET || "").trim(), + webhookSecret, + publicationSigningSecret, + publicationVerificationSecrets: [...new Set([publicationSigningSecret, ...previousPublicationSecrets].filter(Boolean))], covenCodeBin: (env.COVEN_CODE_BIN || "coven-code").trim() || "coven-code", covenCodeModel: (env.COVEN_CODE_MODEL || "gpt-5.5").trim(), maxReviewFixLoops: envInt(env.COVEN_REVIEW_FIX_LOOPS, 0, 0, 5), codexTokensPath: configuredCodexTokensPath(env), + runtimeIsolation: (env.COVEN_RUNTIME_ISOLATION || "disabled").trim().toLowerCase(), + bwrapBin: resolve(env.COVEN_BWRAP_BIN || "/usr/bin/bwrap"), + runtimeRootfs: env.COVEN_RUNTIME_ROOTFS ? resolve(env.COVEN_RUNTIME_ROOTFS) : "", + runtimeNetwork: (env.COVEN_RUNTIME_NETWORK || "none").trim().toLowerCase() === "shared" ? "shared" : "none", + runtimeExternalIsolationVerified: (env.COVEN_RUNTIME_EXTERNAL_ISOLATION || "").trim() === "network-egress-and-resource-limits-verified", + revocationEventsVerified: (env.COVEN_GITHUB_REVOCATION_EVENTS || "").trim() === "pull-request-and-push-verified", + hostGitBin: resolve(env.COVEN_HOST_GIT_BIN || "/usr/bin/git"), + runtimeGitBin: (env.COVEN_RUNTIME_GIT_BIN || "/usr/bin/git").trim(), + runtimeShellBin: (env.COVEN_RUNTIME_SHELL_BIN || "/bin/sh").trim(), maxWebhookBodyBytes: MAX_WEBHOOK_BODY_BYTES, demoMode: ["1", "true", "yes"].includes((env.COVEN_GITHUB_DEMO_MODE || "").trim().toLowerCase()), }; - for (const directory of [config.deliveriesDir, config.tasksDir, config.workspacesDir, config.attemptsDir]) { - mkdirSync(directory, {recursive: true}); + ensureManagedDirectory(config.stateDir, "COVEN_GITHUB_STATE_DIR"); + for (const [directory, label] of [ + [config.deliveriesDir, "delivery state directory"], + [config.tasksDir, "task state directory"], + [config.publicationsDir, "publication state directory"], + [config.workspacesDir, "workspace state directory"], + [config.attemptsDir, "attempt state directory"], + ] as Array<[string, string]>) { + ensureManagedChildDirectory(config.stateDir, basename(directory), label); } return config; } @@ -121,11 +270,27 @@ function readJson(path: string, fallback: T): T { } } +function readStateJson(path: string, fallback: T): T { + let descriptor: number | undefined; + try { + descriptor = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK); + const details = fstatSync(descriptor); + if (!details.isFile()) throw new Error(`Persisted state is not a regular file: ${path}`); + if (details.size > MAX_WEBHOOK_BODY_BYTES) throw new Error(`Persisted state is too large: ${path}`); + return JSON.parse(readFileSync(descriptor, "utf8")) as T; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return fallback; + throw error; + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + function writeJsonAtomic(path: string, value: JsonValue): void { mkdirSync(dirname(path), {recursive: true}); const tmpName = join(dirname(path), `${basename(path)}.${randomUUID()}.tmp`); try { - writeFileSync(tmpName, `${stableStringify(value)}\n`, "utf8"); + writeFileSync(tmpName, `${stableStringify(value)}\n`, {encoding: "utf8", flag: "wx", mode: 0o600}); renameSync(tmpName, path); } finally { if (existsSync(tmpName)) { @@ -196,6 +361,7 @@ async function githubRequest( ): Promise { const response = await fetch(url, { method, + signal: AbortSignal.timeout(30_000), headers: { Accept: "application/vnd.github+json", Authorization: `Bearer ${token}`, @@ -207,17 +373,34 @@ async function githubRequest( }); const raw = await response.text(); if (!response.ok) { - throw new Error(`GitHub API ${method} ${url} failed: ${raw}`); + const retryAfter = String(response.headers.get("retry-after") || "").trim(); + const retryAfterSeconds = /^\d+$/.test(retryAfter) ? Number(retryAfter) * 1000 : 0; + const retryAfterDate = retryAfter && !retryAfterSeconds ? Date.parse(retryAfter) - Date.now() : 0; + const rateLimitReset = Number(response.headers.get("x-ratelimit-reset") || 0) * 1000 - Date.now(); + const retryAfterMs = Math.max(0, retryAfterSeconds, Number.isFinite(retryAfterDate) ? retryAfterDate : 0, Number.isFinite(rateLimitReset) ? rateLimitReset : 0); + throw new GithubApiError(response.status, raw, Math.min(MAX_GITHUB_RETRY_DELAY_MS, retryAfterMs), method, url); } return raw ? (JSON.parse(raw) as JsonValue) : {}; } -async function installationToken(config: AdapterConfig, installationId: JsonValue | undefined): Promise { +export async function githubRequestAllPages(url: string, token: string): Promise { + const items: JsonObject[] = []; + for (let page = 1; page <= 100; page += 1) { + const separator = url.includes("?") ? "&" : "?"; + const response = await githubRequest("GET", `${url}${separator}per_page=100&page=${page}`, token); + if (!Array.isArray(response)) throw new Error(`GitHub paginated response was not an array: ${url}`); + items.push(...(response as JsonObject[])); + if (response.length < 100) return items; + } + throw new Error(`GitHub paginated response exceeded 100 pages: ${url}`); +} + +async function installationToken(config: AdapterConfig, installationId: JsonValue | undefined, request: JsonObject): Promise { const response = (await githubRequest( "POST", `https://api.github.com/app/installations/${installationId}/access_tokens`, githubAppJwt(config), - {}, + request, )) as JsonObject; const token = response.token; if (typeof token !== "string" || !token) { @@ -226,6 +409,29 @@ async function installationToken(config: AdapterConfig, installationId: JsonValu return token; } +function repositoryInstallationTokenRequest(repositoryId: JsonValue | undefined, permissions: JsonObject): JsonObject { + const id = Number(repositoryId); + if (!Number.isSafeInteger(id) || id <= 0) { + throw new Error("A valid repository ID is required for a scoped installation token"); + } + return { + repository_ids: [id], + permissions, + }; +} + +export function runtimeInstallationTokenRequest(repositoryId: JsonValue | undefined): JsonObject { + return repositoryInstallationTokenRequest(repositoryId, {contents: "read"}); +} + +export function reviewContextInstallationTokenRequest(repositoryId: JsonValue | undefined): JsonObject { + return repositoryInstallationTokenRequest(repositoryId, {contents: "read", pull_requests: "read"}); +} + +export function publicationInstallationTokenRequest(repositoryId: JsonValue | undefined): JsonObject { + return repositoryInstallationTokenRequest(repositoryId, {issues: "write", pull_requests: "write"}); +} + function loadPolicy(config: AdapterConfig): JsonObject { if (!existsSync(config.policyPath)) { writeJsonAtomic(config.policyPath, DEFAULT_POLICY); @@ -244,13 +450,19 @@ function repoPolicy(config: AdapterConfig, payload: JsonObject): [string, string } function deliveryPath(config: AdapterConfig, deliveryId: string): string { + if (!validRecordId(deliveryId)) throw new Error("Invalid GitHub delivery ID"); return join(config.deliveriesDir, `${deliveryId}.json`); } function taskPath(config: AdapterConfig, taskId: string): string { + if (!validRecordId(taskId)) throw new Error("Invalid task ID"); return join(config.tasksDir, `${taskId}.json`); } +function validRecordId(value: string): boolean { + return /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value); +} + function header(headers: Map, name: string): string { return headers.get(name.toLowerCase()) || ""; } @@ -320,6 +532,12 @@ export async function handleRequest( if (method === "GET" && (request.path === "/" || request.path === "/healthz")) { return {status: 200, body: {ok: true}}; } + if (method === "GET" && request.path === "/readyz") { + const issue = adapterReadinessIssue(config); + return issue + ? {status: 503, body: {ok: false, runtime_ready: false, error: issue}} + : {status: 200, body: {ok: true, runtime_ready: true}}; + } if (method !== "POST" || (request.path !== "/" && request.path !== "/webhook")) { return {status: 404, body: {error: "not found"}}; } @@ -350,9 +568,13 @@ export async function handleRequest( } const deliveryId = header(request.headers, "X-GitHub-Delivery") || randomUUID(); + if (!validRecordId(deliveryId)) { + return {status: 400, body: {error: "invalid delivery id"}}; + } + const routed = await routeDelivery(config, eventName, deliveryId, payload, debug); return { - status: 200, - body: await routeDelivery(config, eventName, deliveryId, payload, debug), + status: routed.reason === "native_review_policy_unsafe" ? 503 : routed.reason === "delivery_task_conflict" ? 409 : 200, + body: routed, }; } @@ -404,6 +626,51 @@ function labelsIncludeTrigger(labels: JsonValue | undefined, policy: JsonObject) return false; } +export function nativeReviewPolicyIssue(policy: JsonObject): string | null { + const publication = (policy.publication as JsonObject | undefined) || {}; + if ((publication.mode || "record_only") !== "comment") return null; + const enabled = new Set((Array.isArray(policy.enabled_triggers) ? policy.enabled_triggers : []).map(String)); + const missing = REQUIRED_NATIVE_REVIEW_TRIGGERS.filter((trigger) => !enabled.has(trigger)); + return missing.length + ? `Native PR publication requires revocation triggers: ${missing.join(", ")}` + : null; +} + +function nativeReviewReadinessIssue(config: AdapterConfig, policy: JsonObject): string | null { + const policyIssue = nativeReviewPolicyIssue(policy); + if (policyIssue) return policyIssue; + const publication = (policy.publication as JsonObject | undefined) || {}; + if (!config.demoMode && (publication.mode || "record_only") === "comment" && !config.revocationEventsVerified) { + return "COVEN_GITHUB_REVOCATION_EVENTS must confirm that the installed GitHub App receives pull_request and push events"; + } + return null; +} + +export function adapterReadinessIssue(config: AdapterConfig): string | null { + if (!config.demoMode) { + const isolationIssue = runtimeIsolationIssue(config); + if (isolationIssue) return isolationIssue; + } + if (!existsSync(config.policyPath)) return `COVEN_GITHUB_POLICY_PATH does not exist: ${config.policyPath}`; + const policy = readJson(config.policyPath, DEFAULT_POLICY); + const installations = (policy.installations as JsonObject | undefined) || {}; + for (const installation of Object.values(installations)) { + if (!installation || typeof installation !== "object" || Array.isArray(installation)) continue; + const repositories = (((installation as JsonObject).repositories as JsonObject | undefined) || {}); + for (const route of Object.values(repositories)) { + if (!route || typeof route !== "object" || Array.isArray(route)) continue; + const issue = nativeReviewReadinessIssue(config, route as JsonObject); + if (issue) return issue; + } + } + return null; +} + +function eventTriggerKey(eventName: string, payload: JsonObject): string { + const action = String(payload.action || "").trim(); + return action ? `${eventName}.${action}` : eventName; +} + export function buildTaskFromEvent( eventName: string, deliveryId: string, @@ -430,7 +697,7 @@ export function buildTaskFromEvent( publication: policy.publication || {mode: "record_only"}, issue_refs: ["OpenCoven/coven-github#2", "OpenCoven/coven-github#7"], }; - if (!familiar) { + if (!familiar && eventName !== "pull_request" && eventName !== "push") { return ignored(base, "missing_familiar_policy"); } @@ -498,6 +765,13 @@ export function buildTaskFromEvent( if (action === "labeled" && !labelsIncludeTrigger(issue.labels, policy)) { return ignored(base, "issue_label_not_enabled"); } + if (action === "assigned") { + const assignee = (payload.assignee as JsonObject | undefined) || (issue.assignee as JsonObject | undefined) || {}; + const botUsernames = new Set((Array.isArray(policy.bot_usernames) ? policy.bot_usernames : []).map((name) => String(name).toLowerCase())); + if (botUsernames.size && !botUsernames.has(String(assignee.login || "").toLowerCase())) { + return ignored(base, "issue_assignment_not_for_bot"); + } + } Object.assign(base, { trigger: action === "assigned" ? "issue_assigned" : "issue_mention", task: { @@ -516,32 +790,46 @@ export function buildTaskFromEvent( const head = (pullRequest.head as JsonObject | undefined) || {}; const baseRef = (pullRequest.base as JsonObject | undefined) || {}; Object.assign(base, { - state: "ignored", - ignored_reason: "pull_request_review_task_not_in_headless_contract_v1", - trigger: "pull_request", + trigger: "pull_request_revision", target: { - action: payload.action, - number: pullRequest.number, + kind: "pull_request", + pr_number: Number(pullRequest.number || 0), head_sha: head.sha, head_ref: head.ref, + base_sha: baseRef.sha, base_ref: baseRef.ref, }, + task: { + kind: "reconcile_pull_request_revision", + action: payload.action, + pr_number: Number(pullRequest.number || 0), + head_sha: head.sha, + base_sha: baseRef.sha, + }, issue_refs: [...((base.issue_refs as JsonValue[]) || []), "OpenCoven/coven-github#10"], }); return base; } if (eventName === "push") { + const ref = String(payload.ref || ""); + if (!ref.startsWith("refs/heads/") || ref.length <= "refs/heads/".length) { + return ignored(base, "push_without_branch_ref"); + } Object.assign(base, { - state: "ignored", - ignored_reason: "push_review_task_not_in_headless_contract_v1", - trigger: "push", + trigger: "base_branch_revision", target: { ref: payload.ref, before: payload.before, after: payload.after, commit_count: Array.isArray(payload.commits) ? payload.commits.length : 0, }, + task: { + kind: "reconcile_base_branch_push", + base_ref: ref.slice("refs/heads/".length), + before: payload.before, + after: payload.after, + }, issue_refs: [...((base.issue_refs as JsonValue[]) || []), "OpenCoven/coven-github#10"], }); return base; @@ -562,20 +850,79 @@ async function routeDelivery( deliveryId: string, payload: JsonObject, debug: (message: string) => void, +): Promise { + const lock = await acquirePublicationLock(config, `delivery:${deliveryId}`); + try { + return await routeClaimedDelivery(config, eventName, deliveryId, payload, debug); + } finally { + releasePublicationLock(lock); + } +} + +async function routeClaimedDelivery( + config: AdapterConfig, + eventName: string, + deliveryId: string, + payload: JsonObject, + debug: (message: string) => void, ): Promise { const deliveryFile = deliveryPath(config, deliveryId); if (existsSync(deliveryFile)) { - const existing = readJson(deliveryFile, {}); + const existing = readStateJson(deliveryFile, {}); + const existingTaskId = String(existing.task_id || ""); + let action = "duplicate_ignored"; + let task = existingTaskId ? readStateJson(taskPath(config, existingTaskId), {}) : {}; + if (existingTaskId && (["queued", "running"].includes(String(task.state || "")) || revisionReconciliationRecoveryEligible(task))) { + action = "duplicate_task_queued"; + } else if (existingTaskId && publicationRecoveryEligible(task)) { + action = "duplicate_retry_queued"; + } return { ok: true, - action: "duplicate_ignored", + action, delivery_id: deliveryId, task_id: existing.task_id, - state: existing.state, + state: task.state || existing.state, + publication_state: task.publication_state || existing.publication_state, + queued: action === "duplicate_retry_queued" || action === "duplicate_task_queued", }; } const delivery = deliveryRecord(deliveryId, eventName, payload); + const orphanTaskFile = taskPath(config, deliveryId); + if (existsSync(orphanTaskFile)) { + const task = readStateJson(orphanTaskFile, {}); + if (task.delivery_id !== deliveryId || task.delivery_payload_hash !== delivery.payload_hash) { + return { + ok: false, + action: "conflict", + delivery_id: deliveryId, + task_id: task.task_id, + reason: "delivery_task_conflict", + error: "A task exists for this delivery ID but cannot be matched to the signed payload; refusing to overwrite it.", + }; + } + delivery.task_id = task.task_id; + delivery.state = task.state; + delivery.routing_result = task.ignored_reason || "recovered_orphan_task"; + writeJsonAtomic(deliveryFile, delivery); + let action = "duplicate_ignored"; + if (["queued", "running"].includes(String(task.state || "")) || revisionReconciliationRecoveryEligible(task)) { + action = "duplicate_task_queued"; + } else if (publicationRecoveryEligible(task)) { + action = "duplicate_retry_queued"; + } + return { + ok: true, + action, + delivery_id: deliveryId, + task_id: task.task_id, + state: task.state, + publication_state: task.publication_state, + queued: action === "duplicate_retry_queued" || action === "duplicate_task_queued", + recovered_orphan_task: true, + }; + } const [installationId, repoId, policy] = repoPolicy(config, payload); if (!policy) { delivery.state = "ignored"; @@ -591,9 +938,39 @@ async function routeDelivery( }; } + const safetyIssue = nativeReviewReadinessIssue(config, policy); + if (safetyIssue) { + return { + ok: false, + action: "retry", + delivery_id: deliveryId, + reason: "native_review_policy_unsafe", + error: safetyIssue, + }; + } + + const trigger = eventTriggerKey(eventName, payload); + const enabledTriggers = new Set((Array.isArray(policy.enabled_triggers) ? policy.enabled_triggers : []).map(String)); + if (!enabledTriggers.has(trigger)) { + delivery.state = "ignored"; + delivery.routing_result = "trigger_not_enabled"; + delivery.installation_id = installationId; + delivery.repository_id = repoId; + writeJsonAtomic(deliveryFile, delivery); + return { + ok: true, + action: "ignored", + delivery_id: deliveryId, + reason: "trigger_not_enabled", + trigger, + }; + } + const task = buildTaskFromEvent(eventName, deliveryId, payload, policy); + task.delivery_payload_hash = delivery.payload_hash; task.policy_snapshot = { enabled_triggers: policy.enabled_triggers || [], + bot_usernames: policy.bot_usernames || [], publication: policy.publication || {mode: "record_only"}, }; writeJsonAtomic(taskPath(config, String(task.task_id)), task); @@ -603,7 +980,7 @@ async function routeDelivery( delivery.routing_result = task.ignored_reason || "queued"; writeJsonAtomic(deliveryFile, delivery); - if (task.state === "queued") { + if (task.state === "queued" && config.demoMode) { try { await runTask(config, String(task.task_id)); } catch (error) { @@ -611,14 +988,16 @@ async function routeDelivery( } } + const persistedTask = readStateJson(taskPath(config, String(task.task_id)), task); + return { ok: true, action: task.state !== "ignored" ? "accepted" : "ignored", delivery_id: deliveryId, task_id: task.task_id, - state: readJson(taskPath(config, String(task.task_id)), task).state, + state: persistedTask.state, reason: task.ignored_reason, - queued: task.state === "queued", + queued: persistedTask.state === "queued", }; } @@ -628,22 +1007,323 @@ function runCommand(args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeo env, encoding: "utf8", timeout: timeoutSeconds * 1000, + killSignal: "SIGKILL", maxBuffer: 20 * 1024 * 1024, }); return { args, - returncode: proc.status ?? 1, + returncode: proc.status ?? 125, stdout: String(proc.stdout || "").slice(-8000), stderr: `${String(proc.stderr || "")}${proc.error ? String(proc.error.message || proc.error) : ""}`.slice(-8000), + signal: proc.signal || null, + timed_out: (proc.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT", + spawn_error: proc.error ? String(proc.error.message || proc.error) : "", }; } +const MAX_RUNTIME_RESULT_BYTES = 2 * 1024 * 1024; + +function pathContains(root: string, candidate: string): boolean { + const path = relative(root, candidate); + return path === "" || (!path.startsWith(`..${sep}`) && path !== ".." && !isAbsolute(path)); +} + +function runtimeRootfsPath(config: AdapterConfig, sandboxPath: string): string { + if (!isAbsolute(sandboxPath)) { + throw new Error(`Sandbox executable path must be absolute: ${sandboxPath || ""}`); + } + const candidate = resolve(config.runtimeRootfs, `.${sandboxPath}`); + if (!pathContains(resolve(config.runtimeRootfs), candidate)) { + throw new Error(`Sandbox executable escapes COVEN_RUNTIME_ROOTFS: ${sandboxPath}`); + } + return candidate; +} + +function sameFilesystemObject(left: string, right: string): boolean { + const leftStat = statSync(left); + const rightStat = statSync(right); + return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino; +} + +function decodeMountInfoPath(value: string): string { + return value.replace(/\\([0-7]{3})/g, (_match, octal: string) => String.fromCharCode(Number.parseInt(octal, 8))); +} + +function rootfsExposureIssue(config: AdapterConfig, rootfs: string): string | null { + const protectedPaths = [ + config.rootDir, + config.stateDir, + config.policyPath, + config.privateKeyPath, + dirname(config.privateKeyPath), + config.codexTokensPath, + dirname(config.codexTokensPath), + homedir(), + ].filter((path) => existsSync(path)).map((path) => realpathSync(path)); + for (const path of protectedPaths) { + let aliasesProtectedAncestor = false; + for (let ancestor = path; ; ancestor = dirname(ancestor)) { + if (sameFilesystemObject(rootfs, ancestor)) { + aliasesProtectedAncestor = true; + break; + } + const parent = dirname(ancestor); + if (parent === ancestor) break; + } + if (pathContains(rootfs, path) || aliasesProtectedAncestor) { + return "COVEN_RUNTIME_ROOTFS aliases or contains an adapter, state, policy, key, token, or parent-home path"; + } + const mapped = runtimeRootfsPath(config, path); + if (existsSync(mapped)) { + return `COVEN_RUNTIME_ROOTFS contains a protected host path at ${path}`; + } + } + const mountInfoPath = "/proc/self/mountinfo"; + if (!existsSync(mountInfoPath)) { + return "Cannot verify COVEN_RUNTIME_ROOTFS mount topology without /proc/self/mountinfo"; + } + for (const line of readFileSync(mountInfoPath, "utf8").split("\n")) { + if (!line) continue; + const fields = line.split(" "); + if (fields.length < 5) continue; + const mountPoint = decodeMountInfoPath(fields[4]); + if (mountPoint !== rootfs && pathContains(rootfs, mountPoint)) { + return `COVEN_RUNTIME_ROOTFS contains nested mount point ${mountPoint}`; + } + } + return null; +} + +export function runtimeIsolationIssue(config: AdapterConfig): string | null { + if (config.runtimeIsolation !== "bwrap") { + return config.runtimeIsolation === "disabled" + ? "COVEN_RUNTIME_ISOLATION is disabled; configure a verified bwrap rootfs before running real tasks" + : `Unsupported COVEN_RUNTIME_ISOLATION value: ${config.runtimeIsolation}`; + } + if (!config.runtimeExternalIsolationVerified) { + return "COVEN_RUNTIME_EXTERNAL_ISOLATION must confirm externally enforced network-egress and CPU, memory, PID, and disk limits"; + } + if (!isAbsolute(config.bwrapBin) || !existsSync(config.bwrapBin)) { + return `COVEN_BWRAP_BIN is not an available absolute path: ${config.bwrapBin}`; + } + try { + const binary = statSync(config.bwrapBin); + if (!binary.isFile() || !(binary.mode & 0o111)) { + return `COVEN_BWRAP_BIN is not executable: ${config.bwrapBin}`; + } + if (binary.uid !== 0 || (binary.mode & 0o022) !== 0) { + return `COVEN_BWRAP_BIN must be root-owned and not group/world writable: ${config.bwrapBin}`; + } + } catch (error) { + return `COVEN_BWRAP_BIN could not be inspected: ${String((error as Error).message || error)}`; + } + try { + const hostGit = statSync(config.hostGitBin); + if (!isAbsolute(config.hostGitBin) || !hostGit.isFile() || !(hostGit.mode & 0o111) || hostGit.uid !== 0 || (hostGit.mode & 0o022) !== 0) { + return `COVEN_HOST_GIT_BIN must be an absolute, root-owned executable that is not group/world writable: ${config.hostGitBin}`; + } + } catch { + return `COVEN_HOST_GIT_BIN is not available: ${config.hostGitBin}`; + } + if (!config.runtimeRootfs || !isAbsolute(config.runtimeRootfs) || !existsSync(config.runtimeRootfs)) { + return "COVEN_RUNTIME_ROOTFS must name an existing absolute directory"; + } + try { + const rootfs = realpathSync(config.runtimeRootfs); + if (!statSync(rootfs).isDirectory() || rootfs === sep) { + return "COVEN_RUNTIME_ROOTFS must be a dedicated directory, not the host root"; + } + const exposureIssue = rootfsExposureIssue(config, rootfs); + if (exposureIssue) return exposureIssue; + } catch (error) { + return `COVEN_RUNTIME_ROOTFS could not be inspected: ${String((error as Error).message || error)}`; + } + for (const executable of [config.covenCodeBin, config.runtimeGitBin, config.runtimeShellBin, "/bin/true"]) { + if (["/workspace", "/run", "/tmp", "/home", "/proc", "/dev"].some((mount) => executable === mount || executable.startsWith(`${mount}/`))) { + return `Sandbox executable path is shadowed by a writable or synthetic mount: ${executable}`; + } + try { + const hostPath = runtimeRootfsPath(config, executable); + const file = statSync(hostPath); + if (!file.isFile() || !(file.mode & 0o111)) { + return `Sandbox executable is missing or not executable: ${executable}`; + } + } catch { + return `Sandbox executable is missing or not executable: ${executable}`; + } + } + return null; +} + +interface SandboxMounts { + workspace: string; + inputDir: string; + outputDir: string; +} + +export function runtimeSandboxArgs( + config: AdapterConfig, + mounts: SandboxMounts, + command: string[], + network: "none" | "shared" = config.runtimeNetwork, +): string[] { + if (!command.length || !isAbsolute(command[0])) { + throw new Error("Sandbox command must start with an absolute executable path"); + } + const allowedExecutables = new Set([config.covenCodeBin, config.runtimeGitBin, config.runtimeShellBin, "/bin/true"]); + if (!allowedExecutables.has(command[0])) { + throw new Error(`Sandbox command is not an approved rootfs executable: ${command[0]}`); + } + const rootfs = realpathSync(config.runtimeRootfs); + const workspace = realpathSync(mounts.workspace); + const inputDir = realpathSync(mounts.inputDir); + const outputDir = realpathSync(mounts.outputDir); + const exposureIssue = rootfsExposureIssue(config, rootfs); + if (exposureIssue) throw new Error(exposureIssue); + const workspacesRoot = realpathSync(config.workspacesDir); + const attemptsRoot = realpathSync(config.attemptsDir); + if (!pathContains(workspacesRoot, workspace)) { + throw new Error("Sandbox workspace mount must stay inside COVEN_GITHUB_STATE_DIR/workspaces"); + } + if (!pathContains(attemptsRoot, inputDir) || !pathContains(attemptsRoot, outputDir)) { + throw new Error("Sandbox input and output mounts must stay inside COVEN_GITHUB_STATE_DIR/attempts"); + } + if (pathContains(inputDir, workspace) || pathContains(outputDir, workspace) || pathContains(workspace, inputDir) || pathContains(workspace, outputDir)) { + throw new Error("Sandbox input, output, and workspace mounts must not overlap"); + } + + const args = [ + config.bwrapBin, + "--die-with-parent", + "--new-session", + "--unshare-user", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + "--unshare-cgroup-try", + "--cap-drop", "ALL", + "--ro-bind", rootfs, "/", + "--proc", "/proc", + "--dev", "/dev", + "--tmpfs", "/tmp", + "--tmpfs", "/home", + "--dir", "/home/coven", + "--tmpfs", "/run", + "--dir", "/run/coven", + "--dir", "/run/coven/input", + "--dir", "/run/coven/output", + "--dir", "/workspace", + "--ro-bind", inputDir, "/run/coven/input", + "--bind", workspace, "/workspace", + "--bind", outputDir, "/run/coven/output", + ]; + const gitDir = join(workspace, ".git"); + if (existsSync(gitDir) && statSync(gitDir).isDirectory()) { + args.push("--ro-bind", realpathSync(gitDir), "/workspace/.git"); + } + if (network === "none") args.push("--unshare-net"); + args.push( + "--chdir", "/workspace", + "--setenv", "HOME", "/home/coven", + "--setenv", "TMPDIR", "/tmp", + "--setenv", "PATH", "/usr/local/bin:/usr/bin:/bin", + "--", + ...command, + ); + return args; +} + +function runSandboxedCommand( + config: AdapterConfig, + mounts: SandboxMounts, + command: string[], + env: NodeJS.ProcessEnv, + timeoutSeconds: number, + network: "none" | "shared" = config.runtimeNetwork, +): CommandResult { + return runCommand(runtimeSandboxArgs(config, mounts, command, network), undefined, env, timeoutSeconds); +} + +export function readBoundedRuntimeResult(path: string): JsonObject { + let descriptor: number | undefined; + try { + descriptor = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK); + const details = fstatSync(descriptor); + if (!details.isFile()) throw new Error("runtime result is not a regular file"); + if (details.size > MAX_RUNTIME_RESULT_BYTES) { + throw new Error(`runtime result exceeds ${MAX_RUNTIME_RESULT_BYTES} bytes`); + } + const parsed = JSON.parse(readFileSync(descriptor, "utf8")) as JsonValue; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("runtime result is not a JSON object"); + } + return parsed as JsonObject; + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} + function writeAskpass(workDir: string): string { const script = join(workDir, "git-askpass.sh"); writeFileSync(script, "#!/bin/sh\nprintf '%s\\n' \"$COVEN_GIT_TOKEN\"\n", {encoding: "utf8", mode: 0o700}); return script; } +export function probeRuntimeIsolation(config: AdapterConfig, attemptDir: string): string | null { + assertTrustedDirectory(attemptDir, "runtime-isolation attempt directory"); + const probeDir = createFreshChildDirectory(attemptDir, "sandbox-probe", "runtime-isolation probe directory"); + const probeRoot = ensureManagedChildDirectory(config.workspacesDir, ".sandbox-probes", "runtime-isolation probe workspace root"); + const workspace = createFreshChildDirectory( + probeRoot, + `${sha256(attemptDir).slice(0, 24)}-${randomUUID()}`, + "runtime-isolation probe workspace", + ); + const inputDir = createFreshChildDirectory(probeDir, "input", "runtime-isolation probe input"); + const outputDir = createFreshChildDirectory(probeDir, "output", "runtime-isolation probe output"); + try { + const markerPath = join(inputDir, "read-only-marker"); + writeFileSync(markerPath, "unchanged\n", "utf8"); + const probe = runSandboxedCommand( + config, + {workspace, inputDir, outputDir}, + [ + config.runtimeShellBin, + "-c", + [ + "set -eu", + "IFS= read -r marker < /run/coven/input/read-only-marker", + "test \"$marker\" = unchanged", + "if printf tampered > /run/coven/input/read-only-marker 2>/dev/null; then exit 21; fi", + "printf workspace-ok > /workspace/probe", + "printf output-ok > /run/coven/output/probe", + "test ! -e /run/coven/host-state", + ].join("\n"), + ], + { + PATH: "/usr/local/bin:/usr/bin:/bin", + HOME: "/home/coven", + TMPDIR: "/tmp", + }, + 30, + "none", + ); + writeJsonAtomic(join(probeDir, "probe.json"), redactedCommandResult(probe)); + if (probe.returncode !== 0) { + return `bubblewrap isolation probe failed: ${probe.stderr || `exit ${probe.returncode}`}`; + } + if (readFileSync(markerPath, "utf8") !== "unchanged\n") { + return "bubblewrap isolation probe modified a read-only input"; + } + if (readFileSync(join(workspace, "probe"), "utf8") !== "workspace-ok" + || readFileSync(join(outputDir, "probe"), "utf8") !== "output-ok") { + return "bubblewrap isolation probe could not write only to the permitted mounts"; + } + return null; + } finally { + rmSync(workspace, {recursive: true, force: true}); + } +} + function sessionBrief( task: JsonObject, workspace: string, @@ -686,12 +1366,20 @@ function runCovenCodeCycle( extraAuditInstruction?: string, ): CycleResult { const suffix = cycle === 0 ? "" : `-repair-${cycle}`; - const briefPath = join(attemptDir, `session-brief${suffix}.json`); - const resultPath = join(attemptDir, `result${suffix}.json`); + const inputDir = join(attemptDir, `runtime-input${suffix}`); + const outputDir = join(attemptDir, `runtime-output${suffix}`); + mkdirSync(inputDir, {recursive: true}); + mkdirSync(outputDir, {recursive: true}); + const briefPath = join(inputDir, `session-brief${suffix}.json`); + const resultPath = join(outputDir, `result${suffix}.json`); const runPath = join(attemptDir, `run${suffix}.json`); + const sandboxBriefPath = `/run/coven/input/session-brief${suffix}.json`; + const sandboxResultPath = `/run/coven/output/result${suffix}.json`; - writeJsonAtomic(briefPath, sessionBrief(task, workspace, reviewContext, extraAuditInstruction)); - const run = runCommand( + writeJsonAtomic(briefPath, sessionBrief(task, "/workspace", reviewContext, extraAuditInstruction)); + const run = runSandboxedCommand( + config, + {workspace, inputDir, outputDir}, [ config.covenCodeBin, "--headless", @@ -701,22 +1389,36 @@ function runCovenCodeCycle( "--model", config.covenCodeModel, "--context", - briefPath, + sandboxBriefPath, "--output", - resultPath, + sandboxResultPath, ], - workspace, env, 1800, + config.runtimeNetwork, ); writeJsonAtomic(runPath, redactedCommandResult(run)); + let result: JsonObject | null = null; + const acceptableExit = [0, 1, 3].includes(run.returncode) && !run.signal && !run.timed_out && !run.spawn_error; + if (existsSync(resultPath) && acceptableExit) { + try { + result = readBoundedRuntimeResult(resultPath); + } catch (error) { + run.returncode = run.returncode || 1; + run.stderr = `${run.stderr}\nRejected runtime result: ${String((error as Error).message || error)}`.slice(-8000); + writeJsonAtomic(runPath, redactedCommandResult(run)); + } + } else if (existsSync(resultPath)) { + run.stderr = `${run.stderr}\nRejected runtime result because the sandbox did not complete normally.`.slice(-8000); + writeJsonAtomic(runPath, redactedCommandResult(run)); + } return { cycle, brief_path: briefPath, result_path: resultPath, run_path: runPath, run, - result: existsSync(resultPath) ? readJson(resultPath, null) : null, + result, }; } @@ -774,58 +1476,130 @@ function taskWithRepairRequest(task: JsonObject, instruction: string): JsonObjec return copy; } -async function runTask(config: AdapterConfig, taskId: string): Promise { +export async function runTask(config: AdapterConfig, taskId: string): Promise { + const lock = await acquirePublicationLock(config, `execution:${taskId}`); + try { + const path = taskPath(config, taskId); + const task = readStateJson(path, {}); + if (publicationRecoveryEligible(task)) return resumeTaskPublication(config, taskId); + if (revisionReconciliationRecoveryEligible(task)) { + if (!retryDeadlineReached(task)) return task; + task.state = "queued"; + delete task.retry_not_before; + task.updated_at = utcNow(); + writeJsonAtomic(path, task); + } + if (task.state === "running") { + task.state = "queued"; + task.recovered_from_interrupted_worker = true; + task.updated_at = utcNow(); + writeJsonAtomic(path, task); + } + return runTaskUnlocked(config, taskId); + } finally { + releasePublicationLock(lock); + } +} + +async function runTaskUnlocked(config: AdapterConfig, taskId: string): Promise { const path = taskPath(config, taskId); - const task = readJson(path, {}); + const task = readStateJson(path, {}); if (task.state !== "queued") { return task; } + if (["reconcile_pull_request_revision", "reconcile_base_branch_push"].includes(String(((task.task as JsonObject | undefined) || {}).kind || ""))) { + return reconcilePullRequestRevisionTask(config, path, task); + } + + if (!config.demoMode) { + const configurationIssue = runtimeIsolationIssue(config); + if (configurationIssue) { + return blockTask(path, task, "runtime_isolation_unavailable", configurationIssue); + } + } + + const nextAttempt = Number(task.attempts || 0) + 1; task.state = "running"; - task.attempts = Number(task.attempts || 0) + 1; + task.attempts = nextAttempt; task.updated_at = utcNow(); writeJsonAtomic(path, task); + let attemptDir: string; + let workspaceAttemptDir: string; + try { + attemptDir = createFreshTaskAttemptDirectory(config.attemptsDir, taskId, nextAttempt, "task artifact"); + workspaceAttemptDir = createFreshTaskAttemptDirectory(config.workspacesDir, taskId, nextAttempt, "task workspace"); + } catch (error) { + return blockTask(path, task, "state_storage_untrusted", String((error as Error).message || error)); + } + if (!config.demoMode) { + try { + const probeIssue = probeRuntimeIsolation(config, attemptDir); + if (probeIssue) { + return blockTask(path, task, "runtime_isolation_unavailable", probeIssue); + } + } catch (error) { + return blockTask(path, task, "runtime_isolation_unavailable", String((error as Error).stack || error)); + } + task.runtime_isolation = { + mode: "bwrap", + verified: true, + verified_at: utcNow(), + network: config.runtimeNetwork, + }; + } - const attemptDir = join(config.attemptsDir, taskId, String(task.attempts)); - mkdirSync(attemptDir, {recursive: true}); - const workspace = join(config.workspacesDir, taskId, "repo"); + const workspace = join(workspaceAttemptDir, "repo"); try { if (config.demoMode) { return completeDemoTask(path, task, workspace, attemptDir); } - const token = await installationToken(config, task.installation_id); + if (lstatIfPresent(workspace)) { + return failTask(path, task, "workspace_untrusted", "Fresh per-attempt workspace already exists; refusing host Git execution"); + } + task.workspace_path = workspace; + writeJsonAtomic(path, task); + const gitToken = await installationToken( + config, + task.installation_id, + runtimeInstallationTokenRequest(task.repository_id), + ); const askpass = writeAskpass(attemptDir); - const env: NodeJS.ProcessEnv = { - ...process.env, + const gitHome = join(attemptDir, "git-home"); + mkdirSync(gitHome, {recursive: true}); + const gitEnv: NodeJS.ProcessEnv = { + ...sanitizedRuntimeEnvironment(process.env), GIT_ASKPASS: askpass, GIT_TERMINAL_PROMPT: "0", - COVEN_GIT_TOKEN: token, - COVEN_CODE_PROVIDER: "codex", - COVEN_CODE_HOSTED_REVIEW: "1", - HOME: dirname(dirname(config.codexTokensPath)), + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + COVEN_GIT_TOKEN: gitToken, + HOME: gitHome, }; const codexAccessToken = loadCodexAccessToken(config); if (!codexAccessToken) { return failTask(path, task, "codex_auth_missing", `Missing Codex access token at ${config.codexTokensPath}`); } - env.OPENAI_API_KEY = codexAccessToken; - - if (!existsSync(workspace)) { - const clone = runCommand( - ["git", "clone", "--depth", "1", "--branch", String(task.default_branch), String(task.clone_url), workspace], - undefined, - env, - 180, - ); - writeJsonAtomic(join(attemptDir, "clone.json"), redactedCommandResult(clone)); - if (clone.returncode !== 0) { - return failTask(path, task, "clone_failed", clone.stderr); - } + const runtimeEnv = runtimeProcessEnvironment(process.env, codexAccessToken); + + const clone = runCommand( + [config.hostGitBin, "clone", "--depth", "1", "--branch", String(task.default_branch), String(task.clone_url), workspace], + undefined, + gitEnv, + 180, + ); + writeJsonAtomic(join(attemptDir, "clone.json"), redactedCommandResult(clone)); + if (clone.returncode !== 0) { + return failTask(path, task, "clone_failed", clone.stderr); } - const reviewContext = await prepareReviewContext(config, task, workspace, token, env, attemptDir); + const prNumber = prNumberForTask(task); + const reviewContextToken = prNumber + ? await installationToken(config, task.installation_id, reviewContextInstallationTokenRequest(task.repository_id)) + : ""; + const reviewContext = await prepareReviewContext(config, task, workspace, reviewContextToken, gitEnv, attemptDir); if (reviewContext) { const reviewContextPath = join(attemptDir, "review-context.json"); writeJsonAtomic(reviewContextPath, reviewContext); @@ -835,11 +1609,7 @@ async function runTask(config: AdapterConfig, taskId: string): Promise void = (message) => console.log(message), +): string[] { + const ids: string[] = []; + for (const entry of readdirSync(config.tasksDir, {withFileTypes: true})) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + const id = entry.name.slice(0, -5); + if (!validRecordId(id)) continue; + try { + const task = readStateJson(join(config.tasksDir, entry.name), {}); + if (task.state === "queued" || task.state === "running" || publicationRecoveryEligible(task) || revisionReconciliationRecoveryEligible(task)) ids.push(id); + } catch (error) { + debug(`COVEN GITHUB TASK RECOVERY SKIP task_id=${id} unreadable task: ${redactTokenish(String((error as Error).message || error))}`); + } + } + return ids; +} + +export type InstallationTokenProvider = ( + config: AdapterConfig, + task: JsonObject, +) => Promise; + +async function publicationToken(config: AdapterConfig, task: JsonObject): Promise { + return installationToken( + config, + task.installation_id, + publicationInstallationTokenRequest(task.repository_id), + ); +} + +interface RevisionReconciliationResult { + revision: PullRevision; + dismissedIds: number[]; +} + +async function reconcileOnePullRequestRevision( + config: AdapterConfig, + task: JsonObject, + repo: string, + prNumber: number, + token: string, +): Promise { + const lock = await acquirePublicationLock(config, `${repo}#pr:${prNumber}`); + try { + const storedPath = publicationRecordPath(config, repo, prNumber); + const stored = readStateJson(storedPath, {}); + const trust = publicationTrust(config, task, `${repo}#pr:${prNumber}`); + const dismissedIds = new Set(); + let lastRevision: PullRevision = {headSha: "", baseSha: ""}; + for (let pass = 0; pass < 4; pass += 1) { + const currentRevision = await currentPullRevision(repo, prNumber, token); + if (!currentRevision.headSha || !currentRevision.baseSha) { + throw new Error("GitHub did not return the current pull request head and base revisions"); + } + lastRevision = currentRevision; + const reviews = await githubRequestAllPages(`https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews`, token); + const staleReviews = trustedPublications(reviews, trust).filter((review) => { + const reviewId = Number(review.id || review.review_id || 0); + return decisiveReviewState(review.state) + && !dismissedIds.has(reviewId) + && !publicationFreshForRevision(review, currentRevision, stored); + }); + for (const review of staleReviews) { + const dismissed = await dismissReviewForStaleRevision(repo, prNumber, token, review, String(review.body || "")); + const reviewId = Number(dismissed.review.id || review.id || 0); + if (reviewId) dismissedIds.add(reviewId); + if (reviewId && reviewId === Number(stored.review_id || 0)) { + stored.decision = "DISMISSED"; + stored.review_body = dismissed.body; + stored.reconciled_at = utcNow(); + writeJsonAtomic(storedPath, stored); + } + } + const verifiedRevision = await currentPullRevision(repo, prNumber, token); + if (samePullRevision(currentRevision, verifiedRevision)) { + return {revision: verifiedRevision, dismissedIds: [...dismissedIds]}; + } + lastRevision = verifiedRevision; + } + throw new Error(`Pull request revision did not stabilize while reconciling ${repo}#${prNumber} at ${lastRevision.headSha}/${lastRevision.baseSha}`); + } finally { + releasePublicationLock(lock); + } +} + +function revisionReconciliationTask(task: JsonObject): boolean { + const kind = String(((task.task as JsonObject | undefined) || {}).kind || ""); + return ["reconcile_pull_request_revision", "reconcile_base_branch_push"].includes(kind); +} + +export function taskSchedulingClass(config: AdapterConfig, taskId: string): "maintenance" | "compute" { + try { + return revisionReconciliationTask(readStateJson(taskPath(config, taskId), {})) ? "maintenance" : "compute"; + } catch { + return "maintenance"; + } +} + +function revisionReconciliationRecoveryEligible(task: JsonObject): boolean { + return revisionReconciliationTask(task) + && task.state === "failed" + && task.publication_state === "revision_reconciliation_retry_pending"; +} + +function retryDelayMs(task: JsonObject, attempt: number, error?: unknown): number { + const generic = Math.min(MAX_GENERIC_RETRY_DELAY_MS, 5_000 * (2 ** Math.min(10, Math.max(0, attempt - 1)))); + const jitterByte = Number.parseInt(sha256(`${String(task.task_id || "task")}:${attempt}`).slice(0, 2), 16); + const jittered = Math.round(generic * (0.75 + (jitterByte / 255) * 0.5)); + return Math.max(jittered, error instanceof GithubApiError ? error.retryAfterMs : 0); +} + +function persistRetryDeadline(task: JsonObject, attempt: number, error?: unknown): void { + task.retry_not_before = new Date(Date.now() + retryDelayMs(task, attempt, error)).toISOString(); +} + +function retryDeadlineReached(task: JsonObject): boolean { + const deadline = Date.parse(String(task.retry_not_before || "")); + return !Number.isFinite(deadline) || deadline <= Date.now(); +} + +function failRevisionReconciliation(path: string, task: JsonObject, error: unknown): JsonObject { + task.state = "failed"; + task.failure_category = "revision_reconciliation_failed"; + task.failure_detail = redactTokenish(String((error as Error).stack || error)).slice(-4000); + task.publication_state = "revision_reconciliation_retry_pending"; + persistRetryDeadline(task, Number(task.attempts || 1), error); + task.updated_at = utcNow(); + writeJsonAtomic(path, task); + return task; +} + +async function reconcilePullRequestRevisionTask(config: AdapterConfig, path: string, task: JsonObject): Promise { + const taskData = (task.task as JsonObject | undefined) || {}; + const kind = String(taskData.kind || ""); + const repo = String(task.repository || ""); + task.state = "running"; + task.attempts = Number(task.attempts || 0) + 1; + task.updated_at = utcNow(); + writeJsonAtomic(path, task); + try { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) { + throw new Error("A valid repository is required for revision reconciliation"); + } + const publication = (task.publication as JsonObject | undefined) || {}; + if ((publication.mode || "record_only") !== "comment") { + task.state = "completed"; + task.publication_state = "revision_reconciliation_not_required"; + task.updated_at = utcNow(); + writeJsonAtomic(path, task); + return task; + } + const policyIssue = nativeReviewReadinessIssue(config, (task.policy_snapshot as JsonObject | undefined) || {}); + if (policyIssue) throw new Error(policyIssue); + + const token = await publicationToken(config, task); + const results: JsonObject[] = []; + if (kind === "reconcile_base_branch_push") { + const baseRef = String(taskData.base_ref || ""); + if (!baseRef || baseRef.includes("\n") || baseRef.includes("\r")) throw new Error("A valid base branch ref is required for push reconciliation"); + const pulls = await githubRequestAllPages(`https://api.github.com/repos/${repo}/pulls?state=open&base=${encodeURIComponent(baseRef)}`, token); + for (const pull of pulls) { + const prNumber = Number(pull.number || 0); + if (!Number.isSafeInteger(prNumber) || prNumber <= 0) throw new Error("GitHub returned an invalid open pull request number"); + const result = await reconcileOnePullRequestRevision(config, task, repo, prNumber, token); + results.push({ + pr_number: prNumber, + head_sha: result.revision.headSha, + base_sha: result.revision.baseSha, + dismissed_review_ids: result.dismissedIds, + }); + } + task.reconciled_base_ref = baseRef; + } else { + const prNumber = Number(taskData.pr_number || 0); + if (!Number.isSafeInteger(prNumber) || prNumber <= 0) throw new Error("A valid pull request number is required for revision reconciliation"); + const result = await reconcileOnePullRequestRevision(config, task, repo, prNumber, token); + results.push({ + pr_number: prNumber, + head_sha: result.revision.headSha, + base_sha: result.revision.baseSha, + dismissed_review_ids: result.dismissedIds, + }); + } + const dismissedIds = results.flatMap((result) => Array.isArray(result.dismissed_review_ids) ? result.dismissed_review_ids as JsonValue[] : []); + task.state = "completed"; + task.publication_state = dismissedIds.length ? "stale_decisive_reviews_dismissed" : "revision_reconciled_no_stale_reviews"; + task.reconciliation_results = results; + if (results.length === 1) { + task.reconciled_revision = {head_sha: results[0].head_sha, base_sha: results[0].base_sha}; + task.dismissed_review_ids = results[0].dismissed_review_ids; + } + delete task.failure_category; + delete task.failure_detail; + task.updated_at = utcNow(); + writeJsonAtomic(path, task); + return task; + } catch (error) { + return failRevisionReconciliation(path, task, error); + } +} + +function publicationRecoveryEligible(task: JsonObject): boolean { + if (!["completed", "failed"].includes(String(task.state || ""))) return false; + const state = String(task.publication_state || ""); + return state === "publication_pending" || state === "publication_failed"; +} + +export async function resumeTaskPublication( + config: AdapterConfig, + taskId: string, + debug: (message: string) => void = (message) => console.log(message), + tokenProvider: InstallationTokenProvider = publicationToken, +): Promise { + const lock = await acquirePublicationLock(config, `task:${taskId}`); + const path = taskPath(config, taskId); + try { + const task = readStateJson(path, {}); + if (!publicationRecoveryEligible(task)) return task; + if (task.publication_state === "publication_failed" && !retryDeadlineReached(task)) return task; + const isolation = (task.runtime_isolation as JsonObject | undefined) || {}; + if (isolation.mode !== "bwrap" || isolation.verified !== true) { + task.publication_state = "publication_blocked_unverified_runtime"; + task.publication_error = "Publication was blocked because the task lacks a verified runtime-isolation receipt."; + task.updated_at = utcNow(); + writeJsonAtomic(path, task); + return task; + } + const publication = (task.publication as JsonObject | undefined) || {}; + if ((publication.mode || "record_only") !== "comment") { + task.publication_state = "held_for_issue_11_publication_gates"; + task.updated_at = utcNow(); + writeJsonAtomic(path, task); + return task; + } + if (prNumberForTask(task)) { + const policyIssue = nativeReviewReadinessIssue(config, (task.policy_snapshot as JsonObject | undefined) || {}); + if (policyIssue) { + task.publication_state = "publication_blocked_unsafe_policy"; + task.publication_error = policyIssue; + task.updated_at = utcNow(); + writeJsonAtomic(path, task); + return task; + } + } + const resultPath = String(task.result_path || ""); + if (!resultPath || !existsSync(resultPath)) { + task.publication_state = "publication_blocked_missing_result"; + task.publication_error = "The finalized task has no readable result artifact."; + task.updated_at = utcNow(); + writeJsonAtomic(path, task); + return task; + } + let result: JsonObject; + try { + result = readBoundedRuntimeResult(resultPath); + } catch (error) { + task.publication_state = "publication_blocked_invalid_result"; + task.publication_error = `The finalized task result artifact was rejected: ${redactTokenish(String((error as Error).message || error))}`; + task.updated_at = utcNow(); + writeJsonAtomic(path, task); + return task; + } + + task.publication_state = "publication_pending"; + task.publication_attempts = Number(task.publication_attempts || 0) + 1; + delete task.retry_not_before; + task.publication_last_attempt_at = utcNow(); + task.updated_at = utcNow(); + writeJsonAtomic(path, task); + try { + const token = await tokenProvider(config, task); + await publishResultIfConfigured(config, task, resultPath, token, result); + } catch (error) { + task.publication_state = "publication_failed"; + task.publication_error = redactTokenish(String((error as Error).stack || error)); + persistRetryDeadline(task, Number(task.publication_attempts || 1), error); + debug(`COVEN GITHUB PUBLICATION RECOVERY FAIL task_id=${taskId} ${task.publication_error}`); + } + if (task.publication_state === "publication_failed" && !task.retry_not_before) { + persistRetryDeadline(task, Number(task.publication_attempts || 1)); + } else if (task.publication_state !== "publication_failed") { + delete task.retry_not_before; + } + task.updated_at = utcNow(); + writeJsonAtomic(path, task); + return task; + } finally { + releasePublicationLock(lock); + } +} + +export async function recoverPendingPublications( + config: AdapterConfig, + debug: (message: string) => void = (message) => console.log(message), + tokenProvider: InstallationTokenProvider = publicationToken, +): Promise { + let attempted = 0; + for (const entry of readdirSync(config.tasksDir, {withFileTypes: true})) { + if (!entry.isFile() || !entry.name.endsWith(".json")) continue; + const id = entry.name.slice(0, -5); + let task: JsonObject; + try { + task = readStateJson(join(config.tasksDir, entry.name), {}); + } catch (error) { + debug(`COVEN GITHUB PUBLICATION RECOVERY SKIP task_id=${id} unreadable task: ${redactTokenish(String((error as Error).message || error))}`); + continue; + } + if (!publicationRecoveryEligible(task)) continue; + attempted += 1; + try { + await resumeTaskPublication(config, id, debug, tokenProvider); + } catch (error) { + debug(`COVEN GITHUB PUBLICATION RECOVERY SKIP task_id=${id} ${redactTokenish(String((error as Error).stack || error))}`); + } + } + return attempted; +} + +function publicationValidationCommands(task: JsonObject): string[] { + const publication = (task.publication as JsonObject | undefined) || {}; + return (Array.isArray(publication.validation_commands) ? publication.validation_commands : []) + .filter((command): command is string => typeof command === "string" && Boolean(command.trim())) + .map((command) => command.trim()) + .slice(0, 10); +} + +function sandboxScratchMounts(attemptDir: string, workspace: string, name: string): SandboxMounts { + const inputDir = join(attemptDir, `${name}-input`); + const outputDir = join(attemptDir, `${name}-output`); + mkdirSync(inputDir, {recursive: true}); + mkdirSync(outputDir, {recursive: true}); + return {workspace, inputDir, outputDir}; +} + +function validationEnvironment(): NodeJS.ProcessEnv { + return { + PATH: "/usr/local/bin:/usr/bin:/bin", + HOME: "/home/coven", + TMPDIR: "/tmp", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_CONFIG_NOSYSTEM: "1", + GIT_TERMINAL_PROMPT: "0", + }; +} + +function runPublicationValidationChecks(config: AdapterConfig, task: JsonObject, workspace: string, attemptDir: string): void { + const evidence = (task.review_evidence as JsonObject | undefined) || {}; + if (!Object.keys(evidence).length) return; + const publication = (task.publication as JsonObject | undefined) || {}; + const requestedTimeout = Number(publication.validation_timeout_seconds || 300); + const timeoutSeconds = Number.isFinite(requestedTimeout) ? Math.max(10, Math.min(1800, Math.trunc(requestedTimeout))) : 300; + const receipts: JsonObject[] = []; + for (const [index, command] of publicationValidationCommands(task).entries()) { + const mounts = sandboxScratchMounts(attemptDir, workspace, `publication-check-${index + 1}`); + const result = runSandboxedCommand( + config, + mounts, + [config.runtimeShellBin, "-lc", command], + validationEnvironment(), + timeoutSeconds, + "none", + ); + const artifactPath = join(attemptDir, `publication-check-${index + 1}.json`); + writeJsonAtomic(artifactPath, redactedCommandResult(result)); + receipts.push({ + command, + returncode: result.returncode, + stdout_sha256: sha256(result.stdout), + stderr_sha256: sha256(result.stderr), + artifact_path: artifactPath, + completed_at: utcNow(), + }); + } + evidence.host_validation_checks = receipts; + task.review_evidence = evidence; +} + +function refreshPublicationWorkspaceEvidence(config: AdapterConfig, task: JsonObject, workspace: string, attemptDir: string): void { + const evidence = (task.review_evidence as JsonObject | undefined) || {}; + if (!Object.keys(evidence).length) return; + const mounts = sandboxScratchMounts(attemptDir, workspace, "publication-git-evidence"); + const gitPrefix = [config.runtimeGitBin, "-c", "core.fsmonitor=false", "-c", "core.hooksPath=/dev/null"]; + const head = runSandboxedCommand(config, mounts, [...gitPrefix, "rev-parse", "HEAD"], validationEnvironment(), 30, "none"); + const status = runSandboxedCommand(config, mounts, [...gitPrefix, "status", "--porcelain", "--untracked-files=all"], validationEnvironment(), 30, "none"); + evidence.publication_workspace_head_sha = head.returncode === 0 ? head.stdout.trim() : ""; + evidence.publication_workspace_clean = status.returncode === 0 && status.stdout.trim() === ""; + evidence.publication_workspace_evidence = { + head_returncode: head.returncode, + status_returncode: status.returncode, + head_stdout_sha256: sha256(head.stdout), + status_stdout_sha256: sha256(status.stdout), + }; + task.review_evidence = evidence; +} + +function completeDemoTask(path: string, task: JsonObject, workspace: string, attemptDir: string): JsonObject { + mkdirSync(workspace, {recursive: true}); + const briefPath = join(attemptDir, "session-brief.json"); + const resultPath = join(attemptDir, "result.json"); + writeJsonAtomic(briefPath, sessionBrief(task, workspace, null)); + writeJsonAtomic(resultPath, { + contract_version: "2", + status: "success", + summary: "Demo mode accepted a signed GitHub delivery, matched policy, and created a familiar task without external GitHub or coven-code calls.", + files_changed: [], + commits: [], + review: { + mode: "demo", + evidence_status: "signed_delivery_policy_route", + reviewed_files: [], + supporting_files: [], + findings: [], + tests_run: [ + { + command: "COVEN_GITHUB_DEMO_MODE=1 signed issues.labeled delivery", + status: "passed", + output_summary: "Webhook signature verified and example policy routed to familiar task.", }, ], limitations: [ @@ -935,14 +2104,6 @@ function completeDemoTask(path: string, task: JsonObject, workspace: string, att return task; } -function commandExists(command: string): boolean { - return runCommand(["/bin/sh", "-lc", `command -v ${shellQuote(command)}`], undefined, undefined, 10).returncode === 0; -} - -function shellQuote(value: string): string { - return `'${String(value).replaceAll("'", "'\"'\"'")}'`; -} - function prNumberForTask(task: JsonObject): number | null { const taskData = (task.task as JsonObject | undefined) || {}; const target = (task.target as JsonObject | undefined) || {}; @@ -970,9 +2131,9 @@ async function prepareReviewContext( const repo = String(task.repository); const pr = (await githubRequest("GET", `https://api.github.com/repos/${repo}/pulls/${prNumber}`, token)) as JsonObject; - const files = (await githubRequest("GET", `https://api.github.com/repos/${repo}/pulls/${prNumber}/files?per_page=100`, token)) as JsonObject[]; + const files = await githubRequestAllPages(`https://api.github.com/repos/${repo}/pulls/${prNumber}/files`, token); - const fetch = runCommand(["git", "fetch", "--depth", "1", "origin", `pull/${prNumber}/head`], workspace, env, 180); + const fetch = runCommand([config.hostGitBin, "fetch", "--depth", "1", "origin", `pull/${prNumber}/head`], workspace, env, 180); writeJsonAtomic(join(attemptDir, "fetch-pr.json"), redactedCommandResult(fetch)); if (fetch.returncode !== 0) { return { @@ -984,15 +2145,18 @@ async function prepareReviewContext( }; } - const checkout = runCommand(["git", "checkout", "--detach", "FETCH_HEAD"], workspace, env); + const checkout = runCommand([config.hostGitBin, "checkout", "--detach", "FETCH_HEAD"], workspace, env); writeJsonAtomic(join(attemptDir, "checkout-pr.json"), redactedCommandResult(checkout)); - const head = runCommand(["git", "rev-parse", "HEAD"], workspace, env); - const status = runCommand(["git", "status", "--short", "--branch"], workspace, env); + const head = runCommand([config.hostGitBin, "rev-parse", "HEAD"], workspace, env); + const status = runCommand([config.hostGitBin, "status", "--short", "--branch"], workspace, env); writeJsonAtomic(join(attemptDir, "workspace-git.json"), redactedCommandResult({ args: ["git evidence"], returncode: head.returncode === 0 && status.returncode === 0 ? 0 : 1, stdout: `HEAD=${head.stdout.trim()}\n${status.stdout.trim()}`, stderr: head.stderr + status.stderr, + signal: head.signal || status.signal, + timed_out: head.timed_out || status.timed_out, + spawn_error: [head.spawn_error, status.spawn_error].filter(Boolean).join("\n"), })); return { @@ -1022,12 +2186,14 @@ function summarizePr(pr: JsonObject): JsonObject { head_ref: head.ref, head_sha: head.sha, merge_commit_sha: pr.merge_commit_sha, + changed_files: pr.changed_files, }; } function summarizePrFiles(files: JsonObject[]): JsonObject[] { return (files || []).map((item) => { const patch = String(item.patch || ""); + const patchTruncated = patchEvidenceIncomplete(patch, Number(item.additions || 0), Number(item.deletions || 0)); return { filename: item.filename, status: item.status, @@ -1036,11 +2202,27 @@ function summarizePrFiles(files: JsonObject[]): JsonObject[] { changes: item.changes, sha: item.sha, patch: patch.slice(0, 12000), - patch_truncated: patch.length > 12000, + patch_truncated: patch.length > 12000 || patchTruncated, }; }); } +export function patchEvidenceIncomplete(patch: string, expectedAdditions: number, expectedDeletions: number): boolean { + let additions = 0; + let deletions = 0; + let inHunk = false; + for (const line of patch.split("\n")) { + if (line.startsWith("@@")) { + inHunk = true; + continue; + } + if (!inHunk || line.startsWith("\\")) continue; + if (line.startsWith("+")) additions += 1; + else if (line.startsWith("-")) deletions += 1; + } + return additions !== expectedAdditions || deletions !== expectedDeletions; +} + function reviewEvidence(reviewContext: JsonObject, reviewContextPath: string, task: JsonObject): JsonObject { const metadata = (reviewContext.metadata as JsonObject | undefined) || {}; const files = Array.isArray(reviewContext.files) ? (reviewContext.files as JsonObject[]) : []; @@ -1053,17 +2235,1093 @@ function reviewEvidence(reviewContext: JsonObject, reviewContextPath: string, ta head_sha: metadata.head_sha, workspace_head_sha: checkout.workspace_head_sha, changed_file_count: files.length, + expected_changed_file_count: metadata.changed_files, changed_files: files.map((file) => file.filename).filter((file): file is JsonValue => file !== undefined), + changed_file_lines: files.map((file) => ({path: file.filename, ...patchDiffLines(String(file.patch || ""))})), + incomplete_patch_files: files + .filter((file) => file.patch_truncated === true || !String(file.patch || "").trim()) + .map((file) => file.filename) + .filter((file): file is JsonValue => file !== undefined), review_context_path: reviewContextPath, review_context_sha256: fileSha256(reviewContextPath), }; } +function patchDiffLines(patch: string): JsonObject { + const leftLines: number[] = []; + const rightLines: number[] = []; + let leftLine: number | null = null; + let rightLine: number | null = null; + for (const line of patch.split("\n")) { + const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/); + if (hunk) { + leftLine = Number(hunk[1]); + rightLine = Number(hunk[2]); + continue; + } + if (leftLine === null || rightLine === null || line.startsWith("\\")) continue; + if (line.startsWith("+")) { + rightLines.push(rightLine); + rightLine += 1; + } else if (line.startsWith("-")) { + leftLines.push(leftLine); + leftLine += 1; + } else { + leftLines.push(leftLine); + rightLines.push(rightLine); + leftLine += 1; + rightLine += 1; + } + } + return {left_lines: leftLines, right_lines: rightLines}; +} + function fileSha256(path: string): string { return sha256(readFileSync(path)); } -async function publishResultIfConfigured(task: JsonObject, resultPath: string, token: string): Promise { +interface NormalizedReviewPublication { + review: JsonObject; + decision: "APPROVE" | "REQUEST_CHANGES" | "COMMENT"; + inlineComments: JsonObject[]; + validationIssues: string[]; + evidenceComplete: boolean; +} + +function publicationRecordPath(config: AdapterConfig, repo: string, prNumber: number): string { + return join(config.publicationsDir, `${sha256(`${repo}#${prNumber}`).slice(0, 24)}.json`); +} + +interface PublicationLock { + path: string; + owner: string; + heartbeat: NodeJS.Timeout; +} + +interface PublicationLockOwner { + owner: string; + pid: number; + hostname: string; + boot_id: string; + process_start: string; +} + +function linuxBootId(): string { + try { + return readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim(); + } catch { + return ""; + } +} + +function linuxProcessStart(pid: number): string { + try { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" "); + return fields[19] || ""; + } catch { + return ""; + } +} + +function publicationLockOwner(owner: string): PublicationLockOwner { + return { + owner, + pid: process.pid, + hostname: hostname(), + boot_id: linuxBootId(), + process_start: linuxProcessStart(process.pid), + }; +} + +function parsePublicationLockOwner(raw: string): PublicationLockOwner | null { + try { + const parsed = JSON.parse(raw) as PublicationLockOwner; + if (!parsed.owner || !Number.isSafeInteger(parsed.pid) || parsed.pid <= 0 || !parsed.hostname) return null; + return parsed; + } catch { + return null; + } +} + +function publicationLockOwnerAlive(owner: PublicationLockOwner): boolean | null { + if (owner.hostname !== hostname()) return null; + const bootId = linuxBootId(); + if (!bootId || !owner.boot_id || !owner.process_start) return null; + if (owner.boot_id !== bootId) return false; + const processStart = linuxProcessStart(owner.pid); + return processStart ? processStart === owner.process_start : false; +} + +async function acquirePublicationLock(config: AdapterConfig, key: string): Promise { + const path = join(config.publicationsDir, `${sha256(key).slice(0, 24)}.lock`); + const owner = randomUUID(); + const ownerRecord = publicationLockOwner(owner); + const ownerText = `${stableCompactStringify(ownerRecord as unknown as JsonObject)}\n`; + while (true) { + const candidatePath = `${path}.candidate-${owner}`; + try { + mkdirSync(candidatePath, {mode: 0o700}); + const candidateOwnerPath = join(candidatePath, "owner"); + writeFileSync(candidateOwnerPath, ownerText, {encoding: "utf8", flag: "wx", mode: 0o600}); + try { + renameSync(candidatePath, path); + } catch (error) { + rmSync(candidatePath, {recursive: true, force: true}); + if (!["EEXIST", "ENOTEMPTY"].includes(String((error as NodeJS.ErrnoException).code || ""))) throw error; + const contention = new Error("Publication lock already exists") as NodeJS.ErrnoException; + contention.code = "EEXIST"; + throw contention; + } + assertTrustedDirectory(path, "publication lock directory"); + const ownerPath = join(path, "owner"); + const heartbeat = setInterval(() => { + const refreshPath = join(path, `.owner-refresh-${owner}`); + try { + const ownerEntry = lstatIfPresent(ownerPath); + if (!ownerEntry?.isFile() || ownerEntry.isSymbolicLink()) throw new Error("publication lock owner file became untrusted"); + const currentOwner = parsePublicationLockOwner(readFileSync(ownerPath, "utf8")); + if (currentOwner?.owner === owner) { + writeFileSync(refreshPath, ownerText, {encoding: "utf8", flag: "wx", mode: 0o600}); + renameSync(refreshPath, ownerPath); + } + } catch { + clearInterval(heartbeat); + } finally { + if (lstatIfPresent(refreshPath)) rmSync(refreshPath, {force: true}); + } + }, 10_000); + heartbeat.unref(); + return {path, owner, heartbeat}; + } catch (error) { + if (lstatIfPresent(candidatePath)) rmSync(candidatePath, {recursive: true, force: true}); + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + try { + assertTrustedDirectory(path, "existing publication lock directory"); + const ownerPath = join(path, "owner"); + const ownerEntry = lstatIfPresent(ownerPath); + if (ownerEntry && (ownerEntry.isSymbolicLink() || !ownerEntry.isFile())) { + throw new Error("Existing publication lock owner is not a regular file"); + } + const leaseMtime = Number(ownerEntry ? ownerEntry.mtimeMs : lstatSync(path).mtimeMs); + const existingOwner = ownerEntry ? parsePublicationLockOwner(readFileSync(ownerPath, "utf8")) : null; + if (existingOwner && publicationLockOwnerAlive(existingOwner) === false) { + const stalePath = `${path}.stale-${owner}`; + try { + renameSync(path, stalePath); + } catch (renameError) { + if ((renameError as NodeJS.ErrnoException).code === "ENOENT") continue; + throw renameError; + } + rmSync(stalePath, {recursive: true, force: true}); + continue; + } + if (!ownerEntry && Date.now() - leaseMtime > 2 * 60 * 1000) { + const stalePath = `${path}.ownerless-${owner}`; + try { + renameSync(path, stalePath); + } catch (renameError) { + if ((renameError as NodeJS.ErrnoException).code === "ENOENT") continue; + throw renameError; + } + rmSync(stalePath, {recursive: true, force: true}); + continue; + } + if (Date.now() - leaseMtime > 2 * 60 * 1000 && (!existingOwner || publicationLockOwnerAlive(existingOwner) === null)) { + throw new Error(`Publication lock ${basename(path)} is stale but its owner cannot be proven dead; refusing an unsafe automatic takeover.`); + } + } catch (retryError) { + if ((retryError as NodeJS.ErrnoException).code !== "ENOENT") { + if ((retryError as NodeJS.ErrnoException).code !== "EEXIST") throw retryError; + } + continue; + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); + } + } +} + +function releasePublicationLock(lock: PublicationLock): void { + clearInterval(lock.heartbeat); + try { + assertTrustedDirectory(lock.path, "publication lock directory"); + const ownerEntry = lstatIfPresent(join(lock.path, "owner")); + if (!ownerEntry?.isFile() || ownerEntry.isSymbolicLink()) throw new Error("Publication lock owner is not a regular file"); + if (parsePublicationLockOwner(readFileSync(join(lock.path, "owner"), "utf8"))?.owner !== lock.owner) return; + const releasePath = `${lock.path}.release-${lock.owner}`; + renameSync(lock.path, releasePath); + rmSync(releasePath, {recursive: true, force: true}); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } +} + +function publicationIdentity(task: JsonObject, result: JsonObject): string { + const evidence = (task.review_evidence as JsonObject | undefined) || {}; + return sha256(stableCompactStringify({ + task_id: task.task_id, + head_sha: evidence.head_sha, + base_sha: evidence.base_sha, + result, + })); +} + +function legacyPublicationIdentity(task: JsonObject, result: JsonObject): string { + const evidence = (task.review_evidence as JsonObject | undefined) || {}; + return sha256(stableCompactStringify({ + task_id: task.task_id, + head_sha: evidence.head_sha, + result, + })); +} + +function publicationIdentityCandidates(task: JsonObject, result: JsonObject): string[] { + return [...new Set([publicationIdentity(task, result), legacyPublicationIdentity(task, result)])]; +} + +interface PublicationTrust { + signingSecret: string; + verificationSecrets: string[]; + target: string; + botUsernames: Set; +} + +function publicationTrust(config: AdapterConfig, task: JsonObject, target: string): PublicationTrust { + if (!config.publicationSigningSecret) throw new Error("COVEN_PUBLICATION_SIGNING_SECRET or GITHUB_WEBHOOK_SECRET is required to sign publication identities"); + const policy = (task.policy_snapshot as JsonObject | undefined) || {}; + return { + signingSecret: config.publicationSigningSecret, + verificationSecrets: config.publicationVerificationSecrets, + target, + botUsernames: new Set((Array.isArray(policy.bot_usernames) ? policy.bot_usernames : []).map((name) => String(name).toLowerCase())), + }; +} + +function markerCreatedAt(taskCreatedAt?: JsonValue): string { + return typeof taskCreatedAt === "string" && Number.isFinite(Date.parse(taskCreatedAt)) ? taskCreatedAt : ""; +} + +function publicationProof(trust: PublicationTrust, identity: string, createdAt: string, baseSha = "", secret = trust.signingSecret): string { + const material = baseSha + ? `${trust.target}\0${identity}\0${createdAt}\0${baseSha}` + : `${trust.target}\0${identity}\0${createdAt}`; + return createHmac("sha256", secret).update(material).digest("hex"); +} + +function publicationMarker(trust: PublicationTrust, identity: string, taskCreatedAt?: JsonValue, baseShaValue?: JsonValue): string { + const createdAt = markerCreatedAt(taskCreatedAt); + const baseSha = typeof baseShaValue === "string" && /^[A-Za-z0-9._-]{1,128}$/.test(baseShaValue) ? baseShaValue : ""; + return [ + ``, + createdAt ? `` : "", + baseSha ? `` : "", + ``, + ].filter(Boolean).join("\n"); +} + +interface ParsedPublicationMarker { + identity: string; + createdAt: string; + baseSha: string; + proof: string; + raw: string; + index: number; +} + +function publicationMarkerFromBody(item: JsonObject): ParsedPublicationMarker { + const body = String(item.body || ""); + let marker: ParsedPublicationMarker = {identity: "", createdAt: "", baseSha: "", proof: "", raw: "", index: -1}; + const pattern = /\r?\n(?:(?:)\r?\n)?(?:(?:)\r?\n)?/g; + for (const match of body.matchAll(pattern)) { + marker = {identity: match[1] || "", createdAt: match[2] || "", baseSha: match[3] || "", proof: match[4] || "", raw: match[0], index: match.index ?? -1}; + } + return marker; +} + +function publicationIdentityFromBody(item: JsonObject): string { + return publicationMarkerFromBody(item).identity; +} + +function publicationCreatedAtFromBody(item: JsonObject): string { + return publicationMarkerFromBody(item).createdAt; +} + +function publicationBaseFromBody(item: JsonObject): string { + return publicationMarkerFromBody(item).baseSha; +} + +function trustedPublication(item: JsonObject, trust: PublicationTrust): boolean { + const {identity, createdAt, baseSha, proof} = publicationMarkerFromBody(item); + const user = (item.user as JsonObject | undefined) || {}; + const login = String(user.login || "").toLowerCase(); + if (!identity || !proof || user.type !== "Bot" || (trust.botUsernames.size && !trust.botUsernames.has(login))) return false; + const actual = Buffer.from(proof, "hex"); + return trust.verificationSecrets.some((secret) => { + const expected = Buffer.from(publicationProof(trust, identity, createdAt, baseSha, secret), "hex"); + return actual.length === expected.length && timingSafeEqual(actual, expected); + }); +} + +function publicationSignedWithCurrentKey(item: JsonObject, trust: PublicationTrust): boolean { + const {identity, createdAt, baseSha, proof} = publicationMarkerFromBody(item); + if (!identity || !proof) return false; + const actual = Buffer.from(proof, "hex"); + const expected = Buffer.from(publicationProof(trust, identity, createdAt, baseSha), "hex"); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +function resignPublicationBody(item: JsonObject, trust: PublicationTrust): string { + const body = String(item.body || ""); + const marker = publicationMarkerFromBody(item); + if (!marker.identity || marker.index < 0 || !marker.raw) throw new Error("Cannot re-sign a publication without a complete marker"); + const replacement = publicationMarker(trust, marker.identity, marker.createdAt, marker.baseSha); + return `${body.slice(0, marker.index)}${replacement}${body.slice(marker.index + marker.raw.length)}`; +} + +async function resignReviewMarkers(repo: string, prNumber: number, token: string, reviews: JsonObject[], trust: PublicationTrust): Promise { + for (const review of reviews) { + if (publicationSignedWithCurrentKey(review, trust)) continue; + const reviewId = Number(review.id || 0); + if (!reviewId) throw new Error("Cannot re-sign a GitHub review without an ID"); + const body = resignPublicationBody(review, trust); + await githubRequest("PUT", `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews/${reviewId}`, token, {body}); + review.body = body; + } +} + +function trustedPublications(items: JsonObject[], trust: PublicationTrust): JsonObject[] { + return items.filter((item) => trustedPublication(item, trust)); +} + +function publicationWithIdentity(items: JsonObject[], identities: string | string[], trust: PublicationTrust): JsonObject | undefined { + const candidates = new Set(Array.isArray(identities) ? identities : [identities]); + return latestCovencatPublication(trustedPublications(items, trust).filter((item) => candidates.has(publicationIdentityFromBody(item)))); +} + +function latestCovencatPublication(items: JsonObject[]): JsonObject | undefined { + return items.reduce((latest, item) => !latest || publicationGeneration(item) >= publicationGeneration(latest) ? item : latest, undefined); +} + +function publicationGeneration(item: JsonObject): number { + const bodyCreatedAt = publicationCreatedAtFromBody(item); + const created = Date.parse(bodyCreatedAt || String(item.submitted_at || item.published_at || "")); + return Number.isFinite(created) ? created : Number(item.id || 0); +} + +function publicationMatchesRevision(review: JsonObject, revision: PullRevision, stored: JsonObject): boolean { + if (String(review.commit_id || "") !== revision.headSha) return false; + const markerBase = publicationBaseFromBody(review); + if (markerBase) return markerBase === revision.baseSha; + const reviewId = Number(review.id || review.review_id || 0); + if (reviewId === Number(stored.review_id || 0) && stored.base_sha) { + return String(stored.head_sha || "") === revision.headSha && String(stored.base_sha || "") === revision.baseSha; + } + // Legacy signed markers did not record the base SHA. Preserve their + // head-level ordering conservatively until a newer base-aware publication + // replaces them; this prevents an older task from bypassing a deployed + // newer review after an upgrade or local state loss. + return true; +} + +function publicationFreshForRevision(review: JsonObject, revision: PullRevision, stored: JsonObject): boolean { + if (String(review.commit_id || "") !== revision.headSha) return false; + const markerBase = publicationBaseFromBody(review); + if (markerBase) return markerBase === revision.baseSha; + const reviewId = Number(review.id || review.review_id || 0); + return Boolean(reviewId + && reviewId === Number(stored.review_id || 0) + && stored.base_sha + && String(stored.head_sha || "") === revision.headSha + && String(stored.base_sha || "") === revision.baseSha); +} + +function previousCovencatPublication(items: JsonObject[], identities: string | string[], trust: PublicationTrust): JsonObject | undefined { + const candidates = new Set(Array.isArray(identities) ? identities : [identities]); + return latestCovencatPublication(trustedPublications(items, trust).filter((item) => { + const itemIdentity = publicationIdentityFromBody(item); + return itemIdentity && !candidates.has(itemIdentity); + })); +} + +function clearPublicationError(task: JsonObject): void { + delete task.publication_error; +} + +function finishReviewPublication( + task: JsonObject, + submitted: SubmittedReview, + pendingDismissals: JsonObject[], + normalState: string, +): void { + if (pendingDismissals.length) { + task.publication_state = "publication_failed"; + task.publication_error = `The review was published, but ${pendingDismissals.length} prior decisive review dismissal${pendingDismissals.length === 1 ? "" : "s"} remain pending.`; + return; + } + task.publication_state = submitted.staleAfterSubmit + ? "published_review_dismissed_stale" + : submitted.staleEvidence + ? "published_review_stale_comment" + : normalState; + clearPublicationError(task); +} + +function inlineLocationError(error: unknown): boolean { + return error instanceof GithubApiError && error.status === 422 && /(comment|diff|line|position|pullrequestreviewcomment)/i.test(error.responseBody); +} + +function selfReviewError(error: unknown): boolean { + return error instanceof GithubApiError && error.status === 422 && /(?:approve|request changes?).{0,40}(?:own|your) pull request|own pull request.{0,40}(?:approve|request changes?)/i.test(error.responseBody); +} + +function safePublicationText(value: string, maxLength = 60_000): string { + return redactTokenish(value).slice(0, maxLength); +} + +function safeReviewNotice(body: string, notice: string, maxLength = 60_000): string { + const safeBody = redactTokenish(body); + const safeNotice = redactTokenish(notice).trim(); + const marker = publicationMarkerFromBody({body: safeBody}); + const withoutMarker = marker.index >= 0 + ? `${safeBody.slice(0, marker.index)}${safeBody.slice(marker.index + marker.raw.length)}`.trimEnd() + : safeBody.trimEnd(); + const suffix = marker.raw ? `${safeNotice}\n\n${marker.raw}` : safeNotice; + const prefixLength = Math.max(0, maxLength - suffix.length - 2); + const prefix = withoutMarker.slice(0, prefixLength).trimEnd(); + return prefix ? `${prefix}\n\n${suffix}` : suffix.slice(0, maxLength); +} + +function decisiveReviewState(value: JsonValue | undefined): boolean { + return ["APPROVE", "APPROVED", "REQUEST_CHANGES", "CHANGES_REQUESTED"].includes(String(value || "").toUpperCase()); +} + +function priorReviewFromRecord(record: JsonObject): JsonObject { + return { + id: record.previous_review_id, + state: record.previous_decision, + html_url: record.previous_review_url, + identity: record.previous_identity, + }; +} + +function publicationRecord( + task: JsonObject, + identity: string, + review: JsonObject, + decision: JsonValue | undefined, + previous: JsonObject, + pendingDismissals: JsonObject[], + submissionPending = false, +): JsonObject { + return { + identity, + review_id: review.id, + review_url: review.html_url, + decision, + task_id: task.task_id, + task_created_at: task.created_at, + head_sha: ((task.review_evidence as JsonObject | undefined) || {}).head_sha, + base_sha: ((task.review_evidence as JsonObject | undefined) || {}).base_sha, + published_at: review.submitted_at || utcNow(), + previous_identity: previous.identity || publicationIdentityFromBody(previous), + previous_review_id: previous.review_id || previous.id, + previous_review_url: previous.review_url || previous.html_url, + previous_decision: previous.decision || previous.state, + supersession_pending: pendingDismissals.length > 0, + pending_dismissals: pendingDismissals, + submission_pending: submissionPending, + desired_decision: submissionPending ? decision : undefined, + review_body: review.body, + }; +} + +function reviewReference(review: JsonObject): JsonObject { + return { + id: review.review_id || review.id, + state: review.decision || review.state, + html_url: review.review_url || review.html_url, + identity: review.identity || publicationIdentityFromBody(review), + }; +} + +function pendingDismissalsFromRecord(record: JsonObject): JsonObject[] { + const pending = Array.isArray(record.pending_dismissals) + ? record.pending_dismissals.filter((item): item is JsonObject => Boolean(item) && typeof item === "object" && !Array.isArray(item)) + : []; + if (!pending.length && record.supersession_pending === true) { + const legacy = priorReviewFromRecord(record); + if (legacy.id) pending.push(legacy); + } + return pending; +} + +function priorDecisiveReviews(items: JsonObject[], identity: string, trust: PublicationTrust, record: JsonObject): JsonObject[] { + const candidates = [ + ...trustedPublications(items, trust).filter((item) => decisiveReviewState(item.state)), + ...(record.identity && record.identity !== identity && decisiveReviewState(record.decision) ? [record] : []), + ...(decisiveReviewState(record.previous_decision) ? [priorReviewFromRecord(record)] : []), + ...pendingDismissalsFromRecord(record), + ]; + const unique = new Map(); + for (const candidate of candidates) { + const reference = reviewReference(candidate); + const id = Number(reference.id || 0); + if (id && decisiveReviewState(reference.state)) unique.set(id, reference); + } + return [...unique.values()]; +} + +async function reviewAfterAmbiguousDismissal( + repo: string, + prNumber: number, + reviewId: number, + token: string, +): Promise { + try { + const live = (await githubRequest("GET", `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews/${reviewId}`, token)) as JsonObject; + if (Number(live.id || live.review_id || 0) !== reviewId) throw new Error("GitHub returned a mismatched review during dismissal recovery"); + return live; + } catch (error) { + if (!(error instanceof GithubApiError) || error.status !== 404) throw error; + const reviews = await githubRequestAllPages(`https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews`, token); + return reviews.find((candidate) => Number(candidate.id || candidate.review_id || 0) === reviewId) || null; + } +} + +async function reconcilePriorDecisiveReviews( + repo: string, + prNumber: number, + token: string, + previousReviews: JsonObject[], + current: JsonObject, + task: JsonObject, +): Promise { + const dismissalWarning = "_Warning: GitHub did not permit covencat to dismiss the prior decisive review; maintainers should dismiss it manually._"; + const currentReviewId = Number(current.review_id || current.id || 0); + const pending: JsonObject[] = []; + const errors: string[] = []; + let attempted = 0; + for (const previous of previousReviews) { + const previousReviewId = Number(previous.review_id || previous.id || 0); + const previousState = previous.decision || previous.state; + if (!previousReviewId || previousReviewId === currentReviewId || !decisiveReviewState(previousState)) continue; + attempted += 1; + try { + await githubRequest("PUT", `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews/${previousReviewId}/dismissals`, token, { + message: `Superseded by ${String(current.review_url || current.html_url || "a newer covencat review")}`, + event: "DISMISS", + }); + } catch (error) { + let alreadyDismissed = false; + try { + const live = await reviewAfterAmbiguousDismissal(repo, prNumber, previousReviewId, token); + alreadyDismissed = live === null || String(live.state || "").toUpperCase() === "DISMISSED"; + } catch { + alreadyDismissed = false; + } + if (alreadyDismissed) continue; + pending.push(reviewReference(previous)); + errors.push(redactTokenish(String((error as Error).stack || error))); + } + } + if (!attempted) { + delete task.publication_supersession_state; + delete task.publication_supersession_error; + return []; + } + if (!pending.length) { + task.publication_supersession_state = "prior_decisive_review_dismissed"; + delete task.publication_supersession_error; + const currentBody = String(current.body || ""); + if (currentReviewId && currentBody.includes(dismissalWarning)) { + const cleanedBody = currentBody.replace(`\n\n${dismissalWarning}`, "").replace(dismissalWarning, "").trimEnd(); + try { + await githubRequest("PUT", `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews/${currentReviewId}`, token, {body: cleanedBody}); + current.body = cleanedBody; + } catch (error) { + throw new Error(`Prior-review dismissal succeeded but warning cleanup must be retried: ${redactTokenish(String((error as Error).stack || error))}`); + } + } + return []; + } + task.publication_supersession_state = "prior_decisive_review_dismissal_failed"; + task.publication_supersession_error = errors.join("\n"); + if (pending.length) { + const currentBody = String(current.body || ""); + if (currentReviewId && currentBody && !currentBody.includes(dismissalWarning)) { + const warnedBody = safeReviewNotice(currentBody, dismissalWarning); + try { + await githubRequest("PUT", `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews/${currentReviewId}`, token, { + body: warnedBody, + }); + current.body = warnedBody; + } catch (updateError) { + task.publication_supersession_error = redactTokenish(`${task.publication_supersession_error}\n${String((updateError as Error).stack || updateError)}`); + } + } + } + return pending; +} + +async function reconcileReplacementSupersession( + repo: string, + prNumber: number, + token: string, + priorReviews: JsonObject[], + submitted: SubmittedReview, + evidenceComplete: boolean, + task: JsonObject, +): Promise { + if (submitted.staleEvidence || submitted.staleAfterSubmit || !evidenceComplete || !decisiveReviewState(submitted.decision)) { + if (priorReviews.length) { + task.publication_supersession_state = submitted.staleEvidence || submitted.staleAfterSubmit + ? "prior_decisive_review_retained_for_stale_replacement" + : !evidenceComplete + ? "prior_decisive_review_retained_for_incomplete_replacement" + : "prior_decisive_review_retained_for_comment_replacement"; + delete task.publication_supersession_error; + } else { + delete task.publication_supersession_state; + delete task.publication_supersession_error; + } + return []; + } + if (!priorReviews.length) { + delete task.publication_supersession_state; + delete task.publication_supersession_error; + return []; + } + const evidenceRevision = reviewEvidenceRevision(task); + const revisionBeforeDismissal = await currentPullRevision(repo, prNumber, token); + if (!samePullRevision(revisionBeforeDismissal, evidenceRevision)) { + const stale = await dismissReviewForStaleRevision(repo, prNumber, token, submitted.review, submitted.body); + Object.assign(submitted, stale); + task.publication_supersession_state = "prior_decisive_review_retained_for_stale_replacement"; + delete task.publication_supersession_error; + return []; + } + const pending = await reconcilePriorDecisiveReviews(repo, prNumber, token, priorReviews, submitted.review, task); + const revisionAfterDismissal = await currentPullRevision(repo, prNumber, token); + if (!samePullRevision(revisionAfterDismissal, evidenceRevision)) { + const stale = await dismissReviewForStaleRevision(repo, prNumber, token, submitted.review, submitted.body); + Object.assign(submitted, stale); + task.publication_supersession_state = "prior_decisive_reviews_reconciled_before_stale_replacement_dismissal"; + } + return pending; +} + +interface SubmittedReview { + review: JsonObject; + body: string; + decision: string; + staleAfterSubmit: boolean; + staleEvidence: boolean; +} + +interface PullRevision { + headSha: string; + baseSha: string; +} + +function reviewEvidenceRevision(task: JsonObject): PullRevision { + const evidence = (task.review_evidence as JsonObject | undefined) || {}; + return {headSha: String(evidence.head_sha || ""), baseSha: String(evidence.base_sha || "")}; +} + +function samePullRevision(left: PullRevision, right: PullRevision): boolean { + return Boolean(left.headSha && left.baseSha && left.headSha === right.headSha && left.baseSha === right.baseSha); +} + +async function currentPullRevision(repo: string, prNumber: number, token: string): Promise { + const pr = (await githubRequest("GET", `https://api.github.com/repos/${repo}/pulls/${prNumber}`, token)) as JsonObject; + return { + headSha: String(((pr.head as JsonObject | undefined) || {}).sha || ""), + baseSha: String(((pr.base as JsonObject | undefined) || {}).sha || ""), + }; +} + +async function dismissReviewForStaleRevision( + repo: string, + prNumber: number, + token: string, + review: JsonObject, + body: string, +): Promise { + const reviewId = Number(review.id || review.review_id || 0); + if (!reviewId) throw new Error("GitHub did not return an ID for the stale decisive review"); + let liveReview = review; + const alreadyDismissed = String(review.state || review.decision || "").toUpperCase() === "DISMISSED"; + if (!alreadyDismissed) { + try { + await githubRequest("PUT", `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews/${reviewId}/dismissals`, token, { + message: "Dismissed automatically because the PR head or base changed after covencat captured its review evidence.", + event: "DISMISS", + }); + } catch (error) { + try { + const verified = await reviewAfterAmbiguousDismissal(repo, prNumber, reviewId, token); + if (!verified) { + return {review: {...review, state: "DISMISSED"}, body, decision: "DISMISSED", staleAfterSubmit: true, staleEvidence: true}; + } + liveReview = verified; + } catch { + throw error; + } + if (String(liveReview.state || "").toUpperCase() !== "DISMISSED") throw error; + } + } + const staleNotice = "_This decisive review was dismissed automatically because the PR head or base changed after its evidence was captured._"; + const liveBody = String(liveReview.body || body); + const publishedBody = liveBody.includes(staleNotice) ? liveBody : safeReviewNotice(liveBody, staleNotice); + if (publishedBody !== liveBody) { + await githubRequest("PUT", `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews/${reviewId}`, token, {body: publishedBody}); + } + return {review: {...review, ...liveReview, state: "DISMISSED", body: publishedBody}, body: publishedBody, decision: "DISMISSED", staleAfterSubmit: true, staleEvidence: true}; +} + +async function submitPendingReview( + repo: string, + prNumber: number, + token: string, + pendingReview: JsonObject, + desiredDecision: "APPROVE" | "REQUEST_CHANGES" | "COMMENT", + evidenceRevision: PullRevision, + body: string, +): Promise { + const reviewId = Number(pendingReview.id || pendingReview.review_id || 0); + if (!reviewId) throw new Error("GitHub did not return an ID for the pending review"); + let decision: "APPROVE" | "REQUEST_CHANGES" | "COMMENT" = desiredDecision; + let publishedBody = body; + let staleEvidence = false; + const revisionBeforeSubmit = await currentPullRevision(repo, prNumber, token); + if (!samePullRevision(revisionBeforeSubmit, evidenceRevision)) { + staleEvidence = true; + if (decisiveReviewState(decision)) decision = "COMMENT"; + publishedBody = safeReviewNotice(body, "_The PR head or base changed before this review was submitted, so stale evidence was published as COMMENT._"); + } + let response: JsonObject; + try { + response = (await githubRequest("POST", `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews/${reviewId}/events`, token, { + event: decision, + body: publishedBody, + })) as JsonObject; + } catch (error) { + if (!selfReviewError(error) || !decisiveReviewState(decision)) throw error; + decision = "COMMENT"; + publishedBody = safeReviewNotice(body, "_GitHub does not allow the App to submit a decisive review on its own pull request, so this was published as COMMENT._"); + response = (await githubRequest("POST", `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews/${reviewId}/events`, token, { + event: decision, + body: publishedBody, + })) as JsonObject; + } + let review: JsonObject = {...pendingReview, ...response, id: response.id || pendingReview.id, body: publishedBody}; + const revisionAfterSubmit = await currentPullRevision(repo, prNumber, token); + if (!samePullRevision(revisionAfterSubmit, evidenceRevision)) { + if (decisiveReviewState(decision)) { + return dismissReviewForStaleRevision(repo, prNumber, token, review, publishedBody); + } + staleEvidence = true; + publishedBody = safeReviewNotice(publishedBody, "_The PR head or base changed during submission; this COMMENT must not supersede prior decisive review state._"); + await githubRequest("PUT", `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews/${reviewId}`, token, {body: publishedBody}); + review = {...review, body: publishedBody}; + } + return {review, body: publishedBody, decision, staleAfterSubmit: false, staleEvidence}; +} + +async function reconcileSubmittedReviewRevision( + repo: string, + prNumber: number, + token: string, + submitted: SubmittedReview, + evidenceRevision: PullRevision, +): Promise { + const currentRevision = await currentPullRevision(repo, prNumber, token); + if (samePullRevision(currentRevision, evidenceRevision)) return submitted; + if (decisiveReviewState(submitted.decision) || String(submitted.review.state || "").toUpperCase() === "DISMISSED" || submitted.decision === "DISMISSED") { + return dismissReviewForStaleRevision(repo, prNumber, token, submitted.review, submitted.body); + } + return {...submitted, staleEvidence: true}; +} + +function repositoryPath(value: JsonValue | undefined): string | null { + if (typeof value !== "string") return null; + const path = value.trim(); + if (!path || path.includes("\n") || path.includes("\r") || /^\d+:\s/.test(path) || path.startsWith("/") || path.includes("\\") || path.split("/").some((part) => !part || part === "." || part === "..")) { + return null; + } + return path; +} + +function repositoryPathExists(root: string | undefined, path: string): boolean { + if (!root) return true; + try { + const normalizedRoot = realpathSync(root); + const candidate = resolve(normalizedRoot, path); + if (!candidate.startsWith(`${normalizedRoot}${sep}`) || !existsSync(candidate)) return false; + let current = normalizedRoot; + for (const part of path.split("/")) { + current = join(current, part); + const details = lstatSync(current); + if (details.isSymbolicLink()) return false; + } + return statSync(candidate).isFile(); + } catch { + return false; + } +} + +function actionableFinding(finding: JsonObject): boolean { + return !["info", "informational", "nit", "note"].includes(String(finding.severity || "").toLowerCase()) && Boolean(finding.title || finding.body || finding.recommendation); +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function reportsMissingTestExecution(text: string, command = ""): boolean { + const normalized = text.toLowerCase() + .replace(/\b(?:no|zero|0)\s+(?:tests?|testing|checks?|commands?|test\s+suites?|suites?)\s+(?:were\s+)?skipped\b/g, "") + .replace(/\bnone\s+(?:(?:of\s+)?(?:the\s+)?(?:tests?|testing|checks?|commands?|test\s+suites?|suites?)\s+)?(?:were\s+)?skipped\b/g, "") + .replace(/\b(?:tests?|testing|checks?|commands?|test\s+suites?|suites?)\s+(?:were\s+)?not\s+skipped\b/g, ""); + if (!normalized.trim()) return false; + const subject = "(?:tests?|testing|checks?|commands?|test\\s+suites?|suites?)"; + if (new RegExp(`\\b(?:no|zero|0)\\s+${subject}\\b.{0,30}\\b(?:run|executed)\\b`, "i").test(normalized)) return true; + if (new RegExp(`\\b${subject}\\b.{0,60}\\b(?:not|never)\\s+(?:(?:be|been|actually)\\s+)?(?:run|executed)\\b`, "i").test(normalized)) return true; + if (new RegExp(`\\b${subject}\\b.{0,60}\\bskip(?:ped)?\\b`, "i").test(normalized)) return true; + if (new RegExp(`\\b${subject}\\b.{0,60}\\b(?:unable|cannot|can't)\\b.{0,30}\\b(?:run|execute)\\b`, "i").test(normalized)) return true; + if (/\bnot[ _-]?run\b/.test(normalized)) return true; + return Boolean(command) && new RegExp(`${escapeRegExp(command.toLowerCase())}.{0,60}(?:not|never|skip(?:ped)?|unable|cannot|can't).{0,30}(?:run|executed)?`, "i").test(normalized); +} + +function reportsFailedTestExecution(text: string, command = ""): boolean { + const normalized = text.toLowerCase() + .replace(/\b(?:no|zero|0)\s+(?:(?:tests?|checks?|commands?|suites?)\s+)?(?:failed|failing|failures?|errored|errors?)\b/g, "") + .replace(/\bnone\s+(?:(?:of\s+)?(?:the\s+)?(?:tests?|checks?|commands?|suites?)\s+)?(?:failed|failing|errored)\b/g, "") + .replace(/\b(?:tests?|checks?|commands?|suites?)\s+(?:failed|failing|errored)\s*:\s*0\b/g, "") + .replace(/\b(?:failed|failures?|errors?)\s*:\s*0\b/g, "") + .replace(/\bno\s+(?:failures?|errors?)\b/g, ""); + if (!normalized.trim()) return false; + if (/\b[1-9]\d*\s+(?:(?:tests?|checks?|commands?|suites?)\s+)?(?:failed|failing|failures?|errors?)\b/.test(normalized)) return true; + if (/\b[1-9]\d*\s+(?:tests?|checks?|commands?|suites?)\b.{0,30}\bdid\s+not\s+pass\b/.test(normalized)) return true; + if (/\b(?:failures?|errors?)\s*[:=]\s*[1-9]\d*\b/.test(normalized)) return true; + if (/(?:^|\n)\s*(?:fail(?:ed)?|error)\b/m.test(normalized)) return true; + if (/\b(?:tests?|checks?|commands?|suites?)\b.{0,50}\b(?:failed|failing|errored)\b/.test(normalized)) return true; + if (/\b(?:exit(?:ed)?(?:\s+(?:code|status))?|return(?:ed)?(?:\s+(?:code|status))?)\s*[:=]?\s*[1-9]\d*\b/.test(normalized)) return true; + if (/\b(?:exit(?:ed)?|return(?:ed)?)\b.{0,20}\b(?:unsuccessfully|non[- ]zero)\b/.test(normalized)) return true; + return Boolean(command) && new RegExp(`${escapeRegExp(command.toLowerCase())}.{0,50}(?:fail(?:ed|ing)?|errored|unsuccessfully|non[- ]zero|errors?\\s*[:=]\\s*[1-9]\\d*)`, "i").test(normalized); +} + +function validFinding(finding: JsonObject): boolean { + const allowed = new Set(["info", "low", "medium", "high", "critical"]); + const line = finding.line; + return allowed.has(String(finding.severity || "")) + && typeof finding.file === "string" + && (line === null || (typeof line === "number" && Number.isInteger(line) && line >= 1)) + && typeof finding.title === "string" + && typeof finding.body === "string" + && (finding.recommendation === null || typeof finding.recommendation === "string"); +} + +export function normalizeReviewPublication( + task: JsonObject, + result: JsonObject, + currentHeadSha?: string, + repositoryRoot?: string, + currentBaseSha?: string, +): NormalizedReviewPublication { + const review = {...((result.review as JsonObject | undefined) || {})}; + const evidence = (task.review_evidence as JsonObject | undefined) || {}; + const changedFiles = new Set((Array.isArray(evidence.changed_files) ? evidence.changed_files : []) + .map((path) => repositoryPath(path)) + .filter((path): path is string => path !== null)); + const changedLines = new Map; RIGHT: Set}>(); + for (const item of (Array.isArray(evidence.changed_file_lines) ? evidence.changed_file_lines : [])) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const file = repositoryPath((item as JsonObject).path); + const left = (item as JsonObject).left_lines; + const right = (item as JsonObject).right_lines; + if (file) { + changedLines.set(file, { + LEFT: new Set((Array.isArray(left) ? left : []).map(Number).filter((line) => Number.isInteger(line) && line > 0)), + RIGHT: new Set((Array.isArray(right) ? right : []).map(Number).filter((line) => Number.isInteger(line) && line > 0)), + }); + } + } + const validationIssues: string[] = []; + if (String(result.contract_version || "") !== "2") validationIssues.push("result contract_version is not v2"); + if (!["success", "failure", "partial", "needs_input"].includes(String(result.status || ""))) validationIssues.push("result status is invalid"); + if (typeof result.summary !== "string" || typeof result.pr_body !== "string") validationIssues.push("result summary or pr_body is invalid"); + if (!Array.isArray(result.commits) || !Array.isArray(result.files_changed)) validationIssues.push("result commits or files_changed is invalid"); + for (const field of ["reviewed_files", "supporting_files", "findings", "tests_run", "limitations"]) { + if (!Array.isArray(review[field])) validationIssues.push(`review.${field} is missing or invalid`); + } + if (!["none", "pull_request", "review_comment"].includes(String(review.mode || ""))) validationIssues.push("review mode is invalid"); + if (!["not_applicable", "complete", "partial", "missing"].includes(String(review.evidence_status || ""))) validationIssues.push("review evidence_status is invalid"); + if (!("no_findings_reason" in review) || (review.no_findings_reason !== null && typeof review.no_findings_reason !== "string")) validationIssues.push("review no_findings_reason is missing or invalid"); + const reviewedFiles = (Array.isArray(review.reviewed_files) ? review.reviewed_files : []) + .map((path) => repositoryPath(path)); + const supportingFiles = (Array.isArray(review.supporting_files) ? review.supporting_files : []) + .map((path) => repositoryPath(path)); + const suppliedInspectedPresent = Array.isArray(result.files_inspected); + const suppliedInspected = (suppliedInspectedPresent ? result.files_inspected as JsonValue[] : []) + .map((path) => repositoryPath(path)); + const validReviewedFiles = reviewedFiles.filter((path): path is string => path !== null && changedFiles.has(path)); + const invalidReviewedFiles = reviewedFiles.filter((path) => path === null || !changedFiles.has(path)); + const reviewedFileSet = new Set(validReviewedFiles); + if (review.mode !== "pull_request") validationIssues.push("result did not declare pull_request review mode"); + if (review.evidence_status !== "complete") validationIssues.push("review evidence was not marked complete"); + if (result.status !== "success") validationIssues.push("runtime result was not successful"); + if (!String(evidence.head_sha || "").trim()) validationIssues.push("PR head revision is missing"); + if (!String(evidence.base_sha || "").trim()) validationIssues.push("PR base revision is missing"); + if (!String(evidence.workspace_head_sha || "").trim()) validationIssues.push("checked-out revision is missing"); + if (evidence.workspace_head_sha !== evidence.head_sha) validationIssues.push("checked-out revision does not match the captured PR head"); + if (!String(evidence.publication_workspace_head_sha || "").trim()) validationIssues.push("post-run workspace revision is missing"); + if (evidence.publication_workspace_head_sha !== evidence.head_sha) validationIssues.push("post-run workspace revision does not match the captured PR head"); + if (evidence.publication_workspace_clean !== true) validationIssues.push("post-run workspace contains uncommitted changes"); + if (currentHeadSha !== undefined && !currentHeadSha) validationIssues.push("current PR head could not be verified"); + if (currentHeadSha && currentHeadSha !== evidence.head_sha) validationIssues.push("PR head changed after review evidence was captured"); + if (currentBaseSha !== undefined && !currentBaseSha) validationIssues.push("current PR base could not be verified"); + if (currentBaseSha && currentBaseSha !== evidence.base_sha) validationIssues.push("PR base changed after review evidence was captured"); + if (!changedFiles.size) validationIssues.push("no changed-file evidence was captured"); + if (Number(evidence.changed_file_count) !== changedFiles.size) validationIssues.push("changed-file count does not match captured changed files"); + if (Number(evidence.expected_changed_file_count) !== changedFiles.size) validationIssues.push("captured files do not cover the PR changed-file count"); + if (!Array.isArray(evidence.incomplete_patch_files)) validationIssues.push("diff evidence completeness is missing"); + if (Array.isArray(evidence.incomplete_patch_files) && evidence.incomplete_patch_files.length) validationIssues.push("captured diff evidence is incomplete or truncated"); + if (!reviewedFiles.length) validationIssues.push("no reviewed files were reported"); + if (invalidReviewedFiles.length) validationIssues.push("reviewed files are not all changed files in the captured PR revision"); + if ([...changedFiles].some((path) => !reviewedFileSet.has(path))) validationIssues.push("review scope does not cover every changed file"); + const verifiedEvidencePath = (path: string): boolean => changedFiles.has(path) || repositoryPathExists(repositoryRoot, path); + if (suppliedInspected.some((path) => path === null || (path !== null && !verifiedEvidencePath(path)))) validationIssues.push("files_inspected contains an invalid or missing repository path"); + const suppliedInspectedSet = new Set(suppliedInspected.filter((path): path is string => path !== null)); + const validSupportingFiles = supportingFiles.filter((path): path is string => path !== null && repositoryPathExists(repositoryRoot, path)); + const expectedInspectedSet = new Set([...reviewedFileSet, ...validSupportingFiles]); + if (suppliedInspectedPresent && ([...suppliedInspectedSet].some((path) => !expectedInspectedSet.has(path)) || [...expectedInspectedSet].some((path) => !suppliedInspectedSet.has(path)))) { + validationIssues.push("files_inspected does not match reviewed_files plus supporting_files"); + } + if (supportingFiles.some((path) => path === null || (path !== null && !repositoryPathExists(repositoryRoot, path)))) validationIssues.push("supporting_files contains an invalid or missing repository path"); + const limitations = Array.isArray(review.limitations) ? review.limitations.filter((item) => String(item || "").trim()) : []; + if (Array.isArray(review.limitations) && review.limitations.some((item) => typeof item !== "string")) validationIssues.push("review limitations contains an invalid entry"); + if (limitations.length) validationIssues.push("review reported limitations"); + + const hostChecks = (Array.isArray(evidence.host_validation_checks) ? evidence.host_validation_checks : []) + .filter((item): item is JsonObject => Boolean(item) && typeof item === "object" && !Array.isArray(item)); + if (!Array.isArray(evidence.host_validation_checks) || !hostChecks.length) { + validationIssues.push("no host-captured validation checks were recorded"); + } + const successfulHostCommands = new Set(); + for (const check of hostChecks) { + const command = typeof check.command === "string" ? check.command.trim() : ""; + const returncode = check.returncode; + const validReceipt = Boolean(command) + && typeof returncode === "number" + && Number.isInteger(returncode) + && /^[a-f0-9]{64}$/.test(String(check.stdout_sha256 || "")) + && /^[a-f0-9]{64}$/.test(String(check.stderr_sha256 || "")); + if (!validReceipt) { + validationIssues.push("host validation check receipt is malformed"); + continue; + } + if (returncode !== 0) { + validationIssues.push(`host validation check ${command} exited ${returncode}`); + continue; + } + successfulHostCommands.add(command); + } + + const testsRun = (Array.isArray(review.tests_run) ? review.tests_run : []) + .filter((item): item is JsonObject => Boolean(item) && typeof item === "object" && !Array.isArray(item)); + if (Array.isArray(review.tests_run) && testsRun.length !== review.tests_run.length) validationIssues.push("tests_run contains an invalid entry"); + if (!testsRun.length) validationIssues.push("no test execution evidence was reported"); + const normalizedTests: JsonObject[] = []; + for (const test of testsRun) { + const command = String(test.command || "").trim(); + const status = String(test.status || "").toLowerCase(); + const output = String(test.output_summary || "").trim(); + const narrative = `${String(result.summary || "")}\n${String(result.pr_body || "")}`.toLowerCase(); + const validShape = typeof test.command === "string" + && ["passed", "failed", "not_run", "unknown"].includes(status) + && (test.output_summary === null || typeof test.output_summary === "string"); + if (!validShape) validationIssues.push(`test evidence for ${command || "an unnamed command"} is malformed`); + const invalidPass = status === "passed" && ( + !validShape + || !command + || !output + || reportsMissingTestExecution(output, command) + || reportsMissingTestExecution(narrative, command) + || reportsFailedTestExecution(output, command) + || reportsFailedTestExecution(narrative, command) + || !successfulHostCommands.has(command) + ); + if (invalidPass) { + validationIssues.push(`test evidence for ${command || "an unnamed command"} is contradictory or incomplete`); + normalizedTests.push({...test, status: "unverified", output_summary: "Reported as passed, but supporting execution evidence was missing or contradictory."}); + } else { + normalizedTests.push({...test, command: command || "unknown command", status: status || "unknown", output_summary: output}); + if (status !== "passed") validationIssues.push(`test ${command || "an unnamed command"} did not pass`); + } + } + + const findings = (Array.isArray(review.findings) ? review.findings : []) + .filter((item): item is JsonObject => Boolean(item) && typeof item === "object" && !Array.isArray(item)); + if (Array.isArray(review.findings) && findings.length !== review.findings.length) validationIssues.push("findings contains an invalid entry"); + const validFindings = findings.filter(validFinding); + if (validFindings.length !== findings.length) validationIssues.push("findings contains a malformed finding"); + if (findings.length && review.no_findings_reason !== null) validationIssues.push("a no-findings reason was reported alongside findings"); + const scopedFindings = validFindings.filter((finding) => { + const path = repositoryPath(finding.file); + return Boolean(path && changedFiles.has(path)); + }); + if (scopedFindings.length !== validFindings.length) validationIssues.push("findings contains a path outside the verified changed-file set"); + const inlineComments: JsonObject[] = []; + const revisionMatchesEvidence = !(currentHeadSha !== undefined && currentHeadSha !== evidence.head_sha) + && !(currentBaseSha !== undefined && currentBaseSha !== evidence.base_sha); + for (const finding of scopedFindings) { + const path = repositoryPath(finding.file); + const line = Number(finding.line); + const locations = path ? changedLines.get(path) : undefined; + const side = locations?.RIGHT.has(line) ? "RIGHT" : locations?.LEFT.has(line) ? "LEFT" : null; + if (revisionMatchesEvidence && path && side && changedFiles.has(path) && Number.isInteger(line) && line > 0 && actionableFinding(finding)) { + inlineComments.push({ + path, + line, + side, + body: findingCommentBody(finding), + }); + } + } + + if (!findings.length && !String(review.no_findings_reason || "").trim()) validationIssues.push("no-findings review is missing its justification"); + const evidenceComplete = validationIssues.length === 0; + const actionable = scopedFindings.some(actionableFinding); + review.evidence_status = evidenceComplete ? "complete" : "partial"; + review.reviewed_files = validReviewedFiles; + review.supporting_files = validSupportingFiles; + review.findings = findings; + review.tests_run = normalizedTests; + review.limitations = limitations; + return { + review, + decision: evidenceComplete && !findings.length ? "APPROVE" : evidenceComplete && actionable ? "REQUEST_CHANGES" : "COMMENT", + inlineComments, + validationIssues, + evidenceComplete, + }; +} + +function findingCommentBody(finding: JsonObject): string { + const parts = [`**${String(finding.severity || "finding")}**: ${String(finding.title || "Untitled finding")}`]; + if (finding.body) parts.push("", String(finding.body)); + if (finding.recommendation) parts.push("", `Suggested resolution: ${String(finding.recommendation)}`); + return safePublicationText(parts.join("\n"), 6000); +} + +export async function publishResultIfConfigured( + config: AdapterConfig, + task: JsonObject, + resultPath: string, + token: string, + validatedResult?: JsonObject, +): Promise { const publication = (task.publication as JsonObject | undefined) || {}; const mode = publication.mode || "record_only"; if (mode !== "comment") { @@ -1071,28 +3329,253 @@ async function publishResultIfConfigured(task: JsonObject, resultPath: string, t return; } - const result = readJson(resultPath, {}); + const result = validatedResult || readBoundedRuntimeResult(resultPath); const taskData = (task.task as JsonObject | undefined) || {}; - const number = taskData.issue_number || taskData.pr_number; + const prNumber = prNumberForTask(task); + const number = taskData.issue_number || taskData.pr_number || prNumber; if (!number) { task.publication_state = "publication_skipped_no_issue_or_pr_number"; return; } - const body = publicationCommentBody(task, result); const repo = String(task.repository); + const identities = publicationIdentityCandidates(task, result); + const identity = identities[0]; + const publicationLock = await acquirePublicationLock(config, `${repo}#${prNumber ? `pr:${prNumber}` : `issue:${number}`}`); try { - const response = (await githubRequest("POST", `https://api.github.com/repos/${repo}/issues/${Number(number)}/comments`, token, {body})) as JsonObject; - task.publication_state = "published_comment"; - task.publication_url = response.html_url; - task.publication_comment_id = response.id; - } catch (error) { - task.publication_state = "publication_failed"; - task.publication_error = redactTokenish(String((error as Error).stack || error)); + try { + const hasReview = Object.keys((result.review as JsonObject | undefined) || {}).length > 0; + const operationalFailure = ["failure", "needs_input"].includes(String(result.status || "")) && !hasReview; + if (prNumber && !operationalFailure) { + const recordPath = publicationRecordPath(config, repo, prNumber); + const stored = readStateJson(recordPath, {}); + + const target = `${repo}#pr:${prNumber}`; + const trust = publicationTrust(config, task, target); + const currentRevision = await currentPullRevision(repo, prNumber, token); + const evidenceRevision = reviewEvidenceRevision(task); + const reviews = await githubRequestAllPages(`https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews`, token); + const trustedReviews = trustedPublications(reviews, trust); + await resignReviewMarkers(repo, prNumber, token, trustedReviews, trust); + const evidence = (task.review_evidence as JsonObject | undefined) || {}; + const currentRevisionRemote = latestCovencatPublication(trustedReviews.filter((review) => publicationMatchesRevision(review, currentRevision, stored))); + const evidenceRevisionRemote = latestCovencatPublication(trustedReviews.filter((review) => publicationMatchesRevision(review, evidenceRevision, stored))) || {}; + const evidenceRevisionRemoteIdentity = publicationIdentityFromBody(evidenceRevisionRemote); + const taskGeneration = Date.parse(String(task.created_at || "")); + const staleRevision = !samePullRevision(evidenceRevision, currentRevision) + && (Boolean(currentRevisionRemote) + || (String(stored.head_sha || "") === currentRevision.headSha && String(stored.base_sha || "") === currentRevision.baseSha)); + const staleGeneration = evidenceRevisionRemoteIdentity + && !identities.includes(evidenceRevisionRemoteIdentity) + && Number.isFinite(taskGeneration) + && publicationGeneration(evidenceRevisionRemote) > taskGeneration; + const existing = publicationWithIdentity( + reviews.filter((review) => publicationMatchesRevision(review, evidenceRevision, stored)), + identities, + trust, + ); + if (staleRevision || staleGeneration) { + const existingState = String(existing?.state || "").toUpperCase(); + if (existing && existingState === "PENDING") { + await githubRequest("DELETE", `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews/${Number(existing.id)}`, token); + } else if (existing && staleRevision && (decisiveReviewState(existing.state) || existingState === "DISMISSED")) { + const stale = await dismissReviewForStaleRevision(repo, prNumber, token, existing, String(existing.body || "")); + task.publication_review_id = stale.review.id || existing.id; + task.publication_url = stale.review.html_url || existing.html_url; + task.publication_decision = "DISMISSED"; + } + task.publication_state = staleRevision ? "publication_skipped_stale_revision" : "publication_skipped_stale_run"; + task.publication_identity = identity; + clearPublicationError(task); + return; + } + + if (existing && evidenceRevisionRemoteIdentity && !identities.includes(evidenceRevisionRemoteIdentity) && publicationGeneration(evidenceRevisionRemote) >= publicationGeneration(existing)) { + if (String(existing.state || "").toUpperCase() === "PENDING") { + await githubRequest("DELETE", `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews/${Number(existing.id)}`, token); + } + task.publication_state = "publication_skipped_stale_run"; + task.publication_identity = identity; + clearPublicationError(task); + return; + } + const previous = previousCovencatPublication(reviews, identities, trust) + || (!identities.includes(String(stored.identity || "")) ? stored : priorReviewFromRecord(stored)); + const repositoryRoot = String(task.workspace_path || join(config.workspacesDir, String(task.task_id), "repo")); + const normalized = normalizeReviewPublication(task, result, currentRevision.headSha, repositoryRoot, currentRevision.baseSha); + const priorDecisive = priorDecisiveReviews(reviews, identity, trust, stored); + if (existing) { + const recoveredPending = String(existing.state || "").toUpperCase() === "PENDING"; + let submitted = recoveredPending + ? await submitPendingReview(repo, prNumber, token, existing, normalized.decision, evidenceRevision, String(existing.body || "")) + : {review: existing, body: String(existing.body || ""), decision: String(existing.state || normalized.decision), staleAfterSubmit: false, staleEvidence: false}; + if (!recoveredPending) submitted = await reconcileSubmittedReviewRevision(repo, prNumber, token, submitted, evidenceRevision); + const pendingDismissals = await reconcileReplacementSupersession(repo, prNumber, token, priorDecisive, submitted, normalized.evidenceComplete, task); + const record = publicationRecord(task, identity, submitted.review, submitted.decision, previous, pendingDismissals); + writeJsonAtomic(recordPath, record); + finishReviewPublication(task, submitted, pendingDismissals, recoveredPending ? "published_review_recovered" : "publication_skipped_duplicate"); + task.publication_identity = identity; + task.publication_review_id = submitted.review.id; + task.publication_url = submitted.review.html_url; + task.publication_decision = submitted.decision; + return; + } + + const storedRevisionCompatible = (!stored.head_sha || String(stored.head_sha) === evidenceRevision.headSha) + && (!stored.base_sha || String(stored.base_sha) === evidenceRevision.baseSha); + if (identities.includes(String(stored.identity || "")) && stored.review_id && storedRevisionCompatible) { + let current: JsonObject = { + id: stored.review_id, + review_id: stored.review_id, + html_url: stored.review_url, + review_url: stored.review_url, + body: stored.review_body, + state: stored.decision, + }; + let liveState = ""; + try { + const live = await githubRequest("GET", `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews/${Number(stored.review_id)}`, token); + if (!live || typeof live !== "object" || Array.isArray(live)) { + throw new Error("GitHub returned a malformed response for the locally recorded review; refusing an ambiguous resubmission"); + } + if (Number(live.id || 0) !== Number(stored.review_id) + || !identities.includes(publicationIdentityFromBody(live)) + || !trustedPublication(live, trust)) { + throw new Error("The locally recorded GitHub review did not match its signed App publication; refusing an ambiguous resubmission"); + } + current = {...current, ...live, body: live.body || current.body}; + liveState = String(live.state || "").toUpperCase(); + } catch (error) { + if (!(error instanceof GithubApiError) || error.status !== 404) throw error; + throw new Error("The locally recorded GitHub review no longer exists; refusing an ambiguous resubmission"); + } + if (!String(current.body || "")) { + throw new Error("Cannot safely recover a GitHub review without its signed body"); + } + const recoveredPending = liveState ? liveState === "PENDING" : stored.submission_pending === true; + let submitted = recoveredPending + ? await submitPendingReview(repo, prNumber, token, current, normalized.decision, evidenceRevision, String(current.body || "")) + : {review: current, body: String(current.body || ""), decision: String(current.state || stored.decision || normalized.decision), staleAfterSubmit: false, staleEvidence: false}; + if (!recoveredPending) submitted = await reconcileSubmittedReviewRevision(repo, prNumber, token, submitted, evidenceRevision); + const pendingDismissals = await reconcileReplacementSupersession(repo, prNumber, token, priorDecisive, submitted, normalized.evidenceComplete, task); + writeJsonAtomic(recordPath, publicationRecord(task, identity, submitted.review, submitted.decision, previous, pendingDismissals)); + finishReviewPublication(task, submitted, pendingDismissals, "publication_skipped_duplicate"); + task.publication_identity = identity; + task.publication_review_id = submitted.review.id; + task.publication_url = submitted.review.html_url; + task.publication_decision = submitted.decision; + return; + } + + for (const pending of trustedReviews.filter((review) => String(review.state || "").toUpperCase() === "PENDING")) { + await githubRequest("DELETE", `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews/${Number(pending.id)}`, token); + } + let publishedBody = `${safePublicationText(publicationReviewBody(task, result, normalized, previous, identity), 59_700)}\n\n${publicationMarker(trust, identity, task.created_at, evidence.base_sha)}`; + const reviewPayload: JsonObject = {body: publishedBody, commit_id: evidence.head_sha}; + if (normalized.inlineComments.length) reviewPayload.comments = normalized.inlineComments; + let pendingReview: JsonObject; + try { + pendingReview = (await githubRequest("POST", `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews`, token, reviewPayload)) as JsonObject; + } catch (error) { + if (!normalized.inlineComments.length || !inlineLocationError(error)) throw error; + publishedBody = safeReviewNotice(publishedBody, "_Inline publication was unavailable; findings are included above._"); + pendingReview = (await githubRequest("POST", `https://api.github.com/repos/${repo}/pulls/${prNumber}/reviews`, token, { + body: publishedBody, + commit_id: evidence.head_sha, + })) as JsonObject; + } + pendingReview = {...pendingReview, body: publishedBody, state: pendingReview.state || "PENDING"}; + writeJsonAtomic(recordPath, publicationRecord(task, identity, pendingReview, normalized.decision, previous, priorDecisive, true)); + const submitted = await submitPendingReview(repo, prNumber, token, pendingReview, normalized.decision, evidenceRevision, publishedBody); + const pendingDismissals = await reconcileReplacementSupersession(repo, prNumber, token, priorDecisive, submitted, normalized.evidenceComplete, task); + finishReviewPublication(task, submitted, pendingDismissals, "published_review"); + task.publication_identity = identity; + task.publication_review_id = submitted.review.id; + task.publication_url = submitted.review.html_url; + task.publication_decision = submitted.decision; + writeJsonAtomic(recordPath, publicationRecord(task, identity, submitted.review, submitted.decision, previous, pendingDismissals)); + return; + } + + const issueTrust = publicationTrust(config, task, `${repo}#issue:${Number(number)}`); + const body = `${safePublicationText(publicationCommentBody(task, result, "Coven task result"), 59_700)}\n\n${publicationMarker(issueTrust, identity, task.created_at)}`; + if (identities.includes(String(task.publication_identity || "")) && task.publication_comment_id) { + try { + const candidateResponse = await githubRequest("GET", `https://api.github.com/repos/${repo}/issues/comments/${Number(task.publication_comment_id)}`, token); + if (!candidateResponse || typeof candidateResponse !== "object" || Array.isArray(candidateResponse)) { + throw new Error("GitHub returned a malformed response for the locally recorded issue comment; refusing an ambiguous republication"); + } + const candidate = candidateResponse as JsonObject; + if (Number(candidate.id || 0) !== Number(task.publication_comment_id)) { + throw new Error("GitHub returned a mismatched issue comment for the locally recorded publication"); + } + if (identities.includes(publicationIdentityFromBody(candidate)) && trustedPublication(candidate, issueTrust)) { + if (!publicationSignedWithCurrentKey(candidate, issueTrust)) { + const resignedBody = resignPublicationBody(candidate, issueTrust); + await githubRequest("PATCH", `https://api.github.com/repos/${repo}/issues/comments/${Number(candidate.id || task.publication_comment_id)}`, token, {body: resignedBody}); + candidate.body = resignedBody; + } + task.publication_state = "publication_skipped_duplicate"; + task.publication_url = candidate.html_url || task.publication_url; + task.publication_comment_id = candidate.id || task.publication_comment_id; + clearPublicationError(task); + return; + } + } catch (error) { + if (!(error instanceof GithubApiError) || error.status !== 404) throw error; + } + } + const comments = await githubRequestAllPages(`https://api.github.com/repos/${repo}/issues/${Number(number)}/comments`, token); + const existing = publicationWithIdentity(comments, identities, issueTrust); + if (existing) { + if (!publicationSignedWithCurrentKey(existing, issueTrust)) { + const resignedBody = resignPublicationBody(existing, issueTrust); + await githubRequest("PATCH", `https://api.github.com/repos/${repo}/issues/comments/${Number(existing.id)}`, token, {body: resignedBody}); + existing.body = resignedBody; + } + task.publication_state = "publication_skipped_duplicate"; + task.publication_identity = identity; + task.publication_url = existing.html_url; + task.publication_comment_id = existing.id; + clearPublicationError(task); + return; + } + const response = (await githubRequest("POST", `https://api.github.com/repos/${repo}/issues/${Number(number)}/comments`, token, {body})) as JsonObject; + task.publication_state = "published_comment"; + task.publication_identity = identity; + task.publication_url = response.html_url; + task.publication_comment_id = response.id; + clearPublicationError(task); + } catch (error) { + task.publication_state = "publication_failed"; + task.publication_error = redactTokenish(String((error as Error).stack || error)); + } + } finally { + releasePublicationLock(publicationLock); } } -function publicationCommentBody(task: JsonObject, result: JsonObject): string { +function publicationReviewBody(task: JsonObject, result: JsonObject, normalized: NormalizedReviewPublication, previous: JsonObject, identity: string): string { + const renderedResult = normalized.evidenceComplete + ? {...result, review: normalized.review} + : { + ...result, + summary: "The runtime review output was downgraded because its publication evidence was incomplete or contradictory.", + pr_body: "", + review: normalized.review, + }; + const body = publicationCommentBody(task, renderedResult, "Coven review"); + const additions: string[] = []; + const previousUrl = previous.review_url || previous.html_url; + if (previousUrl && previous.identity !== identity) { + additions.push(`This review follows [the prior covencat publication](${String(previousUrl)}). A decisive submission replaces its state; a COMMENT does not.`); + } + if (normalized.validationIssues.length) additions.push(`### Publication validation\n- ${normalized.validationIssues.join("\n- ")}\n\nEvidence was incomplete or contradictory, so this is a COMMENT review rather than an approval or change request.`); + if (normalized.review.findings && Array.isArray(normalized.review.findings) && normalized.inlineComments.length < normalized.review.findings.length) additions.push("### Findings without valid inline locations\nThe structured findings above remain part of this review body because their file/line locations could not be safely attached to the current diff."); + return [body, ...additions].join("\n\n"); +} + +function publicationCommentBody(task: JsonObject, result: JsonObject, heading = "Coven task result"): string { const status = result.status || "unknown"; const summary = String(result.summary || "No summary returned."); const prBody = String(result.pr_body || ""); @@ -1101,7 +3584,7 @@ function publicationCommentBody(task: JsonObject, result: JsonObject): string { const taskId = String(task.task_id || ""); const evidence = (task.review_evidence as JsonObject | undefined) || {}; const review = (result.review as JsonObject | undefined) || {}; - const parts = ["## Cody dogfood result", "", `**Status:** ${status}`, "", summary.trim()]; + const parts = [`## ${heading}`, "", `**Status:** ${status}`, "", summary.trim()]; if (prBody.trim() && prBody.trim() !== summary.trim()) { parts.push("", prBody.trim()); } @@ -1122,13 +3605,13 @@ function publicationCommentBody(task: JsonObject, result: JsonObject): string { } else { parts.push("- No PR review evidence was captured for this run."); } - parts.push(...structuredReviewLines(review)); + parts.push(...structuredReviewLines(review, task)); parts.push( "", `**Files changed:** ${filesChanged.length}`, `**Commits:** ${commits.length}`, "", - `_Task \`${taskId}\`. Publication is enabled on the hosted test adapter only._`, + `_Task \`${taskId}\`._`, ); return parts.join("\n"); } @@ -1148,7 +3631,19 @@ function reviewFixLoopLines(task: JsonObject): string[] { return lines; } -function structuredReviewLines(review: JsonObject): string[] { +function githubFileMarkdown(task: JsonObject, raw: JsonValue): string { + const match = String(raw).match(/^(.*?)(?::(\d+))?$/); + const path = repositoryPath(match?.[1]); + const evidence = (task.review_evidence as JsonObject | undefined) || {}; + const ref = String(evidence.head_sha || "").trim(); + const repo = String(task.repository || "").trim(); + if (!path || !ref || !repo || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) return `\`${String(raw)}\``; + const encodedPath = path.split("/").map(encodeURIComponent).join("/"); + const anchor = match?.[2] ? `#L${match[2]}` : ""; + return `[\`${String(raw)}\`](https://github.com/${repo}/blob/${encodeURIComponent(ref)}/${encodedPath}${anchor})`; +} + +function structuredReviewLines(review: JsonObject, task?: JsonObject): string[] { if (!Object.keys(review).length) { return ["", "### Structured review", "- No structured review result was emitted."]; } @@ -1162,7 +3657,7 @@ function structuredReviewLines(review: JsonObject): string[] { const reviewedFiles = Array.isArray(review.reviewed_files) ? review.reviewed_files : []; lines.push(`- Reviewed files: ${reviewedFiles.length}`); if (reviewedFiles.length) { - lines.push(`- Reviewed file list: ${reviewedFiles.slice(0, 20).map((path) => `\`${path}\``).join(", ")}`); + lines.push(`- Reviewed file list: ${reviewedFiles.slice(0, 20).map((path) => task ? githubFileMarkdown(task, path) : `\`${path}\``).join(", ")}`); if (reviewedFiles.length > 20) { lines.push("- Reviewed file list truncated after 20 entries."); } @@ -1170,22 +3665,25 @@ function structuredReviewLines(review: JsonObject): string[] { const supportingFiles = Array.isArray(review.supporting_files) ? review.supporting_files : []; lines.push(`- Supporting files inspected: ${supportingFiles.length}`); if (supportingFiles.length) { - lines.push(`- Supporting file list: ${supportingFiles.slice(0, 20).map((path) => `\`${path}\``).join(", ")}`); + lines.push(`- Supporting file list: ${supportingFiles.slice(0, 20).map((path) => task ? githubFileMarkdown(task, path) : `\`${path}\``).join(", ")}`); if (supportingFiles.length > 20) { lines.push("- Supporting file list truncated after 20 entries."); } } const findings = Array.isArray(review.findings) ? (review.findings as JsonObject[]) : []; lines.push(`- Findings: ${findings.length}`); - findings.slice(0, 10).forEach((finding, index) => { + findings.slice(0, 40).forEach((finding, index) => { let location = String(finding.file || "unknown file"); if (finding.line !== undefined && finding.line !== null) { location = `${location}:${finding.line}`; } lines.push(` ${index + 1}. \`${finding.severity || "unknown"}\` ${location} - ${finding.title || "Untitled finding"}`); + if (finding.body) lines.push(` - ${safePublicationText(String(finding.body), 700)}`); + if (finding.recommendation) lines.push(` - Suggested resolution: ${safePublicationText(String(finding.recommendation), 500)}`); }); - if (findings.length > 10) { - lines.push("- Findings truncated after 10 entries."); + if (findings.length > 40) { + const omitted = findings.length - 40; + lines.push(`- ${omitted} additional finding${omitted === 1 ? " was" : "s were"} omitted because the GitHub review body is size-limited; inspect the persisted result artifact for the complete set.`); } if (review.no_findings_reason) { lines.push(`- No-findings reason: ${review.no_findings_reason}`); @@ -1250,23 +3748,43 @@ function redactedCommandResult(result: CommandResult): JsonObject { }; } -function redactTokenish(text: string): string { +export function sanitizedRuntimeEnvironment(source: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const key of [ + "PATH", "LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ", + "TMPDIR", "TEMP", "TMP", "SystemRoot", "SYSTEMROOT", "WINDIR", + "COMSPEC", "PATHEXT", "USERPROFILE", + ]) { + if (source[key] !== undefined) env[key] = source[key]; + } + return env; +} + +export function runtimeProcessEnvironment(source: NodeJS.ProcessEnv, codexAccessToken: string): NodeJS.ProcessEnv { + return { + ...sanitizedRuntimeEnvironment(source), + PATH: "/usr/local/bin:/usr/bin:/bin", + HOME: "/home/coven", + TMPDIR: "/tmp", + GIT_TERMINAL_PROMPT: "0", + COVEN_CODE_PROVIDER: "codex", + COVEN_CODE_HOSTED_REVIEW: "1", + OPENAI_API_KEY: codexAccessToken, + }; +} + +export function redactTokenish(text: string): string { if (!text) { return text; } - const markers = ["ghs_", "ghu_", "github_pat_", "x-access-token:"]; - let redacted = text; - for (const marker of markers) { - while (redacted.includes(marker)) { - const index = redacted.indexOf(marker); - let end = index + marker.length; - while (end < redacted.length && !" \n\r\t'\"".includes(redacted[end])) { - end += 1; - } - redacted = `${redacted.slice(0, index)}${marker}[redacted]${redacted.slice(end)}`; - } - } - return redacted; + return text + .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[redacted private key]") + .replace(/\b(?:gh[pousr]_|github_pat_)[A-Za-z0-9_-]{6,}/g, "[redacted github token]") + .replace(/\bsk-(?:proj-)?[A-Za-z0-9_-]{8,}/g, "[redacted OpenAI token]") + .replace(/\bBearer\s+[^\s'\"]+/gi, "Bearer [redacted]") + .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g, "[redacted JWT]") + .replace(/x-access-token:[^@\s'\"]+/gi, "x-access-token:[redacted]") + .replace(/(https?:\/\/)[^/\s:@]+:[^@\s/]+@/gi, "$1[redacted]@"); } function failTask(path: string, task: JsonObject, reason: string, detail: string): JsonObject { @@ -1277,3 +3795,13 @@ function failTask(path: string, task: JsonObject, reason: string, detail: string writeJsonAtomic(path, task); return task; } + +function blockTask(path: string, task: JsonObject, reason: string, detail: string): JsonObject { + task.state = "blocked"; + task.failure_category = reason; + task.failure_detail = redactTokenish(String(detail)).slice(-4000); + task.publication_state = "not_started"; + task.updated_at = utcNow(); + writeJsonAtomic(path, task); + return task; +} diff --git a/src/server.ts b/src/server.ts index a6da34b..6d96180 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,8 @@ import {createServer, type IncomingHttpHeaders} from "node:http"; import {pathToFileURL} from "node:url"; +import {fork} from "node:child_process"; -import {createConfig, handleRequest, type AdapterConfig} from "./adapter.js"; +import {createConfig, handleRequest, redactTokenish, runnableTaskIds, taskSchedulingClass, type AdapterConfig} from "./adapter.js"; function headersToMap(headers: IncomingHttpHeaders): Map { const map = new Map(); @@ -29,21 +30,152 @@ async function readBody(req: NodeJS.ReadableStream, limit: number): Promise { - const rawBody = await readBody(req, config.maxWebhookBodyBytes + 1); - const response = await handleRequest(config, { - method: req.method || "GET", - path: req.url?.split("?")[0] || "/", - headers: headersToMap(req.headers), - rawBody, - }); - const body = Buffer.from(JSON.stringify(response.body)); - res.statusCode = response.status; - res.setHeader("Content-Type", "application/json"); - res.setHeader("Content-Length", String(body.length)); - res.end(body); +export type TaskScheduler = (taskId: string) => void; + +export interface TaskWorkerSchedulerOptions { + workerUrl?: URL; + retryBaseMs?: number; + retryMaxMs?: number; +} + +export function createWorkerTaskScheduler(config: AdapterConfig, options: TaskWorkerSchedulerOptions = {}): TaskScheduler { + type LaneName = "maintenance" | "compute"; + const lanes: Record; active: boolean}> = { + maintenance: {pending: new Set(), active: false}, + compute: {pending: new Set(), active: false}, + }; + const crashRetries = new Map(); + const retryTimers = new Map(); + const workerUrl = options.workerUrl || new URL(import.meta.url.endsWith(".ts") ? "./task-worker.ts" : "./task-worker.js", import.meta.url); + const retryBaseMs = Math.max(1, options.retryBaseMs || 5_000); + const retryMaxMs = Math.max(retryBaseMs, options.retryMaxMs || 60 * 60 * 1000); + + const enqueue = (taskId: string): void => { + const laneName = taskSchedulingClass(config, taskId); + lanes[laneName].pending.add(taskId); + void drain(laneName); + }; + + const drain = async (laneName: LaneName): Promise => { + const lane = lanes[laneName]; + if (lane.active) return; + lane.active = true; + try { + while (lane.pending.size) { + const taskId = lane.pending.values().next().value as string; + lane.pending.delete(taskId); + try { + await new Promise((resolveWorker) => { + const worker = fork(workerUrl, [], {stdio: ["ignore", "inherit", "inherit", "ipc"]}); + let settled = false; + let retryableAttempt = 0; + let retryNotBefore = ""; + let retryCategory = ""; + worker.on("message", (message) => { + const result = (message && typeof message === "object" ? message : {}) as Record; + if (result.publication_state === "revision_reconciliation_retry_pending" || result.publication_state === "publication_failed") { + retryableAttempt = Number(result.publication_attempts || result.attempts || 1); + retryNotBefore = String(result.retry_not_before || ""); + retryCategory = String(result.failure_category || result.publication_state || "retryable_failure"); + } + }); + const finish = (code: number | null, detail = "") => { + if (settled) return; + settled = true; + if (code !== 0) { + console.error(`coven-github task worker exited task_id=${taskId} code=${code ?? "spawn-error"}${detail ? ` ${detail}` : ""}`); + const retries = (crashRetries.get(taskId) || 0) + 1; + crashRetries.set(taskId, retries); + if (!retryTimers.has(taskId)) { + const delay = Math.min(retryMaxMs, retryBaseMs * (2 ** Math.min(10, Math.max(0, retries - 1)))); + console.error(`coven-github crashed task retry scheduled task_id=${taskId} retry_at=${new Date(Date.now() + delay).toISOString()}`); + const timer = setTimeout(() => { + retryTimers.delete(taskId); + enqueue(taskId); + }, delay); + timer.unref(); + retryTimers.set(taskId, timer); + } + } else { + crashRetries.delete(taskId); + if (retryableAttempt > 0 && !retryTimers.has(taskId)) { + const persistedDelay = Date.parse(retryNotBefore) - Date.now(); + const delay = Number.isFinite(persistedDelay) && persistedDelay > 0 + ? persistedDelay + : Math.min(retryMaxMs, retryBaseMs * (2 ** Math.min(10, Math.max(0, retryableAttempt - 1)))); + console.error(`coven-github task retry scheduled task_id=${taskId} category=${retryCategory} retry_at=${new Date(Date.now() + delay).toISOString()}`); + const timer = setTimeout(() => { + retryTimers.delete(taskId); + enqueue(taskId); + }, delay); + timer.unref(); + retryTimers.set(taskId, timer); + } + } + resolveWorker(); + }; + worker.once("error", (error) => { + finish(null, redactTokenish(String(error.stack || error))); + }); + worker.once("exit", (code) => { + finish(code); + }); + worker.send({config, taskId}, (error) => { + if (error) { + worker.kill("SIGKILL"); + finish(null, `IPC failed: ${redactTokenish(String(error.stack || error))}`); + } + }); + }); + } catch (error) { + console.error(`coven-github task worker could not start task_id=${taskId}: ${redactTokenish(String((error as Error).stack || error))}`); + } + } + } finally { + lane.active = false; + if (lane.pending.size) void drain(laneName); + } + }; + return (taskId: string) => { + const retryTimer = retryTimers.get(taskId); + if (retryTimer) { + clearTimeout(retryTimer); + retryTimers.delete(taskId); + } + enqueue(taskId); + }; +} + +export function createWebhookServer(config: AdapterConfig = createConfig(), scheduleTask: TaskScheduler = createWorkerTaskScheduler(config)) { + const server = createServer(async (req, res) => { + try { + const rawBody = await readBody(req, config.maxWebhookBodyBytes + 1); + const response = await handleRequest(config, { + method: req.method || "GET", + path: req.url?.split("?")[0] || "/", + headers: headersToMap(req.headers), + rawBody, + }); + const body = Buffer.from(JSON.stringify(response.body)); + res.statusCode = response.status; + res.setHeader("Content-Type", "application/json"); + res.setHeader("Content-Length", String(body.length)); + res.end(body); + if (response.body.queued === true && response.body.task_id) scheduleTask(String(response.body.task_id)); + } catch (error) { + console.error(`coven-github webhook request failed: ${redactTokenish(String((error as Error).stack || error))}`); + if (res.writableEnded || res.destroyed) return; + const body = Buffer.from(JSON.stringify({ok: false, error: "internal server error"})); + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + res.setHeader("Content-Length", String(body.length)); + res.end(body); + } + }); + server.once("listening", () => { + for (const taskId of runnableTaskIds(config)) scheduleTask(taskId); }); + return server; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/src/task-worker.ts b/src/task-worker.ts new file mode 100644 index 0000000..1c2226a --- /dev/null +++ b/src/task-worker.ts @@ -0,0 +1,33 @@ +import {redactTokenish, runTask, type AdapterConfig} from "./adapter.js"; + +interface TaskWorkerData { + config: AdapterConfig; + taskId: string; +} + +if (!process.send) throw new Error("coven-github task-worker requires a parent IPC channel"); + +process.once("message", (message) => { + void (async () => { + const {config, taskId} = message as TaskWorkerData; + try { + const task = await runTask(config, taskId); + await new Promise((resolveSend) => process.send?.({ + ok: true, + task_id: taskId, + state: task.state, + publication_state: task.publication_state, + attempts: task.attempts, + publication_attempts: task.publication_attempts, + retry_not_before: task.retry_not_before, + failure_category: task.failure_category, + }, () => resolveSend()) || resolveSend()); + } catch (error) { + const detail = redactTokenish(String((error as Error).stack || error)); + await new Promise((resolveSend) => process.send?.({ok: false, task_id: taskId, error: detail}, () => resolveSend()) || resolveSend()); + process.exitCode = 1; + } finally { + if (process.connected) process.disconnect(); + } + })(); +}); diff --git a/tests/webhook-adapter.test.ts b/tests/webhook-adapter.test.ts index 4488c99..deffb00 100644 --- a/tests/webhook-adapter.test.ts +++ b/tests/webhook-adapter.test.ts @@ -1,16 +1,174 @@ import assert from "node:assert/strict"; -import { createHmac } from "node:crypto"; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { createHash, createHmac, generateKeyPairSync } from "node:crypto"; +import { once } from "node:events"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import type { AddressInfo } from "node:net"; +import { hostname, tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import { pathToFileURL } from "node:url"; import { buildTaskFromEvent, createConfig, + githubRequestAllPages, handleRequest, + normalizeReviewPublication, + patchEvidenceIncomplete, + publicationInstallationTokenRequest, + publishResultIfConfigured, + readBoundedRuntimeResult, + recoverPendingPublications, + redactTokenish, + reviewContextInstallationTokenRequest, + resumeTaskPublication, + runtimeInstallationTokenRequest, + runtimeIsolationIssue, + runtimeProcessEnvironment, + runtimeSandboxArgs, + runnableTaskIds, + runTask, + sanitizedRuntimeEnvironment, type JsonObject, + type JsonValue, } from "../src/adapter.js"; +import { createWebhookServer, createWorkerTaskScheduler } from "../src/server.js"; + +type GithubMockResult = JsonObject | JsonObject[] | { + httpStatus: number; + response: JsonObject | JsonObject[]; + headers?: Record; +}; + +async function withGithubApiMock(handler: (url: string, init: RequestInit) => GithubMockResult | Promise, work: () => Promise): Promise { + const original = globalThis.fetch; + globalThis.fetch = async (input, init) => { + const result = await handler(String(input), init || {}); + if (!Array.isArray(result) && "httpStatus" in result) { + return new Response(JSON.stringify(result.response), { + status: Number(result.httpStatus), + headers: (result as {headers?: Record}).headers, + }); + } + return new Response(JSON.stringify(result), {status: 200}); + }; + try { + return await work(); + } finally { + globalThis.fetch = original; + } +} + +function reviewTask(taskId = "review-task"): JsonObject { + return { + task_id: taskId, + repository: "OpenCoven/example", + publication: {mode: "comment"}, + runtime_isolation: {mode: "bwrap", verified: true}, + policy_snapshot: { + bot_usernames: ["covencat[bot]"], + enabled_triggers: ["pull_request.synchronize", "pull_request.edited", "pull_request.reopened", "push"], + publication: {mode: "comment"}, + }, + target: {kind: "pull_request", pr_number: 7}, + task: {pr_number: 7}, + review_evidence: { + head_sha: "abc123", + base_sha: "base123", + workspace_head_sha: "abc123", + publication_workspace_head_sha: "abc123", + publication_workspace_clean: true, + changed_file_count: 2, + expected_changed_file_count: 2, + incomplete_patch_files: [], + host_validation_checks: [{ + command: "npm test", + returncode: 0, + stdout_sha256: "a".repeat(64), + stderr_sha256: "b".repeat(64), + }], + changed_files: ["src/app.ts", "tests/app.test.ts"], + changed_file_lines: [{path: "src/app.ts", left_lines: [9], right_lines: [12]}], + }, + }; +} + +function signedPublicationMarker(identity: string, createdAt = "", target = "OpenCoven/example#pr:7"): string { + const proof = createHmac("sha256", "test-webhook-secret").update(`${target}\0${identity}\0${createdAt}`).digest("hex"); + return [ + ``, + createdAt ? `` : "", + ``, + ].filter(Boolean).join("\n"); +} + +function signedBasePublicationMarker(identity: string, baseSha: string, createdAt = "", target = "OpenCoven/example#pr:7"): string { + const proof = createHmac("sha256", "test-webhook-secret").update(`${target}\0${identity}\0${createdAt}\0${baseSha}`).digest("hex"); + return [ + ``, + createdAt ? `` : "", + ``, + ``, + ].filter(Boolean).join("\n"); +} + +function stableCompact(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableCompact).join(",")}]`; + return `{${Object.entries(value as Record) + .filter(([, item]) => item !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => `${JSON.stringify(key)}:${stableCompact(item)}`) + .join(",")}}`; +} + +function publicationIdentityFixture(task: JsonObject, result: JsonObject, includeBase: boolean): string { + const evidence = task.review_evidence as JsonObject; + const material: JsonObject = {task_id: task.task_id, head_sha: evidence.head_sha}; + if (includeBase) material.base_sha = evidence.base_sha; + material.result = result; + return createHash("sha256").update(stableCompact(material)).digest("hex"); +} + +function covencatBot(): JsonObject { + return {login: "covencat[bot]", type: "Bot"}; +} + +function completeReview(findings: JsonObject[] = []): JsonObject { + return { + contract_version: "2", + status: "success", + summary: "Reviewed the pull request.", + pr_body: "", + files_changed: [], + commits: [], + review: { + mode: "pull_request", + evidence_status: "complete", + reviewed_files: ["src/app.ts", "tests/app.test.ts"], + supporting_files: ["README.md"], + findings, + no_findings_reason: findings.length ? null : "The complete changed-file set was reviewed and no actionable defects were found.", + tests_run: [{command: "npm test", status: "passed", output_summary: "all tests passed"}], + limitations: [], + }, + }; +} + +function prepareReviewWorkspace(config: ReturnType, task: JsonObject): void { + const root = join(config.workspacesDir, String(task.task_id), "repo"); + for (const path of ["src/app.ts", "tests/app.test.ts", "README.md"]) { + const target = join(root, path); + mkdirSync(join(target, ".."), {recursive: true}); + writeFileSync(target, `fixture for ${path}\n`); + } +} + +function githubReadFixture(url: string, init: RequestInit): JsonObject | JsonObject[] | null { + if (init.method !== "GET") return null; + if (/\/pulls\/7$/.test(url)) return {head: {sha: "abc123"}, base: {sha: "base123"}}; + return []; +} function tempStateDir(): string { return mkdtempSync(join(tmpdir(), "coven-github-webhook-")); @@ -22,6 +180,7 @@ function testConfig(stateDir: string, webhookSecret = "test-webhook-secret") { COVEN_GITHUB_STATE_DIR: stateDir, COVEN_GITHUB_POLICY_PATH: join(stateDir, "policy.json"), GITHUB_WEBHOOK_SECRET: webhookSecret, + COVEN_GITHUB_REVOCATION_EVENTS: "pull-request-and-push-verified", }, process.cwd(), ); @@ -115,6 +274,183 @@ test("webhook accepts valid signed ping without runtime", async () => { assert.equal(response.body.reason, "no_policy_for_installation_repo"); }); +test("HTTP server returns 500 for corrupt persisted state and remains healthy", async () => { + const secret = "server-state-error-secret"; + const stateDir = tempStateDir(); + const config = testConfig(stateDir, secret); + const deliveryId = "server-corrupt-delivery"; + const outside = join(mkdtempSync(join(tmpdir(), "coven-corrupt-delivery-")), "delivery.json"); + writeFileSync(outside, "{}\n"); + symlinkSync(outside, join(config.deliveriesDir, `${deliveryId}.json`)); + const server = createWebhookServer(config); + const originalConsoleError = console.error; + console.error = () => {}; + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address() as AddressInfo; + const body = Buffer.from("{}"); + const failed = await fetch(`http://127.0.0.1:${address.port}/webhook`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-github-event": "ping", + "x-github-delivery": deliveryId, + "x-hub-signature-256": signature(secret, body), + }, + body, + }); + assert.equal(failed.status, 500); + assert.deepEqual(await failed.json(), {ok: false, error: "internal server error"}); + const healthy = await fetch(`http://127.0.0.1:${address.port}/healthz`); + assert.equal(healthy.status, 200); + assert.deepEqual(await healthy.json(), {ok: true}); + } finally { + console.error = originalConsoleError; + server.close(); + if (server.listening) await once(server, "close"); + } +}); + +test("HTTP server acknowledges a real task before scheduling worker execution", async () => { + const secret = "async-worker-secret"; + const stateDir = tempStateDir(); + const policyPath = join(stateDir, "policy.json"); + writeFileSync(policyPath, readFileSync(new URL("../config/example-policy.json", import.meta.url))); + const config = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: policyPath, + GITHUB_WEBHOOK_SECRET: secret, + }, process.cwd()); + const scheduled: string[] = []; + const server = createWebhookServer(config, (taskId) => scheduled.push(taskId)); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address() as AddressInfo; + const body = Buffer.from(JSON.stringify({ + action: "labeled", + installation: {id: 123456}, + repository: {id: 987654321, full_name: "OpenCoven/example", default_branch: "main"}, + issue: {number: 42, title: "Queue safely", body: "Do not block the listener.", labels: [{name: "coven:fix"}]}, + })); + const response = await fetch(`http://127.0.0.1:${address.port}/webhook`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-github-event": "issues", + "x-github-delivery": "async-worker-delivery", + "x-hub-signature-256": signature(secret, body), + }, + body, + }); + assert.equal(response.status, 200); + const responseBody = await response.json() as JsonObject; + assert.equal(responseBody.state, "queued"); + assert.equal(responseBody.queued, true); + assert.deepEqual(scheduled, ["async-worker-delivery"]); + const persisted = JSON.parse(readFileSync(join(config.tasksDir, "async-worker-delivery.json"), "utf8")) as JsonObject; + assert.equal(persisted.state, "queued"); + const healthy = await fetch(`http://127.0.0.1:${address.port}/healthz`); + assert.equal(healthy.status, 200); + } finally { + server.close(); + if (server.listening) await once(server, "close"); + } +}); + +test("default task worker drains an acknowledged task without blocking the server", async () => { + const secret = "default-worker-secret"; + const stateDir = tempStateDir(); + const policyPath = join(stateDir, "policy.json"); + writeFileSync(policyPath, readFileSync(new URL("../config/example-policy.json", import.meta.url))); + const config = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: policyPath, + GITHUB_WEBHOOK_SECRET: secret, + }, process.cwd()); + const server = createWebhookServer(config); + try { + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address() as AddressInfo; + const body = Buffer.from(JSON.stringify({ + action: "labeled", + installation: {id: 123456}, + repository: {id: 987654321, full_name: "OpenCoven/example", default_branch: "main"}, + issue: {number: 43, title: "Worker execution", body: "Fail closed in the worker.", labels: [{name: "coven:fix"}]}, + })); + const response = await fetch(`http://127.0.0.1:${address.port}/webhook`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-github-event": "issues", + "x-github-delivery": "default-worker-delivery", + "x-hub-signature-256": signature(secret, body), + }, + body, + }); + assert.equal(response.status, 200); + assert.equal((await response.json() as JsonObject).state, "queued"); + const taskFile = join(config.tasksDir, "default-worker-delivery.json"); + let persisted: JsonObject = {}; + for (let attempt = 0; attempt < 100; attempt += 1) { + persisted = JSON.parse(readFileSync(taskFile, "utf8")) as JsonObject; + if (persisted.state !== "queued" && persisted.state !== "running") break; + await new Promise((resolveDelay) => setTimeout(resolveDelay, 20)); + } + assert.equal(persisted.state, "blocked"); + assert.equal(persisted.failure_category, "runtime_isolation_unavailable"); + const healthy = await fetch(`http://127.0.0.1:${address.port}/healthz`); + assert.equal(healthy.status, 200); + } finally { + server.close(); + if (server.listening) await once(server, "close"); + } +}); + +test("task supervisor keeps retrying a worker that exits twice and then recovers", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const taskId = "flaky-worker-supervision"; + writeFileSync(join(config.tasksDir, `${taskId}.json`), JSON.stringify({ + task_id: taskId, + state: "queued", + task: {kind: "respond_to_mention"}, + })); + const counterPath = join(stateDir, "flaky-worker-count"); + const workerPath = join(stateDir, "flaky-worker.mjs"); + writeFileSync(workerPath, [ + 'import {existsSync, readFileSync, writeFileSync} from "node:fs";', + `const counterPath = ${JSON.stringify(counterPath)};`, + 'process.once("message", () => {', + ' const count = (existsSync(counterPath) ? Number(readFileSync(counterPath, "utf8")) : 0) + 1;', + ' writeFileSync(counterPath, String(count));', + ' if (count < 3) process.exit(17);', + ' process.send?.({ok: true, state: "completed"}, () => process.disconnect());', + '});', + ].join("\n")); + const schedule = createWorkerTaskScheduler(config, { + workerUrl: pathToFileURL(workerPath), + retryBaseMs: 5, + retryMaxMs: 20, + }); + const originalConsoleError = console.error; + console.error = () => {}; + try { + schedule(taskId); + const deadline = Date.now() + 2_000; + while ((!existsSync(counterPath) || Number(readFileSync(counterPath, "utf8")) < 3) && Date.now() < deadline) { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 10)); + } + assert.equal(Number(readFileSync(counterPath, "utf8")), 3); + await new Promise((resolveDelay) => setTimeout(resolveDelay, 50)); + assert.equal(Number(readFileSync(counterPath, "utf8")), 3); + } finally { + console.error = originalConsoleError; + } +}); + test("webhook reads body when content length is missing", async () => { const secret = "missing-length-secret"; const config = testConfig(tempStateDir(), secret); @@ -271,6 +607,7 @@ test("webhook secret supports smoke script environment name", async () => { test("config accepts an inline GitHub App private key from env", () => { const config = createConfig( { + COVEN_GITHUB_STATE_DIR: tempStateDir(), GITHUB_APP_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----", }, process.cwd(), @@ -279,6 +616,221 @@ test("config accepts an inline GitHub App private key from env", () => { assert.equal(config.privateKeyPem, "-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----"); }); +test("runtime token is restricted to one repository with contents read authority", () => { + assert.deepEqual(runtimeInstallationTokenRequest(987654321), { + repository_ids: [987654321], + permissions: {contents: "read"}, + }); + assert.deepEqual(reviewContextInstallationTokenRequest(987654321), { + repository_ids: [987654321], + permissions: {contents: "read", pull_requests: "read"}, + }); + assert.deepEqual(publicationInstallationTokenRequest(987654321), { + repository_ids: [987654321], + permissions: {issues: "write", pull_requests: "write"}, + }); + assert.throws(() => runtimeInstallationTokenRequest(undefined), /valid repository ID/); + assert.throws(() => publicationInstallationTokenRequest("not-a-repository"), /valid repository ID/); +}); + +test("real tasks fail closed before GitHub calls when runtime isolation is disabled", async () => { + const secret = "isolation-gate-secret"; + const stateDir = tempStateDir(); + const policyPath = join(stateDir, "policy.json"); + writeFileSync(policyPath, readFileSync(new URL("../config/example-policy.json", import.meta.url))); + const config = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: policyPath, + GITHUB_WEBHOOK_SECRET: secret, + }, process.cwd()); + const body = Buffer.from(JSON.stringify({ + action: "labeled", + installation: {id: 123456}, + repository: { + id: 987654321, + full_name: "OpenCoven/example", + clone_url: "https://github.com/OpenCoven/example.git", + default_branch: "main", + }, + issue: { + number: 42, + title: "Do not execute directly", + body: "This must be sandboxed.", + labels: [{name: "coven:fix"}], + }, + })); + let githubCalls = 0; + const response = await withGithubApiMock(() => { + githubCalls += 1; + throw new Error("GitHub must not be called before isolation succeeds"); + }, async () => { + const accepted = await callWebhook(body, { + "X-GitHub-Event": "issues", + "X-GitHub-Delivery": "delivery-isolation-disabled", + "X-Hub-Signature-256": signature(secret, body), + }, "auto", config); + assert.equal(accepted.body.state, "queued"); + assert.equal(accepted.body.queued, true); + await runTask(config, "delivery-isolation-disabled"); + return accepted; + }); + + assert.equal(response.status, 200); + assert.equal(response.body.state, "queued"); + assert.equal(githubCalls, 0); + const task = JSON.parse(readFileSync(join(stateDir, "tasks", "delivery-isolation-disabled.json"), "utf8")) as JsonObject; + assert.equal(task.state, "blocked"); + assert.equal(task.failure_category, "runtime_isolation_unavailable"); + assert.equal(task.publication_state, "not_started"); + assert.equal(task.attempts, 0); +}); + +test("configuration rejects a state root that is a symbolic link", () => { + const parent = mkdtempSync(join(tmpdir(), "coven-state-parent-")); + const outside = mkdtempSync(join(tmpdir(), "coven-state-outside-")); + const stateLink = join(parent, "state"); + symlinkSync(outside, stateLink, "dir"); + assert.throws(() => testConfig(stateLink), /must be a real directory/); +}); + +test("configuration rejects group or world access to the state root", () => { + if (typeof process.getuid !== "function") return; + const stateDir = tempStateDir(); + chmodSync(stateDir, 0o777); + assert.throws(() => testConfig(stateDir), /must not grant group or world access/); +}); + +test("configuration rejects a state root beneath an unsafe writable ancestor", () => { + if (typeof process.getuid !== "function") return; + const parent = mkdtempSync(join(tmpdir(), "coven-state-unsafe-parent-")); + chmodSync(parent, 0o777); + try { + assert.throws( + () => testConfig(join(parent, "state")), + /must not be beneath a group- or world-writable non-sticky directory/, + ); + } finally { + chmodSync(parent, 0o700); + rmSync(parent, {recursive: true, force: true}); + } +}); + +test("task startup refuses legacy attempt and workspace symlinks before writing through them", async () => { + const secret = "legacy-state-symlink-secret"; + const deliveryId = "delivery-legacy-state-symlink"; + const payload = Buffer.from(JSON.stringify({ + action: "labeled", + installation: {id: 123456}, + repository: { + id: 987654321, + full_name: "OpenCoven/example", + clone_url: "https://github.com/OpenCoven/example.git", + default_branch: "main", + }, + issue: {number: 42, title: "Do not follow state links", body: "Review safely.", labels: [{name: "coven:fix"}]}, + })); + + for (const location of ["attempt-task-root", "workspace-attempt"] as const) { + const stateDir = tempStateDir(); + const policyPath = join(stateDir, "policy.json"); + writeFileSync(policyPath, readFileSync(new URL("../config/example-policy.json", import.meta.url))); + const config = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: policyPath, + GITHUB_WEBHOOK_SECRET: secret, + COVEN_GITHUB_DEMO_MODE: "1", + }, process.cwd()); + const outside = mkdtempSync(join(tmpdir(), "coven-state-link-target-")); + writeFileSync(join(outside, "sentinel"), "unchanged\n"); + if (location === "attempt-task-root") { + symlinkSync(outside, join(config.attemptsDir, deliveryId), "dir"); + } else { + mkdirSync(join(config.workspacesDir, deliveryId)); + symlinkSync(outside, join(config.workspacesDir, deliveryId, "1"), "dir"); + } + + const response = await callWebhook(payload, { + "X-GitHub-Event": "issues", + "X-GitHub-Delivery": deliveryId, + "X-Hub-Signature-256": signature(secret, payload), + }, "auto", config); + const task = JSON.parse(readFileSync(join(config.tasksDir, `${deliveryId}.json`), "utf8")) as JsonObject; + assert.equal(response.body.state, "blocked"); + assert.equal(task.failure_category, "state_storage_untrusted"); + assert.deepEqual(readdirSync(outside), ["sentinel"]); + assert.equal(readFileSync(join(outside, "sentinel"), "utf8"), "unchanged\n"); + } +}); + +test("runtime sandbox exposes only explicit mounts and runtime env omits GitHub credentials", () => { + const stateDir = tempStateDir(); + const rootfs = mkdtempSync(join(tmpdir(), "coven-runtime-rootfs-")); + const bwrap = join(mkdtempSync(join(tmpdir(), "coven-bwrap-")), "bwrap"); + for (const path of ["usr/local/bin/coven-code", "usr/bin/git", "bin/sh", "bin/true"]) { + const target = join(rootfs, path); + mkdirSync(join(target, ".."), {recursive: true}); + writeFileSync(target, "#!/bin/sh\nexit 0\n", {mode: 0o755}); + } + writeFileSync(bwrap, "#!/bin/sh\nexit 0\n", {mode: 0o755}); + const config = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: join(stateDir, "policy.json"), + COVEN_RUNTIME_ISOLATION: "bwrap", + COVEN_RUNTIME_EXTERNAL_ISOLATION: "network-egress-and-resource-limits-verified", + COVEN_BWRAP_BIN: bwrap, + COVEN_RUNTIME_ROOTFS: rootfs, + COVEN_CODE_BIN: "/usr/local/bin/coven-code", + }, process.cwd()); + if (typeof process.getuid === "function" && process.getuid() !== 0) { + assert.match(String(runtimeIsolationIssue(config)), /root-owned/); + } else { + assert.equal(runtimeIsolationIssue(config), null); + } + const workspace = join(stateDir, "workspaces", "task", "repo"); + const inputDir = join(stateDir, "attempts", "task", "1", "input"); + const outputDir = join(stateDir, "attempts", "task", "1", "output"); + mkdirSync(join(workspace, ".git"), {recursive: true}); + mkdirSync(inputDir, {recursive: true}); + mkdirSync(outputDir, {recursive: true}); + const args = runtimeSandboxArgs(config, {workspace, inputDir, outputDir}, ["/usr/local/bin/coven-code", "--headless"]); + const argv = args.join("\0"); + assert.equal(args[0], bwrap); + assert.match(argv, new RegExp(`--ro-bind\\0${inputDir}\\0/run/coven/input`)); + assert.match(argv, new RegExp(`--bind\\0${workspace}\\0/workspace`)); + assert.match(argv, new RegExp(`--bind\\0${outputDir}\\0/run/coven/output`)); + assert.ok(args.includes("--unshare-net")); + assert.ok(args.includes("/workspace/.git")); + assert.equal(args.includes(config.privateKeyPath), false); + assert.equal(args.includes(config.codexTokensPath), false); + const runtimeEnv = runtimeProcessEnvironment({ + PATH: "/host/bin", + HOME: "/host/home", + GITHUB_APP_PRIVATE_KEY: "private", + GITHUB_WEBHOOK_SECRET: "webhook", + COVEN_GIT_TOKEN: "github", + GIT_ASKPASS: "/tmp/askpass", + SSH_AUTH_SOCK: "/tmp/agent", + }, "codex-only"); + assert.equal(runtimeEnv.HOME, "/home/coven"); + assert.equal(runtimeEnv.OPENAI_API_KEY, "codex-only"); + for (const key of ["GITHUB_APP_PRIVATE_KEY", "GITHUB_WEBHOOK_SECRET", "COVEN_GIT_TOKEN", "GIT_ASKPASS", "SSH_AUTH_SOCK"]) { + assert.equal(runtimeEnv[key], undefined); + } +}); + +test("runtime result reader rejects symlinks and oversized artifacts", () => { + const root = mkdtempSync(join(tmpdir(), "coven-runtime-result-")); + const valid = join(root, "valid.json"); + writeFileSync(valid, JSON.stringify({status: "success"})); + assert.deepEqual(readBoundedRuntimeResult(valid), {status: "success"}); + const symlink = join(root, "symlink.json"); + symlinkSync(valid, symlink); + assert.throws(() => readBoundedRuntimeResult(symlink)); + const oversized = join(root, "oversized.json"); + writeFileSync(oversized, `{"data":"${"x".repeat(2 * 1024 * 1024)}"}`); + assert.throws(() => readBoundedRuntimeResult(oversized), /exceeds/); +}); + test("missing familiar policy does not fall back to hardcoded installation", () => { const task = buildTaskFromEvent( "issues", @@ -349,6 +901,191 @@ test("example policy routes a labeled issue to the configured familiar", () => { }); }); +test("routing enforces exact enabled event actions before spending compute", async () => { + const secret = "enabled-trigger-secret"; + const stateDir = tempStateDir(); + const policyPath = join(stateDir, "policy.json"); + writeFileSync(policyPath, readFileSync(new URL("../config/example-policy.json", import.meta.url))); + const config = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: policyPath, + GITHUB_WEBHOOK_SECRET: secret, + COVEN_GITHUB_DEMO_MODE: "1", + }, process.cwd()); + const payload = Buffer.from(JSON.stringify({ + action: "opened", + installation: {id: 123456}, + repository: {id: 987654321, full_name: "OpenCoven/example"}, + issue: {number: 91, title: "An ordinary issue", body: "This action is not enabled.", labels: []}, + })); + const response = await callWebhook(payload, { + "X-GitHub-Event": "issues", + "X-GitHub-Delivery": "disabled-issues-opened", + "X-Hub-Signature-256": signature(secret, payload), + }, "auto", config); + assert.equal(response.body.action, "ignored"); + assert.equal(response.body.reason, "trigger_not_enabled"); + assert.equal(response.body.trigger, "issues.opened"); + assert.equal(existsSync(join(config.tasksDir, "disabled-issues-opened.json")), false); + + const policyRoot = JSON.parse(readFileSync(policyPath, "utf8")) as JsonObject; + const installation = (policyRoot.installations as JsonObject)["123456"] as JsonObject; + const repoPolicy = (installation.repositories as JsonObject)["987654321"] as JsonObject; + const assigned = buildTaskFromEvent("issues", "assigned-elsewhere", { + action: "assigned", + installation: {id: 123456}, + repository: {id: 987654321, full_name: "OpenCoven/example"}, + assignee: {login: "human-maintainer"}, + issue: {number: 92, assignee: {login: "human-maintainer"}}, + }, repoPolicy); + assert.equal(assigned.state, "ignored"); + assert.equal(assigned.ignored_reason, "issue_assignment_not_for_bot"); +}); + +test("unsafe native-review policy returns retryable 503 without claiming the delivery", async () => { + const secret = "unsafe-native-policy-secret"; + const stateDir = tempStateDir(); + const policyPath = join(stateDir, "policy.json"); + const policy = JSON.parse(readFileSync(new URL("../config/example-policy.json", import.meta.url), "utf8")) as JsonObject; + const installation = (policy.installations as JsonObject)["123456"] as JsonObject; + const repoPolicy = (installation.repositories as JsonObject)["987654321"] as JsonObject; + repoPolicy.publication = {mode: "comment"}; + repoPolicy.enabled_triggers = (repoPolicy.enabled_triggers as JsonValue[]).filter((trigger) => trigger !== "push"); + writeFileSync(policyPath, JSON.stringify(policy)); + const config = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: policyPath, + GITHUB_WEBHOOK_SECRET: secret, + COVEN_GITHUB_DEMO_MODE: "1", + }, process.cwd()); + const body = Buffer.from(JSON.stringify({ + action: "labeled", + installation: {id: 123456}, + repository: {id: 987654321, full_name: "OpenCoven/example", default_branch: "main"}, + issue: {number: 93, title: "Retry after policy repair", body: "Do not claim this delivery yet.", labels: [{name: "coven:fix"}]}, + })); + const headers = { + "X-GitHub-Event": "issues", + "X-GitHub-Delivery": "unsafe-policy-retry", + "X-Hub-Signature-256": signature(secret, body), + }; + const rejected = await callWebhook(body, headers, "auto", config); + assert.equal(rejected.status, 503); + assert.equal(rejected.body.action, "retry"); + assert.equal(rejected.body.reason, "native_review_policy_unsafe"); + assert.equal(existsSync(join(config.deliveriesDir, "unsafe-policy-retry.json")), false); + + (repoPolicy.enabled_triggers as JsonValue[]).push("push"); + writeFileSync(policyPath, JSON.stringify(policy)); + const accepted = await callWebhook(body, headers, "auto", config); + assert.equal(accepted.status, 200); + assert.equal(accepted.body.action, "accepted"); + assert.equal(existsSync(join(config.deliveriesDir, "unsafe-policy-retry.json")), true); +}); + +test("native review routing requires explicit live revocation-event verification", async () => { + const secret = "revocation-event-attestation-secret"; + const stateDir = tempStateDir(); + const policyPath = join(stateDir, "policy.json"); + const policy = JSON.parse(readFileSync(new URL("../config/example-policy.json", import.meta.url), "utf8")) as JsonObject; + const installation = (policy.installations as JsonObject)["123456"] as JsonObject; + const repoPolicy = (installation.repositories as JsonObject)["987654321"] as JsonObject; + repoPolicy.publication = {mode: "comment"}; + writeFileSync(policyPath, JSON.stringify(policy)); + const config = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: policyPath, + GITHUB_WEBHOOK_SECRET: secret, + }, process.cwd()); + const body = Buffer.from(JSON.stringify({ + action: "labeled", + installation: {id: 123456}, + repository: {id: 987654321, full_name: "OpenCoven/example", default_branch: "main"}, + issue: {number: 94, title: "Verify App events", body: "Fail closed first.", labels: [{name: "coven:fix"}]}, + })); + const headers = { + "X-GitHub-Event": "issues", + "X-GitHub-Delivery": "revocation-events-unverified", + "X-Hub-Signature-256": signature(secret, body), + }; + const rejected = await callWebhook(body, headers, "auto", config); + assert.equal(rejected.status, 503); + assert.match(String(rejected.body.error || ""), /COVEN_GITHUB_REVOCATION_EVENTS/); + assert.equal(existsSync(join(config.deliveriesDir, "revocation-events-unverified.json")), false); + + config.revocationEventsVerified = true; + const accepted = await callWebhook(body, headers, "auto", config); + assert.equal(accepted.status, 200); + assert.equal(accepted.body.state, "queued"); +}); + +test("pull request revision tasks dismiss stale signed decisive reviews", async () => { + const stateDir = tempStateDir(); + const {privateKey} = generateKeyPairSync("rsa", {modulusLength: 2048}); + const config = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: join(stateDir, "policy.json"), + GITHUB_WEBHOOK_SECRET: "test-webhook-secret", + GITHUB_APP_ID: "1234", + COVEN_GITHUB_REVOCATION_EVENTS: "pull-request-and-push-verified", + GITHUB_APP_PRIVATE_KEY: privateKey.export({type: "pkcs8", format: "pem"}).toString(), + }, process.cwd()); + const policy: JsonObject = { + familiar: {id: "reviewer"}, + publication: {mode: "comment"}, + bot_usernames: ["covencat[bot]"], + enabled_triggers: ["pull_request.synchronize", "pull_request.edited", "pull_request.reopened", "push"], + }; + const task = buildTaskFromEvent("pull_request", "reconcile-stale-review", { + action: "synchronize", + installation: {id: 123456}, + repository: {id: 987654321, full_name: "OpenCoven/example"}, + pull_request: { + number: 7, + head: {sha: "new-head", ref: "feature"}, + base: {sha: "base123", ref: "main"}, + }, + }, policy); + task.policy_snapshot = { + bot_usernames: ["covencat[bot]"], + enabled_triggers: ["pull_request.synchronize", "pull_request.edited", "pull_request.reopened", "push"], + publication: {mode: "comment"}, + }; + writeFileSync(join(config.tasksDir, `${task.task_id}.json`), JSON.stringify(task)); + const oldBody = `Old decisive review\n\n${signedBasePublicationMarker("d".repeat(64), "old-base", "2026-07-14T00:00:00Z")}`; + const mutations: Array<{url: string; body: JsonObject}> = []; + await withGithubApiMock((url, init) => { + if (/\/app\/installations\/123456\/access_tokens$/.test(url)) { + const request = JSON.parse(String(init.body)) as JsonObject; + assert.deepEqual(request.repository_ids, [987654321]); + assert.deepEqual(request.permissions, {issues: "write", pull_requests: "write"}); + return {token: "scoped-publication-token"}; + } + if (/\/pulls\/7$/.test(url)) return {head: {sha: "new-head"}, base: {sha: "base123"}}; + if (init.method === "GET" && /\/pulls\/7\/reviews(?:\?|$)/.test(url)) return [{ + id: 760, + state: "APPROVED", + commit_id: "old-head", + body: oldBody, + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-760", + user: covencatBot(), + }]; + if (init.method === "GET") return []; + const body = init.body ? JSON.parse(String(init.body)) as JsonObject : {}; + mutations.push({url, body}); + return {id: 760, state: "DISMISSED", body: body.body || oldBody, html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-760"}; + }, async () => runTask(config, String(task.task_id))); + const persisted = JSON.parse(readFileSync(join(config.tasksDir, `${task.task_id}.json`), "utf8")) as JsonObject; + assert.equal(persisted.state, "completed"); + assert.equal(persisted.publication_state, "stale_decisive_reviews_dismissed"); + assert.deepEqual(persisted.dismissed_review_ids, [760]); + assert.equal(mutations.length, 2); + assert.match(mutations[0].url, /\/reviews\/760\/dismissals$/); + assert.equal(mutations[0].body.event, "DISMISS"); + assert.match(mutations[1].url, /\/reviews\/760$/); + assert.match(String(mutations[1].body.body || ""), /dismissed automatically/); +}); + test("demo mode handles a signed labeled issue without external GitHub calls", async () => { const secret = "demo-route-secret"; const stateDir = tempStateDir(); @@ -407,3 +1144,2611 @@ test("demo mode handles a signed labeled issue without external GitHub calls", a assert.equal(existsSync(String(task.session_brief_path)), true); assert.equal(existsSync(String(task.result_path)), true); }); + +test("worker recovery reclaims a dead-process lease and retries an interrupted task", async () => { + const stateDir = tempStateDir(); + const config = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: join(stateDir, "policy.json"), + GITHUB_WEBHOOK_SECRET: "worker-recovery-secret", + COVEN_GITHUB_DEMO_MODE: "1", + }, process.cwd()); + const taskId = "interrupted-worker-task"; + writeFileSync(join(config.tasksDir, `${taskId}.json`), JSON.stringify({ + task_id: taskId, + state: "running", + attempts: 1, + repository: "OpenCoven/example", + familiar: {id: "cody", display_name: "Cody"}, + publication: {mode: "record_only"}, + task: {kind: "fix_issue", issue_number: 42}, + })); + const lockName = `${createHash("sha256").update(`execution:${taskId}`).digest("hex").slice(0, 24)}.lock`; + const lockPath = join(config.publicationsDir, lockName); + mkdirSync(lockPath, {mode: 0o700}); + const bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim(); + writeFileSync(join(lockPath, "owner"), JSON.stringify({ + owner: "dead-owner", + pid: 2_000_000_000, + hostname: hostname(), + boot_id: bootId, + process_start: "1", + }), {mode: 0o600}); + const recovered = await runTask(config, taskId); + assert.equal(recovered.state, "completed"); + assert.equal(recovered.attempts, 2); + assert.equal(recovered.recovered_from_interrupted_worker, true); + assert.equal(existsSync(lockPath), false); +}); + +test("publishes a native change-request review with inline findings and skips a retry", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask(); + prepareReviewWorkspace(config, task); + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview([{ + severity: "high", + file: "src/app.ts", + line: 12, + title: "Validate input", + body: "The request is accepted without validation.", + recommendation: "Reject malformed input.", + }]))); + const calls: Array<{url: string; init: RequestInit}> = []; + let publishedBody = ""; + + await withGithubApiMock((url, init) => { + calls.push({url, init}); + if (init.method === "GET" && /\/pulls\/7\/reviews\/101$/.test(url)) { + return {id: 101, state: "CHANGES_REQUESTED", body: publishedBody, html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-101", user: covencatBot()}; + } + const read = githubReadFixture(url, init); + if (read) return read; + if (init.method === "POST" && /\/pulls\/7\/reviews$/.test(url)) { + publishedBody = String((JSON.parse(String(init.body)) as JsonObject).body || ""); + } + return {id: 101, html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-101"}; + }, async () => { + await publishResultIfConfigured(config, task, resultPath, "token"); + await publishResultIfConfigured(config, task, resultPath, "token"); + }); + + const mutations = calls.filter((call) => call.init.method !== "GET"); + assert.equal(mutations.length, 2); + assert.match(mutations[0].url, /\/pulls\/7\/reviews$/); + const draft = JSON.parse(String(mutations[0].init.body)) as JsonObject; + assert.equal(draft.event, undefined); + assert.equal(draft.commit_id, "abc123"); + assert.equal((draft.comments as JsonObject[]).length, 1); + assert.match(mutations[1].url, /\/pulls\/7\/reviews\/101\/events$/); + const submission = JSON.parse(String(mutations[1].init.body)) as JsonObject; + assert.equal(submission.event, "REQUEST_CHANGES"); + assert.ok(mutations[0].init.signal instanceof AbortSignal); + assert.ok(calls.some((call) => call.init.method === "GET" && /\/pulls\/7\/reviews\/101$/.test(call.url))); + assert.equal(task.publication_state, "publication_skipped_duplicate", String(task.publication_error || "")); + assert.equal(task.publication_review_id, 101); +}); + +test("approves only a complete no-findings PR review", () => { + const normalized = normalizeReviewPublication(reviewTask(), completeReview()); + assert.equal(normalized.evidenceComplete, true); + assert.equal(normalized.decision, "APPROVE"); +}); + +test("uses COMMENT for contradictory evidence and includes validation and GitHub file links", async () => { + const task = reviewTask(); + const result = completeReview(); + const review = result.review as JsonObject; + review.tests_run = [{command: "npm test", status: "passed", output_summary: "not run"}]; + result.summary = "npm test - not run"; + const normalized = normalizeReviewPublication(task, result); + + assert.equal(normalized.decision, "COMMENT"); + assert.equal(normalized.evidenceComplete, false); + assert.match(normalized.validationIssues.join("\n"), /contradictory/); + + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + prepareReviewWorkspace(config, task); + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(result)); + let published = ""; + await withGithubApiMock((url, init) => { + const read = githubReadFixture(url, init); + if (read) return read; + published = String(init.body); + return {id: 102, html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-102"}; + }, async () => publishResultIfConfigured(config, task, resultPath, "token")); + + assert.match(published, /"event":"COMMENT"/); + assert.match(published, /Publication validation/); + assert.match(published, /https:\/\/github\.com\/OpenCoven\/example\/blob\/abc123\/src\/app\.ts/); +}); + +test("a newer review links the prior covencat publication with conditional replacement wording", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview())); + const bodies: string[] = []; + const dismissals: JsonObject[] = []; + let id = 200; + let activeId = 0; + const firstTask = reviewTask("first-run"); + const secondTask = reviewTask("newer-run"); + prepareReviewWorkspace(config, firstTask); + prepareReviewWorkspace(config, secondTask); + await withGithubApiMock((url, init) => { + const read = githubReadFixture(url, init); + if (read) return read; + if (init.method === "PUT") { + dismissals.push(JSON.parse(String(init.body)) as JsonObject); + return {state: "DISMISSED"}; + } + if (/\/pulls\/7\/reviews$/.test(url)) { + bodies.push(String(init.body)); + id += 1; + activeId = id; + return {id: activeId, state: "PENDING", html_url: `https://github.com/OpenCoven/example/pull/7#pullrequestreview-${activeId}`}; + } + const event = String((JSON.parse(String(init.body)) as JsonObject).event); + return {id: activeId, state: event === "APPROVE" ? "APPROVED" : "CHANGES_REQUESTED", html_url: `https://github.com/OpenCoven/example/pull/7#pullrequestreview-${activeId}`}; + }, async () => { + await publishResultIfConfigured(config, firstTask, resultPath, "token"); + await publishResultIfConfigured(config, secondTask, resultPath, "token"); + }); + + assert.equal(bodies.length, 2); + assert.match(bodies[1], /A decisive submission replaces its state; a COMMENT does not/); + assert.match(bodies[1], /pullrequestreview-201/); + assert.equal(dismissals[0].event, "DISMISS"); + assert.equal(secondTask.publication_supersession_state, "prior_decisive_review_dismissed"); +}); + +test("keeps a finding in the review body when its line is not in the captured diff", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("invalid-inline-location"); + prepareReviewWorkspace(config, task); + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview([{severity: "high", file: "src/app.ts", line: 99, title: "Out-of-diff finding", body: "Outside the captured hunk.", recommendation: null}]))); + let published = ""; + await withGithubApiMock((url, init) => { + const read = githubReadFixture(url, init); + if (read) return read; + published = String(init.body); + return {id: 250, html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-250"}; + }, async () => publishResultIfConfigured(config, task, resultPath, "token")); + const body = JSON.parse(published) as JsonObject; + assert.equal(body.comments, undefined); + assert.match(String(body.body), /Findings without valid inline locations/); +}); + +test("reports explicit overflow when a review has more findings than fit safely", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("finding-overflow"); + prepareReviewWorkspace(config, task); + const findings = Array.from({length: 41}, (_, index) => ({ + severity: "high", + file: "src/app.ts", + line: 99, + title: `Finding ${index + 1}`, + body: `Details for finding ${index + 1}.`, + recommendation: null, + })); + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview(findings))); + let reviewBody = ""; + await withGithubApiMock((url, init) => { + const read = githubReadFixture(url, init); + if (read) return read; + const payload = JSON.parse(String(init.body)) as JsonObject; + if (/\/pulls\/7\/reviews$/.test(url)) reviewBody = String(payload.body || ""); + return {id: 251, state: /\/events$/.test(url) ? "CHANGES_REQUESTED" : "PENDING", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-251"}; + }, async () => publishResultIfConfigured(config, task, resultPath, "token")); + assert.match(reviewBody, /Finding 40/); + assert.match(reviewBody, /1 additional finding was omitted|1 additional findings were omitted/); +}); + +test("keeps non-PR task results as idempotent issue comments", async () => { + const stateDir = tempStateDir(); + const task: JsonObject = { + task_id: "issue-task", + repository: "OpenCoven/example", + publication: {mode: "comment"}, + task: {issue_number: 11}, + }; + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify({status: "failure", summary: "Runtime was unavailable.", files_changed: [], commits: []})); + const methods: string[] = []; + let publishedBody = ""; + await withGithubApiMock((url, init) => { + methods.push(String(init.method)); + if (init.method === "GET" && /\/issues\/comments\/301$/.test(url)) { + return {id: 301, body: publishedBody, user: covencatBot(), html_url: "https://github.com/OpenCoven/example/issues/11#issuecomment-301"}; + } + if (init.method === "GET") return []; + publishedBody = String((JSON.parse(String(init.body)) as JsonObject).body || ""); + return {id: 301, html_url: "https://github.com/OpenCoven/example/issues/11#issuecomment-301"}; + }, async () => { + await publishResultIfConfigured(testConfig(stateDir), task, resultPath, "token"); + await publishResultIfConfigured(testConfig(stateDir), task, resultPath, "token"); + }); + assert.deepEqual(methods, ["GET", "POST", "GET"]); + assert.equal(task.publication_state, "publication_skipped_duplicate"); + assert.equal(task.publication_comment_id, 301); +}); + +test("downgrades incomplete, contradictory, or malformed review evidence", () => { + const cases: Array<[string, (task: JsonObject, result: JsonObject) => void]> = [ + ["partial changed-file coverage", (_task, result) => { ((result.review as JsonObject).reviewed_files as JsonObject[]).pop(); }], + ["dirty post-run workspace", (task) => { (task.review_evidence as JsonObject).publication_workspace_clean = false; }], + ["stale remote head", (_task, _result) => {}], + ["runtime failure", (_task, result) => { result.status = "failure"; }], + ["review limitation", (_task, result) => { (result.review as JsonObject).limitations = ["Tests were unavailable."]; }], + ["missing no-findings reason", (_task, result) => { (result.review as JsonObject).no_findings_reason = null; }], + ["failed test", (_task, result) => { (result.review as JsonObject).tests_run = [{command: "npm test", status: "failed", output_summary: "1 failed"}]; }], + ["missing test evidence", (_task, result) => { (result.review as JsonObject).tests_run = []; }], + ["malformed finding", (_task, result) => { (result.review as JsonObject).findings = [{title: "missing required fields"}]; (result.review as JsonObject).no_findings_reason = null; }], + ["findings with no-findings reason", (_task, result) => { (result.review as JsonObject).findings = [{severity: "high", file: "src/app.ts", line: 12, title: "Finding", body: "Body", recommendation: null}]; }], + ["invalid supporting path", (_task, result) => { (result.review as JsonObject).supporting_files = ["435: .map((entry) => entry)"]; }], + ["truncated patch evidence", (task) => { (task.review_evidence as JsonObject).incomplete_patch_files = ["src/app.ts"]; }], + ["wrong changed-file count", (task) => { (task.review_evidence as JsonObject).expected_changed_file_count = 3; }], + ["missing host validation receipt", (task) => { delete (task.review_evidence as JsonObject).host_validation_checks; }], + ["failed host validation receipt", (task) => { (((task.review_evidence as JsonObject).host_validation_checks as JsonObject[])[0]).returncode = 1; }], + ["unmatched host validation command", (task) => { (((task.review_evidence as JsonObject).host_validation_checks as JsonObject[])[0]).command = "npm run build"; }], + ["wrong contract", (_task, result) => { result.contract_version = "1"; }], + ]; + + for (const [name, mutate] of cases) { + const task = reviewTask(`case-${name}`); + const result = completeReview(); + mutate(task, result); + const currentHead = name === "stale remote head" ? "new-head" : "abc123"; + const normalized = normalizeReviewPublication(task, result, currentHead); + assert.equal(normalized.decision, "COMMENT", name); + assert.equal(normalized.evidenceComplete, false, name); + } +}); + +test("normalizes contradictory tests instead of publishing passed and not-run claims", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("normalized-contradiction"); + prepareReviewWorkspace(config, task); + const result = completeReview(); + result.summary = "Tests were not executed."; + (result.review as JsonObject).tests_run = [{command: "npm test", status: "passed", output_summary: "all tests passed"}]; + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(result)); + let payload: JsonObject = {}; + await withGithubApiMock((url, init) => { + const read = githubReadFixture(url, init); + if (read) return read; + payload = JSON.parse(String(init.body)) as JsonObject; + return {id: 401, html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-401"}; + }, async () => publishResultIfConfigured(config, task, resultPath, "token")); + assert.equal(payload.event, "COMMENT"); + assert.match(String(payload.body), /`unverified`/); + assert.doesNotMatch(String(payload.body), /Tests were not executed/); +}); + +test("downgrades passed test claims with failure or missing-execution evidence", () => { + const cases: Array<[string, (result: JsonObject) => void]> = [ + ["failed output", (result) => { + (result.review as JsonObject).tests_run = [{command: "npm test", status: "passed", output_summary: "47 passed, 1 failed"}]; + }], + ["failed narrative", (result) => { + result.summary = "npm test failed with one failing test."; + }], + ["failing count", (result) => { + (result.review as JsonObject).tests_run = [{command: "npm test", status: "passed", output_summary: "47 passing, 1 failing"}]; + }], + ["did not pass", (result) => { + (result.review as JsonObject).tests_run = [{command: "npm test", status: "passed", output_summary: "1 test did not pass"}]; + }], + ["unsuccessful exit", (result) => { + (result.review as JsonObject).tests_run = [{command: "npm test", status: "passed", output_summary: "npm test exited unsuccessfully"}]; + }], + ["non-zero return", (result) => { + (result.review as JsonObject).tests_run = [{command: "npm test", status: "passed", output_summary: "process returned non-zero"}]; + }], + ["non-zero exit status", (result) => { + (result.review as JsonObject).tests_run = [{command: "npm test", status: "passed", output_summary: "exit status 1"}]; + }], + ["no tests narrative", (result) => { + result.summary = "No tests were run."; + }], + ["skipped testing narrative", (result) => { + result.summary = "Testing was skipped."; + }], + ["could not run narrative", (result) => { + result.summary = "Tests could not be run."; + }], + ["zero tests output", (result) => { + (result.review as JsonObject).tests_run = [{command: "npm test", status: "passed", output_summary: "Command exited 0; 0 tests were run."}]; + }], + ["suite not executed output", (result) => { + (result.review as JsonObject).tests_run = [{command: "npm test", status: "passed", output_summary: "The test suite was not executed."}]; + }], + ]; + + for (const [name, mutate] of cases) { + const result = completeReview(); + mutate(result); + const normalized = normalizeReviewPublication(reviewTask(`failed-test-${name}`), result, "abc123"); + assert.equal(normalized.decision, "COMMENT", name); + assert.equal(normalized.evidenceComplete, false, name); + assert.match(normalized.validationIssues.join("\n"), /contradictory or incomplete/, name); + assert.equal((normalized.review.tests_run as JsonObject[])[0].status, "unverified", name); + } +}); + +test("keeps explicitly successful test evidence decisive", () => { + const cases: Array<[string, (result: JsonObject) => void]> = [ + ["zero failures", (result) => { + (result.review as JsonObject).tests_run = [{command: "npm test", status: "passed", output_summary: "47 tests passed with 0 failures."}]; + }], + ["none failed or skipped", (result) => { + result.summary = "No tests failed and no tests were skipped."; + }], + ["none skipped", (result) => { + result.summary = "All tests ran; none were skipped."; + }], + ]; + + for (const [name, mutate] of cases) { + const result = completeReview(); + mutate(result); + const normalized = normalizeReviewPublication(reviewTask(`successful-test-${name}`), result, "abc123"); + assert.equal(normalized.decision, "APPROVE", name); + assert.equal(normalized.evidenceComplete, true, name); + } +}); + +test("infers LEFT for a deletion finding from captured diff lines", () => { + const result = completeReview([{ + severity: "high", + file: "src/app.ts", + line: 9, + title: "Deleted guard", + body: "The guard was removed.", + recommendation: "Restore the guard.", + }]); + const normalized = normalizeReviewPublication(reviewTask(), result, "abc123"); + assert.equal(normalized.decision, "REQUEST_CHANGES"); + assert.equal(normalized.inlineComments[0].side, "LEFT"); + assert.equal(normalized.inlineComments[0].line, 9); +}); + +test("accepts a deletion finding when the changed file is absent from the checked-out tree", () => { + const root = tempStateDir(); + writeFileSync(join(root, "README.md"), "supporting evidence\n"); + const task = reviewTask("deleted-file"); + task.review_evidence = { + head_sha: "abc123", + base_sha: "base123", + workspace_head_sha: "abc123", + publication_workspace_head_sha: "abc123", + publication_workspace_clean: true, + changed_file_count: 1, + expected_changed_file_count: 1, + incomplete_patch_files: [], + host_validation_checks: [{ + command: "npm test", + returncode: 0, + stdout_sha256: "a".repeat(64), + stderr_sha256: "b".repeat(64), + }], + changed_files: ["src/deleted.ts"], + changed_file_lines: [{path: "src/deleted.ts", left_lines: [9], right_lines: []}], + }; + const result = completeReview([{ + severity: "high", + file: "src/deleted.ts", + line: 9, + title: "Required guard was deleted", + body: "Deleting this file removes the guard.", + recommendation: "Restore the guard.", + }]); + (result.review as JsonObject).reviewed_files = ["src/deleted.ts"]; + const normalized = normalizeReviewPublication(task, result, "abc123", root); + assert.equal(normalized.decision, "REQUEST_CHANGES"); + assert.equal(normalized.evidenceComplete, true); + assert.equal(normalized.inlineComments[0].side, "LEFT"); +}); + +test("rejects findings outside the captured changed-file set", () => { + const result = completeReview([{ + severity: "high", + file: "src/not-in-pr.ts", + line: null, + title: "Phantom finding", + body: "This path was not part of the reviewed diff.", + recommendation: null, + }]); + const normalized = normalizeReviewPublication(reviewTask(), result, "abc123"); + assert.equal(normalized.decision, "COMMENT"); + assert.match(normalized.validationIssues.join("\n"), /outside the verified changed-file set/); +}); + +test("detects a patch already truncated by the GitHub files API", () => { + const completePatch = "@@ -1,2 +1,2 @@\n-old one\n-old two\n+new one\n+new two"; + assert.equal(patchEvidenceIncomplete(completePatch, 2, 2), false); + assert.equal(patchEvidenceIncomplete(completePatch.slice(0, -9), 2, 2), true); +}); + +test("rejects syntactically valid supporting paths that are missing from the checkout", () => { + const root = tempStateDir(); + const task = reviewTask(); + const result = completeReview(); + (result.review as JsonObject).supporting_files = ["docs/does-not-exist.md"]; + const normalized = normalizeReviewPublication(task, result, "abc123", root); + assert.equal(normalized.decision, "COMMENT"); + assert.match(normalized.validationIssues.join("\n"), /missing repository path/); +}); + +test("rejects supporting paths that traverse a checkout symlink", () => { + const root = tempStateDir(); + const outside = join(tempStateDir(), "outside.md"); + writeFileSync(outside, "outside evidence\n"); + symlinkSync(outside, join(root, "linked.md")); + const task = reviewTask(); + const result = completeReview(); + (result.review as JsonObject).supporting_files = ["linked.md"]; + const normalized = normalizeReviewPublication(task, result, "abc123", root); + assert.equal(normalized.decision, "COMMENT"); + assert.match(normalized.validationIssues.join("\n"), /missing repository path/); +}); + +test("paginates GitHub list endpoints until the final partial page", async () => { + const urls: string[] = []; + await withGithubApiMock((url) => { + urls.push(url); + return /[?&]page=1(?:&|$)/.test(url) ? Array.from({length: 100}, (_, id) => ({id})) : [{id: 100}]; + }, async () => { + const items = await githubRequestAllPages("https://api.github.com/repos/OpenCoven/example/pulls/7/files", "token"); + assert.equal(items.length, 101); + }); + assert.match(urls[0], /page=1/); + assert.match(urls[1], /page=2/); +}); + +test("does not retry a review on unrelated GitHub API failures", async () => { + for (const [status, response] of [ + [500, {message: "internal error"}], + [422, {message: "Validation failed, or the endpoint has been spammed."}], + ] as Array<[number, JsonObject]>) { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask(`api-${status}`); + prepareReviewWorkspace(config, task); + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview())); + let posts = 0; + await withGithubApiMock((url, init) => { + const read = githubReadFixture(url, init); + if (read) return read; + posts += 1; + return {httpStatus: status, response}; + }, async () => publishResultIfConfigured(config, task, resultPath, "token")); + assert.equal(posts, 1); + assert.equal(task.publication_state, "publication_failed"); + } +}); + +test("retries without inline comments only for a deterministic diff-location 422", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("inline-422"); + prepareReviewWorkspace(config, task); + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview([{ + severity: "high", file: "src/app.ts", line: 12, title: "Finding", body: "Body", recommendation: null, + }]))); + const posts: JsonObject[] = []; + await withGithubApiMock((url, init) => { + const read = githubReadFixture(url, init); + if (read) return read; + const payload = JSON.parse(String(init.body)) as JsonObject; + posts.push(payload); + if (posts.length === 1) return {httpStatus: 422, response: {message: "review comment line must be part of the diff"}}; + return { + id: 402, + state: /\/events$/.test(url) ? "CHANGES_REQUESTED" : "PENDING", + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-402", + }; + }, async () => publishResultIfConfigured(config, task, resultPath, "token")); + assert.equal(posts.length, 3); + assert.equal((posts[0].comments as JsonObject[]).length, 1); + assert.equal(posts[1].comments, undefined); + assert.equal(posts[2].event, "REQUEST_CHANGES"); + assert.equal(task.publication_state, "published_review"); +}); + +test("near-limit inline fallback preserves a complete trusted marker and remains idempotent", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("near-limit-inline-fallback"); + prepareReviewWorkspace(config, task); + const result = completeReview([{ + severity: "high", file: "src/app.ts", line: 12, title: "Finding", body: "Body", recommendation: null, + }]); + result.summary = "x".repeat(59_500); + const resultPath = join(stateDir, "near-limit-result.json"); + writeFileSync(resultPath, JSON.stringify(result)); + let publishedBody = ""; + let reviewState = ""; + let reviewPosts = 0; + let submissions = 0; + await withGithubApiMock((url, init) => { + if (/\/pulls\/7$/.test(url)) return {head: {sha: "abc123"}, base: {sha: "base123"}}; + if (init.method === "GET" && /\/pulls\/7\/reviews\/4021$/.test(url)) { + return {id: 4021, state: reviewState, commit_id: "abc123", body: publishedBody, html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-4021", user: covencatBot()}; + } + if (init.method === "GET" && /\/pulls\/7\/reviews(?:\?|$)/.test(url)) { + return publishedBody ? [{ + id: 4021, + state: reviewState, + commit_id: "abc123", + body: publishedBody, + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-4021", + user: covencatBot(), + }] : []; + } + if (init.method === "GET") return []; + if (init.method === "POST" && /\/pulls\/7\/reviews$/.test(url)) { + reviewPosts += 1; + const payload = JSON.parse(String(init.body)) as JsonObject; + if (payload.comments) return {httpStatus: 422, response: {message: "review comment line must be part of the diff"}}; + publishedBody = String(payload.body || ""); + reviewState = "PENDING"; + return {id: 4021, state: reviewState, html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-4021"}; + } + if (init.method === "POST" && /\/events$/.test(url)) { + submissions += 1; + reviewState = "CHANGES_REQUESTED"; + return {id: 4021, state: reviewState, html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-4021"}; + } + return {id: 4021}; + }, async () => { + await publishResultIfConfigured(config, task, resultPath, "token"); + await publishResultIfConfigured(config, task, resultPath, "token"); + }); + assert.equal(reviewPosts, 2); + assert.equal(submissions, 1); + assert.ok(publishedBody.length <= 60_000); + assert.match(publishedBody, /Inline publication was unavailable/); + assert.match(publishedBody, /\n\n$/); + assert.equal(task.publication_state, "publication_skipped_duplicate", String(task.publication_error || "")); +}); + +test("downgrades a forbidden decisive self-review to COMMENT", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("self-review"); + prepareReviewWorkspace(config, task); + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview([{ + severity: "high", file: "src/app.ts", line: 12, title: "Finding", body: "Body", recommendation: null, + }]))); + const events: string[] = []; + await withGithubApiMock((url, init) => { + const read = githubReadFixture(url, init); + if (read) return read; + const payload = JSON.parse(String(init.body)) as JsonObject; + if (/\/pulls\/7\/reviews$/.test(url)) { + assert.equal(payload.event, undefined); + assert.equal((payload.comments as JsonObject[]).length, 1); + return {id: 403, state: "PENDING", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-403"}; + } + events.push(String(payload.event)); + if (events.length === 1) return {httpStatus: 422, response: {message: "Can not approve your own pull request"}}; + return {id: 403, state: "COMMENTED", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-403"}; + }, async () => publishResultIfConfigured(config, task, resultPath, "token")); + assert.deepEqual(events, ["REQUEST_CHANGES", "COMMENT"]); + assert.equal(task.publication_decision, "COMMENT"); +}); + +test("publishes a successful PR result without review evidence as native COMMENT", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("missing-review"); + prepareReviewWorkspace(config, task); + const result = completeReview(); + delete result.review; + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(result)); + let event = ""; + await withGithubApiMock((url, init) => { + const read = githubReadFixture(url, init); + if (read) return read; + event = String((JSON.parse(String(init.body)) as JsonObject).event); + return {id: 404, html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-404"}; + }, async () => publishResultIfConfigured(config, task, resultPath, "token")); + assert.equal(event, "COMMENT"); +}); + +test("publishes a partial PR result without review evidence as native COMMENT", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("partial-missing-review"); + prepareReviewWorkspace(config, task); + const result = completeReview(); + result.status = "partial"; + delete result.review; + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(result)); + let event = ""; + await withGithubApiMock((url, init) => { + const read = githubReadFixture(url, init); + if (read) return read; + event = String((JSON.parse(String(init.body)) as JsonObject).event); + return {id: 4041, html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-4041"}; + }, async () => publishResultIfConfigured(config, task, resultPath, "token")); + assert.equal(event, "COMMENT"); +}); + +test("resumes a persisted pending publication without rerunning the completed task", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("resume-pending-publication"); + const resultPath = join(stateDir, "resume-result.json"); + Object.assign(task, { + state: "completed", + attempts: 4, + runtime_exit_code: 0, + result_path: resultPath, + publication_state: "publication_pending", + installation_id: 12345, + repository_id: 98765, + }); + prepareReviewWorkspace(config, task); + writeFileSync(resultPath, JSON.stringify(completeReview())); + writeFileSync(join(config.tasksDir, `${task.task_id}.json`), JSON.stringify(task)); + + let providedConfig: typeof config | undefined; + let providedTask: JsonObject | undefined; + let reviewCreates = 0; + let submissions = 0; + const tokenProvider = async (candidateConfig: typeof config, candidateTask: JsonObject): Promise => { + providedConfig = candidateConfig; + providedTask = candidateTask; + return "task-aware-token"; + }; + + await withGithubApiMock((url, init) => { + const read = githubReadFixture(url, init); + if (read) return read; + if (/\/pulls\/7\/reviews$/.test(url)) { + reviewCreates += 1; + return {id: 650, state: "PENDING", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-650"}; + } + if (/\/events$/.test(url)) { + submissions += 1; + return {id: 650, state: "APPROVED", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-650"}; + } + return {id: 650}; + }, async () => resumeTaskPublication(config, String(task.task_id), () => {}, tokenProvider)); + + const persisted = JSON.parse(readFileSync(join(config.tasksDir, `${task.task_id}.json`), "utf8")) as JsonObject; + assert.equal(providedConfig, config); + assert.equal(providedTask?.task_id, task.task_id); + assert.equal(providedTask?.installation_id, 12345); + assert.equal(providedTask?.repository_id, 98765); + assert.equal(reviewCreates, 1); + assert.equal(submissions, 1); + assert.equal(persisted.state, "completed"); + assert.equal(persisted.attempts, 4); + assert.equal(persisted.runtime_exit_code, 0); + assert.equal(persisted.publication_attempts, 1); + assert.equal(persisted.publication_state, "published_review"); + assert.equal(persisted.publication_review_id, 650); +}); + +test("blocks recovery when a finalized task has no result artifact", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("resume-missing-result"); + Object.assign(task, { + state: "completed", + result_path: join(stateDir, "missing-result.json"), + publication_state: "publication_pending", + installation_id: 12345, + }); + writeFileSync(join(config.tasksDir, `${task.task_id}.json`), JSON.stringify(task)); + let tokenCalls = 0; + + await resumeTaskPublication(config, String(task.task_id), () => {}, async (_candidateConfig, _candidateTask) => { + tokenCalls += 1; + return "unused-token"; + }); + + const persisted = JSON.parse(readFileSync(join(config.tasksDir, `${task.task_id}.json`), "utf8")) as JsonObject; + assert.equal(tokenCalls, 0); + assert.equal(persisted.state, "completed"); + assert.equal(persisted.publication_state, "publication_blocked_missing_result"); + assert.match(String(persisted.publication_error), /no readable result artifact/); +}); + +test("duplicate delivery queues publication recovery without blocking the webhook", async () => { + const secret = "duplicate-recovery-secret"; + const stateDir = tempStateDir(); + const config = testConfig(stateDir, secret); + const task = reviewTask("duplicate-blocked-recovery"); + Object.assign(task, { + state: "completed", + result_path: join(stateDir, "missing-duplicate-result.json"), + publication_state: "publication_pending", + installation_id: 12345, + }); + writeFileSync(join(config.tasksDir, `${task.task_id}.json`), JSON.stringify(task)); + writeFileSync(join(config.deliveriesDir, `${task.task_id}.json`), JSON.stringify({ + delivery_id: task.task_id, + task_id: task.task_id, + state: "completed", + publication_state: "publication_pending", + })); + const body = Buffer.from("{}"); + const response = await callWebhook(body, { + "X-GitHub-Event": "ping", + "X-GitHub-Delivery": String(task.task_id), + "X-Hub-Signature-256": signature(secret, body), + }, "auto", config); + assert.equal(response.body.action, "duplicate_retry_queued"); + assert.equal(response.body.queued, true); + assert.equal(response.body.publication_state, "publication_pending"); + const recovered = await runTask(config, String(task.task_id)); + assert.equal(recovered.publication_state, "publication_blocked_missing_result"); +}); + +test("startup publication recovery skips corrupt task records and continues", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + writeFileSync(join(config.tasksDir, "corrupt-task.json"), "{not-json"); + const task = reviewTask("recover-after-corrupt-task"); + Object.assign(task, { + state: "completed", + result_path: join(stateDir, "missing-startup-result.json"), + publication_state: "publication_pending", + installation_id: 12345, + }); + writeFileSync(join(config.tasksDir, `${task.task_id}.json`), JSON.stringify(task)); + const debugMessages: string[] = []; + let tokenCalls = 0; + const attempted = await recoverPendingPublications(config, (message) => debugMessages.push(message), async () => { + tokenCalls += 1; + return "unused-token"; + }); + assert.equal(attempted, 1); + assert.equal(tokenCalls, 0); + assert.ok(debugMessages.some((message) => /corrupt-task.*unreadable task/.test(message))); + const persisted = JSON.parse(readFileSync(join(config.tasksDir, `${task.task_id}.json`), "utf8")) as JsonObject; + assert.equal(persisted.publication_state, "publication_blocked_missing_result"); +}); + +test("blocks publication recovery for legacy tasks without an isolation receipt", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("resume-unverified-runtime"); + delete task.runtime_isolation; + const resultPath = join(stateDir, "legacy-result.json"); + Object.assign(task, { + state: "completed", + result_path: resultPath, + publication_state: "publication_pending", + installation_id: 12345, + }); + writeFileSync(resultPath, JSON.stringify(completeReview())); + writeFileSync(join(config.tasksDir, `${task.task_id}.json`), JSON.stringify(task)); + let tokenCalls = 0; + + await resumeTaskPublication(config, String(task.task_id), () => {}, async () => { + tokenCalls += 1; + return "unused-token"; + }); + + const persisted = JSON.parse(readFileSync(join(config.tasksDir, `${task.task_id}.json`), "utf8")) as JsonObject; + assert.equal(tokenCalls, 0); + assert.equal(persisted.publication_state, "publication_blocked_unverified_runtime"); + assert.match(String(persisted.publication_error), /lacks a verified runtime-isolation receipt/); +}); + +test("skips publication recovery for a running task", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("resume-running-task"); + const resultPath = join(stateDir, "running-result.json"); + Object.assign(task, { + state: "running", + attempts: 2, + result_path: resultPath, + publication_state: "publication_pending", + installation_id: 12345, + }); + writeFileSync(resultPath, JSON.stringify(completeReview())); + writeFileSync(join(config.tasksDir, `${task.task_id}.json`), JSON.stringify(task)); + let tokenCalls = 0; + + await resumeTaskPublication(config, String(task.task_id), () => {}, async (_candidateConfig, _candidateTask) => { + tokenCalls += 1; + return "unused-token"; + }); + + const persisted = JSON.parse(readFileSync(join(config.tasksDir, `${task.task_id}.json`), "utf8")) as JsonObject; + assert.equal(tokenCalls, 0); + assert.equal(persisted.state, "running"); + assert.equal(persisted.attempts, 2); + assert.equal(persisted.publication_state, "publication_pending"); + assert.equal(persisted.publication_attempts, undefined); +}); + +test("recovers a review identity after local state loss when generated text contains a reserved marker", async () => { + const firstState = tempStateDir(); + const firstConfig = testConfig(firstState); + const firstTask = reviewTask("recovered-run"); + prepareReviewWorkspace(firstConfig, firstTask); + const firstResult = join(firstState, "result.json"); + const result = completeReview(); + result.summary = [ + "Reviewed the pull request. Copied text:", + ``, + "", + ``, + ].join("\n"); + writeFileSync(firstResult, JSON.stringify(result)); + let publishedBody = ""; + await withGithubApiMock((url, init) => { + const read = githubReadFixture(url, init); + if (read) return read; + publishedBody = String((JSON.parse(String(init.body)) as JsonObject).body); + return { + id: 405, + state: /\/events$/.test(url) ? "APPROVED" : "PENDING", + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-405", + submitted_at: "2026-07-14T00:00:00Z", + }; + }, async () => publishResultIfConfigured(firstConfig, firstTask, firstResult, "token")); + assert.equal(publishedBody.match(//g)?.length, 2); + assert.equal(publishedBody.match(//g)?.length, 2); + + const secondState = tempStateDir(); + const secondConfig = testConfig(secondState); + const secondTask = reviewTask("recovered-run"); + prepareReviewWorkspace(secondConfig, secondTask); + const secondResult = join(secondState, "result.json"); + writeFileSync(secondResult, JSON.stringify(result)); + let posts = 0; + await withGithubApiMock((url, init) => { + if (/\/pulls\/7$/.test(url)) return {head: {sha: "abc123"}, base: {sha: "base123"}}; + if (init.method === "GET") return [{ + id: 405, + state: "APPROVED", + commit_id: "abc123", + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-405", + submitted_at: "2026-07-14T00:00:00Z", + body: publishedBody, + user: covencatBot(), + }]; + posts += 1; + return {id: 406}; + }, async () => publishResultIfConfigured(secondConfig, secondTask, secondResult, "token")); + assert.equal(posts, 0); + assert.equal(secondTask.publication_state, "publication_skipped_duplicate"); + assert.equal(secondTask.publication_review_id, 405); +}); + +test("submits a matching pending review after a crash instead of creating another", async () => { + const seedState = tempStateDir(); + const seedConfig = testConfig(seedState); + const seedTask = reviewTask("pending-recovery"); + prepareReviewWorkspace(seedConfig, seedTask); + const seedResult = join(seedState, "result.json"); + writeFileSync(seedResult, JSON.stringify(completeReview())); + let signedBody = ""; + await withGithubApiMock((url, init) => { + const read = githubReadFixture(url, init); + if (read) return read; + signedBody = String((JSON.parse(String(init.body)) as JsonObject).body || signedBody); + return { + id: 409, + state: /\/events$/.test(url) ? "APPROVED" : "PENDING", + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-409", + }; + }, async () => publishResultIfConfigured(seedConfig, seedTask, seedResult, "token")); + + const retryState = tempStateDir(); + const retryConfig = testConfig(retryState); + const retryTask = reviewTask("pending-recovery"); + prepareReviewWorkspace(retryConfig, retryTask); + const retryResult = join(retryState, "result.json"); + writeFileSync(retryResult, JSON.stringify(completeReview())); + let creates = 0; + let submissions = 0; + await withGithubApiMock((url, init) => { + if (/\/pulls\/7$/.test(url)) return {head: {sha: "abc123"}, base: {sha: "base123"}}; + if (init.method === "GET") return [{ + id: 409, + state: "PENDING", + commit_id: "abc123", + body: signedBody, + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-409", + user: covencatBot(), + }]; + if (/\/pulls\/7\/reviews$/.test(url)) creates += 1; + if (/\/events$/.test(url)) submissions += 1; + return {id: 409, state: "APPROVED", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-409"}; + }, async () => publishResultIfConfigured(retryConfig, retryTask, retryResult, "token")); + assert.equal(creates, 0); + assert.equal(submissions, 1); + assert.equal(retryTask.publication_state, "published_review_recovered"); +}); + +test("retries a failed decisive-review dismissal without republishing the new review", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview())); + const firstTask = reviewTask("supersession-first"); + const secondTask = reviewTask("supersession-second"); + const retryTask = reviewTask("supersession-second"); + prepareReviewWorkspace(config, firstTask); + prepareReviewWorkspace(config, secondTask); + let nextReviewId = 500; + let activeReviewId = 0; + let posts = 0; + let dismissals = 0; + const reviewBodies = new Map(); + const reviewStates = new Map(); + await withGithubApiMock((url, init) => { + if (init.method === "GET" && /\/pulls\/7$/.test(url)) return {head: {sha: "abc123"}, base: {sha: "base123"}}; + const exactReviewId = Number(url.match(/\/pulls\/7\/reviews\/(\d+)$/)?.[1] || 0); + if (init.method === "GET" && exactReviewId) { + return { + id: exactReviewId, + state: reviewStates.get(exactReviewId) || "APPROVED", + body: reviewBodies.get(exactReviewId) || "signed review body", + html_url: `https://github.com/OpenCoven/example/pull/7#pullrequestreview-${exactReviewId}`, + user: covencatBot(), + }; + } + if (init.method === "GET") return []; + if (init.method === "POST" && /\/pulls\/7\/reviews$/.test(url)) { + posts += 1; + nextReviewId += 1; + activeReviewId = nextReviewId; + reviewBodies.set(activeReviewId, String((JSON.parse(String(init.body)) as JsonObject).body || "")); + reviewStates.set(activeReviewId, "PENDING"); + return {id: activeReviewId, state: "PENDING", html_url: `https://github.com/OpenCoven/example/pull/7#pullrequestreview-${activeReviewId}`}; + } + if (init.method === "POST") { + reviewStates.set(activeReviewId, "APPROVED"); + return {id: activeReviewId, state: "APPROVED", html_url: `https://github.com/OpenCoven/example/pull/7#pullrequestreview-${activeReviewId}`}; + } + if (/\/dismissals$/.test(url)) { + dismissals += 1; + if (dismissals === 1) return {httpStatus: 500, response: {message: "temporary failure"}}; + assert.equal((JSON.parse(String(init.body)) as JsonObject).event, "DISMISS"); + const dismissedId = Number(url.match(/\/reviews\/(\d+)\/dismissals$/)?.[1] || 0); + reviewStates.set(dismissedId, "DISMISSED"); + return {state: "DISMISSED"}; + } + if (init.method === "PUT" && exactReviewId) { + reviewBodies.set(exactReviewId, String((JSON.parse(String(init.body)) as JsonObject).body || "")); + return {id: exactReviewId, state: reviewStates.get(exactReviewId) || "APPROVED"}; + } + return {id: nextReviewId}; + }, async () => { + await publishResultIfConfigured(config, firstTask, resultPath, "token"); + await publishResultIfConfigured(config, secondTask, resultPath, "token"); + assert.equal(secondTask.publication_supersession_state, "prior_decisive_review_dismissal_failed"); + await publishResultIfConfigured(config, retryTask, resultPath, "token"); + }); + assert.equal(posts, 2); + assert.equal(dismissals, 2); + assert.equal(retryTask.publication_state, "publication_skipped_duplicate"); + assert.equal(retryTask.publication_supersession_state, "prior_decisive_review_dismissed"); +}); + +test("does not let an older revision supersede a current-head GitHub review", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("late-old-revision"); + const evidence = task.review_evidence as JsonObject; + evidence.head_sha = "oldsha"; + evidence.workspace_head_sha = "oldsha"; + evidence.publication_workspace_head_sha = "oldsha"; + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview())); + const writes: string[] = []; + await withGithubApiMock((url, init) => { + if (/\/pulls\/7$/.test(url)) return {head: {sha: "abc123"}, base: {sha: "base123"}}; + if (init.method === "GET") return [{ + id: 601, + state: "APPROVED", + commit_id: "abc123", + submitted_at: "2026-07-14T12:00:00Z", + body: `current review\n\n${signedPublicationMarker("b".repeat(64))}`, + user: covencatBot(), + }]; + writes.push(`${String(init.method)} ${url}`); + return {id: 602}; + }, async () => publishResultIfConfigured(config, task, resultPath, "token")); + assert.deepEqual(writes, []); + assert.equal(task.publication_state, "publication_skipped_stale_revision"); +}); + +test("does not let an older same-head run supersede a newer GitHub publication", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("late-same-head-run"); + task.created_at = "2026-07-14T10:00:00Z"; + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview())); + let writes = 0; + await withGithubApiMock((url, init) => { + if (/\/pulls\/7$/.test(url)) return {head: {sha: "abc123"}, base: {sha: "base123"}}; + if (init.method === "GET") return [{ + id: 603, + state: "APPROVED", + commit_id: "abc123", + submitted_at: "2026-07-14T12:01:00Z", + body: `newer review\n\n${signedPublicationMarker("c".repeat(64), "2026-07-14T11:00:00Z")}`, + user: covencatBot(), + }]; + writes += 1; + return {id: 604}; + }, async () => publishResultIfConfigured(config, task, resultPath, "token")); + assert.equal(writes, 0); + assert.equal(task.publication_state, "publication_skipped_stale_run"); +}); + +test("compares stale runs against the evidence head even after reviews on another head", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("force-pushed-back-run"); + task.created_at = "2026-07-14T10:00:00Z"; + const evidence = task.review_evidence as JsonObject; + evidence.head_sha = "head-h"; + evidence.workspace_head_sha = "head-h"; + evidence.publication_workspace_head_sha = "head-h"; + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview())); + let writes = 0; + await withGithubApiMock((url, init) => { + if (/\/pulls\/7$/.test(url)) return {head: {sha: "head-h"}, base: {sha: "base123"}}; + if (init.method === "GET") return [ + { + id: 605, + state: "APPROVED", + commit_id: "head-h", + body: signedPublicationMarker("d".repeat(64), "2026-07-14T11:00:00Z"), + user: covencatBot(), + }, + { + id: 606, + state: "APPROVED", + commit_id: "head-j", + body: signedPublicationMarker("e".repeat(64), "2026-07-14T12:00:00Z"), + user: covencatBot(), + }, + ]; + writes += 1; + return {id: 607}; + }, async () => publishResultIfConfigured(config, task, resultPath, "token")); + assert.equal(writes, 0); + assert.equal(task.publication_state, "publication_skipped_stale_run"); +}); + +test("ignores forged markers and markers copied into non-App reviews", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("forged-marker"); + task.created_at = "2026-07-14T10:00:00Z"; + prepareReviewWorkspace(config, task); + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview())); + let creates = 0; + await withGithubApiMock((url, init) => { + if (/\/pulls\/7$/.test(url)) return {head: {sha: "abc123"}, base: {sha: "base123"}}; + if (init.method === "GET") return [ + { + id: 608, + state: "APPROVED", + commit_id: "abc123", + body: signedPublicationMarker("f".repeat(64), "2099-01-01T00:00:00Z"), + user: {login: "attacker", type: "User"}, + }, + { + id: 6081, + state: "APPROVED", + commit_id: "abc123", + body: `\n\n`, + user: covencatBot(), + }, + ]; + if (/\/pulls\/7\/reviews$/.test(url)) { + creates += 1; + return {id: 609, state: "PENDING", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-609"}; + } + return {id: 609, state: "APPROVED", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-609"}; + }, async () => publishResultIfConfigured(config, task, resultPath, "token")); + assert.equal(creates, 1); + assert.equal(task.publication_state, "published_review"); +}); + +test("deduplicates a deployed head-only publication identity after the base-aware upgrade", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("legacy-head-only-identity"); + task.created_at = "2026-07-14T00:00:00Z"; + prepareReviewWorkspace(config, task); + const result = completeReview(); + const resultPath = join(stateDir, "legacy-identity-result.json"); + writeFileSync(resultPath, JSON.stringify(result)); + const legacyIdentity = publicationIdentityFixture(task, result, false); + const upgradedIdentity = publicationIdentityFixture(task, result, true); + const legacyBody = `Legacy review\n\n${signedPublicationMarker(legacyIdentity, String(task.created_at))}`; + let writes = 0; + await withGithubApiMock((url, init) => { + if (/\/pulls\/7$/.test(url)) return {head: {sha: "abc123"}, base: {sha: "base123"}}; + if (init.method === "GET" && /\/pulls\/7\/reviews(?:\?|$)/.test(url)) return [{ + id: 6081, + state: "APPROVED", + commit_id: "abc123", + body: legacyBody, + submitted_at: "2026-07-14T00:01:00Z", + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-6081", + user: covencatBot(), + }]; + if (init.method === "GET") return []; + writes += 1; + return {id: 6081}; + }, async () => publishResultIfConfigured(config, task, resultPath, "token")); + assert.notEqual(legacyIdentity, upgradedIdentity); + assert.equal(writes, 0); + assert.equal(task.publication_state, "publication_skipped_duplicate"); + assert.equal(task.publication_identity, upgradedIdentity); + assert.equal(task.publication_review_id, 6081); +}); + +test("a stored legacy identity from another base is not deduplicated on the current base", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("legacy-base-transition"); + task.created_at = "2026-07-14T00:05:00Z"; + prepareReviewWorkspace(config, task); + const result = completeReview(); + const resultPath = join(stateDir, "legacy-base-transition-result.json"); + writeFileSync(resultPath, JSON.stringify(result)); + const legacyIdentity = publicationIdentityFixture(task, result, false); + const legacyBody = `Legacy base-A review\n\n${signedPublicationMarker(legacyIdentity, "2026-07-14T00:00:00Z")}`; + const recordName = `${createHash("sha256").update("OpenCoven/example#7").digest("hex").slice(0, 24)}.json`; + writeFileSync(join(config.publicationsDir, recordName), JSON.stringify({ + identity: legacyIdentity, + review_id: 6082, + review_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-6082", + review_body: legacyBody, + decision: "APPROVED", + head_sha: "abc123", + base_sha: "base-A", + })); + let creates = 0; + let activeId = 6082; + const dismissals: number[] = []; + await withGithubApiMock((url, init) => { + if (/\/pulls\/7$/.test(url)) return {head: {sha: "abc123"}, base: {sha: "base123"}}; + if (init.method === "GET" && /\/pulls\/7\/reviews(?:\?|$)/.test(url)) return [{ + id: 6082, + state: "APPROVED", + commit_id: "abc123", + body: legacyBody, + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-6082", + user: covencatBot(), + }]; + if (init.method === "GET") return []; + if (init.method === "POST" && /\/pulls\/7\/reviews$/.test(url)) { + creates += 1; + activeId = 6083; + return {id: activeId, state: "PENDING", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-6083"}; + } + if (init.method === "POST" && /\/events$/.test(url)) { + return {id: activeId, state: "APPROVED", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-6083"}; + } + if (init.method === "PUT" && /\/dismissals$/.test(url)) { + dismissals.push(Number(url.match(/\/reviews\/(\d+)\/dismissals$/)?.[1] || 0)); + return {id: 6082, state: "DISMISSED"}; + } + return {id: activeId}; + }, async () => publishResultIfConfigured(config, task, resultPath, "token")); + assert.equal(creates, 1); + assert.deepEqual(dismissals, [6082]); + assert.equal(task.publication_state, "published_review"); + assert.equal(task.publication_review_id, 6083); +}); + +test("same-head reviews on different bases get distinct identities and current-base ordering", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const result = completeReview(); + const resultPath = join(stateDir, "base-identity-result.json"); + writeFileSync(resultPath, JSON.stringify(result)); + const oldBaseTask = reviewTask("same-head-base-identity"); + const currentBaseTask = reviewTask("same-head-base-identity"); + oldBaseTask.created_at = "2026-07-14T00:00:00Z"; + currentBaseTask.created_at = "2026-07-14T00:05:00Z"; + (oldBaseTask.review_evidence as JsonObject).base_sha = "old-base"; + prepareReviewWorkspace(config, oldBaseTask); + let currentBase = "old-base"; + let nextId = 6085; + const reviews: JsonObject[] = []; + const dismissed: number[] = []; + await withGithubApiMock((url, init) => { + if (/\/pulls\/7$/.test(url)) return {head: {sha: "abc123"}, base: {sha: currentBase}}; + if (init.method === "GET" && /\/pulls\/7\/reviews$/.test(url)) return reviews; + if (init.method === "GET") return []; + if (init.method === "POST" && /\/pulls\/7\/reviews$/.test(url)) { + nextId += 1; + const payload = JSON.parse(String(init.body)) as JsonObject; + const review = { + id: nextId, + state: "PENDING", + commit_id: "abc123", + body: payload.body, + html_url: `https://github.com/OpenCoven/example/pull/7#pullrequestreview-${nextId}`, + user: covencatBot(), + }; + reviews.push(review); + return review; + } + if (init.method === "POST" && /\/events$/.test(url)) { + const id = Number(url.match(/\/reviews\/(\d+)\/events$/)?.[1] || 0); + const review = reviews.find((candidate) => candidate.id === id) as JsonObject; + review.state = "APPROVED"; + review.submitted_at = currentBase === "old-base" ? "2026-07-14T00:01:00Z" : "2026-07-14T00:06:00Z"; + return review; + } + if (init.method === "PUT" && /\/dismissals$/.test(url)) { + const id = Number(url.match(/\/reviews\/(\d+)\/dismissals$/)?.[1] || 0); + dismissed.push(id); + const review = reviews.find((candidate) => candidate.id === id) as JsonObject; + review.state = "DISMISSED"; + return review; + } + return {id: nextId}; + }, async () => { + await publishResultIfConfigured(config, oldBaseTask, resultPath, "token"); + currentBase = "base123"; + await publishResultIfConfigured(config, currentBaseTask, resultPath, "token"); + }); + assert.notEqual(oldBaseTask.publication_identity, currentBaseTask.publication_identity); + assert.equal(reviews.length, 2); + assert.match(String(reviews[0].body), /covencat-review-base:old-base/); + assert.match(String(reviews[1].body), /covencat-review-base:base123/); + assert.deepEqual(dismissed, [6086]); + assert.equal(currentBaseTask.publication_state, "published_review"); + assert.equal(currentBaseTask.publication_review_id, 6087); +}); + +test("reconciles existing publications across signing-key rotation", async () => { + const stateDir = tempStateDir(); + const oldConfig = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: join(stateDir, "policy.json"), + GITHUB_WEBHOOK_SECRET: "ingress-secret", + COVEN_PUBLICATION_SIGNING_SECRET: "old-signing-secret", + }, process.cwd()); + const firstTask = reviewTask("signing-key-rotation"); + prepareReviewWorkspace(oldConfig, firstTask); + const resultPath = join(stateDir, "rotation-result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview())); + let oldBody = ""; + await withGithubApiMock((url, init) => { + const read = githubReadFixture(url, init); + if (read) return read; + if (/\/pulls\/7\/reviews$/.test(url)) { + oldBody = String((JSON.parse(String(init.body)) as JsonObject).body || ""); + return {id: 6091, state: "PENDING", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-6091"}; + } + if (/\/events$/.test(url)) return {id: 6091, state: "APPROVED", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-6091"}; + return []; + }, async () => publishResultIfConfigured(oldConfig, firstTask, resultPath, "token")); + assert.match(oldBody, /covencat-publication-proof/); + + const rotatedConfig = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: join(stateDir, "policy.json"), + GITHUB_WEBHOOK_SECRET: "ingress-secret", + COVEN_PUBLICATION_SIGNING_SECRET: "new-signing-secret", + COVEN_PUBLICATION_PREVIOUS_SIGNING_SECRETS: "old-signing-secret", + }, process.cwd()); + const retryTask = reviewTask("signing-key-rotation"); + prepareReviewWorkspace(rotatedConfig, retryTask); + let writes = 0; + let resignedBody = ""; + await withGithubApiMock((url, init) => { + if (/\/pulls\/7$/.test(url)) return {head: {sha: "abc123"}, base: {sha: "base123"}}; + if (init.method === "GET" && /\/pulls\/7\/reviews/.test(url)) return [{ + id: 6091, + state: "APPROVED", + commit_id: "abc123", + body: oldBody, + user: covencatBot(), + }]; + if (init.method === "GET") return []; + writes += 1; + resignedBody = String((JSON.parse(String(init.body)) as JsonObject).body || ""); + return {id: 6091, state: "APPROVED", body: resignedBody}; + }, async () => publishResultIfConfigured(rotatedConfig, retryTask, resultPath, "token")); + assert.equal(writes, 1); + assert.notEqual(resignedBody, oldBody); + assert.match(resignedBody, /covencat-publication-proof/); + assert.equal(retryTask.publication_state, "publication_skipped_duplicate"); + assert.equal(retryTask.publication_review_id, 6091); + + for (const entry of readdirSync(rotatedConfig.publicationsDir)) { + rmSync(join(rotatedConfig.publicationsDir, entry), {recursive: true, force: true}); + } + const newOnlyConfig = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: join(stateDir, "policy.json"), + GITHUB_WEBHOOK_SECRET: "ingress-secret", + COVEN_PUBLICATION_SIGNING_SECRET: "new-signing-secret", + }, process.cwd()); + const finalRetry = reviewTask("signing-key-rotation"); + prepareReviewWorkspace(newOnlyConfig, finalRetry); + let finalWrites = 0; + await withGithubApiMock((url, init) => { + if (/\/pulls\/7$/.test(url)) return {head: {sha: "abc123"}, base: {sha: "base123"}}; + if (init.method === "GET" && /\/pulls\/7\/reviews/.test(url)) return [{ + id: 6091, + state: "APPROVED", + commit_id: "abc123", + body: resignedBody, + user: covencatBot(), + }]; + if (init.method === "GET") return []; + finalWrites += 1; + return {id: 6091}; + }, async () => publishResultIfConfigured(newOnlyConfig, finalRetry, resultPath, "token")); + assert.equal(finalWrites, 0); + assert.equal(finalRetry.publication_state, "publication_skipped_duplicate"); + assert.equal(finalRetry.publication_review_id, 6091); +}); + +test("dismisses a decisive review when the head changes during submission", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("head-race"); + prepareReviewWorkspace(config, task); + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview())); + let headReads = 0; + const mutations: Array<{url: string; body: JsonObject}> = []; + await withGithubApiMock((url, init) => { + if (/\/pulls\/7$/.test(url)) { + headReads += 1; + return {head: {sha: headReads < 3 ? "abc123" : "new-head"}, base: {sha: "base123"}}; + } + if (init.method === "GET") return []; + const body = init.body ? JSON.parse(String(init.body)) as JsonObject : {}; + mutations.push({url, body}); + if (/\/pulls\/7\/reviews$/.test(url)) return {id: 610, state: "PENDING", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-610"}; + if (/\/events$/.test(url)) return {id: 610, state: "APPROVED", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-610"}; + return {id: 610, state: "DISMISSED"}; + }, async () => publishResultIfConfigured(config, task, resultPath, "token")); + assert.equal(mutations[0].body.event, undefined); + assert.equal(mutations[1].body.event, "APPROVE"); + assert.equal(mutations[2].body.event, "DISMISS"); + assert.match(mutations[3].url, /\/reviews\/610$/); + assert.equal(task.publication_state, "published_review_dismissed_stale"); + assert.equal(task.publication_decision, "DISMISSED"); +}); + +test("downgrades a decisive review when the base changes before pending review submission", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("base-before-submit"); + prepareReviewWorkspace(config, task); + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview())); + let revisionReads = 0; + const mutations: Array<{url: string; body: JsonObject}> = []; + + await withGithubApiMock((url, init) => { + if (/\/pulls\/7$/.test(url)) { + revisionReads += 1; + return { + head: {sha: "abc123"}, + base: {sha: revisionReads === 1 ? "base123" : "new-base"}, + }; + } + if (init.method === "GET") return []; + const body = init.body ? JSON.parse(String(init.body)) as JsonObject : {}; + mutations.push({url, body}); + if (/\/pulls\/7\/reviews$/.test(url)) { + return {id: 611, state: "PENDING", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-611"}; + } + if (/\/events$/.test(url)) { + return {id: 611, state: "COMMENTED", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-611"}; + } + return {id: 611}; + }, async () => publishResultIfConfigured(config, task, resultPath, "token")); + + const submission = mutations.find((mutation) => /\/events$/.test(mutation.url)); + assert.equal(revisionReads, 3); + assert.equal(submission?.body.event, "COMMENT"); + assert.match(String(submission?.body.body || ""), /head or base changed before this review was submitted/); + assert.equal(mutations.some((mutation) => /\/dismissals$/.test(mutation.url)), false); + assert.equal(task.publication_state, "published_review_stale_comment"); + assert.equal(task.publication_decision, "COMMENT"); +}); + +test("dismisses a decisive review when the base changes after submission", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("base-after-submit"); + prepareReviewWorkspace(config, task); + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview())); + let revisionReads = 0; + const mutations: Array<{url: string; body: JsonObject}> = []; + + await withGithubApiMock((url, init) => { + if (/\/pulls\/7$/.test(url)) { + revisionReads += 1; + return { + head: {sha: "abc123"}, + base: {sha: revisionReads < 3 ? "base123" : "new-base"}, + }; + } + if (init.method === "GET") return []; + const body = init.body ? JSON.parse(String(init.body)) as JsonObject : {}; + mutations.push({url, body}); + if (/\/pulls\/7\/reviews$/.test(url)) { + return {id: 612, state: "PENDING", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-612"}; + } + if (/\/events$/.test(url)) { + return {id: 612, state: "APPROVED", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-612"}; + } + return {id: 612, state: "DISMISSED"}; + }, async () => publishResultIfConfigured(config, task, resultPath, "token")); + + const submission = mutations.find((mutation) => /\/events$/.test(mutation.url)); + const dismissal = mutations.find((mutation) => /\/dismissals$/.test(mutation.url)); + assert.equal(revisionReads, 3); + assert.equal(submission?.body.event, "APPROVE"); + assert.equal(dismissal?.body.event, "DISMISS"); + assert.match(String(dismissal?.body.message || ""), /head or base changed/); + assert.equal(task.publication_state, "published_review_dismissed_stale"); + assert.equal(task.publication_decision, "DISMISSED"); +}); + +test("retries a failed head-race dismissal until the stale decision is removed", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const firstTask = reviewTask("head-race-retry"); + const retryTask = reviewTask("head-race-retry"); + prepareReviewWorkspace(config, firstTask); + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview())); + let firstHeadReads = 0; + let reviewBody = ""; + let dismissalAttempts = 0; + await withGithubApiMock((url, init) => { + if (/\/pulls\/7$/.test(url)) { + firstHeadReads += 1; + return {head: {sha: firstHeadReads < 3 ? "abc123" : "new-head"}, base: {sha: "base123"}}; + } + if (init.method === "GET") return []; + if (/\/pulls\/7\/reviews$/.test(url)) { + reviewBody = String((JSON.parse(String(init.body)) as JsonObject).body || ""); + return {id: 620, state: "PENDING", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-620"}; + } + if (/\/events$/.test(url)) return {id: 620, state: "APPROVED", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-620"}; + if (/\/dismissals$/.test(url)) { + dismissalAttempts += 1; + return {httpStatus: 500, response: {message: "temporary failure"}}; + } + return {id: 620}; + }, async () => publishResultIfConfigured(config, firstTask, resultPath, "token")); + assert.equal(firstTask.publication_state, "publication_failed"); + + await withGithubApiMock((url, init) => { + if (/\/pulls\/7$/.test(url)) return {head: {sha: "new-head"}, base: {sha: "base123"}}; + if (init.method === "GET") return [{ + id: 620, + state: "APPROVED", + commit_id: "abc123", + body: reviewBody, + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-620", + user: covencatBot(), + }]; + if (/\/dismissals$/.test(url)) { + dismissalAttempts += 1; + return {id: 620, state: "DISMISSED"}; + } + return {id: 620}; + }, async () => publishResultIfConfigured(config, retryTask, resultPath, "token")); + assert.equal(dismissalAttempts, 2); + assert.equal(retryTask.publication_state, "published_review_dismissed_stale"); + assert.equal(retryTask.publication_decision, "DISMISSED"); +}); + +test("recovery annotates an already-dismissed stale review without republishing it", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const task = reviewTask("dismissed-stale-annotation"); + task.created_at = "2026-07-14T00:00:00Z"; + prepareReviewWorkspace(config, task); + const result = completeReview(); + const resultPath = join(stateDir, "dismissed-stale-result.json"); + writeFileSync(resultPath, JSON.stringify(result)); + const identity = publicationIdentityFixture(task, result, true); + const existingBody = `Dismissed stale review\n\n${signedPublicationMarker(identity, String(task.created_at))}`; + const currentBody = `Current revision review\n\n${signedPublicationMarker("e".repeat(64), "2026-07-14T00:10:00Z")}`; + let annotatedBody = ""; + const writes: string[] = []; + await withGithubApiMock((url, init) => { + if (/\/pulls\/7$/.test(url)) return {head: {sha: "new-head"}, base: {sha: "base123"}}; + if (init.method === "GET" && /\/pulls\/7\/reviews(?:\?|$)/.test(url)) return [ + { + id: 740, + state: "DISMISSED", + commit_id: "abc123", + body: existingBody, + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-740", + user: covencatBot(), + }, + { + id: 741, + state: "APPROVED", + commit_id: "new-head", + body: currentBody, + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-741", + user: covencatBot(), + }, + ]; + if (init.method === "GET") return []; + writes.push(`${String(init.method)} ${url}`); + annotatedBody = String((JSON.parse(String(init.body)) as JsonObject).body || ""); + return {id: 740, state: "DISMISSED", body: annotatedBody}; + }, async () => publishResultIfConfigured(config, task, resultPath, "token")); + assert.deepEqual(writes, ["PUT https://api.github.com/repos/OpenCoven/example/pulls/7/reviews/740"]); + assert.match(annotatedBody, /dismissed automatically because the PR head or base changed/); + assert.match(annotatedBody, /$/); + assert.equal(task.publication_state, "publication_skipped_stale_revision"); + assert.equal(task.publication_decision, "DISMISSED"); +}); + +test("a pre-submit stale COMMENT does not dismiss the prior decisive review", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const firstTask = reviewTask("pre-submit-prior"); + const staleTask = reviewTask("pre-submit-stale"); + prepareReviewWorkspace(config, firstTask); + prepareReviewWorkspace(config, staleTask); + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview())); + let secondRun = false; + let secondHeadReads = 0; + let nextId = 630; + let activeId = 0; + let dismissals = 0; + await withGithubApiMock((url, init) => { + if (/\/pulls\/7$/.test(url)) { + if (!secondRun) return {head: {sha: "abc123"}, base: {sha: "base123"}}; + secondHeadReads += 1; + return {head: {sha: secondHeadReads === 1 ? "abc123" : "new-head"}, base: {sha: "base123"}}; + } + if (init.method === "GET") return []; + if (/\/pulls\/7\/reviews$/.test(url)) { + nextId += 1; + activeId = nextId; + return {id: activeId, state: "PENDING", html_url: `https://github.com/OpenCoven/example/pull/7#pullrequestreview-${activeId}`}; + } + if (/\/events$/.test(url)) { + const event = String((JSON.parse(String(init.body)) as JsonObject).event); + return {id: activeId, state: event === "COMMENT" ? "COMMENTED" : "APPROVED", html_url: `https://github.com/OpenCoven/example/pull/7#pullrequestreview-${activeId}`}; + } + if (/\/dismissals$/.test(url)) dismissals += 1; + return {id: activeId}; + }, async () => { + await publishResultIfConfigured(config, firstTask, resultPath, "token"); + secondRun = true; + await publishResultIfConfigured(config, staleTask, resultPath, "token"); + }); + assert.equal(dismissals, 0); + assert.equal(staleTask.publication_state, "published_review_stale_comment"); + assert.equal(staleTask.publication_decision, "COMMENT"); + assert.equal(staleTask.publication_supersession_state, "prior_decisive_review_retained_for_stale_replacement"); +}); + +test("retains a prior decisive review until a complete replacement is published", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const resultPath = join(stateDir, "result.json"); + const firstTask = reviewTask("chain-a"); + const secondTask = reviewTask("chain-b"); + const thirdTask = reviewTask("chain-c"); + for (const task of [firstTask, secondTask, thirdTask]) prepareReviewWorkspace(config, task); + let nextId = 700; + let activeId = 0; + const dismissedIds: number[] = []; + await withGithubApiMock((url, init) => { + const read = githubReadFixture(url, init); + if (read) return read; + if (/\/pulls\/7\/reviews$/.test(url)) { + nextId += 1; + activeId = nextId; + return {id: activeId, state: "PENDING", html_url: `https://github.com/OpenCoven/example/pull/7#pullrequestreview-${activeId}`}; + } + if (/\/events$/.test(url)) { + const event = String((JSON.parse(String(init.body)) as JsonObject).event); + return {id: activeId, state: event === "COMMENT" ? "COMMENTED" : "APPROVED", html_url: `https://github.com/OpenCoven/example/pull/7#pullrequestreview-${activeId}`}; + } + if (/\/dismissals$/.test(url)) { + const reviewId = Number(url.match(/reviews\/(\d+)\/dismissals$/)?.[1] || 0); + dismissedIds.push(reviewId); + return {id: reviewId, state: "DISMISSED"}; + } + return {id: activeId}; + }, async () => { + writeFileSync(resultPath, JSON.stringify(completeReview())); + await publishResultIfConfigured(config, firstTask, resultPath, "token"); + const incomplete = completeReview(); + (incomplete.review as JsonObject).limitations = ["Could not verify one environment-specific behavior."]; + writeFileSync(resultPath, JSON.stringify(incomplete)); + await publishResultIfConfigured(config, secondTask, resultPath, "token"); + assert.equal(secondTask.publication_decision, "COMMENT"); + assert.equal(secondTask.publication_supersession_state, "prior_decisive_review_retained_for_incomplete_replacement"); + assert.deepEqual(dismissedIds, []); + writeFileSync(resultPath, JSON.stringify(completeReview())); + await publishResultIfConfigured(config, thirdTask, resultPath, "token"); + }); + assert.deepEqual(dismissedIds, [701]); + assert.equal(thirdTask.publication_supersession_state, "prior_decisive_review_dismissed"); +}); + +test("a complete information-only COMMENT retains the prior decisive review", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const firstTask = reviewTask("info-comment-first"); + const commentTask = reviewTask("info-comment-second"); + prepareReviewWorkspace(config, firstTask); + prepareReviewWorkspace(config, commentTask); + const resultPath = join(stateDir, "info-comment-result.json"); + let activeId = 730; + let dismissals = 0; + await withGithubApiMock((url, init) => { + const read = githubReadFixture(url, init); + if (read) return read; + if (/\/pulls\/7\/reviews$/.test(url)) { + activeId += 1; + return {id: activeId, state: "PENDING", html_url: `https://github.com/OpenCoven/example/pull/7#pullrequestreview-${activeId}`}; + } + if (/\/events$/.test(url)) { + const event = String((JSON.parse(String(init.body)) as JsonObject).event); + return {id: activeId, state: event === "COMMENT" ? "COMMENTED" : "APPROVED", html_url: `https://github.com/OpenCoven/example/pull/7#pullrequestreview-${activeId}`}; + } + if (/\/dismissals$/.test(url)) { + dismissals += 1; + return {state: "DISMISSED"}; + } + return {id: activeId}; + }, async () => { + writeFileSync(resultPath, JSON.stringify(completeReview())); + await publishResultIfConfigured(config, firstTask, resultPath, "token"); + writeFileSync(resultPath, JSON.stringify(completeReview([{ + severity: "info", + file: "src/app.ts", + line: 12, + title: "Optional observation", + body: "This does not require a code change.", + recommendation: null, + }]))); + await publishResultIfConfigured(config, commentTask, resultPath, "token"); + }); + assert.equal(commentTask.publication_decision, "COMMENT"); + assert.equal(commentTask.publication_supersession_state, "prior_decisive_review_retained_for_comment_replacement"); + assert.equal(dismissals, 0); +}); + +test("serializes concurrent publication attempts for the same identity", async () => { + const stateDir = tempStateDir(); + const config = testConfig(stateDir); + const firstTask = reviewTask("concurrent-run"); + const secondTask = reviewTask("concurrent-run"); + prepareReviewWorkspace(config, firstTask); + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview())); + let posts = 0; + let publishedBody = ""; + await withGithubApiMock(async (url, init) => { + if (init.method === "GET" && /\/pulls\/7$/.test(url)) { + await new Promise((resolveDelay) => setTimeout(resolveDelay, 50)); + return {head: {sha: "abc123"}, base: {sha: "base123"}}; + } + if (init.method === "GET" && /\/pulls\/7\/reviews\/407$/.test(url)) { + return {id: 407, state: "APPROVED", body: publishedBody, html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-407", user: covencatBot()}; + } + if (init.method === "GET") return []; + if (/\/pulls\/7\/reviews$/.test(url)) { + posts += 1; + publishedBody = String((JSON.parse(String(init.body)) as JsonObject).body || ""); + return {id: 407, state: "PENDING", html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-407"}; + } + return {id: 407, state: "APPROVED", body: publishedBody, html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-407"}; + }, async () => Promise.all([ + publishResultIfConfigured(config, firstTask, resultPath, "token"), + publishResultIfConfigured(config, secondTask, resultPath, "token"), + ])); + assert.equal(posts, 1); + assert.equal(secondTask.publication_state, "publication_skipped_duplicate"); +}); + +test("redacts credentials and passes only allowlisted ambient environment keys", () => { + const secretText = [ + "ghs_1234567890", "ghp_1234567890", "github_pat_1234567890", + "sk-proj-1234567890", "Bearer topsecret", "eyJabc.def.ghi", + "https://user:password@example.com/path", + "-----BEGIN PRIVATE KEY-----\nprivate-data\n-----END PRIVATE KEY-----", + ].join("\n"); + const redacted = redactTokenish(secretText); + assert.doesNotMatch(redacted, /1234567890|topsecret|password|private-data|eyJabc/); + const env = sanitizedRuntimeEnvironment({ + PATH: "/bin", LANG: "C.UTF-8", SSH_AUTH_SOCK: "/tmp/agent.sock", + DATABASE_URL: "postgres://user:pass@db", AWS_ACCESS_KEY_ID: "AKIASECRET", + GITHUB_WEBHOOK_SECRET: "webhook-secret", + }); + assert.deepEqual(env, {PATH: "/bin", LANG: "C.UTF-8"}); +}); + +test("keeps idempotency marker after truncating and redacts issue publication text", async () => { + const stateDir = tempStateDir(); + const task: JsonObject = { + task_id: "long-issue", + repository: "OpenCoven/example", + publication: {mode: "comment"}, + task: {issue_number: 12}, + }; + const resultPath = join(stateDir, "result.json"); + writeFileSync(resultPath, JSON.stringify({status: "failure", summary: `${"x".repeat(80_000)} ghs_1234567890`, pr_body: "", files_changed: [], commits: []})); + let body = ""; + await withGithubApiMock((_url, init) => { + if (init.method === "GET") return []; + body = String((JSON.parse(String(init.body)) as JsonObject).body); + return {id: 408, html_url: "https://github.com/OpenCoven/example/issues/12#issuecomment-408"}; + }, async () => publishResultIfConfigured(testConfig(stateDir), task, resultPath, "token")); + assert.match(body, //); + assert.match(body, /$/); + assert.doesNotMatch(body, /ghs_1234567890/); + assert.ok(body.length < 60_000); +}); + +test("concurrent signed deliveries initialize one task without overwriting the winner", async () => { + const secret = "concurrent-delivery-secret"; + const stateDir = tempStateDir(); + const policyPath = join(stateDir, "policy.json"); + writeFileSync(policyPath, readFileSync(new URL("../config/example-policy.json", import.meta.url))); + const config = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: policyPath, + GITHUB_WEBHOOK_SECRET: secret, + }, process.cwd()); + const deliveryId = "concurrent-same-delivery"; + const firstPayload: JsonObject = { + action: "labeled", + installation: {id: 123456}, + repository: {id: 987654321, full_name: "OpenCoven/example", default_branch: "main"}, + issue: { + number: 101, + title: "First delivery payload", + body: "The first caller owns initialization.", + labels: [{name: "coven:fix"}], + }, + }; + const secondPayload: JsonObject = { + action: "labeled", + installation: {id: 123456}, + repository: {id: 987654321, full_name: "OpenCoven/example", default_branch: "main"}, + issue: { + number: 202, + title: "Conflicting duplicate payload", + body: "This duplicate must not overwrite the initialized task.", + labels: [{name: "coven:fix"}], + }, + }; + const request = (payload: JsonObject) => { + const rawBody = Buffer.from(JSON.stringify(payload)); + return { + method: "POST", + path: "/webhook", + headers: new Map([ + ["content-length", String(rawBody.length)], + ["x-github-event", "issues"], + ["x-github-delivery", deliveryId], + ["x-hub-signature-256", signature(secret, rawBody)], + ]), + rawBody, + }; + }; + + const [first, duplicate] = await Promise.all([ + handleRequest(config, request(firstPayload), () => {}), + handleRequest(config, request(secondPayload), () => {}), + ]); + + assert.equal(first.status, 200); + assert.equal(first.body.action, "accepted"); + assert.equal(duplicate.status, 200); + assert.equal(duplicate.body.action, "duplicate_task_queued"); + assert.equal(duplicate.body.queued, true); + assert.deepEqual(readdirSync(config.tasksDir), [`${deliveryId}.json`]); + const task = JSON.parse(readFileSync(join(config.tasksDir, `${deliveryId}.json`), "utf8")) as JsonObject; + assert.equal(task.state, "queued"); + assert.equal(task.attempts, 0); + assert.equal(((task.task as JsonObject).issue_number), 101); + assert.equal(((task.task as JsonObject).issue_title), "First delivery payload"); + const delivery = JSON.parse(readFileSync(join(config.deliveriesDir, `${deliveryId}.json`), "utf8")) as JsonObject; + assert.equal(delivery.payload_hash, createHash("sha256").update(stableCompact(firstPayload)).digest("hex")); +}); + +test("transient revision reconciliation failure remains runnable and succeeds on retry", async () => { + const stateDir = tempStateDir(); + const {privateKey} = generateKeyPairSync("rsa", {modulusLength: 2048}); + const config = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: join(stateDir, "policy.json"), + GITHUB_WEBHOOK_SECRET: "test-webhook-secret", + GITHUB_APP_ID: "1234", + COVEN_GITHUB_REVOCATION_EVENTS: "pull-request-and-push-verified", + GITHUB_APP_PRIVATE_KEY: privateKey.export({type: "pkcs8", format: "pem"}).toString(), + }, process.cwd()); + const policy: JsonObject = { + familiar: {id: "reviewer"}, + publication: {mode: "comment"}, + bot_usernames: ["covencat[bot]"], + enabled_triggers: ["pull_request.synchronize", "pull_request.edited", "pull_request.reopened", "push"], + }; + const task = buildTaskFromEvent("pull_request", "transient-reconciliation", { + action: "synchronize", + installation: {id: 123456}, + repository: {id: 987654321, full_name: "OpenCoven/example"}, + pull_request: { + number: 7, + head: {sha: "current-head", ref: "feature"}, + base: {sha: "current-base", ref: "main"}, + }, + }, policy); + task.policy_snapshot = policy; + const taskFile = join(config.tasksDir, `${task.task_id}.json`); + writeFileSync(taskFile, JSON.stringify(task)); + let tokenCalls = 0; + let revisionReads = 0; + + await withGithubApiMock((url, init) => { + if (/\/app\/installations\/123456\/access_tokens$/.test(url)) { + tokenCalls += 1; + return {token: `publication-token-${tokenCalls}`}; + } + if (/\/pulls\/7$/.test(url)) { + revisionReads += 1; + if (revisionReads === 1) return {httpStatus: 502, response: {message: "temporary upstream failure"}}; + return {head: {sha: "current-head"}, base: {sha: "current-base"}}; + } + if (init.method === "GET" && /\/pulls\/7\/reviews(?:\?|$)/.test(url)) return []; + throw new Error(`Unexpected GitHub request: ${String(init.method)} ${url}`); + }, async () => { + const first = await runTask(config, String(task.task_id)); + assert.equal(first.state, "failed"); + assert.equal(first.attempts, 1); + assert.equal(first.failure_category, "revision_reconciliation_failed"); + assert.equal(first.publication_state, "revision_reconciliation_retry_pending"); + assert.match(String(first.failure_detail), /temporary upstream failure/); + assert.ok(Date.parse(String(first.retry_not_before)) > Date.now()); + assert.deepEqual(runnableTaskIds(config), [String(task.task_id)]); + + const callsBeforeImmediateRetry = tokenCalls + revisionReads; + const deferred = await runTask(config, String(task.task_id)); + assert.equal(deferred.state, "failed"); + assert.equal(deferred.attempts, 1); + assert.equal(tokenCalls + revisionReads, callsBeforeImmediateRetry); + + const retryable = JSON.parse(readFileSync(taskFile, "utf8")) as JsonObject; + retryable.retry_not_before = "1970-01-01T00:00:00.000Z"; + writeFileSync(taskFile, JSON.stringify(retryable)); + const second = await runTask(config, String(task.task_id)); + assert.equal(second.state, "completed"); + assert.equal(second.attempts, 2); + assert.equal(second.publication_state, "revision_reconciled_no_stale_reviews"); + assert.equal(second.retry_not_before, undefined); + assert.deepEqual(runnableTaskIds(config), []); + }); + + assert.equal(tokenCalls, 2); + assert.equal(revisionReads, 3); + const persisted = JSON.parse(readFileSync(join(config.tasksDir, `${task.task_id}.json`), "utf8")) as JsonObject; + assert.equal(persisted.state, "completed"); + assert.equal(persisted.failure_category, undefined); + assert.equal(persisted.failure_detail, undefined); +}); + +test("signed actionless push enumerates open base pull requests and dismisses stale decisive reviews", async () => { + const secret = "actionless-push-secret"; + const stateDir = tempStateDir(); + const policyPath = join(stateDir, "policy.json"); + writeFileSync(policyPath, JSON.stringify({ + version: 1, + installations: { + "123456": { + repositories: { + "987654321": { + enabled_triggers: ["pull_request.synchronize", "pull_request.edited", "pull_request.reopened", "push"], + bot_usernames: ["covencat[bot]"], + familiar: {id: "reviewer"}, + publication: {mode: "comment"}, + }, + }, + }, + }, + })); + const {privateKey} = generateKeyPairSync("rsa", {modulusLength: 2048}); + const config = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: policyPath, + GITHUB_WEBHOOK_SECRET: secret, + COVEN_PUBLICATION_SIGNING_SECRET: "test-webhook-secret", + GITHUB_APP_ID: "1234", + COVEN_GITHUB_REVOCATION_EVENTS: "pull-request-and-push-verified", + GITHUB_APP_PRIVATE_KEY: privateKey.export({type: "pkcs8", format: "pem"}).toString(), + }, process.cwd()); + const payload = Buffer.from(JSON.stringify({ + ref: "refs/heads/main", + before: "old-base", + after: "new-base", + commits: [{id: "new-base"}], + installation: {id: 123456}, + repository: {id: 987654321, full_name: "OpenCoven/example", default_branch: "main"}, + })); + const deliveryId = "actionless-push-reconciliation"; + const staleBody = `Stale review\n\n${signedBasePublicationMarker("a".repeat(64), "old-base", "2026-07-14T00:00:00Z")}`; + const urls: string[] = []; + const dismissed: number[] = []; + + await withGithubApiMock((url, init) => { + urls.push(url); + if (/\/app\/installations\/123456\/access_tokens$/.test(url)) return {token: "push-publication-token"}; + if (init.method === "GET" && /\/pulls\?/.test(url)) return [{number: 7}]; + if (/\/pulls\/7$/.test(url)) return {head: {sha: "feature-head"}, base: {sha: "new-base"}}; + if (init.method === "GET" && /\/pulls\/7\/reviews(?:\?|$)/.test(url)) return [{ + id: 770, + state: "CHANGES_REQUESTED", + commit_id: "feature-head", + body: staleBody, + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-770", + user: covencatBot(), + }]; + if (init.method === "PUT" && /\/dismissals$/.test(url)) { + dismissed.push(770); + return {id: 770, state: "DISMISSED", body: staleBody}; + } + if (init.method === "PUT" && /\/reviews\/770$/.test(url)) { + return {id: 770, state: "DISMISSED", body: String((JSON.parse(String(init.body)) as JsonObject).body || "")}; + } + throw new Error(`Unexpected GitHub request: ${String(init.method)} ${url}`); + }, async () => { + const response = await callWebhook(payload, { + "X-GitHub-Event": "push", + "X-GitHub-Delivery": deliveryId, + "X-Hub-Signature-256": signature(secret, payload), + }, "auto", config); + assert.equal(response.status, 200); + assert.equal(response.body.action, "accepted"); + assert.equal(response.body.state, "queued"); + const queued = JSON.parse(readFileSync(join(config.tasksDir, `${deliveryId}.json`), "utf8")) as JsonObject; + assert.equal(queued.trigger, "base_branch_revision"); + assert.equal(((queued.task as JsonObject).kind), "reconcile_base_branch_push"); + assert.equal(((queued.task as JsonObject).base_ref), "main"); + const completed = await runTask(config, deliveryId); + assert.equal(completed.state, "completed"); + assert.equal(completed.publication_state, "stale_decisive_reviews_dismissed"); + assert.deepEqual(completed.dismissed_review_ids, [770]); + }); + + const pullListUrl = urls.find((url) => /\/pulls\?/.test(url)); + assert.ok(pullListUrl); + assert.equal(new URL(String(pullListUrl)).searchParams.get("state"), "open"); + assert.equal(new URL(String(pullListUrl)).searchParams.get("base"), "main"); + assert.deepEqual(dismissed, [770]); +}); + +test("base-only reconciliation dismisses base-aware and untracked legacy decisive reviews", async () => { + const stateDir = tempStateDir(); + const {privateKey} = generateKeyPairSync("rsa", {modulusLength: 2048}); + const config = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: join(stateDir, "policy.json"), + GITHUB_WEBHOOK_SECRET: "test-webhook-secret", + GITHUB_APP_ID: "1234", + COVEN_GITHUB_REVOCATION_EVENTS: "pull-request-and-push-verified", + GITHUB_APP_PRIVATE_KEY: privateKey.export({type: "pkcs8", format: "pem"}).toString(), + }, process.cwd()); + const policy: JsonObject = { + familiar: {id: "reviewer"}, + publication: {mode: "comment"}, + bot_usernames: ["covencat[bot]"], + enabled_triggers: ["pull_request.synchronize", "pull_request.edited", "pull_request.reopened", "push"], + }; + const task = buildTaskFromEvent("pull_request", "base-only-reconciliation", { + action: "edited", + installation: {id: 123456}, + repository: {id: 987654321, full_name: "OpenCoven/example"}, + pull_request: { + number: 7, + head: {sha: "same-head", ref: "feature"}, + base: {sha: "new-base", ref: "main"}, + }, + }, policy); + task.policy_snapshot = policy; + writeFileSync(join(config.tasksDir, `${task.task_id}.json`), JSON.stringify(task)); + const baseAwareBody = `Prior-base review\n\n${signedBasePublicationMarker("b".repeat(64), "old-base", "2026-07-14T00:00:00Z")}`; + const legacyBody = `Legacy head-only review\n\n${signedPublicationMarker("c".repeat(64), "2026-07-14T00:01:00Z")}`; + const dismissed: number[] = []; + + await withGithubApiMock((url, init) => { + if (/\/app\/installations\/123456\/access_tokens$/.test(url)) return {token: "base-publication-token"}; + if (/\/pulls\/7$/.test(url)) return {head: {sha: "same-head"}, base: {sha: "new-base"}}; + if (init.method === "GET" && /\/pulls\/7\/reviews(?:\?|$)/.test(url)) return [ + { + id: 780, + state: "APPROVED", + commit_id: "same-head", + body: baseAwareBody, + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-780", + user: covencatBot(), + }, + { + id: 781, + state: "CHANGES_REQUESTED", + commit_id: "same-head", + body: legacyBody, + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-781", + user: covencatBot(), + }, + ]; + if (init.method === "PUT" && /\/dismissals$/.test(url)) { + const id = Number(url.match(/\/reviews\/(\d+)\/dismissals$/)?.[1] || 0); + dismissed.push(id); + return {id, state: "DISMISSED"}; + } + if (init.method === "PUT" && /\/reviews\/(?:780|781)$/.test(url)) { + const id = Number(url.match(/\/reviews\/(\d+)$/)?.[1] || 0); + return {id, state: "DISMISSED", body: String((JSON.parse(String(init.body)) as JsonObject).body || "")}; + } + throw new Error(`Unexpected GitHub request: ${String(init.method)} ${url}`); + }, async () => runTask(config, String(task.task_id))); + + assert.deepEqual(dismissed, [780, 781]); + const persisted = JSON.parse(readFileSync(join(config.tasksDir, `${task.task_id}.json`), "utf8")) as JsonObject; + assert.equal(persisted.state, "completed"); + assert.equal(persisted.publication_state, "stale_decisive_reviews_dismissed"); + assert.deepEqual(persisted.dismissed_review_ids, [780, 781]); +}); + +test("reconciliation repeats after a revision race and dismisses stale decisions from both passes", async () => { + const stateDir = tempStateDir(); + const {privateKey} = generateKeyPairSync("rsa", {modulusLength: 2048}); + const config = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: join(stateDir, "policy.json"), + GITHUB_WEBHOOK_SECRET: "test-webhook-secret", + GITHUB_APP_ID: "1234", + COVEN_GITHUB_REVOCATION_EVENTS: "pull-request-and-push-verified", + GITHUB_APP_PRIVATE_KEY: privateKey.export({type: "pkcs8", format: "pem"}).toString(), + }, process.cwd()); + const policy: JsonObject = { + familiar: {id: "reviewer"}, + publication: {mode: "comment"}, + bot_usernames: ["covencat[bot]"], + enabled_triggers: ["pull_request.synchronize", "pull_request.edited", "pull_request.reopened", "push"], + }; + const task = buildTaskFromEvent("pull_request", "revision-race-reconciliation", { + action: "synchronize", + installation: {id: 123456}, + repository: {id: 987654321, full_name: "OpenCoven/example"}, + pull_request: { + number: 7, + head: {sha: "head-a", ref: "feature"}, + base: {sha: "base-a", ref: "main"}, + }, + }, policy); + task.policy_snapshot = policy; + writeFileSync(join(config.tasksDir, `${task.task_id}.json`), JSON.stringify(task)); + const priorBody = `Older revision review\n\n${signedBasePublicationMarker("d".repeat(64), "base-zero", "2026-07-14T00:00:00Z")}`; + const firstRevisionBody = `First observed revision review\n\n${signedBasePublicationMarker("e".repeat(64), "base-a", "2026-07-14T00:01:00Z")}`; + const reviews: JsonObject[] = [ + { + id: 790, + state: "APPROVED", + commit_id: "head-zero", + body: priorBody, + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-790", + user: covencatBot(), + }, + { + id: 791, + state: "CHANGES_REQUESTED", + commit_id: "head-a", + body: firstRevisionBody, + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-791", + user: covencatBot(), + }, + ]; + let revisionReads = 0; + let reviewReads = 0; + const dismissed: number[] = []; + + await withGithubApiMock((url, init) => { + if (/\/app\/installations\/123456\/access_tokens$/.test(url)) return {token: "race-publication-token"}; + if (/\/pulls\/7$/.test(url)) { + revisionReads += 1; + return revisionReads === 1 + ? {head: {sha: "head-a"}, base: {sha: "base-a"}} + : {head: {sha: "head-b"}, base: {sha: "base-b"}}; + } + if (init.method === "GET" && /\/pulls\/7\/reviews(?:\?|$)/.test(url)) { + reviewReads += 1; + return reviews; + } + if (init.method === "PUT" && /\/dismissals$/.test(url)) { + const id = Number(url.match(/\/reviews\/(\d+)\/dismissals$/)?.[1] || 0); + dismissed.push(id); + return {id, state: "DISMISSED"}; + } + if (init.method === "PUT" && /\/reviews\/(?:790|791)$/.test(url)) { + const id = Number(url.match(/\/reviews\/(\d+)$/)?.[1] || 0); + return {id, state: "DISMISSED", body: String((JSON.parse(String(init.body)) as JsonObject).body || "")}; + } + throw new Error(`Unexpected GitHub request: ${String(init.method)} ${url}`); + }, async () => runTask(config, String(task.task_id))); + + assert.equal(revisionReads, 4); + assert.equal(reviewReads, 2); + assert.deepEqual(dismissed, [790, 791]); + const persisted = JSON.parse(readFileSync(join(config.tasksDir, `${task.task_id}.json`), "utf8")) as JsonObject; + assert.equal(persisted.state, "completed"); + assert.deepEqual(persisted.reconciled_revision, {head_sha: "head-b", base_sha: "base-b"}); + assert.deepEqual(persisted.dismissed_review_ids, [790, 791]); +}); + +test("missing external-isolation attestation blocks before attempt creation or token use", async () => { + const stateDir = tempStateDir(); + const config = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: join(stateDir, "policy.json"), + GITHUB_WEBHOOK_SECRET: "external-isolation-secret", + COVEN_RUNTIME_ISOLATION: "bwrap", + COVEN_RUNTIME_ROOTFS: join(stateDir, "unused-rootfs"), + }, process.cwd()); + const task = buildTaskFromEvent("issues", "missing-external-isolation", { + action: "labeled", + installation: {id: 123456}, + repository: { + id: 987654321, + full_name: "OpenCoven/example", + clone_url: "https://github.com/OpenCoven/example.git", + default_branch: "main", + }, + issue: {number: 303, title: "Isolation gate", body: "Do not spend credentials.", labels: [{name: "coven:fix"}]}, + }, { + familiar: {id: "reviewer"}, + trigger_labels: ["coven:fix"], + publication: {mode: "record_only"}, + }); + writeFileSync(join(config.tasksDir, `${task.task_id}.json`), JSON.stringify(task)); + let githubCalls = 0; + + const blocked = await withGithubApiMock(() => { + githubCalls += 1; + throw new Error("GitHub must not be called before external isolation is attested"); + }, async () => runTask(config, String(task.task_id))); + + assert.equal(githubCalls, 0); + assert.equal(blocked.state, "blocked"); + assert.equal(blocked.attempts, 0); + assert.equal(blocked.failure_category, "runtime_isolation_unavailable"); + assert.match(String(blocked.failure_detail), /COVEN_RUNTIME_EXTERNAL_ISOLATION/); + assert.equal(existsSync(join(config.attemptsDir, String(task.task_id))), false); + assert.equal(existsSync(join(config.workspacesDir, String(task.task_id))), false); +}); + +test("masked stale-review dismissal 404 remains retryable while exact or listed state is APPROVED", async () => { + const stateDir = tempStateDir(); + const {privateKey} = generateKeyPairSync("rsa", {modulusLength: 2048}); + const config = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: join(stateDir, "policy.json"), + GITHUB_WEBHOOK_SECRET: "test-webhook-secret", + GITHUB_APP_ID: "1234", + COVEN_GITHUB_REVOCATION_EVENTS: "pull-request-and-push-verified", + GITHUB_APP_PRIVATE_KEY: privateKey.export({type: "pkcs8", format: "pem"}).toString(), + }, process.cwd()); + const policy: JsonObject = { + familiar: {id: "reviewer"}, + publication: {mode: "comment"}, + bot_usernames: ["covencat[bot]"], + enabled_triggers: ["pull_request.synchronize", "pull_request.edited", "pull_request.reopened", "push"], + }; + const task = buildTaskFromEvent("pull_request", "masked-dismissal-404", { + action: "synchronize", + installation: {id: 123456}, + repository: {id: 987654321, full_name: "OpenCoven/example"}, + pull_request: { + number: 7, + head: {sha: "current-head", ref: "feature"}, + base: {sha: "current-base", ref: "main"}, + }, + }, policy); + task.policy_snapshot = policy; + const taskFile = join(config.tasksDir, `${task.task_id}.json`); + writeFileSync(taskFile, JSON.stringify(task)); + const identity = "f".repeat(64); + const reviewBody = `Still-active stale review\n\n${signedBasePublicationMarker(identity, "old-base", "2026-07-14T00:00:00Z")}`; + const liveReview: JsonObject = { + id: 800, + state: "APPROVED", + commit_id: "old-head", + body: reviewBody, + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-800", + user: covencatBot(), + }; + const recordName = `${createHash("sha256").update("OpenCoven/example#7").digest("hex").slice(0, 24)}.json`; + const recordPath = join(config.publicationsDir, recordName); + const originalRecord: JsonObject = { + identity, + review_id: 800, + review_url: liveReview.html_url, + review_body: reviewBody, + decision: "APPROVED", + head_sha: "old-head", + base_sha: "old-base", + }; + writeFileSync(recordPath, JSON.stringify(originalRecord)); + let tokenCalls = 0; + let dismissalAttempts = 0; + let exactReviewReads = 0; + let reviewListReads = 0; + + await withGithubApiMock((url, init) => { + if (/\/app\/installations\/123456\/access_tokens$/.test(url)) { + tokenCalls += 1; + return {token: `masked-dismissal-token-${tokenCalls}`}; + } + if (/\/pulls\/7$/.test(url)) return {head: {sha: "current-head"}, base: {sha: "current-base"}}; + if (init.method === "PUT" && /\/reviews\/800\/dismissals$/.test(url)) { + dismissalAttempts += 1; + return {httpStatus: 404, response: {message: "Not Found"}}; + } + if (init.method === "GET" && /\/pulls\/7\/reviews\/800$/.test(url)) { + exactReviewReads += 1; + return exactReviewReads === 1 + ? liveReview + : {httpStatus: 404, response: {message: "Not Found"}}; + } + if (init.method === "GET" && /\/pulls\/7\/reviews(?:\?|$)/.test(url)) { + reviewListReads += 1; + return [liveReview]; + } + throw new Error(`Unexpected GitHub request: ${String(init.method)} ${url}`); + }, async () => { + const exactReadFailure = await runTask(config, String(task.task_id)); + assert.equal(exactReadFailure.state, "failed"); + assert.equal(exactReadFailure.attempts, 1); + assert.equal(exactReadFailure.publication_state, "revision_reconciliation_retry_pending"); + assert.equal(exactReadFailure.dismissed_review_ids, undefined); + assert.ok(Date.parse(String(exactReadFailure.retry_not_before)) > Date.now()); + assert.deepEqual(runnableTaskIds(config), [String(task.task_id)]); + + const callsBeforeImmediateRetry = tokenCalls + dismissalAttempts + exactReviewReads + reviewListReads; + const deferred = await runTask(config, String(task.task_id)); + assert.equal(deferred.state, "failed"); + assert.equal(deferred.attempts, 1); + assert.equal(tokenCalls + dismissalAttempts + exactReviewReads + reviewListReads, callsBeforeImmediateRetry); + + const retryable = JSON.parse(readFileSync(taskFile, "utf8")) as JsonObject; + retryable.retry_not_before = "1970-01-01T00:00:00.000Z"; + writeFileSync(taskFile, JSON.stringify(retryable)); + const relistFailure = await runTask(config, String(task.task_id)); + assert.equal(relistFailure.state, "failed"); + assert.equal(relistFailure.attempts, 2); + assert.equal(relistFailure.publication_state, "revision_reconciliation_retry_pending"); + assert.equal(relistFailure.dismissed_review_ids, undefined); + assert.equal(relistFailure.reconciliation_results, undefined); + assert.deepEqual(runnableTaskIds(config), [String(task.task_id)]); + }); + + assert.equal(tokenCalls, 2); + assert.equal(dismissalAttempts, 2); + assert.equal(exactReviewReads, 2); + assert.equal(reviewListReads, 3); + assert.deepEqual(JSON.parse(readFileSync(recordPath, "utf8")), originalRecord); +}); + +test("signed retry links matching orphan tasks without overwrite and rejects hash conflicts", async () => { + const secret = "orphan-delivery-secret"; + const stateDir = tempStateDir(); + const config = testConfig(stateDir, secret); + const payloadObject: JsonObject = { + action: "labeled", + installation: {id: 123456}, + repository: {id: 987654321, full_name: "OpenCoven/example", default_branch: "main"}, + issue: {number: 404, title: "Recover transaction", body: "Link existing task state.", labels: [{name: "coven:fix"}]}, + }; + const payload = Buffer.from(JSON.stringify(payloadObject)); + const payloadDigest = createHash("sha256").update(stableCompact(payloadObject)).digest("hex"); + const cases: JsonObject[] = [ + { + task_id: "orphan-running-delivery", + delivery_id: "orphan-running-delivery", + delivery_payload_hash: payloadDigest, + state: "running", + attempts: 4, + workspace_path: "/preserved/running/workspace", + publication_state: "not_started", + custom_runtime_field: "must-survive", + }, + { + task_id: "orphan-terminal-delivery", + delivery_id: "orphan-terminal-delivery", + delivery_payload_hash: payloadDigest, + state: "completed", + attempts: 5, + runtime_exit_code: 0, + result_path: "/preserved/final/result.json", + publication_state: "published_review", + publication_review_id: 12345, + }, + ]; + + for (const originalTask of cases) { + const deliveryId = String(originalTask.delivery_id); + const taskFile = join(config.tasksDir, `${deliveryId}.json`); + writeFileSync(taskFile, JSON.stringify(originalTask)); + const response = await callWebhook(payload, { + "X-GitHub-Event": "issues", + "X-GitHub-Delivery": deliveryId, + "X-Hub-Signature-256": signature(secret, payload), + }, "auto", config); + assert.equal(response.status, 200); + assert.equal(response.body.recovered_orphan_task, true); + assert.equal(response.body.action, originalTask.state === "running" ? "duplicate_task_queued" : "duplicate_ignored"); + assert.deepEqual(JSON.parse(readFileSync(taskFile, "utf8")), originalTask); + const delivery = JSON.parse(readFileSync(join(config.deliveriesDir, `${deliveryId}.json`), "utf8")) as JsonObject; + assert.equal(delivery.task_id, deliveryId); + assert.equal(delivery.payload_hash, payloadDigest); + assert.equal(delivery.state, originalTask.state); + assert.equal(delivery.routing_result, "recovered_orphan_task"); + } + + const conflictId = "orphan-conflicting-delivery"; + const conflictTask: JsonObject = { + task_id: conflictId, + delivery_id: conflictId, + delivery_payload_hash: "0".repeat(64), + state: "running", + attempts: 9, + failure_detail: "preserve conflicting state", + }; + const conflictFile = join(config.tasksDir, `${conflictId}.json`); + writeFileSync(conflictFile, JSON.stringify(conflictTask)); + const conflict = await callWebhook(payload, { + "X-GitHub-Event": "issues", + "X-GitHub-Delivery": conflictId, + "X-Hub-Signature-256": signature(secret, payload), + }, "auto", config); + assert.equal(conflict.status, 409); + assert.equal(conflict.body.action, "conflict"); + assert.equal(conflict.body.reason, "delivery_task_conflict"); + assert.deepEqual(JSON.parse(readFileSync(conflictFile, "utf8")), conflictTask); + assert.equal(existsSync(join(config.deliveriesDir, `${conflictId}.json`)), false); +}); + +test("masked 404 while superseding an active decisive review persists a pending dismissal", async () => { + const stateDir = tempStateDir(); + const config = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: join(stateDir, "policy.json"), + GITHUB_WEBHOOK_SECRET: "test-webhook-secret", + COVEN_GITHUB_REVOCATION_EVENTS: "pull-request-and-push-verified", + }, process.cwd()); + const task = reviewTask("masked-supersession-404"); + task.created_at = "2026-07-14T00:10:00Z"; + const resultPath = join(stateDir, "masked-supersession-result.json"); + writeFileSync(resultPath, JSON.stringify(completeReview())); + Object.assign(task, { + state: "completed", + result_path: resultPath, + publication_state: "publication_pending", + installation_id: 123456, + repository_id: 987654321, + }); + prepareReviewWorkspace(config, task); + const taskFile = join(config.tasksDir, `${task.task_id}.json`); + writeFileSync(taskFile, JSON.stringify(task)); + const priorIdentity = "9".repeat(64); + const priorBody = `Prior active approval\n\n${signedBasePublicationMarker(priorIdentity, "base123", "2026-07-14T00:00:00Z")}`; + const priorReview: JsonObject = { + id: 901, + state: "APPROVED", + commit_id: "abc123", + submitted_at: "2026-07-14T00:00:00Z", + body: priorBody, + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-901", + user: covencatBot(), + }; + let tokenCalls = 0; + let reviewListReads = 0; + let exactPriorReads = 0; + let dismissalAttempts = 0; + let createdReviews = 0; + let submittedReviews = 0; + let warnedCurrentBody = ""; + + await withGithubApiMock((url, init) => { + if (init.method === "GET" && /\/pulls\/7$/.test(url)) { + return {head: {sha: "abc123"}, base: {sha: "base123"}}; + } + if (init.method === "GET" && /\/pulls\/7\/reviews\/901$/.test(url)) { + exactPriorReads += 1; + return priorReview; + } + if (init.method === "GET" && /\/pulls\/7\/reviews(?:\?|$)/.test(url)) { + reviewListReads += 1; + return [priorReview]; + } + if (init.method === "POST" && /\/pulls\/7\/reviews$/.test(url)) { + createdReviews += 1; + return { + id: 902, + state: "PENDING", + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-902", + }; + } + if (init.method === "POST" && /\/pulls\/7\/reviews\/902\/events$/.test(url)) { + submittedReviews += 1; + assert.equal((JSON.parse(String(init.body)) as JsonObject).event, "APPROVE"); + return { + id: 902, + state: "APPROVED", + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-902", + }; + } + if (init.method === "PUT" && /\/pulls\/7\/reviews\/901\/dismissals$/.test(url)) { + dismissalAttempts += 1; + return {httpStatus: 404, response: {message: "Not Found"}}; + } + if (init.method === "PUT" && /\/pulls\/7\/reviews\/902$/.test(url)) { + warnedCurrentBody = String((JSON.parse(String(init.body)) as JsonObject).body || ""); + return {id: 902, state: "APPROVED", body: warnedCurrentBody}; + } + throw new Error(`Unexpected GitHub request: ${String(init.method)} ${url}`); + }, async () => resumeTaskPublication(config, String(task.task_id), () => {}, async () => { + tokenCalls += 1; + return "masked-supersession-token"; + })); + + assert.equal(tokenCalls, 1); + assert.equal(reviewListReads, 1); + assert.equal(exactPriorReads, 1); + assert.equal(dismissalAttempts, 1); + assert.equal(createdReviews, 1); + assert.equal(submittedReviews, 1); + assert.match(warnedCurrentBody, /did not permit covencat to dismiss the prior decisive review/); + const persisted = JSON.parse(readFileSync(taskFile, "utf8")) as JsonObject; + assert.equal(persisted.state, "completed"); + assert.equal(persisted.publication_state, "publication_failed"); + assert.equal(persisted.publication_supersession_state, "prior_decisive_review_dismissal_failed"); + assert.notEqual(persisted.publication_supersession_state, "prior_decisive_review_dismissed"); + assert.match(String(persisted.publication_error), /1 prior decisive review dismissal remains? pending/); + assert.ok(Date.parse(String(persisted.retry_not_before)) > Date.now()); + const recordName = `${createHash("sha256").update("OpenCoven/example#7").digest("hex").slice(0, 24)}.json`; + const record = JSON.parse(readFileSync(join(config.publicationsDir, recordName), "utf8")) as JsonObject; + assert.equal(record.supersession_pending, true); + assert.deepEqual(record.pending_dismissals, [{ + id: 901, + state: "APPROVED", + html_url: "https://github.com/OpenCoven/example/pull/7#pullrequestreview-901", + identity: priorIdentity, + }]); +}); + +test("reconciliation honors persisted Retry-After deadlines for 429 and 403 responses", async () => { + const {privateKey} = generateKeyPairSync("rsa", {modulusLength: 2048}); + const privateKeyPem = privateKey.export({type: "pkcs8", format: "pem"}).toString(); + const policy: JsonObject = { + familiar: {id: "reviewer"}, + publication: {mode: "comment"}, + bot_usernames: ["covencat[bot]"], + enabled_triggers: ["pull_request.synchronize", "pull_request.edited", "pull_request.reopened", "push"], + }; + + for (const status of [429, 403]) { + const stateDir = tempStateDir(); + const config = createConfig({ + COVEN_GITHUB_STATE_DIR: stateDir, + COVEN_GITHUB_POLICY_PATH: join(stateDir, "policy.json"), + GITHUB_WEBHOOK_SECRET: "test-webhook-secret", + GITHUB_APP_ID: "1234", + GITHUB_APP_PRIVATE_KEY: privateKeyPem, + COVEN_GITHUB_REVOCATION_EVENTS: "pull-request-and-push-verified", + }, process.cwd()); + const task = buildTaskFromEvent("pull_request", `retry-after-${status}`, { + action: "synchronize", + installation: {id: 123456}, + repository: {id: 987654321, full_name: "OpenCoven/example"}, + pull_request: { + number: 7, + head: {sha: "current-head", ref: "feature"}, + base: {sha: "current-base", ref: "main"}, + }, + }, policy); + task.policy_snapshot = policy; + const taskFile = join(config.tasksDir, `${task.task_id}.json`); + writeFileSync(taskFile, JSON.stringify(task)); + let apiCalls = 0; + let tokenCalls = 0; + let rateLimitReturned = false; + const startedAt = Date.now(); + + await withGithubApiMock((url, init) => { + apiCalls += 1; + if (/\/app\/installations\/123456\/access_tokens$/.test(url)) { + tokenCalls += 1; + return {token: `retry-after-token-${status}-${tokenCalls}`}; + } + if (/\/pulls\/7$/.test(url)) { + if (!rateLimitReturned) { + rateLimitReturned = true; + return { + httpStatus: status, + response: {message: status === 429 ? "rate limited" : "secondary rate limit"}, + headers: {"retry-after": "120"}, + }; + } + return {head: {sha: "current-head"}, base: {sha: "current-base"}}; + } + if (init.method === "GET" && /\/pulls\/7\/reviews(?:\?|$)/.test(url)) return []; + throw new Error(`Unexpected GitHub request: ${String(init.method)} ${url}`); + }, async () => { + const failed = await runTask(config, String(task.task_id)); + assert.equal(failed.state, "failed"); + assert.equal(failed.attempts, 1); + assert.equal(failed.publication_state, "revision_reconciliation_retry_pending"); + const retryDeadline = Date.parse(String(failed.retry_not_before)); + assert.ok(retryDeadline >= startedAt + 119_000); + assert.ok(retryDeadline <= Date.now() + 121_000); + + const callsBeforeImmediateRetry = apiCalls; + const deferred = await runTask(config, String(task.task_id)); + assert.equal(deferred.state, "failed"); + assert.equal(deferred.attempts, 1); + assert.equal(deferred.retry_not_before, failed.retry_not_before); + assert.equal(apiCalls, callsBeforeImmediateRetry); + + const retryable = JSON.parse(readFileSync(taskFile, "utf8")) as JsonObject; + retryable.retry_not_before = "1970-01-01T00:00:00.000Z"; + writeFileSync(taskFile, JSON.stringify(retryable)); + const completed = await runTask(config, String(task.task_id)); + assert.equal(completed.state, "completed"); + assert.equal(completed.attempts, 2); + assert.equal(completed.publication_state, "revision_reconciled_no_stale_reviews"); + assert.equal(completed.retry_not_before, undefined); + }); + + assert.equal(tokenCalls, 2); + assert.equal(apiCalls, 6); + } +});