refactor: split port diagnostics helpers

This commit is contained in:
Peter Steinberger
2026-01-08 02:42:25 +01:00
parent 2fe3b483b1
commit 3d0156890c
12 changed files with 516 additions and 445 deletions

View File

@@ -37,6 +37,19 @@ file creation:
{ agent: { skipBootstrap: true } } { agent: { skipBootstrap: true } }
``` ```
## Legacy workspace folders
Older installs may have created `~/clawdis` or `~/clawdbot`. Keeping multiple
workspace directories around can cause confusing auth or state drift, because
only one workspace is active at a time.
**Recommendation:** keep a single active workspace. If you no longer use the
legacy folders, archive or move them to Trash (for example `trash ~/clawdis`).
If you intentionally keep multiple workspaces, make sure
`agent.workspace` points to the active one.
`clawdbot doctor` warns when it detects legacy workspace directories.
## Workspace file map (what each file means) ## Workspace file map (what each file means)
These are the standard files Clawdbot expects inside the workspace: These are the standard files Clawdbot expects inside the workspace:

View File

@@ -39,6 +39,15 @@ clawdbot daemon status
It will show the listener(s) and likely causes (gateway already running, SSH tunnel). It will show the listener(s) and likely causes (gateway already running, SSH tunnel).
If needed, stop the service or pick a different port. If needed, stop the service or pick a different port.
### Legacy Workspace Folders Detected
If you upgraded from older installs, you might still have `~/clawdis` or
`~/clawdbot` on disk. Multiple workspace directories can cause confusing auth
or state drift because only one workspace is active.
**Fix:** keep a single active workspace and archive/remove the rest. See
[Agent workspace](/concepts/agent-workspace#legacy-workspace-folders).
### "Agent was aborted" ### "Agent was aborted"
The agent was interrupted mid-response. The agent was interrupted mid-response.

View File

@@ -0,0 +1,67 @@
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
import type { GatewayServiceRuntime } from "../daemon/service-runtime.js";
type RuntimeHintOptions = {
platform?: NodeJS.Platform;
env?: Record<string, string | undefined>;
};
export function formatGatewayRuntimeSummary(
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;
}
export function buildGatewayRuntimeHints(
runtime: GatewayServiceRuntime | undefined,
options: RuntimeHintOptions = {},
): string[] {
const hints: string[] = [];
if (!runtime) return hints;
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
if (runtime.cachedLabel && 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 (platform === "darwin") {
const logs = resolveGatewayLogPaths(env);
hints.push(`Logs: ${logs.stdoutPath}`);
hints.push(`Errors: ${logs.stderrPath}`);
} else if (platform === "linux") {
hints.push(
"Logs: journalctl --user -u clawdbot-gateway.service -n 200 --no-pager",
);
} else if (platform === "win32") {
hints.push('Logs: schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST');
}
}
return hints;
}

View File

@@ -9,14 +9,16 @@ import {
writeConfigFile, writeConfigFile,
} from "../config/config.js"; } from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayLogPaths } from "../daemon/launchd.js";
import { resolveGatewayService } from "../daemon/service.js"; import { resolveGatewayService } from "../daemon/service.js";
import type { GatewayServiceRuntime } from "../daemon/service-runtime.js";
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js"; import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js"; import { resolveUserPath, sleep } from "../utils.js";
import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js"; import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js";
import {
buildGatewayRuntimeHints,
formatGatewayRuntimeSummary,
} from "./doctor-format.js";
import { import {
maybeMigrateLegacyGatewayService, maybeMigrateLegacyGatewayService,
maybeScanExtraGatewayServices, maybeScanExtraGatewayServices,
@@ -57,62 +59,6 @@ function resolveMode(cfg: ClawdbotConfig): "local" | "remote" {
return cfg.gateway?.mode === "remote" ? "remote" : "local"; 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( export async function doctorCommand(
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
options: DoctorOptions = {}, options: DoctorOptions = {},
@@ -279,8 +225,11 @@ export async function doctorCommand(
const serviceRuntime = await service const serviceRuntime = await service
.readRuntime(process.env) .readRuntime(process.env)
.catch(() => undefined); .catch(() => undefined);
const summary = formatRuntimeSummary(serviceRuntime); const summary = formatGatewayRuntimeSummary(serviceRuntime);
const hints = buildGatewayRuntimeHints(serviceRuntime); const hints = buildGatewayRuntimeHints(serviceRuntime, {
platform: process.platform,
env: process.env,
});
if (summary || hints.length > 0) { if (summary || hints.length > 0) {
const lines = []; const lines = [];
if (summary) lines.push(`Runtime: ${summary}`); if (summary) lines.push(`Runtime: ${summary}`);

View File

@@ -7,6 +7,7 @@ import {
GATEWAY_LAUNCH_AGENT_LABEL, GATEWAY_LAUNCH_AGENT_LABEL,
LEGACY_GATEWAY_LAUNCH_AGENT_LABELS, LEGACY_GATEWAY_LAUNCH_AGENT_LABELS,
} from "./constants.js"; } from "./constants.js";
import { parseKeyValueOutput } from "./runtime-parse.js";
import type { GatewayServiceRuntime } from "./service-runtime.js"; import type { GatewayServiceRuntime } from "./service-runtime.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -205,27 +206,22 @@ export type LaunchctlPrintInfo = {
}; };
export function parseLaunchctlPrint(output: string): LaunchctlPrintInfo { export function parseLaunchctlPrint(output: string): LaunchctlPrintInfo {
const entries = parseKeyValueOutput(output, "=");
const info: LaunchctlPrintInfo = {}; const info: LaunchctlPrintInfo = {};
for (const rawLine of output.split("\n")) { const state = entries.state;
const line = rawLine.trim(); if (state) info.state = state;
if (!line) continue; const pidValue = entries.pid;
const match = line.match(/^([a-zA-Z\s]+?)\s*=\s*(.+)$/); if (pidValue) {
if (!match) continue; const pid = Number.parseInt(pidValue, 10);
const key = match[1]?.trim().toLowerCase(); if (Number.isFinite(pid)) info.pid = pid;
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;
}
} }
const exitStatusValue = entries["last exit status"];
if (exitStatusValue) {
const status = Number.parseInt(exitStatusValue, 10);
if (Number.isFinite(status)) info.lastExitStatus = status;
}
const exitReason = entries["last exit reason"];
if (exitReason) info.lastExitReason = exitReason;
return info; return info;
} }

View File

@@ -0,0 +1,17 @@
export function parseKeyValueOutput(
output: string,
separator: string,
): Record<string, string> {
const entries: Record<string, string> = {};
for (const rawLine of output.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const idx = line.indexOf(separator);
if (idx <= 0) continue;
const key = line.slice(0, idx).trim().toLowerCase();
if (!key) continue;
const value = line.slice(idx + separator.length).trim();
entries[key] = value;
}
return entries;
}

View File

@@ -7,6 +7,7 @@ import {
GATEWAY_WINDOWS_TASK_NAME, GATEWAY_WINDOWS_TASK_NAME,
LEGACY_GATEWAY_WINDOWS_TASK_NAMES, LEGACY_GATEWAY_WINDOWS_TASK_NAMES,
} from "./constants.js"; } from "./constants.js";
import { parseKeyValueOutput } from "./runtime-parse.js";
import type { GatewayServiceRuntime } from "./service-runtime.js"; import type { GatewayServiceRuntime } from "./service-runtime.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -110,23 +111,14 @@ export type ScheduledTaskInfo = {
}; };
export function parseSchtasksQuery(output: string): ScheduledTaskInfo { export function parseSchtasksQuery(output: string): ScheduledTaskInfo {
const entries = parseKeyValueOutput(output, ":");
const info: ScheduledTaskInfo = {}; const info: ScheduledTaskInfo = {};
for (const rawLine of output.split(/\r?\n/)) { const status = entries.status;
const line = rawLine.trim(); if (status) info.status = status;
if (!line) continue; const lastRunTime = entries["last run time"];
const idx = line.indexOf(":"); if (lastRunTime) info.lastRunTime = lastRunTime;
if (idx <= 0) continue; const lastRunResult = entries["last run result"];
const key = line.slice(0, idx).trim().toLowerCase(); if (lastRunResult) info.lastRunResult = lastRunResult;
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; return info;
} }

View File

@@ -8,6 +8,7 @@ import {
GATEWAY_SYSTEMD_SERVICE_NAME, GATEWAY_SYSTEMD_SERVICE_NAME,
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES, LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
} from "./constants.js"; } from "./constants.js";
import { parseKeyValueOutput } from "./runtime-parse.js";
import type { GatewayServiceRuntime } from "./service-runtime.js"; import type { GatewayServiceRuntime } from "./service-runtime.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -225,27 +226,24 @@ export type SystemdServiceInfo = {
}; };
export function parseSystemdShow(output: string): SystemdServiceInfo { export function parseSystemdShow(output: string): SystemdServiceInfo {
const entries = parseKeyValueOutput(output, "=");
const info: SystemdServiceInfo = {}; const info: SystemdServiceInfo = {};
for (const rawLine of output.split("\n")) { const activeState = entries.activestate;
const line = rawLine.trim(); if (activeState) info.activeState = activeState;
if (!line || !line.includes("=")) continue; const subState = entries.substate;
const [key, ...rest] = line.split("="); if (subState) info.subState = subState;
const value = rest.join("=").trim(); const mainPidValue = entries.mainpid;
if (!key) continue; if (mainPidValue) {
if (key === "ActiveState") { const pid = Number.parseInt(mainPidValue, 10);
info.activeState = value; if (Number.isFinite(pid) && pid > 0) info.mainPid = pid;
} 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;
}
} }
const execMainStatusValue = entries.execmainstatus;
if (execMainStatusValue) {
const status = Number.parseInt(execMainStatusValue, 10);
if (Number.isFinite(status)) info.execMainStatus = status;
}
const execMainCode = entries.execmaincode;
if (execMainCode) info.execMainCode = execMainCode;
return info; return info;
} }

74
src/infra/ports-format.ts Normal file
View File

@@ -0,0 +1,74 @@
import type {
PortListener,
PortListenerKind,
PortUsage,
} from "./ports-types.js";
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;
}

250
src/infra/ports-inspect.ts Normal file
View File

@@ -0,0 +1,250 @@
import net from "node:net";
import { runCommandWithTimeout } from "../process/exec.js";
import { buildPortHints } from "./ports-format.js";
import type {
PortListener,
PortUsage,
PortUsageStatus,
} from "./ports-types.js";
type CommandResult = {
stdout: string;
stderr: string;
code: number;
error?: string;
};
function isErrno(err: unknown): err is NodeJS.ErrnoException {
return Boolean(err && typeof err === "object" && "code" in err);
}
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 (isErrno(err) && err.code === "EADDRINUSE") return "busy";
return "unknown";
}
}
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,
};
}

20
src/infra/ports-types.ts Normal file
View File

@@ -0,0 +1,20 @@
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[];
};
export type PortListenerKind = "gateway" | "ssh" | "unknown";

View File

@@ -1,8 +1,16 @@
import net from "node:net"; import net from "node:net";
import { danger, info, shouldLogVerbose, warn } from "../globals.js"; import { danger, info, shouldLogVerbose, warn } from "../globals.js";
import { logDebug } from "../logger.js"; import { logDebug } from "../logger.js";
import { runCommandWithTimeout } from "../process/exec.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { formatPortDiagnostics } from "./ports-format.js";
import { inspectPortUsage } from "./ports-inspect.js";
import type {
PortListener,
PortListenerKind,
PortUsage,
PortUsageStatus,
} from "./ports-types.js";
class PortInUseError extends Error { class PortInUseError extends Error {
port: number; port: number;
@@ -94,332 +102,10 @@ export async function handlePortError(
} }
export { PortInUseError }; export { PortInUseError };
export type { PortListener, PortListenerKind, PortUsage, PortUsageStatus };
export type PortListener = { export {
pid?: number; buildPortHints,
command?: string; classifyPortListener,
commandLine?: string; formatPortDiagnostics,
user?: string; } from "./ports-format.js";
address?: string; export { inspectPortUsage } from "./ports-inspect.js";
};
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,
};
}