Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 0a5cce8561 Add failing repro for Gitea source links on a split SSH/web host (#4641) 2026-09-24 07:54:16 +02:00
paseo-bot[bot] 6016ed7059 Reject missing and non-directory paths when an agent creates a local workspace (#5322)
* Reject missing and non-directory paths for local create_workspace

The create_workspace MCP tool with isolation local persisted a workspace
and project for a path that does not exist or is a regular file. The
app's workspace create request already rejects those paths with
"Directory not found"; the tool now applies the same check before
provisioning.

* Make the local create_workspace e2e assertions path-format independent

* Rethrow filesystem errors other than a missing path in local create_workspace

* Express the local create_workspace e2e test through daemon and workspace helpers

* Clean up the offline MCP daemon when its startup fails

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 06:50:41 +02:00
paseo-bot[bot]andJared 8e858f0e35 Keep checked options and the Other answer together on multi-select questions (#5320)
* Keep checked options and the Other answer together on multi-select questions

On a multi-select question card, typing into Other cleared the checked
options, and checking an option after typing dropped the Other text from
state while the field kept showing it, so submit sent the labels alone.

Multi-select now keeps both and submits the checked labels followed by
the Other text, joined with ", ". Single-select still replaces one with
the other, and now clears the Other field on screen when it does.

Closes #4370

Co-authored-by: Jared <77949461+ThePharmer@users.noreply.github.com>

* Query the question card test by role and accessible name

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Jared <77949461+ThePharmer@users.noreply.github.com>
2026-09-24 06:02:37 +02:00
paseo-bot[bot] fbc83613c0 Keep original file names for chat uploads (#5317)
* fix(server): keep original file names for chat uploads

Uploads replaced every character outside ASCII letters, digits, dot,
dash, and space with an underscore, so CJK, Korean, accented, and
parenthesized names reached disk and the agent as underscores.
Replace only characters no file system accepts in a file name, and
shorten names past 255 bytes while keeping the extension.

* test(server): make upload separator test independent of the daemon OS

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 05:01:09 +02:00
paseo-bot[bot] c356394bfa Load config.json saved with a UTF-8 byte order mark (#5315)
* Load config.json saved with a UTF-8 byte order mark

Windows Notepad saves UTF-8 with a BOM. Both config readers passed the
text straight to JSON.parse, so the daemon refused to start and CLI
config commands failed with "Unexpected token".

Closes #4683

* Strip the config BOM with an anchored replace

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 03:48:52 +02:00
paseo-bot[bot] e6085c1e98 Point CLI auth failures at PASEO_PASSWORD instead of daemon start (#5310)
A password-protected daemon that rejects the CLI is running, so the
start hint was the wrong remedy for AUTH_REQUIRED and AUTH_FAILED.

Fixes #4508

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 02:53:02 +02:00
paseo-bot[bot] 1bf5312297 Start the daemon when an empty paseo.pid was left behind (#5306)
A supervisor killed between creating paseo.pid and writing its contents
leaves an empty file. Every later start, status, and stop then failed
with "Cannot read daemon state", so the daemon stayed down until someone
deleted the file by hand.

An empty lock names no owner, so after the existing read retries it now
reads as no lock, and acquiring the lock removes it before creating a new
one. A lock with unparseable contents still refuses, since it may belong
to a supervisor this version cannot read.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 01:53:13 +02:00
paseo-bot[bot] 7fa78244fd fix(cli): emit full permission request ids from permit ls, allow, and deny (#5305)
The permit result items stored the request id already shortened to eight
characters for the table, so JSON, YAML, and quiet output serialized that
display value. Every request id starts with "permission-", so every row
came out as "permissi" and could not be passed back to permit allow.
The items now keep the full id and only the table column shortens it.

Closes #4732

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 00:52:03 +02:00
19 changed files with 695 additions and 69 deletions
@@ -54,6 +54,54 @@ describe("question form card core", () => {
});
});
test("keeps checked options and appends the other answer for multi-select", () => {
const questions = parseQuestionFormQuestions({
questions: [
{
question: "Which fruits do you like?",
header: "Fruits",
options: [{ label: "Apple" }, { label: "Banana" }, { label: "Cherry" }],
multiSelect: true,
allowOther: true,
},
],
});
if (!questions) throw new Error("questions did not parse");
expect(buildQuestionFormAnswers(questions, { 0: new Set([0, 2]) }, { 0: " durian " })).toEqual({
Fruits: "Apple, Cherry, durian",
});
expect(buildQuestionFormAnswers(questions, { 0: new Set([0, 2]) }, {})).toEqual({
Fruits: "Apple, Cherry",
});
expect(buildQuestionFormAnswers(questions, { 0: new Set() }, { 0: "durian" })).toEqual({
Fruits: "durian",
});
expect(buildQuestionFormAnswers(questions, {}, { 0: "durian" })).toEqual({ Fruits: "durian" });
});
test("replaces the selected option with the other answer for single-select", () => {
const questions = parseQuestionFormQuestions({
questions: [
{
question: "Which provider?",
header: "Provider",
options: [{ label: "Claude Code" }, { label: "Codex" }],
multiSelect: false,
allowOther: true,
},
],
});
if (!questions) throw new Error("questions did not parse");
expect(buildQuestionFormAnswers(questions, { 0: new Set([1]) }, { 0: "OpenCode" })).toEqual({
Provider: "OpenCode",
});
expect(buildQuestionFormAnswers(questions, { 0: new Set([1]) }, {})).toEqual({
Provider: "Codex",
});
});
test("shows text input for explicit other questions", () => {
const questions = parseQuestionFormQuestions({
questions: [
@@ -111,10 +111,13 @@ export function buildQuestionFormAnswers(
const q = questions[i];
const selected = selections[i];
const otherText = otherTexts[i]?.trim();
const labels = selected ? Array.from(selected).map((idx) => q.options[idx].label) : [];
if (questionShowsTextInput(q)) {
if (otherText && otherText.length > 0) {
answers[q.header] = otherText;
// Multi-select keeps the checked options and appends the custom answer, the way
// Claude Code's own AskUserQuestion UI does. Single-select replaces the option.
answers[q.header] = q.multiSelect ? [...labels, otherText].join(", ") : otherText;
continue;
}
if (q.allowEmpty && q.options.length === 0) {
@@ -123,8 +126,7 @@ export function buildQuestionFormAnswers(
}
}
if (selected && selected.size > 0) {
const labels = Array.from(selected).map((idx) => q.options[idx].label);
if (labels.length > 0) {
answers[q.header] = labels.join(", ");
}
}
@@ -0,0 +1,149 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { within } from "@testing-library/dom";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n as testI18n } from "@/i18n/i18next";
import type { PendingPermission } from "@/types/shared";
import { QuestionFormCard } from "./question-form-card";
// Load translations so controls expose their real accessible names.
void testI18n;
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
/**
* A real browser with the real web `EditingTextInput`, because the bug under test lives in the
* gap between that input and React state: the input owns its text and never replays state, so a
* card that drops the Other text from state alone keeps showing it while submit ignores it.
*/
interface Mounted {
root: Root;
container: HTMLDivElement;
}
const mounted: Mounted[] = [];
afterEach(() => {
for (const entry of mounted.splice(0)) {
act(() => entry.root.unmount());
entry.container.remove();
}
});
function buildPermission(question: Record<string, unknown>): PendingPermission {
return {
key: "perm-1",
agentId: "agent-1",
request: {
id: "perm-1",
provider: "claude",
name: "AskUserQuestion",
kind: "question",
input: { questions: [question] },
},
};
}
function mountCard(question: Record<string, unknown>) {
const onRespond = vi.fn<(response: AgentPermissionResponse) => void>();
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() =>
root.render(
<QuestionFormCard
permission={buildPermission(question)}
onRespond={onRespond}
isResponding={false}
/>,
),
);
mounted.push({ root, container });
const view = within(container);
const optionRole = question.multiSelect ? "checkbox" : "radio";
const otherInput = () =>
view.getByRole<HTMLInputElement>("textbox", { name: String(question.question) });
const check = (label: string) => act(() => view.getByRole(optionRole, { name: label }).click());
const type = (text: string) => {
const input = otherInput();
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
if (!valueSetter) throw new Error("HTML input value setter is unavailable");
act(() => {
valueSetter.call(input, text);
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: text }));
});
};
const submit = () => act(() => view.getByRole("button", { name: "Submit" }).click());
const submittedAnswers = (): Record<string, string> => {
const response = onRespond.mock.calls[0]?.[0];
if (!response || response.behavior !== "allow") throw new Error("card did not submit");
return (response.updatedInput as { answers: Record<string, string> }).answers;
};
return { check, type, otherInput, submit, submittedAnswers };
}
const multiSelectQuestion = {
question: "Which fruits do you like?",
header: "Fruits",
options: [{ label: "Apple" }, { label: "Banana" }, { label: "Cherry" }],
multiSelect: true,
allowOther: true,
};
const singleSelectQuestion = {
question: "Which provider?",
header: "Provider",
options: [{ label: "Claude Code" }, { label: "Codex" }],
multiSelect: false,
allowOther: true,
};
describe("QuestionFormCard other answers", () => {
it("keeps checked options when the other answer is typed afterwards (multi-select)", () => {
const card = mountCard(multiSelectQuestion);
card.check("Apple");
card.check("Cherry");
card.type("durian");
card.submit();
expect(card.submittedAnswers()).toEqual({ Fruits: "Apple, Cherry, durian" });
});
it("keeps the typed other answer when options are checked afterwards (multi-select)", () => {
const card = mountCard(multiSelectQuestion);
card.type("durian");
card.check("Apple");
card.check("Banana");
expect(card.otherInput().value).toBe("durian");
card.submit();
expect(card.submittedAnswers()).toEqual({ Fruits: "Apple, Banana, durian" });
});
it("replaces the selected option with the typed other answer (single-select)", () => {
const card = mountCard(singleSelectQuestion);
card.check("Codex");
card.type("OpenCode");
card.submit();
expect(card.submittedAnswers()).toEqual({ Provider: "OpenCode" });
});
it("clears the typed other answer on screen when an option is picked afterwards (single-select)", () => {
const card = mountCard(singleSelectQuestion);
card.type("OpenCode");
card.check("Codex");
expect(card.otherInput().value).toBe("");
card.submit();
expect(card.submittedAnswers()).toEqual({ Provider: "Codex" });
});
});
@@ -1,5 +1,5 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback, useMemo, useRef, type RefObject } from "react";
import { View, Text, Pressable, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -9,6 +9,7 @@ import type { PendingPermission } from "@/types/shared";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { isWeb } from "@/constants/platform";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
import {
areQuestionsAnswered,
buildQuestionFormAnswers,
@@ -258,6 +259,7 @@ function QuestionNav({
interface QuestionOtherInputProps {
qIndex: number;
inputRef: RefObject<EditingTextInputHandle | null>;
accessibilityLabel: string;
value: string;
placeholder: string;
@@ -268,6 +270,7 @@ interface QuestionOtherInputProps {
function QuestionOtherInput({
qIndex,
inputRef,
accessibilityLabel,
value,
placeholder,
@@ -303,6 +306,7 @@ function QuestionOtherInput({
);
return (
<TextInput
ref={inputRef}
// @ts-expect-error - outlineStyle is web-only
style={otherInputStyle}
accessibilityLabel={accessibilityLabel}
@@ -328,6 +332,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
const otherInputRef = useRef<EditingTextInputHandle | null>(null);
const [respondingAction, setRespondingAction] = useState<"submit" | "dismiss" | null>(null);
const [activeQuestionIndex, setActiveQuestionIndex] = useState(0);
@@ -349,29 +354,40 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
}
setSelections((prev) => ({ ...prev, [qIndex]: next }));
setOtherTexts((prev) => {
if (!prev[qIndex]) return prev;
const nextTexts = { ...prev };
delete nextTexts[qIndex];
return nextTexts;
});
// Single-select: an option and a custom answer replace each other, as in Claude Code.
// Multi-select keeps both. The editing surface owns its text and never replays state
// (docs/forms.md), so clearing state alone would leave stale text on screen that
// submit ignores; clear the surface explicitly.
if (!multiSelect && otherTexts[qIndex]) {
setOtherTexts((prev) => {
const nextTexts = { ...prev };
delete nextTexts[qIndex];
return nextTexts;
});
otherInputRef.current?.replaceText("");
}
if (!multiSelect && next.size > 0 && qIndex === activeQuestionIndex && questions) {
setActiveQuestionIndex(Math.min(qIndex + 1, questions.length - 1));
}
},
[activeQuestionIndex, questions, selections],
[activeQuestionIndex, otherTexts, questions, selections],
);
const setOtherText = useCallback((qIndex: number, text: string) => {
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
if (text.length > 0) {
setSelections((prev) => {
if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
return { ...prev, [qIndex]: new Set<number>() };
});
}
}, []);
const setOtherText = useCallback(
(qIndex: number, text: string) => {
setOtherTexts((prev) => ({ ...prev, [qIndex]: text }));
const multiSelect = questions?.[qIndex]?.multiSelect ?? false;
if (!multiSelect && text.length > 0) {
setSelections((prev) => {
if (!prev[qIndex] || prev[qIndex].size === 0) return prev;
return { ...prev, [qIndex]: new Set<number>() };
});
}
},
[questions],
);
const allAnswered = areQuestionsAnswered(questions, selections, otherTexts);
const resolvedActiveQuestionIndex = questions
@@ -554,6 +570,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
{showTextInput ? (
<QuestionOtherInput
qIndex={resolvedActiveQuestionIndex}
inputRef={otherInputRef}
accessibilityLabel={activeQuestion.question}
value={otherText}
placeholder={getQuestionInputPlaceholder({
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { buildForgeBlobUrl, buildForgeBranchTreeUrl } from "./forge-url";
// Repro for https://github.com/getpaseo/paseo/issues/4641: a Gitea instance whose
// SSH host (git.example.com) differs from the web URL configured in the tea
// login (https://projects.example.com). The source links must use the web host.
describe("#4641 Gitea source links with a split SSH/web host", () => {
const remoteUrl = "ssh://git@git.example.com:7998/Acme/main.git";
it("links the branch tree on the tea login's web host", () => {
expect(buildForgeBranchTreeUrl("gitea", { remoteUrl, branch: "main" })).toBe(
"https://projects.example.com/Acme/main/src/branch/main",
);
});
it("links a file on the tea login's web host", () => {
expect(buildForgeBlobUrl("gitea", { remoteUrl, branch: "main", path: "README.md" })).toBe(
"https://projects.example.com/Acme/main/src/branch/main/README.md",
);
});
});
+17 -8
View File
@@ -16,7 +16,7 @@ export interface PermissionResponseItem {
export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
idField: "requestId",
columns: [
{ header: "REQUEST ID", field: "requestId", width: 12 },
{ header: "REQUEST ID", field: (item) => item.requestId.slice(0, 8), width: 12 },
{ header: "AGENT", field: "agentShortId", width: 10 },
{ header: "TOOL", field: "name", width: 20 },
{
@@ -32,6 +32,21 @@ export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
],
};
/** Transform a permission the agent answered to a response item */
export function toPermissionResponseItem(
agentId: string,
permission: AgentPermissionRequest,
result: "allowed" | "denied",
): PermissionResponseItem {
return {
requestId: permission.id,
agentId,
agentShortId: agentId.slice(0, 7),
name: permission.name,
result,
};
}
export type PermitAllowResult = ListResult<PermissionResponseItem>;
export interface PermitAllowOptions extends CommandOptions {
@@ -118,13 +133,7 @@ export async function runAllowCommand(
behavior: "allow",
...(updatedInput ? { updatedInput } : {}),
});
return {
requestId: permission.id.slice(0, 8),
agentId: resolvedAgentId,
agentShortId: resolvedAgentId.slice(0, 7),
name: permission.name,
result: "allowed",
};
return toPermissionResponseItem(resolvedAgentId, permission, "allowed");
}),
);
+6 -8
View File
@@ -2,7 +2,11 @@ import type { Command } from "commander";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, CommandError } from "../../output/index.js";
import { permitResponseSchema, type PermissionResponseItem } from "./allow.js";
import {
permitResponseSchema,
toPermissionResponseItem,
type PermissionResponseItem,
} from "./allow.js";
export type PermitDenyResult = ListResult<PermissionResponseItem>;
@@ -83,13 +87,7 @@ export async function runDenyCommand(
...(options.message ? { message: options.message } : {}),
...(options.interrupt ? { interrupt: true } : {}),
});
return {
requestId: permission.id.slice(0, 8),
agentId: resolvedAgentId,
agentShortId: resolvedAgentId.slice(0, 7),
name: permission.name,
result: "denied",
};
return toPermissionResponseItem(resolvedAgentId, permission, "denied");
}),
);
+19 -17
View File
@@ -18,7 +18,7 @@ export const permitLsSchema: OutputSchema<PermissionListItem> = {
idField: "id",
columns: [
{ header: "AGENT", field: "agentShortId", width: 12 },
{ header: "REQ_ID", field: "id", width: 12 },
{ header: "REQ_ID", field: (item) => item.id.slice(0, 8), width: 12 },
{ header: "TOOL", field: "name", width: 20 },
{ header: "DESCRIPTION", field: "description", width: 50 },
],
@@ -30,7 +30,7 @@ function toListItem(
permission: AgentPermissionRequest,
): PermissionListItem {
return {
id: permission.id.slice(0, 8),
id: permission.id,
agentId: agent.id,
agentShortId: agent.id.slice(0, 7),
name: permission.name,
@@ -40,6 +40,22 @@ function toListItem(
export type PermitLsResult = ListResult<PermissionListItem>;
/** Collect the pending permissions of every agent */
export function listPendingPermissions(agents: AgentSnapshotPayload[]): PermitLsResult {
const items: PermissionListItem[] = [];
for (const agent of agents) {
for (const permission of agent.pendingPermissions ?? []) {
items.push(toListItem(agent, permission));
}
}
return {
type: "list",
data: items,
schema: permitLsSchema,
};
}
export interface PermitLsOptions extends CommandOptions {
host?: string;
}
@@ -55,21 +71,7 @@ export async function runLsCommand(
const agents = agentsPayload.entries.map((entry) => entry.agent);
await client.close();
// Collect all pending permissions from all agents
const items: PermissionListItem[] = [];
for (const agent of agents) {
if (agent.pendingPermissions && agent.pendingPermissions.length > 0) {
for (const permission of agent.pendingPermissions) {
items.push(toListItem(agent, permission));
}
}
}
return {
type: "list",
data: items,
schema: permitLsSchema,
};
return listPendingPermissions(agents);
} catch (err) {
await client.close().catch(() => {});
const message = err instanceof Error ? err.message : String(err);
@@ -0,0 +1,45 @@
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { describe, expect, it } from "vitest";
import { render } from "../../output/index.js";
import { permitResponseSchema, toPermissionResponseItem } from "./allow.js";
import { listPendingPermissions } from "./ls.js";
const agentId = "2d85f428-fdaf-4d98-906f-c583de2b805f";
function permissionRequest(id: string): AgentPermissionRequest {
return { id, provider: "claude", name: "Write", kind: "tool" };
}
const writeRequest = permissionRequest("permission-cc93510e-a45d-4c4e-a0bf-fc2821f82783");
const editRequest = permissionRequest("permission-654ff862-b312-4f6e-8aac-427421ff65eb");
const agent = {
id: agentId,
pendingPermissions: [writeRequest, editRequest],
} as AgentSnapshotPayload;
describe("permit ls output", () => {
it("emits full request ids that permit allow accepts", () => {
const result = listPendingPermissions([agent]);
const listed = JSON.parse(render(result, { format: "json" })) as Array<{ id: string }>;
expect(listed.map((item) => item.id)).toEqual([writeRequest.id, editRequest.id]);
expect(render(result, { quiet: true }).split("\n")).toEqual([writeRequest.id, editRequest.id]);
});
});
describe("permit allow and deny output", () => {
it("emits the full id of the request that was answered", () => {
const result = {
type: "list" as const,
data: [toPermissionResponseItem(agentId, writeRequest, "allowed")],
schema: permitResponseSchema,
};
const answered = JSON.parse(render(result, { format: "json" })) as Array<{
requestId: string;
}>;
expect(answered.map((item) => item.requestId)).toEqual([writeRequest.id]);
});
});
+11 -4
View File
@@ -52,13 +52,20 @@ export function buildDaemonConnectionCommandError(options: ConnectOptions & { er
return {
code,
message: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`,
details:
options.target.kind === "instance"
? `Start with: paseo daemon start --home ${JSON.stringify(options.target.home)}`
: "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.",
details: describeConnectionRemedy(code, options.target),
};
}
function describeConnectionRemedy(code: string, target: DaemonTarget): string {
if (code === "AUTH_REQUIRED")
return "The daemon requires a password. Set PASEO_PASSWORD and retry.";
if (code === "AUTH_FAILED")
return "The daemon rejected the password. Check PASEO_PASSWORD and retry.";
if (target.kind === "instance")
return `Start with: paseo daemon start --home ${JSON.stringify(target.home)}`;
return "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.";
}
export function normalizeDaemonHost(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) {
@@ -0,0 +1,51 @@
#!/usr/bin/env npx tsx
import assert from "node:assert";
import { runLocalPaseo } from "./helpers/local-cli.ts";
import { startTestDaemon } from "./helpers/test-daemon.ts";
console.log("=== Daemon Auth Command Errors ===\n");
const daemon = await startTestDaemon({
env: { PASEO_PASSWORD: "shared-secret" },
});
async function lsError(password: string) {
const result = await runLocalPaseo(["ls", "--json"], {
PASEO_HOME: daemon.paseoHome,
PASEO_HOST: "",
PASEO_PASSWORD: password,
});
assert.notStrictEqual(result.exitCode, 0, "ls should fail without a valid password");
return JSON.parse(result.stderr).error as {
code: string;
message: string;
details: string;
};
}
try {
{
console.log("Test 1: missing password asks for PASEO_PASSWORD, not a daemon start");
const error = await lsError("");
assert.strictEqual(error.code, "AUTH_REQUIRED");
assert.match(error.message, /Password required/);
assert.match(error.details, /PASEO_PASSWORD/);
assert.doesNotMatch(error.details, /daemon start/);
console.log("✓ missing password points at PASEO_PASSWORD\n");
}
{
console.log("Test 2: wrong password asks for PASEO_PASSWORD, not a daemon start");
const error = await lsError("wrong-secret");
assert.strictEqual(error.code, "AUTH_FAILED");
assert.match(error.message, /Incorrect password/);
assert.match(error.details, /PASEO_PASSWORD/);
assert.doesNotMatch(error.details, /daemon start/);
console.log("✓ wrong password points at PASEO_PASSWORD\n");
}
} finally {
await daemon.stop();
}
console.log("=== Daemon Auth Command Errors Tests Passed ===");
@@ -89,6 +89,75 @@ async function createMcpClient(url: string, authToken?: string): Promise<McpClie
return { callTool: boundCallTool, close: () => rawClient.close() };
}
interface OfflineMcpDaemon {
client: McpClient;
stop: () => Promise<void>;
}
async function startOfflineMcpDaemon(): Promise<OfflineMcpDaemon> {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
const removeDirectories = async () => {
await rm(paseoHome, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
};
const port = await getAvailablePort();
const daemon = await createPaseoDaemon(
{
listen: `127.0.0.1:${port}`,
paseoHome,
corsAllowedOrigins: [],
hostnames: true,
mcpEnabled: true,
staticDir,
mcpDebug: false,
agentClients: createTestAgentClients(),
agentStoragePath: path.join(paseoHome, "agents"),
},
pino({ level: "silent" }),
).catch(async (error: unknown) => {
await removeDirectories();
throw error;
});
try {
await daemon.start();
const client = await createMcpClient(`http://127.0.0.1:${port}/mcp/agents`);
return {
client,
stop: async () => {
await client.close();
await daemon.stop();
await removeDirectories();
},
};
} catch (error) {
await daemon.stop();
await removeDirectories();
throw error;
}
}
type WorkspaceCreation = { workspaceId: unknown } | { error: unknown };
async function createLocalWorkspace(client: McpClient, cwd: string): Promise<WorkspaceCreation> {
const result = await client.callTool({
name: "create_workspace",
args: { isolation: "local", path: cwd },
});
if (result.isError) {
const content = result.content?.[0];
return { error: content && "text" in content ? content.text : undefined };
}
return { workspaceId: getStructuredContent(result)?.workspaceId };
}
async function listWorkspaces(client: McpClient): Promise<WorkspaceCreation[]> {
const result = await client.callTool({ name: "list_workspaces", args: {} });
const workspaces = getStructuredContent(result)?.workspaces;
if (!Array.isArray(workspaces)) return [];
return workspaces.map((workspace: StructuredContent) => ({ workspaceId: workspace.workspaceId }));
}
interface LaunchRecorder {
recordedLaunches: AgentSessionConfig[];
}
@@ -235,6 +304,27 @@ describe("agent MCP end-to-end (offline)", () => {
}
}, 30_000);
test("create_workspace with local isolation adopts only an existing directory", async () => {
const daemon = await startOfflineMcpDaemon();
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-local-workspace-"));
const missingPath = path.join(root, "does-not-exist");
const filePath = path.join(root, "regular-file");
await writeFile(filePath, "not a directory\n", "utf8");
try {
expect(await createLocalWorkspace(daemon.client, missingPath)).toEqual({
error: expect.stringContaining(`Directory not found: ${missingPath}`),
});
expect(await createLocalWorkspace(daemon.client, filePath)).toEqual({
error: expect.stringContaining(`Directory not found: ${filePath}`),
});
const created = await createLocalWorkspace(daemon.client, root);
expect(await listWorkspaces(daemon.client)).toEqual([created]);
} finally {
await daemon.stop();
await rm(root, { recursive: true, force: true });
}
}, 30_000);
test("password-protected daemon authorizes the agent MCP via the capability token", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-home-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-static-"));
@@ -1,3 +1,4 @@
import { stat } from "node:fs/promises";
import { z } from "zod";
import { ensureValidJson } from "../../json-utils.js";
import type { Logger } from "pino";
@@ -223,6 +224,20 @@ function assertOptionsAbsent(
}
}
async function isExistingDirectory(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory();
} catch (error) {
if (isMissingPathError(error)) return false;
throw error;
}
}
function isMissingPathError(error: unknown): boolean {
if (typeof error !== "object" || error === null || !("code" in error)) return false;
return error.code === "ENOENT" || error.code === "ENOTDIR";
}
function resolveWorkspaceWorktreeTarget(input: WorkspaceWorktreeOptions): WorkspaceWorktreeTarget {
switch (input.mode ?? "branch-off") {
case "branch-off":
@@ -1225,7 +1240,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
path: z
.string()
.optional()
.describe("Local directory or source checkout. Defaults to your current workspace."),
.describe(
"Local directory or source checkout. Defaults to your current workspace. Local isolation adopts an existing directory and never creates one.",
),
projectId: z.string().optional().describe("Existing project id to own the workspace."),
title: z.string().trim().min(1).optional(),
mode: z
@@ -1277,6 +1294,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
let workspace: PersistedWorkspaceRecord;
if (isolation === "local") {
const cwd = resolveScopedCwd(path, { required: true });
if (!(await isExistingDirectory(cwd))) {
throw new Error(`Directory not found: ${cwd}`);
}
assertOptionsAbsent(
[
["mode", mode],
@@ -1,6 +1,6 @@
import { existsSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { basename, dirname, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
@@ -56,6 +56,49 @@ describe("file uploads", () => {
expect(readFileSync(path, "utf8")).toBe("hello world");
});
it("keeps the original file name for non-ASCII and punctuated names", async () => {
const uploads = new FileUploadStore({ paseoHome: makePaseoHome() });
for (const fileName of [
"2026年9月绩效计划表.xlsx",
"테스트 파일 (1).xlsx",
"résumé [final] & notes, v2.pdf",
"cafe\u0301 हिंदी.txt",
]) {
const file = await uploadNamed(uploads, fileName);
expect(file?.fileName).toBe(fileName);
expect(basename(file!.path)).toBe(fileName);
expect(readFileSync(file!.path, "utf8")).toBe("hello world");
}
});
it("replaces path separators, control characters, and characters Windows rejects", async () => {
const uploads = new FileUploadStore({ paseoHome: makePaseoHome() });
await expect(uploadNamed(uploads, "../../etc/passwd")).resolves.toMatchObject({
fileName: "passwd",
});
const backslashed = await uploadNamed(uploads, "dir\\name.txt");
expect(backslashed?.fileName).not.toContain("\\");
expect(backslashed?.fileName).toMatch(/name\.txt$/);
await expect(uploadNamed(uploads, 'a<b>:"c|?*.txt')).resolves.toMatchObject({
fileName: "a_b___c___.txt",
});
await expect(uploadNamed(uploads, "line\nbreak.txt")).resolves.toMatchObject({
fileName: "line_break.txt",
});
});
it("shortens a long non-ASCII name to the file system limit and keeps its extension", async () => {
const uploads = new FileUploadStore({ paseoHome: makePaseoHome() });
const file = await uploadNamed(uploads, `${"绩".repeat(100)}.xlsx`);
expect(file?.fileName).toBe(`${"绩".repeat(83)}.xlsx`);
expect(Buffer.byteLength(file!.fileName)).toBeLessThanOrEqual(255);
expect(readFileSync(file!.path, "utf8")).toBe("hello world");
});
it("rejects chunks beyond the declared size and removes the partial file", async () => {
const paseoHome = makePaseoHome();
const uploads = new FileUploadStore({ paseoHome });
@@ -202,6 +245,25 @@ describe("file uploads", () => {
});
});
let uploadCount = 0;
async function uploadNamed(uploads: FileUploadStore, fileName: string) {
const requestId = `req-named-${uploadCount++}`;
uploads.beginUpload({
type: "file.upload.request",
fileName,
mimeType: "text/plain",
size: 11,
modifiedAt: "2026-05-02T00:00:00.000Z",
requestId,
});
await uploads.receiveFrame(uploadBegins(requestId));
await uploads.receiveFrame(uploadChunk(requestId, "hello world"));
const response = await uploads.receiveFrame(uploadEnds(requestId));
expect(response?.payload.error).toBeNull();
return response?.payload.file;
}
function makePaseoHome(): string {
const root = realpathSync(mkdtempSync(join(tmpdir(), "file-upload-test-")));
tempDirs.push(root);
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { appendFile, mkdir, rm, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";
import { basename, extname, join } from "node:path";
import { FileTransferOpcode, type FileTransferFrame } from "@getpaseo/protocol/binary-frames/index";
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
@@ -220,9 +220,26 @@ function buildUploadResponse(upload: PendingUpload, error: string | null): FileU
};
}
// Most file systems cap a single file name at 255 bytes.
const MAX_FILE_NAME_BYTES = 255;
// Keeps the client's file name, replacing only what cannot appear in a single
// file name on Linux, macOS, or Windows.
function sanitizeFileName(value: string): string {
const name = basename(value)
.replace(/[^a-zA-Z0-9._ -]/g, "_")
.replace(/[\p{Cc}\\/:*?"<>|]/gu, "_")
.trim();
return name.length > 0 && name !== "." && name !== ".." ? name : "upload";
return fitFileNameLength(name.length > 0 && name !== "." && name !== ".." ? name : "upload");
}
function fitFileNameLength(name: string): string {
if (Buffer.byteLength(name) <= MAX_FILE_NAME_BYTES) return name;
const extension = extname(name);
const keptExtension = Buffer.byteLength(extension) < MAX_FILE_NAME_BYTES ? extension : "";
let stem = "";
for (const char of name.slice(0, name.length - keptExtension.length)) {
if (Buffer.byteLength(stem + char + keptExtension) > MAX_FILE_NAME_BYTES) break;
stem += char;
}
return stem + keptExtension;
}
@@ -6,6 +6,7 @@ import { describe, expect, test } from "vitest";
import {
loadPersistedConfig,
PersistedConfigSchema,
readPersistedConfig,
savePersistedConfig,
} from "./persisted-config.js";
import { PRIVATE_DIRECTORY_MODE, PRIVATE_FILE_MODE } from "./private-files.js";
@@ -736,6 +737,34 @@ describe("loadPersistedConfig", () => {
});
});
describe("config.json saved with a UTF-8 byte order mark", () => {
// Windows Notepad writes this shape: a BOM, then CRLF line endings.
const notepadConfig =
'{\r\n "version": 1,\r\n "daemon": { "listen": "127.0.0.1:6767" }\r\n}\r\n';
test("loadPersistedConfig reads it", () => {
const home = createTempHome();
try {
writeFileSync(path.join(home, "config.json"), notepadConfig);
expect(loadPersistedConfig(home).daemon?.listen).toBe("127.0.0.1:6767");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test("readPersistedConfig reads it", () => {
const home = createTempHome();
try {
writeFileSync(path.join(home, "config.json"), notepadConfig);
expect(readPersistedConfig(home).daemon?.listen).toBe("127.0.0.1:6767");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});
describe.skipIf(process.platform === "win32")("persisted config file permissions", () => {
test("initializes config.json with private permissions", () => {
const home = createTempHome();
@@ -438,7 +438,7 @@ export function loadPersistedConfig(paseoHome: string, logger?: LoggerLike): Per
let parsed: unknown;
try {
parsed = JSON.parse(raw);
parsed = parseConfigText(raw);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
throw new Error(`[Config] Invalid JSON in ${configPath}: ${message}`, {
@@ -472,7 +472,14 @@ export function readPersistedConfig(
return options.defaultsIfMissing ? structuredClone(DEFAULT_PERSISTED_CONFIG) : {};
throw error;
}
return PersistedConfigSchema.parse(stripRemovedConfigFields(JSON.parse(raw))) as PersistedConfig;
return PersistedConfigSchema.parse(
stripRemovedConfigFields(parseConfigText(raw)),
) as PersistedConfig;
}
/** Editors such as Windows Notepad save UTF-8 with a byte order mark, which JSON.parse rejects. */
function parseConfigText(raw: string): unknown {
return JSON.parse(raw.replace(/^\uFEFF/, ""));
}
function configPathParts(field: string): string[] {
+35 -1
View File
@@ -1,4 +1,4 @@
import { mkdtemp, open, rm, stat, utimes, writeFile } from "node:fs/promises";
import { mkdtemp, open, readFile, rm, stat, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
@@ -256,4 +256,38 @@ describe("pid-lock ownership", () => {
await rm(paseoHome, { recursive: true, force: true });
}
});
test("starts over an empty lock file left by a supervisor killed before writing it", async () => {
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-empty-"));
const ownerPid = process.pid + 10_000;
try {
await writeFile(join(paseoHome, "paseo.pid"), "");
await expect(getPidLockInfo(paseoHome)).resolves.toBeNull();
await acquirePidLock(paseoHome, null, { ownerPid });
const lock = await getPidLockInfo(paseoHome);
expect(lock?.pid).toBe(ownerPid);
} finally {
await rm(paseoHome, { recursive: true, force: true });
}
});
test("keeps a lock file whose contents cannot be read as a lock", async () => {
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-unparseable-"));
const pidPath = join(paseoHome, "paseo.pid");
try {
await writeFile(pidPath, JSON.stringify({ pid: "unknown" }));
await expect(
acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }),
).rejects.toThrow("Cannot read daemon state");
await expect(readFile(pidPath, "utf-8")).resolves.toBe(JSON.stringify({ pid: "unknown" }));
} finally {
await rm(paseoHome, { recursive: true, force: true });
}
});
});
+22 -4
View File
@@ -1,4 +1,4 @@
import { open, readFile, unlink, utimes } from "node:fs/promises";
import { open, readFile, stat, unlink, utimes } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises";
import { ensurePrivateDirectory } from "./private-files.js";
import { join } from "node:path";
@@ -84,18 +84,26 @@ async function touchPidLockFile(pidPath: string): Promise<void> {
async function readPidLock(pidPath: string): Promise<PidLockInfo | null> {
let lastError: unknown;
let empty = false;
for (let attempt = 0; attempt < PID_LOCK_READ_RETRY_ATTEMPTS; attempt++) {
try {
const content = await readFile(pidPath, "utf-8");
const lock = parsePidLockInfo(JSON.parse(content));
if (lock) return lock;
lastError = new Error("Invalid lock shape");
empty = content === "";
if (!empty) {
const lock = parsePidLockInfo(JSON.parse(content));
if (lock) return lock;
lastError = new Error("Invalid lock shape");
}
} catch (error) {
if (isErrnoException(error) && error.code === "ENOENT") return null;
empty = false;
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, PID_LOCK_READ_RETRY_DELAY_MS));
}
// A supervisor writes its lock right after creating the file, so a file still empty
// after the retries was abandoned in between and names no owner.
if (empty) return null;
throw Object.assign(
new PidLockError(`Cannot read daemon state at ${pidPath}: ${String(lastError)}`),
{ code: "DAEMON_STATE_READ_FAILED" },
@@ -149,6 +157,14 @@ async function clearExistingPidLock(
return "cleared";
}
async function removeEmptyPidLock(pidPath: string): Promise<void> {
try {
if ((await stat(pidPath)).size === 0) await unlink(pidPath);
} catch (error) {
if (!isErrnoException(error) || error.code !== "ENOENT") throw error;
}
}
async function writeNewPidLock(pidPath: string, lockInfo: PidLockInfo): Promise<void> {
let fd;
try {
@@ -191,6 +207,8 @@ export async function acquirePidLock(
if (result === "already_owned") {
return;
}
} else {
await removeEmptyPidLock(pidPath);
}
// Create new lock with exclusive flag