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