fix: improve gateway diagnostics

This commit is contained in:
Peter Steinberger
2026-01-08 02:28:21 +01:00
parent 02ad9eccad
commit 61f5ed8bb7
21 changed files with 1037 additions and 63 deletions

View File

@@ -11,7 +11,14 @@ const serviceStop = vi.fn().mockResolvedValue(undefined);
const serviceRestart = vi.fn().mockResolvedValue(undefined);
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,
status: "free",
listeners: [],
hints: [],
}));
const runtimeLogs: string[] = [];
const runtimeErrors: string[] = [];
@@ -43,6 +50,7 @@ vi.mock("../daemon/service.js", () => ({
restart: serviceRestart,
isLoaded: serviceIsLoaded,
readCommand: serviceReadCommand,
readRuntime: serviceReadRuntime,
}),
}));
@@ -55,6 +63,11 @@ vi.mock("../daemon/inspect.js", () => ({
findExtraGatewayServices(env, opts),
}));
vi.mock("../infra/ports.js", () => ({
inspectPortUsage: (port: number) => inspectPortUsage(port),
formatPortDiagnostics: () => ["Port 18789 is already in use."],
}));
vi.mock("../runtime.js", () => ({
defaultRuntime,
}));
@@ -81,6 +94,7 @@ describe("daemon-cli coverage", () => {
expect.objectContaining({ method: "status" }),
);
expect(findExtraGatewayServices).toHaveBeenCalled();
expect(inspectPortUsage).toHaveBeenCalled();
});
it("passes deep scan flag for daemon status", async () => {

View File

@@ -17,10 +17,17 @@ import {
findExtraGatewayServices,
renderGatewayServiceCleanupHints,
} from "../daemon/inspect.js";
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
import { findLegacyGatewayServices } from "../daemon/legacy.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolveGatewayService } from "../daemon/service.js";
import { callGateway } from "../gateway/call.js";
import {
formatPortDiagnostics,
inspectPortUsage,
type PortListener,
type PortUsageStatus,
} from "../infra/ports.js";
import { defaultRuntime } from "../runtime.js";
import { createDefaultDeps } from "./deps.js";
@@ -34,6 +41,25 @@ type DaemonStatus = {
programArguments: string[];
workingDirectory?: string;
} | null;
runtime?: {
status?: string;
state?: string;
subState?: string;
pid?: number;
lastExitStatus?: number;
lastExitReason?: string;
lastRunResult?: string;
lastRunTime?: string;
detail?: string;
cachedLabel?: boolean;
missingUnit?: boolean;
};
};
port?: {
port: number;
status: PortUsageStatus;
listeners: PortListener[];
hints: string[];
};
rpc?: {
ok: boolean;
@@ -96,6 +122,61 @@ async function probeGatewayStatus(opts: GatewayRpcOpts) {
}
}
function formatRuntimeStatus(runtime: DaemonStatus["service"]["runtime"]) {
if (!runtime) return null;
const status = runtime.status ?? "unknown";
const details: string[] = [];
if (runtime.pid) details.push(`pid ${runtime.pid}`);
if (runtime.state && runtime.state.toLowerCase() !== status) {
details.push(`state ${runtime.state}`);
}
if (runtime.subState) details.push(`sub ${runtime.subState}`);
if (runtime.lastExitStatus !== undefined) {
details.push(`last exit ${runtime.lastExitStatus}`);
}
if (runtime.lastExitReason) {
details.push(`reason ${runtime.lastExitReason}`);
}
if (runtime.lastRunResult) {
details.push(`last run ${runtime.lastRunResult}`);
}
if (runtime.lastRunTime) {
details.push(`last run time ${runtime.lastRunTime}`);
}
if (runtime.detail) details.push(runtime.detail);
return details.length > 0 ? `${status} (${details.join(", ")})` : status;
}
function shouldReportPortUsage(
status: PortUsageStatus | undefined,
rpcOk?: boolean,
) {
if (status !== "busy") return false;
if (rpcOk === true) return false;
return true;
}
function renderRuntimeHints(
runtime: DaemonStatus["service"]["runtime"],
): string[] {
if (!runtime) return [];
const hints: string[] = [];
if (runtime.status === "stopped") {
if (process.platform === "darwin") {
const logs = resolveGatewayLogPaths(process.env);
hints.push(`Logs: ${logs.stdoutPath}`);
hints.push(`Errors: ${logs.stderrPath}`);
} else if (process.platform === "linux") {
hints.push(
"Logs: journalctl --user -u clawdbot-gateway.service -n 200 --no-pager",
);
} else if (process.platform === "win32") {
hints.push('Logs: schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST');
}
}
return hints;
}
function renderGatewayServiceStartHints(): string[] {
switch (process.platform) {
case "darwin":
@@ -117,10 +198,27 @@ async function gatherDaemonStatus(opts: {
deep?: boolean;
}): Promise<DaemonStatus> {
const service = resolveGatewayService();
const [loaded, command] = await Promise.all([
const [loaded, command, runtime] = await Promise.all([
service.isLoaded({ env: process.env }).catch(() => false),
service.readCommand(process.env).catch(() => null),
service.readRuntime(process.env).catch(() => undefined),
]);
let portStatus: DaemonStatus["port"] | undefined;
try {
const cfg = loadConfig();
if (cfg.gateway?.mode !== "remote") {
const port = resolveGatewayPort(cfg, process.env);
const diagnostics = await inspectPortUsage(port);
portStatus = {
port: diagnostics.port,
status: diagnostics.status,
listeners: diagnostics.listeners,
hints: diagnostics.hints,
};
}
} catch {
portStatus = undefined;
}
const legacyServices = await findLegacyGatewayServices(process.env);
const extraServices = await findExtraGatewayServices(process.env, {
deep: opts.deep,
@@ -134,7 +232,9 @@ async function gatherDaemonStatus(opts: {
loadedText: service.loadedText,
notLoadedText: service.notLoadedText,
command,
runtime,
},
port: portStatus,
rpc,
legacyServices,
extraServices,
@@ -159,6 +259,10 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
if (service.command?.workingDirectory) {
defaultRuntime.log(`Working dir: ${service.command.workingDirectory}`);
}
const runtimeLine = formatRuntimeStatus(service.runtime);
if (runtimeLine) {
defaultRuntime.log(`Runtime: ${runtimeLine}`);
}
if (rpc) {
if (rpc.ok) {
defaultRuntime.log("RPC probe: ok");
@@ -166,6 +270,29 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
defaultRuntime.error(`RPC probe: failed (${rpc.error})`);
}
}
if (service.loaded && service.runtime?.status === "stopped") {
defaultRuntime.error(
"Service is loaded but not running (likely exited immediately).",
);
for (const hint of renderRuntimeHints(service.runtime)) {
defaultRuntime.error(hint);
}
}
if (service.runtime?.cachedLabel) {
defaultRuntime.error(
`LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
);
}
if (status.port && shouldReportPortUsage(status.port.status, rpc?.ok)) {
for (const line of formatPortDiagnostics({
port: status.port.port,
status: status.port.status,
listeners: status.port.listeners,
hints: status.port.hints,
})) {
defaultRuntime.error(line);
}
}
if (legacyServices.length > 0) {
defaultRuntime.error("Legacy Clawdis services detected:");

View File

@@ -90,6 +90,7 @@ vi.mock("../daemon/service.js", () => ({
restart: serviceRestart,
isLoaded: serviceIsLoaded,
readCommand: vi.fn(),
readRuntime: vi.fn().mockResolvedValue({ status: "running" }),
}),
}));

View File

@@ -20,6 +20,7 @@ import {
} from "../gateway/ws-logging.js";
import { setVerbose } from "../globals.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
import { createSubsystemLogger } from "../logging.js";
import { defaultRuntime } from "../runtime.js";
import {
@@ -368,6 +369,16 @@ export function registerGatewayCli(program: Command) {
defaultRuntime.error(
`Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot gateway stop`,
);
try {
const diagnostics = await inspectPortUsage(port);
if (diagnostics.status === "busy") {
for (const line of formatPortDiagnostics(diagnostics)) {
defaultRuntime.error(line);
}
}
} catch {
// ignore diagnostics failures
}
await maybeExplainGatewayServiceStop();
defaultRuntime.exit(1);
return;
@@ -578,6 +589,16 @@ export function registerGatewayCli(program: Command) {
defaultRuntime.error(
`Gateway failed to start: ${errMessage}\nIf the gateway is supervised, stop it with: clawdbot gateway stop`,
);
try {
const diagnostics = await inspectPortUsage(port);
if (diagnostics.status === "busy") {
for (const line of formatPortDiagnostics(diagnostics)) {
defaultRuntime.error(line);
}
}
} catch {
// ignore diagnostics failures
}
await maybeExplainGatewayServiceStop();
defaultRuntime.exit(1);
return;

View File

@@ -1,4 +1,5 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DEFAULT_AGENTS_FILENAME } from "../agents/workspace.js";
@@ -39,3 +40,36 @@ export async function shouldSuggestMemorySystem(
return true;
}
export type LegacyWorkspaceDetection = {
activeWorkspace: string;
legacyDirs: string[];
};
export function detectLegacyWorkspaceDirs(params: {
workspaceDir: string;
homedir?: () => string;
exists?: (value: string) => boolean;
}): LegacyWorkspaceDetection {
const homedir = params.homedir ?? os.homedir;
const exists = params.exists ?? fs.existsSync;
const home = homedir();
const activeWorkspace = path.resolve(params.workspaceDir);
const candidates = [path.join(home, "clawdis"), path.join(home, "clawdbot")];
const legacyDirs = candidates.filter((candidate) => {
if (!exists(candidate)) return false;
return path.resolve(candidate) !== activeWorkspace;
});
return { activeWorkspace, legacyDirs };
}
export function formatLegacyWorkspaceWarning(
detection: LegacyWorkspaceDetection,
): string {
return [
"Legacy workspace directories detected (may contain old agent files):",
...detection.legacyDirs.map((dir) => `- ${dir}`),
`Active workspace: ${detection.activeWorkspace}`,
"If unused, archive or move to Trash (e.g. trash ~/clawdis).",
].join("\n");
}

View File

@@ -157,6 +157,7 @@ vi.mock("../daemon/service.js", () => ({
restart: serviceRestart,
isLoaded: serviceIsLoaded,
readCommand: vi.fn(),
readRuntime: vi.fn().mockResolvedValue({ status: "running" }),
}),
}));
@@ -492,6 +493,52 @@ describe("doctor", () => {
),
).toBe(true);
});
it("warns when legacy workspace directories exist", async () => {
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/clawdbot.json",
exists: true,
raw: "{}",
parsed: {},
valid: true,
config: {
agent: { workspace: "/Users/steipete/clawd" },
},
issues: [],
legacyIssues: [],
});
note.mockClear();
const homedirSpy = vi
.spyOn(os, "homedir")
.mockReturnValue("/Users/steipete");
const realExists = fs.existsSync;
const existsSpy = vi.spyOn(fs, "existsSync").mockImplementation((value) => {
if (value === "/Users/steipete/clawdis") return true;
return realExists(value as never);
});
const { doctorCommand } = await import("./doctor.js");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await doctorCommand(runtime, { nonInteractive: true });
expect(
note.mock.calls.some(
([message, title]) =>
title === "Legacy workspace" &&
typeof message === "string" &&
message.includes("/Users/steipete/clawdis"),
),
).toBe(true);
homedirSpy.mockRestore();
existsSpy.mockRestore();
});
it("falls back to legacy sandbox image when missing", async () => {
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/clawdbot.json",

View File

@@ -5,10 +5,14 @@ import {
CONFIG_PATH_CLAWDBOT,
migrateLegacyConfig,
readConfigFileSnapshot,
resolveGatewayPort,
writeConfigFile,
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
import { resolveGatewayService } from "../daemon/service.js";
import type { GatewayServiceRuntime } from "../daemon/service-runtime.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js";
@@ -36,6 +40,8 @@ import {
runLegacyStateMigrations,
} from "./doctor-state-migrations.js";
import {
detectLegacyWorkspaceDirs,
formatLegacyWorkspaceWarning,
MEMORY_SYSTEM_PROMPT,
shouldSuggestMemorySystem,
} from "./doctor-workspace.js";
@@ -51,6 +57,62 @@ function resolveMode(cfg: ClawdbotConfig): "local" | "remote" {
return cfg.gateway?.mode === "remote" ? "remote" : "local";
}
function formatRuntimeSummary(
runtime: GatewayServiceRuntime | undefined,
): string | null {
if (!runtime) return null;
const status = runtime.status ?? "unknown";
const details: string[] = [];
if (runtime.pid) details.push(`pid ${runtime.pid}`);
if (runtime.state && runtime.state.toLowerCase() !== status) {
details.push(`state ${runtime.state}`);
}
if (runtime.subState) details.push(`sub ${runtime.subState}`);
if (runtime.lastExitStatus !== undefined) {
details.push(`last exit ${runtime.lastExitStatus}`);
}
if (runtime.lastExitReason) {
details.push(`reason ${runtime.lastExitReason}`);
}
if (runtime.lastRunResult) {
details.push(`last run ${runtime.lastRunResult}`);
}
if (runtime.lastRunTime) {
details.push(`last run time ${runtime.lastRunTime}`);
}
if (runtime.detail) details.push(runtime.detail);
return details.length > 0 ? `${status} (${details.join(", ")})` : status;
}
function buildGatewayRuntimeHints(
runtime: GatewayServiceRuntime | undefined,
): string[] {
const hints: string[] = [];
if (!runtime) return hints;
if (runtime.cachedLabel && process.platform === "darwin") {
hints.push(
`LaunchAgent label cached but plist missing. Clear with: launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}`,
);
}
if (runtime.status === "stopped") {
hints.push(
"Service is loaded but not running (likely exited immediately).",
);
if (process.platform === "darwin") {
const logs = resolveGatewayLogPaths(process.env);
hints.push(`Logs: ${logs.stdoutPath}`);
hints.push(`Errors: ${logs.stderrPath}`);
} else if (process.platform === "linux") {
hints.push(
"Logs: journalctl --user -u clawdbot-gateway.service -n 200 --no-pager",
);
} else if (process.platform === "win32") {
hints.push('Logs: schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST');
}
}
return hints;
}
export async function doctorCommand(
runtime: RuntimeEnv = defaultRuntime,
options: DoctorOptions = {},
@@ -168,6 +230,10 @@ export async function doctorCommand(
const workspaceDir = resolveUserPath(
cfg.agent?.workspace ?? DEFAULT_WORKSPACE,
);
const legacyWorkspace = detectLegacyWorkspaceDirs({ workspaceDir });
if (legacyWorkspace.legacyDirs.length > 0) {
note(formatLegacyWorkspaceWarning(legacyWorkspace), "Legacy workspace");
}
const skillsReport = buildWorkspaceSkillStatus(workspaceDir, { config: cfg });
note(
[
@@ -198,11 +264,29 @@ export async function doctorCommand(
}
if (!healthOk) {
if (resolveMode(cfg) === "local") {
const port = resolveGatewayPort(cfg, process.env);
const diagnostics = await inspectPortUsage(port);
if (diagnostics.status === "busy") {
note(formatPortDiagnostics(diagnostics).join("\n"), "Gateway port");
}
}
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
if (!loaded) {
note("Gateway daemon not installed.", "Gateway");
} else {
const serviceRuntime = await service
.readRuntime(process.env)
.catch(() => undefined);
const summary = formatRuntimeSummary(serviceRuntime);
const hints = buildGatewayRuntimeHints(serviceRuntime);
if (summary || hints.length > 0) {
const lines = [];
if (summary) lines.push(`Runtime: ${summary}`);
lines.push(...hints);
note(lines.join("\n"), "Gateway");
}
if (process.platform === "darwin") {
note(
`LaunchAgent loaded; stopping requires "clawdbot gateway stop" or launchctl bootout gui/$UID/${GATEWAY_LAUNCH_AGENT_LABEL}.`,

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { parseLaunchctlPrint } from "./launchd.js";
describe("launchd runtime parsing", () => {
it("parses state, pid, and exit status", () => {
const output = [
"state = running",
"pid = 4242",
"last exit status = 1",
"last exit reason = exited",
].join("\n");
expect(parseLaunchctlPrint(output)).toEqual({
state: "running",
pid: 4242,
lastExitStatus: 1,
lastExitReason: "exited",
});
});
});

View File

@@ -7,6 +7,7 @@ import {
GATEWAY_LAUNCH_AGENT_LABEL,
LEGACY_GATEWAY_LAUNCH_AGENT_LABELS,
} from "./constants.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
const execFileAsync = promisify(execFile);
function resolveHomeDir(env: Record<string, string | undefined>): string {
@@ -196,6 +197,38 @@ function resolveGuiDomain(): string {
return `gui/${process.getuid()}`;
}
export type LaunchctlPrintInfo = {
state?: string;
pid?: number;
lastExitStatus?: number;
lastExitReason?: string;
};
export function parseLaunchctlPrint(output: string): LaunchctlPrintInfo {
const info: LaunchctlPrintInfo = {};
for (const rawLine of output.split("\n")) {
const line = rawLine.trim();
if (!line) continue;
const match = line.match(/^([a-zA-Z\s]+?)\s*=\s*(.+)$/);
if (!match) continue;
const key = match[1]?.trim().toLowerCase();
const value = match[2]?.trim();
if (!key || value === undefined) continue;
if (key === "state") {
info.state = value;
} else if (key === "pid") {
const pid = Number.parseInt(value, 10);
if (Number.isFinite(pid)) info.pid = pid;
} else if (key === "last exit status") {
const status = Number.parseInt(value, 10);
if (Number.isFinite(status)) info.lastExitStatus = status;
} else if (key === "last exit reason") {
info.lastExitReason = value;
}
}
return info;
}
export async function isLaunchAgentLoaded(): Promise<boolean> {
const domain = resolveGuiDomain();
const label = GATEWAY_LAUNCH_AGENT_LABEL;
@@ -203,6 +236,50 @@ export async function isLaunchAgentLoaded(): Promise<boolean> {
return res.code === 0;
}
async function hasLaunchAgentPlist(
env: Record<string, string | undefined>,
): Promise<boolean> {
const plistPath = resolveLaunchAgentPlistPath(env);
try {
await fs.access(plistPath);
return true;
} catch {
return false;
}
}
export async function readLaunchAgentRuntime(
env: Record<string, string | undefined>,
): Promise<GatewayServiceRuntime> {
const domain = resolveGuiDomain();
const label = GATEWAY_LAUNCH_AGENT_LABEL;
const res = await execLaunchctl(["print", `${domain}/${label}`]);
if (res.code !== 0) {
return {
status: "unknown",
detail: (res.stderr || res.stdout).trim() || undefined,
missingUnit: true,
};
}
const parsed = parseLaunchctlPrint(res.stdout || res.stderr || "");
const plistExists = await hasLaunchAgentPlist(env);
const state = parsed.state?.toLowerCase();
const status =
state === "running" || parsed.pid
? "running"
: state
? "stopped"
: "unknown";
return {
status,
state: parsed.state,
pid: parsed.pid,
lastExitStatus: parsed.lastExitStatus,
lastExitReason: parsed.lastExitReason,
cachedLabel: !plistExists,
};
}
export type LegacyLaunchAgent = {
label: string;
plistPath: string;

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { parseSchtasksQuery } from "./schtasks.js";
describe("schtasks runtime parsing", () => {
it("parses status and last run info", () => {
const output = [
"TaskName: \\Clawdbot Gateway",
"Status: Ready",
"Last Run Time: 1/8/2026 1:23:45 AM",
"Last Run Result: 0x0",
].join("\r\n");
expect(parseSchtasksQuery(output)).toEqual({
status: "Ready",
lastRunTime: "1/8/2026 1:23:45 AM",
lastRunResult: "0x0",
});
});
});

View File

@@ -7,6 +7,7 @@ import {
GATEWAY_WINDOWS_TASK_NAME,
LEGACY_GATEWAY_WINDOWS_TASK_NAMES,
} from "./constants.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
const execFileAsync = promisify(execFile);
@@ -102,6 +103,33 @@ export async function readScheduledTaskCommand(
}
}
export type ScheduledTaskInfo = {
status?: string;
lastRunTime?: string;
lastRunResult?: string;
};
export function parseSchtasksQuery(output: string): ScheduledTaskInfo {
const info: ScheduledTaskInfo = {};
for (const rawLine of output.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const idx = line.indexOf(":");
if (idx <= 0) continue;
const key = line.slice(0, idx).trim().toLowerCase();
const value = line.slice(idx + 1).trim();
if (!value) continue;
if (key === "status") {
info.status = value;
} else if (key === "last run time") {
info.lastRunTime = value;
} else if (key === "last run result") {
info.lastRunResult = value;
}
}
return info;
}
function buildTaskScript({
programArguments,
workingDirectory,
@@ -274,6 +302,44 @@ export async function isScheduledTaskInstalled(): Promise<boolean> {
const res = await execSchtasks(["/Query", "/TN", GATEWAY_WINDOWS_TASK_NAME]);
return res.code === 0;
}
export async function readScheduledTaskRuntime(): Promise<GatewayServiceRuntime> {
try {
await assertSchtasksAvailable();
} catch (err) {
return {
status: "unknown",
detail: String(err),
};
}
const res = await execSchtasks([
"/Query",
"/TN",
GATEWAY_WINDOWS_TASK_NAME,
"/V",
"/FO",
"LIST",
]);
if (res.code !== 0) {
const detail = (res.stderr || res.stdout).trim();
const missing = detail.toLowerCase().includes("cannot find the file");
return {
status: missing ? "stopped" : "unknown",
detail: detail || undefined,
missingUnit: missing,
};
}
const parsed = parseSchtasksQuery(res.stdout || "");
const statusRaw = parsed.status?.toLowerCase();
const status =
statusRaw === "running" ? "running" : statusRaw ? "stopped" : "unknown";
return {
status,
state: parsed.status,
lastRunTime: parsed.lastRunTime,
lastRunResult: parsed.lastRunResult,
};
}
export type LegacyScheduledTask = {
name: string;
scriptPath: string;

View File

@@ -0,0 +1,13 @@
export type GatewayServiceRuntime = {
status?: "running" | "stopped" | "unknown";
state?: string;
subState?: string;
pid?: number;
lastExitStatus?: number;
lastExitReason?: string;
lastRunResult?: string;
lastRunTime?: string;
detail?: string;
cachedLabel?: boolean;
missingUnit?: boolean;
};

View File

@@ -2,6 +2,7 @@ import {
installLaunchAgent,
isLaunchAgentLoaded,
readLaunchAgentProgramArguments,
readLaunchAgentRuntime,
restartLaunchAgent,
stopLaunchAgent,
uninstallLaunchAgent,
@@ -10,14 +11,17 @@ import {
installScheduledTask,
isScheduledTaskInstalled,
readScheduledTaskCommand,
readScheduledTaskRuntime,
restartScheduledTask,
stopScheduledTask,
uninstallScheduledTask,
} from "./schtasks.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
import {
installSystemdService,
isSystemdServiceEnabled,
readSystemdServiceExecStart,
readSystemdServiceRuntime,
restartSystemdService,
stopSystemdService,
uninstallSystemdService,
@@ -49,6 +53,9 @@ export type GatewayService = {
programArguments: string[];
workingDirectory?: string;
} | null>;
readRuntime: (
env: Record<string, string | undefined>,
) => Promise<GatewayServiceRuntime>;
};
export function resolveGatewayService(): GatewayService {
@@ -71,6 +78,7 @@ export function resolveGatewayService(): GatewayService {
},
isLoaded: async () => isLaunchAgentLoaded(),
readCommand: readLaunchAgentProgramArguments,
readRuntime: readLaunchAgentRuntime,
};
}
@@ -93,6 +101,7 @@ export function resolveGatewayService(): GatewayService {
},
isLoaded: async () => isSystemdServiceEnabled(),
readCommand: readSystemdServiceExecStart,
readRuntime: async () => await readSystemdServiceRuntime(),
};
}
@@ -115,6 +124,7 @@ export function resolveGatewayService(): GatewayService {
},
isLoaded: async () => isScheduledTaskInstalled(),
readCommand: readScheduledTaskCommand,
readRuntime: async () => await readScheduledTaskRuntime(),
};
}

View File

@@ -1,43 +1,21 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { runExec } from "../process/exec.js";
import { readSystemdUserLingerStatus } from "./systemd.js";
import { describe, expect, it } from "vitest";
vi.mock("../process/exec.js", () => ({
runExec: vi.fn(),
runCommandWithTimeout: vi.fn(),
}));
import { parseSystemdShow } from "./systemd.js";
const runExecMock = vi.mocked(runExec);
describe("readSystemdUserLingerStatus", () => {
beforeEach(() => {
runExecMock.mockReset();
});
it("returns yes when loginctl reports Linger=yes", async () => {
runExecMock.mockResolvedValue({
stdout: "Linger=yes\n",
stderr: "",
describe("systemd runtime parsing", () => {
it("parses active state details", () => {
const output = [
"ActiveState=inactive",
"SubState=dead",
"MainPID=0",
"ExecMainStatus=2",
"ExecMainCode=exited",
].join("\n");
expect(parseSystemdShow(output)).toEqual({
activeState: "inactive",
subState: "dead",
execMainStatus: 2,
execMainCode: "exited",
});
const result = await readSystemdUserLingerStatus({ USER: "tobi" });
expect(result).toEqual({ user: "tobi", linger: "yes" });
});
it("returns no when loginctl reports Linger=no", async () => {
runExecMock.mockResolvedValue({
stdout: "Linger=no\n",
stderr: "",
});
const result = await readSystemdUserLingerStatus({ USER: "tobi" });
expect(result).toEqual({ user: "tobi", linger: "no" });
});
it("returns null when Linger is missing", async () => {
runExecMock.mockResolvedValue({
stdout: "UID=1000\n",
stderr: "",
});
const result = await readSystemdUserLingerStatus({ USER: "tobi" });
expect(result).toBeNull();
});
});

View File

@@ -8,6 +8,7 @@ import {
GATEWAY_SYSTEMD_SERVICE_NAME,
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
} from "./constants.js";
import type { GatewayServiceRuntime } from "./service-runtime.js";
const execFileAsync = promisify(execFile);
@@ -215,6 +216,39 @@ export async function readSystemdServiceExecStart(
}
}
export type SystemdServiceInfo = {
activeState?: string;
subState?: string;
mainPid?: number;
execMainStatus?: number;
execMainCode?: string;
};
export function parseSystemdShow(output: string): SystemdServiceInfo {
const info: SystemdServiceInfo = {};
for (const rawLine of output.split("\n")) {
const line = rawLine.trim();
if (!line || !line.includes("=")) continue;
const [key, ...rest] = line.split("=");
const value = rest.join("=").trim();
if (!key) continue;
if (key === "ActiveState") {
info.activeState = value;
} else if (key === "SubState") {
info.subState = value;
} else if (key === "MainPID") {
const pid = Number.parseInt(value, 10);
if (Number.isFinite(pid) && pid > 0) info.mainPid = pid;
} else if (key === "ExecMainStatus") {
const status = Number.parseInt(value, 10);
if (Number.isFinite(status)) info.execMainStatus = status;
} else if (key === "ExecMainCode") {
info.execMainCode = value;
}
}
return info;
}
async function execSystemctl(
args: string[],
): Promise<{ stdout: string; stderr: string; code: number }> {
@@ -369,6 +403,47 @@ export async function isSystemdServiceEnabled(): Promise<boolean> {
const res = await execSystemctl(["--user", "is-enabled", unitName]);
return res.code === 0;
}
export async function readSystemdServiceRuntime(): Promise<GatewayServiceRuntime> {
try {
await assertSystemdAvailable();
} catch (err) {
return {
status: "unknown",
detail: String(err),
};
}
const unitName = `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`;
const res = await execSystemctl([
"--user",
"show",
unitName,
"--no-page",
"--property",
"ActiveState,SubState,MainPID,ExecMainStatus,ExecMainCode",
]);
if (res.code !== 0) {
const detail = (res.stderr || res.stdout).trim();
const missing = detail.toLowerCase().includes("not found");
return {
status: missing ? "stopped" : "unknown",
detail: detail || undefined,
missingUnit: missing,
};
}
const parsed = parseSystemdShow(res.stdout || "");
const activeState = parsed.activeState?.toLowerCase();
const status =
activeState === "active" ? "running" : activeState ? "stopped" : "unknown";
return {
status,
state: parsed.activeState,
subState: parsed.subState,
pid: parsed.mainPid,
lastExitStatus: parsed.execMainStatus,
lastExitReason: parsed.execMainCode,
};
}
export type LegacySystemdUnit = {
name: string;
unitPath: string;

View File

@@ -2,7 +2,10 @@ import net from "node:net";
import { describe, expect, it, vi } from "vitest";
import {
buildPortHints,
classifyPortListener,
ensurePortAvailable,
formatPortDiagnostics,
handlePortError,
PortInUseError,
} from "./ports.js";
@@ -33,4 +36,36 @@ describe("ports helpers", () => {
expect(runtime.error).toHaveBeenCalled();
expect(runtime.exit).toHaveBeenCalledWith(1);
});
it("classifies ssh and gateway listeners", () => {
expect(
classifyPortListener(
{ commandLine: "ssh -N -L 18789:127.0.0.1:18789 user@host" },
18789,
),
).toBe("ssh");
expect(
classifyPortListener(
{
commandLine: "node /Users/me/Projects/clawdbot/dist/entry.js gateway",
},
18789,
),
).toBe("gateway");
});
it("formats port diagnostics with hints", () => {
const diagnostics = {
port: 18789,
status: "busy" as const,
listeners: [{ pid: 123, commandLine: "ssh -N -L 18789:127.0.0.1:18789" }],
hints: buildPortHints(
[{ pid: 123, commandLine: "ssh -N -L 18789:127.0.0.1:18789" }],
18789,
),
};
const lines = formatPortDiagnostics(diagnostics);
expect(lines[0]).toContain("Port 18789 is already in use");
expect(lines.some((line) => line.includes("SSH tunnel"))).toBe(true);
});
});

View File

@@ -1,13 +1,7 @@
import net from "node:net";
import {
danger,
info,
logVerbose,
shouldLogVerbose,
warn,
} from "../globals.js";
import { danger, info, shouldLogVerbose, warn } from "../globals.js";
import { logDebug } from "../logger.js";
import { runExec } from "../process/exec.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
class PortInUseError extends Error {
@@ -29,20 +23,9 @@ function isErrno(err: unknown): err is NodeJS.ErrnoException {
export async function describePortOwner(
port: number,
): Promise<string | undefined> {
// Best-effort process info for a listening port (macOS/Linux).
try {
const { stdout } = await runExec("lsof", [
"-i",
`tcp:${port}`,
"-sTCP:LISTEN",
"-nP",
]);
const trimmed = stdout.trim();
if (trimmed) return trimmed;
} catch (err) {
logVerbose(`lsof unavailable: ${String(err)}`);
}
return undefined;
const diagnostics = await inspectPortUsage(port);
if (diagnostics.listeners.length === 0) return undefined;
return formatPortDiagnostics(diagnostics).join("\n");
}
export async function ensurePortAvailable(port: number): Promise<void> {
@@ -111,3 +94,332 @@ export async function handlePortError(
}
export { PortInUseError };
export type PortListener = {
pid?: number;
command?: string;
commandLine?: string;
user?: string;
address?: string;
};
export type PortUsageStatus = "free" | "busy" | "unknown";
export type PortUsage = {
port: number;
status: PortUsageStatus;
listeners: PortListener[];
hints: string[];
detail?: string;
errors?: string[];
};
type CommandResult = {
stdout: string;
stderr: string;
code: number;
error?: string;
};
async function runCommandSafe(
argv: string[],
timeoutMs = 5_000,
): Promise<CommandResult> {
try {
const res = await runCommandWithTimeout(argv, { timeoutMs });
return {
stdout: res.stdout,
stderr: res.stderr,
code: res.code ?? 1,
};
} catch (err) {
return {
stdout: "",
stderr: "",
code: 1,
error: String(err),
};
}
}
function parseLsofFieldOutput(output: string): PortListener[] {
const lines = output.split(/\r?\n/).filter(Boolean);
const listeners: PortListener[] = [];
let current: PortListener = {};
for (const line of lines) {
if (line.startsWith("p")) {
if (current.pid || current.command) listeners.push(current);
const pid = Number.parseInt(line.slice(1), 10);
current = Number.isFinite(pid) ? { pid } : {};
} else if (line.startsWith("c")) {
current.command = line.slice(1);
}
}
if (current.pid || current.command) listeners.push(current);
return listeners;
}
async function resolveUnixCommandLine(
pid: number,
): Promise<string | undefined> {
const res = await runCommandSafe(["ps", "-p", String(pid), "-o", "command="]);
if (res.code !== 0) return undefined;
const line = res.stdout.trim();
return line || undefined;
}
async function resolveUnixUser(pid: number): Promise<string | undefined> {
const res = await runCommandSafe(["ps", "-p", String(pid), "-o", "user="]);
if (res.code !== 0) return undefined;
const line = res.stdout.trim();
return line || undefined;
}
async function readUnixListeners(
port: number,
): Promise<{ listeners: PortListener[]; detail?: string; errors: string[] }> {
const errors: string[] = [];
const res = await runCommandSafe([
"lsof",
"-nP",
`-iTCP:${port}`,
"-sTCP:LISTEN",
"-FpFc",
]);
if (res.code === 0) {
const listeners = parseLsofFieldOutput(res.stdout);
await Promise.all(
listeners.map(async (listener) => {
if (!listener.pid) return;
const [commandLine, user] = await Promise.all([
resolveUnixCommandLine(listener.pid),
resolveUnixUser(listener.pid),
]);
if (commandLine) listener.commandLine = commandLine;
if (user) listener.user = user;
}),
);
return { listeners, detail: res.stdout.trim() || undefined, errors };
}
if (res.code === 1) {
return { listeners: [], detail: undefined, errors };
}
if (res.error) errors.push(res.error);
const detail = [res.stderr.trim(), res.stdout.trim()]
.filter(Boolean)
.join("\n");
if (detail) errors.push(detail);
return { listeners: [], detail: undefined, errors };
}
function parseNetstatListeners(output: string, port: number): PortListener[] {
const listeners: PortListener[] = [];
const portToken = `:${port}`;
for (const rawLine of output.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
if (!line.toLowerCase().includes("listen")) continue;
if (!line.includes(portToken)) continue;
const parts = line.split(/\s+/);
if (parts.length < 4) continue;
const pidRaw = parts.at(-1);
const pid = pidRaw ? Number.parseInt(pidRaw, 10) : NaN;
const localAddr = parts[1];
const listener: PortListener = {};
if (Number.isFinite(pid)) listener.pid = pid;
if (localAddr?.includes(portToken)) listener.address = localAddr;
listeners.push(listener);
}
return listeners;
}
async function resolveWindowsImageName(
pid: number,
): Promise<string | undefined> {
const res = await runCommandSafe([
"tasklist",
"/FI",
`PID eq ${pid}`,
"/FO",
"LIST",
]);
if (res.code !== 0) return undefined;
for (const rawLine of res.stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line.toLowerCase().startsWith("image name:")) continue;
const value = line.slice("image name:".length).trim();
return value || undefined;
}
return undefined;
}
async function resolveWindowsCommandLine(
pid: number,
): Promise<string | undefined> {
const res = await runCommandSafe([
"wmic",
"process",
"where",
`ProcessId=${pid}`,
"get",
"CommandLine",
"/value",
]);
if (res.code !== 0) return undefined;
for (const rawLine of res.stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line.toLowerCase().startsWith("commandline=")) continue;
const value = line.slice("commandline=".length).trim();
return value || undefined;
}
return undefined;
}
async function readWindowsListeners(
port: number,
): Promise<{ listeners: PortListener[]; detail?: string; errors: string[] }> {
const errors: string[] = [];
const res = await runCommandSafe(["netstat", "-ano", "-p", "tcp"]);
if (res.code !== 0) {
if (res.error) errors.push(res.error);
const detail = [res.stderr.trim(), res.stdout.trim()]
.filter(Boolean)
.join("\n");
if (detail) errors.push(detail);
return { listeners: [], errors };
}
const listeners = parseNetstatListeners(res.stdout, port);
await Promise.all(
listeners.map(async (listener) => {
if (!listener.pid) return;
const [imageName, commandLine] = await Promise.all([
resolveWindowsImageName(listener.pid),
resolveWindowsCommandLine(listener.pid),
]);
if (imageName) listener.command = imageName;
if (commandLine) listener.commandLine = commandLine;
}),
);
return { listeners, detail: res.stdout.trim() || undefined, errors };
}
async function checkPortInUse(port: number): Promise<PortUsageStatus> {
try {
await new Promise<void>((resolve, reject) => {
const tester = net
.createServer()
.once("error", (err) => reject(err))
.once("listening", () => {
tester.close(() => resolve());
})
.listen(port);
});
return "free";
} catch (err) {
if (err instanceof PortInUseError) return "busy";
if (isErrno(err) && err.code === "EADDRINUSE") return "busy";
return "unknown";
}
}
export type PortListenerKind = "gateway" | "ssh" | "unknown";
export function classifyPortListener(
listener: PortListener,
port: number,
): PortListenerKind {
const raw = `${listener.commandLine ?? ""} ${listener.command ?? ""}`
.trim()
.toLowerCase();
if (raw.includes("clawdbot") || raw.includes("clawdis")) return "gateway";
if (raw.includes("ssh")) {
const portToken = String(port);
const tunnelPattern = new RegExp(
`-(l|r)\\s*${portToken}\\b|-(l|r)${portToken}\\b|:${portToken}\\b`,
);
if (!raw || tunnelPattern.test(raw)) return "ssh";
return "ssh";
}
return "unknown";
}
export function buildPortHints(
listeners: PortListener[],
port: number,
): string[] {
if (listeners.length === 0) return [];
const kinds = new Set(
listeners.map((listener) => classifyPortListener(listener, port)),
);
const hints: string[] = [];
if (kinds.has("gateway")) {
hints.push(
"Gateway already running locally. Stop it (clawdbot gateway stop) or use a different port.",
);
}
if (kinds.has("ssh")) {
hints.push(
"SSH tunnel already bound to this port. Close the tunnel or use a different local port in -L.",
);
}
if (kinds.has("unknown")) {
hints.push("Another process is listening on this port.");
}
if (listeners.length > 1) {
hints.push("Multiple listeners detected; ensure only one gateway/tunnel.");
}
return hints;
}
export function formatPortListener(listener: PortListener): string {
const pid = listener.pid ? `pid ${listener.pid}` : "pid ?";
const user = listener.user ? ` ${listener.user}` : "";
const command = listener.commandLine || listener.command || "unknown";
const address = listener.address ? ` (${listener.address})` : "";
return `${pid}${user}: ${command}${address}`;
}
export function formatPortDiagnostics(diagnostics: PortUsage): string[] {
if (diagnostics.status !== "busy") {
return [`Port ${diagnostics.port} is free.`];
}
const lines = [`Port ${diagnostics.port} is already in use.`];
for (const listener of diagnostics.listeners) {
lines.push(`- ${formatPortListener(listener)}`);
}
for (const hint of diagnostics.hints) {
lines.push(`- ${hint}`);
}
return lines;
}
export async function inspectPortUsage(port: number): Promise<PortUsage> {
const errors: string[] = [];
const result =
process.platform === "win32"
? await readWindowsListeners(port)
: await readUnixListeners(port);
errors.push(...result.errors);
let listeners = result.listeners;
let status: PortUsageStatus = listeners.length > 0 ? "busy" : "unknown";
if (listeners.length === 0) {
status = await checkPortInUse(port);
}
if (status !== "busy") {
listeners = [];
}
const hints = buildPortHints(listeners, port);
if (status === "busy" && listeners.length === 0) {
hints.push(
"Port is in use but process details are unavailable (install lsof or run as an admin user).",
);
}
return {
port,
status,
listeners,
hints,
detail: result.detail,
errors: errors.length > 0 ? errors : undefined,
};
}