Skip to content
Merged
155 changes: 127 additions & 28 deletions cloudflare/GITHUB.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ and installation tokens stay inside the Worker; agents receive neither.

## Permissions and events

| Repository permission | Why the installation grants it | Per-operation token |
| --------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------- |
| Metadata: read | Resolve repository identity and maintainer authority | Every operation |
| Contents: write | Read quest files, publish approved quests to service forks, explicit merges | Read for quest reads; write only for publishing or merging |
| Issues: write | Read native issues and publish approved comments | Read or write for that operation |
| Pull requests: write | Observe, create approved draft PRs, explicitly merge an exact head | Read or write for that operation |
| Checks: read | Observe checks on a commit | Check reads only |
| Commit statuses: read | Observe legacy CI status contexts | Check reads only |
| Repository permission | Why the installation grants it | Per-operation token |
| --------------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------- |
| Metadata: read | Resolve repository identity and maintainer authority | Every operation |
| Contents: write | Read quest files, write job branches on service forks and iterated PR heads | Read for quest reads; write only for that publication or merge |
| Issues: write | Read native issues and publish approved comments | Read or write for that operation |
| Pull requests: write | Observe, open and update job PRs, explicitly merge an exact head | Read or write for that operation |
| Checks: read | Observe checks on a commit | Check reads only |
| Commit statuses: read | Observe legacy CI status contexts | Check reads only |

The manifest subscribes to push, issues, issue comments, pull requests, reviews,
checks and statuses. GitHub also sends installation lifecycle and repository
Expand All @@ -29,8 +29,9 @@ does not retry a successful write. GitHub also expires tokens after an hour.
## Trusted boundary

`src/github/service.ts` provides specific server-only operations for quest
reads, issue/PR reads, check/status reads, comments, approved quest publication,
draft PR creation, and an explicitly authorized merge with an expected head SHA.
reads, issue/PR reads, check/status reads, comments, job publication (see
[Publication](#publication)), and an explicitly authorized merge with an
expected head SHA.
Callers pass a user ID obtained from an authenticated local session. The service
resolves its linked OAuth account, looks up the current GitHub login by numeric
ID, and verifies current repository admin or maintain authority. A browser's
Expand All @@ -42,25 +43,122 @@ These methods are integration primitives, not public HTTP routes or an agent
tool interface. Their trusted caller owns the user's approval of the exact
output/action, product policy, branch protections and review/check gating.
Webhook receipt never supplies that approval. No automatic comments, PRs,
merges or runner launches occur. Check reads accept a page number (default 1) and return up to 100 checks
merges or runner launches occur; a job publishes only what its maintainer
authorized, on behalf of that maintainer's current authority. Check reads accept a page number (default 1) and return up to 100 checks
and status contexts with GitHub's totals; callers must request additional pages
before treating the result as complete or approving a merge.

The App does not own repositories. Configure `GITHUB_FORK_OWNER` as a separate
service-controlled namespace and install the App on each provisioned fork.
The operator provisions forks using GitHub's fork flow, subject to organization
policy and private-fork restrictions. An installation token cannot impersonate
a user to own a fork; access to upstream does not grant access to its fork.
Destination permissions/installations must be supplied independently. No fork
creation or organization provisioning permission is requested by this service.
Publication is restricted to `quest/*.md` on an existing `quest/...` branch in
that namespace. The service verifies the fork's immediate parent ID is the
upstream ID. PR creation uses an upstream-scoped token and an explicit head
owner and repository; for organization cross-repository PRs GitHub requires
`head_repo`. Installation tokens cannot span two owners, so the fork-scoped
token only pushes and the upstream token cannot grant maintainer edits on the
fork branch; PRs are created with `maintainer_can_modify: false`.
Agents cannot choose another namespace or obtain Git credentials.
service-controlled namespace and install the App on it. The operator provisions
each upstream's fork there under the upstream's repository name, using GitHub's
fork flow, subject to organization policy and private-fork restrictions. An
installation token cannot impersonate a user to own a fork; access to upstream
does not grant access to its fork. No fork creation or organization
provisioning permission is requested by this service.

## Publication

A running job publishes its work at `POST /api/runner/publish`, authenticated
with its runner's bearer token (`src/jobs/publish.ts`). The JSON request names
the `job` and `attempt`, the commit the changes build on (`head`, or `null`
before the job's branch exists), a commit `message`, and `changes`: up to 500
files, 16 MiB in all, each `{ path, content, executable?, symlink? }` with
base64 content, or `null` content to delete. A symlink's content is its target.
Submodule changes cannot be published. An optional `pull: { title, body, draft }`
opens or updates the job's pull request, and `session` names the agent session
to resume if the publication needs a decision. Checkpoints publish work in
progress the same way, so a reassigned job continues from its branch. The reply
is `{ head, pull? }`, or `202 { decision, cancel }` when the job paused.

Submitted files are data. The publisher never checks them out or runs their
scripts or hooks: it writes blobs, a tree, and a commit with the Git Data API
and moves one branch. Before writing anything it checks, in order:

- The request comes from the job's current `(attempt, runner)` while it runs.
A cancelling attempt may still publish its partial work. A reassignment
increments the attempt, which fences out the previous runner.
- Every path is a plain work-tree path: no `.git`, `.` or `..` segment, no
absolute path, backslash, or control character. Each workflow publishes
only its own changes: `plan` only quest documents under `quest/` (Markdown
other than `AGENTS.md` and `CLAUDE.md`), `bootstrap` only `flake.nix` and
`flake.lock`, `start` and `iterate` their task's changes. No workflow changes
`.github/workflows/`, because the App does not request the workflows
permission. A refused change pauses the job with a `publication` decision
listing each path and reason, instead of being dropped; the maintainer's
answer resumes the agent's session.
- The job's requester still holds maintainer authority, and the repository's
identity, visibility, installation, and enrollment are checked live, as for
every other operation.
- The branch is where the job left it: absent before the first publication,
then the last commit kix published. A `head` that differs gets a reply naming
the commit to continue from. A branch changed outside the job, or one that
earlier work left on the fork, becomes a decision; it is never overwritten.

`plan`, `start`, and `bootstrap` jobs write their branch on the service fork
`GITHUB_FORK_OWNER/<repository>`, whose immediate parent must be the upstream's
repository ID. An `iterate` job writes its pull request's actual head: an
upstream branch through an upstream-scoped token, or a service fork branch.
GitHub gives an installation no write access to a contributor's fork, so that
case pauses with a decision reporting the blocked update; kix never opens a
replacement pull request. No job writes a repository's default branch, and no
runner ever receives a credential.

The App authors the commits as well as the pull requests: the service sets no
author or committer, so GitHub attributes and signs them as the App.
Contributor and model attribution comes from the agent's commit message and
pull request body, written to the repository's own instructions.

Interruptions are reconciled before anything is replayed. The service creates
the commit, records it as pending under the attempt fence, and then moves the
branch with a fast-forward-only update. If the outcome is lost, the next
publication finds the pending commit on the branch and adopts it; until then,
for up to two minutes, other publications of the job wait. GitHub cannot check
kix's fence, so the fence is rechecked immediately before that update: a
fenced-out attempt's write lands only if it passed the check before the
reassignment, and the fast-forward check means it can never overwrite newer
work. Delivery looks for the branch's open pull request before creating one,
and reconciles a refused creation the same way, so a retry never opens a
duplicate. A pull request the maintainer closed is not replaced.

Planning pull requests are always drafts, as are those of jobs asked to stop.
Other jobs set `draft`, which defaults to `true` while decisions or unfinished
work remain; a later delivery with `draft: false` marks the pull request ready.
kix never converts a ready pull request back. Delivery requires an enabled
project; a paused project's running jobs can still preserve their work on their
branches. Pull requests use `maintainer_can_modify: false`, because an
upstream-scoped token cannot grant edits on the fork branch.

GitHub keeps pull request status, checks, and reviews. `mergePullRequest`
merges only an exact head a maintainer explicitly chose, with the first method
the repository allows: squash, then a merge commit, then rebase. GitHub's
branch rules and refusals stand, and an agent's recommendation is never
approval.

Fork pull requests do not make privileged CI safe. Workflows that run a pull
request's code with secrets or write tokens, such as `pull_request_target` or
`workflow_run` jobs that check out its head, run contributed code with upstream
credentials. Upstream workflows must treat kix pull requests as untrusted
contributions like any other fork's. kix grants contributed code no upstream
credentials and never runs it in the publisher. Keep Actions disabled and
secrets absent on service forks, since the bot pushes contributed code there.

Installation grants are broader than any one publication: `contents: write`
and `pull_requests: write` cover every installed repository. Each token names
one repository ID and only the permissions that step needs. Fork writes get
`contents: write` on the fork alone, pull request delivery gets
`pull_requests: write` on the upstream alone, and only an iteration on an
upstream head gets upstream `contents: write`. That token could write any
unprotected upstream branch; the service restricts it to the pull request's
head and refuses the default branch, and repositories should still protect
their default branch.

Mocked tests cover this path. These GitHub behaviors still need a live check
against real installations ([operator setup](../quest/m0/kix/operator-setup.md)):
creating a fork-headed pull request with an upstream installation token while
the fork belongs to another installation; creating a fork commit whose parent
is an upstream commit pushed after the fork was made; GitHub's signature on
App-authored API commits; and pushing an upstream pull request head with an
installation token under the repository's branch rules.

## Webhooks and revocation

Expand Down Expand Up @@ -101,9 +199,10 @@ reconcile current provider state rather than replay issue/PR payloads as facts.
intact. Keep the existing `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` for OAuth
login; they are separate credentials. Never commit conversion responses,
PEM keys, installation tokens or `.dev.vars`.
3. Set `GITHUB_FORK_OWNER` to the service-controlled namespace. Provision its
forks/branches and install the public App there and on selected upstream
repositories. Existing installations must accept permission increases before
3. Set `GITHUB_FORK_OWNER` to the service-controlled namespace. Fork each
pilot upstream into it under the same repository name, disable Actions on
those forks and keep secrets off them, and install the public App there and
on selected upstream repositories. Existing installations must accept permission increases before
operations needing those grants become available. GitHub's installation
UI, not browser form claims, chooses repository access.
4. Run `bun run wrangler d1 migrations apply DB --remote`, then the normal
Expand Down
16 changes: 14 additions & 2 deletions cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ An account limit pauses the job with an account decision; kix never switches
accounts on its own. An answer requeues the job and resumes its session on the
runner that holds it.

The current attempt publishes its work over HTTPS at `POST /api/runner/publish`,
not over its connection, so slow GitHub writes never hold up the hub. It sends
file changes as data; the Worker commits them as the GitHub App to the job's
one branch, which a reassigned attempt continues from, and opens or updates
the job's pull request. Each job records its branch's `head` and its `pull`.
Changes a workflow may not publish pause the job with a `publication`
decision. [GITHUB.md](GITHUB.md#publication) describes the request, its
validation, and how interrupted publications are reconciled.

### Raw output

Runners upload raw agent output as `output` messages of at most 1 MiB of UTF-8
Expand Down Expand Up @@ -199,8 +208,11 @@ replay, and ownership. Job tests race dispatch passes, reassign a disconnected
runner's job and fence its late writes, and cover routing, questions, account
limits, and cancellation. They drive real WebSocket connections through the hub,
including a replaced connection and the alarm that reassigns work, and check the
maintainer-only routes. Output tests use local R2 for chunk ordering, retries,
the caps, expiry, and cleanup after a disconnect. No OAuth credentials or
maintainer-only routes. Publication tests run against a stateful fake of
GitHub's Git and pull request APIs: fenced and stale writes, workflow path
limits and their decisions, lost branch updates and deliveries, iterated heads,
and token scopes. Output tests use local R2 for chunk ordering, retries, the
caps, expiry, and cleanup after a disconnect. No OAuth credentials or
Cloudflare account are required for checks.

After changing bindings, regenerate types:
Expand Down
28 changes: 28 additions & 0 deletions cloudflare/migrations/0009_publication.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- The commit the job's branch holds, as far as kix has published or observed
-- it. NULL until the first publication creates the branch; an iteration starts
-- at the pull request's head.
ALTER TABLE jobs ADD COLUMN head TEXT;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Backfill heads for existing iteration jobs

When this migration runs with an existing queued, running, or waiting iterate job, the new head remains null even though that job's branch already starts at its recorded revision. On its first publication, verify() sees the live PR branch at revision but the stored null head and repeatedly pauses it as an externally created branch. Backfill head = revision for existing iteration jobs as part of the migration.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disagree; leaving the migration without a backfill. Nothing that uses jobs is deployed yet (the merged 0008_output.sql drops job_output for the same reason), so no queued or running iteration job can exist when this runs. New iteration jobs start with head set to the PR head at creation.

(Written by Claude Opus 5.5)

-- A commit whose branch update is in flight. If the Worker stops before
-- recording the outcome, the next publication finds it on the branch and
-- adopts it instead of treating it as someone else's write.
ALTER TABLE jobs ADD COLUMN pending TEXT;
ALTER TABLE jobs ADD COLUMN pendingAt INTEGER;
-- The pull request this job delivered, which later deliveries update.
ALTER TABLE jobs ADD COLUMN pull INTEGER;
-- Publication decisions are service-authored, unlike agent questions. SQLite
-- cannot alter a CHECK constraint, so rebuild the table.
CREATE TABLE job_questions_new (
id TEXT PRIMARY KEY NOT NULL,
job TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
attempt INTEGER NOT NULL,
kind TEXT NOT NULL CHECK(kind IN ('agent','account','publication')),
prompt TEXT NOT NULL,
answer TEXT,
answeredBy TEXT REFERENCES user(id) ON DELETE SET NULL,
askedAt INTEGER NOT NULL,
answeredAt INTEGER,
UNIQUE(job,attempt)
);
INSERT INTO job_questions_new SELECT id,job,attempt,kind,prompt,answer,answeredBy,askedAt,answeredAt FROM job_questions;
DROP TABLE job_questions;
ALTER TABLE job_questions_new RENAME TO job_questions;
Loading
Loading