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: {