Fix brokered git calls being rejected before reaching GitHub (#211)

The computer's git and gh shims passed a literal "--" into the JSON argv
(python -c keeps it in sys.argv), so every brokered clone arrived as
"git -- clone" and the broker refused it. Agents then fell back to real
git with the broker run token as a password, which GitHub rejects.

- Drop the stray "--" from both shim payloads.
- Route git through the broker even behind -c/-C/--no-pager options.
- Share one broker client that falls back to the default gateway for
  computers created before host.docker.internal was mapped, and says
  plainly when the broker is unreachable or refuses a request.
- Broker normalizes GitHub SSH remotes and pasted credentials to plain
  HTTPS and points a rejected stored token at reconnecting GitHub.
- Prompt says plain git clone works and the run token is not a GitHub token.


Claude-Session: https://claude.ai/code/session_01Tgvrj34NMnaqvtgvruYQZZ

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
cynarAI 2026-10-17 18:04:02 +02:00 committed by GitHub
parent a36b45b791
commit d962e5e7f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 174 additions and 29 deletions

View File

@ -51,7 +51,7 @@ ENV PATH=/usr/local/lib/rakazo/bin:/home/rakazo/.local/bin:/usr/local/sbin:/usr/
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 /usr/local/lib/rakazo/bin/
COPY --chmod=755 bin/rakazo-git bin/rakazo-gh bin/rakazo-broker /usr/local/lib/rakazo/bin/
RUN 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

View File

@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Usage: rakazo-broker <endpoint> <json-payload>. Posts to the evOo GitHub broker and replays its output.
set -euo pipefail
endpoint="$1"
payload="$2"
post() {
curl -fsS -X POST "$1/$endpoint" \
-H "Authorization: Bearer ${RAKAZO_GITHUB_BROKER_RUN_TOKEN}" \
-H "Content-Type: application/json" \
--data-binary "$payload"
}
# Computers created before host.docker.internal was mapped still reach the host on their default gateway.
gateway_url() {
python3 -c '
import socket, struct, sys, urllib.parse
url = urllib.parse.urlsplit(sys.argv[1])
if url.hostname != "host.docker.internal":
sys.exit(1)
for line in open("/proc/net/route").readlines()[1:]:
fields = line.split()
if fields[1] == "00000000":
gateway = socket.inet_ntoa(struct.pack("<L", int(fields[2], 16)))
print(url._replace(netloc=gateway + (f":{url.port}" if url.port else "")).geturl())
sys.exit(0)
sys.exit(1)
' "$RAKAZO_GITHUB_BROKER_URL"
}
unreachable() {
echo "evOo could not reach its GitHub connection from this computer. Tell the user GitHub access is broken on the evOo server; do not ask for a token." >&2
exit 1
}
status=0
response="$(post "$RAKAZO_GITHUB_BROKER_URL" 2>/dev/null)" || status=$?
# 6: unresolved host, 7: connection refused, 28: timeout. Anything else is the broker answering.
if [[ "$status" == 6 || "$status" == 7 || "$status" == 28 ]]; then
fallback="$(gateway_url)" || unreachable
status=0
response="$(post "$fallback" 2>/dev/null)" || status=$?
fi
if [[ "$status" == 22 ]]; then
echo "evOo refused this GitHub request. Retry once; if it fails again, tell the user. Do not ask for a token." >&2
exit 1
fi
[[ "$status" == 0 ]] || unreachable
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 b.get("error") or "")' <<<"$response"
exit "$code"

13
infra/sandboxes/computer/bin/rakazo-gh Normal file → Executable file
View File

@ -8,14 +8,5 @@ joined="$*"
if [[ ! "$joined" =~ (^|[[:space:]])pr([[:space:]]|$) ]]; then
exec "$REAL_GH" "$@"
fi
payload="$(python3 -c 'import json,sys; print(json.dumps({"argv":["gh"]+sys.argv[1:]}))' -- "$@")"
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")" || {
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"
payload="$(python3 -c 'import json,sys; print(json.dumps({"argv":["gh"]+sys.argv[1:]}))' "$@")"
exec rakazo-broker v1/gh "$payload"

34
infra/sandboxes/computer/bin/rakazo-git Normal file → Executable file
View File

@ -4,20 +4,26 @@ REAL_GIT=/usr/bin/git
if [[ "${RAKAZO_GITHUB_BROKER_URL:-}" == "" || "${RAKAZO_GITHUB_BROKER_RUN_TOKEN:-}" == "" ]]; then
exec "$REAL_GIT" "$@"
fi
sub="${1:-}"
case "$sub" in
push|fetch|pull|clone|ls-remote)
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")" || {
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"
# Skip global options so `git -c … clone` and `git -C dir fetch` still go through the broker.
# Config overrides are dropped: the broker supplies the credentials.
args=("$@")
i=0
cwd="$PWD"
while (( i < ${#args[@]} )); do
case "${args[i]}" in
-c) i=$((i + 2)) ;;
-C)
cwd="$(cd "$cwd" && cd "${args[i + 1]:-}" && pwd)" || exec "$REAL_GIT" "$@"
i=$((i + 2))
;;
--no-pager | -P) i=$((i + 1)) ;;
*) break ;;
esac
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"
;;
esac
exec "$REAL_GIT" "$@"

View File

@ -0,0 +1,55 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
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. */
function brokerPayload(args: string[], cwd: string, shim = "rakazo-git") {
const bin = mkdtempSync(path.join(tmpdir(), "rakazo-git-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], {
cwd,
encoding: "utf8",
env: {
PATH: `${bin}:${process.env.PATH}`,
RAKAZO_GITHUB_BROKER_URL: "http://host.docker.internal:7091/internal/github-broker",
RAKAZO_GITHUB_BROKER_RUN_TOKEN: "rgb1.test",
},
});
const [endpoint, payload] = output.split("\n");
return { endpoint, body: JSON.parse(payload ?? "{}") as { argv: string[]; cwd: string } };
}
describe("computer git and gh shims", () => {
it("brokers a plain clone from the current directory", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "rakazo-git-cwd-"));
const { endpoint, body } = brokerPayload(["clone", "https://github.com/example/demo"], cwd);
expect(endpoint).toBe("v1/git");
expect(body).toEqual({ argv: ["git", "clone", "https://github.com/example/demo"], cwd });
});
it("brokers clones behind global options and drops config overrides", () => {
const cwd = mkdtempSync(path.join(tmpdir(), "rakazo-git-cwd-"));
const { body } = brokerPayload(
["-c", "credential.helper=!echo password=x", "-C", tmpdir(), "fetch", "origin"],
cwd,
);
expect(body.argv).toEqual(["git", "fetch", "origin"]);
expect(body.cwd).toBe(
execFileSync("bash", ["-c", 'cd "$1" && pwd', "-", tmpdir()], { encoding: "utf8" }).trim(),
);
});
it("brokers gh pull request commands", () => {
const { endpoint, body } = brokerPayload(["pr", "list"], tmpdir(), "rakazo-gh");
expect(endpoint).toBe("v1/gh");
expect(body.argv).toEqual(["gh", "pr", "list"]);
});
});

View File

@ -1,8 +1,10 @@
import { signGithubBrokerRunToken } from "@rakazo/core/node/github-broker-token";
import { describe, expect, it } from "vitest";
import {
explainGitFailure,
type GithubBrokerSession,
githubBrokerSessionsForTests,
githubHttpsRemote,
hostGitInvocation,
hostPathFor,
} from "./github-broker.js";
@ -69,3 +71,30 @@ describe("github broker host git", () => {
).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",
);
});
});

View File

@ -42,6 +42,22 @@ export function hostPathFor(session: GithubBrokerSession, containerPath: string)
return inside.startsWith("..") || path.isAbsolute(inside) ? null : resolved;
}
/** GitHub remotes always go over HTTPS with the broker's token: drop pasted credentials, turn SSH into HTTPS. */
export function githubHttpsRemote(arg: string): string {
const ssh = /^(?:ssh:\/\/)?git@github\.com[:/](.+)$/.exec(arg);
if (ssh) return `https://github.com/${ssh[1]}`;
return arg.replace(/^https:\/\/[^/@]+@github\.com\//, "https://github.com/");
}
const GITHUB_AUTH_FAILURE =
/Invalid username or token|Authentication failed|could not read Username/;
/** GitHub rejecting the stored token means the connection needs renewing, not a token from the user. */
export function explainGitFailure(stderr: string): string {
if (!GITHUB_AUTH_FAILURE.test(stderr)) return stderr;
return `${stderr.trimEnd()}\nGitHub rejected the token evOo stores for this user. Tell the user to reconnect GitHub under Settings, Integrations. Do not ask for a token or set one up yourself.\n`;
}
/** 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,
@ -53,7 +69,7 @@ export function hostGitInvocation(
const mapped: string[] = [];
for (const part of argv.slice(1)) {
if (!part.startsWith("/")) {
mapped.push(part);
mapped.push(githubHttpsRemote(part));
continue;
}
const hostPart = hostPathFor(session, part);
@ -232,7 +248,7 @@ export function mountGithubBrokerRoutes(app: Hono, supervisorToken: string) {
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);
return c.json({ ...result, stderr: explainGitFailure(result.stderr) });
});
app.post("/internal/github-broker/v1/gh", async (c) => {

View File

@ -217,6 +217,7 @@ describe("formatConnectionsInstruction", () => {
const line = formatGithubGitInstruction(["GitHub"]);
expect(line).toMatch(/Clone private repositories with git/);
expect(line).toMatch(/Never ask the user for a personal access token/);
expect(line).toMatch(/RAKAZO_GITHUB_BROKER_RUN_TOKEN is not a GitHub token/);
});
it("says plainly when nothing is connected", () => {

View File

@ -79,7 +79,7 @@ export const ROUTINE_RUN_INSTRUCTION =
export function formatGithubGitInstruction(connectedApps: readonly string[]): string | undefined {
const hasGithub = connectedApps.some((app) => app.trim().toLowerCase() === "github");
if (!hasGithub) return undefined;
return "GitHub is connected. Clone private repositories with git on your computer (authentication is brokered) or use the GitHub integration tools for issues, pull requests, and repository APIs. Never ask the user for a personal access token, password, or SSH key for GitHub.";
return "GitHub is connected. Clone private repositories with git on your computer (authentication is brokered) or use the GitHub integration tools for issues, pull requests, and repository APIs. Plain `git clone https://github.com/owner/repo` works; RAKAZO_GITHUB_BROKER_RUN_TOKEN is not a GitHub token, so never put it or any token into URLs, credential helpers, or git config. Never ask the user for a personal access token, password, or SSH key for GitHub.";
}
export function formatConnectionsInstruction(input: {