diff --git a/docs/concepts/agent-workspace.md b/docs/concepts/agent-workspace.md index 453b57b77..83af0a43f 100644 --- a/docs/concepts/agent-workspace.md +++ b/docs/concepts/agent-workspace.md @@ -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: diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 0f052a3bf..481d22a18 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -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. diff --git a/src/commands/doctor-format.ts b/src/commands/doctor-format.ts new file mode 100644 index 000000000..1f71350fc --- /dev/null +++ b/src/commands/doctor-format.ts @@ -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; +}; + +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; +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 44c625809..cb2297621 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -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}`); diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 6a58055f0..6d862004c 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -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; } diff --git a/src/daemon/runtime-parse.ts b/src/daemon/runtime-parse.ts new file mode 100644 index 000000000..b9c67d24b --- /dev/null +++ b/src/daemon/runtime-parse.ts @@ -0,0 +1,17 @@ +export function parseKeyValueOutput( + output: string, + separator: string, +): Record { + const entries: Record = {}; + 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; +} diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index 91a8ab821..c13d5ca32 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -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; } diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 753ddd44c..417c11ab0 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -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; } diff --git a/src/infra/ports-format.ts b/src/infra/ports-format.ts new file mode 100644 index 000000000..8e6bd39ad --- /dev/null +++ b/src/infra/ports-format.ts @@ -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; +} diff --git a/src/infra/ports-inspect.ts b/src/infra/ports-inspect.ts new file mode 100644 index 000000000..5b853500d --- /dev/null +++ b/src/infra/ports-inspect.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + try { + await new Promise((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 { + 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, + }; +} diff --git a/src/infra/ports-types.ts b/src/infra/ports-types.ts new file mode 100644 index 000000000..56accc93a --- /dev/null +++ b/src/infra/ports-types.ts @@ -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"; diff --git a/src/infra/ports.ts b/src/infra/ports.ts index eaa799dc0..72cc04aa8 100644 --- a/src/infra/ports.ts +++ b/src/infra/ports.ts @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - try { - await new Promise((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 { - 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";