Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 8e94a5731b test(website): reproduce plugin search dropping a term typed before hydration
Typing in the plugin search box before the page's scripts run leaves the
term in the box but not in React state, so Enter searches without it.
2026-10-07 09:25:30 +02:00
paseo-bot[bot] f13bb2136c fix(server): keep accented names on downloaded files (#6281)
A file name that fits in Latin-1, like café.txt, went out as a raw byte in
Content-Disposition filename= with no filename* parameter, and Chromium saved
the file under a name taken from the URL (download.txt). Build the header so
every non-ASCII name carries filename*=UTF-8''… with an ASCII fallback.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 08:57:29 +02:00
paseo-bot[bot] 2323ee6844 Keep reporting a Linux daemon as running after its VM resumes from a host sleep (#6279)
* Keep reporting a Linux daemon as running after its VM resumes from a host sleep

A VM paused while its host sleeps keeps its uptime, but its wall clock
catches up on resume. The boot instant derived from the two then lands
after the running supervisor's lock, so daemon status reported it as
stopped and daemon stop released its lock without stopping it.

The lock now records Linux's boot id, and a lock that carries one is
judged by it. Locks without one keep the wall-clock check.

Closes #6019

* Give the OMP idle-deadline test a deadline a slow runner cannot pass before the first wait

With a 1 ms deadline, the gate's first check, made synchronously when the
turn ends, could already be past it on a loaded Windows runner. The turn
then failed before the test's first scheduler wait, and the test timed out.

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 07:51:12 +02:00
1e00f452ea Download files whose names have non-ASCII characters (#6278)
* fix(server): encode Unicode download filenames in Content-Disposition

* test(server): cover Korean download filenames

* test(server): validate download token and agent ID formats

* fix(server): set the download header through Express instead of a new dependency

The download route already runs on an Express response, and res.attachment()
builds Content-Disposition with the same content-disposition encoder that
Express ships. Use it and drop the direct content-disposition dependency and
its type package. The header values the download tests assert are unchanged.

Co-authored-by: jasonhnd <3293256+jasonhnd@users.noreply.github.com>

---------

Co-authored-by: Dillon <dillonzq@outlook.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: jasonhnd <3293256+jasonhnd@users.noreply.github.com>
2026-10-07 06:59:53 +02:00
paseo-bot[bot] a162e1075f fix(acp): apply configured models the agent does not advertise (#6273)
* fix(acp): apply configured models the agent does not advertise

A model added through a provider's models or additionalModels was listed
in the picker, but an ACP session only accepted ids the agent advertised
in session/new, so the agent silently ran on its default model. The
registry now passes configured model ids to the session, which applies
them through session/set_model. Unconfigured, unadvertised ids are still
skipped with a warning.

* fix(acp): send configured models when the agent advertises none

Also covers the resume path in the regression test.

* test(acp): keep configured-model tests in the ACP agent suite

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-07 06:15:19 +02:00
12 changed files with 422 additions and 60 deletions
@@ -628,6 +628,11 @@ export interface AgentCreateSessionOptions {
* Defaults to true. Providers that cannot honor false should no-op.
*/
persistSession?: boolean;
/**
* Model ids added by provider configuration (`models` / `additionalModels`).
* Providers that validate against runtime-advertised models must accept these.
*/
configuredModelIds?: readonly string[];
}
/** What a resumed session is for: driving the agent, or reading what it already did. */
@@ -637,6 +642,8 @@ export type AgentResumePurpose = "interactive" | "history";
export interface AgentResumeSessionOptions {
/** Defaults to interactive. History loading may be read-only for archived native sessions. */
purpose?: AgentResumePurpose;
/** See AgentCreateSessionOptions.configuredModelIds. */
configuredModelIds?: readonly string[];
}
/**
@@ -492,6 +492,7 @@ function wrapClientProvider(
const listFeatures = inner.listFeatures?.bind(inner);
const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id);
return {
provider,
@@ -509,7 +510,7 @@ function wrapClientProvider(
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
},
launchContext,
options,
{ ...options, configuredModelIds },
),
),
resumeSession: async (handle, overrides, launchContext, options) =>
@@ -526,7 +527,7 @@ function wrapClientProvider(
providerOptions: mergeProviderOptions(providerOptions, overrides?.providerOptions),
},
launchContext,
options,
{ ...options, configuredModelIds },
),
),
fetchCatalog: async (options, context) => {
@@ -5,7 +5,7 @@ import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, test, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
AgentSideConnection,
ClientSideConnection,
@@ -54,8 +54,10 @@ import type { AgentStreamEvent } from "../agent-sdk-types.js";
import type {
AgentCapabilityFlags,
AgentPersistenceHandle,
AgentSession,
ProviderRefreshContext,
} from "../agent-sdk-types.js";
import { buildProviderRegistry } from "../provider-registry.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { buildStringCommandShellInvocation } from "../../../utils/string-command-shell.js";
import { asInternals } from "../../test-utils/class-mocks.js";
@@ -4370,3 +4372,151 @@ describe("ACPAgentSession close() with an unresponsive provider", () => {
expect(isProcessAlive(pid)).toBe(false);
}, 15_000);
});
// An ACP agent that advertises two models (or none, with ACP_ADVERTISE_MODELS=none) but,
// like Cline, runs any model id it is given.
const PERMISSIVE_MODEL_ACP_AGENT = `
import { Readable, Writable } from "node:stream";
const { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } = await import(process.env.ACP_SDK_URL);
let currentModelId = "advertised-default";
const modelState = () =>
process.env.ACP_ADVERTISE_MODELS === "none"
? undefined
: {
currentModelId,
availableModels: [
{ modelId: "advertised-default", name: "Advertised default" },
{ modelId: "advertised-other", name: "Advertised other" },
],
};
new AgentSideConnection(
() => ({
async initialize() {
return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: { loadSession: true } };
},
async newSession() {
return { sessionId: "permissive-session", models: modelState() };
},
async loadSession() {
return { models: modelState() };
},
async unstable_setSessionModel({ modelId }) {
currentModelId = modelId;
return {};
},
async authenticate() {},
async cancel() {},
async prompt() {
return { stopReason: "end_turn" };
},
}),
ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)),
);
`;
describe("custom ACP provider with configured models", () => {
let dir: string;
let agentScript: string;
let session: AgentSession | null = null;
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-configured-models-"));
agentScript = path.join(dir, "agent.mjs");
await writeFile(agentScript, PERMISSIVE_MODEL_ACP_AGENT);
});
afterEach(async () => {
await session?.close();
session = null;
await rm(dir, { recursive: true, force: true });
});
function buildRegistry(
override: {
models?: Array<{ id: string; label: string }>;
additionalModels?: Array<{ id: string; label: string }>;
},
agentEnv: Record<string, string> = {},
) {
return buildProviderRegistry(createTestLogger(), {
providerOverrides: {
permissive: {
extends: "acp",
label: "Permissive",
command: [process.execPath, agentScript],
env: {
ACP_SDK_URL: pathToFileURL(
createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"),
).href,
...agentEnv,
},
...override,
},
},
});
}
async function createSessionOnModel(
registry: ReturnType<typeof buildRegistry>,
model: string,
): Promise<AgentSession> {
const client = registry.permissive.createClient(createTestLogger());
session = await client.createSession({ provider: "permissive", cwd: dir, model });
return session;
}
test("runs on a model added through additionalModels that the agent does not advertise", async () => {
const registry = buildRegistry({
additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }],
});
const created = await createSessionOnModel(registry, "stealth/pixel-canary");
expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary");
});
test("runs on a model from a replacement models list that the agent does not advertise", async () => {
const registry = buildRegistry({
models: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }],
});
const created = await createSessionOnModel(registry, "stealth/pixel-canary");
expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary");
});
test("runs on a model from additionalModels when the agent advertises no models", async () => {
const registry = buildRegistry(
{ additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }] },
{ ACP_ADVERTISE_MODELS: "none" },
);
const created = await createSessionOnModel(registry, "stealth/pixel-canary");
expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary");
});
test("resumes on a model added through additionalModels that the agent does not advertise", async () => {
const registry = buildRegistry({
additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }],
});
const client = registry.permissive.createClient(createTestLogger());
session = await client.resumeSession(
{ provider: "permissive", sessionId: "permissive-session", metadata: { cwd: dir } },
{ model: "stealth/pixel-canary" },
);
expect((await session.getRuntimeInfo()).model).toBe("stealth/pixel-canary");
});
test("keeps the agent's model when the requested model is neither advertised nor configured", async () => {
const registry = buildRegistry({
additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }],
});
const created = await createSessionOnModel(registry, "stale/removed-model");
expect((await created.getRuntimeInfo()).model).toBe("advertised-default");
});
});
@@ -77,6 +77,7 @@ import {
type AgentPromptContentBlock,
type AgentPromptInput,
type AgentResumePurpose,
type AgentCreateSessionOptions,
type AgentResumeSessionOptions,
type AgentRunOptions,
type AgentRunResult,
@@ -512,6 +513,7 @@ interface ACPAgentSessionOptions {
extensionCommandsParser?: ACPExtensionCommandsParser;
handle?: AgentPersistenceHandle;
resumePurpose?: AgentResumePurpose;
configuredModelIds?: readonly string[];
agentId?: string;
launchEnv?: Record<string, string>;
waitForInitialCommands?: boolean;
@@ -979,6 +981,7 @@ export class ACPAgentClient implements AgentClient {
async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
this.assertProvider(config);
const providerOptions = ACPProviderOptionsSchema.parse(config.providerOptions ?? {});
@@ -1006,6 +1009,7 @@ export class ACPAgentClient implements AgentClient {
supportsMcpServers:
providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers,
},
configuredModelIds: options?.configuredModelIds,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
extensionCommandsParser: this.extensionCommandsParser,
@@ -1064,6 +1068,7 @@ export class ACPAgentClient implements AgentClient {
},
handle,
resumePurpose: options?.purpose,
configuredModelIds: options?.configuredModelIds,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
extensionCommandsParser: this.extensionCommandsParser,
@@ -1692,6 +1697,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
configOptions: SessionConfigOption[],
) => SessionConfigOption[];
private readonly configFeatureOptions: ACPConfigFeatureOption[];
private readonly configuredModelIds: ReadonlySet<string>;
private readonly clientCapabilities?: ACPClientCapabilities;
private readonly clientCapabilityMeta?: ACPClientCapabilityMeta;
private readonly modeIdTransformer?: (modeId: string) => string | null;
@@ -1760,6 +1766,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.sessionResponseTransformer = options.sessionResponseTransformer;
this.configOptionsTransformer = options.configOptionsTransformer;
this.configFeatureOptions = options.configFeatureOptions ?? [];
this.configuredModelIds = new Set(options.configuredModelIds);
this.clientCapabilities = options.clientCapabilities;
this.clientCapabilityMeta = options.clientCapabilityMeta;
this.modeIdTransformer = options.modeIdTransformer;
@@ -2194,12 +2201,20 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return;
}
await this.setModelWithSelection({ modelId, selection: this.resolveModelSelection(modelId) });
}
private resolveModelSelection(modelId: string): ACPModelSelection {
const selection = resolveACPModelSelection({
modelId,
availableModels: this.availableModels,
configOptions: this.configOptions,
});
await this.setModelWithSelection({ modelId, selection });
if (selection.availableModel || !this.configuredModelIds.has(modelId)) {
return selection;
}
// A model the user added in provider config is applied even when the agent does not advertise it.
return { ...selection, availableModel: { modelId, name: modelId } };
}
private async setModelWithSelection({
@@ -2213,21 +2228,20 @@ export class ACPAgentSession implements AgentSession, ACPClient {
throw new Error("ACP session not initialized");
}
if (selection.hasAvailableModels) {
if (!selection.availableModel) {
this.warnInvalidSelection(
modelId,
`is not a valid ${this.provider} model. Available options: ${this.availableModels
?.map((model) => model.modelId)
.join(", ")}`,
);
return;
}
if (typeof this.connection.unstable_setSessionModel !== "function") {
throw new Error(this.modelSelectionUnavailableMessage());
}
if (selection.hasAvailableModels && !selection.availableModel) {
this.warnInvalidSelection(
modelId,
`is not a valid ${this.provider} model. Available options: ${this.availableModels
?.map((model) => model.modelId)
.join(", ")}`,
);
return;
}
if (
selection.availableModel &&
typeof this.connection.unstable_setSessionModel === "function"
) {
try {
await this.connection.unstable_setSessionModel({
sessionId: this.sessionId,
@@ -2922,13 +2936,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
const configuredModelId = this.config.model;
let switchedModel = false;
if (configuredModelId && configuredModelId !== this.currentModel) {
const selection = resolveACPModelSelection({
modelId: configuredModelId,
availableModels: this.availableModels,
configOptions: this.configOptions,
});
try {
await this.setModelWithSelection({ modelId: configuredModelId, selection });
await this.setModelWithSelection({
modelId: configuredModelId,
selection: this.resolveModelSelection(configuredModelId),
});
switchedModel = true;
} catch (error) {
if (!this.isModelSelectionUnavailableError(error)) {
@@ -459,7 +459,9 @@ describe("OMP agent client and session", () => {
test("fails a turn when the provider idle gate passes its deadline", async () => {
const scheduler = new ManualIdleScheduler();
const omp = new OmpHarness({ providerIdleScheduler: scheduler, providerIdleDeadlineMs: 1 });
// Long enough that the gate's first check, made right after the turn ends, cannot already
// be past it on a slow runner.
const omp = new OmpHarness({ providerIdleScheduler: scheduler, providerIdleDeadlineMs: 50 });
await omp.start();
const { completion } = await omp.startPromptUntilProviderIdle("first", "first done", {
isStreaming: true,
@@ -473,7 +475,7 @@ describe("OMP agent client and session", () => {
args: { command: "sleep 30" },
});
expect(omp.runningToolCallIds()).toEqual(["tool-at-deadline"]);
await new Promise((resolve) => setTimeout(resolve, 2));
await new Promise((resolve) => setTimeout(resolve, 60));
scheduler.retry();
await expect(completion).rejects.toThrow(/provider idle/i);
expect(omp.runningToolCallIds()).toEqual([]);
+2 -2
View File
@@ -126,6 +126,7 @@ import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-wo
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
import { createPaseoWorktreeWorkflow } from "./worktree-session.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import { formatAttachmentContentDisposition } from "./file-download/content-disposition.js";
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
@@ -836,9 +837,8 @@ export async function createPaseoDaemon(
return;
}
const safeFileName = entry.fileName.replace(/["\r\n]/g, "_");
res.setHeader("Content-Type", entry.mimeType);
res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`);
res.setHeader("Content-Disposition", formatAttachmentContentDisposition(entry.fileName));
res.setHeader("Content-Length", fileStats.size.toString());
const stream = fileHandle.createReadStream();
@@ -24,42 +24,77 @@ describe("daemon E2E", () => {
}, 60000);
describe("file download tokens", () => {
test("issues token over WS and downloads via HTTP", async () => {
const cwd = tmpCwd();
const filePath = path.join(cwd, "download.txt");
const fileContents = "download test payload";
writeFileSync(filePath, fileContents, "utf-8");
test.each([
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
{
fileName: "café.txt",
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
},
{
fileName: "中文报告 (最终版).txt",
disposition:
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
},
{
fileName: "報告書.md",
disposition:
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
},
{
fileName: "보고서.txt",
disposition:
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
},
{
fileName: "report-📄.txt",
disposition:
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
},
])(
"issues token over WS and downloads $fileName via HTTP",
async ({ fileName, disposition }) => {
const cwd = tmpCwd();
try {
const filePath = path.join(cwd, fileName);
const fileContents = "download test payload";
writeFileSync(filePath, fileContents, "utf-8");
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Download Token Test Agent",
});
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Download Token Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
const tokenResponse = await ctx.client.requestDownloadToken(cwd, "download.txt");
const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName);
expect(tokenResponse.error).toBeNull();
expect(tokenResponse.token).toBeTruthy();
expect(tokenResponse.fileName).toBe("download.txt");
expect(tokenResponse.error).toBeNull();
expect(tokenResponse.token).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
expect(tokenResponse.fileName).toBe(fileName);
const response = await fetch(
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`,
);
const response = await fetch(
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`,
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType);
const disposition = response.headers.get("content-disposition") ?? "";
expect(disposition).toContain("download.txt");
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType);
expect(response.headers.get("content-disposition")).toBe(disposition);
const body = await response.text();
expect(body).toBe(fileContents);
rmSync(cwd, { recursive: true, force: true });
}, 60000);
const body = await response.text();
expect(body).toBe(fileContents);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
60000,
);
test("rejects invalid token", async () => {
const response = await fetch(
@@ -3,7 +3,7 @@ import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir, uptime } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { readDaemonInstance, stopDaemonInstance } from "./daemon-instance.js";
import { acquirePidLock, getPidLockInfo, isLocked, type PidLockInfo } from "./pid-lock.js";
@@ -94,3 +94,42 @@ describe("daemon instance identity across a reboot", () => {
await expect(readFile(join(paseoHome, "paseo.pid"), "utf-8")).rejects.toThrow(/ENOENT/);
});
});
// Linux keeps a boot's id while a VM is paused for a host sleep, though the VM's wall clock
// runs ahead of its uptime once it resumes. A sleep longer than the time between boot and
// the supervisor's start puts the wall-clock boot instant after the lock's startedAt.
describe.runIf(process.platform === "linux")("daemon instance identity on Linux", () => {
let paseoHome: string;
beforeEach(async () => {
paseoHome = await mkdtemp(join(tmpdir(), "paseo-daemon-instance-linux-"));
});
afterEach(async () => {
vi.useRealTimers();
await rm(paseoHome, { recursive: true, force: true });
});
test("a supervisor still holds the lock after its paused VM resumes", async () => {
await acquirePidLock(paseoHome, null, { ownerPid: process.pid });
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(Date.now() + uptime() * 1000 + 12 * 60 * 60_000);
expect(await readDaemonInstance(paseoHome)).toMatchObject({ pid: process.pid });
expect(await isLocked(paseoHome)).toMatchObject({ locked: true });
await expect(
acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }),
).rejects.toThrow("Another Paseo daemon is already running");
});
test("a lock written during another boot has no running owner", async () => {
await writeLock(paseoHome, {
...lockFor(process.pid, new Date()),
bootId: "00000000-0000-0000-0000-000000000000",
});
expect(await readDaemonInstance(paseoHome)).toBeNull();
expect(await isLocked(paseoHome)).toMatchObject({ locked: false });
});
});
@@ -0,0 +1,42 @@
import { describe, expect, test } from "vitest";
import { formatAttachmentContentDisposition } from "./content-disposition.js";
describe("formatAttachmentContentDisposition", () => {
test.each([
{ fileName: "report.txt", disposition: 'attachment; filename="report.txt"' },
{
fileName: "café.txt",
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
},
{
fileName: "Résumé Ångström.pdf",
disposition:
"attachment; filename=\"R?sum? ?ngstr?m.pdf\"; filename*=UTF-8''R%C3%A9sum%C3%A9%20%C3%85ngstr%C3%B6m.pdf",
},
{
fileName: "中文报告 (最终版).txt",
disposition:
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
},
{
fileName: "report-📄.txt",
disposition:
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
},
{
fileName: 'say "hi" \\ bye.txt',
disposition: 'attachment; filename="say \\"hi\\" \\\\ bye.txt"',
},
{
fileName: "100%25 done.txt",
disposition: "attachment; filename=\"100%25 done.txt\"; filename*=UTF-8''100%2525%20done.txt",
},
{
fileName: "l'été (1)*.txt",
disposition:
"attachment; filename=\"l'?t? (1)*.txt\"; filename*=UTF-8''l%27%C3%A9t%C3%A9%20%281%29%2A.txt",
},
])("$fileName", ({ fileName, disposition }) => {
expect(formatAttachmentContentDisposition(fileName)).toBe(disposition);
});
});
@@ -0,0 +1,32 @@
const NON_PRINTABLE_ASCII = /[^\x20-\x7e]/g;
const PERCENT_ESCAPE = /%[0-9A-Fa-f]{2}/;
const QUOTED_STRING_SPECIALS = /[\\"]/g;
const NON_ATTR_CHARS_LEFT_BY_URI_ENCODING = /['()*]/g;
/**
* Content-Disposition for a file download (RFC 6266).
*
* Any name that is not plain ASCII also gets `filename*=UTF-8''…`, because
* browsers do not read raw Latin-1 bytes in `filename` and fall back to a name
* taken from the URL. A name with a percent escape gets it too, so browsers do
* not decode the escape in `filename`.
*/
export function formatAttachmentContentDisposition(fileName: string): string {
const asciiFileName = fileName.replace(NON_PRINTABLE_ASCII, "?");
const disposition = `attachment; filename=${quote(asciiFileName)}`;
if (asciiFileName === fileName && !PERCENT_ESCAPE.test(fileName)) {
return disposition;
}
return `${disposition}; filename*=UTF-8''${encodeExtendedValue(fileName)}`;
}
function quote(value: string): string {
return `"${value.replace(QUOTED_STRING_SPECIALS, "\\$&")}"`;
}
function encodeExtendedValue(value: string): string {
return encodeURIComponent(value).replace(
NON_ATTR_CHARS_LEFT_BY_URI_ENCODING,
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
);
}
+28 -2
View File
@@ -1,3 +1,4 @@
import { readFileSync } from "node:fs";
import { open, readFile, stat, unlink, utimes } from "node:fs/promises";
import type { FileHandle } from "node:fs/promises";
import { ensurePrivateDirectory } from "./private-files.js";
@@ -15,6 +16,7 @@ export const pidLockInfoSchema = z.object({
serverId: z.string().nullable().optional(),
desktopManaged: z.boolean().optional(),
heartbeat: z.literal(true).optional(),
bootId: z.string().optional(),
});
export interface PidLockInfo extends z.infer<typeof pidLockInfoSchema> {}
@@ -62,16 +64,38 @@ function precedesThisBoot(startedAt: string): boolean {
return stamped < Date.now() - uptime() * 1000 - BOOT_INSTANT_TOLERANCE_MS;
}
let cachedBootId: string | null | undefined;
// Linux names each boot. The wall-clock boot instant is not enough there: a VM paused while
// its host sleeps keeps its uptime, so after it resumes the instant lands after locks its
// running supervisor wrote.
function currentBootId(): string | null {
if (cachedBootId === undefined) {
try {
cachedBootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf-8").trim() || null;
} catch {
cachedBootId = null;
}
}
return cachedBootId;
}
function writtenDuringThisBoot(lock: PidLockInfo): boolean {
const thisBoot = currentBootId();
if (lock.bootId && thisBoot) return lock.bootId === thisBoot;
return !precedesThisBoot(lock.startedAt);
}
/**
* Whether the process that wrote this lock is still running.
*
* A PID alone does not identify the supervisor: the operating system hands the number to
* something else once the supervisor is gone, and a reboot reassigns it freely. A process
* cannot predate the boot it runs under, so a lock stamped before this boot is abandoned
* cannot predate the boot it runs under, so a lock written during another boot is abandoned
* however alive its PID looks.
*/
export function isPidLockOwnerRunning(lock: PidLockInfo): boolean {
if (precedesThisBoot(lock.startedAt)) return false;
if (!writtenDuringThisBoot(lock)) return false;
return isPidRunning(lock.pid);
}
@@ -214,6 +238,7 @@ export async function acquirePidLock(
}
// Create new lock with exclusive flag
const bootId = currentBootId();
const lockInfo: PidLockInfo = {
pid: lockOwnerPid,
startedAt: new Date().toISOString(),
@@ -221,6 +246,7 @@ export async function acquirePidLock(
uid: process.getuid?.() ?? 0,
listen,
heartbeat: true,
...(bootId ? { bootId } : {}),
...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}),
};
+16
View File
@@ -113,6 +113,22 @@ test("keeps the directory's ranking window when searching", async ({ page }) =>
);
});
test("searches for a term typed before the page finished loading", async ({ page }) => {
let loadScripts!: () => void;
const scriptsHeld = new Promise<void>((resolve) => (loadScripts = resolve));
await page.route(/\.js($|\?)/, async (route) => {
await scriptsHeld;
await route.continue();
});
await page.goto("/plugins?window=month", { waitUntil: "domcontentloaded" });
await searchPlugins(page, "graphite");
loadScripts();
await page.waitForLoadState("load");
await expect(page.getByRole("button", { name: "Clear search" })).toBeVisible();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/);
});
test("clears the search with the clear button", async ({ page }) => {
await page.goto("/plugins/all");
const searchbox = page.getByRole("searchbox", { name: "Search plugins" });