fix: wire gateway auth diagnostics into doctor
This commit is contained in:
@@ -36,6 +36,7 @@ import {
|
|||||||
type PortListener,
|
type PortListener,
|
||||||
type PortUsageStatus,
|
type PortUsageStatus,
|
||||||
} from "../infra/ports.js";
|
} from "../infra/ports.js";
|
||||||
|
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
|
||||||
import { getResolvedLoggerSettings } from "../logging.js";
|
import { getResolvedLoggerSettings } from "../logging.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { createDefaultDeps } from "./deps.js";
|
import { createDefaultDeps } from "./deps.js";
|
||||||
@@ -145,7 +146,56 @@ function parsePort(raw: unknown): number | null {
|
|||||||
return parsed;
|
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<string, string> | 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 {
|
try {
|
||||||
await withProgress(
|
await withProgress(
|
||||||
{
|
{
|
||||||
@@ -159,7 +209,7 @@ async function probeGatewayStatus(opts: GatewayRpcOpts) {
|
|||||||
token: opts.token,
|
token: opts.token,
|
||||||
password: opts.password,
|
password: opts.password,
|
||||||
method: "status",
|
method: "status",
|
||||||
timeoutMs: Number(opts.timeout ?? 10_000),
|
timeoutMs: opts.timeoutMs,
|
||||||
clientName: "cli",
|
clientName: "cli",
|
||||||
mode: "cli",
|
mode: "cli",
|
||||||
}),
|
}),
|
||||||
@@ -209,6 +259,7 @@ function shouldReportPortUsage(
|
|||||||
|
|
||||||
function renderRuntimeHints(
|
function renderRuntimeHints(
|
||||||
runtime: DaemonStatus["service"]["runtime"],
|
runtime: DaemonStatus["service"]["runtime"],
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
): string[] {
|
): string[] {
|
||||||
if (!runtime) return [];
|
if (!runtime) return [];
|
||||||
const hints: string[] = [];
|
const hints: string[] = [];
|
||||||
@@ -227,7 +278,7 @@ function renderRuntimeHints(
|
|||||||
if (runtime.status === "stopped") {
|
if (runtime.status === "stopped") {
|
||||||
if (fileLog) hints.push(`File logs: ${fileLog}`);
|
if (fileLog) hints.push(`File logs: ${fileLog}`);
|
||||||
if (process.platform === "darwin") {
|
if (process.platform === "darwin") {
|
||||||
const logs = resolveGatewayLogPaths(process.env);
|
const logs = resolveGatewayLogPaths(env);
|
||||||
hints.push(`Launchd stdout (if installed): ${logs.stdoutPath}`);
|
hints.push(`Launchd stdout (if installed): ${logs.stdoutPath}`);
|
||||||
hints.push(`Launchd stderr (if installed): ${logs.stderrPath}`);
|
hints.push(`Launchd stderr (if installed): ${logs.stderrPath}`);
|
||||||
} else if (process.platform === "linux") {
|
} else if (process.platform === "linux") {
|
||||||
@@ -272,27 +323,114 @@ async function gatherDaemonStatus(opts: {
|
|||||||
service.readCommand(process.env).catch(() => null),
|
service.readCommand(process.env).catch(() => null),
|
||||||
service.readRuntime(process.env).catch(() => undefined),
|
service.readRuntime(process.env).catch(() => undefined),
|
||||||
]);
|
]);
|
||||||
let portStatus: DaemonStatus["port"] | undefined;
|
|
||||||
try {
|
const serviceEnv = command?.environment ?? undefined;
|
||||||
const cfg = loadConfig();
|
const mergedDaemonEnv = {
|
||||||
if (cfg.gateway?.mode !== "remote") {
|
...(process.env as Record<string, string | undefined>),
|
||||||
const port = resolveGatewayPort(cfg, process.env);
|
...(serviceEnv ?? {}),
|
||||||
const diagnostics = await inspectPortUsage(port);
|
} satisfies Record<string, string | undefined>;
|
||||||
portStatus = {
|
|
||||||
port: diagnostics.port,
|
const cliConfigPath = resolveConfigPath(process.env, resolveStateDir(process.env));
|
||||||
status: diagnostics.status,
|
const daemonConfigPath = resolveConfigPath(
|
||||||
listeners: diagnostics.listeners,
|
mergedDaemonEnv as NodeJS.ProcessEnv,
|
||||||
hints: diagnostics.hints,
|
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,
|
||||||
}
|
}
|
||||||
} catch {
|
: undefined;
|
||||||
portStatus = 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 legacyServices = await findLegacyGatewayServices(process.env);
|
||||||
const extraServices = await findExtraGatewayServices(process.env, {
|
const extraServices = await findExtraGatewayServices(process.env, {
|
||||||
deep: opts.deep,
|
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;
|
let lastError: string | undefined;
|
||||||
if (
|
if (
|
||||||
loaded &&
|
loaded &&
|
||||||
@@ -300,7 +438,9 @@ async function gatherDaemonStatus(opts: {
|
|||||||
portStatus &&
|
portStatus &&
|
||||||
portStatus.status !== "busy"
|
portStatus.status !== "busy"
|
||||||
) {
|
) {
|
||||||
lastError = (await readLastGatewayErrorLine(process.env)) ?? undefined;
|
lastError =
|
||||||
|
(await readLastGatewayErrorLine(mergedDaemonEnv as NodeJS.ProcessEnv)) ??
|
||||||
|
undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -312,9 +452,23 @@ async function gatherDaemonStatus(opts: {
|
|||||||
command,
|
command,
|
||||||
runtime,
|
runtime,
|
||||||
},
|
},
|
||||||
|
config: {
|
||||||
|
cli: cliConfigSummary,
|
||||||
|
daemon: daemonConfigSummary,
|
||||||
|
...(configMismatch ? { mismatch: true } : {}),
|
||||||
|
},
|
||||||
|
gateway: {
|
||||||
|
bindMode,
|
||||||
|
bindHost,
|
||||||
|
port: daemonPort,
|
||||||
|
portSource,
|
||||||
|
probeUrl,
|
||||||
|
...(probeNote ? { probeNote } : {}),
|
||||||
|
},
|
||||||
port: portStatus,
|
port: portStatus,
|
||||||
|
...(portCliStatus ? { portCli: portCliStatus } : {}),
|
||||||
lastError,
|
lastError,
|
||||||
rpc,
|
...(rpc ? { rpc: { ...rpc, url: probeUrl } } : {}),
|
||||||
legacyServices,
|
legacyServices,
|
||||||
extraServices,
|
extraServices,
|
||||||
};
|
};
|
||||||
@@ -341,9 +495,56 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
|
|||||||
`Command: ${service.command.programArguments.join(" ")}`,
|
`Command: ${service.command.programArguments.join(" ")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (service.command?.sourcePath) {
|
||||||
|
defaultRuntime.log(`Service file: ${service.command.sourcePath}`);
|
||||||
|
}
|
||||||
if (service.command?.workingDirectory) {
|
if (service.command?.workingDirectory) {
|
||||||
defaultRuntime.log(`Working dir: ${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 || "<root>"}: ${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 || "<root>"}: ${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);
|
const runtimeLine = formatRuntimeStatus(service.runtime);
|
||||||
if (runtimeLine) {
|
if (runtimeLine) {
|
||||||
defaultRuntime.log(`Runtime: ${runtimeLine}`);
|
defaultRuntime.log(`Runtime: ${runtimeLine}`);
|
||||||
@@ -352,7 +553,12 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
|
|||||||
if (rpc.ok) {
|
if (rpc.ok) {
|
||||||
defaultRuntime.log("RPC probe: ok");
|
defaultRuntime.log("RPC probe: ok");
|
||||||
} else {
|
} 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) {
|
if (service.runtime?.missingUnit) {
|
||||||
@@ -364,7 +570,10 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
|
|||||||
defaultRuntime.error(
|
defaultRuntime.error(
|
||||||
"Service is loaded but not running (likely exited immediately).",
|
"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);
|
defaultRuntime.error(hint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,6 +593,23 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
|
|||||||
defaultRuntime.error(line);
|
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 (
|
if (
|
||||||
service.loaded &&
|
service.loaded &&
|
||||||
service.runtime?.status === "running" &&
|
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`,
|
`Logs: journalctl --user -u ${GATEWAY_SYSTEMD_SERVICE_NAME}.service -n 200 --no-pager`,
|
||||||
);
|
);
|
||||||
} else if (process.platform === "darwin") {
|
} 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(`Logs: ${logs.stdoutPath}`);
|
||||||
defaultRuntime.error(`Errors: ${logs.stderrPath}`);
|
defaultRuntime.error(`Errors: ${logs.stderrPath}`);
|
||||||
}
|
}
|
||||||
@@ -503,6 +731,10 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
|
|||||||
});
|
});
|
||||||
const environment: Record<string, string | undefined> = {
|
const environment: Record<string, string | undefined> = {
|
||||||
PATH: process.env.PATH,
|
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:
|
CLAWDBOT_GATEWAY_TOKEN:
|
||||||
opts.token ||
|
opts.token ||
|
||||||
cfg.gateway?.auth?.token ||
|
cfg.gateway?.auth?.token ||
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "../daemon/constants.js";
|
} from "../daemon/constants.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
import { callGateway } from "../gateway/call.js";
|
import { callGateway } from "../gateway/call.js";
|
||||||
|
import { resolveGatewayAuth } from "../gateway/auth.js";
|
||||||
import { startGatewayServer } from "../gateway/server.js";
|
import { startGatewayServer } from "../gateway/server.js";
|
||||||
import {
|
import {
|
||||||
type GatewayWsLogStyle,
|
type GatewayWsLogStyle,
|
||||||
@@ -413,19 +414,20 @@ export function registerGatewayCli(program: Command) {
|
|||||||
|
|
||||||
const snapshot = await readConfigFileSnapshot().catch(() => null);
|
const snapshot = await readConfigFileSnapshot().catch(() => null);
|
||||||
const miskeys = extractGatewayMiskeys(snapshot?.parsed);
|
const miskeys = extractGatewayMiskeys(snapshot?.parsed);
|
||||||
const authModeFromConfig = cfg.gateway?.auth?.mode;
|
const authConfig = {
|
||||||
const tokenValue =
|
...cfg.gateway?.auth,
|
||||||
opts.token ??
|
...(authMode ? { mode: authMode } : {}),
|
||||||
cfg.gateway?.auth?.token ??
|
...(opts.password ? { password: String(opts.password) } : {}),
|
||||||
process.env.CLAWDBOT_GATEWAY_TOKEN;
|
...(opts.token ? { token: String(opts.token) } : {}),
|
||||||
const passwordValue =
|
};
|
||||||
opts.password ??
|
const resolvedAuth = resolveGatewayAuth({
|
||||||
cfg.gateway?.auth?.password ??
|
authConfig,
|
||||||
process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
env: process.env,
|
||||||
const resolvedAuthMode =
|
tailscaleMode: tailscaleMode ?? cfg.gateway?.tailscale?.mode ?? "off",
|
||||||
authMode ??
|
});
|
||||||
authModeFromConfig ??
|
const resolvedAuthMode = resolvedAuth.mode;
|
||||||
(passwordValue ? "password" : tokenValue ? "token" : "none");
|
const tokenValue = resolvedAuth.token;
|
||||||
|
const passwordValue = resolvedAuth.password;
|
||||||
const authHints: string[] = [];
|
const authHints: string[] = [];
|
||||||
if (miskeys.hasGatewayToken) {
|
if (miskeys.hasGatewayToken) {
|
||||||
authHints.push(
|
authHints.push(
|
||||||
@@ -484,9 +486,10 @@ export function registerGatewayCli(program: Command) {
|
|||||||
await startGatewayServer(port, {
|
await startGatewayServer(port, {
|
||||||
bind,
|
bind,
|
||||||
auth:
|
auth:
|
||||||
authMode || opts.password || authModeRaw
|
authMode || opts.password || opts.token || authModeRaw
|
||||||
? {
|
? {
|
||||||
mode: authMode ?? undefined,
|
mode: authMode ?? undefined,
|
||||||
|
token: opts.token ? String(opts.token) : undefined,
|
||||||
password: opts.password
|
password: opts.password
|
||||||
? String(opts.password)
|
? String(opts.password)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
writeConfigFile,
|
writeConfigFile,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
|
||||||
|
import { readLastGatewayErrorLine } from "../daemon/diagnostics.js";
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
||||||
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
|
import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js";
|
||||||
@@ -216,21 +217,31 @@ export async function doctorCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!healthOk) {
|
if (!healthOk) {
|
||||||
|
const service = resolveGatewayService();
|
||||||
|
const loaded = await service.isLoaded({ env: process.env });
|
||||||
|
let serviceRuntime:
|
||||||
|
| Awaited<ReturnType<typeof service.readRuntime>>
|
||||||
|
| undefined;
|
||||||
|
if (loaded) {
|
||||||
|
serviceRuntime = await service
|
||||||
|
.readRuntime(process.env)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
if (resolveMode(cfg) === "local") {
|
if (resolveMode(cfg) === "local") {
|
||||||
const port = resolveGatewayPort(cfg, process.env);
|
const port = resolveGatewayPort(cfg, process.env);
|
||||||
const diagnostics = await inspectPortUsage(port);
|
const diagnostics = await inspectPortUsage(port);
|
||||||
if (diagnostics.status === "busy") {
|
if (diagnostics.status === "busy") {
|
||||||
note(formatPortDiagnostics(diagnostics).join("\n"), "Gateway port");
|
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) {
|
if (!loaded) {
|
||||||
note("Gateway daemon not installed.", "Gateway");
|
note("Gateway daemon not installed.", "Gateway");
|
||||||
} else {
|
} else {
|
||||||
const serviceRuntime = await service
|
|
||||||
.readRuntime(process.env)
|
|
||||||
.catch(() => undefined);
|
|
||||||
const summary = formatGatewayRuntimeSummary(serviceRuntime);
|
const summary = formatGatewayRuntimeSummary(serviceRuntime);
|
||||||
const hints = buildGatewayRuntimeHints(serviceRuntime, {
|
const hints = buildGatewayRuntimeHints(serviceRuntime, {
|
||||||
platform: process.platform,
|
platform: process.platform,
|
||||||
|
|||||||
@@ -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 () => {
|
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
const { validateConfigObject } = await import("./config.js");
|
const { validateConfigObject } = await import("./config.js");
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
|
|||||||
message:
|
message:
|
||||||
"agent.imageModelFallbacks was replaced by agent.imageModel.fallbacks (run `clawdbot doctor` to migrate).",
|
"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[] = [
|
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<string, unknown>).token;
|
||||||
|
if (token === undefined) return;
|
||||||
|
|
||||||
|
const gatewayObj = gateway as Record<string, unknown>;
|
||||||
|
const auth =
|
||||||
|
gatewayObj.auth && typeof gatewayObj.auth === "object"
|
||||||
|
? (gatewayObj.auth as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
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",
|
id: "telegram.requireMention->telegram.groups.*.requireMention",
|
||||||
describe:
|
describe:
|
||||||
|
|||||||
45
src/daemon/diagnostics.ts
Normal file
45
src/daemon/diagnostics.ts
Normal file
@@ -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<string | null> {
|
||||||
|
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<string | null> {
|
||||||
|
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))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { timingSafeEqual } from "node:crypto";
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import type { IncomingMessage } from "node:http";
|
import type { IncomingMessage } from "node:http";
|
||||||
|
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
|
||||||
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
|
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
|
||||||
|
|
||||||
export type ResolvedGatewayAuth = {
|
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 {
|
export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
|
||||||
if (auth.mode === "token" && !auth.token) {
|
if (auth.mode === "token" && !auth.token) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ import type { WizardSession } from "../wizard/session.js";
|
|||||||
import {
|
import {
|
||||||
assertGatewayAuthConfigured,
|
assertGatewayAuthConfigured,
|
||||||
authorizeGatewayConnect,
|
authorizeGatewayConnect,
|
||||||
|
resolveGatewayAuth,
|
||||||
type ResolvedGatewayAuth,
|
type ResolvedGatewayAuth,
|
||||||
} from "./auth.js";
|
} from "./auth.js";
|
||||||
import {
|
import {
|
||||||
@@ -432,21 +433,12 @@ export async function startGatewayServer(
|
|||||||
...tailscaleOverrides,
|
...tailscaleOverrides,
|
||||||
};
|
};
|
||||||
const tailscaleMode = tailscaleConfig.mode ?? "off";
|
const tailscaleMode = tailscaleConfig.mode ?? "off";
|
||||||
const token =
|
const resolvedAuth = resolveGatewayAuth({
|
||||||
authConfig.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN ?? undefined;
|
authConfig,
|
||||||
const password =
|
env: process.env,
|
||||||
authConfig.password ?? process.env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined;
|
tailscaleMode,
|
||||||
const authMode: ResolvedGatewayAuth["mode"] =
|
});
|
||||||
authConfig.mode ?? (password ? "password" : token ? "token" : "none");
|
const authMode: ResolvedGatewayAuth["mode"] = resolvedAuth.mode;
|
||||||
const allowTailscale =
|
|
||||||
authConfig.allowTailscale ??
|
|
||||||
(tailscaleMode === "serve" && authMode !== "password");
|
|
||||||
const resolvedAuth: ResolvedGatewayAuth = {
|
|
||||||
mode: authMode,
|
|
||||||
token,
|
|
||||||
password,
|
|
||||||
allowTailscale,
|
|
||||||
};
|
|
||||||
let hooksConfig = resolveHooksConfig(cfgAtStart);
|
let hooksConfig = resolveHooksConfig(cfgAtStart);
|
||||||
const canvasHostEnabled =
|
const canvasHostEnabled =
|
||||||
process.env.CLAWDBOT_SKIP_CANVAS_HOST !== "1" &&
|
process.env.CLAWDBOT_SKIP_CANVAS_HOST !== "1" &&
|
||||||
|
|||||||
Reference in New Issue
Block a user