import { spawn } from "node:child_process"; import { statSync } from "node:fs"; import path from "node:path"; import { evaluateGitShellPolicy, ghBrokerArgv, gitNetworkArgv, gitRemoteMatchesAllowlist, resolveSupervisorToken, } from "@rakazo/core"; import { verifyGithubBrokerRunToken } from "@rakazo/core/node/github-broker-token"; import type { Hono } from "hono"; import { z } from "zod"; import { hasValidBearerToken } from "./supervisor-logic.js"; export type GithubBrokerSession = { runId: string; workspacePath: string; allowedRemoteUrl: string | null; defaultBranch: string | null; githubToken: string; /** Where workspacePath is mounted inside the computer. */ containerRoot: string; pushApproved: boolean; }; const DEFAULT_CONTAINER_ROOT = "/home/rakazo"; // Plain git ignores GITHUB_TOKEN, so HTTPS remotes get the token from a one-off credential helper. const GIT_CREDENTIAL_ARGS = [ "-c", "credential.helper=", "-c", 'credential.https://github.com.helper=!f() { test "$1" = get && echo username=x-access-token && echo "password=$GITHUB_TOKEN"; }; f', ]; /** Maps a path inside the computer onto the host workspace; anything outside the mount stays unmapped. */ export function hostPathFor(session: GithubBrokerSession, containerPath: string): string | null { const root = session.containerRoot.replace(/\/$/, ""); if (containerPath !== root && !containerPath.startsWith(`${root}/`)) return null; const resolved = path.resolve(session.workspacePath, path.relative(root, containerPath)); const inside = path.relative(session.workspacePath, resolved); return inside.startsWith("..") || path.isAbsolute(inside) ? null : resolved; } /** GitHub remotes always go over HTTPS with the broker's token: drop pasted credentials, turn SSH into HTTPS. */ export function githubHttpsRemote(arg: string): string { const ssh = /^(?:ssh:\/\/)?git@github\.com[:/](.+)$/.exec(arg); if (ssh) return `https://github.com/${ssh[1]}`; return arg.replace(/^https:\/\/[^/@]+@github\.com\//, "https://github.com/"); } const GITHUB_AUTH_FAILURE = /Invalid username or token|Authentication failed|could not read Username/; /** GitHub rejecting the stored token means the connection needs renewing, not a token from the user. */ export function explainGitFailure(stderr: string): string { if (!GITHUB_AUTH_FAILURE.test(stderr)) return stderr; return `${stderr.trimEnd()}\nGitHub rejected the token evOo stores for this user. Tell the user to reconnect GitHub under Settings, Integrations. Do not ask for a token or set one up yourself.\n`; } /** Host argv and cwd for a git call made in the computer, or an error when it leaves the workspace. */ export function hostGitInvocation( session: GithubBrokerSession, argv: string[], containerCwd: string | undefined, ): { argv: string[]; cwd: string } | { error: string } { const cwd = containerCwd ? hostPathFor(session, containerCwd) : session.workspacePath; if (!cwd) return { error: `Run git inside ${session.containerRoot}.` }; const mapped: string[] = []; for (const part of argv.slice(1)) { if (!part.startsWith("/")) { mapped.push(githubHttpsRemote(part)); continue; } const hostPart = hostPathFor(session, part); if (!hostPart) return { error: `Use a path inside ${session.containerRoot}.` }; mapped.push(hostPart); } return { argv: ["/usr/bin/git", ...GIT_CREDENTIAL_ARGS, ...mapped], cwd }; } /** A root supervisor runs git as the workspace owner, so clones stay writable by the computer. */ export function gitProcessIdentity( workspacePath: string, uid = process.getuid?.(), ): { uid: number; gid: number } | undefined { if (uid !== 0) return undefined; try { const stat = statSync(workspacePath); return stat.uid === 0 ? undefined : { uid: stat.uid, gid: stat.gid }; } catch { return undefined; } } export type BrokerCommandResult = { stdout: string; stderr: string; code: number }; export type BrokerCommandInput = { argv: string[]; cwd: string; env: Record; workspacePath: string; }; /** Runs git or gh for a workspace this process cannot see, e.g. a project folder outside a container's mounts. */ export type BrokerOutsideRunner = { sees(target: string): boolean; run(input: BrokerCommandInput): Promise; }; let runOutside: BrokerOutsideRunner | undefined; const sessions = new Map(); export function githubBrokerSessionsForTests() { return sessions; } function brokerSecret(): string { return resolveSupervisorToken(process.env); } function runIdFromRunToken(authorization: string | undefined): string | null { if (!authorization?.startsWith("Bearer ")) return null; const token = authorization.slice("Bearer ".length).trim(); for (const runId of sessions.keys()) { if (verifyGithubBrokerRunToken(token, brokerSecret(), runId)) return runId; } return null; } function sessionForRequest(authorization: string | undefined): GithubBrokerSession | null { const runId = runIdFromRunToken(authorization); if (!runId) return null; return sessions.get(runId) ?? null; } async function runHostGit( session: GithubBrokerSession, argv: string[], cwd = session.workspacePath, ): Promise { const env = { HOME: session.workspacePath, GIT_TERMINAL_PROMPT: "0", GH_TOKEN: session.githubToken, GITHUB_TOKEN: session.githubToken, }; if (runOutside && !runOutside.sees(cwd)) { return runOutside.run({ argv, cwd, env, workspacePath: session.workspacePath }); } return new Promise((resolve) => { const child = spawn(argv[0]!, argv.slice(1), { cwd, ...gitProcessIdentity(session.workspacePath), env: { ...process.env, ...env }, }); let stdout = ""; let stderr = ""; child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); }); child.stderr?.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf8"); }); child.on("close", (code) => resolve({ stdout, stderr, code: code ?? 1 })); child.on("error", (error) => resolve({ stdout: "", stderr: error.message, code: 1 })); }); } async function readOriginRemote(session: GithubBrokerSession): Promise { const result = await runHostGit(session, ["/usr/bin/git", "remote", "get-url", "origin"]); if (result.code !== 0) return null; return result.stdout.trim() || null; } async function assertRemoteAllowed(session: GithubBrokerSession, argv: string[]) { if (!session.allowedRemoteUrl) return; const remoteArg = argv.find((part, index) => index > 1 && !part.startsWith("-")); const candidate = remoteArg ?? (await readOriginRemote(session)); if (candidate && !gitRemoteMatchesAllowlist(candidate, session.allowedRemoteUrl)) { throw new Error("Push and fetch are limited to this space's linked repository remote."); } } async function resolveDefaultBranch(session: GithubBrokerSession): Promise { if (session.defaultBranch) return session.defaultBranch; const sym = await runHostGit(session, [ "/usr/bin/git", "symbolic-ref", "refs/remotes/origin/HEAD", ]); if (sym.code !== 0) return null; const ref = sym.stdout.trim(); const match = /^refs\/remotes\/origin\/(.+)$/.exec(ref); return match?.[1]?.trim() || null; } export function mountGithubBrokerRoutes( app: Hono, supervisorToken: string, outside?: BrokerOutsideRunner, ) { if (outside) runOutside = outside; app.use("/internal/github-broker/*", async (c, next) => { const pathname = c.req.path; if (pathname.startsWith("/internal/github-broker/sessions")) { if (!hasValidBearerToken(c.req.header("authorization"), supervisorToken)) { return c.json({ error: "unauthorized" }, 401); } await next(); return; } if (!sessionForRequest(c.req.header("authorization"))) { return c.json({ error: "unauthorized" }, 401); } await next(); }); app.post("/internal/github-broker/sessions", async (c) => { const body = z .object({ runId: z.string().min(1), workspacePath: z.string().min(1), allowedRemoteUrl: z.string().nullable(), defaultBranch: z.string().nullable(), githubToken: z.string().min(1), containerRoot: z.string().min(1).optional(), pushApproved: z.boolean().optional(), }) .parse(await c.req.json()); const workspacePath = path.resolve(body.workspacePath); sessions.set(body.runId, { runId: body.runId, workspacePath, allowedRemoteUrl: body.allowedRemoteUrl, defaultBranch: body.defaultBranch, githubToken: body.githubToken, containerRoot: body.containerRoot ?? DEFAULT_CONTAINER_ROOT, pushApproved: body.pushApproved ?? false, }); return c.json({ ok: true }); }); app.patch("/internal/github-broker/sessions/:runId", async (c) => { const runId = c.req.param("runId"); const body = z.object({ pushApproved: z.boolean() }).parse(await c.req.json()); const session = sessions.get(runId); if (!session) return c.json({ error: "not found" }, 404); session.pushApproved = body.pushApproved; return c.json({ ok: true }); }); app.delete("/internal/github-broker/sessions/:runId", async (c) => { sessions.delete(c.req.param("runId")); return c.json({ ok: true }); }); app.post("/internal/github-broker/v1/git", async (c) => { const session = sessionForRequest(c.req.header("authorization")); if (!session) return c.json({ error: "unauthorized" }, 401); const body = z .object({ argv: z.array(z.string()).min(1), cwd: z.string().optional() }) .parse(await c.req.json()); const argv = body.argv[0] === "git" ? body.argv : ["git", ...body.argv]; if (!gitNetworkArgv(argv)) { return c.json({ error: "unsupported git invocation" }, 400); } const command = argv.join(" "); const defaultBranch = await resolveDefaultBranch(session); const policy = evaluateGitShellPolicy({ command, defaultBranch, gitPushApprovedThisRun: session.pushApproved, }); if (policy.action === "block") { return c.json({ stdout: "", stderr: policy.reason, code: 1 }); } if (policy.action === "require_approval") { return c.json({ stdout: "", stderr: policy.reason, code: 1 }); } try { await assertRemoteAllowed(session, argv); } catch (error) { const message = error instanceof Error ? error.message : String(error); return c.json({ stdout: "", stderr: message, code: 1 }); } const invocation = hostGitInvocation(session, argv, body.cwd); if ("error" in invocation) return c.json({ stdout: "", stderr: invocation.error, code: 1 }); const result = await runHostGit(session, invocation.argv, invocation.cwd); return c.json({ ...result, stderr: explainGitFailure(result.stderr) }); }); app.post("/internal/github-broker/v1/gh", async (c) => { const session = sessionForRequest(c.req.header("authorization")); if (!session) return c.json({ error: "unauthorized" }, 401); const body = z.object({ argv: z.array(z.string()).min(1) }).parse(await c.req.json()); const argv = body.argv[0] === "gh" ? body.argv : ["gh", ...body.argv]; if (!ghBrokerArgv(argv)) { return c.json({ error: "unsupported gh invocation" }, 400); } const defaultBranch = await resolveDefaultBranch(session); const policy = evaluateGitShellPolicy({ command: argv.join(" "), defaultBranch, gitPushApprovedThisRun: session.pushApproved, }); if (policy.action === "block" || policy.action === "require_approval") { const reason = policy.reason; return c.json({ stdout: "", stderr: reason, code: 1 }); } const result = await runHostGit(session, ["/usr/bin/gh", ...argv.slice(1)]); return c.json(result); }); }