chore: merge origin/main

This commit is contained in:
Peter Steinberger
2026-01-02 16:50:29 +01:00
124 changed files with 4179 additions and 618 deletions

View File

@@ -101,7 +101,7 @@ function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
if (auth.mode === "token" && !auth.token) {
throw new Error(
"gateway auth mode is token, but CLAWDIS_GATEWAY_TOKEN is not set",
"gateway auth mode is token, but no token was configured (set gateway.auth.token or CLAWDIS_GATEWAY_TOKEN)",
);
}
if (auth.mode === "password" && !auth.password) {

View File

@@ -25,8 +25,8 @@ export async function callGateway<T = unknown>(
): Promise<T> {
const timeoutMs = opts.timeoutMs ?? 10_000;
const config = loadConfig();
const remote =
config.gateway?.mode === "remote" ? config.gateway.remote : undefined;
const isRemoteMode = config.gateway?.mode === "remote";
const remote = isRemoteMode ? config.gateway.remote : undefined;
const url =
(typeof opts.url === "string" && opts.url.trim().length > 0
? opts.url.trim()
@@ -39,9 +39,15 @@ export async function callGateway<T = unknown>(
(typeof opts.token === "string" && opts.token.trim().length > 0
? opts.token.trim()
: undefined) ||
(typeof remote?.token === "string" && remote.token.trim().length > 0
? remote.token.trim()
: undefined);
(isRemoteMode
? typeof remote?.token === "string" && remote.token.trim().length > 0
? remote.token.trim()
: undefined
: process.env.CLAWDIS_GATEWAY_TOKEN?.trim() ||
(typeof config.gateway?.auth?.token === "string" &&
config.gateway.auth.token.trim().length > 0
? config.gateway.auth.token.trim()
: undefined));
const password =
(typeof opts.password === "string" && opts.password.trim().length > 0
? opts.password.trim()

View File

@@ -130,6 +130,11 @@ let testGatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined;
let testGatewayAuth: Record<string, unknown> | undefined;
let testHooksConfig: Record<string, unknown> | undefined;
let testCanvasHostPort: number | undefined;
let testLegacyIssues: Array<{ path: string; message: string }> = [];
let testLegacyParsed: Record<string, unknown> = {};
let testMigrationConfig: Record<string, unknown> | null = null;
let testMigrationChanges: string[] = [];
let testIsNixMode = false;
const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 }));
vi.mock("../config/sessions.js", async () => {
const actual = await vi.importActual<typeof import("../config/sessions.js")>(
@@ -151,6 +156,21 @@ vi.mock("../config/config.js", () => {
path.join(os.homedir(), ".clawdis", "clawdis.json");
const readConfigFileSnapshot = async () => {
if (testLegacyIssues.length > 0) {
return {
path: resolveConfigPath(),
exists: true,
raw: JSON.stringify(testLegacyParsed ?? {}),
parsed: testLegacyParsed ?? {},
valid: false,
config: {},
issues: testLegacyIssues.map((issue) => ({
path: issue.path,
message: issue.message,
})),
legacyIssues: testLegacyIssues,
};
}
const configPath = resolveConfigPath();
try {
await fs.access(configPath);
@@ -163,6 +183,7 @@ vi.mock("../config/config.js", () => {
valid: true,
config: {},
issues: [],
legacyIssues: [],
};
}
try {
@@ -176,6 +197,7 @@ vi.mock("../config/config.js", () => {
valid: true,
config: parsed,
issues: [],
legacyIssues: [],
};
} catch (err) {
return {
@@ -186,27 +208,32 @@ vi.mock("../config/config.js", () => {
valid: false,
config: {},
issues: [{ path: "", message: `read failed: ${String(err)}` }],
legacyIssues: [],
};
}
};
const writeConfigFile = async (cfg: Record<string, unknown>) => {
const writeConfigFile = vi.fn(async (cfg: Record<string, unknown>) => {
const configPath = resolveConfigPath();
await fs.mkdir(path.dirname(configPath), { recursive: true });
const raw = JSON.stringify(cfg, null, 2).trimEnd().concat("\n");
await fs.writeFile(configPath, raw, "utf-8");
};
});
return {
CONFIG_PATH_CLAWDIS: resolveConfigPath(),
STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()),
isNixMode: false,
isNixMode: testIsNixMode,
migrateLegacyConfig: (raw: unknown) => ({
config: testMigrationConfig ?? (raw as Record<string, unknown>),
changes: testMigrationChanges,
}),
loadConfig: () => ({
agent: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
},
routing: {
whatsapp: {
allowFrom: testAllowFrom,
},
session: { mainKey: "main", store: testSessionStorePath },
@@ -279,6 +306,11 @@ beforeEach(async () => {
testGatewayAuth = undefined;
testHooksConfig = undefined;
testCanvasHostPort = undefined;
testLegacyIssues = [];
testLegacyParsed = {};
testMigrationConfig = null;
testMigrationChanges = [];
testIsNixMode = false;
cronIsolatedRun.mockClear();
drainSystemEvents();
resetAgentRunContextForTest();
@@ -516,6 +548,40 @@ describe("gateway server", () => {
},
);
test("auto-migrates legacy config on startup", async () => {
(writeConfigFile as unknown as { mockClear?: () => void })?.mockClear?.();
testLegacyIssues = [
{
path: "routing.allowFrom",
message: "legacy",
},
];
testLegacyParsed = { routing: { allowFrom: ["+15555550123"] } };
testMigrationConfig = { whatsapp: { allowFrom: ["+15555550123"] } };
testMigrationChanges = ["Moved routing.allowFrom → whatsapp.allowFrom."];
const port = await getFreePort();
const server = await startGatewayServer(port);
expect(writeConfigFile).toHaveBeenCalledWith(testMigrationConfig);
await server.close();
});
test("fails in Nix mode when legacy config is present", async () => {
testLegacyIssues = [
{
path: "routing.allowFrom",
message: "legacy",
},
];
testLegacyParsed = { routing: { allowFrom: ["+15555550123"] } };
testIsNixMode = true;
const port = await getFreePort();
await expect(startGatewayServer(port)).rejects.toThrow(
"Legacy config entries detected while running in Nix mode",
);
});
test("models.list returns model catalog", async () => {
piSdkMock.enabled = true;
piSdkMock.models = [
@@ -3865,7 +3931,7 @@ describe("gateway server", () => {
thinkingLevel: "low",
verboseLevel: "on",
},
"group:dev": {
"discord:group:dev": {
sessionId: "sess-group",
updatedAt: now - 120_000,
totalTokens: 50,
@@ -3977,7 +4043,7 @@ describe("gateway server", () => {
const deleted = await rpcReq<{ ok: true; deleted: boolean }>(
ws,
"sessions.delete",
{ key: "group:dev" },
{ key: "discord:group:dev" },
);
expect(deleted.ok).toBe(true);
expect(deleted.payload?.deleted).toBe(true);
@@ -3986,7 +4052,9 @@ describe("gateway server", () => {
}>(ws, "sessions.list", {});
expect(listAfterDelete.ok).toBe(true);
expect(
listAfterDelete.payload?.sessions.some((s) => s.key === "group:dev"),
listAfterDelete.payload?.sessions.some(
(s) => s.key === "discord:group:dev",
),
).toBe(false);
const filesAfterDelete = await fs.readdir(dir);
expect(

View File

@@ -48,6 +48,7 @@ import {
CONFIG_PATH_CLAWDIS,
isNixMode,
loadConfig,
migrateLegacyConfig,
parseConfigJson5,
readConfigFileSnapshot,
STATE_DIR_CLAWDIS,
@@ -55,6 +56,7 @@ import {
writeConfigFile,
} from "../config/config.js";
import {
buildGroupDisplayName,
loadSessionStore,
resolveStorePath,
type SessionEntry,
@@ -455,6 +457,11 @@ type GatewaySessionsDefaults = {
type GatewaySessionRow = {
key: string;
kind: "direct" | "group" | "global" | "unknown";
displayName?: string;
surface?: string;
subject?: string;
room?: string;
space?: string;
updatedAt: number | null;
sessionId?: string;
systemSent?: boolean;
@@ -653,7 +660,6 @@ type DedupeEntry = {
error?: ErrorShape;
};
const getGatewayToken = () => process.env.CLAWDIS_GATEWAY_TOKEN;
function formatForLog(value: unknown): string {
try {
@@ -862,13 +868,41 @@ function loadSessionEntry(sessionKey: string) {
return { cfg, storePath, store, entry };
}
function classifySessionKey(key: string): GatewaySessionRow["kind"] {
function classifySessionKey(
key: string,
entry?: SessionEntry,
): GatewaySessionRow["kind"] {
if (key === "global") return "global";
if (key.startsWith("group:")) return "group";
if (key === "unknown") return "unknown";
if (entry?.chatType === "group" || entry?.chatType === "room") return "group";
if (
key.startsWith("group:") ||
key.includes(":group:") ||
key.includes(":channel:")
) {
return "group";
}
return "direct";
}
function parseGroupKey(
key: string,
): { surface?: string; kind?: "group" | "channel"; id?: string } | null {
if (key.startsWith("group:")) {
const raw = key.slice("group:".length);
return raw ? { id: raw } : null;
}
const parts = key.split(":").filter(Boolean);
if (parts.length >= 3) {
const [surface, kind, ...rest] = parts;
if (kind === "group" || kind === "channel") {
const id = rest.join(":");
return { surface, kind, id };
}
}
return null;
}
function getSessionDefaults(cfg: ClawdisConfig): GatewaySessionsDefaults {
const resolved = resolveConfiguredModelRef({
cfg,
@@ -913,9 +947,32 @@ function listSessionsFromStore(params: {
const input = entry?.inputTokens ?? 0;
const output = entry?.outputTokens ?? 0;
const total = entry?.totalTokens ?? input + output;
const parsed = parseGroupKey(key);
const surface = entry?.surface ?? parsed?.surface;
const subject = entry?.subject;
const room = entry?.room;
const space = entry?.space;
const id = parsed?.id;
const displayName =
entry?.displayName ??
(surface
? buildGroupDisplayName({
surface,
subject,
room,
space,
id,
key,
})
: undefined);
return {
key,
kind: classifySessionKey(key),
kind: classifySessionKey(key, entry),
displayName,
surface,
subject,
room,
space,
updatedAt,
sessionId: entry?.sessionId,
systemSent: entry?.systemSent,
@@ -1265,6 +1322,31 @@ export async function startGatewayServer(
port = 18789,
opts: GatewayServerOptions = {},
): Promise<GatewayServer> {
const configSnapshot = await readConfigFileSnapshot();
if (configSnapshot.legacyIssues.length > 0) {
if (isNixMode) {
throw new Error(
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.",
);
}
const { config: migrated, changes } = migrateLegacyConfig(
configSnapshot.parsed,
);
if (!migrated) {
throw new Error(
"Legacy config entries detected but auto-migration failed. Run \"clawdis doctor\" to migrate.",
);
}
await writeConfigFile(migrated);
if (changes.length > 0) {
log.info(
`gateway: migrated legacy config entries:\n${changes
.map((entry) => `- ${entry}`)
.join("\n")}`,
);
}
}
const cfgAtStart = loadConfig();
const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback";
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
@@ -1288,7 +1370,8 @@ export async function startGatewayServer(
...tailscaleOverrides,
};
const tailscaleMode = tailscaleConfig.mode ?? "off";
const token = getGatewayToken();
const token =
authConfig.token ?? process.env.CLAWDIS_GATEWAY_TOKEN ?? undefined;
const password =
authConfig.password ?? process.env.CLAWDIS_GATEWAY_PASSWORD ?? undefined;
const authMode: ResolvedGatewayAuth["mode"] =
@@ -2017,6 +2100,15 @@ export async function startGatewayServer(
const startTelegramProvider = async () => {
if (telegramTask) return;
const cfg = loadConfig();
if (!cfg.telegram) {
telegramRuntime = {
...telegramRuntime,
running: false,
lastError: "not configured",
};
logTelegram.info("skipping provider start (telegram not configured)");
return;
}
if (cfg.telegram?.enabled === false) {
telegramRuntime = {
...telegramRuntime,
@@ -2111,6 +2203,15 @@ export async function startGatewayServer(
const startDiscordProvider = async () => {
if (discordTask) return;
const cfg = loadConfig();
if (!cfg.discord) {
discordRuntime = {
...discordRuntime,
running: false,
lastError: "not configured",
};
logDiscord.info("skipping provider start (discord not configured)");
return;
}
if (cfg.discord?.enabled === false) {
discordRuntime = {
...discordRuntime,
@@ -2153,9 +2254,7 @@ export async function startGatewayServer(
token: discordToken.trim(),
runtime: discordRuntimeEnv,
abortSignal: discordAbort.signal,
allowFrom: cfg.discord?.allowFrom,
guildAllowFrom: cfg.discord?.guildAllowFrom,
requireMention: cfg.discord?.requireMention,
slashCommand: cfg.discord?.slashCommand,
mediaMaxMb: cfg.discord?.mediaMaxMb,
historyLimit: cfg.discord?.historyLimit,
})
@@ -2216,6 +2315,26 @@ export async function startGatewayServer(
logSignal.info("skipping provider start (signal.enabled=false)");
return;
}
const signalCfg = cfg.signal;
const signalMeaningfullyConfigured = Boolean(
signalCfg.account?.trim() ||
signalCfg.httpUrl?.trim() ||
signalCfg.cliPath?.trim() ||
signalCfg.httpHost?.trim() ||
typeof signalCfg.httpPort === "number" ||
typeof signalCfg.autoStart === "boolean",
);
if (!signalMeaningfullyConfigured) {
signalRuntime = {
...signalRuntime,
running: false,
lastError: "not configured",
};
logSignal.info(
"skipping provider start (signal config present but missing required fields)",
);
return;
}
const host = cfg.signal?.httpHost?.trim() || "127.0.0.1";
const port = cfg.signal?.httpPort ?? 8080;
const baseUrl = cfg.signal?.httpUrl?.trim() || `http://${host}:${port}`;
@@ -2881,6 +3000,12 @@ export async function startGatewayServer(
verboseLevel: entry?.verboseLevel,
model: entry?.model,
contextTokens: entry?.contextTokens,
displayName: entry?.displayName,
chatType: entry?.chatType,
surface: entry?.surface,
subject: entry?.subject,
room: entry?.room,
space: entry?.space,
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
skillsSnapshot: entry?.skillsSnapshot,
@@ -4285,21 +4410,33 @@ export async function startGatewayServer(
? Math.max(1000, timeoutMsRaw)
: 10_000;
const cfg = loadConfig();
const telegramCfg = cfg.telegram;
const telegramEnabled =
Boolean(telegramCfg) && telegramCfg?.enabled !== false;
const { token: telegramToken, source: tokenSource } =
resolveTelegramToken(cfg);
telegramEnabled
? resolveTelegramToken(cfg)
: { token: "", source: "none" as const };
let telegramProbe: TelegramProbe | undefined;
let lastProbeAt: number | null = null;
if (probe && telegramToken) {
if (probe && telegramToken && telegramEnabled) {
telegramProbe = await probeTelegram(
telegramToken,
timeoutMs,
cfg.telegram?.proxy,
telegramCfg?.proxy,
);
lastProbeAt = Date.now();
}
const discordEnvToken = process.env.DISCORD_BOT_TOKEN?.trim();
const discordConfigToken = cfg.discord?.token?.trim();
const discordCfg = cfg.discord;
const discordEnabled =
Boolean(discordCfg) && discordCfg?.enabled !== false;
const discordEnvToken = discordEnabled
? process.env.DISCORD_BOT_TOKEN?.trim()
: "";
const discordConfigToken = discordEnabled
? discordCfg?.token?.trim()
: "";
const discordToken = discordEnvToken || discordConfigToken || "";
const discordTokenSource = discordEnvToken
? "env"
@@ -4308,7 +4445,7 @@ export async function startGatewayServer(
: "none";
let discordProbe: DiscordProbe | undefined;
let discordLastProbeAt: number | null = null;
if (probe && discordToken) {
if (probe && discordToken && discordEnabled) {
discordProbe = await probeDiscord(discordToken, timeoutMs);
discordLastProbeAt = Date.now();
}
@@ -4320,7 +4457,17 @@ export async function startGatewayServer(
const signalBaseUrl =
signalCfg?.httpUrl?.trim() ||
`http://${signalHost}:${signalPort}`;
const signalConfigured = Boolean(signalCfg) && signalEnabled;
const signalConfigured =
Boolean(signalCfg) &&
signalEnabled &&
Boolean(
signalCfg?.account?.trim() ||
signalCfg?.httpUrl?.trim() ||
signalCfg?.cliPath?.trim() ||
signalCfg?.httpHost?.trim() ||
typeof signalCfg?.httpPort === "number" ||
typeof signalCfg?.autoStart === "boolean",
);
let signalProbe: SignalProbe | undefined;
let signalLastProbeAt: number | null = null;
if (probe && signalConfigured) {
@@ -4362,7 +4509,7 @@ export async function startGatewayServer(
lastError: whatsappRuntime.lastError ?? null,
},
telegram: {
configured: Boolean(telegramToken),
configured: telegramEnabled && Boolean(telegramToken),
tokenSource,
running: telegramRuntime.running,
mode: telegramRuntime.mode ?? null,
@@ -4373,7 +4520,7 @@ export async function startGatewayServer(
lastProbeAt,
},
discord: {
configured: Boolean(discordToken),
configured: discordEnabled && Boolean(discordToken),
tokenSource: discordTokenSource,
running: discordRuntime.running,
lastStartAt: discordRuntime.lastStartAt ?? null,
@@ -6521,7 +6668,7 @@ export async function startGatewayServer(
if (explicit) return resolvedTo;
const cfg = cfgForAgent ?? loadConfig();
const rawAllow = cfg.routing?.allowFrom ?? [];
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
if (rawAllow.includes("*")) return resolvedTo;
const allowFrom = rawAllow
.map((val) => normalizeE164(val))