mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e94a5731b | ||
|
|
f13bb2136c | ||
|
|
2323ee6844 | ||
|
|
1e00f452ea | ||
|
|
a162e1075f |
@@ -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([]);
|
||||
|
||||
+15
-72
@@ -1,10 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPiExtensionHost } from "../index.js";
|
||||
import {
|
||||
readSubagentFixture,
|
||||
verifySubagentFixture,
|
||||
} from "../subagent-fixture-test.js";
|
||||
import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js";
|
||||
|
||||
describe("@tintinweb/pi-subagents adapter", () => {
|
||||
test("exposes the background output file while the child is running", () => {
|
||||
@@ -16,105 +13,51 @@ describe("@tintinweb/pi-subagents adapter", () => {
|
||||
result: {
|
||||
details: { agentId: "native-1", status: "background" },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Agent started in background.\nOutput file: /tmp/child.output\n",
|
||||
},
|
||||
{ type: "text", text: "Agent started in background.\nOutput file: /tmp/child.output\n" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(mapping?.subagents).toEqual([
|
||||
expect.objectContaining({ id: "call-1", status: "running" }),
|
||||
]);
|
||||
expect(mapping?.childSessions).toEqual([
|
||||
{ id: "call-1", file: "/tmp/child.output" },
|
||||
]);
|
||||
expect(mapping?.childSessions).toEqual([{ id: "call-1", file: "/tmp/child.output" }]);
|
||||
});
|
||||
test("maps captured foreground lifecycle live and on replay", async () => {
|
||||
const events = await verifySubagentFixture(
|
||||
readSubagentFixture(
|
||||
new URL("./fixtures/foreground.json", import.meta.url)
|
||||
)
|
||||
readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url)),
|
||||
);
|
||||
expect(
|
||||
events
|
||||
.filter((event) => event.event.type === "upsert")
|
||||
.map((event) =>
|
||||
event.event.type === "upsert" ? event.event.status : null
|
||||
)
|
||||
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
|
||||
).toEqual(["running", "completed"]);
|
||||
});
|
||||
// #5828: the extension writes a transcript for foreground agents too, but its
|
||||
// foreground result carries no `Output file:` line, so the child opens empty.
|
||||
test("exposes the foreground child's transcript when it completes", () => {
|
||||
const fixture = readSubagentFixture(
|
||||
new URL("./fixtures/foreground.json", import.meta.url)
|
||||
);
|
||||
const end = fixture.events.find(
|
||||
(event) => event.type === "tool_execution_end"
|
||||
) as {
|
||||
toolCallId: string;
|
||||
result: Parameters<
|
||||
ReturnType<typeof createPiExtensionHost>["mapToolCall"]
|
||||
>[0]["result"];
|
||||
};
|
||||
const mapping = createPiExtensionHost().mapToolCall({
|
||||
callId: end.toolCallId,
|
||||
toolName: "Agent",
|
||||
args: {
|
||||
subagent_type: "general-purpose",
|
||||
prompt: "list the files in this empty workspace",
|
||||
},
|
||||
status: "completed",
|
||||
result: end.result,
|
||||
});
|
||||
expect(mapping?.subagents).toEqual([
|
||||
expect.objectContaining({ id: end.toolCallId, status: "completed" }),
|
||||
]);
|
||||
expect(mapping?.childSessions).toEqual([
|
||||
{ id: end.toolCallId, file: expect.any(String) },
|
||||
]);
|
||||
});
|
||||
test("uses a structured notification to complete a background child", async () => {
|
||||
const source = readSubagentFixture(
|
||||
new URL("./fixtures/background.json", import.meta.url)
|
||||
);
|
||||
const source = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url));
|
||||
const file = (
|
||||
source.messages.find((message) => message.role === "custom") as {
|
||||
details: { outputFile: string };
|
||||
}
|
||||
).details.outputFile;
|
||||
const fixture = readSubagentFixture(
|
||||
new URL("./fixtures/background.json", import.meta.url),
|
||||
{
|
||||
from: file,
|
||||
to: fileURLToPath(
|
||||
new URL("./fixtures/child-session.jsonl", import.meta.url)
|
||||
),
|
||||
}
|
||||
);
|
||||
const fixture = readSubagentFixture(new URL("./fixtures/background.json", import.meta.url), {
|
||||
from: file,
|
||||
to: fileURLToPath(new URL("./fixtures/child-session.jsonl", import.meta.url)),
|
||||
});
|
||||
const events = await verifySubagentFixture(fixture);
|
||||
expect(
|
||||
events
|
||||
.filter((event) => event.event.type === "upsert")
|
||||
.map((event) =>
|
||||
event.event.type === "upsert" ? event.event.status : null
|
||||
)
|
||||
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
|
||||
).toEqual(["running", "running", "completed"]);
|
||||
expect(
|
||||
events.filter((event) => event.event.type === "timeline").length
|
||||
).toBeGreaterThan(0);
|
||||
expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0);
|
||||
});
|
||||
test("completes every background child in a grouped notification", async () => {
|
||||
const events = await verifySubagentFixture(
|
||||
readSubagentFixture(
|
||||
new URL("./fixtures/background-group.json", import.meta.url)
|
||||
)
|
||||
readSubagentFixture(new URL("./fixtures/background-group.json", import.meta.url)),
|
||||
);
|
||||
const finalStatus = new Map<string, string>();
|
||||
for (const { event } of events) {
|
||||
if (event.type === "upsert" && event.status)
|
||||
finalStatus.set(event.id, event.status);
|
||||
if (event.type === "upsert" && event.status) finalStatus.set(event.id, event.status);
|
||||
}
|
||||
expect(Object.fromEntries(finalStatus)).toEqual({
|
||||
call_277173: "completed",
|
||||
@@ -130,7 +73,7 @@ describe("@tintinweb/pi-subagents adapter", () => {
|
||||
args: { prompt: "foo" },
|
||||
status: "completed",
|
||||
result: { details: { agentId: "other", status: "completed" } },
|
||||
})
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()}`,
|
||||
);
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -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" });
|
||||
|
||||
Reference in New Issue
Block a user