Two gaps kept agents from pushing on a containerized supervisor: - The git and gh shims called rakazo-broker by name, and login shells in the computer reset PATH, so the broker was "not found". The shims now call it next to their own resolved path, and /usr/bin/gh is a shim too. - A project folder outside the supervisor container's mounts is invisible to it, so broker git ran against an empty directory. Such calls now run in a one-off computer-image container that binds the folder. Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ Co-authored-by: Claude <noreply@anthropic.com>
317 lines
12 KiB
TypeScript
317 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,
|
|
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 readOriginRemote(session: GithubBrokerSession): Promise<string | null> {
|
|
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<string | null> {
|
|
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);
|
|
});
|
|
}
|