refactor: split port diagnostics helpers
This commit is contained in:
@@ -37,6 +37,19 @@ file creation:
|
||||
{ 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)
|
||||
|
||||
These are the standard files Clawdbot expects inside the workspace:
|
||||
|
||||
@@ -39,6 +39,15 @@ clawdbot daemon status
|
||||
It will show the listener(s) and likely causes (gateway already running, SSH tunnel).
|
||||
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"
|
||||
|
||||
The agent was interrupted mid-response.
|
||||
|
||||
67
src/commands/doctor-format.ts
Normal file
67
src/commands/doctor-format.ts
Normal 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;
|
||||
}
|
||||
@@ -9,14 +9,16 @@ import {
|
||||
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";
|
||||
import { maybeRepairAnthropicOAuthProfileId } from "./doctor-auth.js";
|
||||
import {
|
||||
buildGatewayRuntimeHints,
|
||||
formatGatewayRuntimeSummary,
|
||||
} from "./doctor-format.js";
|
||||
import {
|
||||
maybeMigrateLegacyGatewayService,
|
||||
maybeScanExtraGatewayServices,
|
||||
@@ -57,62 +59,6 @@ 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 = {},
|
||||
@@ -279,8 +225,11 @@ export async function doctorCommand(
|
||||
const serviceRuntime = await service
|
||||
.readRuntime(process.env)
|
||||
.catch(() => undefined);
|
||||
const summary = formatRuntimeSummary(serviceRuntime);
|
||||
const hints = buildGatewayRuntimeHints(serviceRuntime);
|
||||
const summary = formatGatewayRuntimeSummary(serviceRuntime);
|
||||
const hints = buildGatewayRuntimeHints(serviceRuntime, {
|
||||
platform: process.platform,
|
||||
env: process.env,
|
||||
});
|
||||
if (summary || hints.length > 0) {
|
||||
const lines = [];
|
||||
if (summary) lines.push(`Runtime: ${summary}`);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
GATEWAY_LAUNCH_AGENT_LABEL,
|
||||
LEGACY_GATEWAY_LAUNCH_AGENT_LABELS,
|
||||
} from "./constants.js";
|
||||
import { parseKeyValueOutput } from "./runtime-parse.js";
|
||||
import type { GatewayServiceRuntime } from "./service-runtime.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -205,27 +206,22 @@ export type LaunchctlPrintInfo = {
|
||||
};
|
||||
|
||||
export function parseLaunchctlPrint(output: string): LaunchctlPrintInfo {
|
||||
const entries = parseKeyValueOutput(output, "=");
|
||||
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;
|
||||
}
|
||||
const state = entries.state;
|
||||
if (state) info.state = state;
|
||||
const pidValue = entries.pid;
|
||||
if (pidValue) {
|
||||
const pid = Number.parseInt(pidValue, 10);
|
||||
if (Number.isFinite(pid)) info.pid = pid;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
17
src/daemon/runtime-parse.ts
Normal file
17
src/daemon/runtime-parse.ts
Normal 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;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
GATEWAY_WINDOWS_TASK_NAME,
|
||||
LEGACY_GATEWAY_WINDOWS_TASK_NAMES,
|
||||
} from "./constants.js";
|
||||
import { parseKeyValueOutput } from "./runtime-parse.js";
|
||||
import type { GatewayServiceRuntime } from "./service-runtime.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -110,23 +111,14 @@ export type ScheduledTaskInfo = {
|
||||
};
|
||||
|
||||
export function parseSchtasksQuery(output: string): ScheduledTaskInfo {
|
||||
const entries = parseKeyValueOutput(output, ":");
|
||||
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;
|
||||
}
|
||||
}
|
||||
const status = entries.status;
|
||||
if (status) info.status = status;
|
||||
const lastRunTime = entries["last run time"];
|
||||
if (lastRunTime) info.lastRunTime = lastRunTime;
|
||||
const lastRunResult = entries["last run result"];
|
||||
if (lastRunResult) info.lastRunResult = lastRunResult;
|
||||
return info;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
GATEWAY_SYSTEMD_SERVICE_NAME,
|
||||
LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES,
|
||||
} from "./constants.js";
|
||||
import { parseKeyValueOutput } from "./runtime-parse.js";
|
||||
import type { GatewayServiceRuntime } from "./service-runtime.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -225,27 +226,24 @@ export type SystemdServiceInfo = {
|
||||
};
|
||||
|
||||
export function parseSystemdShow(output: string): SystemdServiceInfo {
|
||||
const entries = parseKeyValueOutput(output, "=");
|
||||
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;
|
||||
}
|
||||
const activeState = entries.activestate;
|
||||
if (activeState) info.activeState = activeState;
|
||||
const subState = entries.substate;
|
||||
if (subState) info.subState = subState;
|
||||
const mainPidValue = entries.mainpid;
|
||||
if (mainPidValue) {
|
||||
const pid = Number.parseInt(mainPidValue, 10);
|
||||
if (Number.isFinite(pid) && pid > 0) info.mainPid = pid;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
74
src/infra/ports-format.ts
Normal file
74
src/infra/ports-format.ts
Normal 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
250
src/infra/ports-inspect.ts
Normal 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
20
src/infra/ports-types.ts
Normal 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";
|
||||
@@ -1,8 +1,16 @@
|
||||
import net from "node:net";
|
||||
import { danger, info, shouldLogVerbose, warn } from "../globals.js";
|
||||
import { logDebug } from "../logger.js";
|
||||
import { runCommandWithTimeout } from "../process/exec.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||
import 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 {
|
||||
port: number;
|
||||
@@ -94,332 +102,10 @@ 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,
|
||||
};
|
||||
}
|
||||
export type { PortListener, PortListenerKind, PortUsage, PortUsageStatus };
|
||||
export {
|
||||
buildPortHints,
|
||||
classifyPortListener,
|
||||
formatPortDiagnostics,
|
||||
} from "./ports-format.js";
|
||||
export { inspectPortUsage } from "./ports-inspect.js";
|
||||
|
||||
Reference in New Issue
Block a user