fix: improve gateway diagnostics
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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:");
|
||||
|
||||
@@ -90,6 +90,7 @@ vi.mock("../daemon/service.js", () => ({
|
||||
restart: serviceRestart,
|
||||
isLoaded: serviceIsLoaded,
|
||||
readCommand: vi.fn(),
|
||||
readRuntime: vi.fn().mockResolvedValue({ status: "running" }),
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}.`,
|
||||
|
||||
20
src/daemon/launchd.test.ts
Normal file
20
src/daemon/launchd.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
19
src/daemon/schtasks.test.ts
Normal file
19
src/daemon/schtasks.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
13
src/daemon/service-runtime.ts
Normal file
13
src/daemon/service-runtime.ts
Normal 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;
|
||||
};
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user