mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 23:18:28 +00:00
chore: migrate to oxlint and oxfmt
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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, "_");
|
||||
|
||||
@@ -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 }));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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/");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: "; " });
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
loadVoiceWakeConfig,
|
||||
setVoiceWakeTriggers,
|
||||
} from "../infra/voicewake.js";
|
||||
import { loadVoiceWakeConfig, setVoiceWakeTriggers } from "../infra/voicewake.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
formatValidationErrors,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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})`,
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user