mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-09 01:45:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77fc894ab4 | ||
|
|
6bca7ad8a6 | ||
|
|
e6085c1e98 | ||
|
|
1bf5312297 | ||
|
|
7fa78244fd |
@@ -16,7 +16,7 @@ export interface PermissionResponseItem {
|
||||
export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
|
||||
idField: "requestId",
|
||||
columns: [
|
||||
{ header: "REQUEST ID", field: "requestId", width: 12 },
|
||||
{ header: "REQUEST ID", field: (item) => item.requestId.slice(0, 8), width: 12 },
|
||||
{ header: "AGENT", field: "agentShortId", width: 10 },
|
||||
{ header: "TOOL", field: "name", width: 20 },
|
||||
{
|
||||
@@ -32,6 +32,21 @@ export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
|
||||
],
|
||||
};
|
||||
|
||||
/** Transform a permission the agent answered to a response item */
|
||||
export function toPermissionResponseItem(
|
||||
agentId: string,
|
||||
permission: AgentPermissionRequest,
|
||||
result: "allowed" | "denied",
|
||||
): PermissionResponseItem {
|
||||
return {
|
||||
requestId: permission.id,
|
||||
agentId,
|
||||
agentShortId: agentId.slice(0, 7),
|
||||
name: permission.name,
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
export type PermitAllowResult = ListResult<PermissionResponseItem>;
|
||||
|
||||
export interface PermitAllowOptions extends CommandOptions {
|
||||
@@ -118,13 +133,7 @@ export async function runAllowCommand(
|
||||
behavior: "allow",
|
||||
...(updatedInput ? { updatedInput } : {}),
|
||||
});
|
||||
return {
|
||||
requestId: permission.id.slice(0, 8),
|
||||
agentId: resolvedAgentId,
|
||||
agentShortId: resolvedAgentId.slice(0, 7),
|
||||
name: permission.name,
|
||||
result: "allowed",
|
||||
};
|
||||
return toPermissionResponseItem(resolvedAgentId, permission, "allowed");
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { Command } from "commander";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, CommandError } from "../../output/index.js";
|
||||
import { permitResponseSchema, type PermissionResponseItem } from "./allow.js";
|
||||
import {
|
||||
permitResponseSchema,
|
||||
toPermissionResponseItem,
|
||||
type PermissionResponseItem,
|
||||
} from "./allow.js";
|
||||
|
||||
export type PermitDenyResult = ListResult<PermissionResponseItem>;
|
||||
|
||||
@@ -83,13 +87,7 @@ export async function runDenyCommand(
|
||||
...(options.message ? { message: options.message } : {}),
|
||||
...(options.interrupt ? { interrupt: true } : {}),
|
||||
});
|
||||
return {
|
||||
requestId: permission.id.slice(0, 8),
|
||||
agentId: resolvedAgentId,
|
||||
agentShortId: resolvedAgentId.slice(0, 7),
|
||||
name: permission.name,
|
||||
result: "denied",
|
||||
};
|
||||
return toPermissionResponseItem(resolvedAgentId, permission, "denied");
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export const permitLsSchema: OutputSchema<PermissionListItem> = {
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: "AGENT", field: "agentShortId", width: 12 },
|
||||
{ header: "REQ_ID", field: "id", width: 12 },
|
||||
{ header: "REQ_ID", field: (item) => item.id.slice(0, 8), width: 12 },
|
||||
{ header: "TOOL", field: "name", width: 20 },
|
||||
{ header: "DESCRIPTION", field: "description", width: 50 },
|
||||
],
|
||||
@@ -30,7 +30,7 @@ function toListItem(
|
||||
permission: AgentPermissionRequest,
|
||||
): PermissionListItem {
|
||||
return {
|
||||
id: permission.id.slice(0, 8),
|
||||
id: permission.id,
|
||||
agentId: agent.id,
|
||||
agentShortId: agent.id.slice(0, 7),
|
||||
name: permission.name,
|
||||
@@ -40,6 +40,22 @@ function toListItem(
|
||||
|
||||
export type PermitLsResult = ListResult<PermissionListItem>;
|
||||
|
||||
/** Collect the pending permissions of every agent */
|
||||
export function listPendingPermissions(agents: AgentSnapshotPayload[]): PermitLsResult {
|
||||
const items: PermissionListItem[] = [];
|
||||
for (const agent of agents) {
|
||||
for (const permission of agent.pendingPermissions ?? []) {
|
||||
items.push(toListItem(agent, permission));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "list",
|
||||
data: items,
|
||||
schema: permitLsSchema,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PermitLsOptions extends CommandOptions {
|
||||
host?: string;
|
||||
}
|
||||
@@ -55,21 +71,7 @@ export async function runLsCommand(
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent);
|
||||
await client.close();
|
||||
|
||||
// Collect all pending permissions from all agents
|
||||
const items: PermissionListItem[] = [];
|
||||
for (const agent of agents) {
|
||||
if (agent.pendingPermissions && agent.pendingPermissions.length > 0) {
|
||||
for (const permission of agent.pendingPermissions) {
|
||||
items.push(toListItem(agent, permission));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: "list",
|
||||
data: items,
|
||||
schema: permitLsSchema,
|
||||
};
|
||||
return listPendingPermissions(agents);
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {});
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { render } from "../../output/index.js";
|
||||
import { permitResponseSchema, toPermissionResponseItem } from "./allow.js";
|
||||
import { listPendingPermissions } from "./ls.js";
|
||||
|
||||
const agentId = "2d85f428-fdaf-4d98-906f-c583de2b805f";
|
||||
|
||||
function permissionRequest(id: string): AgentPermissionRequest {
|
||||
return { id, provider: "claude", name: "Write", kind: "tool" };
|
||||
}
|
||||
|
||||
const writeRequest = permissionRequest("permission-cc93510e-a45d-4c4e-a0bf-fc2821f82783");
|
||||
const editRequest = permissionRequest("permission-654ff862-b312-4f6e-8aac-427421ff65eb");
|
||||
|
||||
const agent = {
|
||||
id: agentId,
|
||||
pendingPermissions: [writeRequest, editRequest],
|
||||
} as AgentSnapshotPayload;
|
||||
|
||||
describe("permit ls output", () => {
|
||||
it("emits full request ids that permit allow accepts", () => {
|
||||
const result = listPendingPermissions([agent]);
|
||||
|
||||
const listed = JSON.parse(render(result, { format: "json" })) as Array<{ id: string }>;
|
||||
expect(listed.map((item) => item.id)).toEqual([writeRequest.id, editRequest.id]);
|
||||
expect(render(result, { quiet: true }).split("\n")).toEqual([writeRequest.id, editRequest.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permit allow and deny output", () => {
|
||||
it("emits the full id of the request that was answered", () => {
|
||||
const result = {
|
||||
type: "list" as const,
|
||||
data: [toPermissionResponseItem(agentId, writeRequest, "allowed")],
|
||||
schema: permitResponseSchema,
|
||||
};
|
||||
|
||||
const answered = JSON.parse(render(result, { format: "json" })) as Array<{
|
||||
requestId: string;
|
||||
}>;
|
||||
expect(answered.map((item) => item.requestId)).toEqual([writeRequest.id]);
|
||||
});
|
||||
});
|
||||
@@ -52,13 +52,20 @@ export function buildDaemonConnectionCommandError(options: ConnectOptions & { er
|
||||
return {
|
||||
code,
|
||||
message: `Cannot connect to daemon at ${describeDaemonTarget(options.target)}: ${message}`,
|
||||
details:
|
||||
options.target.kind === "instance"
|
||||
? `Start with: paseo daemon start --home ${JSON.stringify(options.target.home)}`
|
||||
: "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.",
|
||||
details: describeConnectionRemedy(code, options.target),
|
||||
};
|
||||
}
|
||||
|
||||
function describeConnectionRemedy(code: string, target: DaemonTarget): string {
|
||||
if (code === "AUTH_REQUIRED")
|
||||
return "The daemon requires a password. Set PASEO_PASSWORD and retry.";
|
||||
if (code === "AUTH_FAILED")
|
||||
return "The daemon rejected the password. Check PASEO_PASSWORD and retry.";
|
||||
if (target.kind === "instance")
|
||||
return `Start with: paseo daemon start --home ${JSON.stringify(target.home)}`;
|
||||
return "Check the selected endpoint and credentials. SSH transport does not install or start the daemon.";
|
||||
}
|
||||
|
||||
export function normalizeDaemonHost(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import assert from "node:assert";
|
||||
import { runLocalPaseo } from "./helpers/local-cli.ts";
|
||||
import { startTestDaemon } from "./helpers/test-daemon.ts";
|
||||
|
||||
console.log("=== Daemon Auth Command Errors ===\n");
|
||||
|
||||
const daemon = await startTestDaemon({
|
||||
env: { PASEO_PASSWORD: "shared-secret" },
|
||||
});
|
||||
|
||||
async function lsError(password: string) {
|
||||
const result = await runLocalPaseo(["ls", "--json"], {
|
||||
PASEO_HOME: daemon.paseoHome,
|
||||
PASEO_HOST: "",
|
||||
PASEO_PASSWORD: password,
|
||||
});
|
||||
assert.notStrictEqual(result.exitCode, 0, "ls should fail without a valid password");
|
||||
return JSON.parse(result.stderr).error as {
|
||||
code: string;
|
||||
message: string;
|
||||
details: string;
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
{
|
||||
console.log("Test 1: missing password asks for PASEO_PASSWORD, not a daemon start");
|
||||
const error = await lsError("");
|
||||
assert.strictEqual(error.code, "AUTH_REQUIRED");
|
||||
assert.match(error.message, /Password required/);
|
||||
assert.match(error.details, /PASEO_PASSWORD/);
|
||||
assert.doesNotMatch(error.details, /daemon start/);
|
||||
console.log("✓ missing password points at PASEO_PASSWORD\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 2: wrong password asks for PASEO_PASSWORD, not a daemon start");
|
||||
const error = await lsError("wrong-secret");
|
||||
assert.strictEqual(error.code, "AUTH_FAILED");
|
||||
assert.match(error.message, /Incorrect password/);
|
||||
assert.match(error.details, /PASEO_PASSWORD/);
|
||||
assert.doesNotMatch(error.details, /daemon start/);
|
||||
console.log("✓ wrong password points at PASEO_PASSWORD\n");
|
||||
}
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
|
||||
console.log("=== Daemon Auth Command Errors Tests Passed ===");
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import {
|
||||
createDaemonTestContext,
|
||||
type DaemonTestContext,
|
||||
} from "../test-utils/index.js";
|
||||
|
||||
const PNG_BASE64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
|
||||
|
||||
let ctx: DaemonTestContext;
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
cwd = mkdtempSync(path.join(tmpdir(), "sent-images-timeline-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
|
||||
test("the timeline row for a message sent with images carries those images", async () => {
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd,
|
||||
title: "images",
|
||||
});
|
||||
|
||||
await ctx.client.sendMessage(agent.id, "test image", {
|
||||
images: [{ data: PNG_BASE64, mimeType: "image/png" }],
|
||||
});
|
||||
await ctx.client.waitForFinish(agent.id, 30_000);
|
||||
|
||||
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
const userRow = timeline.entries
|
||||
.map((entry) => entry.item)
|
||||
.find((item) => item.type === "user_message" && item.text === "test image");
|
||||
|
||||
expect(userRow).toBeDefined();
|
||||
expect(userRow).toMatchObject({ images: [{ mimeType: "image/png" }] });
|
||||
}, 60_000);
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, open, rm, stat, utimes, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, open, readFile, rm, stat, utimes, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
@@ -256,4 +256,38 @@ describe("pid-lock ownership", () => {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("starts over an empty lock file left by a supervisor killed before writing it", async () => {
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-empty-"));
|
||||
const ownerPid = process.pid + 10_000;
|
||||
|
||||
try {
|
||||
await writeFile(join(paseoHome, "paseo.pid"), "");
|
||||
|
||||
await expect(getPidLockInfo(paseoHome)).resolves.toBeNull();
|
||||
await acquirePidLock(paseoHome, null, { ownerPid });
|
||||
|
||||
const lock = await getPidLockInfo(paseoHome);
|
||||
expect(lock?.pid).toBe(ownerPid);
|
||||
} finally {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps a lock file whose contents cannot be read as a lock", async () => {
|
||||
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-pid-lock-unparseable-"));
|
||||
const pidPath = join(paseoHome, "paseo.pid");
|
||||
|
||||
try {
|
||||
await writeFile(pidPath, JSON.stringify({ pid: "unknown" }));
|
||||
|
||||
await expect(
|
||||
acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }),
|
||||
).rejects.toThrow("Cannot read daemon state");
|
||||
|
||||
await expect(readFile(pidPath, "utf-8")).resolves.toBe(JSON.stringify({ pid: "unknown" }));
|
||||
} finally {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { open, readFile, unlink, utimes } from "node:fs/promises";
|
||||
import { open, readFile, stat, unlink, utimes } from "node:fs/promises";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import { ensurePrivateDirectory } from "./private-files.js";
|
||||
import { join } from "node:path";
|
||||
@@ -84,18 +84,26 @@ async function touchPidLockFile(pidPath: string): Promise<void> {
|
||||
|
||||
async function readPidLock(pidPath: string): Promise<PidLockInfo | null> {
|
||||
let lastError: unknown;
|
||||
let empty = false;
|
||||
for (let attempt = 0; attempt < PID_LOCK_READ_RETRY_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
const content = await readFile(pidPath, "utf-8");
|
||||
const lock = parsePidLockInfo(JSON.parse(content));
|
||||
if (lock) return lock;
|
||||
lastError = new Error("Invalid lock shape");
|
||||
empty = content === "";
|
||||
if (!empty) {
|
||||
const lock = parsePidLockInfo(JSON.parse(content));
|
||||
if (lock) return lock;
|
||||
lastError = new Error("Invalid lock shape");
|
||||
}
|
||||
} catch (error) {
|
||||
if (isErrnoException(error) && error.code === "ENOENT") return null;
|
||||
empty = false;
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, PID_LOCK_READ_RETRY_DELAY_MS));
|
||||
}
|
||||
// A supervisor writes its lock right after creating the file, so a file still empty
|
||||
// after the retries was abandoned in between and names no owner.
|
||||
if (empty) return null;
|
||||
throw Object.assign(
|
||||
new PidLockError(`Cannot read daemon state at ${pidPath}: ${String(lastError)}`),
|
||||
{ code: "DAEMON_STATE_READ_FAILED" },
|
||||
@@ -149,6 +157,14 @@ async function clearExistingPidLock(
|
||||
return "cleared";
|
||||
}
|
||||
|
||||
async function removeEmptyPidLock(pidPath: string): Promise<void> {
|
||||
try {
|
||||
if ((await stat(pidPath)).size === 0) await unlink(pidPath);
|
||||
} catch (error) {
|
||||
if (!isErrnoException(error) || error.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeNewPidLock(pidPath: string, lockInfo: PidLockInfo): Promise<void> {
|
||||
let fd;
|
||||
try {
|
||||
@@ -191,6 +207,8 @@ export async function acquirePidLock(
|
||||
if (result === "already_owned") {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await removeEmptyPidLock(pidPath);
|
||||
}
|
||||
|
||||
// Create new lock with exclusive flag
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
Reference in New Issue
Block a user