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 <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-18 15:04:20 +02:00 committed by GitHub
parent a27cdf733c
commit 1611fe5fa2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 217 additions and 19 deletions

View File

@ -52,10 +52,13 @@ WORKDIR /home/rakazo
# --chmod normalizes modes: a restrictive host umask (e.g. 077 on hardened hosts) would # --chmod normalizes modes: a restrictive host umask (e.g. 077 on hardened hosts) would
# otherwise leave these files unreadable to USER 1000 at runtime. # 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/ 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 \ RUN mkdir -p /usr/local/libexec/rakazo \
&& mv /usr/bin/git /usr/local/libexec/rakazo/git \ && 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-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-git /usr/local/lib/rakazo/bin/git \
&& ln -sf /usr/local/lib/rakazo/bin/rakazo-gh /usr/local/lib/rakazo/bin/gh && 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 COPY --chmod=755 start.sh /usr/local/bin/rakazo-computer

View File

@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail 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 if [[ "${RAKAZO_GITHUB_BROKER_URL:-}" == "" || "${RAKAZO_GITHUB_BROKER_RUN_TOKEN:-}" == "" ]]; then
exec "$REAL_GH" "$@" exec "$REAL_GH" "$@"
fi fi
@ -9,4 +9,4 @@ if [[ ! "$joined" =~ (^|[[:space:]])pr([[:space:]]|$) ]]; then
exec "$REAL_GH" "$@" exec "$REAL_GH" "$@"
fi fi
payload="$(python3 -c 'import json,sys; print(json.dumps({"argv":["gh"]+sys.argv[1:]}))' "$@")" 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"

View File

@ -23,7 +23,7 @@ done
case "${args[i]:-}" in case "${args[i]:-}" 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[2:],"cwd":sys.argv[1]}))' "$cwd" "${args[@]:i}")" 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 esac
exec "$REAL_GIT" "$@" exec "$REAL_GIT" "$@"

View File

@ -1,5 +1,5 @@
import { execFileSync, spawnSync } from "node:child_process"; 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 { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@ -8,17 +8,23 @@ import { describe, expect, it } from "vitest";
const shimPath = (name: string) => const shimPath = (name: string) =>
fileURLToPath(new URL(`../../computer/bin/${name}`, import.meta.url)); 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") { function brokerPayload(args: string[], cwd: string, shim = "rakazo-git") {
const bin = mkdtempSync(path.join(tmpdir(), "rakazo-git-shim-")); 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', { writeFileSync(path.join(bin, "rakazo-broker"), '#!/bin/sh\nprintf "%s\\n%s" "$1" "$2"\n', {
mode: 0o755, 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, cwd,
encoding: "utf8", encoding: "utf8",
env: { 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_URL: "http://host.docker.internal:7091/internal/github-broker",
RAKAZO_GITHUB_BROKER_RUN_TOKEN: "rgb1.test", RAKAZO_GITHUB_BROKER_RUN_TOKEN: "rgb1.test",
}, },

View File

@ -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<string, string> = {
"/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<BrokerCommandResult> {
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);
}
},
};
}

View File

@ -12,6 +12,12 @@ import {
hostGitInvocation, hostGitInvocation,
hostPathFor, hostPathFor,
} from "./github-broker.js"; } from "./github-broker.js";
import {
demuxDockerLogs,
helperContainerOptions,
readMountPoints,
visibleThroughMounts,
} from "./github-broker-helper.js";
import { hasValidBearerToken } from "./supervisor-logic.js"; import { hasValidBearerToken } from "./supervisor-logic.js";
const session: GithubBrokerSession = { 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" });
});
});

View File

@ -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<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>(); const sessions = new Map<string, GithubBrokerSession>();
export function githubBrokerSessionsForTests() { export function githubBrokerSessionsForTests() {
@ -123,18 +140,21 @@ async function runHostGit(
session: GithubBrokerSession, session: GithubBrokerSession,
argv: string[], argv: string[],
cwd = session.workspacePath, cwd = session.workspacePath,
): Promise<{ stdout: string; stderr: string; code: number }> { ): 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) => { return new Promise((resolve) => {
const child = spawn(argv[0]!, argv.slice(1), { const child = spawn(argv[0]!, argv.slice(1), {
cwd, cwd,
...gitProcessIdentity(session.workspacePath), ...gitProcessIdentity(session.workspacePath),
env: { env: { ...process.env, ...env },
...process.env,
HOME: session.workspacePath,
GIT_TERMINAL_PROMPT: "0",
GH_TOKEN: session.githubToken,
GITHUB_TOKEN: session.githubToken,
},
}); });
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
@ -177,7 +197,12 @@ async function resolveDefaultBranch(session: GithubBrokerSession): Promise<strin
return match?.[1]?.trim() || null; return match?.[1]?.trim() || null;
} }
export function mountGithubBrokerRoutes(app: Hono, supervisorToken: string) { export function mountGithubBrokerRoutes(
app: Hono,
supervisorToken: string,
outside?: BrokerOutsideRunner,
) {
if (outside) runOutside = outside;
app.use("/internal/github-broker/*", async (c, next) => { app.use("/internal/github-broker/*", async (c, next) => {
const pathname = c.req.path; const pathname = c.req.path;
if (pathname.startsWith("/internal/github-broker/sessions")) { if (pathname.startsWith("/internal/github-broker/sessions")) {

View File

@ -52,6 +52,7 @@ import {
xdotoolCommand, xdotoolCommand,
} from "./computer-spec.js"; } from "./computer-spec.js";
import { mountGithubBrokerRoutes } from "./github-broker.js"; import { mountGithubBrokerRoutes } from "./github-broker.js";
import { createHelperRunner } from "./github-broker-helper.js";
import { assertComputerHomeWritable } from "./home-ownership.js"; import { assertComputerHomeWritable } from "./home-ownership.js";
import { import {
assertRequestIdentity, assertRequestIdentity,
@ -135,13 +136,18 @@ const app = new Hono();
export { app as supervisorApp }; 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 // 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 // invisible there, so it also serves the broker alone on the default bridge gateway, which only
// containers on this host can reach. // containers on this host can reach.
const brokerApp = new Hono(); const brokerApp = new Hono();
mountGithubBrokerRoutes(brokerApp, supervisorToken); mountGithubBrokerRoutes(brokerApp, supervisorToken, brokerOutsideRunner);
export function needsBridgeBrokerListener( export function needsBridgeBrokerListener(
hostname: string, hostname: string,