Let computer git reach GitHub through the broker with working credentials (#210)

* Let computer git reach GitHub through the broker with working credentials

The broker URL handed to computers pointed at the supervisor's loopback
address, which a container cannot reach, and host git ignored the
integration token for HTTPS remotes. Computers now get the host through
host.docker.internal, host git receives the token via a one-off
credential helper, and clone paths and the working directory map from
the computer onto the host workspace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

* Serve the GitHub broker to computers on a Linux host-run supervisor

A loopback-bound supervisor is unreachable from containers on Linux, so
it also serves the broker routes alone on the default bridge gateway,
which only containers on the same host can reach.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-17 16:38:25 +02:00 committed by GitHub
parent ff7e95a081
commit a36b45b791
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 184 additions and 13 deletions

View File

@ -12,7 +12,10 @@ payload="$(python3 -c 'import json,sys; print(json.dumps({"argv":["gh"]+sys.argv
response="$(curl -fsS -X POST "${RAKAZO_GITHUB_BROKER_URL}/v1/gh" \ response="$(curl -fsS -X POST "${RAKAZO_GITHUB_BROKER_URL}/v1/gh" \
-H "Authorization: Bearer ${RAKAZO_GITHUB_BROKER_RUN_TOKEN}" \ -H "Authorization: Bearer ${RAKAZO_GITHUB_BROKER_RUN_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
--data-binary "$payload")" --data-binary "$payload")" || {
echo "GitHub access through evOo failed (${RAKAZO_GITHUB_BROKER_URL})." >&2
exit 1
}
code="$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("code",1))' <<<"$response")" code="$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("code",1))' <<<"$response")"
python3 -c 'import json,sys; b=json.load(sys.stdin); sys.stdout.write(b.get("stdout") or ""); sys.stderr.write(b.get("stderr") or "")' <<<"$response" python3 -c 'import json,sys; b=json.load(sys.stdin); sys.stdout.write(b.get("stdout") or ""); sys.stderr.write(b.get("stderr") or "")' <<<"$response"
exit "$code" exit "$code"

View File

@ -7,11 +7,14 @@ fi
sub="${1:-}" sub="${1:-}"
case "$sub" in case "$sub" in
push|fetch|pull|clone|ls-remote) push|fetch|pull|clone|ls-remote)
payload="$(python3 -c 'import json,sys; print(json.dumps({"argv":["git"]+sys.argv[1:]}))' -- "$@")" payload="$(python3 -c 'import json,os,sys; print(json.dumps({"argv":["git"]+sys.argv[1:],"cwd":os.getcwd()}))' -- "$@")"
response="$(curl -fsS -X POST "${RAKAZO_GITHUB_BROKER_URL}/v1/git" \ response="$(curl -fsS -X POST "${RAKAZO_GITHUB_BROKER_URL}/v1/git" \
-H "Authorization: Bearer ${RAKAZO_GITHUB_BROKER_RUN_TOKEN}" \ -H "Authorization: Bearer ${RAKAZO_GITHUB_BROKER_RUN_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
--data-binary "$payload")" --data-binary "$payload")" || {
echo "GitHub access through evOo failed (${RAKAZO_GITHUB_BROKER_URL})." >&2
exit 1
}
code="$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("code",1))' <<<"$response")" code="$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("code",1))' <<<"$response")"
python3 -c 'import json,sys; b=json.load(sys.stdin); sys.stdout.write(b.get("stdout") or ""); sys.stderr.write(b.get("stderr") or "")' <<<"$response" python3 -c 'import json,sys; b=json.load(sys.stdin); sys.stdout.write(b.get("stdout") or ""); sys.stderr.write(b.get("stderr") or "")' <<<"$response"
exit "$code" exit "$code"

View File

@ -331,6 +331,8 @@ export function containerCreateOptions(input: ComputerCreateInput) {
...computerResourceLimits(), ...computerResourceLimits(),
ReadonlyPaths: ["/usr/share/novnc"], ReadonlyPaths: ["/usr/share/novnc"],
AutoRemove: false, AutoRemove: false,
// Linux has no host.docker.internal by default; the GitHub broker is reached through it.
ExtraHosts: ["host.docker.internal:host-gateway"],
NetworkMode: input.networkMode ?? "bridge", NetworkMode: input.networkMode ?? "bridge",
}, },
WorkingDir: "/home/rakazo", WorkingDir: "/home/rakazo",

View File

@ -1,22 +1,71 @@
import { signGithubBrokerRunToken } from "@rakazo/core/node/github-broker-token"; import { signGithubBrokerRunToken } from "@rakazo/core/node/github-broker-token";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { githubBrokerSessionsForTests } from "./github-broker.js"; import {
type GithubBrokerSession,
githubBrokerSessionsForTests,
hostGitInvocation,
hostPathFor,
} from "./github-broker.js";
import { hasValidBearerToken } from "./supervisor-logic.js"; import { hasValidBearerToken } from "./supervisor-logic.js";
const session: GithubBrokerSession = {
runId: "run-a",
workspacePath: "/srv/homes/chief",
allowedRemoteUrl: null,
defaultBranch: "main",
githubToken: "ghp_test",
containerRoot: "/home/rakazo",
pushApproved: false,
};
describe("github broker sessions", () => { describe("github broker sessions", () => {
it("stores sessions for supervisor-authenticated registration", () => { it("stores sessions for supervisor-authenticated registration", () => {
const token = "supervisor-test-token"; const token = "supervisor-test-token";
expect(hasValidBearerToken(`Bearer ${token}`, token)).toBe(true); expect(hasValidBearerToken(`Bearer ${token}`, token)).toBe(true);
githubBrokerSessionsForTests().set("run-a", { githubBrokerSessionsForTests().set("run-a", {
runId: "run-a", ...session,
workspacePath: "/tmp/project", workspacePath: "/tmp/project",
allowedRemoteUrl: "https://github.com/example/demo.git", allowedRemoteUrl: "https://github.com/example/demo.git",
defaultBranch: "main",
githubToken: "ghp_test",
pushApproved: false,
}); });
const runToken = signGithubBrokerRunToken("run-a", token); const runToken = signGithubBrokerRunToken("run-a", token);
expect(runToken.startsWith("rgb1.")).toBe(true); expect(runToken.startsWith("rgb1.")).toBe(true);
githubBrokerSessionsForTests().delete("run-a"); githubBrokerSessionsForTests().delete("run-a");
}); });
}); });
describe("github broker host git", () => {
it("maps computer paths onto the host workspace", () => {
expect(hostPathFor(session, "/home/rakazo")).toBe("/srv/homes/chief");
expect(hostPathFor(session, "/home/rakazo/repos/demo")).toBe("/srv/homes/chief/repos/demo");
expect(hostPathFor(session, "/home/rakazo/../etc")).toBeNull();
expect(hostPathFor(session, "/tmp")).toBeNull();
expect(hostPathFor(session, "/home/rakazoX")).toBeNull();
});
it("clones where the agent stands, with the token supplied as a git credential", () => {
const invocation = hostGitInvocation(
session,
["git", "clone", "https://github.com/example/demo.git", "/home/rakazo/repos/demo"],
"/home/rakazo/repos",
);
if ("error" in invocation) throw new Error(invocation.error);
expect(invocation.cwd).toBe("/srv/homes/chief/repos");
expect(invocation.argv[0]).toBe("/usr/bin/git");
expect(invocation.argv).toContain("credential.helper=");
expect(invocation.argv.slice(-3)).toEqual([
"clone",
"https://github.com/example/demo.git",
"/srv/homes/chief/repos/demo",
]);
expect(invocation.argv.join(" ")).not.toContain("ghp_test");
});
it("refuses paths outside the mounted workspace", () => {
expect(hostGitInvocation(session, ["git", "clone", "https://x/y.git"], "/tmp")).toEqual({
error: "Run git inside /home/rakazo.",
});
expect(
hostGitInvocation(session, ["git", "clone", "https://x/y.git", "/etc/y"], "/home/rakazo"),
).toEqual({ error: "Use a path inside /home/rakazo." });
});
});

View File

@ -18,9 +18,51 @@ export type GithubBrokerSession = {
allowedRemoteUrl: string | null; allowedRemoteUrl: string | null;
defaultBranch: string | null; defaultBranch: string | null;
githubToken: string; githubToken: string;
/** Where workspacePath is mounted inside the computer. */
containerRoot: string;
pushApproved: boolean; 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;
}
/** 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(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 };
}
const sessions = new Map<string, GithubBrokerSession>(); const sessions = new Map<string, GithubBrokerSession>();
export function githubBrokerSessionsForTests() { export function githubBrokerSessionsForTests() {
@ -49,10 +91,11 @@ function sessionForRequest(authorization: string | undefined): GithubBrokerSessi
async function runHostGit( async function runHostGit(
session: GithubBrokerSession, session: GithubBrokerSession,
argv: string[], argv: string[],
cwd = session.workspacePath,
): Promise<{ stdout: string; stderr: string; code: number }> { ): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve) => { return new Promise((resolve) => {
const child = spawn(argv[0]!, argv.slice(1), { const child = spawn(argv[0]!, argv.slice(1), {
cwd: session.workspacePath, cwd,
env: { env: {
...process.env, ...process.env,
HOME: session.workspacePath, HOME: session.workspacePath,
@ -126,6 +169,7 @@ export function mountGithubBrokerRoutes(app: Hono, supervisorToken: string) {
allowedRemoteUrl: z.string().nullable(), allowedRemoteUrl: z.string().nullable(),
defaultBranch: z.string().nullable(), defaultBranch: z.string().nullable(),
githubToken: z.string().min(1), githubToken: z.string().min(1),
containerRoot: z.string().min(1).optional(),
pushApproved: z.boolean().optional(), pushApproved: z.boolean().optional(),
}) })
.parse(await c.req.json()); .parse(await c.req.json());
@ -136,6 +180,7 @@ export function mountGithubBrokerRoutes(app: Hono, supervisorToken: string) {
allowedRemoteUrl: body.allowedRemoteUrl, allowedRemoteUrl: body.allowedRemoteUrl,
defaultBranch: body.defaultBranch, defaultBranch: body.defaultBranch,
githubToken: body.githubToken, githubToken: body.githubToken,
containerRoot: body.containerRoot ?? DEFAULT_CONTAINER_ROOT,
pushApproved: body.pushApproved ?? false, pushApproved: body.pushApproved ?? false,
}); });
return c.json({ ok: true }); return c.json({ ok: true });
@ -158,7 +203,9 @@ export function mountGithubBrokerRoutes(app: Hono, supervisorToken: string) {
app.post("/internal/github-broker/v1/git", async (c) => { app.post("/internal/github-broker/v1/git", async (c) => {
const session = sessionForRequest(c.req.header("authorization")); const session = sessionForRequest(c.req.header("authorization"));
if (!session) return c.json({ error: "unauthorized" }, 401); 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 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]; const argv = body.argv[0] === "git" ? body.argv : ["git", ...body.argv];
if (!gitNetworkArgv(argv)) { if (!gitNetworkArgv(argv)) {
return c.json({ error: "unsupported git invocation" }, 400); return c.json({ error: "unsupported git invocation" }, 400);
@ -182,7 +229,9 @@ export function mountGithubBrokerRoutes(app: Hono, supervisorToken: string) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
return c.json({ stdout: "", stderr: message, code: 1 }); return c.json({ stdout: "", stderr: message, code: 1 });
} }
const result = await runHostGit(session, ["/usr/bin/git", ...argv.slice(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); return c.json(result);
}); });

View File

@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
import { import {
MAX_SUPERVISOR_FILE_REQUEST_BYTES, MAX_SUPERVISOR_FILE_REQUEST_BYTES,
MAX_SUPERVISOR_REQUEST_BYTES, MAX_SUPERVISOR_REQUEST_BYTES,
needsBridgeBrokerListener,
resolveDockerSocketPath, resolveDockerSocketPath,
supervisorApp, supervisorApp,
supervisorRequestBodyLimit, supervisorRequestBodyLimit,
@ -194,6 +195,13 @@ describe("sandbox supervisor HTTP boundary", () => {
} }
}); });
it("serves the GitHub broker on the bridge only for a loopback supervisor on a Linux host", () => {
expect(needsBridgeBrokerListener("127.0.0.1", "linux", false)).toBe(true);
expect(needsBridgeBrokerListener("127.0.0.1", "linux", true)).toBe(false);
expect(needsBridgeBrokerListener("127.0.0.1", "darwin", false)).toBe(false);
expect(needsBridgeBrokerListener("0.0.0.0", "linux", false)).toBe(false);
});
it("keeps the larger file-write allowance scoped to that exact POST route", () => { it("keeps the larger file-write allowance scoped to that exact POST route", () => {
expect(supervisorRequestBodyLimit("POST", "/computers/id/files")).toBe( expect(supervisorRequestBodyLimit("POST", "/computers/id/files")).toBe(
MAX_SUPERVISOR_FILE_REQUEST_BYTES, MAX_SUPERVISOR_FILE_REQUEST_BYTES,

View File

@ -136,6 +136,27 @@ export { app as supervisorApp };
mountGithubBrokerRoutes(app, supervisorToken); mountGithubBrokerRoutes(app, supervisorToken);
// Computers reach the broker through host.docker.internal. A loopback-bound supervisor on Linux is
// invisible there, so it also serves the broker alone on the default bridge gateway, which only
// containers on this host can reach.
const brokerApp = new Hono();
mountGithubBrokerRoutes(brokerApp, supervisorToken);
export function needsBridgeBrokerListener(
hostname: string,
platform: NodeJS.Platform = process.platform,
inContainer = existsSync("/.dockerenv"),
): boolean {
return (
platform === "linux" && !inContainer && ["127.0.0.1", "localhost", "::1"].includes(hostname)
);
}
async function bridgeGatewayAddress(): Promise<string | undefined> {
const network = await docker.getNetwork("bridge").inspect();
return network.IPAM?.Config?.find((entry: { Gateway?: string }) => entry.Gateway)?.Gateway;
}
app.use("*", requestLogging()); app.use("*", requestLogging());
export function resolveDockerSocketPath( export function resolveDockerSocketPath(
@ -853,10 +874,26 @@ function startSupervisor() {
const server = serve({ fetch: app.fetch, hostname, port, serverOptions }, () => { const server = serve({ fetch: app.fetch, hostname, port, serverOptions }, () => {
logger.info("supervisor listening", { "http.host": hostname, "http.port": port }); logger.info("supervisor listening", { "http.host": hostname, "http.port": port });
}); });
let brokerServer: ReturnType<typeof serve> | undefined;
if (needsBridgeBrokerListener(hostname)) {
void bridgeGatewayAddress()
.then((gateway) => {
if (!gateway) throw new Error("the default bridge network has no gateway");
brokerServer = serve({ fetch: brokerApp.fetch, hostname: gateway, port }, () => {
logger.info("github broker listening", { "http.host": gateway, "http.port": port });
});
})
.catch((error: unknown) => {
logger.warn("github broker unavailable to computers", {
"error.message": error instanceof Error ? error.message : String(error),
});
});
}
let stopping = false; let stopping = false;
const shutdown = async () => { const shutdown = async () => {
if (stopping) return; if (stopping) return;
stopping = true; stopping = true;
if (brokerServer) await closeListeningServer(brokerServer);
await closeListeningServer(server); await closeListeningServer(server);
await logger.flush({ timeoutMs: 2_000 }); await logger.flush({ timeoutMs: 2_000 });
process.exit(0); process.exit(0);

View File

@ -1218,6 +1218,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
allowedRemoteUrl: spaceRecord?.gitRemoteUrl ?? null, allowedRemoteUrl: spaceRecord?.gitRemoteUrl ?? null,
defaultBranch: spaceRecord?.gitDefaultBranch ?? null, defaultBranch: spaceRecord?.gitDefaultBranch ?? null,
githubToken: githubIntegrationToken, githubToken: githubIntegrationToken,
containerRoot: projectWorkspacePath ? "/home/rakazo/project" : "/home/rakazo",
}).catch(() => undefined); }).catch(() => undefined);
} }
const agentEnvironment = { const agentEnvironment = {

View File

@ -7,6 +7,8 @@ export type GithubBrokerSessionInput = {
allowedRemoteUrl: string | null; allowedRemoteUrl: string | null;
defaultBranch: string | null; defaultBranch: string | null;
githubToken: string; githubToken: string;
/** Where the workspace is mounted inside the computer. */
containerRoot: string;
}; };
function supervisorBaseUrl(): string | undefined { function supervisorBaseUrl(): string | undefined {
@ -14,11 +16,17 @@ function supervisorBaseUrl(): string | undefined {
return raw ? raw.replace(/\/$/, "") : undefined; return raw ? raw.replace(/\/$/, "") : undefined;
} }
function githubBrokerBaseUrl(): string | undefined { const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
/** The computer runs in a container, so a loopback supervisor address must become the host gateway. */
export function githubBrokerBaseUrl(): string | undefined {
const configured = process.env.SANDBOX_GITHUB_BROKER_URL?.trim(); const configured = process.env.SANDBOX_GITHUB_BROKER_URL?.trim();
if (configured) return configured.replace(/\/$/, ""); if (configured) return configured.replace(/\/$/, "");
const supervisor = supervisorBaseUrl(); const supervisor = supervisorBaseUrl();
return supervisor ? `${supervisor}/internal/github-broker` : undefined; if (!supervisor) return undefined;
const url = new URL(`${supervisor}/internal/github-broker`);
if (LOOPBACK_HOSTS.has(url.hostname)) url.hostname = "host.docker.internal";
return url.toString().replace(/\/$/, "");
} }
function supervisorAuthHeaders(): Record<string, string> | undefined { function supervisorAuthHeaders(): Record<string, string> | undefined {
@ -52,6 +60,7 @@ export async function registerGithubBrokerSession(input: GithubBrokerSessionInpu
allowedRemoteUrl: input.allowedRemoteUrl, allowedRemoteUrl: input.allowedRemoteUrl,
defaultBranch: input.defaultBranch, defaultBranch: input.defaultBranch,
githubToken: input.githubToken, githubToken: input.githubToken,
containerRoot: input.containerRoot,
pushApproved: false, pushApproved: false,
}), }),
}); });

View File

@ -1,6 +1,7 @@
import { evaluateGitShellPolicy } from "@rakazo/core"; import { evaluateGitShellPolicy } from "@rakazo/core";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { githubIntegrationEnvironmentForRun } from "./deployment-github-token.js"; import { githubIntegrationEnvironmentForRun } from "./deployment-github-token.js";
import { githubBrokerBaseUrl } from "./github-broker-client.js";
describe("home Chief GitHub broker integration", () => { describe("home Chief GitHub broker integration", () => {
it("exposes broker env without raw GitHub tokens when integration is set", () => { it("exposes broker env without raw GitHub tokens when integration is set", () => {
@ -16,6 +17,15 @@ describe("home Chief GitHub broker integration", () => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
}); });
it("points computers at the host when the supervisor listens on loopback", () => {
vi.stubEnv("SANDBOX_GITHUB_BROKER_URL", "");
vi.stubEnv("SANDBOX_SUPERVISOR_URL", "http://127.0.0.1:7091");
expect(githubBrokerBaseUrl()).toBe("http://host.docker.internal:7091/internal/github-broker");
vi.stubEnv("SANDBOX_SUPERVISOR_URL", "http://supervisor:7091/");
expect(githubBrokerBaseUrl()).toBe("http://supervisor:7091/internal/github-broker");
vi.unstubAllEnvs();
});
it("refuses push to the default branch even when approval was granted", () => { it("refuses push to the default branch even when approval was granted", () => {
const decision = evaluateGitShellPolicy({ const decision = evaluateGitShellPolicy({
command: "git push origin main", command: "git push origin main",