diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts index 8bfc6c1f7..2dda39462 100644 --- a/src/cli/daemon-cli.ts +++ b/src/cli/daemon-cli.ts @@ -36,6 +36,7 @@ import { type PortListener, type PortUsageStatus, } from "../infra/ports.js"; +import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import { getResolvedLoggerSettings } from "../logging.js"; import { defaultRuntime } from "../runtime.js"; import { createDefaultDeps } from "./deps.js"; @@ -145,7 +146,56 @@ function parsePort(raw: unknown): number | null { return parsed; } -async function probeGatewayStatus(opts: GatewayRpcOpts) { +function parsePortFromArgs( + programArguments: string[] | undefined, +): number | null { + if (!programArguments?.length) return null; + for (let i = 0; i < programArguments.length; i += 1) { + const arg = programArguments[i]; + if (arg === "--port") { + const next = programArguments[i + 1]; + const parsed = parsePort(next); + if (parsed) return parsed; + } + if (arg?.startsWith("--port=")) { + const parsed = parsePort(arg.split("=", 2)[1]); + if (parsed) return parsed; + } + } + return null; +} + +function pickProbeHostForBind(bindMode: string, tailnetIPv4: string | null) { + if (bindMode === "tailnet") return tailnetIPv4; + if (bindMode === "auto") return tailnetIPv4 ?? "127.0.0.1"; + return "127.0.0.1"; +} + +function safeDaemonEnv(env: Record | undefined): string[] { + if (!env) return []; + const allow = [ + "CLAWDBOT_PROFILE", + "CLAWDBOT_STATE_DIR", + "CLAWDBOT_CONFIG_PATH", + "CLAWDBOT_GATEWAY_PORT", + "CLAWDBOT_NIX_MODE", + ]; + const lines: string[] = []; + for (const key of allow) { + const value = env[key]; + if (!value?.trim()) continue; + lines.push(`${key}=${value.trim()}`); + } + return lines; +} + +async function probeGatewayStatus(opts: { + url: string; + token?: string; + password?: string; + timeoutMs: number; + json?: boolean; +}) { try { await withProgress( { @@ -159,7 +209,7 @@ async function probeGatewayStatus(opts: GatewayRpcOpts) { token: opts.token, password: opts.password, method: "status", - timeoutMs: Number(opts.timeout ?? 10_000), + timeoutMs: opts.timeoutMs, clientName: "cli", mode: "cli", }), @@ -209,6 +259,7 @@ function shouldReportPortUsage( function renderRuntimeHints( runtime: DaemonStatus["service"]["runtime"], + env: NodeJS.ProcessEnv = process.env, ): string[] { if (!runtime) return []; const hints: string[] = []; @@ -227,7 +278,7 @@ function renderRuntimeHints( if (runtime.status === "stopped") { if (fileLog) hints.push(`File logs: ${fileLog}`); if (process.platform === "darwin") { - const logs = resolveGatewayLogPaths(process.env); + const logs = resolveGatewayLogPaths(env); hints.push(`Launchd stdout (if installed): ${logs.stdoutPath}`); hints.push(`Launchd stderr (if installed): ${logs.stderrPath}`); } else if (process.platform === "linux") { @@ -272,27 +323,114 @@ async function gatherDaemonStatus(opts: { 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 serviceEnv = command?.environment ?? undefined; + const mergedDaemonEnv = { + ...(process.env as Record), + ...(serviceEnv ?? {}), + } satisfies Record; + + const cliConfigPath = resolveConfigPath(process.env, resolveStateDir(process.env)); + const daemonConfigPath = resolveConfigPath( + mergedDaemonEnv as NodeJS.ProcessEnv, + resolveStateDir(mergedDaemonEnv as NodeJS.ProcessEnv), + ); + + const cliIO = createConfigIO({ env: process.env, configPath: cliConfigPath }); + const daemonIO = createConfigIO({ + env: mergedDaemonEnv, + configPath: daemonConfigPath, + }); + + const [cliSnapshot, daemonSnapshot] = await Promise.all([ + cliIO.readConfigFileSnapshot().catch(() => null), + daemonIO.readConfigFileSnapshot().catch(() => null), + ]); + const cliCfg = cliIO.loadConfig(); + const daemonCfg = daemonIO.loadConfig(); + + const cliConfigSummary: ConfigSummary = { + path: cliSnapshot?.path ?? cliConfigPath, + exists: cliSnapshot?.exists ?? false, + valid: cliSnapshot?.valid ?? true, + ...(cliSnapshot?.issues?.length ? { issues: cliSnapshot.issues } : {}), + }; + const daemonConfigSummary: ConfigSummary = { + path: daemonSnapshot?.path ?? daemonConfigPath, + exists: daemonSnapshot?.exists ?? false, + valid: daemonSnapshot?.valid ?? true, + ...(daemonSnapshot?.issues?.length ? { issues: daemonSnapshot.issues } : {}), + }; + const configMismatch = cliConfigSummary.path !== daemonConfigSummary.path; + + const portFromArgs = parsePortFromArgs(command?.programArguments); + const daemonPort = portFromArgs ?? resolveGatewayPort(daemonCfg, mergedDaemonEnv); + const portSource: GatewayStatusSummary["portSource"] = portFromArgs + ? "service args" + : "env/config"; + + const bindMode = daemonCfg.gateway?.bind ?? "loopback"; + const bindHost = resolveGatewayBindHost(bindMode); + const tailnetIPv4 = pickPrimaryTailnetIPv4(); + const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4); + const probeUrlOverride = + typeof opts.rpc.url === "string" && opts.rpc.url.trim().length > 0 + ? opts.rpc.url.trim() + : null; + const probeUrl = probeUrlOverride ?? `ws://${probeHost}:${daemonPort}`; + const probeNote = + !probeUrlOverride && bindMode === "lan" + ? "Local probe uses loopback (127.0.0.1); gateway bind=lan listens on 0.0.0.0." + : !probeUrlOverride && bindMode === "loopback" + ? "Loopback-only gateway; only local clients can connect." + : undefined; + + const cliPort = resolveGatewayPort(cliCfg, process.env); + const [portDiagnostics, portCliDiagnostics] = await Promise.all([ + inspectPortUsage(daemonPort).catch(() => null), + cliPort !== daemonPort ? inspectPortUsage(cliPort).catch(() => null) : null, + ]); + const portStatus: DaemonStatus["port"] | undefined = portDiagnostics + ? { + port: portDiagnostics.port, + status: portDiagnostics.status, + listeners: portDiagnostics.listeners, + hints: portDiagnostics.hints, + } + : undefined; + const portCliStatus: DaemonStatus["portCli"] | undefined = portCliDiagnostics + ? { + port: portCliDiagnostics.port, + status: portCliDiagnostics.status, + listeners: portCliDiagnostics.listeners, + hints: portCliDiagnostics.hints, + } + : undefined; + const legacyServices = await findLegacyGatewayServices(process.env); const extraServices = await findExtraGatewayServices(process.env, { deep: opts.deep, }); - const rpc = opts.probe ? await probeGatewayStatus(opts.rpc) : undefined; + + const timeoutMsRaw = Number.parseInt(String(opts.rpc.timeout ?? "10000"), 10); + const timeoutMs = + Number.isFinite(timeoutMsRaw) && timeoutMsRaw > 0 ? timeoutMsRaw : 10_000; + + const rpc = opts.probe + ? await probeGatewayStatus({ + url: probeUrl, + token: + opts.rpc.token || + mergedDaemonEnv.CLAWDBOT_GATEWAY_TOKEN || + daemonCfg.gateway?.auth?.token, + password: + opts.rpc.password || + mergedDaemonEnv.CLAWDBOT_GATEWAY_PASSWORD || + daemonCfg.gateway?.auth?.password, + timeoutMs, + json: opts.rpc.json, + }) + : undefined; let lastError: string | undefined; if ( loaded && @@ -300,7 +438,9 @@ async function gatherDaemonStatus(opts: { portStatus && portStatus.status !== "busy" ) { - lastError = (await readLastGatewayErrorLine(process.env)) ?? undefined; + lastError = + (await readLastGatewayErrorLine(mergedDaemonEnv as NodeJS.ProcessEnv)) ?? + undefined; } return { @@ -312,9 +452,23 @@ async function gatherDaemonStatus(opts: { command, runtime, }, + config: { + cli: cliConfigSummary, + daemon: daemonConfigSummary, + ...(configMismatch ? { mismatch: true } : {}), + }, + gateway: { + bindMode, + bindHost, + port: daemonPort, + portSource, + probeUrl, + ...(probeNote ? { probeNote } : {}), + }, port: portStatus, + ...(portCliStatus ? { portCli: portCliStatus } : {}), lastError, - rpc, + ...(rpc ? { rpc: { ...rpc, url: probeUrl } } : {}), legacyServices, extraServices, }; @@ -341,9 +495,56 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { `Command: ${service.command.programArguments.join(" ")}`, ); } + if (service.command?.sourcePath) { + defaultRuntime.log(`Service file: ${service.command.sourcePath}`); + } if (service.command?.workingDirectory) { defaultRuntime.log(`Working dir: ${service.command.workingDirectory}`); } + const daemonEnvLines = safeDaemonEnv(service.command?.environment); + if (daemonEnvLines.length > 0) { + defaultRuntime.log(`Daemon env: ${daemonEnvLines.join(" ")}`); + } + if (status.config) { + const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`; + defaultRuntime.log(`Config (cli): ${cliCfg}`); + if (!status.config.cli.valid && status.config.cli.issues?.length) { + for (const issue of status.config.cli.issues.slice(0, 5)) { + defaultRuntime.error(`Config issue: ${issue.path || ""}: ${issue.message}`); + } + } + if (status.config.daemon) { + const daemonCfg = `${status.config.daemon.path}${status.config.daemon.exists ? "" : " (missing)"}${status.config.daemon.valid ? "" : " (invalid)"}`; + defaultRuntime.log(`Config (daemon): ${daemonCfg}`); + if (!status.config.daemon.valid && status.config.daemon.issues?.length) { + for (const issue of status.config.daemon.issues.slice(0, 5)) { + defaultRuntime.error( + `Daemon config issue: ${issue.path || ""}: ${issue.message}`, + ); + } + } + } + if (status.config.mismatch) { + defaultRuntime.error( + "Root cause: CLI and daemon are using different config paths (likely a profile/state-dir mismatch).", + ); + } + } + if (status.gateway) { + const bindHost = status.gateway.bindHost ?? "n/a"; + defaultRuntime.log( + `Gateway: bind=${status.gateway.bindMode} (${bindHost}), port=${status.gateway.port} (${status.gateway.portSource})`, + ); + defaultRuntime.log(`Probe target: ${status.gateway.probeUrl}`); + if (status.gateway.probeNote) { + defaultRuntime.log(`Probe note: ${status.gateway.probeNote}`); + } + if (status.gateway.bindMode === "tailnet" && !status.gateway.bindHost) { + defaultRuntime.error( + "Root cause: gateway bind=tailnet but no tailnet interface was found.", + ); + } + } const runtimeLine = formatRuntimeStatus(service.runtime); if (runtimeLine) { defaultRuntime.log(`Runtime: ${runtimeLine}`); @@ -352,7 +553,12 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { if (rpc.ok) { defaultRuntime.log("RPC probe: ok"); } else { - defaultRuntime.error(`RPC probe: failed (${rpc.error})`); + defaultRuntime.error("RPC probe: failed"); + if (rpc.url) defaultRuntime.error(`RPC target: ${rpc.url}`); + const lines = String(rpc.error ?? "unknown").split(/\r?\n/).filter(Boolean); + for (const line of lines.slice(0, 12)) { + defaultRuntime.error(` ${line}`); + } } } if (service.runtime?.missingUnit) { @@ -364,7 +570,10 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { defaultRuntime.error( "Service is loaded but not running (likely exited immediately).", ); - for (const hint of renderRuntimeHints(service.runtime)) { + for (const hint of renderRuntimeHints( + service.runtime, + (service.command?.environment ?? process.env) as NodeJS.ProcessEnv, + )) { defaultRuntime.error(hint); } } @@ -384,6 +593,23 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { defaultRuntime.error(line); } } + if (status.port) { + const addrs = Array.from( + new Set( + status.port.listeners + .map((l) => l.address?.trim()) + .filter((v): v is string => Boolean(v)), + ), + ); + if (addrs.length > 0) { + defaultRuntime.log(`Listening: ${addrs.join(", ")}`); + } + } + if (status.portCli && status.portCli.port !== status.port?.port) { + defaultRuntime.log( + `Note: CLI config resolves gateway port=${status.portCli.port} (${status.portCli.status}).`, + ); + } if ( service.loaded && service.runtime?.status === "running" && @@ -401,7 +627,9 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) { `Logs: journalctl --user -u ${GATEWAY_SYSTEMD_SERVICE_NAME}.service -n 200 --no-pager`, ); } else if (process.platform === "darwin") { - const logs = resolveGatewayLogPaths(process.env); + const logs = resolveGatewayLogPaths( + (service.command?.environment ?? process.env) as NodeJS.ProcessEnv, + ); defaultRuntime.error(`Logs: ${logs.stdoutPath}`); defaultRuntime.error(`Errors: ${logs.stderrPath}`); } @@ -503,6 +731,10 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) { }); const environment: Record = { PATH: process.env.PATH, + CLAWDBOT_PROFILE: process.env.CLAWDBOT_PROFILE, + CLAWDBOT_STATE_DIR: process.env.CLAWDBOT_STATE_DIR, + CLAWDBOT_CONFIG_PATH: process.env.CLAWDBOT_CONFIG_PATH, + CLAWDBOT_GATEWAY_PORT: String(port), CLAWDBOT_GATEWAY_TOKEN: opts.token || cfg.gateway?.auth?.token || diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index e846b612c..3604f1cf5 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -14,6 +14,7 @@ import { } from "../daemon/constants.js"; import { resolveGatewayService } from "../daemon/service.js"; import { callGateway } from "../gateway/call.js"; +import { resolveGatewayAuth } from "../gateway/auth.js"; import { startGatewayServer } from "../gateway/server.js"; import { type GatewayWsLogStyle, @@ -413,19 +414,20 @@ export function registerGatewayCli(program: Command) { 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 authConfig = { + ...cfg.gateway?.auth, + ...(authMode ? { mode: authMode } : {}), + ...(opts.password ? { password: String(opts.password) } : {}), + ...(opts.token ? { token: String(opts.token) } : {}), + }; + const resolvedAuth = resolveGatewayAuth({ + authConfig, + env: process.env, + tailscaleMode: tailscaleMode ?? cfg.gateway?.tailscale?.mode ?? "off", + }); + const resolvedAuthMode = resolvedAuth.mode; + const tokenValue = resolvedAuth.token; + const passwordValue = resolvedAuth.password; const authHints: string[] = []; if (miskeys.hasGatewayToken) { authHints.push( @@ -484,9 +486,10 @@ export function registerGatewayCli(program: Command) { await startGatewayServer(port, { bind, auth: - authMode || opts.password || authModeRaw + authMode || opts.password || opts.token || authModeRaw ? { mode: authMode ?? undefined, + token: opts.token ? String(opts.token) : undefined, password: opts.password ? String(opts.password) : undefined, diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 908a534eb..e2a084d20 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -9,6 +9,7 @@ import { writeConfigFile, } from "../config/config.js"; import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js"; +import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; import { resolveGatewayService } from "../daemon/service.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js"; @@ -216,21 +217,31 @@ export async function doctorCommand( } if (!healthOk) { + const service = resolveGatewayService(); + const loaded = await service.isLoaded({ env: process.env }); + let serviceRuntime: + | Awaited> + | undefined; + if (loaded) { + serviceRuntime = await service + .readRuntime(process.env) + .catch(() => undefined); + } 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"); + } else if (loaded && serviceRuntime?.status === "running") { + const lastError = await readLastGatewayErrorLine(process.env); + if (lastError) { + note(`Last gateway error: ${lastError}`, "Gateway"); + } } } - 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 = formatGatewayRuntimeSummary(serviceRuntime); const hints = buildGatewayRuntimeHints(serviceRuntime, { platform: process.platform, diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 8a5d50f0a..303d9fe16 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -710,6 +710,30 @@ describe("legacy config detection", () => { } }); + it("rejects gateway.token", async () => { + vi.resetModules(); + const { validateConfigObject } = await import("./config.js"); + const res = validateConfigObject({ + gateway: { token: "legacy-token" }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("gateway.token"); + } + }); + + it("migrates gateway.token to gateway.auth.token", async () => { + vi.resetModules(); + const { migrateLegacyConfig } = await import("./config.js"); + const res = migrateLegacyConfig({ + gateway: { token: "legacy-token" }, + }); + expect(res.changes).toContain("Moved gateway.token → gateway.auth.token."); + expect(res.config?.gateway?.auth?.token).toBe("legacy-token"); + expect(res.config?.gateway?.auth?.mode).toBe("token"); + expect((res.config?.gateway as { token?: string })?.token).toBeUndefined(); + }); + it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => { vi.resetModules(); const { validateConfigObject } = await import("./config.js"); diff --git a/src/config/legacy.ts b/src/config/legacy.ts index 1955955c3..873134c92 100644 --- a/src/config/legacy.ts +++ b/src/config/legacy.ts @@ -60,6 +60,11 @@ const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ message: "agent.imageModelFallbacks was replaced by agent.imageModel.fallbacks (run `clawdbot doctor` to migrate).", }, + { + path: ["gateway", "token"], + message: + "gateway.token is ignored; use gateway.auth.token instead (run `clawdbot doctor` to migrate).", + }, ]; const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [ @@ -154,6 +159,34 @@ const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [ } }, }, + { + id: "gateway.token->gateway.auth.token", + describe: "Move gateway.token to gateway.auth.token", + apply: (raw, changes) => { + const gateway = raw.gateway; + if (!gateway || typeof gateway !== "object") return; + const token = (gateway as Record).token; + if (token === undefined) return; + + const gatewayObj = gateway as Record; + const auth = + gatewayObj.auth && typeof gatewayObj.auth === "object" + ? (gatewayObj.auth as Record) + : {}; + if (auth.token === undefined) { + auth.token = token; + if (!auth.mode) auth.mode = "token"; + changes.push("Moved gateway.token → gateway.auth.token."); + } else { + changes.push("Removed gateway.token (gateway.auth.token already set)."); + } + delete gatewayObj.token; + if (Object.keys(auth).length > 0) { + gatewayObj.auth = auth; + } + raw.gateway = gatewayObj; + }, + }, { id: "telegram.requireMention->telegram.groups.*.requireMention", describe: diff --git a/src/daemon/diagnostics.ts b/src/daemon/diagnostics.ts new file mode 100644 index 000000000..69c17f672 --- /dev/null +++ b/src/daemon/diagnostics.ts @@ -0,0 +1,45 @@ +import fs from "node:fs/promises"; + +import { resolveGatewayLogPaths } from "./launchd.js"; + +const GATEWAY_LOG_ERROR_PATTERNS = [ + /refusing to bind gateway/i, + /gateway auth mode/i, + /gateway start blocked/i, + /failed to bind gateway socket/i, + /tailscale .* requires/i, +]; + +async function readLastLogLine(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, "utf8"); + const lines = raw.split(/\r?\n/).map((line) => line.trim()); + for (let i = lines.length - 1; i >= 0; i -= 1) { + if (lines[i]) return lines[i]; + } + return null; + } catch { + return null; + } +} + +export async function readLastGatewayErrorLine( + env: NodeJS.ProcessEnv, +): Promise { + const { stdoutPath, stderrPath } = resolveGatewayLogPaths(env); + const stderrRaw = await fs.readFile(stderrPath, "utf8").catch(() => ""); + const stdoutRaw = await fs.readFile(stdoutPath, "utf8").catch(() => ""); + const lines = [...stderrRaw.split(/\r?\n/), ...stdoutRaw.split(/\r?\n/)].map( + (line) => line.trim(), + ); + for (let i = lines.length - 1; i >= 0; i -= 1) { + const line = lines[i]; + if (!line) continue; + if (GATEWAY_LOG_ERROR_PATTERNS.some((pattern) => pattern.test(line))) { + return line; + } + } + return ( + (await readLastLogLine(stderrPath)) ?? (await readLastLogLine(stdoutPath)) + ); +} diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index 449ff480f..2c8ba01e7 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -1,5 +1,6 @@ import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage } from "node:http"; +import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js"; export type ResolvedGatewayAuthMode = "none" | "token" | "password"; export type ResolvedGatewayAuth = { @@ -98,6 +99,29 @@ function isTailscaleProxyRequest(req?: IncomingMessage): boolean { ); } +export function resolveGatewayAuth(params: { + authConfig?: GatewayAuthConfig | null; + env?: NodeJS.ProcessEnv; + tailscaleMode?: GatewayTailscaleMode; +}): ResolvedGatewayAuth { + const authConfig = params.authConfig ?? {}; + const env = params.env ?? process.env; + const token = authConfig.token ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined; + const password = + authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined; + const mode: ResolvedGatewayAuth["mode"] = + authConfig.mode ?? (password ? "password" : token ? "token" : "none"); + const allowTailscale = + authConfig.allowTailscale ?? + (params.tailscaleMode === "serve" && mode !== "password"); + return { + mode, + token, + password, + allowTailscale, + }; +} + export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void { if (auth.mode === "token" && !auth.token) { throw new Error( diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 5f21151cd..2609e2ca2 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -102,6 +102,7 @@ import type { WizardSession } from "../wizard/session.js"; import { assertGatewayAuthConfigured, authorizeGatewayConnect, + resolveGatewayAuth, type ResolvedGatewayAuth, } from "./auth.js"; import { @@ -432,21 +433,12 @@ export async function startGatewayServer( ...tailscaleOverrides, }; const tailscaleMode = tailscaleConfig.mode ?? "off"; - const token = - authConfig.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN ?? undefined; - const password = - authConfig.password ?? process.env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined; - const authMode: ResolvedGatewayAuth["mode"] = - authConfig.mode ?? (password ? "password" : token ? "token" : "none"); - const allowTailscale = - authConfig.allowTailscale ?? - (tailscaleMode === "serve" && authMode !== "password"); - const resolvedAuth: ResolvedGatewayAuth = { - mode: authMode, - token, - password, - allowTailscale, - }; + const resolvedAuth = resolveGatewayAuth({ + authConfig, + env: process.env, + tailscaleMode, + }); + const authMode: ResolvedGatewayAuth["mode"] = resolvedAuth.mode; let hooksConfig = resolveHooksConfig(cfgAtStart); const canvasHostEnabled = process.env.CLAWDBOT_SKIP_CANVAS_HOST !== "1" &&