Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 4c6f285930 test(worktrees): await repository cleanup after remote timeouts 2026-10-12 06:20:51 +02:00
paseo-bot[bot] 23fb1cc85a test(schedules): use the canonical Git root for worktree fixtures 2026-10-12 06:20:51 +02:00
paseo-bot[bot]and见安 fd1551d46e fix(cli): reject fractional integer options before rounding
Validate decimal integrality before Number conversion can hide a fraction. Keep exact integer scientific notation, signed capture indices, and schedule limit updates working.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
2026-10-12 06:20:51 +02:00
见安 4d67f08922 test(cli): verify integer options against a real daemon 2026-10-12 06:20:51 +02:00
见安 35edd688b5 fix(terminal): validate complete capture line numbers 2026-10-12 06:20:22 +02:00
见安 eed6478a7d fix(schedule): validate complete max-runs values 2026-10-12 06:20:22 +02:00
paseo-bot[bot]and见安 9b92b2e25c Preserve base worktree paths when merging (#6668)
* fix(git): preserve the base worktree path when merging

* test(git): cover merging into whitespace worktree paths

* test(git): follow the server object parameter convention

* test(git): keep merge fixture line endings deterministic

Disable automatic CRLF checkout in the fixture repository so the exact-content assertion works with Windows Git defaults.

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

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 06:00:59 +02:00
Elowen a310444c67 fix: save workspace files with long valid names (#6658)
* fix(file-explorer): keep atomic save temporary names short

* test(file-explorer): cover saving long names through daemon RPC
2026-10-12 04:58:00 +02:00
paseo-bot[bot]and见安 c364b16d40 Report canceled child tasks and wait for replacement tasks to finish (#6660)
* fix(server): report canceled child turns to their callers

* fix(server): wait for replacement child tasks before notifying callers

A displaced turn emits cancellation while its replacement is still running. Notify on terminal events only once the child becomes idle, so Stop and Reload report cancellation while replacement prompts produce one eventual outcome.

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

* test(server): remove debug output from cancellation regressions

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 04:00:12 +02:00
paseo-bot[bot] dc7bc8db12 fix(app): keep typechecking after Expo generates environment types (#6656)
Expo declares NODE_ENV readonly after web startup. Preserve the early test environment setup with Object.assign and exercise the real configuration with Expo types in a failing-first compiler regression.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 02:49:09 +02:00
paseo-bot[bot] 40f0393064 Record shortcuts using the keyboard layout character (#6652)
* fix(app): record shortcuts using the keyboard layout character

Shortcut capture stored the QWERTY position while dispatch matched the logical character, so a Dvorak B became Cmd+N. Use the existing logical key mappings for capture and preserve code fallback for named and rewritten Option keys.

* fix(app): keep named shortcut keys on their supported codes

Character capture must not advertise unsupported named keys. Restrict the logical lookup to printable mappings so keypad Enter keeps its existing unsupported behavior.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 01:58:11 +02:00
paseo-bot[bot] 166aabd57b Keep subprocess regression tests independent of terminal colors (#6648)
* test(server): isolate subprocess regressions from terminal colors

Inherited NO_COLOR and FORCE_COLOR make Node emit an unrelated warning. It contaminates argv-fixture stderr and fails the closed-output supervisor fixture before its pipe handlers are installed. Clear both settings only in these fixture subprocesses, preserving their output and lifecycle assertions.

* test(server): exercise conflicting colors regardless of parent environment

Run the closed-output and spaced-executable regressions with explicit conflicting color inputs as well as the inherited environment, so removing fixture isolation fails on ordinary CI environments too.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 00:47:28 +02:00
b852ae920b fix(desktop): show the Paseo window icon on Linux (#6645)
Packaged windows read resources/icon.png, but Linux packages only
installed desktop-entry icons. Include the runtime PNG and verify its
bytes in the existing packaged artifact smoke test.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Pantic <panta@talentkit.io>
Co-authored-by: Max Höhl <43786929+whme@users.noreply.github.com>
2026-10-11 23:57:52 +02:00
paseo-bot[bot]andX b3693b79d1 Find hidden agent plan files in workspace suggestions (#6641)
* fix(server): find hidden agent plan files in workspace suggestions

Workspace autocomplete skipped the editor's plan directory while traversing
other known agent directories. Include it in the existing discovery policy
and cover basename and partial-path queries through the daemon client.

Co-authored-by: X <X@16x.org>

* test(server): give plan suggestion queries explicit expectations

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: X <X@16x.org>
2026-10-11 21:54:46 +02:00
0458988825 Connect the self-hosted web app through standard-port proxies (#6638)
* fix(server): give the web UI connection hint an explicit port (#1)

injectConnectionHint built `listen` straight from the Host header, which
carries no port when the client used the scheme's default — exactly what a
browser sends to a proxy terminating TLS on 443. The app parses `listen` with
parseHostPort, which requires an explicit port, so connectionFromListen
returned null and bootstrapInitialConnectionHint bailed before probing,
silently. Boot then fell through to the localhost:6767 fallback, which is the
viewer's own machine rather than the daemon, and the app landed on the welcome
screen with no host at all.

Reaching the daemon directly never hit this: http://host:6767 puts the port in
the Host header. It only appears behind a reverse proxy on 443 or 80, such as a
Tailscale Ingress, and only in a browser profile with no host already stored.

Spell the scheme default out when the header omits it, leaving the IPv6 bracket
form intact.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(server): compute the web bootstrap port before its connection hint

Keep the default-port fix for reverse proxy bootstrap within the repository branching style.

Co-authored-by: Sian Lerk Lau <kiawin@gmail.com>

---------

Co-authored-by: Sian Lerk Lau <kiawin@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 20:51:51 +02:00
paseo-bot[bot] 4bd31e1a2e test(cli): accept Codex catalogs without branded model IDs (#6633)
The provider regression test rejected valid GPT catalogs solely because no ID contained codex. Keep the structural output checks without requiring that upstream branding substring.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 19:47:10 +02:00
paseo-bot[bot]andOkan YILDIRIM 1939ee79ed Accept daemon settings in the published editor schema (#6621)
* fix(config): accept daemon settings in the published editor schema

The website schema omitted accepted configuration fields, so editors
rejected valid browser, profile, plugin and metadata settings. Regenerate
from the daemon schema and guard the published artifact against drift.

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>

* fix(config): preserve proxy help when regenerating the schema

Keep the existing editor descriptions in the source schema so regeneration retains them. The new regression fails when the proxy descriptions disappear.

* test(usage): create SQLite store fixtures in one transaction

The Windows plugin suite exceeded its five-second test deadline while creating and exercising the first OMP store fixture. Commit the fixture as a unit rather than flushing each inserted row separately; preserve the read-only discovery assertions.

* test(config): show structured schema validation errors

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
2026-10-11 18:50:03 +02:00
paseo-bot[bot]andBudi 8d9e7d3ad3 Confirm remote permission approvals before reporting success (#6607)
* fix(cli): await permission resolution before closing transport

* test(cli): verify permission confirmation over real connections

Exercise permission commands through a real WebSocket protocol peer, keeping responses pending until explicit resolution. This catches success before acknowledgement without replacing the client connector or process output. Retain the contributor permission fix and verify both commands, batches, and unconfirmed responses.

Co-authored-by: Budi <11062155+budivoogt@users.noreply.github.com>

* test(cli): catch permission batches completing after one reply

Hold the second acknowledgement until a WebSocket round trip proves the first reply reached the client. A first-reply-only mutation must fail both batch tests.

* test(cli): inject permission clients into command regression tests

* test(cli): use timer helper for permission continuations

* style(cli): format permission regression tests with oxfmt

---------

Co-authored-by: Budi <11062155+budivoogt@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 18:03:43 +02:00
paseo-bot[bot]and见安 114389383d Keep files starting with two dots usable (#6604)
* fix(file-explorer): distinguish parent traversal from dot-prefixed names

* test(file-explorer): cover dot-prefixed paths through daemon RPC

* test(plugins): allow slower session readiness in provider fixtures

The event helper exhausted 200 iterations before a valid delayed session
became ready. Use an elapsed-time deadline while preserving real timers
for watchdog tests, and retain a delayed-start regression with launch
flag assertions.

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

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 16:51:00 +02:00
Elowen 804335bc00 fix(files): rename untracked bracket paths without matching siblings (#6573)
* fix(files): use literal paths when checking rename tracking

* fix(files): preserve Git activity labels for rename lookups

* fix(files): clear inherited literal mode for rename lookups

* test(files): cover tracked renames with inherited literal mode
2026-10-11 13:53:25 +02:00
Jiawen Geng e2fd5a9c57 fix(app): keep markdown text that only looks like HTML verbatim (#6370)
The html-ish pass feeds markdown to htmlparser2 so it can render
<details>, <img> and friends. htmlparser2 reads any `<` followed by a
letter as a tag and collects attributes up to the next `>`. A cell such
as `1<ratio<2` in a table therefore swallowed the rows after it, and the
re-serialized "tag" dropped the newlines and pipes that made them rows.
The same path broke autolinks (`<https://x/y>` became `<https: x y>`)
and cut text after a trailing `a<b`.

Only hand htmlparser2 what CommonMark reads as raw HTML: open tags,
closing tags and comments. Every other `<` stays text.

Unknown tags are rendered back as text, but they were rebuilt from the
parser's lowercased name and attributes, so `As<Integer>()` showed as
`As<integer>()`. Echo the source slice instead.
2026-10-11 13:20:37 +02:00
paseo-bot[bot]andIvan Malison b412374f0c Run React tests when the shell uses NODE_ENV=production (#6572)
* fix(tests): run React tests from production shells

Vitest preserves an inherited NODE_ENV, selecting React without its test APIs. Pin the environment before dependency resolution and worker startup in both test entry points.

Co-authored-by: Ivan Malison <IvanMalison@gmail.com>

* test(app): expect zero edits for a pure staged rename

The rename patch correction on main preserves unchanged content. Keep the discard journey intact and update its obsolete added-line assertion.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Ivan Malison <IvanMalison@gmail.com>
2026-10-11 11:52:50 +02:00
paseo-bot[bot]andYu Han a3f75aa9e4 Show removed SQL comments in Git diffs (#6569)
Hunk content resembling patch headers was discarded before line parsing. Metadata is already ignored before the first hunk, so preserve every added and removed line once a hunk starts.

Closes #6564

Co-authored-by: Yu Han <73627805+Mar-garet@users.noreply.github.com>
2026-10-11 11:03:15 +02:00
paseo-bot[bot]and见安 46b1284177 Release files when HTTP downloads are canceled (#6567)
* fix(server): close canceled HTTP download streams

* test(server): verify canceled downloads release real file descriptors

Exercise the reported HTTP cancellation path without replacing shared Node methods, and include download regressions in the standard integration suite.

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

* test(server): detect canceled download leaks through symlinked paths

Compare descriptor targets with the resolved file path and exercise a symlinked download directory so the regression cannot pass by missing the open file.

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 09:57:29 +02:00
paseo-bot[bot]and见安 a5dba3c992 Show checkout patch lines with external Git diff commands configured (#6562)
* fix(server): parse Git patches without external diff commands

* test(server): keep diff command checks aligned with parsed patches

The external diff regression fix intentionally disables helper commands.
Keep the existing large-file batching assertions aligned with that command
while preserving their patch, omission, and concurrency checks.

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

---------

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 08:58:07 +02:00
paseo-bot[bot] dd0350bc2b Show actual edits when a file is renamed in Changes (#6559)
* fix(server): show actual edits when a file is renamed

Path-limited patch requests discarded the detected rename source, so Git reported the destination as a new file. Include both literal paths and select the destination patch to preserve the rename and avoid duplicating changed copy sources.

* fix(server): preserve small copy patches beside oversized sources

Filter rename and copy output before enforcing the bounded request budget, so a modified source cannot hide its small copy patch.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 07:55:09 +02:00
paseo-bot[bot] cfb18f16d4 fix(app): find starting refs by change request descriptions (#6558)
Preserve returned change request bodies as search-only option text so client filtering keeps description matches. Keep title and ID matches ranked ahead of secondary text.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 06:53:37 +02:00
paseo-bot[bot] 7f9b65d464 test(server): keep commit fixture authors independent of Git config (#6552)
Command-scoped user.name overrides the fixture repository configuration and breaks its author assertion. Supply the fixture author when creating commits and exercise the override with real Git.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 05:46:39 +02:00
paseo-bot[bot] 7e66657bf0 fix(agent): preserve reasoning order in activity transcripts (#6546)
Format projected text entries in sequence instead of grouping replies ahead of their preceding reasoning. Timeline projection already owns fragment merging.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 05:00:02 +02:00
58 changed files with 3194 additions and 740 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, +1, -0");
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +0, -0");
const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/);
const toggleTestId = await renamedToggle.getAttribute("data-testid");
expect(toggleTestId).not.toBeNull();
@@ -391,6 +391,43 @@ 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,6 +29,12 @@ 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: ["**", "**"],
@@ -373,14 +379,16 @@ function renderInlineTokens(tokens: HtmlToken[]): string {
}
const children = tokens.slice(index + 1, closeIndex);
output += renderHtmlTag(token, children);
const close = tokens[closeIndex];
const closeRaw = isClosingTag(close, token.name) ? close.raw : `</${token.name}>`;
output += renderHtmlTag(token, children, closeRaw);
index = closeIndex;
}
return output;
}
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string {
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[], closeRaw: string): string {
if (token.name === "a") {
return renderLinkToken(token, children);
}
@@ -400,7 +408,7 @@ function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string {
if (token.name === "p" || token.name === "div") {
return `\n\n${renderInlineTokens(children).trim()}\n\n`;
}
return `${token.raw}${renderInlineTokens(children)}</${token.name}>`;
return `${token.raw}${renderInlineTokens(children)}${closeRaw}`;
}
function renderTableTokens(tokens: HtmlToken[]): string {
@@ -606,7 +614,15 @@ 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) {
@@ -620,7 +636,7 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
closing: false,
selfClosing,
attributes,
raw: renderStartTag(name, attributes),
raw: readRawTag() ?? renderStartTag(name, attributes),
});
},
onclosetag(name, isImplied) {
@@ -633,7 +649,7 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
closing: true,
selfClosing: false,
attributes: {},
raw: `</${name}>`,
raw: readRawTag() ?? `</${name}>`,
});
},
ontext(value) {
@@ -660,6 +676,8 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
);
return {
write(chunk) {
chunkStart = writtenLength;
writtenLength += chunk.length;
parser.write(chunk);
},
skip(length) {
@@ -698,7 +716,58 @@ function escapeAttribute(value: string): string {
function getProtectedMarkdownRanges(source: string): ProtectedMarkdownRange[] {
const fencedRanges = getFencedCodeRanges(source);
return mergeProtectedRanges([...fencedRanges, ...getInlineCodeRanges(source, fencedRanges)]);
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;
}
function getFencedCodeRanges(source: string): ProtectedMarkdownRange[] {
@@ -160,3 +160,16 @@ 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,18 +10,22 @@ 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 DESCRIPTION_FALLBACK_TIER = 99;
const SECONDARY_TEXT_FALLBACK_TIER = 99;
function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null {
const best = scoreTextFields(search, [opt.label, opt.id]);
if (best) return best;
if (!opt.description) return null;
const descriptionScore = scoreTextFields(search, [opt.description]);
if (!descriptionScore) return null;
return { ...descriptionScore, tier: descriptionScore.tier + DESCRIPTION_FALLBACK_TIER };
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 };
}
export interface BuildVisibleComboboxOptionsInput {
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { formatShortcut } from "@/utils/format-shortcut";
import { keyboardEventToComboString } from "./shortcut-string";
import {
buildKeyboardShortcutHelpSections,
buildEffectiveBindings,
@@ -1026,6 +1027,25 @@ 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,6 +22,45 @@ 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("-");
});
+10 -1
View File
@@ -266,7 +266,16 @@ export function keyboardEventToComboString(event: KeyboardEvent): string | null
if (event.shiftKey) parts.push("Shift");
if (event.metaKey) parts.push("Cmd");
const humanKey = CODE_TO_KEY[event.code];
// 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];
if (humanKey) {
parts.push(humanKey);
} else {
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildVisibleComboboxOptions } from "@/components/ui/combobox-options";
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
import {
type BranchPickerDetail,
@@ -340,3 +341,48 @@ 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,7 +264,10 @@ 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) }, timestamp });
timedOptions.push({
option: { id, label: formatPrLabel(pr), searchText: pr.body ?? undefined },
timestamp,
});
}
const baseItem = input.baseItem;
@@ -279,7 +282,13 @@ export function buildPickerOptionData(input: {
const labeledItem = disambiguate(baseItem, timedOptions);
itemById.set(selectedOptionId, labeledItem);
timedOptions.push({
option: { id: selectedOptionId, label: pickerItemLabel(labeledItem) },
option: {
id: selectedOptionId,
label: pickerItemLabel(labeledItem),
...(labeledItem.kind === "github-pr"
? { searchText: labeledItem.item.body ?? undefined }
: {}),
},
// The base sorts first: it is what a workspace is created from unless you pick something.
timestamp: Number.POSITIVE_INFINITY,
});
+67
View File
@@ -0,0 +1,67 @@
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,6 +3,9 @@ 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) => {
@@ -26,6 +29,7 @@ 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,6 +15,18 @@ 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" },
+124 -91
View File
@@ -1,5 +1,8 @@
import type { Command } from "commander";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
} from "@getpaseo/protocol/agent-types";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
@@ -55,106 +58,136 @@ export interface PermitAllowOptions extends CommandOptions {
host?: string;
}
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
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>;
}
// 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;
}
}
export type PermitClientConnector = (
options: Parameters<typeof connectToDaemon>[0],
) => Promise<PermitClient>;
const client = await connectToDaemon({ target: options.daemonTarget });
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
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();
// 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: "PERMISSION_NOT_FOUND",
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
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;
}
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");
}),
);
const client = await connect({ target: options.daemonTarget });
await client.close();
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;
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;
// 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,
};
} 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;
}
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "ALLOW_PERMISSION_FAILED",
message: `Failed to allow permission: ${message}`,
};
throw error;
}
};
}
export const runAllowCommand = createAllowCommand();
+100 -83
View File
@@ -6,6 +6,7 @@ import {
permitResponseSchema,
toPermissionResponseItem,
type PermissionResponseItem,
type PermitClientConnector,
} from "./allow.js";
export type PermitDenyResult = ListResult<PermissionResponseItem>;
@@ -17,98 +18,114 @@ export interface PermitDenyOptions extends CommandOptions {
host?: string;
}
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 connectToDaemon({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
if (!fetchResult) {
await client.close();
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: "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)}`,
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;
}
// 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) {
const client = await connect({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
if (!fetchResult) {
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(", ")}`,
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
};
throw error;
}
permissionsToDeny = [permission];
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");
}),
);
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;
}
// 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;
}
};
}
export const runDenyCommand = createDenyCommand();
@@ -0,0 +1,177 @@
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);
});
});
}
+3 -2
View File
@@ -1,3 +1,4 @@
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";
@@ -441,8 +442,8 @@ function buildNewAgentConfigPatch(
}
function parsePositiveInt(value: string, flag: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
const parsed = parseSafeInteger(value);
if (parsed === undefined || parsed <= 0) {
throw {
code: "INVALID_INTEGER",
message: `${flag} must be a positive integer`,
@@ -1,3 +1,4 @@
import { parseSafeInteger } from "../../utils/integer.js";
import type { Command } from "commander";
import { renderError, toCommandError } from "../../output/render.js";
import {
@@ -85,8 +86,8 @@ function parseLineNumber(flag: string, value?: string): number | undefined {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed)) {
const parsed = parseSafeInteger(value);
if (parsed === undefined) {
throw {
code: "INVALID_LINE_NUMBER",
message: `Invalid ${flag} value: ${value}`,
+20
View File
@@ -0,0 +1,20 @@
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,10 +364,6 @@ 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,6 +848,11 @@ 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,6 +65,8 @@ 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/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: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: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,6 +29,7 @@ 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;
@@ -84,7 +85,9 @@ async function runSupervisorFixture(options: {
];
const child = spawn(command, args, {
cwd: repoRoot,
env: { ...process.env },
// 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 },
stdio: ["ignore", "pipe", "pipe"],
});
@@ -205,10 +208,16 @@ describe("supervisor durable logging", () => {
expect(result.stderr).toContain('"worker-json-stderr"');
});
test("keeps supervising the worker when nobody reads its stdout or stderr", async () => {
const result = await runSupervisorFixture({
closeOutput: true,
workerSource: `
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: `
process.on("message", (message) => {
if (message?.type === "paseo:graceful-shutdown") process.exit(0);
});
@@ -220,14 +229,15 @@ 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,6 +39,45 @@ 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,36 +25,20 @@ 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 flushBuffers(
function appendTextEntry(
entries: ActivityEntry[],
buffers: { message: string; thought: string },
item: Extract<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>,
options?: ActivityCuratorOptions,
) {
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 = "";
const text = item.text.trim();
if (!text) return;
let prefix = options?.labelAssistantMessages ? "[Assistant] " : "";
if (item.type === "reasoning") prefix = "[Thought] ";
entries.push(activityEntry(`${prefix}${text}`));
}
function formatToolInputJson(input: unknown): string | null {
@@ -147,7 +131,6 @@ 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)) {
@@ -156,17 +139,13 @@ 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":
buffers.thought = appendText(buffers.thought, item.text);
appendTextEntry(entries, item, options);
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()));
@@ -174,7 +153,6 @@ function curateProjectedActivityEntries(
break;
}
case "todo":
flushBuffers(entries, buffers, options);
entries.push(activityEntry("[Tasks]"));
for (const entry of item.items) {
const checkbox = entry.completed ? "[x]" : "[ ]";
@@ -183,18 +161,14 @@ 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,6 +222,11 @@ 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) => {
@@ -540,6 +545,11 @@ 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,13 +407,20 @@ export interface SetupFinishNotificationParams {
logger: Logger;
}
type FinishNotificationReason = "finished" | "errored" | "needs permission" | "was closed";
type FinishNotificationReason =
| "finished"
| "errored"
| "needs permission"
| "was closed"
| "was canceled";
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;
@@ -568,10 +575,8 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
notifySafely("errored");
return;
}
if (agent.lifecycle === "idle" && hasSeenRunning) {
notifySafely("finished");
return;
}
// Idle is published before the terminal stream event for both completion
// and cancellation. Let that event identify the outcome instead.
if (agent.lifecycle === "closed") {
notifySafely("was closed");
return;
@@ -600,6 +605,18 @@ 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,6 +12,8 @@ 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;
@@ -311,6 +313,167 @@ 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,6 +4359,10 @@ 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,6 +134,44 @@ 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,6 +832,9 @@ 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;
@@ -843,6 +846,7 @@ 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) {
@@ -0,0 +1,87 @@
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,5 +1,15 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync } from "fs";
import {
mkdtempSync,
writeFileSync,
rmSync,
readdirSync,
readlinkSync,
realpathSync,
symlinkSync,
} from "fs";
import { once } from "node:events";
import { get } from "node:http";
import { tmpdir } from "os";
import path from "path";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
@@ -8,6 +18,41 @@ 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";
@@ -24,6 +69,39 @@ 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 } from "fs";
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, symlinkSync, readdirSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
@@ -23,6 +23,287 @@ 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,5 +1,14 @@
import { test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, existsSync, rmSync, readFileSync, realpathSync } from "fs";
import {
mkdtempSync,
writeFileSync,
existsSync,
rmSync,
readFileSync,
realpathSync,
mkdirSync,
} from "fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "os";
import path from "path";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
@@ -152,6 +161,78 @@ 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,6 +6,7 @@ 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;
@@ -28,6 +29,75 @@ 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,8 +12,8 @@ import {
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { runGitCommand } from "../../utils/run-git-command.js";
import { describe, expect, it, vi } from "vitest";
import { runGitCommand, snapshotGitCommandRuntimeMetrics } from "../../utils/run-git-command.js";
import {
createExplorerEntry,
deleteExplorerEntry,
@@ -517,6 +517,129 @@ 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),
`.${path.basename(filePath.resolvedPath)}.paseo-${randomUUID()}.tmp`,
`.paseo-${randomUUID()}.tmp`,
);
let temporaryHandle: FileHandle | null = null;
try {
@@ -724,7 +724,11 @@ export async function renameExplorerEntry({
});
const tracked =
repository.exitCode === 0
? await runGitCommand(["ls-files", "-z", "--", sourcePath], { cwd: realRoot })
? await runGitCommand(["ls-files", "-z", "--", `:(literal)${sourcePath}`], {
cwd: realRoot,
// Inherited literal mode disables the :(literal) pathspec magic.
envOverlay: { GIT_LITERAL_PATHSPECS: undefined },
})
: null;
if (tracked?.stdout) {
await runGitCommand(["mv", "--", sourcePath, renamedPath], { cwd: realRoot });
@@ -820,8 +824,9 @@ async function resolveScopedPath({
function assertWithinWorkspace(root: string, candidate: string): void {
const relative = path.relative(root, candidate);
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) return;
throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE);
const isOutsideWorkspace =
relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
if (isOutsideWorkspace) throw new Error(ACCESS_OUTSIDE_WORKSPACE_MESSAGE);
}
async function openFileForRead(filePath: string): Promise<FileHandle> {
@@ -0,0 +1,78 @@
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);
});
+22 -4
View File
@@ -237,7 +237,12 @@ 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(),
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.',
),
mcp: z
.object({
enabled: z.boolean().optional(),
@@ -284,9 +289,22 @@ 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(),
listen: z.string().optional(),
publicBaseUrl: z.url().optional(),
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."),
})
.strict()
.optional(),
@@ -13,6 +13,7 @@ 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" }>;
@@ -42,6 +43,137 @@ 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");
@@ -60,7 +192,13 @@ function createGitRepo(): string {
cwd: repoDir,
stdio: "pipe",
});
return repoDir;
// 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(),
);
}
async function createNewAgentSchedule(options: ScheduleCreateOptions): Promise<ScheduleSummary> {
@@ -0,0 +1,54 @@
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,6 +190,34 @@ 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,16 +79,6 @@ 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;
@@ -116,8 +106,6 @@ 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);
+25 -1
View File
@@ -250,6 +250,22 @@ 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],
@@ -257,8 +273,16 @@ 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: host,
listen,
useTls,
label,
};
@@ -25,7 +25,10 @@ function git(args: string[], cwd?: string): void {
}
function commit(repoDir: string, message: string): void {
git(["-c", "commit.gpgsign=false", "commit", "-m", message], repoDir);
git(
["-c", "commit.gpgsign=false", "commit", "--author=Test User <test@test.com>", "-m", message],
repoDir,
);
}
function commitFile(repoDir: string, name: string, content: string, message: string): void {
@@ -92,6 +95,30 @@ 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,13 +1,14 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } 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 });
});
@@ -62,6 +63,95 @@ 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) => {
+124 -3
View File
@@ -539,10 +539,129 @@ describe("checkout git utilities", () => {
});
expect(diff.structured).toContainEqual(
expect.objectContaining({ path: "renamed.txt", oldPath: "file.txt" }),
expect.objectContaining({
path: "renamed.txt",
oldPath: "file.txt",
additions: 0,
deletions: 0,
hunks: [],
}),
);
});
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);
@@ -1508,9 +1627,11 @@ 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-color --src-prefix=a/ --dst-prefix=b/ HEAD -- :(literal)generated.js :(literal)small.ts",
"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",
);
expect(metrics.maxConcurrent).toBeLessThanOrEqual(8);
});
+40 -14
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-color", "--src-prefix=a/", "--dst-prefix=b/"];
const GIT_PATCH_FORMAT_ARGS = ["--no-ext-diff", "--no-color", "--src-prefix=a/", "--dst-prefix=b/"];
function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }): string[] {
return [
@@ -721,12 +721,19 @@ 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: [...getCheckoutDiffRefArgs(input.refsForDiff), "--", `:(literal)${input.path}`],
extra: [
...(input.oldPath ? ["--diff-filter=RC"] : []),
...getCheckoutDiffRefArgs(input.refsForDiff),
"--",
...paths.map((path) => `:(literal)${path}`),
],
}),
{
cwd: input.cwd,
@@ -735,9 +742,22 @@ 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: result.stdout,
text,
truncated: result.truncated,
};
}
@@ -1167,13 +1187,16 @@ 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}`;
return entries.find((entry) => entry.branchRef === ref)?.path ?? null;
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;
} catch {
return null;
}
@@ -3314,26 +3337,29 @@ async function processTrackedChanges(
let trackedDiffText = "";
let trackedDiffBytes = 0;
const renamedPaths = new Set(
trackedChanges.filter((change) => change.oldPath).map((change) => change.path),
const oldPathByPath = new Map(
trackedChanges
.filter((change) => change.oldPath)
.map((change) => [change.path, change.oldPath]),
);
for (let start = 0; start < trackedDiffPaths.length; start += TRACKED_DIFF_BATCH_SIZE) {
const paths = trackedDiffPaths.slice(start, start + TRACKED_DIFF_BATCH_SIZE);
// Keep rename/copy path filtering identical to the existing per-file command.
// Rename/copy detection needs both paths.
const ordinary = await getTrackedDiffTexts({
cwd,
refsForDiff,
paths: paths.filter((path) => !renamedPaths.has(path)),
paths: paths.filter((path) => !oldPathByPath.has(path)),
ignoreWhitespace,
});
const renamed = await Promise.all(
paths
.filter((path) => renamedPaths.has(path))
.filter((path) => oldPathByPath.has(path))
.map((path) =>
getTrackedDiffTextForPath({
cwd,
refsForDiff,
path,
oldPath: oldPathByPath.get(path),
ignoreWhitespace,
}),
),
@@ -146,6 +146,7 @@ export const WORKSPACE_SEARCH_HIDDEN_DIRECTORIES = [
".agents",
".claude",
".codex",
".cursor",
".github",
".opencode",
".paseo",
@@ -126,10 +126,15 @@ 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"],
@@ -216,6 +221,8 @@ 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),
},
@@ -331,18 +338,25 @@ describe.runIf(isPlatform("win32"))("Windows spawn launch regression", () => {
});
describe.skipIf(isPlatform("win32"))("spawn launch regression smoke", () => {
test("direct launch with a space-containing executable works on this platform", async () => {
const fixture = makeFixture();
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();
const result = await runFixture({
command: fixture.fakeDaemonNode,
args: [fixture.assertScript, ...fixture.expectedArgs],
shell: false,
});
const result = await runFixture({
command: fixture.fakeDaemonNode,
args: [fixture.assertScript, ...fixture.expectedArgs],
shell: false,
env,
});
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");
},
);
});
+3 -2
View File
@@ -10,6 +10,7 @@ import {
type WorktreeConfig,
} from "./worktree";
import { execFileSync } from "child_process";
import { rm } from "node:fs/promises";
import {
mkdtempSync,
mkdirSync,
@@ -89,8 +90,8 @@ describe("paseo worktree manager", () => {
});
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
});
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,6 +45,8 @@ 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)",
);
@@ -78,6 +80,7 @@ async function database(path: string) {
null,
);
insert.run(5, "anthropic", "oauth", "invalid", null);
db.exec("COMMIT");
db.close();
}
@@ -39,6 +39,8 @@ 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)",
);
@@ -72,6 +74,7 @@ async function database(path: string) {
null,
);
insert.run(5, "openai-codex", "oauth", "invalid", null);
db.exec("COMMIT");
db.close();
}
+14 -1
View File
@@ -73,7 +73,8 @@ async function harness(
predicate: (event: ProviderEvent) => boolean,
from = 0,
): Promise<ProviderEvent> {
for (let count = 0; count < 200; count++) {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
const event = events.slice(from).find(predicate);
if (event) return event;
await delay(10);
@@ -1219,6 +1220,18 @@ 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,6 +2,9 @@ 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");