Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] c198d40878 test(server): reproduce directory symlink misclassification
Preserve the test2 -> ./test example from #1891. Directory listings classify the link as a file, so the explorer opens it instead of expanding it. The reported missing row was not reproduced on Linux desktop 0.11.2; this branch intentionally retains a failing test without changing production behavior.
2026-10-11 03:56:18 +02:00
59 changed files with 789 additions and 3217 deletions
@@ -683,7 +683,7 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +0, -0");
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +1, -0");
const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/);
const toggleTestId = await renamedToggle.getAttribute("data-testid");
expect(toggleTestId).not.toBeNull();
@@ -391,43 +391,6 @@ describe("splitHtmlishMarkdown", () => {
);
});
it("keeps table rows after a comparison that only looks like a tag", () => {
const source = [
"| ID | Note | Status |",
"|---|---|---|",
"| a-1 | accepts 1<size<2 only | open |",
"| a-2 | second row | open |",
"",
"Later x -> y.",
].join("\n");
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it.each(["See <https://example.com/docs> for more.", "Stop when a<b", "Keep </ b> and a <b"])(
"keeps %j literal because it is not HTML",
(source) => {
expect(normalizeHtmlishMarkdown(source)).toBe(source);
},
);
it.each([
"Call As<Integer>() before limits<DWORD>::max()",
"<Kbd Data-Key='K'>Ctrl</Kbd> then <Foo/>",
])("echoes unknown tags in %j as written", (source) => {
expect(normalizeHtmlishMarkdown(source)).toBe(source);
});
it("rebuilds an unknown tag that a code span splits", () => {
expect(normalizeHtmlishMarkdown('<Tip title="`x`">hi</Tip>')).toBe("`x`<tip title>hi</Tip>");
});
it("still reads tags and comments next to literal angle brackets", () => {
expect(normalizeHtmlishMarkdown("1<n<2 <b>bold</b><!-- hidden --> 3<4")).toBe(
"1<n<2 **bold** 3<4",
);
});
it("falls back to inert markdown when details are unclosed", () => {
const source = "<details><summary>Open</summary>Still open";
@@ -29,12 +29,6 @@ const FENCE_LINE_RE = /^ {0,3}([`~]{3,})[^\n\r]*(?:\r?\n|$)/gm;
const BACKTICK_RUN_RE = /`+/g;
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
// CommonMark open and closing tags (spec "Raw HTML"); comments are matched separately.
const HTML_ATTRIBUTE = String.raw`\s+[A-Za-z_:][\w.:-]*(?:\s*=\s*(?:[^\s"'=<>\x60]+|'[^']*'|"[^"]*"))?`;
const HTML_TAG_RE = new RegExp(
String.raw`<(?:[A-Za-z][A-Za-z0-9-]*(?:${HTML_ATTRIBUTE})*\s*\/?|\/[A-Za-z][A-Za-z0-9-]*\s*)>`,
"y",
);
const VOID_HTML_TAGS = new Set(["br", "img", "source"]);
const MARKDOWN_TAG_WRAPPERS: Readonly<Record<string, readonly [string, string]>> = {
b: ["**", "**"],
@@ -379,16 +373,14 @@ function renderInlineTokens(tokens: HtmlToken[]): string {
}
const children = tokens.slice(index + 1, closeIndex);
const close = tokens[closeIndex];
const closeRaw = isClosingTag(close, token.name) ? close.raw : `</${token.name}>`;
output += renderHtmlTag(token, children, closeRaw);
output += renderHtmlTag(token, children);
index = closeIndex;
}
return output;
}
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[], closeRaw: string): string {
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string {
if (token.name === "a") {
return renderLinkToken(token, children);
}
@@ -408,7 +400,7 @@ function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[], closeRaw: str
if (token.name === "p" || token.name === "div") {
return `\n\n${renderInlineTokens(children).trim()}\n\n`;
}
return `${token.raw}${renderInlineTokens(children)}${closeRaw}`;
return `${token.raw}${renderInlineTokens(children)}</${token.name}>`;
}
function renderTableTokens(tokens: HtmlToken[]): string {
@@ -614,15 +606,7 @@ function tokenizeHtmlishMarkdown(source: string): HtmlToken[] {
function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishTokenParser {
let stripNextLeadingLineBreak = false;
let skippedLength = 0;
let writtenLength = 0;
let chunkStart = 0;
let parser!: Parser;
// Unknown tags render as text, so keep them as written: the parser lowercases names
// and rewrites attributes. A tag split by a protected range has no single slice.
const readRawTag = (): string | null =>
parser.startIndex < chunkStart
? null
: source.slice(parser.startIndex + skippedLength, parser.endIndex + 1 + skippedLength);
parser = new Parser(
{
onopentag(name, attributes, isImplied) {
@@ -636,7 +620,7 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
closing: false,
selfClosing,
attributes,
raw: readRawTag() ?? renderStartTag(name, attributes),
raw: renderStartTag(name, attributes),
});
},
onclosetag(name, isImplied) {
@@ -649,7 +633,7 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
closing: true,
selfClosing: false,
attributes: {},
raw: readRawTag() ?? `</${name}>`,
raw: `</${name}>`,
});
},
ontext(value) {
@@ -676,8 +660,6 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
);
return {
write(chunk) {
chunkStart = writtenLength;
writtenLength += chunk.length;
parser.write(chunk);
},
skip(length) {
@@ -716,58 +698,7 @@ function escapeAttribute(value: string): string {
function getProtectedMarkdownRanges(source: string): ProtectedMarkdownRange[] {
const fencedRanges = getFencedCodeRanges(source);
const codeRanges = mergeProtectedRanges([
...fencedRanges,
...getInlineCodeRanges(source, fencedRanges),
]);
return mergeProtectedRanges([...codeRanges, ...getLiteralAngleBracketRanges(source, codeRanges)]);
}
/**
* htmlparser2 reads any `<` followed by a letter as a tag and keeps collecting attributes up
* to the next `>`, so `1<n<2` in a table cell swallows the rows after it and an autolink loses
* its slashes. Hand it only what CommonMark would read as raw HTML; every other `<` is text.
*/
function getLiteralAngleBracketRanges(
source: string,
codeRanges: ProtectedMarkdownRange[],
): ProtectedMarkdownRange[] {
const ranges: ProtectedMarkdownRange[] = [];
let codeIndex = 0;
// Reused across comment openers so unclosed comments stay linear.
let commentClose = -1;
let index = source.indexOf("<");
while (index !== -1) {
while ((codeRanges[codeIndex]?.end ?? Infinity) <= index) {
codeIndex += 1;
}
const code = codeRanges[codeIndex];
if (code && code.start <= index) {
index = source.indexOf("<", code.end);
continue;
}
let htmlEnd: number | null;
if (source.startsWith("<!--", index)) {
if (commentClose !== source.length && commentClose < index + 2) {
const close = source.indexOf("-->", index + 2);
commentClose = close === -1 ? source.length : close;
}
htmlEnd = commentClose === source.length ? null : commentClose + 3;
} else {
HTML_TAG_RE.lastIndex = index;
htmlEnd = HTML_TAG_RE.test(source) ? HTML_TAG_RE.lastIndex : null;
}
if (htmlEnd === null) {
ranges.push({ start: index, end: index + 1 });
htmlEnd = index + 1;
}
index = source.indexOf("<", htmlEnd);
}
return ranges;
return mergeProtectedRanges([...fencedRanges, ...getInlineCodeRanges(source, fencedRanges)]);
}
function getFencedCodeRanges(source: string): ProtectedMarkdownRange[] {
@@ -160,16 +160,3 @@ describe("combobox above-search ordering", () => {
expect(getComboboxFallbackIndex(ordered.length, "below-search")).toBe(0);
});
});
describe("search-only option text", () => {
it("finds hidden search text while keeping title matches first", () => {
const bodyMatch = { id: "pr:7", label: "Fix parsing", searchText: "Release notes for 2024" };
const titleMatch = { id: "pr:8", label: "2024 release" };
expect(filterAndRankComboboxOptions([bodyMatch, titleMatch], "2024")).toEqual([
titleMatch,
bodyMatch,
]);
expect(filterAndRankComboboxOptions([bodyMatch], "unrelated")).toEqual([]);
expect(filterAndRankComboboxOptions([bodyMatch], "parsing 2024")).toEqual([bodyMatch]);
});
});
@@ -10,22 +10,18 @@ export interface ComboboxOptionModel {
id: string;
label: string;
description?: string;
/** Additional text to match without displaying it in the option row. */
searchText?: string;
kind?: ComboboxOptionKind;
}
const SECONDARY_TEXT_FALLBACK_TIER = 99;
const DESCRIPTION_FALLBACK_TIER = 99;
function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null {
const best = scoreTextFields(search, [opt.label, opt.id]);
if (best) return best;
const secondaryFields = opt.searchText
? [opt.label, opt.id, opt.description ?? "", opt.searchText]
: [opt.description ?? ""];
const secondaryScore = scoreTextFields(search, secondaryFields);
if (!secondaryScore) return null;
return { ...secondaryScore, tier: secondaryScore.tier + SECONDARY_TEXT_FALLBACK_TIER };
if (!opt.description) return null;
const descriptionScore = scoreTextFields(search, [opt.description]);
if (!descriptionScore) return null;
return { ...descriptionScore, tier: descriptionScore.tier + DESCRIPTION_FALLBACK_TIER };
}
export interface BuildVisibleComboboxOptionsInput {
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { formatShortcut } from "@/utils/format-shortcut";
import { keyboardEventToComboString } from "./shortcut-string";
import {
buildKeyboardShortcutHelpSections,
buildEffectiveBindings,
@@ -1027,25 +1026,6 @@ describe("unassigned shortcuts", () => {
});
describe("matching", () => {
it("dispatches a captured Dvorak binding on the same logical key", () => {
const event = keyboardInput({ key: "b", code: "KeyN", metaKey: true });
const combo = keyboardEventToComboString(event as KeyboardEvent);
expect(combo).toBe("Cmd+B");
const bindings = buildEffectiveBindings({ "sidebar-toggle-left-mac-cmd-b": combo! });
expect(
resolveShortcut({ event, context: { isMac: true, isDesktop: true }, bindings }).match
?.action,
).toBe("sidebar.toggle.left");
expect(
resolveShortcut({
event: { key: "x", code: "KeyB", metaKey: true },
context: { isMac: true, isDesktop: true },
bindings,
}).match,
).toBeNull();
});
it("stops matching a shortcut the user unassigned", () => {
const bindings = buildEffectiveBindings({ [TAB_NEW_BINDING]: UNASSIGNED_COMBO });
@@ -22,45 +22,6 @@ function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
}
describe("keyboardEventToComboString", () => {
it("captures the Dvorak character rather than its QWERTY position", () => {
expect(
keyboardEventToComboString(keyboardEvent({ key: "b", code: "KeyN", metaKey: true })),
).toBe("Cmd+B");
});
it("captures shifted Dvorak letters and punctuation at other physical positions", () => {
expect(
keyboardEventToComboString(
keyboardEvent({ key: "T", code: "KeyK", metaKey: true, shiftKey: true }),
),
).toBe("Shift+Cmd+T");
expect(
keyboardEventToComboString(keyboardEvent({ key: ";", code: "KeyZ", ctrlKey: true })),
).toBe("Ctrl+;");
expect(
keyboardEventToComboString(
keyboardEvent({ key: "?", code: "BracketLeft", ctrlKey: true, shiftKey: true }),
),
).toBe("Ctrl+Shift+/");
});
it("keeps named keys and rewritten Option characters capturable", () => {
expect(keyboardEventToComboString(keyboardEvent({ key: "ArrowLeft", code: "ArrowLeft" }))).toBe(
"ArrowLeft",
);
expect(
keyboardEventToComboString(keyboardEvent({ key: "†", code: "KeyT", altKey: true })),
).toBe("Alt+T");
});
it("does not record a named key that dispatch does not support", () => {
expect(
keyboardEventToComboString(
keyboardEvent({ key: "Enter", code: "NumpadEnter", ctrlKey: true }),
),
).toBeNull();
});
it("captures the minus key (regression: previously returned null)", () => {
expect(keyboardEventToComboString(keyboardEvent({ key: "-", code: "Minus" }))).toBe("-");
});
+1 -10
View File
@@ -266,16 +266,7 @@ export function keyboardEventToComboString(event: KeyboardEvent): string | null
if (event.shiftKey) parts.push("Shift");
if (event.metaKey) parts.push("Cmd");
// Capture the same logical character that dispatch matches. Keep the code
// fallback for named keys and Option combinations whose key is rewritten.
const eventKey = event.key.toLowerCase();
const logicalKey = Object.entries(KEY_MAP).find(
([, mapping]) =>
mapping.key?.length === 1 &&
(mapping.key.toLowerCase() === eventKey ||
(event.shiftKey && mapping.shiftedKey === event.key)),
)?.[0];
const humanKey = logicalKey ?? CODE_TO_KEY[event.code];
const humanKey = CODE_TO_KEY[event.code];
if (humanKey) {
parts.push(humanKey);
} else {
@@ -1,5 +1,4 @@
import { describe, expect, it } from "vitest";
import { buildVisibleComboboxOptions } from "@/components/ui/combobox-options";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import {
type BranchPickerDetail,
@@ -341,48 +340,3 @@ describe("defaultBasePickerItem", () => {
expect(defaultBasePickerItem({ currentBranch: null })).toBeNull();
});
});
describe("starting-ref search", () => {
it.each(["gitlab", "github"] as const)(
"keeps a %s change request returned for a body-only match",
(forge) => {
const item: ForgeSearchItem = {
...prItem,
forge,
number: 3896,
title: "fix(config): send environment credentials only to trusted hosts",
body: "This mirrors the fix shipped for CVE-2024-53858.",
headRefName: "8545-duo-test",
};
const { options } = buildPickerOptionData({
branchDetails: [],
prItems: [item],
baseItem: null,
});
const visible = buildVisibleComboboxOptions({
options,
searchQuery: "2024",
searchable: true,
allowCustomValue: false,
customValuePrefix: "Use",
});
expect(visible.map((option) => option.label)).toEqual([
`${forge === "gitlab" ? "!" : "#"}3896 fix(config): send environment credentials only to trusted hosts`,
]);
const selected = buildPickerOptionData({
branchDetails: [],
prItems: [],
baseItem: { kind: "github-pr", item },
});
expect(
buildVisibleComboboxOptions({
options: selected.options,
searchQuery: "2024",
searchable: true,
allowCustomValue: false,
customValuePrefix: "Use",
}),
).toEqual(visible);
},
);
});
@@ -264,10 +264,7 @@ export function buildPickerOptionData(input: {
itemById.set(id, { kind: "github-pr", item: pr });
const updatedAtMs = pr.updatedAt ? Date.parse(pr.updatedAt) : 0;
const timestamp = Number.isNaN(updatedAtMs) ? 0 : Math.floor(updatedAtMs / 1000);
timedOptions.push({
option: { id, label: formatPrLabel(pr), searchText: pr.body ?? undefined },
timestamp,
});
timedOptions.push({ option: { id, label: formatPrLabel(pr) }, timestamp });
}
const baseItem = input.baseItem;
@@ -282,13 +279,7 @@ export function buildPickerOptionData(input: {
const labeledItem = disambiguate(baseItem, timedOptions);
itemById.set(selectedOptionId, labeledItem);
timedOptions.push({
option: {
id: selectedOptionId,
label: pickerItemLabel(labeledItem),
...(labeledItem.kind === "github-pr"
? { searchText: labeledItem.item.body ?? undefined }
: {}),
},
option: { id: selectedOptionId, label: pickerItemLabel(labeledItem) },
// The base sorts first: it is what a workspace is created from unless you pick something.
timestamp: Number.POSITIVE_INFINITY,
});
-67
View File
@@ -1,67 +0,0 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
const appDir = fileURLToPath(new URL(".", import.meta.url));
const rootDir = path.resolve(appDir, "../..");
const require = createRequire(import.meta.url);
const vitestEntry = path.join(path.dirname(require.resolve("vitest/package.json")), "vitest.mjs");
const tsgoEntry = path.join(
path.dirname(require.resolve("@typescript/native-preview/package.json")),
"bin/tsgo.js",
);
it("the test configuration typechecks with Expo's generated environment declaration", () => {
const fixtureDir = mkdtempSync(path.join(appDir, ".vitest-expo-env-"));
try {
writeFileSync(path.join(fixtureDir, "expo-env.d.ts"), '/// <reference types="expo/types" />\n');
writeFileSync(
path.join(fixtureDir, "tsconfig.json"),
JSON.stringify({
extends: "../tsconfig.json",
files: ["../vitest.config.ts", "expo-env.d.ts"],
include: [],
}),
);
const result = spawnSync(
process.execPath,
[tsgoEntry, "--noEmit", "--project", path.join(fixtureDir, "tsconfig.json")],
{ cwd: appDir, encoding: "utf8", timeout: 30_000 },
);
expect(result.error).toBeUndefined();
expect(result.status, result.stdout + result.stderr).toBe(0);
} finally {
rmSync(fixtureDir, { recursive: true, force: true });
}
}, 40_000);
it.each([
{
name: "root",
cwd: rootDir,
args: ["packages/app/src/screens/workspace/use-mounted-tab-set.test.ts"],
},
{
name: "app",
cwd: appDir,
args: ["--project", "unit", "src/screens/workspace/use-mounted-tab-set.test.ts"],
},
])(
"$name tests can render React when the shell exports NODE_ENV=production",
({ cwd, args }) => {
const result = spawnSync(process.execPath, [vitestEntry, "run", ...args], {
cwd,
env: { ...process.env, NODE_ENV: "production" },
encoding: "utf8",
timeout: 30_000,
});
expect(result.error).toBeUndefined();
expect(result.status, result.stdout + result.stderr).toBe(0);
},
40_000,
);
-4
View File
@@ -3,9 +3,6 @@ import { playwright } from "@vitest/browser-playwright";
import path from "path";
import fs from "fs";
// Set this before Vite resolves dependencies and Vitest starts its workers.
Object.assign(process.env, { NODE_ENV: "test" });
const appNodeModules = path.resolve(__dirname, "node_modules");
const rootNodeModules = path.resolve(__dirname, "../../node_modules");
const resolvePackageEntry = (packageName: string) => {
@@ -29,7 +26,6 @@ export default defineConfig({
"src/**/*.{test,spec}.{ts,tsx}",
"native-release-version.test.ts",
"app.config.test.ts",
"vitest-environment.test.ts",
],
setupFiles: [path.resolve(__dirname, "vitest.setup.ts")],
exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"],
@@ -15,18 +15,6 @@ function collect(items: readonly AgentTimelineItem[], turnId?: string): string {
}
describe("createFollowTranscriptWriter", () => {
it("matches one-shot logs when reasoning and assistant text alternate", () => {
const items: AgentTimelineItem[] = [
{ type: "reasoning", text: "Check " },
{ type: "reasoning", text: "the commands." },
{ type: "assistant_message", text: "I will list the commands.", messageId: "msg_1" },
{ type: "reasoning", text: "Then summarize." },
{ type: "assistant_message", text: "Done.", messageId: "msg_2" },
];
expect(collect(items).trimEnd()).toBe(formatAgentActivityTranscript(items));
});
it("keeps a word that arrives split across fragments intact", () => {
const output = collect([
{ type: "assistant_message", text: "the paseo-pr", messageId: "msg_1" },
+96 -129
View File
@@ -1,8 +1,5 @@
import type { Command } from "commander";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
} from "@getpaseo/protocol/agent-types";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
@@ -58,136 +55,106 @@ export interface PermitAllowOptions extends CommandOptions {
host?: string;
}
export interface PermitClient {
fetchAgent(options: { agentId: string }): Promise<{
agent: { id: string; pendingPermissions?: AgentPermissionRequest[] };
} | null>;
respondToPermissionAndWait(
agentId: string,
requestId: string,
response: AgentPermissionResponse,
): Promise<unknown>;
close(): Promise<void>;
}
export type PermitClientConnector = (
options: Parameters<typeof connectToDaemon>[0],
) => Promise<PermitClient>;
export function createAllowCommand(connect: PermitClientConnector = connectToDaemon) {
return async function runAllowCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitAllowOptions,
_command: Command,
): Promise<PermitAllowResult> {
// No validation needed - if no reqId provided, allow all by default
// Parse input JSON if provided
let updatedInput: Record<string, unknown> | undefined;
if (options.input) {
try {
updatedInput = JSON.parse(options.input);
} catch (err) {
const error: CommandError = {
code: "INVALID_JSON",
message: `Invalid JSON for --input: ${err instanceof Error ? err.message : String(err)}`,
details: 'Provide valid JSON, e.g., --input \'{"key": "value"}\'',
};
throw error;
}
}
const client = await connect({ target: options.daemonTarget });
export async function runAllowCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitAllowOptions,
_command: Command,
): Promise<PermitAllowResult> {
// No validation needed - if no reqId provided, allow all by default
// Parse input JSON if provided
let updatedInput: Record<string, unknown> | undefined;
if (options.input) {
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
if (!fetchResult) {
await client.close();
const error: CommandError = {
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
};
throw error;
}
const agent = fetchResult.agent;
const resolvedAgentId = agent.id;
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || [];
if (pendingPermissions.length === 0) {
await client.close();
const error: CommandError = {
code: "NO_PENDING_PERMISSIONS",
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
};
throw error;
}
// Determine which permissions to allow
let permissionsToAllow: AgentPermissionRequest[];
if (!reqId || options.all) {
// Default: allow all pending permissions if no req_id specified
// --all flag is kept as an explicit alias for clarity
permissionsToAllow = pendingPermissions;
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId));
if (!permission) {
await client.close();
const error: CommandError = {
code: "PERMISSION_NOT_FOUND",
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
};
throw error;
}
permissionsToAllow = [permission];
}
// Allow permissions
const results: PermissionResponseItem[] = await Promise.all(
permissionsToAllow.map(async (permission) => {
try {
await client.respondToPermissionAndWait(resolvedAgentId, permission.id, {
behavior: "allow",
...(updatedInput ? { updatedInput } : {}),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "PERMISSION_RESPONSE_UNCONFIRMED",
message: `Permission response unconfirmed for request ${permission.id}: ${message}`,
details: 'Use "paseo permit ls" to check whether the request is still pending',
};
throw error;
}
return toPermissionResponseItem(resolvedAgentId, permission, "allowed");
}),
);
await client.close();
return {
type: "list",
data: results,
schema: permitResponseSchema,
};
updatedInput = JSON.parse(options.input);
} catch (err) {
await client.close().catch(() => {});
// Re-throw CommandErrors
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "ALLOW_PERMISSION_FAILED",
message: `Failed to allow permission: ${message}`,
code: "INVALID_JSON",
message: `Invalid JSON for --input: ${err instanceof Error ? err.message : String(err)}`,
details: 'Provide valid JSON, e.g., --input \'{"key": "value"}\'',
};
throw error;
}
};
}
}
export const runAllowCommand = createAllowCommand();
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
if (!fetchResult) {
await client.close();
const error: CommandError = {
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
};
throw error;
}
const agent = fetchResult.agent;
const resolvedAgentId = agent.id;
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || [];
if (pendingPermissions.length === 0) {
await client.close();
const error: CommandError = {
code: "NO_PENDING_PERMISSIONS",
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
};
throw error;
}
// Determine which permissions to allow
let permissionsToAllow: AgentPermissionRequest[];
if (!reqId || options.all) {
// Default: allow all pending permissions if no req_id specified
// --all flag is kept as an explicit alias for clarity
permissionsToAllow = pendingPermissions;
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId));
if (!permission) {
await client.close();
const error: CommandError = {
code: "PERMISSION_NOT_FOUND",
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
};
throw error;
}
permissionsToAllow = [permission];
}
// Allow permissions
const results: PermissionResponseItem[] = await Promise.all(
permissionsToAllow.map(async (permission) => {
await client.respondToPermission(resolvedAgentId, permission.id, {
behavior: "allow",
...(updatedInput ? { updatedInput } : {}),
});
return toPermissionResponseItem(resolvedAgentId, permission, "allowed");
}),
);
await client.close();
return {
type: "list",
data: results,
schema: permitResponseSchema,
};
} catch (err) {
await client.close().catch(() => {});
// Re-throw CommandErrors
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "ALLOW_PERMISSION_FAILED",
message: `Failed to allow permission: ${message}`,
};
throw error;
}
}
+87 -104
View File
@@ -6,7 +6,6 @@ import {
permitResponseSchema,
toPermissionResponseItem,
type PermissionResponseItem,
type PermitClientConnector,
} from "./allow.js";
export type PermitDenyResult = ListResult<PermissionResponseItem>;
@@ -18,114 +17,98 @@ export interface PermitDenyOptions extends CommandOptions {
host?: string;
}
export function createDenyCommand(connect: PermitClientConnector = connectToDaemon) {
return async function runDenyCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitDenyOptions,
_command: Command,
): Promise<PermitDenyResult> {
// Validate arguments
if (!options.all && !reqId) {
const error: CommandError = {
code: "MISSING_ARGUMENT",
message: "Request ID is required unless --all is specified",
details: "Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all",
};
throw error;
}
export async function runDenyCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitDenyOptions,
_command: Command,
): Promise<PermitDenyResult> {
// Validate arguments
if (!options.all && !reqId) {
const error: CommandError = {
code: "MISSING_ARGUMENT",
message: "Request ID is required unless --all is specified",
details: "Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all",
};
throw error;
}
const client = await connect({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
if (!fetchResult) {
await client.close();
const error: CommandError = {
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
};
throw error;
}
const agent = fetchResult.agent;
const resolvedAgentId = agent.id;
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || [];
if (pendingPermissions.length === 0) {
await client.close();
const error: CommandError = {
code: "NO_PENDING_PERMISSIONS",
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
};
throw error;
}
// Determine which permissions to deny
let permissionsToDeny: AgentPermissionRequest[];
if (options.all) {
permissionsToDeny = pendingPermissions;
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find(
(p) => p.id === reqId || p.id.startsWith(reqId!),
);
if (!permission) {
await client.close();
const error: CommandError = {
code: "PERMISSION_NOT_FOUND",
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
};
throw error;
}
permissionsToDeny = [permission];
}
// Deny permissions
const results: PermissionResponseItem[] = await Promise.all(
permissionsToDeny.map(async (permission) => {
try {
await client.respondToPermissionAndWait(resolvedAgentId, permission.id, {
behavior: "deny",
...(options.message ? { message: options.message } : {}),
...(options.interrupt ? { interrupt: true } : {}),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "PERMISSION_RESPONSE_UNCONFIRMED",
message: `Permission response unconfirmed for request ${permission.id}: ${message}`,
details: 'Use "paseo permit ls" to check whether the request is still pending',
};
throw error;
}
return toPermissionResponseItem(resolvedAgentId, permission, "denied");
}),
);
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
if (!fetchResult) {
await client.close();
return {
type: "list",
data: results,
schema: permitResponseSchema,
};
} catch (err) {
await client.close().catch(() => {});
// Re-throw CommandErrors
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DENY_PERMISSION_FAILED",
message: `Failed to deny permission: ${message}`,
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
};
throw error;
}
};
}
const agent = fetchResult.agent;
const resolvedAgentId = agent.id;
export const runDenyCommand = createDenyCommand();
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || [];
if (pendingPermissions.length === 0) {
await client.close();
const error: CommandError = {
code: "NO_PENDING_PERMISSIONS",
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
};
throw error;
}
// Determine which permissions to deny
let permissionsToDeny: AgentPermissionRequest[];
if (options.all) {
permissionsToDeny = pendingPermissions;
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId!));
if (!permission) {
await client.close();
const error: CommandError = {
code: "PERMISSION_NOT_FOUND",
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
};
throw error;
}
permissionsToDeny = [permission];
}
// Deny permissions
const results: PermissionResponseItem[] = await Promise.all(
permissionsToDeny.map(async (permission) => {
await client.respondToPermission(resolvedAgentId, permission.id, {
behavior: "deny",
...(options.message ? { message: options.message } : {}),
...(options.interrupt ? { interrupt: true } : {}),
});
return toPermissionResponseItem(resolvedAgentId, permission, "denied");
}),
);
await client.close();
return {
type: "list",
data: results,
schema: permitResponseSchema,
};
} catch (err) {
await client.close().catch(() => {});
// Re-throw CommandErrors
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DENY_PERMISSION_FAILED",
message: `Failed to deny permission: ${message}`,
};
throw error;
}
}
@@ -1,177 +0,0 @@
import { setImmediate as waitForCommandContinuations } from "node:timers/promises";
import { Command } from "commander";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
} from "@getpaseo/protocol/agent-types";
import { describe, expect, it } from "vitest";
import { createAllowCommand, type PermitClient } from "./allow.js";
import { createDenyCommand } from "./deny.js";
const agentId = "2d85f428-fdaf-4d98-906f-c583de2b805f";
const requests: AgentPermissionRequest[] = [
{
id: "permission-write",
provider: "codex",
name: "CodexBash",
kind: "tool",
},
{ id: "permission-edit", provider: "codex", name: "CodexBash", kind: "tool" },
];
class PermissionClient implements PermitClient {
readonly pending = [...requests];
readonly responses = new Map<string, AgentPermissionResponse>();
closed = false;
private notifyReceived = () => {};
private readonly confirmations = new Map<
string,
{
resolve: () => void;
reject: (error: Error) => void;
}
>();
async fetchAgent() {
return { agent: { id: agentId, pendingPermissions: this.pending } };
}
async respondToPermission(
_agentId: string,
requestId: string,
response: AgentPermissionResponse,
) {
this.responses.set(requestId, response);
this.notifyReceived();
}
async respondToPermissionAndWait(
responseAgentId: string,
requestId: string,
response: AgentPermissionResponse,
): Promise<void> {
const confirmation = new Promise<void>((resolve, reject) => {
this.confirmations.set(requestId, { resolve, reject });
});
await this.respondToPermission(responseAgentId, requestId, response);
await confirmation;
}
async close() {
this.closed = true;
}
async waitForResponses(count: number) {
while (this.responses.size < count) {
await new Promise<void>((resolve) => {
this.notifyReceived = resolve;
});
}
}
confirm(requestId: string) {
const confirmation = this.confirmations.get(requestId);
if (!confirmation) throw new Error(`No confirmation waiter: ${requestId}`);
this.pending.splice(
this.pending.findIndex((request) => request.id === requestId),
1,
);
confirmation.resolve();
}
fail(requestId: string, error: Error) {
const confirmation = this.confirmations.get(requestId);
if (!confirmation) throw new Error(`No confirmation waiter: ${requestId}`);
confirmation.reject(error);
}
}
const options = {
daemonTarget: { kind: "endpoint" as const, host: "test.invalid" },
};
const actions = [
{ name: "allow", create: createAllowCommand, result: "allowed" },
{ name: "deny", create: createDenyCommand, result: "denied" },
];
for (const action of actions) {
describe(`permit ${action.name}`, () => {
it("returns success and closes only after confirmation", async () => {
const client = new PermissionClient();
let completed = false;
const command = action.create(async () => client)(
agentId,
requests[0].id,
options,
new Command(),
);
void command.then(
() => (completed = true),
() => false,
);
await client.waitForResponses(1);
await waitForCommandContinuations();
expect(completed).toBe(false);
expect(client.closed).toBe(false);
expect(client.pending).toEqual(requests);
client.confirm(requests[0].id);
const result = await command;
expect(result.data).toEqual([
{
requestId: requests[0].id,
agentId,
agentShortId: agentId.slice(0, 7),
name: "CodexBash",
result: action.result,
},
]);
expect(client.closed).toBe(true);
expect(client.pending).toEqual([requests[1]]);
});
it("waits for every confirmation with --all", async () => {
const client = new PermissionClient();
let completed = false;
const command = action.create(async () => client)(
agentId,
undefined,
{ ...options, all: true },
new Command(),
);
void command.then(
() => (completed = true),
() => false,
);
await client.waitForResponses(2);
client.confirm(requests[0].id);
// Wait beyond the first reply's promise continuations while withholding the second.
await waitForCommandContinuations();
expect(completed).toBe(false);
expect(client.closed).toBe(false);
client.confirm(requests[1].id);
const result = await command;
expect(result.data.map((item) => item.result)).toEqual([action.result, action.result]);
expect(client.closed).toBe(true);
expect(client.pending).toEqual([]);
});
it("rejects an unconfirmed response without returning success", async () => {
const client = new PermissionClient();
const command = action.create(async () => client)(
agentId,
requests[0].id,
options,
new Command(),
);
const failure = expect(command).rejects.toMatchObject({
code: "PERMISSION_RESPONSE_UNCONFIRMED",
message: expect.stringContaining(requests[0].id),
details: 'Use "paseo permit ls" to check whether the request is still pending',
});
await client.waitForResponses(1);
client.fail(requests[0].id, new Error("Timeout waiting for message (15000ms)"));
await failure;
expect(client.closed).toBe(true);
expect(client.pending).toEqual(requests);
});
});
}
+2 -3
View File
@@ -1,4 +1,3 @@
import { parseSafeInteger } from "../../utils/integer.js";
import type { DaemonTarget } from "../../utils/daemon-target.js";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
@@ -442,8 +441,8 @@ function buildNewAgentConfigPatch(
}
function parsePositiveInt(value: string, flag: string): number {
const parsed = parseSafeInteger(value);
if (parsed === undefined || parsed <= 0) {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw {
code: "INVALID_INTEGER",
message: `${flag} must be a positive integer`,
@@ -1,4 +1,3 @@
import { parseSafeInteger } from "../../utils/integer.js";
import type { Command } from "commander";
import { renderError, toCommandError } from "../../output/render.js";
import {
@@ -86,8 +85,8 @@ function parseLineNumber(flag: string, value?: string): number | undefined {
return undefined;
}
const parsed = parseSafeInteger(value);
if (parsed === undefined) {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed)) {
throw {
code: "INVALID_LINE_NUMBER",
message: `Invalid ${flag} value: ${value}`,
-20
View File
@@ -1,20 +0,0 @@
export function parseSafeInteger(value: string): number | undefined {
const input = value.trim();
const match = /^[+-]?(\d*)(?:\.(\d*))?(?:e([+-]?\d+))?$/i.exec(input);
if (!match) {
return undefined;
}
const digits = match[1] + (match[2] ?? "");
const parsed = Number(input);
if (digits.length === 0 || !Number.isSafeInteger(parsed)) {
return undefined;
}
// Check the decimal value before floating-point rounding can hide a fraction.
const decimalPlaces = (match[2]?.length ?? 0) - Number(match[3] ?? "0");
if (decimalPlaces > 0 && /[1-9]/.test(digits.slice(-decimalPlaces))) {
return undefined;
}
return parsed;
}
+4
View File
@@ -364,6 +364,10 @@ try {
ids.every((id) => id.startsWith("gpt-")),
"all codex model IDs should be from the gpt family",
);
assert(
ids.some((id) => id.includes("codex")),
"codex model list should include at least one codex-optimized model",
);
assert(
data.every((m) => m.model && m.id && m.description),
"every codex model should have model, id, and description fields",
@@ -848,11 +848,6 @@ async function assertSandboxState({ browser, page, expectedSandbox, stdout, stde
function assertLinuxDesktopIdentity(appPath) {
if (process.platform === "linux") {
assert.deepEqual(
fs.readFileSync(path.join(appPath, "resources", "icon.png")),
fs.readFileSync(path.join(__dirname, "..", "assets", "icon.png")),
"Packaged Linux windows must have the Paseo icon at their runtime lookup path",
);
const metadata = JSON.parse(
extractFile(path.join(appPath, "resources", "app.asar"), "package.json").toString(),
);
-2
View File
@@ -65,8 +65,6 @@ linux:
extraResources:
- from: bin/paseo
to: bin/paseo
- from: assets/icon.png
to: icon.png
target:
- AppImage
- deb
+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/schedule-run-lifecycle.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.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\"",
"test:integration": "vitest run --maxWorkers=1 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/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && 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\"",
"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",
@@ -29,7 +29,6 @@ async function runSupervisorFixture(options: {
blockLogPath?: boolean;
/** Close the reading end of the supervisor's stdout and stderr as soon as it starts. */
closeOutput?: boolean;
env?: NodeJS.ProcessEnv;
}): Promise<{
code: number | null;
signal: NodeJS.Signals | null;
@@ -85,9 +84,7 @@ async function runSupervisorFixture(options: {
];
const child = spawn(command, args, {
cwd: repoRoot,
// A Node color-conflict warning can hit closed stderr before runSupervisor installs
// its pipe-error handlers. This fixture exercises the supervisor's own output.
env: { ...process.env, ...options.env, NO_COLOR: undefined, FORCE_COLOR: undefined },
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
});
@@ -208,16 +205,10 @@ describe("supervisor durable logging", () => {
expect(result.stderr).toContain('"worker-json-stderr"');
});
test.each([
{ colors: "inherited", env: {} },
{ colors: "conflicting", env: { NO_COLOR: "1", FORCE_COLOR: "1" } },
])(
"keeps supervising the worker when nobody reads its stdout or stderr with $colors colors",
async ({ env }) => {
const result = await runSupervisorFixture({
closeOutput: true,
env,
workerSource: `
test("keeps supervising the worker when nobody reads its stdout or stderr", async () => {
const result = await runSupervisorFixture({
closeOutput: true,
workerSource: `
process.on("message", (message) => {
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
});
@@ -229,15 +220,14 @@ describe("supervisor durable logging", () => {
}, 500);
setInterval(() => {}, 1000);
`,
});
});
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.log).toContain("first stderr line\n");
expect(result.log).toContain("later stdout line\n");
expect(result.log).toContain('"reason":"closed_output_probe"');
},
);
expect(result.code).toBe(0);
expect(result.signal).toBeNull();
expect(result.log).toContain("first stderr line\n");
expect(result.log).toContain("later stdout line\n");
expect(result.log).toContain('"reason":"closed_output_probe"');
});
test("preserves raw non-JSON stdout and stderr lines", async () => {
const result = await runSupervisorFixture({
@@ -39,45 +39,6 @@ function row(seq: number, item: AgentTimelineItem): AgentTimelineRow {
}
describe("curateAgentActivity", () => {
it("keeps reasoning before the assistant text that followed it", () => {
const timeline: AgentTimelineItem[] = [
{ type: "reasoning", text: "Check the commands first." },
{ type: "assistant_message", text: "I will list the commands." },
toolCallItem({
callId: "shell-order",
name: "Bash",
detail: {
type: "shell",
command: "ls packages/cli/src/commands/agent/",
output: "",
exitCode: 0,
},
}),
];
expect(curateAgentActivity(timeline)).toBe(
"[Thought] Check the commands first.\nI will list the commands.\n[Shell] ls packages/cli/src/commands/agent/",
);
});
it("preserves alternating text kinds, labels, and fragment concatenation", () => {
const timeline: AgentTimelineItem[] = [
{ type: "assistant_message", text: "First message." },
{ type: "reasoning", text: "First " },
{ type: "reasoning", text: "thought." },
{ type: "assistant_message", text: "Second " },
{ type: "assistant_message", text: "message." },
{ type: "reasoning", text: "Second thought." },
];
expect(curateAgentActivity(timeline, { labelAssistantMessages: true })).toBe(
"[Assistant] First message.\n[Thought] First thought.\n[Assistant] Second message.\n[Thought] Second thought.",
);
expect(curateAgentActivity(timeline, { maxItems: 2 })).toBe(
"Second message.\n[Thought] Second thought.",
);
});
it("renders user/assistant/reasoning entries", () => {
const timeline: AgentTimelineItem[] = [
{ type: "user_message", text: "Hello" },
@@ -25,20 +25,36 @@ interface ActivityEntry {
type TextAgentAttachment = Extract<AgentAttachment, { type: "text" }>;
function appendText(buffer: string, text: string): string {
const normalized = text.trim();
if (!normalized) {
return buffer;
}
if (!buffer) {
return normalized;
}
return `${buffer}\n${normalized}`;
}
function activityEntry(text: string): ActivityEntry {
return { text };
}
function appendTextEntry(
function flushBuffers(
entries: ActivityEntry[],
item: Extract<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>,
buffers: { message: string; thought: string },
options?: ActivityCuratorOptions,
) {
const text = item.text.trim();
if (!text) return;
let prefix = options?.labelAssistantMessages ? "[Assistant] " : "";
if (item.type === "reasoning") prefix = "[Thought] ";
entries.push(activityEntry(`${prefix}${text}`));
if (buffers.message.trim()) {
const text = buffers.message.trim();
entries.push(activityEntry(options?.labelAssistantMessages ? `[Assistant] ${text}` : text));
}
if (buffers.thought.trim()) {
const text = buffers.thought.trim();
entries.push(activityEntry(`[Thought] ${text}`));
}
buffers.message = "";
buffers.thought = "";
}
function formatToolInputJson(input: unknown): string | null {
@@ -131,6 +147,7 @@ function curateProjectedActivityEntries(
const recentItems = maxItems > 0 && items.length > maxItems ? items.slice(-maxItems) : items;
const entries: ActivityEntry[] = [];
const buffers = { message: "", thought: "" };
for (const item of recentItems) {
if (!shouldIncludeItem(item, options)) {
@@ -139,13 +156,17 @@ function curateProjectedActivityEntries(
switch (item.type) {
case "user_message":
flushBuffers(entries, buffers, options);
entries.push(activityEntry(`[User] ${item.text.trim()}`));
break;
case "assistant_message":
buffers.message = appendText(buffers.message, item.text);
break;
case "reasoning":
appendTextEntry(entries, item, options);
buffers.thought = appendText(buffers.thought, item.text);
break;
case "tool_call": {
flushBuffers(entries, buffers, options);
entries.push(formatToolCallEntry(item, options));
if (item.detail.type === "sub_agent" && item.detail.log.trim()) {
entries.push(activityEntry(item.detail.log.trim()));
@@ -153,6 +174,7 @@ function curateProjectedActivityEntries(
break;
}
case "todo":
flushBuffers(entries, buffers, options);
entries.push(activityEntry("[Tasks]"));
for (const entry of item.items) {
const checkbox = entry.completed ? "[x]" : "[ ]";
@@ -161,14 +183,18 @@ function curateProjectedActivityEntries(
}
break;
case "error":
flushBuffers(entries, buffers, options);
entries.push(activityEntry(`[Error] ${item.message}`));
break;
case "compaction":
flushBuffers(entries, buffers, options);
entries.push(activityEntry("[Compacted]"));
break;
}
}
flushBuffers(entries, buffers, options);
return entries;
}
@@ -222,11 +222,6 @@ function createFinishNotificationScenario(
type: "agent_state",
agent: childAgent,
});
subscriber?.({
type: "agent_stream",
agentId: "child-agent",
event: { type: "turn_completed", provider: "codex" },
});
},
async finishChildAndReadParentPrompt() {
const parentPrompt = new Promise<string>((resolve) => {
@@ -545,11 +540,6 @@ it("does not notify archived callers", async () => {
type: "agent_state",
agent: childAgent,
});
subscriber?.({
type: "agent_stream",
agentId: "child-agent",
event: { type: "turn_completed", provider: "codex" },
});
await vi.waitFor(() => {
expect(agentStorageGetSpy).toHaveBeenCalledWith("caller-agent");
@@ -407,20 +407,13 @@ export interface SetupFinishNotificationParams {
logger: Logger;
}
type FinishNotificationReason =
| "finished"
| "errored"
| "needs permission"
| "was closed"
| "was canceled";
type FinishNotificationReason = "finished" | "errored" | "needs permission" | "was closed";
const finishNotificationEvents = {
finished: "finished",
errored: "errored",
"needs permission": "permission-required",
"was closed": "closed",
// Use the existing failure event so older clients still parse agent-message metadata.
"was canceled": "errored",
} as const;
const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000;
@@ -575,8 +568,10 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
notifySafely("errored");
return;
}
// Idle is published before the terminal stream event for both completion
// and cancellation. Let that event identify the outcome instead.
if (agent.lifecycle === "idle" && hasSeenRunning) {
notifySafely("finished");
return;
}
if (agent.lifecycle === "closed") {
notifySafely("was closed");
return;
@@ -605,18 +600,6 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
return;
}
if (event.event.type === "turn_completed" || event.event.type === "turn_canceled") {
// Replacing a turn keeps the child running through the displaced turn's
// cancellation. The task settles only when the child becomes idle.
if (agentManager.getAgent(childAgentId)?.lifecycle !== "idle") return;
if (event.event.type === "turn_completed" && hasSeenRunning) {
notifySafely("finished");
} else if (event.event.type === "turn_canceled" && hasSeenTurn) {
notifySafely("was canceled");
}
return;
}
if (event.event.type === "permission_requested") {
// A permission pause is an intermediate checkpoint. Forget the run
// observed before it so an idle state during follow-up startup cannot
@@ -12,8 +12,6 @@ import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
import type { AgentClient, AgentProvider, AgentSessionConfig } from "./agent-sdk-types.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { parseAgentMessage } from "./agent-messages/index.js";
interface StructuredContent {
[key: string]: unknown;
@@ -313,167 +311,6 @@ afterAll(async () => {
});
describe("Suite A: Core Fixes", () => {
test.each([
["Stop", "cancelAgent"],
["Reload", "refreshAgent"],
] as const)(
"%s tells the parent that a watched child was canceled",
async (_action, method) => {
let releaseChild!: () => void;
let childReachedGate!: () => void;
const heldTurn = new Promise<void>((resolve) => {
releaseChild = resolve;
});
const childAtGate = new Promise<void>((resolve) => {
childReachedGate = resolve;
});
const parentPrompts: string[] = [];
const daemon = await createTestPaseoDaemon({
agentClients: createTestAgentClients({
onStartTurn(prompt, config) {
if (config.title === "Watching parent" && typeof prompt === "string")
parentPrompts.push(prompt);
},
async beforeTurnComplete(prompt) {
if (
typeof prompt === "string" &&
parseAgentMessage(prompt).text === "Hold this child turn"
) {
childReachedGate();
await heldTurn;
}
},
}),
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.11.2",
});
let mcp: McpClient | undefined;
let unsubscribe: (() => void) | undefined;
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const parent = await client.createAgent({
provider: "claude",
cwd: daemon.paseoHome,
title: "Watching parent",
modeId: "bypassPermissions",
});
mcp = await createMcpClient(
buildExpectedAgentMcpUrl({ host: "127.0.0.1", port: daemon.port, agentId: parent.id }),
);
const created = await callToolStructured(mcp, "create_agent", {
relationship: { kind: "subagent" },
workspace: { kind: "current" },
title: "Watched child",
provider: "codex/gpt-5.4",
initialPrompt: "Hold this child turn",
notifyOnFinish: true,
settings: { modeId: "full-access" },
});
const childId = str(created.agentId);
await childAtGate;
expect(daemon.daemon.agentManager.getAgent(childId)?.lifecycle).toBe("running");
const terminalEvents: string[] = [];
unsubscribe = daemon.daemon.agentManager.subscribe(
(event) => {
if (
event.type === "agent_stream" &&
event.agentId === childId &&
(event.event.type === "turn_completed" || event.event.type === "turn_canceled")
)
terminalEvents.push(event.event.type);
},
{ agentId: childId, replayState: false },
);
await client[method](childId);
await waitFor({
timeoutMs: 10000,
label: "parent cancellation notification",
check: () => (parentPrompts.length === 1 ? true : null),
});
const notification = parseAgentMessage(parentPrompts[0]);
expect(terminalEvents).toEqual(["turn_canceled"]);
expect(notification.source).toEqual({
kind: "agent-notification",
agentId: childId,
title: "Watched child",
event: "errored",
});
expect(notification.text).toContain(`Agent ${childId} (Watched child) was canceled.`);
expect(notification.text).not.toContain("finished.");
await waitFor({
timeoutMs: 10000,
label: "parent notification turn drained",
check: () =>
daemon.daemon.agentManager.getAgent(parent.id)?.lifecycle === "idle" ? true : null,
});
expect(parentPrompts).toHaveLength(1);
} finally {
releaseChild();
unsubscribe?.();
await mcp?.close();
await client.close();
await daemon.close();
}
},
30000,
);
test.each([
{ outcome: "finished", prompt: "say done and stop", status: "idle" },
{ outcome: "errored", prompt: "Emit a turn failure", status: "error" },
])(
"a watched child still reports $outcome after send_agent_prompt",
async ({ outcome, prompt, status }) => {
const childId = await createChildAgent({
title: "Follow-up child",
settings: { modeId: "bypassPermissions" },
});
try {
await waitFor({
timeoutMs: 10000,
label: "initial child turn completed",
check: () =>
daemonHandle.daemon.agentManager.getAgent(childId)?.lifecycle === "idle" ? true : null,
});
await callToolStructured(agentScopedClient, "send_agent_prompt", {
agentId: childId,
prompt,
notifyOnFinish: true,
});
const notifications = await waitFor({
timeoutMs: 10000,
label: `${outcome} notification`,
check: () => {
const items = daemonHandle.daemon.agentManager
.getTimeline(parentAgentId)
.filter(
(item) => item.type === "tool_call" && item.agentMessage?.sender.id === childId,
);
return items.length > 0 ? items : null;
},
});
expect(notifications).toEqual([
expect.objectContaining({
type: "tool_call",
agentMessage: {
event: outcome,
sender: { id: childId, title: "Follow-up child" },
text: expect.stringContaining(`(Follow-up child) ${outcome}.`),
},
}),
]);
expect(daemonHandle.daemon.agentManager.getAgent(childId)?.lifecycle).toBe(status);
} finally {
await archiveAgentIfPresent(childId);
}
},
30000,
);
test("AGENT_WAIT_TIMEOUT_MS is 30000", () => {
expect(AGENT_WAIT_TIMEOUT_MS).toBe(30_000);
});
@@ -4359,10 +4359,6 @@ describe("send_agent_prompt MCP tool", () => {
await vi.waitFor(() => expect(finishNotifications()).not.toHaveLength(0));
await new Promise((resolve) => setTimeout(resolve, 100));
expect(finishNotifications()).toHaveLength(1);
expect(parentClient.sessions[0]!.prompts).toHaveLength(1);
expect(parseAgentMessage(parentClient.sessions[0]!.prompts[0])).toMatchObject({
source: { kind: "agent-notification", agentId: childId, event: "finished" },
});
} finally {
await removeAgentStateDir(agentManager, storage, workdir);
}
@@ -134,44 +134,6 @@ describe("daemon web UI bootstrap", () => {
});
});
test("spells out the scheme default port when the Host header omits it", async () => {
const distDir = await createWebUiDist();
daemonHandle = await createTestPaseoDaemon({
mcpEnabled: false,
webUi: {
enabled: true,
distDir,
},
});
// A browser reaching a proxy that terminates TLS on 443 sends a Host header
// with no port at all. The app rejects a portless `listen`, so the daemon
// has to supply the default itself.
const httpsHint = readInjectedConnectionHint(
await fetchDaemonWebUi({
port: daemonHandle.port,
headers: { host: "paseo.example.test", "x-forwarded-proto": "https" },
}),
);
const httpHint = readInjectedConnectionHint(
await fetchDaemonWebUi({
port: daemonHandle.port,
headers: { host: "paseo.example.test" },
}),
);
const ipv6Hint = readInjectedConnectionHint(
await fetchDaemonWebUi({
port: daemonHandle.port,
headers: { host: "[::1]", "x-forwarded-proto": "https" },
}),
);
expect(httpsHint.listen).toBe("paseo.example.test:443");
expect(httpHint.listen).toBe("paseo.example.test:80");
expect(ipv6Hint.listen).toBe("[::1]:443");
});
test("ignores forwarded HTTPS when proxy trust is disabled", async () => {
const distDir = await createWebUiDist();
-4
View File
@@ -832,9 +832,6 @@ export async function createPaseoDaemon(
try {
fileHandle = await open(entry.absolutePath, DOWNLOAD_OPEN_FLAGS);
const fileStats = await fileHandle.stat();
if (res.destroyed) {
return;
}
if (!fileStats.isFile()) {
res.status(404).json({ error: "File not found" });
return;
@@ -846,7 +843,6 @@ export async function createPaseoDaemon(
const stream = fileHandle.createReadStream();
fileHandle = null;
res.once("close", () => stream.destroy());
stream.on("error", (err) => {
logger.error({ err }, "Failed to stream download");
if (!res.headersSent) {
@@ -1,87 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { createDaemonTestContext } from "../test-utils/index.js";
function git(cwd: string, ...args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8" });
}
test.each(["uncommitted", "base", "commit"] as const)(
"%s diff preserves removed SQL comments and their deletion counts",
async (mode) => {
const ctx = await createDaemonTestContext();
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-content-"));
try {
git(cwd, "init", "-b", "main");
git(cwd, "config", "user.name", "Test");
git(cwd, "config", "user.email", "test@example.com");
writeFileSync(join(cwd, "query.sql"), "-- old comment\nSELECT 1;\n");
writeFileSync(join(cwd, "control.sql"), "SELECT 1;\n");
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
writeFileSync(join(cwd, "query.sql"), "-- new comment\nSELECT 1;\n");
writeFileSync(join(cwd, "control.sql"), "SELECT 2;\n");
expect(git(cwd, "diff", "--numstat", "HEAD", "--", "query.sql").trim()).toBe(
"1\t1\tquery.sql",
);
if (mode !== "uncommitted") {
git(cwd, "checkout", "-b", "feature");
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
}
const files =
mode === "commit"
? [
(
await ctx.client.getCommitFileDiff(
cwd,
git(cwd, "rev-parse", "HEAD").trim(),
"query.sql",
)
).file,
(
await ctx.client.getCommitFileDiff(
cwd,
git(cwd, "rev-parse", "HEAD").trim(),
"control.sql",
)
).file,
]
: (await ctx.client.getCheckoutDiff(cwd, { mode, baseRef: "main" })).files;
const control = files.find((file) => file?.path === "control.sql");
expect(control).toMatchObject({ additions: 1, deletions: 1 });
expect(control?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "remove", content: "SELECT 1;" }),
expect.objectContaining({ type: "add", content: "SELECT 2;" }),
]),
);
const query = files.find((file) => file?.path === "query.sql");
expect
.soft(query)
.toMatchObject({ additions: 1, deletions: 1, isNew: false, isDeleted: false });
expect
.soft(query?.hunks.flatMap((hunk) => hunk.lines))
.toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "remove", content: "-- old comment" }),
expect.objectContaining({ type: "add", content: "-- new comment" }),
expect.objectContaining({ type: "context", content: "SELECT 1;" }),
]),
);
} finally {
await ctx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}
},
60_000,
);
@@ -1,15 +1,5 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import {
mkdtempSync,
writeFileSync,
rmSync,
readdirSync,
readlinkSync,
realpathSync,
symlinkSync,
} from "fs";
import { once } from "node:events";
import { get } from "node:http";
import { mkdtempSync, writeFileSync, rmSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
@@ -18,41 +8,6 @@ function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-e2e-"));
}
// Linux exposes the real open descriptors without replacing file or HTTP methods.
function openDownloadDescriptors(filePath: string): string[] {
const realFilePath = realpathSync(filePath);
return readdirSync("/proc/self/fd").filter((fd) => {
try {
return readlinkSync(`/proc/self/fd/${fd}`) === realFilePath;
} catch (error) {
// A descriptor can close between listing it and reading its link.
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
});
}
async function expectDownloadClosed(filePath: string): Promise<void> {
await expect.poll(() => openDownloadDescriptors(filePath)).toEqual([]);
}
async function cancelDownloadAfterFirstChunk(url: string): Promise<void> {
const request = get(url);
try {
const [response] = await once(request, "response");
expect(response.statusCode).toBe(200);
const [chunk] = await once(response, "data");
expect(chunk.length).toBeGreaterThan(0);
expect(chunk.length).toBeLessThan(64 * 1024 * 1024);
const closed = once(response, "close");
response.destroy();
request.destroy();
await closed;
} finally {
request.destroy();
}
}
// Use gpt-5.4-mini with low thinking preset for faster test execution
const CODEX_TEST_MODEL = "gpt-5.4-mini";
const CODEX_TEST_THINKING_OPTION_ID = "low";
@@ -69,39 +24,6 @@ describe("daemon E2E", () => {
}, 60000);
describe("file download tokens", () => {
test.skipIf(process.platform !== "linux")(
"releases file descriptors after repeated HTTP download cancellations",
async () => {
const directory = tmpCwd();
const cwd = path.join(directory, "linked-files");
symlinkSync(mkdtempSync(path.join(directory, "files-")), cwd, "dir");
const filePath = path.join(cwd, "large.bin");
const fileSize = 64 * 1024 * 1024;
writeFileSync(filePath, Buffer.alloc(fileSize));
try {
const completedToken = await ctx.client.requestDownloadToken(cwd, "large.bin");
expect(completedToken.error).toBeNull();
const completed = await fetch(
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${completedToken.token}`,
);
expect(completed.status).toBe(200);
expect((await completed.arrayBuffer()).byteLength).toBe(fileSize);
await expectDownloadClosed(filePath);
for (let attempt = 0; attempt < 3; attempt++) {
const token = await ctx.client.requestDownloadToken(cwd, "large.bin");
expect(token.error).toBeNull();
await cancelDownloadAfterFirstChunk(
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${token.token}`,
);
await expectDownloadClosed(filePath);
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
},
);
test.each([
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
{
@@ -1,5 +1,5 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, symlinkSync, readdirSync } from "fs";
import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
@@ -23,287 +23,6 @@ afterEach(async () => {
}, 60000);
describe("file explorer", () => {
test.each([
{ description: "a long ASCII name", fileName: `${"a".repeat(236)}.txt` },
{ description: "an ordinary name", fileName: "notes.txt" },
{ description: "a long Unicode name", fileName: `${"文".repeat(76)}.txt` },
])("saves text files with $description", async ({ fileName }) => {
const cwd = tmpCwd();
try {
const created = await ctx.client.createFileEntry({
cwd,
parentPath: ".",
name: fileName,
kind: "file",
});
expect(created).toMatchObject({ path: fileName, success: true, error: null });
writeFileSync(path.join(cwd, fileName), "original payload\n");
const initial = await ctx.client.readFile(cwd, fileName);
const saved = await ctx.client.writeFile({
cwd,
path: fileName,
content: "saved payload\n",
expectedModifiedAt: initial.modifiedAt,
expectedRevision: initial.revision,
});
expect(saved).toMatchObject({ status: "written", size: 14 });
const after = await ctx.client.readFile(cwd, fileName);
expect(after.path).toBe(fileName);
expect(new TextDecoder().decode(after.bytes)).toBe("saved payload\n");
const staleSave = await ctx.client.writeFile({
cwd,
path: fileName,
content: "stale payload\n",
expectedModifiedAt: initial.modifiedAt,
expectedRevision: initial.revision,
});
expect(staleSave).toMatchObject({
status: "conflict",
version: {
status: "ready",
path: fileName,
size: after.size,
modifiedAt: after.modifiedAt,
revision: after.revision,
},
});
const afterConflict = await ctx.client.readFile(cwd, fileName);
expect(new TextDecoder().decode(afterConflict.bytes)).toBe("saved payload\n");
const secondSave = await ctx.client.writeFile({
cwd,
path: fileName,
content: "second payload\n",
expectedModifiedAt: afterConflict.modifiedAt,
expectedRevision: afterConflict.revision,
});
expect(secondSave).toMatchObject({ status: "written", size: 15 });
const final = await ctx.client.readFile(cwd, fileName);
expect(new TextDecoder().decode(final.bytes)).toBe("second payload\n");
expect(readdirSync(cwd)).toEqual([fileName]);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test.each([
{
query: "implementation",
expectedPaths: [
".cursor/plans/implementation-plan.md",
".codex/plans/implementation-plan.md",
".claude/plans/implementation-plan.md",
],
},
{
query: ".cursor/plans/implementation",
expectedPaths: [".cursor/plans/implementation-plan.md"],
},
])(
"workspace suggestions find hidden agent plans for '$query'",
async ({ query, expectedPaths }) => {
const cwd = tmpCwd();
try {
for (const directory of [".cursor", ".codex", ".claude", ".hidden"]) {
mkdirSync(path.join(cwd, directory, "plans"), { recursive: true });
writeFileSync(path.join(cwd, directory, "plans", "implementation-plan.md"), "plan\n");
}
const response = await ctx.client.getDirectorySuggestions({
cwd,
query,
limit: 50,
includeFiles: true,
includeDirectories: true,
});
expect(response.error).toBeNull();
expect(response.entries).toHaveLength(expectedPaths.length);
expect(response.entries).toEqual(
expect.arrayContaining(
expectedPaths.map((entryPath) => ({ path: entryPath, kind: "file" })),
),
);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
);
test.each(["..", "..."])("supports file operations on names starting with %s", async (prefix) => {
const cwd = tmpCwd();
const fileName = `${prefix}notes.txt`;
const directoryName = `${prefix}drafts`;
try {
const createdFile = await ctx.client.createFileEntry({
cwd,
parentPath: ".",
name: fileName,
kind: "file",
});
expect(createdFile).toMatchObject({ path: fileName, success: true, error: null });
writeFileSync(path.join(cwd, fileName), "notes payload\n");
writeFileSync(path.join(cwd, ".notes.txt"), "dotfile control\n");
writeFileSync(path.join(cwd, "notes.txt"), "ordinary control\n");
const createdDirectory = await ctx.client.createFileEntry({
cwd,
parentPath: ".",
name: directoryName,
kind: "directory",
});
expect(createdDirectory).toMatchObject({ path: directoryName, success: true, error: null });
const createdChild = await ctx.client.createFileEntry({
cwd,
parentPath: directoryName,
name: "inside.txt",
kind: "file",
});
expect(createdChild).toMatchObject({
path: `${directoryName}/inside.txt`,
success: true,
error: null,
});
writeFileSync(path.join(cwd, directoryName, "inside.txt"), "nested payload\n");
const directory = await ctx.client.listDirectory(cwd, ".");
expect(
Object.fromEntries(directory.entries.map((entry) => [entry.path, entry.kind])),
).toEqual({
[directoryName]: "directory",
[fileName]: "file",
".notes.txt": "file",
"notes.txt": "file",
});
const children = await ctx.client.listDirectory(cwd, directoryName);
expect(children.entries.map((entry) => entry.path)).toEqual([`${directoryName}/inside.txt`]);
const file = await ctx.client.readFile(cwd, fileName);
expect(file.path).toBe(fileName);
expect(new TextDecoder().decode(file.bytes)).toBe("notes payload\n");
const nestedFile = await ctx.client.readFile(cwd, `${directoryName}/inside.txt`);
expect(nestedFile.path).toBe(`${directoryName}/inside.txt`);
expect(new TextDecoder().decode(nestedFile.bytes)).toBe("nested payload\n");
const dotfile = await ctx.client.readFile(cwd, ".notes.txt");
expect(new TextDecoder().decode(dotfile.bytes)).toBe("dotfile control\n");
const ordinaryFile = await ctx.client.readFile(cwd, "notes.txt");
expect(new TextDecoder().decode(ordinaryFile.bytes)).toBe("ordinary control\n");
const download = await ctx.client.requestDownloadToken(cwd, fileName);
expect(download.error).toBeNull();
expect(download.fileName).toBe(fileName);
const response = await fetch(
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${download.token}`,
);
expect(response.status).toBe(200);
expect(await response.text()).toBe("notes payload\n");
const renamedFileName = `${prefix}renamed.txt`;
const renamed = await ctx.client.renameFileEntry({
cwd,
path: fileName,
name: renamedFileName,
});
expect(renamed).toMatchObject({ renamedPath: renamedFileName, success: true, error: null });
const renamedFile = await ctx.client.readFile(cwd, renamedFileName);
expect(new TextDecoder().decode(renamedFile.bytes)).toBe("notes payload\n");
const copiedDirectoryName = `${directoryName} copy`;
const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: directoryName });
expect(duplicated).toMatchObject({
duplicatedPath: copiedDirectoryName,
success: true,
error: null,
});
const copiedFile = await ctx.client.readFile(cwd, `${copiedDirectoryName}/inside.txt`);
expect(new TextDecoder().decode(copiedFile.bytes)).toBe("nested payload\n");
const deletedDirectory = await ctx.client.deleteFileEntry({ cwd, path: directoryName });
expect(deletedDirectory).toMatchObject({ success: true, error: null });
const deletedFile = await ctx.client.deleteFileEntry({ cwd, path: renamedFileName });
expect(deletedFile).toMatchObject({ success: true, error: null });
const remaining = await ctx.client.listDirectory(cwd, ".");
expect(remaining.entries.map((entry) => entry.path).sort()).toEqual(
[".notes.txt", "notes.txt", copiedDirectoryName].sort(),
);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("rejects parent traversal and absolute paths to a sibling workspace", async () => {
const directory = tmpCwd();
const cwd = path.join(directory, "workspace");
const outside = path.join(directory, "workspace-sibling");
try {
mkdirSync(cwd);
mkdirSync(outside);
writeFileSync(path.join(outside, "secret.txt"), "outside payload\n");
for (const requestedPath of ["..", "../workspace-sibling", outside]) {
await expect(ctx.client.listDirectory(cwd, requestedPath)).rejects.toThrow(
"Access outside of workspace is not allowed",
);
}
for (const requestedPath of [
"../workspace-sibling/secret.txt",
path.join(outside, "secret.txt"),
]) {
await expect(ctx.client.readFile(cwd, requestedPath)).rejects.toThrow(
"Access outside of workspace is not allowed",
);
const download = await ctx.client.requestDownloadToken(cwd, requestedPath);
expect(download).toMatchObject({
token: null,
error: "Access outside of workspace is not allowed",
});
const deleted = await ctx.client.deleteFileEntry({ cwd, path: requestedPath });
expect(deleted).toMatchObject({
success: false,
error: "Access outside of workspace is not allowed",
});
}
expect(readdirSync(outside)).toEqual(["secret.txt"]);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("accepts symlinks to double-dot names inside the workspace and rejects outside targets", async () => {
const directory = tmpCwd();
const cwd = path.join(directory, "workspace");
const outside = path.join(directory, "workspace-sibling");
try {
mkdirSync(path.join(cwd, "..drafts"), { recursive: true });
mkdirSync(outside);
writeFileSync(path.join(cwd, "..drafts", "notes.txt"), "inside payload\n");
writeFileSync(path.join(outside, "secret.txt"), "outside payload\n");
// Junctions also work on Windows without requiring symlink privileges.
symlinkSync(path.join(cwd, "..drafts"), path.join(cwd, "inside-link"), "junction");
symlinkSync(outside, path.join(cwd, "outside-link"), "junction");
const listing = await ctx.client.listDirectory(cwd, ".");
expect(listing.entries.map((entry) => entry.path).sort()).toEqual([
"..drafts",
"inside-link",
]);
const file = await ctx.client.readFile(cwd, "inside-link/notes.txt");
expect(file.path).toBe("inside-link/notes.txt");
expect(new TextDecoder().decode(file.bytes)).toBe("inside payload\n");
await expect(ctx.client.readFile(cwd, "outside-link/secret.txt")).rejects.toThrow(
"Access outside of workspace is not allowed",
);
const download = await ctx.client.requestDownloadToken(cwd, "outside-link/secret.txt");
expect(download).toMatchObject({
token: null,
error: "Access outside of workspace is not allowed",
});
expect(readdirSync(outside)).toEqual(["secret.txt"]);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("lists directory contents", async () => {
const cwd = tmpCwd();
@@ -1,14 +1,5 @@
import { test, expect, beforeEach, afterEach } from "vitest";
import {
mkdtempSync,
writeFileSync,
existsSync,
rmSync,
readFileSync,
realpathSync,
mkdirSync,
} from "fs";
import { execFileSync } from "node:child_process";
import { mkdtempSync, writeFileSync, existsSync, rmSync, readFileSync, realpathSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
@@ -161,78 +152,6 @@ afterEach(async () => {
await ctx.cleanup();
}, 60000);
interface GitCommandOptions {
cwd: string;
args: string[];
}
async function mergeFeatureIntoBaseWorktree(baseName: string): Promise<void> {
const directory = realpathSync(tmpCwd());
const base = path.join(directory, baseName);
const feature = path.join(directory, "feature");
function git({ cwd, args }: GitCommandOptions): string {
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" });
}
try {
mkdirSync(base);
git({ cwd: base, args: ["init", "-b", "main"] });
git({ cwd: base, args: ["config", "user.name", "Paseo Test"] });
git({ cwd: base, args: ["config", "user.email", "test@paseo.local"] });
git({ cwd: base, args: ["config", "commit.gpgsign", "false"] });
git({ cwd: base, args: ["config", "core.autocrlf", "false"] });
git({ cwd: base, args: ["commit", "--allow-empty", "-m", "base"] });
git({ cwd: base, args: ["worktree", "add", "-b", "feature", feature] });
writeFileSync(path.join(feature, "feature.txt"), "feature payload\n");
git({ cwd: feature, args: ["add", "feature.txt"] });
git({ cwd: feature, args: ["commit", "-m", "feature"] });
const featureCommit = git({ cwd: feature, args: ["rev-parse", "HEAD"] }).trim();
const merged = await ctx.client.checkoutMerge(feature, { baseRef: "main" });
expect(merged).toMatchObject({ success: true, error: null });
expect(git({ cwd: base, args: ["rev-parse", "HEAD"] }).trim()).toBe(featureCommit);
expect(readFileSync(path.join(base, "feature.txt"), "utf8")).toBe("feature payload\n");
expect(git({ cwd: feature, args: ["symbolic-ref", "--short", "HEAD"] }).trim()).toBe("feature");
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
test.skipIf(isPlatform("win32")).each(["base\ncheckout", "base "])(
"merges into a base worktree whose path preserves %j",
mergeFeatureIntoBaseWorktree,
);
test.each(["base checkout", "中文 checkout"])(
"merges into a base worktree named %j",
mergeFeatureIntoBaseWorktree,
);
test("merges into a local base branch without another worktree", async () => {
const cwd = realpathSync(tmpCwd());
function git(args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: "pipe" });
}
try {
git(["init", "-b", "main"]);
git(["config", "user.name", "Paseo Test"]);
git(["config", "user.email", "test@paseo.local"]);
git(["config", "commit.gpgsign", "false"]);
git(["commit", "--allow-empty", "-m", "base"]);
git(["checkout", "-b", "feature"]);
writeFileSync(path.join(cwd, "feature.txt"), "feature payload\n");
git(["add", "feature.txt"]);
git(["commit", "-m", "feature"]);
const featureCommit = git(["rev-parse", "HEAD"]).trim();
const merged = await ctx.client.checkoutMerge(cwd, { baseRef: "main" });
expect(merged).toMatchObject({ success: true, error: null });
expect(git(["rev-parse", "main"]).trim()).toBe(featureCommit);
expect(git(["symbolic-ref", "--short", "HEAD"]).trim()).toBe("feature");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("returns diff for modified file in git repo", async () => {
const cwd = tmpCwd();
@@ -6,7 +6,6 @@ import { afterEach, beforeEach, expect, test } from "vitest";
import { createPaseoClient, type PaseoClient } from "@getpaseo/client";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { runPaseoCli } from "../test-utils/cli.js";
let daemon: TestPaseoDaemon;
let client: DaemonClient;
@@ -29,75 +28,6 @@ afterEach(async () => {
await rm(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
});
async function createQuietTerminal() {
const workspaceId = await createWorkspace("CLI capture");
return sdk.terminals.create({
workspaceId,
command: process.execPath,
args: ["-e", "process.stdin.resume()"],
size: { rows: 10, cols: 40 },
});
}
test.each([
["--end", "4503599627370496.5"],
["--start", "1.0000000000000001"],
["--end", "4.5035996273704965e15"],
["--end", "1e-324"],
["--start", "1.5"],
["--end", "1.5"],
["--start", "1abc"],
["--end", "1abc"],
["--start", ""],
["--end", ""],
["--start", "9007199254740993"],
["--end", "9007199254740993"],
])("CLI terminal capture rejects %s %j", async (flag, value) => {
const terminal = await createQuietTerminal();
try {
const result = await runPaseoCli({
daemon,
args: ["terminal", "capture", terminal.id, flag, value, "--json"],
});
expect(result.exitCode, result.stdout + result.stderr).toBe(1);
expect(JSON.parse(result.stderr)).toEqual({
error: {
code: "INVALID_LINE_NUMBER",
message: `Invalid ${flag} value: ${value}`,
details: "Use an integer line number.",
},
});
} finally {
await terminal.kill();
}
});
test.each([
{ args: [], lineCount: 10 },
{ args: ["--start", "0", "--end", "1"], lineCount: 2 },
{ args: ["--end", "1.0"], lineCount: 2 },
{ args: ["--end", "10e-1"], lineCount: 2 },
{ args: ["--end", ".1e1"], lineCount: 2 },
{ args: ["--start", "-2", "--end", "-1"], lineCount: 2 },
{ args: ["--scrollback", "--start", "7", "--end", "1"], lineCount: 2 },
])("CLI terminal capture preserves valid ranges $args", async ({ args, lineCount }) => {
const terminal = await createQuietTerminal();
try {
const result = await runPaseoCli({
daemon,
args: ["terminal", "capture", terminal.id, ...args, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({
terminalId: terminal.id,
lines: Array.from({ length: lineCount }, () => ""),
totalLines: 10,
});
} finally {
await terminal.kill();
}
});
test("SDK and workspace handles preserve ownership and actual process directories", async () => {
const first = await createWorkspace("main");
const second = await createWorkspace("feature-work");
@@ -12,6 +12,32 @@ async function createTempDir(prefix: string): Promise<string> {
}
describe.skipIf(isPlatform("win32"))("service POSIX-only", () => {
it("lists and expands the reported relative directory symlink", async () => {
const root = await createTempDir("paseo-file-explorer-");
try {
await mkdir(path.join(root, "test"));
await writeFile(path.join(root, "test", "x.txt"), "inside\n", "utf-8");
await symlink("./test", path.join(root, "test2"));
const listing = await listDirectoryEntries({ root });
expect(listing.entries.find((entry) => entry.name === "test2")).toMatchObject({
path: "test2",
kind: "directory",
});
const expanded = await listDirectoryEntries({ root, relativePath: "test2" });
expect(expanded.path).toBe("test2");
expect(expanded.entries.map((entry) => entry.name)).toEqual(["x.txt"]);
expect(await readExplorerFile({ root, relativePath: "test2/x.txt" })).toMatchObject({
path: "test2/x.txt",
content: "inside\n",
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("lists directory entries even when a dangling symlink exists", async () => {
const root = await createTempDir("paseo-file-explorer-");
@@ -12,8 +12,8 @@ import {
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { runGitCommand, snapshotGitCommandRuntimeMetrics } from "../../utils/run-git-command.js";
import { describe, expect, it } from "vitest";
import { runGitCommand } from "../../utils/run-git-command.js";
import {
createExplorerEntry,
deleteExplorerEntry,
@@ -517,129 +517,6 @@ describe("file explorer service", () => {
}
});
it("renames an untracked bracketed filename without matching a tracked sibling", async () => {
const root = await createTempDir("paseo-entry-literal-rename-");
try {
await runGitCommand(["init"], { cwd: root });
await writeFile(path.join(root, "i.tsx"), "tracked\n", "utf8");
await runGitCommand(["add", "i.tsx"], { cwd: root });
await writeFile(path.join(root, "[id].tsx"), "untracked\n", "utf8");
snapshotGitCommandRuntimeMetrics();
const result = await renameExplorerEntry({
root,
relativePath: "[id].tsx",
name: "renamed.tsx",
});
expect(result).toEqual({ status: "ok", path: "renamed.tsx" });
expect(snapshotGitCommandRuntimeMetrics().operationsTop).toEqual([
["ls-files", 1],
["rev-parse", 1],
]);
expect(await readFile(path.join(root, "renamed.tsx"), "utf8")).toBe("untracked\n");
await expect(stat(path.join(root, "[id].tsx"))).rejects.toMatchObject({ code: "ENOENT" });
expect(await readFile(path.join(root, "i.tsx"), "utf8")).toBe("tracked\n");
expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe("i.tsx\0");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("renames an untracked bracketed folder without matching a tracked sibling", async () => {
const root = await createTempDir("paseo-folder-literal-rename-");
try {
await runGitCommand(["init"], { cwd: root });
await mkdir(path.join(root, "i"));
await writeFile(path.join(root, "i", "route.tsx"), "tracked\n", "utf8");
await runGitCommand(["add", "i"], { cwd: root });
await mkdir(path.join(root, "[id]"));
await writeFile(path.join(root, "[id]", "route.tsx"), "untracked\n", "utf8");
const result = await renameExplorerEntry({ root, relativePath: "[id]", name: "route" });
expect(result).toEqual({ status: "ok", path: "route" });
expect(await readFile(path.join(root, "route", "route.tsx"), "utf8")).toBe("untracked\n");
await expect(stat(path.join(root, "[id]"))).rejects.toMatchObject({ code: "ENOENT" });
expect(await readFile(path.join(root, "i", "route.tsx"), "utf8")).toBe("tracked\n");
expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe("i/route.tsx\0");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("uses Git to rename a tracked bracketed filename and preserves its tracked sibling", async () => {
const root = await createTempDir("paseo-tracked-literal-rename-");
try {
await runGitCommand(["init"], { cwd: root });
await writeFile(path.join(root, "[id].tsx"), "tracked route\n", "utf8");
await writeFile(path.join(root, "i.tsx"), "tracked sibling\n", "utf8");
await runGitCommand(["add", "--", "[id].tsx", "i.tsx"], { cwd: root });
const result = await renameExplorerEntry({
root,
relativePath: "[id].tsx",
name: "renamed.tsx",
});
expect(result).toEqual({ status: "ok", path: "renamed.tsx" });
expect(await readFile(path.join(root, "renamed.tsx"), "utf8")).toBe("tracked route\n");
await expect(stat(path.join(root, "[id].tsx"))).rejects.toMatchObject({ code: "ENOENT" });
expect(await readFile(path.join(root, "i.tsx"), "utf8")).toBe("tracked sibling\n");
expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe(
"i.tsx\0renamed.tsx\0",
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it.each([
{
sourcePath: "[id].tsx",
sourceFile: "[id].tsx",
targetName: "renamed.tsx",
targetFile: "renamed.tsx",
},
{
sourcePath: "[id]",
sourceFile: "[id]/route.tsx",
targetName: "route",
targetFile: "route/route.tsx",
},
])(
"keeps tracked renames in the index with inherited literal pathspecs ($sourcePath)",
async ({ sourcePath, sourceFile, targetName, targetFile }) => {
const root = await createTempDir("paseo-inherited-literal-rename-");
try {
vi.stubEnv("GIT_LITERAL_PATHSPECS", "1");
await runGitCommand(["init"], { cwd: root });
await mkdir(path.dirname(path.join(root, sourceFile)), { recursive: true });
await writeFile(path.join(root, sourceFile), "tracked route\n", "utf8");
await writeFile(path.join(root, "i.tsx"), "tracked sibling\n", "utf8");
await runGitCommand(["add", "."], { cwd: root });
const result = await renameExplorerEntry({
root,
relativePath: sourcePath,
name: targetName,
});
expect(result).toEqual({ status: "ok", path: targetName });
expect(await readFile(path.join(root, targetFile), "utf8")).toBe("tracked route\n");
await expect(stat(path.join(root, sourcePath))).rejects.toMatchObject({ code: "ENOENT" });
expect(await readFile(path.join(root, "i.tsx"), "utf8")).toBe("tracked sibling\n");
expect((await runGitCommand(["ls-files", "-z"], { cwd: root })).stdout).toBe(
`i.tsx\0${targetFile}\0`,
);
expect(process.env.GIT_LITERAL_PATHSPECS).toBe("1");
} finally {
vi.unstubAllEnvs();
await rm(root, { recursive: true, force: true });
}
},
);
it("supports case-only renames for tracked and untracked files", async () => {
const root = await createTempDir("paseo-entry-case-rename-");
try {
@@ -489,7 +489,7 @@ export async function writeExplorerFile({
const temporaryPath = path.join(
path.dirname(filePath.resolvedPath),
`.paseo-${randomUUID()}.tmp`,
`.${path.basename(filePath.resolvedPath)}.paseo-${randomUUID()}.tmp`,
);
let temporaryHandle: FileHandle | null = null;
try {
@@ -724,11 +724,7 @@ export async function renameExplorerEntry({
});
const tracked =
repository.exitCode === 0
? await runGitCommand(["ls-files", "-z", "--", `:(literal)${sourcePath}`], {
cwd: realRoot,
// Inherited literal mode disables the :(literal) pathspec magic.
envOverlay: { GIT_LITERAL_PATHSPECS: undefined },
})
? await runGitCommand(["ls-files", "-z", "--", sourcePath], { cwd: realRoot })
: null;
if (tracked?.stdout) {
await runGitCommand(["mv", "--", sourcePath, renamedPath], { cwd: realRoot });
@@ -824,9 +820,8 @@ async function resolveScopedPath({
function assertWithinWorkspace(root: string, candidate: string): void {
const relative = path.relative(root, candidate);
const isOutsideWorkspace =
relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
if (isOutsideWorkspace) throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE);
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) return;
throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE);
}
async function openFileForRead(filePath: string): Promise<FileHandle> {
@@ -1,78 +0,0 @@
import { readFileSync } from "node:fs";
import Ajv from "ajv";
import { expect, test } from "vitest";
import { z } from "zod";
import { PersistedConfigSchema } from "./persisted-config.js";
const publishedSchema = JSON.parse(
readFileSync(
new URL("../../../website/public/schemas/paseo.config.v1.json", import.meta.url),
"utf8",
),
);
test("editors accept the daemon's browser, profile, plugin and metadata configuration", () => {
const config = {
$schema: "https://paseo.sh/schemas/paseo.config.v1.json",
version: 1,
daemon: {
browserTools: { enabled: true },
terminalProfiles: [],
agentProfiles: [],
},
pluginsEnabled: true,
plugins: {},
agents: {
metadataGeneration: { providers: [{ provider: "claude", model: "claude-opus-5" }] },
},
};
expect(PersistedConfigSchema.parse(config)).toEqual(config);
const validate = new Ajv({ allErrors: true, strict: false }).compile(publishedSchema);
const valid = validate(config);
expect(validate.errors).toBeNull();
expect(valid).toBe(true);
});
test("the published schema stays current with the daemon's configuration schema", () => {
const generated = z.toJSONSchema(PersistedConfigSchema, {
target: "draft-07",
unrepresentable: "any",
io: "input",
});
generated.title = "PaseoConfigV1";
expect(
publishedSchema,
"Run npm run generate:config-schema --workspace=@getpaseo/server",
).toEqual(generated);
});
test("editors retain the existing proxy configuration help", () => {
const daemon = publishedSchema.properties.daemon.properties;
expect(daemon.trustedProxies.description).toBe(
'Express trusted proxy setting for X-Forwarded-* headers. Defaults to ["loopback"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.',
);
expect(daemon.serviceProxy.properties.enabled.description).toBe(
"Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.",
);
expect(daemon.serviceProxy.properties.listen.description).toBe(
"Optional service-only listener address. Presence enables the standalone service proxy listener.",
);
expect(daemon.serviceProxy.properties.publicBaseUrl.description).toBe(
"Optional public base URL for service host aliases.",
);
});
test.each([
{ daemon: { browserTools: { enabled: "yes" } } },
{ daemon: { terminalProfiles: "default" } },
{ daemon: { agentProfiles: "default" } },
{ pluginsEnabled: "yes" },
{ plugins: [] },
{ agents: { metadataGeneration: { providers: [{ provider: "" }] } } },
{ unknownSetting: true },
])("editors still reject invalid configuration: %j", (config) => {
expect(PersistedConfigSchema.safeParse(config).success).toBe(false);
const validate = new Ajv({ allErrors: true, strict: false }).compile(publishedSchema);
expect(validate(config)).toBe(false);
});
+4 -22
View File
@@ -237,12 +237,7 @@ export const PersistedConfigSchema = z
listen: z.string().optional(),
hostnames: z.union([z.literal(true), z.array(z.string())]).optional(),
allowedHosts: z.union([z.literal(true), z.array(z.string())]).optional(),
trustedProxies: z
.union([z.literal(true), z.array(z.string())])
.optional()
.describe(
'Express trusted proxy setting for X-Forwarded-* headers. Defaults to ["loopback"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.',
),
trustedProxies: z.union([z.literal(true), z.array(z.string())]).optional(),
mcp: z
.object({
enabled: z.boolean().optional(),
@@ -289,22 +284,9 @@ export const PersistedConfigSchema = z
// COMPAT(serviceProxyEnabled): added 2026-06-02, remove after 2026-12-02.
// Parsed only to suppress optional public/listen layers for old configs;
// localhost service proxying remains always enabled.
enabled: z
.boolean()
.optional()
.describe(
"Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.",
),
listen: z
.string()
.optional()
.describe(
"Optional service-only listener address. Presence enables the standalone service proxy listener.",
),
publicBaseUrl: z
.url()
.optional()
.describe("Optional public base URL for service host aliases."),
enabled: z.boolean().optional(),
listen: z.string().optional(),
publicBaseUrl: z.url().optional(),
})
.strict()
.optional(),
@@ -13,7 +13,6 @@ import {
} from "./test-utils/index.js";
import type { SessionOutboundMessage } from "./messages.js";
import { getPaseoWorktreesRoot } from "../utils/worktree.js";
import { runPaseoCli } from "./test-utils/cli.js";
type AgentUpdateMessage = Extract<SessionOutboundMessage, { type: "agent_update" }>;
type WorkspaceUpdateMessage = Extract<SessionOutboundMessage, { type: "workspace_update" }>;
@@ -43,137 +42,6 @@ function makeTempDir(prefix: string): string {
return dir;
}
test.each([
"4503599627370496.5",
"1.0000000000000001",
"2.5",
"2runs",
"9007199254740993",
"0",
"-1",
"",
])("CLI schedule create rejects max-runs %j without saving a schedule", async (maxRuns) => {
const cwd = makeTempDir("schedule-cli-integer-");
const result = await runPaseoCli({
daemon: ctx.daemon,
args: [
"schedule",
"create",
"Integer limit",
"--cron",
"0 0 1 1 *",
"--provider",
"claude",
"--cwd",
cwd,
"--max-runs",
maxRuns,
"--json",
],
});
expect(result.exitCode, result.stdout + result.stderr).toBe(1);
expect(JSON.parse(result.stderr)).toEqual({
error: { code: "INVALID_INTEGER", message: "--max-runs must be a positive integer" },
});
expect((await ctx.client.scheduleList()).schedules).toEqual([]);
});
test.each(["2.5", "2runs", "4503599627370496.5"])(
"CLI schedule update rejects max-runs %j without changing the schedule",
async (maxRuns) => {
const cwd = makeTempDir("schedule-cli-update-integer-");
const schedule = await createNewAgentSchedule({
prompt: "Original prompt",
cadence: { type: "cron", expression: "0 0 1 1 *", timezone: "UTC" },
target: { type: "new-agent", config: { provider: "claude", cwd } },
maxRuns: 3,
runOnCreate: false,
});
const before = await ctx.client.scheduleInspect({ id: schedule.id });
const result = await runPaseoCli({
daemon: ctx.daemon,
args: [
"schedule",
"update",
schedule.id,
"--prompt",
"Should not apply",
"--max-runs",
maxRuns,
"--json",
],
});
expect(result.exitCode, result.stdout + result.stderr).toBe(1);
expect(JSON.parse(result.stderr)).toEqual({
error: { code: "INVALID_INTEGER", message: "--max-runs must be a positive integer" },
});
const after = await ctx.client.scheduleInspect({ id: schedule.id });
expect(after.error).toBeNull();
expect(after.schedule).toEqual(before.schedule);
},
);
test.each([
{ limitArgs: [], maxRuns: null },
{ limitArgs: ["--max-runs", "3"], maxRuns: 3 },
{ limitArgs: ["--max-runs", "3.0"], maxRuns: 3 },
{ limitArgs: ["--max-runs", "1e2"], maxRuns: 100 },
])(
"CLI schedule create stores max-runs $maxRuns from $limitArgs",
async ({ limitArgs, maxRuns }) => {
const cwd = makeTempDir("schedule-cli-valid-integer-");
const result = await runPaseoCli({
daemon: ctx.daemon,
args: [
"schedule",
"create",
"Integer limit",
"--cron",
"0 0 1 1 *",
"--provider",
"claude",
"--cwd",
cwd,
...limitArgs,
"--json",
],
});
expect(result.exitCode, result.stderr).toBe(0);
expect((await ctx.client.scheduleList()).schedules).toEqual([
expect.objectContaining({ maxRuns, prompt: "Integer limit" }),
]);
},
);
test("CLI schedule update preserves, changes, and clears valid run limits", async () => {
const cwd = makeTempDir("schedule-cli-valid-update-");
const schedule = await createNewAgentSchedule({
prompt: "Original prompt",
cadence: { type: "cron", expression: "0 0 1 1 *", timezone: "UTC" },
target: { type: "new-agent", config: { provider: "claude", cwd } },
maxRuns: 3,
runOnCreate: false,
});
for (const { args, maxRuns } of [
{ args: ["--name", "Renamed"], maxRuns: 3 },
{ args: ["--max-runs", "5"], maxRuns: 5 },
{ args: ["--no-max-runs"], maxRuns: null },
]) {
const result = await runPaseoCli({
daemon: ctx.daemon,
args: ["schedule", "update", schedule.id, ...args, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
expect((await ctx.client.scheduleInspect({ id: schedule.id })).schedule).toMatchObject({
maxRuns,
name: "Renamed",
prompt: schedule.prompt,
target: schedule.target,
cadence: schedule.cadence,
});
}
}, 60_000);
function createGitRepo(): string {
const tempRoot = makeTempDir("schedule-run-worktree-");
const repoDir = path.join(tempRoot, "repo");
@@ -192,13 +60,7 @@ function createGitRepo(): string {
cwd: repoDir,
stdio: "pipe",
});
// Use Git's canonical root, as worktree creation does, including Windows path aliases.
return realpathSync(
execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd: repoDir,
encoding: "utf8",
}).trim(),
);
return repoDir;
}
async function createNewAgentSchedule(options: ScheduleCreateOptions): Promise<ScheduleSummary> {
@@ -1,54 +0,0 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import type { TestPaseoDaemon } from "./paseo-daemon.js";
interface CliOptions {
daemon: Pick<TestPaseoDaemon, "port" | "paseoHome">;
args: string[];
}
interface CliResult {
exitCode: number | null;
stdout: string;
stderr: string;
}
export function runPaseoCli({ daemon, args }: CliOptions): Promise<CliResult> {
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[
"--no-warnings",
"--conditions=source",
"--import",
"tsx",
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
"--host",
`127.0.0.1:${daemon.port}`,
...args,
],
{
env: { ...process.env, PASEO_HOME: daemon.paseoHome },
stdio: ["ignore", "pipe", "pipe"],
timeout: 20_000,
killSignal: "SIGKILL",
},
);
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.once("error", reject);
child.once("close", (exitCode, signal) => {
if (signal) {
reject(new Error(`CLI exited on ${signal}:\n${stdout}\n${stderr}`));
} else {
resolve({ exitCode, stdout, stderr });
}
});
});
}
@@ -190,34 +190,6 @@ index 1234567..abcdefg 100644
`;
describe("parseDiff", () => {
it.each(["-- comment", "++ marker"])(
"preserves hunk content resembling patch headers: %s",
(content) => {
const files = parseDiff(`diff --git a/example.txt b/example.txt
index 1234567..abcdefg 100644
--- a/example.txt
+++ b/example.txt
@@ -1 +1 @@
-${content} before
+${content} after
`);
expect(files).toHaveLength(1);
expect(files[0]).toMatchObject({
path: "example.txt",
isNew: false,
isDeleted: false,
additions: 1,
deletions: 1,
});
expect(files[0].hunks[0].lines).toEqual([
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: `${content} before` },
{ type: "add", content: `${content} after` },
]);
},
);
it("parses a simple diff with one hunk", () => {
const files = parseDiff(SIMPLE_DIFF);
@@ -79,6 +79,16 @@ function extractPathFromDiffHeader(lines: string[]): string {
return "unknown";
}
function isMetadataLine(line: string): boolean {
return (
line.startsWith("index ") ||
line.startsWith("--- ") ||
line.startsWith("+++ ") ||
line.startsWith("new file mode") ||
line.startsWith("deleted file mode")
);
}
function parseHunkHeader(line: string): DiffHunk | null {
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
if (!hunkMatch) return null;
@@ -106,6 +116,8 @@ function parseSectionBody(lines: string[]): ParsedSectionBody {
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (isMetadataLine(line)) continue;
const newHunk = parseHunkHeader(line);
if (newHunk) {
if (currentHunk) hunks.push(currentHunk);
+1 -25
View File
@@ -250,22 +250,6 @@ function serializeInlineScriptJson(value: unknown): string {
.replace(/&/g, "\\u0026");
}
/**
* A Host header carries no port when the client used the scheme's default one,
* which is what every browser sends to a proxy terminating TLS on 443. The app
* parses `listen` with parseHostPort, which requires an explicit port and
* rejects the value outright, so the hint has to spell the port out.
*/
function hasExplicitPort(host: string): boolean {
if (host.startsWith("[")) {
// IPv6 literals are bracketed: [::1] has no port, [::1]:443 does.
const end = host.indexOf("]");
return end !== -1 && /^:\d{1,5}$/.test(host.slice(end + 1));
}
const colon = host.indexOf(":");
return colon !== -1 && colon === host.lastIndexOf(":") && /^\d{1,5}$/.test(host.slice(colon + 1));
}
function injectConnectionHint(
html: string,
req: Parameters<RequestHandler>[0],
@@ -273,16 +257,8 @@ function injectConnectionHint(
): string {
const host = typeof req.headers.host === "string" ? req.headers.host : "";
const useTls = req.protocol === "https";
let listen = host;
if (host && !hasExplicitPort(host)) {
let defaultPort = 80;
if (useTls) {
defaultPort = 443;
}
listen = `${host}:${defaultPort}`;
}
const hint = {
listen,
listen: host,
useTls,
label,
};
@@ -25,10 +25,7 @@ function git(args: string[], cwd?: string): void {
}
function commit(repoDir: string, message: string): void {
git(
["-c", "commit.gpgsign=false", "commit", "--author=Test User <test@test.com>", "-m", message],
repoDir,
);
git(["-c", "commit.gpgsign=false", "commit", "-m", message], repoDir);
}
function commitFile(repoDir: string, name: string, content: string, message: string): void {
@@ -95,30 +92,6 @@ function addBareRemote(repoDir: string, tempDir: string): string {
}
describe("listCheckoutCommits", () => {
it("keeps fixture authors independent of command-scoped Git configuration", async () => {
const overrides = {
GIT_CONFIG_COUNT: "1",
GIT_CONFIG_KEY_0: "user.name",
GIT_CONFIG_VALUE_0: "Other User",
};
const previous = Object.fromEntries(
Object.keys(overrides).map((key) => [key, process.env[key]]),
);
let repoDir: string;
try {
Object.assign(process.env, overrides);
({ repoDir } = initRepoOnMain());
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
const { commits } = await listCheckoutCommits({ cwd: repoDir });
expect(commits[0]?.authorName).toBe("Test User");
});
it("lists recent commits newest-first with on-remote flags and file stats", async () => {
const { repoDir, tempDir } = initRepoOnMain();
git(["checkout", "-b", "feature"], repoDir);
@@ -1,14 +1,13 @@
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { getCheckoutDiff, getCommitFileDiff } from "./checkout-git.js";
const repos: string[] = [];
afterEach(() => {
vi.unstubAllEnvs();
for (const repo of repos.splice(0)) rmSync(repo, { recursive: true, force: true });
});
@@ -63,95 +62,6 @@ function createChangedRepo(config: string): string {
return cwd;
}
function createExternalDiffCommand() {
const directory = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-external-diff-")));
repos.push(directory);
const script = join(directory, "external-diff.cjs");
const marker = join(directory, "invoked");
writeFileSync(
script,
'require("node:fs").writeFileSync(process.argv[2], "invoked");\n' +
'process.stdout.write("CUSTOM EXTERNAL DIFF\\n");\n',
);
const command = [process.execPath, script, marker]
.map((argument) => `'${argument.replaceAll("'", "'\\''")}'`)
.join(" ");
return { command, marker };
}
function createExternalDiffRepo() {
vi.stubEnv("GIT_EXTERNAL_DIFF", undefined);
const cwd = createChangedRepo("color.diff=always");
const { command, marker } = createExternalDiffCommand();
git(cwd, "config", "diff.external", command);
expect(git(cwd, "diff", "HEAD", "--", "file.txt")).toBe("CUSTOM EXTERNAL DIFF\n");
expect(existsSync(marker)).toBe(true);
rmSync(marker);
return { cwd, command, marker };
}
function expectStandardPatch(result: Awaited<ReturnType<typeof getCheckoutDiff>>) {
expect(result.structured).toMatchObject([
{
path: "file.txt",
additions: 1,
deletions: 1,
status: "ok",
hunks: [
{
lines: [
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "before" },
{ type: "add", content: "after" },
],
},
],
},
]);
}
it("returns Git patch hunks when a repository configures an external diff command", async () => {
const { cwd, command, marker } = createExternalDiffRepo();
const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true });
expectStandardPatch(result);
expect(existsSync(marker)).toBe(false);
expect(git(cwd, "config", "--get", "diff.external").trim()).toBe(command);
});
it("returns Git patch hunks against the base branch with an external diff command", async () => {
const { cwd, command, marker } = createExternalDiffRepo();
git(cwd, "checkout", "-b", "feature");
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
const result = await getCheckoutDiff(cwd, {
mode: "base",
baseRef: "main",
includeStructured: true,
});
expectStandardPatch(result);
expect(existsSync(marker)).toBe(false);
expect(git(cwd, "config", "--get", "diff.external").trim()).toBe(command);
});
it("returns Git patch hunks when GIT_EXTERNAL_DIFF configures the external command", async () => {
const cwd = createChangedRepo("color.diff=always");
const { command, marker } = createExternalDiffCommand();
vi.stubEnv("GIT_EXTERNAL_DIFF", command);
expect(git(cwd, "diff", "HEAD", "--", "file.txt")).toBe("CUSTOM EXTERNAL DIFF\n");
expect(existsSync(marker)).toBe(true);
rmSync(marker);
const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true });
expectStandardPatch(result);
expect(existsSync(marker)).toBe(false);
expect(process.env.GIT_EXTERNAL_DIFF).toBe(command);
});
describe.each(["diff.mnemonicPrefix=true", "color.diff=always"])(
"patch format with %s",
(config) => {
+3 -124
View File
@@ -539,129 +539,10 @@ describe("checkout git utilities", () => {
});
expect(diff.structured).toContainEqual(
expect.objectContaining({
path: "renamed.txt",
oldPath: "file.txt",
additions: 0,
deletions: 0,
hunks: [],
}),
expect.objectContaining({ path: "renamed.txt", oldPath: "file.txt" }),
);
});
it.each([
{ mode: "uncommitted", oldPath: "to-rename.txt", path: "renamed.txt" },
{ mode: "base", oldPath: "to-rename.txt", path: "renamed.txt" },
{ mode: "uncommitted", oldPath: "old [file].txt", path: "new [file].txt" },
] as const)(
"shows only the edited line of a rename ($mode, $path)",
async ({ mode, oldPath, path }) => {
const original = Array.from({ length: 12 }, (_, index) => `line ${index + 1}`);
commitFile(repoDir, oldPath, original.join("\n") + "\n", "file to rename");
if (mode === "base") {
execFileSync("git", ["checkout", "-b", "feature/rename"], { cwd: repoDir });
}
execFileSync("git", ["mv", oldPath, path], { cwd: repoDir });
const edited = [...original];
edited[5] = "edited line 6";
writeFileSync(join(repoDir, path), edited.join("\n") + "\n");
execFileSync("git", ["add", path], { cwd: repoDir });
expect(
execFileSync("git", ["diff", "--name-status", "HEAD"], { cwd: repoDir }).toString(),
).toMatch(/R\d+\t/);
if (mode === "base") {
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "rename and edit"], {
cwd: repoDir,
});
}
const diff = await getCheckoutDiff(repoDir, {
mode,
...(mode === "base" ? { baseRef: "main" } : {}),
includeStructured: true,
});
expect(diff.structured).toEqual([
expect.objectContaining({
path,
oldPath,
isNew: false,
isDeleted: false,
additions: 1,
deletions: 1,
status: "ok",
}),
]);
expect(diff.diff).toContain(`rename from ${oldPath}`);
expect(diff.diff).toContain(`rename to ${path}`);
expect(diff.diff).toContain("-line 6\n+edited line 6");
expect(diff.diff).not.toContain("new file mode");
},
);
it("includes a changed copy source only once in the diff", async () => {
const original =
Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n") + "\n";
commitFile(repoDir, "source.txt", original, "copy source");
execFileSync("git", ["config", "diff.renames", "copies"], { cwd: repoDir });
writeFileSync(join(repoDir, "copy.txt"), original);
writeFileSync(join(repoDir, "source.txt"), original.replace("line 6", "edited line 6"));
execFileSync("git", ["add", "copy.txt", "source.txt"], { cwd: repoDir });
expect(
execFileSync("git", ["diff", "--name-status", "HEAD"], { cwd: repoDir }).toString(),
).toContain("C100\tsource.txt\tcopy.txt");
const diff = await getCheckoutDiff(repoDir, {
mode: "uncommitted",
includeStructured: true,
});
expect(diff.structured).toEqual([
expect.objectContaining({
path: "copy.txt",
oldPath: "source.txt",
additions: 0,
deletions: 0,
}),
expect.objectContaining({ path: "source.txt", additions: 1, deletions: 1 }),
]);
expect(diff.diff.match(/^diff --git a\/source.txt b\/source.txt$/gm)).toHaveLength(1);
expect(diff.diff).toContain("copy from source.txt");
expect(diff.diff).toContain("copy to copy.txt");
});
it("keeps a small copy patch when its edited source exceeds the patch limit", async () => {
const original =
Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n") + "\n";
commitFile(repoDir, "source.txt", original, "copy source");
execFileSync("git", ["config", "diff.renames", "copies"], { cwd: repoDir });
writeFileSync(join(repoDir, "copy.txt"), original);
writeFileSync(join(repoDir, "source.txt"), original + "added source line\n".repeat(80_000));
execFileSync("git", ["add", "copy.txt", "source.txt"], { cwd: repoDir });
expect(
execFileSync("git", ["diff", "--name-status", "HEAD"], { cwd: repoDir }).toString(),
).toContain("C100\tsource.txt\tcopy.txt");
const diff = await getCheckoutDiff(repoDir, {
mode: "uncommitted",
includeStructured: true,
});
expect(diff.structured).toEqual([
expect.objectContaining({
path: "copy.txt",
oldPath: "source.txt",
additions: 0,
deletions: 0,
hunks: [],
status: "ok",
}),
expect.objectContaining({ path: "source.txt", status: "too_large", hunks: [] }),
]);
expect(diff.diff).toContain("copy from source.txt");
expect(diff.diff).toContain("copy to copy.txt");
expect(diff.diff).not.toContain("+added source line");
});
it("reads the origin URL once when collecting facts for an origin-tracking branch", async () => {
setupRemoteTrackingMain(repoDir, tempDir);
@@ -1627,11 +1508,9 @@ const x = 1;
expect(diff.diff).toContain("# generated.js: diff too large omitted");
expect(diff.diff).toContain(`-export const value = "old";`);
expect(diff.diff).toContain(`+export const value = "new";`);
expect(commands).toContain("diff --no-color --src-prefix=a/ --dst-prefix=b/ --numstat HEAD");
expect(commands).toContain(
"diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ --numstat HEAD",
);
expect(commands).toContain(
"diff --no-ext-diff --no-color --src-prefix=a/ --dst-prefix=b/ HEAD -- :(literal)generated.js :(literal)small.ts",
"diff --no-color --src-prefix=a/ --dst-prefix=b/ HEAD -- :(literal)generated.js :(literal)small.ts",
);
expect(metrics.maxConcurrent).toBeLessThanOrEqual(8);
});
+14 -40
View File
@@ -637,7 +637,7 @@ function normalizeNumstatPath(pathField: string): string {
}
// Parsed patches need a stable wire format regardless of terminal presentation settings.
const GIT_PATCH_FORMAT_ARGS = ["--no-ext-diff", "--no-color", "--src-prefix=a/", "--dst-prefix=b/"];
const GIT_PATCH_FORMAT_ARGS = ["--no-color", "--src-prefix=a/", "--dst-prefix=b/"];
function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }): string[] {
return [
@@ -721,19 +721,12 @@ async function getTrackedDiffTextForPath(input: {
cwd: string;
refsForDiff: CheckoutDiffRefs;
path: string;
oldPath?: string;
ignoreWhitespace: boolean;
}): Promise<{ path: string; text: string; truncated: boolean }> {
const paths = input.oldPath ? [input.oldPath, input.path] : [input.path];
const result = await runGitCommand(
buildGitDiffArgs({
ignoreWhitespace: input.ignoreWhitespace,
extra: [
...(input.oldPath ? ["--diff-filter=RC"] : []),
...getCheckoutDiffRefArgs(input.refsForDiff),
"--",
...paths.map((path) => `:(literal)${path}`),
],
extra: [...getCheckoutDiffRefArgs(input.refsForDiff), "--", `:(literal)${input.path}`],
}),
{
cwd: input.cwd,
@@ -742,22 +735,9 @@ async function getTrackedDiffTextForPath(input: {
},
);
// A source path can participate in another rename/copy in the same request.
// Return only the requested destination; the filter keeps source edits out of its budget.
let text = result.stdout;
if (input.oldPath && !result.truncated) {
text = result.stdout
.split(/(?=^diff --git )/m)
.filter((section) => {
const hunkStart = section.indexOf("\n@@ ");
const header = hunkStart < 0 ? section : section.slice(0, hunkStart);
return parseDiff(header)[0]?.path === input.path;
})
.join("");
}
return {
path: input.path,
text,
text: result.stdout,
truncated: result.truncated,
};
}
@@ -1187,16 +1167,13 @@ export function parseWorktreeList(output: string): GitWorktreeEntry[] {
async function getWorktreePathForBranch(cwd: string, branchName: string): Promise<string | null> {
try {
const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], {
cwd,
envOverlay: READ_ONLY_GIT_ENV,
});
const entries = parseWorktreeList(stdout);
const ref = branchName.startsWith("refs/heads/") ? branchName : `refs/heads/${branchName}`;
const { stdout } = await runGitCommand(
["for-each-ref", "--format=%(refname)%00%(worktreepath)%00", ref],
{ cwd, envOverlay: READ_ONLY_GIT_ENV },
);
for (const record of stdout.split("\0\n")) {
const [reference, worktreePath] = record.split("\0");
if (reference === ref) return worktreePath || null;
}
return null;
return entries.find((entry) => entry.branchRef === ref)?.path ?? null;
} catch {
return null;
}
@@ -3337,29 +3314,26 @@ async function processTrackedChanges(
let trackedDiffText = "";
let trackedDiffBytes = 0;
const oldPathByPath = new Map(
trackedChanges
.filter((change) => change.oldPath)
.map((change) => [change.path, change.oldPath]),
const renamedPaths = new Set(
trackedChanges.filter((change) => change.oldPath).map((change) => change.path),
);
for (let start = 0; start < trackedDiffPaths.length; start += TRACKED_DIFF_BATCH_SIZE) {
const paths = trackedDiffPaths.slice(start, start + TRACKED_DIFF_BATCH_SIZE);
// Rename/copy detection needs both paths.
// Keep rename/copy path filtering identical to the existing per-file command.
const ordinary = await getTrackedDiffTexts({
cwd,
refsForDiff,
paths: paths.filter((path) => !oldPathByPath.has(path)),
paths: paths.filter((path) => !renamedPaths.has(path)),
ignoreWhitespace,
});
const renamed = await Promise.all(
paths
.filter((path) => oldPathByPath.has(path))
.filter((path) => renamedPaths.has(path))
.map((path) =>
getTrackedDiffTextForPath({
cwd,
refsForDiff,
path,
oldPath: oldPathByPath.get(path),
ignoreWhitespace,
}),
),
@@ -146,7 +146,6 @@ export const WORKSPACE_SEARCH_HIDDEN_DIRECTORIES = [
".agents",
".claude",
".codex",
".cursor",
".github",
".opencode",
".paseo",
@@ -126,15 +126,10 @@ async function runFixture(params: {
command: string;
args: string[];
shell?: boolean;
env?: NodeJS.ProcessEnv;
}): Promise<SpawnResult> {
const child = spawnProcess(params.command, params.args, {
env: {
...process.env,
...params.env,
// Terminal color settings must not add Node warnings to the argv fixture's stderr.
NO_COLOR: undefined,
FORCE_COLOR: undefined,
PASEO_EXPECTED_ARGV_JSON: JSON.stringify(["--config", JSON_ARG]),
},
stdio: ["ignore", "pipe", "pipe"],
@@ -221,8 +216,6 @@ async function findAndLaunch(fixture: LaunchFixture): Promise<{
const child = spawnProcess(found, fixture.args, {
env: {
...process.env,
NO_COLOR: undefined,
FORCE_COLOR: undefined,
PASEO_EXPECTED_ARGV_JSON: fixture.expectedArgvJson,
PASEO_ARGV_SLICE_FROM: String(fixture.sliceFrom),
},
@@ -338,25 +331,18 @@ describe.runIf(isPlatform("win32"))("Windows spawn launch regression", () => {
});
describe.skipIf(isPlatform("win32"))("spawn launch regression smoke", () => {
test.each([
{ colors: "inherited", env: {} },
{ colors: "conflicting", env: { NO_COLOR: "1", FORCE_COLOR: "1" } },
])(
"direct launch with a space-containing executable works with $colors colors",
async ({ env }) => {
const fixture = makeFixture();
test("direct launch with a space-containing executable works on this platform", async () => {
const fixture = makeFixture();
const result = await runFixture({
command: fixture.fakeDaemonNode,
args: [fixture.assertScript, ...fixture.expectedArgs],
shell: false,
env,
});
const result = await runFixture({
command: fixture.fakeDaemonNode,
args: [fixture.assertScript, ...fixture.expectedArgs],
shell: false,
});
expect(result.error).toBeNull();
expect(result.code).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout.trim()).toBe("ARGV_OK");
},
);
expect(result.error).toBeNull();
expect(result.code).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout.trim()).toBe("ARGV_OK");
});
});
+2 -3
View File
@@ -10,7 +10,6 @@ import {
type WorktreeConfig,
} from "./worktree";
import { execFileSync } from "child_process";
import { rm } from "node:fs/promises";
import {
mkdtempSync,
mkdirSync,
@@ -90,8 +89,8 @@ describe("paseo worktree manager", () => {
});
});
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("treats a worktree as paseo-owned even when its .git admin is missing", async () => {
File diff suppressed because it is too large Load Diff
@@ -45,8 +45,6 @@ async function logins(options: Parameters<typeof discover>[1]) {
async function database(path: string) {
await mkdir((await import("node:path")).dirname(path), { recursive: true });
const db = new DatabaseSync(path);
// Create the fixture with one durable commit instead of one per row.
db.exec("BEGIN");
db.exec(
"CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, credential_type TEXT, data TEXT, disabled_cause TEXT)",
);
@@ -80,7 +78,6 @@ async function database(path: string) {
null,
);
insert.run(5, "anthropic", "oauth", "invalid", null);
db.exec("COMMIT");
db.close();
}
@@ -39,8 +39,6 @@ async function database(path: string) {
await mkdir((await import("node:path")).dirname(path), { recursive: true });
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(path);
// Create the fixture with one durable commit instead of one per row.
db.exec("BEGIN");
db.exec(
"CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, credential_type TEXT, data TEXT, disabled_cause TEXT)",
);
@@ -74,7 +72,6 @@ async function database(path: string) {
null,
);
insert.run(5, "openai-codex", "oauth", "invalid", null);
db.exec("COMMIT");
db.close();
}
+1 -14
View File
@@ -73,8 +73,7 @@ async function harness(
predicate: (event: ProviderEvent) => boolean,
from = 0,
): Promise<ProviderEvent> {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
for (let count = 0; count < 200; count++) {
const event = events.slice(from).find(predicate);
if (event) return event;
await delay(10);
@@ -1220,18 +1219,6 @@ test("silent active turns page after two minutes and stop polling on recovered t
await vi.advanceTimersByTimeAsync(240000);
expect((await h.recorded()).filter((f) => f.method === "view/page")).toHaveLength(1);
});
test("session readiness accepts initialization beyond the old event polling window", async () => {
const h = await harness("text-reasoning", { MUSE_TEST_INITIALIZE_DELAY_MS: "3500" });
expect(await h.open()).toMatchObject({ type: "session.ready" });
expect((await h.recorded()).find((frame) => frame.event === "hostStarted").args).toEqual([
"serve",
"--sandbox-network",
"proxy-only",
"--disable-sandbox",
"--trust-workspace",
]);
}, 10000);
test("default launch disables sandbox and trusts workspace", async () => {
const h = await harness();
await h.open();
-3
View File
@@ -2,9 +2,6 @@ import fs from "node:fs";
import path from "node:path";
import { configDefaults, defineConfig } from "vitest/config";
// React's test APIs require its development build, even in a production shell.
process.env.NODE_ENV = "test";
const appDir = path.resolve(__dirname, "packages/app");
const appNodeModules = path.resolve(appDir, "node_modules");
const rootNodeModules = path.resolve(__dirname, "node_modules");