Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 6f040f93b5 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.
2026-10-27 10:23:44 +00:00
paseo-bot[bot] a7575127c2 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.
2026-10-27 10:13:04 +00:00
21 changed files with 151 additions and 143 deletions
+1 -4
View File
@@ -90,7 +90,6 @@ import {
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
import {
createWorkspaceFileTabTarget,
type WorkspaceFileLocation,
@@ -460,7 +459,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
view: "files",
});
});
const openInlineOutsideDirectory = useOpenOutsideWorkspaceFolder(resolvedServerId);
const inlinePathOpener = useMemo(
() =>
createInlinePathOpener({
@@ -468,9 +466,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
client,
openFile: openInlineFile,
openDirectory: openInlineDirectory,
openOutsideDirectory: openInlineOutsideDirectory,
}),
[workspaceRoot, client, openInlineFile, openInlineDirectory, openInlineOutsideDirectory],
[workspaceRoot, client, openInlineFile, openInlineDirectory],
);
useEffect(() => {
if (!isActive) inlinePathOpener.cancel();
@@ -71,9 +71,6 @@ it("ignores an older probe after a newer file link opens", async () => {
openDirectory: (directory) => {
opened.push(directory);
},
openOutsideDirectory: (directory) => {
opened.push(directory);
},
});
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
@@ -98,9 +95,6 @@ it("ignores a pending probe when its view is left", async () => {
openDirectory: (directory) => {
opened.push(directory);
},
openOutsideDirectory: (directory) => {
opened.push(directory);
},
});
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
opener.cancel();
@@ -108,25 +102,3 @@ it("ignores a pending probe when its view is left", async () => {
await pending;
expect(opened).toEqual([]);
});
it("sends folders outside the workspace away from the workspace Files panel", async () => {
const { createInlinePathOpener } = await import("./path-open");
const workspaceFolders: string[] = [];
const outsideFolders: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/Users/me/repo",
client: null,
openFile: () => {},
openDirectory: (directory) => {
workspaceFolders.push(directory);
},
openOutsideDirectory: (directory) => {
outsideFolders.push(directory);
},
});
for (const raw of ["~/Downloads/contracts/", "/tmp/reports/", "/Users/me/repo/docs/"]) {
await opener.open({ raw, path: raw }, "preferred");
}
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
expect(workspaceFolders).toEqual(["docs"]);
});
@@ -14,23 +14,12 @@ interface PathOpenInput {
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
}
type PathOpenTarget =
| { kind: "file" }
| { kind: "directory"; directoryPath: string }
| { kind: "outsideDirectory"; directoryPath: string };
// Workspace paths arrive relative to the root; anything still absolute or
// home-relative lives outside it, where the Files panel cannot list it.
function isOutsideWorkspacePath(pathValue: string): boolean {
return isAbsolutePath(pathValue) || pathValue === "~" || pathValue.startsWith("~/");
}
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
if (!input.file) {
return isOutsideWorkspacePath(input.directory)
? { kind: "outsideDirectory", directoryPath: input.directory }
: { kind: "directory", directoryPath: input.directory };
return { kind: "directory", directoryPath: input.directory };
}
if (
input.lineStart !== undefined ||
@@ -53,7 +42,6 @@ export function createInlinePathOpener(input: {
client: PathOpenInput["client"];
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
openDirectory: (path: string) => void;
openOutsideDirectory: (path: string) => void;
}) {
let generation = 0;
return {
@@ -70,8 +58,6 @@ export function createInlinePathOpener(input: {
if (ownGeneration !== generation) return;
if (destination.kind === "directory") {
input.openDirectory(destination.directoryPath);
} else if (destination.kind === "outsideDirectory") {
input.openOutsideDirectory(destination.directoryPath);
} else if (normalized.file) {
const location = normalizeWorkspaceFileLocation({
path: normalized.file,
-1
View File
@@ -67,7 +67,6 @@ export const ar: TranslationResources = {
daemonClientUnavailable: "عميل Daemon غير متوفر",
daemonClientDisconnected: "تم قطع اتصال عميل Daemon",
noFileFound: "لم يتم العثور على ملف لـ{{token}}",
folderOutsideWorkspace: "{{path}} خارج مساحة العمل هذه",
unexpectedDictationError: "حدث خطأ غير متوقع أثناء معالجة الإملاء.",
},
connectionStatus: {
-1
View File
@@ -63,7 +63,6 @@ export const en = {
daemonClientUnavailable: "Daemon client unavailable",
daemonClientDisconnected: "Daemon client is disconnected",
noFileFound: "No file found for {{token}}",
folderOutsideWorkspace: "{{path}} is outside this workspace",
unexpectedDictationError: "An unexpected error occurred while handling dictation.",
},
connectionStatus: {
-1
View File
@@ -68,7 +68,6 @@ export const es: TranslationResources = {
daemonClientUnavailable: "ClienteDaemonno disponible",
daemonClientDisconnected: "El clienteDaemonestá desconectado",
noFileFound: "No se encontró ningún archivo para{{token}}",
folderOutsideWorkspace: "{{path}} está fuera de este espacio de trabajo",
unexpectedDictationError: "Se produjo un error inesperado al manejar el dictado.",
},
connectionStatus: {
-1
View File
@@ -68,7 +68,6 @@ export const fr: TranslationResources = {
daemonClientUnavailable: "Client du daemon indisponible",
daemonClientDisconnected: "Le client du daemon est déconnecté",
noFileFound: "Aucun fichier trouvé pour {{token}}",
folderOutsideWorkspace: "{{path}} se trouve en dehors de cet espace de travail",
unexpectedDictationError: "Une erreur inattendue s’est produite pendant la dictée.",
},
connectionStatus: {
-1
View File
@@ -68,7 +68,6 @@ export const ja: TranslationResources = {
daemonClientUnavailable: "デーモンクライアントが利用できません",
daemonClientDisconnected: "デーモンクライアントが切断されています",
noFileFound: "{{token}}のファイルが見つかりません",
folderOutsideWorkspace: "{{path}} はこのワークスペースの外にあります",
unexpectedDictationError: "音声入力処理中に予期しないエラーが発生しました。",
},
connectionStatus: {
-1
View File
@@ -67,7 +67,6 @@ export const ko: TranslationResources = {
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
daemonClientDisconnected: "데몬 클라이언트 연결이 끊어졌습니다",
noFileFound: "{{token}}에 해당하는 파일을 찾을 수 없습니다",
folderOutsideWorkspace: "{{path}}은(는) 이 워크스페이스 밖에 있습니다",
unexpectedDictationError: "받아쓰기를 처리하는 중 예기치 않은 오류가 발생했습니다.",
},
connectionStatus: {
-1
View File
@@ -68,7 +68,6 @@ export const ptBR: TranslationResources = {
daemonClientUnavailable: "Cliente do daemon indisponível",
daemonClientDisconnected: "O cliente do daemon está desconectado",
noFileFound: "Nenhum arquivo encontrado para {{token}}",
folderOutsideWorkspace: "{{path}} está fora deste workspace",
unexpectedDictationError: "Ocorreu um erro inesperado ao processar o ditado.",
},
connectionStatus: {
-1
View File
@@ -68,7 +68,6 @@ export const ru: TranslationResources = {
daemonClientUnavailable: "Daemon клиента недоступен",
daemonClientDisconnected: "Daemon клиента отключен",
noFileFound: "Файл для {{token}} не найден",
folderOutsideWorkspace: "{{path}} находится вне этого рабочего пространства",
unexpectedDictationError: "При обработке диктовки произошла непредвиденная ошибка.",
},
connectionStatus: {
-1
View File
@@ -67,7 +67,6 @@ export const zhCN: TranslationResources = {
daemonClientUnavailable: "Daemon client 不可用",
daemonClientDisconnected: "Daemon client 已断开连接",
noFileFound: "未找到 {{token}} 对应的文件",
folderOutsideWorkspace: "{{path}} 不在此工作区内",
unexpectedDictationError: "处理听写时发生意外错误。",
},
connectionStatus: {
@@ -64,7 +64,6 @@ import { WorkspaceScriptsButton } from "@/screens/workspace/scripts";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useNavigateToImportedAgent } from "@/hooks/use-import-session";
import { useToast } from "@/contexts/toast-context";
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
import { getOrCreateClientId } from "@/utils/client-id";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
@@ -3683,7 +3682,6 @@ function WorkspaceScreenContent({
view: "files",
});
});
const openMarkdownOutsideDirectory = useOpenOutsideWorkspaceFolder(normalizedServerId);
const markdownPathOpener = useMemo(
() =>
createInlinePathOpener({
@@ -3691,15 +3689,8 @@ function WorkspaceScreenContent({
client,
openFile: openMarkdownFile,
openDirectory: openMarkdownDirectory,
openOutsideDirectory: openMarkdownOutsideDirectory,
}),
[
workspaceDirectory,
client,
openMarkdownFile,
openMarkdownDirectory,
openMarkdownOutsideDirectory,
],
[workspaceDirectory, client, openMarkdownFile, openMarkdownDirectory],
);
useEffect(() => {
if (!isRouteFocused) markdownPathOpener.cancel();
@@ -1,36 +0,0 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useToast } from "@/contexts/toast-context";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
/**
* Opens a folder outside the workspace in the system file manager. The Files
* panel only lists the workspace, so hosts without a local file manager get a
* toast instead.
*/
export function useOpenOutsideWorkspaceFolder(serverId: string): (folderPath: string) => void {
const { t } = useTranslation();
const toast = useToast();
const isLocalExecution = useIsLocalDaemon(serverId);
const { targets } = useDesktopOpenTargets({ isLocalExecution });
const fileManagerTarget = targets.find((target) => target.kind === "file-manager");
return useCallback(
(folderPath: string) => {
const showOutsideWorkspace = () =>
toast.show(t("common.errors.folderOutsideWorkspace", { path: folderPath }));
if (!fileManagerTarget) {
showOutsideWorkspace();
return;
}
void openDesktopTarget({ editorId: fileManagerTarget.id, workspacePath: folderPath }).catch(
(error: unknown) => {
console.warn("[open-outside-workspace-folder] open failed", error);
showOutsideWorkspace();
},
);
},
[fileManagerTarget, t, toast],
);
}
@@ -86,10 +86,6 @@ class FakeEditorTargets implements EditorTargetRuntime {
return this.paths.has(targetPath);
}
expandHomePath(targetPath: string): string {
return targetPath.startsWith("~/") ? `/home/me/${targetPath.slice(2)}` : targetPath;
}
isAbsolutePath(targetPath: string): boolean {
return targetPath.startsWith("/") || /^[A-Z]:\//u.test(targetPath);
}
@@ -517,19 +513,6 @@ describe("editor target registry", () => {
expect(runtime.revealedPaths).toEqual(["C:/repo/src/app.ts"]);
});
it("opens a home-relative folder in the file manager", async () => {
const runtime = new FakeEditorTargets();
runtime.addPath("/home/me/Downloads/contracts");
await openEditorTarget(
{ editorId: "file-manager", workspacePath: "~/Downloads/contracts" },
runtime,
[fileManagerTarget],
);
expect(runtime.openedPaths).toEqual(["/home/me/Downloads/contracts"]);
});
it("keeps the platform file-manager ids used by stored preferences", async () => {
const macTargets = await listAvailableEditorTargets(new FakeEditorTargets("darwin"), [
finderTarget,
@@ -97,16 +97,10 @@ export function getEditorTarget(
}
export async function openEditorTarget(
request: EditorTargetLaunchInput & { editorId: string },
input: EditorTargetLaunchInput & { editorId: string },
runtime: EditorTargetRuntime,
targets: readonly EditorTarget[] = EDITOR_TARGETS,
): Promise<void> {
// Assistant replies name folders like `~/Downloads/x`; expand them before the absolute-path guard.
const input = {
...request,
workspacePath: runtime.expandHomePath(request.workspacePath),
filePath: request.filePath === undefined ? undefined : runtime.expandHomePath(request.filePath),
};
if (!runtime.isAbsolutePath(input.workspacePath)) {
throw new Error("Editor target workspace path must be an absolute local path");
}
@@ -61,14 +61,6 @@ function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean {
return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value);
}
function expandHomePath(value: string, homeDirectory: string): string {
if (value === "~") return homeDirectory;
if (value.startsWith("~/") || value.startsWith("~\\")) {
return path.join(homeDirectory, value.slice(2));
}
return value;
}
function resolveExecutable(
commands: readonly string[],
input: {
@@ -174,7 +166,6 @@ export function createEditorTargetRuntime(
pathExists,
isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform),
listDirectory,
expandHomePath: (targetPath) => expandHomePath(targetPath, homeDirectory),
resolveCommand: (commands) => resolveExecutable(commands, { env, pathExists, platform }),
async spawnDetached({ command, args }) {
const commandScript = isWindowsCommandScript(command, platform);
@@ -30,8 +30,6 @@ export interface EditorTargetRuntime {
pathExists(path: string): boolean;
isAbsolutePath(path: string): boolean;
listDirectory(path: string): EditorTargetDirectoryEntry[];
/** Resolves a leading `~` to the user's home directory. */
expandHomePath(path: string): string;
resolveCommand(commands: readonly string[]): string | null;
spawnDetached(input: { command: string; args: readonly string[] }): Promise<void>;
openPath(path: string): Promise<void>;
@@ -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",
}),
}),
@@ -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<void>,
): Promise<void> {
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<void>((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<void>((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",
});
});
},
);
@@ -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) {