Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andLorenzo Fiore 3e172d18c4 test: retain real OMP ACP provider ID collision reproduction
Reproduce #3178 through configuration parsing, provider registry resolution, and real session creation. The unique omp-acp ID succeeds, while omp keeps the built-in RPC runtime despite extends: acp and times out. This is an opt-in diagnosis test, with no production behavior changes.

Co-authored-by: Lorenzo Fiore <fiorelorenzo.fl@gmail.com>
2026-10-11 23:55:40 +02:00
paseo-bot[bot]andX b3693b79d1 Find hidden agent plan files in workspace suggestions (#6641)
* fix(server): find hidden agent plan files in workspace suggestions

Workspace autocomplete skipped the editor's plan directory while traversing
other known agent directories. Include it in the existing discovery policy
and cover basename and partial-path queries through the daemon client.

Co-authored-by: X <X@16x.org>

* test(server): give plan suggestion queries explicit expectations

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: X <X@16x.org>
2026-10-11 21:54:46 +02:00
0458988825 Connect the self-hosted web app through standard-port proxies (#6638)
* fix(server): give the web UI connection hint an explicit port (#1)

injectConnectionHint built `listen` straight from the Host header, which
carries no port when the client used the scheme's default — exactly what a
browser sends to a proxy terminating TLS on 443. The app parses `listen` with
parseHostPort, which requires an explicit port, so connectionFromListen
returned null and bootstrapInitialConnectionHint bailed before probing,
silently. Boot then fell through to the localhost:6767 fallback, which is the
viewer's own machine rather than the daemon, and the app landed on the welcome
screen with no host at all.

Reaching the daemon directly never hit this: http://host:6767 puts the port in
the Host header. It only appears behind a reverse proxy on 443 or 80, such as a
Tailscale Ingress, and only in a browser profile with no host already stored.

Spell the scheme default out when the header omits it, leaving the IPv6 bracket
form intact.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* fix(server): compute the web bootstrap port before its connection hint

Keep the default-port fix for reverse proxy bootstrap within the repository branching style.

Co-authored-by: Sian Lerk Lau <kiawin@gmail.com>

---------

Co-authored-by: Sian Lerk Lau <kiawin@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 20:51:51 +02:00
paseo-bot[bot] 4bd31e1a2e test(cli): accept Codex catalogs without branded model IDs (#6633)
The provider regression test rejected valid GPT catalogs solely because no ID contained codex. Keep the structural output checks without requiring that upstream branding substring.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 19:47:10 +02:00
paseo-bot[bot]andOkan YILDIRIM 1939ee79ed Accept daemon settings in the published editor schema (#6621)
* fix(config): accept daemon settings in the published editor schema

The website schema omitted accepted configuration fields, so editors
rejected valid browser, profile, plugin and metadata settings. Regenerate
from the daemon schema and guard the published artifact against drift.

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>

* fix(config): preserve proxy help when regenerating the schema

Keep the existing editor descriptions in the source schema so regeneration retains them. The new regression fails when the proxy descriptions disappear.

* test(usage): create SQLite store fixtures in one transaction

The Windows plugin suite exceeded its five-second test deadline while creating and exercising the first OMP store fixture. Commit the fixture as a unit rather than flushing each inserted row separately; preserve the read-only discovery assertions.

* test(config): show structured schema validation errors

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
2026-10-11 18:50:03 +02:00
paseo-bot[bot]andBudi 8d9e7d3ad3 Confirm remote permission approvals before reporting success (#6607)
* fix(cli): await permission resolution before closing transport

* test(cli): verify permission confirmation over real connections

Exercise permission commands through a real WebSocket protocol peer, keeping responses pending until explicit resolution. This catches success before acknowledgement without replacing the client connector or process output. Retain the contributor permission fix and verify both commands, batches, and unconfirmed responses.

Co-authored-by: Budi <11062155+budivoogt@users.noreply.github.com>

* test(cli): catch permission batches completing after one reply

Hold the second acknowledgement until a WebSocket round trip proves the first reply reached the client. A first-reply-only mutation must fail both batch tests.

* test(cli): inject permission clients into command regression tests

* test(cli): use timer helper for permission continuations

* style(cli): format permission regression tests with oxfmt

---------

Co-authored-by: Budi <11062155+budivoogt@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-11 18:03:43 +02:00
16 changed files with 1354 additions and 716 deletions
+124 -91
View File
@@ -1,5 +1,8 @@
import type { Command } from "commander";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
} from "@getpaseo/protocol/agent-types";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
@@ -55,106 +58,136 @@ export interface PermitAllowOptions extends CommandOptions {
host?: string;
}
export async function runAllowCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitAllowOptions,
_command: Command,
): Promise<PermitAllowResult> {
// No validation needed - if no reqId provided, allow all by default
export interface PermitClient {
fetchAgent(options: { agentId: string }): Promise<{
agent: { id: string; pendingPermissions?: AgentPermissionRequest[] };
} | null>;
respondToPermissionAndWait(
agentId: string,
requestId: string,
response: AgentPermissionResponse,
): Promise<unknown>;
close(): Promise<void>;
}
// Parse input JSON if provided
let updatedInput: Record<string, unknown> | undefined;
if (options.input) {
try {
updatedInput = JSON.parse(options.input);
} catch (err) {
const error: CommandError = {
code: "INVALID_JSON",
message: `Invalid JSON for --input: ${err instanceof Error ? err.message : String(err)}`,
details: 'Provide valid JSON, e.g., --input \'{"key": "value"}\'',
};
throw error;
}
}
export type PermitClientConnector = (
options: Parameters<typeof connectToDaemon>[0],
) => Promise<PermitClient>;
const client = await connectToDaemon({ target: options.daemonTarget });
export function createAllowCommand(connect: PermitClientConnector = connectToDaemon) {
return async function runAllowCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitAllowOptions,
_command: Command,
): Promise<PermitAllowResult> {
// No validation needed - if no reqId provided, allow all by default
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
if (!fetchResult) {
await client.close();
const error: CommandError = {
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
};
throw error;
}
const agent = fetchResult.agent;
const resolvedAgentId = agent.id;
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || [];
if (pendingPermissions.length === 0) {
await client.close();
const error: CommandError = {
code: "NO_PENDING_PERMISSIONS",
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
};
throw error;
}
// Determine which permissions to allow
let permissionsToAllow: AgentPermissionRequest[];
if (!reqId || options.all) {
// Default: allow all pending permissions if no req_id specified
// --all flag is kept as an explicit alias for clarity
permissionsToAllow = pendingPermissions;
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId));
if (!permission) {
await client.close();
// Parse input JSON if provided
let updatedInput: Record<string, unknown> | undefined;
if (options.input) {
try {
updatedInput = JSON.parse(options.input);
} catch (err) {
const error: CommandError = {
code: "PERMISSION_NOT_FOUND",
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
code: "INVALID_JSON",
message: `Invalid JSON for --input: ${err instanceof Error ? err.message : String(err)}`,
details: 'Provide valid JSON, e.g., --input \'{"key": "value"}\'',
};
throw error;
}
permissionsToAllow = [permission];
}
// Allow permissions
const results: PermissionResponseItem[] = await Promise.all(
permissionsToAllow.map(async (permission) => {
await client.respondToPermission(resolvedAgentId, permission.id, {
behavior: "allow",
...(updatedInput ? { updatedInput } : {}),
});
return toPermissionResponseItem(resolvedAgentId, permission, "allowed");
}),
);
const client = await connect({ target: options.daemonTarget });
await client.close();
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
if (!fetchResult) {
await client.close();
const error: CommandError = {
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
};
throw error;
}
const agent = fetchResult.agent;
const resolvedAgentId = agent.id;
return {
type: "list",
data: results,
schema: permitResponseSchema,
};
} catch (err) {
await client.close().catch(() => {});
// Re-throw CommandErrors
if (err && typeof err === "object" && "code" in err) {
throw err;
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || [];
if (pendingPermissions.length === 0) {
await client.close();
const error: CommandError = {
code: "NO_PENDING_PERMISSIONS",
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
};
throw error;
}
// Determine which permissions to allow
let permissionsToAllow: AgentPermissionRequest[];
if (!reqId || options.all) {
// Default: allow all pending permissions if no req_id specified
// --all flag is kept as an explicit alias for clarity
permissionsToAllow = pendingPermissions;
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId));
if (!permission) {
await client.close();
const error: CommandError = {
code: "PERMISSION_NOT_FOUND",
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
};
throw error;
}
permissionsToAllow = [permission];
}
// Allow permissions
const results: PermissionResponseItem[] = await Promise.all(
permissionsToAllow.map(async (permission) => {
try {
await client.respondToPermissionAndWait(resolvedAgentId, permission.id, {
behavior: "allow",
...(updatedInput ? { updatedInput } : {}),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "PERMISSION_RESPONSE_UNCONFIRMED",
message: `Permission response unconfirmed for request ${permission.id}: ${message}`,
details: 'Use "paseo permit ls" to check whether the request is still pending',
};
throw error;
}
return toPermissionResponseItem(resolvedAgentId, permission, "allowed");
}),
);
await client.close();
return {
type: "list",
data: results,
schema: permitResponseSchema,
};
} catch (err) {
await client.close().catch(() => {});
// Re-throw CommandErrors
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "ALLOW_PERMISSION_FAILED",
message: `Failed to allow permission: ${message}`,
};
throw error;
}
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "ALLOW_PERMISSION_FAILED",
message: `Failed to allow permission: ${message}`,
};
throw error;
}
};
}
export const runAllowCommand = createAllowCommand();
+100 -83
View File
@@ -6,6 +6,7 @@ import {
permitResponseSchema,
toPermissionResponseItem,
type PermissionResponseItem,
type PermitClientConnector,
} from "./allow.js";
export type PermitDenyResult = ListResult<PermissionResponseItem>;
@@ -17,98 +18,114 @@ export interface PermitDenyOptions extends CommandOptions {
host?: string;
}
export async function runDenyCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitDenyOptions,
_command: Command,
): Promise<PermitDenyResult> {
// Validate arguments
if (!options.all && !reqId) {
const error: CommandError = {
code: "MISSING_ARGUMENT",
message: "Request ID is required unless --all is specified",
details: "Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all",
};
throw error;
}
const client = await connectToDaemon({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
if (!fetchResult) {
await client.close();
export function createDenyCommand(connect: PermitClientConnector = connectToDaemon) {
return async function runDenyCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitDenyOptions,
_command: Command,
): Promise<PermitDenyResult> {
// Validate arguments
if (!options.all && !reqId) {
const error: CommandError = {
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
};
throw error;
}
const agent = fetchResult.agent;
const resolvedAgentId = agent.id;
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || [];
if (pendingPermissions.length === 0) {
await client.close();
const error: CommandError = {
code: "NO_PENDING_PERMISSIONS",
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
code: "MISSING_ARGUMENT",
message: "Request ID is required unless --all is specified",
details: "Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all",
};
throw error;
}
// Determine which permissions to deny
let permissionsToDeny: AgentPermissionRequest[];
if (options.all) {
permissionsToDeny = pendingPermissions;
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId!));
if (!permission) {
const client = await connect({ target: options.daemonTarget });
try {
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
if (!fetchResult) {
await client.close();
const error: CommandError = {
code: "PERMISSION_NOT_FOUND",
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
};
throw error;
}
permissionsToDeny = [permission];
const agent = fetchResult.agent;
const resolvedAgentId = agent.id;
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || [];
if (pendingPermissions.length === 0) {
await client.close();
const error: CommandError = {
code: "NO_PENDING_PERMISSIONS",
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
};
throw error;
}
// Determine which permissions to deny
let permissionsToDeny: AgentPermissionRequest[];
if (options.all) {
permissionsToDeny = pendingPermissions;
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find(
(p) => p.id === reqId || p.id.startsWith(reqId!),
);
if (!permission) {
await client.close();
const error: CommandError = {
code: "PERMISSION_NOT_FOUND",
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
};
throw error;
}
permissionsToDeny = [permission];
}
// Deny permissions
const results: PermissionResponseItem[] = await Promise.all(
permissionsToDeny.map(async (permission) => {
try {
await client.respondToPermissionAndWait(resolvedAgentId, permission.id, {
behavior: "deny",
...(options.message ? { message: options.message } : {}),
...(options.interrupt ? { interrupt: true } : {}),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "PERMISSION_RESPONSE_UNCONFIRMED",
message: `Permission response unconfirmed for request ${permission.id}: ${message}`,
details: 'Use "paseo permit ls" to check whether the request is still pending',
};
throw error;
}
return toPermissionResponseItem(resolvedAgentId, permission, "denied");
}),
);
await client.close();
return {
type: "list",
data: results,
schema: permitResponseSchema,
};
} catch (err) {
await client.close().catch(() => {});
// Re-throw CommandErrors
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DENY_PERMISSION_FAILED",
message: `Failed to deny permission: ${message}`,
};
throw error;
}
// Deny permissions
const results: PermissionResponseItem[] = await Promise.all(
permissionsToDeny.map(async (permission) => {
await client.respondToPermission(resolvedAgentId, permission.id, {
behavior: "deny",
...(options.message ? { message: options.message } : {}),
...(options.interrupt ? { interrupt: true } : {}),
});
return toPermissionResponseItem(resolvedAgentId, permission, "denied");
}),
);
await client.close();
return {
type: "list",
data: results,
schema: permitResponseSchema,
};
} catch (err) {
await client.close().catch(() => {});
// Re-throw CommandErrors
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DENY_PERMISSION_FAILED",
message: `Failed to deny permission: ${message}`,
};
throw error;
}
};
}
export const runDenyCommand = createDenyCommand();
@@ -0,0 +1,177 @@
import { setImmediate as waitForCommandContinuations } from "node:timers/promises";
import { Command } from "commander";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
} from "@getpaseo/protocol/agent-types";
import { describe, expect, it } from "vitest";
import { createAllowCommand, type PermitClient } from "./allow.js";
import { createDenyCommand } from "./deny.js";
const agentId = "2d85f428-fdaf-4d98-906f-c583de2b805f";
const requests: AgentPermissionRequest[] = [
{
id: "permission-write",
provider: "codex",
name: "CodexBash",
kind: "tool",
},
{ id: "permission-edit", provider: "codex", name: "CodexBash", kind: "tool" },
];
class PermissionClient implements PermitClient {
readonly pending = [...requests];
readonly responses = new Map<string, AgentPermissionResponse>();
closed = false;
private notifyReceived = () => {};
private readonly confirmations = new Map<
string,
{
resolve: () => void;
reject: (error: Error) => void;
}
>();
async fetchAgent() {
return { agent: { id: agentId, pendingPermissions: this.pending } };
}
async respondToPermission(
_agentId: string,
requestId: string,
response: AgentPermissionResponse,
) {
this.responses.set(requestId, response);
this.notifyReceived();
}
async respondToPermissionAndWait(
responseAgentId: string,
requestId: string,
response: AgentPermissionResponse,
): Promise<void> {
const confirmation = new Promise<void>((resolve, reject) => {
this.confirmations.set(requestId, { resolve, reject });
});
await this.respondToPermission(responseAgentId, requestId, response);
await confirmation;
}
async close() {
this.closed = true;
}
async waitForResponses(count: number) {
while (this.responses.size < count) {
await new Promise<void>((resolve) => {
this.notifyReceived = resolve;
});
}
}
confirm(requestId: string) {
const confirmation = this.confirmations.get(requestId);
if (!confirmation) throw new Error(`No confirmation waiter: ${requestId}`);
this.pending.splice(
this.pending.findIndex((request) => request.id === requestId),
1,
);
confirmation.resolve();
}
fail(requestId: string, error: Error) {
const confirmation = this.confirmations.get(requestId);
if (!confirmation) throw new Error(`No confirmation waiter: ${requestId}`);
confirmation.reject(error);
}
}
const options = {
daemonTarget: { kind: "endpoint" as const, host: "test.invalid" },
};
const actions = [
{ name: "allow", create: createAllowCommand, result: "allowed" },
{ name: "deny", create: createDenyCommand, result: "denied" },
];
for (const action of actions) {
describe(`permit ${action.name}`, () => {
it("returns success and closes only after confirmation", async () => {
const client = new PermissionClient();
let completed = false;
const command = action.create(async () => client)(
agentId,
requests[0].id,
options,
new Command(),
);
void command.then(
() => (completed = true),
() => false,
);
await client.waitForResponses(1);
await waitForCommandContinuations();
expect(completed).toBe(false);
expect(client.closed).toBe(false);
expect(client.pending).toEqual(requests);
client.confirm(requests[0].id);
const result = await command;
expect(result.data).toEqual([
{
requestId: requests[0].id,
agentId,
agentShortId: agentId.slice(0, 7),
name: "CodexBash",
result: action.result,
},
]);
expect(client.closed).toBe(true);
expect(client.pending).toEqual([requests[1]]);
});
it("waits for every confirmation with --all", async () => {
const client = new PermissionClient();
let completed = false;
const command = action.create(async () => client)(
agentId,
undefined,
{ ...options, all: true },
new Command(),
);
void command.then(
() => (completed = true),
() => false,
);
await client.waitForResponses(2);
client.confirm(requests[0].id);
// Wait beyond the first reply's promise continuations while withholding the second.
await waitForCommandContinuations();
expect(completed).toBe(false);
expect(client.closed).toBe(false);
client.confirm(requests[1].id);
const result = await command;
expect(result.data.map((item) => item.result)).toEqual([action.result, action.result]);
expect(client.closed).toBe(true);
expect(client.pending).toEqual([]);
});
it("rejects an unconfirmed response without returning success", async () => {
const client = new PermissionClient();
const command = action.create(async () => client)(
agentId,
requests[0].id,
options,
new Command(),
);
const failure = expect(command).rejects.toMatchObject({
code: "PERMISSION_RESPONSE_UNCONFIRMED",
message: expect.stringContaining(requests[0].id),
details: 'Use "paseo permit ls" to check whether the request is still pending',
});
await client.waitForResponses(1);
client.fail(requests[0].id, new Error("Timeout waiting for message (15000ms)"));
await failure;
expect(client.closed).toBe(true);
expect(client.pending).toEqual(requests);
});
});
}
-4
View File
@@ -364,10 +364,6 @@ try {
ids.every((id) => id.startsWith("gpt-")),
"all codex model IDs should be from the gpt family",
);
assert(
ids.some((id) => id.includes("codex")),
"codex model list should include at least one codex-optimized model",
);
assert(
data.every((m) => m.model && m.id && m.description),
"every codex model should have model, id, and description fields",
@@ -0,0 +1,52 @@
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import path from "node:path";
import { expect, test } from "vitest";
import { ProviderOverridesSchema } from "@getpaseo/protocol/provider-config";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { buildProviderRegistry } from "./provider-registry.js";
import type { AgentSession } from "./agent-sdk-types.js";
// Retained reproduction for #3178, awaiting the built-in ID configuration decision.
// Requires OMP on PATH (or OMP_COMMAND). Uses no model calls or existing provider state.
const runReproduction = process.env.OMP_PROVIDER_COLLISION_REPRO === "1";
for (const providerId of ["omp-acp", "omp"]) {
test.skipIf(!runReproduction)(
`explicit ACP configuration creates an ACP session under '${providerId}'`,
async () => {
const cwd = await mkdtemp(path.join(process.cwd(), ".provider-collision-"));
const agentDir = path.join(cwd, "agent");
await mkdir(agentDir);
const providerOverrides = ProviderOverridesSchema.parse({
[providerId]: {
extends: "acp",
label: "Oh My Pi ACP",
command: [process.env.OMP_COMMAND ?? "omp", "acp"],
enabled: true,
env: {
PI_CODING_AGENT_DIR: agentDir,
OMP_PROFILE: "default",
PI_PROFILE: "default",
XDG_CONFIG_HOME: path.join(cwd, "config"),
XDG_DATA_HOME: path.join(cwd, "data"),
XDG_CACHE_HOME: path.join(cwd, "cache"),
XDG_STATE_HOME: path.join(cwd, "state"),
},
},
});
const logger = createTestLogger();
const registry = buildProviderRegistry(logger, { providerOverrides });
const client = registry[providerId]!.createClient(logger);
let session: AgentSession | undefined;
try {
session = await client.createSession({ provider: providerId, cwd });
expect(session.provider).toBe(providerId);
} finally {
await session?.close();
await client.shutdown?.();
await rm(cwd, { recursive: true, force: true });
}
},
30_000,
);
}
@@ -1,28 +0,0 @@
import { expect, test } from "vitest";
import {
mapCodexToolCallFromThreadItem,
splitCodexMcpToolResultImages,
} from "./tool-call-mapper.js";
test("Codex screenshot metadata takes the existing image extraction path", () => {
const result = {
content: [{ type: "text", text: "Captured screenshot" }],
_meta: {
"codex/toolSurface": {
screenshot: { url: "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ==", width: 1280 },
},
},
};
const mapped = mapCodexToolCallFromThreadItem({
type: "mcpToolCall",
id: "screenshot",
server: "node_repl",
tool: "js",
status: "completed",
result,
});
expect(JSON.stringify(mapped)).not.toContain("data:image");
expect(splitCodexMcpToolResultImages(result).images).toEqual([
{ data: "/9j/4AAQSkZJRgABAQ==", mimeType: "image/jpeg" },
]);
});
@@ -134,6 +134,44 @@ describe("daemon web UI bootstrap", () => {
});
});
test("spells out the scheme default port when the Host header omits it", async () => {
const distDir = await createWebUiDist();
daemonHandle = await createTestPaseoDaemon({
mcpEnabled: false,
webUi: {
enabled: true,
distDir,
},
});
// A browser reaching a proxy that terminates TLS on 443 sends a Host header
// with no port at all. The app rejects a portless `listen`, so the daemon
// has to supply the default itself.
const httpsHint = readInjectedConnectionHint(
await fetchDaemonWebUi({
port: daemonHandle.port,
headers: { host: "paseo.example.test", "x-forwarded-proto": "https" },
}),
);
const httpHint = readInjectedConnectionHint(
await fetchDaemonWebUi({
port: daemonHandle.port,
headers: { host: "paseo.example.test" },
}),
);
const ipv6Hint = readInjectedConnectionHint(
await fetchDaemonWebUi({
port: daemonHandle.port,
headers: { host: "[::1]", "x-forwarded-proto": "https" },
}),
);
expect(httpsHint.listen).toBe("paseo.example.test:443");
expect(httpHint.listen).toBe("paseo.example.test:80");
expect(ipv6Hint.listen).toBe("[::1]:443");
});
test("ignores forwarded HTTPS when proxy trust is disabled", async () => {
const distDir = await createWebUiDist();
@@ -23,6 +23,49 @@ afterEach(async () => {
}, 60000);
describe("file explorer", () => {
test.each([
{
query: "implementation",
expectedPaths: [
".cursor/plans/implementation-plan.md",
".codex/plans/implementation-plan.md",
".claude/plans/implementation-plan.md",
],
},
{
query: ".cursor/plans/implementation",
expectedPaths: [".cursor/plans/implementation-plan.md"],
},
])(
"workspace suggestions find hidden agent plans for '$query'",
async ({ query, expectedPaths }) => {
const cwd = tmpCwd();
try {
for (const directory of [".cursor", ".codex", ".claude", ".hidden"]) {
mkdirSync(path.join(cwd, directory, "plans"), { recursive: true });
writeFileSync(path.join(cwd, directory, "plans", "implementation-plan.md"), "plan\n");
}
const response = await ctx.client.getDirectorySuggestions({
cwd,
query,
limit: 50,
includeFiles: true,
includeDirectories: true,
});
expect(response.error).toBeNull();
expect(response.entries).toHaveLength(expectedPaths.length);
expect(response.entries).toEqual(
expect.arrayContaining(
expectedPaths.map((entryPath) => ({ path: entryPath, kind: "file" })),
),
);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
);
test.each(["..", "..."])("supports file operations on names starting with %s", async (prefix) => {
const cwd = tmpCwd();
const fileName = `${prefix}notes.txt`;
@@ -1,76 +0,0 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { Writable } from "node:stream";
import pino from "pino";
import { expect, test } from "vitest";
import { createDaemonTestContext } from "../test-utils/index.js";
// Diagnostic regression for #2220. The provider is faked; committed image-heavy
// tool outputs and the daemon/client WebSocket exchange use production code.
test.each([40, 0])(
"fetching image-heavy history with limit %i keeps the direct connection usable",
async (limit) => {
const highWater: Array<{ bufferedAmount: number; maxBufferedBytes: number }> = [];
const logger = pino(
{ level: "warn" },
new Writable({
write(chunk, _encoding, callback) {
const record = JSON.parse(chunk.toString());
if (record.msg === "Closing physical WebSocket at outbound high-water mark")
highWater.push(record);
callback();
},
}),
);
const ctx = await createDaemonTestContext({ logger });
const cwd = mkdtempSync(path.join(tmpdir(), "timeline-repro-"));
try {
const agent = await ctx.client.createAgent({ provider: "codex", cwd });
const imageUrl = `data:image/jpeg;base64,${"A".repeat(14 * 1024 * 1024)}`;
for (let index = 0; index < 5; index += 1) {
await ctx.daemon.daemon.agentManager.appendTimelineItem(agent.id, {
type: "tool_call",
callId: `screenshot-${index}`,
name: "node_repl.js",
status: "completed",
error: null,
detail: {
type: "unknown",
input: { index },
output: { _meta: { "codex/toolSurface": { screenshot: { url: imageUrl } } } },
},
});
}
// Control: the same connection and data work when the selected page is small.
const one = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 1 });
expect(one.entries).toHaveLength(1);
expect(one.hasOlder).toBe(true);
// A 40-item fetch (the app size) or limit 0 (the CLI logs request) should return a usable page,
// with remaining history reachable by its cursors, rather than kill the socket.
const fetch = ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit });
const outcome = await fetch.then(
(page) => ({ page }),
(error: Error) => ({ error }),
);
if ("error" in outcome) {
expect(highWater).toMatchObject([
{ bufferedAmount: 0, maxBufferedBytes: 64 * 1024 * 1024 },
]);
console.log({
limit,
selectedImageBytes: 5 * 14 * 1024 * 1024,
close: outcome.error.message,
bufferedAmount: highWater[0].bufferedAmount,
});
throw outcome.error;
}
expect(outcome.page.agentId).toBe(agent.id);
} finally {
await ctx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}
},
60_000,
);
@@ -0,0 +1,78 @@
import { readFileSync } from "node:fs";
import Ajv from "ajv";
import { expect, test } from "vitest";
import { z } from "zod";
import { PersistedConfigSchema } from "./persisted-config.js";
const publishedSchema = JSON.parse(
readFileSync(
new URL("../../../website/public/schemas/paseo.config.v1.json", import.meta.url),
"utf8",
),
);
test("editors accept the daemon's browser, profile, plugin and metadata configuration", () => {
const config = {
$schema: "https://paseo.sh/schemas/paseo.config.v1.json",
version: 1,
daemon: {
browserTools: { enabled: true },
terminalProfiles: [],
agentProfiles: [],
},
pluginsEnabled: true,
plugins: {},
agents: {
metadataGeneration: { providers: [{ provider: "claude", model: "claude-opus-5" }] },
},
};
expect(PersistedConfigSchema.parse(config)).toEqual(config);
const validate = new Ajv({ allErrors: true, strict: false }).compile(publishedSchema);
const valid = validate(config);
expect(validate.errors).toBeNull();
expect(valid).toBe(true);
});
test("the published schema stays current with the daemon's configuration schema", () => {
const generated = z.toJSONSchema(PersistedConfigSchema, {
target: "draft-07",
unrepresentable: "any",
io: "input",
});
generated.title = "PaseoConfigV1";
expect(
publishedSchema,
"Run npm run generate:config-schema --workspace=@getpaseo/server",
).toEqual(generated);
});
test("editors retain the existing proxy configuration help", () => {
const daemon = publishedSchema.properties.daemon.properties;
expect(daemon.trustedProxies.description).toBe(
'Express trusted proxy setting for X-Forwarded-* headers. Defaults to ["loopback"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.',
);
expect(daemon.serviceProxy.properties.enabled.description).toBe(
"Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.",
);
expect(daemon.serviceProxy.properties.listen.description).toBe(
"Optional service-only listener address. Presence enables the standalone service proxy listener.",
);
expect(daemon.serviceProxy.properties.publicBaseUrl.description).toBe(
"Optional public base URL for service host aliases.",
);
});
test.each([
{ daemon: { browserTools: { enabled: "yes" } } },
{ daemon: { terminalProfiles: "default" } },
{ daemon: { agentProfiles: "default" } },
{ pluginsEnabled: "yes" },
{ plugins: [] },
{ agents: { metadataGeneration: { providers: [{ provider: "" }] } } },
{ unknownSetting: true },
])("editors still reject invalid configuration: %j", (config) => {
expect(PersistedConfigSchema.safeParse(config).success).toBe(false);
const validate = new Ajv({ allErrors: true, strict: false }).compile(publishedSchema);
expect(validate(config)).toBe(false);
});
+22 -4
View File
@@ -237,7 +237,12 @@ export const PersistedConfigSchema = z
listen: z.string().optional(),
hostnames: z.union([z.literal(true), z.array(z.string())]).optional(),
allowedHosts: z.union([z.literal(true), z.array(z.string())]).optional(),
trustedProxies: z.union([z.literal(true), z.array(z.string())]).optional(),
trustedProxies: z
.union([z.literal(true), z.array(z.string())])
.optional()
.describe(
'Express trusted proxy setting for X-Forwarded-* headers. Defaults to ["loopback"]. Use true only when the final trusted proxy overwrites client-supplied forwarded headers.',
),
mcp: z
.object({
enabled: z.boolean().optional(),
@@ -284,9 +289,22 @@ export const PersistedConfigSchema = z
// COMPAT(serviceProxyEnabled): added 2026-06-02, remove after 2026-12-02.
// Parsed only to suppress optional public/listen layers for old configs;
// localhost service proxying remains always enabled.
enabled: z.boolean().optional(),
listen: z.string().optional(),
publicBaseUrl: z.url().optional(),
enabled: z
.boolean()
.optional()
.describe(
"Compatibility shim. false suppresses optional service proxy listen/public layers only; localhost proxying remains enabled.",
),
listen: z
.string()
.optional()
.describe(
"Optional service-only listener address. Presence enables the standalone service proxy listener.",
),
publicBaseUrl: z
.url()
.optional()
.describe("Optional public base URL for service host aliases."),
})
.strict()
.optional(),
+25 -1
View File
@@ -250,6 +250,22 @@ function serializeInlineScriptJson(value: unknown): string {
.replace(/&/g, "\\u0026");
}
/**
* A Host header carries no port when the client used the scheme's default one,
* which is what every browser sends to a proxy terminating TLS on 443. The app
* parses `listen` with parseHostPort, which requires an explicit port and
* rejects the value outright, so the hint has to spell the port out.
*/
function hasExplicitPort(host: string): boolean {
if (host.startsWith("[")) {
// IPv6 literals are bracketed: [::1] has no port, [::1]:443 does.
const end = host.indexOf("]");
return end !== -1 && /^:\d{1,5}$/.test(host.slice(end + 1));
}
const colon = host.indexOf(":");
return colon !== -1 && colon === host.lastIndexOf(":") && /^\d{1,5}$/.test(host.slice(colon + 1));
}
function injectConnectionHint(
html: string,
req: Parameters<RequestHandler>[0],
@@ -257,8 +273,16 @@ function injectConnectionHint(
): string {
const host = typeof req.headers.host === "string" ? req.headers.host : "";
const useTls = req.protocol === "https";
let listen = host;
if (host && !hasExplicitPort(host)) {
let defaultPort = 80;
if (useTls) {
defaultPort = 443;
}
listen = `${host}:${defaultPort}`;
}
const hint = {
listen: host,
listen,
useTls,
label,
};
@@ -146,6 +146,7 @@ export const WORKSPACE_SEARCH_HIDDEN_DIRECTORIES = [
".agents",
".claude",
".codex",
".cursor",
".github",
".opencode",
".paseo",
File diff suppressed because it is too large Load Diff
@@ -45,6 +45,8 @@ async function logins(options: Parameters<typeof discover>[1]) {
async function database(path: string) {
await mkdir((await import("node:path")).dirname(path), { recursive: true });
const db = new DatabaseSync(path);
// Create the fixture with one durable commit instead of one per row.
db.exec("BEGIN");
db.exec(
"CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, credential_type TEXT, data TEXT, disabled_cause TEXT)",
);
@@ -78,6 +80,7 @@ async function database(path: string) {
null,
);
insert.run(5, "anthropic", "oauth", "invalid", null);
db.exec("COMMIT");
db.close();
}
@@ -39,6 +39,8 @@ async function database(path: string) {
await mkdir((await import("node:path")).dirname(path), { recursive: true });
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(path);
// Create the fixture with one durable commit instead of one per row.
db.exec("BEGIN");
db.exec(
"CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, credential_type TEXT, data TEXT, disabled_cause TEXT)",
);
@@ -72,6 +74,7 @@ async function database(path: string) {
null,
);
insert.run(5, "openai-codex", "oauth", "invalid", null);
db.exec("COMMIT");
db.close();
}