Compare commits

...
Author SHA1 Message Date
paseo-bot[bot]andLorenzo Fiore 3e172d18c4 test: retain real OMP ACP provider ID collision reproduction
Reproduce #3178 through configuration parsing, provider registry resolution, and real session creation. The unique omp-acp ID succeeds, while omp keeps the built-in RPC runtime despite extends: acp and times out. This is an opt-in diagnosis test, with no production behavior changes.

Co-authored-by: Lorenzo Fiore <fiorelorenzo.fl@gmail.com>
2026-10-11 23:55:40 +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
20 changed files with 1788 additions and 626 deletions
@@ -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[] {
+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);
});
});
}
-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",
+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/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/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/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
"test:integration:all": "npm run test:e2e",
"test:integration:real": "vitest run real.e2e.test.ts",
@@ -0,0 +1,52 @@
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { expect, test } from "vitest";
import { ProviderOverridesSchema } from "@getpaseo/protocol/provider-config";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { buildProviderRegistry } from "./provider-registry.js";
import type { AgentSession } from "./agent-sdk-types.js";
// Retained reproduction for #3178, awaiting the built-in ID configuration decision.
// Requires OMP on PATH (or OMP_COMMAND). Uses no model calls or existing provider state.
const runReproduction = process.env.OMP_PROVIDER_COLLISION_REPRO === "1";
for (const providerId of ["omp-acp", "omp"]) {
test.skipIf(!runReproduction)(
`explicit ACP configuration creates an ACP session under '${providerId}'`,
async () => {
const cwd = await mkdtemp(path.join(process.cwd(), ".provider-collision-"));
const agentDir = path.join(cwd, "agent");
await mkdir(agentDir);
const providerOverrides = ProviderOverridesSchema.parse({
[providerId]: {
extends: "acp",
label: "Oh My Pi ACP",
command: [process.env.OMP_COMMAND ?? "omp", "acp"],
enabled: true,
env: {
PI_CODING_AGENT_DIR: agentDir,
OMP_PROFILE: "default",
PI_PROFILE: "default",
XDG_CONFIG_HOME: path.join(cwd, "config"),
XDG_DATA_HOME: path.join(cwd, "data"),
XDG_CACHE_HOME: path.join(cwd, "cache"),
XDG_STATE_HOME: path.join(cwd, "state"),
},
},
});
const logger = createTestLogger();
const registry = buildProviderRegistry(logger, { providerOverrides });
const client = registry[providerId]!.createClient(logger);
let session: AgentSession | undefined;
try {
session = await client.createSession({ provider: providerId, cwd });
expect(session.provider).toBe(providerId);
} finally {
await session?.close();
await client.shutdown?.();
await rm(cwd, { recursive: true, force: true });
}
},
30_000,
);
}
@@ -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();
@@ -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,222 @@ afterEach(async () => {
}, 60000);
describe("file explorer", () => {
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();
@@ -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 {
@@ -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(),
+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,
};
@@ -146,6 +146,7 @@ export const WORKSPACE_SEARCH_HIDDEN_DIRECTORIES = [
".agents",
".claude",
".codex",
".cursor",
".github",
".opencode",
".paseo",
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();