Report daemon auth failures in status

This commit is contained in:
Mohamed Boudra
2026-06-05 23:03:32 +07:00
parent 20c03355f9
commit 9b21ccd7f1
2 changed files with 138 additions and 6 deletions
+46 -6
View File
@@ -1,7 +1,7 @@
import type { Command } from "commander"; import type { Command } from "commander";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { getOrCreateServerId, findExecutable, execCommand } from "@getpaseo/server"; import { getOrCreateServerId, findExecutable, execCommand } from "@getpaseo/server";
import { tryConnectToDaemon } from "../../utils/client.js"; import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js"; import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
import { resolveNodePathFromPid } from "./runtime-toolchain.js"; import { resolveNodePathFromPid } from "./runtime-toolchain.js";
@@ -16,7 +16,7 @@ interface ProviderBinaryStatus {
interface DaemonStatus { interface DaemonStatus {
serverId: string | null; serverId: string | null;
localDaemon: "running" | "stopped" | "stale_pid" | "unresponsive"; localDaemon: "running" | "stopped" | "stale_pid" | "unresponsive";
connectedDaemon: "reachable" | "unreachable" | "not_probed"; connectedDaemon: "reachable" | "unreachable" | "auth_required" | "auth_failed" | "not_probed";
home: string; home: string;
listen: string; listen: string;
relay: string; relay: string;
@@ -33,6 +33,7 @@ interface DaemonStatus {
daemonVersion: string | null; daemonVersion: string | null;
desktopManaged: boolean; desktopManaged: boolean;
providers: ProviderBinaryStatus[]; providers: ProviderBinaryStatus[];
agentsUnavailableReason?: string;
note?: string; note?: string;
} }
@@ -96,7 +97,7 @@ function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
} }
if (item.key === "Connected Daemon") { if (item.key === "Connected Daemon") {
if (item.value === "reachable") return "green"; if (item.value === "reachable") return "green";
if (item.value === "not_probed") return "yellow"; if (item.value === "not_probed" || item.value === "auth_required") return "yellow";
return "red"; return "red";
} }
if (item.key.startsWith(" ")) { if (item.key.startsWith(" ")) {
@@ -139,7 +140,7 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
} else { } else {
rows.push({ rows.push({
key: "Agents", key: "Agents",
value: "Unavailable (daemon API not reachable)", value: `Unavailable (${status.agentsUnavailableReason ?? "daemon API not reachable"})`,
}); });
} }
@@ -218,16 +219,49 @@ interface DaemonProbeResult {
idleAgents?: number; idleAgents?: number;
daemonNodeOverride?: string; daemonNodeOverride?: string;
daemonProviders?: ProviderBinaryStatus[]; daemonProviders?: ProviderBinaryStatus[];
agentsUnavailableReason?: string;
note?: string; note?: string;
} }
type DaemonAuthProbeFailure = "auth_required" | "auth_failed";
function classifyDaemonAuthProbeFailure(error: unknown): DaemonAuthProbeFailure | null {
if (!(error instanceof Error)) return null;
if (error.message === "Password required") return "auth_required";
if (error.message === "Incorrect password") return "auth_failed";
return null;
}
function describeDaemonAuthProbeFailure(host: string, failure: DaemonAuthProbeFailure): string {
if (failure === "auth_required") {
return `Daemon is reachable at ${host} but requires a password. Set PASEO_PASSWORD and retry.`;
}
return `Daemon is reachable at ${host} but the supplied password was rejected. Check PASEO_PASSWORD and retry.`;
}
function describeAgentsUnavailableReason(failure: DaemonAuthProbeFailure): string {
if (failure === "auth_required") return "password required";
return "incorrect password";
}
async function probeDaemonOverWebsocket(args: { async function probeDaemonOverWebsocket(args: {
host: string; host: string;
state: ReturnType<typeof resolveLocalDaemonState>; state: ReturnType<typeof resolveLocalDaemonState>;
}): Promise<DaemonProbeResult> { }): Promise<DaemonProbeResult> {
const { host, state } = args; const { host, state } = args;
const client = await tryConnectToDaemon({ host, timeout: 1500 }); let client: Awaited<ReturnType<typeof connectToDaemon>>;
if (!client) { try {
client = await connectToDaemon({ host, timeout: 1500 });
} catch (error) {
const authFailure = classifyDaemonAuthProbeFailure(error);
if (authFailure) {
return {
connectedDaemon: authFailure,
agentsUnavailableReason: describeAgentsUnavailableReason(authFailure),
note: describeDaemonAuthProbeFailure(host, authFailure),
};
}
if (state.running) { if (state.running) {
return { return {
connectedDaemon: "unreachable", connectedDaemon: "unreachable",
@@ -309,6 +343,7 @@ interface ProbeMergeState {
runningAgents: number | null; runningAgents: number | null;
idleAgents: number | null; idleAgents: number | null;
daemonProviders: ProviderBinaryStatus[] | undefined; daemonProviders: ProviderBinaryStatus[] | undefined;
agentsUnavailableReason: string | undefined;
note: string | undefined; note: string | undefined;
} }
@@ -322,6 +357,7 @@ function applyProbeToStatus(input: ProbeMergeState): Omit<ProbeMergeState, "prob
runningAgents: probe.runningAgents !== undefined ? probe.runningAgents : input.runningAgents, runningAgents: probe.runningAgents !== undefined ? probe.runningAgents : input.runningAgents,
idleAgents: probe.idleAgents !== undefined ? probe.idleAgents : input.idleAgents, idleAgents: probe.idleAgents !== undefined ? probe.idleAgents : input.idleAgents,
daemonProviders: probe.daemonProviders ?? input.daemonProviders, daemonProviders: probe.daemonProviders ?? input.daemonProviders,
agentsUnavailableReason: probe.agentsUnavailableReason ?? input.agentsUnavailableReason,
note: probe.note ? appendNote(input.note, probe.note) : input.note, note: probe.note ? appendNote(input.note, probe.note) : input.note,
}; };
} }
@@ -371,6 +407,7 @@ export async function runStatusCommand(
let idleAgents: number | null = null; let idleAgents: number | null = null;
let daemonVersion: string | null = null; let daemonVersion: string | null = null;
let daemonProviders: ProviderBinaryStatus[] | undefined; let daemonProviders: ProviderBinaryStatus[] | undefined;
let agentsUnavailableReason: string | undefined;
let note: string | undefined; let note: string | undefined;
if (!state.running && state.stalePidFile && state.pidInfo) { if (!state.running && state.stalePidFile && state.pidInfo) {
@@ -388,6 +425,7 @@ export async function runStatusCommand(
runningAgents, runningAgents,
idleAgents, idleAgents,
daemonProviders, daemonProviders,
agentsUnavailableReason,
note, note,
} = applyProbeToStatus({ } = applyProbeToStatus({
probe, probe,
@@ -398,6 +436,7 @@ export async function runStatusCommand(
runningAgents, runningAgents,
idleAgents, idleAgents,
daemonProviders, daemonProviders,
agentsUnavailableReason,
note, note,
})); }));
} else { } else {
@@ -434,6 +473,7 @@ export async function runStatusCommand(
daemonVersion, daemonVersion,
desktopManaged: state.pidInfo?.desktopManaged === true, desktopManaged: state.pidInfo?.desktopManaged === true,
providers, providers,
agentsUnavailableReason,
note, note,
}; };
@@ -0,0 +1,92 @@
#!/usr/bin/env npx tsx
import assert from "node:assert";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createTestPaseoDaemon } from "../../server/src/server/test-utils/paseo-daemon.ts";
import { runLocalPaseo } from "./helpers/local-cli.ts";
console.log("=== Daemon Status Auth ===\n");
const CORRECT_PASSWORD_HASH = "$2b$12$GMhF7pN4QnMlHOQXOqjd1OitKWPSmAO3FwB0PHzKtcZR/sAMryz76";
const daemon = await createTestPaseoDaemon({
auth: { password: CORRECT_PASSWORD_HASH },
});
try {
await writeFile(
join(daemon.paseoHome, "paseo.pid"),
`${JSON.stringify(
{
pid: process.pid,
startedAt: new Date().toISOString(),
hostname: "status-auth-test",
uid: process.getuid?.(),
listen: `0.0.0.0:${daemon.port}`,
},
null,
2,
)}\n`,
);
{
console.log("Test 1: status reports password requirement without marking daemon unreachable");
const result = await runLocalPaseo(["daemon", "status", "--json"], {
PASEO_HOME: daemon.paseoHome,
PASEO_HOST: "",
PASEO_PASSWORD: "",
});
assert.strictEqual(result.exitCode, 0, "status should still succeed");
const status = JSON.parse(result.stdout);
assert.strictEqual(status.localDaemon, "running");
assert.strictEqual(status.connectedDaemon, "auth_required");
assert.strictEqual(status.runningAgents, null);
assert.strictEqual(status.idleAgents, null);
assert.match(status.note, /requires a password/i);
assert.doesNotMatch(status.note, /not reachable/i);
console.log("✓ missing password reports auth_required\n");
}
{
console.log("Test 2: status reports rejected supplied password separately");
const result = await runLocalPaseo(["daemon", "status", "--json"], {
PASEO_HOME: daemon.paseoHome,
PASEO_HOST: "",
PASEO_PASSWORD: "wrong-secret",
});
assert.strictEqual(result.exitCode, 0, "status should still succeed");
const status = JSON.parse(result.stdout);
assert.strictEqual(status.localDaemon, "running");
assert.strictEqual(status.connectedDaemon, "auth_failed");
assert.match(status.note, /password was rejected/i);
assert.doesNotMatch(status.note, /not reachable/i);
console.log("✓ wrong password reports auth_failed\n");
}
{
console.log("Test 3: status reaches the same daemon when password is supplied");
const result = await runLocalPaseo(["daemon", "status", "--json"], {
PASEO_HOME: daemon.paseoHome,
PASEO_HOST: "",
PASEO_PASSWORD: "shared-secret",
});
assert.strictEqual(result.exitCode, 0, "status should succeed with password");
const status = JSON.parse(result.stdout);
assert.strictEqual(status.localDaemon, "running");
assert.strictEqual(status.connectedDaemon, "reachable");
assert.strictEqual(status.runningAgents, 0);
assert.strictEqual(status.idleAgents, 0);
console.log("✓ password-authenticated status remains reachable\n");
}
} finally {
await daemon.close();
}
console.log("=== Daemon Status Auth Tests Passed ===");