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
11 changed files with 489 additions and 27 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",
);
});
});
@@ -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[] {