chore: migrate to oxlint and oxfmt

Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
This commit is contained in:
Peter Steinberger
2026-01-14 14:31:43 +00:00
parent 912ebffc63
commit c379191f80
1480 changed files with 28608 additions and 43547 deletions

View File

@@ -1,9 +1,6 @@
import { timingSafeEqual } from "node:crypto";
import type { IncomingMessage } from "node:http";
import type {
GatewayAuthConfig,
GatewayTailscaleMode,
} from "../config/config.js";
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
export type ResolvedGatewayAuth = {
@@ -52,14 +49,10 @@ function isLocalDirectRequest(req?: IncomingMessage): boolean {
const host = (req.headers.host ?? "").toLowerCase();
const hostIsLocal =
host.startsWith("localhost") ||
host.startsWith("127.0.0.1") ||
host.startsWith("[::1]");
host.startsWith("localhost") || host.startsWith("127.0.0.1") || host.startsWith("[::1]");
const hasForwarded = Boolean(
req.headers["x-forwarded-for"] ||
req.headers["x-real-ip"] ||
req.headers["x-forwarded-host"],
req.headers["x-forwarded-for"] || req.headers["x-real-ip"] || req.headers["x-forwarded-host"],
);
return hostIsLocal && !hasForwarded;
@@ -71,17 +64,11 @@ function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
if (typeof login !== "string" || !login.trim()) return null;
const nameRaw = req.headers["tailscale-user-name"];
const profilePic = req.headers["tailscale-user-profile-pic"];
const name =
typeof nameRaw === "string" && nameRaw.trim()
? nameRaw.trim()
: login.trim();
const name = typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : login.trim();
return {
login: login.trim(),
name,
profilePic:
typeof profilePic === "string" && profilePic.trim()
? profilePic.trim()
: undefined,
profilePic: typeof profilePic === "string" && profilePic.trim() ? profilePic.trim() : undefined,
};
}
@@ -89,17 +76,14 @@ function hasTailscaleProxyHeaders(req?: IncomingMessage): boolean {
if (!req) return false;
return Boolean(
req.headers["x-forwarded-for"] &&
req.headers["x-forwarded-proto"] &&
req.headers["x-forwarded-host"],
req.headers["x-forwarded-proto"] &&
req.headers["x-forwarded-host"],
);
}
function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
if (!req) return false;
return (
isLoopbackAddress(req.socket?.remoteAddress) &&
hasTailscaleProxyHeaders(req)
);
return isLoopbackAddress(req.socket?.remoteAddress) && hasTailscaleProxyHeaders(req);
}
export function resolveGatewayAuth(params: {
@@ -110,13 +94,11 @@ export function resolveGatewayAuth(params: {
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 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");
authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password");
return {
mode,
token,
@@ -132,9 +114,7 @@ export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
);
}
if (auth.mode === "password" && !auth.password) {
throw new Error(
"gateway auth mode is password, but no password was configured",
);
throw new Error("gateway auth mode is password, but no password was configured");
}
}

View File

@@ -59,9 +59,7 @@ vi.mock("./client.js", () => ({
},
}));
const { buildGatewayConnectionDetails, callGateway } = await import(
"./call.js"
);
const { buildGatewayConnectionDetails, callGateway } = await import("./call.js");
describe("callGateway url resolution", () => {
beforeEach(() => {
@@ -131,9 +129,7 @@ describe("buildGatewayConnectionDetails", () => {
const details = buildGatewayConnectionDetails();
expect(details.url).toBe("ws://127.0.0.1:18789");
expect(details.urlSource).toBe(
"missing gateway.remote.url (fallback local)",
);
expect(details.urlSource).toBe("missing gateway.remote.url (fallback local)");
expect(details.bindDetail).toBe("Bind: loopback");
expect(details.remoteFallbackNote).toContain(
"gateway.mode=remote but gateway.remote.url is missing",
@@ -209,11 +205,9 @@ describe("callGateway error details", () => {
vi.useFakeTimers();
let err: Error | null = null;
const promise = callGateway({ method: "health", timeoutMs: 5 }).catch(
(caught) => {
err = caught as Error;
},
);
const promise = callGateway({ method: "health", timeoutMs: 5 }).catch((caught) => {
err = caught as Error;
});
await vi.advanceTimersByTimeAsync(5);
await promise;

View File

@@ -52,8 +52,7 @@ export function buildGatewayConnectionDetails(
): GatewayConnectionDetails {
const config = options.config ?? loadConfig();
const configPath =
options.configPath ??
resolveConfigPath(process.env, resolveStateDir(process.env));
options.configPath ?? resolveConfigPath(process.env, resolveStateDir(process.env));
const isRemoteMode = config.gateway?.mode === "remote";
const remote = isRemoteMode ? config.gateway?.remote : undefined;
const localPort = resolveGatewayPort(config);
@@ -69,9 +68,7 @@ export function buildGatewayConnectionDetails(
? options.url.trim()
: undefined;
const remoteUrl =
typeof remote?.url === "string" && remote.url.trim().length > 0
? remote.url.trim()
: undefined;
typeof remote?.url === "string" && remote.url.trim().length > 0 ? remote.url.trim() : undefined;
const remoteMisconfigured = isRemoteMode && !urlOverride && !remoteUrl;
const url = urlOverride || remoteUrl || localUrl;
const urlSource = urlOverride
@@ -86,8 +83,7 @@ export function buildGatewayConnectionDetails(
const remoteFallbackNote = remoteMisconfigured
? "Warn: gateway.mode=remote but gateway.remote.url is missing; set gateway.remote.url or switch gateway.mode=local."
: undefined;
const bindDetail =
!urlOverride && !remoteUrl ? `Bind: ${bindMode}` : undefined;
const bindDetail = !urlOverride && !remoteUrl ? `Bind: ${bindMode}` : undefined;
const message = [
`Gateway target: ${url}`,
`Source: ${urlSource}`,
@@ -107,25 +103,18 @@ export function buildGatewayConnectionDetails(
};
}
export async function callGateway<T = unknown>(
opts: CallGatewayOptions,
): Promise<T> {
export async function callGateway<T = unknown>(opts: CallGatewayOptions): Promise<T> {
const timeoutMs = opts.timeoutMs ?? 10_000;
const config = loadConfig();
const isRemoteMode = config.gateway?.mode === "remote";
const remote = isRemoteMode ? config.gateway?.remote : undefined;
const urlOverride =
typeof opts.url === "string" && opts.url.trim().length > 0
? opts.url.trim()
: undefined;
typeof opts.url === "string" && opts.url.trim().length > 0 ? opts.url.trim() : undefined;
const remoteUrl =
typeof remote?.url === "string" && remote.url.trim().length > 0
? remote.url.trim()
: undefined;
typeof remote?.url === "string" && remote.url.trim().length > 0 ? remote.url.trim() : undefined;
if (isRemoteMode && !urlOverride && !remoteUrl) {
const configPath =
opts.configPath ??
resolveConfigPath(process.env, resolveStateDir(process.env));
opts.configPath ?? resolveConfigPath(process.env, resolveStateDir(process.env));
throw new Error(
[
"gateway remote mode misconfigured: gateway.remote.url missing",
@@ -160,8 +149,7 @@ export async function callGateway<T = unknown>(
: undefined) ||
process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() ||
(isRemoteMode
? typeof remote?.password === "string" &&
remote.password.trim().length > 0
? typeof remote?.password === "string" && remote.password.trim().length > 0
? remote.password.trim()
: undefined
: typeof authPassword === "string" && authPassword.trim().length > 0
@@ -171,11 +159,7 @@ export async function callGateway<T = unknown>(
const formatCloseError = (code: number, reason: string) => {
const reasonText = reason?.trim() || "no close reason";
const hint =
code === 1006
? "abnormal closure (no close frame)"
: code === 1000
? "normal closure"
: "";
code === 1006 ? "abnormal closure (no close frame)" : code === 1000 ? "normal closure" : "";
const suffix = hint ? ` ${hint}` : "";
return `gateway closed (${code}${suffix}): ${reasonText}\n${connectionDetails.message}`;
};

View File

@@ -21,13 +21,7 @@ export function resolveChatRunExpiresAtMs(params: {
minMs?: number;
maxMs?: number;
}): number {
const {
now,
timeoutMs,
graceMs = 60_000,
minMs = 2 * 60_000,
maxMs = 24 * 60 * 60_000,
} = params;
const { now, timeoutMs, graceMs = 60_000, minMs = 2 * 60_000, maxMs = 24 * 60 * 60_000 } = params;
const boundedTimeoutMs = Math.max(0, timeoutMs);
const target = now + boundedTimeoutMs + graceMs;
const min = now + minMs;
@@ -46,16 +40,8 @@ export type ChatAbortOps = {
sessionKey?: string,
) => { sessionKey: string; clientRunId: string } | undefined;
agentRunSeq: Map<string, number>;
broadcast: (
event: string,
payload: unknown,
opts?: { dropIfSlow?: boolean },
) => void;
bridgeSendToSession: (
sessionKey: string,
event: string,
payload: unknown,
) => void;
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
};
function broadcastChatAborted(

View File

@@ -52,9 +52,9 @@ describe("buildMessageWithAttachments", () => {
fileName: "big.png",
content: big,
};
expect(() =>
buildMessageWithAttachments("x", [att], { maxBytes: 5_000_000 }),
).toThrow(/exceeds size limit/i);
expect(() => buildMessageWithAttachments("x", [att], { maxBytes: 5_000_000 })).toThrow(
/exceeds size limit/i,
);
});
});

View File

@@ -28,9 +28,7 @@ function normalizeMime(mime?: string): string | undefined {
return cleaned || undefined;
}
async function sniffMimeFromBase64(
base64: string,
): Promise<string | undefined> {
async function sniffMimeFromBase64(base64: string): Promise<string | undefined> {
const trimmed = base64.trim();
if (!trimmed) return undefined;
@@ -95,23 +93,17 @@ export async function parseMessageWithAttachments(
throw new Error(`attachment ${label}: invalid base64 content`);
}
if (sizeBytes <= 0 || sizeBytes > maxBytes) {
throw new Error(
`attachment ${label}: exceeds size limit (${sizeBytes} > ${maxBytes} bytes)`,
);
throw new Error(`attachment ${label}: exceeds size limit (${sizeBytes} > ${maxBytes} bytes)`);
}
const providedMime = normalizeMime(mime);
const sniffedMime = normalizeMime(await sniffMimeFromBase64(b64));
if (sniffedMime && !isImageMime(sniffedMime)) {
log?.warn(
`attachment ${label}: detected non-image (${sniffedMime}), dropping`,
);
log?.warn(`attachment ${label}: detected non-image (${sniffedMime}), dropping`);
continue;
}
if (!sniffedMime && !isImageMime(providedMime)) {
log?.warn(
`attachment ${label}: unable to detect image mime type, dropping`,
);
log?.warn(`attachment ${label}: unable to detect image mime type, dropping`);
continue;
}
if (sniffedMime && providedMime && sniffedMime !== providedMime) {
@@ -169,9 +161,7 @@ export function buildMessageWithAttachments(
throw new Error(`attachment ${label}: invalid base64 content`);
}
if (sizeBytes <= 0 || sizeBytes > maxBytes) {
throw new Error(
`attachment ${label}: exceeds size limit (${sizeBytes} > ${maxBytes} bytes)`,
);
throw new Error(`attachment ${label}: exceeds size limit (${sizeBytes} > ${maxBytes} bytes)`);
}
const safeLabel = label.replace(/\s+/g, "_");

View File

@@ -51,9 +51,7 @@ describe("GatewayClient", () => {
tickIntervalMs: 5,
},
};
socket.send(
JSON.stringify({ type: "res", id, ok: true, payload: helloOk }),
);
socket.send(JSON.stringify({ type: "res", id, ok: true, payload: helloOk }));
});
});

View File

@@ -82,9 +82,7 @@ export class GatewayClient {
this.ws.on("close", (code, reason) => {
const reasonText = rawDataToString(reason);
this.ws = null;
this.flushPendingErrors(
new Error(`gateway closed (${code}): ${reasonText}`),
);
this.flushPendingErrors(new Error(`gateway closed (${code}): ${reasonText}`));
this.scheduleReconnect();
this.opts.onClose?.(code, reasonText);
});
@@ -139,9 +137,7 @@ export class GatewayClient {
this.opts.onHelloOk?.(helloOk);
})
.catch((err) => {
this.opts.onConnectError?.(
err instanceof Error ? err : new Error(String(err)),
);
this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err)));
const msg = `gateway connect failed: ${String(err)}`;
if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE) logDebug(msg);
else logError(msg);
@@ -178,8 +174,7 @@ export class GatewayClient {
}
this.pending.delete(parsed.id);
if (parsed.ok) pending.resolve(parsed.payload);
else
pending.reject(new Error(parsed.error?.message ?? "unknown error"));
else pending.reject(new Error(parsed.error?.message ?? "unknown error"));
}
} catch (err) {
logDebug(`gateway client parse error: ${String(err)}`);
@@ -229,11 +224,7 @@ export class GatewayClient {
const frame: RequestFrame = { type: "req", id, method, params };
if (!validateRequestFrame(frame)) {
throw new Error(
`invalid request frame: ${JSON.stringify(
validateRequestFrame.errors,
null,
2,
)}`,
`invalid request frame: ${JSON.stringify(validateRequestFrame.errors, null, 2)}`,
);
}
const expectFinal = opts?.expectFinal === true;

View File

@@ -44,9 +44,7 @@ describe("buildGatewayReloadPlan", () => {
listChannelPlugins()
.filter((plugin) =>
(plugin.reload?.configPrefixes ?? []).some((prefix) =>
changedPaths.some(
(path) => path === prefix || path.startsWith(`${prefix}.`),
),
changedPaths.some((path) => path === prefix || path.startsWith(`${prefix}.`)),
),
)
.map((plugin) => plugin.id),

View File

@@ -1,13 +1,6 @@
import chokidar from "chokidar";
import {
type ChannelId,
listChannelPlugins,
} from "../channels/plugins/index.js";
import type {
ClawdbotConfig,
ConfigFileSnapshot,
GatewayReloadMode,
} from "../config/config.js";
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
import type { ClawdbotConfig, ConfigFileSnapshot, GatewayReloadMode } from "../config/config.js";
export type GatewayReloadSettings = {
mode: GatewayReloadMode;
@@ -96,28 +89,22 @@ let cachedReloadRules: ReloadRule[] | null = null;
function listReloadRules(): ReloadRule[] {
if (cachedReloadRules) return cachedReloadRules;
// Channel docking: plugins contribute hot reload/no-op prefixes here.
const channelReloadRules: ReloadRule[] = listChannelPlugins().flatMap(
(plugin) => [
...(plugin.reload?.configPrefixes ?? []).map(
(prefix): ReloadRule => ({
prefix,
kind: "hot",
actions: [`restart-channel:${plugin.id}` as ReloadAction],
}),
),
...(plugin.reload?.noopPrefixes ?? []).map(
(prefix): ReloadRule => ({
prefix,
kind: "none",
}),
),
],
);
const rules = [
...BASE_RELOAD_RULES,
...channelReloadRules,
...BASE_RELOAD_RULES_TAIL,
];
const channelReloadRules: ReloadRule[] = listChannelPlugins().flatMap((plugin) => [
...(plugin.reload?.configPrefixes ?? []).map(
(prefix): ReloadRule => ({
prefix,
kind: "hot",
actions: [`restart-channel:${plugin.id}` as ReloadAction],
}),
),
...(plugin.reload?.noopPrefixes ?? []).map(
(prefix): ReloadRule => ({
prefix,
kind: "none",
}),
),
]);
const rules = [...BASE_RELOAD_RULES, ...channelReloadRules, ...BASE_RELOAD_RULES_TAIL];
cachedReloadRules = rules;
return rules;
}
@@ -132,17 +119,13 @@ function matchRule(path: string): ReloadRule | null {
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(
value &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]",
typeof value === "object" &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]",
);
}
export function diffConfigPaths(
prev: unknown,
next: unknown,
prefix = "",
): string[] {
export function diffConfigPaths(prev: unknown, next: unknown, prefix = ""): string[] {
if (prev === next) return [];
if (isPlainObject(prev) && isPlainObject(next)) {
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
@@ -160,25 +143,17 @@ export function diffConfigPaths(
return paths;
}
if (Array.isArray(prev) && Array.isArray(next)) {
if (
prev.length === next.length &&
prev.every((val, idx) => val === next[idx])
) {
if (prev.length === next.length && prev.every((val, idx) => val === next[idx])) {
return [];
}
}
return [prefix || "<root>"];
}
export function resolveGatewayReloadSettings(
cfg: ClawdbotConfig,
): GatewayReloadSettings {
export function resolveGatewayReloadSettings(cfg: ClawdbotConfig): GatewayReloadSettings {
const rawMode = cfg.gateway?.reload?.mode;
const mode =
rawMode === "off" ||
rawMode === "restart" ||
rawMode === "hot" ||
rawMode === "hybrid"
rawMode === "off" || rawMode === "restart" || rawMode === "hot" || rawMode === "hybrid"
? rawMode
: DEFAULT_RELOAD_SETTINGS.mode;
const debounceRaw = cfg.gateway?.reload?.debounceMs;
@@ -189,9 +164,7 @@ export function resolveGatewayReloadSettings(
return { mode, debounceMs };
}
export function buildGatewayReloadPlan(
changedPaths: string[],
): GatewayReloadPlan {
export function buildGatewayReloadPlan(changedPaths: string[]): GatewayReloadPlan {
const plan: GatewayReloadPlan = {
changedPaths,
restartGateway: false,
@@ -269,10 +242,7 @@ export type GatewayConfigReloader = {
export function startGatewayConfigReloader(opts: {
initialConfig: ClawdbotConfig;
readSnapshot: () => Promise<ConfigFileSnapshot>;
onHotReload: (
plan: GatewayReloadPlan,
nextConfig: ClawdbotConfig,
) => Promise<void>;
onHotReload: (plan: GatewayReloadPlan, nextConfig: ClawdbotConfig) => Promise<void>;
onRestart: (plan: GatewayReloadPlan, nextConfig: ClawdbotConfig) => void;
log: {
info: (msg: string) => void;
@@ -312,9 +282,7 @@ export function startGatewayConfigReloader(opts: {
try {
const snapshot = await opts.readSnapshot();
if (!snapshot.valid) {
const issues = snapshot.issues
.map((issue) => `${issue.path}: ${issue.message}`)
.join(", ");
const issues = snapshot.issues.map((issue) => `${issue.path}: ${issue.message}`).join(", ");
opts.log.warn(`config reload skipped (invalid config): ${issues}`);
return;
}
@@ -324,9 +292,7 @@ export function startGatewayConfigReloader(opts: {
settings = resolveGatewayReloadSettings(nextConfig);
if (changedPaths.length === 0) return;
opts.log.info(
`config change detected; evaluating reload (${changedPaths.join(", ")})`,
);
opts.log.info(`config change detected; evaluating reload (${changedPaths.join(", ")})`);
const plan = buildGatewayReloadPlan(changedPaths);
if (settings.mode === "off") {
opts.log.info("config reload disabled (gateway.reload.mode=off)");

View File

@@ -98,11 +98,7 @@ function injectControlUiBasePath(html: string, basePath: string): string {
return `${script}${html}`;
}
function serveIndexHtml(
res: ServerResponse,
indexPath: string,
basePath: string,
) {
function serveIndexHtml(res: ServerResponse, indexPath: string, basePath: string) {
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
const raw = fs.readFileSync(indexPath, "utf8");
@@ -163,9 +159,7 @@ export function handleControlUiHttpRequest(
}
const uiPath =
basePath && pathname.startsWith(`${basePath}/`)
? pathname.slice(basePath.length)
: pathname;
basePath && pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : pathname;
const rel = (() => {
if (uiPath === ROOT_PREFIX) return "";
const assetsIndex = uiPath.indexOf("/assets/");

View File

@@ -18,12 +18,7 @@ const CLI_RESUME = process.env.CLAWDBOT_LIVE_CLI_BACKEND_RESUME_PROBE === "1";
const describeLive = LIVE && CLI_LIVE ? describe : describe.skip;
const DEFAULT_MODEL = "claude-cli/claude-sonnet-4-5";
const DEFAULT_CLAUDE_ARGS = [
"-p",
"--output-format",
"json",
"--dangerously-skip-permissions",
];
const DEFAULT_CLAUDE_ARGS = ["-p", "--output-format", "json", "--dangerously-skip-permissions"];
const DEFAULT_CODEX_ARGS = [
"exec",
"--json",
@@ -79,26 +74,16 @@ function extractPayloadText(result: unknown): string {
const record = result as Record<string, unknown>;
const payloads = Array.isArray(record.payloads) ? record.payloads : [];
const texts = payloads
.map((p) =>
p && typeof p === "object"
? (p as Record<string, unknown>).text
: undefined,
)
.map((p) => (p && typeof p === "object" ? (p as Record<string, unknown>).text : undefined))
.filter((t): t is string => typeof t === "string" && t.trim().length > 0);
return texts.join("\n").trim();
}
function parseJsonStringArray(
name: string,
raw?: string,
): string[] | undefined {
function parseJsonStringArray(name: string, raw?: string): string[] | undefined {
const trimmed = raw?.trim();
if (!trimmed) return undefined;
const parsed = JSON.parse(trimmed);
if (
!Array.isArray(parsed) ||
!parsed.every((entry) => typeof entry === "string")
) {
if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string")) {
throw new Error(`${name} must be a JSON array of strings.`);
}
return parsed;
@@ -108,15 +93,10 @@ function parseImageMode(raw?: string): "list" | "repeat" | undefined {
const trimmed = raw?.trim();
if (!trimmed) return undefined;
if (trimmed === "list" || trimmed === "repeat") return trimmed;
throw new Error(
"CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_MODE must be 'list' or 'repeat'.",
);
throw new Error("CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_MODE must be 'list' or 'repeat'.");
}
function withMcpConfigOverrides(
args: string[],
mcpConfigPath: string,
): string[] {
function withMcpConfigOverrides(args: string[], mcpConfigPath: string): string[] {
const next = [...args];
if (!next.includes("--strict-mcp-config")) {
next.push("--strict-mcp-config");
@@ -162,9 +142,9 @@ async function getFreeGatewayPort(): Promise<number> {
for (let attempt = 0; attempt < 25; attempt += 1) {
const port = await getFreePort();
const candidates = [port, port + 1, port + 2, port + 4];
const ok = (
await Promise.all(candidates.map((candidate) => isPortFree(candidate)))
).every(Boolean);
const ok = (await Promise.all(candidates.map((candidate) => isPortFree(candidate)))).every(
Boolean,
);
if (ok) return port;
}
throw new Error("failed to acquire a free gateway port block");
@@ -191,10 +171,7 @@ async function connectClient(params: { url: string; token: string }) {
onClose: (code, reason) =>
stop(new Error(`gateway closed during connect (${code}): ${reason}`)),
});
const timer = setTimeout(
() => stop(new Error("gateway connect timeout")),
10_000,
);
const timer = setTimeout(() => stop(new Error("gateway connect timeout")), 10_000);
timer.unref();
client.start();
});
@@ -223,8 +200,7 @@ describeLive("gateway live (cli backend)", () => {
const token = `test-${randomUUID()}`;
process.env.CLAWDBOT_GATEWAY_TOKEN = token;
const rawModel =
process.env.CLAWDBOT_LIVE_CLI_BACKEND_MODEL ?? DEFAULT_MODEL;
const rawModel = process.env.CLAWDBOT_LIVE_CLI_BACKEND_MODEL ?? DEFAULT_MODEL;
const parsed = parseModelRef(rawModel, "claude-cli");
if (!parsed) {
throw new Error(
@@ -241,9 +217,7 @@ describeLive("gateway live (cli backend)", () => {
? { command: "codex", args: DEFAULT_CODEX_ARGS }
: null;
const cliCommand =
process.env.CLAWDBOT_LIVE_CLI_BACKEND_COMMAND ??
providerDefaults?.command;
const cliCommand = process.env.CLAWDBOT_LIVE_CLI_BACKEND_COMMAND ?? providerDefaults?.command;
if (!cliCommand) {
throw new Error(
`CLAWDBOT_LIVE_CLI_BACKEND_COMMAND is required for provider "${providerId}".`,
@@ -255,20 +229,15 @@ describeLive("gateway live (cli backend)", () => {
process.env.CLAWDBOT_LIVE_CLI_BACKEND_ARGS,
) ?? providerDefaults?.args;
if (!baseCliArgs || baseCliArgs.length === 0) {
throw new Error(
`CLAWDBOT_LIVE_CLI_BACKEND_ARGS is required for provider "${providerId}".`,
);
throw new Error(`CLAWDBOT_LIVE_CLI_BACKEND_ARGS is required for provider "${providerId}".`);
}
const cliClearEnv =
parseJsonStringArray(
"CLAWDBOT_LIVE_CLI_BACKEND_CLEAR_ENV",
process.env.CLAWDBOT_LIVE_CLI_BACKEND_CLEAR_ENV,
) ?? (providerId === "claude-cli" ? DEFAULT_CLEAR_ENV : []);
const cliImageArg =
process.env.CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_ARG?.trim() || undefined;
const cliImageMode = parseImageMode(
process.env.CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_MODE,
);
const cliImageArg = process.env.CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_ARG?.trim() || undefined;
const cliImageMode = parseImageMode(process.env.CLAWDBOT_LIVE_CLI_BACKEND_IMAGE_MODE);
if (cliImageMode && !cliImageArg) {
throw new Error(
@@ -276,18 +245,12 @@ describeLive("gateway live (cli backend)", () => {
);
}
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-live-cli-"),
);
const disableMcpConfig =
process.env.CLAWDBOT_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG !== "0";
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-live-cli-"));
const disableMcpConfig = process.env.CLAWDBOT_LIVE_CLI_BACKEND_DISABLE_MCP_CONFIG !== "0";
let cliArgs = baseCliArgs;
if (providerId === "claude-cli" && disableMcpConfig) {
const mcpConfigPath = path.join(tempDir, "claude-mcp.json");
await fs.writeFile(
mcpConfigPath,
`${JSON.stringify({ mcpServers: {} }, null, 2)}\n`,
);
await fs.writeFile(mcpConfigPath, `${JSON.stringify({ mcpServers: {} }, null, 2)}\n`);
cliArgs = withMcpConfigOverrides(baseCliArgs, mcpConfigPath);
}
@@ -310,9 +273,7 @@ describeLive("gateway live (cli backend)", () => {
args: cliArgs,
clearEnv: cliClearEnv.length > 0 ? cliClearEnv : undefined,
systemPromptWhen: "never",
...(cliImageArg
? { imageArg: cliImageArg, imageMode: cliImageMode }
: {}),
...(cliImageArg ? { imageArg: cliImageArg, imageMode: cliImageMode } : {}),
},
},
sandbox: { mode: "off" },
@@ -418,53 +379,40 @@ describeLive("gateway live (cli backend)", () => {
{ expectFinal: true },
);
if (imageProbe?.status !== "ok") {
throw new Error(
`image probe failed: status=${String(imageProbe?.status)}`,
);
throw new Error(`image probe failed: status=${String(imageProbe?.status)}`);
}
const imageText = extractPayloadText(imageProbe?.result);
if (!/\bcat\b/i.test(imageText)) {
throw new Error(`image probe missing 'cat': ${imageText}`);
}
const candidates =
imageText.toUpperCase().match(/[A-Z0-9]{6,20}/g) ?? [];
const candidates = imageText.toUpperCase().match(/[A-Z0-9]{6,20}/g) ?? [];
const bestDistance = candidates.reduce((best, cand) => {
if (Math.abs(cand.length - imageCode.length) > 2) return best;
return Math.min(best, editDistance(cand, imageCode));
}, Number.POSITIVE_INFINITY);
if (!(bestDistance <= 5)) {
throw new Error(
`image probe missing code (${imageCode}): ${imageText}`,
);
throw new Error(`image probe missing code (${imageCode}): ${imageText}`);
}
}
} finally {
client.stop();
await server.close();
await fs.rm(tempDir, { recursive: true, force: true });
if (previous.configPath === undefined)
delete process.env.CLAWDBOT_CONFIG_PATH;
if (previous.configPath === undefined) delete process.env.CLAWDBOT_CONFIG_PATH;
else process.env.CLAWDBOT_CONFIG_PATH = previous.configPath;
if (previous.token === undefined)
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
if (previous.token === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN;
else process.env.CLAWDBOT_GATEWAY_TOKEN = previous.token;
if (previous.skipChannels === undefined)
delete process.env.CLAWDBOT_SKIP_CHANNELS;
if (previous.skipChannels === undefined) delete process.env.CLAWDBOT_SKIP_CHANNELS;
else process.env.CLAWDBOT_SKIP_CHANNELS = previous.skipChannels;
if (previous.skipGmail === undefined)
delete process.env.CLAWDBOT_SKIP_GMAIL_WATCHER;
if (previous.skipGmail === undefined) delete process.env.CLAWDBOT_SKIP_GMAIL_WATCHER;
else process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = previous.skipGmail;
if (previous.skipCron === undefined)
delete process.env.CLAWDBOT_SKIP_CRON;
if (previous.skipCron === undefined) delete process.env.CLAWDBOT_SKIP_CRON;
else process.env.CLAWDBOT_SKIP_CRON = previous.skipCron;
if (previous.skipCanvas === undefined)
delete process.env.CLAWDBOT_SKIP_CANVAS_HOST;
if (previous.skipCanvas === undefined) delete process.env.CLAWDBOT_SKIP_CANVAS_HOST;
else process.env.CLAWDBOT_SKIP_CANVAS_HOST = previous.skipCanvas;
if (previous.anthropicApiKey === undefined)
delete process.env.ANTHROPIC_API_KEY;
if (previous.anthropicApiKey === undefined) delete process.env.ANTHROPIC_API_KEY;
else process.env.ANTHROPIC_API_KEY = previous.anthropicApiKey;
if (previous.anthropicApiKeyOld === undefined)
delete process.env.ANTHROPIC_API_KEY_OLD;
if (previous.anthropicApiKeyOld === undefined) delete process.env.ANTHROPIC_API_KEY_OLD;
else process.env.ANTHROPIC_API_KEY_OLD = previous.anthropicApiKeyOld;
}
}, 60_000);

View File

@@ -5,10 +5,7 @@ import os from "node:os";
import path from "node:path";
import type { Api, Model } from "@mariozechner/pi-ai";
import {
discoverAuthStorage,
discoverModels,
} from "@mariozechner/pi-coding-agent";
import { discoverAuthStorage, discoverModels } from "@mariozechner/pi-coding-agent";
import { describe, it } from "vitest";
import { resolveClawdbotAgentDir } from "../agents/agent-paths.js";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
@@ -27,10 +24,7 @@ import { getApiKeyForModel } from "../agents/model-auth.js";
import { ensureClawdbotModelsJson } from "../agents/models-config.js";
import { loadConfig } from "../config/config.js";
import type { ClawdbotConfig, ModelProviderConfig } from "../config/types.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-channel.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { GatewayClient } from "./client.js";
import { renderCatNoncePngBase64 } from "./live-image-probe.js";
import { startGatewayServer } from "./server.js";
@@ -67,8 +61,7 @@ function assertNoReasoningTags(params: {
}): void {
if (!params.text) return;
if (THINKING_TAG_RE.test(params.text) || FINAL_TAG_RE.test(params.text)) {
const snippet =
params.text.length > 200 ? `${params.text.slice(0, 200)}` : params.text;
const snippet = params.text.length > 200 ? `${params.text.slice(0, 200)}` : params.text;
throw new Error(
`[${params.label}] reasoning tag leak (${params.model} / ${params.phase}): ${snippet}`,
);
@@ -79,11 +72,7 @@ function extractPayloadText(result: unknown): string {
const record = result as Record<string, unknown>;
const payloads = Array.isArray(record.payloads) ? record.payloads : [];
const texts = payloads
.map((p) =>
p && typeof p === "object"
? (p as Record<string, unknown>).text
: undefined,
)
.map((p) => (p && typeof p === "object" ? (p as Record<string, unknown>).text : undefined))
.filter((t): t is string => typeof t === "string" && t.trim().length > 0);
return texts.join("\n").trim();
}
@@ -196,9 +185,9 @@ async function getFreeGatewayPort(): Promise<number> {
for (let attempt = 0; attempt < 25; attempt += 1) {
const port = await getFreePort();
const candidates = [port, port + 1, port + 2, port + 4];
const ok = (
await Promise.all(candidates.map((candidate) => isPortFree(candidate)))
).every(Boolean);
const ok = (await Promise.all(candidates.map((candidate) => isPortFree(candidate)))).every(
Boolean,
);
if (ok) return port;
}
throw new Error("failed to acquire a free gateway port block");
@@ -231,10 +220,7 @@ async function connectClient(params: { url: string; token: string }) {
onClose: (code, reason) =>
stop(new Error(`gateway closed during connect (${code}): ${reason}`)),
});
const timer = setTimeout(
() => stop(new Error("gateway connect timeout")),
10_000,
);
const timer = setTimeout(() => stop(new Error("gateway connect timeout")), 10_000);
timer.unref();
client.start();
});
@@ -270,8 +256,7 @@ function buildLiveGatewayConfig(params: {
: {}),
...providerOverrides,
};
const providers =
Object.keys(nextProviders).length > 0 ? nextProviders : baseProviders;
const providers = Object.keys(nextProviders).length > 0 ? nextProviders : baseProviders;
return {
...params.cfg,
agents: {
@@ -285,15 +270,11 @@ function buildLiveGatewayConfig(params: {
// Live tests should avoid Docker sandboxing so tool probes can
// operate on the temporary probe files we create in the host workspace.
sandbox: { mode: "off" },
models: Object.fromEntries(
params.candidates.map((m) => [`${m.provider}/${m.id}`, {}]),
),
models: Object.fromEntries(params.candidates.map((m) => [`${m.provider}/${m.id}`, {}])),
},
},
models:
Object.keys(providers).length > 0
? { ...params.cfg.models, providers }
: params.cfg.models,
Object.keys(providers).length > 0 ? { ...params.cfg.models, providers } : params.cfg.models,
};
}
@@ -342,12 +323,7 @@ function buildMinimaxProviderOverride(params: {
baseUrl: string;
}): ModelProviderConfig | null {
const existing = params.cfg.models?.providers?.minimax;
if (
!existing ||
!Array.isArray(existing.models) ||
existing.models.length === 0
)
return null;
if (!existing || !Array.isArray(existing.models) || existing.models.length === 0) return null;
return {
...existing,
api: params.api,
@@ -392,9 +368,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
lastGood: hostStore.lastGood ? { ...hostStore.lastGood } : undefined,
usageStats: hostStore.usageStats ? { ...hostStore.usageStats } : undefined,
};
tempStateDir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-live-state-"),
);
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-live-state-"));
process.env.CLAWDBOT_STATE_DIR = tempStateDir;
tempAgentDir = path.join(tempStateDir, "agents", "main", "agent");
saveAuthProfileStore(sanitizedStore, tempAgentDir);
@@ -405,10 +379,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
await fs.mkdir(workspaceDir, { recursive: true });
const nonceA = randomUUID();
const nonceB = randomUUID();
const toolProbePath = path.join(
workspaceDir,
`.clawdbot-live-tool-probe.${nonceA}.txt`,
);
const toolProbePath = path.join(workspaceDir, `.clawdbot-live-tool-probe.${nonceA}.txt`);
await fs.writeFile(toolProbePath, `nonceA=${nonceA}\nnonceB=${nonceB}\n`);
const agentDir = resolveClawdbotAgentDir();
@@ -447,9 +418,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
const anthropicKeys = collectAnthropicApiKeys();
if (anthropicKeys.length > 0) {
process.env.ANTHROPIC_API_KEY = anthropicKeys[0];
logProgress(
`[${params.label}] anthropic keys loaded: ${anthropicKeys.length}`,
);
logProgress(`[${params.label}] anthropic keys loaded: ${anthropicKeys.length}`);
}
const sessionKey = `agent:${agentId}:${params.label}`;
const failures: Array<{ model: string; error: string }> = [];
@@ -461,9 +430,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
const progressLabel = `[${params.label}] ${index + 1}/${total} ${modelKey}`;
const attemptMax =
model.provider === "anthropic" && anthropicKeys.length > 0
? anthropicKeys.length
: 1;
model.provider === "anthropic" && anthropicKeys.length > 0 ? anthropicKeys.length : 1;
for (let attempt = 0; attempt < attemptMax; attempt += 1) {
if (model.provider === "anthropic" && anthropicKeys.length > 0) {
@@ -504,9 +471,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
isEmptyStreamText(text) &&
(model.provider === "minimax" || model.provider === "openai-codex")
) {
logProgress(
`${progressLabel}: skip (${model.provider} empty response)`,
);
logProgress(`${progressLabel}: skip (${model.provider} empty response)`);
break;
}
if (model.provider === "google" && isGoogleModelNotFoundText(text)) {
@@ -522,10 +487,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
label: params.label,
});
if (!isMeaningful(text)) throw new Error(`not meaningful: ${text}`);
if (
!/\bmicro\s*-?\s*tasks?\b/i.test(text) ||
!/\bmacro\s*-?\s*tasks?\b/i.test(text)
) {
if (!/\bmicro\s*-?\s*tasks?\b/i.test(text) || !/\bmacro\s*-?\s*tasks?\b/i.test(text)) {
throw new Error(`missing required keywords: ${text}`);
}
@@ -547,18 +509,14 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
{ expectFinal: true },
);
if (toolProbe?.status !== "ok") {
throw new Error(
`tool probe failed: status=${String(toolProbe?.status)}`,
);
throw new Error(`tool probe failed: status=${String(toolProbe?.status)}`);
}
const toolText = extractPayloadText(toolProbe?.result);
if (
isEmptyStreamText(toolText) &&
(model.provider === "minimax" || model.provider === "openai-codex")
) {
logProgress(
`${progressLabel}: skip (${model.provider} empty response)`,
);
logProgress(`${progressLabel}: skip (${model.provider} empty response)`);
break;
}
assertNoReasoningTags({
@@ -593,19 +551,14 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
{ expectFinal: true },
);
if (execReadProbe?.status !== "ok") {
throw new Error(
`exec+read probe failed: status=${String(execReadProbe?.status)}`,
);
throw new Error(`exec+read probe failed: status=${String(execReadProbe?.status)}`);
}
const execReadText = extractPayloadText(execReadProbe?.result);
if (
isEmptyStreamText(execReadText) &&
(model.provider === "minimax" ||
model.provider === "openai-codex")
(model.provider === "minimax" || model.provider === "openai-codex")
) {
logProgress(
`${progressLabel}: skip (${model.provider} empty response)`,
);
logProgress(`${progressLabel}: skip (${model.provider} empty response)`);
break;
}
assertNoReasoningTags({
@@ -652,19 +605,14 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
// Best-effort: do not fail the whole live suite on flaky image handling.
// (We still keep prompt + tool probes as hard checks.)
if (imageProbe?.status !== "ok") {
logProgress(
`${progressLabel}: image skip (status=${String(imageProbe?.status)})`,
);
logProgress(`${progressLabel}: image skip (status=${String(imageProbe?.status)})`);
} else {
const imageText = extractPayloadText(imageProbe?.result);
if (
isEmptyStreamText(imageText) &&
(model.provider === "minimax" ||
model.provider === "openai-codex")
(model.provider === "minimax" || model.provider === "openai-codex")
) {
logProgress(
`${progressLabel}: image skip (${model.provider} empty response)`,
);
logProgress(`${progressLabel}: image skip (${model.provider} empty response)`);
} else {
assertNoReasoningTags({
text: imageText,
@@ -675,11 +623,9 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
if (!/\bcat\b/i.test(imageText)) {
logProgress(`${progressLabel}: image skip (missing 'cat')`);
} else {
const candidates =
imageText.toUpperCase().match(/[A-Z0-9]{6,20}/g) ?? [];
const candidates = imageText.toUpperCase().match(/[A-Z0-9]{6,20}/g) ?? [];
const bestDistance = candidates.reduce((best, cand) => {
if (Math.abs(cand.length - imageCode.length) > 2)
return best;
if (Math.abs(cand.length - imageCode.length) > 2) return best;
return Math.min(best, editDistance(cand, imageCode));
}, Number.POSITIVE_INFINITY);
// OCR / image-read flake: allow a small edit distance, but still require the "cat" token above.
@@ -694,8 +640,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
// Regression: tool-call-only turn followed by a user message (OpenAI responses bug class).
if (
(model.provider === "openai" && model.api === "openai-responses") ||
(model.provider === "openai-codex" &&
model.api === "openai-codex-responses")
(model.provider === "openai-codex" && model.api === "openai-codex-responses")
) {
logProgress(`${progressLabel}: tool-only regression`);
const runId2 = randomUUID();
@@ -711,9 +656,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
{ expectFinal: true },
);
if (first?.status !== "ok") {
throw new Error(
`tool-only turn failed: status=${String(first?.status)}`,
);
throw new Error(`tool-only turn failed: status=${String(first?.status)}`);
}
const firstText = extractPayloadText(first?.result);
assertNoReasoningTags({
@@ -735,9 +678,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
{ expectFinal: true },
);
if (second?.status !== "ok") {
throw new Error(
`post-tool message failed: status=${String(second?.status)}`,
);
throw new Error(`post-tool message failed: status=${String(second?.status)}`);
}
const reply = extractPayloadText(second?.result);
assertNoReasoningTags({
@@ -763,14 +704,9 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
logProgress(`${progressLabel}: rate limit, retrying with next key`);
continue;
}
if (
model.provider === "anthropic" &&
isAnthropicBillingError(message)
) {
if (model.provider === "anthropic" && isAnthropicBillingError(message)) {
if (attempt + 1 < attemptMax) {
logProgress(
`${progressLabel}: billing issue, retrying with next key`,
);
logProgress(`${progressLabel}: billing issue, retrying with next key`);
continue;
}
logProgress(`${progressLabel}: skip (anthropic billing)`);
@@ -781,9 +717,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
isEmptyStreamText(message) &&
attempt + 1 < attemptMax
) {
logProgress(
`${progressLabel}: empty response, retrying with next key`,
);
logProgress(`${progressLabel}: empty response, retrying with next key`);
continue;
}
if (model.provider === "anthropic" && isEmptyStreamText(message)) {
@@ -792,10 +726,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
break;
}
// OpenAI Codex refresh tokens can become single-use; skip instead of failing all live tests.
if (
model.provider === "openai-codex" &&
isRefreshTokenReused(message)
) {
if (model.provider === "openai-codex" && isRefreshTokenReused(message)) {
logProgress(`${progressLabel}: skip (codex refresh token reused)`);
break;
}
@@ -821,9 +752,7 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
.slice(0, 20)
.map((f) => `- ${f.model}: ${f.error}`)
.join("\n");
throw new Error(
`gateway live model failures (${failures.length}):\n${preview}`,
);
throw new Error(`gateway live model failures (${failures.length}):\n${preview}`);
}
if (skippedCount === total) {
logProgress(`[${params.label}] skipped all models (missing profiles)`);
@@ -868,15 +797,12 @@ describeLive("gateway live (dev agent, profile keys)", () => {
const all = modelRegistry.getAll() as Array<Model<Api>>;
const rawModels = process.env.CLAWDBOT_LIVE_GATEWAY_MODELS?.trim();
const useModern =
!rawModels || rawModels === "modern" || rawModels === "all";
const useModern = !rawModels || rawModels === "modern" || rawModels === "all";
const useExplicit = Boolean(rawModels) && !useModern;
const filter = useExplicit ? parseFilter(rawModels) : null;
const wanted = filter
? all.filter((m) => filter.has(`${m.provider}/${m.id}`))
: all.filter((m) =>
isModernModelRef({ provider: m.provider, id: m.id }),
);
: all.filter((m) => isModernModelRef({ provider: m.provider, id: m.id }));
const candidates: Array<Model<Api>> = [];
for (const model of wanted) {
@@ -902,16 +828,10 @@ describeLive("gateway live (dev agent, profile keys)", () => {
logProgress("[all-models] no API keys found; skipping");
return;
}
logProgress(
`[all-models] selection=${useExplicit ? "explicit" : "modern"}`,
);
const imageCandidates = candidates.filter((m) =>
m.input?.includes("image"),
);
logProgress(`[all-models] selection=${useExplicit ? "explicit" : "modern"}`);
const imageCandidates = candidates.filter((m) => m.input?.includes("image"));
if (imageCandidates.length === 0) {
logProgress(
"[all-models] no image-capable models selected; image probe will be skipped",
);
logProgress("[all-models] no image-capable models selected; image probe will be skipped");
}
await runGatewayModelSuite({
label: "all-models",
@@ -922,13 +842,9 @@ describeLive("gateway live (dev agent, profile keys)", () => {
thinkingLevel: THINKING_LEVEL,
});
const minimaxCandidates = candidates.filter(
(model) => model.provider === "minimax",
);
const minimaxCandidates = candidates.filter((model) => model.provider === "minimax");
if (minimaxCandidates.length === 0) {
logProgress(
"[minimax] no candidates with keys; skipping dual endpoint probes",
);
logProgress("[minimax] no candidates with keys; skipping dual endpoint probes");
return;
}
@@ -948,9 +864,7 @@ describeLive("gateway live (dev agent, profile keys)", () => {
providerOverrides: { minimax: minimaxAnthropic },
});
} else {
logProgress(
"[minimax-anthropic] missing minimax provider config; skipping",
);
logProgress("[minimax-anthropic] missing minimax provider config; skipping");
}
},
20 * 60 * 1000,
@@ -981,10 +895,7 @@ describeLive("gateway live (dev agent, profile keys)", () => {
const agentDir = resolveClawdbotAgentDir();
const authStorage = discoverAuthStorage(agentDir);
const modelRegistry = discoverModels(authStorage, agentDir);
const anthropic = modelRegistry.find(
"anthropic",
"claude-opus-4-5",
) as Model<Api> | null;
const anthropic = modelRegistry.find("anthropic", "claude-opus-4-5") as Model<Api> | null;
const zai = modelRegistry.find("zai", "glm-4.7") as Model<Api> | null;
if (!anthropic || !zai) return;
@@ -1000,10 +911,7 @@ describeLive("gateway live (dev agent, profile keys)", () => {
await fs.mkdir(workspaceDir, { recursive: true });
const nonceA = randomUUID();
const nonceB = randomUUID();
const toolProbePath = path.join(
workspaceDir,
`.clawdbot-live-zai-fallback.${nonceA}.txt`,
);
const toolProbePath = path.join(workspaceDir, `.clawdbot-live-zai-fallback.${nonceA}.txt`);
await fs.writeFile(toolProbePath, `nonceA=${nonceA}\nnonceB=${nonceB}\n`);
const port = await getFreeGatewayPort();
@@ -1044,9 +952,7 @@ describeLive("gateway live (dev agent, profile keys)", () => {
{ expectFinal: true },
);
if (toolProbe?.status !== "ok") {
throw new Error(
`anthropic tool probe failed: status=${String(toolProbe?.status)}`,
);
throw new Error(`anthropic tool probe failed: status=${String(toolProbe?.status)}`);
}
const toolText = extractPayloadText(toolProbe?.result);
assertNoReasoningTags({
@@ -1079,9 +985,7 @@ describeLive("gateway live (dev agent, profile keys)", () => {
{ expectFinal: true },
);
if (followup?.status !== "ok") {
throw new Error(
`zai followup failed: status=${String(followup?.status)}`,
);
throw new Error(`zai followup failed: status=${String(followup?.status)}`);
}
const followupText = extractPayloadText(followup?.result);
assertNoReasoningTags({

View File

@@ -5,10 +5,7 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-channel.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { GatewayClient } from "./client.js";
import { startGatewayServer } from "./server.js";
@@ -148,14 +145,11 @@ function decodeBodyText(body: unknown): string {
if (!body) return "";
if (typeof body === "string") return body;
if (body instanceof Uint8Array) return Buffer.from(body).toString("utf8");
if (body instanceof ArrayBuffer)
return Buffer.from(new Uint8Array(body)).toString("utf8");
if (body instanceof ArrayBuffer) return Buffer.from(new Uint8Array(body)).toString("utf8");
return "";
}
async function buildOpenAIResponsesSse(
params: OpenAIResponsesParams,
): Promise<Response> {
async function buildOpenAIResponsesSse(params: OpenAIResponsesParams): Promise<Response> {
const events: OpenAIResponseStreamEvent[] = [];
for await (const event of fakeOpenAIResponsesStream(params)) {
events.push(event);
@@ -212,9 +206,9 @@ async function getFreeGatewayPort(): Promise<number> {
for (let attempt = 0; attempt < 25; attempt += 1) {
const port = await getFreePort();
const candidates = [port, port + 1, port + 2, port + 4];
const ok = (
await Promise.all(candidates.map((candidate) => isPortFree(candidate)))
).every(Boolean);
const ok = (await Promise.all(candidates.map((candidate) => isPortFree(candidate)))).every(
Boolean,
);
if (ok) return port;
}
throw new Error("failed to acquire a free gateway port block");
@@ -224,49 +218,37 @@ function extractPayloadText(result: unknown): string {
const record = result as Record<string, unknown>;
const payloads = Array.isArray(record.payloads) ? record.payloads : [];
const texts = payloads
.map((p) =>
p && typeof p === "object"
? (p as Record<string, unknown>).text
: undefined,
)
.map((p) => (p && typeof p === "object" ? (p as Record<string, unknown>).text : undefined))
.filter((t): t is string => typeof t === "string" && t.trim().length > 0);
return texts.join("\n").trim();
}
async function connectClient(params: { url: string; token: string }) {
return await new Promise<InstanceType<typeof GatewayClient>>(
(resolve, reject) => {
let settled = false;
const stop = (
err?: Error,
client?: InstanceType<typeof GatewayClient>,
) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (err) reject(err);
else resolve(client as InstanceType<typeof GatewayClient>);
};
const client = new GatewayClient({
url: params.url,
token: params.token,
clientName: GATEWAY_CLIENT_NAMES.TEST,
clientDisplayName: "vitest-mock-openai",
clientVersion: "dev",
mode: GATEWAY_CLIENT_MODES.TEST,
onHelloOk: () => stop(undefined, client),
onConnectError: (err) => stop(err),
onClose: (code, reason) =>
stop(new Error(`gateway closed during connect (${code}): ${reason}`)),
});
const timer = setTimeout(
() => stop(new Error("gateway connect timeout")),
10_000,
);
timer.unref();
client.start();
},
);
return await new Promise<InstanceType<typeof GatewayClient>>((resolve, reject) => {
let settled = false;
const stop = (err?: Error, client?: InstanceType<typeof GatewayClient>) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (err) reject(err);
else resolve(client as InstanceType<typeof GatewayClient>);
};
const client = new GatewayClient({
url: params.url,
token: params.token,
clientName: GATEWAY_CLIENT_NAMES.TEST,
clientDisplayName: "vitest-mock-openai",
clientVersion: "dev",
mode: GATEWAY_CLIENT_MODES.TEST,
onHelloOk: () => stop(undefined, client),
onConnectError: (err) => stop(err),
onClose: (code, reason) =>
stop(new Error(`gateway closed during connect (${code}): ${reason}`)),
});
const timer = setTimeout(() => stop(new Error("gateway connect timeout")), 10_000);
timer.unref();
client.start();
});
}
describe("gateway (mock openai): tool calling", () => {
@@ -287,16 +269,9 @@ describe("gateway (mock openai): tool calling", () => {
url === openaiResponsesUrl ||
url.startsWith(`${openaiResponsesUrl}/`) ||
url.startsWith(`${openaiResponsesUrl}?`);
const fetchImpl = async (
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> => {
const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (isOpenAIResponsesRequest(url)) {
const bodyText =
@@ -306,9 +281,7 @@ describe("gateway (mock openai): tool calling", () => {
? await input.clone().text()
: "";
const parsed = bodyText
? (JSON.parse(bodyText) as Record<string, unknown>)
: {};
const parsed = bodyText ? (JSON.parse(bodyText) as Record<string, unknown>) : {};
const inputItems = Array.isArray(parsed.input) ? parsed.input : [];
return await buildOpenAIResponsesSse({ input: inputItems });
}
@@ -321,9 +294,7 @@ describe("gateway (mock openai): tool calling", () => {
// TypeScript: Bun's fetch typing includes extra properties; keep this test portable.
(globalThis as unknown as { fetch: unknown }).fetch = fetchImpl;
const tempHome = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-gw-mock-home-"),
);
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-mock-home-"));
process.env.HOME = tempHome;
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
@@ -338,10 +309,7 @@ describe("gateway (mock openai): tool calling", () => {
const nonceA = randomUUID();
const nonceB = randomUUID();
const toolProbePath = path.join(
workspaceDir,
`.clawdbot-tool-probe.${nonceA}.txt`,
);
const toolProbePath = path.join(workspaceDir, `.clawdbot-tool-probe.${nonceA}.txt`);
await fs.writeFile(toolProbePath, `nonceA=${nonceA}\nnonceB=${nonceB}\n`);
const configDir = path.join(tempHome, ".clawdbot");

View File

@@ -8,10 +8,7 @@ import { describe, expect, it } from "vitest";
import { WebSocket } from "ws";
import { rawDataToString } from "../infra/ws.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-channel.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { PROTOCOL_VERSION } from "./protocol/index.js";
async function getFreePort(): Promise<number> {
@@ -51,9 +48,9 @@ async function getFreeGatewayPort(): Promise<number> {
for (let attempt = 0; attempt < 25; attempt += 1) {
const port = await getFreePort();
const candidates = [port, port + 1, port + 2, port + 4];
const ok = (
await Promise.all(candidates.map((candidate) => isPortFree(candidate)))
).every(Boolean);
const ok = (await Promise.all(candidates.map((candidate) => isPortFree(candidate)))).every(
Boolean,
);
if (ok) return port;
}
throw new Error("failed to acquire a free gateway port block");
@@ -122,39 +119,31 @@ async function connectReq(params: { url: string; token?: string }) {
async function connectClient(params: { url: string; token?: string }) {
const { GatewayClient } = await import("./client.js");
return await new Promise<InstanceType<typeof GatewayClient>>(
(resolve, reject) => {
let settled = false;
const stop = (
err?: Error,
client?: InstanceType<typeof GatewayClient>,
) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (err) reject(err);
else resolve(client as InstanceType<typeof GatewayClient>);
};
const client = new GatewayClient({
url: params.url,
token: params.token,
clientName: GATEWAY_CLIENT_NAMES.TEST,
clientDisplayName: "vitest-wizard",
clientVersion: "dev",
mode: GATEWAY_CLIENT_MODES.TEST,
onHelloOk: () => stop(undefined, client),
onConnectError: (err) => stop(err),
onClose: (code, reason) =>
stop(new Error(`gateway closed during connect (${code}): ${reason}`)),
});
const timer = setTimeout(
() => stop(new Error("gateway connect timeout")),
10_000,
);
timer.unref();
client.start();
},
);
return await new Promise<InstanceType<typeof GatewayClient>>((resolve, reject) => {
let settled = false;
const stop = (err?: Error, client?: InstanceType<typeof GatewayClient>) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (err) reject(err);
else resolve(client as InstanceType<typeof GatewayClient>);
};
const client = new GatewayClient({
url: params.url,
token: params.token,
clientName: GATEWAY_CLIENT_NAMES.TEST,
clientDisplayName: "vitest-wizard",
clientVersion: "dev",
mode: GATEWAY_CLIENT_MODES.TEST,
onHelloOk: () => stop(undefined, client),
onConnectError: (err) => stop(err),
onClose: (code, reason) =>
stop(new Error(`gateway closed during connect (${code}): ${reason}`)),
});
const timer = setTimeout(() => stop(new Error("gateway connect timeout")), 10_000);
timer.unref();
client.start();
});
}
type WizardStep = {
@@ -189,9 +178,7 @@ describe("gateway wizard (e2e)", () => {
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
const tempHome = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-wizard-home-"),
);
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-wizard-home-"));
process.env.HOME = tempHome;
delete process.env.CLAWDBOT_STATE_DIR;
delete process.env.CLAWDBOT_CONFIG_PATH;
@@ -241,24 +228,18 @@ describe("gateway wizard (e2e)", () => {
expect(next.status).toBe("done");
const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js");
const parsed = JSON.parse(
await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8"),
);
const parsed = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8"));
const token = (parsed as Record<string, unknown>)?.gateway as
| Record<string, unknown>
| undefined;
expect((token?.auth as { token?: string } | undefined)?.token).toBe(
wizardToken,
);
expect((token?.auth as { token?: string } | undefined)?.token).toBe(wizardToken);
} finally {
client.stop();
await server.close({ reason: "wizard e2e complete" });
}
const port2 = await getFreeGatewayPort();
const { startGatewayServer: startGatewayServer2 } = await import(
"./server.js"
);
const { startGatewayServer: startGatewayServer2 } = await import("./server.js");
const server2 = await startGatewayServer2(port2, {
bind: "loopback",
controlUiEnabled: false,

View File

@@ -101,9 +101,7 @@ type HookTransformFn = (
ctx: HookMappingContext,
) => HookTransformResult | Promise<HookTransformResult>;
export function resolveHookMappings(
hooks?: HooksConfig,
): HookMappingResolved[] {
export function resolveHookMappings(hooks?: HooksConfig): HookMappingResolved[] {
const presets = hooks?.presets ?? [];
const mappings: HookMappingConfig[] = [];
if (hooks?.mappings) mappings.push(...hooks.mappings);
@@ -118,9 +116,7 @@ export function resolveHookMappings(
? resolvePath(configDir, hooks.transformsDir)
: configDir;
return mappings.map((mapping, index) =>
normalizeHookMapping(mapping, index, transformsDir),
);
return mappings.map((mapping, index) => normalizeHookMapping(mapping, index, transformsDir));
}
export async function applyHookMappings(
@@ -193,8 +189,7 @@ function mappingMatches(mapping: HookMappingResolved, ctx: HookMappingContext) {
if (mapping.matchPath !== normalizeMatchPath(ctx.path)) return false;
}
if (mapping.matchSource) {
const source =
typeof ctx.payload.source === "string" ? ctx.payload.source : undefined;
const source = typeof ctx.payload.source === "string" ? ctx.payload.source : undefined;
if (!source || source !== mapping.matchSource) return false;
}
return true;
@@ -242,40 +237,25 @@ function mergeAction(
if (!override) {
return validateAction(base);
}
const kind = (override.kind ?? base.kind ?? defaultAction) as
| "wake"
| "agent";
const kind = (override.kind ?? base.kind ?? defaultAction) as "wake" | "agent";
if (kind === "wake") {
const baseWake = base.kind === "wake" ? base : undefined;
const text =
typeof override.text === "string"
? override.text
: (baseWake?.text ?? "");
const mode =
override.mode === "next-heartbeat"
? "next-heartbeat"
: (baseWake?.mode ?? "now");
const text = typeof override.text === "string" ? override.text : (baseWake?.text ?? "");
const mode = override.mode === "next-heartbeat" ? "next-heartbeat" : (baseWake?.mode ?? "now");
return validateAction({ kind: "wake", text, mode });
}
const baseAgent = base.kind === "agent" ? base : undefined;
const message =
typeof override.message === "string"
? override.message
: (baseAgent?.message ?? "");
typeof override.message === "string" ? override.message : (baseAgent?.message ?? "");
const wakeMode =
override.wakeMode === "next-heartbeat"
? "next-heartbeat"
: (baseAgent?.wakeMode ?? "now");
override.wakeMode === "next-heartbeat" ? "next-heartbeat" : (baseAgent?.wakeMode ?? "now");
return validateAction({
kind: "agent",
message,
wakeMode,
name: override.name ?? baseAgent?.name,
sessionKey: override.sessionKey ?? baseAgent?.sessionKey,
deliver:
typeof override.deliver === "boolean"
? override.deliver
: baseAgent?.deliver,
deliver: typeof override.deliver === "boolean" ? override.deliver : baseAgent?.deliver,
channel: override.channel ?? baseAgent?.channel,
to: override.to ?? baseAgent?.to,
model: override.model ?? baseAgent?.model,
@@ -297,9 +277,7 @@ function validateAction(action: HookAction): HookMappingResult {
return { ok: true, action };
}
async function loadTransform(
transform: HookMappingTransformResolved,
): Promise<HookTransformFn> {
async function loadTransform(transform: HookMappingTransformResolved): Promise<HookTransformFn> {
const cached = transformCache.get(transform.modulePath);
if (cached) return cached;
const url = pathToFileURL(transform.modulePath).href;
@@ -309,13 +287,8 @@ async function loadTransform(
return fn;
}
function resolveTransformFn(
mod: Record<string, unknown>,
exportName?: string,
): HookTransformFn {
const candidate = exportName
? mod[exportName]
: (mod.default ?? mod.transform);
function resolveTransformFn(mod: Record<string, unknown>, exportName?: string): HookTransformFn {
const candidate = exportName ? mod[exportName] : (mod.default ?? mod.transform);
if (typeof candidate !== "function") {
throw new Error("hook transform module must export a function");
}
@@ -347,8 +320,7 @@ function renderTemplate(template: string, ctx: HookMappingContext) {
const value = resolveTemplateExpr(expr.trim(), ctx);
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean")
return String(value);
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
});
}

View File

@@ -57,10 +57,7 @@ describe("gateway hooks helpers", () => {
});
test("normalizeAgentPayload defaults + validates channel", () => {
const ok = normalizeAgentPayload(
{ message: "hello" },
{ idFactory: () => "fixed" },
);
const ok = normalizeAgentPayload({ message: "hello" }, { idFactory: () => "fixed" });
expect(ok.ok).toBe(true);
if (ok.ok) {
expect(ok.value.sessionKey).toBe("hook:fixed");

View File

@@ -4,10 +4,7 @@ import { listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelId } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import {
type HookMappingResolved,
resolveHookMappings,
} from "./hooks-mapping.js";
import { type HookMappingResolved, resolveHookMappings } from "./hooks-mapping.js";
const DEFAULT_HOOKS_PATH = "/hooks";
const DEFAULT_HOOKS_MAX_BODY_BYTES = 256 * 1024;
@@ -19,9 +16,7 @@ export type HooksConfigResolved = {
mappings: HookMappingResolved[];
};
export function resolveHooksConfig(
cfg: ClawdbotConfig,
): HooksConfigResolved | null {
export function resolveHooksConfig(cfg: ClawdbotConfig): HooksConfigResolved | null {
if (cfg.hooks?.enabled !== true) return null;
const token = cfg.hooks?.token?.trim();
if (!token) {
@@ -29,8 +24,7 @@ export function resolveHooksConfig(
}
const rawPath = cfg.hooks?.path?.trim() || DEFAULT_HOOKS_PATH;
const withSlash = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
const trimmed =
withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
const trimmed = withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
if (trimmed === "/") {
throw new Error("hooks.path may not be '/'");
}
@@ -47,14 +41,9 @@ export function resolveHooksConfig(
};
}
export function extractHookToken(
req: IncomingMessage,
url: URL,
): string | undefined {
export function extractHookToken(req: IncomingMessage, url: URL): string | undefined {
const auth =
typeof req.headers.authorization === "string"
? req.headers.authorization.trim()
: "";
typeof req.headers.authorization === "string" ? req.headers.authorization.trim() : "";
if (auth.toLowerCase().startsWith("bearer ")) {
const token = auth.slice(7).trim();
if (token) return token;
@@ -147,10 +136,7 @@ export type HookAgentPayload = {
timeoutSeconds?: number;
};
const HOOK_CHANNEL_VALUES = [
"last",
...listChannelPlugins().map((plugin) => plugin.id),
];
const HOOK_CHANNEL_VALUES = ["last", ...listChannelPlugins().map((plugin) => plugin.id)];
export type HookMessageChannel = ChannelId | "last";
@@ -178,14 +164,11 @@ export function normalizeAgentPayload(
value: HookAgentPayload;
}
| { ok: false; error: string } {
const message =
typeof payload.message === "string" ? payload.message.trim() : "";
const message = typeof payload.message === "string" ? payload.message.trim() : "";
if (!message) return { ok: false, error: "message required" };
const nameRaw = payload.name;
const name =
typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : "Hook";
const wakeMode =
payload.wakeMode === "next-heartbeat" ? "next-heartbeat" : "now";
const name = typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : "Hook";
const wakeMode = payload.wakeMode === "next-heartbeat" ? "next-heartbeat" : "now";
const sessionKeyRaw = payload.sessionKey;
const idFactory = opts?.idFactory ?? randomUUID;
const sessionKey =
@@ -195,27 +178,19 @@ export function normalizeAgentPayload(
const channel = resolveHookChannel(payload.channel);
if (!channel) return { ok: false, error: HOOK_CHANNEL_ERROR };
const toRaw = payload.to;
const to =
typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined;
const to = typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined;
const modelRaw = payload.model;
const model =
typeof modelRaw === "string" && modelRaw.trim()
? modelRaw.trim()
: undefined;
const model = typeof modelRaw === "string" && modelRaw.trim() ? modelRaw.trim() : undefined;
if (modelRaw !== undefined && !model) {
return { ok: false, error: "model required" };
}
const deliver = resolveHookDeliver(payload.deliver);
const thinkingRaw = payload.thinking;
const thinking =
typeof thinkingRaw === "string" && thinkingRaw.trim()
? thinkingRaw.trim()
: undefined;
typeof thinkingRaw === "string" && thinkingRaw.trim() ? thinkingRaw.trim() : undefined;
const timeoutRaw = payload.timeoutSeconds;
const timeoutSeconds =
typeof timeoutRaw === "number" &&
Number.isFinite(timeoutRaw) &&
timeoutRaw > 0
typeof timeoutRaw === "number" && Number.isFinite(timeoutRaw) && timeoutRaw > 0
? Math.floor(timeoutRaw)
: undefined;
return {

View File

@@ -40,9 +40,7 @@ function encodePngRgba(buffer: Buffer, width: number, height: number) {
}
const compressed = deflateSync(raw);
const signature = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);

View File

@@ -1,11 +1,7 @@
import { describe, expect, it } from "vitest";
import { emitAgentEvent } from "../infra/agent-events.js";
import {
agentCommand,
getFreePort,
installGatewayTestHooks,
} from "./test-helpers.js";
import { agentCommand, getFreePort, installGatewayTestHooks } from "./test-helpers.js";
installGatewayTestHooks();
@@ -18,10 +14,7 @@ async function startServerWithDefaultConfig(port: number) {
});
}
async function startServer(
port: number,
opts?: { openAiChatCompletionsEnabled?: boolean },
) {
async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) {
const { startGatewayServer } = await import("./server.js");
return await startGatewayServer(port, {
host: "127.0.0.1",
@@ -31,11 +24,7 @@ async function startServer(
});
}
async function postChatCompletions(
port: number,
body: unknown,
headers?: Record<string, string>,
) {
async function postChatCompletions(port: number, body: unknown, headers?: Record<string, string>) {
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "POST",
headers: {
@@ -133,9 +122,9 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect(
(opts as { sessionKey?: string } | undefined)?.sessionKey ?? "",
).toMatch(/^agent:beta:/);
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:beta:/,
);
} finally {
await server.close({ reason: "test done" });
}
@@ -157,9 +146,9 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect(
(opts as { sessionKey?: string } | undefined)?.sessionKey ?? "",
).toMatch(/^agent:beta:/);
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:beta:/,
);
} finally {
await server.close({ reason: "test done" });
}
@@ -185,9 +174,9 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect(
(opts as { sessionKey?: string } | undefined)?.sessionKey ?? "",
).toMatch(/^agent:alpha:/);
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:alpha:/,
);
} finally {
await server.close({ reason: "test done" });
}
@@ -236,9 +225,9 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect(
(opts as { sessionKey?: string } | undefined)?.sessionKey ?? "",
).toContain("openai-user:alice");
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain(
"openai-user:alice",
);
} finally {
await server.close({ reason: "test done" });
}
@@ -267,9 +256,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { message?: string } | undefined)?.message).toBe(
"hello\nworld",
);
expect((opts as { message?: string } | undefined)?.message).toBe("hello\nworld");
} finally {
await server.close({ reason: "test done" });
}
@@ -293,8 +280,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
expect(json.object).toBe("chat.completion");
expect(Array.isArray(json.choices)).toBe(true);
const choice0 = (json.choices as Array<Record<string, unknown>>)[0] ?? {};
const msg =
(choice0.message as Record<string, unknown> | undefined) ?? {};
const msg = (choice0.message as Record<string, unknown> | undefined) ?? {};
expect(msg.role).toBe("assistant");
expect(msg.content).toBe("hello");
} finally {
@@ -337,9 +323,7 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type") ?? "").toContain(
"text/event-stream",
);
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
const text = await res.text();
const data = parseSseDataLines(text);
@@ -348,18 +332,10 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
const jsonChunks = data
.filter((d) => d !== "[DONE]")
.map((d) => JSON.parse(d) as Record<string, unknown>);
expect(jsonChunks.some((c) => c.object === "chat.completion.chunk")).toBe(
true,
);
expect(jsonChunks.some((c) => c.object === "chat.completion.chunk")).toBe(true);
const allContent = jsonChunks
.flatMap(
(c) =>
(c.choices as Array<Record<string, unknown>> | undefined) ?? [],
)
.map(
(choice) =>
(choice.delta as Record<string, unknown> | undefined)?.content,
)
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
.filter((v): v is string => typeof v === "string")
.join("");
expect(allContent).toBe("hello");

View File

@@ -4,10 +4,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
import { createDefaultDeps } from "../cli/deps.js";
import { agentCommand } from "../commands/agent.js";
import { emitAgentEvent, onAgentEvent } from "../infra/agent-events.js";
import {
buildAgentMainSessionKey,
normalizeAgentId,
} from "../routing/session-key.js";
import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import { authorizeGatewayConnect, type ResolvedGatewayAuth } from "./auth.js";
import { readJsonBody } from "./hooks.js";
@@ -106,8 +103,7 @@ function buildAgentPrompt(messagesUnknown: unknown): {
return {
message: lastUser,
extraSystemPrompt:
systemParts.length > 0 ? systemParts.join("\n\n") : undefined,
extraSystemPrompt: systemParts.length > 0 ? systemParts.join("\n\n") : undefined,
};
}
@@ -120,9 +116,7 @@ function resolveAgentIdFromHeader(req: IncomingMessage): string | undefined {
return normalizeAgentId(raw);
}
function resolveAgentIdFromModel(
model: string | undefined,
): string | undefined {
function resolveAgentIdFromModel(model: string | undefined): string | undefined {
const raw = model?.trim();
if (!raw) return undefined;
@@ -169,10 +163,7 @@ export async function handleOpenAiHttpRequest(
res: ServerResponse,
opts: OpenAiHttpOptions,
): Promise<boolean> {
const url = new URL(
req.url ?? "/",
`http://${req.headers.host || "localhost"}`,
);
const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`);
if (url.pathname !== "/v1/chat/completions") return false;
if (req.method !== "POST") {
@@ -241,9 +232,7 @@ export async function handleOpenAiHttpRequest(
deps,
);
const payloads = (
result as { payloads?: Array<{ text?: string }> } | null
)?.payloads;
const payloads = (result as { payloads?: Array<{ text?: string }> } | null)?.payloads;
const content =
Array.isArray(payloads) && payloads.length > 0
? payloads
@@ -291,12 +280,7 @@ export async function handleOpenAiHttpRequest(
if (evt.stream === "assistant") {
const delta = evt.data?.delta;
const text = evt.data?.text;
const content =
typeof delta === "string"
? delta
: typeof text === "string"
? text
: "";
const content = typeof delta === "string" ? delta : typeof text === "string" ? text : "";
if (!content) return;
if (!wroteRole) {
@@ -373,9 +357,7 @@ export async function handleOpenAiHttpRequest(
});
}
const payloads = (
result as { payloads?: Array<{ text?: string }> } | null
)?.payloads;
const payloads = (result as { payloads?: Array<{ text?: string }> } | null)?.payloads;
const content =
Array.isArray(payloads) && payloads.length > 0
? payloads

View File

@@ -1,10 +1,7 @@
import { randomUUID } from "node:crypto";
import type { SystemPresence } from "../infra/system-presence.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-channel.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { GatewayClient } from "./client.js";
export type GatewayProbeAuth = {
@@ -86,9 +83,7 @@ export async function probeGateway(opts: {
close,
health,
status,
presence: Array.isArray(presence)
? (presence as SystemPresence[])
: null,
presence: Array.isArray(presence) ? (presence as SystemPresence[]) : null,
configSnapshot,
});
} catch (err) {

View File

@@ -10,8 +10,7 @@ export const GATEWAY_CLIENT_IDS = {
PROBE: "clawdbot-probe",
} as const;
export type GatewayClientId =
(typeof GATEWAY_CLIENT_IDS)[keyof typeof GATEWAY_CLIENT_IDS];
export type GatewayClientId = (typeof GATEWAY_CLIENT_IDS)[keyof typeof GATEWAY_CLIENT_IDS];
// Back-compat naming (internal): these values are IDs, not display names.
export const GATEWAY_CLIENT_NAMES = GATEWAY_CLIENT_IDS;
@@ -26,8 +25,7 @@ export const GATEWAY_CLIENT_MODES = {
TEST: "test",
} as const;
export type GatewayClientMode =
(typeof GATEWAY_CLIENT_MODES)[keyof typeof GATEWAY_CLIENT_MODES];
export type GatewayClientMode = (typeof GATEWAY_CLIENT_MODES)[keyof typeof GATEWAY_CLIENT_MODES];
export type GatewayClientInfo = {
id: GatewayClientId;
@@ -40,16 +38,10 @@ export type GatewayClientInfo = {
instanceId?: string;
};
const GATEWAY_CLIENT_ID_SET = new Set<GatewayClientId>(
Object.values(GATEWAY_CLIENT_IDS),
);
const GATEWAY_CLIENT_MODE_SET = new Set<GatewayClientMode>(
Object.values(GATEWAY_CLIENT_MODES),
);
const GATEWAY_CLIENT_ID_SET = new Set<GatewayClientId>(Object.values(GATEWAY_CLIENT_IDS));
const GATEWAY_CLIENT_MODE_SET = new Set<GatewayClientMode>(Object.values(GATEWAY_CLIENT_MODES));
export function normalizeGatewayClientId(
raw?: string | null,
): GatewayClientId | undefined {
export function normalizeGatewayClientId(raw?: string | null): GatewayClientId | undefined {
const normalized = raw?.trim().toLowerCase();
if (!normalized) return undefined;
return GATEWAY_CLIENT_ID_SET.has(normalized as GatewayClientId)
@@ -57,15 +49,11 @@ export function normalizeGatewayClientId(
: undefined;
}
export function normalizeGatewayClientName(
raw?: string | null,
): GatewayClientName | undefined {
export function normalizeGatewayClientName(raw?: string | null): GatewayClientName | undefined {
return normalizeGatewayClientId(raw);
}
export function normalizeGatewayClientMode(
raw?: string | null,
): GatewayClientMode | undefined {
export function normalizeGatewayClientMode(raw?: string | null): GatewayClientMode | undefined {
const normalized = raw?.trim().toLowerCase();
if (!normalized) return undefined;
return GATEWAY_CLIENT_MODE_SET.has(normalized as GatewayClientMode)

View File

@@ -151,39 +151,26 @@ import {
WizardStepSchema,
} from "./schema.js";
const ajv = new (
AjvPkg as unknown as new (
opts?: object,
) => import("ajv").default
)({
const ajv = new (AjvPkg as unknown as new (opts?: object) => import("ajv").default)({
allErrors: true,
strict: false,
removeAdditional: false,
});
export const validateConnectParams =
ajv.compile<ConnectParams>(ConnectParamsSchema);
export const validateRequestFrame =
ajv.compile<RequestFrame>(RequestFrameSchema);
export const validateResponseFrame =
ajv.compile<ResponseFrame>(ResponseFrameSchema);
export const validateConnectParams = ajv.compile<ConnectParams>(ConnectParamsSchema);
export const validateRequestFrame = ajv.compile<RequestFrame>(RequestFrameSchema);
export const validateResponseFrame = ajv.compile<ResponseFrame>(ResponseFrameSchema);
export const validateEventFrame = ajv.compile<EventFrame>(EventFrameSchema);
export const validateSendParams = ajv.compile(SendParamsSchema);
export const validatePollParams = ajv.compile<PollParams>(PollParamsSchema);
export const validateAgentParams = ajv.compile(AgentParamsSchema);
export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(
AgentWaitParamsSchema,
);
export const validateAgentWaitParams = ajv.compile<AgentWaitParams>(AgentWaitParamsSchema);
export const validateWakeParams = ajv.compile<WakeParams>(WakeParamsSchema);
export const validateAgentsListParams = ajv.compile<AgentsListParams>(
AgentsListParamsSchema,
);
export const validateAgentsListParams = ajv.compile<AgentsListParams>(AgentsListParamsSchema);
export const validateNodePairRequestParams = ajv.compile<NodePairRequestParams>(
NodePairRequestParamsSchema,
);
export const validateNodePairListParams = ajv.compile<NodePairListParams>(
NodePairListParamsSchema,
);
export const validateNodePairListParams = ajv.compile<NodePairListParams>(NodePairListParamsSchema);
export const validateNodePairApproveParams = ajv.compile<NodePairApproveParams>(
NodePairApproveParamsSchema,
);
@@ -193,117 +180,62 @@ export const validateNodePairRejectParams = ajv.compile<NodePairRejectParams>(
export const validateNodePairVerifyParams = ajv.compile<NodePairVerifyParams>(
NodePairVerifyParamsSchema,
);
export const validateNodeRenameParams = ajv.compile<NodeRenameParams>(
NodeRenameParamsSchema,
);
export const validateNodeListParams =
ajv.compile<NodeListParams>(NodeListParamsSchema);
export const validateNodeDescribeParams = ajv.compile<NodeDescribeParams>(
NodeDescribeParamsSchema,
);
export const validateNodeInvokeParams = ajv.compile<NodeInvokeParams>(
NodeInvokeParamsSchema,
);
export const validateSessionsListParams = ajv.compile<SessionsListParams>(
SessionsListParamsSchema,
);
export const validateNodeRenameParams = ajv.compile<NodeRenameParams>(NodeRenameParamsSchema);
export const validateNodeListParams = ajv.compile<NodeListParams>(NodeListParamsSchema);
export const validateNodeDescribeParams = ajv.compile<NodeDescribeParams>(NodeDescribeParamsSchema);
export const validateNodeInvokeParams = ajv.compile<NodeInvokeParams>(NodeInvokeParamsSchema);
export const validateSessionsListParams = ajv.compile<SessionsListParams>(SessionsListParamsSchema);
export const validateSessionsResolveParams = ajv.compile<SessionsResolveParams>(
SessionsResolveParamsSchema,
);
export const validateSessionsPatchParams = ajv.compile<SessionsPatchParams>(
SessionsPatchParamsSchema,
);
export const validateSessionsResetParams = ajv.compile<SessionsResetParams>(
SessionsResetParamsSchema,
);
export const validateSessionsPatchParams =
ajv.compile<SessionsPatchParams>(SessionsPatchParamsSchema);
export const validateSessionsResetParams =
ajv.compile<SessionsResetParams>(SessionsResetParamsSchema);
export const validateSessionsDeleteParams = ajv.compile<SessionsDeleteParams>(
SessionsDeleteParamsSchema,
);
export const validateSessionsCompactParams = ajv.compile<SessionsCompactParams>(
SessionsCompactParamsSchema,
);
export const validateConfigGetParams = ajv.compile<ConfigGetParams>(
ConfigGetParamsSchema,
);
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(
ConfigSetParamsSchema,
);
export const validateConfigApplyParams = ajv.compile<ConfigApplyParams>(
ConfigApplyParamsSchema,
);
export const validateConfigSchemaParams = ajv.compile<ConfigSchemaParams>(
ConfigSchemaParamsSchema,
);
export const validateWizardStartParams = ajv.compile<WizardStartParams>(
WizardStartParamsSchema,
);
export const validateWizardNextParams = ajv.compile<WizardNextParams>(
WizardNextParamsSchema,
);
export const validateWizardCancelParams = ajv.compile<WizardCancelParams>(
WizardCancelParamsSchema,
);
export const validateWizardStatusParams = ajv.compile<WizardStatusParams>(
WizardStatusParamsSchema,
);
export const validateTalkModeParams =
ajv.compile<TalkModeParams>(TalkModeParamsSchema);
export const validateConfigGetParams = ajv.compile<ConfigGetParams>(ConfigGetParamsSchema);
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(ConfigSetParamsSchema);
export const validateConfigApplyParams = ajv.compile<ConfigApplyParams>(ConfigApplyParamsSchema);
export const validateConfigSchemaParams = ajv.compile<ConfigSchemaParams>(ConfigSchemaParamsSchema);
export const validateWizardStartParams = ajv.compile<WizardStartParams>(WizardStartParamsSchema);
export const validateWizardNextParams = ajv.compile<WizardNextParams>(WizardNextParamsSchema);
export const validateWizardCancelParams = ajv.compile<WizardCancelParams>(WizardCancelParamsSchema);
export const validateWizardStatusParams = ajv.compile<WizardStatusParams>(WizardStatusParamsSchema);
export const validateTalkModeParams = ajv.compile<TalkModeParams>(TalkModeParamsSchema);
export const validateChannelsStatusParams = ajv.compile<ChannelsStatusParams>(
ChannelsStatusParamsSchema,
);
export const validateChannelsLogoutParams = ajv.compile<ChannelsLogoutParams>(
ChannelsLogoutParamsSchema,
);
export const validateModelsListParams = ajv.compile<ModelsListParams>(
ModelsListParamsSchema,
);
export const validateSkillsStatusParams = ajv.compile<SkillsStatusParams>(
SkillsStatusParamsSchema,
);
export const validateSkillsInstallParams = ajv.compile<SkillsInstallParams>(
SkillsInstallParamsSchema,
);
export const validateSkillsUpdateParams = ajv.compile<SkillsUpdateParams>(
SkillsUpdateParamsSchema,
);
export const validateCronListParams =
ajv.compile<CronListParams>(CronListParamsSchema);
export const validateCronStatusParams = ajv.compile<CronStatusParams>(
CronStatusParamsSchema,
);
export const validateCronAddParams =
ajv.compile<CronAddParams>(CronAddParamsSchema);
export const validateCronUpdateParams = ajv.compile<CronUpdateParams>(
CronUpdateParamsSchema,
);
export const validateCronRemoveParams = ajv.compile<CronRemoveParams>(
CronRemoveParamsSchema,
);
export const validateCronRunParams =
ajv.compile<CronRunParams>(CronRunParamsSchema);
export const validateCronRunsParams =
ajv.compile<CronRunsParams>(CronRunsParamsSchema);
export const validateLogsTailParams =
ajv.compile<LogsTailParams>(LogsTailParamsSchema);
export const validateModelsListParams = ajv.compile<ModelsListParams>(ModelsListParamsSchema);
export const validateSkillsStatusParams = ajv.compile<SkillsStatusParams>(SkillsStatusParamsSchema);
export const validateSkillsInstallParams =
ajv.compile<SkillsInstallParams>(SkillsInstallParamsSchema);
export const validateSkillsUpdateParams = ajv.compile<SkillsUpdateParams>(SkillsUpdateParamsSchema);
export const validateCronListParams = ajv.compile<CronListParams>(CronListParamsSchema);
export const validateCronStatusParams = ajv.compile<CronStatusParams>(CronStatusParamsSchema);
export const validateCronAddParams = ajv.compile<CronAddParams>(CronAddParamsSchema);
export const validateCronUpdateParams = ajv.compile<CronUpdateParams>(CronUpdateParamsSchema);
export const validateCronRemoveParams = ajv.compile<CronRemoveParams>(CronRemoveParamsSchema);
export const validateCronRunParams = ajv.compile<CronRunParams>(CronRunParamsSchema);
export const validateCronRunsParams = ajv.compile<CronRunsParams>(CronRunsParamsSchema);
export const validateLogsTailParams = ajv.compile<LogsTailParams>(LogsTailParamsSchema);
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
export const validateChatAbortParams = ajv.compile<ChatAbortParams>(
ChatAbortParamsSchema,
);
export const validateChatAbortParams = ajv.compile<ChatAbortParams>(ChatAbortParamsSchema);
export const validateChatEvent = ajv.compile(ChatEventSchema);
export const validateUpdateRunParams = ajv.compile<UpdateRunParams>(
UpdateRunParamsSchema,
);
export const validateWebLoginStartParams = ajv.compile<WebLoginStartParams>(
WebLoginStartParamsSchema,
);
export const validateWebLoginWaitParams = ajv.compile<WebLoginWaitParams>(
WebLoginWaitParamsSchema,
);
export const validateUpdateRunParams = ajv.compile<UpdateRunParams>(UpdateRunParamsSchema);
export const validateWebLoginStartParams =
ajv.compile<WebLoginStartParams>(WebLoginStartParamsSchema);
export const validateWebLoginWaitParams = ajv.compile<WebLoginWaitParams>(WebLoginWaitParamsSchema);
export function formatValidationErrors(
errors: ErrorObject[] | null | undefined,
) {
export function formatValidationErrors(errors: ErrorObject[] | null | undefined) {
if (!errors) return "unknown validation error";
return ajv.errorsText(errors, { separator: "; " });
}

View File

@@ -21,10 +21,7 @@ export const AgentSummarySchema = Type.Object(
{ additionalProperties: false },
);
export const AgentsListParamsSchema = Type.Object(
{},
{ additionalProperties: false },
);
export const AgentsListParamsSchema = Type.Object({}, { additionalProperties: false });
export const AgentsListResultSchema = Type.Object(
{
@@ -36,10 +33,7 @@ export const AgentsListResultSchema = Type.Object(
{ additionalProperties: false },
);
export const ModelsListParamsSchema = Type.Object(
{},
{ additionalProperties: false },
);
export const ModelsListParamsSchema = Type.Object({}, { additionalProperties: false });
export const ModelsListResultSchema = Type.Object(
{
@@ -48,10 +42,7 @@ export const ModelsListResultSchema = Type.Object(
{ additionalProperties: false },
);
export const SkillsStatusParamsSchema = Type.Object(
{},
{ additionalProperties: false },
);
export const SkillsStatusParamsSchema = Type.Object({}, { additionalProperties: false });
export const SkillsInstallParamsSchema = Type.Object(
{

View File

@@ -47,9 +47,7 @@ export const ChannelAccountSnapshotSchema = Type.Object(
allowUnmentionedGroups: Type.Optional(Type.Boolean()),
cliPath: Type.Optional(Type.Union([Type.String(), Type.Null()])),
dbPath: Type.Optional(Type.Union([Type.String(), Type.Null()])),
port: Type.Optional(
Type.Union([Type.Integer({ minimum: 0 }), Type.Null()]),
),
port: Type.Optional(Type.Union([Type.Integer({ minimum: 0 }), Type.Null()])),
probe: Type.Optional(Type.Unknown()),
audit: Type.Optional(Type.Unknown()),
application: Type.Optional(Type.Unknown()),
@@ -63,10 +61,7 @@ export const ChannelsStatusResultSchema = Type.Object(
channelOrder: Type.Array(NonEmptyString),
channelLabels: Type.Record(NonEmptyString, NonEmptyString),
channels: Type.Record(NonEmptyString, Type.Unknown()),
channelAccounts: Type.Record(
NonEmptyString,
Type.Array(ChannelAccountSnapshotSchema),
),
channelAccounts: Type.Record(NonEmptyString, Type.Array(ChannelAccountSnapshotSchema)),
channelDefaultAccountId: Type.Record(NonEmptyString, NonEmptyString),
},
{ additionalProperties: false },

View File

@@ -2,10 +2,7 @@ import { Type } from "@sinclair/typebox";
import { NonEmptyString } from "./primitives.js";
export const ConfigGetParamsSchema = Type.Object(
{},
{ additionalProperties: false },
);
export const ConfigGetParamsSchema = Type.Object({}, { additionalProperties: false });
export const ConfigSetParamsSchema = Type.Object(
{
@@ -24,10 +21,7 @@ export const ConfigApplyParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const ConfigSchemaParamsSchema = Type.Object(
{},
{ additionalProperties: false },
);
export const ConfigSchemaParamsSchema = Type.Object({}, { additionalProperties: false });
export const UpdateRunParamsSchema = Type.Object(
{

View File

@@ -44,9 +44,7 @@ export const CronPayloadSchema = Type.Union([
thinking: Type.Optional(Type.String()),
timeoutSeconds: Type.Optional(Type.Integer({ minimum: 1 })),
deliver: Type.Optional(Type.Boolean()),
channel: Type.Optional(
Type.Union([Type.Literal("last"), NonEmptyString]),
),
channel: Type.Optional(Type.Union([Type.Literal("last"), NonEmptyString])),
to: Type.Optional(Type.String()),
bestEffortDeliver: Type.Optional(Type.Boolean()),
},
@@ -67,11 +65,7 @@ export const CronJobStateSchema = Type.Object(
runningAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
lastRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })),
lastStatus: Type.Optional(
Type.Union([
Type.Literal("ok"),
Type.Literal("error"),
Type.Literal("skipped"),
]),
Type.Union([Type.Literal("ok"), Type.Literal("error"), Type.Literal("skipped")]),
),
lastError: Type.Optional(Type.String()),
lastDurationMs: Type.Optional(Type.Integer({ minimum: 0 })),
@@ -106,10 +100,7 @@ export const CronListParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const CronStatusParamsSchema = Type.Object(
{},
{ additionalProperties: false },
);
export const CronStatusParamsSchema = Type.Object({}, { additionalProperties: false });
export const CronAddParamsSchema = Type.Object(
{
@@ -163,18 +154,14 @@ export const CronRunParamsSchema = Type.Union([
Type.Object(
{
id: NonEmptyString,
mode: Type.Optional(
Type.Union([Type.Literal("due"), Type.Literal("force")]),
),
mode: Type.Optional(Type.Union([Type.Literal("due"), Type.Literal("force")])),
},
{ additionalProperties: false },
),
Type.Object(
{
jobId: NonEmptyString,
mode: Type.Optional(
Type.Union([Type.Literal("due"), Type.Literal("force")]),
),
mode: Type.Optional(Type.Union([Type.Literal("due"), Type.Literal("force")])),
},
{ additionalProperties: false },
),
@@ -203,11 +190,7 @@ export const CronRunLogEntrySchema = Type.Object(
jobId: NonEmptyString,
action: Type.Literal("finished"),
status: Type.Optional(
Type.Union([
Type.Literal("ok"),
Type.Literal("error"),
Type.Literal("skipped"),
]),
Type.Union([Type.Literal("ok"), Type.Literal("error"), Type.Literal("skipped")]),
),
error: Type.Optional(Type.String()),
summary: Type.Optional(Type.String()),

View File

@@ -1,9 +1,5 @@
import { Type } from "@sinclair/typebox";
import {
GatewayClientIdSchema,
GatewayClientModeSchema,
NonEmptyString,
} from "./primitives.js";
import { GatewayClientIdSchema, GatewayClientModeSchema, NonEmptyString } from "./primitives.js";
import { SnapshotSchema, StateVersionSchema } from "./snapshot.js";
export const TickEventSchema = Type.Object(

View File

@@ -18,10 +18,7 @@ export const NodePairRequestParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const NodePairListParamsSchema = Type.Object(
{},
{ additionalProperties: false },
);
export const NodePairListParamsSchema = Type.Object({}, { additionalProperties: false });
export const NodePairApproveParamsSchema = Type.Object(
{ requestId: NonEmptyString },
@@ -43,10 +40,7 @@ export const NodeRenameParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const NodeListParamsSchema = Type.Object(
{},
{ additionalProperties: false },
);
export const NodeListParamsSchema = Type.Object({}, { additionalProperties: false });
export const NodeDescribeParamsSchema = Type.Object(
{ nodeId: NonEmptyString },

View File

@@ -84,11 +84,7 @@ import {
SessionsResetParamsSchema,
SessionsResolveParamsSchema,
} from "./sessions.js";
import {
PresenceEntrySchema,
SnapshotSchema,
StateVersionSchema,
} from "./snapshot.js";
import { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js";
import {
WizardCancelParamsSchema,
WizardNextParamsSchema,

View File

@@ -44,11 +44,7 @@ export const SessionsPatchParamsSchema = Type.Object(
Type.Union([Type.Literal("allow"), Type.Literal("deny"), Type.Null()]),
),
groupActivation: Type.Optional(
Type.Union([
Type.Literal("mention"),
Type.Literal("always"),
Type.Null(),
]),
Type.Union([Type.Literal("mention"), Type.Literal("always"), Type.Null()]),
),
},
{ additionalProperties: false },

View File

@@ -80,11 +80,7 @@ import type {
SessionsResetParamsSchema,
SessionsResolveParamsSchema,
} from "./sessions.js";
import type {
PresenceEntrySchema,
SnapshotSchema,
StateVersionSchema,
} from "./snapshot.js";
import type { PresenceEntrySchema, SnapshotSchema, StateVersionSchema } from "./snapshot.js";
import type {
WizardCancelParamsSchema,
WizardNextParamsSchema,

View File

@@ -4,9 +4,7 @@ import { NonEmptyString } from "./primitives.js";
export const WizardStartParamsSchema = Type.Object(
{
mode: Type.Optional(
Type.Union([Type.Literal("local"), Type.Literal("remote")]),
),
mode: Type.Optional(Type.Union([Type.Literal("local"), Type.Literal("remote")])),
workspace: Type.Optional(Type.String()),
},
{ additionalProperties: false },
@@ -69,9 +67,7 @@ export const WizardStepSchema = Type.Object(
initialValue: Type.Optional(Type.Unknown()),
placeholder: Type.Optional(Type.String()),
sensitive: Type.Optional(Type.Boolean()),
executor: Type.Optional(
Type.Union([Type.Literal("gateway"), Type.Literal("client")]),
),
executor: Type.Optional(Type.Union([Type.Literal("gateway"), Type.Literal("client")])),
},
{ additionalProperties: false },
);

View File

@@ -5,10 +5,7 @@ import { loadConfig } from "../config/config.js";
import { saveSessionStore } from "../config/sessions.js";
import { normalizeMainKey } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import type {
BridgeEvent,
BridgeHandlersContext,
} from "./server-bridge-types.js";
import type { BridgeEvent, BridgeHandlersContext } from "./server-bridge-types.js";
import { loadSessionEntry } from "./session-utils.js";
import { formatForLog } from "./ws-log.js";
@@ -27,19 +24,15 @@ export const handleBridgeEvent = async (
return;
}
const obj =
typeof payload === "object" && payload !== null
? (payload as Record<string, unknown>)
: {};
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : {};
const text = typeof obj.text === "string" ? obj.text.trim() : "";
if (!text) return;
if (text.length > 20_000) return;
const sessionKeyRaw =
typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
const sessionKeyRaw = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
const cfg = loadConfig();
const rawMainKey = normalizeMainKey(cfg.session?.mainKey);
const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : rawMainKey;
const { storePath, store, entry, canonicalKey } =
loadSessionEntry(sessionKey);
const { storePath, store, entry, canonicalKey } = loadSessionEntry(sessionKey);
const now = Date.now();
const sessionId = entry?.sessionId ?? randomUUID();
store[canonicalKey] = {
@@ -102,20 +95,14 @@ export const handleBridgeEvent = async (
if (!message) return;
if (message.length > 20_000) return;
const channelRaw =
typeof link?.channel === "string" ? link.channel.trim() : "";
const channelRaw = typeof link?.channel === "string" ? link.channel.trim() : "";
const channel = normalizeChannelId(channelRaw) ?? undefined;
const to =
typeof link?.to === "string" && link.to.trim()
? link.to.trim()
: undefined;
const to = typeof link?.to === "string" && link.to.trim() ? link.to.trim() : undefined;
const deliver = Boolean(link?.deliver) && Boolean(channel);
const sessionKeyRaw = (link?.sessionKey ?? "").trim();
const sessionKey =
sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`;
const { storePath, store, entry, canonicalKey } =
loadSessionEntry(sessionKey);
const sessionKey = sessionKeyRaw.length > 0 ? sessionKeyRaw : `node-${nodeId}`;
const { storePath, store, entry, canonicalKey } = loadSessionEntry(sessionKey);
const now = Date.now();
const sessionId = entry?.sessionId ?? randomUUID();
store[canonicalKey] = {
@@ -143,9 +130,7 @@ export const handleBridgeEvent = async (
to,
channel,
timeout:
typeof link?.timeoutSeconds === "number"
? link.timeoutSeconds.toString()
: undefined,
typeof link?.timeoutSeconds === "number" ? link.timeoutSeconds.toString() : undefined,
messageChannel: "node",
},
defaultRuntime,
@@ -164,11 +149,8 @@ export const handleBridgeEvent = async (
return;
}
const obj =
typeof payload === "object" && payload !== null
? (payload as Record<string, unknown>)
: {};
const sessionKey =
typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : {};
const sessionKey = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
if (!sessionKey) return;
ctx.bridgeSubscribe(nodeId, sessionKey);
return;
@@ -182,11 +164,8 @@ export const handleBridgeEvent = async (
return;
}
const obj =
typeof payload === "object" && payload !== null
? (payload as Record<string, unknown>)
: {};
const sessionKey =
typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : {};
const sessionKey = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
if (!sessionKey) return;
ctx.bridgeUnsubscribe(nodeId, sessionKey);
return;

View File

@@ -11,10 +11,7 @@ import {
isChatStopCommandText,
resolveChatRunExpiresAtMs,
} from "./chat-abort.js";
import {
type ChatImageContent,
parseMessageWithAttachments,
} from "./chat-attachments.js";
import { type ChatImageContent, parseMessageWithAttachments } from "./chat-attachments.js";
import {
ErrorCodes,
errorShape,
@@ -32,12 +29,7 @@ import {
resolveSessionModelRef,
} from "./session-utils.js";
export const handleChatBridgeMethods: BridgeMethodHandler = async (
ctx,
nodeId,
method,
params,
) => {
export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId, method, params) => {
switch (method) {
case "chat.history": {
if (!validateChatHistoryParams(params)) {
@@ -56,16 +48,10 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (
const { cfg, storePath, entry } = loadSessionEntry(sessionKey);
const sessionId = entry?.sessionId;
const rawMessages =
sessionId && storePath
? readSessionMessages(sessionId, storePath, entry?.sessionFile)
: [];
sessionId && storePath ? readSessionMessages(sessionId, storePath, entry?.sessionFile) : [];
const max = typeof limit === "number" ? limit : 200;
const sliced =
rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
const capped = capArrayByJsonBytes(
sliced,
MAX_CHAT_HISTORY_MESSAGES_BYTES,
).items;
const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
const capped = capArrayByJsonBytes(sliced, MAX_CHAT_HISTORY_MESSAGES_BYTES).items;
let thinkingLevel = entry?.thinkingLevel;
if (!thinkingLevel) {
const configured = cfg.agents?.defaults?.thinkingDefault;
@@ -214,11 +200,10 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (
let parsedImages: ChatImageContent[] = [];
if (normalizedAttachments.length > 0) {
try {
const parsed = await parseMessageWithAttachments(
p.message,
normalizedAttachments,
{ maxBytes: 5_000_000, log: ctx.logBridge },
);
const parsed = await parseMessageWithAttachments(p.message, normalizedAttachments, {
maxBytes: 5_000_000,
log: ctx.logBridge,
});
parsedMessage = parsed.message;
parsedImages = parsed.images;
} catch (err) {
@@ -232,9 +217,7 @@ export const handleChatBridgeMethods: BridgeMethodHandler = async (
}
}
const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry(
p.sessionKey,
);
const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
const timeoutMs = resolveAgentTimeoutMs({
cfg,
overrideMs: p.timeoutMs,

View File

@@ -1,7 +1,4 @@
import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../agents/agent-scope.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import {
CONFIG_PATH_CLAWDBOT,
loadConfig,
@@ -52,10 +49,7 @@ export const handleConfigBridgeMethods: BridgeMethodHandler = async (
};
}
const cfg = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(
cfg,
resolveDefaultAgentId(cfg),
);
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const pluginRegistry = loadClawdbotPlugins({
config: cfg,
workspaceDir,

View File

@@ -128,9 +128,7 @@ export const handleSessionsBridgeMethods: BridgeMethodHandler = async (
const storePath = target.storePath;
const store = loadSessionStore(storePath);
const primaryKey = target.storeKeys[0] ?? key;
const existingKey = target.storeKeys.find(
(candidate) => store[candidate],
);
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
store[primaryKey] = store[existingKey];
delete store[existingKey];
@@ -249,8 +247,7 @@ export const handleSessionsBridgeMethods: BridgeMethodHandler = async (
};
}
const deleteTranscript =
typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true;
const deleteTranscript = typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true;
const { storePath, store, entry } = loadSessionEntry(key);
const sessionId = entry?.sessionId;

View File

@@ -1,7 +1,4 @@
import {
loadVoiceWakeConfig,
setVoiceWakeTriggers,
} from "../infra/voicewake.js";
import { loadVoiceWakeConfig, setVoiceWakeTriggers } from "../infra/voicewake.js";
import {
ErrorCodes,
formatValidationErrors,

View File

@@ -1,20 +1,11 @@
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
import type {
CanvasHostHandler,
CanvasHostServer,
} from "../canvas-host/server.js";
import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.js";
import { startCanvasHost } from "../canvas-host/server.js";
import type { CliDeps } from "../cli/deps.js";
import type { HealthSummary } from "../commands/health.js";
import {
deriveDefaultBridgePort,
deriveDefaultCanvasHostPort,
} from "../config/port-defaults.js";
import { deriveDefaultBridgePort, deriveDefaultCanvasHostPort } from "../config/port-defaults.js";
import type { NodeBridgeServer } from "../infra/bridge/server.js";
import {
pickPrimaryTailnetIPv4,
pickPrimaryTailnetIPv6,
} from "../infra/tailnet.js";
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
import type { RuntimeEnv } from "../runtime.js";
import type { ChatAbortControllerEntry } from "./chat-abort.js";
import { createBridgeHandlers } from "./server-bridge.js";
@@ -36,11 +27,7 @@ export type GatewayBridgeRuntime = {
canvasHostServer: CanvasHostServer | null;
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
bonjourStop: (() => Promise<void>) | null;
bridgeSendToSession: (
sessionKey: string,
event: string,
payload: unknown,
) => void;
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
bridgeSendToAllSubscribed: (event: string, payload: unknown) => void;
broadcastVoiceWakeChanged: (triggers: string[]) => void;
};
@@ -83,35 +70,26 @@ export async function startGatewayBridgeRuntime(params: {
) => ChatRunEntry | undefined;
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
getHealthCache: () => HealthSummary | null;
refreshGatewayHealthSnapshot: (opts?: {
probe?: boolean;
}) => Promise<HealthSummary>;
refreshGatewayHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
loadGatewayModelCatalog?: () => Promise<ModelCatalogEntry[]>;
logBridge: { info: (msg: string) => void; warn: (msg: string) => void };
logCanvas: { warn: (msg: string) => void };
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
}): Promise<GatewayBridgeRuntime> {
const wideAreaDiscoveryEnabled =
params.cfg.discovery?.wideArea?.enabled === true;
const wideAreaDiscoveryEnabled = params.cfg.discovery?.wideArea?.enabled === true;
const bridgeEnabled = (() => {
if (params.cfg.bridge?.enabled !== undefined)
return params.cfg.bridge.enabled === true;
if (params.cfg.bridge?.enabled !== undefined) return params.cfg.bridge.enabled === true;
return process.env.CLAWDBOT_BRIDGE_ENABLED !== "0";
})();
const bridgePort = (() => {
if (
typeof params.cfg.bridge?.port === "number" &&
params.cfg.bridge.port > 0
) {
if (typeof params.cfg.bridge?.port === "number" && params.cfg.bridge.port > 0) {
return params.cfg.bridge.port;
}
if (process.env.CLAWDBOT_BRIDGE_PORT !== undefined) {
const parsed = Number.parseInt(process.env.CLAWDBOT_BRIDGE_PORT, 10);
return Number.isFinite(parsed) && parsed > 0
? parsed
: deriveDefaultBridgePort(params.port);
return Number.isFinite(parsed) && parsed > 0 ? parsed : deriveDefaultBridgePort(params.port);
}
return deriveDefaultBridgePort(params.port);
})();
@@ -123,8 +101,7 @@ export async function startGatewayBridgeRuntime(params: {
if (env) return env;
}
const bind =
params.cfg.bridge?.bind ?? (wideAreaDiscoveryEnabled ? "auto" : "lan");
const bind = params.cfg.bridge?.bind ?? (wideAreaDiscoveryEnabled ? "auto" : "lan");
if (bind === "loopback") return "127.0.0.1";
if (bind === "lan") return "0.0.0.0";
@@ -169,9 +146,7 @@ export async function startGatewayBridgeRuntime(params: {
canvasHostServer = started;
}
} catch (err) {
params.logCanvas.warn(
`failed to start on ${bridgeHost}:${canvasHostPort}: ${String(err)}`,
);
params.logCanvas.warn(`failed to start on ${bridgeHost}:${canvasHostPort}: ${String(err)}`);
}
}
@@ -183,28 +158,13 @@ export async function startGatewayBridgeRuntime(params: {
const bridgeSendEvent: BridgeSendEventFn = (opts) => {
bridge?.sendEvent(opts);
};
const bridgeListConnected: BridgeListConnectedFn = () =>
bridge?.listConnected() ?? [];
const bridgeSendToSession = (
sessionKey: string,
event: string,
payload: unknown,
) =>
bridgeSubscriptions.sendToSession(
sessionKey,
event,
payload,
bridgeSendEvent,
);
const bridgeListConnected: BridgeListConnectedFn = () => bridge?.listConnected() ?? [];
const bridgeSendToSession = (sessionKey: string, event: string, payload: unknown) =>
bridgeSubscriptions.sendToSession(sessionKey, event, payload, bridgeSendEvent);
const bridgeSendToAllSubscribed = (event: string, payload: unknown) =>
bridgeSubscriptions.sendToAllSubscribed(event, payload, bridgeSendEvent);
const bridgeSendToAllConnected = (event: string, payload: unknown) =>
bridgeSubscriptions.sendToAllConnected(
event,
payload,
bridgeListConnected,
bridgeSendEvent,
);
bridgeSubscriptions.sendToAllConnected(event, payload, bridgeListConnected, bridgeSendEvent);
const broadcastVoiceWakeChanged = (triggers: string[]) => {
const payload = { triggers };
@@ -229,17 +189,13 @@ export async function startGatewayBridgeRuntime(params: {
agentRunSeq: params.agentRunSeq,
getHealthCache: params.getHealthCache,
refreshHealthSnapshot: params.refreshGatewayHealthSnapshot,
loadGatewayModelCatalog:
params.loadGatewayModelCatalog ?? loadGatewayModelCatalog,
loadGatewayModelCatalog: params.loadGatewayModelCatalog ?? loadGatewayModelCatalog,
logBridge: params.logBridge,
});
const canvasHostPortForBridge = canvasHostServer?.port;
const canvasHostHostForBridge =
canvasHostServer &&
bridgeHost &&
bridgeHost !== "0.0.0.0" &&
bridgeHost !== "::"
canvasHostServer && bridgeHost && bridgeHost !== "0.0.0.0" && bridgeHost !== "::"
? bridgeHost
: undefined;

View File

@@ -9,11 +9,8 @@ describe("bridge subscription manager", () => {
event: string;
payloadJSON?: string | null;
}> = [];
const sendEvent = (evt: {
nodeId: string;
event: string;
payloadJSON?: string | null;
}) => sent.push(evt);
const sendEvent = (evt: { nodeId: string; event: string; payloadJSON?: string | null }) =>
sent.push(evt);
manager.subscribe("node-a", "main");
manager.subscribe("node-b", "main");

View File

@@ -34,8 +34,7 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
const bridgeNodeSubscriptions = new Map<string, Set<string>>();
const bridgeSessionSubscribers = new Map<string, Set<string>>();
const toPayloadJSON = (payload: unknown) =>
payload ? JSON.stringify(payload) : null;
const toPayloadJSON = (payload: unknown) => (payload ? JSON.stringify(payload) : null);
const subscribe = (nodeId: string, sessionKey: string) => {
const normalizedNodeId = nodeId.trim();
@@ -69,8 +68,7 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
const sessionSet = bridgeSessionSubscribers.get(normalizedSessionKey);
sessionSet?.delete(normalizedNodeId);
if (sessionSet?.size === 0)
bridgeSessionSubscribers.delete(normalizedSessionKey);
if (sessionSet?.size === 0) bridgeSessionSubscribers.delete(normalizedSessionKey);
};
const unsubscribeAll = (nodeId: string) => {

View File

@@ -7,16 +7,8 @@ import type { DedupeEntry } from "./server-shared.js";
export type BridgeHandlersContext = {
deps: CliDeps;
broadcast: (
event: string,
payload: unknown,
opts?: { dropIfSlow?: boolean },
) => void;
bridgeSendToSession: (
sessionKey: string,
event: string,
payload: unknown,
) => void;
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
bridgeSubscribe: (nodeId: string, sessionKey: string) => void;
bridgeUnsubscribe: (nodeId: string, sessionKey: string) => void;
broadcastVoiceWakeChanged: (triggers: string[]) => void;

View File

@@ -2,9 +2,7 @@ import type { GatewayWsClient } from "./server/ws-types.js";
import { MAX_BUFFERED_BYTES } from "./server-constants.js";
import { logWs, summarizeAgentEventForWsLog } from "./ws-log.js";
export function createGatewayBroadcaster(params: {
clients: Set<GatewayWsClient>;
}) {
export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient> }) {
let seq = 0;
const broadcast = (
event: string,

View File

@@ -7,9 +7,7 @@ export async function startBrowserControlServerIfEnabled(): Promise<BrowserContr
// Lazy import: keeps startup fast, but still bundles for the embedded
// gateway (bun --compile) via the static specifier path.
const override = process.env.CLAWDBOT_BROWSER_CONTROL_MODULE?.trim();
const mod = override
? await import(override)
: await import("../browser/server.js");
const mod = override ? await import(override) : await import("../browser/server.js");
await mod.startBrowserControlServerFromConfig();
return { stop: mod.stopBrowserControlServer };
}

View File

@@ -1,9 +1,5 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import {
type ChannelId,
getChannelPlugin,
listChannelPlugins,
} from "../channels/plugins/index.js";
import { type ChannelId, getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js";
import type { ChannelAccountSnapshot } from "../channels/plugins/types.js";
import type { ClawdbotConfig } from "../config/config.js";
import { formatErrorMessage } from "../infra/errors.js";
@@ -13,9 +9,7 @@ import type { RuntimeEnv } from "../runtime.js";
export type ChannelRuntimeSnapshot = {
channels: Partial<Record<ChannelId, ChannelAccountSnapshot>>;
channelAccounts: Partial<
Record<ChannelId, Record<string, ChannelAccountSnapshot>>
>;
channelAccounts: Partial<Record<ChannelId, Record<string, ChannelAccountSnapshot>>>;
};
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
@@ -45,10 +39,7 @@ function resolveDefaultRuntime(channelId: ChannelId): ChannelAccountSnapshot {
return plugin?.status?.defaultRuntime ?? { accountId: DEFAULT_ACCOUNT_ID };
}
function cloneDefaultRuntime(
channelId: ChannelId,
accountId: string,
): ChannelAccountSnapshot {
function cloneDefaultRuntime(channelId: ChannelId, accountId: string): ChannelAccountSnapshot {
return { ...resolveDefaultRuntime(channelId), accountId };
}
@@ -63,17 +54,11 @@ export type ChannelManager = {
startChannels: () => Promise<void>;
startChannel: (channel: ChannelId, accountId?: string) => Promise<void>;
stopChannel: (channel: ChannelId, accountId?: string) => Promise<void>;
markChannelLoggedOut: (
channelId: ChannelId,
cleared: boolean,
accountId?: string,
) => void;
markChannelLoggedOut: (channelId: ChannelId, cleared: boolean, accountId?: string) => void;
};
// Channel docking: lifecycle hooks (`plugin.gateway`) flow through this manager.
export function createChannelManager(
opts: ChannelManagerOptions,
): ChannelManager {
export function createChannelManager(opts: ChannelManagerOptions): ChannelManager {
const { loadConfig, channelLogs, channelRuntimeEnvs } = opts;
const channelStores = new Map<ChannelId, ChannelRuntimeStore>();
@@ -86,14 +71,9 @@ export function createChannelManager(
return next;
};
const getRuntime = (
channelId: ChannelId,
accountId: string,
): ChannelAccountSnapshot => {
const getRuntime = (channelId: ChannelId, accountId: string): ChannelAccountSnapshot => {
const store = getStore(channelId);
return (
store.runtimes.get(accountId) ?? cloneDefaultRuntime(channelId, accountId)
);
return store.runtimes.get(accountId) ?? cloneDefaultRuntime(channelId, accountId);
};
const setRuntime = (
@@ -114,9 +94,7 @@ export function createChannelManager(
if (!startAccount) return;
const cfg = loadConfig();
const store = getStore(channelId);
const accountIds = accountId
? [accountId]
: plugin.config.listAccountIds(cfg);
const accountIds = accountId ? [accountId] : plugin.config.listAccountIds(cfg);
if (accountIds.length === 0) return;
await Promise.all(
@@ -130,8 +108,7 @@ export function createChannelManager(
setRuntime(channelId, id, {
accountId: id,
running: false,
lastError:
plugin.config.disabledReason?.(account, cfg) ?? "disabled",
lastError: plugin.config.disabledReason?.(account, cfg) ?? "disabled",
});
return;
}
@@ -144,9 +121,7 @@ export function createChannelManager(
setRuntime(channelId, id, {
accountId: id,
running: false,
lastError:
plugin.config.unconfiguredReason?.(account, cfg) ??
"not configured",
lastError: plugin.config.unconfiguredReason?.(account, cfg) ?? "not configured",
});
return;
}
@@ -246,11 +221,7 @@ export function createChannelManager(
}
};
const markChannelLoggedOut = (
channelId: ChannelId,
cleared: boolean,
accountId?: string,
) => {
const markChannelLoggedOut = (channelId: ChannelId, cleared: boolean, accountId?: string) => {
const plugin = getChannelPlugin(channelId);
if (!plugin) return;
const cfg = loadConfig();
@@ -292,24 +263,19 @@ export function createChannelManager(
: isAccountEnabled(account);
const described = plugin.config.describeAccount?.(account, cfg);
const configured = described?.configured;
const current =
store.runtimes.get(id) ?? cloneDefaultRuntime(plugin.id, id);
const current = store.runtimes.get(id) ?? cloneDefaultRuntime(plugin.id, id);
const next = { ...current, accountId: id };
if (!next.running) {
if (!enabled) {
next.lastError ??=
plugin.config.disabledReason?.(account, cfg) ?? "disabled";
next.lastError ??= plugin.config.disabledReason?.(account, cfg) ?? "disabled";
} else if (configured === false) {
next.lastError ??=
plugin.config.unconfiguredReason?.(account, cfg) ??
"not configured";
next.lastError ??= plugin.config.unconfiguredReason?.(account, cfg) ?? "not configured";
}
}
accounts[id] = next;
}
const defaultAccount =
accounts[defaultAccountId] ??
cloneDefaultRuntime(plugin.id, defaultAccountId);
accounts[defaultAccountId] ?? cloneDefaultRuntime(plugin.id, defaultAccountId);
channels[plugin.id] = defaultAccount;
channelAccounts[plugin.id] = accounts;
}

View File

@@ -1,8 +1,5 @@
import { normalizeVerboseLevel } from "../auto-reply/thinking.js";
import {
type AgentEventPayload,
getAgentRunContext,
} from "../infra/agent-events.js";
import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js";
import { loadSessionEntry } from "./session-utils.js";
import { formatForLog } from "./ws-log.js";
@@ -15,11 +12,7 @@ export type ChatRunRegistry = {
add: (sessionId: string, entry: ChatRunEntry) => void;
peek: (sessionId: string) => ChatRunEntry | undefined;
shift: (sessionId: string) => ChatRunEntry | undefined;
remove: (
sessionId: string,
clientRunId: string,
sessionKey?: string,
) => ChatRunEntry | undefined;
remove: (sessionId: string, clientRunId: string, sessionKey?: string) => ChatRunEntry | undefined;
clear: () => void;
};
@@ -45,17 +38,12 @@ export function createChatRunRegistry(): ChatRunRegistry {
return entry;
};
const remove = (
sessionId: string,
clientRunId: string,
sessionKey?: string,
) => {
const remove = (sessionId: string, clientRunId: string, sessionKey?: string) => {
const queue = chatRunSessions.get(sessionId);
if (!queue || queue.length === 0) return undefined;
const idx = queue.findIndex(
(entry) =>
entry.clientRunId === clientRunId &&
(sessionKey ? entry.sessionKey === sessionKey : true),
entry.clientRunId === clientRunId && (sessionKey ? entry.sessionKey === sessionKey : true),
);
if (idx < 0) return undefined;
const [entry] = queue.splice(idx, 1);
@@ -106,11 +94,7 @@ export type ChatEventBroadcast = (
opts?: { dropIfSlow?: boolean },
) => void;
export type BridgeSendToSession = (
sessionKey: string,
event: string,
payload: unknown,
) => void;
export type BridgeSendToSession = (sessionKey: string, event: string, payload: unknown) => void;
export type AgentEventHandlerOptions = {
broadcast: ChatEventBroadcast;
@@ -129,12 +113,7 @@ export function createAgentEventHandler({
resolveSessionKeyForRun,
clearAgentRunContext,
}: AgentEventHandlerOptions) {
const emitChatDelta = (
sessionKey: string,
clientRunId: string,
seq: number,
text: string,
) => {
const emitChatDelta = (sessionKey: string, clientRunId: string, seq: number, text: string) => {
chatRunState.buffers.set(clientRunId, text);
const now = Date.now();
const last = chatRunState.deltaSentAt.get(clientRunId) ?? 0;
@@ -203,9 +182,7 @@ export function createAgentEventHandler({
const { cfg, entry } = loadSessionEntry(sessionKey);
const sessionVerbose = normalizeVerboseLevel(entry?.verboseLevel);
if (sessionVerbose) return sessionVerbose === "on";
const defaultVerbose = normalizeVerboseLevel(
cfg.agents?.defaults?.verboseDefault,
);
const defaultVerbose = normalizeVerboseLevel(cfg.agents?.defaults?.verboseDefault);
return defaultVerbose === "on";
} catch {
return false;
@@ -214,12 +191,10 @@ export function createAgentEventHandler({
return (evt: AgentEventPayload) => {
const chatLink = chatRunState.registry.peek(evt.runId);
const sessionKey =
chatLink?.sessionKey ?? resolveSessionKeyForRun(evt.runId);
const sessionKey = chatLink?.sessionKey ?? resolveSessionKeyForRun(evt.runId);
const clientRunId = chatLink?.clientRunId ?? evt.runId;
const isAborted =
chatRunState.abortedRuns.has(clientRunId) ||
chatRunState.abortedRuns.has(evt.runId);
chatRunState.abortedRuns.has(clientRunId) || chatRunState.abortedRuns.has(evt.runId);
// Include sessionKey so Control UI can filter tool streams per session.
const agentPayload = sessionKey ? { ...evt, sessionKey } : evt;
const last = agentRunSeq.get(evt.runId) ?? 0;
@@ -244,22 +219,13 @@ export function createAgentEventHandler({
broadcast("agent", agentPayload);
const lifecyclePhase =
evt.stream === "lifecycle" && typeof evt.data?.phase === "string"
? evt.data.phase
: null;
evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null;
if (sessionKey) {
bridgeSendToSession(sessionKey, "agent", agentPayload);
if (
!isAborted &&
evt.stream === "assistant" &&
typeof evt.data?.text === "string"
) {
if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") {
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
} else if (
!isAborted &&
(lifecyclePhase === "end" || lifecyclePhase === "error")
) {
} else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
if (chatLink) {
const finished = chatRunState.registry.shift(evt.runId);
if (!finished) {
@@ -282,10 +248,7 @@ export function createAgentEventHandler({
evt.data?.error,
);
}
} else if (
isAborted &&
(lifecyclePhase === "end" || lifecyclePhase === "error")
) {
} else if (isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
chatRunState.abortedRuns.delete(clientRunId);
chatRunState.abortedRuns.delete(evt.runId);
chatRunState.buffers.delete(clientRunId);

View File

@@ -1,13 +1,7 @@
import type { Server as HttpServer } from "node:http";
import type { WebSocketServer } from "ws";
import type {
CanvasHostHandler,
CanvasHostServer,
} from "../canvas-host/server.js";
import {
type ChannelId,
listChannelPlugins,
} from "../channels/plugins/index.js";
import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.js";
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
import { stopGmailWatcher } from "../hooks/gmail-watcher.js";
import type { NodeBridgeServer } from "../infra/bridge/server.js";
import type { PluginServicesHandle } from "../plugins/services.js";
@@ -23,11 +17,7 @@ export function createGatewayCloseHandler(params: {
cron: { stop: () => void };
heartbeatRunner: { stop: () => void };
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
broadcast: (
event: string,
payload: unknown,
opts?: { dropIfSlow?: boolean },
) => void;
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
tickInterval: ReturnType<typeof setInterval>;
healthInterval: ReturnType<typeof setInterval>;
dedupeCleanup: ReturnType<typeof setInterval>;
@@ -40,16 +30,11 @@ export function createGatewayCloseHandler(params: {
wss: WebSocketServer;
httpServer: HttpServer;
}) {
return async (opts?: {
reason?: string;
restartExpectedMs?: number | null;
}) => {
const reasonRaw =
typeof opts?.reason === "string" ? opts.reason.trim() : "";
return async (opts?: { reason?: string; restartExpectedMs?: number | null }) => {
const reasonRaw = typeof opts?.reason === "string" ? opts.reason.trim() : "";
const reason = reasonRaw || "gateway stopping";
const restartExpectedMs =
typeof opts?.restartExpectedMs === "number" &&
Number.isFinite(opts.restartExpectedMs)
typeof opts?.restartExpectedMs === "number" && Number.isFinite(opts.restartExpectedMs)
? Math.max(0, Math.floor(opts.restartExpectedMs))
: null;
if (params.bonjourStop) {

View File

@@ -22,36 +22,24 @@ export type GatewayCronState = {
export function buildGatewayCronService(params: {
cfg: ReturnType<typeof loadConfig>;
deps: CliDeps;
broadcast: (
event: string,
payload: unknown,
opts?: { dropIfSlow?: boolean },
) => void;
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
}): GatewayCronState {
const cronLogger = getChildLogger({ module: "cron" });
const storePath = resolveCronStorePath(params.cfg.cron?.store);
const cronEnabled =
process.env.CLAWDBOT_SKIP_CRON !== "1" &&
params.cfg.cron?.enabled !== false;
const cronEnabled = process.env.CLAWDBOT_SKIP_CRON !== "1" && params.cfg.cron?.enabled !== false;
const resolveCronAgent = (requested?: string | null) => {
const runtimeConfig = loadConfig();
const normalized =
typeof requested === "string" && requested.trim()
? normalizeAgentId(requested)
: undefined;
typeof requested === "string" && requested.trim() ? normalizeAgentId(requested) : undefined;
const hasAgent =
normalized !== undefined &&
Array.isArray(runtimeConfig.agents?.list) &&
runtimeConfig.agents.list.some(
(entry) =>
entry &&
typeof entry.id === "string" &&
normalizeAgentId(entry.id) === normalized,
entry && typeof entry.id === "string" && normalizeAgentId(entry.id) === normalized,
);
const agentId = hasAgent
? normalized
: resolveDefaultAgentId(runtimeConfig);
const agentId = hasAgent ? normalized : resolveDefaultAgentId(runtimeConfig);
return { agentId, cfg: runtimeConfig };
};
@@ -106,10 +94,7 @@ export function buildGatewayCronService(params: {
durationMs: evt.durationMs,
nextRunAtMs: evt.nextRunAtMs,
}).catch((err) => {
cronLogger.warn(
{ err: String(err), logPath },
"cron: run log append failed",
);
cronLogger.warn({ err: String(err), logPath }, "cron: run log append failed");
});
}
},

View File

@@ -1,12 +1,6 @@
import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js";
import {
pickPrimaryTailnetIPv4,
pickPrimaryTailnetIPv6,
} from "../infra/tailnet.js";
import {
WIDE_AREA_DISCOVERY_DOMAIN,
writeWideAreaBridgeZone,
} from "../infra/widearea-dns.js";
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
import { WIDE_AREA_DISCOVERY_DOMAIN, writeWideAreaBridgeZone } from "../infra/widearea-dns.js";
import {
formatBonjourInstanceName,
resolveBonjourCliPath,
@@ -25,10 +19,7 @@ export async function startGatewayDiscovery(params: {
const tailnetDns = await resolveTailnetDnsHint();
const sshPortEnv = process.env.CLAWDBOT_SSH_PORT?.trim();
const sshPortParsed = sshPortEnv ? Number.parseInt(sshPortEnv, 10) : NaN;
const sshPort =
Number.isFinite(sshPortParsed) && sshPortParsed > 0
? sshPortParsed
: undefined;
const sshPort = Number.isFinite(sshPortParsed) && sshPortParsed > 0 ? sshPortParsed : undefined;
try {
const bonjour = await startGatewayBonjourAdvertiser({
@@ -68,9 +59,7 @@ export async function startGatewayDiscovery(params: {
`wide-area DNS-SD ${result.changed ? "updated" : "unchanged"} (${WIDE_AREA_DISCOVERY_DOMAIN}${result.zonePath})`,
);
} catch (err) {
params.logDiscovery.warn(
`wide-area discovery update failed: ${String(err)}`,
);
params.logDiscovery.warn(`wide-area discovery update failed: ${String(err)}`);
}
}
}

View File

@@ -18,9 +18,7 @@ export function formatBonjourInstanceName(displayName: string) {
return `${trimmed} (Clawdbot)`;
}
export function resolveBonjourCliPath(
opts: ResolveBonjourCliPathOptions = {},
): string | undefined {
export function resolveBonjourCliPath(opts: ResolveBonjourCliPathOptions = {}): string | undefined {
const env = opts.env ?? process.env;
const envPath = env.CLAWDBOT_CLI_PATH?.trim();
if (envPath) return envPath;
@@ -65,8 +63,7 @@ export async function resolveTailnetDnsHint(opts?: {
const exec =
opts?.exec ??
((command, args) =>
runExec(command, args, { timeoutMs: 1500, maxBuffer: 200_000 }));
((command, args) => runExec(command, args, { timeoutMs: 1500, maxBuffer: 200_000 }));
try {
return await getTailnetHostname(exec);
} catch {

View File

@@ -27,10 +27,7 @@ import { handleOpenAiHttpRequest } from "./openai-http.js";
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
type HookDispatchers = {
dispatchWakeHook: (value: {
text: string;
mode: "now" | "next-heartbeat";
}) => void;
dispatchWakeHook: (value: { text: string; mode: "now" | "next-heartbeat" }) => void;
dispatchAgentHook: (value: {
message: string;
name: string;
@@ -51,10 +48,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown) {
res.end(JSON.stringify(body));
}
export type HooksRequestHandler = (
req: IncomingMessage,
res: ServerResponse,
) => Promise<boolean>;
export type HooksRequestHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
export function createHooksRequestHandler(
opts: {
@@ -64,14 +58,7 @@ export function createHooksRequestHandler(
logHooks: SubsystemLogger;
} & HookDispatchers,
): HooksRequestHandler {
const {
getHooksConfig,
bindHost,
port,
logHooks,
dispatchAgentHook,
dispatchWakeHook,
} = opts;
const { getHooksConfig, bindHost, port, logHooks, dispatchAgentHook, dispatchWakeHook } = opts;
return async (req, res) => {
const hooksConfig = getHooksConfig();
if (!hooksConfig) return false;
@@ -112,14 +99,11 @@ export function createHooksRequestHandler(
return true;
}
const payload =
typeof body.value === "object" && body.value !== null ? body.value : {};
const payload = typeof body.value === "object" && body.value !== null ? body.value : {};
const headers = normalizeHookHeaders(req);
if (subPath === "wake") {
const normalized = normalizeWakePayload(
payload as Record<string, unknown>,
);
const normalized = normalizeWakePayload(payload as Record<string, unknown>);
if (!normalized.ok) {
sendJson(res, 400, { ok: false, error: normalized.error });
return true;
@@ -130,9 +114,7 @@ export function createHooksRequestHandler(
}
if (subPath === "agent") {
const normalized = normalizeAgentPayload(
payload as Record<string, unknown>,
);
const normalized = normalizeAgentPayload(payload as Record<string, unknown>);
if (!normalized.ok) {
sendJson(res, 400, { ok: false, error: normalized.error });
return true;
@@ -225,8 +207,7 @@ export function createGatewayHttpServer(opts: {
void (async () => {
if (await handleHooksRequest(req, res)) return;
if (openAiChatCompletionsEnabled) {
if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth }))
return;
if (await handleOpenAiHttpRequest(req, res, { auth: resolvedAuth })) return;
}
if (canvasHost) {
if (await handleA2uiHttpRequest(req, res)) return;

View File

@@ -1,13 +1,8 @@
import type { loadConfig } from "../config/config.js";
import { setCommandLaneConcurrency } from "../process/command-queue.js";
export function applyGatewayLaneConcurrency(
cfg: ReturnType<typeof loadConfig>,
) {
export function applyGatewayLaneConcurrency(cfg: ReturnType<typeof loadConfig>) {
setCommandLaneConcurrency("cron", cfg.cron?.maxConcurrentRuns ?? 1);
setCommandLaneConcurrency("main", cfg.agents?.defaults?.maxConcurrent ?? 1);
setCommandLaneConcurrency(
"subagent",
cfg.agents?.defaults?.subagents?.maxConcurrent ?? 1,
);
setCommandLaneConcurrency("subagent", cfg.agents?.defaults?.subagents?.maxConcurrent ?? 1);
}

View File

@@ -1,8 +1,5 @@
import type { HealthSummary } from "../commands/health.js";
import {
abortChatRunById,
type ChatAbortControllerEntry,
} from "./chat-abort.js";
import { abortChatRunById, type ChatAbortControllerEntry } from "./chat-abort.js";
import { setBroadcastHealthUpdate } from "./server/health-state.js";
import type { ChatRunEntry } from "./server-chat.js";
import {
@@ -26,9 +23,7 @@ export function startGatewayMaintenanceTimers(params: {
bridgeSendToAllSubscribed: (event: string, payload: unknown) => void;
getPresenceVersion: () => number;
getHealthVersion: () => number;
refreshGatewayHealthSnapshot: (opts?: {
probe?: boolean;
}) => Promise<HealthSummary>;
refreshGatewayHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
logHealth: { error: (msg: string) => void };
dedupe: Map<string, DedupeEntry>;
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
@@ -41,11 +36,7 @@ export function startGatewayMaintenanceTimers(params: {
sessionKey?: string,
) => ChatRunEntry | undefined;
agentRunSeq: Map<string, number>;
bridgeSendToSession: (
sessionKey: string,
event: string,
payload: unknown,
) => void;
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
}): {
tickInterval: ReturnType<typeof setInterval>;
healthInterval: ReturnType<typeof setInterval>;
@@ -72,17 +63,13 @@ export function startGatewayMaintenanceTimers(params: {
const healthInterval = setInterval(() => {
void params
.refreshGatewayHealthSnapshot({ probe: true })
.catch((err) =>
params.logHealth.error(`refresh failed: ${formatError(err)}`),
);
.catch((err) => params.logHealth.error(`refresh failed: ${formatError(err)}`));
}, HEALTH_REFRESH_INTERVAL_MS);
// Prime cache so first client gets a snapshot without waiting.
void params
.refreshGatewayHealthSnapshot({ probe: true })
.catch((err) =>
params.logHealth.error(`initial refresh failed: ${formatError(err)}`),
);
.catch((err) => params.logHealth.error(`initial refresh failed: ${formatError(err)}`));
// dedupe cache cleanup
const dedupeCleanup = setInterval(() => {
@@ -91,9 +78,7 @@ export function startGatewayMaintenanceTimers(params: {
if (now - v.ts > DEDUPE_TTL_MS) params.dedupe.delete(k);
}
if (params.dedupe.size > DEDUPE_MAX) {
const entries = [...params.dedupe.entries()].sort(
(a, b) => a[1].ts - b[1].ts,
);
const entries = [...params.dedupe.entries()].sort((a, b) => a[1].ts - b[1].ts);
for (let i = 0; i < params.dedupe.size - DEDUPE_MAX; i++) {
params.dedupe.delete(entries[i][0]);
}

View File

@@ -59,13 +59,9 @@ const BASE_METHODS = [
"chat.send",
];
const CHANNEL_METHODS = listChannelPlugins().flatMap(
(plugin) => plugin.gatewayMethods ?? [],
);
const CHANNEL_METHODS = listChannelPlugins().flatMap((plugin) => plugin.gatewayMethods ?? []);
export const GATEWAY_METHODS = Array.from(
new Set([...BASE_METHODS, ...CHANNEL_METHODS]),
);
export const GATEWAY_METHODS = Array.from(new Set([...BASE_METHODS, ...CHANNEL_METHODS]));
export const GATEWAY_EVENTS = [
"agent",

View File

@@ -15,10 +15,7 @@ import { sessionsHandlers } from "./server-methods/sessions.js";
import { skillsHandlers } from "./server-methods/skills.js";
import { systemHandlers } from "./server-methods/system.js";
import { talkHandlers } from "./server-methods/talk.js";
import type {
GatewayRequestHandlers,
GatewayRequestOptions,
} from "./server-methods/types.js";
import type { GatewayRequestHandlers, GatewayRequestOptions } from "./server-methods/types.js";
import { updateHandlers } from "./server-methods/update.js";
import { usageHandlers } from "./server-methods/usage.js";
import { voicewakeHandlers } from "./server-methods/voicewake.js";
@@ -53,8 +50,7 @@ export async function handleGatewayRequest(
opts: GatewayRequestOptions & { extraHandlers?: GatewayRequestHandlers },
): Promise<void> {
const { req, respond, client, isWebchatConnect, context } = opts;
const handler =
opts.extraHandlers?.[req.method] ?? coreGatewayHandlers[req.method];
const handler = opts.extraHandlers?.[req.method] ?? coreGatewayHandlers[req.method];
if (!handler) {
respond(
false,

View File

@@ -36,9 +36,7 @@ function ensureAgentRunListener() {
const phase = evt.data?.phase;
if (phase === "start") {
const startedAt =
typeof evt.data?.startedAt === "number"
? (evt.data.startedAt as number)
: undefined;
typeof evt.data?.startedAt === "number" ? (evt.data.startedAt as number) : undefined;
agentRunStarts.set(evt.runId, startedAt ?? Date.now());
return;
}
@@ -48,13 +46,8 @@ function ensureAgentRunListener() {
? (evt.data.startedAt as number)
: agentRunStarts.get(evt.runId);
const endedAt =
typeof evt.data?.endedAt === "number"
? (evt.data.endedAt as number)
: undefined;
const error =
typeof evt.data?.error === "string"
? (evt.data.error as string)
: undefined;
typeof evt.data?.endedAt === "number" ? (evt.data.endedAt as number) : undefined;
const error = typeof evt.data?.error === "string" ? (evt.data.error as string) : undefined;
agentRunStarts.delete(evt.runId);
recordAgentRunSnapshot({
runId: evt.runId,
@@ -106,13 +99,8 @@ export async function waitForAgentJob(params: {
? (evt.data.startedAt as number)
: agentRunStarts.get(evt.runId);
const endedAt =
typeof evt.data?.endedAt === "number"
? (evt.data.endedAt as number)
: undefined;
const error =
typeof evt.data?.error === "string"
? (evt.data.error as string)
: undefined;
typeof evt.data?.endedAt === "number" ? (evt.data.endedAt as number) : undefined;
const error = typeof evt.data?.error === "string" ? (evt.data.error as string) : undefined;
const snapshot: AgentRunSnapshot = {
runId: evt.runId,
status: phase === "error" ? "error" : "ok",

View File

@@ -98,31 +98,21 @@ export const agentHandlers: GatewayRequestHandlers = {
let images: Array<{ type: "image"; data: string; mimeType: string }> = [];
if (normalizedAttachments.length > 0) {
try {
const parsed = await parseMessageWithAttachments(
message,
normalizedAttachments,
{ maxBytes: 5_000_000, log: context.logGateway },
);
const parsed = await parseMessageWithAttachments(message, normalizedAttachments, {
maxBytes: 5_000_000,
log: context.logGateway,
});
message = parsed.message.trim();
images = parsed.images;
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, String(err)),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(err)));
return;
}
}
const rawChannel =
typeof request.channel === "string" ? request.channel.trim() : "";
const rawChannel = typeof request.channel === "string" ? request.channel.trim() : "";
if (rawChannel) {
const normalized = normalizeMessageChannel(rawChannel);
if (
normalized &&
normalized !== "last" &&
!isGatewayMessageChannel(normalized)
) {
if (normalized && normalized !== "last" && !isGatewayMessageChannel(normalized)) {
respond(
false,
undefined,
@@ -145,8 +135,7 @@ export const agentHandlers: GatewayRequestHandlers = {
let cfgForAgent: ReturnType<typeof loadConfig> | undefined;
if (requestedSessionKey) {
const { cfg, storePath, store, entry, canonicalKey } =
loadSessionEntry(requestedSessionKey);
const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry(requestedSessionKey);
cfgForAgent = cfg;
const now = Date.now();
const sessionId = entry?.sessionId ?? randomUUID();
@@ -180,10 +169,7 @@ export const agentHandlers: GatewayRequestHandlers = {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"send blocked by session policy",
),
errorShape(ErrorCodes.INVALID_REQUEST, "send blocked by session policy"),
);
return;
}
@@ -197,10 +183,7 @@ export const agentHandlers: GatewayRequestHandlers = {
await saveSessionStore(storePath, store);
}
}
if (
canonicalSessionKey === mainSessionKey ||
canonicalSessionKey === "global"
) {
if (canonicalSessionKey === mainSessionKey || canonicalSessionKey === "global") {
context.addChatRun(idem, {
sessionKey: requestedSessionKey,
clientRunId: idem,
@@ -215,10 +198,7 @@ export const agentHandlers: GatewayRequestHandlers = {
const requestedChannel = normalizeMessageChannel(request.channel) ?? "last";
const lastChannel = sessionEntry?.lastChannel;
const lastTo =
typeof sessionEntry?.lastTo === "string"
? sessionEntry.lastTo.trim()
: "";
const lastTo = typeof sessionEntry?.lastTo === "string" ? sessionEntry.lastTo.trim() : "";
const wantsDelivery = request.deliver === true;
@@ -241,9 +221,7 @@ export const agentHandlers: GatewayRequestHandlers = {
})();
const explicitTo =
typeof request.to === "string" && request.to.trim()
? request.to.trim()
: undefined;
typeof request.to === "string" && request.to.trim() ? request.to.trim() : undefined;
const deliveryTargetMode = explicitTo
? "explicit"
: isDeliverableMessageChannel(resolvedChannel)
@@ -251,9 +229,7 @@ export const agentHandlers: GatewayRequestHandlers = {
: undefined;
let resolvedTo =
explicitTo ||
(isDeliverableMessageChannel(resolvedChannel)
? lastTo || undefined
: undefined);
(isDeliverableMessageChannel(resolvedChannel) ? lastTo || undefined : undefined);
if (!resolvedTo && isDeliverableMessageChannel(resolvedChannel)) {
const cfg = cfgForAgent ?? loadConfig();
const fallback = resolveOutboundTarget({
@@ -267,8 +243,7 @@ export const agentHandlers: GatewayRequestHandlers = {
}
}
const deliver =
request.deliver === true && resolvedChannel !== INTERNAL_MESSAGE_CHANNEL;
const deliver = request.deliver === true && resolvedChannel !== INTERNAL_MESSAGE_CHANNEL;
const accepted = {
runId,

View File

@@ -6,10 +6,7 @@ import {
normalizeChannelId,
} from "../../channels/plugins/index.js";
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
import type {
ChannelAccountSnapshot,
ChannelPlugin,
} from "../../channels/plugins/types.js";
import type { ChannelAccountSnapshot, ChannelPlugin } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig, readConfigFileSnapshot } from "../../config/config.js";
import { getChannelActivity } from "../../infra/channel-activity.js";
@@ -44,10 +41,7 @@ export async function logoutChannelAccount(params: {
params.plugin.config.defaultAccountId?.(params.cfg) ||
params.plugin.config.listAccountIds(params.cfg)[0] ||
DEFAULT_ACCOUNT_ID;
const account = params.plugin.config.resolveAccount(
params.cfg,
resolvedAccountId,
);
const account = params.plugin.config.resolveAccount(params.cfg, resolvedAccountId);
await params.context.stopChannel(params.channelId, resolvedAccountId);
const result = await params.plugin.gateway?.logoutAccount?.({
cfg: params.cfg,
@@ -59,14 +53,9 @@ export async function logoutChannelAccount(params: {
throw new Error(`Channel ${params.channelId} does not support logout`);
}
const cleared = Boolean(result.cleared);
const loggedOut =
typeof result.loggedOut === "boolean" ? result.loggedOut : cleared;
const loggedOut = typeof result.loggedOut === "boolean" ? result.loggedOut : cleared;
if (loggedOut) {
params.context.markChannelLoggedOut(
params.channelId,
true,
resolvedAccountId,
);
params.context.markChannelLoggedOut(params.channelId, true, resolvedAccountId);
}
return {
channel: params.channelId,
@@ -91,8 +80,7 @@ export const channelsHandlers: GatewayRequestHandlers = {
}
const probe = (params as { probe?: boolean }).probe === true;
const timeoutMsRaw = (params as { timeoutMs?: unknown }).timeoutMs;
const timeoutMs =
typeof timeoutMsRaw === "number" ? Math.max(1000, timeoutMsRaw) : 10_000;
const timeoutMs = typeof timeoutMsRaw === "number" ? Math.max(1000, timeoutMsRaw) : 10_000;
const cfg = loadConfig();
const runtime = context.getRuntimeSnapshot();
const plugins = listChannelPlugins();
@@ -108,8 +96,7 @@ export const channelsHandlers: GatewayRequestHandlers = {
const accounts = runtime.channelAccounts[channelId];
const defaultRuntime = runtime.channels[channelId];
const raw =
accounts?.[accountId] ??
(accountId === defaultAccountId ? defaultRuntime : undefined);
accounts?.[accountId] ?? (accountId === defaultAccountId ? defaultRuntime : undefined);
if (!raw) return undefined;
return raw;
};
@@ -174,11 +161,7 @@ export const channelsHandlers: GatewayRequestHandlers = {
});
}
}
const runtimeSnapshot = resolveRuntimeSnapshot(
channelId,
accountId,
defaultAccountId,
);
const runtimeSnapshot = resolveRuntimeSnapshot(channelId, accountId, defaultAccountId);
const snapshot = await buildChannelAccountSnapshot({
plugin,
cfg,
@@ -201,33 +184,26 @@ export const channelsHandlers: GatewayRequestHandlers = {
accounts.push(snapshot);
}
const defaultAccount =
accounts.find((entry) => entry.accountId === defaultAccountId) ??
accounts[0];
accounts.find((entry) => entry.accountId === defaultAccountId) ?? accounts[0];
return { accounts, defaultAccountId, defaultAccount, resolvedAccounts };
};
const payload: Record<string, unknown> = {
ts: Date.now(),
channelOrder: plugins.map((plugin) => plugin.id),
channelLabels: Object.fromEntries(
plugins.map((plugin) => [plugin.id, plugin.meta.label]),
),
channelLabels: Object.fromEntries(plugins.map((plugin) => [plugin.id, plugin.meta.label])),
channels: {} as Record<string, unknown>,
channelAccounts: {} as Record<string, unknown>,
channelDefaultAccountId: {} as Record<string, unknown>,
};
const channelsMap = payload.channels as Record<string, unknown>;
const accountsMap = payload.channelAccounts as Record<string, unknown>;
const defaultAccountIdMap = payload.channelDefaultAccountId as Record<
string,
unknown
>;
const defaultAccountIdMap = payload.channelDefaultAccountId as Record<string, unknown>;
for (const plugin of plugins) {
const { accounts, defaultAccountId, defaultAccount, resolvedAccounts } =
await buildChannelAccounts(plugin.id);
const fallbackAccount =
resolvedAccounts[defaultAccountId] ??
plugin.config.resolveAccount(cfg, defaultAccountId);
resolvedAccounts[defaultAccountId] ?? plugin.config.resolveAccount(cfg, defaultAccountId);
const summary = plugin.status?.buildChannelSummary
? await plugin.status.buildChannelSummary({
account: fallbackAccount,
@@ -262,16 +238,12 @@ export const channelsHandlers: GatewayRequestHandlers = {
return;
}
const rawChannel = (params as { channel?: unknown }).channel;
const channelId =
typeof rawChannel === "string" ? normalizeChannelId(rawChannel) : null;
const channelId = typeof rawChannel === "string" ? normalizeChannelId(rawChannel) : null;
if (!channelId) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"invalid channels.logout channel",
),
errorShape(ErrorCodes.INVALID_REQUEST, "invalid channels.logout channel"),
);
return;
}
@@ -280,25 +252,18 @@ export const channelsHandlers: GatewayRequestHandlers = {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`channel ${channelId} does not support logout`,
),
errorShape(ErrorCodes.INVALID_REQUEST, `channel ${channelId} does not support logout`),
);
return;
}
const accountIdRaw = (params as { accountId?: unknown }).accountId;
const accountId =
typeof accountIdRaw === "string" ? accountIdRaw.trim() : undefined;
const accountId = typeof accountIdRaw === "string" ? accountIdRaw.trim() : undefined;
const snapshot = await readConfigFileSnapshot();
if (!snapshot.valid) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"config invalid; fix it before logging out",
),
errorShape(ErrorCodes.INVALID_REQUEST, "config invalid; fix it before logging out"),
);
return;
}
@@ -312,11 +277,7 @@ export const channelsHandlers: GatewayRequestHandlers = {
});
respond(true, payload, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
}
},
};

View File

@@ -14,10 +14,7 @@ import {
isChatStopCommandText,
resolveChatRunExpiresAtMs,
} from "../chat-abort.js";
import {
type ChatImageContent,
parseMessageWithAttachments,
} from "../chat-attachments.js";
import { type ChatImageContent, parseMessageWithAttachments } from "../chat-attachments.js";
import {
ErrorCodes,
errorShape,
@@ -56,19 +53,13 @@ export const chatHandlers: GatewayRequestHandlers = {
const { cfg, storePath, entry } = loadSessionEntry(sessionKey);
const sessionId = entry?.sessionId;
const rawMessages =
sessionId && storePath
? readSessionMessages(sessionId, storePath, entry?.sessionFile)
: [];
sessionId && storePath ? readSessionMessages(sessionId, storePath, entry?.sessionFile) : [];
const hardMax = 1000;
const defaultLimit = 200;
const requested = typeof limit === "number" ? limit : defaultLimit;
const max = Math.min(hardMax, requested);
const sliced =
rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
const capped = capArrayByJsonBytes(
sliced,
MAX_CHAT_HISTORY_MESSAGES_BYTES,
).items;
const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
const capped = capArrayByJsonBytes(sliced, MAX_CHAT_HISTORY_MESSAGES_BYTES).items;
let thinkingLevel = entry?.thinkingLevel;
if (!thinkingLevel) {
const configured = cfg.agents?.defaults?.thinkingDefault;
@@ -138,10 +129,7 @@ export const chatHandlers: GatewayRequestHandlers = {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"runId does not match sessionKey",
),
errorShape(ErrorCodes.INVALID_REQUEST, "runId does not match sessionKey"),
);
return;
}
@@ -206,25 +194,18 @@ export const chatHandlers: GatewayRequestHandlers = {
let parsedImages: ChatImageContent[] = [];
if (normalizedAttachments.length > 0) {
try {
const parsed = await parseMessageWithAttachments(
p.message,
normalizedAttachments,
{ maxBytes: 5_000_000, log: context.logGateway },
);
const parsed = await parseMessageWithAttachments(p.message, normalizedAttachments, {
maxBytes: 5_000_000,
log: context.logGateway,
});
parsedMessage = parsed.message;
parsedImages = parsed.images;
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, String(err)),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(err)));
return;
}
}
const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry(
p.sessionKey,
);
const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
const timeoutMs = resolveAgentTimeoutMs({
cfg,
overrideMs: p.timeoutMs,
@@ -249,10 +230,7 @@ export const chatHandlers: GatewayRequestHandlers = {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"send blocked by session policy",
),
errorShape(ErrorCodes.INVALID_REQUEST, "send blocked by session policy"),
);
return;
}
@@ -285,12 +263,10 @@ export const chatHandlers: GatewayRequestHandlers = {
const activeExisting = context.chatAbortControllers.get(clientRunId);
if (activeExisting) {
respond(
true,
{ runId: clientRunId, status: "in_flight" as const },
undefined,
{ cached: true, runId: clientRunId },
);
respond(true, { runId: clientRunId, status: "in_flight" as const }, undefined, {
cached: true,
runId: clientRunId,
});
return;
}

View File

@@ -1,7 +1,4 @@
import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
CONFIG_PATH_CLAWDBOT,
loadConfig,
@@ -58,10 +55,7 @@ export const configHandlers: GatewayRequestHandlers = {
return;
}
const cfg = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(
cfg,
resolveDefaultAgentId(cfg),
);
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const pluginRegistry = loadClawdbotPlugins({
config: cfg,
workspaceDir,
@@ -99,20 +93,13 @@ export const configHandlers: GatewayRequestHandlers = {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"invalid config.set params: raw (string) required",
),
errorShape(ErrorCodes.INVALID_REQUEST, "invalid config.set params: raw (string) required"),
);
return;
}
const parsedRes = parseConfigJson5(rawValue);
if (!parsedRes.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error));
return;
}
const validated = validateConfigObject(parsedRes.parsed);
@@ -163,11 +150,7 @@ export const configHandlers: GatewayRequestHandlers = {
}
const parsedRes = parseConfigJson5(rawValue);
if (!parsedRes.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, parsedRes.error));
return;
}
const validated = validateConfigObject(parsedRes.parsed);
@@ -191,11 +174,9 @@ export const configHandlers: GatewayRequestHandlers = {
typeof (params as { note?: unknown }).note === "string"
? (params as { note?: string }).note?.trim() || undefined
: undefined;
const restartDelayMsRaw = (params as { restartDelayMs?: unknown })
.restartDelayMs;
const restartDelayMsRaw = (params as { restartDelayMs?: unknown }).restartDelayMs;
const restartDelayMs =
typeof restartDelayMsRaw === "number" &&
Number.isFinite(restartDelayMsRaw)
typeof restartDelayMsRaw === "number" && Number.isFinite(restartDelayMsRaw)
? Math.max(0, Math.floor(restartDelayMsRaw))
: undefined;

View File

@@ -6,10 +6,7 @@ export const connectHandlers: GatewayRequestHandlers = {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"connect is only valid as the first request",
),
errorShape(ErrorCodes.INVALID_REQUEST, "connect is only valid as the first request"),
);
},
};

View File

@@ -1,11 +1,5 @@
import {
normalizeCronJobCreate,
normalizeCronJobPatch,
} from "../../cron/normalize.js";
import {
readCronRunLogEntries,
resolveCronRunLogPath,
} from "../../cron/run-log.js";
import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js";
import { readCronRunLogEntries, resolveCronRunLogPath } from "../../cron/run-log.js";
import type { CronJobCreate, CronJobPatch } from "../../cron/types.js";
import {
ErrorCodes,
@@ -92,9 +86,7 @@ export const cronHandlers: GatewayRequestHandlers = {
respond(true, job, undefined);
},
"cron.update": async ({ params, respond, context }) => {
const normalizedPatch = normalizeCronJobPatch(
(params as { patch?: unknown } | null)?.patch,
);
const normalizedPatch = normalizeCronJobPatch((params as { patch?: unknown } | null)?.patch);
const candidate =
normalizedPatch && typeof params === "object" && params !== null
? { ...(params as Record<string, unknown>), patch: normalizedPatch }
@@ -120,17 +112,11 @@ export const cronHandlers: GatewayRequestHandlers = {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"invalid cron.update params: missing id",
),
errorShape(ErrorCodes.INVALID_REQUEST, "invalid cron.update params: missing id"),
);
return;
}
const job = await context.cron.update(
jobId,
p.patch as unknown as CronJobPatch,
);
const job = await context.cron.update(jobId, p.patch as unknown as CronJobPatch);
respond(true, job, undefined);
},
"cron.remove": async ({ params, respond, context }) => {
@@ -151,10 +137,7 @@ export const cronHandlers: GatewayRequestHandlers = {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"invalid cron.remove params: missing id",
),
errorShape(ErrorCodes.INVALID_REQUEST, "invalid cron.remove params: missing id"),
);
return;
}
@@ -179,10 +162,7 @@ export const cronHandlers: GatewayRequestHandlers = {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"invalid cron.run params: missing id",
),
errorShape(ErrorCodes.INVALID_REQUEST, "invalid cron.run params: missing id"),
);
return;
}
@@ -207,10 +187,7 @@ export const cronHandlers: GatewayRequestHandlers = {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"invalid cron.runs params: missing id",
),
errorShape(ErrorCodes.INVALID_REQUEST, "invalid cron.runs params: missing id"),
);
return;
}

View File

@@ -13,9 +13,7 @@ export const healthHandlers: GatewayRequestHandlers = {
if (cached && now - cached.ts < HEALTH_REFRESH_INTERVAL_MS) {
respond(true, cached, undefined, { cached: true });
void refreshHealthSnapshot({ probe: false }).catch((err) =>
logHealth.error(
`background health refresh failed: ${formatError(err)}`,
),
logHealth.error(`background health refresh failed: ${formatError(err)}`),
);
return;
}
@@ -23,11 +21,7 @@ export const healthHandlers: GatewayRequestHandlers = {
const snap = await refreshHealthSnapshot({ probe: false });
respond(true, snap, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
}
},
status: async ({ respond }) => {

View File

@@ -23,11 +23,7 @@ export const modelsHandlers: GatewayRequestHandlers = {
const models = await context.loadGatewayModelCatalog();
respond(true, { models }, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, String(err)),
);
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, String(err)));
}
},
};

View File

@@ -1,9 +1,5 @@
import type { ErrorObject } from "ajv";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
} from "../protocol/index.js";
import { ErrorCodes, errorShape, formatValidationErrors } from "../protocol/index.js";
import { formatForLog } from "../ws-log.js";
import type { RespondFn } from "./types.js";
@@ -26,18 +22,11 @@ export function respondInvalidParams(params: {
);
}
export async function respondUnavailableOnThrow(
respond: RespondFn,
fn: () => Promise<void>,
) {
export async function respondUnavailableOnThrow(respond: RespondFn, fn: () => Promise<void>) {
try {
await fn();
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
}
}

View File

@@ -97,11 +97,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
await respondUnavailableOnThrow(respond, async () => {
const approved = await approveNodePairing(requestId);
if (!approved) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
return;
}
context.broadcast(
@@ -130,11 +126,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
await respondUnavailableOnThrow(respond, async () => {
const rejected = await rejectNodePairing(requestId);
if (!rejected) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
return;
}
context.broadcast(
@@ -184,27 +176,15 @@ export const nodeHandlers: GatewayRequestHandlers = {
await respondUnavailableOnThrow(respond, async () => {
const trimmed = displayName.trim();
if (!trimmed) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "displayName required"),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "displayName required"));
return;
}
const updated = await renamePairedNode(nodeId, trimmed);
if (!updated) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"));
return;
}
respond(
true,
{ nodeId: updated.nodeId, displayName: updated.displayName },
undefined,
);
respond(true, { nodeId: updated.nodeId, displayName: updated.displayName }, undefined);
});
},
"node.list": async ({ params, respond, context }) => {
@@ -221,21 +201,14 @@ export const nodeHandlers: GatewayRequestHandlers = {
const pairedById = new Map(list.paired.map((n) => [n.nodeId, n]));
const connected = context.bridge?.listConnected?.() ?? [];
const connectedById = new Map(connected.map((n) => [n.nodeId, n]));
const nodeIds = new Set<string>([
...pairedById.keys(),
...connectedById.keys(),
]);
const nodeIds = new Set<string>([...pairedById.keys(), ...connectedById.keys()]);
const nodes = [...nodeIds].map((nodeId) => {
const paired = pairedById.get(nodeId);
const live = connectedById.get(nodeId);
const caps = uniqueSortedStrings([
...(live?.caps ?? paired?.caps ?? []),
]);
const commands = uniqueSortedStrings([
...(live?.commands ?? paired?.commands ?? []),
]);
const caps = uniqueSortedStrings([...(live?.caps ?? paired?.caps ?? [])]);
const commands = uniqueSortedStrings([...(live?.commands ?? paired?.commands ?? [])]);
return {
nodeId,
@@ -277,11 +250,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
const { nodeId } = params as { nodeId: string };
const id = String(nodeId ?? "").trim();
if (!id) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required"));
return;
}
await respondUnavailableOnThrow(respond, async () => {
@@ -291,18 +260,12 @@ export const nodeHandlers: GatewayRequestHandlers = {
const live = connected.find((n) => n.nodeId === id);
if (!paired && !live) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown nodeId"));
return;
}
const caps = uniqueSortedStrings([...(live?.caps ?? paired?.caps ?? [])]);
const commands = uniqueSortedStrings([
...(live?.commands ?? paired?.commands ?? []),
]);
const commands = uniqueSortedStrings([...(live?.commands ?? paired?.commands ?? [])]);
respond(
true,
@@ -336,11 +299,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
}
const bridge = context.bridge;
if (!bridge) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"),
);
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
return;
}
const p = params as {
@@ -362,10 +321,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
}
await respondUnavailableOnThrow(respond, async () => {
const paramsJSON =
"params" in p && p.params !== undefined
? JSON.stringify(p.params)
: null;
const paramsJSON = "params" in p && p.params !== undefined ? JSON.stringify(p.params) : null;
const res = await bridge.invoke({
nodeId,
command,
@@ -376,11 +332,9 @@ export const nodeHandlers: GatewayRequestHandlers = {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
res.error?.message ?? "node invoke failed",
{ details: { nodeError: res.error ?? null } },
),
errorShape(ErrorCodes.UNAVAILABLE, res.error?.message ?? "node invoke failed", {
details: { nodeError: res.error ?? null },
}),
);
return;
}

View File

@@ -1,7 +1,4 @@
import {
getChannelPlugin,
normalizeChannelId,
} from "../../channels/plugins/index.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { ChannelId } from "../../channels/plugins/types.js";
import { DEFAULT_CHAT_CHANNEL } from "../../channels/registry.js";
import { loadConfig } from "../../config/config.js";
@@ -52,19 +49,13 @@ export const sendHandlers: GatewayRequestHandlers = {
}
const to = request.to.trim();
const message = request.message.trim();
const channelInput =
typeof request.channel === "string" ? request.channel : undefined;
const normalizedChannel = channelInput
? normalizeChannelId(channelInput)
: null;
const channelInput = typeof request.channel === "string" ? request.channel : undefined;
const normalizedChannel = channelInput ? normalizeChannelId(channelInput) : null;
if (channelInput && !normalizedChannel) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`unsupported channel: ${channelInput}`,
),
errorShape(ErrorCodes.INVALID_REQUEST, `unsupported channel: ${channelInput}`),
);
return;
}
@@ -80,10 +71,7 @@ export const sendHandlers: GatewayRequestHandlers = {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`unsupported channel: ${channel}`,
),
errorShape(ErrorCodes.INVALID_REQUEST, `unsupported channel: ${channel}`),
);
return;
}
@@ -96,11 +84,7 @@ export const sendHandlers: GatewayRequestHandlers = {
mode: "explicit",
});
if (!resolved.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, String(resolved.error)),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(resolved.error)));
return;
}
const results = await deliverOutboundPayloads({
@@ -174,19 +158,13 @@ export const sendHandlers: GatewayRequestHandlers = {
return;
}
const to = request.to.trim();
const channelInput =
typeof request.channel === "string" ? request.channel : undefined;
const normalizedChannel = channelInput
? normalizeChannelId(channelInput)
: null;
const channelInput = typeof request.channel === "string" ? request.channel : undefined;
const normalizedChannel = channelInput ? normalizeChannelId(channelInput) : null;
if (channelInput && !normalizedChannel) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`unsupported poll channel: ${channelInput}`,
),
errorShape(ErrorCodes.INVALID_REQUEST, `unsupported poll channel: ${channelInput}`),
);
return;
}
@@ -208,10 +186,7 @@ export const sendHandlers: GatewayRequestHandlers = {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`unsupported poll channel: ${channel}`,
),
errorShape(ErrorCodes.INVALID_REQUEST, `unsupported poll channel: ${channel}`),
);
return;
}
@@ -224,11 +199,7 @@ export const sendHandlers: GatewayRequestHandlers = {
mode: "explicit",
});
if (!resolved.ok) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, String(resolved.error)),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, String(resolved.error)));
return;
}
const normalized = outbound.pollMaxOptions

View File

@@ -99,11 +99,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const p = params as import("../protocol/index.js").SessionsPatchParams;
const key = String(p.key ?? "").trim();
if (!key) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "key required"),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "key required"));
return;
}
@@ -153,11 +149,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const p = params as import("../protocol/index.js").SessionsResetParams;
const key = String(p.key ?? "").trim();
if (!key) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "key required"),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "key required"));
return;
}
@@ -192,11 +184,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
};
store[primaryKey] = next;
await saveSessionStore(storePath, store);
respond(
true,
{ ok: true, key: target.canonicalKey, entry: next },
undefined,
);
respond(true, { ok: true, key: target.canonicalKey, entry: next }, undefined);
},
"sessions.delete": async ({ params, respond }) => {
if (!validateSessionsDeleteParams(params)) {
@@ -213,11 +201,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const p = params as import("../protocol/index.js").SessionsDeleteParams;
const key = String(p.key ?? "").trim();
if (!key) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "key required"),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "key required"));
return;
}
@@ -228,16 +212,12 @@ export const sessionsHandlers: GatewayRequestHandlers = {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`Cannot delete the main session (${mainKey}).`,
),
errorShape(ErrorCodes.INVALID_REQUEST, `Cannot delete the main session (${mainKey}).`),
);
return;
}
const deleteTranscript =
typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true;
const deleteTranscript = typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true;
const storePath = target.storePath;
const store = loadSessionStore(storePath);
@@ -286,11 +266,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
}
}
respond(
true,
{ ok: true, key: target.canonicalKey, deleted: existed, archived },
undefined,
);
respond(true, { ok: true, key: target.canonicalKey, deleted: existed, archived }, undefined);
},
"sessions.compact": async ({ params, respond }) => {
if (!validateSessionsCompactParams(params)) {
@@ -307,11 +283,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
const p = params as import("../protocol/index.js").SessionsCompactParams;
const key = String(p.key ?? "").trim();
if (!key) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "key required"),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "key required"));
return;
}

View File

@@ -1,7 +1,4 @@
import {
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "../../agents/agent-scope.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { installSkill } from "../../agents/skills-install.js";
import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js";
import type { ClawdbotConfig } from "../../config/config.js";
@@ -30,10 +27,7 @@ export const skillsHandlers: GatewayRequestHandlers = {
return;
}
const cfg = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(
cfg,
resolveDefaultAgentId(cfg),
);
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const report = buildWorkspaceSkillStatus(workspaceDir, {
config: cfg,
});
@@ -57,10 +51,7 @@ export const skillsHandlers: GatewayRequestHandlers = {
timeoutMs?: number;
};
const cfg = loadConfig();
const workspaceDirRaw = resolveAgentWorkspaceDir(
cfg,
resolveDefaultAgentId(cfg),
);
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const result = await installSkill({
workspaceDir: workspaceDirRaw,
skillName: p.name,
@@ -71,9 +62,7 @@ export const skillsHandlers: GatewayRequestHandlers = {
respond(
result.ok,
result,
result.ok
? undefined
: errorShape(ErrorCodes.UNAVAILABLE, result.message),
result.ok ? undefined : errorShape(ErrorCodes.UNAVAILABLE, result.message),
);
},
"skills.update": async ({ params, respond }) => {
@@ -124,10 +113,6 @@ export const skillsHandlers: GatewayRequestHandlers = {
skills,
};
await writeConfigFile(nextConfig);
respond(
true,
{ ok: true, skillKey: p.skillKey, config: current },
undefined,
);
respond(true, { ok: true, skillKey: p.skillKey, config: current }, undefined);
},
};

View File

@@ -1,14 +1,8 @@
import { resolveMainSessionKeyFromConfig } from "../../config/sessions.js";
import { getLastHeartbeatEvent } from "../../infra/heartbeat-events.js";
import { setHeartbeatsEnabled } from "../../infra/heartbeat-runner.js";
import {
enqueueSystemEvent,
isSystemEventContextChanged,
} from "../../infra/system-events.js";
import {
listSystemPresence,
updateSystemPresence,
} from "../../infra/system-presence.js";
import { enqueueSystemEvent, isSystemEventContextChanged } from "../../infra/system-events.js";
import { listSystemPresence, updateSystemPresence } from "../../infra/system-presence.js";
import { ErrorCodes, errorShape } from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";
@@ -39,39 +33,26 @@ export const systemHandlers: GatewayRequestHandlers = {
"system-event": ({ params, respond, context }) => {
const text = typeof params.text === "string" ? params.text.trim() : "";
if (!text) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "text required"),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "text required"));
return;
}
const sessionKey = resolveMainSessionKeyFromConfig();
const instanceId =
typeof params.instanceId === "string" ? params.instanceId : undefined;
const instanceId = typeof params.instanceId === "string" ? params.instanceId : undefined;
const host = typeof params.host === "string" ? params.host : undefined;
const ip = typeof params.ip === "string" ? params.ip : undefined;
const mode = typeof params.mode === "string" ? params.mode : undefined;
const version =
typeof params.version === "string" ? params.version : undefined;
const platform =
typeof params.platform === "string" ? params.platform : undefined;
const deviceFamily =
typeof params.deviceFamily === "string" ? params.deviceFamily : undefined;
const version = typeof params.version === "string" ? params.version : undefined;
const platform = typeof params.platform === "string" ? params.platform : undefined;
const deviceFamily = typeof params.deviceFamily === "string" ? params.deviceFamily : undefined;
const modelIdentifier =
typeof params.modelIdentifier === "string"
? params.modelIdentifier
: undefined;
typeof params.modelIdentifier === "string" ? params.modelIdentifier : undefined;
const lastInputSeconds =
typeof params.lastInputSeconds === "number" &&
Number.isFinite(params.lastInputSeconds)
typeof params.lastInputSeconds === "number" && Number.isFinite(params.lastInputSeconds)
? params.lastInputSeconds
: undefined;
const reason =
typeof params.reason === "string" ? params.reason : undefined;
const reason = typeof params.reason === "string" ? params.reason : undefined;
const tags =
Array.isArray(params.tags) &&
params.tags.every((t) => typeof t === "string")
Array.isArray(params.tags) && params.tags.every((t) => typeof t === "string")
? (params.tags as string[])
: undefined;
const presenceUpdate = updateSystemPresence({
@@ -95,24 +76,15 @@ export const systemHandlers: GatewayRequestHandlers = {
const reasonValue = next.reason ?? reason;
const normalizedReason = (reasonValue ?? "").toLowerCase();
const ignoreReason =
normalizedReason.startsWith("periodic") ||
normalizedReason === "heartbeat";
normalizedReason.startsWith("periodic") || normalizedReason === "heartbeat";
const hostChanged = changed.has("host");
const ipChanged = changed.has("ip");
const versionChanged = changed.has("version");
const modeChanged = changed.has("mode");
const reasonChanged = changed.has("reason") && !ignoreReason;
const hasChanges =
hostChanged ||
ipChanged ||
versionChanged ||
modeChanged ||
reasonChanged;
const hasChanges = hostChanged || ipChanged || versionChanged || modeChanged || reasonChanged;
if (hasChanges) {
const contextChanged = isSystemEventContextChanged(
sessionKey,
presenceUpdate.key,
);
const contextChanged = isSystemEventContextChanged(sessionKey, presenceUpdate.key);
const parts: string[] = [];
if (contextChanged || hostChanged || ipChanged) {
const hostLabel = next.host?.trim() || "Unknown";

View File

@@ -8,18 +8,11 @@ import type { GatewayRequestHandlers } from "./types.js";
export const talkHandlers: GatewayRequestHandlers = {
"talk.mode": ({ params, respond, context, client, isWebchatConnect }) => {
if (
client &&
isWebchatConnect(client.connect) &&
!context.hasConnectedMobileNode()
) {
if (client && isWebchatConnect(client.connect) && !context.hasConnectedMobileNode()) {
respond(
false,
undefined,
errorShape(
ErrorCodes.UNAVAILABLE,
"talk disabled: no connected iOS/Android nodes",
),
errorShape(ErrorCodes.UNAVAILABLE, "talk disabled: no connected iOS/Android nodes"),
);
return;
}

View File

@@ -5,11 +5,7 @@ import type { CronService } from "../../cron/service.js";
import type { startNodeBridgeServer } from "../../infra/bridge/server.js";
import type { WizardSession } from "../../wizard/session.js";
import type { ChatAbortControllerEntry } from "../chat-abort.js";
import type {
ConnectParams,
ErrorShape,
RequestFrame,
} from "../protocol/index.js";
import type { ConnectParams, ErrorShape, RequestFrame } from "../protocol/index.js";
import type { ChannelRuntimeSnapshot } from "../server-channels.js";
import type { DedupeEntry } from "../server-shared.js";
@@ -44,21 +40,14 @@ export type GatewayRequestContext = {
},
) => void;
bridge: Awaited<ReturnType<typeof startNodeBridgeServer>> | null;
bridgeSendToSession: (
sessionKey: string,
event: string,
payload: unknown,
) => void;
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
hasConnectedMobileNode: () => boolean;
agentRunSeq: Map<string, number>;
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
chatAbortedRuns: Map<string, number>;
chatRunBuffers: Map<string, string>;
chatDeltaSentAt: Map<string, number>;
addChatRun: (
sessionId: string,
entry: { sessionKey: string; clientRunId: string },
) => void;
addChatRun: (sessionId: string, entry: { sessionKey: string; clientRunId: string }) => void;
removeChatRun: (
sessionId: string,
clientRunId: string,
@@ -107,8 +96,6 @@ export type GatewayRequestHandlerOptions = {
context: GatewayRequestContext;
};
export type GatewayRequestHandler = (
opts: GatewayRequestHandlerOptions,
) => Promise<void> | void;
export type GatewayRequestHandler = (opts: GatewayRequestHandlerOptions) => Promise<void> | void;
export type GatewayRequestHandlers = Record<string, GatewayRequestHandler>;

View File

@@ -35,11 +35,9 @@ export const updateHandlers: GatewayRequestHandlers = {
typeof (params as { note?: unknown }).note === "string"
? (params as { note?: string }).note?.trim() || undefined
: undefined;
const restartDelayMsRaw = (params as { restartDelayMs?: unknown })
.restartDelayMs;
const restartDelayMsRaw = (params as { restartDelayMs?: unknown }).restartDelayMs;
const restartDelayMs =
typeof restartDelayMsRaw === "number" &&
Number.isFinite(restartDelayMsRaw)
typeof restartDelayMsRaw === "number" && Number.isFinite(restartDelayMsRaw)
? Math.max(0, Math.floor(restartDelayMsRaw))
: undefined;
const timeoutMsRaw = (params as { timeoutMs?: unknown }).timeoutMs;

View File

@@ -1,7 +1,4 @@
import {
loadVoiceWakeConfig,
setVoiceWakeTriggers,
} from "../../infra/voicewake.js";
import { loadVoiceWakeConfig, setVoiceWakeTriggers } from "../../infra/voicewake.js";
import { ErrorCodes, errorShape } from "../protocol/index.js";
import { normalizeVoiceWakeTriggers } from "../server-utils.js";
import { formatForLog } from "../ws-log.js";
@@ -13,11 +10,7 @@ export const voicewakeHandlers: GatewayRequestHandlers = {
const cfg = await loadVoiceWakeConfig();
respond(true, { triggers: cfg.triggers });
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
}
},
"voicewake.set": async ({ params, respond, context }) => {
@@ -25,10 +18,7 @@ export const voicewakeHandlers: GatewayRequestHandlers = {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"voicewake.set requires triggers: string[]",
),
errorShape(ErrorCodes.INVALID_REQUEST, "voicewake.set requires triggers: string[]"),
);
return;
}
@@ -38,11 +28,7 @@ export const voicewakeHandlers: GatewayRequestHandlers = {
context.broadcastVoiceWakeChanged(cfg.triggers);
respond(true, { triggers: cfg.triggers });
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
}
},
};

View File

@@ -13,9 +13,7 @@ const WEB_LOGIN_METHODS = new Set(["web.login.start", "web.login.wait"]);
const resolveWebLoginProvider = () =>
listChannelPlugins().find((plugin) =>
(plugin.gatewayMethods ?? []).some((method) =>
WEB_LOGIN_METHODS.has(method),
),
(plugin.gatewayMethods ?? []).some((method) => WEB_LOGIN_METHODS.has(method)),
) ?? null;
export const webHandlers: GatewayRequestHandlers = {
@@ -41,10 +39,7 @@ export const webHandlers: GatewayRequestHandlers = {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"web login provider is not available",
),
errorShape(ErrorCodes.INVALID_REQUEST, "web login provider is not available"),
);
return;
}
@@ -71,11 +66,7 @@ export const webHandlers: GatewayRequestHandlers = {
});
respond(true, result, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
}
},
"web.login.wait": async ({ params, respond, context }) => {
@@ -100,10 +91,7 @@ export const webHandlers: GatewayRequestHandlers = {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"web login provider is not available",
),
errorShape(ErrorCodes.INVALID_REQUEST, "web login provider is not available"),
);
return;
}
@@ -130,11 +118,7 @@ export const webHandlers: GatewayRequestHandlers = {
}
respond(true, result, undefined);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)),
);
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(err)));
}
},
};

View File

@@ -28,18 +28,13 @@ export const wizardHandlers: GatewayRequestHandlers = {
}
const running = context.findRunningWizard();
if (running) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, "wizard already running"),
);
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "wizard already running"));
return;
}
const sessionId = randomUUID();
const opts = {
mode: params.mode as "local" | "remote" | undefined,
workspace:
typeof params.workspace === "string" ? params.workspace : undefined,
workspace: typeof params.workspace === "string" ? params.workspace : undefined,
};
const session = new WizardSession((prompter) =>
context.wizardRunner(opts, defaultRuntime, prompter),
@@ -66,33 +61,19 @@ export const wizardHandlers: GatewayRequestHandlers = {
const sessionId = params.sessionId as string;
const session = context.wizardSessions.get(sessionId);
if (!session) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found"),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found"));
return;
}
const answer = params.answer as
| { stepId?: string; value?: unknown }
| undefined;
const answer = params.answer as { stepId?: string; value?: unknown } | undefined;
if (answer) {
if (session.getStatus() !== "running") {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "wizard not running"),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "wizard not running"));
return;
}
try {
await session.answer(String(answer.stepId ?? ""), answer.value);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, formatForLog(err)),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, formatForLog(err)));
return;
}
}
@@ -117,11 +98,7 @@ export const wizardHandlers: GatewayRequestHandlers = {
const sessionId = params.sessionId as string;
const session = context.wizardSessions.get(sessionId);
if (!session) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found"),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found"));
return;
}
session.cancel();
@@ -147,11 +124,7 @@ export const wizardHandlers: GatewayRequestHandlers = {
const sessionId = params.sessionId as string;
const session = context.wizardSessions.get(sessionId);
if (!session) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found"),
);
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found"));
return;
}
const status = {

View File

@@ -5,9 +5,7 @@ type BridgeLike = {
const isMobilePlatform = (platform: unknown): boolean => {
const p = typeof platform === "string" ? platform.trim().toLowerCase() : "";
if (!p) return false;
return (
p.startsWith("ios") || p.startsWith("ipados") || p.startsWith("android")
);
return p.startsWith("ios") || p.startsWith("ipados") || p.startsWith("android");
};
export function hasConnectedMobileNode(bridge: BridgeLike | null): boolean {

View File

@@ -1,9 +1,6 @@
import type { NodeBridgeServer } from "../infra/bridge/server.js";
import { startNodeBridgeServer } from "../infra/bridge/server.js";
import {
listSystemPresence,
upsertPresence,
} from "../infra/system-presence.js";
import { listSystemPresence, upsertPresence } from "../infra/system-presence.js";
import { loadVoiceWakeConfig } from "../infra/voicewake.js";
import { isLoopbackAddress } from "./net.js";
import {
@@ -11,11 +8,7 @@ import {
getPresenceVersion,
incrementPresenceVersion,
} from "./server/health-state.js";
import type {
BridgeEvent,
BridgeRequest,
BridgeResponse,
} from "./server-bridge-types.js";
import type { BridgeEvent, BridgeRequest, BridgeResponse } from "./server-bridge-types.js";
export type GatewayNodeBridgeRuntime = {
bridge: NodeBridgeServer | null;
@@ -38,10 +31,7 @@ export async function startGatewayNodeBridge(params: {
},
) => void;
bridgeUnsubscribeAll: (nodeId: string) => void;
handleBridgeRequest: (
nodeId: string,
req: BridgeRequest,
) => Promise<BridgeResponse>;
handleBridgeRequest: (nodeId: string, req: BridgeRequest) => Promise<BridgeResponse>;
handleBridgeEvent: (nodeId: string, evt: BridgeEvent) => Promise<void> | void;
logBridge: { info: (msg: string) => void; warn: (msg: string) => void };
}): Promise<GatewayNodeBridgeRuntime> {
@@ -149,19 +139,13 @@ export async function startGatewayNodeBridge(params: {
},
});
if (started.port > 0) {
params.logBridge.info(
`listening on tcp://${params.bridgeHost}:${started.port} (node)`,
);
params.logBridge.info(`listening on tcp://${params.bridgeHost}:${started.port} (node)`);
return { bridge: started, nodePresenceTimers };
}
} catch (err) {
params.logBridge.warn(`failed to start: ${String(err)}`);
}
} else if (
params.bridgeEnabled &&
params.bridgePort > 0 &&
!params.bridgeHost
) {
} else if (params.bridgeEnabled && params.bridgePort > 0 && !params.bridgeHost) {
params.logBridge.warn(
"bind policy requested tailnet IP, but no tailnet interface was found; refusing to start bridge",
);

View File

@@ -26,9 +26,7 @@ export function loadGatewayPlugins(params: {
coreGatewayHandlers: params.coreGatewayHandlers,
});
const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers);
const gatewayMethods = Array.from(
new Set([...params.baseMethods, ...pluginMethods]),
);
const gatewayMethods = Array.from(new Set([...params.baseMethods, ...pluginMethods]));
if (pluginRegistry.diagnostics.length > 0) {
for (const diag of pluginRegistry.diagnostics) {
if (diag.level === "error") {

View File

@@ -6,27 +6,18 @@ import { setCommandLaneConcurrency } from "../process/command-queue.js";
import type { ChannelKind, GatewayReloadPlan } from "./config-reload.js";
import { resolveHooksConfig } from "./hooks.js";
import { startBrowserControlServerIfEnabled } from "./server-browser.js";
import {
buildGatewayCronService,
type GatewayCronState,
} from "./server-cron.js";
import { buildGatewayCronService, type GatewayCronState } from "./server-cron.js";
type GatewayHotReloadState = {
hooksConfig: ReturnType<typeof resolveHooksConfig>;
heartbeatRunner: { stop: () => void };
cronState: GatewayCronState;
browserControl: Awaited<
ReturnType<typeof startBrowserControlServerIfEnabled>
> | null;
browserControl: Awaited<ReturnType<typeof startBrowserControlServerIfEnabled>> | null;
};
export function createGatewayReloadHandlers(params: {
deps: CliDeps;
broadcast: (
event: string,
payload: unknown,
opts?: { dropIfSlow?: boolean },
) => void;
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
getState: () => GatewayHotReloadState;
setState: (state: GatewayHotReloadState) => void;
startChannel: (name: ChannelKind) => Promise<void>;
@@ -70,9 +61,7 @@ export function createGatewayReloadHandlers(params: {
});
void nextState.cronState.cron
.start()
.catch((err) =>
params.logCron.error(`failed to start: ${String(err)}`),
);
.catch((err) => params.logCron.error(`failed to start: ${String(err)}`));
}
if (plan.restartBrowserControl) {
@@ -98,19 +87,13 @@ export function createGatewayReloadHandlers(params: {
gmailResult.reason !== "hooks not enabled" &&
gmailResult.reason !== "no gmail account configured"
) {
params.logHooks.warn(
`gmail watcher not started: ${gmailResult.reason}`,
);
params.logHooks.warn(`gmail watcher not started: ${gmailResult.reason}`);
}
} catch (err) {
params.logHooks.error(
`gmail watcher failed to start: ${String(err)}`,
);
params.logHooks.error(`gmail watcher failed to start: ${String(err)}`);
}
} else {
params.logHooks.info(
"skipping gmail watcher restart (CLAWDBOT_SKIP_GMAIL_WATCHER=1)",
);
params.logHooks.info("skipping gmail watcher restart (CLAWDBOT_SKIP_GMAIL_WATCHER=1)");
}
}
@@ -135,23 +118,16 @@ export function createGatewayReloadHandlers(params: {
}
setCommandLaneConcurrency("cron", nextConfig.cron?.maxConcurrentRuns ?? 1);
setCommandLaneConcurrency(
"main",
nextConfig.agents?.defaults?.maxConcurrent ?? 1,
);
setCommandLaneConcurrency("main", nextConfig.agents?.defaults?.maxConcurrent ?? 1);
setCommandLaneConcurrency(
"subagent",
nextConfig.agents?.defaults?.subagents?.maxConcurrent ?? 1,
);
if (plan.hotReasons.length > 0) {
params.logReload.info(
`config hot reload applied (${plan.hotReasons.join(", ")})`,
);
params.logReload.info(`config hot reload applied (${plan.hotReasons.join(", ")})`);
} else if (plan.noopPaths.length > 0) {
params.logReload.info(
`config change applied (dynamic reads: ${plan.noopPaths.join(", ")})`,
);
params.logReload.info(`config change applied (dynamic reads: ${plan.noopPaths.join(", ")})`);
}
params.setState(nextState);
@@ -164,9 +140,7 @@ export function createGatewayReloadHandlers(params: {
const reasons = plan.restartReasons.length
? plan.restartReasons.join(", ")
: plan.changedPaths.join(", ");
params.logReload.warn(
`config change requires gateway restart (${reasons})`,
);
params.logReload.warn(`config change requires gateway restart (${reasons})`);
if (process.listenerCount("SIGUSR1") === 0) {
params.logReload.warn("no SIGUSR1 listener found; restart skipped");
return;

View File

@@ -38,17 +38,14 @@ export async function resolveGatewayRuntimeConfig(params: {
}): Promise<GatewayRuntimeConfig> {
const bindMode = params.bind ?? params.cfg.gateway?.bind ?? "loopback";
const customBindHost = params.cfg.gateway?.customBindHost;
const bindHost =
params.host ?? (await resolveGatewayBindHost(bindMode, customBindHost));
const bindHost = params.host ?? (await resolveGatewayBindHost(bindMode, customBindHost));
const controlUiEnabled =
params.controlUiEnabled ?? params.cfg.gateway?.controlUi?.enabled ?? true;
const openAiChatCompletionsEnabled =
params.openAiChatCompletionsEnabled ??
params.cfg.gateway?.http?.endpoints?.chatCompletions?.enabled ??
false;
const controlUiBasePath = normalizeControlUiBasePath(
params.cfg.gateway?.controlUi?.basePath,
);
const controlUiBasePath = normalizeControlUiBasePath(params.cfg.gateway?.controlUi?.basePath);
const authBase = params.cfg.gateway?.auth ?? {};
const authOverrides = params.auth ?? {};
const authConfig = {
@@ -70,8 +67,7 @@ export async function resolveGatewayRuntimeConfig(params: {
const authMode: ResolvedGatewayAuth["mode"] = resolvedAuth.mode;
const hooksConfig = resolveHooksConfig(params.cfg);
const canvasHostEnabled =
process.env.CLAWDBOT_SKIP_CANVAS_HOST !== "1" &&
params.cfg.canvasHost?.enabled !== false;
process.env.CLAWDBOT_SKIP_CANVAS_HOST !== "1" && params.cfg.canvasHost?.enabled !== false;
assertGatewayAuthConfigured(resolvedAuth);
if (tailscaleMode === "funnel" && authMode !== "password") {
@@ -80,9 +76,7 @@ export async function resolveGatewayRuntimeConfig(params: {
);
}
if (tailscaleMode !== "off" && !isLoopbackHost(bindHost)) {
throw new Error(
"tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)",
);
throw new Error("tailscale serve/funnel requires gateway bind=loopback (127.0.0.1)");
}
if (!isLoopbackHost(bindHost) && authMode === "none") {
throw new Error(

View File

@@ -1,10 +1,7 @@
import type { Server as HttpServer } from "node:http";
import { WebSocketServer } from "ws";
import { CANVAS_HOST_PATH } from "../canvas-host/a2ui.js";
import {
type CanvasHostHandler,
createCanvasHostHandler,
} from "../canvas-host/server.js";
import { type CanvasHostHandler, createCanvasHostHandler } from "../canvas-host/server.js";
import type { CliDeps } from "../cli/deps.js";
import type { createSubsystemLogger } from "../logging.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -17,10 +14,7 @@ import type { GatewayWsClient } from "./server/ws-types.js";
import { createGatewayBroadcaster } from "./server-broadcast.js";
import { type ChatRunEntry, createChatRunState } from "./server-chat.js";
import { MAX_PAYLOAD_BYTES } from "./server-constants.js";
import {
attachGatewayUpgradeHandler,
createGatewayHttpServer,
} from "./server-http.js";
import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js";
import type { DedupeEntry } from "./server-shared.js";
export async function createGatewayRuntimeState(params: {

View File

@@ -1,9 +1,6 @@
import { loadConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import {
getAgentRunContext,
registerAgentRunContext,
} from "../infra/agent-events.js";
import { getAgentRunContext, registerAgentRunContext } from "../infra/agent-events.js";
export function resolveSessionKeyForRun(runId: string) {
const cached = getAgentRunContext(runId)?.sessionKey;
@@ -11,9 +8,7 @@ export function resolveSessionKeyForRun(runId: string) {
const cfg = loadConfig();
const storePath = resolveStorePath(cfg.session?.store);
const store = loadSessionStore(storePath);
const found = Object.entries(store).find(
([, entry]) => entry?.sessionId === runId,
);
const found = Object.entries(store).find(([, entry]) => entry?.sessionId === runId);
const sessionKey = found?.[0];
if (sessionKey) {
registerAgentRunContext(runId, { sessionKey });

View File

@@ -11,19 +11,16 @@ export function logGatewayStartup(params: {
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
isNixMode: boolean;
}) {
const { provider: agentProvider, model: agentModel } =
resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const { provider: agentProvider, model: agentModel } = resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const modelRef = `${agentProvider}/${agentModel}`;
params.log.info(`agent model: ${modelRef}`, {
consoleMessage: `agent model: ${chalk.whiteBright(modelRef)}`,
});
params.log.info(
`listening on ws://${params.bindHost}:${params.port} (PID ${process.pid})`,
);
params.log.info(`listening on ws://${params.bindHost}:${params.port} (PID ${process.pid})`);
params.log.info(`log file: ${getResolvedLoggerSettings().file}`);
if (params.isNixMode) {
params.log.info("gateway: running in Nix mode (config managed externally)");

View File

@@ -9,10 +9,7 @@ import type { CliDeps } from "../cli/deps.js";
import type { loadConfig } from "../config/config.js";
import { startGmailWatcher } from "../hooks/gmail-watcher.js";
import type { loadClawdbotPlugins } from "../plugins/loader.js";
import {
type PluginServicesHandle,
startPluginServices,
} from "../plugins/services.js";
import { type PluginServicesHandle, startPluginServices } from "../plugins/services.js";
import { startBrowserControlServerIfEnabled } from "./server-browser.js";
import {
scheduleRestartSentinelWake,
@@ -35,9 +32,7 @@ export async function startGatewaySidecars(params: {
logBrowser: { error: (msg: string) => void };
}) {
// Start clawd browser control server (unless disabled via config).
let browserControl: Awaited<
ReturnType<typeof startBrowserControlServerIfEnabled>
> = null;
let browserControl: Awaited<ReturnType<typeof startBrowserControlServerIfEnabled>> = null;
try {
browserControl = await startBrowserControlServerIfEnabled();
} catch (err) {
@@ -55,9 +50,7 @@ export async function startGatewaySidecars(params: {
gmailResult.reason !== "hooks not enabled" &&
gmailResult.reason !== "no gmail account configured"
) {
params.logHooks.warn(
`gmail watcher not started: ${gmailResult.reason}`,
);
params.logHooks.warn(`gmail watcher not started: ${gmailResult.reason}`);
}
} catch (err) {
params.logHooks.error(`gmail watcher failed to start: ${String(err)}`);
@@ -71,12 +64,11 @@ export async function startGatewaySidecars(params: {
defaultProvider: DEFAULT_PROVIDER,
});
if (hooksModelRef) {
const { provider: defaultProvider, model: defaultModel } =
resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const { provider: defaultProvider, model: defaultModel } = resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const catalog = await loadModelCatalog({ config: params.cfg });
const status = getModelRefStatus({
cfg: params.cfg,
@@ -101,8 +93,7 @@ export async function startGatewaySidecars(params: {
// Launch configured channels so gateway replies via the surface the message came from.
// Tests can opt out via CLAWDBOT_SKIP_CHANNELS (or legacy CLAWDBOT_SKIP_PROVIDERS).
const skipChannels =
process.env.CLAWDBOT_SKIP_CHANNELS === "1" ||
process.env.CLAWDBOT_SKIP_PROVIDERS === "1";
process.env.CLAWDBOT_SKIP_CHANNELS === "1" || process.env.CLAWDBOT_SKIP_PROVIDERS === "1";
if (!skipChannels) {
try {
await params.startChannels();

View File

@@ -25,9 +25,7 @@ export async function startGatewayTailscaleExposure(params: {
}
const host = await getTailnetHostname().catch(() => null);
if (host) {
const uiPath = params.controlUiBasePath
? `${params.controlUiBasePath}/`
: "/";
const uiPath = params.controlUiBasePath ? `${params.controlUiBasePath}/` : "/";
params.logTailscale.info(
`${params.tailscaleMode} enabled: https://${host}${uiPath} (WS via wss://${host})`,
);

View File

@@ -5,9 +5,7 @@ import { formatError, normalizeVoiceWakeTriggers } from "./server-utils.js";
describe("normalizeVoiceWakeTriggers", () => {
test("returns defaults when input is empty", () => {
expect(normalizeVoiceWakeTriggers([])).toEqual(defaultVoiceWakeTriggers());
expect(normalizeVoiceWakeTriggers(null)).toEqual(
defaultVoiceWakeTriggers(),
);
expect(normalizeVoiceWakeTriggers(null)).toEqual(defaultVoiceWakeTriggers());
});
test("trims and limits entries", () => {
@@ -22,9 +20,7 @@ describe("formatError", () => {
});
test("handles status/code", () => {
expect(formatError({ status: 500, code: "EPIPE" })).toBe(
"status=500 code=EPIPE",
);
expect(formatError({ status: 500, code: "EPIPE" })).toBe("status=500 code=EPIPE");
expect(formatError({ status: 404 })).toBe("status=404 code=unknown");
expect(formatError({ code: "ENOENT" })).toBe("status=unknown code=ENOENT");
});

View File

@@ -3,10 +3,7 @@ import type { createSubsystemLogger } from "../logging.js";
import type { ResolvedGatewayAuth } from "./auth.js";
import { attachGatewayWsConnectionHandler } from "./server/ws-connection.js";
import type { GatewayWsClient } from "./server/ws-types.js";
import type {
GatewayRequestContext,
GatewayRequestHandlers,
} from "./server-methods/types.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "./server-methods/types.js";
export function attachGatewayWsHandlers(params: {
wss: WebSocketServer;

View File

@@ -3,14 +3,8 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import {
emitAgentEvent,
registerAgentRunContext,
} from "../infra/agent-events.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-channel.js";
import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
agentCommand,
connectOk,
@@ -121,10 +115,7 @@ describe("gateway server agent", () => {
expect(resTeams.ok).toBe(true);
const spy = vi.mocked(agentCommand);
const lastIMessageCall = spy.mock.calls.at(-2)?.[0] as Record<
string,
unknown
>;
const lastIMessageCall = spy.mock.calls.at(-2)?.[0] as Record<string, unknown>;
expectChannels(lastIMessageCall, "imessage");
expect(lastIMessageCall.to).toBe("chat_id:123");
@@ -242,46 +233,36 @@ describe("gateway server agent", () => {
await server.close();
});
test(
"agent ack response then final response",
{ timeout: 8000 },
async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
test("agent ack response then final response", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const ackP = onceMessage(
ws,
(o) =>
o.type === "res" &&
o.id === "ag1" &&
o.payload?.status === "accepted",
);
const finalP = onceMessage(
ws,
(o) =>
o.type === "res" &&
o.id === "ag1" &&
o.payload?.status !== "accepted",
);
ws.send(
JSON.stringify({
type: "req",
id: "ag1",
method: "agent",
params: { message: "hi", idempotencyKey: "idem-ag" },
}),
);
const ackP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "ag1" && o.payload?.status === "accepted",
);
const finalP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted",
);
ws.send(
JSON.stringify({
type: "req",
id: "ag1",
method: "agent",
params: { message: "hi", idempotencyKey: "idem-ag" },
}),
);
const ack = await ackP;
const final = await finalP;
expect(ack.payload.runId).toBeDefined();
expect(final.payload.runId).toBe(ack.payload.runId);
expect(final.payload.status).toBe("ok");
const ack = await ackP;
const final = await finalP;
expect(ack.payload.runId).toBeDefined();
expect(final.payload.runId).toBe(ack.payload.runId);
expect(final.payload.status).toBe("ok");
ws.close();
await server.close();
},
);
ws.close();
await server.close();
});
test("agent dedupes by idempotencyKey after completion", async () => {
const { server, ws } = await startServerWithClient();
@@ -289,8 +270,7 @@ describe("gateway server agent", () => {
const firstFinalP = onceMessage(
ws,
(o) =>
o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted",
(o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted",
);
ws.send(
JSON.stringify({
@@ -333,8 +313,7 @@ describe("gateway server agent", () => {
const ws1 = await dial();
const final1P = onceMessage(
ws1,
(o) =>
o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted",
(o) => o.type === "res" && o.id === "ag1" && o.payload?.status !== "accepted",
6000,
);
ws1.send(
@@ -351,8 +330,7 @@ describe("gateway server agent", () => {
const ws2 = await dial();
const final2P = onceMessage(
ws2,
(o) =>
o.type === "res" && o.id === "ag2" && o.payload?.status !== "accepted",
(o) => o.type === "res" && o.id === "ag2" && o.payload?.status !== "accepted",
6000,
);
ws2.send(
@@ -403,9 +381,7 @@ describe("gateway server agent", () => {
ws,
(o) => {
if (o.type !== "event" || o.event !== "chat") return false;
const payload = o.payload as
| { state?: unknown; runId?: unknown }
| undefined;
const payload = o.payload as { state?: unknown; runId?: unknown } | undefined;
return payload?.state === "final" && payload.runId === "run-auto-1";
},
8000,

View File

@@ -2,14 +2,8 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import {
emitAgentEvent,
registerAgentRunContext,
} from "../infra/agent-events.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-channel.js";
import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
connectOk,
installGatewayTestHooks,
@@ -48,10 +42,7 @@ describe("gateway server agent", () => {
const agentEvtP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "agent" &&
o.payload?.runId === "run-tool-1",
(o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-1",
8000,
);
@@ -116,10 +107,7 @@ describe("gateway server agent", () => {
const evt = await onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "agent" &&
o.payload?.runId === "run-tool-off",
(o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-off",
8000,
);
const payload =

View File

@@ -20,10 +20,9 @@ describe("gateway server agents", () => {
const { ws } = await startServerWithClient();
const hello = await connectOk(ws);
expect(
(hello as unknown as { features?: { methods?: string[] } }).features
?.methods,
).toEqual(expect.arrayContaining(["agents.list"]));
expect((hello as unknown as { features?: { methods?: string[] } }).features?.methods).toEqual(
expect.arrayContaining(["agents.list"]),
);
const res = await rpcReq<{
defaultId: string;
@@ -36,11 +35,7 @@ describe("gateway server agents", () => {
expect(res.payload?.defaultId).toBe("work");
expect(res.payload?.mainKey).toBe("main");
expect(res.payload?.scope).toBe("per-sender");
expect(res.payload?.agents.map((agent) => agent.id)).toEqual([
"work",
"home",
"main",
]);
expect(res.payload?.agents.map((agent) => agent.id)).toEqual(["work", "home", "main"]);
const work = res.payload?.agents.find((agent) => agent.id === "work");
const home = res.payload?.agents.find((agent) => agent.id === "home");
expect(work?.name).toBe("Work");

View File

@@ -14,27 +14,21 @@ import {
installGatewayTestHooks();
describe("gateway server auth/connect", () => {
test(
"closes silent handshakes after timeout",
{ timeout: 15_000 },
async () => {
const { server, ws } = await startServerWithClient();
const closed = await new Promise<boolean>((resolve) => {
const timer = setTimeout(() => resolve(false), 12_000);
ws.once("close", () => {
clearTimeout(timer);
resolve(true);
});
test("closes silent handshakes after timeout", { timeout: 15_000 }, async () => {
const { server, ws } = await startServerWithClient();
const closed = await new Promise<boolean>((resolve) => {
const timer = setTimeout(() => resolve(false), 12_000);
ws.once("close", () => {
clearTimeout(timer);
resolve(true);
});
expect(closed).toBe(true);
await server.close();
},
);
});
expect(closed).toBe(true);
await server.close();
});
test("connect (req) handshake returns hello-ok payload", async () => {
const { CONFIG_PATH_CLAWDBOT, STATE_DIR_CLAWDBOT } = await import(
"../config/config.js"
);
const { CONFIG_PATH_CLAWDBOT, STATE_DIR_CLAWDBOT } = await import("../config/config.js");
const port = await getFreePort();
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
@@ -131,13 +125,9 @@ describe("gateway server auth/connect", () => {
{ timeout: 15000 },
async () => {
const { server, ws } = await startServerWithClient();
const closeInfoPromise = new Promise<{ code: number; reason: string }>(
(resolve) => {
ws.once("close", (code, reason) =>
resolve({ code, reason: reason.toString() }),
);
},
);
const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => {
ws.once("close", (code, reason) => resolve({ code, reason: reason.toString() }));
});
ws.send(
JSON.stringify({
@@ -162,14 +152,10 @@ describe("gateway server auth/connect", () => {
error?: { message?: string };
}>(
ws,
(o) =>
(o as { type?: string }).type === "res" &&
(o as { id?: string }).id === "h-bad",
(o) => (o as { type?: string }).type === "res" && (o as { id?: string }).id === "h-bad",
);
expect(res.ok).toBe(false);
expect(String(res.error?.message ?? "")).toContain(
"invalid connect params",
);
expect(String(res.error?.message ?? "")).toContain("invalid connect params");
const closeInfo = await closeInfoPromise;
expect(closeInfo.code).toBe(1008);

View File

@@ -54,11 +54,9 @@ describe("gateway server channels", () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq<{ cleared?: boolean; channel?: string }>(
ws,
"channels.logout",
{ channel: "whatsapp" },
);
const res = await rpcReq<{ cleared?: boolean; channel?: string }>(ws, "channels.logout", {
channel: "whatsapp",
});
expect(res.ok).toBe(true);
expect(res.payload?.channel).toBe("whatsapp");
expect(res.payload?.cleared).toBe(false);
@@ -70,8 +68,7 @@ describe("gateway server channels", () => {
test("channels.logout clears telegram bot token from config", async () => {
const prevToken = process.env.TELEGRAM_BOT_TOKEN;
delete process.env.TELEGRAM_BOT_TOKEN;
const { readConfigFileSnapshot, writeConfigFile } =
await loadConfigHelpers();
const { readConfigFileSnapshot, writeConfigFile } = await loadConfigHelpers();
await writeConfigFile({
channels: {
telegram: {
@@ -97,9 +94,7 @@ describe("gateway server channels", () => {
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(true);
expect(snap.config?.channels?.telegram?.botToken).toBeUndefined();
expect(snap.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(
false,
);
expect(snap.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
ws.close();
await server.close();

View File

@@ -59,17 +59,12 @@ describe("gateway server chat", () => {
}),
);
}
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
largeLines.join("\n"),
"utf-8",
);
await fs.writeFile(path.join(dir, "sess-main.jsonl"), largeLines.join("\n"), "utf-8");
const cappedRes = await rpcReq<{ messages?: unknown[] }>(
ws,
"chat.history",
{ sessionKey: "main", limit: 1000 },
);
const cappedRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
limit: 1000,
});
expect(cappedRes.ok).toBe(true);
const cappedMsgs = cappedRes.payload?.messages ?? [];
const bytes = Buffer.byteLength(JSON.stringify(cappedMsgs), "utf8");
@@ -110,9 +105,7 @@ describe("gateway server chat", () => {
});
expect(res.ok).toBe(true);
const stored = JSON.parse(
await fs.readFile(testState.sessionStorePath, "utf-8"),
) as {
const stored = JSON.parse(await fs.readFile(testState.sessionStorePath, "utf-8")) as {
main?: { lastChannel?: string; lastTo?: string };
};
expect(stored.main?.lastChannel).toBe("whatsapp");
@@ -122,113 +115,97 @@ describe("gateway server chat", () => {
await server.close();
});
test(
"chat.abort cancels an in-flight chat.send",
{ timeout: 15000 },
async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
test("chat.abort cancels an in-flight chat.send", { timeout: 15000 }, async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
null,
2,
),
"utf-8",
},
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
let inFlight: Promise<unknown> | undefined;
try {
await connectOk(ws);
const spy = vi.mocked(agentCommand);
const callsBefore = spy.mock.calls.length;
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const sendResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-abort-1", 8000);
const abortResP = onceMessage(ws, (o) => o.type === "res" && o.id === "abort-1", 8000);
const abortedEventP = onceMessage(
ws,
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted",
8000,
);
inFlight = Promise.allSettled([sendResP, abortResP, abortedEventP]);
ws.send(
JSON.stringify({
type: "req",
id: "send-abort-1",
method: "chat.send",
params: {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-abort-1",
timeoutMs: 30_000,
},
}),
);
const { server, ws } = await startServerWithClient();
let inFlight: Promise<unknown> | undefined;
try {
await connectOk(ws);
const sendRes = await sendResP;
expect(sendRes.ok).toBe(true);
const spy = vi.mocked(agentCommand);
const callsBefore = spy.mock.calls.length;
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
await new Promise<void>((resolve, reject) => {
const deadline = Date.now() + 1000;
const tick = () => {
if (spy.mock.calls.length > callsBefore) return resolve();
if (Date.now() > deadline) return reject(new Error("timeout waiting for agentCommand"));
setTimeout(tick, 5);
};
tick();
});
const sendResP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "send-abort-1",
8000,
);
const abortResP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "abort-1",
8000,
);
const abortedEventP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "aborted",
8000,
);
inFlight = Promise.allSettled([sendResP, abortResP, abortedEventP]);
ws.send(
JSON.stringify({
type: "req",
id: "abort-1",
method: "chat.abort",
params: { sessionKey: "main", runId: "idem-abort-1" },
}),
);
ws.send(
JSON.stringify({
type: "req",
id: "send-abort-1",
method: "chat.send",
params: {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-abort-1",
timeoutMs: 30_000,
},
}),
);
const abortRes = await abortResP;
expect(abortRes.ok).toBe(true);
const sendRes = await sendResP;
expect(sendRes.ok).toBe(true);
await new Promise<void>((resolve, reject) => {
const deadline = Date.now() + 1000;
const tick = () => {
if (spy.mock.calls.length > callsBefore) return resolve();
if (Date.now() > deadline)
return reject(new Error("timeout waiting for agentCommand"));
setTimeout(tick, 5);
};
tick();
});
ws.send(
JSON.stringify({
type: "req",
id: "abort-1",
method: "chat.abort",
params: { sessionKey: "main", runId: "idem-abort-1" },
}),
);
const abortRes = await abortResP;
expect(abortRes.ok).toBe(true);
const evt = await abortedEventP;
expect(evt.payload?.runId).toBe("idem-abort-1");
expect(evt.payload?.sessionKey).toBe("main");
} finally {
ws.close();
await inFlight;
await server.close();
}
},
);
const evt = await abortedEventP;
expect(evt.payload?.runId).toBe("idem-abort-1");
expect(evt.payload?.sessionKey).toBe("main");
} finally {
ws.close();
await inFlight;
await server.close();
}
});
test("chat.abort cancels while saving the session store", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
@@ -265,16 +242,10 @@ describe("gateway server chat", () => {
const abortedEventP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "aborted",
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted",
);
const sendResP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "send-abort-save-1",
);
const sendResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-abort-save-1");
ws.send(
JSON.stringify({
@@ -290,10 +261,7 @@ describe("gateway server chat", () => {
}),
);
const abortResP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "abort-save-1",
);
const abortResP = onceMessage(ws, (o) => o.type === "res" && o.id === "abort-save-1");
ws.send(
JSON.stringify({
type: "req",
@@ -317,97 +285,81 @@ describe("gateway server chat", () => {
await server.close();
});
test(
"chat.send treats /stop as an out-of-band abort",
{ timeout: 15000 },
async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify(
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
null,
2,
),
"utf-8",
);
test("chat.send treats /stop as an out-of-band abort", { timeout: 15000 }, async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify({ main: { sessionId: "sess-main", updatedAt: Date.now() } }, null, 2),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const spy = vi.mocked(agentCommand);
const callsBefore = spy.mock.calls.length;
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
const spy = vi.mocked(agentCommand);
const callsBefore = spy.mock.calls.length;
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const sendResP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "send-stop-1",
8000,
);
ws.send(
JSON.stringify({
type: "req",
id: "send-stop-1",
method: "chat.send",
params: {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-stop-run",
},
}),
);
const sendRes = await sendResP;
expect(sendRes.ok).toBe(true);
const sendResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-stop-1", 8000);
ws.send(
JSON.stringify({
type: "req",
id: "send-stop-1",
method: "chat.send",
params: {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-stop-run",
},
}),
);
const sendRes = await sendResP;
expect(sendRes.ok).toBe(true);
await waitFor(() => spy.mock.calls.length > callsBefore);
await waitFor(() => spy.mock.calls.length > callsBefore);
const abortedEventP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "aborted" &&
o.payload?.runId === "idem-stop-run",
8000,
);
const abortedEventP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "aborted" &&
o.payload?.runId === "idem-stop-run",
8000,
);
const stopResP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "send-stop-2",
8000,
);
ws.send(
JSON.stringify({
type: "req",
id: "send-stop-2",
method: "chat.send",
params: {
sessionKey: "main",
message: "/stop",
idempotencyKey: "idem-stop-req",
},
}),
);
const stopRes = await stopResP;
expect(stopRes.ok).toBe(true);
const stopResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-stop-2", 8000);
ws.send(
JSON.stringify({
type: "req",
id: "send-stop-2",
method: "chat.send",
params: {
sessionKey: "main",
message: "/stop",
idempotencyKey: "idem-stop-req",
},
}),
);
const stopRes = await stopResP;
expect(stopRes.ok).toBe(true);
const evt = await abortedEventP;
expect(evt.payload?.sessionKey).toBe("main");
const evt = await abortedEventP;
expect(evt.payload?.sessionKey).toBe("main");
expect(spy.mock.calls.length).toBe(callsBefore + 1);
expect(spy.mock.calls.length).toBe(callsBefore + 1);
ws.close();
await server.close();
},
);
ws.close();
await server.close();
});
test("chat.send idempotency returns started → in_flight → ok", async () => {
const { server, ws } = await startServerWithClient();
@@ -422,27 +374,19 @@ describe("gateway server chat", () => {
await runDone;
});
const started = await rpcReq<{ runId?: string; status?: string }>(
ws,
"chat.send",
{
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
},
);
const started = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
});
expect(started.ok).toBe(true);
expect(started.payload?.status).toBe("started");
const inFlight = await rpcReq<{ runId?: string; status?: string }>(
ws,
"chat.send",
{
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
},
);
const inFlight = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
});
expect(inFlight.ok).toBe(true);
expect(inFlight.payload?.status).toBe("in_flight");
@@ -450,15 +394,11 @@ describe("gateway server chat", () => {
let completed = false;
for (let i = 0; i < 50; i++) {
const again = await rpcReq<{ runId?: string; status?: string }>(
ws,
"chat.send",
{
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
},
);
const again = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
});
if (again.ok && again.payload?.status === "ok") {
completed = true;
break;

View File

@@ -96,11 +96,7 @@ describe("gateway server chat", () => {
test("chat.abort returns aborted=false for unknown runId", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile(
testState.sessionStorePath,
JSON.stringify({}, null, 2),
"utf-8",
);
await fs.writeFile(testState.sessionStorePath, JSON.stringify({}, null, 2), "utf-8");
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -153,11 +149,7 @@ describe("gateway server chat", () => {
});
});
const sendResP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "send-mismatch-1",
10_000,
);
const sendResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-mismatch-1", 10_000);
ws.send(
JSON.stringify({
type: "req",
@@ -232,26 +224,19 @@ describe("gateway server chat", () => {
}),
);
const sendRes = await onceMessage(
ws,
(o) => o.type === "res" && o.id === "send-complete-1",
);
const sendRes = await onceMessage(ws, (o) => o.type === "res" && o.id === "send-complete-1");
expect(sendRes.ok).toBe(true);
// chat.send returns before the run ends; wait until dedupe is populated
// (meaning the run completed and the abort controller was cleared).
let completed = false;
for (let i = 0; i < 50; i++) {
const again = await rpcReq<{ runId?: string; status?: string }>(
ws,
"chat.send",
{
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-complete-1",
timeoutMs: 30_000,
},
);
const again = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-complete-1",
timeoutMs: 30_000,
});
if (again.ok && again.payload?.status === "ok") {
completed = true;
break;
@@ -308,10 +293,7 @@ describe("gateway server chat", () => {
const final1P = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "final",
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "final",
8000,
);
@@ -330,10 +312,7 @@ describe("gateway server chat", () => {
const final2P = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "final",
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "final",
8000,
);

View File

@@ -2,10 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-channel.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
agentCommand,
connectOk,
@@ -87,9 +84,7 @@ describe("gateway server chat", () => {
expect(res.ok).toBe(true);
await waitFor(() => spy.mock.calls.length > callsBefore);
const call = spy.mock.calls.at(-1)?.[0] as
| { sessionKey?: string }
| undefined;
const call = spy.mock.calls.at(-1)?.[0] as { sessionKey?: string } | undefined;
expect(call?.sessionKey).toBe("agent:main:subagent:abc");
ws.close();
@@ -137,9 +132,7 @@ describe("gateway server chat", () => {
idempotencyKey: "idem-1",
});
expect(res.ok).toBe(false);
expect(
(res.error as { message?: string } | undefined)?.message ?? "",
).toMatch(/send blocked/i);
expect((res.error as { message?: string } | undefined)?.message ?? "").toMatch(/send blocked/i);
ws.close();
await server.close();
@@ -179,9 +172,7 @@ describe("gateway server chat", () => {
idempotencyKey: "idem-2",
});
expect(res.ok).toBe(false);
expect(
(res.error as { message?: string } | undefined)?.message ?? "",
).toMatch(/send blocked/i);
expect((res.error as { message?: string } | undefined)?.message ?? "").toMatch(/send blocked/i);
ws.close();
await server.close();
@@ -218,11 +209,7 @@ describe("gateway server chat", () => {
}),
);
const res = await onceMessage(
ws,
(o) => o.type === "res" && o.id === reqId,
8000,
);
const res = await onceMessage(ws, (o) => o.type === "res" && o.id === reqId, 8000);
expect(res.ok).toBe(true);
expect(res.payload?.runId).toBeDefined();
@@ -230,9 +217,7 @@ describe("gateway server chat", () => {
const call = spy.mock.calls.at(-1)?.[0] as
| { images?: Array<{ type: string; data: string; mimeType: string }> }
| undefined;
expect(call?.images).toEqual([
{ type: "image", data: pngB64, mimeType: "image/png" },
]);
expect(call?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
ws.close();
await server.close();
@@ -278,35 +263,23 @@ describe("gateway server chat", () => {
}),
);
}
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
lines.join("\n"),
"utf-8",
);
await fs.writeFile(path.join(dir, "sess-main.jsonl"), lines.join("\n"), "utf-8");
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const defaultRes = await rpcReq<{ messages?: unknown[] }>(
ws,
"chat.history",
{
sessionKey: "main",
},
);
const defaultRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
});
expect(defaultRes.ok).toBe(true);
const defaultMsgs = defaultRes.payload?.messages ?? [];
expect(defaultMsgs.length).toBe(200);
expect(firstContentText(defaultMsgs[0])).toBe("m100");
const limitedRes = await rpcReq<{ messages?: unknown[] }>(
ws,
"chat.history",
{
sessionKey: "main",
limit: 5,
},
);
const limitedRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
limit: 5,
});
expect(limitedRes.ok).toBe(true);
const limitedMsgs = limitedRes.payload?.messages ?? [];
expect(limitedMsgs.length).toBe(5);
@@ -324,19 +297,11 @@ describe("gateway server chat", () => {
}),
);
}
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
largeLines.join("\n"),
"utf-8",
);
await fs.writeFile(path.join(dir, "sess-main.jsonl"), largeLines.join("\n"), "utf-8");
const cappedRes = await rpcReq<{ messages?: unknown[] }>(
ws,
"chat.history",
{
sessionKey: "main",
},
);
const cappedRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
});
expect(cappedRes.ok).toBe(true);
const cappedMsgs = cappedRes.payload?.messages ?? [];
expect(cappedMsgs.length).toBe(200);

View File

@@ -43,11 +43,7 @@ describe("gateway config.apply", () => {
await vi.advanceTimersByTimeAsync(0);
expect(sigusr1).toHaveBeenCalled();
const sentinelPath = path.join(
os.homedir(),
".clawdbot",
"restart-sentinel.json",
);
const sentinelPath = path.join(os.homedir(), ".clawdbot", "restart-sentinel.json");
const raw = await fs.readFile(sentinelPath, "utf-8");
const parsed = JSON.parse(raw) as { payload?: { kind?: string } };
expect(parsed.payload?.kind).toBe("config-apply");

View File

@@ -18,10 +18,7 @@ describe("gateway server cron", () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(
testState.cronStorePath,
JSON.stringify({ version: 1, jobs: [] }),
);
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -35,9 +32,7 @@ describe("gateway server cron", () => {
payload: { kind: "systemEvent", text: "hello" },
});
expect(addRes.ok).toBe(true);
expect(typeof (addRes.payload as { id?: unknown } | null)?.id).toBe(
"string",
);
expect(typeof (addRes.payload as { id?: unknown } | null)?.id).toBe("string");
const listRes = await rpcReq(ws, "cron.list", {
includeDisabled: true,
@@ -46,9 +41,7 @@ describe("gateway server cron", () => {
const jobs = (listRes.payload as { jobs?: unknown } | null)?.jobs;
expect(Array.isArray(jobs)).toBe(true);
expect((jobs as unknown[]).length).toBe(1);
expect(((jobs as Array<{ name?: unknown }>)[0]?.name as string) ?? "").toBe(
"daily",
);
expect(((jobs as Array<{ name?: unknown }>)[0]?.name as string) ?? "").toBe("daily");
ws.close();
await server.close();
@@ -61,10 +54,7 @@ describe("gateway server cron", () => {
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
testState.sessionConfig = { mainKey: "primary" };
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(
testState.cronStorePath,
JSON.stringify({ version: 1, jobs: [] }),
);
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -87,9 +77,7 @@ describe("gateway server cron", () => {
expect(runRes.ok).toBe(true);
const events = await waitForSystemEvent();
expect(events.some((event) => event.includes("cron route check"))).toBe(
true,
);
expect(events.some((event) => event.includes("cron route check"))).toBe(true);
ws.close();
await server.close();
@@ -102,10 +90,7 @@ describe("gateway server cron", () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(
testState.cronStorePath,
JSON.stringify({ version: 1, jobs: [] }),
);
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -124,9 +109,7 @@ describe("gateway server cron", () => {
| undefined;
expect(payload?.sessionTarget).toBe("main");
expect(payload?.wakeMode).toBe("next-heartbeat");
expect((payload?.schedule as { kind?: unknown } | undefined)?.kind).toBe(
"at",
);
expect((payload?.schedule as { kind?: unknown } | undefined)?.kind).toBe("at");
ws.close();
await server.close();
@@ -138,10 +121,7 @@ describe("gateway server cron", () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(
testState.cronStorePath,
JSON.stringify({ version: 1, jobs: [] }),
);
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -184,10 +164,7 @@ describe("gateway server cron", () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(
testState.cronStorePath,
JSON.stringify({ version: 1, jobs: [] }),
);
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -225,10 +202,7 @@ describe("gateway server cron", () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(
testState.cronStorePath,
JSON.stringify({ version: 1, jobs: [] }),
);
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -261,15 +235,10 @@ describe("gateway server cron", () => {
});
test("writes cron run history to runs/<jobId>.jsonl", async () => {
const dir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-gw-cron-log-"),
);
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-log-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(
testState.cronStorePath,
JSON.stringify({ version: 1, jobs: [] }),
);
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -323,9 +292,7 @@ describe("gateway server cron", () => {
const entries = (runsRes.payload as { entries?: unknown } | null)?.entries;
expect(Array.isArray(entries)).toBe(true);
expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId);
expect((entries as Array<{ summary?: unknown }>).at(-1)?.summary).toBe(
"hello",
);
expect((entries as Array<{ summary?: unknown }>).at(-1)?.summary).toBe("hello");
ws.close();
await server.close();
@@ -334,16 +301,11 @@ describe("gateway server cron", () => {
});
test("writes cron run history to per-job runs/ when store is jobs.json", async () => {
const dir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-gw-cron-log-jobs-"),
);
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-log-jobs-"));
const cronDir = path.join(dir, "cron");
testState.cronStorePath = path.join(cronDir, "jobs.json");
await fs.mkdir(cronDir, { recursive: true });
await fs.writeFile(
testState.cronStorePath,
JSON.stringify({ version: 1, jobs: [] }),
);
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -396,9 +358,7 @@ describe("gateway server cron", () => {
const entries = (runsRes.payload as { entries?: unknown } | null)?.entries;
expect(Array.isArray(entries)).toBe(true);
expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId);
expect((entries as Array<{ summary?: unknown }>).at(-1)?.summary).toBe(
"hello",
);
expect((entries as Array<{ summary?: unknown }>).at(-1)?.summary).toBe("hello");
ws.close();
await server.close();
@@ -407,9 +367,7 @@ describe("gateway server cron", () => {
});
test("enables cron scheduler by default and runs due jobs automatically", async () => {
const dir = await fs.mkdtemp(
path.join(os.tmpdir(), "clawdbot-gw-cron-default-on-"),
);
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-default-on-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
testState.cronEnabled = undefined;
@@ -417,10 +375,7 @@ describe("gateway server cron", () => {
await fs.mkdir(path.dirname(testState.cronStorePath), {
recursive: true,
});
await fs.writeFile(
testState.cronStorePath,
JSON.stringify({ version: 1, jobs: [] }),
);
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
@@ -431,10 +386,7 @@ describe("gateway server cron", () => {
| { enabled?: unknown; storePath?: unknown }
| undefined;
expect(statusPayload?.enabled).toBe(true);
const storePath =
typeof statusPayload?.storePath === "string"
? statusPayload.storePath
: "";
const storePath = typeof statusPayload?.storePath === "string" ? statusPayload.storePath : "";
expect(storePath).toContain("jobs.json");
// Keep the job due immediately; we poll run logs instead of relying on
@@ -460,8 +412,7 @@ describe("gateway server cron", () => {
limit: 10,
});
expect(runsRes.ok).toBe(true);
const entries = (runsRes.payload as { entries?: unknown } | null)
?.entries;
const entries = (runsRes.payload as { entries?: unknown } | null)?.entries;
if (Array.isArray(entries) && entries.length > 0) return entries;
await new Promise((r) => setTimeout(r, 20));
}

View File

@@ -3,10 +3,7 @@ import { describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import { emitAgentEvent } from "../infra/agent-events.js";
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-channel.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
connectOk,
getFreePort,
@@ -19,51 +16,35 @@ import {
installGatewayTestHooks();
describe("gateway server health/presence", () => {
test(
"connect + health + presence + status succeed",
{ timeout: 8000 },
async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
test("connect + health + presence + status succeed", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const healthP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "health1",
);
const statusP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "status1",
);
const presenceP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "presence1",
);
const channelsP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "channels1",
);
const healthP = onceMessage(ws, (o) => o.type === "res" && o.id === "health1");
const statusP = onceMessage(ws, (o) => o.type === "res" && o.id === "status1");
const presenceP = onceMessage(ws, (o) => o.type === "res" && o.id === "presence1");
const channelsP = onceMessage(ws, (o) => o.type === "res" && o.id === "channels1");
const sendReq = (id: string, method: string) =>
ws.send(JSON.stringify({ type: "req", id, method }));
sendReq("health1", "health");
sendReq("status1", "status");
sendReq("presence1", "system-presence");
sendReq("channels1", "channels.status");
const sendReq = (id: string, method: string) =>
ws.send(JSON.stringify({ type: "req", id, method }));
sendReq("health1", "health");
sendReq("status1", "status");
sendReq("presence1", "system-presence");
sendReq("channels1", "channels.status");
const health = await healthP;
const status = await statusP;
const presence = await presenceP;
const channels = await channelsP;
expect(health.ok).toBe(true);
expect(status.ok).toBe(true);
expect(presence.ok).toBe(true);
expect(channels.ok).toBe(true);
expect(Array.isArray(presence.payload)).toBe(true);
const health = await healthP;
const status = await statusP;
const presence = await presenceP;
const channels = await channelsP;
expect(health.ok).toBe(true);
expect(status.ok).toBe(true);
expect(presence.ok).toBe(true);
expect(channels.ok).toBe(true);
expect(Array.isArray(presence.payload)).toBe(true);
ws.close();
await server.close();
},
);
ws.close();
await server.close();
});
test("broadcasts heartbeat events and serves last-heartbeat", async () => {
type HeartbeatPayload = {
@@ -106,10 +87,7 @@ describe("gateway server health/presence", () => {
method: "last-heartbeat",
}),
);
const last = await onceMessage<ResFrame>(
ws,
(o) => o.type === "res" && o.id === "hb-last",
);
const last = await onceMessage<ResFrame>(ws, (o) => o.type === "res" && o.id === "hb-last");
expect(last.ok).toBe(true);
const lastPayload = last.payload as HeartbeatPayload | null | undefined;
expect(lastPayload?.status).toBe("sent");
@@ -128,43 +106,34 @@ describe("gateway server health/presence", () => {
(o) => o.type === "res" && o.id === "hb-toggle-off",
);
expect(toggle.ok).toBe(true);
expect((toggle.payload as { enabled?: boolean } | undefined)?.enabled).toBe(
false,
);
expect((toggle.payload as { enabled?: boolean } | undefined)?.enabled).toBe(false);
ws.close();
await server.close();
});
test(
"presence events carry seq + stateVersion",
{ timeout: 8000 },
async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
test("presence events carry seq + stateVersion", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const presenceEventP = onceMessage(
ws,
(o) => o.type === "event" && o.event === "presence",
);
ws.send(
JSON.stringify({
type: "req",
id: "evt-1",
method: "system-event",
params: { text: "note from test" },
}),
);
const presenceEventP = onceMessage(ws, (o) => o.type === "event" && o.event === "presence");
ws.send(
JSON.stringify({
type: "req",
id: "evt-1",
method: "system-event",
params: { text: "note from test" },
}),
);
const evt = await presenceEventP;
expect(typeof evt.seq).toBe("number");
expect(evt.stateVersion?.presence).toBeGreaterThan(0);
expect(Array.isArray(evt.payload?.presence)).toBe(true);
const evt = await presenceEventP;
expect(typeof evt.seq).toBe("number");
expect(evt.stateVersion?.presence).toBeGreaterThan(0);
expect(Array.isArray(evt.payload?.presence)).toBe(true);
ws.close();
await server.close();
},
);
ws.close();
await server.close();
});
test("agent events stream with seq", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
@@ -193,50 +162,42 @@ describe("gateway server health/presence", () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const shutdownP = onceMessage(
ws,
(o) => o.type === "event" && o.event === "shutdown",
5000,
);
const shutdownP = onceMessage(ws, (o) => o.type === "event" && o.event === "shutdown", 5000);
await server.close();
const evt = await shutdownP;
expect(evt.payload?.reason).toBeDefined();
});
test(
"presence broadcast reaches multiple clients",
{ timeout: 8000 },
async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
const mkClient = async () => {
const c = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => c.once("open", resolve));
await connectOk(c);
return c;
};
test("presence broadcast reaches multiple clients", { timeout: 8000 }, async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
const mkClient = async () => {
const c = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => c.once("open", resolve));
await connectOk(c);
return c;
};
const clients = await Promise.all([mkClient(), mkClient(), mkClient()]);
const waits = clients.map((c) =>
onceMessage(c, (o) => o.type === "event" && o.event === "presence"),
);
clients[0].send(
JSON.stringify({
type: "req",
id: "broadcast",
method: "system-event",
params: { text: "fanout" },
}),
);
const events = await Promise.all(waits);
for (const evt of events) {
expect(evt.payload?.presence?.length).toBeGreaterThan(0);
expect(typeof evt.seq).toBe("number");
}
for (const c of clients) c.close();
await server.close();
},
);
const clients = await Promise.all([mkClient(), mkClient(), mkClient()]);
const waits = clients.map((c) =>
onceMessage(c, (o) => o.type === "event" && o.event === "presence"),
);
clients[0].send(
JSON.stringify({
type: "req",
id: "broadcast",
method: "system-event",
params: { text: "fanout" },
}),
);
const events = await Promise.all(waits);
for (const evt of events) {
expect(evt.payload?.presence?.length).toBeGreaterThan(0);
expect(typeof evt.seq).toBe("number");
}
for (const c of clients) c.close();
await server.close();
});
test("presence includes client fingerprint", async () => {
const { server, ws } = await startServerWithClient();
@@ -252,11 +213,7 @@ describe("gateway server health/presence", () => {
},
});
const presenceP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "fingerprint",
4000,
);
const presenceP = onceMessage(ws, (o) => o.type === "res" && o.id === "fingerprint", 4000);
ws.send(
JSON.stringify({
type: "req",
@@ -291,11 +248,7 @@ describe("gateway server health/presence", () => {
},
});
const presenceP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "cli-presence",
4000,
);
const presenceP = onceMessage(ws, (o) => o.type === "res" && o.id === "cli-presence", 4000);
ws.send(
JSON.stringify({
type: "req",

Some files were not shown because too many files have changed in this diff Show More