mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-11 02:46:29 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e172d18c4 | ||
|
|
b3693b79d1 | ||
|
|
0458988825 | ||
|
|
4bd31e1a2e | ||
|
|
1939ee79ed | ||
|
|
8d9e7d3ad3 |
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user