mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c6f285930 | ||
|
|
23fb1cc85a | ||
|
|
fd1551d46e | ||
|
|
4d67f08922 | ||
|
|
35edd688b5 | ||
|
|
eed6478a7d | ||
|
|
9b92b2e25c | ||
|
|
a310444c67 | ||
|
|
c364b16d40 | ||
|
|
dc7bc8db12 | ||
|
|
40f0393064 | ||
|
|
166aabd57b | ||
|
|
b852ae920b | ||
|
|
b3693b79d1 | ||
|
|
0458988825 | ||
|
|
4bd31e1a2e | ||
|
|
1939ee79ed | ||
|
|
8d9e7d3ad3 | ||
|
|
114389383d | ||
|
|
804335bc00 | ||
|
|
e2fd5a9c57 |
@@ -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[] {
|
||||
|
||||
@@ -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("-");
|
||||
});
|
||||
|
||||
@@ -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 { 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";
|
||||
@@ -8,6 +9,35 @@ 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([
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
// Set this before Vite resolves dependencies and Vitest starts its workers.
|
||||
process.env.NODE_ENV = "test";
|
||||
Object.assign(process.env, { NODE_ENV: "test" });
|
||||
|
||||
const appNodeModules = path.resolve(__dirname, "node_modules");
|
||||
const rootNodeModules = path.resolve(__dirname, "../../node_modules");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -65,6 +65,8 @@ linux:
|
||||
extraResources:
|
||||
- from: bin/paseo
|
||||
to: bin/paseo
|
||||
- from: assets/icon.png
|
||||
to: icon.png
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
|
||||
@@ -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/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({
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1187,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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user