diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bc0047ae..e08ecccc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ ### Fixes - CLI/Daemon: add `clawdbot logs` tailing and improve restart/service hints across platforms. +- Gateway/CLI: tighten LAN bind auth checks, warn on mis-keyed gateway tokens, and surface last gateway error when daemon looks running but the port is closed. - Auto-reply: keep typing indicators alive during tool execution without changing typing-mode semantics. Thanks @thesash for PR #452. - macOS: harden Voice Wake tester/runtime (pause trigger, mic persistence, local-only tester) and keep transcript logs private. Thanks @xadenryan for PR #438. - macOS: preserve node bridge tunnel port override so remote nodes connect on the bridge port. Thanks @sircrumpet for PR #364. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 3527399f6..b0741e479 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1555,6 +1555,8 @@ Notes: - `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag). - `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI). - Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`. +- Non-loopback binds (`lan`/`tailnet`/`auto`) require auth. Use `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`). +- `gateway.remote.token` is **only** for remote CLI calls; it does not enable local gateway auth. `gateway.token` is ignored. Auth and Tailscale: - `gateway.auth.mode` sets the handshake requirements (`token` or `password`). diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index 5f8960238..e812034e9 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -29,6 +29,18 @@ Doctor/daemon will show runtime state (PID/last exit) and log hints. - Linux systemd (if installed): `journalctl --user -u clawdbot-gateway.service -n 200 --no-pager` - Windows: `schtasks /Query /TN "Clawdbot Gateway" /V /FO LIST` +### Service Running but Port Not Listening + +If the service reports **running** but nothing is listening on the gateway port, +the Gateway likely refused to bind. + +**Check:** +- `gateway.mode` must be `local` for `clawdbot gateway` and the daemon. +- Non-loopback binds (`lan`/`tailnet`/`auto`) require auth: + `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`). +- `gateway.remote.token` is for remote CLI calls only; it does **not** enable local auth. +- `gateway.token` is ignored; use `gateway.auth.token`. + ### Address Already in Use (Port 18789) This means something is already listening on the gateway port. diff --git a/docs/platforms/exe-dev.md b/docs/platforms/exe-dev.md index 0261be2fb..b2ccae4a9 100644 --- a/docs/platforms/exe-dev.md +++ b/docs/platforms/exe-dev.md @@ -129,6 +129,10 @@ For daemon runs, persist it in `~/.clawdbot/clawdbot.json`: } ``` +Notes: +- Non-loopback binds require `gateway.auth.token` (or `CLAWDBOT_GATEWAY_TOKEN`). +- `gateway.remote.token` is only for remote CLI calls; it does not enable local auth. + Then point exe.dev’s proxy at `8080` (or whatever port you chose) and open your VM’s HTTPS URL: ```bash diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts index 0e818246b..8bfc6c1f7 100644 --- a/src/cli/daemon-cli.ts +++ b/src/cli/daemon-cli.ts @@ -5,7 +5,13 @@ import { DEFAULT_GATEWAY_DAEMON_RUNTIME, isGatewayDaemonRuntime, } from "../commands/daemon-runtime.js"; -import { loadConfig, resolveGatewayPort } from "../config/config.js"; +import { + createConfigIO, + loadConfig, + resolveConfigPath, + resolveGatewayPort, + resolveStateDir, +} from "../config/config.js"; import { resolveIsNixMode } from "../config/paths.js"; import { GATEWAY_LAUNCH_AGENT_LABEL, @@ -17,11 +23,13 @@ import { findExtraGatewayServices, renderGatewayServiceCleanupHints, } from "../daemon/inspect.js"; +import { readLastGatewayErrorLine } from "../daemon/diagnostics.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 { resolveGatewayBindHost } from "../gateway/net.js"; import { formatPortDiagnostics, inspectPortUsage, @@ -33,6 +41,22 @@ import { defaultRuntime } from "../runtime.js"; import { createDefaultDeps } from "./deps.js"; import { withProgress } from "./progress.js"; +type ConfigSummary = { + path: string; + exists: boolean; + valid: boolean; + issues?: Array<{ path: string; message: string }>; +}; + +type GatewayStatusSummary = { + bindMode: string; + bindHost: string | null; + port: number; + portSource: "service args" | "env/config"; + probeUrl: string; + probeNote?: string; +}; + type DaemonStatus = { service: { label: string; @@ -42,6 +66,8 @@ type DaemonStatus = { command?: { programArguments: string[]; workingDirectory?: string; + environment?: Record; + sourcePath?: string; } | null; runtime?: { status?: string; @@ -57,15 +83,29 @@ type DaemonStatus = { missingUnit?: boolean; }; }; + config?: { + cli: ConfigSummary; + daemon?: ConfigSummary; + mismatch?: boolean; + }; + gateway?: GatewayStatusSummary; port?: { port: number; status: PortUsageStatus; listeners: PortListener[]; hints: string[]; }; + portCli?: { + port: number; + status: PortUsageStatus; + listeners: PortListener[]; + hints: string[]; + }; + lastError?: string; rpc?: { ok: boolean; error?: string; + url?: string; }; legacyServices: Array<{ label: string; detail: string }>; extraServices: Array<{ label: string; detail: string; scope: string }>; @@ -253,6 +293,15 @@ async function gatherDaemonStatus(opts: { deep: opts.deep, }); const rpc = opts.probe ? await probeGatewayStatus(opts.rpc) : undefined; + let lastError: string | undefined; + if ( + loaded && + runtime?.status === "running" && + portStatus && + portStatus.status !== "busy" + ) { + lastError = (await readLastGatewayErrorLine(process.env)) ?? undefined; + } return { service: { @@ -264,6 +313,7 @@ async function gatherDaemonStatus(opts: { runtime, }, port: portStatus, + lastError, rpc, legacyServices, extraServices, @@ -334,6 +384,28 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { defaultRuntime.error(line); } } + if ( + service.loaded && + service.runtime?.status === "running" && + status.port && + status.port.status !== "busy" + ) { + defaultRuntime.error( + `Gateway port ${status.port.port} is not listening (service appears running).`, + ); + if (status.lastError) { + defaultRuntime.error(`Last gateway error: ${status.lastError}`); + } + if (process.platform === "linux") { + defaultRuntime.error( + `Logs: journalctl --user -u ${GATEWAY_SYSTEMD_SERVICE_NAME}.service -n 200 --no-pager`, + ); + } else if (process.platform === "darwin") { + const logs = resolveGatewayLogPaths(process.env); + defaultRuntime.error(`Logs: ${logs.stdoutPath}`); + defaultRuntime.error(`Errors: ${logs.stderrPath}`); + } + } if (legacyServices.length > 0) { defaultRuntime.error("Legacy Clawdis services detected:"); diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index 814eef12b..e846b612c 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -4,6 +4,7 @@ import type { Command } from "commander"; import { CONFIG_PATH_CLAWDBOT, loadConfig, + readConfigFileSnapshot, resolveGatewayPort, } from "../config/config.js"; import { @@ -70,6 +71,26 @@ function describeUnknownError(err: unknown): string { return "Unknown error"; } +function extractGatewayMiskeys(parsed: unknown): { + hasGatewayToken: boolean; + hasRemoteToken: boolean; +} { + if (!parsed || typeof parsed !== "object") { + return { hasGatewayToken: false, hasRemoteToken: false }; + } + const gateway = (parsed as Record).gateway; + if (!gateway || typeof gateway !== "object") { + return { hasGatewayToken: false, hasRemoteToken: false }; + } + const hasGatewayToken = "token" in (gateway as Record); + const remote = (gateway as Record).remote; + const hasRemoteToken = + remote && typeof remote === "object" + ? "token" in (remote as Record) + : false; + return { hasGatewayToken, hasRemoteToken }; +} + function renderGatewayServiceStopHints(): string[] { switch (process.platform) { case "darwin": @@ -368,7 +389,7 @@ export function registerGatewayCli(program: Command) { ); } else { defaultRuntime.error( - "Gateway start blocked: set gateway.mode=local (or pass --allow-unconfigured).", + `Gateway start blocked: set gateway.mode=local (current: ${mode ?? "unset"}) or pass --allow-unconfigured.`, ); } defaultRuntime.exit(1); @@ -390,6 +411,72 @@ export function registerGatewayCli(program: Command) { return; } + const snapshot = await readConfigFileSnapshot().catch(() => null); + const miskeys = extractGatewayMiskeys(snapshot?.parsed); + const authModeFromConfig = cfg.gateway?.auth?.mode; + const tokenValue = + opts.token ?? + cfg.gateway?.auth?.token ?? + process.env.CLAWDBOT_GATEWAY_TOKEN; + const passwordValue = + opts.password ?? + cfg.gateway?.auth?.password ?? + process.env.CLAWDBOT_GATEWAY_PASSWORD; + const resolvedAuthMode = + authMode ?? + authModeFromConfig ?? + (passwordValue ? "password" : tokenValue ? "token" : "none"); + const authHints: string[] = []; + if (miskeys.hasGatewayToken) { + authHints.push( + 'Found "gateway.token" in config. Use "gateway.auth.token" instead.', + ); + } + if (miskeys.hasRemoteToken) { + authHints.push( + '"gateway.remote.token" is for remote CLI calls; it does not enable local gateway auth.', + ); + } + if (resolvedAuthMode === "token" && !tokenValue) { + defaultRuntime.error( + [ + "Gateway auth is set to token, but no token is configured.", + "Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN), or pass --token.", + ...authHints, + ] + .filter(Boolean) + .join("\n"), + ); + defaultRuntime.exit(1); + return; + } + if (resolvedAuthMode === "password" && !passwordValue) { + defaultRuntime.error( + [ + "Gateway auth is set to password, but no password is configured.", + "Set gateway.auth.password (or CLAWDBOT_GATEWAY_PASSWORD), or pass --password.", + ...authHints, + ] + .filter(Boolean) + .join("\n"), + ); + defaultRuntime.exit(1); + return; + } + if (bind !== "loopback" && resolvedAuthMode === "none") { + defaultRuntime.error( + [ + `Refusing to bind gateway to ${bind} without auth.`, + "Set gateway.auth.token (or CLAWDBOT_GATEWAY_TOKEN) or pass --token.", + ...authHints, + ] + .filter(Boolean) + .join("\n"), + ); + defaultRuntime.exit(1); + return; + } + try { await runGatewayLoop({ runtime: defaultRuntime, diff --git a/src/config/io.ts b/src/config/io.ts index 9ce2f72e5..a1e97b0a1 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -59,6 +59,20 @@ export type ConfigIoDeps = { logger?: Pick; }; +function warnOnConfigMiskeys( + raw: unknown, + logger: Pick, +): void { + if (!raw || typeof raw !== "object") return; + const gateway = (raw as Record).gateway; + if (!gateway || typeof gateway !== "object") return; + if ("token" in (gateway as Record)) { + logger.warn( + 'Config uses "gateway.token". This key is ignored; use "gateway.auth.token" instead.', + ); + } +} + function resolveConfigPathForDeps(deps: Required): string { if (deps.configPath) return deps.configPath; return resolveConfigPath(deps.env, resolveStateDir(deps.env, deps.homedir)); @@ -106,6 +120,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { } const raw = deps.fs.readFileSync(configPath, "utf-8"); const parsed = deps.json5.parse(raw); + warnOnConfigMiskeys(parsed, deps.logger); if (typeof parsed !== "object" || parsed === null) return {}; const validated = ClawdbotSchema.safeParse(parsed); if (!validated.success) { diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 6ab603f45..5f21151cd 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -464,7 +464,7 @@ export async function startGatewayServer( } if (!isLoopbackHost(bindHost) && authMode === "none") { throw new Error( - `refusing to bind gateway to ${bindHost}:${port} without auth (set gateway.auth or CLAWDBOT_GATEWAY_TOKEN)`, + `refusing to bind gateway to ${bindHost}:${port} without auth (set gateway.auth.token or CLAWDBOT_GATEWAY_TOKEN, or pass --token)`, ); }