From 5b92ccdbacad0258230336d5ba1e56b82161016d Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 16 Oct 2026 09:26:48 +0200 Subject: [PATCH] computer: Hold Git credentials on the host with an auth resolver The Git commands the model runs, the worker-shell `git` command and `ws:git`, had no way to authenticate. Host code could pass `headers` or `onAuth` on each typed call, but a model running `git clone` or `git push` against a private repository had to put a token on the command line, where it ends up in the transcript. createGitClient() now takes an `auth` resolver. The client calls it for every HTTPS request a clone, fetch, pull, or push makes, with the repository URL and whether the request reads or writes, and adds the credentials it returns as an Authorization header. The resolver wraps the HTTP transport rather than each command, so the typed API, cli(), the shell command, and ws:git all get it without their own plumbing. The token never reaches argv, the remote URL, .git/config, or output. An Authorization header passed on a call wins, and the resolver is not asked. Plain http:// requests never get credentials. Fixes #218. --- .changeset/git-host-auth.md | 5 + docs/13_git_interface.md | 37 ++++- packages/computer/README.md | 13 ++ packages/computer/src/git/auth.test.ts | 182 +++++++++++++++++++++++++ packages/computer/src/git/auth.ts | 112 +++++++++++++++ packages/computer/src/git/index.ts | 36 ++++- 6 files changed, 381 insertions(+), 4 deletions(-) create mode 100644 .changeset/git-host-auth.md create mode 100644 packages/computer/src/git/auth.test.ts create mode 100644 packages/computer/src/git/auth.ts diff --git a/.changeset/git-host-auth.md b/.changeset/git-host-auth.md new file mode 100644 index 00000000..bbe5d666 --- /dev/null +++ b/.changeset/git-host-auth.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": minor +--- + +`createGitClient({ auth })` takes a resolver that adds credentials to every HTTPS clone, fetch, pull, and push, including from the shell's `git` command and `ws:git`, so the model never sees the token. diff --git a/docs/13_git_interface.md b/docs/13_git_interface.md index 0f7458ea..a9187b0c 100644 --- a/docs/13_git_interface.md +++ b/docs/13_git_interface.md @@ -189,7 +189,38 @@ If none of the four yields a name and email, commit: author identity unknown` with exit code 128. This config-after-env order matches real git. -### Auth (`headers` and `onAuth`) +### Auth (`auth`, `headers`, and `onAuth`) + +Give `createGitClient()` an `auth` resolver to hold credentials on the +host. The client calls it for every HTTPS request a clone, fetch, +pull, or push makes, with the repository URL and whether the request +reads (`fetch`) or writes (`push`). Return a `GitAuth` and it goes on +the request as an `Authorization` header, or return `undefined` to +send the request without credentials: + +```ts +const ws = new Workspace({ + storage: ctx.storage, + git: createGitClient({ + auth: async ({ url, operation }) => { + if (url.startsWith("https://github.com/acme/")) { + return { username: "x-access-token", password: env.GITHUB_TOKEN }; + } + if (url.startsWith("https://gitlab.com/acme/")) { + return { username: "oauth2", password: env.GITLAB_TOKEN }; + } + return undefined; + }, + }), +}); +``` + +The resolver sits on the HTTP transport, so it covers the typed API, +`cli()`, the worker-shell `git` command, and `ws:git`. A model can +run `git clone` or `git push` against a private repository without a +token in argv, the remote URL, `.git/config`, or the output. Plain +`http://` requests never get credentials. An `Authorization` header +passed on a call wins, and the resolver is not asked. Network commands (`fetch`, `pull`, `push`, `clone`) accept the same auth contract `isomorphic-git` exposes: @@ -206,8 +237,8 @@ same auth contract `isomorphic-git` exposes: Return a `GitAuth` (`username` / `password` / `headers`) and `isomorphic-git` retries with it. -The CLI does not surface `onAuth` (no clean argv shape for a -callback); for interactive flows reach for the typed API. +The CLI does not surface `headers` or `onAuth` (no clean argv shape +for a callback); use the `auth` resolver instead. ### Errors diff --git a/packages/computer/README.md b/packages/computer/README.md index 81799049..dae73652 100644 --- a/packages/computer/README.md +++ b/packages/computer/README.md @@ -376,6 +376,19 @@ await ws.git.add({ paths: ["notes.md"] }); await ws.git.commit({ message: "add notes" }); ``` +To let the model clone and push private repositories without seeing a +token, give the client an `auth` resolver. The host runs it for every +HTTPS request, including the shell's `git` command and `ws:git`: + +```ts +createGitClient({ + auth: ({ url, operation }) => + url.startsWith("https://github.com/acme/") + ? { username: "x-access-token", password: env.GITHUB_TOKEN } + : undefined, +}); +``` + The git subpath bundles `isomorphic-git` lazily and swaps its `pako` dependency for the Workers `node:zlib` implementation, so the default package graph stays free of git. There's also an argv entry point diff --git a/packages/computer/src/git/auth.test.ts b/packages/computer/src/git/auth.test.ts new file mode 100644 index 00000000..e794d990 --- /dev/null +++ b/packages/computer/src/git/auth.test.ts @@ -0,0 +1,182 @@ +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { describe, expect, it, vi } from "vitest"; + +import { Workspace } from "../workspace.js"; +import { type GitAuthResolver, type GitHttpRequest, withHostAuth } from "./auth.js"; +import { createGitClient } from "./index.js"; + +// A transport that records each request and answers 401, so a +// network command stops after its first request. +function recordingHttp() { + const requests: GitHttpRequest[] = []; + return { + requests, + async request(request: GitHttpRequest) { + requests.push(request); + return { + url: request.url, + method: request.method ?? "GET", + statusCode: 401, + statusMessage: "Unauthorized", + headers: {}, + body: [], + }; + }, + }; +} + +describe("withHostAuth", () => { + it("adds Basic credentials and asks with the repository URL", async () => { + const http = recordingHttp(); + const resolve = vi.fn(() => ({ username: "x-access-token", password: "t0k" })); + + await withHostAuth(http, resolve).request({ + url: "https://github.com/acme/private.git/info/refs?service=git-upload-pack", + headers: { "user-agent": "git/test" }, + }); + + expect(resolve).toHaveBeenCalledWith({ + url: "https://github.com/acme/private.git", + operation: "fetch", + }); + expect(http.requests[0]?.headers).toEqual({ + "user-agent": "git/test", + Authorization: `Basic ${btoa("x-access-token:t0k")}`, + }); + }); + + it("reports push for receive-pack requests", async () => { + const http = recordingHttp(); + const operations: string[] = []; + const client = withHostAuth(http, ({ operation }) => { + operations.push(operation); + return undefined; + }); + + await client.request({ + url: "https://example.test/r.git/info/refs?service=git-receive-pack", + }); + await client.request({ url: "https://example.test/r.git/git-receive-pack", method: "POST" }); + await client.request({ url: "https://example.test/r.git/git-upload-pack", method: "POST" }); + + expect(operations).toEqual(["push", "push", "fetch"]); + }); + + it("passes headers through and lets them carry their own Authorization", async () => { + const http = recordingHttp(); + await withHostAuth(http, () => ({ + headers: { Authorization: "Bearer abc", "X-Extra": "1" }, + })).request({ url: "https://example.test/r.git/info/refs?service=git-upload-pack" }); + + expect(http.requests[0]?.headers).toEqual({ Authorization: "Bearer abc", "X-Extra": "1" }); + }); + + it("leaves the request alone when the caller set Authorization", async () => { + const http = recordingHttp(); + const resolve = vi.fn(() => ({ username: "u", password: "p" })); + + await withHostAuth(http, resolve).request({ + url: "https://example.test/r.git/info/refs?service=git-upload-pack", + headers: { authorization: "Bearer caller" }, + }); + + expect(resolve).not.toHaveBeenCalled(); + expect(http.requests[0]?.headers).toEqual({ authorization: "Bearer caller" }); + }); + + it("never sends credentials over plain http", async () => { + const http = recordingHttp(); + const resolve = vi.fn(() => ({ username: "u", password: "p" })); + + await withHostAuth(http, resolve).request({ + url: "http://example.test/r.git/info/refs?service=git-upload-pack", + }); + + expect(resolve).not.toHaveBeenCalled(); + expect(http.requests[0]?.headers).toBeUndefined(); + }); + + it("sends the request without credentials when the resolver returns undefined", async () => { + const http = recordingHttp(); + await withHostAuth(http, () => undefined).request({ + url: "https://example.test/r.git/info/refs?service=git-upload-pack", + headers: { "user-agent": "git/test" }, + }); + + expect(http.requests[0]?.headers).toEqual({ "user-agent": "git/test" }); + }); + + it("encodes non-ASCII credentials as UTF-8", async () => { + const http = recordingHttp(); + await withHostAuth(http, () => ({ username: "ΓΌ", password: "p" })).request({ + url: "https://example.test/r.git/info/refs?service=git-upload-pack", + }); + + expect(http.requests[0]?.headers?.Authorization).toBe("Basic w7w6cA=="); + }); +}); + +describe("createGitClient({ auth })", () => { + const url = "https://git.example.test/acme/private.git"; + + function setup() { + const http = recordingHttp(); + const auth = vi.fn(() => ({ username: "x-access-token", password: "t0k" })); + const ws = new Workspace({ + git: createGitClient({ auth, http }), + storage: new SQLiteTestStorage(), + }); + const expected = `Basic ${btoa("x-access-token:t0k")}`; + return { http, auth, git: ws.git, expected, ws }; + } + + it("authenticates a typed clone", async () => { + const { http, auth, git, expected } = setup(); + + await expect(git.clone({ url, dir: "/repo" })).rejects.toThrow("401"); + + expect(auth).toHaveBeenCalledWith({ url, operation: "fetch" }); + expect(http.requests[0]?.headers?.Authorization).toBe(expected); + }); + + it("authenticates a clone from the CLI without a token in argv", async () => { + const { http, git, expected } = setup(); + + const result = await git.cli({ argv: ["clone", url, "repo"], cwd: "/" }); + + expect(result.exitCode).not.toBe(0); + expect(http.requests[0]?.headers?.Authorization).toBe(expected); + expect(result.stdout + result.stderr).not.toContain("t0k"); + }); + + it("asks for push credentials when pushing", async () => { + const { http, auth, git, expected, ws } = setup(); + await git.init({ dir: "/repo" }); + await ws.fs.writeFile("/repo/README.md", "hi\n"); + await git.add({ dir: "/repo", paths: ["README.md"] }); + await git.commit({ + dir: "/repo", + message: "x", + author: { name: "T", email: "t@example.test" }, + }); + await git.remoteAdd({ dir: "/repo", name: "origin", url }); + + const result = await git.cli({ argv: ["push", "origin", "main"], cwd: "/repo" }); + + expect(result.exitCode).not.toBe(0); + expect(auth).toHaveBeenCalledWith({ url, operation: "push" }); + expect(http.requests[0]?.url).toContain("service=git-receive-pack"); + expect(http.requests[0]?.headers?.Authorization).toBe(expected); + }); + + it("lets headers passed on the call win over the resolver", async () => { + const { http, auth, git } = setup(); + + await expect( + git.clone({ url, dir: "/repo", headers: { Authorization: "Bearer caller" } }), + ).rejects.toThrow("401"); + + expect(auth).not.toHaveBeenCalled(); + expect(http.requests[0]?.headers?.Authorization).toBe("Bearer caller"); + }); +}); diff --git a/packages/computer/src/git/auth.ts b/packages/computer/src/git/auth.ts new file mode 100644 index 00000000..515d31a4 --- /dev/null +++ b/packages/computer/src/git/auth.ts @@ -0,0 +1,112 @@ +// Host-held Git credentials. +// +// `createGitClient({ auth })` takes a resolver that the host runs +// for every HTTPS request a clone, fetch, pull, or push makes. The +// resolver returns credentials for the repository, and they go on +// the request as an `Authorization` header. The model never sees +// them: they are not in argv, the remote URL, `.git/config`, or the +// command output. +// +// The resolver sits on the HTTP transport rather than on each +// command, so every way into Git gets it: the typed client, `cli()`, +// the worker-shell `git` command, and `ws:git`. Credentials the +// caller passes on a call (an `Authorization` header) win, and the +// resolver is not asked. + +import type { GitAuth } from "./network.js"; + +/** What a request is for. `push` writes to the remote; everything else reads. */ +export type GitAuthOperation = "fetch" | "push"; + +/** The request the host is asked to authenticate. */ +export interface GitAuthRequest { + /** + * Repository URL, without the smart-HTTP suffix Git adds + * (`/info/refs`, `/git-upload-pack`, `/git-receive-pack`) or a query + * string. For example `https://github.com/acme/private.git`. + */ + url: string; + /** `push` for requests that write to the remote, `fetch` for the rest. */ + operation: GitAuthOperation; +} + +/** + * Returns credentials for a repository, or `undefined` to send the + * request without any. Runs on the host for every HTTPS request, so + * cache tokens yourself if minting one is slow. + */ +export type GitAuthResolver = ( + request: GitAuthRequest, +) => GitAuth | undefined | Promise; + +/** The part of isomorphic-git's HTTP client the resolver wraps. */ +export interface GitHttpRequest { + url: string; + method?: string; + headers?: Record; + [key: string]: unknown; +} + +export interface GitHttpClient { + request(request: GitHttpRequest): Promise; +} + +/** + * Wrap an HTTP client so each HTTPS request asks `resolve` for + * credentials, unless it already has an `Authorization` header. + * Plain `http://` requests are never given credentials. + */ +export function withHostAuth(http: GitHttpClient, resolve: GitAuthResolver): GitHttpClient { + return { + async request(request) { + const headers = request.headers ?? {}; + if (hasAuthorization(headers) || !request.url.startsWith("https://")) { + return http.request(request); + } + const auth = await resolve({ + url: repositoryURL(request.url), + operation: operationOf(request.url), + }); + if (auth === undefined) return http.request(request); + return http.request({ ...request, headers: { ...headers, ...authHeaders(auth) } }); + }, + }; +} + +function hasAuthorization(headers: Record): boolean { + return Object.keys(headers).some((name) => name.toLowerCase() === "authorization"); +} + +// Git's smart-HTTP protocol asks for `/info/refs?service=...` +// and then posts to `/git-upload-pack` or +// `/git-receive-pack`. Strip those so the resolver sees the +// URL the user cloned. +function repositoryURL(requestURL: string): string { + const url = new URL(requestURL); + url.search = ""; + url.hash = ""; + url.pathname = url.pathname.replace(/\/(info\/refs|git-upload-pack|git-receive-pack)$/, ""); + return url.toString(); +} + +function operationOf(requestURL: string): GitAuthOperation { + const url = new URL(requestURL); + return url.pathname.endsWith("/git-receive-pack") || + url.searchParams.get("service") === "git-receive-pack" + ? "push" + : "fetch"; +} + +function authHeaders(auth: GitAuth): Record { + const headers: Record = { ...auth.headers }; + if (!hasAuthorization(headers) && (auth.username !== undefined || auth.password !== undefined)) { + headers.Authorization = `Basic ${base64(`${auth.username ?? ""}:${auth.password ?? ""}`)}`; + } + return headers; +} + +function base64(text: string): string { + let binary = ""; + for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte); + return btoa(binary); +} diff --git a/packages/computer/src/git/index.ts b/packages/computer/src/git/index.ts index c4b34fe0..b91e96fe 100644 --- a/packages/computer/src/git/index.ts +++ b/packages/computer/src/git/index.ts @@ -24,6 +24,7 @@ import type { SQLiteWorkspaceProvider } from "@cloudflare/dofs"; import { type IsomorphicGitFSClient, workspaceIsomorphicGitClient } from "./adapter.js"; +import { type GitAuthResolver, type GitHttpClient, withHostAuth } from "./auth.js"; import { type GitCliInput, type GitCliResult, runGitCli } from "./cli.js"; import { cloneWith, type GitCloneOptions, type IsomorphicGitClient } from "./clone.js"; import { @@ -143,6 +144,7 @@ import { stashPushWith, } from "./worktree.js"; +export type { GitAuthOperation, GitAuthRequest, GitAuthResolver } from "./auth.js"; export type { GitCliInput, GitCliResult } from "./cli.js"; export type { GitCloneOptions, MessageCallback, ProgressCallback } from "./clone.js"; export type { CommitResult, GitCommitOptions } from "./commit.js"; @@ -334,6 +336,31 @@ export interface CreateGitClientOptions { * Production callers do not pass this. */ adapter?: (provider: SQLiteWorkspaceProvider) => Promise; + /** + * Credentials for clone, fetch, pull, and push, held by the host. + * Called for every HTTPS request with the repository URL and + * whether the request reads (`fetch`) or writes (`push`). Return + * a `GitAuth`, or `undefined` to send the request without one. + * + * This covers the typed client, `cli()`, the worker-shell `git` + * command, and `ws:git`, so the model can clone and push private + * repositories without a token in the command or the remote URL. + * An `Authorization` header passed on a call wins over the resolver. + * + * @example + * createGitClient({ + * auth: ({ url }) => + * url.startsWith("https://github.com/acme/") + * ? { username: "x-access-token", password: env.GITHUB_TOKEN } + * : undefined, + * }); + */ + auth?: GitAuthResolver; + /** + * Test seam for substituting the HTTP transport. Production + * callers do not pass this. + */ + http?: GitHttpClient; } export type GitClientFactory = (options: WorkspaceGitClientOptions) => GitClient; @@ -360,6 +387,8 @@ export type GitClientFactory = (options: WorkspaceGitClientOptions) => GitClient */ export function createGitClient({ adapter = workspaceIsomorphicGitClient, + auth, + http, }: CreateGitClientOptions = {}): GitClientFactory { return function createWorkspaceGitClient({ ws, @@ -384,7 +413,12 @@ export function createGitClient({ return gitPromise as Promise; }; const loadHttp = (): Promise => { - if (!httpPromise) httpPromise = loadDefaultHTTP(); + if (!httpPromise) { + httpPromise = (http ? Promise.resolve(http) : loadDefaultHTTP()).then((transport) => + // SAFETY: Both sources are isomorphic-git HTTP clients, which have `request()`. The default one is typed `object` only to keep isomorphic-git's types off the public surface. + auth ? withHostAuth(transport as GitHttpClient, auth) : transport, + ); + } return httpPromise; }; const loadDiffPatch = (): Promise => {