Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 26dd2c5a90 fix(claude): allow Auto mode on hosted providers
Claude Code supports Auto mode on Bedrock and Vertex, but Paseo removed it from the catalog and rejected explicit selections before reaching Claude Code. Remove that obsolete eligibility gate while preserving the existing default modes and SDK error handling.
2026-10-14 19:16:38 +02:00
paseo-bot[bot] b58af43a06 fix(app): render mixed HTML headings without literal tags (#6650)
Keep a matched heading together before splitting its image and text contents. Splitting first stranded the opening heading tag in a text slice without its closing tag.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 18:14:08 +02:00
truelecter 1bf3a57a1c fix(nix): raise Node heap for the desktop build (#6537)
Metro's transform worker runs out of heap on the generated ws-outbound.aot.js validator on machines where Node's default heap is 2 GB, such as the 7 GB macOS runner of the Nix workflow. Set NODE_OPTIONS=--max-old-space-size=4096 in the desktop derivation, as desktop-release.yml already does.

Closes #6535
2026-10-14 22:32:26 +07:00
56448842f0 Download files over the connected host (#6595)
* Let readFile callers disable the request timeout

readFile resolves only when FileEnd arrives, so the default 60 s request
timeout bounds the whole transfer. A large file over a slow relay fails
before it finishes even though every chunk is arriving. Thread an optional
timeout through requestFileExplorer; 0 disables the timer, as the
workspace.create request already does, and a dead connection still rejects
on disconnect.

The relay e2e case reads a 1 MiB file through a real daemon and a local
relay with timeout 0 and compares SHA-256 digests with the source.

* Reject a binary file transfer whose byte count differs from FileBegin.size

concatByteChunks allocated FileBegin.size bytes and copied the chunks in:
a short transfer resolved as a zero-padded file, and a long one threw a
RangeError inside the transport callback and left the request pending.
Count the received bytes at FileEnd and reject the read when they differ
from the announced size.

* Download files over the session socket when no direct connection is active

The download store only knew the HTTP token route and picked the first
directTcp connection in the host profile, whatever connection the host
runtime was using. A relay-only host has none, so every download ended in
"Download host is unavailable." (#543). SSH, socket and pipe hosts failed
the same way, and a stale directTcp entry won over a working relay (#3753).

Choose the transport from the active connection: directTcp keeps the HTTP
token download unchanged; anything else reads the file over the session
socket with the binary frames previews already use, then hands the bytes
to the browser's download manager on web and Electron or writes them to
the cache directory and offers the share sheet on native. Files over
64 MiB are refused before any byte moves, using the existing fileTooLarge
string, and a daemon response without content fails instead of saving an
empty file.

* fix(app): enforce the session download size limit during the read

The token size is sampled before the file is read. Pass the download byte budget through the actual read so a changed file cannot bypass the in-memory limit.

Co-authored-by: Michael Vandesompel <mv@droggol.be>
Co-authored-by: YASICJUNWOO <joonoo3@naver.com>
Co-authored-by: JrDw0 <JrDw0@users.noreply.github.com>

* test: verify TCP downloads and own relay fixture cleanup

---------

Co-authored-by: Michael Vandesompel <mv@droggol.be>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: YASICJUNWOO <joonoo3@naver.com>
Co-authored-by: JrDw0 <JrDw0@users.noreply.github.com>
2026-10-14 17:25:31 +02:00
paseo-bot[bot] 14e6384516 fix(desktop): keep running when launcher console pipes close (#6639)
Desktop logging reaches inherited stdout and stderr before the CLI guards run. A launcher that closes its output reader raised an uncaught EPIPE dialog. Handle closed console pipes at desktop startup while preserving console logging and surfacing other stream errors.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 15:27:12 +02:00
paseo-bot[bot]andRaisal P Wardana cafa04d3fb fix(app): traverse workspaces beyond nine (#6586)
Relative navigation shared the list capped for digit shortcuts, so it wrapped at the ninth workspace. Keep all sidebar targets and cap only numbered badges. Carry forward #4128 with keyboard-routing and browser regressions.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Raisal P Wardana <IEatCodeDaily@users.noreply.github.com>
2026-10-14 13:14:08 +02:00
paseo-bot[bot]and见安 938c4e2632 Show Git diffs for filenames with quotes and control characters (#6583)
* fix(diff): decode quoted Git patch paths

* fix(server): preserve Git paths in diff and commit listings

* refactor(diff): make quoted-path conversions explicit

* test(server): verify quoted filename diffs through daemon RPC

Keep the checkout and commit-file regression on the public client interface, and align the large-file command assertion with NUL-delimited statistics.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>

* test(server): run filename RPC regression in integration checks

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 12:09:47 +02:00
paseo-bot[bot] d57096a456 Start newly configured workspace services without restarting (#6542)
* fix(server): start newly configured workspace services without restarting

Extend the workspace port plan when paseo.json declares another service, preserving existing peer ports. Serialize extensions and roll back only their new reservations on failure.

* fix(server): serialize service restarts with port plan extensions

Keep concurrent restart and newly added service allocations in the same workspace update transaction so neither overwrites the other.

* fix(server): keep service port update failures scoped and release cancelled reservations

* test(hub): show creation errors in re-enrollment failures

Assert the error field along with success so failed CI reports include the reason agent creation was rejected.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 11:07:06 +02:00
paseo-bot[bot] ada7ba43f0 Keep long custom models removable in provider settings (#6507)
* fix(app): keep long custom models removable

Custom model labels and IDs could consume the row width and push Remove outside the sheet. Allow those text cells to shrink while preserving the button width and short-name alignment.

* test(app): wait for legacy agent creation before checking prompts

Agent-list membership includes initialization. Waiting for finish at that point can return on the initial idle transition before the initial prompt starts. Wait for the matching creation response before checking each completed turn, preserving the existing exact-once assertions.

* test(app): wait for directory search keyboard focus

The search page becomes visible before its deferred focus callback runs. Wait for the input to own keyboard focus so the journey types its query rather than dropping it during navigation.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-14 10:21:52 +02:00
38 changed files with 1910 additions and 223 deletions
+6
View File
@@ -86,6 +86,12 @@ buildNpmPackage {
# Expo's web build pulls in some pre-bundled assets; ensure it doesn't try
# to phone home during the build.
CI = "1";
# Metro's worker runs out of heap transforming the generated
# ws-outbound.aot.js validator (about 12 MB). Node's default heap limit is
# 2 GB on machines with less than 15 GB of memory, such as GitHub's 7 GB
# macOS runners, and 4 GB above that. 4096 MB is that 4 GB ceiling, so this
# only raises the limit on smaller builders, like desktop-release.yml does.
NODE_OPTIONS = "--max-old-space-size=4096";
};
buildPhase = ''
@@ -223,6 +223,7 @@ test.describe("Add Project command-center flow", () => {
await page.keyboard.press("Enter");
await expectAddProjectPage(page, "directory-search");
await expect(addProjectFlowInput(page)).toBeFocused();
await page.keyboard.type(projectPickerFixture.fuzzyQuery);
await expect(addProjectFlow(page)).toContainText(projectPickerFixture.projectName, {
timeout: 30_000,
@@ -116,6 +116,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
);
expect(workspaces).toHaveLength(1);
gate.release();
await gate.waitForCreated();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(1);
const first = (await client.fetchAgents()).entries[0]!.agent;
await client.waitForFinish(first.id, 20_000);
@@ -128,6 +129,7 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
const secondPrompt = "Create another agent: emit 1 coalesced agent stream updates";
await fillComposerDraft(page, secondPrompt);
await pressSubmitBeforeTheNextRender(page, "Send message");
await gate.waitForCreated();
await expect.poll(async () => (await client.fetchAgents()).entries.length).toBe(2);
const second = (await client.fetchAgents()).entries.find(
({ agent }) => agent.id !== first.id,
@@ -206,21 +208,40 @@ for (const version of ["0.2.5", "0.7.2", "0.8.0"]) {
async function holdLegacyAgentCreation(page: Page, port: number) {
const readers = await loadSessionMessageReaders();
const arrived = Promise.withResolvers<void>();
let created = Promise.withResolvers<void>();
let createRequestId: string | undefined;
let release = () => {};
let holding = true;
await page.routeWebSocket(new RegExp(`:${port}/ws`), (browser) => {
const server = browser.connectToServer();
browser.onMessage((frame) => {
if (holding && readers.client(frame)?.type === "create_agent_request") {
const message = readers.client(frame);
if (message?.type === "create_agent_request") createRequestId = message.requestId;
if (holding && message?.type === "create_agent_request") {
holding = false;
release = () => server.send(frame);
arrived.resolve();
} else server.send(frame);
});
server.onMessage((frame) => browser.send(frame));
server.onMessage((frame) => {
const message = readers.server(frame);
if (
message?.type === "status" &&
message.payload.status === "agent_created" &&
message.payload.requestId === createRequestId
) {
created.resolve();
}
browser.send(frame);
});
});
return {
waitForRequest: () => arrived.promise,
async waitForCreated() {
// Agent-list membership includes initialization, before the initial prompt starts.
await created.promise;
created = Promise.withResolvers<void>();
},
release() {
const send = release;
release = () => {};
@@ -0,0 +1,29 @@
import { test } from "../support/fixtures";
import {
addCustomModel,
expectCustomModelRetained,
openCustomModels,
removeCustomModel,
} from "../support/helpers/custom-models";
const longModelId = "custom-model-with-a-long-name-".repeat(6);
const shortModelId = "custom-short";
for (const width of [1200, 390]) {
test(`removes a long custom model at ${width}px without removing another model`, async ({
page,
}) => {
await page.setViewportSize({ width: 1200, height: 1000 });
await openCustomModels(page);
await addCustomModel(page, shortModelId);
await addCustomModel(page, longModelId);
await test.step("remove the long model at the target width", async () => {
await page.setViewportSize({ width, height: 1000 });
await removeCustomModel(page, longModelId);
await expectCustomModelRetained(page, shortModelId);
});
await removeCustomModel(page, shortModelId);
});
}
@@ -2,10 +2,10 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, type Page } from "@playwright/test";
import { test } from "../support/fixtures";
import { openFileExplorer } from "../support/helpers/file-explorer";
import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer";
import { openChangesPanel } from "../support/helpers/workspace-tabs";
import { gotoWorkspace } from "../support/helpers/launcher";
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
let workspace: SeededWorkspace;
@@ -444,3 +444,25 @@ test("hides unsupported file operations and revert actions", async ({ page }) =>
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0);
});
test("downloads a file over direct TCP with its original filename and bytes", async ({
page,
}, testInfo) => {
const fileName = "rapport données.bin";
const source = Buffer.from([0, 1, 2, 127, 128, 255, 10]);
await writeFile(path.join(workspace.workspaceDirectory, fileName), source);
await gotoWorkspace(page, workspace.workspaceId);
await openFileExplorer(page);
const downloaded = await downloadFileFromExplorer(page, fileName);
const url = new URL(downloaded.url());
expect(url.protocol).toBe("http:");
expect(url.port).toBe(getE2EDaemonPort());
expect(url.pathname).toBe("/api/files/download");
expect(url.searchParams.get("token")).toBeTruthy();
expect(downloaded.suggestedFilename()).toBe(fileName);
const savedFile = testInfo.outputPath(fileName);
await downloaded.saveAs(savedFile);
expect(await downloaded.failure()).toBeNull();
expect(await readFile(savedFile)).toEqual(source);
});
@@ -0,0 +1,74 @@
import type { Page } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app";
import { seedWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import {
expectSidebarWorkspaceSelected,
switchWorkspaceViaSidebar,
} from "../support/helpers/workspace-ui";
async function readVisibleWorkspaceOrder(page: Page): Promise<string[]> {
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
await expect(rows).toHaveCount(12);
return rows.evaluateAll(
(elements, prefix) =>
elements.map((element) => element.getAttribute("data-testid")!.slice(prefix.length)),
`sidebar-workspace-row-${getServerId()}:`,
);
}
async function navigateWorkspace(
page: Page,
direction: "next" | "previous",
workspaceId: string,
): Promise<void> {
await page.keyboard.press(direction === "next" ? "Alt+]" : "Alt+[");
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId });
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}$`));
}
test("previous and next traverse every visible workspace while digit shortcuts stop at nine", async ({
page,
}) => {
test.setTimeout(120_000);
const project = await seedWorkspace({
repoPrefix: "relative-navigation-",
title: "Workspace 01",
});
try {
for (let index = 2; index <= 12; index += 1) {
const created = await project.client.createWorkspace({
source: { kind: "directory", path: project.repoPath },
title: `Workspace ${String(index).padStart(2, "0")}`,
});
expect(created.error).toBeNull();
}
await page.setViewportSize({ width: 1440, height: 1100 });
await gotoAppShell(page);
const order = await readVisibleWorkspaceOrder(page);
await test.step("next crosses the ninth workspace and previous returns to it", async () => {
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[8] });
await navigateWorkspace(page, "next", order[9]);
await navigateWorkspace(page, "previous", order[8]);
});
await test.step("wrapping uses the true first and last visible workspace", async () => {
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
await navigateWorkspace(page, "previous", order[11]);
await navigateWorkspace(page, "next", order[0]);
});
await test.step("the ninth digit shortcut still selects the ninth workspace", async () => {
await page.keyboard.press("Alt+9");
await expectSidebarWorkspaceSelected({
page,
serverId: getServerId(),
workspaceId: order[8],
});
});
} finally {
await project.cleanup();
}
});
@@ -0,0 +1,37 @@
import { expect, type Page } from "@playwright/test";
import { gotoAppShell, openSettings } from "./app";
import { openSettingsHostSection } from "./settings";
import { getServerId } from "./server-id";
export async function openCustomModels(page: Page): Promise<void> {
await gotoAppShell(page);
await openSettings(page);
await openSettingsHostSection(page, getServerId(), "providers");
await page.getByRole("button", { name: "Claude provider details", exact: true }).click();
await expect(page.getByRole("textbox", { name: "Search models" })).toBeVisible();
}
export async function addCustomModel(page: Page, id: string): Promise<void> {
await page.getByRole("button", { name: "Add model", exact: true }).click();
await page.getByPlaceholder("e.g. openai/gpt-5").fill(id);
await page
.getByTestId("add-custom-model-sheet")
.getByRole("button", { name: "Add", exact: true })
.click();
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toHaveCount(1);
}
export async function removeCustomModel(page: Page, id: string): Promise<void> {
await page.getByRole("textbox", { name: "Search models" }).fill(id);
const remove = page.getByRole("button", { name: `Remove ${id}`, exact: true });
await expect(remove).toBeInViewport({ ratio: 1 });
await remove.click();
await expect(remove).toHaveCount(0);
}
export async function expectCustomModelRetained(page: Page, id: string): Promise<void> {
await page.getByRole("textbox", { name: "Search models" }).fill(id);
await expect(page.getByRole("button", { name: `Remove ${id}`, exact: true })).toBeInViewport({
ratio: 1,
});
}
@@ -1,4 +1,4 @@
import { expect, type Page } from "@playwright/test";
import { expect, type Download, type Page } from "@playwright/test";
import { openFilesPanel } from "./workspace-tabs";
function fileExplorerTree(page: Page) {
@@ -39,3 +39,10 @@ export async function expectFileTabOpen(page: Page, filePath: string): Promise<v
timeout: 30_000,
});
}
export async function downloadFileFromExplorer(page: Page, fileName: string): Promise<Download> {
await fileExplorerEntry(page, fileName).click({ button: "right" });
const downloaded = page.waitForEvent("download");
await page.getByRole("menuitem", { name: "Download", exact: true }).click();
return downloaded;
}
@@ -3,6 +3,71 @@ import { describe, expect, it } from "vitest";
import { normalizeHtmlishMarkdown, splitHtmlishMarkdown } from "./html-ish";
describe("splitHtmlishMarkdown", () => {
it("renders heading text beside a linked picture without literal heading tags", () => {
const source =
'<h2><a href="https://example.com/r"><picture><source srcset="https://example.com/d.svg"><img alt="Retrigger" src="https://example.com/l.svg"></picture></a>Confidence Score: 5/5</h2>';
expect(splitHtmlishMarkdown(source)).toEqual([
{
kind: "inlineImage",
alt: "Retrigger",
src: "https://example.com/l.svg",
href: "https://example.com/r",
flowsWithText: true,
},
{ kind: "markdown", text: "Confidence Score: 5/5" },
]);
});
it.each(["h1", "h2", "h3", "h4", "h5", "h6"])(
"keeps a mixed %s heading together after preceding markdown",
(heading) => {
const source = `Intro\n\n<${heading}><img alt="Icon" src="https://example.com/icon.svg">Caption</${heading}>\n\nOutro`;
expect(splitHtmlishMarkdown(source)).toEqual([
{ kind: "markdown", text: "Intro\n\n" },
{
kind: "inlineImage",
alt: "Icon",
src: "https://example.com/icon.svg",
flowsWithText: true,
},
{ kind: "markdown", text: "Caption\n\nOutro" },
]);
},
);
it("preserves formatted text before and after images in a details heading", () => {
const source =
'<details><summary>More</summary><h2><strong>Before</strong><img alt="Icon" src="https://example.com/icon.svg"><em>After</em></h2></details>';
expect(splitHtmlishMarkdown(source)).toEqual([
{
kind: "details",
summary: "More",
body: "**Before***After*",
bodyParts: [
{ kind: "markdown", text: "**Before**" },
{ kind: "inlineImage", alt: "Icon", src: "https://example.com/icon.svg" },
{ kind: "markdown", text: "*After*" },
],
},
]);
});
it("keeps mixed HTML headings literal in code examples", () => {
const heading = '<h2><img src="https://example.com/icon.svg">Caption</h2>';
const source = `Use \`${heading}\`\n\n\`\`\`html\n${heading}\n\`\`\``;
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it("preserves unknown wrappers around text-only headings", () => {
expect(splitHtmlishMarkdown("<section><h2>Heading</h2></section>")).toEqual([
{ kind: "markdown", text: "<section>Heading</section>" },
]);
});
const inlineImageBody = [
'<a href="#"><img alt="Priority" src="https://example.com/assets/priority.svg?v=9" align="top"></a> Spoofed browser User-Agent allows access control bypass',
"",
@@ -125,9 +125,20 @@ function splitHtmlishTokens(tokens: HtmlToken[]): MarkdownDisplayPart[] {
continue;
}
const nextDetailsIndex = findNextOpenTag(tokens, cursor + 1, "details");
const nextInlineImageIndex = findNextInlineImageIndex(tokens, cursor + 1);
const end = Math.min(nextDetailsIndex ?? tokens.length, nextInlineImageIndex ?? tokens.length);
if (isHeadingTag(token)) {
const closeIndex = findMixedHeadingClose(tokens, cursor);
if (closeIndex !== null) {
// Headings render as their contents. Split those contents together so an
// embedded image cannot strand the opening heading tag in a text slice.
for (const part of splitHtmlishTokens(tokens.slice(cursor + 1, closeIndex))) {
appendDisplayPart(parts, part);
}
cursor = closeIndex + 1;
continue;
}
}
const end = findNextDisplayPartIndex(tokens, cursor + 1) ?? tokens.length;
appendMarkdownPart(parts, renderInlineTokens(tokens.slice(cursor, end)));
cursor = end;
}
@@ -259,15 +270,44 @@ function flowsWithFollowingText(tokens: HtmlToken[], start: number, end: number)
return false;
}
function findNextInlineImageIndex(tokens: HtmlToken[], start: number): number | null {
function findNextDisplayPartIndex(tokens: HtmlToken[], start: number): number | null {
for (let index = start; index < tokens.length; index += 1) {
if (parseInlineImageAt(tokens, index)) {
if (
isOpenTag(tokens[index], "details") ||
findMixedHeadingClose(tokens, index) !== null ||
parseInlineImageAt(tokens, index)
) {
return index;
}
}
return null;
}
function findMixedHeadingClose(tokens: HtmlToken[], start: number): number | null {
const token = tokens[start];
if (!isHeadingTag(token)) {
return null;
}
const closeIndex = findMatchingClose(tokens, start, token.name);
if (closeIndex === null) {
return null;
}
for (let index = start + 1; index < closeIndex; index += 1) {
if (parseInlineImageAt(tokens, index)) {
return closeIndex;
}
}
return null;
}
function appendDisplayPart(parts: MarkdownDisplayPart[], part: MarkdownDisplayPart): void {
if (part.kind === "markdown") {
appendMarkdownPart(parts, part.text);
} else {
parts.push(part);
}
}
function appendMarkdownPart(parts: MarkdownDisplayPart[], text: string): void {
if (!text) {
return;
@@ -95,11 +95,11 @@ function CustomModelRow({
return (
<View style={sheetStyles.modelRow}>
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
<Text style={[sheetStyles.modelTitle, sheetStyles.customModelText]} numberOfLines={1}>
{model.label}
</Text>
<Text
style={sheetStyles.monoHint}
style={[sheetStyles.monoHint, sheetStyles.customModelText]}
numberOfLines={1}
selectable
dataSet={CODE_SURFACE_DATASET}
@@ -762,6 +762,7 @@ const sheetStyles = StyleSheet.create((theme) => ({
iconButton: {
width: 28,
height: 28,
flexShrink: 0,
borderRadius: theme.borderRadius.full,
alignItems: "center",
justifyContent: "center",
@@ -807,6 +808,10 @@ const sheetStyles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.base,
flexShrink: 0,
},
customModelText: {
flexShrink: 1,
minWidth: 0,
},
modelRowFiller: {
flex: 1,
},
+15 -5
View File
@@ -1,5 +1,5 @@
import { useCallback, useMemo } from "react";
import { useHosts } from "@/runtime/host-runtime";
import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime";
import { useDownloadStore } from "@/stores/download-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
@@ -12,8 +12,9 @@ interface UseFileDownloadParams {
/**
* Returns a stable callback that downloads a single workspace file by its
* workspace-relative path. Shared by the file explorer tree and the git diff
* pane so both surfaces download through the same host token + download-store
* pipeline instead of duplicating the plumbing.
* pane so both surfaces download through the same download-store pipeline instead
* of duplicating the plumbing: the HTTP token download over a direct TCP connection,
* the session socket over anything else.
*/
export function useFileDownload({
serverId,
@@ -30,7 +31,7 @@ export function useFileDownload({
() => workspaceId?.trim() || normalizedWorkspaceRoot,
[normalizedWorkspaceRoot, workspaceId],
);
const { requestFileDownloadToken } = useFileExplorerActions({
const { requestFileDownloadToken, requestFileBytes } = useFileExplorerActions({
serverId,
workspaceId,
workspaceRoot: normalizedWorkspaceRoot,
@@ -48,9 +49,18 @@ export function useFileDownload({
fileName,
path,
daemonProfile,
activeConnectionId: getHostRuntimeStore().getSnapshot(serverId)?.activeConnectionId ?? null,
requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath),
readFile: requestFileBytes,
});
},
[daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId],
[
daemonProfile,
requestFileBytes,
requestFileDownloadToken,
serverId,
startDownload,
workspaceScopeId,
],
);
}
@@ -253,6 +253,21 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
[client, normalizedWorkspaceRoot, t],
);
const requestFileBytes = useCallback(
async (path: string, maxBytes: number) => {
if (!normalizedWorkspaceRoot) {
throw new Error(t("workspace.fileExplorer.states.unavailable"));
}
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
// No request timeout: a large file over a slow relay legitimately takes longer than the
// 60 s default, and a dead connection still rejects on disconnect.
return client.readFile(normalizedWorkspaceRoot, path, undefined, maxBytes, 0);
},
[client, normalizedWorkspaceRoot, t],
);
const createEntry = useCallback(
async (input: { parentPath: string; name: string; kind: "file" | "directory" }) => {
if (!client || !normalizedWorkspaceRoot) {
@@ -342,6 +357,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor
requestDirectoryListing,
requestFilePreview,
requestFileDownloadToken,
requestFileBytes,
createEntry,
renameEntry,
duplicateEntry,
@@ -0,0 +1,265 @@
import { beforeEach, describe, expect, it } from "vitest";
import { defaultHostAppearance } from "@/hosts/appearance";
import { i18n } from "@/i18n/i18next";
import {
MAX_SESSION_DOWNLOAD_BYTES,
useDownloadStore,
type Download,
type DownloadedBytes,
} from "@/stores/download-store";
import type {
DirectTcpHostConnection,
HostProfile,
RelayHostConnection,
} from "@/types/host-connection";
const FILE_BYTES = new TextEncoder().encode("relay download payload");
const FILE_PATH = "reports/données été.bin";
const FILE_NAME = "données été.bin";
const MIME_TYPE = "application/octet-stream";
const relayConnection: RelayHostConnection = {
id: "relay:relay.paseo.sh:443",
type: "relay",
relayEndpoint: "relay.paseo.sh:443",
daemonPublicKeyB64: "cHVibGljLWtleQ==",
};
// The #3753 shape: the relay hostname stored as a directTcp entry that nothing can reach.
const staleTcpConnection: DirectTcpHostConnection = {
id: "direct:relay.paseo.sh:443",
type: "directTcp",
endpoint: "relay.paseo.sh:443",
};
const localTcpConnection: DirectTcpHostConnection = {
id: "direct:localhost:6767",
type: "directTcp",
endpoint: "localhost:6767",
};
function profileWith(connections: HostProfile["connections"]): HostProfile {
return {
serverId: "srv_remote",
label: "Remote",
appearance: defaultHostAppearance(),
lifecycle: {},
connections,
preferredConnectionId: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
}
function createTokenRequest(size: number | null = FILE_BYTES.byteLength) {
const requested: string[] = [];
return {
requested,
request: async (path: string) => {
requested.push(path);
return { token: "tok", fileName: FILE_NAME, mimeType: MIME_TYPE, size, error: null };
},
};
}
function createFileReader(read: () => Promise<{ bytes: Uint8Array; size: number }>) {
const requested: string[] = [];
return {
requested,
read: async (path: string) => {
requested.push(path);
return read();
},
};
}
function createFileSaver() {
const savedFiles: DownloadedBytes[] = [];
return {
savedFiles,
save: async (file: DownloadedBytes) => {
savedFiles.push(file);
return null;
},
};
}
interface StartInput {
profile: HostProfile;
activeConnectionId: string | null;
token?: ReturnType<typeof createTokenRequest>;
reader?: ReturnType<typeof createFileReader>;
saver?: ReturnType<typeof createFileSaver>;
}
async function startDownload(input: StartInput): Promise<Download | undefined> {
const token = input.token ?? createTokenRequest();
const reader =
input.reader ??
createFileReader(async () => ({ bytes: FILE_BYTES, size: FILE_BYTES.byteLength }));
const saver = input.saver ?? createFileSaver();
await useDownloadStore.getState().startDownload({
serverId: "srv_remote",
scopeId: "workspace-1",
fileName: FILE_NAME,
path: FILE_PATH,
daemonProfile: input.profile,
activeConnectionId: input.activeConnectionId,
requestFileDownloadToken: token.request,
readFile: reader.read,
saveFile: saver.save,
});
const { activeDownloadId, downloads } = useDownloadStore.getState();
return activeDownloadId ? downloads.get(activeDownloadId) : undefined;
}
describe("download store transport selection", () => {
beforeEach(() => {
useDownloadStore.setState({ downloads: new Map(), activeDownloadId: null });
});
it("downloads over the session when the active connection is the relay", async () => {
const token = createTokenRequest();
const reader = createFileReader(async () => ({
bytes: FILE_BYTES,
size: FILE_BYTES.byteLength,
}));
const saver = createFileSaver();
const download = await startDownload({
profile: profileWith([relayConnection]),
activeConnectionId: relayConnection.id,
token,
reader,
saver,
});
expect(download).toMatchObject({ fileName: FILE_NAME, status: "complete" });
expect(token.requested).toEqual([FILE_PATH]);
expect(reader.requested).toEqual([FILE_PATH]);
expect(saver.savedFiles).toEqual([
{ bytes: FILE_BYTES, fileName: FILE_NAME, mimeType: MIME_TYPE },
]);
});
it("bounds the session read even when the token reports a smaller file", async () => {
const readRequests: Array<{ path: string; maxBytes: number }> = [];
const saver = createFileSaver();
await useDownloadStore.getState().startDownload({
serverId: "srv_remote",
scopeId: "workspace-1",
fileName: FILE_NAME,
path: FILE_PATH,
daemonProfile: profileWith([relayConnection]),
activeConnectionId: relayConnection.id,
requestFileDownloadToken: createTokenRequest(1).request,
readFile: async (path: string, maxBytes: number) => {
readRequests.push({ path, maxBytes });
return { bytes: FILE_BYTES, size: FILE_BYTES.byteLength };
},
saveFile: saver.save,
});
expect(readRequests).toEqual([{ path: FILE_PATH, maxBytes: MAX_SESSION_DOWNLOAD_BYTES }]);
expect(saver.savedFiles).toEqual([
{ bytes: FILE_BYTES, fileName: FILE_NAME, mimeType: MIME_TYPE },
]);
});
it("ignores a directTcp entry that is not the active connection", async () => {
const reader = createFileReader(async () => ({
bytes: FILE_BYTES,
size: FILE_BYTES.byteLength,
}));
const saver = createFileSaver();
const download = await startDownload({
profile: profileWith([staleTcpConnection, relayConnection]),
activeConnectionId: relayConnection.id,
reader,
saver,
});
expect(download).toMatchObject({ status: "complete" });
expect(reader.requested).toEqual([FILE_PATH]);
expect(saver.savedFiles).toHaveLength(1);
});
it("keeps the HTTP token download when the active connection is directTcp", async () => {
const reader = createFileReader(async () => ({
bytes: FILE_BYTES,
size: FILE_BYTES.byteLength,
}));
const saver = createFileSaver();
const download = await startDownload({
profile: profileWith([localTcpConnection, relayConnection]),
activeConnectionId: localTcpConnection.id,
reader,
saver,
});
expect(download).toMatchObject({ status: "complete" });
expect(reader.requested).toEqual([]);
expect(saver.savedFiles).toEqual([]);
});
it("fails the download and saves nothing when the session read fails", async () => {
const reader = createFileReader(async () => {
throw new Error("Connection lost");
});
const saver = createFileSaver();
const download = await startDownload({
profile: profileWith([relayConnection]),
activeConnectionId: relayConnection.id,
reader,
saver,
});
expect(download).toMatchObject({ status: "error", message: "Connection lost" });
expect(saver.savedFiles).toEqual([]);
});
it("refuses a file over the session size limit before reading it", async () => {
const token = createTokenRequest(MAX_SESSION_DOWNLOAD_BYTES + 1);
const reader = createFileReader(async () => ({
bytes: FILE_BYTES,
size: FILE_BYTES.byteLength,
}));
const saver = createFileSaver();
const download = await startDownload({
profile: profileWith([relayConnection]),
activeConnectionId: relayConnection.id,
token,
reader,
saver,
});
expect(download).toMatchObject({
status: "error",
message: i18n.t("composer.errors.fileTooLarge", { fileName: FILE_NAME, size: "64 MB" }),
});
expect(download?.message).toContain("64 MB");
expect(reader.requested).toEqual([]);
expect(saver.savedFiles).toEqual([]);
});
it("fails instead of saving an empty file when the daemon returns no content", async () => {
const reader = createFileReader(async () => ({ bytes: new Uint8Array(), size: 6 }));
const saver = createFileSaver();
const download = await startDownload({
profile: profileWith([relayConnection]),
activeConnectionId: relayConnection.id,
reader,
saver,
});
expect(download).toMatchObject({
status: "error",
message: "File transfer incomplete: expected 6 bytes, received 0.",
});
expect(saver.savedFiles).toEqual([]);
});
});
+105 -14
View File
@@ -16,6 +16,16 @@ interface DownloadProgress {
eta: number;
}
// The session path holds the file in memory two to three times over (client chunks, the
// concatenated result, then the Blob or native write). Lift once the client streams chunks to disk.
export const MAX_SESSION_DOWNLOAD_BYTES = 64 * 1024 * 1024;
export interface DownloadedBytes {
bytes: Uint8Array;
fileName: string;
mimeType: string | null;
}
export interface Download {
id: string;
serverId: string;
@@ -37,12 +47,18 @@ interface DownloadState {
fileName: string;
path: string;
daemonProfile: HostProfile | undefined;
/** Connection the host runtime is using right now; `null` while disconnected. */
activeConnectionId: string | null;
requestFileDownloadToken: (path: string) => Promise<{
token: string | null;
fileName: string | null;
mimeType: string | null;
size: number | null;
error: string | null;
}>;
/** Reads the whole file over the session socket; used when no direct HTTP endpoint is active. */
readFile: (path: string, maxBytes: number) => Promise<{ bytes: Uint8Array; size: number }>;
saveFile?: typeof saveDownloadedBytes;
}) => Promise<void>;
updateProgress: (id: string, progress: DownloadProgress) => void;
@@ -66,7 +82,10 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
fileName,
path,
daemonProfile,
activeConnectionId,
requestFileDownloadToken,
readFile,
saveFile = saveDownloadedBytes,
}) => {
const id = generateDownloadId();
const download: Download = {
@@ -89,12 +108,42 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
throw new Error(tokenResponse.error ?? i18n.t("downloads.requestTokenFailed"));
}
const downloadTarget = resolveDaemonDownloadTarget(daemonProfile);
const resolvedFileName = tokenResponse.fileName ?? fileName;
const downloadTarget = resolveDaemonDownloadTarget(daemonProfile, activeConnectionId);
if (!downloadTarget) {
// Relay, SSH, socket and pipe hosts have no HTTP endpoint: read the file over the
// session socket that is already connected (#543).
if (
typeof tokenResponse.size === "number" &&
tokenResponse.size > MAX_SESSION_DOWNLOAD_BYTES
) {
throw new Error(
i18n.t("composer.errors.fileTooLarge", { fileName: resolvedFileName, size: "64 MB" }),
);
}
const { bytes, size } = await readFile(path, MAX_SESSION_DOWNLOAD_BYTES);
// A daemon without a binary channel answers `encoding: "none"` for a binary file: no
// content, real size. Never save that as a complete download.
if (bytes.byteLength !== size) {
throw new Error(
`File transfer incomplete: expected ${size} bytes, received ${bytes.byteLength}.`,
);
}
const savedUri = await saveFile({
bytes,
fileName: resolvedFileName,
mimeType: tokenResponse.mimeType,
});
get().completeDownload(id);
if (savedUri !== null) {
await shareSavedFile(savedUri, tokenResponse.mimeType, resolvedFileName);
}
return;
}
if (!downloadTarget.baseUrl) {
throw new Error(i18n.t("downloads.hostUnavailable"));
}
const resolvedFileName = tokenResponse.fileName ?? fileName;
const downloadUrl = buildDownloadUrl(
downloadTarget.baseUrl,
tokenResponse.token,
@@ -146,14 +195,7 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
get().completeDownload(id);
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(result.uri, {
mimeType: tokenResponse.mimeType ?? undefined,
dialogTitle: resolvedFileName
? i18n.t("downloads.shareFileNamed", { fileName: resolvedFileName })
: i18n.t("downloads.shareFile"),
});
}
await shareSavedFile(result.uri, tokenResponse.mimeType, resolvedFileName);
} catch (error) {
const message = error instanceof Error ? error.message : i18n.t("downloads.failed");
if (isWeb) {
@@ -244,10 +286,18 @@ interface DownloadTarget {
authCredentials: { username: string; password: string } | null;
}
function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget {
const connection = daemon?.connections.find((conn) => conn.type === "directTcp") ?? null;
if (!connection) {
return { baseUrl: null, authHeader: null, authCredentials: null };
/**
* The HTTP download route is only reachable over a direct TCP connection. Returns `null` when
* the connection in use is anything else (relay, SSH, socket, pipe, or none yet), so the caller
* reads the file over the session socket instead.
*/
function resolveDaemonDownloadTarget(
daemon: HostProfile | undefined,
activeConnectionId: string | null,
): DownloadTarget | null {
const connection = daemon?.connections.find((conn) => conn.id === activeConnectionId);
if (connection?.type !== "directTcp") {
return null;
}
let parsed: URL;
@@ -299,6 +349,47 @@ function buildDownloadUrl(
return url.toString();
}
/**
* Saves bytes read over the session. Web and Electron hand them to the browser's download
* manager and return `null`; native writes the file and returns its URI for the share sheet.
*/
async function saveDownloadedBytes({
bytes,
fileName,
mimeType,
}: DownloadedBytes): Promise<string | null> {
if (isWeb) {
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
const url = URL.createObjectURL(
new Blob([buffer], { type: mimeType ?? "application/octet-stream" }),
);
triggerBrowserDownload(url, fileName);
// The browser may still be reading the blob while its save prompt is open.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
return null;
}
// Resolve and write in one tick so two downloads of the same name cannot pick the same path.
const targetFile = resolveDownloadTargetFile(fileName);
targetFile.write(bytes);
return targetFile.uri;
}
async function shareSavedFile(
uri: string,
mimeType: string | null,
fileName: string,
): Promise<void> {
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(uri, {
mimeType: mimeType ?? undefined,
dialogTitle: fileName
? i18n.t("downloads.shareFileNamed", { fileName })
: i18n.t("downloads.shareFile"),
});
}
}
function triggerBrowserDownload(url: string, fileName: string) {
if (typeof document === "undefined") {
if (typeof window !== "undefined") {
@@ -5,6 +5,7 @@ import type {
SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { buildStatusGroups } from "@/hooks/sidebar-status-view-model";
import { routeKeyboardShortcut } from "@/keyboard/route-shortcut";
import {
buildSidebarShortcutModel,
@@ -65,6 +66,46 @@ function project(projectKey: string, workspaces: SidebarWorkspaceEntry[]): Sideb
}
describe("buildSidebarShortcutModel", () => {
it.each([
[9, 1, 10],
[10, -1, 9],
[12, 1, 1],
[1, -1, 12],
] as const)(
"navigates from workspace %i by %i to workspace %i across the full visible sidebar",
(current, delta, next) => {
const workspaces = Array.from({ length: 12 }, (_, index) =>
workspace({
serverId: "s",
workspaceId: `ws-${index + 1}`,
workspaceDirectory: `/repo/w${index + 1}`,
name: `w${index + 1}`,
}),
);
const model = buildSidebarShortcutModel({
projects: [project("p", workspaces)],
collapsedProjectKeys: new Set<string>(),
});
expect(
routeKeyboardShortcut(
{ action: "workspace.navigate.relative", payload: { delta } },
{
pathname: `/h/s/workspace/ws-${current}`,
isMobile: false,
sidebarShortcutTargets: model.shortcutTargets,
navigationActiveWorkspace: null,
commandCenterOpen: false,
shortcutsDialogOpen: false,
},
),
).toEqual({ kind: "navigate-workspace", serverId: "s", workspaceId: `ws-${next}` });
expect(model.shortcutIndexByWorkspaceKey.size).toBe(9);
expect(model.shortcutIndexByWorkspaceKey.get("s:ws-9")).toBe(9);
expect(model.shortcutIndexByWorkspaceKey.has("s:ws-10")).toBe(false);
},
);
it("builds shortcut targets in visual order and excludes collapsed projects", () => {
const projects = [
project("p1", [
@@ -111,7 +152,7 @@ describe("buildSidebarShortcutModel", () => {
expect(model.shortcutIndexByWorkspaceKey.get("s1:ws-repo2-main")).toBeUndefined();
});
it("limits shortcuts to 9", () => {
it("keeps all traversal targets while numbering only the first 9", () => {
const workspaces = Array.from({ length: 20 }, (_, index) =>
workspace({
serverId: "s",
@@ -127,9 +168,11 @@ describe("buildSidebarShortcutModel", () => {
collapsedProjectKeys: new Set<string>(),
});
expect(model.shortcutTargets).toHaveLength(9);
expect(model.shortcutTargets[0]).toEqual({ serverId: "s", workspaceId: "ws-1" });
expect(model.shortcutTargets).toHaveLength(20);
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "ws-9" });
expect(model.shortcutTargets[9]).toEqual({ serverId: "s", workspaceId: "ws-10" });
expect(model.shortcutIndexByWorkspaceKey.get("s:ws-9")).toBe(9);
expect(model.shortcutIndexByWorkspaceKey.get("s:ws-10")).toBeUndefined();
});
it("excludes a collapsed project's workspaces regardless of project kind", () => {
+3 -6
View File
@@ -70,13 +70,10 @@ export function buildSidebarShortcutSections(input: {
}
for (const workspace of section.workspaces) {
if (shortcutTargets.length >= maxShortcuts) {
break;
}
const shortcutNumber = shortcutTargets.length + 1;
shortcutTargets.push(createShortcutTarget(workspace));
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutNumber);
if (shortcutTargets.length <= maxShortcuts) {
shortcutIndexByWorkspaceKey.set(workspace.workspaceKey, shortcutTargets.length);
}
}
}
+96
View File
@@ -2801,6 +2801,102 @@ test("readFile drops an old daemon's over-budget binary chunks and reports the r
await expect(responsePromise).rejects.toThrow("File is too large to display");
});
async function connectFileClient(clientId: string) {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId,
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
return { mock, client };
}
function sendBinaryFile(
mock: ReturnType<typeof createMockTransport>,
requestId: string,
announcedSize: number,
chunks: string[],
) {
mock.triggerMessage(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileBegin,
requestId,
metadata: {
mime: "application/octet-stream",
size: announcedSize,
encoding: "binary",
modifiedAt: "2026-05-02T00:00:00.000Z",
},
}),
);
for (const chunk of chunks) {
mock.triggerMessage(
encodeFileTransferFrame({
opcode: FileTransferOpcode.FileChunk,
requestId,
payload: new TextEncoder().encode(chunk),
}),
);
}
}
test("readFile with timeout 0 outlives the default request timeout", async () => {
useHeartbeatClock();
const { mock, client } = await connectFileClient("clsk_file_no_timeout");
const responsePromise = client.readFile("/tmp/project", "slow.bin", "req-slow", undefined, 0);
await vi.advanceTimersByTimeAsync(60_001);
sendBinaryFile(mock, "req-slow", 5, ["hello"]);
mock.triggerMessage(
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-slow" }),
);
const result = await responsePromise;
expect(result.size).toBe(5);
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
});
test("readFile rejects a binary transfer that ends before the announced size", async () => {
const { mock, client } = await connectFileClient("clsk_file_short");
const responsePromise = client.readFile("/tmp/project", "short.bin", "req-short");
sendBinaryFile(mock, "req-short", 10, ["hello"]);
mock.triggerMessage(
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-short" }),
);
await expect(responsePromise).rejects.toThrow(
"File transfer incomplete: expected 10 bytes, received 5.",
);
});
test("readFile rejects a binary transfer that exceeds the announced size", async () => {
const { mock, client } = await connectFileClient("clsk_file_long");
const responsePromise = client.readFile("/tmp/project", "long.bin", "req-long");
const settled = responsePromise.then(
() => null,
(error: unknown) => error,
);
sendBinaryFile(mock, "req-long", 4, ["hello!"]);
expect(() =>
mock.triggerMessage(
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-long" }),
),
).not.toThrow();
expect(await settled).toMatchObject({
message: "File transfer incomplete: expected 4 bytes, received 6.",
});
});
test("uploadFile sends metadata request and file bytes as binary chunks", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
+21
View File
@@ -4758,6 +4758,7 @@ export class DaemonClient {
requestId?: string,
acceptBinary = false,
maxBytes?: number,
timeout?: number,
): Promise<FileExplorerPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
@@ -4770,6 +4771,7 @@ export class DaemonClient {
...(maxBytes ? { maxBytes } : {}),
},
responseType: "file_explorer_response",
timeout,
});
}
@@ -4788,11 +4790,17 @@ export class DaemonClient {
return payload.directory;
}
/**
* `timeout` bounds the whole transfer, not each frame. `0` disables the timer (as
* `workspace.create` does) for files that legitimately take longer than the default over a
* slow link; a dead connection still rejects on disconnect.
*/
async readFile(
cwd: string,
path: string,
requestId?: string,
maxBytes?: number,
timeout?: number,
): Promise<FileReadResult> {
const resolvedRequestId = this.createRequestId(requestId);
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path, maxBytes });
@@ -4804,6 +4812,7 @@ export class DaemonClient {
resolvedRequestId,
true,
maxBytes,
timeout,
);
if (payload.error) {
throw new Error(payload.error);
@@ -6540,6 +6549,18 @@ export class DaemonClient {
return;
}
const received = transfer.chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
if (received !== transfer.size) {
this.activeBinaryFileTransfers.delete(frame.requestId);
this.rejectWaitersForRequestId(
frame.requestId,
new Error(
`File transfer incomplete: expected ${transfer.size} bytes, received ${received}.`,
),
);
return;
}
const bytes = concatByteChunks(transfer.chunks, transfer.size);
this.activeBinaryFileTransfers.delete(frame.requestId);
this.completedBinaryFileReads.set(frame.requestId, {
@@ -0,0 +1,88 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const modulePath = fileURLToPath(new URL("./console-streams.ts", import.meta.url));
type Output = "stdout" | "stderr";
async function writeToConsole(input: { closed?: Output; invalidStdout?: boolean } = {}) {
const child = spawn(
process.execPath,
[
"--import",
"tsx",
"-e",
`
const { protectConsoleStreams } = require(${JSON.stringify(modulePath)});
protectConsoleStreams();
process.send("ready");
process.once("message", () => {
if (${JSON.stringify(input.invalidStdout)}) require("node:fs").closeSync(1);
console.info("desktop info");
console.warn("desktop warning");
setImmediate(() => { process.send("alive"); process.disconnect(); });
});
`,
],
{
cwd: fileURLToPath(new URL(".", import.meta.url)),
stdio: ["ignore", "pipe", "pipe", "ipc"],
env: Object.fromEntries(Object.entries(process.env).filter(([key]) => key !== "FORCE_COLOR")),
},
);
let stdout = "";
let stderr = "";
let alive = false;
child.stdout.setEncoding("utf8").on("data", (text: string) => {
stdout += text;
});
child.stderr.setEncoding("utf8").on("data", (text: string) => {
stderr += text;
});
child.on("message", (message) => {
if (message === "ready") {
if (input.closed) child[input.closed].destroy();
child.send("write");
}
if (message === "alive") alive = true;
});
const code = await new Promise<number | null>((resolve, reject) => {
child.once("close", resolve);
child.once("error", reject);
});
return { code, stdout, stderr, alive };
}
describe("desktop console streams", () => {
for (const stream of ["stdout", "stderr"] as const) {
it(`keeps running when the launcher closes ${stream}`, async () => {
const result = await writeToConsole({ closed: stream });
expect(result.code, result.stderr).toBe(0);
expect(result.alive).toBe(true);
expect(result[stream === "stdout" ? "stderr" : "stdout"]).toBe(
stream === "stdout" ? "desktop warning\n" : "desktop info\n",
);
});
}
// Windows stdout uses a native handle rather than the POSIX fd closed here.
it.skipIf(process.platform === "win32")(
"does not suppress a console failure other than a closed pipe",
async () => {
const result = await writeToConsole({ invalidStdout: true });
expect(result.code).toBe(1);
expect(result.alive).toBe(false);
expect(result.stderr).toContain("EBADF");
},
);
it("preserves logging while the console has readers", async () => {
expect(await writeToConsole()).toEqual({
code: 0,
stdout: "desktop info\n",
stderr: "desktop warning\n",
alive: true,
});
});
});
@@ -0,0 +1,11 @@
function ignoreClosedConsole(error: NodeJS.ErrnoException): void {
if (error.code !== "EPIPE") {
throw error;
}
}
/** Keep desktop logging independent of the program that launched its console. */
export function protectConsoleStreams(): void {
process.stdout.on("error", ignoreClosedConsole);
process.stderr.on("error", ignoreClosedConsole);
}
+3
View File
@@ -1,3 +1,6 @@
import { protectConsoleStreams } from "./diagnostics/console-streams.js";
protectConsoleStreams();
process.emitWarning = (() => {}) as typeof process.emitWarning;
import log from "electron-log/main";
+1 -1
View File
@@ -89,7 +89,7 @@
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
"test": "npm run test:unit && npm run test:integration",
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"",
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\" && vitest run --maxWorkers=1 src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"oversized attachments\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -0,0 +1,54 @@
import { expect, test } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./agent.js";
const hostedEnvironments = [
{ transport: "Bedrock", env: { CLAUDE_CODE_USE_BEDROCK: "1" } },
{ transport: "Vertex", env: { CLAUDE_CODE_USE_VERTEX: "true" } },
];
test.each(hostedEnvironments)(
"offers Auto mode on $transport while preserving its default mode",
async ({ env }) => {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
runtimeSettings: { env },
resolveVersion: async () => "2.1.263",
discoverModels: async () => [],
});
const catalog = await client.fetchCatalog({ cwd: process.cwd() });
expect(catalog.modes?.map((mode) => mode.id)).toContain("auto");
expect(catalog.defaultModeId).toBe("default");
},
);
test.each(hostedEnvironments)(
"passes an explicit Auto selection on $transport to Claude Code",
async ({ env }) => {
const sdkError = new Error("Claude Code rejected this request");
let requestedMode: string | undefined;
let requestedEnv: NodeJS.ProcessEnv | undefined;
const client = new ClaudeAgentClient({
logger: createTestLogger(),
runtimeSettings: { env },
resolveBinary: async () => "/test/claude",
queryFactory: ({ options }) => {
requestedMode = options.permissionMode;
requestedEnv = options.env;
throw sdkError;
},
});
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
await expect(session.setMode("auto")).rejects.toBe(sdkError);
expect(requestedMode).toBe("auto");
expect(requestedEnv).toMatchObject(env);
} finally {
await session.close();
}
},
);
@@ -201,23 +201,6 @@ test("exposes and applies auto permission mode", async () => {
}
});
test("rejects auto mode when Claude Code uses Bedrock", async () => {
const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK;
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
const session = await createSession();
try {
await expect(session.setMode("auto")).rejects.toThrow(
"Claude Auto mode requires the Anthropic API and is not supported when Claude Code uses Bedrock",
);
expect(sdkQueryFactory).not.toHaveBeenCalled();
} finally {
restoreEnvValue("CLAUDE_CODE_USE_BEDROCK", previousBedrock);
await session.close();
}
});
test.each([
{ env: {}, expected: "auto" },
{ env: { CLAUDE_CODE_USE_BEDROCK: "1" }, expected: "default" },
@@ -293,40 +276,6 @@ test("allows launch env to disable inherited Bedrock transport for auto mode", a
}
});
test("fails an auto mode turn when Claude Code uses Vertex", async () => {
const previousVertex = process.env.CLAUDE_CODE_USE_VERTEX;
process.env.CLAUDE_CODE_USE_VERTEX = "true";
sdkQueryFactory.mockImplementation(() => {
throw new Error("query should not start");
});
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: sdkQueryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
modeId: "auto",
});
try {
const events = await collectUntilTerminal(streamSession(session, "hello"));
const failure = events.find(
(event): event is Extract<AgentStreamEvent, { type: "turn_failed" }> =>
event.type === "turn_failed",
);
expect(failure?.error).toContain(
"Claude Auto mode requires the Anthropic API and is not supported when Claude Code uses Vertex",
);
expect(sdkQueryFactory).not.toHaveBeenCalled();
} finally {
restoreEnvValue("CLAUDE_CODE_USE_VERTEX", previousVertex);
await session.close();
}
});
test("switches an auto mode session to another mode when Claude Code uses Bedrock", async () => {
const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK;
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
@@ -947,37 +947,11 @@ function isTruthyEnvValue(value: string | undefined): boolean {
);
}
function claudeAutoModeUnavailableOn(env: NodeJS.ProcessEnv): "Bedrock" | "Vertex" | null {
if (isTruthyEnvValue(env.CLAUDE_CODE_USE_BEDROCK)) {
return "Bedrock";
}
if (isTruthyEnvValue(env.CLAUDE_CODE_USE_VERTEX)) {
return "Vertex";
}
return null;
}
function assertClaudeModeCanRun(mode: PermissionMode, env: NodeJS.ProcessEnv): void {
if (mode !== "auto") {
return;
}
const transport = claudeAutoModeUnavailableOn(env);
if (transport === null) {
return;
}
throw new Error(
`Claude Auto mode requires the Anthropic API and is not supported when Claude Code uses ${transport}. Select another permission mode or unset the ${transport === "Bedrock" ? "CLAUDE_CODE_USE_BEDROCK" : "CLAUDE_CODE_USE_VERTEX"} environment variable.`,
);
}
function claudeModeCatalog(env: NodeJS.ProcessEnv): {
modes: AgentMode[];
defaultModeId: PermissionMode;
} {
if (claudeAutoModeUnavailableOn(env)) {
return { modes: DEFAULT_MODES.filter((mode) => mode.id !== "auto"), defaultModeId: "default" };
}
return { modes: DEFAULT_MODES, defaultModeId: "auto" };
function claudeDefaultMode(env: NodeJS.ProcessEnv): PermissionMode {
return isTruthyEnvValue(env.CLAUDE_CODE_USE_BEDROCK) ||
isTruthyEnvValue(env.CLAUDE_CODE_USE_VERTEX)
? "default"
: "auto";
}
function coerceSessionMetadata(metadata: AgentMetadata | undefined): Partial<AgentSessionConfig> {
@@ -1642,15 +1616,15 @@ export class ClaudeAgentClient implements AgentClient {
discoveredModelIds,
),
);
const modeCatalog = claudeModeCatalog(env);
return {
models,
...modeCatalog,
modes: DEFAULT_MODES,
defaultModeId: claudeDefaultMode(env),
};
}
async resolveDefaultModeId({ env: launchEnv }: ResolveAgentDefaultModeInput): Promise<string> {
return claudeModeCatalog(this.buildProviderEnv(launchEnv)).defaultModeId;
return claudeDefaultMode(this.buildProviderEnv(launchEnv));
}
private buildProviderEnv(launchEnv?: Record<string, string>): NodeJS.ProcessEnv {
@@ -2540,7 +2514,6 @@ class ClaudeAgentSession implements AgentSession {
}
const normalized = isPermissionMode(modeId) ? modeId : "default";
assertClaudeModeCanRun(normalized, this.harnessEnvironment);
const previousMode = this.currentMode;
const launchesQuery = !this.query || this.queryRestartNeeded;
const activeQuery = await this.ensureQuery(normalized);
@@ -3424,7 +3397,6 @@ class ClaudeAgentSession implements AgentSession {
);
const settingsOptions = this.buildSettingsOptions(providerOptions, { ultracode });
const sdkEnv = this.harnessEnvironment;
assertClaudeModeCanRun(permissionMode, sdkEnv);
const claudeBinary = await this.resolveBinary();
this.logger.debug(
@@ -8,9 +8,13 @@ import net from "node:net";
import path from "node:path";
import { spawn, type ChildProcess } from "node:child_process";
import { Buffer } from "node:buffer";
import { createHash, randomBytes } from "node:crypto";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import { generateLocalPairingOffer } from "../pairing-offer.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { FILE_EXPLORER_STREAM_CHUNK_BYTES } from "../file-explorer/service.js";
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
import {
deriveSharedKey,
@@ -75,6 +79,10 @@ function decodeOfferFromFragmentUrl(url: string): {
return { serverId: offer.serverId, daemonPublicKeyB64: offer.daemonPublicKeyB64 };
}
function sha256(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
function encodeCiphertext(ciphertext: ArrayBuffer): string {
return Buffer.from(new Uint8Array(ciphertext)).toString("base64");
}
@@ -860,4 +868,67 @@ async function waitForCapturedLog(
await stopRelay();
}
}, 90000);
test("reads a workspace file over the E2EE relay session byte-for-byte", async () => {
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
const { logger, lines } = createCapturingLogger();
let daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>> | null = null;
let workspaceDir: string | null = null;
const fileName = "rapport données.bin";
const source = randomBytes(4 * FILE_EXPLORER_STREAM_CHUNK_BYTES + 123);
let client: DaemonClient | null = null;
try {
await startRelay({ useLocalRelay: true });
daemon = await createTestPaseoDaemon({
listen: "127.0.0.1",
logger,
relayEnabled: true,
relayEndpoint: `127.0.0.1:${relayPort}`,
});
workspaceDir = await mkdtemp(path.join(os.tmpdir(), "paseo-relay-download-"));
await writeFile(path.join(workspaceDir, fileName), source);
const offerUrl = await getPairingOfferUrl({
paseoHome: daemon.paseoHome,
relayEnabled: daemon.config.relayEnabled,
relayEndpoint: daemon.config.relayEndpoint,
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
appBaseUrl: daemon.config.appBaseUrl,
});
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
client = new DaemonClient({
url: buildRelayWebSocketUrl({
endpoint: `127.0.0.1:${relayPort}`,
useTls: false,
serverId,
role: "client",
}),
clientType: "cli",
connectTimeoutMs: 30_000,
e2ee: { enabled: true, daemonPublicKeyB64 },
reconnect: { enabled: false },
});
await client.connect();
const result = await client.readFile(workspaceDir, fileName, undefined, undefined, 0);
expect(result.size).toBe(source.byteLength);
expect(result.bytes.byteLength).toBe(source.byteLength);
expect(sha256(result.bytes)).toBe(sha256(source));
} catch (err) {
const tail = lines.slice(-50).join("");
// eslint-disable-next-line no-console
console.error("daemon logs (tail):\n", tail);
throw err;
} finally {
await client?.close().catch(() => undefined);
await daemon?.close();
await stopRelay();
if (workspaceDir) {
await rm(workspaceDir, { recursive: true, force: true });
}
}
}, 90000);
});
@@ -0,0 +1,62 @@
import { test, expect } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createTestPaseoDaemon, DaemonClient } from "./test-utils/index.js";
test.skipIf(process.platform === "win32")(
"checkout and commit diffs preserve filenames that Git quotes",
async () => {
const cwd = mkdtempSync(join(tmpdir(), "git-filename-diffs-"));
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
const names = [
'report "draft".txt',
"back\\slash.txt",
"tab\tname.txt",
"normal.txt",
"with spaces.txt",
];
const editLines = expect.arrayContaining([
expect.objectContaining({ type: "remove", content: "before" }),
expect.objectContaining({ type: "add", content: "after" }),
]);
const daemon = await createTestPaseoDaemon({ agentClients: {} });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
git("init", "-b", "main");
git("config", "user.name", "Test");
git("config", "user.email", "test@example.com");
git("config", "core.quotepath", "false");
for (const name of names) writeFileSync(join(cwd, name), "before\n");
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "initial");
for (const name of names) writeFileSync(join(cwd, name), "after\n");
await client.connect();
const checkout = await client.getCheckoutDiff(cwd, { mode: "uncommitted" });
expect(checkout.error).toBeNull();
expect(checkout.files.map((file) => file.path).sort()).toEqual([...names].sort());
for (const file of checkout.files) {
expect(file).toMatchObject({ status: "ok", additions: 1, deletions: 1 });
expect(file.hunks.flatMap((hunk) => hunk.lines)).toEqual(editLines);
}
git("add", ".");
git("-c", "commit.gpgsign=false", "commit", "-m", "edit");
const sha = git("rev-parse", "HEAD");
for (const name of names) {
const commit = await client.getCommitFileDiff(cwd, sha, name);
expect(commit.file).toMatchObject({ path: name, additions: 1, deletions: 1 });
expect(commit.file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(editLines);
const file = await client.readFile(cwd, name);
expect(file.kind).toBe("text");
expect(new TextDecoder().decode(file.bytes)).toBe("after\n");
}
} finally {
await client.close();
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
},
);
@@ -773,8 +773,12 @@ describe("Hub relationship", () => {
const second = await relationship.ownedCreateResult("second-create");
expect(relationship.relationshipFile()?.relationship.daemonId).toBe(daemonId);
expect(first).toMatchObject({ payload: { success: true, executionId: "first-execution" } });
expect(second).toMatchObject({ payload: { success: true, executionId: "second-execution" } });
expect(first).toMatchObject({
payload: { success: true, executionId: "first-execution", error: null },
});
expect(second).toMatchObject({
payload: { success: true, executionId: "second-execution", error: null },
});
expect(await relationship.durableOwnedAgentIds()).toHaveLength(2);
});
@@ -218,6 +218,85 @@ index 1234567..abcdefg 100644
},
);
it("decodes quoted Git patch paths before matching files", () => {
const files = parseDiff(
[
'diff --git "a/report \\"draft\\".txt" "b/report \\"draft\\".txt"',
"index 1234567..abcdefg 100644",
'--- "a/report \\"draft\\".txt"',
'+++ "b/report \\"draft\\".txt"',
"@@ -1 +1 @@",
"-before",
"+after",
"",
].join("\n"),
);
expect(files).toEqual([
{
path: 'report "draft".txt',
isNew: false,
isDeleted: false,
additions: 1,
deletions: 1,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "before" },
{ type: "add", content: "after" },
],
},
],
},
]);
});
it.each([
["tab\\tname.txt", "tab\tname.txt"],
["line\\nname.txt", "line\nname.txt"],
["back\\\\slash.txt", "back\\slash.txt"],
["bell\\aname.txt", "bell\u0007name.txt"],
["vertical\\vname.txt", "vertical\u000bname.txt"],
["\\346\\265\\213\\350\\257\\225.txt", "测试.txt"],
['测试 \\"draft\\".txt', '测试 "draft".txt'],
])("decodes C-quoted path %s without changing its diff lines", (encoded, path) => {
const files = parseDiff(
[
'diff --git "a/' + encoded + '" "b/' + encoded + '"',
"index 1234567..abcdefg 100644",
'--- "a/' + encoded + '"',
'+++ "b/' + encoded + '"',
"@@ -1 +1 @@",
"-before",
"+after",
"",
].join("\n"),
);
expect(files).toHaveLength(1);
expect(files[0]).toMatchObject({ path, additions: 1, deletions: 1 });
expect(files[0].hunks[0].lines).toEqual([
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "before" },
{ type: "add", content: "after" },
]);
});
it.each([
['"a/old \\"draft\\".txt" b/new with spaces.txt', "new with spaces.txt"],
['a/old with spaces.txt "b/new \\"draft\\".txt"', 'new "draft".txt'],
['"report \\"draft\\".txt" "report \\"draft\\".txt"', 'report "draft".txt'],
])("reads quoted paths from header-only patches: %s", (header, path) => {
expect(parseDiff("diff --git " + header + "\nsimilarity index 100%\n")).toEqual([
{ path, isNew: false, isDeleted: false, additions: 0, deletions: 0, hunks: [] },
]);
});
it("parses a simple diff with one hunk", () => {
const files = parseDiff(SIMPLE_DIFF);
@@ -1,6 +1,7 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { highlightCode, isLanguageSupported, type HighlightToken } from "@getpaseo/highlight";
import { unquoteGitPath } from "../../utils/git-quoted-path.js";
const MAX_DIFF_HIGHLIGHT_LINE_CHARS = 10_000;
@@ -53,7 +54,7 @@ function extractPathFromMetadata(lines: string[], prefix: "--- " | "+++ "): stri
return null;
}
const path = line.slice(prefix.length).replace(/\t.*$/, "").trimEnd();
const path = unquoteGitPath(line.slice(prefix.length).replace(/\t.*$/, ""));
return path === "/dev/null" ? null : path;
}
@@ -64,6 +65,17 @@ function extractPathFromDiffHeader(lines: string[]): string {
return prefixedPathMatch[2];
}
const quotedPathMatch =
firstLine.match(/^("(?:\\.|[^"\\])*") (.+)$/) ?? firstLine.match(/^(.+) ("(?:\\.|[^"\\])*")$/);
if (quotedPathMatch) {
const oldPath = unquoteGitPath(quotedPathMatch[1]);
const newPath = unquoteGitPath(quotedPathMatch[2]);
if (usesDiffPathPrefixes(oldPath, newPath)) {
return newPath.slice(2);
}
return newPath;
}
const metadataPath =
extractPathFromMetadata(lines, "+++ ") ?? extractPathFromMetadata(lines, "--- ");
if (metadataPath) {
@@ -33,7 +33,7 @@ describe("ensureWorkspaceServicePortPlan", () => {
expect(allocatedServices).toEqual(["api", "web", "worker"]);
});
it("returns the existing plan without calling the allocator on later calls", async () => {
it("extends the existing plan with newly declared services without reallocating peers", async () => {
let allocationCount = 0;
const firstPlan = await ensureWorkspaceServicePortPlan({
@@ -54,9 +54,11 @@ describe("ensureWorkspaceServicePortPlan", () => {
},
});
expect(Array.from(secondPlan.entries())).toEqual(Array.from(firstPlan.entries()));
expect(secondPlan.has("new-service")).toBe(false);
expect(allocationCount).toBe(2);
expect(Array.from(secondPlan.entries())).toEqual([
...firstPlan.entries(),
["new-service", 4300],
]);
expect(allocationCount).toBe(3);
});
it("shares one first-plan build across concurrent callers", async () => {
@@ -101,6 +103,103 @@ describe("ensureWorkspaceServicePortPlan", () => {
expect(allocationCount).toBe(2);
});
it("extends a pending plan for a concurrent caller with another service", async () => {
const allocation = createDeferredPort();
const workspaceId = "registry-concurrent-extension-workspace";
const firstPlan = ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }],
allocatePort: async () => allocation.promise,
});
let allocations = 0;
const secondPlan = ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }, { scriptName: "web" }],
allocatePort: async () => {
allocations += 1;
return 6101;
},
});
allocation.resolve(6100);
expect(Array.from((await firstPlan).entries())).toEqual([["backend-dev", 6100]]);
expect(Array.from((await secondPlan).entries())).toEqual([
["backend-dev", 6100],
["web", 6101],
]);
expect(allocations).toBe(1);
releaseWorkspaceServicePortPlan(workspaceId);
});
it("keeps the previous plan and reservations when an extension fails", async () => {
const workspaceId = "registry-failed-extension-workspace";
await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }],
allocatePort: async () => 6200,
});
let allocations = 0;
await expect(
ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }, { scriptName: "web" }, { scriptName: "worker" }],
allocatePort: async () => {
allocations += 1;
if (allocations === 1) return 6201;
throw new Error("Allocation failed");
},
}),
).rejects.toThrow("Allocation failed");
const priorPlan = await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }],
allocatePort: async () => {
throw new Error("Existing port must survive");
},
});
expect(Array.from(priorPlan.entries())).toEqual([["backend-dev", 6200]]);
const otherWorkspace = "registry-failed-extension-other-workspace";
const reusedPlan = await ensureWorkspaceServicePortPlan({
workspaceId: otherWorkspace,
services: [{ scriptName: "web" }],
allocatePort: async ({ reservedPorts }) => {
expect(reservedPorts.has(6200)).toBe(true);
expect(reservedPorts.has(6201)).toBe(false);
return 6201;
},
});
expect(reusedPlan.get("web")).toBe(6201);
releaseWorkspaceServicePortPlan(workspaceId);
releaseWorkspaceServicePortPlan(otherWorkspace);
});
it("rejects a new explicit port that collides with an existing service", async () => {
const workspaceId = "registry-extension-explicit-collision-workspace";
await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }],
allocatePort: async () => 6300,
});
await expect(
ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6300 }],
allocatePort: async () => 6301,
}),
).rejects.toThrow("Service 'web' has a duplicate port 6300");
const plan = await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6301 }],
allocatePort: async () => {
throw new Error("No allocation needed");
},
});
expect(Array.from(plan.entries())).toEqual([
["backend-dev", 6300],
["web", 6301],
]);
releaseWorkspaceServicePortPlan(workspaceId);
});
it("uses explicit configured ports without calling the allocator", async () => {
let allocationCount = 0;
@@ -312,6 +411,123 @@ describe("refreshWorkspaceServicePort", () => {
]);
});
it("preserves a concurrent service addition while refreshing a stopped service", async () => {
const workspaceId = "registry-refresh-and-extend-workspace";
await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }],
allocatePort: async () => 6400,
});
const allocation = createDeferredPort();
const refreshing = refreshWorkspaceServicePort({
workspaceId,
service: { scriptName: "backend-dev" },
allocatePort: async () => allocation.promise,
});
const extending = ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "backend-dev" }, { scriptName: "web", port: 6402 }],
allocatePort: async () => {
throw new Error("Explicit addition needs no allocation");
},
});
allocation.resolve(6401);
expect(await refreshing).toBe(6401);
await extending;
const plan = await ensureWorkspaceServicePortPlan({
workspaceId,
services: [],
allocatePort: async () => {
throw new Error("No allocation needed");
},
});
expect(Array.from(plan.entries())).toEqual([
["backend-dev", 6401],
["web", 6402],
]);
releaseWorkspaceServicePortPlan(workspaceId);
});
it.each(["reused port", "allocation failure"])(
"cancels a pending refresh and its waiter after workspace release (%s)",
async (outcome) => {
const workspaceId = "registry-cancelled-refresh-workspace";
await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "api" }],
allocatePort: async () => 6500,
});
const allocation = createDeferredPort();
const refreshing = refreshWorkspaceServicePort({
workspaceId,
service: { scriptName: "api" },
allocatePort: async () => {
const port = await allocation.promise;
if (outcome === "allocation failure") throw new Error("Allocation failed");
return port;
},
});
const waiting = refreshWorkspaceServicePort({
workspaceId,
service: { scriptName: "web", port: 6501 },
allocatePort: async () => 6501,
});
const assertions = Promise.all([
expect(refreshing).rejects.toThrow("Workspace service port plan was released"),
expect(waiting).rejects.toThrow("Workspace service port plan was released"),
]);
releaseWorkspaceServicePortPlan(workspaceId);
allocation.resolve(6500);
await assertions;
const reusedPlan = await ensureWorkspaceServicePortPlan({
workspaceId: "registry-after-cancelled-refresh-workspace",
services: [{ scriptName: "api" }],
allocatePort: async () => 6500,
});
expect(reusedPlan.get("api")).toBe(6500);
releaseWorkspaceServicePortPlan("registry-after-cancelled-refresh-workspace");
},
);
it("lets a waiting service restart allocate after another service's restart fails", async () => {
const workspaceId = "registry-independent-refresh-failure-workspace";
await ensureWorkspaceServicePortPlan({
workspaceId,
services: [{ scriptName: "api" }, { scriptName: "web" }],
allocatePort: createSequentialPortAllocator(6600),
});
const allocation = createDeferredPort();
const failedRefresh = refreshWorkspaceServicePort({
workspaceId,
service: { scriptName: "api" },
allocatePort: async () => {
await allocation.promise;
throw new Error("API port script failed");
},
});
const failedAssertion = expect(failedRefresh).rejects.toThrow("API port script failed");
const waitingRefresh = refreshWorkspaceServicePort({
workspaceId,
service: { scriptName: "web" },
allocatePort: async () => 6602,
});
allocation.resolve(0);
await failedAssertion;
expect(await waitingRefresh).toBe(6602);
const plan = await ensureWorkspaceServicePortPlan({
workspaceId,
services: [],
allocatePort: async () => {
throw new Error("No allocation needed");
},
});
expect(Array.from(plan.entries())).toEqual([
["api", 6600],
["web", 6602],
]);
releaseWorkspaceServicePortPlan(workspaceId);
});
it("uses an explicit configured port without calling the allocator", async () => {
let allocationCount = 0;
@@ -24,6 +24,24 @@ interface PendingWorkspaceServicePortPlanToken {
isReleased: boolean;
}
class WorkspaceServicePortPlanReleasedError extends Error {
constructor(workspaceId: string) {
super(`Workspace service port plan was released while being created for '${workspaceId}'`);
}
}
async function waitForWorkspaceServicePortPlanUpdate(
pendingPlan: Promise<Map<string, number>>,
): Promise<void> {
try {
await pendingPlan;
} catch (error) {
// Allocation failure belongs to the original request. A waiting request
// retries against the rolled-back plan; workspace archival cancels both.
if (error instanceof WorkspaceServicePortPlanReleasedError) throw error;
}
}
const workspaceServicePortPlans = new Map<string, Map<string, number>>();
const pendingWorkspaceServicePortPlans = new Map<string, Promise<Map<string, number>>>();
const pendingWorkspaceServicePortPlanTokens = new Map<
@@ -37,25 +55,19 @@ const MAX_DYNAMIC_PORT_ALLOCATION_ATTEMPTS = 10;
export async function ensureWorkspaceServicePortPlan(
options: EnsureWorkspaceServicePortPlanOptions,
): Promise<ReadonlyMap<string, number>> {
const existingPlan = workspaceServicePortPlans.get(options.workspaceId);
if (existingPlan) {
const pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
if (pendingPlan) {
await waitForWorkspaceServicePortPlanUpdate(pendingPlan);
return ensureWorkspaceServicePortPlan(options);
}
const existingPlan =
workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>();
if (options.services.every((service) => existingPlan.has(service.scriptName))) {
return new Map(existingPlan);
}
let pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
if (!pendingPlan) {
const token: PendingWorkspaceServicePortPlanToken = { isReleased: false };
pendingPlan = createPendingWorkspaceServicePortPlan({
workspaceId: options.workspaceId,
services: options.services,
allocatePort: options.allocatePort,
token,
});
pendingWorkspaceServicePortPlans.set(options.workspaceId, pendingPlan);
pendingWorkspaceServicePortPlanTokens.set(options.workspaceId, token);
}
return new Map(await pendingPlan);
return new Map(await startWorkspaceServicePortPlanUpdate({ ...options, existingPlan }));
}
export function requirePlannedWorkspaceServicePort(
@@ -82,27 +94,62 @@ export function releaseWorkspaceServicePortPlan(workspaceId: string): void {
dynamicPortsByWorkspace.delete(workspaceId);
}
async function createPendingWorkspaceServicePortPlan(options: {
workspaceId: string;
services: readonly WorkspaceServicePortDeclaration[];
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
token: PendingWorkspaceServicePortPlanToken;
}): Promise<Map<string, number>> {
interface WorkspaceServicePortPlanUpdateOptions extends EnsureWorkspaceServicePortPlanOptions {
existingPlan: ReadonlyMap<string, number>;
refreshScriptName?: string;
}
function startWorkspaceServicePortPlanUpdate(
options: WorkspaceServicePortPlanUpdateOptions,
): Promise<Map<string, number>> {
const token: PendingWorkspaceServicePortPlanToken = { isReleased: false };
const plan = createPendingWorkspaceServicePortPlan({ ...options, token });
pendingWorkspaceServicePortPlans.set(options.workspaceId, plan);
pendingWorkspaceServicePortPlanTokens.set(options.workspaceId, token);
return plan;
}
async function createPendingWorkspaceServicePortPlan(
options: WorkspaceServicePortPlanUpdateOptions & { token: PendingWorkspaceServicePortPlanToken },
): Promise<Map<string, number>> {
const previousDynamicPorts = new Map(dynamicPortsByWorkspace.get(options.workspaceId));
const retainedPlan = new Map(options.existingPlan);
if (options.refreshScriptName !== undefined) retainedPlan.delete(options.refreshScriptName);
try {
const plan = await buildWorkspaceServicePortPlan({
const updatedPlan = await buildWorkspaceServicePortPlan({
workspaceId: options.workspaceId,
services: options.services,
allocatePort: options.allocatePort,
existingPlan: retainedPlan,
});
const plan = new Map(options.existingPlan);
for (const [scriptName, port] of updatedPlan) plan.set(scriptName, port);
if (options.token.isReleased) {
throw new Error(
`Workspace service port plan was released while being created for '${options.workspaceId}'`,
);
throw new WorkspaceServicePortPlanReleasedError(options.workspaceId);
}
for (const [scriptName, port] of previousDynamicPorts) {
if (plan.get(scriptName) !== port) {
releaseDynamicPort({ workspaceId: options.workspaceId, scriptName, port });
}
}
workspaceServicePortPlans.set(options.workspaceId, plan);
return plan;
} catch (error) {
releaseWorkspaceServicePortPlan(options.workspaceId);
const dynamicPorts = dynamicPortsByWorkspace.get(options.workspaceId);
if (dynamicPorts) {
for (const [scriptName, port] of dynamicPorts) {
if (options.token.isReleased || previousDynamicPorts.get(scriptName) !== port) {
releaseDynamicPort({ workspaceId: options.workspaceId, scriptName, port });
}
}
}
if (!options.token.isReleased) {
for (const [scriptName, port] of previousDynamicPorts) {
reserveDynamicPort({ workspaceId: options.workspaceId, scriptName, port });
}
}
if (options.token.isReleased)
throw new WorkspaceServicePortPlanReleasedError(options.workspaceId);
throw error;
} finally {
pendingWorkspaceServicePortPlans.delete(options.workspaceId);
@@ -111,12 +158,16 @@ async function createPendingWorkspaceServicePortPlan(options: {
}
async function buildWorkspaceServicePortPlan(options: {
existingPlan: ReadonlyMap<string, number>;
workspaceId: string;
services: readonly WorkspaceServicePortDeclaration[];
allocatePort: (request: WorkspaceServicePortAllocationRequest) => Promise<number>;
}): Promise<Map<string, number>> {
const explicitPortOwners = new Map<number, string>();
const explicitPortOwners = new Map<number, string>(
Array.from(options.existingPlan, ([scriptName, port]) => [port, scriptName]),
);
for (const service of options.services) {
if (options.existingPlan.has(service.scriptName)) continue;
if (service.port === undefined) continue;
if (explicitPortOwners.has(service.port)) {
throw new Error(`Service '${service.scriptName}' has a duplicate port ${service.port}`);
@@ -124,8 +175,9 @@ async function buildWorkspaceServicePortPlan(options: {
explicitPortOwners.set(service.port, service.scriptName);
}
const plan = new Map<string, number>();
const plan = new Map(options.existingPlan);
for (const service of options.services) {
if (plan.has(service.scriptName)) continue;
if (service.port !== undefined) {
plan.set(service.scriptName, service.port);
continue;
@@ -148,32 +200,20 @@ async function buildWorkspaceServicePortPlan(options: {
export async function refreshWorkspaceServicePort(
options: RefreshWorkspaceServicePortOptions,
): Promise<number> {
const plan = workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>();
const pendingPlan = pendingWorkspaceServicePortPlans.get(options.workspaceId);
if (pendingPlan) {
await waitForWorkspaceServicePortPlanUpdate(pendingPlan);
return refreshWorkspaceServicePort(options);
}
const reservedPorts = new Set(plan.values());
const previousPort = plan.get(options.service.scriptName);
if (previousPort !== undefined) {
reservedPorts.delete(previousPort);
}
const oldDynamicPort = dynamicPortsByWorkspace
.get(options.workspaceId)
?.get(options.service.scriptName);
const port = await resolveServicePort({
service: options.service,
const plan = await startWorkspaceServicePortPlanUpdate({
workspaceId: options.workspaceId,
services: [options.service],
allocatePort: options.allocatePort,
reservedPorts,
existingPlan: workspaceServicePortPlans.get(options.workspaceId) ?? new Map<string, number>(),
refreshScriptName: options.service.scriptName,
});
if (oldDynamicPort !== undefined && oldDynamicPort !== port) {
releaseDynamicPort({
workspaceId: options.workspaceId,
scriptName: options.service.scriptName,
port: oldDynamicPort,
});
}
plan.set(options.service.scriptName, port);
workspaceServicePortPlans.set(options.workspaceId, plan);
return port;
return requirePlannedWorkspaceServicePort(plan, options.service.scriptName);
}
async function resolveServicePort(options: {
@@ -837,6 +837,54 @@ describe("runAsyncWorktreeBootstrap", () => {
});
});
it("starts a service added to paseo.json without restarting the daemon", async () => {
const backend = {
type: "service" as const,
command: "uv run uvicorn app.main:app --reload --port $PASEO_PORT",
};
commitPaseoScripts({ "backend-dev": backend });
const serviceProxy = new ScriptRouteStore();
const runtimeStore = new WorkspaceScriptRuntimeStore();
const createTerminalCalls: CreateTerminalCall[] = [];
const terminalRecords: StubTerminalRecord[] = [];
const options = {
repoRoot: repoDir,
workspaceId: repoDir,
projectSlug: "repo",
branchName: "feature-added-service",
daemonPort: 6767,
serviceProxy,
runtimeStore,
terminalManager: createStubTerminalManager(createTerminalCalls, terminalRecords),
};
const backendResult = await spawnWorkspaceScript({ ...options, scriptName: "backend-dev" });
writeFileSync(
join(repoDir, "paseo.json"),
JSON.stringify({
scripts: {
"backend-dev": backend,
web: { type: "service", command: "pnpm run dev -- --port $PASEO_PORT" },
},
}),
);
const webResult = await spawnWorkspaceScript({ ...options, scriptName: "web" });
expect(webResult.port).toEqual(expect.any(Number));
expect(webResult.port).not.toBe(backendResult.port);
expect(terminalRecords[1]?.sentInputs).toEqual(["pnpm run dev -- --port $PASEO_PORT\r"]);
expect(createTerminalCalls[1]?.env?.PASEO_PORT).toBe(String(webResult.port));
expect(createTerminalCalls[1]?.env?.PASEO_SERVICE_BACKEND_DEV_PORT).toBe(
String(backendResult.port),
);
expect(serviceProxy.listRoutes()).toHaveLength(2);
expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "backend-dev" })).toMatchObject({
lifecycle: "running",
terminalId: backendResult.terminalId,
});
});
it("spawns services with public aliases and public service URLs", async () => {
commitPaseoScripts(
{
@@ -3,15 +3,218 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { getCheckoutDiff, getCommitFileDiff } from "./checkout-git.js";
import {
getCheckoutDiff,
getCheckoutShortstat,
getCommitFileDiff,
listCheckoutCommits,
} from "./checkout-git.js";
const repos: string[] = [];
const EDITED_TEXT_DIFF = {
isNew: false,
isDeleted: false,
additions: 1,
deletions: 1,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "before" },
{ type: "add", content: "after" },
],
},
],
};
afterEach(() => {
vi.unstubAllEnvs();
for (const repo of repos.splice(0)) rmSync(repo, { recursive: true, force: true });
});
it.skipIf(process.platform === "win32")(
"returns a quoted filename and its uncommitted diff using its actual path",
async () => {
const cwd = createChangedRepo("core.quotepath=false");
const name = 'report "draft".txt';
writeFileSync(join(cwd, "file.txt"), "before\n");
writeFileSync(join(cwd, name), "before\n");
git(cwd, "add", "--", name);
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add quoted filename");
writeFileSync(join(cwd, name), "after\n");
const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true });
expect(result.structured).toHaveLength(1);
expect(result.structured?.[0]).toMatchObject({
path: name,
status: "ok",
...EDITED_TEXT_DIFF,
});
},
);
describe.skipIf(process.platform === "win32")("quoted paths from real Git", () => {
const names = [
"normal.txt",
"with spaces.txt",
'report "draft".txt',
"back\\slash.txt",
"tab\tname.txt",
"line\nname.txt",
" leading and trailing .txt ",
'report "old => draft".txt',
];
function createQuotedPathsRepo(): string {
const cwd = createChangedRepo("core.quotepath=false");
writeFileSync(join(cwd, "file.txt"), "before\n");
for (const name of names) writeFileSync(join(cwd, name), "before\n");
git(cwd, "add", "--", ...names);
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add filenames");
git(cwd, "checkout", "-b", "feature");
for (const name of names) writeFileSync(join(cwd, name), "after\n");
return cwd;
}
it("preserves tracked and untracked paths, diff lines and summary counts", async () => {
const cwd = createQuotedPathsRepo();
const untrackedNames = names.map((name) => "untracked-" + name);
for (const name of untrackedNames) writeFileSync(join(cwd, name), "new file\n");
const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true });
expect(result.structured?.map((file) => file.path).sort()).toEqual(
[...names, ...untrackedNames].sort(),
);
for (const name of names) {
expect(result.structured?.find((file) => file.path === name)).toMatchObject({
path: name,
status: "ok",
...EDITED_TEXT_DIFF,
});
}
for (const name of untrackedNames) {
expect(result.structured?.find((file) => file.path === name)).toMatchObject({
path: name,
status: "ok",
isNew: true,
isDeleted: false,
additions: 1,
deletions: 0,
hunks: [
{
oldStart: 0,
oldCount: 0,
newStart: 1,
newCount: 1,
lines: [
{ type: "header", content: "@@ -0,0 +1 @@" },
{ type: "add", content: "new file" },
],
},
],
});
}
expect(await getCheckoutShortstat(cwd)).toEqual({
additions: names.length + untrackedNames.length,
deletions: names.length,
});
});
it("opens base and commit diffs using the actual paths from the commit list", async () => {
const cwd = createQuotedPathsRepo();
git(cwd, "add", "--", ...names);
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit filenames");
const sha = git(cwd, "rev-parse", "HEAD").trim();
const base = await getCheckoutDiff(cwd, {
mode: "base",
baseRef: "main",
includeStructured: true,
});
expect(base.structured?.map((file) => file.path).sort()).toEqual([...names].sort());
for (const name of names) {
expect(base.structured?.find((file) => file.path === name)).toMatchObject({
path: name,
status: "ok",
...EDITED_TEXT_DIFF,
});
}
const history = await listCheckoutCommits({ cwd });
const commit = history.commits[0];
expect(commit.sha).toBe(sha);
expect(commit.files).toEqual(
[...names].sort().map((name) => ({
path: name,
additions: 1,
deletions: 1,
status: "modified",
})),
);
for (const file of commit.files) {
expect(await getCommitFileDiff({ cwd, sha, path: file.path })).toMatchObject({
path: file.path,
...EDITED_TEXT_DIFF,
});
}
});
it("preserves quoted rename destinations and binary paths in diff and commit lists", async () => {
const cwd = createChangedRepo("core.quotepath=false");
const oldPath = 'old "draft".txt';
const newPath = 'new "draft".txt';
const binaryPath = "image\tdata.bin";
writeFileSync(join(cwd, "file.txt"), "before\n");
writeFileSync(join(cwd, oldPath), "unchanged\n");
writeFileSync(join(cwd, binaryPath), Buffer.from([0, 1, 2]));
git(cwd, "add", "--", oldPath, binaryPath);
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add rename and binary files");
git(cwd, "checkout", "-b", "feature");
git(cwd, "mv", "--", oldPath, newPath);
writeFileSync(join(cwd, binaryPath), Buffer.from([0, 3, 4]));
const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true });
expect(
result.structured?.map(({ path, oldPath: sourcePath, status }) => ({
path,
oldPath: sourcePath,
status,
})),
).toEqual([
{ path: binaryPath, oldPath: undefined, status: "binary" },
{
path: newPath,
oldPath,
status: "ok",
},
]);
expect(result.structured?.[0]).toEqual({
path: binaryPath,
isNew: false,
isDeleted: false,
additions: 0,
deletions: 0,
hunks: [],
status: "binary",
});
git(cwd, "add", "--", binaryPath);
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "rename file and edit binary");
const history = await listCheckoutCommits({ cwd });
expect(history.commits[0].files).toEqual([
{ path: binaryPath, additions: 0, deletions: 0, status: "modified" },
{ path: newPath, additions: 0, deletions: 0, status: "renamed" },
]);
});
});
it.each(["diff.mnemonicPrefix=true", "color.diff=always", "diff.noprefix=true"])(
"preserves batched paths, spaces and literal prefix directories with %s",
async (config) => {
@@ -1628,7 +1628,7 @@ const x = 1;
expect(diff.diff).toContain(`-export const value = "old";`);
expect(diff.diff).toContain(`+export const value = "new";`);
expect(commands).toContain(
"diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ --numstat HEAD",
"diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ -z --numstat HEAD",
);
expect(commands).toContain(
"diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ HEAD -- :(literal)generated.js :(literal)small.ts",
+30 -35
View File
@@ -32,6 +32,7 @@ import {
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { runGitCommand, type RunGitCommand } from "./run-git-command.js";
import { readGitFileContents } from "./git-file-contents.js";
import { unquoteGitPath } from "./git-quoted-path.js";
import { isPaseoOwnedWorktreeCwd, resolvePaseoWorktreesBaseRoot } from "./worktree.js";
import {
branchNameFromRef,
@@ -515,22 +516,18 @@ async function listCheckoutFileChanges(
const { stdout: nameStatusOut } = await runGitCommand(
buildGitDiffArgs({
ignoreWhitespace,
extra: ["--name-status", ...getCheckoutDiffRefArgs(refs)],
extra: ["-z", "--name-status", ...getCheckoutDiffRefArgs(refs)],
}),
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
);
for (const line of nameStatusOut
.split("\n")
.map((l) => l.trim())
.filter(Boolean)) {
// `--name-status` uses TAB separators, which preserves filenames with spaces.
const tabParts = line.split("\t");
const rawStatus = (tabParts[0] ?? "").trim();
const nameStatusFields = nameStatusOut.split("\0");
for (let index = 0; index < nameStatusFields.length; ) {
const rawStatus = nameStatusFields[index++];
if (!rawStatus) continue;
if (rawStatus.startsWith("R") || rawStatus.startsWith("C")) {
const oldPath = tabParts[1];
const newPath = tabParts[2];
const oldPath = nameStatusFields[index++];
const newPath = nameStatusFields[index++];
if (newPath) {
changes.push({
path: newPath,
@@ -543,7 +540,7 @@ async function listCheckoutFileChanges(
continue;
}
const path = tabParts[1];
const path = nameStatusFields[index++];
if (!path) continue;
const code = rawStatus[0];
changes.push({
@@ -556,16 +553,13 @@ async function listCheckoutFileChanges(
if (refs.includeUntracked) {
const { stdout: untrackedOut } = await runGitCommand(
["ls-files", "--others", "--exclude-standard"],
["ls-files", "-z", "--others", "--exclude-standard"],
{
cwd,
envOverlay: READ_ONLY_GIT_ENV,
},
);
for (const file of untrackedOut
.split("\n")
.map((l) => l.trim())
.filter(Boolean)) {
for (const file of untrackedOut.split("\0").filter(Boolean)) {
changes.push({
path: file,
status: "U",
@@ -623,18 +617,22 @@ async function tryResolveMergeBase(cwd: string, baseRef: string): Promise<string
type FileStat = { additions: number; deletions: number; isBinary: boolean } | null;
function normalizeNumstatPath(pathField: string): string {
if (/^"(?:\\.|[^"\\])*"$/.test(pathField)) {
return unquoteGitPath(pathField);
}
const braceRenameMatch = pathField.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
if (braceRenameMatch) {
const [, prefix, , renamed, suffix] = braceRenameMatch;
return `${prefix}${renamed}${suffix}`;
return unquoteGitPath(`${prefix}${renamed}${suffix}`);
}
const inlineRenameMatch = pathField.match(/^(.*) => (.*)$/);
if (inlineRenameMatch) {
return inlineRenameMatch[2] ?? pathField;
return unquoteGitPath(inlineRenameMatch[2] ?? pathField);
}
return pathField;
return unquoteGitPath(pathField);
}
// Parsed patches need a stable wire format regardless of terminal presentation settings.
@@ -669,7 +667,7 @@ async function getTrackedNumstatByPath(
const result = await runGitCommand(
buildGitDiffArgs({
ignoreWhitespace,
extra: ["--numstat", ...getCheckoutDiffRefArgs(refs)],
extra: ["-z", "--numstat", ...getCheckoutDiffRefArgs(refs)],
}),
{
cwd,
@@ -680,21 +678,21 @@ async function getTrackedNumstatByPath(
);
const stats = new Map<string, FileStat>();
const lines = result.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
for (const line of lines) {
const parts = line.split("\t");
const records = result.stdout.split("\0");
for (let index = 0; index < records.length; index += 1) {
const parts = records[index].split("\t");
if (parts.length < 3) {
continue;
}
const additionsField = parts[0] ?? "";
const deletionsField = parts[1] ?? "";
const rawPath = parts.slice(2).join("\t");
const path = normalizeNumstatPath(rawPath);
let path = parts.slice(2).join("\t");
if (!path) {
// A rename has an empty path here, followed by separate old/new NUL fields.
path = records[index + 2];
index += 2;
}
if (!path) {
continue;
@@ -2424,7 +2422,7 @@ function parseRawStatusLine(line: string, statuses: Map<string, CheckoutCommitFi
if (!path) {
return;
}
statuses.set(path, status);
statuses.set(unquoteGitPath(path), status);
}
// A `--numstat` line: `<adds>\t<dels>\t<path>` (renames use `old => new`, binary
@@ -2738,16 +2736,13 @@ async function countUntrackedAdditions(
): Promise<number> {
try {
const { stdout } = await getRunGitCommand(context)(
["ls-files", "--others", "--exclude-standard"],
["ls-files", "-z", "--others", "--exclude-standard"],
{
cwd,
envOverlay: READ_ONLY_GIT_ENV,
},
);
const files = stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const files = stdout.split("\0").filter(Boolean);
let additions = 0;
for (const file of files.slice(0, UNTRACKED_SHORTSTAT_MAX_FILES)) {
@@ -0,0 +1,34 @@
const GIT_PATH_ESCAPE_BYTES: Record<string, number> = {
a: 7,
b: 8,
t: 9,
n: 10,
v: 11,
f: 12,
r: 13,
'"': 34,
"\\": 92,
};
// core.quotepath=false still quotes control characters, double quotes and backslashes.
export function unquoteGitPath(value: string): string {
if (!value.startsWith('"') || !value.endsWith('"')) return value;
const encoded = value.slice(1, -1);
const parts: Buffer[] = [];
let offset = 0;
for (const match of encoded.matchAll(/\\([0-7]{1,3}|[abfnrtv"\\])/g)) {
parts.push(Buffer.from(encoded.slice(offset, match.index)));
const escape = match[1];
let byte: number;
if (/^[0-7]/.test(escape)) {
byte = Number.parseInt(escape, 8);
} else {
byte = GIT_PATH_ESCAPE_BYTES[escape];
}
parts.push(Buffer.from([byte]));
offset = match.index + match[0].length;
}
parts.push(Buffer.from(encoded.slice(offset)));
return Buffer.concat(parts).toString("utf8");
}