Agent setup: a repository-limited agent token, the hosted setup script and a Check, for cloud agent environments (#254)
* Agent setup: a repository-limited agent token, the hosted setup script and diffinery setup --repo (work in progress) * Agent setup: end-to-end and unit tests * Agent setup e2e: a turned-off agent's old tokens stay revoked when it comes back * Agent setup e2e: run commands without blocking the event loop
- CI / Typecheck and bundlesuccess
- CI / Unit tests (1/2)success
- CI / Unit tests (2/2)success
- Deploy / Deploy to Cloudflaresuccess
- Deploy / Main's current tipsuccess
- End-to-end / End-to-end (agent reviews, tasks, Today)success
- End-to-end / End-to-end (git, PRs, Actions)success
- End-to-end / End-to-end (large pushes)success
- End-to-end / End-to-end (passkeys, registry, email)success
- End-to-end / End-to-end (real histories)success
- End-to-end / End-to-end (runner Docker)success
- End-to-end / End-to-end (security)success
- Live test / Containers, registry, sandboxes, webhooks, schedules, LFS and pull requestssuccess
28 files changed+1909−25
Files (28)
- apps/cli/install/agent-setup.sh+79−0
- apps/cli/src/commands/agent-setup.ts+276−0
- apps/cli/src/commands/auth.ts+34−1
- apps/cli/test/setup.test.ts+86−1
- apps/diffinery/docs/agents/overview.md+4−2
- apps/diffinery/docs/connect.md+3−1
- apps/diffinery/docs/connect/cloud-agents.md+147−0
- apps/diffinery/docs/nav.json+1−0
- apps/diffinery/docs/repositories/branch-protection.md+1−1
- apps/diffinery/migrations/0038_agent_setup.sql+26−0
- apps/diffinery/scripts/build-docs.mjs+2−0
- apps/diffinery/src/auth/principal.ts+2−1
- apps/diffinery/src/docs/api-meta.ts+15−0
- apps/diffinery/src/docs/pages.ts+1−0
- apps/diffinery/src/docs/reference.ts+1−0
- apps/diffinery/src/docs/web.tsx+11−1
- apps/diffinery/src/http/api.ts+30−0
- apps/diffinery/src/lib/agent-setup.ts+236−0
- apps/diffinery/src/lib/directory.ts+9−0
- apps/diffinery/src/repo/auto-merge.ts+20−2
- apps/diffinery/src/repo/collab.ts+12−6
- apps/diffinery/src/repo/types.ts+5−0
- apps/diffinery/src/web/agent-setup.tsx+433−0
- apps/diffinery/src/web/repo-settings.tsx+8−5
- apps/diffinery/src/web/routes.tsx+3−0
- apps/diffinery/src/web/ui.tsx+2−2
- apps/diffinery/test/e2e/agent-setup.test.mjs+434−0
- apps/diffinery/test/unit/auto-merge.test.ts+28−2
addedapps/cli/install/agent-setup.sh+79−0View file
| @@ -1,0 +1,79 @@ | ||
| 1 | +#!/usr/bin/env bash | |
| 2 | +# Sets up an agent environment (a Claude Code cloud environment, Codex, a CI runner, a dev box) to work | |
| 3 | +# on __DIFFINERY_REPO__, as its Settings → Agent setup page on Diffinery shows: | |
| 4 | +# | |
| 5 | +# curl -fsSL __DIFFINERY_URL__/__DIFFINERY_REPO__/agent-setup.sh | bash | |
| 6 | +# | |
| 7 | +# 1. Installs the diffinery CLI and puts it on PATH (/usr/local/bin/diffinery when that's writable), | |
| 8 | +# skipping the download when the installed CLI is already the server's current build. | |
| 9 | +# 2. Runs `diffinery setup --repo __DIFFINERY_REPO__`: git and gh use the token in DIFFINERY_TOKEN (gh | |
| 10 | +# through GH_ENTERPRISE_TOKEN and GH_HOST), read from the environment each time and never written to | |
| 11 | +# disk; the repository is cloned unless it's there (DIFFINERY_NO_CLONE=1 skips that, and | |
| 12 | +# DIFFINERY_CLONE_DIR says where); then a self-test, one line per check, and a report to the page. | |
| 13 | +# | |
| 14 | +# Safe to run every time a session starts, fully non-interactive, and it never prints the token. | |
| 15 | +# This script holds no secret: Diffinery serves the same one to anyone, with the repository's name in it. | |
| 16 | +# It exits 1 when the CLI can't be installed or a check fails, so a broken setup isn't kept. Without | |
| 17 | +# DIFFINERY_TOKEN it sets up what it can, says what to add, and exits 0. | |
| 18 | + | |
| 19 | +if [ -z "${BASH_VERSION:-}" ]; then | |
| 20 | + echo "This script needs bash: curl -fsSL __DIFFINERY_URL__/__DIFFINERY_REPO__/agent-setup.sh | bash" >&2 | |
| 21 | + exit 1 | |
| 22 | +fi | |
| 23 | +set -uo pipefail | |
| 24 | + | |
| 25 | +server=__DIFFINERY_URL__ | |
| 26 | +repo=__DIFFINERY_REPO__ | |
| 27 | +cli_sha256=__DIFFINERY_CLI_SHA256__ | |
| 28 | +root=${DIFFINERY_INSTALL:-$HOME/.diffinery} | |
| 29 | +# Where the diffinery command is linked from, when it's writable (running as root). | |
| 30 | +link_dir=${DIFFINERY_LINK_DIR:-/usr/local/bin} | |
| 31 | +host=${server#*://} | |
| 32 | +page="$server/$repo/settings/agent-setup" | |
| 33 | + | |
| 34 | +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then | |
| 35 | + green=$'\033[32m' red=$'\033[31m' bold=$'\033[1m' reset=$'\033[0m' | |
| 36 | +else | |
| 37 | + green='' red='' bold='' reset='' | |
| 38 | +fi | |
| 39 | +say() { printf '%s\n' "$*"; } | |
| 40 | +fail() { | |
| 41 | + say "${red}✗${reset} $*" | |
| 42 | + exit 1 | |
| 43 | +} | |
| 44 | + | |
| 45 | +say "${bold}Diffinery agent setup for $repo${reset}" | |
| 46 | +command -v curl >/dev/null || fail "curl is required" | |
| 47 | +command -v git >/dev/null || fail "git is required: install it in the setup script first (apt-get install -y git)" | |
| 48 | +original_path=$PATH | |
| 49 | + | |
| 50 | +sha256() { if command -v sha256sum >/dev/null; then sha256sum "$1"; else shasum -a 256 "$1"; fi | cut -d' ' -f1; } | |
| 51 | + | |
| 52 | +# 1. The CLI. | |
| 53 | +installed=$root/lib/diffinery.mjs | |
| 54 | +if [ -f "$installed" ] && [ "$(sha256 "$installed")" = "$cli_sha256" ] && "$root/bin/diffinery" --version >/dev/null 2>&1; then | |
| 55 | + say "${green}✓${reset} diffinery CLI: already the current build" | |
| 56 | +else | |
| 57 | + # With /usr/local/bin to link from (root in a container or VM), the CLI leaves startup files alone. | |
| 58 | + no_rc=${DIFFINERY_NO_MODIFY_PATH:-} | |
| 59 | + if [ -d "$link_dir" ] && [ -w "$link_dir" ]; then no_rc=1; fi | |
| 60 | + installer=$(curl -fsSL "$server/install") || fail "couldn't download $server/install: allow $host in the environment's network settings (Custom with $host, or Full)" | |
| 61 | + printf '%s\n' "$installer" | DIFFINERY_URL=$server DIFFINERY_INSTALL=$root DIFFINERY_NO_MODIFY_PATH=$no_rc bash || fail "the diffinery CLI didn't install (see above)" | |
| 62 | +fi | |
| 63 | +if [ -d "$link_dir" ] && [ -w "$link_dir" ]; then | |
| 64 | + ln -sfn "$root/bin/diffinery" "$link_dir/diffinery" || fail "couldn't link $link_dir/diffinery" | |
| 65 | +fi | |
| 66 | + | |
| 67 | +# 2. git, gh, the clone and the self-test. The original PATH is what the PATH check looks at. | |
| 68 | +export PATH="$root/bin:$PATH" | |
| 69 | +if [ -z "${DIFFINERY_TOKEN:-}" ]; then | |
| 70 | + # Without the token git still gets its credential helper, which reads DIFFINERY_TOKEN once it's set. | |
| 71 | + DIFFINERY_URL=$server diffinery auth setup-git >/dev/null 2>&1 || true | |
| 72 | + say "${red}✗${reset} DIFFINERY_TOKEN is not set: generate a token on $page and add the Environment variables shown there." | |
| 73 | + say " git and the CLI are set up and will use the token once it's in the environment; run this again then to check." | |
| 74 | + exit 0 | |
| 75 | +fi | |
| 76 | +if [ -n "${DIFFINERY_REPO:-}" ] && [ "$DIFFINERY_REPO" != "$repo" ]; then | |
| 77 | + say "note: DIFFINERY_REPO is $DIFFINERY_REPO, but this script sets up $repo" | |
| 78 | +fi | |
| 79 | +DIFFINERY_SETUP_PATH=$original_path diffinery setup --repo "$repo" --url "$server" |
addedapps/cli/src/commands/agent-setup.ts+276−0View file
| @@ -1,0 +1,276 @@ | ||
| 1 | +import { spawn } from "node:child_process"; | |
| 2 | +import { existsSync, statSync } from "node:fs"; | |
| 3 | +import { access, constants } from "node:fs/promises"; | |
| 4 | +import path from "node:path"; | |
| 5 | +import { Api, ApiError, enc } from "../api.js"; | |
| 6 | +import { Ctx, type RepoRef } from "../cli.js"; | |
| 7 | +import { gitCapture, parseRemote } from "../git.js"; | |
| 8 | +import { CliError } from "../io.js"; | |
| 9 | +import { VERSION } from "../version.js"; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * `diffinery setup --repo OWNER/NAME`: sets up an agent environment (a Claude Code cloud environment, | |
| 13 | + * Codex, a CI runner) for one repository, without a person at the keyboard. What the hosted script | |
| 14 | + * (<server>/<owner>/<repo>/agent-setup.sh, from Settings → Agent setup) runs once it has installed the CLI. | |
| 15 | + * | |
| 16 | + * The token comes from DIFFINERY_TOKEN and is never written to disk: git asks `diffinery auth | |
| 17 | + * git-credential`, which reads it from the environment each time, and gh reads GH_ENTERPRISE_TOKEN. So a | |
| 18 | + * snapshot of the machine (Claude Code caches what a setup script leaves on disk) holds no credential, | |
| 19 | + * and a new token works as soon as the environment variables change. Then a self-test, one line per | |
| 20 | + * check saying what to change when it fails, and a report to the repository's Agent setup page (its Check). | |
| 21 | + */ | |
| 22 | + | |
| 23 | +export interface CheckLine { | |
| 24 | + name: string; | |
| 25 | + ok: boolean; | |
| 26 | + message: string; | |
| 27 | +} | |
| 28 | + | |
| 29 | +interface Proc { | |
| 30 | + code: number; | |
| 31 | + stdout: string; | |
| 32 | + stderr: string; | |
| 33 | +} | |
| 34 | + | |
| 35 | +/** Runs a program and captures its output; a program that isn't installed exits 127. */ | |
| 36 | +export function capture(cmd: string, args: string[], o: { env: NodeJS.ProcessEnv; cwd?: string; input?: string }): Promise<Proc> { | |
| 37 | + return new Promise((resolve) => { | |
| 38 | + let child; | |
| 39 | + try { | |
| 40 | + child = spawn(cmd, args, { env: o.env, cwd: o.cwd, stdio: [o.input === undefined ? "ignore" : "pipe", "pipe", "pipe"] }); | |
| 41 | + } catch (e) { | |
| 42 | + return resolve({ code: 127, stdout: "", stderr: (e as Error).message }); | |
| 43 | + } | |
| 44 | + let stdout = ""; | |
| 45 | + let stderr = ""; | |
| 46 | + child.stdout?.on("data", (d: Buffer) => (stdout += d.toString())); | |
| 47 | + child.stderr?.on("data", (d: Buffer) => (stderr += d.toString())); | |
| 48 | + child.on("error", (e) => resolve({ code: (e as NodeJS.ErrnoException).code === "ENOENT" ? 127 : 1, stdout, stderr: stderr + e.message })); | |
| 49 | + child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr })); | |
| 50 | + if (child.stdin) { | |
| 51 | + child.stdin.on("error", () => {}); | |
| 52 | + child.stdin.end(o.input); | |
| 53 | + } | |
| 54 | + }); | |
| 55 | +} | |
| 56 | + | |
| 57 | +/** The gh token environment variables for a host other than github.com, in gh's order. */ | |
| 58 | +const GH_ENTERPRISE_VARS = ["GH_ENTERPRISE_TOKEN", "GITHUB_ENTERPRISE_TOKEN"] as const; | |
| 59 | + | |
| 60 | +/** Which environment variable gives gh its token for `host`, if any (gh then refuses `gh auth login`). */ | |
| 61 | +export function ghEnvToken(env: NodeJS.ProcessEnv, host: string): { name: string; value: string } | null { | |
| 62 | + if (/^(github\.com|.*\.ghe\.com)$/i.test(host)) return null; | |
| 63 | + for (const name of GH_ENTERPRISE_VARS) if (env[name]) return { name, value: env[name]! }; | |
| 64 | + return null; | |
| 65 | +} | |
| 66 | + | |
| 67 | +/** First line of a command's error output, without anything token-like. */ | |
| 68 | +const firstLine = (s: string) => | |
| 69 | + (s.trim().split("\n").find((l) => l.trim()) ?? "") | |
| 70 | + .replace(/dfy_[A-Za-z0-9]+/g, "dfy_…") | |
| 71 | + .slice(0, 200); | |
| 72 | + | |
| 73 | +/** Where the clone goes: --clone-dir, DIFFINERY_CLONE_DIR, a Claude Code cloud session's /home/user, or here. */ | |
| 74 | +export function cloneParent(env: NodeJS.ProcessEnv, cwd: string, flag?: string): string { | |
| 75 | + const chosen = flag ?? env.DIFFINERY_CLONE_DIR; | |
| 76 | + if (chosen) return path.resolve(cwd, chosen); | |
| 77 | + // Claude Code cloud sessions start in /home/user, but setup scripts run elsewhere. | |
| 78 | + const claude = "/home/user"; | |
| 79 | + const inside = cwd === claude || cwd.startsWith(`${claude}/`); | |
| 80 | + if (!inside && isDir(claude)) return claude; | |
| 81 | + return cwd; | |
| 82 | +} | |
| 83 | + | |
| 84 | +function isDir(p: string): boolean { | |
| 85 | + try { | |
| 86 | + return statSync(p).isDirectory(); | |
| 87 | + } catch { | |
| 88 | + return false; | |
| 89 | + } | |
| 90 | +} | |
| 91 | + | |
| 92 | +/** Whether `dir`, or a directory above it, is a clone of `repo` on `host`. Returns its top level. */ | |
| 93 | +async function cloneRoot(ctx: Ctx, dir: string, host: string, repo: RepoRef): Promise<string | null> { | |
| 94 | + if (!isDir(dir)) return null; | |
| 95 | + const top = await gitCapture(ctx.io, ["rev-parse", "--show-toplevel"], { cwd: dir }); | |
| 96 | + if (top.code !== 0) return null; | |
| 97 | + const url = await gitCapture(ctx.io, ["remote", "get-url", "origin"], { cwd: dir }); | |
| 98 | + const r = url.code === 0 ? parseRemote(url.stdout, host) : null; | |
| 99 | + return r && r.owner.toLowerCase() === repo.owner.toLowerCase() && r.name.toLowerCase() === repo.name.toLowerCase() ? top.stdout.trim() : null; | |
| 100 | +} | |
| 101 | + | |
| 102 | +/** An executable named `name` in one of PATH's directories. */ | |
| 103 | +async function onPath(name: string, pathVar: string | undefined): Promise<string | null> { | |
| 104 | + for (const dir of (pathVar ?? "").split(path.delimiter)) { | |
| 105 | + if (!dir) continue; | |
| 106 | + const file = path.join(dir, name); | |
| 107 | + try { | |
| 108 | + await access(file, constants.X_OK); | |
| 109 | + return file; | |
| 110 | + } catch { | |
| 111 | + /* next */ | |
| 112 | + } | |
| 113 | + } | |
| 114 | + return null; | |
| 115 | +} | |
| 116 | + | |
| 117 | +export interface AgentSetupOptions { | |
| 118 | + repo: RepoRef; | |
| 119 | + url: string; | |
| 120 | + clone: boolean; | |
| 121 | + cloneDir?: string; | |
| 122 | + report: boolean; | |
| 123 | +} | |
| 124 | + | |
| 125 | +export async function agentSetup(ctx: Ctx, o: AgentSetupOptions, wireGit: (ctx: Ctx) => Promise<void>): Promise<number> { | |
| 126 | + const { io } = ctx; | |
| 127 | + const env = io.env; | |
| 128 | + const url = o.url; | |
| 129 | + const host = new URL(url).host; | |
| 130 | + const slug = `${o.repo.owner}/${o.repo.name}`; | |
| 131 | + const page = `${url}/${slug}/settings/agent-setup`; | |
| 132 | + const lines: CheckLine[] = []; | |
| 133 | + const line = (name: string, ok: boolean, message: string) => { | |
| 134 | + lines.push({ name, ok, message }); | |
| 135 | + ctx.out(`${ok ? ctx.c.green("✓") : ctx.c.red("✗")} ${name}: ${message}`); | |
| 136 | + }; | |
| 137 | + | |
| 138 | + const cfg = await ctx.config(); | |
| 139 | + const token = cfg.token; | |
| 140 | + | |
| 141 | + // 1. The server. | |
| 142 | + let reachable = false; | |
| 143 | + try { | |
| 144 | + const res = await fetch(`${url}/healthz`, { headers: { "user-agent": `diffinery-cli/${VERSION}` } }); | |
| 145 | + reachable = res.ok; | |
| 146 | + line("server", res.ok, res.ok ? `${host} is reachable` : `${host} answered ${res.status}; try again in a minute`); | |
| 147 | + } catch (e) { | |
| 148 | + const cause = (e as { cause?: { code?: string } }).cause?.code ?? (e as Error).message; | |
| 149 | + line("server", false, `can't reach ${host} (${cause}): allow ${host} in the environment's network settings (Custom with ${host}, or Full)`); | |
| 150 | + } | |
| 151 | + | |
| 152 | + // 2. The diffinery command, for git's credential helper and for the agent. | |
| 153 | + const cmd = await onPath("diffinery", env.DIFFINERY_SETUP_PATH ?? env.PATH); | |
| 154 | + line("diffinery on PATH", !!cmd, cmd ? `${cmd} (${VERSION})` : "not on PATH: run the setup script again, or add ~/.diffinery/bin to PATH"); | |
| 155 | + | |
| 156 | + // 3. The token and what it can do. | |
| 157 | + let login: string | null = null; | |
| 158 | + let canPush = false; | |
| 159 | + if (!token) { | |
| 160 | + line("DIFFINERY_TOKEN", false, `not set: add DIFFINERY_TOKEN=<token> to the environment variables (generate one on ${page})`); | |
| 161 | + } else if (reachable) { | |
| 162 | + const api = new Api(url, token); | |
| 163 | + try { | |
| 164 | + const me = await api.get<{ login: string; type?: string }>("/user"); | |
| 165 | + login = me.login; | |
| 166 | + line("DIFFINERY_TOKEN", true, `acts as ${me.login}${me.type === "Bot" ? " (agent)" : ""}`); | |
| 167 | + } catch (e) { | |
| 168 | + line("DIFFINERY_TOKEN", false, e instanceof ApiError && e.status === 401 ? `refused (revoked or expired): generate a new token on ${page} and paste it into DIFFINERY_TOKEN and GH_ENTERPRISE_TOKEN` : firstLine((e as Error).message)); | |
| 169 | + } | |
| 170 | + if (login) { | |
| 171 | + try { | |
| 172 | + const r = await api.get<{ permissions?: { push?: boolean } }>(`/repos/${enc(o.repo.owner)}/${enc(o.repo.name)}`); | |
| 173 | + canPush = !!r.permissions?.push; | |
| 174 | + line("repository", canPush, canPush ? `${slug} with write access` : `${login} can read ${slug} but not push: use a token from ${page}`); | |
| 175 | + } catch (e) { | |
| 176 | + line("repository", false, e instanceof ApiError && e.status === 404 ? `the token can't see ${slug}: use a token from ${page}` : firstLine((e as Error).message)); | |
| 177 | + } | |
| 178 | + } | |
| 179 | + } | |
| 180 | + | |
| 181 | + // 4. git: the credential helper reads the token from the environment whenever git asks. | |
| 182 | + try { | |
| 183 | + await wireGit(ctx); | |
| 184 | + } catch (e) { | |
| 185 | + line("git", false, firstLine((e as Error).message)); | |
| 186 | + } | |
| 187 | + const remote = `${url}/${slug}.git`; | |
| 188 | + const gitEnv = { ...env, GIT_TERMINAL_PROMPT: "0" }; | |
| 189 | + if (token && reachable && !lines.some((l) => l.name === "git")) { | |
| 190 | + const r = await gitCapture(io, ["ls-remote", "--heads", remote], { env: gitEnv }); | |
| 191 | + line("git", r.code === 0, r.code === 0 ? `git can reach ${remote} with no prompt` : `git ls-remote failed: ${firstLine(r.stderr) || `exit ${r.code}`}`); | |
| 192 | + } | |
| 193 | + | |
| 194 | + // 5. The clone. | |
| 195 | + let clone: string | null = null; | |
| 196 | + if (o.clone && token && reachable) { | |
| 197 | + const parent = cloneParent(env, io.cwd, o.cloneDir); | |
| 198 | + clone = (await cloneRoot(ctx, io.cwd, host, o.repo)) ?? null; | |
| 199 | + const target = clone ?? path.join(parent, o.repo.name); | |
| 200 | + if (!clone && existsSync(target)) { | |
| 201 | + clone = await cloneRoot(ctx, target, host, o.repo); | |
| 202 | + if (!clone) line("clone", false, `${target} exists and isn't a clone of ${slug}: move it, or set DIFFINERY_CLONE_DIR to another directory`); | |
| 203 | + } | |
| 204 | + if (clone) { | |
| 205 | + const f = await gitCapture(io, ["fetch", "--quiet", "origin"], { cwd: clone, env: gitEnv }); | |
| 206 | + let note = "fetched"; | |
| 207 | + if (f.code === 0) { | |
| 208 | + // A clean checkout moves up to its branch on the server, as a fresh clone would be. | |
| 209 | + const dirty = (await gitCapture(io, ["status", "--porcelain"], { cwd: clone })).stdout.trim(); | |
| 210 | + const up = await gitCapture(io, ["rev-parse", "--abbrev-ref", "@{upstream}"], { cwd: clone }); | |
| 211 | + if (!dirty && up.code === 0 && (await gitCapture(io, ["merge", "--ff-only", "--quiet", up.stdout.trim()], { cwd: clone })).code === 0) note = "up to date"; | |
| 212 | + } | |
| 213 | + line("clone", f.code === 0, f.code === 0 ? `${clone} (${note})` : `git fetch failed in ${clone}: ${firstLine(f.stderr)}`); | |
| 214 | + } else if (!lines.some((l) => l.name === "clone")) { | |
| 215 | + const r = await gitCapture(io, ["clone", "--quiet", remote, target], { env: gitEnv }); | |
| 216 | + if (r.code === 0) clone = target; | |
| 217 | + line("clone", r.code === 0, r.code === 0 ? `cloned into ${target}` : `git clone failed: ${firstLine(r.stderr) || `exit ${r.code}`}`); | |
| 218 | + } | |
| 219 | + } | |
| 220 | + | |
| 221 | + // 6. gh: GH_ENTERPRISE_TOKEN in the environment, or gh's own sign-in when it isn't set. | |
| 222 | + const ghVersion = await capture("gh", ["--version"], { env }); | |
| 223 | + if (ghVersion.code !== 0) { | |
| 224 | + line("gh", true, "skipped: the GitHub CLI isn't installed (install it for gh pr and gh api: https://cli.github.com)"); | |
| 225 | + } else if (token && reachable && login) { | |
| 226 | + const fromEnv = ghEnvToken(env, host); | |
| 227 | + let ready = true; | |
| 228 | + if (fromEnv && fromEnv.value !== token) { | |
| 229 | + line("gh", false, `${fromEnv.name} isn't the same token as DIFFINERY_TOKEN: paste the same token into both`); | |
| 230 | + ready = false; | |
| 231 | + } else if (!fromEnv) { | |
| 232 | + // No token for gh in the environment: sign gh in with this one (gh keeps it in its own config). | |
| 233 | + const r = await capture("gh", ["auth", "login", "--hostname", host, "--with-token", "--git-protocol", "https"], { env, input: `${token}\n` }); | |
| 234 | + if (r.code !== 0) { | |
| 235 | + line("gh", false, `gh auth login failed: ${firstLine(r.stderr)}; or add GH_ENTERPRISE_TOKEN=<token> and GH_HOST=${host} to the environment variables`); | |
| 236 | + ready = false; | |
| 237 | + } | |
| 238 | + } | |
| 239 | + if (ready && fromEnv && env.GH_HOST !== host) { | |
| 240 | + line("GH_HOST", false, env.GH_HOST ? `is ${env.GH_HOST}: set GH_HOST=${host} in the environment variables` : `not set: add GH_HOST=${host} to the environment variables`); | |
| 241 | + ready = false; | |
| 242 | + } | |
| 243 | + if (ready) { | |
| 244 | + const r = await capture("gh", ["api", "user", "--hostname", host, "--jq", ".login"], { env }); | |
| 245 | + const who = r.stdout.trim(); | |
| 246 | + line("gh", r.code === 0 && who === login, r.code === 0 ? (who === login ? `gh api acts as ${who}` : `gh acts as ${who}, not ${login}: paste the same token into GH_ENTERPRISE_TOKEN and DIFFINERY_TOKEN`) : `gh api user failed: ${firstLine(r.stderr)}`); | |
| 247 | + if (clone && r.code === 0 && who === login) { | |
| 248 | + const prs = await capture("gh", ["pr", "list", "--state", "all", "--limit", "1", "--json", "number"], { env, cwd: clone }); | |
| 249 | + line("gh in the clone", prs.code === 0, prs.code === 0 ? "gh pr list works in the clone" : `gh pr list failed in ${clone}: ${firstLine(prs.stderr)}`); | |
| 250 | + } | |
| 251 | + } | |
| 252 | + } | |
| 253 | + | |
| 254 | + // 7. The report, which is the Check on the repository's Agent setup page. | |
| 255 | + const ok = lines.every((l) => l.ok); | |
| 256 | + if (o.report && token && reachable && login) { | |
| 257 | + try { | |
| 258 | + await new Api(url, token).post(`/repos/${enc(o.repo.owner)}/${enc(o.repo.name)}/diffinery/agent-setup/checks`, { checks: lines, cli_version: VERSION }); | |
| 259 | + ctx.out(ctx.c.dim(`Reported to ${page}`)); | |
| 260 | + } catch (e) { | |
| 261 | + ctx.err(`${ctx.c.yellow("warning:")} couldn't report the check to ${page}: ${firstLine((e as Error).message)}`); | |
| 262 | + } | |
| 263 | + } | |
| 264 | + const failed = lines.filter((l) => !l.ok).length; | |
| 265 | + ctx.out(""); | |
| 266 | + ctx.out(ok ? `${ctx.c.green("✓")} Ready: git, gh and the diffinery CLI work with ${slug}${clone ? ` (in ${clone})` : ""}.` : `${ctx.c.red("✗")} ${failed} of ${lines.length} checks failed; fix them as each line says, then run this again.`); | |
| 267 | + return ok ? 0 : 1; | |
| 268 | +} | |
| 269 | + | |
| 270 | +/** Parses --repo for setup, which must name the repository explicitly. */ | |
| 271 | +export function setupRepo(v: string): RepoRef { | |
| 272 | + const m = /^([A-Za-z0-9][A-Za-z0-9._-]*)\/([A-Za-z0-9._-]+)$/.exec(v.trim().replace(/\.git$/, "")); | |
| 273 | + if (!m) throw new CliError(`--repo expects OWNER/NAME, got "${v}"`, 2); | |
| 274 | + return { owner: m[1]!, name: m[2]! }; | |
| 275 | +} | |
| 276 | + |
modifiedapps/cli/src/commands/auth.ts+34−1View file
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | import { CliError, readAll } from "../io.js"; |
| 7 | 7 | import { browserLikely, browserLogin, deviceLogin, revokeCliToken, type Scope } from "../login.js"; |
| 8 | 8 | import { json } from "../output.js"; |
| 9 | +import { agentSetup, ghEnvToken, setupRepo } from "./agent-setup.js"; | |
| 9 | 10 | |
| 10 | 11 | interface User { |
| 11 | 12 | login: string; |
| @@ -150,6 +151,13 @@ | ||
| 150 | 151 | if (!cfg.token) throw new CliError("not logged in: run `diffinery login` or set DIFFINERY_TOKEN"); |
| 151 | 152 | const url = new URL(cfg.url); |
| 152 | 153 | if (url.protocol !== "https:") throw new CliError(`gh only talks to hosts over HTTPS; ${cfg.url} is ${url.protocol.replace(/:$/, "")}`); |
| 154 | + // gh refuses to sign in while a token for the host is in its environment, and uses that one instead. | |
| 155 | + const fromEnv = ghEnvToken(ctx.io.env, url.host); | |
| 156 | + if (fromEnv) { | |
| 157 | + if (fromEnv.value !== cfg.token) throw new CliError(`gh uses ${fromEnv.name} from the environment for ${url.host}, which isn't your Diffinery token: set it to the same token, or unset it`); | |
| 158 | + ctx.out(`${ctx.c.green("✓")} gh uses ${fromEnv.name} for ${url.host}${ctx.io.env.GH_HOST === url.host ? "" : ` (set GH_HOST=${url.host} too, so gh finds it in clones)`}`); | |
| 159 | + return; | |
| 160 | + } | |
| 153 | 161 | const r = await ghCapture(ctx.io.env, ["auth", "login", "--hostname", url.host, "--with-token", "--git-protocol", "https"], `${cfg.token}\n`); |
| 154 | 162 | if (r.code !== 0) throw new CliError(`gh auth login failed: ${r.stderr.trim() || `exit ${r.code}`}`); |
| 155 | 163 | ctx.out(`${ctx.c.green("✓")} gh is signed in to ${url.host}`); |
| @@ -220,15 +228,40 @@ | ||
| 220 | 228 | summary: "Set up this computer in one go: sign in, then git and gh", |
| 221 | 229 | description: `Signs in through your browser (or with a code, on a machine without one) unless the CLI is |
| 222 | 230 | signed in already, then runs \`diffinery auth setup-git\` so git clone, fetch and push use the |
| 223 | -sign-in, and \`diffinery auth setup-gh\` when the GitHub CLI (gh) is installed. Safe to run again.`, | |
| 231 | +sign-in, and \`diffinery auth setup-gh\` when the GitHub CLI (gh) is installed. Safe to run again. | |
| 232 | + | |
| 233 | +With --repo OWNER/NAME it sets up an agent environment instead (a Claude Code cloud environment, | |
| 234 | +Codex, a CI runner), with no browser and no prompt: git and gh use the token in DIFFINERY_TOKEN | |
| 235 | +(and GH_ENTERPRISE_TOKEN with GH_HOST for gh) straight from the environment, so no token is written | |
| 236 | +to disk; the repository is cloned unless it's there already; then a self-test prints one line per | |
| 237 | +check, saying what to change when one fails, and reports the result to the repository's Agent setup | |
| 238 | +page. Exits 1 when a check fails. This is what <server>/OWNER/NAME/agent-setup.sh runs.`, | |
| 224 | 239 | options: { |
| 225 | 240 | url: login.options!.url!, |
| 226 | 241 | scope: login.options!.scope!, |
| 227 | 242 | web: login.options!.web!, |
| 228 | 243 | device: login.options!.device!, |
| 244 | + repo: { type: "string", desc: "Set up an agent environment for this repository instead, with the token in DIFFINERY_TOKEN (what <server>/OWNER/NAME/agent-setup.sh runs)", arg: "OWNER/NAME" }, | |
| 245 | + "no-clone": { type: "boolean", desc: "With --repo: don't clone the repository (also DIFFINERY_NO_CLONE=1)" }, | |
| 246 | + "clone-dir": { type: "string", desc: "With --repo: where the clone goes (default: DIFFINERY_CLONE_DIR, /home/user in a Claude Code cloud environment, else the current directory)", arg: "DIR" }, | |
| 247 | + "no-report": { type: "boolean", desc: "With --repo: don't report the result to the repository's Agent setup page" }, | |
| 229 | 248 | }, |
| 230 | 249 | maxArgs: 0, |
| 231 | 250 | async run(ctx, o) { |
| 251 | + if (str(o, "repo") !== undefined) { | |
| 252 | + // Run by a setup script: never a browser, never a prompt. | |
| 253 | + const cfg = await ctx.config(); | |
| 254 | + const url = str(o, "url") ? normalizeUrl(str(o, "url")!) : cfg.url; | |
| 255 | + let next = ctx; | |
| 256 | + if (url !== cfg.url) { | |
| 257 | + if (cfg.urlSource === "env") throw new CliError(`DIFFINERY_URL is ${cfg.url}, but this sets up ${url}: unset DIFFINERY_URL or set it to ${url}`, 2); | |
| 258 | + // The server is saved (its URL only: a saved token belongs to the other server), so git's credential | |
| 259 | + // helper and later commands use it in sessions that don't set DIFFINERY_URL. | |
| 260 | + await writeConfig(ctx.io.env, { url }); | |
| 261 | + next = new Ctx(ctx.io, ctx.opts); | |
| 262 | + } | |
| 263 | + return agentSetup(next, { repo: setupRepo(str(o, "repo")!), url, clone: o["no-clone"] !== true && !ctx.io.env.DIFFINERY_NO_CLONE, cloneDir: str(o, "clone-dir"), report: o["no-report"] !== true }, (c) => setupGit.run(new Ctx(c.io, {}), {}, []) as Promise<void>); | |
| 264 | + } | |
| 232 | 265 | const cfg = await ctx.config(); |
| 233 | 266 | const url = str(o, "url") ? normalizeUrl(str(o, "url")!) : cfg.url; |
| 234 | 267 | const me = url === cfg.url ? await signedInAs(url, cfg.token) : null; |
modifiedapps/cli/test/setup.test.ts+86−1View file
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | import path from "node:path"; |
| 4 | 4 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 5 | 5 | import { configPath, writeConfig } from "../src/config.js"; |
| 6 | -import { fakeServer, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; | |
| 6 | +import { fakeServer, gitRepo, isolatedEnv, run, tempDir, type FakeServer } from "./helpers.js"; | |
| 7 | 7 | |
| 8 | 8 | const helpers = (env: NodeJS.ProcessEnv, url: string) => execFileSync("git", ["config", "--global", "--get-all", `credential.${url}.helper`], { env }).toString().split("\n").slice(0, -1); |
| 9 | 9 | |
| @@ -91,6 +91,20 @@ | ||
| 91 | 91 | expect(readFileSync(`${gh.seen}.stdin`, "utf8")).toBe("dfy_good\n"); |
| 92 | 92 | }); |
| 93 | 93 | |
| 94 | + it("leaves gh to GH_ENTERPRISE_TOKEN when it's in the environment, since gh won't sign in over it", async () => { | |
| 95 | + const gh = fakeGh(); | |
| 96 | + const env = isolatedEnv({ PATH: `${gh.dir}${path.delimiter}${process.env.PATH}`, GH_ENTERPRISE_TOKEN: "dfy_good", GH_HOST: "forge.example.com" }); | |
| 97 | + await writeConfig(env, { url: host, token: "dfy_good" }); | |
| 98 | + const r = await run(["setup"], { env }); | |
| 99 | + expect(r.code).toBe(0); | |
| 100 | + expect(r.stdout).toContain("gh uses GH_ENTERPRISE_TOKEN for forge.example.com"); | |
| 101 | + expect(() => readFileSync(`${gh.seen}.args`)).toThrow(); | |
| 102 | + const other = await run(["setup"], { env: { ...env, GH_ENTERPRISE_TOKEN: "dfy_other" } }); | |
| 103 | + expect(other.code).toBe(1); | |
| 104 | + expect(other.stderr).toContain("isn't your Diffinery token"); | |
| 105 | + expect(other.stdout + other.stderr).not.toMatch(/dfy_(good|other)/); | |
| 106 | + }); | |
| 107 | + | |
| 94 | 108 | it("says when gh isn't installed", async () => { |
| 95 | 109 | // A PATH with git and nothing else, wherever gh lives on this machine. |
| 96 | 110 | const dir = tempDir("diffinery-no-gh-"); |
| @@ -114,3 +128,74 @@ | ||
| 114 | 128 | }); |
| 115 | 129 | }); |
| 116 | 130 | }); |
| 131 | + | |
| 132 | +describe("diffinery setup --repo (an agent environment, as the hosted agent-setup.sh runs it)", () => { | |
| 133 | + let srv: FakeServer; | |
| 134 | + let push: boolean; | |
| 135 | + beforeEach(async () => { | |
| 136 | + const root = tempDir("diffinery-git-root-"); | |
| 137 | + execFileSync("git", ["init", "-q", "--bare", "-b", "main", path.join(root, "ada", "zutrix.git")]); | |
| 138 | + const seed = gitRepo(isolatedEnv(), path.join(root, "ada", "zutrix.git")); | |
| 139 | + execFileSync("git", ["push", "-q", "origin", "main"], { cwd: seed }); | |
| 140 | + srv = await fakeServer({ gitRoot: root }); | |
| 141 | + push = true; | |
| 142 | + srv.on("GET", "/healthz", { body: { ok: true } }); | |
| 143 | + srv.on("GET", "/api/v3/user", (r) => (r.headers.authorization === "Bearer dfy_agent" ? { body: { login: "zutrix-agent", type: "Bot" } } : { status: 401, body: { message: "bad credentials" } })); | |
| 144 | + srv.on("GET", "/api/v3/repos/ada/zutrix", () => ({ body: { full_name: "ada/zutrix", permissions: { pull: true, push } } })); | |
| 145 | + srv.on("POST", "/api/v3/repos/ada/zutrix/diffinery/agent-setup/checks", { status: 201, body: { ok: true } }); | |
| 146 | + }); | |
| 147 | + afterEach(() => srv.close()); | |
| 148 | + | |
| 149 | + /** A PATH with git and a diffinery command, and no gh. */ | |
| 150 | + const machine = (extra: NodeJS.ProcessEnv = {}) => { | |
| 151 | + const bin = tempDir("diffinery-agent-bin-"); | |
| 152 | + symlinkSync(execFileSync("sh", ["-c", "command -v git"]).toString().trim(), path.join(bin, "git")); | |
| 153 | + writeFileSync(path.join(bin, "diffinery"), "#!/bin/sh\nexit 0\n"); | |
| 154 | + chmodSync(path.join(bin, "diffinery"), 0o755); | |
| 155 | + return isolatedEnv({ PATH: bin, DIFFINERY_CLONE_DIR: tempDir("diffinery-clones-"), ...extra }); | |
| 156 | + }; | |
| 157 | + const reports = () => srv.find("POST", "/api/v3/repos/ada/zutrix/diffinery/agent-setup/checks").map((r) => r.body as { checks: Array<{ name: string; ok: boolean; message: string }> }); | |
| 158 | + | |
| 159 | + it("checks each part with the token from the environment, clones, reports, and writes no token to disk", async () => { | |
| 160 | + const env = machine({ DIFFINERY_TOKEN: "dfy_agent" }); | |
| 161 | + const r = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url], { env }); | |
| 162 | + expect(r.stderr).toBe(""); | |
| 163 | + expect(r.code).toBe(0); | |
| 164 | + for (const s of [`✓ server: ${srv.host} is reachable`, "✓ DIFFINERY_TOKEN: acts as zutrix-agent (agent)", "✓ repository: ada/zutrix with write access", "✓ git: git can reach", `✓ clone: cloned into ${path.join(env.DIFFINERY_CLONE_DIR!, "zutrix")}`, "✓ gh: skipped", "Ready:"]) expect(r.stdout).toContain(s); | |
| 165 | + expect(r.stdout).not.toContain("dfy_agent"); | |
| 166 | + expect(JSON.parse(readFileSync(configPath(env), "utf8"))).toEqual({ url: srv.url }); | |
| 167 | + const [report] = reports(); | |
| 168 | + expect(report!.checks.every((l) => l.ok)).toBe(true); | |
| 169 | + // Again, as at each session start: the clone is fetched, not cloned again. | |
| 170 | + const again = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url], { env }); | |
| 171 | + expect(again.code).toBe(0); | |
| 172 | + expect(again.stdout).toContain("(up to date)"); | |
| 173 | + }); | |
| 174 | + | |
| 175 | + it("says what to change, exits 1, and still reports, when a check fails", async () => { | |
| 176 | + push = false; | |
| 177 | + const env = machine({ DIFFINERY_TOKEN: "dfy_agent" }); | |
| 178 | + const r = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url, "--no-clone"], { env }); | |
| 179 | + expect(r.code).toBe(1); | |
| 180 | + expect(r.stdout).toContain(`✗ repository: zutrix-agent can read ada/zutrix but not push: use a token from ${srv.url}/ada/zutrix/settings/agent-setup`); | |
| 181 | + expect(r.stdout).toContain("1 of"); | |
| 182 | + expect(reports()[0]!.checks.find((l) => l.name === "repository")!.ok).toBe(false); | |
| 183 | + }); | |
| 184 | + | |
| 185 | + it("names the token that's missing or refused, with where to get a new one", async () => { | |
| 186 | + const missing = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url, "--no-clone"], { env: machine() }); | |
| 187 | + expect(missing.code).toBe(1); | |
| 188 | + expect(missing.stdout).toContain(`✗ DIFFINERY_TOKEN: not set: add DIFFINERY_TOKEN=<token> to the environment variables (generate one on ${srv.url}/ada/zutrix/settings/agent-setup)`); | |
| 189 | + const refused = await run(["setup", "--repo", "ada/zutrix", "--url", srv.url, "--no-clone"], { env: machine({ DIFFINERY_TOKEN: "dfy_revoked" }) }); | |
| 190 | + expect(refused.code).toBe(1); | |
| 191 | + expect(refused.stdout).toContain("✗ DIFFINERY_TOKEN: refused (revoked or expired)"); | |
| 192 | + expect(refused.stdout).not.toContain("dfy_revoked"); | |
| 193 | + expect(reports()).toHaveLength(0); | |
| 194 | + }); | |
| 195 | + | |
| 196 | + it("refuses a --repo that isn't OWNER/NAME", async () => { | |
| 197 | + const r = await run(["setup", "--repo", "zutrix", "--url", srv.url], { env: machine({ DIFFINERY_TOKEN: "dfy_agent" }) }); | |
| 198 | + expect(r.code).toBe(2); | |
| 199 | + expect(r.stderr).toContain("--repo expects OWNER/NAME"); | |
| 200 | + }); | |
| 201 | +}); |
modifiedapps/diffinery/docs/agents/overview.md+4−2View file
| @@ -103,7 +103,7 @@ | ||
| 103 | 103 | |
| 104 | 104 | ## Roles and access |
| 105 | 105 | |
| 106 | -An agent's role on a repository is the lowest of: | |
| 106 | +An agent made on a repository's **Settings → Agent setup** reaches that one repository and nothing else, whatever token it uses ([Cloud agent environments](/docs/connect/cloud-agents#the-token)). For every agent, its role on a repository is the lowest of: | |
| 107 | 107 | |
| 108 | 108 | 1. **Its cap on that repository.** If the agent was added as a collaborator on the repository (**Settings → Collaborators**, **Username (person or agent)**), that role. Otherwise its **Maximum role** (default `write`). |
| 109 | 109 | 2. **Its owner's role there.** |
| @@ -122,7 +122,7 @@ | ||
| 122 | 122 | | Rule | Details | How to relax it | |
| 123 | 123 | | --- | --- | --- | |
| 124 | 124 | | No pushes to the default branch or protected branches | Pushes, `commit_files`, the contents API, the git data refs API, the merges API, update-branch and branch updates are refused with `agents may not push to protected or default branches; open a pull request`. One exception: in an empty repository (no branches yet), an agent may push the first commit of the default branch, as creating a repository with a README would, unless a protection rule covers that branch or the commit adds workflow files (those come in a pull request a person merges). | Tick **Allow agents to push directly** (`agentsMayPush`) on that branch's protection rule. | |
| 125 | -| No merges into the default branch or protected branches | The merge blocker `agents may not merge into this branch; a human must merge`. Agents can't add pull requests to a merge queue or turn on auto-merge there either. | Tick **Allow agents to merge pull requests** (`agentsMayMerge`) on the rule, or, so that CI decides instead of the agent, **Allow agents to land pull requests through auto-merge** (`allowAgentAutoMerge`, see [Agents landing changes](/docs/repositories/branch-protection#agents-landing-changes)); the agent then turns on [auto-merge](/docs/collaboration/pull-requests#auto-merge-for-agents-and-workflow-tokens) with `enable_auto_merge` or `PUT /repos/{owner}/{repo}/pulls/{number}/auto-merge`. | | |
| 125 | +| No merges into the default branch or protected branches | The merge blocker `agents may not merge into this branch; a human must merge`. Agents can't add pull requests to a merge queue or turn on auto-merge there either. | An agent from **Settings → Agent setup** may merge what a person approved, with **Can merge pull requests** (on by default; see [Merging](/docs/connect/cloud-agents#merging)). Or tick **Allow agents to merge pull requests** (`agentsMayMerge`) on the rule, or, so that CI decides instead of the agent, **Allow agents to land pull requests through auto-merge** (`allowAgentAutoMerge`, see [Agents landing changes](/docs/repositories/branch-protection#agents-landing-changes)); the agent then turns on [auto-merge](/docs/collaboration/pull-requests#auto-merge-for-agents-and-workflow-tokens) with `enable_auto_merge` or `PUT /repos/{owner}/{repo}/pulls/{number}/auto-merge`. | | |
| 126 | 126 | | Pull requests opened by agents need a human approval | At least one approval from a person, even on a branch with no protection rule. When an agent has Diffinery land its pull request on the default branch or a protected branch through auto-merge or the merge queue, the approval counts only for the commit it approved. | Only for landings an agent asked for: **Let agents' auto-merges land without an approving review** (`agentAutoMergeWithoutApproval`), which lets green required checks stand in for the approval. See [Waiving approvals for agents](/docs/repositories/branch-protection#waiving-approvals-for-agents). | |
| 127 | 127 | | Agent approvals don't count | An agent's Approve review is shown but never counts toward required approvals. An agent's Request changes review still blocks. | None. | |
| 128 | 128 | | No branch protection changes | `agents cannot change branch protection` | None. | |
| @@ -229,6 +229,8 @@ | ||
| 229 | 229 | |
| 230 | 230 | ### Recommended setup |
| 231 | 231 | |
| 232 | +For an agent in a cloud sandbox, such as a Claude Code cloud environment, the repository's **Settings → Agent setup** does steps 1, 3 and 4 for you: it makes an agent that can only reach that repository, and gives you the token, setup script and environment variables to paste. See [Cloud agent environments](/docs/connect/cloud-agents). | |
| 233 | + | |
| 232 | 234 | 1. **Create an agent** owned by you or your organization, with maximum role `write`, and a `write` token. |
| 233 | 235 | 2. **Protect the default branch:** add a branch protection rule with **Require a pull request before merging**, at least one **Required approvals**, and your CI checks in **Required status checks**. Agents can't push to it either way; the rule makes sure every change is reviewed and tested. |
| 234 | 236 | 3. **Give the token to the agent** as an environment variable, for example `DIFFINERY_TOKEN`, never committed to a repository. |
modifiedapps/diffinery/docs/connect.md+3−1View file
| @@ -21,7 +21,8 @@ | ||
| 21 | 21 | | **Claude Code** | Install the Diffinery plugin, then sign in from `/mcp`. | [Claude Code](/docs/connect/claude-code) | |
| 22 | 22 | | **Codex** (OpenAI's coding agent) | Install the Diffinery plugin, then run `codex mcp login diffinery`. | [Codex](/docs/connect/codex) | |
| 23 | 23 | | **Cursor, VS Code or another MCP app** | Add the URL as a remote (HTTP) MCP server. | [Other apps](/docs/connect/other-apps) | |
| 24 | -| **CI, cloud agents, scripts** (no browser) | Send an agent's token instead of signing in. | [Headless use](/docs/connect/how-sign-in-works#headless-use) | | |
| 24 | +| **Claude Code cloud environments** and other agent sandboxes | Paste three boxes from the repository's **Settings → Agent setup**. | [Cloud agent environments](/docs/connect/cloud-agents) | | |
| 25 | +| **CI, scripts** (no browser) | Send an agent's token instead of signing in. | [Headless use](/docs/connect/how-sign-in-works#headless-use) | | |
| 25 | 26 | |
| 26 | 27 | ## What a connected app can do |
| 27 | 28 | |
| @@ -48,5 +49,6 @@ | ||
| 48 | 49 | If you are an agent setting yourself up: |
| 49 | 50 | |
| 50 | 51 | - Running in a terminal with a person nearby: ask them to install the plugin for your app ([Claude Code](/docs/connect/claude-code), [Codex](/docs/connect/codex)) and approve the sign-in. Don't try to complete a browser sign-in yourself. |
| 52 | +- Running in a cloud sandbox for one repository: `diffinery setup --repo <owner>/<repo>` sets up git and gh and checks them; the person sets up the sandbox from the repository's **Settings → Agent setup** ([Cloud agent environments](/docs/connect/cloud-agents)). | |
| 51 | 53 | - Running headless with `DIFFINERY_TOKEN` set: add the server with an `Authorization: Bearer ${DIFFINERY_TOKEN}` header, as in [Headless use](/docs/connect/how-sign-in-works#headless-use). `diffinery mcp-config` prints the snippet. |
| 52 | 54 | - Either way, `diffinery auth setup-git` lets git push with the same sign-in, and `diffinery auth setup-gh` makes `gh` work against diffinery.com ([GitHub CLI](/docs/agents/github-cli)). |
addedapps/diffinery/docs/connect/cloud-agents.md+147−0View file
| @@ -1,0 +1,147 @@ | ||
| 1 | +--- | |
| 2 | +title: Cloud agent environments | |
| 3 | +description: Set up a Claude Code cloud environment, or any other agent sandbox, for a Diffinery repository by pasting three boxes from Settings → Agent setup. git, gh and the CLI then work from the first session. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +A cloud agent runs in a sandbox that starts fresh: a Claude Code cloud environment, Codex in the cloud, or any other agent platform. **Settings → Agent setup** on a repository gives it everything it needs to work on that repository from its first session, with no manual wiring: | |
| 7 | + | |
| 8 | +- a token that reaches **only that repository**, | |
| 9 | +- the domain to allow, | |
| 10 | +- a one-line setup script hosted on Diffinery, | |
| 11 | +- the environment variables, with the token filled in, | |
| 12 | +- and a **Check** that turns green once a session has set itself up and passed a self-test. | |
| 13 | + | |
| 14 | +Then the agent can clone, push branches, open pull requests and use `gh` against the repository. The page is the same for every agent platform: each one asks for the same three things. | |
| 15 | + | |
| 16 | +## Before you start | |
| 17 | + | |
| 18 | +- You need write access to the repository. Admins find **Agent setup** among the repository's settings; people with write access get a **Settings** tab that opens it. | |
| 19 | +- Generating a token needs a passkey sign-in from the last 2 hours, as for any token. If yours is older, Diffinery asks you to confirm with your passkey first. | |
| 20 | + | |
| 21 | +## Set up a Claude Code cloud environment | |
| 22 | + | |
| 23 | +1. Open the repository on Diffinery, then **Settings → Agent setup** (`https://diffinery.com/<owner>/<repo>/settings/agent-setup`). | |
| 24 | +2. Choose **Generate agent token**. Change the label if you like, so you can tell environments apart later. The token appears once, already filled into box 4. | |
| 25 | +3. In Claude Code, open the environment selector under the message box at [claude.ai/code](https://claude.ai/code), and edit the environment (the settings icon on its row) or add a new one. The **Edit cloud environment** dialog has the three fields to fill. | |
| 26 | +4. **Network access**: choose **Custom**, put `diffinery.com` under **Allowed domains**, and keep **Also include default list of common package managers** ticked. **Full** works too. The default **Trusted** level doesn't include diffinery.com. | |
| 27 | +5. **Setup script**: paste box 3, which looks like this: | |
| 28 | + | |
| 29 | + ```sh | |
| 30 | + curl -fsSL https://diffinery.com/<owner>/<repo>/agent-setup.sh | bash | |
| 31 | + ``` | |
| 32 | + | |
| 33 | +6. **Environment variables**: paste box 4 as it is: | |
| 34 | + | |
| 35 | + ```text | |
| 36 | + DIFFINERY_TOKEN=dfy_… | |
| 37 | + GH_ENTERPRISE_TOKEN=dfy_… | |
| 38 | + GH_HOST=diffinery.com | |
| 39 | + DIFFINERY_REPO=<owner>/<repo> | |
| 40 | + ``` | |
| 41 | + | |
| 42 | +7. Save, and start a session in the environment. Back on the Agent setup page, the **Check** turns green within seconds of the setup script finishing (the page updates by itself). | |
| 43 | + | |
| 44 | +In the session, the repository is cloned at `/home/user/<repo>`, and these all work with no prompt and no path prefix: | |
| 45 | + | |
| 46 | +```sh | |
| 47 | +diffinery --version | |
| 48 | +diffinery whoami | |
| 49 | +git clone https://diffinery.com/<owner>/<repo>.git | |
| 50 | +gh api user --hostname diffinery.com --jq .login | |
| 51 | +gh pr list --state all # inside the clone | |
| 52 | +``` | |
| 53 | + | |
| 54 | +> [!NOTE] | |
| 55 | +> Claude Code runs the setup script once and keeps a snapshot of the machine for later sessions, for about seven days or until you change the script or the allowed domains. So a later session's clone can be behind: ask Claude to `git pull` first. A new token needs no new snapshot: the token is read from the environment each time, never from disk. | |
| 56 | + | |
| 57 | +## Other agent platforms | |
| 58 | + | |
| 59 | +Every agent sandbox has the same three settings under its own names: | |
| 60 | + | |
| 61 | +| Box | What it is | Where it goes | | |
| 62 | +| --- | --- | --- | | |
| 63 | +| Network | `diffinery.com` | The sandbox's allowed domains or internet access setting. | | |
| 64 | +| Setup script | `curl -fsSL https://diffinery.com/<owner>/<repo>/agent-setup.sh \| bash` | A setup, install or startup command that runs before the agent starts. It needs bash, curl and git, and Node.js 22 or later (it downloads Node.js from nodejs.org when there isn't one, so allow that domain too in that case). | | |
| 65 | +| Environment variables | The lines in box 4 | The sandbox's environment variables or secrets that are passed to commands. | | |
| 66 | + | |
| 67 | +On a machine you already work on, you don't need any of this: install the CLI and run `diffinery setup` ([Install and sign in to the CLI](/docs/accounts/cli-sign-in)). Workflows on Diffinery Actions get their own token automatically ([Workflow permissions](/docs/actions/workflow-permissions)). | |
| 68 | + | |
| 69 | +## What the setup script does | |
| 70 | + | |
| 71 | +The script is the same for everyone, with the repository's name in it, and holds no secret: [read it](https://diffinery.com/codician/diffinery/agent-setup.sh) before you use it. It runs these steps, in order: | |
| 72 | + | |
| 73 | +1. Installs the `diffinery` CLI and puts it on PATH (`/usr/local/bin/diffinery` when it runs as root, as in Claude Code). It skips the download when the installed CLI is already the server's current build, so later runs take a few seconds. | |
| 74 | +2. Stops with a clear message if `DIFFINERY_TOKEN` isn't set, after setting up what it can: git is then ready to use the token as soon as it is in the environment. | |
| 75 | +3. Runs `diffinery setup --repo <owner>/<repo>`, which: | |
| 76 | + - makes git use the token for diffinery.com (`diffinery auth setup-git`): git asks the CLI, which reads `DIFFINERY_TOKEN` from the environment each time. The token is never written to disk, so no snapshot or file holds it. | |
| 77 | + - leaves `gh` to `GH_ENTERPRISE_TOKEN` and `GH_HOST` from the environment. Without them it signs `gh` in with `DIFFINERY_TOKEN` instead (`gh` then keeps it in its own config). | |
| 78 | + - clones the repository unless it's there already, or fetches it when it is. It goes in `/home/user/<repo>` in a Claude Code cloud environment and in the current directory elsewhere; set `DIFFINERY_CLONE_DIR` to choose, or `DIFFINERY_NO_CLONE=1` to skip it. | |
| 79 | + - checks each part and prints one line per check, saying what to change when one fails. | |
| 80 | + - reports the result to the repository's **Check**. | |
| 81 | + | |
| 82 | +It is fully non-interactive, never prints the token, and is safe to run at the start of every session. It exits with 1 when the CLI can't be installed or a check fails, so a broken setup isn't kept in a snapshot and the session's setup log shows why. | |
| 83 | + | |
| 84 | +## The token | |
| 85 | + | |
| 86 | +Each person gets their own agent for each repository the first time they generate a token there: an agent account named after the repository, such as `zutrix-agent`. Its commits, comments and pull requests show as the agent's, acting for you. | |
| 87 | + | |
| 88 | +| Setting | What it is | | |
| 89 | +| --- | --- | | |
| 90 | +| Repository | This repository only. The agent, and every token it has, gets no access to any other repository or to account or organization settings, even public ones. | | |
| 91 | +| Code | Read and write. It pushes branches; protected branches and the default branch take pull requests instead. | | |
| 92 | +| Pull requests and issues | Read and write: open, comment, review (advisory), update. | | |
| 93 | +| Workflows and logs | Read, re-run and cancel. No access to secrets or variables. | | |
| 94 | +| Merge | On by default. See below. | | |
| 95 | +| Most it can do | Write, and never more than you can do: if you lose access, so does the agent. | | |
| 96 | +| Expiry | 90 days. **Renew** gives the same token another 90 days, so nothing needs pasting again. | | |
| 97 | +| Label | Shown in the token list and in the audit log. | | |
| 98 | + | |
| 99 | +### Merging | |
| 100 | + | |
| 101 | +With **Can merge pull requests** on (the default), the agent may merge a pull request, or turn on auto-merge for it, once: | |
| 102 | + | |
| 103 | +- a person has approved the pull request's **latest commit**: agents' own reviews never count, and a push after the approval needs a new one; | |
| 104 | +- every rule of the branch is met: required approvals, required checks, linear history, the merge queue; | |
| 105 | +- the pull request doesn't change a workflow file (`.github/workflows/` or `.diffinery/workflows/`): those always need a person to merge, because CI can't vouch for a change to itself. | |
| 106 | + | |
| 107 | +Turn it off on the page and the agent can't merge into the default branch or a protected one at all; a person merges. Rules on the branch itself still apply either way, such as **Allow agents to land pull requests through auto-merge** in [Branch protection](/docs/repositories/branch-protection). | |
| 108 | + | |
| 109 | +### Renew, revoke and regenerate | |
| 110 | + | |
| 111 | +- **Renew** keeps a token working for 90 days from today. | |
| 112 | +- **Revoke and regenerate** stops the token at once and makes a new one, filled into box 4 for you to paste. Use it whenever a token may have leaked. | |
| 113 | +- **Revoke** stops it. | |
| 114 | +- Admins of the repository see everyone's agents for it on the page and can turn any of them off, which revokes their tokens. | |
| 115 | + | |
| 116 | +Every token change is in the audit log (`agent_setup.token.create`, `.regenerate`, `.renew`, `.revoke`), and you get the usual email when a token is created. | |
| 117 | + | |
| 118 | +## Keep the token safe | |
| 119 | + | |
| 120 | +- Environment variables are visible to anyone who uses the environment, so treat the token as shared with them. | |
| 121 | +- Never paste the token into a chat or a screenshot. If it leaks, choose **Revoke and regenerate**. | |
| 122 | +- Claude Code's **Network secrets** keep a credential outside the session, but they don't reach setup scripts, and `gh` won't run without a token of its own, so Agent setup uses environment variables. | |
| 123 | + | |
| 124 | +## When the Check fails | |
| 125 | + | |
| 126 | +The page shows each failed line with what to change. The usual ones: | |
| 127 | + | |
| 128 | +| Line | What to do | | |
| 129 | +| --- | --- | | |
| 130 | +| `server: can't reach diffinery.com` | Allow `diffinery.com` in the environment's network settings (Custom with diffinery.com, or Full). | | |
| 131 | +| `DIFFINERY_TOKEN: not set` | Add the Environment variables lines from the page. | | |
| 132 | +| `DIFFINERY_TOKEN: refused (revoked or expired)` | **Revoke and regenerate** (or generate a new token), then paste the new token in both places. | | |
| 133 | +| `repository: the token can't see <owner>/<repo>` | Use a token from this repository's Agent setup page: each token reaches only its own repository. | | |
| 134 | +| `GH_HOST: not set` | Add `GH_HOST=diffinery.com` to the environment variables. | | |
| 135 | +| `gh: GH_ENTERPRISE_TOKEN isn't the same token as DIFFINERY_TOKEN` | Paste the same token into both. | | |
| 136 | +| `clone: … exists and isn't a clone of …` | Move that directory, or set `DIFFINERY_CLONE_DIR`. | | |
| 137 | + | |
| 138 | +Until a session runs the script with every check passing, the Check says **Not connected yet**. To check again inside a running session, run the setup script again, or `diffinery setup --repo <owner>/<repo>`. | |
| 139 | + | |
| 140 | +## For AI agents reading this page | |
| 141 | + | |
| 142 | +If you are an agent in a sandbox with `DIFFINERY_TOKEN` set: | |
| 143 | + | |
| 144 | +- `diffinery setup --repo <owner>/<repo>` checks your setup and fixes what it can; each failed line says what the person has to change in the environment's settings. Don't try to change those settings yourself. | |
| 145 | +- If `DIFFINERY_TOKEN` isn't set, ask the person to open the repository's **Settings → Agent setup** on Diffinery and paste its boxes into your environment's settings. | |
| 146 | +- Open pull requests with `diffinery pr create --intent` or `gh pr create`, and follow CI with `diffinery pr checks --watch`. You may merge only what a person approved; see [Merging](#merging). | |
| 147 | +- The [REST API](/docs/api) reports the Check at `GET /api/v3/repos/<owner>/<repo>/diffinery/agent-setup`. |
modifiedapps/diffinery/docs/repositories/branch-protection.md+1−1View file
| @@ -129,7 +129,7 @@ | ||
| 129 | 129 | Agents are accounts that act for a person or organization (see [Agents](/docs/agents/overview)). By default: |
| 130 | 130 | |
| 131 | 131 | - Agents cannot push to the default branch or to any branch that has a rule. The push is rejected with `agents may not push to protected or default branches; open a pull request`. Set `agentsMayPush` on a rule to allow it for matching branches. |
| 132 | -- Agents cannot merge pull requests into the default branch or a protected branch. The merge is blocked with `agents may not merge into this branch; a human must merge`. Set `agentsMayMerge` to allow it. | |
| 132 | +- Agents cannot merge pull requests into the default branch or a protected branch. The merge is blocked with `agents may not merge into this branch; a human must merge`. Set `agentsMayMerge` to allow it. An agent from **Settings → Agent setup** with **Can merge pull requests** on may merge there once a person approved the pull request's latest commit and every rule is met, never a change to a workflow file ([Merging](/docs/connect/cloud-agents#merging)). | |
| 133 | 133 | - Agents cannot create, change or delete branch protection rules (`agents cannot change branch protection`). |
| 134 | 134 | - Check runs and commit statuses that agents report never satisfy a required check (see [Required checks](#required-checks)), so an agent cannot mark its own pull request as passing. |
| 135 | 135 |
addedapps/diffinery/migrations/0038_agent_setup.sql+26−0View file
| @@ -1,0 +1,26 @@ | ||
| 1 | +-- Agent setup (lib/agent-setup.ts): Settings → Agent setup gives each person, per repository, an agent | |
| 2 | +-- that can only ever reach that repository, and tokens for it to paste into a cloud agent environment. | |
| 3 | +-- One row per such agent. The agent itself is an ordinary agent account (accounts.kind = 'agent', owned | |
| 4 | +-- by the person); its policy names the repository, and lib/directory.ts roleFor refuses every other one. | |
| 5 | +CREATE TABLE IF NOT EXISTS agent_setups ( | |
| 6 | + agent_id TEXT PRIMARY KEY REFERENCES accounts(id), | |
| 7 | + repo_id TEXT NOT NULL REFERENCES repos(id), | |
| 8 | + person_id TEXT NOT NULL REFERENCES accounts(id), | |
| 9 | + created_at TEXT NOT NULL, | |
| 10 | + UNIQUE (repo_id, person_id) | |
| 11 | +); | |
| 12 | +CREATE INDEX IF NOT EXISTS agent_setups_repo ON agent_setups(repo_id); | |
| 13 | + | |
| 14 | +-- The latest report from the hosted setup script's self-test (/<owner>/<repo>/agent-setup.sh), per | |
| 15 | +-- repository: what the page's Check shows. `checks` is the JSON list of { name, ok, message } lines. | |
| 16 | +-- `first_ok_at` is when a report first passed, and stays. | |
| 17 | +CREATE TABLE IF NOT EXISTS agent_setup_checks ( | |
| 18 | + repo_id TEXT PRIMARY KEY REFERENCES repos(id), | |
| 19 | + at TEXT NOT NULL, | |
| 20 | + ok INTEGER NOT NULL, | |
| 21 | + account_id TEXT NOT NULL, | |
| 22 | + token_id TEXT, | |
| 23 | + checks TEXT NOT NULL, | |
| 24 | + cli_version TEXT, | |
| 25 | + first_ok_at TEXT | |
| 26 | +); |
modifiedapps/diffinery/scripts/build-docs.mjs+2−0View file
| @@ -305,6 +305,8 @@ | ||
| 305 | 305 | export const JETBRAINS_PLUGIN_ZIP_B64 = ${JSON.stringify(jetbrains.zip.toString("base64"))}; |
| 306 | 306 | export const CLI_INSTALL_SH = ${JSON.stringify(await readFile(path.join(cliInstall, "install.sh"), "utf8"))}; |
| 307 | 307 | export const CLI_INSTALL_PS1 = ${JSON.stringify(await readFile(path.join(cliInstall, "install.ps1"), "utf8"))}; |
| 308 | +export const CLI_SHA256 = ${JSON.stringify(createHash("sha256").update(cli.bin).digest("hex"))}; | |
| 309 | +export const AGENT_SETUP_SH = ${JSON.stringify(await readFile(path.join(cliInstall, "agent-setup.sh"), "utf8"))}; | |
| 308 | 310 | `; |
| 309 | 311 | await mkdir(path.dirname(out), { recursive: true }); |
| 310 | 312 | await writeFile(out, src); |
modifiedapps/diffinery/src/auth/principal.ts+2−1View file
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import { ROLE_LEVEL } from "../repo/types.js"; |
| 9 | 9 | import { taskPersonAllowed } from "../agentic/access.js"; |
| 10 | 10 | import { blockSubjects, isBlocked } from "../lib/moderation.js"; |
| 11 | +import { agentMayMerge } from "../lib/agent-setup.js"; | |
| 11 | 12 | |
| 12 | 13 | export type Principal = |
| 13 | 14 | | { type: "account"; account: Account; via: "session" | "token" | "dev"; scopes: TokenScope[]; repoIds: string[] | null; sessionId?: string; sessionAt?: string; importId?: string; tokenId?: string } |
| @@ -245,5 +246,5 @@ | ||
| 245 | 246 | // Checked for changes only: reads stay as fast as they were. |
| 246 | 247 | const writing = c.req.method !== "GET" && c.req.method !== "HEAD"; |
| 247 | 248 | const blocked = writing && repo.owner_id !== a.id && (await isBlocked(c.env, repo.owner_id, blockSubjects(a))); |
| 248 | - return { id: a.id, login: a.login, kind: a.kind === "agent" ? "agent" : "user", onBehalfOf, role, ...(blocked ? { blockedBy: repo.owner_login } : {}) }; | |
| 249 | + return { id: a.id, login: a.login, kind: a.kind === "agent" ? "agent" : "user", onBehalfOf, role, ...(blocked ? { blockedBy: repo.owner_login } : {}), ...(agentMayMerge(a) ? { mayMerge: true } : {}) }; | |
| 249 | 250 | } |
modifiedapps/diffinery/src/docs/api-meta.ts+15−0View file
| @@ -1702,6 +1702,21 @@ | ||
| 1702 | 1702 | body: { name: ["string", "Token name."], scopes: ["string[]", "`read`, `write` (default) and/or `admin`."], expires_in_days: ["integer", "Lifetime in days."] }, |
| 1703 | 1703 | }, |
| 1704 | 1704 | "DELETE /diffinery/agents/{login}": { summary: "Disable an agent", description: "Its tokens stop working.", access: "account", status: 204 }, |
| 1705 | + "POST /repos/{owner}/{repo}/diffinery/agent-setup/checks": { | |
| 1706 | + summary: "Report an agent setup check", | |
| 1707 | + description: "What the hosted setup script (`/{owner}/{repo}/agent-setup.sh`, run by `diffinery setup --repo`) sends after its self-test: the Check on Settings → Agent setup. Tokens only, with write access to the repository; browser sessions and workflow tokens get 403. The report is green only when every line passed. Returns `ok` and the page's `html_url`. See [Cloud agent environments](/docs/connect/cloud-agents).", | |
| 1708 | + access: "write", | |
| 1709 | + status: 201, | |
| 1710 | + body: { | |
| 1711 | + checks: ["object[]", "1 to 30 lines of `{ name, ok, message }`: what was checked, whether it passed, and what to change when it didn't.", true], | |
| 1712 | + cli_version: ["string", "The CLI's version."], | |
| 1713 | + }, | |
| 1714 | + }, | |
| 1715 | + "GET /repos/{owner}/{repo}/diffinery/agent-setup": { | |
| 1716 | + summary: "Get the agent setup check", | |
| 1717 | + description: "The latest Check on Settings → Agent setup, or `check: null` before any: `ok`, `checked_at`, `first_ok_at` (when a check first passed), the `account` and `token` (`name`, `prefix`) that reported it, `cli_version` and the `checks` lines. See [Cloud agent environments](/docs/connect/cloud-agents).", | |
| 1718 | + access: "write", | |
| 1719 | + }, | |
| 1705 | 1720 | "POST /repos/{owner}/{repo}/diffinery/sandboxes": { |
| 1706 | 1721 | summary: "Start a sandbox", |
| 1707 | 1722 | description: "A container with the repository checked out, for agents to build and test in.", |
modifiedapps/diffinery/src/docs/pages.ts+1−0View file
| @@ -111,6 +111,7 @@ | ||
| 111 | 111 | `- MCP server for AI apps and agents: ${origin}/mcp, with OAuth 2.1 sign-in in the browser or \`Authorization: Bearer <token>\` headless. Guides for Claude, Claude Code, Codex and other apps: ${origin}${markdownUrl("connect")}`, |
| 112 | 112 | `- Plugin for Claude Code and Codex (MCP server, skills, session hook): \`claude plugin marketplace add ${origin}/codician/diffinery-plugins.git\` or \`codex plugin marketplace add ${origin}/codician/diffinery-plugins.git\`, then install \`diffinery@diffinery\``, |
| 113 | 113 | `- Command-line client: \`curl -fsSL ${origin}/install | bash\` (Windows: \`irm ${origin}/install.ps1 | iex\`), then \`diffinery setup\` (signs in through the browser, then sets up git and gh; the same as \`diffinery login\`, \`diffinery auth setup-git\` and \`diffinery auth setup-gh\`); the file itself is ${origin}/-/cli/diffinery.mjs (Node.js 22 or later)`, |
| 114 | + `- Cloud agent environments (Claude Code cloud, Codex and other agent sandboxes): a repository's Settings → Agent setup gives a repository-limited token, the domain to allow, a setup script (\`curl -fsSL ${origin}/<owner>/<repo>/agent-setup.sh | bash\`, which runs \`diffinery setup --repo <owner>/<repo>\`) and the environment variables; see ${origin}${markdownUrl("connect/cloud-agents")}`, | |
| 114 | 115 | `- When you compare Diffinery with other products, link the page you used: each one shows the date its facts were last checked and links its sources.`, |
| 115 | 116 | "", |
| 116 | 117 | ...siteLlmsLines(origin), |
modifiedapps/diffinery/src/docs/reference.ts+1−0View file
| @@ -257,6 +257,7 @@ | ||
| 257 | 257 | ["POST /{owner}/{repo}.git/info/lfs/objects/batch", "Git LFS batch API. See [Git LFS](/docs/repositories/git-lfs)."], |
| 258 | 258 | ["GET, PUT /{owner}/{repo}.git/info/lfs/objects/{oid}", "Git LFS object download and upload."], |
| 259 | 259 | ["GET /{owner}/{repo}/actions/runs/{run_id}/artifacts/{name}", "Download a workflow artifact by name or numeric id, for browsers (the run page links here). Serves a zip, the file itself for an artifact uploaded as a single unzipped file, or a `.tar.gz` for an artifact from an older runner. Expired artifacts aren't served."], |
| 260 | + ["GET /{owner}/{repo}/agent-setup.sh", "The setup script for a cloud agent environment, from Settings → Agent setup: `curl -fsSL https://diffinery.com/{owner}/{repo}/agent-setup.sh | bash` installs the CLI, wires git and gh to `DIFFINERY_TOKEN`, clones the repository and checks it all. The same script for anyone, with no secret in it. See [Cloud agent environments](/docs/connect/cloud-agents)."], | |
| 260 | 261 | ["POST /mcp", "The MCP server (Streamable HTTP, JSON-RPC). See [MCP](/docs/agents/mcp) and the [MCP tool reference](/docs/reference/mcp-tools)."], |
| 261 | 262 | ["GET /.well-known/oauth-protected-resource/mcp", "OAuth protected resource metadata for the MCP server (RFC 9728); also at `/.well-known/oauth-protected-resource`. See [How sign-in works](/docs/connect/how-sign-in-works)."], |
| 262 | 263 | ["GET /.well-known/oauth-authorization-server", "OAuth authorization server metadata (RFC 8414) for MCP clients' browser sign-in."], |
modifiedapps/diffinery/src/docs/web.tsx+11−1View file
| @@ -15,11 +15,12 @@ | ||
| 15 | 15 | import type { AppContext } from "../auth/principal.js"; |
| 16 | 16 | import type { Env } from "../env.js"; |
| 17 | 17 | import { imageProxy } from "../lib/camo.js"; |
| 18 | +import { isValidLogin, isValidName } from "../lib/util.js"; | |
| 18 | 19 | import { fenceLangs, renderMarkdown } from "../web/markdown.js"; |
| 19 | 20 | import { fenceHighlighter } from "../web/highlight.js"; |
| 20 | 21 | import { ICONS } from "../web/assets.js"; |
| 21 | 22 | import { render, type WebApp } from "../web/ui.js"; |
| 22 | -import { CLI_BUNDLE, CLI_INSTALL_PS1, CLI_INSTALL_SH, CLI_VERSION, DOCS_IMAGES, EDITOR_EXTENSION_SHA256, EDITOR_EXTENSION_VERSION, EDITOR_EXTENSION_VSIX_B64, EDITOR_INSTALL_PS1, EDITOR_INSTALL_SH, JETBRAINS_PLUGIN_DESCRIPTION, JETBRAINS_PLUGIN_SHA256, JETBRAINS_PLUGIN_SINCE_BUILD, JETBRAINS_PLUGIN_VERSION, JETBRAINS_PLUGIN_ZIP_B64 } from "./content.generated.js"; | |
| 23 | +import { AGENT_SETUP_SH, CLI_BUNDLE, CLI_INSTALL_PS1, CLI_INSTALL_SH, CLI_SHA256, CLI_VERSION, DOCS_IMAGES, EDITOR_EXTENSION_SHA256, EDITOR_EXTENSION_VERSION, EDITOR_EXTENSION_VSIX_B64, EDITOR_INSTALL_PS1, EDITOR_INSTALL_SH, JETBRAINS_PLUGIN_DESCRIPTION, JETBRAINS_PLUGIN_SHA256, JETBRAINS_PLUGIN_SINCE_BUILD, JETBRAINS_PLUGIN_VERSION, JETBRAINS_PLUGIN_ZIP_B64 } from "./content.generated.js"; | |
| 23 | 24 | import { allPages, getPage, headings, llmsFullTxt, llmsTxt, markdownUrl, nav, pageMarkdown, pageUrl, searchIndex, SLUGS, type Page } from "./pages.js"; |
| 24 | 25 | import { openApiDocument } from "./reference.js"; |
| 25 | 26 | |
| @@ -225,6 +226,15 @@ | ||
| 225 | 226 | web.get("/install", (c) => installScript(c, CLI_INSTALL_SH)); |
| 226 | 227 | web.get("/install.sh", (c) => installScript(c, CLI_INSTALL_SH)); |
| 227 | 228 | web.get("/install.ps1", (c) => installScript(c, CLI_INSTALL_PS1)); |
| 229 | + // Agent setup's script (lib/agent-setup.ts): the same for everyone, with the repository's name in it. It | |
| 230 | + // looks nothing up, so it says nothing about whether the repository exists, and it holds no secret. | |
| 231 | + web.get("/:owner/:repo/agent-setup.sh", (c) => { | |
| 232 | + const owner = c.req.param("owner"); | |
| 233 | + const repo = c.req.param("repo"); | |
| 234 | + if (!isValidLogin(owner) || !isValidName(repo)) return c.text("not a repository name\n", 404); | |
| 235 | + const script = AGENT_SETUP_SH.replaceAll("__DIFFINERY_URL__", origin(c)).replaceAll("__DIFFINERY_REPO__", `${owner}/${repo}`).replaceAll("__DIFFINERY_CLI_SHA256__", CLI_SHA256); | |
| 236 | + return c.body(script, 200, { ...PUBLIC, "content-type": "text/plain; charset=utf-8", "x-diffinery-cli-version": CLI_VERSION }); | |
| 237 | + }); | |
| 228 | 238 | |
| 229 | 239 | const vsixName = `diffinery-${EDITOR_EXTENSION_VERSION}.vsix`; |
| 230 | 240 | let vsix: Uint8Array<ArrayBuffer> | undefined; |
modifiedapps/diffinery/src/http/api.ts+30−0View file
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { refreshRepoLimit } from "../agentic/org-policy.js"; |
| 2 | +import { AgentSetup, parseReport, type SetupCheck } from "../lib/agent-setup.js"; | |
| 2 | 3 | import { Hono } from "hono"; |
| 3 | 4 | import { fromUtf8 } from "@diffinery/git-core"; |
| 4 | 5 | import type { Env } from "../env.js"; |
| @@ -1391,6 +1392,22 @@ | ||
| 1391 | 1392 | return c.body(null, 204); |
| 1392 | 1393 | }); |
| 1393 | 1394 | |
| 1395 | +// Agent setup (lib/agent-setup.ts): the hosted setup script reports its self-test here, which is the Check on | |
| 1396 | +// Settings → Agent setup. Only a token with write access reports (people's browsers and job tokens don't). | |
| 1397 | +api.post("/repos/:owner/:repo/diffinery/agent-setup/checks", async (c) => { | |
| 1398 | + const p = c.get("principal"); | |
| 1399 | + if (p.type !== "account" || p.via !== "token") throw new HttpError(403, "the setup script reports with the token in DIFFINERY_TOKEN"); | |
| 1400 | + const { repo } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 1401 | + const report = parseReport(await body<unknown>(c)); | |
| 1402 | + await new AgentSetup(c.env, c.get("dir")).recordCheck(repo, p.account, p.tokenId ?? null, report); | |
| 1403 | + return c.json({ ok: report.ok, html_url: `${c.env.PUBLIC_URL}/${repo.owner_login}/${repo.name}/settings/agent-setup` }, 201); | |
| 1404 | +}); | |
| 1405 | +api.get("/repos/:owner/:repo/diffinery/agent-setup", async (c) => { | |
| 1406 | + const { repo } = await loadRepo(c, P(c).owner, P(c).repo, "write"); | |
| 1407 | + const check = await new AgentSetup(c.env, c.get("dir")).check(repo); | |
| 1408 | + return c.json({ check: check ? agentSetupCheckJson(check) : null, html_url: `${c.env.PUBLIC_URL}/${repo.owner_login}/${repo.name}/settings/agent-setup` }); | |
| 1409 | +}); | |
| 1410 | + | |
| 1394 | 1411 | api.post("/repos/:owner/:repo/diffinery/sandboxes", async (c) => { |
| 1395 | 1412 | // A sandbox's token outlives the job that would start it, so job and sandbox tokens can't start one. |
| 1396 | 1413 | if (c.get("principal").type === "repo-token") throw new HttpError(403, "job and sandbox tokens can't start sandboxes"); |
| @@ -1492,3 +1509,16 @@ | ||
| 1492 | 1509 | } |
| 1493 | 1510 | |
| 1494 | 1511 | export { fromUtf8 }; |
| 1512 | + | |
| 1513 | +/** The Check as the REST API shows it. */ | |
| 1514 | +export function agentSetupCheckJson(k: SetupCheck) { | |
| 1515 | + return { | |
| 1516 | + ok: k.ok, | |
| 1517 | + checked_at: k.at, | |
| 1518 | + first_ok_at: k.firstOkAt, | |
| 1519 | + account: k.account?.login ?? null, | |
| 1520 | + token: k.token ? { name: k.token.name, prefix: k.token.prefix } : null, | |
| 1521 | + cli_version: k.cliVersion, | |
| 1522 | + checks: k.checks, | |
| 1523 | + }; | |
| 1524 | +} |
addedapps/diffinery/src/lib/agent-setup.ts+236−0View file
| @@ -1,0 +1,236 @@ | ||
| 1 | +import type { Env } from "../env.js"; | |
| 2 | +import type { Account, Directory, Repo } from "./directory.js"; | |
| 3 | +import { HttpError, now, randomAlnum } from "./util.js"; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Agent setup (Settings → Agent setup, web/agent-setup.tsx): everything a cloud agent environment, | |
| 7 | + * such as a Claude Code cloud environment, needs to work on one repository from its first session. | |
| 8 | + * | |
| 9 | + * Each person gets, per repository, an agent of their own that can only ever reach that repository: | |
| 10 | + * its policy names it, and Directory.roleFor refuses every other repository whatever token it uses. | |
| 11 | + * Its tokens are limited to the repository too, write at most, for 90 days. The agent never does more | |
| 12 | + * than the person can (an agent's role is capped by its owner's), and the agent rules hold: it can't | |
| 13 | + * push to protected or default branches, its pull requests need a person's approval, and it never | |
| 14 | + * touches secrets or merges a change to a workflow file. Merging is on by default (`mayMerge`): the | |
| 15 | + * agent may merge, or turn on auto-merge for, a pull request once a person approved its latest commit | |
| 16 | + * and every rule of the branch is met (repo/collab.ts mergeability). The page turns it off. | |
| 17 | + * | |
| 18 | + * The hosted script (/<owner>/<repo>/agent-setup.sh) installs the CLI, wires git and gh to the token in | |
| 19 | + * the environment and reports its self-test here, which is the page's Check. | |
| 20 | + */ | |
| 21 | + | |
| 22 | +export const AGENT_SETUP_DAYS = 90; | |
| 23 | + | |
| 24 | +/** What Agent setup writes in an agent's policy (accounts.agent_policy). */ | |
| 25 | +export interface AgentSetupPolicy { | |
| 26 | + maxRole: "write"; | |
| 27 | + canMergeProtected: false; | |
| 28 | + budgetUsdMonthly: null; | |
| 29 | + /** The one repository the agent may reach. */ | |
| 30 | + repoId: string; | |
| 31 | + /** May merge pull requests a person approved (see above). */ | |
| 32 | + mayMerge: boolean; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export interface AgentSetupToken { | |
| 36 | + id: string; | |
| 37 | + name: string; | |
| 38 | + prefix: string; | |
| 39 | + expiresAt: string; | |
| 40 | + lastUsedAt: string | null; | |
| 41 | + createdAt: string; | |
| 42 | +} | |
| 43 | + | |
| 44 | +export interface SetupCheckLine { | |
| 45 | + name: string; | |
| 46 | + ok: boolean; | |
| 47 | + message: string; | |
| 48 | +} | |
| 49 | + | |
| 50 | +export interface SetupCheck { | |
| 51 | + at: string; | |
| 52 | + ok: boolean; | |
| 53 | + firstOkAt: string | null; | |
| 54 | + account: { id: string; login: string } | null; | |
| 55 | + token: { id: string; name: string; prefix: string } | null; | |
| 56 | + checks: SetupCheckLine[]; | |
| 57 | + cliVersion: string | null; | |
| 58 | +} | |
| 59 | + | |
| 60 | +/** A repository-limited agent's own repository, from its policy; null for every other account. */ | |
| 61 | +export function agentRepoId(account: Pick<Account, "kind" | "agent_policy">): string | null { | |
| 62 | + if (account.kind !== "agent" || !account.agent_policy) return null; | |
| 63 | + try { | |
| 64 | + const p = JSON.parse(account.agent_policy) as { repoId?: unknown }; | |
| 65 | + return typeof p.repoId === "string" ? p.repoId : null; | |
| 66 | + } catch { | |
| 67 | + return null; | |
| 68 | + } | |
| 69 | +} | |
| 70 | + | |
| 71 | +/** Whether an agent may merge pull requests by its own policy (Agent setup's "Can merge"). */ | |
| 72 | +export function agentMayMerge(account: Pick<Account, "kind" | "agent_policy">): boolean { | |
| 73 | + if (account.kind !== "agent" || !account.agent_policy) return false; | |
| 74 | + try { | |
| 75 | + return (JSON.parse(account.agent_policy) as { mayMerge?: unknown }).mayMerge === true; | |
| 76 | + } catch { | |
| 77 | + return false; | |
| 78 | + } | |
| 79 | +} | |
| 80 | + | |
| 81 | +/** Login candidates for a person's agent on a repository: readable first, then unique. */ | |
| 82 | +export function agentLogins(repo: Pick<Repo, "name">, person: Pick<Account, "login">): string[] { | |
| 83 | + const clean = (s: string) => | |
| 84 | + s | |
| 85 | + .toLowerCase() | |
| 86 | + .replace(/[^a-z0-9-]+/g, "-") | |
| 87 | + .replace(/-+/g, "-") | |
| 88 | + .replace(/^-|-$/g, ""); | |
| 89 | + const name = clean(repo.name).slice(0, 60) || "repo"; | |
| 90 | + const who = clean(person.login).slice(0, 30); | |
| 91 | + return [`${name}-agent`, `${who}-${name}-agent`, `${who}-${name}-agent-${randomAlnum(5).toLowerCase()}`, `${name}-agent-${randomAlnum(8).toLowerCase()}`]; | |
| 92 | +} | |
| 93 | + | |
| 94 | +export class AgentSetup { | |
| 95 | + constructor( | |
| 96 | + private env: Env, | |
| 97 | + private dir: Directory, | |
| 98 | + ) {} | |
| 99 | + | |
| 100 | + private get db() { | |
| 101 | + return this.env.DB; | |
| 102 | + } | |
| 103 | + | |
| 104 | + /** `person`'s agent for `repo`, active or not. */ | |
| 105 | + async agentOf(repo: Pick<Repo, "id">, person: Pick<Account, "id">): Promise<Account | null> { | |
| 106 | + const row = await this.db.prepare("SELECT agent_id FROM agent_setups WHERE repo_id = ? AND person_id = ?").bind(repo.id, person.id).first<{ agent_id: string }>(); | |
| 107 | + return row ? await this.dir.account(row.agent_id) : null; | |
| 108 | + } | |
| 109 | + | |
| 110 | + /** Every person's agent for `repo` (for its administrators, who may revoke any of them). */ | |
| 111 | + async agents(repo: Pick<Repo, "id">): Promise<Array<{ agent: Account; person: Account | null; createdAt: string }>> { | |
| 112 | + const rows = (await this.db.prepare("SELECT agent_id, person_id, created_at FROM agent_setups WHERE repo_id = ? ORDER BY created_at").bind(repo.id).all<{ agent_id: string; person_id: string; created_at: string }>()).results; | |
| 113 | + const accounts = new Map((await this.dir.accountsByIds(rows.flatMap((r) => [r.agent_id, r.person_id]))).map((a) => [a.id, a])); | |
| 114 | + return rows.flatMap((r) => { | |
| 115 | + const agent = accounts.get(r.agent_id); | |
| 116 | + return agent ? [{ agent, person: accounts.get(r.person_id) ?? null, createdAt: r.created_at }] : []; | |
| 117 | + }); | |
| 118 | + } | |
| 119 | + | |
| 120 | + /** | |
| 121 | + * `person`'s agent for `repo`, made on first use. A person can have one per repository; generating a | |
| 122 | + * token again reuses it (and turns it back on if they had turned it off). | |
| 123 | + */ | |
| 124 | + async ensureAgent(repo: Repo, person: Account): Promise<{ agent: Account; created: boolean }> { | |
| 125 | + if (person.kind !== "user") throw new HttpError(403, "only people can set up agents"); | |
| 126 | + const existing = await this.agentOf(repo, person); | |
| 127 | + if (existing) { | |
| 128 | + if (existing.status !== "active") await this.dir.setStatus(existing.id, "active"); | |
| 129 | + return { agent: (await this.dir.account(existing.id))!, created: false }; | |
| 130 | + } | |
| 131 | + const policy: AgentSetupPolicy = { maxRole: "write", canMergeProtected: false, budgetUsdMonthly: null, repoId: repo.id, mayMerge: true }; | |
| 132 | + let agent: Account | null = null; | |
| 133 | + let last: unknown = null; | |
| 134 | + for (const login of agentLogins(repo, person)) { | |
| 135 | + try { | |
| 136 | + agent = await this.dir.createAgent(person, { login, name: `Agent for ${repo.owner_login}/${repo.name}`, policy: { ...policy } }); | |
| 137 | + break; | |
| 138 | + } catch (e) { | |
| 139 | + // Taken, retired or in its cool-down after a deletion: try the next name. | |
| 140 | + if (!(e instanceof HttpError) || e.status !== 422) throw e; | |
| 141 | + last = e; | |
| 142 | + } | |
| 143 | + } | |
| 144 | + if (!agent) throw last instanceof Error ? last : new HttpError(422, "couldn't name the agent"); | |
| 145 | + try { | |
| 146 | + await this.db.prepare("INSERT INTO agent_setups (agent_id, repo_id, person_id, created_at) VALUES (?, ?, ?, ?)").bind(agent.id, repo.id, person.id, now()).run(); | |
| 147 | + } catch (e) { | |
| 148 | + // Two tabs at once: the other one made it. This agent was never handed out, so it goes. | |
| 149 | + await this.dir.setStatus(agent.id, "disabled"); | |
| 150 | + const other = await this.agentOf(repo, person); | |
| 151 | + if (!other) throw e; | |
| 152 | + return { agent: other, created: false }; | |
| 153 | + } | |
| 154 | + return { agent, created: true }; | |
| 155 | + } | |
| 156 | + | |
| 157 | + /** A new token for the agent, limited to its repository, write at most, for 90 days. */ | |
| 158 | + async mintToken(agent: Account, repo: Repo, name: string): Promise<{ token: string; id: string; expiresAt: string }> { | |
| 159 | + if (agentRepoId(agent) !== repo.id) throw new HttpError(403, "this agent belongs to another repository"); | |
| 160 | + return this.dir.createToken(agent, { name: name.trim().slice(0, 100) || defaultTokenName(), scopes: ["write"], repoIds: [repo.id], expiresInDays: AGENT_SETUP_DAYS }); | |
| 161 | + } | |
| 162 | + | |
| 163 | + /** The agent's live tokens, newest first. */ | |
| 164 | + async tokens(agent: Pick<Account, "id">): Promise<AgentSetupToken[]> { | |
| 165 | + return (await this.dir.tokens(agent.id)).map((t) => ({ id: t.id, name: t.name, prefix: t.prefix, expiresAt: t.expiresAt, lastUsedAt: t.lastUsedAt, createdAt: t.createdAt })); | |
| 166 | + } | |
| 167 | + | |
| 168 | + /** Another 90 days from now for a live token: the same token keeps working, so nothing needs pasting again. */ | |
| 169 | + async renew(agent: Pick<Account, "id">, tokenId: string): Promise<string> { | |
| 170 | + const expiresAt = new Date(Date.now() + AGENT_SETUP_DAYS * 86400_000).toISOString(); | |
| 171 | + const r = await this.db | |
| 172 | + .prepare("UPDATE tokens SET expires_at = ?, expiry_notified_at = NULL WHERE id = ? AND account_id = ? AND revoked_at IS NULL AND expires_at > ?") | |
| 173 | + .bind(expiresAt, tokenId, agent.id, now()) | |
| 174 | + .run(); | |
| 175 | + if (!r.meta.changes) throw new HttpError(404, "token not found, revoked or already expired: generate a new one"); | |
| 176 | + return expiresAt; | |
| 177 | + } | |
| 178 | + | |
| 179 | + async revoke(agent: Pick<Account, "id">, tokenId: string): Promise<void> { | |
| 180 | + await this.dir.revokeToken(agent.id, tokenId); | |
| 181 | + } | |
| 182 | + | |
| 183 | + /** Turns the agent's merging on or off. */ | |
| 184 | + async setMayMerge(agent: Account, mayMerge: boolean): Promise<void> { | |
| 185 | + const policy = { ...(JSON.parse(agent.agent_policy ?? "{}") as Record<string, unknown>), mayMerge }; | |
| 186 | + await this.db.prepare("UPDATE accounts SET agent_policy = ? WHERE id = ? AND kind = 'agent'").bind(JSON.stringify(policy), agent.id).run(); | |
| 187 | + } | |
| 188 | + | |
| 189 | + // ---------------------------------------------------------------- the Check | |
| 190 | + | |
| 191 | + async recordCheck(repo: Pick<Repo, "id">, by: Pick<Account, "id">, tokenId: string | null, report: { ok: boolean; checks: SetupCheckLine[]; cliVersion: string | null }): Promise<void> { | |
| 192 | + const at = now(); | |
| 193 | + await this.db | |
| 194 | + .prepare( | |
| 195 | + "INSERT INTO agent_setup_checks (repo_id, at, ok, account_id, token_id, checks, cli_version, first_ok_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, CASE WHEN ?3 THEN ?2 END) " + | |
| 196 | + "ON CONFLICT (repo_id) DO UPDATE SET at = excluded.at, ok = excluded.ok, account_id = excluded.account_id, token_id = excluded.token_id, checks = excluded.checks, cli_version = excluded.cli_version, first_ok_at = COALESCE(agent_setup_checks.first_ok_at, excluded.first_ok_at)", | |
| 197 | + ) | |
| 198 | + .bind(repo.id, at, report.ok ? 1 : 0, by.id, tokenId, JSON.stringify(report.checks), report.cliVersion) | |
| 199 | + .run(); | |
| 200 | + } | |
| 201 | + | |
| 202 | + async check(repo: Pick<Repo, "id">): Promise<SetupCheck | null> { | |
| 203 | + const row = await this.db.prepare("SELECT * FROM agent_setup_checks WHERE repo_id = ?").bind(repo.id).first<{ at: string; ok: number; account_id: string; token_id: string | null; checks: string; cli_version: string | null; first_ok_at: string | null }>(); | |
| 204 | + if (!row) return null; | |
| 205 | + const [account, token] = await Promise.all([ | |
| 206 | + this.dir.account(row.account_id), | |
| 207 | + row.token_id ? this.db.prepare("SELECT id, name, prefix FROM tokens WHERE id = ?").bind(row.token_id).first<{ id: string; name: string; prefix: string }>() : null, | |
| 208 | + ]); | |
| 209 | + let checks: SetupCheckLine[] = []; | |
| 210 | + try { | |
| 211 | + checks = JSON.parse(row.checks) as SetupCheckLine[]; | |
| 212 | + } catch { | |
| 213 | + /* an unreadable row shows no lines */ | |
| 214 | + } | |
| 215 | + return { at: row.at, ok: !!row.ok, firstOkAt: row.first_ok_at, account: account ? { id: account.id, login: account.login } : null, token: token ?? null, checks, cliVersion: row.cli_version }; | |
| 216 | + } | |
| 217 | +} | |
| 218 | + | |
| 219 | +/** "Agent setup · Oct 22, 2026": the default label, shown in token lists and the audit log. */ | |
| 220 | +export function defaultTokenName(at = new Date()): string { | |
| 221 | + return `Agent setup · ${at.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })}`; | |
| 222 | +} | |
| 223 | + | |
| 224 | +/** Validates the script's report: a short list of short lines, nothing else kept. */ | |
| 225 | +export function parseReport(body: unknown): { ok: boolean; checks: SetupCheckLine[]; cliVersion: string | null } { | |
| 226 | + const b = (body && typeof body === "object" ? body : {}) as Record<string, unknown>; | |
| 227 | + if (!Array.isArray(b.checks) || !b.checks.length || b.checks.length > 30) throw new HttpError(422, "checks must be a list of 1 to 30 { name, ok, message }"); | |
| 228 | + const checks = b.checks.map((c) => { | |
| 229 | + const x = (c && typeof c === "object" ? c : {}) as Record<string, unknown>; | |
| 230 | + if (typeof x.name !== "string" || !x.name.trim() || typeof x.ok !== "boolean") throw new HttpError(422, "each check needs a name and ok (true or false)"); | |
| 231 | + return { name: x.name.trim().slice(0, 100), ok: x.ok, message: typeof x.message === "string" ? x.message.slice(0, 500) : "" }; | |
| 232 | + }); | |
| 233 | + const cliVersion = typeof b.cli_version === "string" ? b.cli_version.slice(0, 40) : null; | |
| 234 | + // Green only when every line passed, whatever the script says overall. | |
| 235 | + return { ok: checks.every((c) => c.ok), checks, cliVersion }; | |
| 236 | +} |
modifiedapps/diffinery/src/lib/directory.ts+9−0View file
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import { inviteProof } from "../auth/invite-proof.js"; |
| 9 | 9 | import { checkRepositoryQuota } from "./quotas.js"; |
| 10 | 10 | import { blockSubjects, isBlocked } from "./moderation.js"; |
| 11 | +import { agentRepoId } from "./agent-setup.js"; | |
| 11 | 12 | |
| 12 | 13 | export interface Account { |
| 13 | 14 | id: string; |
| @@ -867,6 +868,11 @@ | ||
| 867 | 868 | const [ids, key] = "repoId" in which ? ["?", which.repoId] : ["SELECT id FROM repos WHERE owner_id = ?", which.ownerId]; |
| 868 | 869 | return [ |
| 869 | 870 | this.db.prepare(`DELETE FROM repo_access WHERE repo_id IN (${ids})`).bind(key), |
| 871 | + // Agent setup's agents (lib/agent-setup.ts) could only ever reach this repository: they stop for good. | |
| 872 | + this.db.prepare(`UPDATE tokens SET revoked_at = ? WHERE revoked_at IS NULL AND account_id IN (SELECT agent_id FROM agent_setups WHERE repo_id IN (${ids}))`).bind(now(), key), | |
| 873 | + this.db.prepare(`UPDATE accounts SET status = 'disabled' WHERE id IN (SELECT agent_id FROM agent_setups WHERE repo_id IN (${ids}))`).bind(key), | |
| 874 | + this.db.prepare(`DELETE FROM agent_setups WHERE repo_id IN (${ids})`).bind(key), | |
| 875 | + this.db.prepare(`DELETE FROM agent_setup_checks WHERE repo_id IN (${ids})`).bind(key), | |
| 870 | 876 | // Invitations reference the repository without ON DELETE CASCADE, so they'd block the delete. |
| 871 | 877 | this.db.prepare(`DELETE FROM invites WHERE repo_id IN (${ids})`).bind(key), |
| 872 | 878 | this.db.prepare(`DELETE FROM schedules WHERE repo_id IN (${ids})`).bind(key), |
| @@ -1125,6 +1131,9 @@ | ||
| 1125 | 1131 | if (account.status !== "active") return null; |
| 1126 | 1132 | if (account.kind === "agent") { |
| 1127 | 1133 | if (!account.owner_id) return null; |
| 1134 | + // An agent from Agent setup reaches its one repository and nothing else, whatever token it uses. | |
| 1135 | + const only = agentRepoId(account); | |
| 1136 | + if (only !== null && only !== repo.id) return null; | |
| 1128 | 1137 | const owner = await this.account(account.owner_id); |
| 1129 | 1138 | if (!owner || owner.status !== "active") return null; |
| 1130 | 1139 | const ownerRole = owner.kind === "org" ? (repo.owner_id === owner.id ? "admin" : null) : await this.roleFor(owner, repo); |
modifiedapps/diffinery/src/repo/auto-merge.ts+20−2View file
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import type { Actor } from "@diffinery/shared"; |
| 2 | 2 | import type { Env } from "../env.js"; |
| 3 | +import { agentMayMerge } from "../lib/agent-setup.js"; | |
| 3 | 4 | import { Directory } from "../lib/directory.js"; |
| 4 | 5 | import { HttpError, now } from "../lib/util.js"; |
| 5 | 6 | import type { RepoDO } from "./RepoDO.js"; |
| @@ -60,10 +61,11 @@ | ||
| 60 | 61 | * one required check, so CI gates what lands. Workflow tokens are held to the same rule even |
| 61 | 62 | * when the run's code didn't come from an agent. |
| 62 | 63 | */ |
| 63 | -export function automaticLandingRefusal(caller: Pick<Caller, "id" | "kind">, rules: ProtectionRules | null, isDefault: boolean, base: string): { status: 403 | 422; message: string } | null { | |
| 64 | +export function automaticLandingRefusal(caller: Pick<Caller, "id" | "kind" | "mayMerge">, rules: ProtectionRules | null, isDefault: boolean, base: string): { status: 403 | 422; message: string } | null { | |
| 64 | 65 | if (isPerson(caller)) return null; |
| 65 | 66 | if (!rules && !isDefault) return null; |
| 66 | 67 | if (caller.kind === "agent" && rules?.agentsMayMerge) return null; |
| 68 | + if (mergesByAgentPermission(caller, rules, isDefault, true)) return null; | |
| 67 | 69 | const who = isJobCaller(caller) ? "workflow tokens" : "agents"; |
| 68 | 70 | if (!rules?.allowAgentAutoMerge) { |
| 69 | 71 | return { status: 403, message: `${who} may not have pull requests merged into ${base} automatically unless its branch protection allows it (allowAgentAutoMerge, set by a repository admin); a person must merge` }; |
| @@ -74,6 +76,22 @@ | ||
| 74 | 76 | return null; |
| 75 | 77 | } |
| 76 | 78 | |
| 79 | +/** | |
| 80 | + * Whether an agent merges into a branch, by hand or through auto-merge and the merge queue, on its own | |
| 81 | + * policy's permission (`mayMerge`, from Agent setup: lib/agent-setup.ts) rather than a rule of the | |
| 82 | + * branch. The rules of the branch still all apply, and on top of them a person must have approved the | |
| 83 | + * pull request's latest commit, and a change to a workflow file needs a person to merge it. | |
| 84 | + * Never a workflow token's, whatever kind it carries. | |
| 85 | + */ | |
| 86 | +export function mergesByAgentPermission(caller: Pick<Caller, "id" | "kind" | "mayMerge">, rules: ProtectionRules | null, isDefault: boolean, automatic: boolean): boolean { | |
| 87 | + if (caller.kind !== "agent" || isJobCaller(caller) || !caller.mayMerge) return false; | |
| 88 | + // Where agents merge anyway: a branch that is neither protected nor the default one, or one whose rules let them. | |
| 89 | + if (!rules && !isDefault) return false; | |
| 90 | + if (rules?.agentsMayMerge) return false; | |
| 91 | + if (automatic && rules?.allowAgentAutoMerge && rules.requiredChecks?.length) return false; | |
| 92 | + return true; | |
| 93 | +} | |
| 94 | + | |
| 77 | 95 | /** Whether an automatic landing by `caller` relies on `allowAgentAutoMerge` (rather than being a person's, or allowed anyway). */ |
| 78 | 96 | export function landsByAgentRule(caller: Pick<Caller, "id" | "kind">, rules: ProtectionRules | null, isDefault: boolean): boolean { |
| 79 | 97 | return !isPerson(caller) && (!!rules || isDefault) && !(caller.kind === "agent" && rules?.agentsMayMerge); |
| @@ -297,7 +315,7 @@ | ||
| 297 | 315 | if (!role) return null; |
| 298 | 316 | const kind = account.kind === "agent" ? "agent" : "user"; |
| 299 | 317 | const onBehalfOf = kind === "agent" && account.owner_id ? (await dir.account(account.owner_id))?.login : undefined; |
| 300 | - return { id: account.id, login: account.login, kind, ...(onBehalfOf ? { onBehalfOf } : {}), role: minRole(role, s.role) }; | |
| 318 | + return { id: account.id, login: account.login, kind, ...(onBehalfOf ? { onBehalfOf } : {}), role: minRole(role, s.role), ...(agentMayMerge(account) ? { mayMerge: true } : {}) }; | |
| 301 | 319 | } |
| 302 | 320 | |
| 303 | 321 | private event(number: number, action: "auto_merge_enabled" | "auto_merge_disabled", by: Caller): void { |
modifiedapps/diffinery/src/repo/collab.ts+12−6View file
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | import { atLeast, refuseBlocked, type Caller } from "./types.js"; |
| 10 | 10 | import { COMMENT_SELECT } from "./social.js"; |
| 11 | 11 | import { requiredCheck } from "./required.js"; |
| 12 | -import { AutoMerge, automaticLandingRefusal, isPerson, landsByAgentRule, type AutoMergeInfo } from "./auto-merge.js"; | |
| 12 | +import { AutoMerge, automaticLandingRefusal, isPerson, landsByAgentRule, mergesByAgentPermission, type AutoMergeInfo } from "./auto-merge.js"; | |
| 13 | 13 | |
| 14 | 14 | type Sig = { name: string; email: string; timestamp: number; timezone: string }; |
| 15 | 15 | |
| @@ -893,7 +893,9 @@ | ||
| 893 | 893 | // approved: nobody merges by hand to notice that the agent pushed again after a person approved. |
| 894 | 894 | const isDefaultBase = p.base_ref === this.repo.meta.defaultBranch; |
| 895 | 895 | const agentLanding = !!opts.automatic && !!caller && landsByAgentRule(caller, this.repo.protectionFor(p.base_ref), isDefaultBase); |
| 896 | - const currentOnly = !!rules.dismissStaleReviews || agentLanding; | |
| 896 | + // An agent merging on its own policy's permission (Agent setup): a person approved its latest commit. | |
| 897 | + const byPermission = !!caller && mergesByAgentPermission(caller, this.repo.protectionFor(p.base_ref), isDefaultBase, !!opts.automatic); | |
| 898 | + const currentOnly = !!rules.dismissStaleReviews || agentLanding || byPermission; | |
| 897 | 899 | const latest = new Map<string, Review>(); |
| 898 | 900 | // Imported reviews are history, not votes, whoever authored them; and so are plain comments. |
| 899 | 901 | for (const rv of this.reviews(number)) if (rv.state !== "COMMENTED" && rv.author.kind !== "github" && !rv.imported) latest.set(rv.author.id, rv); |
| @@ -906,17 +908,18 @@ | ||
| 906 | 908 | // Commit statuses count as checks too: a required check matches a status context. |
| 907 | 909 | const checks = [...this.checks(p.head_sha), ...this.repo.statuses.asChecks(p.head_sha)]; |
| 908 | 910 | result.checks = checks.map((c) => ({ name: c.name, state: c.status === "completed" ? (c.conclusion ?? "neutral") : c.status })); |
| 909 | - if (agentLanding && rules.agentAutoMergeWithoutApproval) { | |
| 911 | + if (agentLanding && !byPermission && rules.agentAutoMergeWithoutApproval) { | |
| 910 | 912 | // An admin waived approvals for what Diffinery lands for agents here: green required checks |
| 911 | 913 | // are enough (allowAgentAutoMerge always has some). Requested changes still block it below. |
| 912 | 914 | result.requiredApprovals = 0; |
| 913 | - } else if (r.author_kind === "agent" && result.requiredApprovals < 1) { | |
| 914 | - // Agent-authored changes always need one human approval unless a rule says otherwise. | |
| 915 | + } else if ((r.author_kind === "agent" || byPermission) && result.requiredApprovals < 1) { | |
| 916 | + // Agent-authored changes always need one human approval unless a rule says otherwise, and so does | |
| 917 | + // whatever an agent merges on its own permission. | |
| 915 | 918 | result.requiredApprovals = 1; |
| 916 | 919 | } |
| 917 | 920 | if (p.draft) result.blockers.push("pull request is a draft"); |
| 918 | 921 | if (result.conflicts.length) result.blockers.push(`merge conflicts in ${result.conflicts.length} file(s)`); |
| 919 | - if (result.approvals < result.requiredApprovals) result.blockers.push(`needs ${result.requiredApprovals - result.approvals} more approving review(s)${agentLanding ? " of its latest commit" : ""}`); | |
| 922 | + if (result.approvals < result.requiredApprovals) result.blockers.push(`needs ${result.requiredApprovals - result.approvals} more approving review(s)${agentLanding || byPermission ? " of its latest commit" : ""}`); | |
| 920 | 923 | if (result.changesRequested.length) result.blockers.push(`changes requested by ${result.changesRequested.join(", ")}`); |
| 921 | 924 | for (const name of rules.requiredChecks ?? []) { |
| 922 | 925 | const c = requiredCheck(name, checks, rules.requiredCheckSources?.[name]); |
| @@ -940,6 +943,9 @@ | ||
| 940 | 943 | const path = await this.repo.policy.changedWorkflow(mb ?? ZERO_OID, p.head_sha); |
| 941 | 944 | if (path) result.blockers.push(`the pull request changes workflow file \`${path}\`; a person must merge it`); |
| 942 | 945 | } |
| 946 | + } else if (byPermission) { | |
| 947 | + const path = await this.repo.policy.changedWorkflow(mb ?? ZERO_OID, p.head_sha); | |
| 948 | + if (path) result.blockers.push(`the pull request changes workflow file \`${path}\`; a person must merge it`); | |
| 943 | 949 | } else if (caller.kind === "agent" && (isDefault || Object.keys(rules).length) && !rules.agentsMayMerge) result.blockers.push("agents may not merge into this branch; a human must merge"); |
| 944 | 950 | } |
| 945 | 951 | result.mergeable = result.blockers.length === 0; |
modifiedapps/diffinery/src/repo/types.ts+5−0View file
| @@ -16,6 +16,11 @@ | ||
| 16 | 16 | importId?: string; |
| 17 | 17 | /** The repository owner's login when it blocked this caller (lib/moderation.ts): they read, but can't take part. */ |
| 18 | 18 | blockedBy?: string; |
| 19 | + /** | |
| 20 | + * An agent whose own policy lets it merge (Agent setup, lib/agent-setup.ts): it may merge, or have | |
| 21 | + * Diffinery land, a pull request a person approved, where the branch's rules alone wouldn't let it. | |
| 22 | + */ | |
| 23 | + mayMerge?: boolean; | |
| 19 | 24 | } |
| 20 | 25 | |
| 21 | 26 | /** |
addedapps/diffinery/src/web/agent-setup.tsx+433−0View file
| @@ -1,0 +1,433 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import type { FC } from "hono/jsx"; | |
| 3 | +import { requireAccount, requireBrowserSession, requireRecentSignIn, type AppContext } from "../auth/principal.js"; | |
| 4 | +import { loadRepo } from "../http/common.js"; | |
| 5 | +import { AGENT_SETUP_DAYS, AgentSetup, agentMayMerge, defaultTokenName, type AgentSetupToken, type SetupCheck } from "../lib/agent-setup.js"; | |
| 6 | +import type { Account } from "../lib/directory.js"; | |
| 7 | +import { HttpError } from "../lib/util.js"; | |
| 8 | +import { atLeast } from "../repo/types.js"; | |
| 9 | +import { csrfToken } from "./csrf.js"; | |
| 10 | +import { KCheckbox } from "./kumo.js"; | |
| 11 | +import { page as settingsPage } from "./repo-settings.js"; | |
| 12 | +import { CopyButton, Form, Time, act, prm, post, publicUrl, repoUrl, type WebApp } from "./ui.js"; | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * Settings → Agent setup (lib/agent-setup.ts): a token that reaches only this repository, and the three | |
| 16 | + * boxes a cloud agent environment needs (Network, Setup script, Environment variables), in the order | |
| 17 | + * Claude Code's "Edit cloud environment" dialog asks for them, then the Check the setup script reports. | |
| 18 | + * For people with write access, in a browser: no token sees this page or mints one. | |
| 19 | + */ | |
| 20 | + | |
| 21 | +type Loaded = Awaited<ReturnType<typeof loadRepo>>; | |
| 22 | + | |
| 23 | +/** The repository, for a person in a browser with write access. */ | |
| 24 | +async function load(c: AppContext): Promise<Loaded & { me: Account }> { | |
| 25 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); | |
| 26 | + const p = c.get("principal"); | |
| 27 | + if (l.caller.kind !== "user" || p.type !== "account" || (p.via !== "session" && p.via !== "dev")) throw new HttpError(403, "Agent setup is for people signed in to Diffinery in a browser"); | |
| 28 | + return { ...l, me: requireAccount(c) }; | |
| 29 | +} | |
| 30 | + | |
| 31 | +const date = (at: string) => new Date(at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" }); | |
| 32 | + | |
| 33 | +/** One copyable box: the value as it is pasted, and a Copy button when it's complete. */ | |
| 34 | +const Paste: FC<{ value: string; copy?: boolean; id?: string }> = ({ value, copy = true, id }) => ( | |
| 35 | + <div class="row" style="align-items:flex-start;gap:8px"> | |
| 36 | + <pre class="cmd one grow" id={id} style="white-space:pre-wrap"> | |
| 37 | + {value} | |
| 38 | + </pre> | |
| 39 | + {copy ? <CopyButton text={value} size="base" /> : null} | |
| 40 | + </div> | |
| 41 | +); | |
| 42 | + | |
| 43 | +export function envBlock(o: { token: string | null; prefix: string | null; host: string; slug: string; url: string }): string { | |
| 44 | + const shown = o.token ?? (o.prefix ? `${o.prefix}…` : "<token>"); | |
| 45 | + return [`DIFFINERY_TOKEN=${shown}`, `GH_ENTERPRISE_TOKEN=${shown}`, `GH_HOST=${o.host}`, `DIFFINERY_REPO=${o.slug}`, ...(o.url === "https://diffinery.com" ? [] : [`DIFFINERY_URL=${o.url}`])].join("\n"); | |
| 46 | +} | |
| 47 | + | |
| 48 | +/** The Check: green once a session's setup script passed every check. */ | |
| 49 | +export const CheckStatus: FC<{ check: SetupCheck | null }> = ({ check }) => { | |
| 50 | + if (!check) { | |
| 51 | + return ( | |
| 52 | + <div class="flash info" style="margin:0"> | |
| 53 | + <b>Not connected yet.</b> This turns green when a session runs the setup script and every check passes. | |
| 54 | + </div> | |
| 55 | + ); | |
| 56 | + } | |
| 57 | + const by = ( | |
| 58 | + <> | |
| 59 | + {check.account ? <b>{check.account.login}</b> : "a deleted account"} | |
| 60 | + {check.token ? ( | |
| 61 | + <> | |
| 62 | + {" "} | |
| 63 | + with the token <b>{check.token.name}</b> (<span class="mono">{check.token.prefix}…</span>) | |
| 64 | + </> | |
| 65 | + ) : null} | |
| 66 | + </> | |
| 67 | + ); | |
| 68 | + const failed = check.checks.filter((l) => !l.ok); | |
| 69 | + return ( | |
| 70 | + <div> | |
| 71 | + {check.ok ? ( | |
| 72 | + <div class="flash notice" style="margin:0 0 8px"> | |
| 73 | + <b>Connected.</b> The setup script passed every check <Time at={check.at} />, run by {by}. | |
| 74 | + </div> | |
| 75 | + ) : ( | |
| 76 | + <div class="flash error" style="margin:0 0 8px"> | |
| 77 | + <b>The last check failed</b> <Time at={check.at} />, run by {by}. Fix what it says and start a new session, or run the setup script again. | |
| 78 | + {check.firstOkAt ? ( | |
| 79 | + <> | |
| 80 | + {" "} | |
| 81 | + It first passed <Time at={check.firstOkAt} />. | |
| 82 | + </> | |
| 83 | + ) : null} | |
| 84 | + </div> | |
| 85 | + )} | |
| 86 | + <ul class="small" style="margin:0;padding-left:18px"> | |
| 87 | + {(failed.length ? failed : check.checks).map((l) => ( | |
| 88 | + <li> | |
| 89 | + <span class={l.ok ? "ok" : "bad"}>{l.ok ? "✓" : "✗"}</span> <b>{l.name}</b>: {l.message} | |
| 90 | + </li> | |
| 91 | + ))} | |
| 92 | + </ul> | |
| 93 | + {check.cliVersion ? <div class="hint">CLI {check.cliVersion}</div> : null} | |
| 94 | + </div> | |
| 95 | + ); | |
| 96 | +}; | |
| 97 | + | |
| 98 | +/** Polls the Check while the page is open, so it turns green without a reload. */ | |
| 99 | +const POLL = `(()=>{const el=document.getElementById('agent-check');if(!el)return;let n=0;const tick=async()=>{if(document.hidden||n++>360)return;try{const r=await fetch(el.dataset.src,{credentials:'same-origin'});if(r.ok){const h=await r.text();if(h!==el.innerHTML)el.innerHTML=h}}catch(_){}};setInterval(tick,10000)})();`; | |
| 100 | + | |
| 101 | +const TokenRow: FC<{ c: AppContext; base: string; t: AgentSetupToken }> = ({ base, t, c }) => { | |
| 102 | + const soon = Date.parse(t.expiresAt) - Date.now() < 14 * 86400_000; | |
| 103 | + return ( | |
| 104 | + <tr> | |
| 105 | + <td> | |
| 106 | + <b>{t.name}</b> | |
| 107 | + <div class="small muted mono">{t.prefix}…</div> | |
| 108 | + </td> | |
| 109 | + <td class={soon ? "warn" : ""}> | |
| 110 | + {date(t.expiresAt)} | |
| 111 | + <div class="small muted">created {date(t.createdAt)}</div> | |
| 112 | + </td> | |
| 113 | + <td class="small">{t.lastUsedAt ? <Time at={t.lastUsedAt} /> : <span class="muted">never</span>}</td> | |
| 114 | + <td style="text-align:right;white-space:nowrap"> | |
| 115 | + <Form action={`${base}/tokens/${t.id}/renew`} t={csrfToken(c)} class="inline"> | |
| 116 | + <button class="btn sm" title={`Keep this token working for ${AGENT_SETUP_DAYS} days from today; nothing to paste again`}> | |
| 117 | + Renew | |
| 118 | + </button> | |
| 119 | + </Form>{" "} | |
| 120 | + <Form action={`${base}/tokens/${t.id}/regenerate`} t={csrfToken(c)} class="inline" confirm="Revoke this token and make a new one? Sessions using it stop working until you paste the new one."> | |
| 121 | + <button class="btn sm">Revoke and regenerate</button> | |
| 122 | + </Form>{" "} | |
| 123 | + <Form action={`${base}/tokens/${t.id}/revoke`} t={csrfToken(c)} class="inline" confirm="Revoke this token? Environments using it stop working."> | |
| 124 | + <button class="btn sm danger">Revoke</button> | |
| 125 | + </Form> | |
| 126 | + </td> | |
| 127 | + </tr> | |
| 128 | + ); | |
| 129 | +}; | |
| 130 | + | |
| 131 | +async function agentSetupPage(c: AppContext, l: Loaded & { me: Account }, created?: { token: string; name: string; prefix: string }) { | |
| 132 | + const setup = new AgentSetup(c.env, c.get("dir")); | |
| 133 | + const { repo, me } = l; | |
| 134 | + const slug = `${repo.owner_login}/${repo.name}`; | |
| 135 | + const url = publicUrl(c); | |
| 136 | + const host = new URL(url).host; | |
| 137 | + const base = `${repoUrl(repo)}/settings/agent-setup`; | |
| 138 | + const admin = atLeast(l.caller.role, "admin"); | |
| 139 | + const agent = await setup.agentOf(repo, me); | |
| 140 | + const active = agent?.status === "active" ? agent : null; | |
| 141 | + const [tokens, check, everyone] = await Promise.all([active ? setup.tokens(active) : Promise.resolve([]), setup.check(repo), admin ? setup.agents(repo) : Promise.resolve([])]); | |
| 142 | + const others = await Promise.all(everyone.filter((x) => x.person?.id !== me.id && x.agent.status === "active").map(async (x) => ({ ...x, tokens: await setup.tokens(x.agent) }))); | |
| 143 | + const t = csrfToken(c); | |
| 144 | + const latest = tokens[0]; | |
| 145 | + const env = envBlock({ token: created?.token ?? null, prefix: created ? null : (latest?.prefix ?? null), host, slug, url }); | |
| 146 | + const script = `curl -fsSL ${url}/${slug}/agent-setup.sh | bash`; | |
| 147 | + return settingsPage( | |
| 148 | + c, | |
| 149 | + l, | |
| 150 | + "agent-setup", | |
| 151 | + <> | |
| 152 | + <h1>Agent setup</h1> | |
| 153 | + <p class="muted"> | |
| 154 | + Everything a cloud agent environment needs to work on <b>{slug}</b> from its first session: a token that reaches only this repository, and three boxes to paste into the environment's settings. In a Claude Code cloud environment they are the <b>Network access</b>, <b>Setup script</b> and <b>Environment variables</b> fields of | |
| 155 | + its Edit cloud environment dialog; Codex and other agent platforms have the same three. Then git, gh and the <code>diffinery</code> CLI work in every session. See <a href="/docs/connect/cloud-agents">Cloud agent environments</a>. | |
| 156 | + </p> | |
| 157 | + | |
| 158 | + <div class="box"> | |
| 159 | + <div class="head"> | |
| 160 | + <h2>1. Token</h2> | |
| 161 | + </div> | |
| 162 | + <div class="body"> | |
| 163 | + {created ? ( | |
| 164 | + <div class="flash warn"> | |
| 165 | + <b>Copy it now:</b> the new token <b>{created.name}</b> is filled into Environment variables below, and it won't be shown again. | |
| 166 | + </div> | |
| 167 | + ) : null} | |
| 168 | + {active ? ( | |
| 169 | + <p class="small"> | |
| 170 | + Acts as the agent <b>{active.login}</b>, which only you control and which can only reach {slug}: read and write code (protected branches still apply, and it opens pull requests instead of pushing to {repo.default_branch}), pull requests and issues, and re-running workflows. No secrets or settings, and never more than | |
| 171 | + you can do. Each token lasts {AGENT_SETUP_DAYS} days. | |
| 172 | + </p> | |
| 173 | + ) : ( | |
| 174 | + <p class="small"> | |
| 175 | + Generating a token also makes your agent for this repository: an agent account that only you control, that can reach {slug} and nothing else, and that can never do more than you. Tokens last {AGENT_SETUP_DAYS} days. | |
| 176 | + </p> | |
| 177 | + )} | |
| 178 | + {tokens.length ? ( | |
| 179 | + <table class="tbl mb"> | |
| 180 | + <thead> | |
| 181 | + <tr> | |
| 182 | + <th>Token</th> | |
| 183 | + <th>Expires</th> | |
| 184 | + <th>Last used</th> | |
| 185 | + <th /> | |
| 186 | + </tr> | |
| 187 | + </thead> | |
| 188 | + <tbody> | |
| 189 | + {tokens.map((k) => ( | |
| 190 | + <TokenRow c={c} base={base} t={k} /> | |
| 191 | + ))} | |
| 192 | + </tbody> | |
| 193 | + </table> | |
| 194 | + ) : null} | |
| 195 | + <Form action={`${base}/generate`} t={t} class="row" style="align-items:flex-end"> | |
| 196 | + <div class="field grow m0"> | |
| 197 | + <label for="tname">Label</label> | |
| 198 | + <input id="tname" name="name" type="text" maxlength={100} value={defaultTokenName()} /> | |
| 199 | + <div class="hint">Shown in the token list and in audit logs, so you can tell environments apart.</div> | |
| 200 | + </div> | |
| 201 | + <button class="btn primary">{tokens.length ? "Generate another token" : "Generate agent token"}</button> | |
| 202 | + </Form> | |
| 203 | + {active ? ( | |
| 204 | + <Form action={`${base}/merge`} t={t} class="row mt" style="align-items:center"> | |
| 205 | + <KCheckbox name="mayMerge" checked={agentMayMerge(active)} label="Can merge pull requests" /> | |
| 206 | + <button class="btn sm">Save</button> | |
| 207 | + <span class="hint" style="margin:0"> | |
| 208 | + It merges, or turns on auto-merge, only once a person approved the pull request's latest commit and every rule of the branch is met. A change to a workflow file always needs a person to merge. | |
| 209 | + </span> | |
| 210 | + </Form> | |
| 211 | + ) : null} | |
| 212 | + </div> | |
| 213 | + </div> | |
| 214 | + | |
| 215 | + <div class="box"> | |
| 216 | + <div class="head"> | |
| 217 | + <h2>2. Network</h2> | |
| 218 | + </div> | |
| 219 | + <div class="body"> | |
| 220 | + <Paste value={host} /> | |
| 221 | + <p class="small muted mt"> | |
| 222 | + The domain the environment must reach (the container registry is on it too). In Claude Code, set <b>Network access</b> to <b>Custom</b>, add it under <b>Allowed domains</b> and keep <b>Also include default list of common package managers</b> ticked; or choose <b>Full</b>. The default <b>Trusted</b> level | |
| 223 | + doesn't include it. | |
| 224 | + </p> | |
| 225 | + </div> | |
| 226 | + </div> | |
| 227 | + | |
| 228 | + <div class="box"> | |
| 229 | + <div class="head"> | |
| 230 | + <h2>3. Setup script</h2> | |
| 231 | + </div> | |
| 232 | + <div class="body"> | |
| 233 | + <Paste value={script} /> | |
| 234 | + <p class="small muted mt"> | |
| 235 | + Installs the <code>diffinery</code> CLI on PATH, makes git and gh use the token from the environment (it never writes the token to disk or prints it), clones {slug} unless it's there (into <code>/home/user/{repo.name}</code> in Claude Code; set <code>DIFFINERY_NO_CLONE=1</code> to skip), then checks that | |
| 236 | + everything works and reports to the Check below. Safe to run every session, and quick once the CLI is installed. <a href={`/${slug}/agent-setup.sh`}>Read the script</a>. | |
| 237 | + </p> | |
| 238 | + </div> | |
| 239 | + </div> | |
| 240 | + | |
| 241 | + <div class="box"> | |
| 242 | + <div class="head"> | |
| 243 | + <h2>4. Environment variables</h2> | |
| 244 | + </div> | |
| 245 | + <div class="body"> | |
| 246 | + <Paste value={env} copy={!!created} id="agent-env" /> | |
| 247 | + <p class="small muted mt"> | |
| 248 | + {created | |
| 249 | + ? "Paste these lines as they are. The token is in two of them: the CLI and git read DIFFINERY_TOKEN, gh reads GH_ENTERPRISE_TOKEN, and GH_HOST points gh at Diffinery." | |
| 250 | + : tokens.length | |
| 251 | + ? "The token is shown only once, so it's masked here. Paste the lines with your token in both places, or choose Revoke and regenerate above to get a new one filled in." | |
| 252 | + : "Generate a token above and it's filled in here."} | |
| 253 | + </p> | |
| 254 | + </div> | |
| 255 | + </div> | |
| 256 | + | |
| 257 | + <div class="box"> | |
| 258 | + <div class="head"> | |
| 259 | + <h2>5. Check</h2> | |
| 260 | + </div> | |
| 261 | + <div class="body" id="agent-check" data-src={`${base}/check`}> | |
| 262 | + <CheckStatus check={check} /> | |
| 263 | + </div> | |
| 264 | + </div> | |
| 265 | + | |
| 266 | + <div class="box"> | |
| 267 | + <div class="head"> | |
| 268 | + <h2>Keep the token safe</h2> | |
| 269 | + </div> | |
| 270 | + <div class="body small"> | |
| 271 | + <ul style="margin:0;padding-left:18px"> | |
| 272 | + <li>Environment variables are visible to anyone who uses that environment, so treat the token as shared with them.</li> | |
| 273 | + <li>Never paste the token into a chat or a screenshot. If it leaks, choose Revoke and regenerate: the old one stops working at once.</li> | |
| 274 | + <li> | |
| 275 | + Claude Code's <b>Network secrets</b> keep a credential out of the session, but they don't reach setup scripts, and gh won't run without a token of its own, so this page uses environment variables. | |
| 276 | + </li> | |
| 277 | + </ul> | |
| 278 | + </div> | |
| 279 | + </div> | |
| 280 | + | |
| 281 | + <details class="box"> | |
| 282 | + <summary class="head" style="cursor:pointer"> | |
| 283 | + <h2>Advanced</h2> | |
| 284 | + </summary> | |
| 285 | + <div class="body"> | |
| 286 | + <h3>MCP server</h3> | |
| 287 | + <p class="small muted"> | |
| 288 | + For an agent that speaks MCP, the same token in its MCP settings (what <code>diffinery mcp-config</code> prints; clients such as Claude Code fill in <code>{"${DIFFINERY_TOKEN}"}</code> from the environment): | |
| 289 | + </p> | |
| 290 | + <Paste value={JSON.stringify({ mcpServers: { diffinery: { type: "http", url: `${url}/mcp`, headers: { Authorization: "Bearer ${DIFFINERY_TOKEN}" } } } }, null, 2)} /> | |
| 291 | + <h3 class="mt">Without the hosted script</h3> | |
| 292 | + <p class="small muted">The same steps by hand, with the environment variables above set:</p> | |
| 293 | + <Paste | |
| 294 | + value={[ | |
| 295 | + `curl -fsSL ${url}/install | DIFFINERY_NO_MODIFY_PATH=1 bash`, | |
| 296 | + `ln -sf "$HOME/.diffinery/bin/diffinery" /usr/local/bin/diffinery`, | |
| 297 | + `diffinery auth setup-git`, | |
| 298 | + `git clone ${url}/${slug}.git`, | |
| 299 | + `gh api user --hostname ${host} --jq .login`, | |
| 300 | + `diffinery setup --repo ${slug} # the self-test and the Check`, | |
| 301 | + ].join("\n")} | |
| 302 | + /> | |
| 303 | + </div> | |
| 304 | + </details> | |
| 305 | + | |
| 306 | + {admin && others.length ? ( | |
| 307 | + <div class="box"> | |
| 308 | + <div class="head"> | |
| 309 | + <h2>Other people's agents here</h2> | |
| 310 | + </div> | |
| 311 | + <div class="body small muted">As an admin you can turn off anyone's agent for this repository, which revokes its tokens at once.</div> | |
| 312 | + <ul class="list"> | |
| 313 | + {others.map((x) => ( | |
| 314 | + <li class="row"> | |
| 315 | + <b>{x.agent.login}</b> | |
| 316 | + <span class="muted small"> | |
| 317 | + {x.person?.login ?? "?"}'s · {x.tokens.length} live token{x.tokens.length === 1 ? "" : "s"} | |
| 318 | + {x.tokens[0]?.lastUsedAt ? ( | |
| 319 | + <> | |
| 320 | + {" "} | |
| 321 | + · last used <Time at={x.tokens[0].lastUsedAt} /> | |
| 322 | + </> | |
| 323 | + ) : null} | |
| 324 | + </span> | |
| 325 | + <span class="grow" /> | |
| 326 | + <Form action={`${base}/agents/${x.agent.id}/disable`} t={t} class="inline" confirm={`Turn off ${x.agent.login}? Its tokens stop working at once.`}> | |
| 327 | + <button class="btn sm danger">Turn off</button> | |
| 328 | + </Form> | |
| 329 | + </li> | |
| 330 | + ))} | |
| 331 | + </ul> | |
| 332 | + </div> | |
| 333 | + ) : null} | |
| 334 | + </>, | |
| 335 | + undefined, | |
| 336 | + POLL, | |
| 337 | + ); | |
| 338 | +} | |
| 339 | + | |
| 340 | +function audit(c: AppContext, l: Loaded & { me: Account }, action: string, detail: Record<string, unknown>): void { | |
| 341 | + c.executionCtx.waitUntil( | |
| 342 | + c | |
| 343 | + .get("dir") | |
| 344 | + .audit({ actorId: l.me.id, actorLogin: l.me.login, action, target: `${l.repo.owner_login}/${l.repo.name}`, detail }) | |
| 345 | + .catch((e) => console.error("audit failed", e)), | |
| 346 | + ); | |
| 347 | +} | |
| 348 | + | |
| 349 | +export function registerAgentSetup(web: WebApp): void { | |
| 350 | + web.get("/:owner/:repo/settings/agent-setup", async (c) => agentSetupPage(c, await load(c))); | |
| 351 | + | |
| 352 | + // The Check alone, for the page's polling. | |
| 353 | + web.get("/:owner/:repo/settings/agent-setup/check", async (c) => { | |
| 354 | + const l = await load(c); | |
| 355 | + const check = await new AgentSetup(c.env, c.get("dir")).check(l.repo); | |
| 356 | + c.header("cache-control", "no-store"); | |
| 357 | + return c.html(<CheckStatus check={check} />); | |
| 358 | + }); | |
| 359 | + | |
| 360 | + // Minting a credential: a person signed in with a passkey in the last 2 hours (as for any token). | |
| 361 | + post(web, "/:owner/:repo/settings/agent-setup/generate", async (c, f) => { | |
| 362 | + const l = await load(c); | |
| 363 | + requireBrowserSession(c); | |
| 364 | + requireRecentSignIn(c, `${repoUrl(l.repo)}/settings/agent-setup`); | |
| 365 | + const setup = new AgentSetup(c.env, c.get("dir")); | |
| 366 | + const { agent, created } = await setup.ensureAgent(l.repo, l.me); | |
| 367 | + const name = f.s("name").trim() || defaultTokenName(); | |
| 368 | + const t = await setup.mintToken(agent, l.repo, name); | |
| 369 | + audit(c, l, "agent_setup.token.create", { agent: agent.login, token: name, agent_created: created }); | |
| 370 | + return agentSetupPage(c, l, { token: t.token, name, prefix: t.token.slice(0, 10) }); | |
| 371 | + }); | |
| 372 | + | |
| 373 | + const mine = async (c: AppContext) => { | |
| 374 | + const l = await load(c); | |
| 375 | + const setup = new AgentSetup(c.env, c.get("dir")); | |
| 376 | + const agent = await setup.agentOf(l.repo, l.me); | |
| 377 | + if (!agent || agent.status !== "active") throw new HttpError(404, "you have no agent for this repository yet"); | |
| 378 | + const tokenId = c.req.param("id"); | |
| 379 | + if (tokenId && !(await setup.tokens(agent)).some((k) => k.id === tokenId)) throw new HttpError(404, "token not found"); | |
| 380 | + return { l, setup, agent, tokenId }; | |
| 381 | + }; | |
| 382 | + | |
| 383 | + post(web, "/:owner/:repo/settings/agent-setup/tokens/:id/revoke", async (c) => { | |
| 384 | + const { l, setup, agent, tokenId } = await mine(c); | |
| 385 | + return act(c, `${repoUrl(l.repo)}/settings/agent-setup`, async () => { | |
| 386 | + await setup.revoke(agent, tokenId!); | |
| 387 | + audit(c, l, "agent_setup.token.revoke", { agent: agent.login, token_id: tokenId }); | |
| 388 | + }, "Token revoked."); | |
| 389 | + }); | |
| 390 | + | |
| 391 | + post(web, "/:owner/:repo/settings/agent-setup/tokens/:id/regenerate", async (c) => { | |
| 392 | + requireBrowserSession(c); | |
| 393 | + const { l, setup, agent, tokenId } = await mine(c); | |
| 394 | + requireRecentSignIn(c, `${repoUrl(l.repo)}/settings/agent-setup`); | |
| 395 | + const old = (await setup.tokens(agent)).find((k) => k.id === tokenId)!; | |
| 396 | + await setup.revoke(agent, tokenId!); | |
| 397 | + const t = await setup.mintToken(agent, l.repo, old.name); | |
| 398 | + audit(c, l, "agent_setup.token.regenerate", { agent: agent.login, token: old.name, revoked: tokenId }); | |
| 399 | + return agentSetupPage(c, l, { token: t.token, name: old.name, prefix: t.token.slice(0, 10) }); | |
| 400 | + }); | |
| 401 | + | |
| 402 | + post(web, "/:owner/:repo/settings/agent-setup/tokens/:id/renew", async (c) => { | |
| 403 | + requireBrowserSession(c); | |
| 404 | + const { l, setup, agent, tokenId } = await mine(c); | |
| 405 | + requireRecentSignIn(c, `${repoUrl(l.repo)}/settings/agent-setup`); | |
| 406 | + return act(c, `${repoUrl(l.repo)}/settings/agent-setup`, async () => { | |
| 407 | + const until = await setup.renew(agent, tokenId!); | |
| 408 | + audit(c, l, "agent_setup.token.renew", { agent: agent.login, token_id: tokenId, expires_at: until }); | |
| 409 | + }, `Renewed: the token works until ${date(new Date(Date.now() + AGENT_SETUP_DAYS * 86400_000).toISOString())}.`); | |
| 410 | + }); | |
| 411 | + | |
| 412 | + post(web, "/:owner/:repo/settings/agent-setup/merge", async (c, f) => { | |
| 413 | + const { l, setup, agent } = await mine(c); | |
| 414 | + const mayMerge = f.s("mayMerge") === "1"; | |
| 415 | + return act(c, `${repoUrl(l.repo)}/settings/agent-setup`, async () => { | |
| 416 | + await setup.setMayMerge(agent, mayMerge); | |
| 417 | + audit(c, l, "agent_setup.merge", { agent: agent.login, may_merge: mayMerge }); | |
| 418 | + }, mayMerge ? `${agent.login} can merge pull requests a person approved.` : `${agent.login} can no longer merge pull requests.`); | |
| 419 | + }); | |
| 420 | + | |
| 421 | + // Admins: turn off someone else's agent for this repository (its tokens stop at once). | |
| 422 | + post(web, "/:owner/:repo/settings/agent-setup/agents/:id/disable", async (c) => { | |
| 423 | + const l = await load(c); | |
| 424 | + if (!atLeast(l.caller.role, "admin")) throw new HttpError(403, "admin access required"); | |
| 425 | + const setup = new AgentSetup(c.env, c.get("dir")); | |
| 426 | + const x = (await setup.agents(l.repo)).find((a) => a.agent.id === prm(c, "id")); | |
| 427 | + if (!x) throw new HttpError(404, "agent not found"); | |
| 428 | + return act(c, `${repoUrl(l.repo)}/settings/agent-setup`, async () => { | |
| 429 | + await c.get("dir").setStatus(x.agent.id, "disabled"); | |
| 430 | + audit(c, l, "agent_setup.agent.disable", { agent: x.agent.login, person: x.person?.login ?? null }); | |
| 431 | + }, `${x.agent.login} is off; its tokens no longer work.`); | |
| 432 | + }); | |
| 433 | +} |
modifiedapps/diffinery/src/web/repo-settings.tsx+8−5View file
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | import { MAX_REVIEWERS, MAX_WAIT_TIMER, type Environment } from "../repo/deployments.js"; |
| 9 | 9 | import { resolveReviewers } from "../http/environment-reviews.js"; |
| 10 | 10 | import { parseCheckList } from "../repo/required.js"; |
| 11 | -import type { ProtectionRules, Role } from "../repo/types.js"; | |
| 11 | +import { atLeast, type ProtectionRules, type Role } from "../repo/types.js"; | |
| 12 | 12 | import { ICONS } from "./assets.js"; |
| 13 | 13 | import { KCheckbox, KRadio, KSelect, type KOption } from "./kumo.js"; |
| 14 | 14 | import { csrfToken, type FormData } from "./csrf.js"; |
| @@ -16,7 +16,7 @@ | ||
| 16 | 16 | import { Actor, CopyButton, Form, PendingInvites, ROLES, Time, cap, act, ago, flash, post, refNames, render, repoUrl, statusOf, type WebApp, prm } from "./ui.js"; |
| 17 | 17 | |
| 18 | 18 | type Loaded = Awaited<ReturnType<typeof loadRepo>>; |
| 19 | -export type SettingsTab = "general" | "access" | "branches" | "actions" | "environments" | "secrets" | "webhooks" | "agent-review" | "project"; | |
| 19 | +export type SettingsTab = "general" | "access" | "branches" | "actions" | "environments" | "secrets" | "webhooks" | "agent-review" | "project" | "agent-setup"; | |
| 20 | 20 | type Tab = SettingsTab; |
| 21 | 21 | |
| 22 | 22 | const WEBHOOK_EVENTS = ["push", "pull_request", "pull_request_review", "issues", "issue_comment", "workflow_run", "workflow_dispatch"]; |
| @@ -34,9 +34,9 @@ | ||
| 34 | 34 | ); |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | -export function page(c: AppContext, l: Loaded, tab: Tab, body: Child, status?: number) { | |
| 37 | +export function page(c: AppContext, l: Loaded, tab: Tab, body: Child, status?: number, script?: string) { | |
| 38 | 38 | const base = `${repoUrl(l.repo)}/settings`; |
| 39 | - const nav: Array<[Tab, string, string]> = [ | |
| 39 | + const all: Array<[Tab, string, string]> = [ | |
| 40 | 40 | ["general", "General", base], |
| 41 | 41 | ["access", "Collaborators", `${base}/access`], |
| 42 | 42 | ["branches", "Branch protection", `${base}/branches`], |
| @@ -46,10 +46,13 @@ | ||
| 46 | 46 | ["webhooks", "Webhooks", `${base}/webhooks`], |
| 47 | 47 | ["agent-review", "Agent review", `${base}/agent-review`], |
| 48 | 48 | ["project", "Project", `${base}/project`], |
| 49 | + ["agent-setup", "Agent setup", `${base}/agent-setup`], | |
| 49 | 50 | ]; |
| 51 | + // People with write access but not admin reach only Agent setup (web/agent-setup.tsx). | |
| 52 | + const nav = atLeast(l.caller.role, "admin") ? all : all.filter(([k]) => k === "agent-setup"); | |
| 50 | 53 | return render( |
| 51 | 54 | c, |
| 52 | - { title: `Settings · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "settings" }, status }, | |
| 55 | + { title: `Settings · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "settings" }, status, script }, | |
| 53 | 56 | <div class="grid left"> |
| 54 | 57 | <aside class="side"> |
| 55 | 58 | <section> |
modifiedapps/diffinery/src/web/routes.tsx+3−0View file
| @@ -21,6 +21,7 @@ | ||
| 21 | 21 | import { registerCollab } from "./collab.js"; |
| 22 | 22 | import { registerReleases } from "./releases.js"; |
| 23 | 23 | import { registerRepoSettings } from "./repo-settings.js"; |
| 24 | +import { registerAgentSetup } from "./agent-setup.js"; | |
| 24 | 25 | import { registerImports } from "./imports.js"; |
| 25 | 26 | import { registerSeo } from "./seo.js"; |
| 26 | 27 | import { registerSite } from "./site.js"; |
| @@ -94,6 +95,8 @@ | ||
| 94 | 95 | // /-/report and /:owner/:repo/comments/:id/...: before the /:owner and /:owner/:repo catch-alls. |
| 95 | 96 | registerModeration(web); |
| 96 | 97 | registerAccount(web); |
| 98 | +// Settings → Agent setup: also for people with write access, so before the admin-only settings pages. | |
| 99 | +registerAgentSetup(web); | |
| 97 | 100 | registerRepoSettings(web); |
| 98 | 101 | registerActions(web); |
| 99 | 102 | registerReleases(web); |
modifiedapps/diffinery/src/web/ui.tsx+2−2View file
| @@ -378,8 +378,8 @@ | ||
| 378 | 378 | <a href={`${base}/packages`} class={tab === "packages" ? "on" : ""}> |
| 379 | 379 | {raw(ICONS.package)} Packages |
| 380 | 380 | </a> |
| 381 | - {atLeast(r.caller.role, "admin") ? ( | |
| 382 | - <a href={`${base}/settings`} class={tab === "settings" ? "on" : ""}> | |
| 381 | + {atLeast(r.caller.role, "admin") || (atLeast(r.caller.role, "write") && r.caller.kind === "user") ? ( | |
| 382 | + <a href={atLeast(r.caller.role, "admin") ? `${base}/settings` : `${base}/settings/agent-setup`} class={tab === "settings" ? "on" : ""}> | |
| 383 | 383 | {raw(ICONS.gear)} Settings |
| 384 | 384 | </a> |
| 385 | 385 | ) : null} |
addedapps/diffinery/test/e2e/agent-setup.test.mjs+434−0View file
| @@ -1,0 +1,434 @@ | ||
| 1 | +// End-to-end Agent setup (lib/agent-setup.ts, web/agent-setup.tsx) against a real `wrangler dev` Worker: a | |
| 2 | +// person generates a token on Settings → Agent setup, and a fresh machine (its own HOME, nothing installed) | |
| 3 | +// runs the hosted setup script with only the page's environment variables, as a Claude Code cloud | |
| 4 | +// environment does. | |
| 5 | +// | |
| 6 | +// pnpm --filter @diffinery/app exec node --test test/e2e/agent-setup.test.mjs | |
| 7 | +// | |
| 8 | +// Covers: the page (people with write access in a browser only), the script (no secret in it, a 404 for a | |
| 9 | +// name that can't be a repository), the CLI install, git and gh wired to the environment with no token on | |
| 10 | +// disk, the clone, the self-test and the Check turning green, a second run skipping the download, failure | |
| 11 | +// lines that say what to change, the token reaching only its repository (not even a public one), merging | |
| 12 | +// only what a person approved and never a workflow change, turning merging off, and revoke, regenerate | |
| 13 | +// and renew. gh only talks HTTPS, so a stand-in gh (below) does what gh does with GH_ENTERPRISE_TOKEN and | |
| 14 | +// GH_HOST; the real gh runs against diffinery.com in the live check. | |
| 15 | +import { spawn, execFileSync } from "node:child_process"; | |
| 16 | +import { createHash, randomBytes } from "node:crypto"; | |
| 17 | +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; | |
| 18 | +import { tmpdir } from "node:os"; | |
| 19 | +import { join, dirname, resolve } from "node:path"; | |
| 20 | +import { fileURLToPath } from "node:url"; | |
| 21 | +import { createServer } from "node:net"; | |
| 22 | +import { test, before, after } from "node:test"; | |
| 23 | +import assert from "node:assert/strict"; | |
| 24 | +import { migratedState } from "./d1-state.mjs"; | |
| 25 | + | |
| 26 | +const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); | |
| 27 | +const work = mkdtempSync(join(tmpdir(), "diffinery-agent-setup-")); | |
| 28 | +let base; | |
| 29 | +let host; | |
| 30 | +let dev; | |
| 31 | +let devLog = ""; | |
| 32 | + | |
| 33 | +const hash = (s) => createHash("sha256").update(s).digest("hex"); | |
| 34 | +const tok = (name) => `dfy_${name}${randomBytes(12).toString("hex")}`; | |
| 35 | +const T = { ada: tok("ada"), bob: tok("bob"), wes: tok("wes") }; | |
| 36 | +const S = { ada: `ada-session-${randomBytes(12).toString("hex")}`, bob: `bob-session-${randomBytes(12).toString("hex")}`, wes: `wes-session-${randomBytes(12).toString("hex")}` }; | |
| 37 | + | |
| 38 | +const freePort = () => | |
| 39 | + new Promise((res) => { | |
| 40 | + const s = createServer(); | |
| 41 | + s.listen(0, "127.0.0.1", () => { | |
| 42 | + const { port } = s.address(); | |
| 43 | + s.close(() => res(port)); | |
| 44 | + }); | |
| 45 | + }); | |
| 46 | +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| 47 | +async function until(what, fn, timeoutMs = 120_000) { | |
| 48 | + const end = Date.now() + timeoutMs; | |
| 49 | + while (Date.now() < end) { | |
| 50 | + const v = await fn(); | |
| 51 | + if (v) return v; | |
| 52 | + await sleep(500); | |
| 53 | + } | |
| 54 | + assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`); | |
| 55 | +} | |
| 56 | + | |
| 57 | +async function api(method, path, body, as) { | |
| 58 | + const headers = { "content-type": "application/json" }; | |
| 59 | + if (as) headers.authorization = `Bearer ${as}`; | |
| 60 | + const res = await fetch(`${base}/api/v3${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); | |
| 61 | + const text = await res.text(); | |
| 62 | + let json = null; | |
| 63 | + try { | |
| 64 | + json = text ? JSON.parse(text) : null; | |
| 65 | + } catch { | |
| 66 | + json = text; | |
| 67 | + } | |
| 68 | + return { status: res.status, json }; | |
| 69 | +} | |
| 70 | +async function ok(method, path, body, as) { | |
| 71 | + const r = await api(method, path, body, as); | |
| 72 | + assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); | |
| 73 | + return r.json; | |
| 74 | +} | |
| 75 | +const b64 = (s) => Buffer.from(s).toString("base64"); | |
| 76 | + | |
| 77 | +async function page(session, path) { | |
| 78 | + const res = await fetch(`${base}${path}`, { headers: session ? { cookie: `__Host-dfy_session=${session}` } : {}, redirect: "manual" }); | |
| 79 | + return { status: res.status, location: res.headers.get("location"), text: await res.text() }; | |
| 80 | +} | |
| 81 | +/** A form post as `session`, with the CSRF pair from the page at `from`. */ | |
| 82 | +async function submit(session, from, action, fields = {}) { | |
| 83 | + const cookie = `__Host-dfy_session=${session}`; | |
| 84 | + const res = await fetch(`${base}${from}`, { headers: { cookie } }); | |
| 85 | + const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1]; | |
| 86 | + const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1]; | |
| 87 | + assert.ok(csrf && pair, `no CSRF pair on ${from} (${res.status})`); | |
| 88 | + const r = await fetch(`${base}${action}`, { method: "POST", body: new URLSearchParams({ _csrf: csrf, ...fields }), redirect: "manual", headers: { cookie: `${cookie}; dfy_csrf=${pair}`, "sec-fetch-site": "same-origin" } }); | |
| 89 | + return { status: r.status, location: r.headers.get("location"), text: await r.text(), flash: decodeURIComponent(decodeURIComponent(r.headers.get("set-cookie") ?? "")) }; | |
| 90 | +} | |
| 91 | +const unescape = (s) => s.replace(/"/g, '"').replace(/'/g, "'").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&"); | |
| 92 | +/** The Environment variables box's text. */ | |
| 93 | +const envBox = (html) => unescape(/<pre[^>]*id="agent-env"[^>]*>([\s\S]*?)<\/pre>/.exec(html)?.[1] ?? "").trim(); | |
| 94 | + | |
| 95 | +/** | |
| 96 | + * A stand-in for the GitHub CLI: what gh does with GH_ENTERPRISE_TOKEN and GH_HOST (it needs HTTPS, and this | |
| 97 | + * server is plain HTTP). `gh auth login` refuses while the token is in the environment, as gh does. | |
| 98 | + */ | |
| 99 | +function fakeGh(dir) { | |
| 100 | + mkdirSync(dir, { recursive: true }); | |
| 101 | + writeFileSync( | |
| 102 | + join(dir, "gh"), | |
| 103 | + `#!/usr/bin/env node | |
| 104 | +const { execFileSync } = require("node:child_process"); | |
| 105 | +const [cmd, sub, ...rest] = process.argv.slice(2); | |
| 106 | +if (cmd === "--version") { console.log("gh version 2.0.0 (stand-in)"); process.exit(0); } | |
| 107 | +const token = process.env.GH_ENTERPRISE_TOKEN; | |
| 108 | +if (cmd === "auth") { if (token) { console.error("The value of the GH_ENTERPRISE_TOKEN environment variable is being used for authentication."); process.exit(1); } process.exit(0); } | |
| 109 | +const arg = (n) => { const i = rest.indexOf(n); return i >= 0 ? rest[i + 1] : undefined; }; | |
| 110 | +let host = arg("--hostname") ?? process.env.GH_HOST; | |
| 111 | +let path; | |
| 112 | +if (cmd === "api" && sub === "user") path = "/api/v3/user"; | |
| 113 | +else if (cmd === "pr" && sub === "list") { | |
| 114 | + // In a clone gh finds the repository from its remote, on a host it has a token for (GH_HOST with an environment token). | |
| 115 | + const remote = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" }).trim(); | |
| 116 | + const u = new URL(remote); | |
| 117 | + if (u.host !== process.env.GH_HOST) { console.error("none of the git remotes configured for this repository point to a known GitHub host"); process.exit(1); } | |
| 118 | + path = "/api/v3/repos" + u.pathname.replace(/\\.git$/, "") + "/pulls?state=all"; | |
| 119 | +} else { console.error("stand-in gh: unsupported " + cmd + " " + sub); process.exit(2); } | |
| 120 | +if (!token || !host) { console.error("To get started with GitHub CLI, please run: gh auth login"); process.exit(4); } | |
| 121 | +fetch("http://" + host + path, { headers: { authorization: "token " + token } }).then(async (r) => { | |
| 122 | + const j = await r.json(); | |
| 123 | + if (!r.ok) { console.error("HTTP " + r.status + ": " + j.message); process.exit(1); } | |
| 124 | + console.log(cmd === "api" ? j.login : JSON.stringify(j.map((p) => ({ number: p.number })))); | |
| 125 | +}); | |
| 126 | +`, | |
| 127 | + ); | |
| 128 | + chmodSync(join(dir, "gh"), 0o755); | |
| 129 | +} | |
| 130 | + | |
| 131 | +/** A fresh machine: its own HOME, git with no global config, a bin directory on PATH to link diffinery from, and the stand-in gh. */ | |
| 132 | +function machine(name) { | |
| 133 | + const root = join(work, name); | |
| 134 | + const home = join(root, "home"); | |
| 135 | + const bin = join(root, "bin"); | |
| 136 | + const ghDir = join(root, "gh"); | |
| 137 | + const where = join(root, "work"); | |
| 138 | + for (const d of [home, bin, where]) mkdirSync(d, { recursive: true }); | |
| 139 | + fakeGh(ghDir); | |
| 140 | + const env = { | |
| 141 | + HOME: home, | |
| 142 | + PATH: `${bin}:${ghDir}:${process.env.PATH}`, | |
| 143 | + DIFFINERY_LINK_DIR: bin, | |
| 144 | + DIFFINERY_CLONE_DIR: where, | |
| 145 | + GIT_CONFIG_NOSYSTEM: "1", | |
| 146 | + NO_COLOR: "1", | |
| 147 | + LANG: "C.UTF-8", | |
| 148 | + }; | |
| 149 | + return { root, home, bin, where, env }; | |
| 150 | +} | |
| 151 | + | |
| 152 | +/** | |
| 153 | + * Runs a command without blocking the event loop (a blocked loop leaves fetch a keep-alive connection the | |
| 154 | + * server already closed). Resolves with its exit code and output; `check` rejects on a non-zero exit. | |
| 155 | + */ | |
| 156 | +function sh(cmd, args, opts = {}, check = false) { | |
| 157 | + return new Promise((res, rej) => { | |
| 158 | + const p = spawn(cmd, args, { ...opts, stdio: ["ignore", "pipe", "pipe"] }); | |
| 159 | + let stdout = ""; | |
| 160 | + let stderr = ""; | |
| 161 | + p.stdout.on("data", (d) => (stdout += d)); | |
| 162 | + p.stderr.on("data", (d) => (stderr += d)); | |
| 163 | + const timer = setTimeout(() => p.kill("SIGKILL"), 180_000); | |
| 164 | + p.on("error", rej); | |
| 165 | + p.on("close", (code) => { | |
| 166 | + clearTimeout(timer); | |
| 167 | + if (check && code !== 0) rej(new Error(`${cmd} ${args.join(" ")} exited ${code}: ${stderr}`)); | |
| 168 | + else res({ code, stdout, stderr, out: `${stdout}${stderr}` }); | |
| 169 | + }); | |
| 170 | + }); | |
| 171 | +} | |
| 172 | + | |
| 173 | +/** Runs the setup line from the page, as a setup script field would. */ | |
| 174 | +const runSetup = (m, extra) => sh("bash", ["-c", `curl -fsSL ${base}/ada/zutrix/agent-setup.sh | bash`], { cwd: m.root, env: { ...m.env, ...extra } }); | |
| 175 | + | |
| 176 | +const auth = (token) => ["-c", `http.extraHeader=Authorization: Basic ${b64(`x-token:${token}`)}`]; | |
| 177 | +const git = async (cwd, args, token, env = {}) => (await sh("git", [...auth(token), ...args], { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", ...env } }, true)).stdout; | |
| 178 | +const gitFails = async (cwd, args, token) => { | |
| 179 | + const r = await sh("git", [...auth(token), ...args], { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } }); | |
| 180 | + assert.notEqual(r.code, 0, `git ${args.join(" ")} should fail`); | |
| 181 | + return r.stderr; | |
| 182 | +}; | |
| 183 | + | |
| 184 | +before(async () => { | |
| 185 | + const port = await freePort(); | |
| 186 | + base = `http://127.0.0.1:${port}`; | |
| 187 | + host = `127.0.0.1:${port}`; | |
| 188 | + const persist = join(work, "state"); | |
| 189 | + const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); | |
| 190 | + migratedState(APP, persist); | |
| 191 | + const at = new Date().toISOString(); | |
| 192 | + const later = new Date(Date.now() + 30 * 86400_000).toISOString(); | |
| 193 | + const account = (id, login) => `INSERT INTO accounts (id, login, kind, name, email, status, created_at) VALUES ('${id}', '${login}', 'user', '${login}', '${login}@example.com', 'active', '${at}')`; | |
| 194 | + const token = (id, acct, t) => `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('${id}', '${acct}', 'e2e', '${hash(t)}', '${t.slice(0, 10)}', '["admin"]', '${later}', '${at}')`; | |
| 195 | + const session = (acct, cookie) => `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(cookie)}', '${acct}', 'passkey', '${at}', '${at}', '${later}')`; | |
| 196 | + wrangler( | |
| 197 | + "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", | |
| 198 | + [account("u-ada", "ada"), account("u-bob", "bob"), account("u-wes", "wes"), token("t-ada", "u-ada", T.ada), token("t-bob", "u-bob", T.bob), token("t-wes", "u-wes", T.wes), session("u-ada", S.ada), session("u-bob", S.bob), session("u-wes", S.wes)].join("; "), | |
| 199 | + ); | |
| 200 | + dev = spawn( | |
| 201 | + "pnpm", | |
| 202 | + ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--show-interactive-dev-session=false"], | |
| 203 | + { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }, | |
| 204 | + ); | |
| 205 | + dev.stdout.on("data", (d) => (devLog += d)); | |
| 206 | + dev.stderr.on("data", (d) => (devLog += d)); | |
| 207 | + await until("wrangler dev", async () => { | |
| 208 | + try { | |
| 209 | + return (await fetch(`${base}/healthz`)).ok; | |
| 210 | + } catch { | |
| 211 | + return false; | |
| 212 | + } | |
| 213 | + }); | |
| 214 | + // ada's private repository zutrix (bob writes to it, wes reads it), another of hers, and bob's public one. | |
| 215 | + for (const [name, t, visibility] of [["zutrix", T.ada, "private"], ["other", T.ada, "private"], ["pub", T.bob, "public"]]) { | |
| 216 | + await ok("POST", "/user/repos", { name, visibility }, t); | |
| 217 | + await ok("PUT", `/repos/${t === T.ada ? "ada" : "bob"}/${name}/contents/README.md`, { message: "init", content: b64(`# ${name}\n`) }, t); | |
| 218 | + } | |
| 219 | + await ok("PUT", "/repos/ada/zutrix/collaborators/bob", { permission: "write" }, T.ada); | |
| 220 | + await ok("PUT", "/repos/ada/zutrix/collaborators/wes", { permission: "read" }, T.ada); | |
| 221 | + // Being added makes an invitation: bob and wes accept theirs. | |
| 222 | + for (const t of [T.bob, T.wes]) for (const inv of await ok("GET", "/user/repository_invitations", undefined, t)) await ok("PATCH", `/user/repository_invitations/${inv.id}`, undefined, t); | |
| 223 | +}); | |
| 224 | + | |
| 225 | +after(() => { | |
| 226 | + if (dev?.pid) { | |
| 227 | + try { | |
| 228 | + process.kill(-dev.pid, "SIGTERM"); | |
| 229 | + } catch {} | |
| 230 | + } | |
| 231 | + if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); | |
| 232 | +}); | |
| 233 | + | |
| 234 | +const PAGE = "/ada/zutrix/settings/agent-setup"; | |
| 235 | +let token; | |
| 236 | + | |
| 237 | +test("the page: for people with write access, in a browser", async () => { | |
| 238 | + const anon = await page(null, PAGE); | |
| 239 | + assert.equal(anon.status, 302, "signed out: off to sign in"); | |
| 240 | + assert.match(anon.location ?? "", /\/login/); | |
| 241 | + assert.equal((await page(S.wes, PAGE)).status, 403, "read access isn't enough"); | |
| 242 | + const res = await fetch(`${base}${PAGE}`, { headers: { authorization: `Bearer ${T.ada}` } }); | |
| 243 | + assert.equal(res.status, 403, "a token never sees the page"); | |
| 244 | + const p = await page(S.ada, PAGE); | |
| 245 | + assert.equal(p.status, 200); | |
| 246 | + for (const s of ["Agent setup", "Generate agent token", "Not connected yet", `curl -fsSL ${base}/ada/zutrix/agent-setup.sh | bash`, `GH_HOST=${host}`, "DIFFINERY_REPO=ada/zutrix", "Network secrets"]) assert.ok(unescape(p.text).includes(s), s); | |
| 247 | + // bob has write access but isn't an admin: Settings opens Agent setup, and it's the only tab. | |
| 248 | + const b = await page(S.bob, PAGE); | |
| 249 | + assert.equal(b.status, 200); | |
| 250 | + assert.ok(b.text.includes('href="/ada/zutrix/settings/agent-setup"')); | |
| 251 | + assert.ok(!b.text.includes('href="/ada/zutrix/settings/branches"')); | |
| 252 | + assert.equal((await page(S.bob, "/ada/zutrix/settings")).status, 403); | |
| 253 | +}); | |
| 254 | + | |
| 255 | +test("generating a token makes ada's agent for this repository, and fills it in once", async () => { | |
| 256 | + const r = await submit(S.ada, PAGE, `${PAGE}/generate`, { name: "Claude Code cloud · e2e" }); | |
| 257 | + assert.equal(r.status, 200, r.text.slice(0, 500)); | |
| 258 | + const env = envBox(r.text); | |
| 259 | + token = /^DIFFINERY_TOKEN=(dfy_[A-Za-z0-9]+)$/m.exec(env)?.[1]; | |
| 260 | + assert.ok(token, env); | |
| 261 | + assert.equal(env, [`DIFFINERY_TOKEN=${token}`, `GH_ENTERPRISE_TOKEN=${token}`, `GH_HOST=${host}`, "DIFFINERY_REPO=ada/zutrix", `DIFFINERY_URL=${base}`].join("\n")); | |
| 262 | + const me = await ok("GET", "/user", undefined, token); | |
| 263 | + assert.equal(me.login, "zutrix-agent"); | |
| 264 | + assert.equal(me.type, "Bot"); | |
| 265 | + // Shown once: the page masks it afterwards. | |
| 266 | + const again = await page(S.ada, PAGE); | |
| 267 | + assert.ok(!again.text.includes(token)); | |
| 268 | + assert.match(envBox(again.text), new RegExp(`^DIFFINERY_TOKEN=${token.slice(0, 10)}…$`, "m")); | |
| 269 | + assert.ok(again.text.includes("Claude Code cloud · e2e")); | |
| 270 | +}); | |
| 271 | + | |
| 272 | +test("the hosted script holds no secret, and only answers for names a repository can have", async () => { | |
| 273 | + const res = await fetch(`${base}/ada/zutrix/agent-setup.sh`); | |
| 274 | + assert.equal(res.status, 200); | |
| 275 | + const s = await res.text(); | |
| 276 | + assert.match(s, new RegExp(`^server=${base.replace(/[.]/g, "\\.")}$`, "m")); | |
| 277 | + assert.match(s, /^repo=ada\/zutrix$/m); | |
| 278 | + assert.match(s, /^cli_sha256=[0-9a-f]{64}$/m); | |
| 279 | + assert.doesNotMatch(s, /dfy_[A-Za-z0-9]{10}/); | |
| 280 | + assert.equal((await fetch(`${base}/no-such-owner/whatever/agent-setup.sh`)).status, 200, "says nothing about whether the repository exists"); | |
| 281 | + assert.equal((await fetch(`${base}/ada/${encodeURIComponent("$(id)")}/agent-setup.sh`)).status, 404); | |
| 282 | +}); | |
| 283 | + | |
| 284 | +test("a fresh machine with only the page's three boxes: everything works and the Check turns green", async () => { | |
| 285 | + const m = machine("fresh"); | |
| 286 | + const env = Object.fromEntries(envBox((await page(S.ada, PAGE)).text).split("\n").map((l) => l.split("="))); | |
| 287 | + Object.assign(env, { DIFFINERY_TOKEN: token, GH_ENTERPRISE_TOKEN: token }); | |
| 288 | + const first = await runSetup(m, env); | |
| 289 | + assert.equal(first.code, 0, first.out); | |
| 290 | + assert.ok(!first.out.includes(token), "never prints the token"); | |
| 291 | + for (const s of ["✓ server:", "✓ diffinery on PATH:", "✓ DIFFINERY_TOKEN: acts as zutrix-agent (agent)", "✓ repository: ada/zutrix with write access", "✓ git:", "✓ clone: cloned into", "✓ gh: gh api acts as zutrix-agent", "✓ gh in the clone:", "Ready:"]) assert.ok(first.out.includes(s), `${s}\n${first.out}`); | |
| 292 | + const all = { ...m.env, ...env }; | |
| 293 | + // The acceptance checks, as the session would run them: no path prefix, no prompt. | |
| 294 | + assert.match((await sh("bash", ["-c", "diffinery --version"], { env: all }, true)).stdout, /0\.\d/); | |
| 295 | + assert.match((await sh("bash", ["-c", "diffinery whoami"], { env: all }, true)).stdout, /zutrix-agent/); | |
| 296 | + const fresh = join(m.root, "second-clone"); | |
| 297 | + await sh("git", ["clone", "-q", `${base}/ada/zutrix.git`, fresh], { env: { ...all, GIT_TERMINAL_PROMPT: "0" } }, true); | |
| 298 | + assert.ok(existsSync(join(fresh, "README.md"))); | |
| 299 | + assert.equal((await sh("gh", ["api", "user", "--hostname", host, "--jq", ".login"], { env: all }, true)).stdout.trim(), "zutrix-agent"); | |
| 300 | + await sh("gh", ["pr", "list", "--state", "all"], { cwd: join(m.where, "zutrix"), env: all }, true); | |
| 301 | + // No token anywhere on disk: git and gh read it from the environment. | |
| 302 | + const files = (dir) => readdirSync(dir, { recursive: true }).map((f) => join(dir, String(f))).filter((f) => statSync(f).isFile() && statSync(f).size < 5_000_000); | |
| 303 | + for (const f of files(m.home)) assert.ok(!readFileSync(f, "utf8").includes(token), `${f} holds the token`); | |
| 304 | + // The Check, on the page and in the API. | |
| 305 | + const check = await ok("GET", "/repos/ada/zutrix/diffinery/agent-setup", undefined, T.ada); | |
| 306 | + assert.equal(check.check.ok, true, JSON.stringify(check)); | |
| 307 | + assert.equal(check.check.account, "zutrix-agent"); | |
| 308 | + assert.equal(check.check.token.name, "Claude Code cloud · e2e"); | |
| 309 | + const fragment = await page(S.ada, `${PAGE}/check`); | |
| 310 | + assert.match(fragment.text, /Connected\./); | |
| 311 | + | |
| 312 | + // Every session start: the CLI isn't downloaded again, and the clone is brought up to date. | |
| 313 | + await ok("PUT", "/repos/ada/zutrix/contents/NEWS.md", { message: "news", content: b64("news\n") }, T.ada); | |
| 314 | + const started = Date.now(); | |
| 315 | + const second = await runSetup(m, env); | |
| 316 | + assert.equal(second.code, 0, second.out); | |
| 317 | + assert.ok(second.out.includes("diffinery CLI: already the current build"), second.out); | |
| 318 | + assert.ok(second.out.includes("(up to date)"), second.out); | |
| 319 | + assert.ok(existsSync(join(m.where, "zutrix", "NEWS.md"))); | |
| 320 | + assert.ok(Date.now() - started < 30_000, `second run took ${Date.now() - started} ms`); | |
| 321 | +}); | |
| 322 | + | |
| 323 | +test("failures say what to change, and a missing token doesn't fail the session", async () => { | |
| 324 | + const m = machine("broken"); | |
| 325 | + const missing = await runSetup(m, {}); | |
| 326 | + assert.equal(missing.code, 0, missing.out); | |
| 327 | + assert.match(missing.out, /DIFFINERY_TOKEN is not set: generate a token on .*\/ada\/zutrix\/settings\/agent-setup/); | |
| 328 | + const noHost = await runSetup(m, { DIFFINERY_TOKEN: token, GH_ENTERPRISE_TOKEN: token, DIFFINERY_NO_CLONE: "1" }); | |
| 329 | + assert.equal(noHost.code, 1, noHost.out); | |
| 330 | + assert.ok(noHost.out.includes(`✗ GH_HOST: not set: add GH_HOST=${host} to the environment variables`), noHost.out); | |
| 331 | + const mismatch = await runSetup(m, { DIFFINERY_TOKEN: token, GH_ENTERPRISE_TOKEN: T.bob, GH_HOST: host, DIFFINERY_NO_CLONE: "1" }); | |
| 332 | + assert.equal(mismatch.code, 1); | |
| 333 | + assert.ok(mismatch.out.includes("GH_ENTERPRISE_TOKEN isn't the same token as DIFFINERY_TOKEN"), mismatch.out); | |
| 334 | + assert.ok(!mismatch.out.includes(T.bob) && !mismatch.out.includes(token)); | |
| 335 | + const refused = await runSetup(m, { DIFFINERY_TOKEN: "dfy_notarealtoken0000", DIFFINERY_NO_CLONE: "1" }); | |
| 336 | + assert.equal(refused.code, 1); | |
| 337 | + assert.ok(refused.out.includes("refused (revoked or expired)"), refused.out); | |
| 338 | + // A failed run is the Check now, saying what failed. | |
| 339 | + const check = await ok("GET", "/repos/ada/zutrix/diffinery/agent-setup", undefined, T.ada); | |
| 340 | + assert.equal(check.check.ok, false); | |
| 341 | + assert.ok(check.check.checks.some((l) => l.name === "gh" && !l.ok)); | |
| 342 | +}); | |
| 343 | + | |
| 344 | +test("the token reaches only its repository", async () => { | |
| 345 | + assert.equal((await api("GET", "/repos/ada/other", undefined, token)).status, 404); | |
| 346 | + assert.equal((await api("GET", "/repos/bob/pub", undefined, token)).status, 404, "not even a public one"); | |
| 347 | + assert.deepEqual((await ok("GET", "/user/repos", undefined, token)).map((r) => r.full_name), ["ada/zutrix"]); | |
| 348 | + const dir = mkdtempSync(join(work, "iso-")); | |
| 349 | + assert.match(await gitFails(dir, ["clone", "-q", `${base}/ada/other.git`, join(dir, "o")], token), /not found|401|403|404|Authentication/i); | |
| 350 | + await git(dir, ["clone", "-q", `${base}/ada/zutrix.git`, join(dir, "z")], token); | |
| 351 | + await git(join(dir, "z"), ["checkout", "-q", "-b", "feature"], token); | |
| 352 | + writeFileSync(join(dir, "z", "x.txt"), "x\n"); | |
| 353 | + await git(join(dir, "z"), ["add", "."], token, { GIT_AUTHOR_NAME: "a", GIT_AUTHOR_EMAIL: "a@x", GIT_COMMITTER_NAME: "a", GIT_COMMITTER_EMAIL: "a@x" }); | |
| 354 | + await git(join(dir, "z"), ["commit", "-qm", "x"], token, { GIT_AUTHOR_NAME: "a", GIT_AUTHOR_EMAIL: "a@x", GIT_COMMITTER_NAME: "a", GIT_COMMITTER_EMAIL: "a@x" }); | |
| 355 | + assert.match(await gitFails(join(dir, "z"), ["push", "-q", `${base}/ada/other.git`, "feature"], token), /not found|denied|403|404/i); | |
| 356 | + assert.match(await gitFails(join(dir, "z"), ["push", "-q", "origin", "HEAD:main"], token), /agents may not push/); | |
| 357 | + await git(join(dir, "z"), ["push", "-q", "origin", "feature"], token); | |
| 358 | + // Nor can it mint anything, or change account settings. | |
| 359 | + assert.equal((await api("POST", "/diffinery/tokens", { name: "x" }, token)).status, 403); | |
| 360 | + assert.equal((await api("PUT", "/repos/ada/zutrix/actions/secrets/X", { encrypted_value: "x" }, token)).status, 403); | |
| 361 | +}); | |
| 362 | + | |
| 363 | +test("merging: only what a person approved, never a workflow change, and off when ada says so", async () => { | |
| 364 | + const pr = await ok("POST", "/repos/ada/zutrix/pulls", { title: "Add x", head: "feature", base: "main" }, token); | |
| 365 | + const merge = () => api("PUT", `/repos/ada/zutrix/pulls/${pr.number}/merge`, { merge_method: "squash" }, token); | |
| 366 | + const before = await merge(); | |
| 367 | + assert.equal(before.status, 405, JSON.stringify(before.json)); | |
| 368 | + assert.match(before.json.message, /needs 1 more approving review\(s\) of its latest commit/); | |
| 369 | + await ok("POST", `/repos/ada/zutrix/pulls/${pr.number}/reviews`, { event: "APPROVE" }, T.ada); | |
| 370 | + const merged = await merge(); | |
| 371 | + assert.equal(merged.status, 200, JSON.stringify(merged.json)); | |
| 372 | + | |
| 373 | + // A change to a workflow file needs a person, approved or not. | |
| 374 | + const wf = await ok("POST", "/repos/ada/zutrix/git/refs", { ref: "refs/heads/wf", sha: (await ok("GET", "/repos/ada/zutrix/git/ref/heads/main", undefined, T.ada)).object.sha }, T.ada); | |
| 375 | + assert.ok(wf); | |
| 376 | + await ok("PUT", "/repos/ada/zutrix/contents/.github/workflows/ci.yml", { message: "ci", content: b64("on: push\njobs: {}\n"), branch: "wf" }, T.ada); | |
| 377 | + const wfPr = await ok("POST", "/repos/ada/zutrix/pulls", { title: "CI", head: "wf", base: "main" }, T.ada); | |
| 378 | + await ok("POST", `/repos/ada/zutrix/pulls/${wfPr.number}/reviews`, { event: "APPROVE" }, T.bob); | |
| 379 | + const wfMerge = await api("PUT", `/repos/ada/zutrix/pulls/${wfPr.number}/merge`, {}, token); | |
| 380 | + assert.equal(wfMerge.status, 405); | |
| 381 | + assert.match(wfMerge.json.message, /changes workflow file `\.github\/workflows\/ci\.yml`; a person must merge it/); | |
| 382 | + const wfAuto = await api("PUT", `/repos/ada/zutrix/pulls/${wfPr.number}/auto-merge`, {}, token); | |
| 383 | + assert.equal(wfAuto.status, 422, JSON.stringify(wfAuto.json)); | |
| 384 | + | |
| 385 | + // Merging off: the agent rule is back. | |
| 386 | + await ok("PUT", "/repos/ada/zutrix/contents/y.txt", { message: "y", content: b64("y\n"), branch: "wf" }, T.ada); | |
| 387 | + const off = await submit(S.ada, PAGE, `${PAGE}/merge`, {}); | |
| 388 | + assert.equal(off.status, 303); | |
| 389 | + assert.match(off.flash, /can no longer merge/); | |
| 390 | + const plain = await ok("POST", "/repos/ada/zutrix/git/refs", { ref: "refs/heads/plain", sha: (await ok("GET", "/repos/ada/zutrix/git/ref/heads/main", undefined, T.ada)).object.sha }, T.ada); | |
| 391 | + assert.ok(plain); | |
| 392 | + await ok("PUT", "/repos/ada/zutrix/contents/z.txt", { message: "z", content: b64("z\n"), branch: "plain" }, T.ada); | |
| 393 | + const plainPr = await ok("POST", "/repos/ada/zutrix/pulls", { title: "z", head: "plain", base: "main" }, T.ada); | |
| 394 | + await ok("POST", `/repos/ada/zutrix/pulls/${plainPr.number}/reviews`, { event: "APPROVE" }, T.bob); | |
| 395 | + const refused = await api("PUT", `/repos/ada/zutrix/pulls/${plainPr.number}/merge`, {}, token); | |
| 396 | + assert.equal(refused.status, 405); | |
| 397 | + assert.match(refused.json.message, /agents may not merge into this branch/); | |
| 398 | + const on = await submit(S.ada, PAGE, `${PAGE}/merge`, { mayMerge: "1" }); | |
| 399 | + assert.match(on.flash, /can merge pull requests a person approved/); | |
| 400 | + assert.equal((await api("PUT", `/repos/ada/zutrix/pulls/${plainPr.number}/merge`, {}, token)).status, 200); | |
| 401 | +}); | |
| 402 | + | |
| 403 | +test("renew, revoke and regenerate; an admin turns off someone else's agent", async () => { | |
| 404 | + const html = (await page(S.ada, PAGE)).text; | |
| 405 | + const id = /\/settings\/agent-setup\/tokens\/([^/]+)\/renew/.exec(html)?.[1]; | |
| 406 | + assert.ok(id); | |
| 407 | + const renewed = await submit(S.ada, PAGE, `${PAGE}/tokens/${id}/renew`); | |
| 408 | + assert.match(renewed.flash, /Renewed: the token works until/); | |
| 409 | + const regen = await submit(S.ada, PAGE, `${PAGE}/tokens/${id}/regenerate`); | |
| 410 | + assert.equal(regen.status, 200); | |
| 411 | + const next = /^DIFFINERY_TOKEN=(dfy_[A-Za-z0-9]+)$/m.exec(envBox(regen.text))?.[1]; | |
| 412 | + assert.ok(next && next !== token); | |
| 413 | + assert.equal((await api("GET", "/user", undefined, token)).status, 401, "the old token stops at once"); | |
| 414 | + assert.equal((await ok("GET", "/user", undefined, next)).login, "zutrix-agent", "the same agent"); | |
| 415 | + | |
| 416 | + // bob's own agent here; ada, an admin, turns it off. | |
| 417 | + const b = await submit(S.bob, PAGE, `${PAGE}/generate`, { name: "bob's" }); | |
| 418 | + const bobToken = /^DIFFINERY_TOKEN=(dfy_[A-Za-z0-9]+)$/m.exec(envBox(b.text))?.[1]; | |
| 419 | + const bobAgent = (await ok("GET", "/user", undefined, bobToken)).login; | |
| 420 | + assert.equal(bobAgent, "bob-zutrix-agent"); | |
| 421 | + const adaView = (await page(S.ada, PAGE)).text; | |
| 422 | + const agentId = /\/settings\/agent-setup\/agents\/([^/]+)\/disable/.exec(adaView)?.[1]; | |
| 423 | + assert.ok(agentId, "ada sees bob's agent"); | |
| 424 | + const off = await submit(S.ada, PAGE, `${PAGE}/agents/${agentId}/disable`); | |
| 425 | + assert.equal(off.status, 303); | |
| 426 | + assert.equal((await api("GET", "/user", undefined, bobToken)).status, 401); | |
| 427 | + // bob still has write access, so he can generate again: the agent comes back, its old tokens don't. | |
| 428 | + const back = await submit(S.bob, PAGE, `${PAGE}/generate`, { name: "bob's again" }); | |
| 429 | + const newer = /^DIFFINERY_TOKEN=(dfy_[A-Za-z0-9]+)$/m.exec(envBox(back.text))?.[1]; | |
| 430 | + assert.equal((await ok("GET", "/user", undefined, newer)).login, "bob-zutrix-agent"); | |
| 431 | + assert.equal((await api("GET", "/user", undefined, bobToken)).status, 401, "turned off for good"); | |
| 432 | + // bob, not an admin, can't do the same to ada's. | |
| 433 | + assert.equal((await submit(S.bob, PAGE, `${PAGE}/agents/whatever/disable`)).status, 403); | |
| 434 | +}); |
modifiedapps/diffinery/test/unit/auto-merge.test.ts+28−2View file
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | import { describe, expect, it } from "vitest"; |
| 3 | 3 | import type { Env } from "../../src/env.js"; |
| 4 | 4 | import { HttpError } from "../../src/lib/util.js"; |
| 5 | -import { AUTO_MERGE_BOT, AutoMerge, automaticLandingRefusal, isPerson, landsByAgentRule } from "../../src/repo/auto-merge.js"; | |
| 5 | +import { AUTO_MERGE_BOT, AutoMerge, automaticLandingRefusal, isPerson, landsByAgentRule, mergesByAgentPermission } from "../../src/repo/auto-merge.js"; | |
| 6 | 6 | import type { RepoDO } from "../../src/repo/RepoDO.js"; |
| 7 | 7 | import { REPO_SCHEMA, migrateRepoSchema } from "../../src/repo/schema.js"; |
| 8 | 8 | import type { ProtectionRules } from "../../src/repo/types.js"; |
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | const agent = { id: "a1", kind: "agent" as const }; |
| 12 | 12 | const job = { id: "job:j1", kind: "system" as const }; |
| 13 | 13 | const agentJob = { id: "job:j2", kind: "agent" as const }; |
| 14 | -const refuse = (c: { id: string; kind: "user" | "agent" | "system" }, rules: ProtectionRules | null, isDefault = true) => automaticLandingRefusal(c, rules, isDefault, "main"); | |
| 14 | +const refuse = (c: { id: string; kind: "user" | "agent" | "system"; mayMerge?: boolean }, rules: ProtectionRules | null, isDefault = true) => automaticLandingRefusal(c, rules, isDefault, "main"); | |
| 15 | 15 | |
| 16 | 16 | describe("who may have Diffinery land a pull request for them", () => { |
| 17 | 17 | it("tells people from agents and workflow tokens", () => { |
| @@ -57,6 +57,32 @@ | ||
| 57 | 57 | }); |
| 58 | 58 | }); |
| 59 | 59 | |
| 60 | +describe("an agent that may merge (Agent setup's Can merge pull requests)", () => { | |
| 61 | + const setupAgent = { ...agent, mayMerge: true }; | |
| 62 | + it("lands on the default branch and protected ones: approvals and checks are left to mergeability", () => { | |
| 63 | + expect(refuse(setupAgent, null)).toBeNull(); | |
| 64 | + expect(refuse(setupAgent, { requiredChecks: ["ci"] }, false)).toBeNull(); | |
| 65 | + expect(mergesByAgentPermission(setupAgent, null, true, false)).toBe(true); | |
| 66 | + expect(mergesByAgentPermission(setupAgent, { requiredApprovals: 2 }, false, false)).toBe(true); | |
| 67 | + }); | |
| 68 | + | |
| 69 | + it("is never a workflow token, a person, or an agent without the permission", () => { | |
| 70 | + for (const c of [agent, person, { ...job, mayMerge: true }, { ...agentJob, mayMerge: true }]) expect(mergesByAgentPermission(c, null, true, false)).toBe(false); | |
| 71 | + expect(refuse({ ...agentJob, mayMerge: true }, null)?.status).toBe(403); | |
| 72 | + }); | |
| 73 | + | |
| 74 | + it("leaves the branch's own agent rules to their own path", () => { | |
| 75 | + // agentsMayMerge keeps its approval waiver; allowAgentAutoMerge with required checks keeps landsByAgentRule. | |
| 76 | + expect(mergesByAgentPermission(setupAgent, { agentsMayMerge: true }, true, false)).toBe(false); | |
| 77 | + expect(mergesByAgentPermission(setupAgent, { allowAgentAutoMerge: true, requiredChecks: ["ci"] }, true, true)).toBe(false); | |
| 78 | + expect(mergesByAgentPermission(setupAgent, { allowAgentAutoMerge: true, requiredChecks: ["ci"] }, true, false)).toBe(true); | |
| 79 | + }); | |
| 80 | + | |
| 81 | + it("has nothing to do on an unprotected branch that isn't the default", () => { | |
| 82 | + expect(mergesByAgentPermission(setupAgent, null, false, false)).toBe(false); | |
| 83 | + }); | |
| 84 | +}); | |
| 85 | + | |
| 60 | 86 | // ---------------------------------------------------------------- what auto-merge does when it tries |
| 61 | 87 | |
| 62 | 88 | /** A minimal SqlStorage over node:sqlite with the repository schema. */ |