fix: wire gateway auth diagnostics into doctor
This commit is contained in:
@@ -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<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 {
|
||||
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<string, string | undefined>),
|
||||
...(serviceEnv ?? {}),
|
||||
} satisfies Record<string, string | undefined>;
|
||||
|
||||
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 || "<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);
|
||||
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<string, string | undefined> = {
|
||||
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 ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ReturnType<typeof service.readRuntime>>
|
||||
| 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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<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",
|
||||
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 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(
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
Reference in New Issue
Block a user