Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ jobs:

- name: Build
run: npm run build

- name: App route smoke
run: node scripts/demo-app-smoke.mjs
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ reproduced, and changed through PRs instead of server-only edits.
connects a GitHub App install to a familiar route.
- `docs/coven-github-connection.md` - operator guide for connecting this
TypeScript deployment bundle to the canonical `coven-github` app manifest.
- `scripts/demo-app-smoke.mjs` - local signed-delivery demo for the example
policy route.
- `scripts/smoke-webhook.sh` - local HMAC signature smoke test for a running
webhook endpoint.

Expand Down Expand Up @@ -77,6 +79,7 @@ while a correctly HMAC-signed GitHub `ping` delivery is accepted without needing
```bash
npm test
npm run build
npm run smoke:app
```

## Policy
Expand All @@ -98,6 +101,9 @@ connection guide in
## Current behavior

- Emits headless contract v2 session briefs.
- Supports explicit `COVEN_GITHUB_DEMO_MODE=1` local app smoke runs that verify
signed delivery -> policy route -> delivery/task/result state without calling
GitHub or `coven-code`.
- Captures PR checkout metadata and changed-file patches before invoking
`coven-code`.
- Publishes visible structured review evidence, including `reviewed_files`,
Expand Down
17 changes: 17 additions & 0 deletions docs/coven-github-connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,23 @@ WEBHOOK_SECRET="$GITHUB_WEBHOOK_SECRET" \
That proves the HTTP endpoint and HMAC signature path before any GitHub token or
runtime work is attempted.

## Local App Demo

Before creating a real GitHub App, run the self-contained demo:

```bash
npm run smoke:app
```

The demo starts the built Node server on localhost, signs an `issues.labeled`
payload with the same `sha256=` HMAC format GitHub uses, loads
`config/example-policy.json`, and prints the resulting delivery, task, session
brief, and result paths.

It runs with `COVEN_GITHUB_DEMO_MODE=1`. That mode is intentionally explicit:
it verifies the app ingress and routing path, but does not mint GitHub
installation tokens, clone repositories, run `coven-code`, or publish comments.

## Functional App Smoke

On a repository where the App is installed:
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"build": "tsc -p tsconfig.json",
"start": "node dist/src/server.js",
"dev": "tsx src/server.ts",
"smoke:app": "npm run build && node scripts/demo-app-smoke.mjs",
"test": "node --import tsx --test tests/*.test.ts"
},
"devDependencies": {
Expand Down
102 changes: 102 additions & 0 deletions scripts/demo-app-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env node
import {createHmac} from "node:crypto";
import {mkdtempSync, readFileSync, writeFileSync} from "node:fs";
import {tmpdir} from "node:os";
import {join} from "node:path";

import {createConfig} from "../dist/src/adapter.js";
import {createWebhookServer} from "../dist/src/server.js";

const root = new URL("..", import.meta.url).pathname;
Comment on lines +2 to +10
const stateDir = mkdtempSync(join(tmpdir(), "coven-github-demo-"));
const policyPath = join(stateDir, "policy.json");
const secret = "local-demo-webhook-secret";
const deliveryId = "demo-delivery-issues-labeled";
const port = Number.parseInt(process.env.PORT || "3137", 10);

writeFileSync(
policyPath,
readFileSync(new URL("../config/example-policy.json", import.meta.url)),
);

const payload = {
action: "labeled",
installation: {id: 123456},
repository: {
id: 987654321,
full_name: "OpenCoven/example",
clone_url: "https://github.com/OpenCoven/example.git",
default_branch: "main",
},
issue: {
number: 42,
title: "Wire the app",
body: "Make the first app route functional.",
labels: [{name: "coven:fix"}],
},
};
const body = Buffer.from(JSON.stringify(payload));
const signature = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;

const config = createConfig(
{
COVEN_GITHUB_DEMO_MODE: "1",
COVEN_GITHUB_STATE_DIR: stateDir,
COVEN_GITHUB_POLICY_PATH: policyPath,
GITHUB_WEBHOOK_SECRET: secret,
},
root,
);

const server = createWebhookServer(config);
await new Promise((resolve) => server.listen(port, "127.0.0.1", resolve));

try {
const response = await fetch(`http://127.0.0.1:${port}/webhook`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-GitHub-Event": "issues",
"X-GitHub-Delivery": deliveryId,
"X-Hub-Signature-256": signature,
},
body,
});
const result = await response.json();
const delivery = JSON.parse(readFileSync(join(stateDir, "deliveries", `${deliveryId}.json`), "utf8"));
const task = JSON.parse(readFileSync(join(stateDir, "tasks", `${deliveryId}.json`), "utf8"));
const demoResult = JSON.parse(readFileSync(task.result_path, "utf8"));

console.log(JSON.stringify({
ok: response.ok,
status: response.status,
response: result,
state_dir: stateDir,
delivery: {
id: delivery.delivery_id,
event: delivery.event,
routing_result: delivery.routing_result,
state: delivery.state,
repository: delivery.repository,
},
task: {
id: task.task_id,
state: task.state,
trigger: task.trigger,
familiar: task.familiar,
issue_number: task.task?.issue_number,
publication_state: task.publication_state,
session_brief_path: task.session_brief_path,
result_path: task.result_path,
},
result: {
status: demoResult.status,
summary: demoResult.summary,
evidence_status: demoResult.review?.evidence_status,
tests_run: demoResult.review?.tests_run,
limitations: demoResult.review?.limitations,
},
}, null, 2));
} finally {
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
48 changes: 48 additions & 0 deletions src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface AdapterConfig {
maxReviewFixLoops: number;
codexTokensPath: string;
maxWebhookBodyBytes: number;
demoMode: boolean;
}

export interface AdapterRequest {
Expand Down Expand Up @@ -78,6 +79,7 @@ export function createConfig(env: NodeJS.ProcessEnv = process.env, rootDir = pro
maxReviewFixLoops: envInt(env.COVEN_REVIEW_FIX_LOOPS, 0, 0, 5),
codexTokensPath: configuredCodexTokensPath(env),
maxWebhookBodyBytes: MAX_WEBHOOK_BODY_BYTES,
demoMode: ["1", "true", "yes"].includes((env.COVEN_GITHUB_DEMO_MODE || "").trim().toLowerCase()),
};

for (const directory of [config.deliveriesDir, config.tasksDir, config.workspacesDir, config.attemptsDir]) {
Expand Down Expand Up @@ -787,6 +789,10 @@ async function runTask(config: AdapterConfig, taskId: string): Promise<JsonObjec
const workspace = join(config.workspacesDir, taskId, "repo");

try {
if (config.demoMode) {
return completeDemoTask(path, task, workspace, attemptDir);
}

const token = await installationToken(config, task.installation_id);
const askpass = writeAskpass(attemptDir);
const env: NodeJS.ProcessEnv = {
Expand Down Expand Up @@ -885,6 +891,48 @@ async function runTask(config: AdapterConfig, taskId: string): Promise<JsonObjec
}
}

function completeDemoTask(path: string, task: JsonObject, workspace: string, attemptDir: string): JsonObject {
mkdirSync(workspace, {recursive: true});
const briefPath = join(attemptDir, "session-brief.json");
const resultPath = join(attemptDir, "result.json");
writeJsonAtomic(briefPath, sessionBrief(task, workspace, null));
writeJsonAtomic(resultPath, {
contract_version: "2",
status: "success",
summary: "Demo mode accepted a signed GitHub delivery, matched policy, and created a familiar task without external GitHub or coven-code calls.",
files_changed: [],
commits: [],
review: {
mode: "demo",
evidence_status: "signed_delivery_policy_route",
reviewed_files: [],
supporting_files: [],
findings: [],
tests_run: [
{
command: "COVEN_GITHUB_DEMO_MODE=1 signed issues.labeled delivery",
status: "passed",
output_summary: "Webhook signature verified and example policy routed to familiar task.",
},
],
limitations: [
"Demo mode does not mint GitHub installation tokens, clone repositories, run coven-code, or publish GitHub comments.",
],
},
});

task.state = "completed";
task.demo_mode = true;
task.runtime_exit_code = 0;
task.session_brief_path = briefPath;
task.session_brief_sha256 = fileSha256(briefPath);
task.result_path = resultPath;
task.publication_state = "demo_mode_no_github_calls";
task.updated_at = utcNow();
writeJsonAtomic(path, task);
return task;
}

function commandExists(command: string): boolean {
return runCommand(["/bin/sh", "-lc", `command -v ${shellQuote(command)}`], undefined, undefined, 10).returncode === 0;
}
Expand Down
61 changes: 60 additions & 1 deletion tests/webhook-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import { mkdtempSync, readFileSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
Expand Down Expand Up @@ -337,3 +337,62 @@ test("example policy routes a labeled issue to the configured familiar", () => {
skills: ["systematic-debugging", "test-driven-development"],
});
});

test("demo mode handles a signed labeled issue without external GitHub calls", async () => {
const secret = "demo-route-secret";
const stateDir = tempStateDir();
const policyPath = join(stateDir, "policy.json");
writeFileSync(
policyPath,
readFileSync(new URL("../config/example-policy.json", import.meta.url)),
);
const config = createConfig(
{
COVEN_GITHUB_DEMO_MODE: "1",
COVEN_GITHUB_STATE_DIR: stateDir,
COVEN_GITHUB_POLICY_PATH: policyPath,
GITHUB_WEBHOOK_SECRET: secret,
},
process.cwd(),
);
const body = Buffer.from(JSON.stringify({
action: "labeled",
installation: {id: 123456},
repository: {
id: 987654321,
full_name: "OpenCoven/example",
clone_url: "https://github.com/OpenCoven/example.git",
default_branch: "main",
},
issue: {
number: 42,
title: "Wire the app",
body: "Make the first app route functional.",
labels: [{name: "coven:fix"}],
},
}));

const response = await callWebhook(
body,
{
"X-GitHub-Event": "issues",
"X-GitHub-Delivery": "delivery-demo-mode",
"X-Hub-Signature-256": signature(secret, body),
},
"auto",
config,
);

assert.equal(response.status, 200);
assert.equal(response.body.action, "accepted");
assert.equal(response.body.state, "completed");

const taskPath = join(stateDir, "tasks", "delivery-demo-mode.json");
assert.equal(existsSync(taskPath), true);
const task = JSON.parse(readFileSync(taskPath, "utf8")) as JsonObject;
assert.equal(task.state, "completed");
assert.equal(task.demo_mode, true);
assert.equal(task.publication_state, "demo_mode_no_github_calls");
assert.equal(existsSync(String(task.session_brief_path)), true);
assert.equal(existsSync(String(task.result_path)), true);
});
Loading