Skip to content
Open
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({ fetch })` sends every Git network request through a host `fetch`, so a wrapper can add credentials the model never sees. See [Host Fetch and authentication](https://github.com/cloudflare/computer/blob/main/docs/13_git_interface.md#host-fetch-and-authentication).
47 changes: 44 additions & 3 deletions docs/13_git_interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,48 @@ 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`)
### Host Fetch and authentication

Give `createGitClient()` a Fetch-compatible `fetch` and every request a
clone, fetch, pull or push makes goes through it: the typed API, `cli()`,
the worker-shell `git` command and `codemode:git`. Pass a wrapper that adds
credentials, or a service binding's bound `fetch`. Credentials then never
reach argv, the remote URL, `.git/config` or command output.

```ts
const gitFetch: typeof fetch = async (input, init) => {
const request = new Request(input, init);
const url = new URL(request.url);
if (
url.origin === "https://github.com" &&
url.pathname.startsWith("/acme/") &&
!request.headers.has("Authorization")
) {
const authed = new Request(request, { redirect: "manual" });
authed.headers.set("Authorization", `Bearer ${env.GITHUB_TOKEN}`);
// Don't follow a redirect with the token attached; Git reports the 3xx as an error.
return fetch(authed);
}
return fetch(request);
};

const ws = new Workspace({
storage: ctx.storage,
git: createGitClient({ fetch: gitFetch }),
});
```

What gets credentials is up to the wrapper: scope them to HTTPS and the
repositories you trust, decide whether an `Authorization` header from the
caller wins, and don't follow redirects with them attached. To hand out a
read token for fetches and a write token for pushes, look at the Git
service: `service=git-receive-pack` or a `/git-receive-pack` path is a push,
and `git-upload-pack` is a read.

Request and response bodies stream through as Fetch streams, so packfiles
aren't buffered, and the `fetch` you pass must accept a streaming request
body. Workers and Node do; browsers may not. Without `fetch`, Git uses
`isomorphic-git`'s own transport.

Network commands (`fetch`, `pull`, `push`, `clone`) accept the
same auth contract `isomorphic-git` exposes:
Expand All @@ -206,8 +247,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 a host-provided `fetch` 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 @@ -502,6 +502,19 @@ await ws.git.add({ paths: ["notes.md"] });
await ws.git.commit({ message: "add notes" });
```

Pass a Fetch-compatible implementation to route Git traffic through a host
wrapper or a service binding. It covers typed calls, the shell's `git`
command and `codemode:git`:

```ts
createGitClient({ fetch: env.GIT_SERVICE.fetch.bind(env.GIT_SERVICE) });
```

For private repositories, a host fetch wrapper can inject credentials without
putting tokens in model-visible commands or remote URLs. The host must scope
credentials to trusted HTTPS repositories and enforce redirect safety; see the
[authentication example](../../docs/13_git_interface.md#host-fetch-and-authentication).

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
159 changes: 159 additions & 0 deletions packages/computer/src/git/http.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { SQLiteTestStorage } from "@cloudflare/dofs/testing";
import { describe, expect, it, vi } from "vitest";

import { Workspace } from "../workspace";
import { withFetch } from "./http";
import { createGitClient } from "./index";

async function collect(body: AsyncIterable<Uint8Array>) {
const chunks = [];
for await (const chunk of body) chunks.push(...chunk);
return chunks;
}

describe("Fetch transport", () => {
it("bridges streaming uploads, response bytes, headers and status", async () => {
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
expect(init?.method).toBe("POST");
expect(init?.headers).toEqual({ Authorization: "Bearer caller" });
expect(await new Response(init?.body).arrayBuffer()).toEqual(
new Uint8Array([1, 2, 3]).buffer,
);
const response = new Response(new Uint8Array([4, 5]), {
status: 201,
statusText: "Created",
headers: { "X-Test": "yes" },
});
Object.defineProperty(response, "url", { value: "https://redirect.test/repo" });
return response;
});
const signal = new AbortController().signal;
const result = await withFetch(fetcher).request({
url: "https://example.test/repo",
method: "POST",
headers: { Authorization: "Bearer caller" },
signal,
body: (async function* () {
yield new Uint8Array([1, 2]);
yield new Uint8Array([3]);
})(),
});
expect(fetcher.mock.calls[0]?.[1]?.signal).toBe(signal);
expect(result).toMatchObject({
url: "https://redirect.test/repo",
method: "POST",
statusCode: 201,
statusMessage: "Created",
headers: { "x-test": "yes" },
});
expect(await collect((result as { body: AsyncIterable<Uint8Array> }).body)).toEqual([4, 5]);
});

it("handles GET and empty responses without manufacturing an upload body", async () => {
const fetcher = vi.fn<typeof fetch>(async (_url, init) => {
expect(init?.method).toBe("GET");
expect(init?.body).toBeUndefined();
return new Response(null, { status: 204 });
});
const result = await withFetch(fetcher).request({ url: "https://example.test/repo" });
expect(await collect((result as { body: AsyncIterable<Uint8Array> }).body)).toEqual([]);
});

it("cancels the response stream when Git stops reading", async () => {
const cancel = vi.fn<() => void>();
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(new Uint8Array([1]));
},
cancel,
});
const result = await withFetch(async () => new Response(stream)).request({
url: "https://example.test",
});
for await (const _chunk of (result as { body: AsyncIterable<Uint8Array> }).body) break;
expect(cancel).toHaveBeenCalledOnce();
expect(stream.locked).toBe(false);
});

it("propagates fetch failures", async () => {
await expect(
withFetch(async () => {
throw new Error("offline");
}).request({ url: "https://example.test" }),
).rejects.toThrow("offline");
});
});

describe("createGitClient({ fetch })", () => {
const url = "https://git.example.test/acme/private.git";
function setup() {
const requests: Request[] = [];
// Authentication is host policy, not a Git-specific callback.
const fetcher = vi.fn<typeof fetch>(async (input, init) => {
const request = new Request(input, init);
if (
new URL(request.url).origin === "https://git.example.test" &&
!request.headers.has("Authorization")
) {
request.headers.set("Authorization", "Bearer host-token");
}
requests.push(request);
return new Response(null, { status: 401, statusText: "Unauthorized" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Successful Git transfers remain untested

All Workspace cases stop at a 401 discovery response, before any pack transfer. The upload test uses Response to consume bytes rather than sending a Fetch request. A successful clone or push against a local HTTP server would exercise the missing integration.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

});
const ws = new Workspace({
storage: new SQLiteTestStorage(),
git: createGitClient({ fetch: fetcher }),
});
return { ws, git: ws.git, requests, fetcher };
}

it("uses the supplied fetch for a typed clone", async () => {
const { git, requests } = setup();
await expect(git.clone({ url, dir: "/repo" })).rejects.toThrow("401");
expect(requests[0]?.url).toContain("service=git-upload-pack");
expect(requests[0]?.headers.get("Authorization")).toBe("Bearer host-token");
});

it("uses host credentials for CLI clone without leaking them to output", async () => {
const { git, requests } = setup();
const result = await git.cli({ argv: ["clone", url, "repo"], cwd: "/" });
expect(result.exitCode).not.toBe(0);
expect(requests[0]?.headers.get("Authorization")).toBe("Bearer host-token");
expect(result.stdout + result.stderr).not.toContain("host-token");
});

it("uses the same fetch for typed fetch, pull and CLI push", async () => {
const { git, ws, requests } = 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 });
await expect(git.fetch({ dir: "/repo" })).rejects.toThrow("401");
await expect(
git.pull({ dir: "/repo", author: { name: "T", email: "t@example.test" } }),
).rejects.toThrow("401");
const result = await git.cli({ argv: ["push", "origin", "main"], cwd: "/repo" });
expect(result.exitCode).not.toBe(0);
expect(requests.map((r) => new URL(r.url).searchParams.get("service"))).toEqual([
"git-upload-pack",
"git-upload-pack",
"git-receive-pack",
]);
expect(requests.every((r) => r.headers.get("Authorization") === "Bearer host-token")).toBe(
true,
);
});

it("passes explicit Authorization through to the host fetch", async () => {
const { git, requests } = setup();
await expect(
git.clone({ url, dir: "/repo", headers: { Authorization: "Bearer caller" } }),
).rejects.toThrow("401");
expect(requests[0]?.headers.get("Authorization")).toBe("Bearer caller");
});
});
73 changes: 73 additions & 0 deletions packages/computer/src/git/http.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/** Minimal isomorphic-git transport contract, kept off the public Fetch API. */
export interface GitHttpRequest {
url: string;
method?: string;
headers?: Record<string, string>;
body?: Iterable<Uint8Array> | AsyncIterable<Uint8Array>;
signal?: AbortSignal;
}

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

/** Bridge Git's iterable bodies to Fetch without buffering packfiles. */
export function withFetch(fetcher: typeof globalThis.fetch): GitHttpClient {
return {
async request({ url, method = "GET", headers = {}, body, signal }) {
const init: RequestInit & { duplex?: "half" } = { method, headers, signal };
if (body !== undefined) {
const iterator = (async function* () {
yield* body;
})();
init.body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const next = await iterator.next();
if (next.done) controller.close();
else controller.enqueue(next.value);
} catch (error) {
controller.error(error);
}
},
async cancel() {
await iterator.return(undefined);
},
});
// Required by Node's Fetch for streaming uploads; ignored by Workers.
init.duplex = "half";
}
const response = await fetcher(url, init);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Failed fetch leaves upload streams open

When a host fetch rejects a Git POST, withFetch leaves its upload stream uncancelled. The stream can already have pulled from the pack iterator, leaving it open after the failed push.

Learn more

A Git push supplies an iterable pack body, and the adapter wraps it in a ReadableStream that can pull before the host fetch settles. If the supplied fetch rejects without consuming that body, the adapter returns immediately; its cancel() handler never runs and the underlying iterator never receives return(). This is distinct from cancellation of the response stream, which only exists after fetch resolves.

Example: A host fetch wrapper rejects a POST while refreshing its token. The stream has pulled its first pack chunk, but the rejected push never closes the pack iterator.

Recommended fix: Hold the upload stream separately and cancel it when fetcher(url, init) rejects. Cover rejection before and after the upload begins with a generator that records its cleanup.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

return {
url: response.url || url,
method,
statusCode: response.status,
statusMessage: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: responseBytes(response.body),
};
},
};
}

async function* responseBytes(body: ReadableStream<Uint8Array> | null) {
if (!body) return;
const reader = body.getReader();
let done = false;
try {
while (true) {
const next = await reader.read();
if (next.done) {
done = true;
return;
}
yield next.value;
}
} finally {
try {
if (!done) await reader.cancel();
} finally {
reader.releaseLock();
}
}
}
14 changes: 13 additions & 1 deletion packages/computer/src/git/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
type IsomorphicGitDiffClient,
type ReadFileFn,
} from "./diff";
import { withFetch } from "./http";
import { type GitInitOptions, type IsomorphicGitInitClient, initWith } from "./init";
import {
type FetchResult,
Expand Down Expand Up @@ -334,6 +335,15 @@ export interface CreateGitClientOptions {
* Production callers do not pass this.
*/
adapter?: (provider: SQLiteWorkspaceProvider) => Promise<IsomorphicGitFSClient>;
/**
* The `fetch` every clone, fetch, pull and push sends its requests
* through, from the typed API, `cli()`, the worker-shell `git` command
* and `codemode:git` alike. Pass a wrapper that adds credentials, or a
* service binding's bound `fetch`. The wrapper decides which requests get
* credentials, whether a caller's `Authorization` header wins, and how
* redirects are handled. Defaults to isomorphic-git's own transport.
*/
fetch?: typeof globalThis.fetch;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 New Git option lacks consumer example

The new fetch option changes the public createGitClient API, but no runnable example uses it. Repository guidance requires updating examples alongside public API changes.

Learn more

The repository treats its examples as real consumers of the public API and explicitly requires updating them when that API changes. The new option is demonstrated in documentation, but no runnable example exercises it.

Example: A developer starting from the worker-shell example sees createGitClient() without a host fetch and cannot use its shell Git command against a private remote without configuring authentication separately.

Recommended fix: Update a Git-enabled example to pass a scoped host fetch wrapper and demonstrate the new option in a runnable consumer.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}

export type GitClientFactory = (options: WorkspaceGitClientOptions) => GitClient;
Expand All @@ -360,6 +370,7 @@ export type GitClientFactory = (options: WorkspaceGitClientOptions) => GitClient
*/
export function createGitClient({
adapter = workspaceIsomorphicGitClient,
fetch: fetcher,
}: CreateGitClientOptions = {}): GitClientFactory {
return function createWorkspaceGitClient({
ws,
Expand All @@ -384,7 +395,8 @@ export function createGitClient({
return gitPromise as Promise<T>;
};
const loadHttp = (): Promise<object> => {
if (!httpPromise) httpPromise = loadDefaultHTTP();
if (!httpPromise)
httpPromise = fetcher ? Promise.resolve(withFetch(fetcher)) : loadDefaultHTTP();
return httpPromise;
};
const loadDiffPatch = (): Promise<CreatePatchFn> => {
Expand Down
Loading