Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 77fc894ab4 Format repro test 2026-09-24 03:33:03 +02:00
paseo-bot[bot] 6bca7ad8a6 Add failing repro: timeline drops images sent with a message (#4556) 2026-09-24 03:32:59 +02:00
paseo-bot[bot] e6085c1e98 Point CLI auth failures at PASEO_PASSWORD instead of daemon start (#5310)
A password-protected daemon that rejects the CLI is running, so the
start hint was the wrong remedy for AUTH_REQUIRED and AUTH_FAILED.

Fixes #4508

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 02:53:02 +02:00
paseo-bot[bot] 1bf5312297 Start the daemon when an empty paseo.pid was left behind (#5306)
A supervisor killed between creating paseo.pid and writing its contents
leaves an empty file. Every later start, status, and stop then failed
with "Cannot read daemon state", so the daemon stayed down until someone
deleted the file by hand.

An empty lock names no owner, so after the existing read retries it now
reads as no lock, and acquiring the lock removes it before creating a new
one. A lock with unparseable contents still refuses, since it may belong
to a supervisor this version cannot read.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 01:53:13 +02:00
paseo-bot[bot] 7fa78244fd fix(cli): emit full permission request ids from permit ls, allow, and deny (#5305)
The permit result items stored the request id already shortened to eight
characters for the table, so JSON, YAML, and quiet output serialized that
display value. Every request id starts with "permission-", so every row
came out as "permissi" and could not be passed back to permit allow.
The items now keep the full id and only the table column shortens it.

Closes #4732

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-24 00:52:03 +02:00
13 changed files with 255 additions and 42 deletions
+17 -8
View File
@@ -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");
}),
);
+6 -8
View File
@@ -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");
}),
);
+19 -17
View File
@@ -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]);
});
});
+11 -4
View File
@@ -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);
+35 -1
View File
@@ -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 });
}
});
});
+22 -4
View File
@@ -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