337 lines
12 KiB
TypeScript

import { spawn } from "node:child_process";
import { statSync } from "node:fs";
import path from "node:path";
import {
evaluateGitShellPolicy,
ghBrokerArgv,
gitNetworkArgv,
gitRemoteMatchesAllowlist,
normalizeGitRemoteUrl,
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<string, string>;
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<BrokerCommandResult>;
};
let runOutside: BrokerOutsideRunner | undefined;
const sessions = new Map<string, GithubBrokerSession>();
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<BrokerCommandResult> {
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 readRemoteUrl(
session: GithubBrokerSession,
remoteName: string,
cwd = session.workspacePath,
): Promise<string | null> {
const result = await runHostGit(
session,
["/usr/bin/git", "remote", "get-url", "--", remoteName],
cwd,
);
if (result.code !== 0) return null;
return result.stdout.trim() || null;
}
export async function assertRemoteAllowed(
session: GithubBrokerSession,
argv: string[],
cwd = session.workspacePath,
) {
if (!session.allowedRemoteUrl) return;
const remoteArg = argv.find((part, index) => index > 1 && !part.startsWith("-"));
const candidate = remoteArg
? normalizeGitRemoteUrl(remoteArg)
? remoteArg
: ((await readRemoteUrl(session, remoteArg, cwd)) ?? remoteArg)
: await readRemoteUrl(session, "origin", cwd);
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,
cwd = session.workspacePath,
): Promise<string | null> {
if (session.defaultBranch) return session.defaultBranch;
const sym = await runHostGit(
session,
["/usr/bin/git", "symbolic-ref", "refs/remotes/origin/HEAD"],
cwd,
);
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 invocation = hostGitInvocation(session, argv, body.cwd);
if ("error" in invocation) return c.json({ stdout: "", stderr: invocation.error, code: 1 });
const command = argv.join(" ");
const defaultBranch = await resolveDefaultBranch(session, invocation.cwd);
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, invocation.cwd);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return c.json({ stdout: "", stderr: message, 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);
});
}