Repository navigation
computer: Send Git's network requests through a host fetch #245
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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). |
| 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" }); | ||
| }); | ||
| 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"); | ||
| }); | ||
| }); | ||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, Learn moreA Git push supplies an iterable pack body, and the adapter wraps it in a 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 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(); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 New Git option lacks consumer example The new Learn moreThe 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 Recommended fix: Update a Git-enabled example to pass a scoped host fetch wrapper and demonstrate the new option in a runnable consumer. Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| } | ||
|
|
||
| export type GitClientFactory = (options: WorkspaceGitClientOptions) => GitClient; | ||
|
|
@@ -360,6 +370,7 @@ export type GitClientFactory = (options: WorkspaceGitClientOptions) => GitClient | |
| */ | ||
| export function createGitClient({ | ||
| adapter = workspaceIsomorphicGitClient, | ||
| fetch: fetcher, | ||
| }: CreateGitClientOptions = {}): GitClientFactory { | ||
| return function createWorkspaceGitClient({ | ||
| ws, | ||
|
|
@@ -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> => { | ||
|
|
||
There was a problem hiding this comment.
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
Responseto consume bytes rather than sending a Fetch request. A successful clone or push against a local HTTP server would exercise the missing integration.Was this helpful? React with 👍 or 👎 to provide feedback.