fix: improve gateway diagnostics

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

View File

@@ -18,6 +18,7 @@
- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides).
### Fixes
- Doctor/Daemon: surface gateway runtime state + port collision diagnostics; warn on legacy workspace dirs.
- Discord: format slow listener logs in seconds to match shared duration style.
- CLI: show colored table output for `clawdbot cron list` (JSON behind `--json`).
- CLI: add cron `create`/`remove`/`delete` aliases for job management.

View File

@@ -47,8 +47,11 @@ cat ~/.clawdbot/clawdbot.json
- Legacy config migration and normalization.
- Legacy on-disk state migration (sessions/agent dir/WhatsApp auth).
- State integrity and permissions checks (sessions, transcripts, state dir).
- Legacy workspace dir detection (`~/clawdis`, `~/clawdbot`).
- Sandbox image repair when sandboxing is enabled.
- Legacy service migration and extra gateway detection.
- Gateway runtime checks (service installed but not running; cached launchd label).
- Gateway port collision diagnostics (default `18789`).
- Security warnings for open DM policies.
- systemd linger check on Linux.
- Writes updated config + wizard metadata.
@@ -140,11 +143,17 @@ workspace.
Doctor runs a health check and offers to restart the gateway when it looks
unhealthy.
### 11) Config write + wizard metadata
### 11) Gateway runtime + port diagnostics
Doctor inspects the daemon runtime (PID, last exit status) and warns when the
service is installed but not actually running. It also checks for port collisions
on the gateway port (default `18789`) and reports likely causes (gateway already
running, SSH tunnel).
### 12) Config write + wizard metadata
Doctor persists any config changes and stamps wizard metadata to record the
doctor run.
### 12) Workspace tips (backup + memory system)
### 13) Workspace tips (backup + memory system)
Doctor suggests a workspace memory system when missing and prints a backup tip
if the workspace is not already under git.

View File

@@ -171,6 +171,7 @@ clawdbot daemon restart
Notes:
- `daemon status` probes the Gateway RPC by default (same URL/token defaults as `gateway status`).
- `daemon status --deep` adds system-level scans (LaunchDaemons/system units).
- `daemon status` now reports runtime state (PID/exit status) and port collisions when the gateway isnt reachable.
- `gateway install|uninstall|start|stop|restart` remain supported as aliases; `daemon` is the dedicated manager.
- `gateway daemon status` is an alias for `clawdbot daemon status`.
- If other gateway-like services are detected, the CLI warns. We recommend **one gateway per machine**; one gateway can host multiple agents.

View File

@@ -9,6 +9,36 @@ When your CLAWDBOT misbehaves, here's how to fix it.
## Common Issues
### Service Installed but Nothing is Running
If the gateway service is installed but the process exits immediately, the daemon
can appear “loaded” while nothing is running.
**Check:**
```bash
clawdbot daemon status
clawdbot doctor
```
Doctor/daemon will show runtime state (PID/last exit) and log hints.
**Logs:**
- macOS: `~/.clawdbot/logs/gateway.log` and `gateway.err.log`
- Linux: `journalctl --user -u clawdbot-gateway.service -n 200 --no-pager`
- Windows: `schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST`
### Address Already in Use (Port 18789)
This means something is already listening on the gateway port.
**Check:**
```bash
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.
### "Agent was aborted"
The agent was interrupted mid-response.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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