From 61909e8b5d0d7d8a2c12f662c05ec676d7f029fa Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 5 Jul 2026 20:42:35 -0400 Subject: [PATCH 01/20] feat(contract): require structured review results (#119) --- crates/github/src/lib.rs | 81 ++++++++++++++++++- crates/github/src/tasks.rs | 1 + crates/worker/src/lib.rs | 5 +- crates/worker/tests/contract.rs | 19 ++++- docs/contracts/result.example.json | 19 ++++- docs/contracts/result.schema.json | 96 ++++++++++++++++++++++- docs/contracts/session-brief.example.json | 2 +- docs/contracts/session-brief.schema.json | 4 +- docs/headless-contract.md | 56 ++++++++++--- 9 files changed, 258 insertions(+), 25 deletions(-) diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs index 5e05f32..bd19597 100644 --- a/crates/github/src/lib.rs +++ b/crates/github/src/lib.rs @@ -12,7 +12,7 @@ pub const DEFAULT_API_BASE_URL: &str = "https://api.github.com"; /// Major version of the coven-code headless execution contract this adapter /// speaks. See `docs/headless-contract.md`. Bump only on breaking changes. -pub const HEADLESS_CONTRACT_VERSION: &str = "1"; +pub const HEADLESS_CONTRACT_VERSION: &str = "2"; fn default_contract_version() -> String { HEADLESS_CONTRACT_VERSION.to_string() @@ -187,6 +187,7 @@ pub struct SessionResult { pub files_changed: Vec, pub summary: String, pub pr_body: String, + pub review: ReviewResult, pub exit_reason: Option, } @@ -205,6 +206,84 @@ pub struct CommitInfo { pub message: String, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ReviewResult { + pub mode: ReviewMode, + pub evidence_status: ReviewEvidenceStatus, + pub reviewed_files: Vec, + pub findings: Vec, + pub tests_run: Vec, + pub no_findings_reason: Option, + pub limitations: Vec, +} + +impl ReviewResult { + pub fn none() -> Self { + Self { + mode: ReviewMode::None, + evidence_status: ReviewEvidenceStatus::NotApplicable, + reviewed_files: Vec::new(), + findings: Vec::new(), + tests_run: Vec::new(), + no_findings_reason: None, + limitations: Vec::new(), + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ReviewMode { + None, + PullRequest, + ReviewComment, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ReviewEvidenceStatus { + NotApplicable, + Complete, + Partial, + Missing, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ReviewFinding { + pub severity: ReviewSeverity, + pub file: String, + pub line: Option, + pub title: String, + pub body: String, + pub recommendation: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ReviewSeverity { + Info, + Low, + Medium, + High, + Critical, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ReviewTestRun { + pub command: String, + pub status: ReviewTestStatus, + pub output_summary: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ReviewTestStatus { + Passed, + Failed, + NotRun, + Unknown, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ExitReason { diff --git a/crates/github/src/tasks.rs b/crates/github/src/tasks.rs index 4b1aff8..b35bf6c 100644 --- a/crates/github/src/tasks.rs +++ b/crates/github/src/tasks.rs @@ -191,6 +191,7 @@ mod tests { files_changed: vec![], summary: "Done".to_string(), pr_body: "Body".to_string(), + review: crate::ReviewResult::none(), exit_reason: None, }, Some(9), diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index c26f7da..e99c107 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -625,7 +625,7 @@ fn task_issue_number(kind: &TaskKind) -> Option { #[cfg(test)] mod disposition_tests { use super::*; - use coven_github_api::{CommitInfo, HEADLESS_CONTRACT_VERSION}; + use coven_github_api::{CommitInfo, ReviewResult, HEADLESS_CONTRACT_VERSION}; use coven_github_config::{GitHubAppConfig, ServerConfig, WorkerConfig}; use std::path::PathBuf; @@ -643,6 +643,7 @@ mod disposition_tests { files_changed: vec![], summary: "summary".to_string(), pr_body: "body".to_string(), + review: ReviewResult::none(), exit_reason: None, } } @@ -828,7 +829,7 @@ mod process_tests { /// A minimal contract-valid result.json with the given status/exit_reason. fn result_json(status: &str, exit_reason: &str) -> String { format!( - r#"{{"contract_version":"1","status":"{status}","branch":null,"commits":[],"files_changed":[],"summary":"s","pr_body":"","exit_reason":{exit_reason}}}"# + r#"{{"contract_version":"2","status":"{status}","branch":null,"commits":[],"files_changed":[],"summary":"s","pr_body":"","review":{{"mode":"none","evidence_status":"not_applicable","reviewed_files":[],"findings":[],"tests_run":[],"no_findings_reason":null,"limitations":[]}},"exit_reason":{exit_reason}}}"# ) } diff --git a/crates/worker/tests/contract.rs b/crates/worker/tests/contract.rs index b5cfd91..238b03a 100644 --- a/crates/worker/tests/contract.rs +++ b/crates/worker/tests/contract.rs @@ -5,7 +5,10 @@ //! If these fail, either the runtime contract drifted or the docs are stale โ€” //! fix one of them, do not just bless the test. -use coven_github_api::{ExitReason, SessionResult, SessionStatus, HEADLESS_CONTRACT_VERSION}; +use coven_github_api::{ + ExitReason, ReviewEvidenceStatus, ReviewMode, SessionResult, SessionStatus, + HEADLESS_CONTRACT_VERSION, +}; use coven_github_worker::brief::SessionBrief; fn fixture(name: &str) -> String { @@ -43,6 +46,11 @@ fn golden_result_deserializes_into_adapter_type() { assert_eq!(result.contract_version, HEADLESS_CONTRACT_VERSION); assert_eq!(result.status, SessionStatus::Success); + assert_eq!(result.review.mode, ReviewMode::None); + assert_eq!( + result.review.evidence_status, + ReviewEvidenceStatus::NotApplicable + ); assert_eq!(result.branch.as_deref(), Some("cody/fix-issue-42")); assert_eq!(result.commits.len(), 1); assert_eq!( @@ -63,6 +71,15 @@ fn result_without_contract_version_defaults_to_current() { "files_changed": [], "summary": "Could not reproduce.", "pr_body": "", + "review": { + "mode": "none", + "evidence_status": "not_applicable", + "reviewed_files": [], + "findings": [], + "tests_run": [], + "no_findings_reason": null, + "limitations": [] + }, "exit_reason": "ambiguous_spec" }"#; let result: SessionResult = serde_json::from_str(raw).expect("legacy result must parse"); diff --git a/docs/contracts/result.example.json b/docs/contracts/result.example.json index 53fd6ac..c6afd86 100644 --- a/docs/contracts/result.example.json +++ b/docs/contracts/result.example.json @@ -1,5 +1,5 @@ { - "contract_version": "1", + "contract_version": "2", "status": "success", "branch": "cody/fix-issue-42", "commits": [ @@ -7,6 +7,21 @@ ], "files_changed": ["src/auth/refresh.rs"], "summary": "Fixed OAuth token refresh by adding a 60-second clock skew buffer.", - "pr_body": "## Hey, I'm Cody ๐Ÿฆ„\n\nIssue #42 traced to the OAuth refresh path not accounting for clock skew. I added a 60-second buffer to the expiry check.\n\n**Changed:** `src/auth/refresh.rs` (+12 / -3)\n**Tests:** 8/8 passing (added 2 regression cases)\n", + "pr_body": "## Hey, I'm Cody\n\nIssue #42 traced to the OAuth refresh path not accounting for clock skew. I added a 60-second buffer to the expiry check.\n\n**Changed:** `src/auth/refresh.rs` (+12 / -3)\n**Tests:** 8/8 passing (added 2 regression cases)\n", + "review": { + "mode": "none", + "evidence_status": "not_applicable", + "reviewed_files": [], + "findings": [], + "tests_run": [ + { + "command": "cargo test -p auth refresh", + "status": "passed", + "output_summary": "8/8 passing" + } + ], + "no_findings_reason": null, + "limitations": [] + }, "exit_reason": null } diff --git a/docs/contracts/result.schema.json b/docs/contracts/result.schema.json index 0a86c4c..434c6e4 100644 --- a/docs/contracts/result.schema.json +++ b/docs/contracts/result.schema.json @@ -2,14 +2,14 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/OpenCoven/coven-github/docs/contracts/result.schema.json", "title": "coven-code headless result envelope", - "description": "Terminal task state coven-code --headless writes to --output. Contract version 1.", + "description": "Terminal task state coven-code --headless writes to --output. Contract version 2.", "type": "object", "additionalProperties": false, - "required": ["status", "commits", "files_changed", "summary", "pr_body"], + "required": ["contract_version", "status", "commits", "files_changed", "summary", "pr_body", "review"], "properties": { "contract_version": { "type": "string", - "const": "1" + "const": "2" }, "status": { "type": "string", @@ -36,6 +36,96 @@ }, "summary": { "type": "string" }, "pr_body": { "type": "string" }, + "review": { + "type": "object", + "additionalProperties": false, + "required": [ + "mode", + "evidence_status", + "reviewed_files", + "findings", + "tests_run", + "no_findings_reason", + "limitations" + ], + "properties": { + "mode": { + "type": "string", + "enum": ["none", "pull_request", "review_comment"] + }, + "evidence_status": { + "type": "string", + "enum": ["not_applicable", "complete", "partial", "missing"] + }, + "reviewed_files": { + "type": "array", + "items": { "type": "string" } + }, + "findings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["severity", "file", "line", "title", "body", "recommendation"], + "properties": { + "severity": { + "type": "string", + "enum": ["info", "low", "medium", "high", "critical"] + }, + "file": { "type": "string" }, + "line": { "type": ["integer", "null"], "minimum": 1 }, + "title": { "type": "string" }, + "body": { "type": "string" }, + "recommendation": { "type": ["string", "null"] } + } + } + }, + "tests_run": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["command", "status", "output_summary"], + "properties": { + "command": { "type": "string" }, + "status": { + "type": "string", + "enum": ["passed", "failed", "not_run", "unknown"] + }, + "output_summary": { "type": ["string", "null"] } + } + } + }, + "no_findings_reason": { "type": ["string", "null"] }, + "limitations": { + "type": "array", + "items": { "type": "string" } + } + }, + "allOf": [ + { + "if": { + "properties": { "mode": { "enum": ["pull_request", "review_comment"] } }, + "required": ["mode"] + }, + "then": { + "anyOf": [ + { "properties": { "reviewed_files": { "minItems": 1 } }, "required": ["reviewed_files"] }, + { "properties": { "evidence_status": { "const": "missing" } }, "required": ["evidence_status"] } + ], + "oneOf": [ + { "properties": { "findings": { "minItems": 1 } }, "required": ["findings"] }, + { + "properties": { + "no_findings_reason": { "type": "string", "minLength": 1 } + }, + "required": ["no_findings_reason"] + } + ] + } + } + ] + }, "exit_reason": { "type": ["string", "null"], "enum": ["test_failure", "ambiguous_spec", "git_conflict", "infra_error", null] diff --git a/docs/contracts/session-brief.example.json b/docs/contracts/session-brief.example.json index 8ecbc9a..92719bd 100644 --- a/docs/contracts/session-brief.example.json +++ b/docs/contracts/session-brief.example.json @@ -1,5 +1,5 @@ { - "contract_version": "1", + "contract_version": "2", "trigger": "issue_assigned", "repo": { "owner": "OpenCoven", diff --git a/docs/contracts/session-brief.schema.json b/docs/contracts/session-brief.schema.json index 613a352..850c31e 100644 --- a/docs/contracts/session-brief.schema.json +++ b/docs/contracts/session-brief.schema.json @@ -2,14 +2,14 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/OpenCoven/coven-github/docs/contracts/session-brief.schema.json", "title": "coven-code headless session brief", - "description": "Tokenless read-context payload the adapter writes for coven-code --headless --context. Contract version 1.", + "description": "Tokenless read-context payload the adapter writes for coven-code --headless --context. Contract version 2.", "type": "object", "additionalProperties": false, "required": ["trigger", "repo", "task", "familiar", "workspace"], "properties": { "contract_version": { "type": "string", - "const": "1" + "const": "2" }, "trigger": { "type": "string", diff --git a/docs/headless-contract.md b/docs/headless-contract.md index f71eb54..a25bc5f 100644 --- a/docs/headless-contract.md +++ b/docs/headless-contract.md @@ -1,6 +1,6 @@ # coven-code Headless Execution Contract -**Contract version: `1`** ยท Status: **Locked** (V1 / M1) +**Contract version: `2`** - Status: **Locked** (V2 / structured review output) This document is the single source of truth for the interface between `coven-github` (the GitHub ingress adapter) and `coven-code` (the execution @@ -64,7 +64,7 @@ The adapter is the **producer**; the runtime is the **consumer**. The brief is ```json { - "contract_version": "1", + "contract_version": "2", "trigger": "issue_assigned", "repo": { "owner": "OpenCoven", @@ -94,7 +94,7 @@ The adapter is the **producer**; the runtime is the **consumer**. The brief is | Field | Type | Notes | |---|---|---| -| `contract_version` | string | MUST be `"1"`. Consumers MUST reject a brief whose major version they do not implement. | +| `contract_version` | string | MUST be `"2"`. Consumers MUST reject a brief whose major version they do not implement. | | `trigger` | string enum | `issue_assigned` \| `pr_review_comment` \| `issue_mention`. | | `repo.owner` | string | | | `repo.name` | string | | @@ -128,7 +128,7 @@ the file MAY be absent. ```json { - "contract_version": "1", + "contract_version": "2", "status": "success", "branch": "cody/fix-issue-42", "commits": [ @@ -136,7 +136,16 @@ the file MAY be absent. ], "files_changed": ["src/auth/refresh.rs"], "summary": "Fixed OAuth token refresh by adding a 60-second clock skew buffer.", - "pr_body": "## Hey, I'm Cody ๐Ÿฆ„\n\nI looked at issue #42โ€ฆ", + "pr_body": "## Hey, I'm Cody\n\nI looked at issue #42...", + "review": { + "mode": "pull_request", + "evidence_status": "complete", + "reviewed_files": ["src/auth/refresh.rs"], + "findings": [], + "tests_run": [], + "no_findings_reason": "Reviewed the supplied PR file and found no blocking issues.", + "limitations": [] + }, "exit_reason": null } ``` @@ -145,22 +154,43 @@ the file MAY be absent. | Field | Type | Notes | |---|---|---| -| `contract_version` | string | MUST be `"1"`. If absent, the consumer assumes `"1"` for backward compatibility, but producers MUST emit it. | -| `status` | string enum | `success` \| `failure` \| `partial` \| `needs_input`. See [3.2](#32-status). | +| `contract_version` | string | MUST be `"2"`. Producers MUST emit it. | +| `status` | string enum | `success` \| `failure` \| `partial` \| `needs_input`. See [3.3](#33-status). | | `branch` | string \| null | Branch the runtime pushed. `null` when no branch was created. The adapter only opens a PR when `branch` is set **and** `commits` is non-empty. | | `commits` | array | `{ "sha": string, "message": string }`. MAY be empty. | | `files_changed` | string[] | Workspace-relative paths. MAY be empty. | | `summary` | string | One-line familiar-voice summary. Used in the Check Run and PR title. | | `pr_body` | string | Full PR body, **authored by the familiar** in its own voice โ€” not a template. | -| `exit_reason` | string enum \| null | `null` on success; otherwise the terminal cause. See [3.3](#33-exit_reason). | +| `review` | object | Structured review evidence and findings. Required even when `mode` is `none`. See [3.2](#32-review). | +| `exit_reason` | string enum \| null | `null` on success; otherwise the terminal cause. See [3.4](#34-exit_reason). | > **Drift note (supersedes `COVEN-GITHUB.md`):** the prose result envelope listed -> an `events` array. Progress/event streaming is **not** part of the v1 result +> an `events` array. Progress/event streaming is **not** part of the v2 result > envelope โ€” it is deferred to M2 and will travel over a separate channel. The -> v1 envelope carries terminal task state only. Producers MUST NOT rely on +> v2 envelope carries terminal task state only. Producers MUST NOT rely on > `events` being read. -### 3.2 `status` +### 3.2 `review` + +`review` is the machine-readable proof that a hosted review actually examined +the intended code. It is required on every result. Non-review tasks MUST set +`mode: "none"` and `evidence_status: "not_applicable"`. + +| Field | Type | Notes | +|---|---|---| +| `mode` | string enum | `none`, `pull_request`, or `review_comment`. | +| `evidence_status` | string enum | `not_applicable`, `complete`, `partial`, or `missing`. PR review modes MUST NOT use `not_applicable`. | +| `reviewed_files` | string[] | Workspace-relative files supplied to or inspected by the runtime. PR review modes MUST include at least one file unless `evidence_status` is `missing`. | +| `findings` | array | Structured findings. Empty is allowed only when `no_findings_reason` is a non-empty string. | +| `tests_run` | array | Commands run while reviewing, with `passed`, `failed`, `not_run`, or `unknown` status. | +| `no_findings_reason` | string \| null | Required when `mode` is a review mode and `findings` is empty. | +| `limitations` | string[] | Evidence gaps, skipped checks, or other caveats. | + +Each finding carries `severity`, `file`, optional `line`, `title`, `body`, and +optional `recommendation`. Valid severities are `info`, `low`, `medium`, `high`, +and `critical`. + +### 3.3 `status` | Value | Meaning | |---|---| @@ -172,7 +202,7 @@ the file MAY be absent. The adapter treats `success` and `partial` as PR-opening outcomes; `failure` and `needs_input` do not open a PR by themselves. -### 3.3 `exit_reason` +### 3.4 `exit_reason` `null` on success. Otherwise one of: @@ -211,7 +241,7 @@ A process **killed by signal**, or one that **times out** (the adapter enforces ## 5. Security invariants -These are non-negotiable for v1: +These are non-negotiable for v2: 1. The session brief is **tokenless**. Serializing a brief MUST NOT produce an `auth` field, a `"token"` field, or a credential-bearing `clone_url` From fdc255f0cb1357b0faa2cef543a00ae17bbeeac0 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 5 Jul 2026 22:19:24 -0400 Subject: [PATCH 02/20] feat(contract): record supporting review files (#119) Signed-off-by: Timothy Wayne Gregg --- crates/github/src/lib.rs | 2 ++ crates/worker/src/lib.rs | 2 +- crates/worker/tests/contract.rs | 1 + docs/contracts/result.example.json | 1 + docs/contracts/result.schema.json | 5 +++++ docs/headless-contract.md | 2 ++ 6 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs index bd19597..97d04cf 100644 --- a/crates/github/src/lib.rs +++ b/crates/github/src/lib.rs @@ -211,6 +211,7 @@ pub struct ReviewResult { pub mode: ReviewMode, pub evidence_status: ReviewEvidenceStatus, pub reviewed_files: Vec, + pub supporting_files: Vec, pub findings: Vec, pub tests_run: Vec, pub no_findings_reason: Option, @@ -223,6 +224,7 @@ impl ReviewResult { mode: ReviewMode::None, evidence_status: ReviewEvidenceStatus::NotApplicable, reviewed_files: Vec::new(), + supporting_files: Vec::new(), findings: Vec::new(), tests_run: Vec::new(), no_findings_reason: None, diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index e99c107..bc8372c 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -829,7 +829,7 @@ mod process_tests { /// A minimal contract-valid result.json with the given status/exit_reason. fn result_json(status: &str, exit_reason: &str) -> String { format!( - r#"{{"contract_version":"2","status":"{status}","branch":null,"commits":[],"files_changed":[],"summary":"s","pr_body":"","review":{{"mode":"none","evidence_status":"not_applicable","reviewed_files":[],"findings":[],"tests_run":[],"no_findings_reason":null,"limitations":[]}},"exit_reason":{exit_reason}}}"# + r#"{{"contract_version":"2","status":"{status}","branch":null,"commits":[],"files_changed":[],"summary":"s","pr_body":"","review":{{"mode":"none","evidence_status":"not_applicable","reviewed_files":[],"supporting_files":[],"findings":[],"tests_run":[],"no_findings_reason":null,"limitations":[]}},"exit_reason":{exit_reason}}}"# ) } diff --git a/crates/worker/tests/contract.rs b/crates/worker/tests/contract.rs index 238b03a..45387e1 100644 --- a/crates/worker/tests/contract.rs +++ b/crates/worker/tests/contract.rs @@ -75,6 +75,7 @@ fn result_without_contract_version_defaults_to_current() { "mode": "none", "evidence_status": "not_applicable", "reviewed_files": [], + "supporting_files": [], "findings": [], "tests_run": [], "no_findings_reason": null, diff --git a/docs/contracts/result.example.json b/docs/contracts/result.example.json index c6afd86..7796d31 100644 --- a/docs/contracts/result.example.json +++ b/docs/contracts/result.example.json @@ -12,6 +12,7 @@ "mode": "none", "evidence_status": "not_applicable", "reviewed_files": [], + "supporting_files": [], "findings": [], "tests_run": [ { diff --git a/docs/contracts/result.schema.json b/docs/contracts/result.schema.json index 434c6e4..6b7167b 100644 --- a/docs/contracts/result.schema.json +++ b/docs/contracts/result.schema.json @@ -43,6 +43,7 @@ "mode", "evidence_status", "reviewed_files", + "supporting_files", "findings", "tests_run", "no_findings_reason", @@ -61,6 +62,10 @@ "type": "array", "items": { "type": "string" } }, + "supporting_files": { + "type": "array", + "items": { "type": "string" } + }, "findings": { "type": "array", "items": { diff --git a/docs/headless-contract.md b/docs/headless-contract.md index a25bc5f..23f3fc6 100644 --- a/docs/headless-contract.md +++ b/docs/headless-contract.md @@ -141,6 +141,7 @@ the file MAY be absent. "mode": "pull_request", "evidence_status": "complete", "reviewed_files": ["src/auth/refresh.rs"], + "supporting_files": ["src/auth/mod.rs", "tests/auth_refresh.rs"], "findings": [], "tests_run": [], "no_findings_reason": "Reviewed the supplied PR file and found no blocking issues.", @@ -181,6 +182,7 @@ the intended code. It is required on every result. Non-review tasks MUST set | `mode` | string enum | `none`, `pull_request`, or `review_comment`. | | `evidence_status` | string enum | `not_applicable`, `complete`, `partial`, or `missing`. PR review modes MUST NOT use `not_applicable`. | | `reviewed_files` | string[] | Workspace-relative files supplied to or inspected by the runtime. PR review modes MUST include at least one file unless `evidence_status` is `missing`. | +| `supporting_files` | string[] | Workspace-relative files beyond the changed-file list that the runtime can prove were inspected for context. Empty means no broader-codebase inspection was proven, not that none was needed. | | `findings` | array | Structured findings. Empty is allowed only when `no_findings_reason` is a non-empty string. | | `tests_run` | array | Commands run while reviewing, with `passed`, `failed`, `not_run`, or `unknown` status. | | `no_findings_reason` | string \| null | Required when `mode` is a review mode and `findings` is empty. | From 44d266323fc52c91a4aee17ed068bae289ecebe0 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Sun, 5 Jul 2026 22:30:49 -0400 Subject: [PATCH 03/20] chore(hosted): track CompleteTech dogfood adapter (#119) Signed-off-by: Timothy Wayne Gregg --- .gitignore | 3 + deploy/complete-tech/README.md | 36 + deploy/complete-tech/coven_github_adapter.py | 955 +++++++++++++++++++ 3 files changed, 994 insertions(+) create mode 100644 deploy/complete-tech/README.md create mode 100644 deploy/complete-tech/coven_github_adapter.py diff --git a/.gitignore b/.gitignore index 1e3c192..a8407f6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ keys/ *.pem .env .DS_Store +deploy/complete-tech/.coven-github-private-key.pem +deploy/complete-tech/coven-github-policy.json +deploy/complete-tech/coven-github-state/ diff --git a/deploy/complete-tech/README.md b/deploy/complete-tech/README.md new file mode 100644 index 0000000..5c695ae --- /dev/null +++ b/deploy/complete-tech/README.md @@ -0,0 +1,36 @@ +# CompleteTech hosted dogfood adapter + +This directory tracks the Python adapter currently deployed for the +CompleteTech-hosted dogfood GitHub App at `webhook.complete.tech`. + +The adapter is intentionally deployment-specific. It is not the canonical Rust +worker implementation; it exists so hosted dogfood behavior can be reviewed, +reproduced, and changed through PRs instead of invisible server edits. + +## Files + +- `coven_github_adapter.py` - webhook handler, task runner, PR evidence capture, + Codex-backed headless runtime invocation, and dogfood comment publisher. + +## Runtime inputs + +The deployment expects secrets and mutable state to be supplied outside git: + +- `GITHUB_APP_PRIVATE_KEY_PATH` or `.coven-github-private-key.pem` +- `GITHUB_APP_ID` +- `COVEN_GITHUB_STATE_DIR` +- `COVEN_GITHUB_POLICY_PATH` +- `COVEN_CODE_BIN` +- Codex OAuth tokens under the deployed account's `.coven-code` directory + +Do not commit private keys, webhook secrets, OAuth tokens, generated task state, +workspaces, or attempt artifacts. + +## Current dogfood behavior + +- Emits headless contract v2 session briefs. +- 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. diff --git a/deploy/complete-tech/coven_github_adapter.py b/deploy/complete-tech/coven_github_adapter.py new file mode 100644 index 0000000..2d1b6ef --- /dev/null +++ b/deploy/complete-tech/coven_github_adapter.py @@ -0,0 +1,955 @@ +import base64 +import hashlib +import json +import os +import subprocess +import tempfile +import time +import traceback +from datetime import datetime, timezone +from pathlib import Path +from urllib.error import HTTPError +from urllib.request import Request, urlopen + + +ROOT_DIR = Path(__file__).resolve().parent +STATE_DIR = Path(os.environ.get("COVEN_GITHUB_STATE_DIR", ROOT_DIR / "coven-github-state")) +DELIVERIES_DIR = STATE_DIR / "deliveries" +TASKS_DIR = STATE_DIR / "tasks" +WORKSPACES_DIR = STATE_DIR / "workspaces" +ATTEMPTS_DIR = STATE_DIR / "attempts" +POLICY_PATH = Path(os.environ.get("COVEN_GITHUB_POLICY_PATH", ROOT_DIR / "coven-github-policy.json")) +PRIVATE_KEY_PATH = Path( + os.environ.get("GITHUB_APP_PRIVATE_KEY_PATH", ROOT_DIR / ".coven-github-private-key.pem") +) +APP_ID = os.environ.get("GITHUB_APP_ID", "4224630").strip() +COVEN_CODE_BIN = os.environ.get("COVEN_CODE_BIN", "coven-code").strip() or "coven-code" +COVEN_CODE_MODEL = os.environ.get("COVEN_CODE_MODEL", "gpt-5.5").strip() + + +def account_home(): + try: + import pwd + + return Path(pwd.getpwuid(os.getuid()).pw_dir) + except Exception: + return Path.home() + + +def configured_codex_tokens_path(): + configured = os.environ.get("COVEN_CODE_CODEX_TOKENS_PATH", "").strip() + if configured: + return Path(configured).expanduser() + return account_home() / ".coven-code" / "codex_tokens.json" + + +CODEX_TOKENS_PATH = configured_codex_tokens_path() + +for directory in (DELIVERIES_DIR, TASKS_DIR, WORKSPACES_DIR, ATTEMPTS_DIR): + directory.mkdir(parents=True, exist_ok=True) + + +DEFAULT_POLICY = { + "version": 1, + "installations": { + "144638200": { + "repositories": { + "1290307745": { + "full_name": "CompleteTech-LLC-AI-Research/coven-code", + "default_branch": "main", + "enabled_triggers": [ + "issue_mention", + "issue_label", + "pr_review_comment", + "pull_request", + "push", + ], + "trigger_labels": ["coven", "coven:fix", "coven:review"], + "bot_usernames": ["coven-github", "cody"], + "familiar": { + "id": "cody", + "display_name": "Cody", + "model": None, + "skills": ["code-review"], + }, + "publication": {"mode": "comment"}, + } + } + } + }, +} + + +def utc_now(): + return datetime.now(timezone.utc).isoformat() + + +def read_json(path, default): + try: + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + except FileNotFoundError: + return default + + +def write_json_atomic(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", dir=str(path.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(value, handle, sort_keys=True, indent=2) + handle.write("\n") + os.replace(tmp_name, str(path)) + finally: + if os.path.exists(tmp_name): + os.unlink(tmp_name) + + +def b64url(raw): + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def sign_rs256(message): + from cryptography.hazmat.primitives import hashes, serialization + from cryptography.hazmat.primitives.asymmetric import padding + + key_data = PRIVATE_KEY_PATH.read_bytes() + private_key = serialization.load_pem_private_key(key_data, password=None) + return private_key.sign(message, padding.PKCS1v15(), hashes.SHA256()) + + +def github_app_jwt(): + now = int(time.time()) + header = {"alg": "RS256", "typ": "JWT"} + payload = {"iat": now - 60, "exp": now + 540, "iss": APP_ID} + signing_input = ( + b64url(json.dumps(header, separators=(",", ":")).encode("utf-8")) + + "." + + b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8")) + ).encode("ascii") + return signing_input.decode("ascii") + "." + b64url(sign_rs256(signing_input)) + + +def github_request(method, url, token, body=None): + headers = { + "Accept": "application/vnd.github+json", + "Authorization": "Bearer " + token, + "User-Agent": "coven-github-hosted-prototype", + "X-GitHub-Api-Version": "2022-11-28", + } + data = None + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + request = Request(url, data=data, headers=headers, method=method) + try: + with urlopen(request, timeout=30) as response: + raw = response.read().decode("utf-8") + return json.loads(raw) if raw else {} + except HTTPError as exc: + raw = exc.read().decode("utf-8", errors="replace") + raise RuntimeError("GitHub API {} {} failed: {}".format(method, url, raw)) + + +def installation_token(installation_id): + app_token = github_app_jwt() + response = github_request( + "POST", + "https://api.github.com/app/installations/{}/access_tokens".format(installation_id), + app_token, + {}, + ) + token = response.get("token") + if not token: + raise RuntimeError("GitHub installation token response did not include token") + return token + + +def load_policy(): + if not POLICY_PATH.exists(): + write_json_atomic(POLICY_PATH, DEFAULT_POLICY) + return read_json(POLICY_PATH, DEFAULT_POLICY) + + +def repo_policy(payload): + policy = load_policy() + installation_id = str((payload.get("installation") or {}).get("id") or "") + repository = payload.get("repository") or {} + repo_id = str(repository.get("id") or "") + installation = (policy.get("installations") or {}).get(installation_id) or {} + repo = (installation.get("repositories") or {}).get(repo_id) + return installation_id, repo_id, repo + + +def delivery_path(delivery_id): + return DELIVERIES_DIR / (delivery_id + ".json") + + +def task_path(task_id): + return TASKS_DIR / (task_id + ".json") + + +def payload_hash(payload): + raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return hashlib.sha256(raw).hexdigest() + + +def delivery_record(delivery_id, event_name, payload): + repository = payload.get("repository") or {} + installation = payload.get("installation") or {} + return { + "delivery_id": delivery_id, + "event": event_name, + "action": payload.get("action"), + "installation_id": installation.get("id"), + "repository_id": repository.get("id"), + "repository": repository.get("full_name"), + "payload_hash": payload_hash(payload), + "received_at": utc_now(), + "state": "received", + "issue_refs": ["OpenCoven/coven-github#2"], + } + + +def mentioned(text, policy): + normalized = (text or "").lower() + for username in policy.get("bot_usernames") or []: + if "@" + username.lower() in normalized: + return True + return False + + +def labels_include_trigger(labels, policy): + wanted = set((policy.get("trigger_labels") or [])) + for label in labels or []: + name = (label.get("name") if isinstance(label, dict) else str(label)).strip() + if name in wanted: + return True + return False + + +def build_task_from_event(event_name, delivery_id, payload, policy): + repository = payload.get("repository") or {} + installation = payload.get("installation") or {} + familiar = policy.get("familiar") or DEFAULT_POLICY["installations"]["144638200"]["repositories"]["1290307745"]["familiar"] + base = { + "task_id": delivery_id, + "delivery_id": delivery_id, + "created_at": utc_now(), + "updated_at": utc_now(), + "state": "queued", + "attempts": 0, + "installation_id": installation.get("id"), + "repository_id": repository.get("id"), + "repository": repository.get("full_name"), + "clone_url": repository.get("clone_url") + or "https://github.com/{}.git".format(repository.get("full_name")), + "default_branch": repository.get("default_branch") or policy.get("default_branch") or "main", + "familiar": familiar, + "publication": policy.get("publication") or {"mode": "record_only"}, + "issue_refs": ["OpenCoven/coven-github#2", "OpenCoven/coven-github#7"], + } + + if event_name == "issue_comment": + issue = payload.get("issue") or {} + comment = payload.get("comment") or {} + if not mentioned(comment.get("body"), policy): + return ignored(base, "issue_comment_without_mention") + if issue.get("pull_request"): + base.update( + { + "trigger": "pr_mention", + "target": { + "kind": "pull_request", + "pr_number": int(issue.get("number") or 0), + }, + "task": { + "kind": "respond_to_mention", + "issue_number": int(issue.get("number") or 0), + "comment_body": comment.get("body") or "", + }, + "issue_refs": base["issue_refs"] + ["OpenCoven/coven-github#4"], + } + ) + return base + base.update( + { + "trigger": "issue_mention", + "task": { + "kind": "respond_to_mention", + "issue_number": int(issue.get("number") or 0), + "comment_body": comment.get("body") or "", + }, + "issue_refs": base["issue_refs"] + ["OpenCoven/coven-github#4"], + } + ) + return base + + if event_name == "pull_request_review_comment": + comment = payload.get("comment") or {} + pull_request = payload.get("pull_request") or {} + if not mentioned(comment.get("body"), policy): + return ignored(base, "pr_review_comment_without_mention") + base.update( + { + "trigger": "pr_review_comment", + "task": { + "kind": "address_review_comment", + "pr_number": int(pull_request.get("number") or 0), + "comment_body": comment.get("body") or "", + "diff_hunk": comment.get("diff_hunk"), + "path": comment.get("path"), + "line": comment.get("line"), + "side": comment.get("side"), + "commit_id": comment.get("commit_id"), + "html_url": comment.get("html_url"), + }, + "issue_refs": base["issue_refs"] + ["OpenCoven/coven-github#4"], + } + ) + return base + + if event_name == "issues": + issue = payload.get("issue") or {} + action = payload.get("action") + if action not in ("assigned", "labeled", "opened"): + return ignored(base, "unsupported_issue_action") + if action == "labeled" and not labels_include_trigger(issue.get("labels"), policy): + return ignored(base, "issue_label_not_enabled") + base.update( + { + "trigger": "issue_assigned" if action == "assigned" else "issue_mention", + "task": { + "kind": "fix_issue", + "issue_number": int(issue.get("number") or 0), + "issue_title": issue.get("title") or "", + "issue_body": issue.get("body") or "", + }, + "issue_refs": base["issue_refs"] + ["OpenCoven/coven-github#4"], + } + ) + return base + + if event_name == "pull_request": + pull_request = payload.get("pull_request") or {} + base.update( + { + "state": "ignored", + "ignored_reason": "pull_request_review_task_not_in_headless_contract_v1", + "trigger": "pull_request", + "target": { + "action": payload.get("action"), + "number": pull_request.get("number"), + "head_sha": (pull_request.get("head") or {}).get("sha"), + "head_ref": (pull_request.get("head") or {}).get("ref"), + "base_ref": (pull_request.get("base") or {}).get("ref"), + }, + "issue_refs": base["issue_refs"] + ["OpenCoven/coven-github#10"], + } + ) + return base + + if event_name == "push": + base.update( + { + "state": "ignored", + "ignored_reason": "push_review_task_not_in_headless_contract_v1", + "trigger": "push", + "target": { + "ref": payload.get("ref"), + "before": payload.get("before"), + "after": payload.get("after"), + "commit_count": len(payload.get("commits") or []), + }, + "issue_refs": base["issue_refs"] + ["OpenCoven/coven-github#10"], + } + ) + return base + + return ignored(base, "unsupported_event") + + +def ignored(base, reason): + base["state"] = "ignored" + base["ignored_reason"] = reason + return base + + +def route_delivery(event_name, delivery_id, payload, debug): + delivery_file = delivery_path(delivery_id) + if delivery_file.exists(): + existing = read_json(delivery_file, {}) + return { + "ok": True, + "action": "duplicate_ignored", + "delivery_id": delivery_id, + "task_id": existing.get("task_id"), + "state": existing.get("state"), + } + + delivery = delivery_record(delivery_id, event_name, payload) + installation_id, repo_id, policy = repo_policy(payload) + if not policy: + delivery["state"] = "ignored" + delivery["routing_result"] = "no_policy_for_installation_repo" + delivery["installation_id"] = installation_id or delivery.get("installation_id") + delivery["repository_id"] = repo_id or delivery.get("repository_id") + write_json_atomic(delivery_file, delivery) + return { + "ok": True, + "action": "ignored", + "delivery_id": delivery_id, + "reason": "no_policy_for_installation_repo", + } + + task = build_task_from_event(event_name, delivery_id, payload, policy) + task["policy_snapshot"] = { + "enabled_triggers": policy.get("enabled_triggers") or [], + "publication": policy.get("publication") or {"mode": "record_only"}, + } + write_json_atomic(task_path(task["task_id"]), task) + + delivery["task_id"] = task["task_id"] + delivery["state"] = task["state"] + delivery["routing_result"] = task.get("ignored_reason") or "queued" + write_json_atomic(delivery_file, delivery) + + if task["state"] == "queued": + try: + run_task(task["task_id"], debug) + except Exception: + debug("COVEN GITHUB TASK RUN FAIL task_id={} {}".format(task["task_id"], traceback.format_exc())) + + return { + "ok": True, + "action": "accepted" if task["state"] != "ignored" else "ignored", + "delivery_id": delivery_id, + "task_id": task["task_id"], + "state": read_json(task_path(task["task_id"]), task).get("state"), + "reason": task.get("ignored_reason"), + "queued": task["state"] == "queued", + } + + +def run_command(args, cwd=None, env=None, timeout=300): + proc = subprocess.run( + args, + cwd=cwd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + text=True, + ) + return { + "args": args, + "returncode": proc.returncode, + "stdout": proc.stdout[-8000:], + "stderr": proc.stderr[-8000:], + } + + +def write_askpass(work_dir): + script = work_dir / "git-askpass.sh" + script.write_text("#!/bin/sh\nprintf '%s\\n' \"$COVEN_GIT_TOKEN\"\n", encoding="utf-8") + script.chmod(0o700) + return script + + +def session_brief(task, workspace, review_context=None): + owner, name = (task["repository"] or "/").split("/", 1) + brief = { + "contract_version": "2", + "trigger": task["trigger"], + "repo": { + "owner": owner, + "name": name, + "clone_url": task["clone_url"], + "default_branch": task["default_branch"], + }, + "task": task["task"], + "familiar": task["familiar"], + "workspace": {"root": str(workspace)}, + } + if review_context: + brief["review_context"] = review_context + brief["audit_instruction"] = ( + "This run is evidence-backed. Review the supplied PR metadata and " + "changed-file patches in review_context before responding. Cite the " + "specific changed files you inspected in the result summary." + ) + return brief + + +def run_task(task_id, debug): + path = task_path(task_id) + task = read_json(path, {}) + if task.get("state") != "queued": + return task + + task["state"] = "running" + task["attempts"] = int(task.get("attempts") or 0) + 1 + task["updated_at"] = utc_now() + write_json_atomic(path, task) + + attempt_dir = ATTEMPTS_DIR / task_id / str(task["attempts"]) + attempt_dir.mkdir(parents=True, exist_ok=True) + workspace = WORKSPACES_DIR / task_id / "repo" + + try: + token = installation_token(task["installation_id"]) + askpass = write_askpass(attempt_dir) + env = os.environ.copy() + env["GIT_ASKPASS"] = str(askpass) + env["GIT_TERMINAL_PROMPT"] = "0" + env["COVEN_GIT_TOKEN"] = token + env["COVEN_CODE_PROVIDER"] = "codex" + env["COVEN_CODE_HOSTED_REVIEW"] = "1" + env["HOME"] = str(CODEX_TOKENS_PATH.parent.parent) + codex_access_token = load_codex_access_token() + if not codex_access_token: + return fail_task( + path, + task, + "codex_auth_missing", + "Missing Codex access token at {}".format(CODEX_TOKENS_PATH), + ) + env["OPENAI_API_KEY"] = codex_access_token + + if not workspace.exists(): + clone = run_command( + [ + "git", + "clone", + "--depth", + "1", + "--branch", + task["default_branch"], + task["clone_url"], + str(workspace), + ], + env=env, + timeout=180, + ) + write_json_atomic(attempt_dir / "clone.json", redacted_command_result(clone)) + if clone["returncode"] != 0: + return fail_task(path, task, "clone_failed", clone["stderr"]) + + review_context = prepare_review_context(task, workspace, token, env, attempt_dir) + if review_context: + review_context_path = attempt_dir / "review-context.json" + write_json_atomic(review_context_path, review_context) + task["review_context_path"] = str(review_context_path) + task["review_context_sha256"] = file_sha256(review_context_path) + task["review_evidence"] = review_evidence(review_context, review_context_path, task) + write_json_atomic(path, task) + + brief_path = attempt_dir / "session-brief.json" + result_path = attempt_dir / "result.json" + write_json_atomic(brief_path, session_brief(task, workspace, review_context)) + task["session_brief_path"] = str(brief_path) + task["session_brief_sha256"] = file_sha256(brief_path) + write_json_atomic(path, task) + + if not command_exists(COVEN_CODE_BIN): + return fail_task( + path, + task, + "runtime_missing", + "COVEN_CODE_BIN is not available on the host: {}".format(COVEN_CODE_BIN), + ) + + run = run_command( + [ + COVEN_CODE_BIN, + "--headless", + "--hosted-review", + "--provider", + "codex", + "--model", + COVEN_CODE_MODEL, + "--context", + str(brief_path), + "--output", + str(result_path), + ], + cwd=str(workspace), + env=env, + timeout=1800, + ) + write_json_atomic(attempt_dir / "run.json", redacted_command_result(run)) + task["runtime_exit_code"] = run["returncode"] + task["result_path"] = str(result_path) + if not result_path.exists(): + return fail_task( + path, + task, + "result_missing", + "coven-code exited {} without writing result.json: {}".format( + run["returncode"], run["stderr"] + ), + ) + task["state"] = "completed" if run["returncode"] in (0, 1, 3) else "failed" + task["updated_at"] = utc_now() + publish_result_if_configured(task, result_path, token) + write_json_atomic(path, task) + return task + except Exception as exc: + return fail_task(path, task, "infra_error", repr(exc)) + + +def command_exists(command): + probe = run_command(["/bin/sh", "-lc", "command -v {}".format(shell_quote(command))], timeout=10) + return probe["returncode"] == 0 + + +def shell_quote(value): + return "'" + str(value).replace("'", "'\"'\"'") + "'" + + +def pr_number_for_task(task): + task_data = task.get("task") or {} + target = task.get("target") or {} + if target.get("kind") == "pull_request" and target.get("pr_number"): + return int(target.get("pr_number")) + value = task_data.get("pr_number") + if value: + return int(value) + return None + + +def prepare_review_context(task, workspace, token, env, attempt_dir): + pr_number = pr_number_for_task(task) + if not pr_number: + return None + + repo = task.get("repository") + pr = github_request( + "GET", + "https://api.github.com/repos/{}/pulls/{}".format(repo, pr_number), + token, + ) + files = github_request( + "GET", + "https://api.github.com/repos/{}/pulls/{}/files?per_page=100".format(repo, pr_number), + token, + ) + + fetch = run_command( + ["git", "fetch", "--depth", "1", "origin", "pull/{}/head".format(pr_number)], + cwd=str(workspace), + env=env, + timeout=180, + ) + write_json_atomic(attempt_dir / "fetch-pr.json", redacted_command_result(fetch)) + if fetch["returncode"] != 0: + return { + "kind": "pull_request", + "pr_number": pr_number, + "fetch_error": fetch["stderr"], + "metadata": summarize_pr(pr), + "files": summarize_pr_files(files), + } + + checkout = run_command(["git", "checkout", "--detach", "FETCH_HEAD"], cwd=str(workspace), env=env) + write_json_atomic(attempt_dir / "checkout-pr.json", redacted_command_result(checkout)) + + head = run_command(["git", "rev-parse", "HEAD"], cwd=str(workspace), env=env) + status = run_command(["git", "status", "--short", "--branch"], cwd=str(workspace), env=env) + write_json_atomic(attempt_dir / "workspace-git.json", redacted_command_result({ + "args": ["git evidence"], + "returncode": 0 if head["returncode"] == 0 and status["returncode"] == 0 else 1, + "stdout": "HEAD={}\n{}".format(head["stdout"].strip(), status["stdout"].strip()), + "stderr": head["stderr"] + status["stderr"], + })) + + return { + "kind": "pull_request", + "pr_number": pr_number, + "metadata": summarize_pr(pr), + "files": summarize_pr_files(files), + "checkout": { + "fetch_returncode": fetch["returncode"], + "checkout_returncode": checkout["returncode"], + "workspace_head_sha": head["stdout"].strip(), + "workspace_status": status["stdout"].strip(), + }, + } + + +def summarize_pr(pr): + return { + "number": pr.get("number"), + "title": pr.get("title"), + "state": pr.get("state"), + "html_url": pr.get("html_url"), + "base_ref": (pr.get("base") or {}).get("ref"), + "base_sha": (pr.get("base") or {}).get("sha"), + "head_ref": (pr.get("head") or {}).get("ref"), + "head_sha": (pr.get("head") or {}).get("sha"), + "merge_commit_sha": pr.get("merge_commit_sha"), + } + + +def summarize_pr_files(files): + summarized = [] + for item in files or []: + patch = item.get("patch") or "" + summarized.append( + { + "filename": item.get("filename"), + "status": item.get("status"), + "additions": item.get("additions"), + "deletions": item.get("deletions"), + "changes": item.get("changes"), + "sha": item.get("sha"), + "patch": patch[:12000], + "patch_truncated": len(patch) > 12000, + } + ) + return summarized + + +def review_evidence(review_context, review_context_path, task): + metadata = review_context.get("metadata") or {} + files = review_context.get("files") or [] + checkout = review_context.get("checkout") or {} + return { + "pr_number": review_context.get("pr_number"), + "base_ref": metadata.get("base_ref"), + "base_sha": metadata.get("base_sha"), + "head_ref": metadata.get("head_ref"), + "head_sha": metadata.get("head_sha"), + "workspace_head_sha": checkout.get("workspace_head_sha"), + "changed_file_count": len(files), + "changed_files": [f.get("filename") for f in files], + "review_context_path": str(review_context_path), + "review_context_sha256": file_sha256(review_context_path), + } + + +def file_sha256(path): + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def publish_result_if_configured(task, result_path, token): + publication = task.get("publication") or {} + mode = publication.get("mode") or "record_only" + if mode != "comment": + task["publication_state"] = "held_for_issue_11_publication_gates" + return + + result = read_json(result_path, {}) + number = ( + (task.get("task") or {}).get("issue_number") + or (task.get("task") or {}).get("pr_number") + ) + if not number: + task["publication_state"] = "publication_skipped_no_issue_or_pr_number" + return + + body = publication_comment_body(task, result) + repo = task.get("repository") + url = "https://api.github.com/repos/{}/issues/{}/comments".format(repo, int(number)) + try: + response = github_request("POST", url, token, {"body": body}) + task["publication_state"] = "published_comment" + task["publication_url"] = response.get("html_url") + task["publication_comment_id"] = response.get("id") + except Exception as exc: + task["publication_state"] = "publication_failed" + task["publication_error"] = redact_tokenish(repr(exc)) + + +def publication_comment_body(task, result): + status = result.get("status") or "unknown" + summary = result.get("summary") or "No summary returned." + pr_body = result.get("pr_body") or "" + files_changed = result.get("files_changed") or [] + commits = result.get("commits") or [] + task_id = task.get("task_id") or "" + evidence = task.get("review_evidence") or {} + review = result.get("review") or {} + + parts = [ + "## Cody dogfood result", + "", + "**Status:** {}".format(status), + "", + summary.strip(), + ] + if pr_body.strip() and pr_body.strip() != summary.strip(): + parts.extend(["", pr_body.strip()]) + parts.extend(["", "### Evidence"]) + if evidence: + changed_files = evidence.get("changed_files") or [] + parts.extend( + [ + "- PR: #{}".format(evidence.get("pr_number")), + "- Base: `{}` @ `{}`".format(evidence.get("base_ref"), evidence.get("base_sha")), + "- Head: `{}` @ `{}`".format(evidence.get("head_ref"), evidence.get("head_sha")), + "- Checked-out workspace HEAD: `{}`".format(evidence.get("workspace_head_sha")), + "- Changed files supplied to agent: {}".format(evidence.get("changed_file_count")), + "- Review context SHA-256: `{}`".format(evidence.get("review_context_sha256")), + ] + ) + if changed_files: + parts.append("- Files: {}".format(", ".join("`{}`".format(f) for f in changed_files[:20]))) + else: + parts.append("- No PR review evidence was captured for this run.") + parts.extend(structured_review_lines(review)) + parts.extend( + [ + "", + "**Files changed:** {}".format(len(files_changed)), + "**Commits:** {}".format(len(commits)), + "", + "_Task `{}`. Publication is enabled on the hosted test adapter only._".format(task_id), + ] + ) + return "\n".join(parts) + + +def structured_review_lines(review): + if not review: + return ["", "### Structured review", "- No structured review result was emitted."] + + lines = [ + "", + "### Structured review", + "- Mode: `{}`".format(review.get("mode") or "unknown"), + "- Evidence status: `{}`".format(review.get("evidence_status") or "unknown"), + ] + + reviewed_files = review.get("reviewed_files") or [] + lines.append("- Reviewed files: {}".format(len(reviewed_files))) + if reviewed_files: + lines.append( + "- Reviewed file list: {}".format( + ", ".join("`{}`".format(path) for path in reviewed_files[:20]) + ) + ) + if len(reviewed_files) > 20: + lines.append("- Reviewed file list truncated after 20 entries.") + + supporting_files = review.get("supporting_files") or [] + lines.append("- Supporting files inspected: {}".format(len(supporting_files))) + if supporting_files: + lines.append( + "- Supporting file list: {}".format( + ", ".join("`{}`".format(path) for path in supporting_files[:20]) + ) + ) + if len(supporting_files) > 20: + lines.append("- Supporting file list truncated after 20 entries.") + + findings = review.get("findings") or [] + lines.append("- Findings: {}".format(len(findings))) + for index, finding in enumerate(findings[:10], start=1): + location = finding.get("file") or "unknown file" + if finding.get("line") is not None: + location = "{}:{}".format(location, finding.get("line")) + lines.append( + " {}. `{}` {} - {}".format( + index, + finding.get("severity") or "unknown", + location, + finding.get("title") or "Untitled finding", + ) + ) + if len(findings) > 10: + lines.append("- Findings truncated after 10 entries.") + + no_findings_reason = review.get("no_findings_reason") + if no_findings_reason: + lines.append("- No-findings reason: {}".format(no_findings_reason)) + + tests_run = review.get("tests_run") or [] + lines.append("- Tests reported by runtime: {}".format(len(tests_run))) + for test in tests_run[:10]: + summary = test.get("output_summary") + suffix = " - {}".format(summary) if summary else "" + lines.append( + " - `{}`: `{}`{}".format( + test.get("command") or "unknown command", + test.get("status") or "unknown", + suffix, + ) + ) + if len(tests_run) > 10: + lines.append("- Test list truncated after 10 entries.") + + limitations = review.get("limitations") or [] + lines.append("- Limitations: {}".format(len(limitations))) + for limitation in limitations[:10]: + lines.append(" - {}".format(limitation)) + if len(limitations) > 10: + lines.append("- Limitation list truncated after 10 entries.") + + return lines + + +def load_codex_access_token(): + for path in codex_token_candidates(): + try: + data = read_json(path, {}) + token = str(data.get("access_token") or "").strip() + if token: + return token + except Exception: + continue + return None + + +def codex_token_candidates(): + yield CODEX_TOKENS_PATH + + coven_home = CODEX_TOKENS_PATH.parent + registry = read_json(coven_home / "accounts.json", {}) + active = ( + registry.get("providers", {}) + .get("codex", {}) + .get("active") + ) + if active: + yield coven_home / "accounts" / "codex" / str(active) / "codex_tokens.json" + + accounts_root = coven_home / "accounts" / "codex" + if accounts_root.exists(): + for path in accounts_root.glob("*/codex_tokens.json"): + yield path + + +def redacted_command_result(result): + redacted = dict(result) + for key in ("stdout", "stderr"): + redacted[key] = redact_tokenish(redacted.get(key) or "") + return redacted + + +def redact_tokenish(text): + if not text: + return text + markers = ["ghs_", "ghu_", "github_pat_", "x-access-token:"] + redacted = text + for marker in markers: + while marker in redacted: + index = redacted.find(marker) + end = index + len(marker) + while end < len(redacted) and redacted[end] not in " \n\r\t'\"": + end += 1 + redacted = redacted[:index] + marker + "[redacted]" + redacted[end:] + return redacted + + +def fail_task(path, task, reason, detail): + task["state"] = "failed" + task["failure_category"] = reason + task["failure_detail"] = redact_tokenish(str(detail))[-4000:] + task["updated_at"] = utc_now() + write_json_atomic(path, task) + return task From 3cd400a27053f519411965f7f821dc31bfa910b1 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Mon, 6 Jul 2026 01:16:02 -0400 Subject: [PATCH 04/20] feat(hosted): add bounded review fix loop (#119) Signed-off-by: Timothy Wayne Gregg --- crates/worker/src/lib.rs | 6 +- deploy/complete-tech/README.md | 5 + deploy/complete-tech/coven_github_adapter.py | 239 ++++++++++++++++--- 3 files changed, 218 insertions(+), 32 deletions(-) diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index bc8372c..35bcee1 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -391,7 +391,7 @@ fn disposition(result: &SessionResult) -> Disposition { enum Attempt { /// The runtime exited 0/1/3 and wrote a parseable `result.json`. Terminal: /// the adapter acts on `status`/`exit_reason` and MUST NOT retry. - Completed(SessionResult), + Completed(Box), /// Exit 2, timeout, kill-by-signal, an unexpected exit code, or a spawn/read /// failure. Retry-safe per the contract. RetrySafe(anyhow::Error), @@ -430,7 +430,7 @@ async fn run_session_with_backoff( let mut attempts = 0u32; loop { match run_coven_code(config, brief_path, result_path, git_token).await { - Attempt::Completed(result) => return Ok(result), + Attempt::Completed(result) => return Ok(*result), Attempt::RetrySafe(e) if attempts < max_retries => { attempts += 1; warn!("coven-code attempt {attempts} hit a retry-safe failure ({e:#}), retryingโ€ฆ"); @@ -488,7 +488,7 @@ async fn run_coven_code( // exit codes; if it isn't, the runtime misbehaved โ€” fall back to a // retry-safe failure rather than silently losing the task. Some(code @ (0 | 1 | 3)) => match read_result(result_path).await { - Ok(result) => Attempt::Completed(result), + Ok(result) => Attempt::Completed(Box::new(result)), Err(e) => Attempt::RetrySafe(anyhow::anyhow!( "coven-code exited {code} but result.json was unusable: {e}" )), diff --git a/deploy/complete-tech/README.md b/deploy/complete-tech/README.md index 5c695ae..64484b2 100644 --- a/deploy/complete-tech/README.md +++ b/deploy/complete-tech/README.md @@ -21,6 +21,8 @@ The deployment expects secrets and mutable state to be supplied outside git: - `COVEN_GITHUB_STATE_DIR` - `COVEN_GITHUB_POLICY_PATH` - `COVEN_CODE_BIN` +- `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 Do not commit private keys, webhook secrets, OAuth tokens, generated task state, @@ -34,3 +36,6 @@ workspaces, or attempt artifacts. - Publishes visible structured review evidence, including `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/deploy/complete-tech/coven_github_adapter.py b/deploy/complete-tech/coven_github_adapter.py index 2d1b6ef..0277d7b 100644 --- a/deploy/complete-tech/coven_github_adapter.py +++ b/deploy/complete-tech/coven_github_adapter.py @@ -27,6 +27,20 @@ COVEN_CODE_MODEL = os.environ.get("COVEN_CODE_MODEL", "gpt-5.5").strip() +def env_int(name, default, minimum=0, maximum=10): + raw = os.environ.get(name, "").strip() + if not raw: + return default + try: + value = int(raw) + except ValueError: + return default + return max(minimum, min(maximum, value)) + + +MAX_REVIEW_FIX_LOOPS = env_int("COVEN_REVIEW_FIX_LOOPS", 0, minimum=0, maximum=5) + + def account_home(): try: import pwd @@ -456,7 +470,7 @@ def write_askpass(work_dir): return script -def session_brief(task, workspace, review_context=None): +def session_brief(task, workspace, review_context=None, extra_audit_instruction=None): owner, name = (task["repository"] or "/").split("/", 1) brief = { "contract_version": "2", @@ -473,14 +487,122 @@ def session_brief(task, workspace, review_context=None): } if review_context: brief["review_context"] = review_context - brief["audit_instruction"] = ( + instruction = ( "This run is evidence-backed. Review the supplied PR metadata and " "changed-file patches in review_context before responding. Cite the " "specific changed files you inspected in the result summary." ) + if extra_audit_instruction: + instruction = instruction + "\n\n" + extra_audit_instruction + brief["audit_instruction"] = instruction return brief +def run_coven_code_cycle( + task, + workspace, + review_context, + attempt_dir, + env, + cycle, + extra_audit_instruction=None, +): + suffix = "" if cycle == 0 else "-repair-{}".format(cycle) + brief_path = attempt_dir / "session-brief{}.json".format(suffix) + result_path = attempt_dir / "result{}.json".format(suffix) + run_path = attempt_dir / "run{}.json".format(suffix) + + write_json_atomic( + brief_path, + session_brief(task, workspace, review_context, extra_audit_instruction), + ) + run = run_command( + [ + COVEN_CODE_BIN, + "--headless", + "--hosted-review", + "--provider", + "codex", + "--model", + COVEN_CODE_MODEL, + "--context", + str(brief_path), + "--output", + str(result_path), + ], + cwd=str(workspace), + env=env, + timeout=1800, + ) + write_json_atomic(run_path, redacted_command_result(run)) + result = read_json(result_path, None) if result_path.exists() else None + return { + "cycle": cycle, + "brief_path": brief_path, + "result_path": result_path, + "run_path": run_path, + "run": run, + "result": result, + } + + +def review_findings(result): + if not result: + return [] + review = result.get("review") or {} + mode = review.get("mode") + if mode not in ("pull_request", "review_comment"): + return [] + return review.get("findings") or [] + + +def review_fix_instruction(findings, iteration, max_iterations): + lines = [ + "Autofix review loop iteration {}/{}.".format(iteration, max_iterations), + "The previous hosted review returned structured findings. Fix the findings below, run the relevant checks you can run safely, then perform another bounded review of the updated code using the required review sections.", + "If a finding cannot be fixed safely, leave a clear limitation and explain the remaining blocker. Do not merely restate the findings.", + "", + "Findings to fix:", + ] + for index, finding in enumerate(findings[:10], start=1): + location = finding.get("file") or "unknown file" + if finding.get("line") is not None: + location = "{}:{}".format(location, finding.get("line")) + lines.append( + "{}. [{}] `{}` {}".format( + index, + finding.get("severity") or "unknown", + location, + finding.get("title") or "Untitled finding", + ) + ) + body = (finding.get("body") or "").strip() + if body: + lines.append(" Body: {}".format(body[:1200])) + recommendation = (finding.get("recommendation") or "").strip() + if recommendation: + lines.append(" Recommendation: {}".format(recommendation[:1200])) + if len(findings) > 10: + lines.append("Only the first 10 findings are listed; inspect the prior result for the full set.") + return "\n".join(lines) + + +def task_with_repair_request(task, instruction): + copy = json.loads(json.dumps(task)) + task_data = copy.get("task") or {} + explicit_request = ( + "\n\nPlease fix the review findings from the previous hosted review cycle. " + "After fixing them, rerun relevant checks and produce another structured review.\n\n" + + instruction + ) + if "comment_body" in task_data: + task_data["comment_body"] = (task_data.get("comment_body") or "") + explicit_request + elif "issue_body" in task_data: + task_data["issue_body"] = (task_data.get("issue_body") or "") + explicit_request + copy["task"] = task_data + return copy + + def run_task(task_id, debug): path = task_path(task_id) task = read_json(path, {}) @@ -544,13 +666,6 @@ def run_task(task_id, debug): task["review_evidence"] = review_evidence(review_context, review_context_path, task) write_json_atomic(path, task) - brief_path = attempt_dir / "session-brief.json" - result_path = attempt_dir / "result.json" - write_json_atomic(brief_path, session_brief(task, workspace, review_context)) - task["session_brief_path"] = str(brief_path) - task["session_brief_sha256"] = file_sha256(brief_path) - write_json_atomic(path, task) - if not command_exists(COVEN_CODE_BIN): return fail_task( path, @@ -559,28 +674,17 @@ def run_task(task_id, debug): "COVEN_CODE_BIN is not available on the host: {}".format(COVEN_CODE_BIN), ) - run = run_command( - [ - COVEN_CODE_BIN, - "--headless", - "--hosted-review", - "--provider", - "codex", - "--model", - COVEN_CODE_MODEL, - "--context", - str(brief_path), - "--output", - str(result_path), - ], - cwd=str(workspace), - env=env, - timeout=1800, - ) - write_json_atomic(attempt_dir / "run.json", redacted_command_result(run)) + cycle_result = run_coven_code_cycle(task, workspace, review_context, attempt_dir, env, 0) + brief_path = cycle_result["brief_path"] + result_path = cycle_result["result_path"] + run = cycle_result["run"] + task["session_brief_path"] = str(brief_path) + task["session_brief_sha256"] = file_sha256(brief_path) task["runtime_exit_code"] = run["returncode"] task["result_path"] = str(result_path) - if not result_path.exists(): + write_json_atomic(path, task) + + if cycle_result["result"] is None: return fail_task( path, task, @@ -589,6 +693,58 @@ def run_task(task_id, debug): run["returncode"], run["stderr"] ), ) + + final_cycle = cycle_result + loop_records = [] + for iteration in range(1, MAX_REVIEW_FIX_LOOPS + 1): + findings = review_findings(final_cycle["result"]) + if not findings: + break + instruction = review_fix_instruction(findings, iteration, MAX_REVIEW_FIX_LOOPS) + repair_task = task_with_repair_request(task, instruction) + repair_cycle = run_coven_code_cycle( + repair_task, + workspace, + review_context, + attempt_dir, + env, + iteration, + instruction, + ) + remaining = review_findings(repair_cycle["result"]) + loop_records.append( + { + "iteration": iteration, + "input_findings": len(findings), + "runtime_exit_code": repair_cycle["run"]["returncode"], + "result_path": str(repair_cycle["result_path"]), + "result_status": (repair_cycle["result"] or {}).get("status"), + "remaining_findings": len(remaining), + } + ) + task["review_fix_loops"] = loop_records + task["runtime_exit_code"] = repair_cycle["run"]["returncode"] + task["result_path"] = str(repair_cycle["result_path"]) + task["updated_at"] = utc_now() + write_json_atomic(path, task) + + if repair_cycle["result"] is None: + return fail_task( + path, + task, + "result_missing", + "review repair loop {} exited {} without writing result.json: {}".format( + iteration, + repair_cycle["run"]["returncode"], + repair_cycle["run"]["stderr"], + ), + ) + final_cycle = repair_cycle + + result_path = final_cycle["result_path"] + run = final_cycle["run"] + task["runtime_exit_code"] = run["returncode"] + task["result_path"] = str(result_path) task["state"] = "completed" if run["returncode"] in (0, 1, 3) else "failed" task["updated_at"] = utc_now() publish_result_if_configured(task, result_path, token) @@ -784,6 +940,7 @@ def publication_comment_body(task, result): ] if pr_body.strip() and pr_body.strip() != summary.strip(): parts.extend(["", pr_body.strip()]) + parts.extend(review_fix_loop_lines(task)) parts.extend(["", "### Evidence"]) if evidence: changed_files = evidence.get("changed_files") or [] @@ -814,6 +971,30 @@ def publication_comment_body(task, result): return "\n".join(parts) +def review_fix_loop_lines(task): + loops = task.get("review_fix_loops") or [] + if not loops: + return [] + + lines = ["", "### Review fix loop"] + for loop in loops: + lines.append( + "- Iteration {iteration}: input findings {input_findings}, result `{result_status}`, remaining findings {remaining_findings}.".format( + iteration=loop.get("iteration"), + input_findings=loop.get("input_findings"), + result_status=loop.get("result_status") or "unknown", + remaining_findings=loop.get("remaining_findings"), + ) + ) + if loops and loops[-1].get("remaining_findings", 0): + lines.append( + "- Loop stopped after {} configured iteration(s); unresolved findings remain.".format( + len(loops) + ) + ) + return lines + + def structured_review_lines(review): if not review: return ["", "### Structured review", "- No structured review result was emitted."] From 669fca69b993acfcce90e5170c8456163aa04d40 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Mon, 6 Jul 2026 03:09:20 -0400 Subject: [PATCH 05/20] fix(hosted): address review gating and evidence gaps (#119) Signed-off-by: Timothy Wayne Gregg --- deploy/{complete-tech => coven-github}/README.md | 0 deploy/{complete-tech => coven-github}/coven_github_adapter.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename deploy/{complete-tech => coven-github}/README.md (100%) rename deploy/{complete-tech => coven-github}/coven_github_adapter.py (100%) diff --git a/deploy/complete-tech/README.md b/deploy/coven-github/README.md similarity index 100% rename from deploy/complete-tech/README.md rename to deploy/coven-github/README.md diff --git a/deploy/complete-tech/coven_github_adapter.py b/deploy/coven-github/coven_github_adapter.py similarity index 100% rename from deploy/complete-tech/coven_github_adapter.py rename to deploy/coven-github/coven_github_adapter.py From a6a794d643c342162072ec7b72377f1f6ae75d9c Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Mon, 6 Jul 2026 03:09:32 -0400 Subject: [PATCH 06/20] fix(hosted): apply review hardening (#119) Signed-off-by: Timothy Wayne Gregg --- .gitignore | 6 +- crates/worker/src/lib.rs | 36 +++++ deploy/coven-github/README.md | 12 +- deploy/coven-github/coven_github_adapter.py | 151 ++++++++++++++------ docs/contracts/session-brief.schema.json | 2 +- 5 files changed, 155 insertions(+), 52 deletions(-) diff --git a/.gitignore b/.gitignore index a8407f6..27e947b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,6 @@ keys/ *.pem .env .DS_Store -deploy/complete-tech/.coven-github-private-key.pem -deploy/complete-tech/coven-github-policy.json -deploy/complete-tech/coven-github-state/ +deploy/coven-github/.coven-github-private-key.pem +deploy/coven-github/coven-github-policy.json +deploy/coven-github/coven-github-state/ diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 35bcee1..08412f3 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -506,6 +506,13 @@ async fn read_result(result_path: &Path) -> Result { .await .map_err(|_| anyhow::anyhow!("result.json not written by coven-code"))?; let result: SessionResult = serde_json::from_slice(&bytes)?; + if result.contract_version != coven_github_api::HEADLESS_CONTRACT_VERSION { + anyhow::bail!( + "unsupported result contract_version {}; expected {}", + result.contract_version, + coven_github_api::HEADLESS_CONTRACT_VERSION + ); + } Ok(result) } @@ -622,6 +629,35 @@ fn task_issue_number(kind: &TaskKind) -> Option { } } +#[cfg(test)] +mod result_tests { + use super::*; + use std::fs; + + #[tokio::test] + async fn read_result_rejects_unsupported_contract_version() { + let path = std::env::temp_dir().join(format!( + "coven-github-result-version-{}.json", + uuid::Uuid::new_v4() + )); + fs::write( + &path, + r#"{"contract_version":"1","status":"success","branch":null,"commits":[],"files_changed":[],"summary":"s","pr_body":"","review":{"mode":"none","evidence_status":"not_applicable","reviewed_files":[],"supporting_files":[],"findings":[],"tests_run":[],"no_findings_reason":null,"limitations":[]},"exit_reason":null}"#, + ) + .expect("result fixture should be written"); + + let error = read_result(&path) + .await + .expect_err("v1 result must be rejected"); + assert!( + format!("{error:#}").contains("unsupported result contract_version 1"), + "unexpected error: {error:#}" + ); + + let _ = fs::remove_file(path); + } +} + #[cfg(test)] mod disposition_tests { use super::*; diff --git a/deploy/coven-github/README.md b/deploy/coven-github/README.md index 64484b2..ece56de 100644 --- a/deploy/coven-github/README.md +++ b/deploy/coven-github/README.md @@ -1,7 +1,7 @@ -# CompleteTech hosted dogfood adapter +# coven-github hosted dogfood adapter -This directory tracks the Python adapter currently deployed for the -CompleteTech-hosted dogfood GitHub App at `webhook.complete.tech`. +This directory tracks the Python adapter used by the hosted coven-github +dogfood GitHub App. The adapter is intentionally deployment-specific. It is not the canonical Rust worker implementation; it exists so hosted dogfood behavior can be reviewed, @@ -9,8 +9,9 @@ reproduced, and changed through PRs instead of invisible server edits. ## Files -- `coven_github_adapter.py` - webhook handler, task runner, PR evidence capture, - Codex-backed headless runtime invocation, and dogfood comment publisher. +- `coven_github_adapter.py` - webhook routing helpers, task runner, PR evidence + capture, Codex-backed headless runtime invocation, and dogfood comment + publisher. ## Runtime inputs @@ -18,6 +19,7 @@ The deployment expects secrets and mutable state to be supplied outside git: - `GITHUB_APP_PRIVATE_KEY_PATH` or `.coven-github-private-key.pem` - `GITHUB_APP_ID` +- `GITHUB_WEBHOOK_SECRET` - `COVEN_GITHUB_STATE_DIR` - `COVEN_GITHUB_POLICY_PATH` - `COVEN_CODE_BIN` diff --git a/deploy/coven-github/coven_github_adapter.py b/deploy/coven-github/coven_github_adapter.py index 0277d7b..4195086 100644 --- a/deploy/coven-github/coven_github_adapter.py +++ b/deploy/coven-github/coven_github_adapter.py @@ -1,5 +1,6 @@ import base64 import hashlib +import hmac import json import os import subprocess @@ -22,9 +23,10 @@ PRIVATE_KEY_PATH = Path( os.environ.get("GITHUB_APP_PRIVATE_KEY_PATH", ROOT_DIR / ".coven-github-private-key.pem") ) -APP_ID = os.environ.get("GITHUB_APP_ID", "4224630").strip() +APP_ID = os.environ.get("GITHUB_APP_ID", "").strip() COVEN_CODE_BIN = os.environ.get("COVEN_CODE_BIN", "coven-code").strip() or "coven-code" COVEN_CODE_MODEL = os.environ.get("COVEN_CODE_MODEL", "gpt-5.5").strip() +WEBHOOK_SECRET = os.environ.get("GITHUB_WEBHOOK_SECRET", "").strip() def env_int(name, default, minimum=0, maximum=10): @@ -63,34 +65,17 @@ def configured_codex_tokens_path(): directory.mkdir(parents=True, exist_ok=True) +DEFAULT_FAMILIAR = { + "id": "cody", + "display_name": "Cody", + "model": None, + "skills": ["code-review"], +} + + DEFAULT_POLICY = { "version": 1, - "installations": { - "144638200": { - "repositories": { - "1290307745": { - "full_name": "CompleteTech-LLC-AI-Research/coven-code", - "default_branch": "main", - "enabled_triggers": [ - "issue_mention", - "issue_label", - "pr_review_comment", - "pull_request", - "push", - ], - "trigger_labels": ["coven", "coven:fix", "coven:review"], - "bot_usernames": ["coven-github", "cody"], - "familiar": { - "id": "cody", - "display_name": "Cody", - "model": None, - "skills": ["code-review"], - }, - "publication": {"mode": "comment"}, - } - } - } - }, + "installations": {}, } @@ -133,6 +118,8 @@ def sign_rs256(message): def github_app_jwt(): + if not APP_ID: + raise RuntimeError("GITHUB_APP_ID is required") now = int(time.time()) header = {"alg": "RS256", "typ": "JWT"} payload = {"iat": now - 60, "exp": now + 540, "iss": APP_ID} @@ -242,10 +229,17 @@ def labels_include_trigger(labels, policy): return False +def trigger_enabled(policy, trigger): + enabled = policy.get("enabled_triggers") + if enabled is None: + return True + return trigger in set(enabled or []) + + def build_task_from_event(event_name, delivery_id, payload, policy): repository = payload.get("repository") or {} installation = payload.get("installation") or {} - familiar = policy.get("familiar") or DEFAULT_POLICY["installations"]["144638200"]["repositories"]["1290307745"]["familiar"] + familiar = policy.get("familiar") or DEFAULT_FAMILIAR base = { "task_id": delivery_id, "delivery_id": delivery_id, @@ -269,10 +263,12 @@ def build_task_from_event(event_name, delivery_id, payload, policy): comment = payload.get("comment") or {} if not mentioned(comment.get("body"), policy): return ignored(base, "issue_comment_without_mention") + if not trigger_enabled(policy, "issue_mention"): + return ignored(base, "issue_mention_not_enabled") if issue.get("pull_request"): base.update( { - "trigger": "pr_mention", + "trigger": "issue_mention", "target": { "kind": "pull_request", "pr_number": int(issue.get("number") or 0), @@ -304,6 +300,8 @@ def build_task_from_event(event_name, delivery_id, payload, policy): pull_request = payload.get("pull_request") or {} if not mentioned(comment.get("body"), policy): return ignored(base, "pr_review_comment_without_mention") + if not trigger_enabled(policy, "pr_review_comment"): + return ignored(base, "pr_review_comment_not_enabled") base.update( { "trigger": "pr_review_comment", @@ -328,8 +326,14 @@ def build_task_from_event(event_name, delivery_id, payload, policy): action = payload.get("action") if action not in ("assigned", "labeled", "opened"): return ignored(base, "unsupported_issue_action") + if action == "assigned" and not trigger_enabled(policy, "issue_assigned"): + return ignored(base, "issue_assigned_not_enabled") if action == "labeled" and not labels_include_trigger(issue.get("labels"), policy): return ignored(base, "issue_label_not_enabled") + if action == "labeled" and not trigger_enabled(policy, "issue_label"): + return ignored(base, "issue_label_not_enabled") + if action == "opened" and not trigger_enabled(policy, "issue_mention"): + return ignored(base, "issue_mention_not_enabled") base.update( { "trigger": "issue_assigned" if action == "assigned" else "issue_mention", @@ -346,10 +350,12 @@ def build_task_from_event(event_name, delivery_id, payload, policy): if event_name == "pull_request": pull_request = payload.get("pull_request") or {} + if not trigger_enabled(policy, "pull_request"): + return ignored(base, "pull_request_not_enabled") base.update( { "state": "ignored", - "ignored_reason": "pull_request_review_task_not_in_headless_contract_v1", + "ignored_reason": "pull_request_review_task_not_in_headless_contract_v2", "trigger": "pull_request", "target": { "action": payload.get("action"), @@ -364,10 +370,12 @@ def build_task_from_event(event_name, delivery_id, payload, policy): return base if event_name == "push": + if not trigger_enabled(policy, "push"): + return ignored(base, "push_not_enabled") base.update( { "state": "ignored", - "ignored_reason": "push_review_task_not_in_headless_contract_v1", + "ignored_reason": "push_review_task_not_in_headless_contract_v2", "trigger": "push", "target": { "ref": payload.get("ref"), @@ -389,6 +397,53 @@ def ignored(base, reason): return base +def header_value(headers, name): + wanted = name.lower() + for key, value in (headers or {}).items(): + if str(key).lower() == wanted: + return value + return None + + +def verify_webhook_signature(secret, body, signature): + if not secret: + raise RuntimeError("GITHUB_WEBHOOK_SECRET is required") + if not signature or not str(signature).startswith("sha256="): + return False + expected = "sha256=" + hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() + return hmac.compare_digest(expected, str(signature)) + + +def route_signed_delivery(headers, body, debug, webhook_secret=None): + raw_body = body.encode("utf-8") if isinstance(body, str) else body + if raw_body is None: + raw_body = b"" + + signature = header_value(headers, "x-hub-signature-256") + if not verify_webhook_signature(webhook_secret or WEBHOOK_SECRET, raw_body, signature): + return {"ok": False, "status": 401, "error": "invalid signature"} + + event_name = header_value(headers, "x-github-event") + if not event_name: + return {"ok": False, "status": 400, "error": "missing event type"} + + delivery_id = header_value(headers, "x-github-delivery") + if not delivery_id: + return {"ok": False, "status": 400, "error": "missing delivery id"} + + try: + payload = json.loads(raw_body.decode("utf-8")) + except json.JSONDecodeError: + return {"ok": False, "status": 400, "error": "invalid json"} + + if event_name == "ping": + return {"ok": True, "status": 200, "pong": True, "delivery_id": delivery_id} + + result = route_delivery(str(event_name), str(delivery_id), payload, debug) + result["status"] = 200 + return result + + def route_delivery(event_name, delivery_id, payload, debug): delivery_file = delivery_path(delivery_id) if delivery_file.exists(): @@ -785,11 +840,7 @@ def prepare_review_context(task, workspace, token, env, attempt_dir): "https://api.github.com/repos/{}/pulls/{}".format(repo, pr_number), token, ) - files = github_request( - "GET", - "https://api.github.com/repos/{}/pulls/{}/files?per_page=100".format(repo, pr_number), - token, - ) + files = paginated_pr_files(repo, pr_number, token) fetch = run_command( ["git", "fetch", "--depth", "1", "origin", "pull/{}/head".format(pr_number)], @@ -799,16 +850,12 @@ def prepare_review_context(task, workspace, token, env, attempt_dir): ) write_json_atomic(attempt_dir / "fetch-pr.json", redacted_command_result(fetch)) if fetch["returncode"] != 0: - return { - "kind": "pull_request", - "pr_number": pr_number, - "fetch_error": fetch["stderr"], - "metadata": summarize_pr(pr), - "files": summarize_pr_files(files), - } + raise RuntimeError("failed to fetch PR #{} head: {}".format(pr_number, fetch["stderr"])) checkout = run_command(["git", "checkout", "--detach", "FETCH_HEAD"], cwd=str(workspace), env=env) write_json_atomic(attempt_dir / "checkout-pr.json", redacted_command_result(checkout)) + if checkout["returncode"] != 0: + raise RuntimeError("failed to checkout PR #{} head: {}".format(pr_number, checkout["stderr"])) head = run_command(["git", "rev-parse", "HEAD"], cwd=str(workspace), env=env) status = run_command(["git", "status", "--short", "--branch"], cwd=str(workspace), env=env) @@ -833,6 +880,24 @@ def prepare_review_context(task, workspace, token, env, attempt_dir): } +def paginated_pr_files(repo, pr_number, token): + files = [] + page = 1 + while True: + page_items = github_request( + "GET", + "https://api.github.com/repos/{}/pulls/{}/files?per_page=100&page={}".format( + repo, pr_number, page + ), + token, + ) + files.extend(page_items or []) + if len(page_items or []) < 100: + break + page += 1 + return files + + def summarize_pr(pr): return { "number": pr.get("number"), diff --git a/docs/contracts/session-brief.schema.json b/docs/contracts/session-brief.schema.json index 850c31e..f8abd31 100644 --- a/docs/contracts/session-brief.schema.json +++ b/docs/contracts/session-brief.schema.json @@ -5,7 +5,7 @@ "description": "Tokenless read-context payload the adapter writes for coven-code --headless --context. Contract version 2.", "type": "object", "additionalProperties": false, - "required": ["trigger", "repo", "task", "familiar", "workspace"], + "required": ["contract_version", "trigger", "repo", "task", "familiar", "workspace"], "properties": { "contract_version": { "type": "string", From 7cfc1bed57ccbdc125e6eff4bd8b00c4093a356f Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Mon, 6 Jul 2026 03:19:45 -0400 Subject: [PATCH 07/20] fix(hosted): align review context and routing contract (#119) Signed-off-by: Timothy Wayne Gregg --- crates/worker/src/brief.rs | 6 +++ crates/worker/src/lib.rs | 44 ++++++++++++++++-- crates/worker/tests/contract.rs | 51 +++++++++++++++++++++ deploy/coven-github/coven_github_adapter.py | 26 +++++++++-- docs/contracts/result.schema.json | 5 ++ docs/contracts/session-brief.schema.json | 9 ++++ docs/headless-contract.md | 2 + 7 files changed, 136 insertions(+), 7 deletions(-) diff --git a/crates/worker/src/brief.rs b/crates/worker/src/brief.rs index 2dce3d7..f03074d 100644 --- a/crates/worker/src/brief.rs +++ b/crates/worker/src/brief.rs @@ -27,6 +27,10 @@ pub struct SessionBrief { pub task: TaskBrief, pub familiar: FamiliarBrief, pub workspace: WorkspaceBrief, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review_context: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audit_instruction: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -136,6 +140,8 @@ pub fn build( workspace: WorkspaceBrief { root: workspace.to_string_lossy().to_string(), }, + review_context: None, + audit_instruction: None, } } diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 08412f3..c833d01 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -7,8 +7,8 @@ use tokio::process::Command; use tracing::{error, info, warn}; use coven_github_api::{ - check_run, installation, pr, repo, tasks::TaskStore, SessionResult, SessionStatus, Task, - TaskKind, DEFAULT_API_BASE_URL, + check_run, installation, pr, repo, tasks::TaskStore, ReviewEvidenceStatus, ReviewMode, + SessionResult, SessionStatus, Task, TaskKind, DEFAULT_API_BASE_URL, }; use coven_github_config::{Config, FamiliarConfig}; @@ -506,6 +506,11 @@ async fn read_result(result_path: &Path) -> Result { .await .map_err(|_| anyhow::anyhow!("result.json not written by coven-code"))?; let result: SessionResult = serde_json::from_slice(&bytes)?; + validate_result_contract(&result)?; + Ok(result) +} + +fn validate_result_contract(result: &SessionResult) -> Result<()> { if result.contract_version != coven_github_api::HEADLESS_CONTRACT_VERSION { anyhow::bail!( "unsupported result contract_version {}; expected {}", @@ -513,7 +518,17 @@ async fn read_result(result_path: &Path) -> Result { coven_github_api::HEADLESS_CONTRACT_VERSION ); } - Ok(result) + if matches!( + result.review.mode, + ReviewMode::PullRequest | ReviewMode::ReviewComment + ) && result.review.evidence_status == ReviewEvidenceStatus::NotApplicable + { + anyhow::bail!( + "review evidence_status not_applicable is invalid for {:?}", + result.review.mode + ); + } + Ok(()) } fn cave_base_url(config: &Config) -> &str { @@ -656,6 +671,29 @@ mod result_tests { let _ = fs::remove_file(path); } + + #[tokio::test] + async fn read_result_rejects_not_applicable_evidence_for_review_modes() { + let path = std::env::temp_dir().join(format!( + "coven-github-result-review-evidence-{}.json", + uuid::Uuid::new_v4() + )); + fs::write( + &path, + r#"{"contract_version":"2","status":"success","branch":null,"commits":[],"files_changed":[],"summary":"s","pr_body":"","review":{"mode":"pull_request","evidence_status":"not_applicable","reviewed_files":["src/lib.rs"],"supporting_files":[],"findings":[],"tests_run":[],"no_findings_reason":"reviewed supplied file","limitations":[]},"exit_reason":null}"#, + ) + .expect("result fixture should be written"); + + let error = read_result(&path) + .await + .expect_err("review result must reject not_applicable evidence"); + assert!( + format!("{error:#}").contains("review evidence_status not_applicable is invalid"), + "unexpected error: {error:#}" + ); + + let _ = fs::remove_file(path); + } } #[cfg(test)] diff --git a/crates/worker/tests/contract.rs b/crates/worker/tests/contract.rs index 45387e1..8e74365 100644 --- a/crates/worker/tests/contract.rs +++ b/crates/worker/tests/contract.rs @@ -38,6 +38,57 @@ fn golden_session_brief_deserializes_into_adapter_type() { assert_eq!(reserialized, original, "brief did not round-trip cleanly"); } +#[test] +fn hosted_review_session_brief_deserializes_optional_context() { + let raw = r#"{ + "contract_version": "2", + "trigger": "pr_review_comment", + "repo": { + "owner": "OpenCoven", + "name": "coven-github", + "clone_url": "https://github.com/OpenCoven/coven-github.git", + "default_branch": "main" + }, + "task": { + "kind": "address_review_comment", + "pr_number": 31, + "comment_body": "review this", + "diff_hunk": null + }, + "familiar": { + "id": "cody", + "display_name": "Cody", + "model": null, + "skills": [] + }, + "workspace": { + "root": "/tmp/coven" + }, + "review_context": { + "pr_number": 31, + "files": [{ "path": "src/lib.rs" }] + }, + "audit_instruction": "Inspect supplied changed-file patches." + }"#; + + let brief: SessionBrief = + serde_json::from_str(raw).expect("hosted review brief must match SessionBrief"); + + assert_eq!(brief.trigger, "pr_review_comment"); + assert_eq!( + brief + .review_context + .as_ref() + .and_then(|context| context.get("pr_number")) + .and_then(serde_json::Value::as_u64), + Some(31) + ); + assert_eq!( + brief.audit_instruction.as_deref(), + Some("Inspect supplied changed-file patches.") + ); +} + #[test] fn golden_result_deserializes_into_adapter_type() { let raw = fixture("result.example.json"); diff --git a/deploy/coven-github/coven_github_adapter.py b/deploy/coven-github/coven_github_adapter.py index 4195086..e8e832b 100644 --- a/deploy/coven-github/coven_github_adapter.py +++ b/deploy/coven-github/coven_github_adapter.py @@ -229,6 +229,24 @@ def labels_include_trigger(labels, policy): return False +def issue_assigned_to_bot(issue, policy): + wanted = {str(username).lower() for username in (policy.get("bot_usernames") or [])} + if not wanted: + return False + + candidates = [] + assignee = issue.get("assignee") + if assignee: + candidates.append(assignee) + candidates.extend(issue.get("assignees") or []) + + for candidate in candidates: + login = candidate.get("login") if isinstance(candidate, dict) else str(candidate) + if str(login).lower() in wanted: + return True + return False + + def trigger_enabled(policy, trigger): enabled = policy.get("enabled_triggers") if enabled is None: @@ -324,19 +342,19 @@ def build_task_from_event(event_name, delivery_id, payload, policy): if event_name == "issues": issue = payload.get("issue") or {} action = payload.get("action") - if action not in ("assigned", "labeled", "opened"): + if action not in ("assigned", "labeled"): return ignored(base, "unsupported_issue_action") if action == "assigned" and not trigger_enabled(policy, "issue_assigned"): return ignored(base, "issue_assigned_not_enabled") + if action == "assigned" and not issue_assigned_to_bot(issue, policy): + return ignored(base, "issue_assigned_to_unmanaged_user") if action == "labeled" and not labels_include_trigger(issue.get("labels"), policy): return ignored(base, "issue_label_not_enabled") if action == "labeled" and not trigger_enabled(policy, "issue_label"): return ignored(base, "issue_label_not_enabled") - if action == "opened" and not trigger_enabled(policy, "issue_mention"): - return ignored(base, "issue_mention_not_enabled") base.update( { - "trigger": "issue_assigned" if action == "assigned" else "issue_mention", + "trigger": "issue_assigned", "task": { "kind": "fix_issue", "issue_number": int(issue.get("number") or 0), diff --git a/docs/contracts/result.schema.json b/docs/contracts/result.schema.json index 6b7167b..2ecd61e 100644 --- a/docs/contracts/result.schema.json +++ b/docs/contracts/result.schema.json @@ -114,6 +114,11 @@ "required": ["mode"] }, "then": { + "properties": { + "evidence_status": { + "enum": ["complete", "partial", "missing"] + } + }, "anyOf": [ { "properties": { "reviewed_files": { "minItems": 1 } }, "required": ["reviewed_files"] }, { "properties": { "evidence_status": { "const": "missing" } }, "required": ["evidence_status"] } diff --git a/docs/contracts/session-brief.schema.json b/docs/contracts/session-brief.schema.json index f8abd31..f7f5d47 100644 --- a/docs/contracts/session-brief.schema.json +++ b/docs/contracts/session-brief.schema.json @@ -84,6 +84,15 @@ "properties": { "root": { "type": "string" } } + }, + "review_context": { + "type": "object", + "description": "Optional tokenless hosted-review evidence supplied by the adapter.", + "additionalProperties": true + }, + "audit_instruction": { + "type": "string", + "description": "Optional hosted-review instruction paired with review_context." } } } diff --git a/docs/headless-contract.md b/docs/headless-contract.md index 23f3fc6..86b6b87 100644 --- a/docs/headless-contract.md +++ b/docs/headless-contract.md @@ -106,6 +106,8 @@ The adapter is the **producer**; the runtime is the **consumer**. The brief is | `familiar.model` | string \| null | BYOM model id; `null`/absent means runtime default. | | `familiar.skills` | string[] | Skill ids to load for the session. MAY be empty. | | `workspace.root` | string | Absolute path to the pre-cloned, isolated workspace. The runtime operates **inside** this directory and MUST NOT write outside it. | +| `review_context` | object | Optional tokenless hosted-review evidence supplied by the adapter. When present, it contains PR metadata and changed-file context the runtime MUST inspect before producing review output. | +| `audit_instruction` | string | Optional hosted-review instruction paired with `review_context`. Consumers that do not implement hosted review MAY ignore it. | ### 2.2 Task kinds From 4e19b9367fe74260ed6dfc7aeeab984046b338e2 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Mon, 6 Jul 2026 03:31:28 -0400 Subject: [PATCH 08/20] fix(hosted): enforce result contract before review loops (#119) Signed-off-by: Timothy Wayne Gregg --- crates/github/src/lib.rs | 7 +- crates/worker/src/lib.rs | 23 ++++ crates/worker/tests/contract.rs | 17 +-- deploy/coven-github/coven_github_adapter.py | 130 +++++++++++++++++--- 4 files changed, 148 insertions(+), 29 deletions(-) diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs index 97d04cf..0bca7bd 100644 --- a/crates/github/src/lib.rs +++ b/crates/github/src/lib.rs @@ -14,9 +14,6 @@ pub const DEFAULT_API_BASE_URL: &str = "https://api.github.com"; /// speaks. See `docs/headless-contract.md`. Bump only on breaking changes. pub const HEADLESS_CONTRACT_VERSION: &str = "2"; -fn default_contract_version() -> String { - HEADLESS_CONTRACT_VERSION.to_string() -} const GITHUB_API_VERSION: &str = "2026-03-10"; #[derive(Debug, Clone, PartialEq, Eq)] @@ -176,10 +173,8 @@ pub enum TaskKind { /// Structured result envelope written by coven-code --headless. #[derive(Debug, Clone, Deserialize, Serialize)] pub struct SessionResult { - /// Contract major version. Defaults to the current version when a runtime - /// omits it, but conformant producers MUST emit it. See + /// Contract major version. Conformant producers MUST emit it. See /// `docs/headless-contract.md`. - #[serde(default = "default_contract_version")] pub contract_version: String, pub status: SessionStatus, pub branch: Option, diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index c833d01..ed0d34d 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -672,6 +672,29 @@ mod result_tests { let _ = fs::remove_file(path); } + #[tokio::test] + async fn read_result_rejects_missing_contract_version() { + let path = std::env::temp_dir().join(format!( + "coven-github-result-missing-version-{}.json", + uuid::Uuid::new_v4() + )); + fs::write( + &path, + r#"{"status":"success","branch":null,"commits":[],"files_changed":[],"summary":"s","pr_body":"","review":{"mode":"none","evidence_status":"not_applicable","reviewed_files":[],"supporting_files":[],"findings":[],"tests_run":[],"no_findings_reason":null,"limitations":[]},"exit_reason":null}"#, + ) + .expect("result fixture should be written"); + + let error = read_result(&path) + .await + .expect_err("missing contract_version result must be rejected"); + assert!( + format!("{error:#}").contains("missing field `contract_version`"), + "unexpected error: {error:#}" + ); + + let _ = fs::remove_file(path); + } + #[tokio::test] async fn read_result_rejects_not_applicable_evidence_for_review_modes() { let path = std::env::temp_dir().join(format!( diff --git a/crates/worker/tests/contract.rs b/crates/worker/tests/contract.rs index 8e74365..81e2b9f 100644 --- a/crates/worker/tests/contract.rs +++ b/crates/worker/tests/contract.rs @@ -112,9 +112,7 @@ fn golden_result_deserializes_into_adapter_type() { } #[test] -fn result_without_contract_version_defaults_to_current() { - // Backward compatibility: a runtime that predates the version field still - // parses, and is treated as the current major version. +fn result_without_contract_version_is_rejected() { let raw = r#"{ "status": "failure", "branch": null, @@ -134,10 +132,15 @@ fn result_without_contract_version_defaults_to_current() { }, "exit_reason": "ambiguous_spec" }"#; - let result: SessionResult = serde_json::from_str(raw).expect("legacy result must parse"); - assert_eq!(result.contract_version, HEADLESS_CONTRACT_VERSION); - assert_eq!(result.status, SessionStatus::Failure); - assert_eq!(result.exit_reason, Some(ExitReason::AmbiguousSpec)); + let error = serde_json::from_str::(raw) + .expect_err("v2 result without contract_version must be rejected"); + + assert!( + error + .to_string() + .contains("missing field `contract_version`"), + "unexpected error: {error}" + ); } #[test] diff --git a/deploy/coven-github/coven_github_adapter.py b/deploy/coven-github/coven_github_adapter.py index e8e832b..1a77716 100644 --- a/deploy/coven-github/coven_github_adapter.py +++ b/deploy/coven-github/coven_github_adapter.py @@ -41,6 +41,10 @@ def env_int(name, default, minimum=0, maximum=10): MAX_REVIEW_FIX_LOOPS = env_int("COVEN_REVIEW_FIX_LOOPS", 0, minimum=0, maximum=5) +TERMINAL_RESULT_EXIT_CODES = {0, 1, 3} +RESULT_STATUSES = {"success", "failure", "partial", "needs_input"} +REVIEW_MODES = {"none", "pull_request", "review_comment"} +REVIEW_EVIDENCE_STATUSES = {"not_applicable", "complete", "partial", "missing"} def account_home(): @@ -543,6 +547,66 @@ def write_askpass(work_dir): return script +def validate_result_contract(result): + if not isinstance(result, dict): + raise ValueError("result.json must be a JSON object") + + for field in ("contract_version", "status", "commits", "files_changed", "summary", "pr_body", "review"): + if field not in result: + raise ValueError("result.json missing required field {}".format(field)) + + if result.get("contract_version") != "2": + raise ValueError("unsupported result contract_version {}".format(result.get("contract_version"))) + if result.get("status") not in RESULT_STATUSES: + raise ValueError("unsupported result status {}".format(result.get("status"))) + if not isinstance(result.get("commits"), list): + raise ValueError("result.commits must be an array") + if not isinstance(result.get("files_changed"), list): + raise ValueError("result.files_changed must be an array") + if not isinstance(result.get("summary"), str): + raise ValueError("result.summary must be a string") + if not isinstance(result.get("pr_body"), str): + raise ValueError("result.pr_body must be a string") + + review = result.get("review") + if not isinstance(review, dict): + raise ValueError("result.review must be an object") + for field in ( + "mode", + "evidence_status", + "reviewed_files", + "supporting_files", + "findings", + "tests_run", + "no_findings_reason", + "limitations", + ): + if field not in review: + raise ValueError("result.review missing required field {}".format(field)) + + mode = review.get("mode") + evidence_status = review.get("evidence_status") + if mode not in REVIEW_MODES: + raise ValueError("unsupported review mode {}".format(mode)) + if evidence_status not in REVIEW_EVIDENCE_STATUSES: + raise ValueError("unsupported review evidence_status {}".format(evidence_status)) + + for field in ("reviewed_files", "supporting_files", "findings", "tests_run", "limitations"): + if not isinstance(review.get(field), list): + raise ValueError("result.review.{} must be an array".format(field)) + + if mode in ("pull_request", "review_comment"): + if evidence_status == "not_applicable": + raise ValueError("review evidence_status not_applicable is invalid for {}".format(mode)) + if evidence_status != "missing" and not review.get("reviewed_files"): + raise ValueError("reviewed_files is required for review mode {}".format(mode)) + no_findings_reason = review.get("no_findings_reason") + if not review.get("findings") and not ( + isinstance(no_findings_reason, str) and no_findings_reason.strip() + ): + raise ValueError("no_findings_reason is required when review findings are empty") + + def session_brief(task, workspace, review_context=None, extra_audit_instruction=None): owner, name = (task["repository"] or "/").split("/", 1) brief = { @@ -608,7 +672,15 @@ def run_coven_code_cycle( timeout=1800, ) write_json_atomic(run_path, redacted_command_result(run)) - result = read_json(result_path, None) if result_path.exists() else None + result = None + result_error = None + if result_path.exists(): + try: + result = read_json(result_path, None) + validate_result_contract(result) + except Exception as exc: + result_error = str(exc) + result = None return { "cycle": cycle, "brief_path": brief_path, @@ -616,6 +688,7 @@ def run_coven_code_cycle( "run_path": run_path, "run": run, "result": result, + "result_error": result_error, } @@ -757,15 +830,26 @@ def run_task(task_id, debug): task["result_path"] = str(result_path) write_json_atomic(path, task) - if cycle_result["result"] is None: + if run["returncode"] not in TERMINAL_RESULT_EXIT_CODES: return fail_task( path, task, - "result_missing", - "coven-code exited {} without writing result.json: {}".format( + "runtime_non_terminal", + "coven-code exited {} before a terminal result could be used: {}".format( run["returncode"], run["stderr"] ), ) + if cycle_result["result"] is None: + reason = "result_invalid" if cycle_result.get("result_error") else "result_missing" + detail = cycle_result.get("result_error") or "coven-code exited {} without writing result.json: {}".format( + run["returncode"], run["stderr"] + ) + return fail_task( + path, + task, + reason, + detail, + ) final_cycle = cycle_result loop_records = [] @@ -784,6 +868,32 @@ def run_task(task_id, debug): iteration, instruction, ) + if repair_cycle["run"]["returncode"] not in TERMINAL_RESULT_EXIT_CODES: + return fail_task( + path, + task, + "runtime_non_terminal", + "review repair loop {} exited {} before a terminal result could be used: {}".format( + iteration, + repair_cycle["run"]["returncode"], + repair_cycle["run"]["stderr"], + ), + ) + if repair_cycle["result"] is None: + reason = "result_invalid" if repair_cycle.get("result_error") else "result_missing" + detail = repair_cycle.get("result_error") or ( + "review repair loop {} exited {} without writing result.json: {}".format( + iteration, + repair_cycle["run"]["returncode"], + repair_cycle["run"]["stderr"], + ) + ) + return fail_task( + path, + task, + reason, + detail, + ) remaining = review_findings(repair_cycle["result"]) loop_records.append( { @@ -800,18 +910,6 @@ def run_task(task_id, debug): task["result_path"] = str(repair_cycle["result_path"]) task["updated_at"] = utc_now() write_json_atomic(path, task) - - if repair_cycle["result"] is None: - return fail_task( - path, - task, - "result_missing", - "review repair loop {} exited {} without writing result.json: {}".format( - iteration, - repair_cycle["run"]["returncode"], - repair_cycle["run"]["stderr"], - ), - ) final_cycle = repair_cycle result_path = final_cycle["result_path"] From 9091dc69543410ce58719d2789be716f53cbbd53 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Mon, 6 Jul 2026 03:48:56 -0400 Subject: [PATCH 09/20] fix(hosted): tighten review result validation (#119) Signed-off-by: Timothy Wayne Gregg --- crates/worker/src/lib.rs | 31 ++++++ deploy/coven-github/coven_github_adapter.py | 110 ++++++++++++++++++-- docs/contracts/result.schema.json | 12 +++ 3 files changed, 146 insertions(+), 7 deletions(-) diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index ed0d34d..efc3e3e 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -528,6 +528,14 @@ fn validate_result_contract(result: &SessionResult) -> Result<()> { result.review.mode ); } + if result.review.mode == ReviewMode::None + && result.review.evidence_status != ReviewEvidenceStatus::NotApplicable + { + anyhow::bail!( + "review evidence_status {:?} is invalid for none mode", + result.review.evidence_status + ); + } Ok(()) } @@ -717,6 +725,29 @@ mod result_tests { let _ = fs::remove_file(path); } + + #[tokio::test] + async fn read_result_rejects_applicable_evidence_for_none_mode() { + let path = std::env::temp_dir().join(format!( + "coven-github-result-none-evidence-{}.json", + uuid::Uuid::new_v4() + )); + fs::write( + &path, + r#"{"contract_version":"2","status":"success","branch":null,"commits":[],"files_changed":[],"summary":"s","pr_body":"","review":{"mode":"none","evidence_status":"complete","reviewed_files":[],"supporting_files":[],"findings":[],"tests_run":[],"no_findings_reason":null,"limitations":[]},"exit_reason":null}"#, + ) + .expect("result fixture should be written"); + + let error = read_result(&path) + .await + .expect_err("none-mode result must reject applicable evidence"); + assert!( + format!("{error:#}").contains("is invalid for none mode"), + "unexpected error: {error:#}" + ); + + let _ = fs::remove_file(path); + } } #[cfg(test)] diff --git a/deploy/coven-github/coven_github_adapter.py b/deploy/coven-github/coven_github_adapter.py index 1a77716..909c328 100644 --- a/deploy/coven-github/coven_github_adapter.py +++ b/deploy/coven-github/coven_github_adapter.py @@ -547,6 +547,90 @@ def write_askpass(work_dir): return script +def require_string(value, path): + if not isinstance(value, str): + raise ValueError("{} must be a string".format(path)) + + +def require_optional_string(value, path): + if value is not None and not isinstance(value, str): + raise ValueError("{} must be a string or null".format(path)) + + +def validate_string_array(values, path): + if not isinstance(values, list): + raise ValueError("{} must be an array".format(path)) + for index, item in enumerate(values): + require_string(item, "{}[{}]".format(path, index)) + + +def validate_commits(commits): + if not isinstance(commits, list): + raise ValueError("result.commits must be an array") + for index, commit in enumerate(commits): + if not isinstance(commit, dict): + raise ValueError("result.commits[{}] must be an object".format(index)) + for field in ("sha", "message"): + if field not in commit: + raise ValueError( + "result.commits[{}] missing required field {}".format(index, field) + ) + require_string(commit.get(field), "result.commits[{}].{}".format(index, field)) + + +def validate_findings(findings): + if not isinstance(findings, list): + raise ValueError("result.review.findings must be an array") + severities = {"info", "low", "medium", "high", "critical"} + for index, finding in enumerate(findings): + if not isinstance(finding, dict): + raise ValueError("result.review.findings[{}] must be an object".format(index)) + for field in ("severity", "file", "line", "title", "body", "recommendation"): + if field not in finding: + raise ValueError( + "result.review.findings[{}] missing required field {}".format(index, field) + ) + severity = finding.get("severity") + if severity not in severities: + raise ValueError("unsupported review finding severity {}".format(severity)) + require_string(finding.get("file"), "result.review.findings[{}].file".format(index)) + line = finding.get("line") + if line is not None and (isinstance(line, bool) or not isinstance(line, int) or line < 1): + raise ValueError( + "result.review.findings[{}].line must be an integer >= 1 or null".format( + index + ) + ) + require_string(finding.get("title"), "result.review.findings[{}].title".format(index)) + require_string(finding.get("body"), "result.review.findings[{}].body".format(index)) + require_optional_string( + finding.get("recommendation"), + "result.review.findings[{}].recommendation".format(index), + ) + + +def validate_tests_run(tests_run): + if not isinstance(tests_run, list): + raise ValueError("result.review.tests_run must be an array") + statuses = {"passed", "failed", "not_run", "unknown"} + for index, test in enumerate(tests_run): + if not isinstance(test, dict): + raise ValueError("result.review.tests_run[{}] must be an object".format(index)) + for field in ("command", "status", "output_summary"): + if field not in test: + raise ValueError( + "result.review.tests_run[{}] missing required field {}".format(index, field) + ) + require_string(test.get("command"), "result.review.tests_run[{}].command".format(index)) + status = test.get("status") + if status not in statuses: + raise ValueError("unsupported review test status {}".format(status)) + require_optional_string( + test.get("output_summary"), + "result.review.tests_run[{}].output_summary".format(index), + ) + + def validate_result_contract(result): if not isinstance(result, dict): raise ValueError("result.json must be a JSON object") @@ -559,14 +643,22 @@ def validate_result_contract(result): raise ValueError("unsupported result contract_version {}".format(result.get("contract_version"))) if result.get("status") not in RESULT_STATUSES: raise ValueError("unsupported result status {}".format(result.get("status"))) - if not isinstance(result.get("commits"), list): - raise ValueError("result.commits must be an array") - if not isinstance(result.get("files_changed"), list): - raise ValueError("result.files_changed must be an array") + validate_commits(result.get("commits")) + validate_string_array(result.get("files_changed"), "result.files_changed") if not isinstance(result.get("summary"), str): raise ValueError("result.summary must be a string") if not isinstance(result.get("pr_body"), str): raise ValueError("result.pr_body must be a string") + if result.get("branch") is not None and not isinstance(result.get("branch"), str): + raise ValueError("result.branch must be a string or null") + if result.get("exit_reason") not in ( + "test_failure", + "ambiguous_spec", + "git_conflict", + "infra_error", + None, + ): + raise ValueError("unsupported result exit_reason {}".format(result.get("exit_reason"))) review = result.get("review") if not isinstance(review, dict): @@ -591,9 +683,11 @@ def validate_result_contract(result): if evidence_status not in REVIEW_EVIDENCE_STATUSES: raise ValueError("unsupported review evidence_status {}".format(evidence_status)) - for field in ("reviewed_files", "supporting_files", "findings", "tests_run", "limitations"): - if not isinstance(review.get(field), list): - raise ValueError("result.review.{} must be an array".format(field)) + validate_string_array(review.get("reviewed_files"), "result.review.reviewed_files") + validate_string_array(review.get("supporting_files"), "result.review.supporting_files") + validate_findings(review.get("findings")) + validate_tests_run(review.get("tests_run")) + validate_string_array(review.get("limitations"), "result.review.limitations") if mode in ("pull_request", "review_comment"): if evidence_status == "not_applicable": @@ -605,6 +699,8 @@ def validate_result_contract(result): isinstance(no_findings_reason, str) and no_findings_reason.strip() ): raise ValueError("no_findings_reason is required when review findings are empty") + if mode == "none" and evidence_status != "not_applicable": + raise ValueError("review evidence_status {} is invalid for none mode".format(evidence_status)) def session_brief(task, workspace, review_context=None, extra_audit_instruction=None): diff --git a/docs/contracts/result.schema.json b/docs/contracts/result.schema.json index 2ecd61e..24a94d6 100644 --- a/docs/contracts/result.schema.json +++ b/docs/contracts/result.schema.json @@ -133,6 +133,18 @@ } ] } + }, + { + "if": { + "properties": { "mode": { "const": "none" } }, + "required": ["mode"] + }, + "then": { + "properties": { + "evidence_status": { "const": "not_applicable" } + }, + "required": ["evidence_status"] + } } ] }, From cacece4b57d1baf83eb7275ee883c5a90c91dacf Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Mon, 6 Jul 2026 03:57:29 -0400 Subject: [PATCH 10/20] fix(worker): enforce v2 review contract invariants (#119) Signed-off-by: Timothy Wayne Gregg --- crates/worker/src/brief.rs | 16 +++++-- crates/worker/src/lib.rs | 81 ++++++++++++++++++++++++++++++--- crates/worker/tests/contract.rs | 76 +++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 10 deletions(-) diff --git a/crates/worker/src/brief.rs b/crates/worker/src/brief.rs index f03074d..761e16d 100644 --- a/crates/worker/src/brief.rs +++ b/crates/worker/src/brief.rs @@ -6,8 +6,18 @@ use std::path::Path; use coven_github_api::{Task, TaskKind, HEADLESS_CONTRACT_VERSION}; use coven_github_config::FamiliarConfig; -fn default_contract_version() -> String { - HEADLESS_CONTRACT_VERSION.to_string() +fn deserialize_contract_version<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let version = String::deserialize(deserializer)?; + if version != HEADLESS_CONTRACT_VERSION { + return Err(serde::de::Error::custom(format!( + "unsupported session brief contract_version {}; expected {}", + version, HEADLESS_CONTRACT_VERSION + ))); + } + Ok(version) } /// The session-brief.json schema injected into coven-code --headless. @@ -20,7 +30,7 @@ fn default_contract_version() -> String { pub struct SessionBrief { /// Contract major version this brief is written against. See /// `docs/headless-contract.md`. - #[serde(default = "default_contract_version")] + #[serde(deserialize_with = "deserialize_contract_version")] pub contract_version: String, pub trigger: String, pub repo: RepoBrief, diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index efc3e3e..3ea4477 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -518,15 +518,36 @@ fn validate_result_contract(result: &SessionResult) -> Result<()> { coven_github_api::HEADLESS_CONTRACT_VERSION ); } - if matches!( + let is_review_mode = matches!( result.review.mode, ReviewMode::PullRequest | ReviewMode::ReviewComment - ) && result.review.evidence_status == ReviewEvidenceStatus::NotApplicable - { - anyhow::bail!( - "review evidence_status not_applicable is invalid for {:?}", - result.review.mode - ); + ); + if is_review_mode { + if result.review.evidence_status == ReviewEvidenceStatus::NotApplicable { + anyhow::bail!( + "review evidence_status not_applicable is invalid for {:?}", + result.review.mode + ); + } + if result.review.evidence_status != ReviewEvidenceStatus::Missing + && result.review.reviewed_files.is_empty() + { + anyhow::bail!( + "reviewed_files is required for review mode {:?}", + result.review.mode + ); + } + if result.review.findings.is_empty() + && result + .review + .no_findings_reason + .as_deref() + .map(str::trim) + .unwrap_or_default() + .is_empty() + { + anyhow::bail!("no_findings_reason is required when review findings are empty"); + } } if result.review.mode == ReviewMode::None && result.review.evidence_status != ReviewEvidenceStatus::NotApplicable @@ -726,6 +747,52 @@ mod result_tests { let _ = fs::remove_file(path); } + #[tokio::test] + async fn read_result_rejects_review_without_reviewed_files() { + let path = std::env::temp_dir().join(format!( + "coven-github-result-review-files-{}.json", + uuid::Uuid::new_v4() + )); + fs::write( + &path, + r#"{"contract_version":"2","status":"success","branch":null,"commits":[],"files_changed":[],"summary":"s","pr_body":"","review":{"mode":"pull_request","evidence_status":"complete","reviewed_files":[],"supporting_files":[],"findings":[{"severity":"low","file":"src/lib.rs","line":null,"title":"t","body":"b","recommendation":null}],"tests_run":[],"no_findings_reason":null,"limitations":[]},"exit_reason":null}"#, + ) + .expect("result fixture should be written"); + + let error = read_result(&path) + .await + .expect_err("review result must reject missing reviewed_files"); + assert!( + format!("{error:#}").contains("reviewed_files is required"), + "unexpected error: {error:#}" + ); + + let _ = fs::remove_file(path); + } + + #[tokio::test] + async fn read_result_rejects_empty_review_without_reason() { + let path = std::env::temp_dir().join(format!( + "coven-github-result-review-reason-{}.json", + uuid::Uuid::new_v4() + )); + fs::write( + &path, + r#"{"contract_version":"2","status":"success","branch":null,"commits":[],"files_changed":[],"summary":"s","pr_body":"","review":{"mode":"pull_request","evidence_status":"complete","reviewed_files":["src/lib.rs"],"supporting_files":[],"findings":[],"tests_run":[],"no_findings_reason":" ","limitations":[]},"exit_reason":null}"#, + ) + .expect("result fixture should be written"); + + let error = read_result(&path) + .await + .expect_err("empty review result must require a reason"); + assert!( + format!("{error:#}").contains("no_findings_reason is required"), + "unexpected error: {error:#}" + ); + + let _ = fs::remove_file(path); + } + #[tokio::test] async fn read_result_rejects_applicable_evidence_for_none_mode() { let path = std::env::temp_dir().join(format!( diff --git a/crates/worker/tests/contract.rs b/crates/worker/tests/contract.rs index 81e2b9f..b4429d8 100644 --- a/crates/worker/tests/contract.rs +++ b/crates/worker/tests/contract.rs @@ -89,6 +89,82 @@ fn hosted_review_session_brief_deserializes_optional_context() { ); } +#[test] +fn session_brief_without_contract_version_is_rejected() { + let raw = r#"{ + "trigger": "issue_assigned", + "repo": { + "owner": "OpenCoven", + "name": "coven-github", + "clone_url": "https://github.com/OpenCoven/coven-github.git", + "default_branch": "main" + }, + "task": { + "kind": "fix_issue", + "issue_number": 119, + "issue_title": "Review contract", + "issue_body": "Tighten the contract." + }, + "familiar": { + "id": "cody", + "display_name": "Cody", + "model": null, + "skills": [] + }, + "workspace": { + "root": "/tmp/coven" + } + }"#; + + let error = serde_json::from_str::(raw) + .expect_err("v2 brief without contract_version must be rejected"); + + assert!( + error + .to_string() + .contains("missing field `contract_version`"), + "unexpected error: {error}" + ); +} + +#[test] +fn session_brief_with_unsupported_contract_version_is_rejected() { + let raw = r#"{ + "contract_version": "1", + "trigger": "issue_assigned", + "repo": { + "owner": "OpenCoven", + "name": "coven-github", + "clone_url": "https://github.com/OpenCoven/coven-github.git", + "default_branch": "main" + }, + "task": { + "kind": "fix_issue", + "issue_number": 119, + "issue_title": "Review contract", + "issue_body": "Tighten the contract." + }, + "familiar": { + "id": "cody", + "display_name": "Cody", + "model": null, + "skills": [] + }, + "workspace": { + "root": "/tmp/coven" + } + }"#; + + let error = serde_json::from_str::(raw).expect_err("v1 brief must be rejected"); + + assert!( + error + .to_string() + .contains("unsupported session brief contract_version 1"), + "unexpected error: {error}" + ); +} + #[test] fn golden_result_deserializes_into_adapter_type() { let raw = fixture("result.example.json"); From 6bdfb7bba2da0a6172b4b5fa59417faf4c88d2fc Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Mon, 6 Jul 2026 04:04:41 -0400 Subject: [PATCH 11/20] fix(contract): validate result exit reasons (#119) Signed-off-by: Timothy Wayne Gregg --- crates/worker/src/lib.rs | 55 +++++++++++++++++++++ deploy/coven-github/coven_github_adapter.py | 6 +++ docs/contracts/result.schema.json | 30 ++++++++++- 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 3ea4477..0420b41 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -518,6 +518,15 @@ fn validate_result_contract(result: &SessionResult) -> Result<()> { coven_github_api::HEADLESS_CONTRACT_VERSION ); } + if result.status == SessionStatus::Success && result.exit_reason.is_some() { + anyhow::bail!("result exit_reason must be null when status is success"); + } + if result.status != SessionStatus::Success && result.exit_reason.is_none() { + anyhow::bail!( + "result exit_reason is required when status is {:?}", + result.status + ); + } let is_review_mode = matches!( result.review.mode, ReviewMode::PullRequest | ReviewMode::ReviewComment @@ -724,6 +733,52 @@ mod result_tests { let _ = fs::remove_file(path); } + #[tokio::test] + async fn read_result_rejects_success_with_exit_reason() { + let path = std::env::temp_dir().join(format!( + "coven-github-result-success-exit-reason-{}.json", + uuid::Uuid::new_v4() + )); + fs::write( + &path, + r#"{"contract_version":"2","status":"success","branch":null,"commits":[],"files_changed":[],"summary":"s","pr_body":"","review":{"mode":"none","evidence_status":"not_applicable","reviewed_files":[],"supporting_files":[],"findings":[],"tests_run":[],"no_findings_reason":null,"limitations":[]},"exit_reason":"infra_error"}"#, + ) + .expect("result fixture should be written"); + + let error = read_result(&path) + .await + .expect_err("success result must reject non-null exit_reason"); + assert!( + format!("{error:#}").contains("must be null when status is success"), + "unexpected error: {error:#}" + ); + + let _ = fs::remove_file(path); + } + + #[tokio::test] + async fn read_result_rejects_failure_without_exit_reason() { + let path = std::env::temp_dir().join(format!( + "coven-github-result-failure-exit-reason-{}.json", + uuid::Uuid::new_v4() + )); + fs::write( + &path, + r#"{"contract_version":"2","status":"failure","branch":null,"commits":[],"files_changed":[],"summary":"s","pr_body":"","review":{"mode":"none","evidence_status":"not_applicable","reviewed_files":[],"supporting_files":[],"findings":[],"tests_run":[],"no_findings_reason":null,"limitations":[]},"exit_reason":null}"#, + ) + .expect("result fixture should be written"); + + let error = read_result(&path) + .await + .expect_err("non-success result must require exit_reason"); + assert!( + format!("{error:#}").contains("exit_reason is required"), + "unexpected error: {error:#}" + ); + + let _ = fs::remove_file(path); + } + #[tokio::test] async fn read_result_rejects_not_applicable_evidence_for_review_modes() { let path = std::env::temp_dir().join(format!( diff --git a/deploy/coven-github/coven_github_adapter.py b/deploy/coven-github/coven_github_adapter.py index 909c328..c31a6c2 100644 --- a/deploy/coven-github/coven_github_adapter.py +++ b/deploy/coven-github/coven_github_adapter.py @@ -643,6 +643,7 @@ def validate_result_contract(result): raise ValueError("unsupported result contract_version {}".format(result.get("contract_version"))) if result.get("status") not in RESULT_STATUSES: raise ValueError("unsupported result status {}".format(result.get("status"))) + status = result.get("status") validate_commits(result.get("commits")) validate_string_array(result.get("files_changed"), "result.files_changed") if not isinstance(result.get("summary"), str): @@ -659,6 +660,10 @@ def validate_result_contract(result): None, ): raise ValueError("unsupported result exit_reason {}".format(result.get("exit_reason"))) + if status == "success" and result.get("exit_reason") is not None: + raise ValueError("result.exit_reason must be null when status is success") + if status != "success" and result.get("exit_reason") is None: + raise ValueError("result.exit_reason is required when status is {}".format(status)) review = result.get("review") if not isinstance(review, dict): @@ -687,6 +692,7 @@ def validate_result_contract(result): validate_string_array(review.get("supporting_files"), "result.review.supporting_files") validate_findings(review.get("findings")) validate_tests_run(review.get("tests_run")) + require_optional_string(review.get("no_findings_reason"), "result.review.no_findings_reason") validate_string_array(review.get("limitations"), "result.review.limitations") if mode in ("pull_request", "review_comment"): diff --git a/docs/contracts/result.schema.json b/docs/contracts/result.schema.json index 24a94d6..be4409d 100644 --- a/docs/contracts/result.schema.json +++ b/docs/contracts/result.schema.json @@ -152,5 +152,33 @@ "type": ["string", "null"], "enum": ["test_failure", "ambiguous_spec", "git_conflict", "infra_error", null] } - } + }, + "allOf": [ + { + "if": { + "properties": { "status": { "const": "success" } }, + "required": ["status"] + }, + "then": { + "properties": { + "exit_reason": { "const": null } + } + } + }, + { + "if": { + "properties": { "status": { "enum": ["failure", "partial", "needs_input"] } }, + "required": ["status"] + }, + "then": { + "properties": { + "exit_reason": { + "type": "string", + "enum": ["test_failure", "ambiguous_spec", "git_conflict", "infra_error"] + } + }, + "required": ["exit_reason"] + } + } + ] } From 2cc54f708172eda3431cf456b4f7a927e846248c Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Mon, 6 Jul 2026 04:13:49 -0400 Subject: [PATCH 12/20] fix(contract): close result envelope validation (#119) Signed-off-by: Timothy Wayne Gregg --- crates/github/src/lib.rs | 5 ++ crates/worker/src/lib.rs | 70 +++++++++++++++++++++ deploy/coven-github/coven_github_adapter.py | 52 +++++++++++---- docs/contracts/result.schema.json | 2 +- 4 files changed, 115 insertions(+), 14 deletions(-) diff --git a/crates/github/src/lib.rs b/crates/github/src/lib.rs index 0bca7bd..bf711a0 100644 --- a/crates/github/src/lib.rs +++ b/crates/github/src/lib.rs @@ -172,6 +172,7 @@ pub enum TaskKind { /// Structured result envelope written by coven-code --headless. #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct SessionResult { /// Contract major version. Conformant producers MUST emit it. See /// `docs/headless-contract.md`. @@ -196,12 +197,14 @@ pub enum SessionStatus { } #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct CommitInfo { pub sha: String, pub message: String, } #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct ReviewResult { pub mode: ReviewMode, pub evidence_status: ReviewEvidenceStatus, @@ -246,6 +249,7 @@ pub enum ReviewEvidenceStatus { } #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct ReviewFinding { pub severity: ReviewSeverity, pub file: String, @@ -266,6 +270,7 @@ pub enum ReviewSeverity { } #[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] pub struct ReviewTestRun { pub command: String, pub status: ReviewTestStatus, diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 0420b41..977762b 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -779,6 +779,52 @@ mod result_tests { let _ = fs::remove_file(path); } + #[tokio::test] + async fn read_result_rejects_unknown_root_field() { + let path = std::env::temp_dir().join(format!( + "coven-github-result-extra-root-{}.json", + uuid::Uuid::new_v4() + )); + fs::write( + &path, + r#"{"contract_version":"2","status":"success","branch":null,"commits":[],"files_changed":[],"summary":"s","pr_body":"","review":{"mode":"none","evidence_status":"not_applicable","reviewed_files":[],"supporting_files":[],"findings":[],"tests_run":[],"no_findings_reason":null,"limitations":[]},"exit_reason":null,"extra_root_field":"rejected"}"#, + ) + .expect("result fixture should be written"); + + let error = read_result(&path) + .await + .expect_err("unknown root field must be rejected"); + assert!( + format!("{error:#}").contains("unknown field `extra_root_field`"), + "unexpected error: {error:#}" + ); + + let _ = fs::remove_file(path); + } + + #[tokio::test] + async fn read_result_rejects_unknown_review_field() { + let path = std::env::temp_dir().join(format!( + "coven-github-result-extra-review-{}.json", + uuid::Uuid::new_v4() + )); + fs::write( + &path, + r#"{"contract_version":"2","status":"success","branch":null,"commits":[],"files_changed":[],"summary":"s","pr_body":"","review":{"mode":"none","evidence_status":"not_applicable","reviewed_files":[],"supporting_files":[],"findings":[],"tests_run":[],"no_findings_reason":null,"limitations":[],"extra_review_field":"rejected"},"exit_reason":null}"#, + ) + .expect("result fixture should be written"); + + let error = read_result(&path) + .await + .expect_err("unknown review field must be rejected"); + assert!( + format!("{error:#}").contains("unknown field `extra_review_field`"), + "unexpected error: {error:#}" + ); + + let _ = fs::remove_file(path); + } + #[tokio::test] async fn read_result_rejects_not_applicable_evidence_for_review_modes() { let path = std::env::temp_dir().join(format!( @@ -825,6 +871,30 @@ mod result_tests { let _ = fs::remove_file(path); } + #[tokio::test] + async fn read_result_accepts_review_findings_with_reason() { + let path = std::env::temp_dir().join(format!( + "coven-github-result-findings-reason-{}.json", + uuid::Uuid::new_v4() + )); + fs::write( + &path, + r#"{"contract_version":"2","status":"success","branch":null,"commits":[],"files_changed":[],"summary":"s","pr_body":"","review":{"mode":"pull_request","evidence_status":"complete","reviewed_files":["src/lib.rs"],"supporting_files":[],"findings":[{"severity":"low","file":"src/lib.rs","line":null,"title":"t","body":"b","recommendation":null}],"tests_run":[],"no_findings_reason":"Also reviewed nearby context and found this issue.","limitations":[]},"exit_reason":null}"#, + ) + .expect("result fixture should be written"); + + let result = read_result(&path) + .await + .expect("findings with a reason should remain valid"); + assert_eq!(result.review.findings.len(), 1); + assert_eq!( + result.review.no_findings_reason.as_deref(), + Some("Also reviewed nearby context and found this issue.") + ); + + let _ = fs::remove_file(path); + } + #[tokio::test] async fn read_result_rejects_empty_review_without_reason() { let path = std::env::temp_dir().join(format!( diff --git a/deploy/coven-github/coven_github_adapter.py b/deploy/coven-github/coven_github_adapter.py index c31a6c2..12eca58 100644 --- a/deploy/coven-github/coven_github_adapter.py +++ b/deploy/coven-github/coven_github_adapter.py @@ -45,6 +45,30 @@ def env_int(name, default, minimum=0, maximum=10): RESULT_STATUSES = {"success", "failure", "partial", "needs_input"} REVIEW_MODES = {"none", "pull_request", "review_comment"} REVIEW_EVIDENCE_STATUSES = {"not_applicable", "complete", "partial", "missing"} +RESULT_FIELDS = { + "contract_version", + "status", + "branch", + "commits", + "files_changed", + "summary", + "pr_body", + "review", + "exit_reason", +} +COMMIT_FIELDS = {"sha", "message"} +REVIEW_FIELDS = { + "mode", + "evidence_status", + "reviewed_files", + "supporting_files", + "findings", + "tests_run", + "no_findings_reason", + "limitations", +} +FINDING_FIELDS = {"severity", "file", "line", "title", "body", "recommendation"} +TEST_RUN_FIELDS = {"command", "status", "output_summary"} def account_home(): @@ -557,6 +581,12 @@ def require_optional_string(value, path): raise ValueError("{} must be a string or null".format(path)) +def validate_object_fields(value, allowed, path): + extra = set(value.keys()) - allowed + if extra: + raise ValueError("{} has unsupported field {}".format(path, sorted(extra)[0])) + + def validate_string_array(values, path): if not isinstance(values, list): raise ValueError("{} must be an array".format(path)) @@ -570,7 +600,8 @@ def validate_commits(commits): for index, commit in enumerate(commits): if not isinstance(commit, dict): raise ValueError("result.commits[{}] must be an object".format(index)) - for field in ("sha", "message"): + validate_object_fields(commit, COMMIT_FIELDS, "result.commits[{}]".format(index)) + for field in COMMIT_FIELDS: if field not in commit: raise ValueError( "result.commits[{}] missing required field {}".format(index, field) @@ -585,7 +616,8 @@ def validate_findings(findings): for index, finding in enumerate(findings): if not isinstance(finding, dict): raise ValueError("result.review.findings[{}] must be an object".format(index)) - for field in ("severity", "file", "line", "title", "body", "recommendation"): + validate_object_fields(finding, FINDING_FIELDS, "result.review.findings[{}]".format(index)) + for field in FINDING_FIELDS: if field not in finding: raise ValueError( "result.review.findings[{}] missing required field {}".format(index, field) @@ -616,7 +648,8 @@ def validate_tests_run(tests_run): for index, test in enumerate(tests_run): if not isinstance(test, dict): raise ValueError("result.review.tests_run[{}] must be an object".format(index)) - for field in ("command", "status", "output_summary"): + validate_object_fields(test, TEST_RUN_FIELDS, "result.review.tests_run[{}]".format(index)) + for field in TEST_RUN_FIELDS: if field not in test: raise ValueError( "result.review.tests_run[{}] missing required field {}".format(index, field) @@ -635,6 +668,7 @@ def validate_result_contract(result): if not isinstance(result, dict): raise ValueError("result.json must be a JSON object") + validate_object_fields(result, RESULT_FIELDS, "result.json") for field in ("contract_version", "status", "commits", "files_changed", "summary", "pr_body", "review"): if field not in result: raise ValueError("result.json missing required field {}".format(field)) @@ -668,16 +702,8 @@ def validate_result_contract(result): review = result.get("review") if not isinstance(review, dict): raise ValueError("result.review must be an object") - for field in ( - "mode", - "evidence_status", - "reviewed_files", - "supporting_files", - "findings", - "tests_run", - "no_findings_reason", - "limitations", - ): + validate_object_fields(review, REVIEW_FIELDS, "result.review") + for field in REVIEW_FIELDS: if field not in review: raise ValueError("result.review missing required field {}".format(field)) diff --git a/docs/contracts/result.schema.json b/docs/contracts/result.schema.json index be4409d..6685f81 100644 --- a/docs/contracts/result.schema.json +++ b/docs/contracts/result.schema.json @@ -123,7 +123,7 @@ { "properties": { "reviewed_files": { "minItems": 1 } }, "required": ["reviewed_files"] }, { "properties": { "evidence_status": { "const": "missing" } }, "required": ["evidence_status"] } ], - "oneOf": [ + "anyOf": [ { "properties": { "findings": { "minItems": 1 } }, "required": ["findings"] }, { "properties": { From cec7a28a01e40c7785d562d895e5d9fcd2acbac0 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Mon, 6 Jul 2026 04:20:03 -0400 Subject: [PATCH 13/20] fix(contract): align session brief validation (#119) Signed-off-by: Timothy Wayne Gregg --- crates/worker/src/brief.rs | 6 +- crates/worker/tests/contract.rs | 163 ++++++++++++++++++++++++++++++ docs/contracts/result.schema.json | 26 +++-- 3 files changed, 184 insertions(+), 11 deletions(-) diff --git a/crates/worker/src/brief.rs b/crates/worker/src/brief.rs index 761e16d..11a27b5 100644 --- a/crates/worker/src/brief.rs +++ b/crates/worker/src/brief.rs @@ -27,6 +27,7 @@ where /// write authority (comments, Check Runs, branches, PRs) stays with the adapter /// behind its publication gate. See issue #4. #[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct SessionBrief { /// Contract major version this brief is written against. See /// `docs/headless-contract.md`. @@ -44,6 +45,7 @@ pub struct SessionBrief { } #[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct RepoBrief { pub owner: String, pub name: String, @@ -52,7 +54,7 @@ pub struct RepoBrief { } #[derive(Debug, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum TaskBrief { FixIssue { issue_number: u64, @@ -71,6 +73,7 @@ pub enum TaskBrief { } #[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct FamiliarBrief { pub id: String, pub display_name: String, @@ -79,6 +82,7 @@ pub struct FamiliarBrief { } #[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct WorkspaceBrief { pub root: String, } diff --git a/crates/worker/tests/contract.rs b/crates/worker/tests/contract.rs index b4429d8..d0b8dc7 100644 --- a/crates/worker/tests/contract.rs +++ b/crates/worker/tests/contract.rs @@ -165,6 +165,169 @@ fn session_brief_with_unsupported_contract_version_is_rejected() { ); } +#[test] +fn session_brief_rejects_unknown_fields() { + let fixtures = [ + ( + "extra_root", + r#"{ + "contract_version": "2", + "trigger": "issue_assigned", + "repo": { + "owner": "OpenCoven", + "name": "coven-github", + "clone_url": "https://github.com/OpenCoven/coven-github.git", + "default_branch": "main" + }, + "task": { + "kind": "fix_issue", + "issue_number": 119, + "issue_title": "Review contract", + "issue_body": "Tighten the contract." + }, + "familiar": { + "id": "cody", + "display_name": "Cody", + "model": null, + "skills": [] + }, + "workspace": { + "root": "/tmp/coven" + }, + "extra_root": true + }"#, + ), + ( + "extra_repo", + r#"{ + "contract_version": "2", + "trigger": "issue_assigned", + "repo": { + "owner": "OpenCoven", + "name": "coven-github", + "clone_url": "https://github.com/OpenCoven/coven-github.git", + "default_branch": "main", + "extra_repo": true + }, + "task": { + "kind": "fix_issue", + "issue_number": 119, + "issue_title": "Review contract", + "issue_body": "Tighten the contract." + }, + "familiar": { + "id": "cody", + "display_name": "Cody", + "model": null, + "skills": [] + }, + "workspace": { + "root": "/tmp/coven" + } + }"#, + ), + ( + "extra_task", + r#"{ + "contract_version": "2", + "trigger": "issue_assigned", + "repo": { + "owner": "OpenCoven", + "name": "coven-github", + "clone_url": "https://github.com/OpenCoven/coven-github.git", + "default_branch": "main" + }, + "task": { + "kind": "fix_issue", + "issue_number": 119, + "issue_title": "Review contract", + "issue_body": "Tighten the contract.", + "extra_task": true + }, + "familiar": { + "id": "cody", + "display_name": "Cody", + "model": null, + "skills": [] + }, + "workspace": { + "root": "/tmp/coven" + } + }"#, + ), + ( + "extra_familiar", + r#"{ + "contract_version": "2", + "trigger": "issue_assigned", + "repo": { + "owner": "OpenCoven", + "name": "coven-github", + "clone_url": "https://github.com/OpenCoven/coven-github.git", + "default_branch": "main" + }, + "task": { + "kind": "fix_issue", + "issue_number": 119, + "issue_title": "Review contract", + "issue_body": "Tighten the contract." + }, + "familiar": { + "id": "cody", + "display_name": "Cody", + "model": null, + "skills": [], + "extra_familiar": true + }, + "workspace": { + "root": "/tmp/coven" + } + }"#, + ), + ( + "extra_workspace", + r#"{ + "contract_version": "2", + "trigger": "issue_assigned", + "repo": { + "owner": "OpenCoven", + "name": "coven-github", + "clone_url": "https://github.com/OpenCoven/coven-github.git", + "default_branch": "main" + }, + "task": { + "kind": "fix_issue", + "issue_number": 119, + "issue_title": "Review contract", + "issue_body": "Tighten the contract." + }, + "familiar": { + "id": "cody", + "display_name": "Cody", + "model": null, + "skills": [] + }, + "workspace": { + "root": "/tmp/coven", + "extra_workspace": true + } + }"#, + ), + ]; + + for (field, raw) in fixtures { + let error = match serde_json::from_str::(raw) { + Ok(_) => panic!("brief with {field} must be rejected"), + Err(error) => error, + }; + let message = error.to_string(); + assert!( + message.contains("unknown field"), + "unexpected error for {field}: {message}" + ); + } +} + #[test] fn golden_result_deserializes_into_adapter_type() { let raw = fixture("result.example.json"); diff --git a/docs/contracts/result.schema.json b/docs/contracts/result.schema.json index 6685f81..1fc1a47 100644 --- a/docs/contracts/result.schema.json +++ b/docs/contracts/result.schema.json @@ -119,17 +119,23 @@ "enum": ["complete", "partial", "missing"] } }, - "anyOf": [ - { "properties": { "reviewed_files": { "minItems": 1 } }, "required": ["reviewed_files"] }, - { "properties": { "evidence_status": { "const": "missing" } }, "required": ["evidence_status"] } - ], - "anyOf": [ - { "properties": { "findings": { "minItems": 1 } }, "required": ["findings"] }, + "allOf": [ { - "properties": { - "no_findings_reason": { "type": "string", "minLength": 1 } - }, - "required": ["no_findings_reason"] + "anyOf": [ + { "properties": { "reviewed_files": { "minItems": 1 } }, "required": ["reviewed_files"] }, + { "properties": { "evidence_status": { "const": "missing" } }, "required": ["evidence_status"] } + ] + }, + { + "anyOf": [ + { "properties": { "findings": { "minItems": 1 } }, "required": ["findings"] }, + { + "properties": { + "no_findings_reason": { "type": "string", "minLength": 1 } + }, + "required": ["no_findings_reason"] + } + ] } ] } From e5b59c8dbca6d9ccf3dde4adeceb20ee7345c6e7 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Mon, 6 Jul 2026 04:30:12 -0400 Subject: [PATCH 14/20] fix(hosted): harden review routing evidence (#119) Signed-off-by: Timothy Wayne Gregg --- deploy/coven-github/coven_github_adapter.py | 30 ++++++++- .../coven-github/test_coven_github_adapter.py | 66 +++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 deploy/coven-github/test_coven_github_adapter.py diff --git a/deploy/coven-github/coven_github_adapter.py b/deploy/coven-github/coven_github_adapter.py index 12eca58..3dad945 100644 --- a/deploy/coven-github/coven_github_adapter.py +++ b/deploy/coven-github/coven_github_adapter.py @@ -3,6 +3,7 @@ import hmac import json import os +import re import subprocess import tempfile import time @@ -241,9 +242,15 @@ def delivery_record(delivery_id, event_name, payload): def mentioned(text, policy): - normalized = (text or "").lower() + normalized = text or "" for username in policy.get("bot_usernames") or []: - if "@" + username.lower() in normalized: + login = str(username).strip() + if not login: + continue + pattern = r"(? Date: Mon, 6 Jul 2026 04:37:28 -0400 Subject: [PATCH 15/20] ci: run hosted adapter python tests (#119) Signed-off-by: Timothy Wayne Gregg --- .github/workflows/ci.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 805eee1..7b26b54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ env: jobs: check: - name: cargo check + clippy + test + name: python + cargo check + clippy + test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -19,6 +19,10 @@ jobs: with: components: clippy - uses: Swatinem/rust-cache@v2 + - name: hosted adapter python compile + run: python -B -m py_compile deploy/coven-github/coven_github_adapter.py deploy/coven-github/test_coven_github_adapter.py + - name: hosted adapter python tests + run: python -B -m unittest discover -s deploy/coven-github -p "test_*.py" - name: cargo check run: cargo check --all-targets - name: clippy From 2584b34ef3db88e68bbd089a27edba12feb28401 Mon Sep 17 00:00:00 2001 From: Timothy Wayne Gregg Date: Mon, 6 Jul 2026 04:42:25 -0400 Subject: [PATCH 16/20] fix(hosted): reject team-style bot mentions (#119) --- deploy/coven-github/coven_github_adapter.py | 2 +- deploy/coven-github/test_coven_github_adapter.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/deploy/coven-github/coven_github_adapter.py b/deploy/coven-github/coven_github_adapter.py index 3dad945..3400c03 100644 --- a/deploy/coven-github/coven_github_adapter.py +++ b/deploy/coven-github/coven_github_adapter.py @@ -247,7 +247,7 @@ def mentioned(text, policy): login = str(username).strip() if not login: continue - pattern = r"(? Date: Mon, 6 Jul 2026 06:47:06 -0500 Subject: [PATCH 17/20] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- deploy/coven-github/coven_github_adapter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/coven-github/coven_github_adapter.py b/deploy/coven-github/coven_github_adapter.py index 3400c03..1ed3f3c 100644 --- a/deploy/coven-github/coven_github_adapter.py +++ b/deploy/coven-github/coven_github_adapter.py @@ -908,7 +908,7 @@ def run_task(task_id, debug): env["COVEN_GIT_TOKEN"] = token env["COVEN_CODE_PROVIDER"] = "codex" env["COVEN_CODE_HOSTED_REVIEW"] = "1" - env["HOME"] = str(CODEX_TOKENS_PATH.parent.parent) + env["HOME"] = str(account_home()) codex_access_token = load_codex_access_token() if not codex_access_token: return fail_task( From cf4c313a943e5f76081f2404c3ac665a58ee0735 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:47:23 +0000 Subject: [PATCH 18/20] Initial plan From 29926e1caf370f84217f2b66cb0f4a66cf294ad0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:50:28 +0000 Subject: [PATCH 19/20] fix webhook config handling --- .../coven_github_adapter.cpython-312.pyc | Bin 0 -> 69306 bytes .../test_coven_github_adapter.cpython-312.pyc | Bin 0 -> 4952 bytes deploy/coven-github/coven_github_adapter.py | 8 +++++- .../coven-github/test_coven_github_adapter.py | 23 ++++++++++++++++++ docs/headless-contract.md | 6 ++--- 5 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 deploy/coven-github/__pycache__/coven_github_adapter.cpython-312.pyc create mode 100644 deploy/coven-github/__pycache__/test_coven_github_adapter.cpython-312.pyc diff --git a/deploy/coven-github/__pycache__/coven_github_adapter.cpython-312.pyc b/deploy/coven-github/__pycache__/coven_github_adapter.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eb46cc89e5b50ac72b4a1c0d9997a86f693cfd45 GIT binary patch literal 69306 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 bK5ew&-C_?~slm+osZjoVmHC{9&A0ytY%Zf8 literal 0 HcmV?d00001 diff --git a/deploy/coven-github/__pycache__/test_coven_github_adapter.cpython-312.pyc b/deploy/coven-github/__pycache__/test_coven_github_adapter.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8ce293e64a12938933623791a092c6188de29aec GIT binary patch literal 4952 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 iU(4?~kl Date: Mon, 6 Jul 2026 11:53:36 +0000 Subject: [PATCH 20/20] fix review thread feedback --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b26b54..d83f190 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ jobs: check: name: python + cargo check + clippy + test runs-on: ubuntu-latest + permissions: + contents: read steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable