From a36b45b791e02b28bc5373bcc41b1066a688fbd9 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:38:25 +0200 Subject: [PATCH] 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) 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) Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ --------- Co-authored-by: Claude --- infra/sandboxes/computer/bin/rakazo-gh | 5 +- infra/sandboxes/computer/bin/rakazo-git | 7 ++- .../sandboxes/supervisor/src/computer-spec.ts | 2 + .../supervisor/src/github-broker.test.ts | 59 +++++++++++++++++-- .../sandboxes/supervisor/src/github-broker.ts | 55 ++++++++++++++++- infra/sandboxes/supervisor/src/index.test.ts | 8 +++ infra/sandboxes/supervisor/src/index.ts | 37 ++++++++++++ packages/adapters/src/executor.ts | 1 + packages/adapters/src/github-broker-client.ts | 13 +++- .../src/github-broker-integration.test.ts | 10 ++++ 10 files changed, 184 insertions(+), 13 deletions(-) diff --git a/infra/sandboxes/computer/bin/rakazo-gh b/infra/sandboxes/computer/bin/rakazo-gh index 263fd5b..84164e5 100644 --- a/infra/sandboxes/computer/bin/rakazo-gh +++ b/infra/sandboxes/computer/bin/rakazo-gh @@ -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" \ -H "Authorization: Bearer ${RAKAZO_GITHUB_BROKER_RUN_TOKEN}" \ -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")" 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" diff --git a/infra/sandboxes/computer/bin/rakazo-git b/infra/sandboxes/computer/bin/rakazo-git index 11daa33..9127dac 100644 --- a/infra/sandboxes/computer/bin/rakazo-git +++ b/infra/sandboxes/computer/bin/rakazo-git @@ -7,11 +7,14 @@ fi sub="${1:-}" case "$sub" in 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" \ -H "Authorization: Bearer ${RAKAZO_GITHUB_BROKER_RUN_TOKEN}" \ -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")" 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" diff --git a/infra/sandboxes/supervisor/src/computer-spec.ts b/infra/sandboxes/supervisor/src/computer-spec.ts index 952af5e..90775e5 100644 --- a/infra/sandboxes/supervisor/src/computer-spec.ts +++ b/infra/sandboxes/supervisor/src/computer-spec.ts @@ -331,6 +331,8 @@ export function containerCreateOptions(input: ComputerCreateInput) { ...computerResourceLimits(), ReadonlyPaths: ["/usr/share/novnc"], 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", }, WorkingDir: "/home/rakazo", diff --git a/infra/sandboxes/supervisor/src/github-broker.test.ts b/infra/sandboxes/supervisor/src/github-broker.test.ts index b5f3a71..889afc8 100644 --- a/infra/sandboxes/supervisor/src/github-broker.test.ts +++ b/infra/sandboxes/supervisor/src/github-broker.test.ts @@ -1,22 +1,71 @@ import { signGithubBrokerRunToken } from "@rakazo/core/node/github-broker-token"; 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"; +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", () => { it("stores sessions for supervisor-authenticated registration", () => { const token = "supervisor-test-token"; expect(hasValidBearerToken(`Bearer ${token}`, token)).toBe(true); githubBrokerSessionsForTests().set("run-a", { - runId: "run-a", + ...session, workspacePath: "/tmp/project", allowedRemoteUrl: "https://github.com/example/demo.git", - defaultBranch: "main", - githubToken: "ghp_test", - pushApproved: false, }); const runToken = signGithubBrokerRunToken("run-a", token); expect(runToken.startsWith("rgb1.")).toBe(true); 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." }); + }); +}); diff --git a/infra/sandboxes/supervisor/src/github-broker.ts b/infra/sandboxes/supervisor/src/github-broker.ts index 7e462e1..4856d7c 100644 --- a/infra/sandboxes/supervisor/src/github-broker.ts +++ b/infra/sandboxes/supervisor/src/github-broker.ts @@ -18,9 +18,51 @@ export type GithubBrokerSession = { 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; +} + +/** 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(); export function githubBrokerSessionsForTests() { @@ -49,10 +91,11 @@ function sessionForRequest(authorization: string | undefined): GithubBrokerSessi async function runHostGit( session: GithubBrokerSession, argv: string[], + cwd = session.workspacePath, ): Promise<{ stdout: string; stderr: string; code: number }> { return new Promise((resolve) => { const child = spawn(argv[0]!, argv.slice(1), { - cwd: session.workspacePath, + cwd, env: { ...process.env, HOME: session.workspacePath, @@ -126,6 +169,7 @@ export function mountGithubBrokerRoutes(app: Hono, supervisorToken: string) { 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()); @@ -136,6 +180,7 @@ export function mountGithubBrokerRoutes(app: Hono, supervisorToken: string) { allowedRemoteUrl: body.allowedRemoteUrl, defaultBranch: body.defaultBranch, githubToken: body.githubToken, + containerRoot: body.containerRoot ?? DEFAULT_CONTAINER_ROOT, pushApproved: body.pushApproved ?? false, }); 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) => { 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 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); @@ -182,7 +229,9 @@ export function mountGithubBrokerRoutes(app: Hono, supervisorToken: string) { const message = error instanceof Error ? error.message : String(error); 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); }); diff --git a/infra/sandboxes/supervisor/src/index.test.ts b/infra/sandboxes/supervisor/src/index.test.ts index 3b1b0cd..c6a8cc7 100644 --- a/infra/sandboxes/supervisor/src/index.test.ts +++ b/infra/sandboxes/supervisor/src/index.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { MAX_SUPERVISOR_FILE_REQUEST_BYTES, MAX_SUPERVISOR_REQUEST_BYTES, + needsBridgeBrokerListener, resolveDockerSocketPath, supervisorApp, 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", () => { expect(supervisorRequestBodyLimit("POST", "/computers/id/files")).toBe( MAX_SUPERVISOR_FILE_REQUEST_BYTES, diff --git a/infra/sandboxes/supervisor/src/index.ts b/infra/sandboxes/supervisor/src/index.ts index ca95d82..ccccb2f 100644 --- a/infra/sandboxes/supervisor/src/index.ts +++ b/infra/sandboxes/supervisor/src/index.ts @@ -136,6 +136,27 @@ export { app as supervisorApp }; 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 { + const network = await docker.getNetwork("bridge").inspect(); + return network.IPAM?.Config?.find((entry: { Gateway?: string }) => entry.Gateway)?.Gateway; +} + app.use("*", requestLogging()); export function resolveDockerSocketPath( @@ -853,10 +874,26 @@ function startSupervisor() { const server = serve({ fetch: app.fetch, hostname, port, serverOptions }, () => { logger.info("supervisor listening", { "http.host": hostname, "http.port": port }); }); + let brokerServer: ReturnType | 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; const shutdown = async () => { if (stopping) return; stopping = true; + if (brokerServer) await closeListeningServer(brokerServer); await closeListeningServer(server); await logger.flush({ timeoutMs: 2_000 }); process.exit(0); diff --git a/packages/adapters/src/executor.ts b/packages/adapters/src/executor.ts index 3c22ead..2174f2f 100644 --- a/packages/adapters/src/executor.ts +++ b/packages/adapters/src/executor.ts @@ -1218,6 +1218,7 @@ export function createRunExecutor(deps: ExecutorDeps) { allowedRemoteUrl: spaceRecord?.gitRemoteUrl ?? null, defaultBranch: spaceRecord?.gitDefaultBranch ?? null, githubToken: githubIntegrationToken, + containerRoot: projectWorkspacePath ? "/home/rakazo/project" : "/home/rakazo", }).catch(() => undefined); } const agentEnvironment = { diff --git a/packages/adapters/src/github-broker-client.ts b/packages/adapters/src/github-broker-client.ts index 76660f4..2294964 100644 --- a/packages/adapters/src/github-broker-client.ts +++ b/packages/adapters/src/github-broker-client.ts @@ -7,6 +7,8 @@ export type GithubBrokerSessionInput = { allowedRemoteUrl: string | null; defaultBranch: string | null; githubToken: string; + /** Where the workspace is mounted inside the computer. */ + containerRoot: string; }; function supervisorBaseUrl(): string | undefined { @@ -14,11 +16,17 @@ function supervisorBaseUrl(): string | 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(); if (configured) return configured.replace(/\/$/, ""); 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 | undefined { @@ -52,6 +60,7 @@ export async function registerGithubBrokerSession(input: GithubBrokerSessionInpu allowedRemoteUrl: input.allowedRemoteUrl, defaultBranch: input.defaultBranch, githubToken: input.githubToken, + containerRoot: input.containerRoot, pushApproved: false, }), }); diff --git a/packages/adapters/src/github-broker-integration.test.ts b/packages/adapters/src/github-broker-integration.test.ts index 8f797b4..ebb1e49 100644 --- a/packages/adapters/src/github-broker-integration.test.ts +++ b/packages/adapters/src/github-broker-integration.test.ts @@ -1,6 +1,7 @@ import { evaluateGitShellPolicy } from "@rakazo/core"; import { describe, expect, it, vi } from "vitest"; import { githubIntegrationEnvironmentForRun } from "./deployment-github-token.js"; +import { githubBrokerBaseUrl } from "./github-broker-client.js"; describe("home Chief GitHub broker integration", () => { it("exposes broker env without raw GitHub tokens when integration is set", () => { @@ -16,6 +17,15 @@ describe("home Chief GitHub broker integration", () => { 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", () => { const decision = evaluateGitShellPolicy({ command: "git push origin main",