feat: expand daemon status diagnostics

This commit is contained in:
Peter Steinberger
2026-01-08 08:24:28 +01:00
parent 15dd6b65b6
commit a676e16fbb
7 changed files with 208 additions and 16 deletions

View File

@@ -1,5 +1,5 @@
import { Command } from "commander"; import { Command } from "commander";
import { describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const callGateway = vi.fn(async () => ({ ok: true })); const callGateway = vi.fn(async () => ({ ok: true }));
const resolveGatewayProgramArguments = vi.fn(async () => ({ const resolveGatewayProgramArguments = vi.fn(async () => ({
@@ -13,8 +13,8 @@ const serviceIsLoaded = vi.fn().mockResolvedValue(false);
const serviceReadCommand = vi.fn().mockResolvedValue(null); const serviceReadCommand = vi.fn().mockResolvedValue(null);
const serviceReadRuntime = vi.fn().mockResolvedValue({ status: "running" }); const serviceReadRuntime = vi.fn().mockResolvedValue({ status: "running" });
const findExtraGatewayServices = vi.fn(async () => []); const findExtraGatewayServices = vi.fn(async () => []);
const inspectPortUsage = vi.fn(async () => ({ const inspectPortUsage = vi.fn(async (port: number) => ({
port: 18789, port,
status: "free", status: "free",
listeners: [], listeners: [],
hints: [], hints: [],
@@ -77,6 +77,39 @@ vi.mock("./deps.js", () => ({
})); }));
describe("daemon-cli coverage", () => { describe("daemon-cli coverage", () => {
const originalEnv = {
CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR,
CLAWDBOT_CONFIG_PATH: process.env.CLAWDBOT_CONFIG_PATH,
CLAWDBOT_GATEWAY_PORT: process.env.CLAWDBOT_GATEWAY_PORT,
CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE,
};
beforeEach(() => {
process.env.CLAWDBOT_STATE_DIR = "/tmp/clawdbot-cli-state";
process.env.CLAWDBOT_CONFIG_PATH = "/tmp/clawdbot-cli-state/clawdbot.json";
delete process.env.CLAWDBOT_GATEWAY_PORT;
delete process.env.CLAWDBOT_PROFILE;
serviceReadCommand.mockResolvedValue(null);
});
afterEach(() => {
if (originalEnv.CLAWDBOT_STATE_DIR !== undefined)
process.env.CLAWDBOT_STATE_DIR = originalEnv.CLAWDBOT_STATE_DIR;
else delete process.env.CLAWDBOT_STATE_DIR;
if (originalEnv.CLAWDBOT_CONFIG_PATH !== undefined)
process.env.CLAWDBOT_CONFIG_PATH = originalEnv.CLAWDBOT_CONFIG_PATH;
else delete process.env.CLAWDBOT_CONFIG_PATH;
if (originalEnv.CLAWDBOT_GATEWAY_PORT !== undefined)
process.env.CLAWDBOT_GATEWAY_PORT = originalEnv.CLAWDBOT_GATEWAY_PORT;
else delete process.env.CLAWDBOT_GATEWAY_PORT;
if (originalEnv.CLAWDBOT_PROFILE !== undefined)
process.env.CLAWDBOT_PROFILE = originalEnv.CLAWDBOT_PROFILE;
else delete process.env.CLAWDBOT_PROFILE;
});
it("probes gateway status by default", async () => { it("probes gateway status by default", async () => {
runtimeLogs.length = 0; runtimeLogs.length = 0;
runtimeErrors.length = 0; runtimeErrors.length = 0;
@@ -97,6 +130,51 @@ describe("daemon-cli coverage", () => {
expect(inspectPortUsage).toHaveBeenCalled(); expect(inspectPortUsage).toHaveBeenCalled();
}); });
it("derives probe URL from service args + env (json)", async () => {
runtimeLogs.length = 0;
runtimeErrors.length = 0;
callGateway.mockClear();
inspectPortUsage.mockClear();
serviceReadCommand.mockResolvedValueOnce({
programArguments: ["/bin/node", "cli", "gateway", "--port", "19001"],
environment: {
CLAWDBOT_PROFILE: "dev",
CLAWDBOT_STATE_DIR: "/tmp/clawdbot-daemon-state",
CLAWDBOT_CONFIG_PATH: "/tmp/clawdbot-daemon-state/clawdbot.json",
CLAWDBOT_GATEWAY_PORT: "19001",
},
sourcePath: "/tmp/com.clawdbot.gateway.plist",
});
const { registerDaemonCli } = await import("./daemon-cli.js");
const program = new Command();
program.exitOverride();
registerDaemonCli(program);
await program.parseAsync(["daemon", "status", "--json"], { from: "user" });
expect(callGateway).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:19001",
method: "status",
}),
);
expect(inspectPortUsage).toHaveBeenCalledWith(19001);
const parsed = JSON.parse(runtimeLogs[0] ?? "{}") as {
gateway?: { port?: number; portSource?: string; probeUrl?: string };
config?: { mismatch?: boolean };
rpc?: { url?: string; ok?: boolean };
};
expect(parsed.gateway?.port).toBe(19001);
expect(parsed.gateway?.portSource).toBe("service args");
expect(parsed.gateway?.probeUrl).toBe("ws://127.0.0.1:19001");
expect(parsed.config?.mismatch).toBe(true);
expect(parsed.rpc?.url).toBe("ws://127.0.0.1:19001");
expect(parsed.rpc?.ok).toBe(true);
});
it("passes deep scan flag for daemon status", async () => { it("passes deep scan flag for daemon status", async () => {
findExtraGatewayServices.mockClear(); findExtraGatewayServices.mockClear();

View File

@@ -18,12 +18,12 @@ import {
GATEWAY_SYSTEMD_SERVICE_NAME, GATEWAY_SYSTEMD_SERVICE_NAME,
GATEWAY_WINDOWS_TASK_NAME, GATEWAY_WINDOWS_TASK_NAME,
} from "../daemon/constants.js"; } from "../daemon/constants.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import { import {
type FindExtraGatewayServicesOptions, type FindExtraGatewayServicesOptions,
findExtraGatewayServices, findExtraGatewayServices,
renderGatewayServiceCleanupHints, renderGatewayServiceCleanupHints,
} from "../daemon/inspect.js"; } from "../daemon/inspect.js";
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
import { resolveGatewayLogPaths } from "../daemon/launchd.js"; import { resolveGatewayLogPaths } from "../daemon/launchd.js";
import { findLegacyGatewayServices } from "../daemon/legacy.js"; import { findLegacyGatewayServices } from "../daemon/legacy.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
@@ -165,8 +165,11 @@ function parsePortFromArgs(
return null; return null;
} }
function pickProbeHostForBind(bindMode: string, tailnetIPv4: string | null) { function pickProbeHostForBind(
if (bindMode === "tailnet") return tailnetIPv4; bindMode: string,
tailnetIPv4: string | undefined,
) {
if (bindMode === "tailnet") return tailnetIPv4 ?? "127.0.0.1";
if (bindMode === "auto") return tailnetIPv4 ?? "127.0.0.1"; if (bindMode === "auto") return tailnetIPv4 ?? "127.0.0.1";
return "127.0.0.1"; return "127.0.0.1";
} }
@@ -330,7 +333,10 @@ async function gatherDaemonStatus(opts: {
...(serviceEnv ?? {}), ...(serviceEnv ?? {}),
} satisfies Record<string, string | undefined>; } satisfies Record<string, string | undefined>;
const cliConfigPath = resolveConfigPath(process.env, resolveStateDir(process.env)); const cliConfigPath = resolveConfigPath(
process.env,
resolveStateDir(process.env),
);
const daemonConfigPath = resolveConfigPath( const daemonConfigPath = resolveConfigPath(
mergedDaemonEnv as NodeJS.ProcessEnv, mergedDaemonEnv as NodeJS.ProcessEnv,
resolveStateDir(mergedDaemonEnv as NodeJS.ProcessEnv), resolveStateDir(mergedDaemonEnv as NodeJS.ProcessEnv),
@@ -359,12 +365,15 @@ async function gatherDaemonStatus(opts: {
path: daemonSnapshot?.path ?? daemonConfigPath, path: daemonSnapshot?.path ?? daemonConfigPath,
exists: daemonSnapshot?.exists ?? false, exists: daemonSnapshot?.exists ?? false,
valid: daemonSnapshot?.valid ?? true, valid: daemonSnapshot?.valid ?? true,
...(daemonSnapshot?.issues?.length ? { issues: daemonSnapshot.issues } : {}), ...(daemonSnapshot?.issues?.length
? { issues: daemonSnapshot.issues }
: {}),
}; };
const configMismatch = cliConfigSummary.path !== daemonConfigSummary.path; const configMismatch = cliConfigSummary.path !== daemonConfigSummary.path;
const portFromArgs = parsePortFromArgs(command?.programArguments); const portFromArgs = parsePortFromArgs(command?.programArguments);
const daemonPort = portFromArgs ?? resolveGatewayPort(daemonCfg, mergedDaemonEnv); const daemonPort =
portFromArgs ?? resolveGatewayPort(daemonCfg, mergedDaemonEnv);
const portSource: GatewayStatusSummary["portSource"] = portFromArgs const portSource: GatewayStatusSummary["portSource"] = portFromArgs
? "service args" ? "service args"
: "env/config"; : "env/config";
@@ -510,7 +519,9 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
defaultRuntime.log(`Config (cli): ${cliCfg}`); defaultRuntime.log(`Config (cli): ${cliCfg}`);
if (!status.config.cli.valid && status.config.cli.issues?.length) { if (!status.config.cli.valid && status.config.cli.issues?.length) {
for (const issue of status.config.cli.issues.slice(0, 5)) { for (const issue of status.config.cli.issues.slice(0, 5)) {
defaultRuntime.error(`Config issue: ${issue.path || "<root>"}: ${issue.message}`); defaultRuntime.error(
`Config issue: ${issue.path || "<root>"}: ${issue.message}`,
);
} }
} }
if (status.config.daemon) { if (status.config.daemon) {
@@ -555,7 +566,9 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
} else { } else {
defaultRuntime.error("RPC probe: failed"); defaultRuntime.error("RPC probe: failed");
if (rpc.url) defaultRuntime.error(`RPC target: ${rpc.url}`); if (rpc.url) defaultRuntime.error(`RPC target: ${rpc.url}`);
const lines = String(rpc.error ?? "unknown").split(/\r?\n/).filter(Boolean); const lines = String(rpc.error ?? "unknown")
.split(/\r?\n/)
.filter(Boolean);
for (const line of lines.slice(0, 12)) { for (const line of lines.slice(0, 12)) {
defaultRuntime.error(` ${line}`); defaultRuntime.error(` ${line}`);
} }

View File

@@ -39,7 +39,16 @@ export function resolveGatewayLogPaths(
stderrPath: string; stderrPath: string;
} { } {
const home = resolveHomeDir(env); const home = resolveHomeDir(env);
const logDir = path.join(home, ".clawdbot", "logs"); const stateOverride =
env.CLAWDBOT_STATE_DIR?.trim() || env.CLAWDIS_STATE_DIR?.trim();
const profile = env.CLAWDBOT_PROFILE?.trim();
const suffix =
profile && profile.toLowerCase() !== "default" ? `-${profile}` : "";
const defaultStateDir = path.join(home, `.clawdbot${suffix}`);
const stateDir = stateOverride
? resolveUserPathWithHome(stateOverride, home)
: defaultStateDir;
const logDir = path.join(stateDir, "logs");
return { return {
logDir, logDir,
stdoutPath: path.join(logDir, "gateway.log"), stdoutPath: path.join(logDir, "gateway.log"),
@@ -47,6 +56,16 @@ export function resolveGatewayLogPaths(
}; };
} }
function resolveUserPathWithHome(input: string, home: string): string {
const trimmed = input.trim();
if (!trimmed) return trimmed;
if (trimmed.startsWith("~")) {
const expanded = trimmed.replace(/^~(?=$|[\\/])/, home);
return path.resolve(expanded);
}
return path.resolve(trimmed);
}
function plistEscape(value: string): string { function plistEscape(value: string): string {
return value return value
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")
@@ -88,7 +107,12 @@ function renderEnvDict(
export async function readLaunchAgentProgramArguments( export async function readLaunchAgentProgramArguments(
env: Record<string, string | undefined>, env: Record<string, string | undefined>,
): Promise<{ programArguments: string[]; workingDirectory?: string } | null> { ): Promise<{
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string>;
sourcePath?: string;
} | null> {
const plistPath = resolveLaunchAgentPlistPath(env); const plistPath = resolveLaunchAgentPlistPath(env);
try { try {
const plist = await fs.readFile(plistPath, "utf8"); const plist = await fs.readFile(plistPath, "utf8");
@@ -105,9 +129,25 @@ export async function readLaunchAgentProgramArguments(
const workingDirectory = workingDirMatch const workingDirectory = workingDirMatch
? plistUnescape(workingDirMatch[1] ?? "").trim() ? plistUnescape(workingDirMatch[1] ?? "").trim()
: ""; : "";
const envMatch = plist.match(
/<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/i,
);
const environment: Record<string, string> = {};
if (envMatch) {
for (const pair of envMatch[1].matchAll(
/<key>([\s\S]*?)<\/key>\s*<string>([\s\S]*?)<\/string>/gi,
)) {
const key = plistUnescape(pair[1] ?? "").trim();
if (!key) continue;
const value = plistUnescape(pair[2] ?? "").trim();
environment[key] = value;
}
}
return { return {
programArguments: args.filter(Boolean), programArguments: args.filter(Boolean),
...(workingDirectory ? { workingDirectory } : {}), ...(workingDirectory ? { workingDirectory } : {}),
...(Object.keys(environment).length > 0 ? { environment } : {}),
sourcePath: plistPath,
}; };
} catch { } catch {
return null; return null;

View File

@@ -52,6 +52,8 @@ export type GatewayService = {
readCommand: (env: Record<string, string | undefined>) => Promise<{ readCommand: (env: Record<string, string | undefined>) => Promise<{
programArguments: string[]; programArguments: string[];
workingDirectory?: string; workingDirectory?: string;
environment?: Record<string, string>;
sourcePath?: string;
} | null>; } | null>;
readRuntime: ( readRuntime: (
env: Record<string, string | undefined>, env: Record<string, string | undefined>,

View File

@@ -191,12 +191,18 @@ function parseSystemdExecStart(value: string): string[] {
export async function readSystemdServiceExecStart( export async function readSystemdServiceExecStart(
env: Record<string, string | undefined>, env: Record<string, string | undefined>,
): Promise<{ programArguments: string[]; workingDirectory?: string } | null> { ): Promise<{
programArguments: string[];
workingDirectory?: string;
environment?: Record<string, string>;
sourcePath?: string;
} | null> {
const unitPath = resolveSystemdUnitPath(env); const unitPath = resolveSystemdUnitPath(env);
try { try {
const content = await fs.readFile(unitPath, "utf8"); const content = await fs.readFile(unitPath, "utf8");
let execStart = ""; let execStart = "";
let workingDirectory = ""; let workingDirectory = "";
const environment: Record<string, string> = {};
for (const rawLine of content.split("\n")) { for (const rawLine of content.split("\n")) {
const line = rawLine.trim(); const line = rawLine.trim();
if (!line || line.startsWith("#")) continue; if (!line || line.startsWith("#")) continue;
@@ -204,6 +210,10 @@ export async function readSystemdServiceExecStart(
execStart = line.slice("ExecStart=".length).trim(); execStart = line.slice("ExecStart=".length).trim();
} else if (line.startsWith("WorkingDirectory=")) { } else if (line.startsWith("WorkingDirectory=")) {
workingDirectory = line.slice("WorkingDirectory=".length).trim(); workingDirectory = line.slice("WorkingDirectory=".length).trim();
} else if (line.startsWith("Environment=")) {
const raw = line.slice("Environment=".length).trim();
const parsed = parseSystemdEnvAssignment(raw);
if (parsed) environment[parsed.key] = parsed.value;
} }
} }
if (!execStart) return null; if (!execStart) return null;
@@ -211,12 +221,47 @@ export async function readSystemdServiceExecStart(
return { return {
programArguments, programArguments,
...(workingDirectory ? { workingDirectory } : {}), ...(workingDirectory ? { workingDirectory } : {}),
...(Object.keys(environment).length > 0 ? { environment } : {}),
sourcePath: unitPath,
}; };
} catch { } catch {
return null; return null;
} }
} }
function parseSystemdEnvAssignment(
raw: string,
): { key: string; value: string } | null {
const trimmed = raw.trim();
if (!trimmed) return null;
const unquoted = (() => {
if (!(trimmed.startsWith('"') && trimmed.endsWith('"'))) return trimmed;
let out = "";
let escapeNext = false;
for (const ch of trimmed.slice(1, -1)) {
if (escapeNext) {
out += ch;
escapeNext = false;
continue;
}
if (ch === "\\") {
escapeNext = true;
continue;
}
out += ch;
}
return out;
})();
const eq = unquoted.indexOf("=");
if (eq <= 0) return null;
const key = unquoted.slice(0, eq).trim();
if (!key) return null;
const value = unquoted.slice(eq + 1);
return { key, value };
}
export type SystemdServiceInfo = { export type SystemdServiceInfo = {
activeState?: string; activeState?: string;
subState?: string; subState?: string;

View File

@@ -1,6 +1,11 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig, resolveGatewayPort } from "../config/config.js"; import {
loadConfig,
resolveConfigPath,
resolveGatewayPort,
resolveStateDir,
} from "../config/config.js";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
import { GatewayClient } from "./client.js"; import { GatewayClient } from "./client.js";
import { PROTOCOL_VERSION } from "./protocol/index.js"; import { PROTOCOL_VERSION } from "./protocol/index.js";
@@ -34,6 +39,10 @@ export function buildGatewayConnectionDetails(
options: { config?: ClawdbotConfig; url?: string } = {}, options: { config?: ClawdbotConfig; url?: string } = {},
): GatewayConnectionDetails { ): GatewayConnectionDetails {
const config = options.config ?? loadConfig(); const config = options.config ?? loadConfig();
const configPath = resolveConfigPath(
process.env,
resolveStateDir(process.env),
);
const isRemoteMode = config.gateway?.mode === "remote"; const isRemoteMode = config.gateway?.mode === "remote";
const remote = isRemoteMode ? config.gateway?.remote : undefined; const remote = isRemoteMode ? config.gateway?.remote : undefined;
const localPort = resolveGatewayPort(config); const localPort = resolveGatewayPort(config);
@@ -70,6 +79,7 @@ export function buildGatewayConnectionDetails(
const message = [ const message = [
`Gateway target: ${url}`, `Gateway target: ${url}`,
`Source: ${urlSource}`, `Source: ${urlSource}`,
`Config: ${configPath}`,
bindDetail, bindDetail,
remoteFallbackNote, remoteFallbackNote,
] ]

View File

@@ -50,6 +50,10 @@ function parseLsofFieldOutput(output: string): PortListener[] {
current = Number.isFinite(pid) ? { pid } : {}; current = Number.isFinite(pid) ? { pid } : {};
} else if (line.startsWith("c")) { } else if (line.startsWith("c")) {
current.command = line.slice(1); current.command = line.slice(1);
} else if (line.startsWith("n")) {
// TCP 127.0.0.1:18789 (LISTEN)
// TCP *:18789 (LISTEN)
if (!current.address) current.address = line.slice(1);
} }
} }
if (current.pid || current.command) listeners.push(current); if (current.pid || current.command) listeners.push(current);
@@ -81,7 +85,7 @@ async function readUnixListeners(
"-nP", "-nP",
`-iTCP:${port}`, `-iTCP:${port}`,
"-sTCP:LISTEN", "-sTCP:LISTEN",
"-FpFc", "-FpFcn",
]); ]);
if (res.code === 0) { if (res.code === 0) {
const listeners = parseLsofFieldOutput(res.stdout); const listeners = parseLsofFieldOutput(res.stdout);