From 0b81ba9311f2295188f3b7905eea0ac241c5ef19 Mon Sep 17 00:00:00 2001 From: fschrhunt <264049687+fschrhunt@users.noreply.github.com> Date: Tue, 28 Apr 2026 16:05:19 -0500 Subject: [PATCH] feat(router): enforce fixed agent PR branches --- .gitignore | 1 + README.md | 7 +++++ src/github/client.ts | 20 ++++++++++++++ src/server/router.ts | 62 ++++++++++++++++++++++++++++++++++++++++-- src/store/job-store.ts | 27 ++++++++++++++++-- src/types.ts | 3 ++ test/job-store.test.ts | 15 ++++++++++ test/router.test.ts | 62 +++++++++++++++++++++++++++++++++++++++++- 8 files changed, 191 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index e7447b0..6f473f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +.state/ .env .env.* !.env.example diff --git a/README.md b/README.md index 3cb973c..14f3418 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,11 @@ cp agent-router.config.example.json agent-router.config.json - Senders must have `write`, `maintain`, or `admin` repository permission. - Repositories can be allowlisted in `agent-router.config.json`. - The runner claims jobs over an authenticated local API. +- PR fix jobs only run on fixed lane branches: + - `@codex` -> `codex/workspace` + - `@claude` -> `claude/workspace` + - `@huma` -> `huma/workspace` +- AgentRouter blocks fork PRs and unexpected PR branches instead of creating new branches. ## Webhook Events @@ -101,6 +106,8 @@ AgentRouter currently understands: When a valid mention is accepted, AgentRouter stores a queued job. Runners claim jobs per lane and report completion back to the local API. +For pull requests, jobs include the PR head branch and base branch. The router only queues a PR job when the PR head branch matches the mentioned agent's fixed workspace branch in the same repository. + ## Development ```bash diff --git a/src/github/client.ts b/src/github/client.ts index 159854c..38afce2 100644 --- a/src/github/client.ts +++ b/src/github/client.ts @@ -3,6 +3,12 @@ import { Octokit } from "@octokit/rest"; import type { AppConfig } from "../config.js"; import type { TriggerRequest } from "../types.js"; +export interface PullRequestContext { + headBranch: string; + headRepository: string; + baseBranch: string; +} + export class GitHubClientFactory { constructor(private config: AppConfig) {} @@ -37,3 +43,17 @@ export async function postIssueComment(octokit: Octokit, trigger: TriggerRequest body, }); } + +export async function getPullRequestContext(octokit: Octokit, trigger: TriggerRequest): Promise { + const response = await octokit.pulls.get({ + owner: trigger.owner, + repo: trigger.repo, + pull_number: trigger.targetNumber, + }); + const headRepository = response.data.head.repo?.full_name ?? `${response.data.head.repo?.owner.login}/${response.data.head.repo?.name}`; + return { + headBranch: response.data.head.ref, + headRepository, + baseBranch: response.data.base.ref, + }; +} diff --git a/src/server/router.ts b/src/server/router.ts index e7fc54d..3d4174d 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -1,10 +1,10 @@ import { timingSafeEqual } from "node:crypto"; import { verify } from "@octokit/webhooks-methods"; import { isRepositoryAllowed, type AppConfig } from "../config.js"; -import { actorCanTrigger, GitHubClientFactory, postIssueComment } from "../github/client.js"; +import { actorCanTrigger, getPullRequestContext, GitHubClientFactory, postIssueComment, type PullRequestContext } from "../github/client.js"; import { normalizeGitHubEvent } from "../github/events.js"; import { JobStore } from "../store/job-store.js"; -import type { AgentName } from "../types.js"; +import type { AgentName, TriggerRequest } from "../types.js"; export interface RouterDeps { config: AppConfig; @@ -36,8 +36,20 @@ export async function handleWebhook( return { status: 403, body: { error: "actor not allowed" } }; } + if (trigger.isPullRequest) { + const pr = await getPullRequestContext(octokit, trigger); + const routing = evaluatePullRequestRouting(trigger, pr); + if (!routing.allowed) { + await postIssueComment(octokit, trigger, routing.message); + return { status: 202, body: { blocked: true, reason: routing.reason } }; + } + trigger.pullRequestHeadBranch = pr.headBranch; + trigger.pullRequestHeadRepository = pr.headRepository; + trigger.pullRequestBaseBranch = pr.baseBranch; + } + const job = deps.store.createJob(trigger); - await postIssueComment(octokit, trigger, `Queued @${trigger.agent} job #${job.id}.`); + await postIssueComment(octokit, trigger, queuedMessage(job.id, trigger)); return { status: 202, body: { queued: true, jobId: job.id } }; } @@ -52,3 +64,47 @@ export function parseAgent(value: string | null): AgentName | null { if (value === "codex" || value === "claude" || value === "huma") return value; return null; } + +export function fixedLaneBranch(agent: AgentName): string { + return `${agent}/workspace`; +} + +export function evaluatePullRequestRouting( + trigger: TriggerRequest, + pr: PullRequestContext, +): { allowed: true } | { allowed: false; reason: string; message: string } { + const expectedBranch = fixedLaneBranch(trigger.agent); + if (pr.headRepository.toLowerCase() !== trigger.repository.toLowerCase()) { + return { + allowed: false, + reason: "fork head repository", + message: [ + `Blocked @${trigger.agent} job: I only push fixes to branches in \`${trigger.repository}\`.`, + `This PR head is \`${pr.headRepository}:${pr.headBranch}\`, so I will not create a new branch or push to a fork.`, + ].join("\n\n"), + }; + } + + if (pr.headBranch !== expectedBranch) { + return { + allowed: false, + reason: "unexpected head branch", + message: [ + `Blocked @${trigger.agent} job: I only push fixes to the fixed \`${expectedBranch}\` lane branch for @${trigger.agent}.`, + `This PR head is \`${pr.headBranch}\`. I will not create a new branch or move work onto a different branch.`, + ].join("\n\n"), + }; + } + + return { allowed: true }; +} + +function queuedMessage(jobId: number, trigger: TriggerRequest): string { + if (!trigger.isPullRequest) { + return `Queued @${trigger.agent} job #${jobId}.`; + } + return [ + `Queued @${trigger.agent} job #${jobId}.`, + `Branch policy: fixes will use \`${trigger.pullRequestHeadBranch}\` directly. No new branch will be created.`, + ].join("\n\n"); +} diff --git a/src/store/job-store.ts b/src/store/job-store.ts index 0a9a942..e76f7bb 100644 --- a/src/store/job-store.ts +++ b/src/store/job-store.ts @@ -16,6 +16,9 @@ interface JobRow { body: string; html_url: string; installation_id: number; + pull_request_head_branch: string | null; + pull_request_head_repository: string | null; + pull_request_base_branch: string | null; status: AgentJob["status"]; created_at: string; updated_at: string; @@ -44,6 +47,9 @@ export class JobStore { body text not null, html_url text not null, installation_id integer not null, + pull_request_head_branch text, + pull_request_head_repository text, + pull_request_base_branch text, status text not null default 'queued', created_at text not null default (datetime('now')), updated_at text not null default (datetime('now')), @@ -53,14 +59,18 @@ export class JobStore { ); create index if not exists jobs_agent_status_idx on jobs(agent, status, id); `); + this.ensureColumn("pull_request_head_branch", "text"); + this.ensureColumn("pull_request_head_repository", "text"); + this.ensureColumn("pull_request_base_branch", "text"); } createJob(trigger: TriggerRequest): AgentJob { const stmt = this.db.prepare(` insert into jobs ( agent, source, repository, owner, repo, actor, target_number, is_pull_request, - body, html_url, installation_id - ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + body, html_url, installation_id, pull_request_head_branch, + pull_request_head_repository, pull_request_base_branch + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) returning * `); const row = asJobRow(stmt.get( @@ -75,6 +85,9 @@ export class JobStore { trigger.body, trigger.htmlUrl, trigger.installationId, + trigger.pullRequestHeadBranch ?? null, + trigger.pullRequestHeadRepository ?? null, + trigger.pullRequestBaseBranch ?? null, )); return mapJob(row); } @@ -111,6 +124,13 @@ export class JobStore { `).get(error, id)); return row ? mapJob(row) : null; } + + private ensureColumn(name: string, type: string): void { + const rows = this.db.prepare("pragma table_info(jobs)").all() as Array<{ name: string }>; + if (!rows.some((row) => row.name === name)) { + this.db.exec(`alter table jobs add column ${name} ${type}`); + } + } } function asJobRow(row: unknown): JobRow { @@ -136,6 +156,9 @@ function mapJob(row: JobRow): AgentJob { body: row.body, htmlUrl: row.html_url, installationId: row.installation_id, + pullRequestHeadBranch: row.pull_request_head_branch, + pullRequestHeadRepository: row.pull_request_head_repository, + pullRequestBaseBranch: row.pull_request_base_branch, status: row.status, createdAt: row.created_at, updatedAt: row.updated_at, diff --git a/src/types.ts b/src/types.ts index 4709731..d66be02 100644 --- a/src/types.ts +++ b/src/types.ts @@ -18,6 +18,9 @@ export interface TriggerRequest { body: string; htmlUrl: string; installationId: number; + pullRequestHeadBranch?: string | null; + pullRequestHeadRepository?: string | null; + pullRequestBaseBranch?: string | null; } export interface AgentJob extends TriggerRequest { diff --git a/test/job-store.test.ts b/test/job-store.test.ts index c0575c6..4075186 100644 --- a/test/job-store.test.ts +++ b/test/job-store.test.ts @@ -30,6 +30,21 @@ describe("JobStore", () => { expect(store.claimNext("codex")).toBeNull(); expect(store.claimNext("claude")?.agent).toBe("claude"); }); + + it("preserves pull request branch context", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-router-")); + const store = new JobStore(path.join(dir, "jobs.sqlite")); + const created = store.createJob(trigger({ + isPullRequest: true, + pullRequestHeadBranch: "codex/workspace", + pullRequestHeadRepository: "fschrhunt/AgentRouter", + pullRequestBaseBranch: "main", + })); + + expect(created.pullRequestHeadBranch).toBe("codex/workspace"); + expect(created.pullRequestHeadRepository).toBe("fschrhunt/AgentRouter"); + expect(created.pullRequestBaseBranch).toBe("main"); + }); }); function trigger(overrides: Partial = {}): TriggerRequest { diff --git a/test/router.test.ts b/test/router.test.ts index 6caf2d7..523022b 100644 --- a/test/router.test.ts +++ b/test/router.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; -import { isRunnerAuthorized, parseAgent } from "../src/server/router.js"; +import { evaluatePullRequestRouting, fixedLaneBranch, isRunnerAuthorized, parseAgent } from "../src/server/router.js"; +import type { PullRequestContext } from "../src/github/client.js"; +import type { TriggerRequest } from "../src/types.js"; describe("runner auth", () => { it("accepts exact tokens", () => { @@ -23,3 +25,61 @@ describe("parseAgent", () => { expect(parseAgent("other")).toBeNull(); }); }); + +describe("fixed lane PR routing", () => { + it("maps agents to their fixed workspace branches", () => { + expect(fixedLaneBranch("codex")).toBe("codex/workspace"); + expect(fixedLaneBranch("claude")).toBe("claude/workspace"); + expect(fixedLaneBranch("huma")).toBe("huma/workspace"); + }); + + it("allows pushing to the matching fixed branch in the same repo", () => { + expect(evaluatePullRequestRouting(trigger(), pr())).toEqual({ allowed: true }); + }); + + it("blocks PRs from unexpected branches instead of creating a branch", () => { + const result = evaluatePullRequestRouting(trigger(), pr({ headBranch: "feature/random" })); + + expect(result.allowed).toBe(false); + if (!result.allowed) { + expect(result.reason).toBe("unexpected head branch"); + expect(result.message).toContain("I will not create a new branch"); + } + }); + + it("blocks fork PRs even when the branch name matches", () => { + const result = evaluatePullRequestRouting(trigger(), pr({ headRepository: "someone/AgentRouter" })); + + expect(result.allowed).toBe(false); + if (!result.allowed) { + expect(result.reason).toBe("fork head repository"); + expect(result.message).toContain("push to a fork"); + } + }); +}); + +function trigger(overrides: Partial = {}): TriggerRequest { + return { + agent: "codex", + source: "issue_comment", + repository: "fschrhunt/AgentRouter", + owner: "fschrhunt", + repo: "AgentRouter", + actor: "fschrhunt", + targetNumber: 1, + isPullRequest: true, + body: "@codex fix this", + htmlUrl: "https://github.com/fschrhunt/AgentRouter/pull/1#issuecomment-1", + installationId: 123, + ...overrides, + }; +} + +function pr(overrides: Partial = {}): PullRequestContext { + return { + headBranch: "codex/workspace", + headRepository: "fschrhunt/AgentRouter", + baseBranch: "main", + ...overrides, + }; +}