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",