From 1611fe5fa2ec2b2e4f6ea76e9d5a550e989fc88f Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:04:20 +0200 Subject: [PATCH] Let computers push from project folders through the GitHub broker (#217) 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 --- infra/sandboxes/computer/Dockerfile | 5 +- infra/sandboxes/computer/bin/rakazo-gh | 4 +- infra/sandboxes/computer/bin/rakazo-git | 2 +- .../supervisor/src/computer-git-shim.test.ts | 14 ++- .../supervisor/src/github-broker-helper.ts | 100 ++++++++++++++++++ .../supervisor/src/github-broker.test.ts | 58 ++++++++++ .../sandboxes/supervisor/src/github-broker.ts | 43 ++++++-- infra/sandboxes/supervisor/src/index.ts | 10 +- 8 files changed, 217 insertions(+), 19 deletions(-) create mode 100644 infra/sandboxes/supervisor/src/github-broker-helper.ts diff --git a/infra/sandboxes/computer/Dockerfile b/infra/sandboxes/computer/Dockerfile index 3e2b974..c710847 100644 --- a/infra/sandboxes/computer/Dockerfile +++ b/infra/sandboxes/computer/Dockerfile @@ -52,10 +52,13 @@ WORKDIR /home/rakazo # --chmod normalizes modes: a restrictive host umask (e.g. 077 on hardened hosts) would # otherwise leave these files unreadable to USER 1000 at runtime. COPY --chmod=755 bin/rakazo-git bin/rakazo-gh bin/rakazo-broker /usr/local/lib/rakazo/bin/ -# /usr/bin/git is the shim too, so calling git by absolute path still goes through the broker. +# /usr/bin/git and /usr/bin/gh are the shims too, so absolute paths and login shells that reset +# PATH still go through the broker. RUN mkdir -p /usr/local/libexec/rakazo \ && mv /usr/bin/git /usr/local/libexec/rakazo/git \ + && mv /usr/bin/gh /usr/local/libexec/rakazo/gh \ && ln -sf /usr/local/lib/rakazo/bin/rakazo-git /usr/bin/git \ + && ln -sf /usr/local/lib/rakazo/bin/rakazo-gh /usr/bin/gh \ && ln -sf /usr/local/lib/rakazo/bin/rakazo-git /usr/local/lib/rakazo/bin/git \ && ln -sf /usr/local/lib/rakazo/bin/rakazo-gh /usr/local/lib/rakazo/bin/gh COPY --chmod=755 start.sh /usr/local/bin/rakazo-computer diff --git a/infra/sandboxes/computer/bin/rakazo-gh b/infra/sandboxes/computer/bin/rakazo-gh index 3f3424d..36380b3 100755 --- a/infra/sandboxes/computer/bin/rakazo-gh +++ b/infra/sandboxes/computer/bin/rakazo-gh @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -REAL_GH=/usr/bin/gh +REAL_GH=/usr/local/libexec/rakazo/gh if [[ "${RAKAZO_GITHUB_BROKER_URL:-}" == "" || "${RAKAZO_GITHUB_BROKER_RUN_TOKEN:-}" == "" ]]; then exec "$REAL_GH" "$@" fi @@ -9,4 +9,4 @@ if [[ ! "$joined" =~ (^|[[:space:]])pr([[:space:]]|$) ]]; then exec "$REAL_GH" "$@" fi payload="$(python3 -c 'import json,sys; print(json.dumps({"argv":["gh"]+sys.argv[1:]}))' "$@")" -exec rakazo-broker v1/gh "$payload" +exec "$(dirname "$(readlink -f "$0")")/rakazo-broker" v1/gh "$payload" diff --git a/infra/sandboxes/computer/bin/rakazo-git b/infra/sandboxes/computer/bin/rakazo-git index fbc2b28..617bc5f 100755 --- a/infra/sandboxes/computer/bin/rakazo-git +++ b/infra/sandboxes/computer/bin/rakazo-git @@ -23,7 +23,7 @@ done case "${args[i]:-}" in push | fetch | pull | clone | ls-remote) payload="$(python3 -c 'import json,sys; print(json.dumps({"argv":["git"]+sys.argv[2:],"cwd":sys.argv[1]}))' "$cwd" "${args[@]:i}")" - exec rakazo-broker v1/git "$payload" + exec "$(dirname "$(readlink -f "$0")")/rakazo-broker" v1/git "$payload" ;; esac exec "$REAL_GIT" "$@" diff --git a/infra/sandboxes/supervisor/src/computer-git-shim.test.ts b/infra/sandboxes/supervisor/src/computer-git-shim.test.ts index 0622de3..0e24b25 100644 --- a/infra/sandboxes/supervisor/src/computer-git-shim.test.ts +++ b/infra/sandboxes/supervisor/src/computer-git-shim.test.ts @@ -1,5 +1,5 @@ import { execFileSync, spawnSync } from "node:child_process"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { copyFileSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -8,17 +8,23 @@ import { describe, expect, it } from "vitest"; const shimPath = (name: string) => fileURLToPath(new URL(`../../computer/bin/${name}`, import.meta.url)); -/** Runs the computer's git shim with a stub broker that echoes what it would send. */ +/** + * Runs the computer's git shim through a symlink, as /usr/bin/git does, next to a stub broker that + * echoes what it would send. The stub is not on PATH: login shells reset PATH in the computer. + */ function brokerPayload(args: string[], cwd: string, shim = "rakazo-git") { const bin = mkdtempSync(path.join(tmpdir(), "rakazo-git-shim-")); + copyFileSync(shimPath(shim), path.join(bin, shim)); writeFileSync(path.join(bin, "rakazo-broker"), '#!/bin/sh\nprintf "%s\\n%s" "$1" "$2"\n', { mode: 0o755, }); - const output = execFileSync("bash", [shimPath(shim), ...args], { + const linked = mkdtempSync(path.join(tmpdir(), "rakazo-git-link-")); + symlinkSync(path.join(bin, shim), path.join(linked, shim)); + const output = execFileSync("bash", [path.join(linked, shim), ...args], { cwd, encoding: "utf8", env: { - PATH: `${bin}:${process.env.PATH}`, + PATH: process.env.PATH, RAKAZO_GITHUB_BROKER_URL: "http://host.docker.internal:7091/internal/github-broker", RAKAZO_GITHUB_BROKER_RUN_TOKEN: "rgb1.test", }, diff --git a/infra/sandboxes/supervisor/src/github-broker-helper.ts b/infra/sandboxes/supervisor/src/github-broker-helper.ts new file mode 100644 index 0000000..8ddae04 --- /dev/null +++ b/infra/sandboxes/supervisor/src/github-broker-helper.ts @@ -0,0 +1,100 @@ +import { readFileSync } from "node:fs"; +import type Docker from "dockerode"; +import type { + BrokerCommandInput, + BrokerCommandResult, + BrokerOutsideRunner, +} from "./github-broker.js"; + +// The computer image keeps the real binaries behind its broker shims. +const HELPER_BINARIES: Record = { + "/usr/bin/git": "/usr/local/libexec/rakazo/git", + "/usr/bin/gh": "/usr/local/libexec/rakazo/gh", +}; + +/** Mount points of this process, from /proc/self/mountinfo. */ +export function readMountPoints( + mountinfo = readFileSync("/proc/self/mountinfo", "utf8"), +): string[] { + return mountinfo + .split("\n") + .map((line) => line.split(" ")[4]) + .filter((point): point is string => Boolean(point)) + .map((point) => point.replace(/\\040/g, " ")); +} + +/** + * Inside a container only bind mounts reach the host; parent folders of a mount exist but are empty, + * so a path counts as visible only below a mount other than the root. + */ +export function visibleThroughMounts(target: string, mountPoints: string[]): boolean { + return mountPoints.some( + (point) => point !== "/" && (target === point || target.startsWith(`${point}/`)), + ); +} + +/** A one-off container that binds the workspace a containerized supervisor cannot see. */ +export function helperContainerOptions( + input: BrokerCommandInput, + image: string, + user: string, +): Docker.ContainerCreateOptions { + const [binary = "", ...args] = input.argv; + return { + Image: image, + Entrypoint: [HELPER_BINARIES[binary] ?? binary], + Cmd: args, + WorkingDir: input.cwd, + User: user, + Env: Object.entries(input.env).map(([name, value]) => `${name}=${value}`), + Labels: { "rakazo.role": "github-broker-helper" }, + HostConfig: { Binds: [`${input.workspacePath}:${input.workspacePath}`] }, + }; +} + +/** Splits Docker's multiplexed log stream: an 8-byte header (stream id, size) per frame. */ +export function demuxDockerLogs(buffer: Buffer): { stdout: string; stderr: string } { + const out: Buffer[] = []; + const err: Buffer[] = []; + let offset = 0; + while (offset + 8 <= buffer.length) { + const size = buffer.readUInt32BE(offset + 4); + const frame = buffer.subarray(offset + 8, offset + 8 + size); + (buffer[offset] === 2 ? err : out).push(frame); + offset += 8 + size; + } + return { + stdout: Buffer.concat(out).toString("utf8"), + stderr: Buffer.concat(err).toString("utf8"), + }; +} + +export function createHelperRunner( + docker: Docker, + image: string, + user: string, + mountPoints: string[] = readMountPoints(), +): BrokerOutsideRunner { + return { + sees: (target) => visibleThroughMounts(target, mountPoints), + async run(input): Promise { + let container: Docker.Container | undefined; + try { + container = await docker.createContainer(helperContainerOptions(input, image, user)); + await container.start(); + const { StatusCode } = (await container.wait()) as { StatusCode: number }; + const logs = (await container.logs({ + stdout: true, + stderr: true, + follow: false, + })) as Buffer; + return { ...demuxDockerLogs(logs), code: StatusCode }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { stdout: "", stderr: message, code: 1 }; + } finally { + await container?.remove({ force: true }).catch(() => undefined); + } + }, + }; +} diff --git a/infra/sandboxes/supervisor/src/github-broker.test.ts b/infra/sandboxes/supervisor/src/github-broker.test.ts index e5d42ae..563a615 100644 --- a/infra/sandboxes/supervisor/src/github-broker.test.ts +++ b/infra/sandboxes/supervisor/src/github-broker.test.ts @@ -12,6 +12,12 @@ import { hostGitInvocation, hostPathFor, } from "./github-broker.js"; +import { + demuxDockerLogs, + helperContainerOptions, + readMountPoints, + visibleThroughMounts, +} from "./github-broker-helper.js"; import { hasValidBearerToken } from "./supervisor-logic.js"; const session: GithubBrokerSession = { @@ -123,3 +129,55 @@ describe("github broker host tools", () => { } }); }); + +describe("github broker helper container", () => { + const mounts = readMountPoints( + [ + "1 0 0:1 / / rw - overlay overlay rw", + "2 1 0:2 /srv/data /srv/data rw - ext4 /dev/sda1 rw", + "3 1 0:3 / /var/run/docker.sock rw - tmpfs tmpfs rw", + ].join("\n"), + ); + + it("sees only paths below the supervisor's own mounts", () => { + expect(mounts).toEqual(["/", "/srv/data", "/var/run/docker.sock"]); + expect(visibleThroughMounts("/srv/data/homes/chief", mounts)).toBe(true); + // The parent of a bind mount exists in the container but is empty. + expect(visibleThroughMounts("/srv", mounts)).toBe(false); + expect(visibleThroughMounts("/home/owner/Projects/app", mounts)).toBe(false); + }); + + it("binds the workspace and runs the computer image's real binary", () => { + const options = helperContainerOptions( + { + argv: ["/usr/bin/git", "push", "origin", "fix"], + cwd: "/home/owner/Projects/app", + env: { GITHUB_TOKEN: "ghp_test" }, + workspacePath: "/home/owner/Projects/app", + }, + "rakazo/computer:local", + "1000:1000", + ); + expect(options).toMatchObject({ + Image: "rakazo/computer:local", + Entrypoint: ["/usr/local/libexec/rakazo/git"], + Cmd: ["push", "origin", "fix"], + WorkingDir: "/home/owner/Projects/app", + User: "1000:1000", + Env: ["GITHUB_TOKEN=ghp_test"], + HostConfig: { Binds: ["/home/owner/Projects/app:/home/owner/Projects/app"] }, + }); + }); + + it("splits Docker's multiplexed output into stdout and stderr", () => { + const frame = (stream: number, text: string) => { + const header = Buffer.alloc(8); + header[0] = stream; + header.writeUInt32BE(Buffer.byteLength(text), 4); + return Buffer.concat([header, Buffer.from(text)]); + }; + expect( + demuxDockerLogs(Buffer.concat([frame(1, "out"), frame(2, "err"), frame(1, "!")])), + ).toEqual({ stdout: "out!", stderr: "err" }); + }); +}); diff --git a/infra/sandboxes/supervisor/src/github-broker.ts b/infra/sandboxes/supervisor/src/github-broker.ts index b799d7a..9e23091 100644 --- a/infra/sandboxes/supervisor/src/github-broker.ts +++ b/infra/sandboxes/supervisor/src/github-broker.ts @@ -94,6 +94,23 @@ export function gitProcessIdentity( } } +export type BrokerCommandResult = { stdout: string; stderr: string; code: number }; + +export type BrokerCommandInput = { + argv: string[]; + cwd: string; + env: Record; + 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; +}; + +let runOutside: BrokerOutsideRunner | undefined; + const sessions = new Map(); export function githubBrokerSessionsForTests() { @@ -123,18 +140,21 @@ async function runHostGit( session: GithubBrokerSession, argv: string[], cwd = session.workspacePath, -): Promise<{ stdout: string; stderr: string; code: number }> { +): Promise { + 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, - HOME: session.workspacePath, - GIT_TERMINAL_PROMPT: "0", - GH_TOKEN: session.githubToken, - GITHUB_TOKEN: session.githubToken, - }, + env: { ...process.env, ...env }, }); let stdout = ""; let stderr = ""; @@ -177,7 +197,12 @@ async function resolveDefaultBranch(session: GithubBrokerSession): Promise { const pathname = c.req.path; if (pathname.startsWith("/internal/github-broker/sessions")) { diff --git a/infra/sandboxes/supervisor/src/index.ts b/infra/sandboxes/supervisor/src/index.ts index 45f1c48..d417684 100644 --- a/infra/sandboxes/supervisor/src/index.ts +++ b/infra/sandboxes/supervisor/src/index.ts @@ -52,6 +52,7 @@ import { xdotoolCommand, } from "./computer-spec.js"; import { mountGithubBrokerRoutes } from "./github-broker.js"; +import { createHelperRunner } from "./github-broker-helper.js"; import { assertComputerHomeWritable } from "./home-ownership.js"; import { assertRequestIdentity, @@ -135,13 +136,18 @@ const app = new Hono(); export { app as supervisorApp }; -mountGithubBrokerRoutes(app, supervisorToken); +// A containerized supervisor only sees its own mounts; git for other project folders runs in a +// one-off computer-image container that binds the folder. +const brokerOutsideRunner = existsSync("/.dockerenv") + ? createHelperRunner(docker, COMPUTER_IMAGE, COMPUTER_USER) + : undefined; +mountGithubBrokerRoutes(app, supervisorToken, brokerOutsideRunner); // 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); +mountGithubBrokerRoutes(brokerApp, supervisorToken, brokerOutsideRunner); export function needsBridgeBrokerListener( hostname: string,