From d962e5e7f458fa04a1235ae5fa3a91f261070cff Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:04:02 +0200 Subject: [PATCH] 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 --- infra/sandboxes/computer/Dockerfile | 2 +- infra/sandboxes/computer/bin/rakazo-broker | 47 ++++++++++++++++ infra/sandboxes/computer/bin/rakazo-gh | 13 +---- infra/sandboxes/computer/bin/rakazo-git | 34 +++++++----- .../supervisor/src/computer-git-shim.test.ts | 55 +++++++++++++++++++ .../supervisor/src/github-broker.test.ts | 29 ++++++++++ .../sandboxes/supervisor/src/github-broker.ts | 20 ++++++- packages/adapters/src/executor-prompt.test.ts | 1 + packages/adapters/src/executor-prompt.ts | 2 +- 9 files changed, 174 insertions(+), 29 deletions(-) create mode 100755 infra/sandboxes/computer/bin/rakazo-broker mode change 100644 => 100755 infra/sandboxes/computer/bin/rakazo-gh mode change 100644 => 100755 infra/sandboxes/computer/bin/rakazo-git create mode 100644 infra/sandboxes/supervisor/src/computer-git-shim.test.ts diff --git a/infra/sandboxes/computer/Dockerfile b/infra/sandboxes/computer/Dockerfile index 6c36fdf..71f2ed5 100644 --- a/infra/sandboxes/computer/Dockerfile +++ b/infra/sandboxes/computer/Dockerfile @@ -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 diff --git a/infra/sandboxes/computer/bin/rakazo-broker b/infra/sandboxes/computer/bin/rakazo-broker new file mode 100755 index 0000000..f3ece77 --- /dev/null +++ b/infra/sandboxes/computer/bin/rakazo-broker @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Usage: rakazo-broker . 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("&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" diff --git a/infra/sandboxes/computer/bin/rakazo-gh b/infra/sandboxes/computer/bin/rakazo-gh old mode 100644 new mode 100755 index 84164e5..3f3424d --- a/infra/sandboxes/computer/bin/rakazo-gh +++ b/infra/sandboxes/computer/bin/rakazo-gh @@ -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" diff --git a/infra/sandboxes/computer/bin/rakazo-git b/infra/sandboxes/computer/bin/rakazo-git old mode 100644 new mode 100755 index 9127dac..c97fc4a --- a/infra/sandboxes/computer/bin/rakazo-git +++ b/infra/sandboxes/computer/bin/rakazo-git @@ -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" "$@" diff --git a/infra/sandboxes/supervisor/src/computer-git-shim.test.ts b/infra/sandboxes/supervisor/src/computer-git-shim.test.ts new file mode 100644 index 0000000..49eb267 --- /dev/null +++ b/infra/sandboxes/supervisor/src/computer-git-shim.test.ts @@ -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"]); + }); +}); diff --git a/infra/sandboxes/supervisor/src/github-broker.test.ts b/infra/sandboxes/supervisor/src/github-broker.test.ts index 889afc8..c842c13 100644 --- a/infra/sandboxes/supervisor/src/github-broker.test.ts +++ b/infra/sandboxes/supervisor/src/github-broker.test.ts @@ -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", + ); + }); +}); diff --git a/infra/sandboxes/supervisor/src/github-broker.ts b/infra/sandboxes/supervisor/src/github-broker.ts index 4856d7c..8c714ee 100644 --- a/infra/sandboxes/supervisor/src/github-broker.ts +++ b/infra/sandboxes/supervisor/src/github-broker.ts @@ -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) => { diff --git a/packages/adapters/src/executor-prompt.test.ts b/packages/adapters/src/executor-prompt.test.ts index 8ac9bd6..397ec8b 100644 --- a/packages/adapters/src/executor-prompt.test.ts +++ b/packages/adapters/src/executor-prompt.test.ts @@ -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", () => { diff --git a/packages/adapters/src/executor-prompt.ts b/packages/adapters/src/executor-prompt.ts index 290a76f..7d7d090 100644 --- a/packages/adapters/src/executor-prompt.ts +++ b/packages/adapters/src/executor-prompt.ts @@ -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: {