From 694696e82c35f57c21f4d5116f836673d0edeed3 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 12:00:55 +0000 Subject: [PATCH] fix(server): keep background Git fetches from opening login prompts (#8019) * fix(server): keep background Git fetches from opening login prompts Git tries graphical askpass before consulting GIT_TERMINAL_PROMPT, so passive fetches repeatedly opened a login despite terminal prompting being disabled. Disable askpass and credential-manager interaction for this fetch while preserving stored credential lookup. * test(server): make fetch and ACP expectations match supported Git and timeline behavior Newer Git also creates origin/HEAD on the first fetch, so assert the authenticated branch result without excluding that valid additional ref. Main now qualifies ACP timeline identities by chunk kind while preserving the native messageId; update the plugin round-trip test to check that contract. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/plugins/runtime.posix.test.ts | 3 +- .../src/server/workspace-git-fetch.test.ts | 144 +++++++++++++++++- .../server/src/server/workspace-git-fetch.ts | 4 +- 3 files changed, 146 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 30ada955a6..a777ff188e 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -651,7 +651,8 @@ lines.on("line", (line) => { type: "timeline.item", item: expect.objectContaining({ type: "assistant_message", - id: "assistant-1", + id: JSON.stringify(["agent_message_chunk", "assistant-1"]), + messageId: "assistant-1", text: "ACP says hi", }), }), diff --git a/packages/server/src/server/workspace-git-fetch.test.ts b/packages/server/src/server/workspace-git-fetch.test.ts index 29a95b64d8..de9e69959e 100644 --- a/packages/server/src/server/workspace-git-fetch.test.ts +++ b/packages/server/src/server/workspace-git-fetch.test.ts @@ -1,4 +1,8 @@ import { describe, expect, test } from "vitest"; +import { createServer } from "node:http"; +import { mkdtemp, readFile, writeFile, rm, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { diffWorkspaceGitRefs, @@ -6,14 +10,21 @@ import { fetchWorkspaceGitRemote, parseWorkspaceGitRefs, } from "./workspace-git-fetch.js"; -import type { RunGitCommand } from "../utils/run-git-command.js"; +import { runGitCommand, type RunGitCommand } from "../utils/run-git-command.js"; describe("workspace Git remote refs", () => { test("runs the complete fetch through the supplied Git runner", async () => { const commands: string[][] = []; let refRead = 0; - const runGitCommand: RunGitCommand = async (args) => { + const run: RunGitCommand = async (args, options) => { commands.push(args); + if (args[0] === "fetch") { + expect(options.envOverlay).toEqual({ + GIT_TERMINAL_PROMPT: "0", + GIT_ASKPASS: "", + GCM_INTERACTIVE: "0", + }); + } const stdout = args[0] === "for-each-ref" ? `refs/remotes/origin/main\0${refRead++ === 0 ? "aaaaaaaa" : "bbbbbbbb"}` @@ -22,7 +33,7 @@ describe("workspace Git remote refs", () => { }; await expect( - fetchWorkspaceGitRemote("/repo", { onRefSnapshot() {} }, runGitCommand), + fetchWorkspaceGitRemote("/repo", { onRefSnapshot() {} }, run), ).resolves.toMatchObject({ changes: [ { @@ -109,3 +120,130 @@ describe("workspace Git remote refs", () => { ]); }); }); + +// Serve a real bare repository over Git's dumb HTTP transport, behind Basic authentication. +// Both the authentication challenge and successful fetch exercise Git itself. +async function withAuthenticatedRemote( + verify: (fixture: { + cwd: string; + run: RunGitCommand; + root: string; + origin: string; + expectedOid: string; + }) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), "paseo-fetch-auth-")); + const cwd = join(root, "checkout"); + const remote = join(root, "remote.git"); + const emptyConfig = join(root, "gitconfig"); + const askpass = join(root, "askpass.sh"); + const marker = join(root, "prompts"); + await writeFile(emptyConfig, ""); + await writeFile( + askpass, + `#!/bin/sh\nprintf 'prompt\\n' >> '${marker.replaceAll("'", "'\\''")}'\nexit 1\n`, + ); + await chmod(askpass, 0o755); + const askpassCommand = askpass; + const run: RunGitCommand = (args, options) => + runGitCommand(args, { + ...options, + env: { + ...options.env, + GIT_CONFIG_GLOBAL: emptyConfig, + GIT_CONFIG_NOSYSTEM: "1", + GIT_ASKPASS: askpassCommand, + SSH_ASKPASS: askpassCommand, + }, + }); + const server = createServer(async (request, response) => { + if ( + request.headers.authorization !== `Basic ${Buffer.from("fixture:secret").toString("base64")}` + ) { + response.writeHead(401, { "WWW-Authenticate": 'Basic realm="git-test"' }); + response.end(); + return; + } + const pathname = new URL(request.url!, "http://localhost").pathname; + try { + const contents = await readFile(join(root, pathname)); + response.writeHead(200, { "Content-Type": "application/octet-stream" }); + response.end(contents); + } catch { + response.writeHead(404); + response.end(); + } + }); + try { + await run(["init", "--initial-branch=main", cwd], { cwd: root }); + await run( + [ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "commit", + "--allow-empty", + "-m", + "initial", + ], + { cwd }, + ); + await run(["clone", "--bare", cwd, remote], { cwd: root }); + await run(["update-server-info"], { cwd: remote }); + await run(["config", "credential.helper", ""], { cwd }); + await run(["config", "core.askPass", askpassCommand], { cwd }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected TCP server"); + const origin = `http://127.0.0.1:${address.port}/remote.git`; + await run(["remote", "add", "origin", origin], { cwd }); + const expectedOid = (await run(["rev-parse", "HEAD"], { cwd })).stdout.trim(); + await verify({ cwd, run, root, origin, expectedOid }); + } finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +} + +test.skipIf(process.platform === "win32")( + "background fetches never invoke askpass when credentials are missing", + async () => { + await withAuthenticatedRemote(async ({ cwd, run, root }) => { + for (let attempt = 0; attempt < 2; attempt++) { + const result = await fetchWorkspaceGitRemote(cwd, { onRefSnapshot() {} }, run); + expect(String(result.error)).toContain("terminal prompts disabled"); + } + await expect(readFile(join(root, "prompts"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + }, +); + +test.skipIf(process.platform === "win32")( + "background fetches still use stored credentials", + async () => { + await withAuthenticatedRemote(async ({ cwd, run, root, origin, expectedOid }) => { + const credentials = join(root, "credentials"); + await writeFile(credentials, origin.replace("http://", "http://fixture:secret@") + "\n"); + await run(["config", "credential.helper", `store --file="${credentials}"`], { cwd }); + + const result = await fetchWorkspaceGitRemote(cwd, { onRefSnapshot() {} }, run); + + expect(result.error).toBeNull(); + expect(result.changes).toContainEqual({ + kind: "added", + ref: "origin/main", + afterOid: expectedOid, + }); + expect((await run(["rev-parse", "refs/remotes/origin/main"], { cwd })).stdout.trim()).toBe( + expectedOid, + ); + await expect(readFile(join(root, "prompts"), "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + }, +); diff --git a/packages/server/src/server/workspace-git-fetch.ts b/packages/server/src/server/workspace-git-fetch.ts index 72f5d407da..5673600d23 100644 --- a/packages/server/src/server/workspace-git-fetch.ts +++ b/packages/server/src/server/workspace-git-fetch.ts @@ -33,7 +33,9 @@ export async function fetchWorkspaceGitRemote( try { await run(["fetch", "origin", "--prune"], { cwd, - envOverlay: { GIT_TERMINAL_PROMPT: "0" }, + // Terminal prompting and graphical credential prompting have separate controls. + // Keep cached credentials available, but never start a login from a passive fetch. + envOverlay: { GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", GCM_INTERACTIVE: "0" }, timeout: 120_000, }); } catch (caught) {