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
11 changes: 6 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,12 @@ GITHUB_WEBHOOK_SECRET=

# === Optional: Cloudflare Email Worker ingest ===
# In addition to GitHub webhooks, the plugin can receive GitHub
# notification emails forwarded by a Cloudflare Email Worker (see
# packages/cloudflare-email-worker). The worker HMAC-signs the raw
# RFC822 body and POSTs it to /webhooks/email; the plugin verifies the
# signature, parses headers, fetches canonical state from the GitHub
# API via gh, and dispatches through the same trigger system.
# notification emails relayed by a Cloudflare Email Worker (see
# packages/cloudflare-email-worker). The worker HMAC-signs a small JSON
# event (header metadata only — never the email body) and POSTs it to
# /webhooks/email; the plugin verifies the signature, identifies the
# referenced issue/PR, fetches canonical state from the GitHub API via
# gh, and dispatches through the same trigger system.
#
# Required only if you have at least one trigger with source: "email"
# in webhooks.json. Without it, /webhooks/email rejects every delivery
Expand Down
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
<!-- lore:019ddf82-a57d-74e2-92f1-e4b42484708b -->
* **Bundled agents copied to ~/.config/opencode/agents at image build time**: Agent \`.md\` files in \`agents/\` are COPYed to \`/home/developer/.config/opencode/agents/\` at build time. Five agents: \`github-issue-resolver\` (primary, issue→draft PR), \`pr-reviewer\` (primary, delegates to fix-applier for bot-authored PRs), \`pr-fix-applier\` (subagent, \`task: deny\` to prevent recursion), \`ci-fixer\` (primary, 3-attempt budget via sentinel PR comments), \`pr-comment-responder\` (primary, triage inline/top-level comments). All use \`mode: primary\` except \`pr-fix-applier\` (\`mode: subagent\`). Agent name passed to \`runOpencode\` must match filename without \`.md\`.

<!-- lore:019de571-09e1-709b-906d-e5be3eebae88 -->
* **Cloudflare Email Worker: dumb pipe pattern with unconditional forward + gated webhook**: Email Worker is a dumb pipe: (1) unconditionally \`message.forward(FORWARD\_TO)\` if env var set, (2) check \`ALLOWED\_SENDERS\` allowlist, (3) if allowlisted, POST small JSON payload to WEBHOOK\_URL (not raw RFC822). Fields: from, to, subject, message\_id, in\_reply\_to, references, list\_id, x\_github\_reason, x\_github\_sender. HMAC-signed with \`x-email-signature-256\` header. Forward wrapped in try/catch so a bad FORWARD\_TO doesn't block webhook dispatch. Order: forward first, then POST. Plugin side reads \`req.json()\` directly — no header parsing step.

<!-- lore:019ddb97-585b-74f2-9ab1-adc4c03f338b -->
* **docker-entrypoint.sh skills bootstrap pattern**: Skills are baked into the image via Dockerfile \`COPY skills/ /home/developer/.config/opencode/skills/\` — no \`npx skills add\` calls at runtime. \`docker-entrypoint.sh\` does NOT run any skills bootstrap or cron scheduler; it only: chowns the dev volume if root-owned, inits git, configures \`gh auth setup-git\` + git user identity from \`gh api user\`, then \`exec opencode web\`. The webhook plugin (opencode-webhooks) loads in-process — there is no sidecar process.

Expand Down Expand Up @@ -35,9 +38,15 @@
<!-- lore:019ddb97-387d-7086-babf-1a0fd6cc2978 -->
* **GitHub CLI auth lost on server restart — symlink to persistent volume**: gh CLI auth is lost on Railway redeploy because it stores tokens in ~/.config/gh/ on the ephemeral rootfs. sentry-cli survives because it reads SENTRY\_AUTH\_TOKEN from env vars on every invocation. Two fixes: (1) \*\*Recommended\*\*: set GH\_TOKEN as a Railway env var (PAT with repo/workflow/read:org scopes) — gh auto-detects it, no disk state needed, matches sentry-cli pattern. (2) Symlink approach: in docker-entrypoint.sh, run \`mkdir -p ~/.config/gh ~/dev/.gh-config\` then \`ln -sfn ~/dev/.gh-config/$f ~/.config/gh/$f\` for hosts.yml and config.yml. Use \`GH\_TOKEN\` not \`GITHUB\_TOKEN\` — Railway/Actions can override the latter.

<!-- lore:019de571-09ff-7714-8333-015078d0fb10 -->
* **HMAC over JSON: plugin must verify raw bytes, not re-serialized JSON**: When the email worker HMAC-signs a JSON body, the plugin must verify against the exact bytes received (\`req.text()\` before \`JSON.parse\`), not a re-serialized version. \`JSON.stringify\` key ordering is insertion-order-stable in JS/V8 but not spec-guaranteed cross-runtime. Re-serializing will produce matching output today but is fragile. Pattern: \`const raw = await req.text(); verify(raw); const payload = JSON.parse(raw)\`.

<!-- lore:019ddf88-31c5-7c9c-83b2-30b2e84407cc -->
* **Hono middleware ordering: \`use('/')\` matches POST routes registered after it**: AGENTS.md contains stale lore from the abandoned \`.opencode/plans/add-hono-sidecar.md\` design: references to Hono framework, API\_TOKEN bearer gate, sidecar process supervision, \`npx skills add\` bootstrap, and 'one trigger' in webhooks.json are all outdated. Current implementation: in-process plugin with \`Bun.serve\` (no Hono), no API\_TOKEN, no sidecar, skills baked via Dockerfile \`COPY\`, and 9 triggers in webhooks.json. Do not rely on AGENTS.md architecture bullets without cross-checking against \`packages/opencode-webhooks/src/\` and \`docker-entrypoint.sh\`.

<!-- lore:019de571-09ed-7a93-80a1-2bdb76b811d3 -->
* **message.forward() failure blocks entire email pipeline if not caught**: In a Cloudflare Email Worker, if \`message.forward()\` throws (e.g. unverified destination), the worker throws and CF retries the entire email — blocking all webhook dispatch until fixed. Always wrap \`message.forward()\` in try/catch and log the error, then continue to the POST. Otherwise a misconfigured \`FORWARD\_TO\` silently kills the whole pipeline.

<!-- lore:019ddf9b-acec-7435-ac05-5e06fb4359bb -->
* **opencode.json \`experimental\` key rejects unknown subkeys (additionalProperties: false)**: The published OpenCode config JSON schema defines \`experimental\` with \`additionalProperties: false\`. Adding a custom key like \`experimental.webhook\` will fail strict schema validation in editors. Workaround: store plugin-specific config in a separate file (e.g. \`~/.config/opencode/webhooks.json\`) read directly via \`Bun.file().json()\`, or drop the \`$schema\` reference from \`opencode.json\` to silence editor errors. Do NOT put custom plugin config under \`experimental\` expecting schema tolerance.

Expand Down
48 changes: 35 additions & 13 deletions packages/cloudflare-email-worker/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
# opencode-cloudflare-email-worker

Cloudflare Email Worker that forwards GitHub notification emails to the [`opencode-webhooks`](../opencode-webhooks/) plugin's `POST /webhooks/email` endpoint.
Cloudflare Email Worker that sits in front of the [`opencode-webhooks`](../opencode-webhooks/) plugin. It does two things per inbound email:

This is the **email ingest path**. The plugin parses RFC822 headers, identifies the referenced GitHub issue/PR via `Message-ID`, fetches canonical state via `gh`, and dispatches to OpenCode agents — same shape as a real GitHub webhook.
1. **Always forwards** the email verbatim to `FORWARD_TO` (your real inbox), if the env var is set. DKIM is preserved via Cloudflare's `message.forward()`.
2. **If the sender is in `ALLOWED_SENDERS`**, it builds a small JSON event from the headers (`from`, `to`, `subject`, `message_id`, `in_reply_to`, `references`, `list_id`, `x_github_reason`, `x_github_sender`), HMAC-signs it, and POSTs it to `WEBHOOK_URL` (the plugin's `/webhooks/email` endpoint).

The worker is a **dumb pipe** — no header parsing, no body inspection, no allowlist beyond the simple From-address gate. The plugin owns identity resolution (Message-ID → owner/repo/issue#), GitHub API fetches, dedup, and dispatch.

## Why

GitHub doesn't expose a "send me everything that involves my user" webhook. But it already filters down to "stuff I care about" before sending notification emails — mentions, review requests, assignments, comments on PRs/issues you're involved in, across every repo your account touches. This worker lets you turn that mailbox into an event stream.
GitHub doesn't expose a "send me everything that involves my user" webhook. But it already filters down to "stuff I care about" before sending notification emails — mentions, review requests, assignments, comments on PRs/issues you're involved in, across every repo your account touches. This worker lets you turn that mailbox into an event stream **without** giving up the ability to read the same emails as a human.

## Architecture

Expand All @@ -15,14 +18,20 @@ GitHub ──email──▶ gh@yourdomain.com
│ Cloudflare Email Routing
Email Worker
├─ allowlist (static + /regex/)
└─ HMAC-sign + POST
Email Worker (dumb pipe)
├─ message.forward(FORWARD_TO) ──▶ your real inbox (always)
└─ if From ∈ ALLOWED_SENDERS:
POST {from, to, subject,
message_id, in_reply_to,
references, list_id,
x_github_reason,
x_github_sender}
HMAC-signed application/json
https://your-host/webhooks/email
opencode-webhooks plugin
(parsesynthesize → dispatch)
(verifyidentify → gh fetch → dispatch)
```

## Setup
Expand All @@ -31,9 +40,11 @@ GitHub ──email──▶ gh@yourdomain.com
- Cloudflare dashboard → your zone → **Email** → enable Email Routing.
- Add a destination address (e.g. `gh@yourdomain.com`) and verify it via the email Cloudflare sends.

2. **Edit `wrangler.toml`**.
- `ALLOWED_SENDERS` — JSON-encoded string array. Exact strings are case-insensitive matches; strings of the form `/regex/` are treated as case-insensitive regex. Default allows `notifications@github.com` and any `*@github.com`.
- `SIDECAR_URL` — public URL of your opencode-webhooks endpoint, e.g. `https://your-opencode.example.com:5050/webhooks/email`. Must be reachable from the Cloudflare worker network.
2. **Edit config**.
- `wrangler.json` → `vars.WEBHOOK_URL` — public URL of your opencode-webhooks endpoint, e.g. `https://your-opencode.example.com:5050/webhooks/email`. Must be reachable from the Cloudflare worker network.
- `wrangler.json` → `vars.FORWARD_TO` — destination address for the verbatim forward (e.g. `you@yourdomain.com`). **Must be verified in Cloudflare Email Routing first** (Email Routing → Destination addresses → Add). Leave unset (or remove the key) to skip forwarding entirely.
- `src/index.ts` → `ALLOWED_SENDERS` — TypeScript const at the top of the file. Exact strings are case-insensitive matches; strings of the form `/regex/` are treated as case-insensitive regex. Default allows `notifications@github.com` and any `*@github.com`. Compiles once at module load (zero per-request overhead). A malformed regex literal will throw at module init and the worker won't start — fix the literal and redeploy.
- `wrangler.json` → `observability.logs.enabled` — set to `true` (default in this repo) so you can `wrangler tail` and see structured logs in the Cloudflare dashboard.

3. **Set the shared HMAC secret**:
```sh
Expand Down Expand Up @@ -69,8 +80,19 @@ GitHub ──email──▶ gh@yourdomain.com
## Test

- Send yourself a mention/review request from another GitHub account.
- Watch the worker: `bun run tail`. Should log a successful POST.
- Watch the worker: `bun run tail`. Should log a successful webhook POST and (if `FORWARD_TO` is set) a successful forward.
- Watch the container's stdout. Should log `[opencode-webhooks] trigger 'email-mention' → session ...`.
- Check your `FORWARD_TO` inbox — the original email should have arrived with DKIM intact.

## Failure modes

| Scenario | Behavior |
|---|---|
| `FORWARD_TO` unset | Skipped, no error. Webhook still fires for allowlisted senders. |
| `FORWARD_TO` set but unverified | `message.forward()` throws — caught and logged; webhook still fires. Verify the address in Cloudflare Email Routing → Destination addresses. |
| Sender not in `ALLOWED_SENDERS` | Forward still happens. Webhook is skipped (logged at info). |
| Plugin returns 5xx | Cloudflare retries the email later. Forward already happened, so retries don't double-forward. |
| Plugin returns 4xx (bad signature, dedup, etc.) | Logged, accepted (no retry). Forward already happened. |

## Security model

Expand All @@ -80,8 +102,8 @@ GitHub ──email──▶ gh@yourdomain.com
| Worker `ALLOWED_SENDERS` | Drops any From not in the allowlist (defense vs. spoofs that pass DMARC because the attacker controls `*.github.com`-adjacent domains). |
| `EMAIL_WEBHOOK_SECRET` HMAC | Authenticates the worker → plugin link. Without it, the plugin returns 503. |
| Plugin re-checks `email_allowed_senders` | Same allowlist applied server-side as defense in depth (and lets you tighten without redeploying the worker). |
| Plugin never reads body | The email body never reaches the LLM. Only RFC822 headers (Message-ID, X-GitHub-*) drive routing; the canonical issue/PR/comment is fetched from the GitHub API. Eliminates prompt-injection from email content. |
| Self-loop guard | The plugin drops emails whose `X-GitHub-Sender` matches the bot's own login. |
| Plugin never sees the body | The worker only sends the headers it cares about — never the body. The canonical issue/PR/comment is fetched from the GitHub API instead. Eliminates prompt-injection from email content. |
| Self-loop guard | The plugin drops emails whose `x_github_sender` matches the bot's own login. |

## Cost

Expand Down
118 changes: 75 additions & 43 deletions packages/cloudflare-email-worker/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,84 +1,116 @@
// Cloudflare Email Worker: GitHub notification → /webhooks/email.
// Cloudflare Email Worker: dumb pipe in front of opencode-webhooks.
//
// Pipeline:
// 1. Read message.from / message.to / Message-ID for observability.
// 2. Match message.from against ALLOWED_SENDERS (static + /regex/).
// Drop unmatched mail without forwarding (Cloudflare won't bounce).
// 3. Buffer the raw RFC822 body.
// 4. HMAC-sha256 sign the body with EMAIL_WEBHOOK_SECRET.
// 5. POST to SIDECAR_URL with the signature + envelope headers.
// 6. On 5xx, throw so Cloudflare retries the email later. On 4xx, log
// and accept (those are permanent — bad signature, dedup hit, etc.).
// Pipeline per inbound email:
// 1. message.forward(FORWARD_TO) — unconditional, every email reaches
// the operator's real inbox so nothing is silently swallowed.
// 2. If message.from is in ALLOWED_SENDERS, build a small JSON event
// from the headers we care about, HMAC-sign it, and POST to
// WEBHOOK_URL. Non-allowlisted mail is forward-only (no agent).
// 3. On 5xx from the webhook, throw so Cloudflare retries the email.
// 4xx is permanent (signature rejected, dedup hit, etc.) — accept.
//
// All RFC822 parsing stays out of the worker: the plugin already has
// gh-api access for canonical state, and Cloudflare hands us a parsed
// `message.headers` so we don't need to re-implement header decoding.

// Sender allowlist for the webhook gate. Strings starting and ending
// with "/" are treated as case-insensitive regex; everything else is
// case-insensitive exact match against the bare address parsed out of
// the From header.
const ALLOWED_SENDERS: readonly string[] = [
"notifications@github.com",
"/^.*@github\\.com$/",
]

export interface Env {
ALLOWED_SENDERS: string // JSON-encoded string[]
SIDECAR_URL: string
WEBHOOK_URL: string
EMAIL_WEBHOOK_SECRET: string
// Optional. If set, every inbound email is forwarded here verbatim
// (DKIM-preserving via Cloudflare's message.forward()). The address
// must be verified in Cloudflare Email Routing first.
FORWARD_TO?: string
}

type Pattern =
| { kind: "exact"; value: string }
| { kind: "regex"; re: RegExp }

const COMPILED_PATTERNS: Pattern[] = compilePatterns(ALLOWED_SENDERS)

export default {
async email(message, env, _ctx) {
const patterns = parsePatterns(env.ALLOWED_SENDERS)
if (!matchesAnyPattern(message.from, patterns)) {
const messageId = message.headers.get("message-id") ?? ""

// 1. Always forward to the operator's inbox if configured. Wrap in
// try/catch so a bad FORWARD_TO (unverified destination, etc.)
// doesn't block webhook dispatch — log loudly and continue.
if (env.FORWARD_TO) {
try {
await message.forward(env.FORWARD_TO)
} catch (err) {
console.error(
`forward failed: to=${env.FORWARD_TO} message-id=${messageId} err=${err instanceof Error ? err.message : String(err)}`,
)
}
}

// 2. Webhook gate: only allowlisted senders trigger an agent run.
if (!matchesAnyPattern(message.from, COMPILED_PATTERNS)) {
console.log(
`drop: from=${message.from} to=${message.to} (no allowlist match)`,
`webhook skipped: from=${message.from} (not in allowlist)`,
)
return
}

const body = new Uint8Array(
await new Response(message.raw).arrayBuffer(),
)
const references = (message.headers.get("references") ?? "")
.split(/\s+/)
.filter(Boolean)

const sig = await hmacSha256Hex(env.EMAIL_WEBHOOK_SECRET, body)
const messageId = message.headers.get("message-id") ?? ""
const payload = {
from: message.from,
to: message.to,
subject: message.headers.get("subject") ?? "",
message_id: messageId,
in_reply_to: message.headers.get("in-reply-to") ?? null,
references,
list_id: message.headers.get("list-id") ?? null,
x_github_reason: message.headers.get("x-github-reason") ?? null,
x_github_sender: message.headers.get("x-github-sender") ?? null,
}

const body = JSON.stringify(payload)
const sig = await hmacSha256Hex(
env.EMAIL_WEBHOOK_SECRET,
new TextEncoder().encode(body),
)

const res = await fetch(env.SIDECAR_URL, {
const res = await fetch(env.WEBHOOK_URL, {
method: "POST",
headers: {
"content-type": "message/rfc822",
"content-type": "application/json",
"x-email-signature-256": `sha256=${sig}`,
"x-email-from": message.from,
"x-email-to": message.to,
"x-email-message-id": messageId,
},
body,
})

if (!res.ok) {
const text = await res.text().catch(() => "")
console.error(
`forward failed: status=${res.status} from=${message.from} message-id=${messageId} body=${text.slice(0, 200)}`,
`webhook failed: status=${res.status} from=${message.from} message-id=${messageId} body=${text.slice(0, 200)}`,
)
// Re-throw on 5xx so Cloudflare retries; on 4xx accept (permanent).
if (res.status >= 500) {
throw new Error(`sidecar ${res.status}`)
throw new Error(`webhook ${res.status}`)
}
}
},
} satisfies ExportedHandler<Env>

function parsePatterns(raw: string): Pattern[] {
let arr: unknown
try {
arr = JSON.parse(raw)
} catch {
return []
}
if (!Array.isArray(arr)) return []
function compilePatterns(raw: readonly string[]): Pattern[] {
const out: Pattern[] = []
for (const s of arr) {
if (typeof s !== "string" || s.length === 0) continue
for (const s of raw) {
if (s.length === 0) continue
if (s.length >= 2 && s.startsWith("/") && s.endsWith("/")) {
try {
out.push({ kind: "regex", re: new RegExp(s.slice(1, -1), "i") })
} catch {
// bad regex — skip
}
out.push({ kind: "regex", re: new RegExp(s.slice(1, -1), "i") })
continue
}
out.push({ kind: "exact", value: s.toLowerCase() })
Expand Down
Loading