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>
184 lines
6.7 KiB
TypeScript
184 lines
6.7 KiB
TypeScript
import { chownSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { signGithubBrokerRunToken } from "@rakazo/core/node/github-broker-token";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
explainGitFailure,
|
|
type GithubBrokerSession,
|
|
githubBrokerSessionsForTests,
|
|
githubHttpsRemote,
|
|
gitProcessIdentity,
|
|
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 = {
|
|
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", {
|
|
...session,
|
|
workspacePath: "/tmp/project",
|
|
allowedRemoteUrl: "https://github.com/example/demo.git",
|
|
});
|
|
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." });
|
|
});
|
|
});
|
|
|
|
describe("github broker remotes", () => {
|
|
it("sends GitHub remotes over HTTPS without pasted credentials", () => {
|
|
expect(githubHttpsRemote("git@github.com:example/demo.git")).toBe(
|
|
"https://github.com/example/demo.git",
|
|
);
|
|
expect(githubHttpsRemote("ssh://git@github.com/example/demo")).toBe(
|
|
"https://github.com/example/demo",
|
|
);
|
|
expect(githubHttpsRemote("https://x-access-token:rgb1.abc@github.com/example/demo.git")).toBe(
|
|
"https://github.com/example/demo.git",
|
|
);
|
|
expect(githubHttpsRemote("https://gitlab.com/example/demo.git")).toBe(
|
|
"https://gitlab.com/example/demo.git",
|
|
);
|
|
expect(githubHttpsRemote("--depth=1")).toBe("--depth=1");
|
|
});
|
|
|
|
it("points a rejected token at reconnecting GitHub", () => {
|
|
expect(explainGitFailure("remote: Invalid username or token.\n")).toContain(
|
|
"reconnect GitHub under Settings, Integrations",
|
|
);
|
|
expect(explainGitFailure("fatal: repository not found\n")).toBe(
|
|
"fatal: repository not found\n",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("github broker host tools", () => {
|
|
it("ships git and gh in the supervisor image", () => {
|
|
const dockerfile = readFileSync(path.join(import.meta.dirname, "../Dockerfile"), "utf8");
|
|
expect(dockerfile).toMatch(/apt-get install[^\n]* gh git/);
|
|
});
|
|
|
|
it("runs git as the workspace owner only from a root supervisor", () => {
|
|
const workspace = mkdtempSync(path.join(os.tmpdir(), "broker-owner-"));
|
|
try {
|
|
expect(gitProcessIdentity(workspace, 1000)).toBeUndefined();
|
|
expect(gitProcessIdentity(path.join(workspace, "missing"), 0)).toBeUndefined();
|
|
if (process.getuid?.() === 0) {
|
|
chownSync(workspace, 1000, 1000);
|
|
expect(gitProcessIdentity(workspace, 0)).toEqual({ uid: 1000, gid: 1000 });
|
|
}
|
|
} finally {
|
|
rmSync(workspace, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
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" });
|
|
});
|
|
});
|