From a676e16fbb14f7ad3b79ed11e2bc094313dcfcef Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 08:24:28 +0100 Subject: [PATCH] feat: expand daemon status diagnostics --- src/cli/daemon-cli.coverage.test.ts | 84 +++++++++++++++++++++++++++-- src/cli/daemon-cli.ts | 29 +++++++--- src/daemon/launchd.ts | 44 ++++++++++++++- src/daemon/service.ts | 2 + src/daemon/systemd.ts | 47 +++++++++++++++- src/gateway/call.ts | 12 ++++- src/infra/ports-inspect.ts | 6 ++- 7 files changed, 208 insertions(+), 16 deletions(-) diff --git a/src/cli/daemon-cli.coverage.test.ts b/src/cli/daemon-cli.coverage.test.ts index ac1de8161..98ec0303c 100644 --- a/src/cli/daemon-cli.coverage.test.ts +++ b/src/cli/daemon-cli.coverage.test.ts @@ -1,5 +1,5 @@ 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 resolveGatewayProgramArguments = vi.fn(async () => ({ @@ -13,8 +13,8 @@ const serviceIsLoaded = vi.fn().mockResolvedValue(false); const serviceReadCommand = vi.fn().mockResolvedValue(null); const serviceReadRuntime = vi.fn().mockResolvedValue({ status: "running" }); const findExtraGatewayServices = vi.fn(async () => []); -const inspectPortUsage = vi.fn(async () => ({ - port: 18789, +const inspectPortUsage = vi.fn(async (port: number) => ({ + port, status: "free", listeners: [], hints: [], @@ -77,6 +77,39 @@ vi.mock("./deps.js", () => ({ })); 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 () => { runtimeLogs.length = 0; runtimeErrors.length = 0; @@ -97,6 +130,51 @@ describe("daemon-cli coverage", () => { 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 () => { findExtraGatewayServices.mockClear(); diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts index 2dda39462..552161684 100644 --- a/src/cli/daemon-cli.ts +++ b/src/cli/daemon-cli.ts @@ -18,12 +18,12 @@ import { GATEWAY_SYSTEMD_SERVICE_NAME, GATEWAY_WINDOWS_TASK_NAME, } from "../daemon/constants.js"; +import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; import { type FindExtraGatewayServicesOptions, findExtraGatewayServices, renderGatewayServiceCleanupHints, } from "../daemon/inspect.js"; -import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; import { resolveGatewayLogPaths } from "../daemon/launchd.js"; import { findLegacyGatewayServices } from "../daemon/legacy.js"; import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; @@ -165,8 +165,11 @@ function parsePortFromArgs( return null; } -function pickProbeHostForBind(bindMode: string, tailnetIPv4: string | null) { - if (bindMode === "tailnet") return tailnetIPv4; +function pickProbeHostForBind( + bindMode: string, + tailnetIPv4: string | undefined, +) { + if (bindMode === "tailnet") return tailnetIPv4 ?? "127.0.0.1"; if (bindMode === "auto") return tailnetIPv4 ?? "127.0.0.1"; return "127.0.0.1"; } @@ -330,7 +333,10 @@ async function gatherDaemonStatus(opts: { ...(serviceEnv ?? {}), } satisfies Record; - const cliConfigPath = resolveConfigPath(process.env, resolveStateDir(process.env)); + const cliConfigPath = resolveConfigPath( + process.env, + resolveStateDir(process.env), + ); const daemonConfigPath = resolveConfigPath( mergedDaemonEnv as NodeJS.ProcessEnv, resolveStateDir(mergedDaemonEnv as NodeJS.ProcessEnv), @@ -359,12 +365,15 @@ async function gatherDaemonStatus(opts: { path: daemonSnapshot?.path ?? daemonConfigPath, exists: daemonSnapshot?.exists ?? false, valid: daemonSnapshot?.valid ?? true, - ...(daemonSnapshot?.issues?.length ? { issues: daemonSnapshot.issues } : {}), + ...(daemonSnapshot?.issues?.length + ? { issues: daemonSnapshot.issues } + : {}), }; const configMismatch = cliConfigSummary.path !== daemonConfigSummary.path; const portFromArgs = parsePortFromArgs(command?.programArguments); - const daemonPort = portFromArgs ?? resolveGatewayPort(daemonCfg, mergedDaemonEnv); + const daemonPort = + portFromArgs ?? resolveGatewayPort(daemonCfg, mergedDaemonEnv); const portSource: GatewayStatusSummary["portSource"] = portFromArgs ? "service args" : "env/config"; @@ -510,7 +519,9 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { defaultRuntime.log(`Config (cli): ${cliCfg}`); if (!status.config.cli.valid && status.config.cli.issues?.length) { for (const issue of status.config.cli.issues.slice(0, 5)) { - defaultRuntime.error(`Config issue: ${issue.path || ""}: ${issue.message}`); + defaultRuntime.error( + `Config issue: ${issue.path || ""}: ${issue.message}`, + ); } } if (status.config.daemon) { @@ -555,7 +566,9 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { } else { defaultRuntime.error("RPC probe: failed"); 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)) { defaultRuntime.error(` ${line}`); } diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 6d862004c..468af6f18 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -39,7 +39,16 @@ export function resolveGatewayLogPaths( stderrPath: string; } { 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 { logDir, 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 { return value .replaceAll("&", "&") @@ -88,7 +107,12 @@ function renderEnvDict( export async function readLaunchAgentProgramArguments( env: Record, -): Promise<{ programArguments: string[]; workingDirectory?: string } | null> { +): Promise<{ + programArguments: string[]; + workingDirectory?: string; + environment?: Record; + sourcePath?: string; +} | null> { const plistPath = resolveLaunchAgentPlistPath(env); try { const plist = await fs.readFile(plistPath, "utf8"); @@ -105,9 +129,25 @@ export async function readLaunchAgentProgramArguments( const workingDirectory = workingDirMatch ? plistUnescape(workingDirMatch[1] ?? "").trim() : ""; + const envMatch = plist.match( + /EnvironmentVariables<\/key>\s*([\s\S]*?)<\/dict>/i, + ); + const environment: Record = {}; + if (envMatch) { + for (const pair of envMatch[1].matchAll( + /([\s\S]*?)<\/key>\s*([\s\S]*?)<\/string>/gi, + )) { + const key = plistUnescape(pair[1] ?? "").trim(); + if (!key) continue; + const value = plistUnescape(pair[2] ?? "").trim(); + environment[key] = value; + } + } return { programArguments: args.filter(Boolean), ...(workingDirectory ? { workingDirectory } : {}), + ...(Object.keys(environment).length > 0 ? { environment } : {}), + sourcePath: plistPath, }; } catch { return null; diff --git a/src/daemon/service.ts b/src/daemon/service.ts index 4e09e41a7..a7f45f6c1 100644 --- a/src/daemon/service.ts +++ b/src/daemon/service.ts @@ -52,6 +52,8 @@ export type GatewayService = { readCommand: (env: Record) => Promise<{ programArguments: string[]; workingDirectory?: string; + environment?: Record; + sourcePath?: string; } | null>; readRuntime: ( env: Record, diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 417c11ab0..c0a8b495b 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -191,12 +191,18 @@ function parseSystemdExecStart(value: string): string[] { export async function readSystemdServiceExecStart( env: Record, -): Promise<{ programArguments: string[]; workingDirectory?: string } | null> { +): Promise<{ + programArguments: string[]; + workingDirectory?: string; + environment?: Record; + sourcePath?: string; +} | null> { const unitPath = resolveSystemdUnitPath(env); try { const content = await fs.readFile(unitPath, "utf8"); let execStart = ""; let workingDirectory = ""; + const environment: Record = {}; for (const rawLine of content.split("\n")) { const line = rawLine.trim(); if (!line || line.startsWith("#")) continue; @@ -204,6 +210,10 @@ export async function readSystemdServiceExecStart( execStart = line.slice("ExecStart=".length).trim(); } else if (line.startsWith("WorkingDirectory=")) { 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; @@ -211,12 +221,47 @@ export async function readSystemdServiceExecStart( return { programArguments, ...(workingDirectory ? { workingDirectory } : {}), + ...(Object.keys(environment).length > 0 ? { environment } : {}), + sourcePath: unitPath, }; } catch { 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 = { activeState?: string; subState?: string; diff --git a/src/gateway/call.ts b/src/gateway/call.ts index c2819e73c..1f66463f1 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -1,6 +1,11 @@ import { randomUUID } from "node:crypto"; 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 { GatewayClient } from "./client.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; @@ -34,6 +39,10 @@ export function buildGatewayConnectionDetails( options: { config?: ClawdbotConfig; url?: string } = {}, ): GatewayConnectionDetails { const config = options.config ?? loadConfig(); + const configPath = resolveConfigPath( + process.env, + resolveStateDir(process.env), + ); const isRemoteMode = config.gateway?.mode === "remote"; const remote = isRemoteMode ? config.gateway?.remote : undefined; const localPort = resolveGatewayPort(config); @@ -70,6 +79,7 @@ export function buildGatewayConnectionDetails( const message = [ `Gateway target: ${url}`, `Source: ${urlSource}`, + `Config: ${configPath}`, bindDetail, remoteFallbackNote, ] diff --git a/src/infra/ports-inspect.ts b/src/infra/ports-inspect.ts index 5b853500d..f31b137bd 100644 --- a/src/infra/ports-inspect.ts +++ b/src/infra/ports-inspect.ts @@ -50,6 +50,10 @@ function parseLsofFieldOutput(output: string): PortListener[] { current = Number.isFinite(pid) ? { pid } : {}; } else if (line.startsWith("c")) { 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); @@ -81,7 +85,7 @@ async function readUnixListeners( "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", - "-FpFc", + "-FpFcn", ]); if (res.code === 0) { const listeners = parseLsofFieldOutput(res.stdout);