Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] ce5693099d test(schedule): reproduce #6189, a run ends while a background subagent runs
A new-agent schedule run whose agent ends its turn with a provider subagent
still running is marked succeeded and its session is closed, killing the
child. This test fails on main.
2026-10-07 04:17:56 +02:00
17 changed files with 261 additions and 528 deletions
@@ -628,11 +628,6 @@ 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. */
@@ -642,8 +637,6 @@ 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,7 +492,6 @@ 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,
@@ -510,7 +509,7 @@ function wrapClientProvider(
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
},
launchContext,
{ ...options, configuredModelIds },
options,
),
),
resumeSession: async (handle, overrides, launchContext, options) =>
@@ -527,7 +526,7 @@ function wrapClientProvider(
providerOptions: mergeProviderOptions(providerOptions, overrides?.providerOptions),
},
launchContext,
{ ...options, configuredModelIds },
options,
),
),
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, beforeEach, describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
AgentSideConnection,
ClientSideConnection,
@@ -54,10 +54,8 @@ 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";
@@ -4372,151 +4370,3 @@ 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,7 +77,6 @@ import {
type AgentPromptContentBlock,
type AgentPromptInput,
type AgentResumePurpose,
type AgentCreateSessionOptions,
type AgentResumeSessionOptions,
type AgentRunOptions,
type AgentRunResult,
@@ -513,7 +512,6 @@ interface ACPAgentSessionOptions {
extensionCommandsParser?: ACPExtensionCommandsParser;
handle?: AgentPersistenceHandle;
resumePurpose?: AgentResumePurpose;
configuredModelIds?: readonly string[];
agentId?: string;
launchEnv?: Record<string, string>;
waitForInitialCommands?: boolean;
@@ -981,7 +979,6 @@ export class ACPAgentClient implements AgentClient {
async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions,
): Promise<AgentSession> {
this.assertProvider(config);
const providerOptions = ACPProviderOptionsSchema.parse(config.providerOptions ?? {});
@@ -1009,7 +1006,6 @@ export class ACPAgentClient implements AgentClient {
supportsMcpServers:
providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers,
},
configuredModelIds: options?.configuredModelIds,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
extensionCommandsParser: this.extensionCommandsParser,
@@ -1068,7 +1064,6 @@ export class ACPAgentClient implements AgentClient {
},
handle,
resumePurpose: options?.purpose,
configuredModelIds: options?.configuredModelIds,
agentId: launchContext?.agentId,
launchEnv: launchContext?.env,
extensionCommandsParser: this.extensionCommandsParser,
@@ -1697,7 +1692,6 @@ 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;
@@ -1766,7 +1760,6 @@ 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;
@@ -2201,20 +2194,12 @@ 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,
});
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 } };
await this.setModelWithSelection({ modelId, selection });
}
private async setModelWithSelection({
@@ -2228,20 +2213,21 @@ export class ACPAgentSession implements AgentSession, ACPClient {
throw new Error("ACP session not initialized");
}
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.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.availableModel &&
typeof this.connection.unstable_setSessionModel === "function"
) {
try {
await this.connection.unstable_setSessionModel({
sessionId: this.sessionId,
@@ -2936,11 +2922,13 @@ 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: this.resolveModelSelection(configuredModelId),
});
await this.setModelWithSelection({ modelId: configuredModelId, selection });
switchedModel = true;
} catch (error) {
if (!this.isModelSelectionUnavailableError(error)) {
@@ -327,57 +327,6 @@ test("fails an auto mode turn when Claude Code uses Vertex", async () => {
}
});
test("switches an auto mode session to another mode when Claude Code uses Bedrock", async () => {
const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK;
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
sdkQueryFactory.mockImplementation(() => queryMock);
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory: sdkQueryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
modeId: "auto",
});
try {
await session.setMode("acceptEdits");
expect(await session.getCurrentMode()).toBe("acceptEdits");
expect(sdkQueryFactory).toHaveBeenCalledTimes(1);
expect(sdkQueryFactory.mock.calls[0]?.[0]?.options?.permissionMode).toBe("acceptEdits");
} finally {
restoreEnvValue("CLAUDE_CODE_USE_BEDROCK", previousBedrock);
await session.close();
}
});
test("does not keep a query started in a mode that Claude Code rejected", async () => {
const launchedModes: Array<string | undefined> = [];
sdkQueryFactory.mockImplementation(({ options }: { options: { permissionMode?: string } }) => {
launchedModes.push(options.permissionMode);
const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
if (launchedModes.length === 1) {
queryMock.setPermissionMode.mockRejectedValue(new Error("bypass refused"));
}
return queryMock;
});
const session = await createSession();
try {
await expect(session.setMode("bypassPermissions")).rejects.toThrow("bypass refused");
await session.listCommands();
expect(await session.getCurrentMode()).toBe("default");
expect(launchedModes).toEqual(["bypassPermissions", "default"]);
} finally {
await session.close();
}
});
test("logs redacted query summary and never leaks sentinel secrets", async () => {
const envSecret = "PASEO_ENV_SENTINEL_SECRET";
const runtimeSecret = "PASEO_RUNTIME_SENTINEL_SECRET";
@@ -1088,11 +1037,11 @@ test("preserves bypass capability across query restarts triggered by thinking ch
expect(capturedOptions).toHaveLength(2);
expect(capturedOptions[0]).toMatchObject({
permissionMode: "bypassPermissions",
permissionMode: "default",
allowDangerouslySkipPermissions: true,
});
expect(capturedOptions[1]).toMatchObject({
permissionMode: "bypassPermissions",
permissionMode: "acceptEdits",
allowDangerouslySkipPermissions: true,
effort: "high",
});
@@ -2441,15 +2441,8 @@ class ClaudeAgentSession implements AgentSession {
const normalized = isPermissionMode(modeId) ? modeId : "default";
assertClaudeModeCanRun(normalized, this.harnessEnvironment);
const previousMode = this.currentMode;
const launchesQuery = !this.query || this.queryRestartNeeded;
const activeQuery = await this.ensureQuery(normalized);
try {
await activeQuery.setPermissionMode(normalized);
} catch (error) {
// The query was launched in the rejected mode; relaunch in the current mode next time.
if (launchesQuery) this.queryRestartNeeded = true;
throw error;
}
const activeQuery = await this.ensureQuery();
await activeQuery.setPermissionMode(normalized);
if (normalized === "plan") {
if (previousMode !== "plan") {
this.planResumeMode = previousMode;
@@ -3135,7 +3128,7 @@ class ClaudeAgentSession implements AgentSession {
return { kind: "fresh-session" };
}
private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise<Query> {
private async ensureQuery(): Promise<Query> {
if (this.query && !this.queryRestartNeeded) {
return this.query;
}
@@ -3179,7 +3172,7 @@ class ClaudeAgentSession implements AgentSession {
this.persistence = null;
const input = createAsyncMessageInput<SDKUserMessage>();
const options = await this.buildOptions(launchMode);
const options = await this.buildOptions();
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
this.input = input;
this.query = claudeQuery(
@@ -3305,7 +3298,7 @@ class ClaudeAgentSession implements AgentSession {
});
}
private async buildOptions(permissionMode: PermissionMode): Promise<ClaudeOptions> {
private async buildOptions(): Promise<ClaudeOptions> {
const { thinking, effort, ultracode } = this.resolveThinkingConfig();
const appendedSystemPrompt = this.buildAppendedSystemPrompt();
const providerOptions = applyClaudeToolPolicy(
@@ -3314,7 +3307,7 @@ class ClaudeAgentSession implements AgentSession {
);
const settingsOptions = this.buildSettingsOptions(providerOptions, { ultracode });
const sdkEnv = this.harnessEnvironment;
assertClaudeModeCanRun(permissionMode, sdkEnv);
assertClaudeModeCanRun(this.currentMode, sdkEnv);
const claudeBinary = await this.resolveBinary();
this.logger.debug(
@@ -3337,7 +3330,7 @@ class ClaudeAgentSession implements AgentSession {
const base: ClaudeOptions = {
cwd: this.config.cwd,
includePartialMessages: true,
permissionMode,
permissionMode: this.currentMode,
// Dynamic mode switching can recreate the underlying Claude query. Keep the
// bypass launch capability available so later setPermissionMode("bypassPermissions")
// calls do not fail after a model/thinking/rewind-driven restart.
@@ -459,9 +459,7 @@ describe("OMP agent client and session", () => {
test("fails a turn when the provider idle gate passes its deadline", async () => {
const scheduler = new ManualIdleScheduler();
// 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 });
const omp = new OmpHarness({ providerIdleScheduler: scheduler, providerIdleDeadlineMs: 1 });
await omp.start();
const { completion } = await omp.startPromptUntilProviderIdle("first", "first done", {
isStreaming: true,
@@ -475,7 +473,7 @@ describe("OMP agent client and session", () => {
args: { command: "sleep 30" },
});
expect(omp.runningToolCallIds()).toEqual(["tool-at-deadline"]);
await new Promise((resolve) => setTimeout(resolve, 60));
await new Promise((resolve) => setTimeout(resolve, 2));
scheduler.retry();
await expect(completion).rejects.toThrow(/provider idle/i);
expect(omp.runningToolCallIds()).toEqual([]);
+2 -2
View File
@@ -126,7 +126,6 @@ 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";
@@ -837,8 +836,9 @@ export async function createPaseoDaemon(
return;
}
const safeFileName = entry.fileName.replace(/["\r\n]/g, "_");
res.setHeader("Content-Type", entry.mimeType);
res.setHeader("Content-Disposition", formatAttachmentContentDisposition(entry.fileName));
res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`);
res.setHeader("Content-Length", fileStats.size.toString());
const stream = fileHandle.createReadStream();
@@ -24,77 +24,42 @@ describe("daemon E2E", () => {
}, 60000);
describe("file download tokens", () => {
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");
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");
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).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
expect(agent.id).toBeTruthy();
const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName);
const tokenResponse = await ctx.client.requestDownloadToken(cwd, "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);
expect(tokenResponse.error).toBeNull();
expect(tokenResponse.token).toBeTruthy();
expect(tokenResponse.fileName).toBe("download.txt");
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);
expect(response.headers.get("content-disposition")).toBe(disposition);
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");
const body = await response.text();
expect(body).toBe(fileContents);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
60000,
);
const body = await response.text();
expect(body).toBe(fileContents);
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, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { readDaemonInstance, stopDaemonInstance } from "./daemon-instance.js";
import { acquirePidLock, getPidLockInfo, isLocked, type PidLockInfo } from "./pid-lock.js";
@@ -94,42 +94,3 @@ 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 });
});
});
@@ -1,42 +0,0 @@
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);
});
});
@@ -1,32 +0,0 @@
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()}`,
);
}
+2 -28
View File
@@ -1,4 +1,3 @@
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";
@@ -16,7 +15,6 @@ 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> {}
@@ -64,38 +62,16 @@ 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 written during another boot is abandoned
* cannot predate the boot it runs under, so a lock stamped before this boot is abandoned
* however alive its PID looks.
*/
export function isPidLockOwnerRunning(lock: PidLockInfo): boolean {
if (!writtenDuringThisBoot(lock)) return false;
if (precedesThisBoot(lock.startedAt)) return false;
return isPidRunning(lock.pid);
}
@@ -238,7 +214,6 @@ export async function acquirePidLock(
}
// Create new lock with exclusive flag
const bootId = currentBootId();
const lockInfo: PidLockInfo = {
pid: lockOwnerPid,
startedAt: new Date().toISOString(),
@@ -246,7 +221,6 @@ export async function acquirePidLock(
uid: process.getuid?.() ?? 0,
listen,
heartbeat: true,
...(bootId ? { bootId } : {}),
...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}),
};
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import pino from "pino";
import type pino from "pino";
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
import { exportPublicKey, generateKeyPair } from "@getpaseo/relay";
import { startRelayTransport } from "./relay-transport";
@@ -146,37 +146,6 @@ describe("relay-transport control lifecycle", () => {
vi.useRealTimers();
});
test("releases a stalled encrypted data connection without restarting control", async () => {
vi.useFakeTimers();
let attachments = 0;
controllers.push(
startRelayTransport({
logger: pino({ level: "silent" }),
attachSocket: async () => {
attachments += 1;
},
relayEndpoint: "relay.paseo.sh:443",
relayUseTls: true,
serverId: "srv_test",
daemonKeyPair: generateKeyPair(),
createWebSocket: relay.createWebSocket,
}),
);
const control = relay.sockets[0];
control.open();
const connected = JSON.stringify({ type: "connected", connectionId: "stalled" });
control.message(connected);
const stalled = relay.sockets[1];
stalled.open();
await vi.advanceTimersByTimeAsync(15_000);
expect(stalled.terminateCalls).toBe(1);
expect(attachments).toBe(0);
expect(control.terminateCalls).toBe(0);
control.message(connected);
expect(relay.sockets).toHaveLength(3);
expect(relay.sockets[2].url).toBe(stalled.url);
});
test("logs relay_control_connected only after first valid control message", () => {
const logger = createMockLogger();
const controller = startRelayTransport({
@@ -420,10 +420,6 @@ async function attachEncryptedSocket(
attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>,
metadata?: ExternalSocketMetadata,
): Promise<void> {
const handshakeTimeout = setTimeout(() => {
logger.warn("relay_e2ee_handshake_timeout_terminating");
socket.terminate();
}, 15_000);
try {
const relayTransport = createRelayTransportAdapter(socket, logger);
const emitter = new EventEmitter();
@@ -444,7 +440,6 @@ async function attachEncryptedSocket(
emitter.emit("error", error);
},
});
clearTimeout(handshakeTimeout);
const encryptedSocket = createEncryptedRelaySocket({
channel,
emitter,
@@ -464,8 +459,6 @@ async function attachEncryptedSocket(
} catch {
// ignore
}
} finally {
clearTimeout(handshakeTimeout);
}
}
@@ -1514,6 +1514,197 @@ describe("ScheduleService", () => {
expect(storedAgent?.archivedAt).toBeTruthy();
});
test("keeps a new-agent run open while the agent's background subagent is still running", async () => {
class BackgroundChildSession implements AgentSession {
readonly provider = "claude";
readonly capabilities = SCHEDULE_TEST_CAPABILITIES;
readonly id = "background-child-session";
closed = false;
launched = false;
private turnCount = 0;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
constructor(private readonly config: AgentSessionConfig) {}
async run(_prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<AgentRunResult> {
throw new Error("Scheduled runs start turns");
}
// The shape of a Claude turn that calls Agent with run_in_background: true and ends without
// waiting: the child is still running when the parent's turn completes.
async startTurn(
_prompt: AgentPromptInput,
_options?: AgentRunOptions,
): Promise<{ turnId: string }> {
const turnId = `turn-${++this.turnCount}`;
setImmediate(() => {
this.emit({ type: "turn_started", provider: this.provider, turnId });
this.emit({
type: "provider_subagent",
provider: this.provider,
event: { type: "upsert", id: "probe-child", title: "probe child", status: "running" },
});
this.emit({
type: "timeline",
provider: this.provider,
turnId,
item: { type: "assistant_message", text: "LAUNCHED" },
});
this.emit({
type: "turn_completed",
provider: this.provider,
turnId,
usage: { inputTokens: 1, outputTokens: 1 },
});
this.launched = true;
});
return { turnId };
}
// Claude marks the child completed, then its notification wakes the parent for a turn of
// its own.
finishChild(): void {
this.emit({
type: "provider_subagent",
provider: this.provider,
event: { type: "upsert", id: "probe-child", status: "completed" },
});
this.emit({ type: "turn_started", provider: this.provider });
this.emit({
type: "timeline",
provider: this.provider,
item: { type: "assistant_message", text: "CHILD_REPORTED" },
});
this.emit({
type: "turn_completed",
provider: this.provider,
usage: { inputTokens: 1, outputTokens: 1 },
});
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
this.subscribers.delete(callback);
};
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
async getRuntimeInfo() {
return {
provider: this.provider,
sessionId: this.id,
model: this.config.model ?? null,
modeId: this.config.modeId ?? null,
};
}
async getAvailableModes(): Promise<AgentMode[]> {
return [];
}
async getCurrentMode(): Promise<string | null> {
return this.config.modeId ?? null;
}
async setMode(modeId: string): Promise<void> {
this.config.modeId = modeId;
}
getPendingPermissions(): AgentPermissionRequest[] {
return [];
}
async respondToPermission(
_requestId: string,
_response: AgentPermissionResponse,
): Promise<void> {}
describePersistence(): AgentPersistenceHandle {
return { provider: this.provider, sessionId: this.id, metadata: { ...this.config } };
}
async interrupt(): Promise<void> {}
async close(): Promise<void> {
this.closed = true;
}
private emit(event: AgentStreamEvent): void {
for (const subscriber of this.subscribers) {
subscriber(event);
}
}
}
class BackgroundChildClient implements AgentClient {
readonly provider = "claude";
readonly capabilities = SCHEDULE_TEST_CAPABILITIES;
readonly sessions: BackgroundChildSession[] = [];
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const session = new BackgroundChildSession(config);
this.sessions.push(session);
return session;
}
async resumeSession(): Promise<AgentSession> {
throw new Error("Scheduled runs create fresh sessions");
}
async fetchCatalog(): Promise<{ models: AgentModelDefinition[]; modes: AgentMode[] }> {
return { models: [], modes: [] };
}
async isAvailable(): Promise<boolean> {
return true;
}
}
const client = new BackgroundChildClient();
const manager = new AgentManager({
logger: createTestLogger(),
clients: { claude: client },
registry: agentStorage,
});
const service = createScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: manager,
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
});
const created = await service.create({
prompt: "launch a background child and end your turn",
cadence: { type: "cron", expression: "0 3 1 1 *" },
target: {
type: "new-agent",
config: { provider: "claude", model: "test-model", cwd: tempDir },
},
runOnCreate: false,
});
let runSettled = false;
const run = service.runOnce(created.id).finally(() => {
runSettled = true;
});
await vi.waitFor(() => {
expect(client.sessions[0]?.launched).toBe(true);
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(runSettled).toBe(false);
expect(client.sessions[0]?.closed).toBe(false);
client.sessions[0]!.finishChild();
const finished = await run;
expect(finished.runs[0]).toMatchObject({ status: "succeeded", output: "CHILD_REPORTED" });
expect(client.sessions[0]?.closed).toBe(true);
});
test("records prompt-start failures as failed and archives the scheduled agent", async () => {
class StartFailureScheduleSession implements AgentSession {
readonly provider = "claude";
-16
View File
@@ -113,22 +113,6 @@ 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" });