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
6 changes: 6 additions & 0 deletions .env.1password.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
GITHUB_APP_ID=op://Private/CovenCat/App ID
GITHUB_WEBHOOK_SECRET=op://Private/CovenCat/Webhook Secret
GITHUB_APP_PRIVATE_KEY=op://Private/CovenCat/Private Key
COVEN_GITHUB_POLICY_PATH=./coven-github-policy.json
COVEN_GITHUB_STATE_DIR=./coven-github-state
COVEN_CODE_BIN=coven-code
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ while a correctly HMAC-signed GitHub `ping` delivery is accepted without needing
```bash
npm test
npm run build
npm run doctor:app
npm run smoke:app
```

Expand Down
19 changes: 18 additions & 1 deletion docs/coven-github-connection.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,23 @@ After creating the App, keep these values outside git:

- App ID -> `GITHUB_APP_ID`
- Webhook secret -> `GITHUB_WEBHOOK_SECRET`
- Private key PEM path -> `GITHUB_APP_PRIVATE_KEY_PATH`
- Private key PEM -> `GITHUB_APP_PRIVATE_KEY`, or private key PEM path ->
`GITHUB_APP_PRIVATE_KEY_PATH`

For 1Password-backed local runs, copy
`.env.1password.example` to an ignored local env file and update the `op://`
references to the fields in your item:

```bash
cp .env.1password.example .env.1password.local
```

Then run commands through 1Password without exposing secret values:

```bash
op run --env-file .env.1password.local -- npm run doctor:app
op run --env-file .env.1password.local -- npm start
```

## Policy

Expand Down Expand Up @@ -78,6 +94,7 @@ export COVEN_GITHUB_POLICY_PATH="$PWD/coven-github-policy.json"
export COVEN_GITHUB_STATE_DIR="$PWD/coven-github-state"
export COVEN_CODE_BIN="$(command -v coven-code)"

npm run doctor:app
npm start
```

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
},
"scripts": {
"build": "tsc -p tsconfig.json",
"doctor:app": "npm run build && node scripts/doctor-app-config.mjs",
"start": "node dist/src/server.js",
"dev": "tsx src/server.ts",
"smoke:app": "npm run build && node scripts/demo-app-smoke.mjs",
Expand Down
89 changes: 89 additions & 0 deletions scripts/doctor-app-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/usr/bin/env node
import {existsSync, readFileSync} from "node:fs";
import {join} from "node:path";
Comment on lines +2 to +3

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

function finding(level, field, message, next) {
return {level, field, message, next};
}

function hasPem(value) {
return /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(value || "");
}

function loadJson(path) {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch {
return null;
}
}

const config = createConfig(process.env, process.cwd());
const findings = [];

if (!config.appId) {
findings.push(finding("error", "GITHUB_APP_ID", "missing GitHub App ID", "Set GITHUB_APP_ID or run through 1Password with an App ID env ref."));
} else if (!/^\d+$/.test(config.appId)) {
findings.push(finding("error", "GITHUB_APP_ID", "GitHub App ID must be numeric", "Use the numeric App ID from the GitHub App settings page."));
}

if (!config.webhookSecret) {
findings.push(finding("error", "GITHUB_WEBHOOK_SECRET", "missing webhook secret", "Set GITHUB_WEBHOOK_SECRET from the GitHub App webhook secret."));
}

if (!config.privateKeyPem && !existsSync(config.privateKeyPath)) {
findings.push(finding("error", "GITHUB_APP_PRIVATE_KEY", "missing GitHub App private key", "Set GITHUB_APP_PRIVATE_KEY from 1Password or GITHUB_APP_PRIVATE_KEY_PATH to a PEM file."));
} else if (config.privateKeyPem && !hasPem(config.privateKeyPem)) {
findings.push(finding("error", "GITHUB_APP_PRIVATE_KEY", "private key env var does not look like a PEM", "Store the full downloaded GitHub App private key PEM in 1Password."));
} else if (!config.privateKeyPem) {
const key = readFileSync(config.privateKeyPath, "utf8");
if (!hasPem(key)) {
findings.push(finding("error", "GITHUB_APP_PRIVATE_KEY_PATH", "private key file does not look like a PEM", "Point GITHUB_APP_PRIVATE_KEY_PATH at the downloaded GitHub App private key."));
}
}
Comment on lines +40 to +45

const policy = loadJson(config.policyPath);
if (!policy) {
findings.push(finding("error", "COVEN_GITHUB_POLICY_PATH", "policy file is missing or invalid JSON", "Copy config/example-policy.json and replace the installation/repository IDs."));
} else {
const installations = policy.installations || {};
const installationIds = Object.keys(installations);
if (!installationIds.length) {
findings.push(finding("error", "policy.installations", "policy has no installation routes", "Add the GitHub App installation ID and repository ID."));
}
if (installationIds.includes("123456")) {
findings.push(finding("error", "policy.installations", "policy still uses placeholder installation ID 123456", "Replace it with the real installation ID."));
}
for (const installationId of installationIds) {
const repos = installations[installationId]?.repositories || {};
if (!Object.keys(repos).length) {
findings.push(finding("error", "policy.repositories", `installation ${installationId} has no repositories`, "Add at least one repository ID route."));
}
if (Object.keys(repos).includes("987654321")) {
findings.push(finding("error", "policy.repositories", "policy still uses placeholder repository ID 987654321", "Replace it with the real repository ID."));
}
}
}

if (!process.env.COVEN_CODE_BIN && !config.demoMode) {
findings.push(finding("warning", "COVEN_CODE_BIN", "COVEN_CODE_BIN is not set", "Set it to the coven-code binary path before running real tasks."));
}

const errors = findings.filter((item) => item.level === "error");
const output = {
ok: errors.length === 0,
demo_mode: config.demoMode,
checked: {
app_id: Boolean(config.appId),
webhook_secret: Boolean(config.webhookSecret),
private_key: Boolean(config.privateKeyPem || existsSync(config.privateKeyPath)),
policy_path: config.policyPath,
state_dir: config.stateDir,
},
findings,
};

console.log(JSON.stringify(output, null, 2));
process.exit(errors.length ? 1 : 0);
4 changes: 3 additions & 1 deletion src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export interface AdapterConfig {
attemptsDir: string;
policyPath: string;
privateKeyPath: string;
privateKeyPem: string;
appId: string;
webhookSecret: string;
covenCodeBin: string;
Expand Down Expand Up @@ -72,6 +73,7 @@ export function createConfig(env: NodeJS.ProcessEnv = process.env, rootDir = pro
attemptsDir: join(stateDir, "attempts"),
policyPath: resolve(env.COVEN_GITHUB_POLICY_PATH || join(rootDir, "coven-github-policy.json")),
privateKeyPath: resolve(env.GITHUB_APP_PRIVATE_KEY_PATH || join(rootDir, ".coven-github-private-key.pem")),
privateKeyPem: (env.GITHUB_APP_PRIVATE_KEY || "").trim(),
appId: (env.GITHUB_APP_ID || "").trim(),
webhookSecret: (env.GITHUB_WEBHOOK_SECRET || env.WEBHOOK_SECRET || "").trim(),
covenCodeBin: (env.COVEN_CODE_BIN || "coven-code").trim() || "coven-code",
Expand Down Expand Up @@ -181,7 +183,7 @@ function githubAppJwt(config: AdapterConfig): string {
const header = {alg: "RS256", typ: "JWT"};
const payload = {iat: now - 60, exp: now + 540, iss: config.appId};
const signingInput = `${b64url(JSON.stringify(header))}.${b64url(JSON.stringify(payload))}`;
const privateKey = readFileSync(config.privateKeyPath, "utf8");
const privateKey = config.privateKeyPem || readFileSync(config.privateKeyPath, "utf8");
const signature = createSign("RSA-SHA256").update(signingInput).sign(privateKey);
return `${signingInput}.${b64url(signature)}`;
}
Expand Down
11 changes: 11 additions & 0 deletions tests/webhook-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,17 @@ test("webhook secret supports smoke script environment name", async () => {
assert.equal(response.body.ok, true);
});

test("config accepts an inline GitHub App private key from env", () => {
const config = createConfig(
{
GITHUB_APP_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----",
},
process.cwd(),
);
Comment on lines +271 to +277

assert.equal(config.privateKeyPem, "-----BEGIN PRIVATE KEY-----\nexample\n-----END PRIVATE KEY-----");
});

test("missing familiar policy does not fall back to hardcoded installation", () => {
const task = buildTaskFromEvent(
"issues",
Expand Down
Loading