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:
parent
a27cdf733c
commit
1611fe5fa2
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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" "$@"
|
||||
|
||||
@ -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",
|
||||
},
|
||||
|
||||
100
infra/sandboxes/supervisor/src/github-broker-helper.ts
Normal file
100
infra/sandboxes/supervisor/src/github-broker-helper.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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" });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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>();
|
||||
|
||||
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<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) => {
|
||||
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<strin
|
||||
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) => {
|
||||
const pathname = c.req.path;
|
||||
if (pathname.startsWith("/internal/github-broker/sessions")) {
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user