Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/git-host-auth.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 34 additions & 3 deletions docs/13_git_interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
13 changes: 13 additions & 0 deletions packages/computer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
182 changes: 182 additions & 0 deletions packages/computer/src/git/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<GitAuthResolver>(() => ({ 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<GitAuthResolver>(() => ({ 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<GitAuthResolver>(() => ({ 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<GitAuthResolver>(() => ({ 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");
});
});
112 changes: 112 additions & 0 deletions packages/computer/src/git/auth.ts
Original file line number Diff line number Diff line change
@@ -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<GitAuth | undefined>;

/** The part of isomorphic-git's HTTP client the resolver wraps. */
export interface GitHttpRequest {
url: string;
method?: string;
headers?: Record<string, string>;
[key: string]: unknown;
}

export interface GitHttpClient {
request(request: GitHttpRequest): Promise<unknown>;
}

/**
* 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<string, string>): boolean {
return Object.keys(headers).some((name) => name.toLowerCase() === "authorization");
}

// Git's smart-HTTP protocol asks for `<repo>/info/refs?service=...`
// and then posts to `<repo>/git-upload-pack` or
// `<repo>/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<string, string> {
const headers: Record<string, string> = { ...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);
}
Loading
Loading