mirror of
https://github.com/farcasclaudiu/openclaw.git
synced 2026-06-28 17:01:53 +03:00
chore: Enable "curly" rule to avoid single-statement if confusion/errors.
This commit is contained in:
@@ -20,10 +20,16 @@ export type AssistantIdentity = {
|
||||
};
|
||||
|
||||
function coerceIdentityValue(value: string | undefined, maxLength: number): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (trimmed.length <= maxLength) return trimmed;
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (trimmed.length <= maxLength) {
|
||||
return trimmed;
|
||||
}
|
||||
return trimmed.slice(0, maxLength);
|
||||
}
|
||||
|
||||
@@ -32,17 +38,29 @@ function isAvatarUrl(value: string): boolean {
|
||||
}
|
||||
|
||||
function looksLikeAvatarPath(value: string): boolean {
|
||||
if (/[\\/]/.test(value)) return true;
|
||||
if (/[\\/]/.test(value)) {
|
||||
return true;
|
||||
}
|
||||
return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value);
|
||||
}
|
||||
|
||||
function normalizeAvatarValue(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (isAvatarUrl(trimmed)) return trimmed;
|
||||
if (looksLikeAvatarPath(trimmed)) return trimmed;
|
||||
if (!/\s/.test(trimmed) && trimmed.length <= 4) return trimmed;
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (isAvatarUrl(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
if (looksLikeAvatarPath(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
if (!/\s/.test(trimmed) && trimmed.length <= 4) {
|
||||
return trimmed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
+51
-17
@@ -33,7 +33,9 @@ type TailscaleUser = {
|
||||
type TailscaleWhoisLookup = (ip: string) => Promise<TailscaleWhoisIdentity | null>;
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||
}
|
||||
|
||||
@@ -42,20 +44,34 @@ function normalizeLogin(login: string): string {
|
||||
}
|
||||
|
||||
function isLoopbackAddress(ip: string | undefined): boolean {
|
||||
if (!ip) return false;
|
||||
if (ip === "127.0.0.1") return true;
|
||||
if (ip.startsWith("127.")) return true;
|
||||
if (ip === "::1") return true;
|
||||
if (ip.startsWith("::ffff:127.")) return true;
|
||||
if (!ip) {
|
||||
return false;
|
||||
}
|
||||
if (ip === "127.0.0.1") {
|
||||
return true;
|
||||
}
|
||||
if (ip.startsWith("127.")) {
|
||||
return true;
|
||||
}
|
||||
if (ip === "::1") {
|
||||
return true;
|
||||
}
|
||||
if (ip.startsWith("::ffff:127.")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getHostName(hostHeader?: string): string {
|
||||
const host = (hostHeader ?? "").trim().toLowerCase();
|
||||
if (!host) return "";
|
||||
if (!host) {
|
||||
return "";
|
||||
}
|
||||
if (host.startsWith("[")) {
|
||||
const end = host.indexOf("]");
|
||||
if (end !== -1) return host.slice(1, end);
|
||||
if (end !== -1) {
|
||||
return host.slice(1, end);
|
||||
}
|
||||
}
|
||||
const [name] = host.split(":");
|
||||
return name ?? "";
|
||||
@@ -66,7 +82,9 @@ function headerValue(value: string | string[] | undefined): string | undefined {
|
||||
}
|
||||
|
||||
function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined {
|
||||
if (!req) return undefined;
|
||||
if (!req) {
|
||||
return undefined;
|
||||
}
|
||||
const forwardedFor = headerValue(req.headers?.["x-forwarded-for"]);
|
||||
return forwardedFor ? parseForwardedForClientIp(forwardedFor) : undefined;
|
||||
}
|
||||
@@ -75,7 +93,9 @@ function resolveRequestClientIp(
|
||||
req?: IncomingMessage,
|
||||
trustedProxies?: string[],
|
||||
): string | undefined {
|
||||
if (!req) return undefined;
|
||||
if (!req) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveGatewayClientIp({
|
||||
remoteAddr: req.socket?.remoteAddress ?? "",
|
||||
forwardedFor: headerValue(req.headers?.["x-forwarded-for"]),
|
||||
@@ -85,9 +105,13 @@ function resolveRequestClientIp(
|
||||
}
|
||||
|
||||
export function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: string[]): boolean {
|
||||
if (!req) return false;
|
||||
if (!req) {
|
||||
return false;
|
||||
}
|
||||
const clientIp = resolveRequestClientIp(req, trustedProxies) ?? "";
|
||||
if (!isLoopbackAddress(clientIp)) return false;
|
||||
if (!isLoopbackAddress(clientIp)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const host = getHostName(req.headers?.host);
|
||||
const hostIsLocal = host === "localhost" || host === "127.0.0.1" || host === "::1";
|
||||
@@ -104,9 +128,13 @@ export function isLocalDirectRequest(req?: IncomingMessage, trustedProxies?: str
|
||||
}
|
||||
|
||||
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
||||
if (!req) return null;
|
||||
if (!req) {
|
||||
return null;
|
||||
}
|
||||
const login = req.headers["tailscale-user-login"];
|
||||
if (typeof login !== "string" || !login.trim()) return 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();
|
||||
@@ -118,7 +146,9 @@ function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
||||
}
|
||||
|
||||
function hasTailscaleProxyHeaders(req?: IncomingMessage): boolean {
|
||||
if (!req) return false;
|
||||
if (!req) {
|
||||
return false;
|
||||
}
|
||||
return Boolean(
|
||||
req.headers["x-forwarded-for"] &&
|
||||
req.headers["x-forwarded-proto"] &&
|
||||
@@ -127,7 +157,9 @@ function hasTailscaleProxyHeaders(req?: IncomingMessage): boolean {
|
||||
}
|
||||
|
||||
function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
|
||||
if (!req) return false;
|
||||
if (!req) {
|
||||
return false;
|
||||
}
|
||||
return isLoopbackAddress(req.socket?.remoteAddress) && hasTailscaleProxyHeaders(req);
|
||||
}
|
||||
|
||||
@@ -191,7 +223,9 @@ export function resolveGatewayAuth(params: {
|
||||
|
||||
export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
|
||||
if (auth.mode === "token" && !auth.token) {
|
||||
if (auth.allowTailscale) return;
|
||||
if (auth.allowTailscale) {
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
"gateway auth mode is token, but no token was configured (set gateway.auth.token or OPENCLAW_GATEWAY_TOKEN)",
|
||||
);
|
||||
|
||||
+6
-2
@@ -38,11 +38,15 @@ async function loadBootFile(
|
||||
try {
|
||||
const content = await fs.readFile(bootPath, "utf-8");
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) return { status: "empty" };
|
||||
if (!trimmed) {
|
||||
return { status: "empty" };
|
||||
}
|
||||
return { status: "ok", content: trimmed };
|
||||
} catch (err) {
|
||||
const anyErr = err as { code?: string };
|
||||
if (anyErr.code === "ENOENT") return { status: "missing" };
|
||||
if (anyErr.code === "ENOENT") {
|
||||
return { status: "missing" };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,12 @@ vi.mock("../infra/tailnet.js", () => ({
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
describeGatewayCloseCode: (code: number) => {
|
||||
if (code === 1000) return "normal closure";
|
||||
if (code === 1006) return "abnormal closure (no close frame)";
|
||||
if (code === 1000) {
|
||||
return "normal closure";
|
||||
}
|
||||
if (code === 1006) {
|
||||
return "abnormal closure (no close frame)";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
GatewayClient: class {
|
||||
|
||||
+11
-4
@@ -190,11 +190,16 @@ export async function callGateway<T = unknown>(opts: CallGatewayOptions): Promis
|
||||
let settled = false;
|
||||
let ignoreClose = false;
|
||||
const stop = (err?: Error, value?: T) => {
|
||||
if (settled) return;
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (err) reject(err);
|
||||
else resolve(value as T);
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(value as T);
|
||||
}
|
||||
};
|
||||
|
||||
const client = new GatewayClient({
|
||||
@@ -228,7 +233,9 @@ export async function callGateway<T = unknown>(opts: CallGatewayOptions): Promis
|
||||
}
|
||||
},
|
||||
onClose: (code, reason) => {
|
||||
if (settled || ignoreClose) return;
|
||||
if (settled || ignoreClose) {
|
||||
return;
|
||||
}
|
||||
ignoreClose = true;
|
||||
client.stop();
|
||||
stop(new Error(formatCloseError(code, reason)));
|
||||
|
||||
@@ -10,7 +10,9 @@ export type ChatAbortControllerEntry = {
|
||||
|
||||
export function isChatStopCommandText(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
return trimmed.toLowerCase() === "/stop" || isAbortTrigger(trimmed);
|
||||
}
|
||||
|
||||
@@ -74,8 +76,12 @@ export function abortChatRunById(
|
||||
): { aborted: boolean } {
|
||||
const { runId, sessionKey, stopReason } = params;
|
||||
const active = ops.chatAbortControllers.get(runId);
|
||||
if (!active) return { aborted: false };
|
||||
if (active.sessionKey !== sessionKey) return { aborted: false };
|
||||
if (!active) {
|
||||
return { aborted: false };
|
||||
}
|
||||
if (active.sessionKey !== sessionKey) {
|
||||
return { aborted: false };
|
||||
}
|
||||
|
||||
ops.chatAbortedRuns.set(runId, Date.now());
|
||||
active.controller.abort();
|
||||
@@ -97,9 +103,13 @@ export function abortChatRunsForSessionKey(
|
||||
const { sessionKey, stopReason } = params;
|
||||
const runIds: string[] = [];
|
||||
for (const [runId, active] of ops.chatAbortControllers) {
|
||||
if (active.sessionKey !== sessionKey) continue;
|
||||
if (active.sessionKey !== sessionKey) {
|
||||
continue;
|
||||
}
|
||||
const res = abortChatRunById(ops, { runId, sessionKey, stopReason });
|
||||
if (res.aborted) runIds.push(runId);
|
||||
if (res.aborted) {
|
||||
runIds.push(runId);
|
||||
}
|
||||
}
|
||||
return { aborted: runIds.length > 0, runIds };
|
||||
}
|
||||
|
||||
@@ -23,18 +23,24 @@ type AttachmentLog = {
|
||||
};
|
||||
|
||||
function normalizeMime(mime?: string): string | undefined {
|
||||
if (!mime) return undefined;
|
||||
if (!mime) {
|
||||
return undefined;
|
||||
}
|
||||
const cleaned = mime.split(";")[0]?.trim().toLowerCase();
|
||||
return cleaned || undefined;
|
||||
}
|
||||
|
||||
async function sniffMimeFromBase64(base64: string): Promise<string | undefined> {
|
||||
const trimmed = base64.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const take = Math.min(256, trimmed.length);
|
||||
const sliceLen = take - (take % 4);
|
||||
if (sliceLen < 8) return undefined;
|
||||
if (sliceLen < 8) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const head = Buffer.from(trimmed.slice(0, sliceLen), "base64");
|
||||
@@ -67,7 +73,9 @@ export async function parseMessageWithAttachments(
|
||||
const images: ChatImageContent[] = [];
|
||||
|
||||
for (const [idx, att] of attachments.entries()) {
|
||||
if (!att) continue;
|
||||
if (!att) {
|
||||
continue;
|
||||
}
|
||||
const mime = att.mimeType ?? "";
|
||||
const content = att.content;
|
||||
const label = att.fileName || att.type || `attachment-${idx + 1}`;
|
||||
@@ -132,12 +140,16 @@ export function buildMessageWithAttachments(
|
||||
opts?: { maxBytes?: number },
|
||||
): string {
|
||||
const maxBytes = opts?.maxBytes ?? 2_000_000; // 2 MB
|
||||
if (!attachments || attachments.length === 0) return message;
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return message;
|
||||
}
|
||||
|
||||
const blocks: string[] = [];
|
||||
|
||||
for (const [idx, att] of attachments.entries()) {
|
||||
if (!att) continue;
|
||||
if (!att) {
|
||||
continue;
|
||||
}
|
||||
const mime = att.mimeType ?? "";
|
||||
const content = att.content;
|
||||
const label = att.fileName || att.type || `attachment-${idx + 1}`;
|
||||
@@ -169,7 +181,9 @@ export function buildMessageWithAttachments(
|
||||
blocks.push(dataUrl);
|
||||
}
|
||||
|
||||
if (blocks.length === 0) return message;
|
||||
if (blocks.length === 0) {
|
||||
return message;
|
||||
}
|
||||
const separator = message.trim().length > 0 ? "\n\n" : "";
|
||||
return `${message}${separator}${blocks.join("\n\n")}`;
|
||||
}
|
||||
|
||||
@@ -18,21 +18,31 @@ const ENVELOPE_CHANNELS = [
|
||||
const MESSAGE_ID_LINE = /^\s*\[message_id:\s*[^\]]+\]\s*$/i;
|
||||
|
||||
function looksLikeEnvelopeHeader(header: string): boolean {
|
||||
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) return true;
|
||||
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) return true;
|
||||
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}Z\b/.test(header)) {
|
||||
return true;
|
||||
}
|
||||
if (/\d{4}-\d{2}-\d{2} \d{2}:\d{2}\b/.test(header)) {
|
||||
return true;
|
||||
}
|
||||
return ENVELOPE_CHANNELS.some((label) => header.startsWith(`${label} `));
|
||||
}
|
||||
|
||||
export function stripEnvelope(text: string): string {
|
||||
const match = text.match(ENVELOPE_PREFIX);
|
||||
if (!match) return text;
|
||||
if (!match) {
|
||||
return text;
|
||||
}
|
||||
const header = match[1] ?? "";
|
||||
if (!looksLikeEnvelopeHeader(header)) return text;
|
||||
if (!looksLikeEnvelopeHeader(header)) {
|
||||
return text;
|
||||
}
|
||||
return text.slice(match[0].length);
|
||||
}
|
||||
|
||||
function stripMessageIdHints(text: string): string {
|
||||
if (!text.includes("[message_id:")) return text;
|
||||
if (!text.includes("[message_id:")) {
|
||||
return text;
|
||||
}
|
||||
const lines = text.split(/\r?\n/);
|
||||
const filtered = lines.filter((line) => !MESSAGE_ID_LINE.test(line));
|
||||
return filtered.length === lines.length ? text : filtered.join("\n");
|
||||
@@ -41,11 +51,17 @@ function stripMessageIdHints(text: string): string {
|
||||
function stripEnvelopeFromContent(content: unknown[]): { content: unknown[]; changed: boolean } {
|
||||
let changed = false;
|
||||
const next = content.map((item) => {
|
||||
if (!item || typeof item !== "object") return item;
|
||||
if (!item || typeof item !== "object") {
|
||||
return item;
|
||||
}
|
||||
const entry = item as Record<string, unknown>;
|
||||
if (entry.type !== "text" || typeof entry.text !== "string") return item;
|
||||
if (entry.type !== "text" || typeof entry.text !== "string") {
|
||||
return item;
|
||||
}
|
||||
const stripped = stripMessageIdHints(stripEnvelope(entry.text));
|
||||
if (stripped === entry.text) return item;
|
||||
if (stripped === entry.text) {
|
||||
return item;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...entry,
|
||||
@@ -56,10 +72,14 @@ function stripEnvelopeFromContent(content: unknown[]): { content: unknown[]; cha
|
||||
}
|
||||
|
||||
export function stripEnvelopeFromMessage(message: unknown): unknown {
|
||||
if (!message || typeof message !== "object") return message;
|
||||
if (!message || typeof message !== "object") {
|
||||
return message;
|
||||
}
|
||||
const entry = message as Record<string, unknown>;
|
||||
const role = typeof entry.role === "string" ? entry.role.toLowerCase() : "";
|
||||
if (role !== "user") return message;
|
||||
if (role !== "user") {
|
||||
return message;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const next: Record<string, unknown> = { ...entry };
|
||||
@@ -88,11 +108,15 @@ export function stripEnvelopeFromMessage(message: unknown): unknown {
|
||||
}
|
||||
|
||||
export function stripEnvelopeFromMessages(messages: unknown[]): unknown[] {
|
||||
if (messages.length === 0) return messages;
|
||||
if (messages.length === 0) {
|
||||
return messages;
|
||||
}
|
||||
let changed = false;
|
||||
const next = messages.map((message) => {
|
||||
const stripped = stripEnvelopeFromMessage(message);
|
||||
if (stripped !== message) changed = true;
|
||||
if (stripped !== message) {
|
||||
changed = true;
|
||||
}
|
||||
return stripped;
|
||||
});
|
||||
return changed ? next : messages;
|
||||
|
||||
@@ -146,7 +146,9 @@ r1USnb+wUdA7Zoj/mQ==
|
||||
const error = await new Promise<Error>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (err: Error) => {
|
||||
if (settled) return;
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(err);
|
||||
};
|
||||
|
||||
+49
-17
@@ -99,7 +99,9 @@ export class GatewayClient {
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.closed) return;
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
const url = this.opts.url ?? "ws://127.0.0.1:18789";
|
||||
if (this.opts.tlsFingerprint && !url.startsWith("wss://")) {
|
||||
this.opts.onConnectError?.(new Error("gateway tls fingerprint requires wss:// gateway url"));
|
||||
@@ -173,7 +175,9 @@ export class GatewayClient {
|
||||
}
|
||||
|
||||
private sendConnect() {
|
||||
if (this.connectSent) return;
|
||||
if (this.connectSent) {
|
||||
return;
|
||||
}
|
||||
this.connectSent = true;
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer);
|
||||
@@ -196,7 +200,9 @@ export class GatewayClient {
|
||||
const nonce = this.connectNonce ?? undefined;
|
||||
const scopes = this.opts.scopes ?? ["operator.admin"];
|
||||
const device = (() => {
|
||||
if (!this.opts.deviceIdentity) return undefined;
|
||||
if (!this.opts.deviceIdentity) {
|
||||
return undefined;
|
||||
}
|
||||
const payload = buildDeviceAuthPayload({
|
||||
deviceId: this.opts.deviceIdentity.deviceId,
|
||||
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
|
||||
@@ -269,8 +275,11 @@ export class GatewayClient {
|
||||
}
|
||||
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);
|
||||
if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE) {
|
||||
logDebug(msg);
|
||||
} else {
|
||||
logError(msg);
|
||||
}
|
||||
this.ws?.close(1008, "connect failed");
|
||||
});
|
||||
}
|
||||
@@ -304,7 +313,9 @@ export class GatewayClient {
|
||||
}
|
||||
if (validateResponseFrame(parsed)) {
|
||||
const pending = this.pending.get(parsed.id);
|
||||
if (!pending) return;
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
// If the payload is an ack with status accepted, keep waiting for final.
|
||||
const payload = parsed.payload as { status?: unknown } | undefined;
|
||||
const status = payload?.status;
|
||||
@@ -312,8 +323,11 @@ export class GatewayClient {
|
||||
return;
|
||||
}
|
||||
this.pending.delete(parsed.id);
|
||||
if (parsed.ok) pending.resolve(parsed.payload);
|
||||
else pending.reject(new Error(parsed.error?.message ?? "unknown error"));
|
||||
if (parsed.ok) {
|
||||
pending.resolve(parsed.payload);
|
||||
} else {
|
||||
pending.reject(new Error(parsed.error?.message ?? "unknown error"));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logDebug(`gateway client parse error: ${String(err)}`);
|
||||
@@ -323,14 +337,18 @@ export class GatewayClient {
|
||||
private queueConnect() {
|
||||
this.connectNonce = null;
|
||||
this.connectSent = false;
|
||||
if (this.connectTimer) clearTimeout(this.connectTimer);
|
||||
if (this.connectTimer) {
|
||||
clearTimeout(this.connectTimer);
|
||||
}
|
||||
this.connectTimer = setTimeout(() => {
|
||||
this.sendConnect();
|
||||
}, 750);
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.closed) return;
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
if (this.tickTimer) {
|
||||
clearInterval(this.tickTimer);
|
||||
this.tickTimer = null;
|
||||
@@ -348,11 +366,17 @@ export class GatewayClient {
|
||||
}
|
||||
|
||||
private startTickWatch() {
|
||||
if (this.tickTimer) clearInterval(this.tickTimer);
|
||||
if (this.tickTimer) {
|
||||
clearInterval(this.tickTimer);
|
||||
}
|
||||
const interval = Math.max(this.tickIntervalMs, 1000);
|
||||
this.tickTimer = setInterval(() => {
|
||||
if (this.closed) return;
|
||||
if (!this.lastTick) return;
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
if (!this.lastTick) {
|
||||
return;
|
||||
}
|
||||
const gap = Date.now() - this.lastTick;
|
||||
if (gap > this.tickIntervalMs * 2) {
|
||||
this.ws?.close(4000, "tick timeout");
|
||||
@@ -361,9 +385,13 @@ export class GatewayClient {
|
||||
}
|
||||
|
||||
private validateTlsFingerprint(): Error | null {
|
||||
if (!this.opts.tlsFingerprint || !this.ws) return null;
|
||||
if (!this.opts.tlsFingerprint || !this.ws) {
|
||||
return null;
|
||||
}
|
||||
const expected = normalizeFingerprint(this.opts.tlsFingerprint);
|
||||
if (!expected) return new Error("gateway tls fingerprint missing");
|
||||
if (!expected) {
|
||||
return new Error("gateway tls fingerprint missing");
|
||||
}
|
||||
const socket = (
|
||||
this.ws as WebSocket & {
|
||||
_socket?: { getPeerCertificate?: () => { fingerprint256?: string } };
|
||||
@@ -374,8 +402,12 @@ export class GatewayClient {
|
||||
}
|
||||
const cert = socket.getPeerCertificate();
|
||||
const fingerprint = normalizeFingerprint(cert?.fingerprint256 ?? "");
|
||||
if (!fingerprint) return new Error("gateway tls fingerprint unavailable");
|
||||
if (fingerprint !== expected) return new Error("gateway tls fingerprint mismatch");
|
||||
if (!fingerprint) {
|
||||
return new Error("gateway tls fingerprint unavailable");
|
||||
}
|
||||
if (fingerprint !== expected) {
|
||||
return new Error("gateway tls fingerprint mismatch");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -93,7 +93,9 @@ function listReloadRules(): ReloadRule[] {
|
||||
cachedReloadRules = null;
|
||||
cachedRegistry = registry;
|
||||
}
|
||||
if (cachedReloadRules) return cachedReloadRules;
|
||||
if (cachedReloadRules) {
|
||||
return cachedReloadRules;
|
||||
}
|
||||
// Channel docking: plugins contribute hot reload/no-op prefixes here.
|
||||
const channelReloadRules: ReloadRule[] = listChannelPlugins().flatMap((plugin) => [
|
||||
...(plugin.reload?.configPrefixes ?? []).map(
|
||||
@@ -117,7 +119,9 @@ function listReloadRules(): ReloadRule[] {
|
||||
|
||||
function matchRule(path: string): ReloadRule | null {
|
||||
for (const rule of listReloadRules()) {
|
||||
if (path === rule.prefix || path.startsWith(`${rule.prefix}.`)) return rule;
|
||||
if (path === rule.prefix || path.startsWith(`${rule.prefix}.`)) {
|
||||
return rule;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -132,14 +136,18 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
export function diffConfigPaths(prev: unknown, next: unknown, prefix = ""): string[] {
|
||||
if (prev === next) return [];
|
||||
if (prev === next) {
|
||||
return [];
|
||||
}
|
||||
if (isPlainObject(prev) && isPlainObject(next)) {
|
||||
const keys = new Set([...Object.keys(prev), ...Object.keys(next)]);
|
||||
const paths: string[] = [];
|
||||
for (const key of keys) {
|
||||
const prevValue = prev[key];
|
||||
const nextValue = next[key];
|
||||
if (prevValue === undefined && nextValue === undefined) continue;
|
||||
if (prevValue === undefined && nextValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
const childPrefix = prefix ? `${prefix}.${key}` : key;
|
||||
const childPaths = diffConfigPaths(prevValue, nextValue, childPrefix);
|
||||
if (childPaths.length > 0) {
|
||||
@@ -266,8 +274,12 @@ export function startGatewayConfigReloader(opts: {
|
||||
let restartQueued = false;
|
||||
|
||||
const schedule = () => {
|
||||
if (stopped) return;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
const wait = settings.debounceMs;
|
||||
debounceTimer = setTimeout(() => {
|
||||
void runReload();
|
||||
@@ -275,7 +287,9 @@ export function startGatewayConfigReloader(opts: {
|
||||
};
|
||||
|
||||
const runReload = async () => {
|
||||
if (stopped) return;
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
if (running) {
|
||||
pending = true;
|
||||
return;
|
||||
@@ -296,7 +310,9 @@ export function startGatewayConfigReloader(opts: {
|
||||
const changedPaths = diffConfigPaths(currentConfig, nextConfig);
|
||||
currentConfig = nextConfig;
|
||||
settings = resolveGatewayReloadSettings(nextConfig);
|
||||
if (changedPaths.length === 0) return;
|
||||
if (changedPaths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
opts.log.info(`config change detected; evaluating reload (${changedPaths.join(", ")})`);
|
||||
const plan = buildGatewayReloadPlan(changedPaths);
|
||||
@@ -350,7 +366,9 @@ export function startGatewayConfigReloader(opts: {
|
||||
watcher.on("unlink", schedule);
|
||||
let watcherClosed = false;
|
||||
watcher.on("error", (err) => {
|
||||
if (watcherClosed) return;
|
||||
if (watcherClosed) {
|
||||
return;
|
||||
}
|
||||
watcherClosed = true;
|
||||
opts.log.warn(`config watcher error: ${String(err)}`);
|
||||
void watcher.close().catch(() => {});
|
||||
@@ -359,7 +377,9 @@ export function startGatewayConfigReloader(opts: {
|
||||
return {
|
||||
stop: async () => {
|
||||
stopped = true;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
debounceTimer = null;
|
||||
watcherClosed = true;
|
||||
await watcher.close().catch(() => {});
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
const CONTROL_UI_AVATAR_PREFIX = "/avatar";
|
||||
|
||||
export function normalizeControlUiBasePath(basePath?: string): string {
|
||||
if (!basePath) return "";
|
||||
if (!basePath) {
|
||||
return "";
|
||||
}
|
||||
let normalized = basePath.trim();
|
||||
if (!normalized) return "";
|
||||
if (!normalized.startsWith("/")) normalized = `/${normalized}`;
|
||||
if (normalized === "/") return "";
|
||||
if (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
if (!normalized.startsWith("/")) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
if (normalized === "/") {
|
||||
return "";
|
||||
}
|
||||
if (normalized.endsWith("/")) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -17,7 +27,9 @@ export function buildControlUiAvatarUrl(basePath: string, agentId: string): stri
|
||||
}
|
||||
|
||||
function looksLikeLocalAvatarPath(value: string): boolean {
|
||||
if (/[\\/]/.test(value)) return true;
|
||||
if (/[\\/]/.test(value)) {
|
||||
return true;
|
||||
}
|
||||
return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value);
|
||||
}
|
||||
|
||||
@@ -27,8 +39,12 @@ export function resolveAssistantAvatarUrl(params: {
|
||||
basePath?: string;
|
||||
}): string | undefined {
|
||||
const avatar = params.avatar?.trim();
|
||||
if (!avatar) return undefined;
|
||||
if (/^https?:\/\//i.test(avatar) || /^data:image\//i.test(avatar)) return avatar;
|
||||
if (!avatar) {
|
||||
return undefined;
|
||||
}
|
||||
if (/^https?:\/\//i.test(avatar) || /^data:image\//i.test(avatar)) {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
const basePath = normalizeControlUiBasePath(params.basePath);
|
||||
const baseAvatarPrefix = basePath
|
||||
@@ -37,9 +53,13 @@ export function resolveAssistantAvatarUrl(params: {
|
||||
if (basePath && avatar.startsWith(`${CONTROL_UI_AVATAR_PREFIX}/`)) {
|
||||
return `${basePath}${avatar}`;
|
||||
}
|
||||
if (avatar.startsWith(baseAvatarPrefix)) return avatar;
|
||||
if (avatar.startsWith(baseAvatarPrefix)) {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
if (!params.agentId) return avatar;
|
||||
if (!params.agentId) {
|
||||
return avatar;
|
||||
}
|
||||
if (looksLikeLocalAvatarPath(avatar)) {
|
||||
return buildControlUiAvatarUrl(basePath, params.agentId);
|
||||
}
|
||||
|
||||
+36
-12
@@ -40,7 +40,9 @@ function resolveControlUiRoot(): string | null {
|
||||
path.resolve(process.cwd(), "dist", "control-ui"),
|
||||
].filter((dir): dir is string => Boolean(dir));
|
||||
for (const dir of candidates) {
|
||||
if (fs.existsSync(path.join(dir, "index.html"))) return dir;
|
||||
if (fs.existsSync(path.join(dir, "index.html"))) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -103,8 +105,12 @@ export function handleControlUiAvatarRequest(
|
||||
opts: { basePath?: string; resolveAvatar: (agentId: string) => ControlUiAvatarResolution },
|
||||
): boolean {
|
||||
const urlRaw = req.url;
|
||||
if (!urlRaw) return false;
|
||||
if (req.method !== "GET" && req.method !== "HEAD") return false;
|
||||
if (!urlRaw) {
|
||||
return false;
|
||||
}
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = new URL(urlRaw, "http://localhost");
|
||||
const basePath = normalizeControlUiBasePath(opts.basePath);
|
||||
@@ -112,7 +118,9 @@ export function handleControlUiAvatarRequest(
|
||||
const pathWithBase = basePath
|
||||
? `${basePath}${CONTROL_UI_AVATAR_PREFIX}/`
|
||||
: `${CONTROL_UI_AVATAR_PREFIX}/`;
|
||||
if (!pathname.startsWith(pathWithBase)) return false;
|
||||
if (!pathname.startsWith(pathWithBase)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const agentIdParts = pathname.slice(pathWithBase.length).split("/").filter(Boolean);
|
||||
const agentId = agentIdParts[0] ?? "";
|
||||
@@ -185,7 +193,9 @@ function injectControlUiConfig(html: string, opts: ControlUiInjectionOpts): stri
|
||||
)};` +
|
||||
`</script>`;
|
||||
// Check if already injected
|
||||
if (html.includes("__OPENCLAW_ASSISTANT_NAME__")) return html;
|
||||
if (html.includes("__OPENCLAW_ASSISTANT_NAME__")) {
|
||||
return html;
|
||||
}
|
||||
const headClose = html.indexOf("</head>");
|
||||
if (headClose !== -1) {
|
||||
return `${html.slice(0, headClose)}${script}${html.slice(headClose)}`;
|
||||
@@ -227,10 +237,16 @@ function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndex
|
||||
}
|
||||
|
||||
function isSafeRelativePath(relPath: string) {
|
||||
if (!relPath) return false;
|
||||
if (!relPath) {
|
||||
return false;
|
||||
}
|
||||
const normalized = path.posix.normalize(relPath);
|
||||
if (normalized.startsWith("../") || normalized === "..") return false;
|
||||
if (normalized.includes("\0")) return false;
|
||||
if (normalized.startsWith("../") || normalized === "..") {
|
||||
return false;
|
||||
}
|
||||
if (normalized.includes("\0")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -240,7 +256,9 @@ export function handleControlUiHttpRequest(
|
||||
opts?: ControlUiRequestOptions,
|
||||
): boolean {
|
||||
const urlRaw = req.url;
|
||||
if (!urlRaw) return false;
|
||||
if (!urlRaw) {
|
||||
return false;
|
||||
}
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
@@ -266,7 +284,9 @@ export function handleControlUiHttpRequest(
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
if (!pathname.startsWith(`${basePath}/`)) return false;
|
||||
if (!pathname.startsWith(`${basePath}/`)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const root = resolveControlUiRoot();
|
||||
@@ -282,9 +302,13 @@ export function handleControlUiHttpRequest(
|
||||
const uiPath =
|
||||
basePath && pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) : pathname;
|
||||
const rel = (() => {
|
||||
if (uiPath === ROOT_PREFIX) return "";
|
||||
if (uiPath === ROOT_PREFIX) {
|
||||
return "";
|
||||
}
|
||||
const assetsIndex = uiPath.indexOf("/assets/");
|
||||
if (assetsIndex >= 0) return uiPath.slice(assetsIndex + 1);
|
||||
if (assetsIndex >= 0) {
|
||||
return uiPath.slice(assetsIndex + 1);
|
||||
}
|
||||
return uiPath.slice(1);
|
||||
})();
|
||||
const requested = rel && !rel.endsWith("/") ? rel : `${rel}index.html`;
|
||||
|
||||
@@ -64,7 +64,9 @@ export class ExecApprovalManager {
|
||||
|
||||
resolve(recordId: string, decision: ExecApprovalDecision, resolvedBy?: string | null): boolean {
|
||||
const pending = this.pending.get(recordId);
|
||||
if (!pending) return false;
|
||||
if (!pending) {
|
||||
return false;
|
||||
}
|
||||
clearTimeout(pending.timer);
|
||||
pending.record.resolvedAtMs = Date.now();
|
||||
pending.record.decision = decision;
|
||||
|
||||
@@ -45,11 +45,17 @@ function randomImageProbeCode(len = 6): string {
|
||||
}
|
||||
|
||||
function editDistance(a: string, b: string): number {
|
||||
if (a === b) return 0;
|
||||
if (a === b) {
|
||||
return 0;
|
||||
}
|
||||
const aLen = a.length;
|
||||
const bLen = b.length;
|
||||
if (aLen === 0) return bLen;
|
||||
if (bLen === 0) return aLen;
|
||||
if (aLen === 0) {
|
||||
return bLen;
|
||||
}
|
||||
if (bLen === 0) {
|
||||
return aLen;
|
||||
}
|
||||
|
||||
let prev = Array.from({ length: bLen + 1 }, (_v, idx) => idx);
|
||||
let curr = Array.from({ length: bLen + 1 }, () => 0);
|
||||
@@ -82,7 +88,9 @@ function extractPayloadText(result: unknown): string {
|
||||
|
||||
function parseJsonStringArray(name: string, raw?: string): string[] | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!Array.isArray(parsed) || !parsed.every((entry) => typeof entry === "string")) {
|
||||
throw new Error(`${name} must be a JSON array of strings.`);
|
||||
@@ -92,8 +100,12 @@ function parseJsonStringArray(name: string, raw?: string): string[] | undefined
|
||||
|
||||
function parseImageMode(raw?: string): "list" | "repeat" | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (trimmed === "list" || trimmed === "repeat") return trimmed;
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (trimmed === "list" || trimmed === "repeat") {
|
||||
return trimmed;
|
||||
}
|
||||
throw new Error("OPENCLAW_LIVE_CLI_BACKEND_IMAGE_MODE must be 'list' or 'repeat'.");
|
||||
}
|
||||
|
||||
@@ -121,15 +133,20 @@ async function getFreePort(): Promise<number> {
|
||||
}
|
||||
const port = addr.port;
|
||||
srv.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve(port);
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(port);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function isPortFree(port: number): Promise<boolean> {
|
||||
if (!Number.isFinite(port) || port <= 0 || port > 65535) return false;
|
||||
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
||||
return false;
|
||||
}
|
||||
return await new Promise((resolve) => {
|
||||
const srv = createServer();
|
||||
srv.once("error", () => resolve(false));
|
||||
@@ -146,7 +163,9 @@ async function getFreeGatewayPort(): Promise<number> {
|
||||
const ok = (await Promise.all(candidates.map((candidate) => isPortFree(candidate)))).every(
|
||||
Boolean,
|
||||
);
|
||||
if (ok) return port;
|
||||
if (ok) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
throw new Error("failed to acquire a free gateway port block");
|
||||
}
|
||||
@@ -155,11 +174,16 @@ async function connectClient(params: { url: string; token: string }) {
|
||||
return await new Promise<GatewayClient>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const stop = (err?: Error, client?: GatewayClient) => {
|
||||
if (settled) return;
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (err) reject(err);
|
||||
else resolve(client as GatewayClient);
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(client as GatewayClient);
|
||||
}
|
||||
};
|
||||
const client = new GatewayClient({
|
||||
url: params.url,
|
||||
@@ -388,7 +412,9 @@ describeLive("gateway live (cli backend)", () => {
|
||||
}
|
||||
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);
|
||||
if (!(bestDistance <= 5)) {
|
||||
@@ -399,22 +425,46 @@ describeLive("gateway live (cli backend)", () => {
|
||||
client.stop();
|
||||
await server.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
if (previous.configPath === undefined) delete process.env.OPENCLAW_CONFIG_PATH;
|
||||
else process.env.OPENCLAW_CONFIG_PATH = previous.configPath;
|
||||
if (previous.token === undefined) delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
else process.env.OPENCLAW_GATEWAY_TOKEN = previous.token;
|
||||
if (previous.skipChannels === undefined) delete process.env.OPENCLAW_SKIP_CHANNELS;
|
||||
else process.env.OPENCLAW_SKIP_CHANNELS = previous.skipChannels;
|
||||
if (previous.skipGmail === undefined) delete process.env.OPENCLAW_SKIP_GMAIL_WATCHER;
|
||||
else process.env.OPENCLAW_SKIP_GMAIL_WATCHER = previous.skipGmail;
|
||||
if (previous.skipCron === undefined) delete process.env.OPENCLAW_SKIP_CRON;
|
||||
else process.env.OPENCLAW_SKIP_CRON = previous.skipCron;
|
||||
if (previous.skipCanvas === undefined) delete process.env.OPENCLAW_SKIP_CANVAS_HOST;
|
||||
else process.env.OPENCLAW_SKIP_CANVAS_HOST = previous.skipCanvas;
|
||||
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;
|
||||
else process.env.ANTHROPIC_API_KEY_OLD = previous.anthropicApiKeyOld;
|
||||
if (previous.configPath === undefined) {
|
||||
delete process.env.OPENCLAW_CONFIG_PATH;
|
||||
} else {
|
||||
process.env.OPENCLAW_CONFIG_PATH = previous.configPath;
|
||||
}
|
||||
if (previous.token === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = previous.token;
|
||||
}
|
||||
if (previous.skipChannels === undefined) {
|
||||
delete process.env.OPENCLAW_SKIP_CHANNELS;
|
||||
} else {
|
||||
process.env.OPENCLAW_SKIP_CHANNELS = previous.skipChannels;
|
||||
}
|
||||
if (previous.skipGmail === undefined) {
|
||||
delete process.env.OPENCLAW_SKIP_GMAIL_WATCHER;
|
||||
} else {
|
||||
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = previous.skipGmail;
|
||||
}
|
||||
if (previous.skipCron === undefined) {
|
||||
delete process.env.OPENCLAW_SKIP_CRON;
|
||||
} else {
|
||||
process.env.OPENCLAW_SKIP_CRON = previous.skipCron;
|
||||
}
|
||||
if (previous.skipCanvas === undefined) {
|
||||
delete process.env.OPENCLAW_SKIP_CANVAS_HOST;
|
||||
} else {
|
||||
process.env.OPENCLAW_SKIP_CANVAS_HOST = previous.skipCanvas;
|
||||
}
|
||||
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;
|
||||
} else {
|
||||
process.env.ANTHROPIC_API_KEY_OLD = previous.anthropicApiKeyOld;
|
||||
}
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
@@ -44,7 +44,9 @@ const describeLive = LIVE || GATEWAY_LIVE ? describe : describe.skip;
|
||||
|
||||
function parseFilter(raw?: string): Set<string> | null {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed || trimmed === "all") return null;
|
||||
if (!trimmed || trimmed === "all") {
|
||||
return null;
|
||||
}
|
||||
const ids = trimmed
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
@@ -62,7 +64,9 @@ function assertNoReasoningTags(params: {
|
||||
phase: string;
|
||||
label: string;
|
||||
}): void {
|
||||
if (!params.text) return;
|
||||
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;
|
||||
throw new Error(
|
||||
@@ -81,22 +85,40 @@ function extractPayloadText(result: unknown): string {
|
||||
}
|
||||
|
||||
function isMeaningful(text: string): boolean {
|
||||
if (!text) return false;
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.toLowerCase() === "ok") return false;
|
||||
if (trimmed.length < 60) return false;
|
||||
if (trimmed.toLowerCase() === "ok") {
|
||||
return false;
|
||||
}
|
||||
if (trimmed.length < 60) {
|
||||
return false;
|
||||
}
|
||||
const words = trimmed.split(/\s+/g).filter(Boolean);
|
||||
if (words.length < 12) return false;
|
||||
if (words.length < 12) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isGoogleModelNotFoundText(text: string): boolean {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return false;
|
||||
if (!/not found/i.test(trimmed)) return false;
|
||||
if (/models\/.+ is not found for api version/i.test(trimmed)) return true;
|
||||
if (/"status"\s*:\s*"NOT_FOUND"/.test(trimmed)) return true;
|
||||
if (/"code"\s*:\s*404/.test(trimmed)) return true;
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (!/not found/i.test(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
if (/models\/.+ is not found for api version/i.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (/"status"\s*:\s*"NOT_FOUND"/.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
if (/"code"\s*:\s*404/.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -124,7 +146,9 @@ function isOpenAIReasoningSequenceError(error: string): boolean {
|
||||
|
||||
function isToolNonceRefusal(error: string): boolean {
|
||||
const msg = error.toLowerCase();
|
||||
if (!msg.includes("nonce")) return false;
|
||||
if (!msg.includes("nonce")) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
msg.includes("token") ||
|
||||
msg.includes("secret") ||
|
||||
@@ -226,11 +250,17 @@ function randomImageProbeCode(len = 6): string {
|
||||
}
|
||||
|
||||
function editDistance(a: string, b: string): number {
|
||||
if (a === b) return 0;
|
||||
if (a === b) {
|
||||
return 0;
|
||||
}
|
||||
const aLen = a.length;
|
||||
const bLen = b.length;
|
||||
if (aLen === 0) return bLen;
|
||||
if (bLen === 0) return aLen;
|
||||
if (aLen === 0) {
|
||||
return bLen;
|
||||
}
|
||||
if (bLen === 0) {
|
||||
return aLen;
|
||||
}
|
||||
|
||||
let prev = Array.from({ length: bLen + 1 }, (_v, idx) => idx);
|
||||
let curr = Array.from({ length: bLen + 1 }, () => 0);
|
||||
@@ -264,15 +294,20 @@ async function getFreePort(): Promise<number> {
|
||||
}
|
||||
const port = addr.port;
|
||||
srv.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve(port);
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(port);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function isPortFree(port: number): Promise<boolean> {
|
||||
if (!Number.isFinite(port) || port <= 0 || port > 65535) return false;
|
||||
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
||||
return false;
|
||||
}
|
||||
return await new Promise((resolve) => {
|
||||
const srv = createServer();
|
||||
srv.once("error", () => resolve(false));
|
||||
@@ -291,7 +326,9 @@ async function getFreeGatewayPort(): Promise<number> {
|
||||
const ok = (await Promise.all(candidates.map((candidate) => isPortFree(candidate)))).every(
|
||||
Boolean,
|
||||
);
|
||||
if (ok) return port;
|
||||
if (ok) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
throw new Error("failed to acquire a free gateway port block");
|
||||
}
|
||||
@@ -305,11 +342,16 @@ async function connectClient(params: { url: string; token: string }) {
|
||||
return await new Promise<GatewayClient>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const stop = (err?: Error, client?: GatewayClient) => {
|
||||
if (settled) return;
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (err) reject(err);
|
||||
else resolve(client as GatewayClient);
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(client as GatewayClient);
|
||||
}
|
||||
};
|
||||
const client = new GatewayClient({
|
||||
url: params.url,
|
||||
@@ -386,7 +428,9 @@ function sanitizeAuthConfig(params: {
|
||||
agentDir: string;
|
||||
}): OpenClawConfig["auth"] | undefined {
|
||||
const auth = params.cfg.auth;
|
||||
if (!auth) return auth;
|
||||
if (!auth) {
|
||||
return auth;
|
||||
}
|
||||
const store = ensureAuthProfileStore(params.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
@@ -395,10 +439,14 @@ function sanitizeAuthConfig(params: {
|
||||
if (auth.profiles) {
|
||||
profiles = {};
|
||||
for (const [profileId, profile] of Object.entries(auth.profiles)) {
|
||||
if (!store.profiles[profileId]) continue;
|
||||
if (!store.profiles[profileId]) {
|
||||
continue;
|
||||
}
|
||||
profiles[profileId] = profile;
|
||||
}
|
||||
if (Object.keys(profiles).length === 0) profiles = undefined;
|
||||
if (Object.keys(profiles).length === 0) {
|
||||
profiles = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let order: Record<string, string[]> | undefined;
|
||||
@@ -406,13 +454,19 @@ function sanitizeAuthConfig(params: {
|
||||
order = {};
|
||||
for (const [provider, ids] of Object.entries(auth.order)) {
|
||||
const filtered = ids.filter((id) => Boolean(store.profiles[id]));
|
||||
if (filtered.length === 0) continue;
|
||||
if (filtered.length === 0) {
|
||||
continue;
|
||||
}
|
||||
order[provider] = filtered;
|
||||
}
|
||||
if (Object.keys(order).length === 0) order = undefined;
|
||||
if (Object.keys(order).length === 0) {
|
||||
order = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (!profiles && !order && !auth.cooldowns) return undefined;
|
||||
if (!profiles && !order && !auth.cooldowns) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...auth,
|
||||
profiles,
|
||||
@@ -426,7 +480,9 @@ 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,
|
||||
@@ -761,7 +817,9 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
||||
} else {
|
||||
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.
|
||||
@@ -977,7 +1035,9 @@ describeLive("gateway live (dev agent, profile keys)", () => {
|
||||
|
||||
const candidates: Array<Model<Api>> = [];
|
||||
for (const model of wanted) {
|
||||
if (PROVIDERS && !PROVIDERS.has(model.provider)) continue;
|
||||
if (PROVIDERS && !PROVIDERS.has(model.provider)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const apiKeyInfo = await getApiKeyForModel({
|
||||
@@ -1042,7 +1102,9 @@ describeLive("gateway live (dev agent, profile keys)", () => {
|
||||
);
|
||||
|
||||
it("z.ai fallback handles anthropic tool history", async () => {
|
||||
if (!ZAI_FALLBACK) return;
|
||||
if (!ZAI_FALLBACK) {
|
||||
return;
|
||||
}
|
||||
const previous = {
|
||||
configPath: process.env.OPENCLAW_CONFIG_PATH,
|
||||
token: process.env.OPENCLAW_GATEWAY_TOKEN,
|
||||
@@ -1069,7 +1131,9 @@ describeLive("gateway live (dev agent, profile keys)", () => {
|
||||
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;
|
||||
if (!anthropic || !zai) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await getApiKeyForModel({ model: anthropic, cfg });
|
||||
await getApiKeyForModel({ model: zai, cfg });
|
||||
|
||||
@@ -219,9 +219,13 @@ describe("gateway e2e", () => {
|
||||
let didSendToken = false;
|
||||
while (!next.done) {
|
||||
const step = next.step;
|
||||
if (!step) throw new Error("wizard missing step");
|
||||
if (!step) {
|
||||
throw new Error("wizard missing step");
|
||||
}
|
||||
const value = step.type === "text" ? wizardToken : null;
|
||||
if (step.type === "text") didSendToken = true;
|
||||
if (step.type === "text") {
|
||||
didSendToken = true;
|
||||
}
|
||||
next = await client.request("wizard.next", {
|
||||
sessionId,
|
||||
answer: { stepId: step.id, value },
|
||||
|
||||
@@ -104,10 +104,14 @@ export function resolveHookMappings(hooks?: HooksConfig): HookMappingResolved[]
|
||||
const presets = hooks?.presets ?? [];
|
||||
const gmailAllowUnsafe = hooks?.gmail?.allowUnsafeExternalContent;
|
||||
const mappings: HookMappingConfig[] = [];
|
||||
if (hooks?.mappings) mappings.push(...hooks.mappings);
|
||||
if (hooks?.mappings) {
|
||||
mappings.push(...hooks.mappings);
|
||||
}
|
||||
for (const preset of presets) {
|
||||
const presetMappings = hookPresetMappings[preset];
|
||||
if (!presetMappings) continue;
|
||||
if (!presetMappings) {
|
||||
continue;
|
||||
}
|
||||
if (preset === "gmail" && typeof gmailAllowUnsafe === "boolean") {
|
||||
mappings.push(
|
||||
...presetMappings.map((mapping) => ({
|
||||
@@ -119,7 +123,9 @@ export function resolveHookMappings(hooks?: HooksConfig): HookMappingResolved[]
|
||||
}
|
||||
mappings.push(...presetMappings);
|
||||
}
|
||||
if (mappings.length === 0) return [];
|
||||
if (mappings.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const configDir = path.dirname(CONFIG_PATH);
|
||||
const transformsDir = hooks?.transformsDir
|
||||
@@ -133,12 +139,18 @@ export async function applyHookMappings(
|
||||
mappings: HookMappingResolved[],
|
||||
ctx: HookMappingContext,
|
||||
): Promise<HookMappingResult | null> {
|
||||
if (mappings.length === 0) return null;
|
||||
if (mappings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
for (const mapping of mappings) {
|
||||
if (!mappingMatches(mapping, ctx)) continue;
|
||||
if (!mappingMatches(mapping, ctx)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const base = buildActionFromMapping(mapping, ctx);
|
||||
if (!base.ok) return base;
|
||||
if (!base.ok) {
|
||||
return base;
|
||||
}
|
||||
|
||||
let override: HookTransformResult = null;
|
||||
if (mapping.transform) {
|
||||
@@ -149,9 +161,13 @@ export async function applyHookMappings(
|
||||
}
|
||||
}
|
||||
|
||||
if (!base.action) return { ok: true, action: null, skipped: true };
|
||||
if (!base.action) {
|
||||
return { ok: true, action: null, skipped: true };
|
||||
}
|
||||
const merged = mergeAction(base.action, override, mapping.action);
|
||||
if (!merged.ok) return merged;
|
||||
if (!merged.ok) {
|
||||
return merged;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
return null;
|
||||
@@ -197,11 +213,15 @@ function normalizeHookMapping(
|
||||
|
||||
function mappingMatches(mapping: HookMappingResolved, ctx: HookMappingContext) {
|
||||
if (mapping.matchPath) {
|
||||
if (mapping.matchPath !== normalizeMatchPath(ctx.path)) return false;
|
||||
if (mapping.matchPath !== normalizeMatchPath(ctx.path)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (mapping.matchSource) {
|
||||
const source = typeof ctx.payload.source === "string" ? ctx.payload.source : undefined;
|
||||
if (!source || source !== mapping.matchSource) return false;
|
||||
if (!source || source !== mapping.matchSource) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -295,7 +315,9 @@ function validateAction(action: HookAction): HookMappingResult {
|
||||
|
||||
async function loadTransform(transform: HookMappingTransformResolved): Promise<HookTransformFn> {
|
||||
const cached = transformCache.get(transform.modulePath);
|
||||
if (cached) return cached;
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const url = pathToFileURL(transform.modulePath).href;
|
||||
const mod = (await import(url)) as Record<string, unknown>;
|
||||
const fn = resolveTransformFn(mod, transform.exportName);
|
||||
@@ -312,38 +334,60 @@ function resolveTransformFn(mod: Record<string, unknown>, exportName?: string):
|
||||
}
|
||||
|
||||
function resolvePath(baseDir: string, target: string): string {
|
||||
if (!target) return baseDir;
|
||||
if (path.isAbsolute(target)) return target;
|
||||
if (!target) {
|
||||
return baseDir;
|
||||
}
|
||||
if (path.isAbsolute(target)) {
|
||||
return target;
|
||||
}
|
||||
return path.join(baseDir, target);
|
||||
}
|
||||
|
||||
function normalizeMatchPath(raw?: string): string | undefined {
|
||||
if (!raw) return undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed.replace(/^\/+/, "").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function renderOptional(value: string | undefined, ctx: HookMappingContext) {
|
||||
if (!value) return undefined;
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const rendered = renderTemplate(value, ctx).trim();
|
||||
return rendered ? rendered : undefined;
|
||||
}
|
||||
|
||||
function renderTemplate(template: string, ctx: HookMappingContext) {
|
||||
if (!template) return "";
|
||||
if (!template) {
|
||||
return "";
|
||||
}
|
||||
return template.replace(/\{\{\s*([^}]+)\s*\}\}/g, (_, expr: string) => {
|
||||
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 (value === undefined || value === null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
});
|
||||
}
|
||||
|
||||
function resolveTemplateExpr(expr: string, ctx: HookMappingContext) {
|
||||
if (expr === "path") return ctx.path;
|
||||
if (expr === "now") return new Date().toISOString();
|
||||
if (expr === "path") {
|
||||
return ctx.path;
|
||||
}
|
||||
if (expr === "now") {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
if (expr.startsWith("headers.")) {
|
||||
return getByPath(ctx.headers, expr.slice("headers.".length));
|
||||
}
|
||||
@@ -360,7 +404,9 @@ function resolveTemplateExpr(expr: string, ctx: HookMappingContext) {
|
||||
}
|
||||
|
||||
function getByPath(input: Record<string, unknown>, pathExpr: string): unknown {
|
||||
if (!pathExpr) return undefined;
|
||||
if (!pathExpr) {
|
||||
return undefined;
|
||||
}
|
||||
const parts: Array<string | number> = [];
|
||||
const re = /([^.[\]]+)|(\[(\d+)\])/g;
|
||||
let match = re.exec(pathExpr);
|
||||
@@ -374,13 +420,19 @@ function getByPath(input: Record<string, unknown>, pathExpr: string): unknown {
|
||||
}
|
||||
let current: unknown = input;
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
if (current === null || current === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof part === "number") {
|
||||
if (!Array.isArray(current)) return undefined;
|
||||
if (!Array.isArray(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[part] as unknown;
|
||||
continue;
|
||||
}
|
||||
if (typeof current !== "object") return undefined;
|
||||
if (typeof current !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
return current;
|
||||
|
||||
+39
-13
@@ -17,7 +17,9 @@ export type HooksConfigResolved = {
|
||||
};
|
||||
|
||||
export function resolveHooksConfig(cfg: OpenClawConfig): HooksConfigResolved | null {
|
||||
if (cfg.hooks?.enabled !== true) return null;
|
||||
if (cfg.hooks?.enabled !== true) {
|
||||
return null;
|
||||
}
|
||||
const token = cfg.hooks?.token?.trim();
|
||||
if (!token) {
|
||||
throw new Error("hooks.enabled requires hooks.token");
|
||||
@@ -51,15 +53,21 @@ export function extractHookToken(req: IncomingMessage, url: URL): HookTokenResul
|
||||
typeof req.headers.authorization === "string" ? req.headers.authorization.trim() : "";
|
||||
if (auth.toLowerCase().startsWith("bearer ")) {
|
||||
const token = auth.slice(7).trim();
|
||||
if (token) return { token, fromQuery: false };
|
||||
if (token) {
|
||||
return { token, fromQuery: false };
|
||||
}
|
||||
}
|
||||
const headerToken =
|
||||
typeof req.headers["x-openclaw-token"] === "string"
|
||||
? req.headers["x-openclaw-token"].trim()
|
||||
: "";
|
||||
if (headerToken) return { token: headerToken, fromQuery: false };
|
||||
if (headerToken) {
|
||||
return { token: headerToken, fromQuery: false };
|
||||
}
|
||||
const queryToken = url.searchParams.get("token");
|
||||
if (queryToken) return { token: queryToken.trim(), fromQuery: true };
|
||||
if (queryToken) {
|
||||
return { token: queryToken.trim(), fromQuery: true };
|
||||
}
|
||||
return { token: undefined, fromQuery: false };
|
||||
}
|
||||
|
||||
@@ -72,7 +80,9 @@ export async function readJsonBody(
|
||||
let total = 0;
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
if (done) return;
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) {
|
||||
done = true;
|
||||
@@ -83,7 +93,9 @@ export async function readJsonBody(
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => {
|
||||
if (done) return;
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
const raw = Buffer.concat(chunks).toString("utf-8").trim();
|
||||
if (!raw) {
|
||||
@@ -98,7 +110,9 @@ export async function readJsonBody(
|
||||
}
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
if (done) return;
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
resolve({ ok: false, error: String(err) });
|
||||
});
|
||||
@@ -123,7 +137,9 @@ export function normalizeWakePayload(
|
||||
| { ok: true; value: { text: string; mode: "now" | "next-heartbeat" } }
|
||||
| { ok: false; error: string } {
|
||||
const text = typeof payload.text === "string" ? payload.text.trim() : "";
|
||||
if (!text) return { ok: false, error: "text required" };
|
||||
if (!text) {
|
||||
return { ok: false, error: "text required" };
|
||||
}
|
||||
const mode = payload.mode === "next-heartbeat" ? "next-heartbeat" : "now";
|
||||
return { ok: true, value: { text, mode } };
|
||||
}
|
||||
@@ -149,10 +165,16 @@ const getHookChannelSet = () => new Set<string>(listHookChannelValues());
|
||||
export const getHookChannelError = () => `channel must be ${listHookChannelValues().join("|")}`;
|
||||
|
||||
export function resolveHookChannel(raw: unknown): HookMessageChannel | null {
|
||||
if (raw === undefined) return "last";
|
||||
if (typeof raw !== "string") return null;
|
||||
if (raw === undefined) {
|
||||
return "last";
|
||||
}
|
||||
if (typeof raw !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeMessageChannel(raw);
|
||||
if (!normalized || !getHookChannelSet().has(normalized)) return null;
|
||||
if (!normalized || !getHookChannelSet().has(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized as HookMessageChannel;
|
||||
}
|
||||
|
||||
@@ -170,7 +192,9 @@ export function normalizeAgentPayload(
|
||||
}
|
||||
| { ok: false; error: string } {
|
||||
const message = typeof payload.message === "string" ? payload.message.trim() : "";
|
||||
if (!message) return { ok: false, error: "message required" };
|
||||
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";
|
||||
@@ -181,7 +205,9 @@ export function normalizeAgentPayload(
|
||||
? sessionKeyRaw.trim()
|
||||
: `hook:${idFactory()}`;
|
||||
const channel = resolveHookChannel(payload.channel);
|
||||
if (!channel) return { ok: false, error: getHookChannelError() };
|
||||
if (!channel) {
|
||||
return { ok: false, error: getHookChannelError() };
|
||||
}
|
||||
const toRaw = payload.to;
|
||||
const to = typeof toRaw === "string" && toRaw.trim() ? toRaw.trim() : undefined;
|
||||
const modelRaw = payload.model;
|
||||
|
||||
@@ -5,14 +5,20 @@ import { buildAgentMainSessionKey, normalizeAgentId } from "../routing/session-k
|
||||
|
||||
export function getHeader(req: IncomingMessage, name: string): string | undefined {
|
||||
const raw = req.headers[name.toLowerCase()];
|
||||
if (typeof raw === "string") return raw;
|
||||
if (Array.isArray(raw)) return raw[0];
|
||||
if (typeof raw === "string") {
|
||||
return raw;
|
||||
}
|
||||
if (Array.isArray(raw)) {
|
||||
return raw[0];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getBearerToken(req: IncomingMessage): string | undefined {
|
||||
const raw = getHeader(req, "authorization")?.trim() ?? "";
|
||||
if (!raw.toLowerCase().startsWith("bearer ")) return undefined;
|
||||
if (!raw.toLowerCase().startsWith("bearer ")) {
|
||||
return undefined;
|
||||
}
|
||||
const token = raw.slice(7).trim();
|
||||
return token || undefined;
|
||||
}
|
||||
@@ -22,19 +28,25 @@ export function resolveAgentIdFromHeader(req: IncomingMessage): string | undefin
|
||||
getHeader(req, "x-openclaw-agent-id")?.trim() ||
|
||||
getHeader(req, "x-openclaw-agent")?.trim() ||
|
||||
"";
|
||||
if (!raw) return undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeAgentId(raw);
|
||||
}
|
||||
|
||||
export function resolveAgentIdFromModel(model: string | undefined): string | undefined {
|
||||
const raw = model?.trim();
|
||||
if (!raw) return undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const m =
|
||||
raw.match(/^openclaw[:/](?<agentId>[a-z0-9][a-z0-9_-]{0,63})$/i) ??
|
||||
raw.match(/^agent:(?<agentId>[a-z0-9][a-z0-9_-]{0,63})$/i);
|
||||
const agentId = m?.groups?.agentId;
|
||||
if (!agentId) return undefined;
|
||||
if (!agentId) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeAgentId(agentId);
|
||||
}
|
||||
|
||||
@@ -43,7 +55,9 @@ export function resolveAgentIdForRequest(params: {
|
||||
model: string | undefined;
|
||||
}): string {
|
||||
const fromHeader = resolveAgentIdFromHeader(params.req);
|
||||
if (fromHeader) return fromHeader;
|
||||
if (fromHeader) {
|
||||
return fromHeader;
|
||||
}
|
||||
|
||||
const fromModel = resolveAgentIdFromModel(params.model);
|
||||
return fromModel ?? "main";
|
||||
@@ -56,7 +70,9 @@ export function resolveSessionKey(params: {
|
||||
prefix: string;
|
||||
}): string {
|
||||
const explicit = getHeader(params.req, "x-openclaw-session-key")?.trim();
|
||||
if (explicit) return explicit;
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
const user = params.user?.trim();
|
||||
const mainKey = user ? `${params.prefix}-user:${user}` : `${params.prefix}:${randomUUID()}`;
|
||||
|
||||
@@ -68,10 +68,16 @@ function fillPixel(
|
||||
b: number,
|
||||
a = 255,
|
||||
) {
|
||||
if (x < 0 || y < 0) return;
|
||||
if (x >= width) return;
|
||||
if (x < 0 || y < 0) {
|
||||
return;
|
||||
}
|
||||
if (x >= width) {
|
||||
return;
|
||||
}
|
||||
const idx = (y * width + x) * 4;
|
||||
if (idx < 0 || idx + 3 >= buf.length) return;
|
||||
if (idx < 0 || idx + 3 >= buf.length) {
|
||||
return;
|
||||
}
|
||||
buf[idx] = r;
|
||||
buf[idx + 1] = g;
|
||||
buf[idx + 2] = b;
|
||||
@@ -109,12 +115,16 @@ function drawGlyph5x7(params: {
|
||||
color: { r: number; g: number; b: number; a?: number };
|
||||
}) {
|
||||
const rows = GLYPH_ROWS_5X7[params.char];
|
||||
if (!rows) return;
|
||||
if (!rows) {
|
||||
return;
|
||||
}
|
||||
for (let row = 0; row < 7; row += 1) {
|
||||
const bits = rows[row] ?? 0;
|
||||
for (let col = 0; col < 5; col += 1) {
|
||||
const on = (bits & (1 << (4 - col))) !== 0;
|
||||
if (!on) continue;
|
||||
if (!on) {
|
||||
continue;
|
||||
}
|
||||
for (let dy = 0; dy < params.scale; dy += 1) {
|
||||
for (let dx = 0; dx < params.scale; dx += 1) {
|
||||
fillPixel(
|
||||
|
||||
+84
-28
@@ -3,54 +3,80 @@ import net from "node:net";
|
||||
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
|
||||
|
||||
export function isLoopbackAddress(ip: string | undefined): boolean {
|
||||
if (!ip) return false;
|
||||
if (ip === "127.0.0.1") return true;
|
||||
if (ip.startsWith("127.")) return true;
|
||||
if (ip === "::1") return true;
|
||||
if (ip.startsWith("::ffff:127.")) return true;
|
||||
if (!ip) {
|
||||
return false;
|
||||
}
|
||||
if (ip === "127.0.0.1") {
|
||||
return true;
|
||||
}
|
||||
if (ip.startsWith("127.")) {
|
||||
return true;
|
||||
}
|
||||
if (ip === "::1") {
|
||||
return true;
|
||||
}
|
||||
if (ip.startsWith("::ffff:127.")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeIPv4MappedAddress(ip: string): string {
|
||||
if (ip.startsWith("::ffff:")) return ip.slice("::ffff:".length);
|
||||
if (ip.startsWith("::ffff:")) {
|
||||
return ip.slice("::ffff:".length);
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
|
||||
function normalizeIp(ip: string | undefined): string | undefined {
|
||||
const trimmed = ip?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeIPv4MappedAddress(trimmed.toLowerCase());
|
||||
}
|
||||
|
||||
function stripOptionalPort(ip: string): string {
|
||||
if (ip.startsWith("[")) {
|
||||
const end = ip.indexOf("]");
|
||||
if (end !== -1) return ip.slice(1, end);
|
||||
if (end !== -1) {
|
||||
return ip.slice(1, end);
|
||||
}
|
||||
}
|
||||
if (net.isIP(ip)) {
|
||||
return ip;
|
||||
}
|
||||
if (net.isIP(ip)) return ip;
|
||||
const lastColon = ip.lastIndexOf(":");
|
||||
if (lastColon > -1 && ip.includes(".") && ip.indexOf(":") === lastColon) {
|
||||
const candidate = ip.slice(0, lastColon);
|
||||
if (net.isIP(candidate) === 4) return candidate;
|
||||
if (net.isIP(candidate) === 4) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return ip;
|
||||
}
|
||||
|
||||
export function parseForwardedForClientIp(forwardedFor?: string): string | undefined {
|
||||
const raw = forwardedFor?.split(",")[0]?.trim();
|
||||
if (!raw) return undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeIp(stripOptionalPort(raw));
|
||||
}
|
||||
|
||||
function parseRealIp(realIp?: string): string | undefined {
|
||||
const raw = realIp?.trim();
|
||||
if (!raw) return undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeIp(stripOptionalPort(raw));
|
||||
}
|
||||
|
||||
export function isTrustedProxyAddress(ip: string | undefined, trustedProxies?: string[]): boolean {
|
||||
const normalized = normalizeIp(ip);
|
||||
if (!normalized || !trustedProxies || trustedProxies.length === 0) return false;
|
||||
if (!normalized || !trustedProxies || trustedProxies.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return trustedProxies.some((proxy) => normalizeIp(proxy) === normalized);
|
||||
}
|
||||
|
||||
@@ -61,19 +87,31 @@ export function resolveGatewayClientIp(params: {
|
||||
trustedProxies?: string[];
|
||||
}): string | undefined {
|
||||
const remote = normalizeIp(params.remoteAddr);
|
||||
if (!remote) return undefined;
|
||||
if (!isTrustedProxyAddress(remote, params.trustedProxies)) return remote;
|
||||
if (!remote) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isTrustedProxyAddress(remote, params.trustedProxies)) {
|
||||
return remote;
|
||||
}
|
||||
return parseForwardedForClientIp(params.forwardedFor) ?? parseRealIp(params.realIp) ?? remote;
|
||||
}
|
||||
|
||||
export function isLocalGatewayAddress(ip: string | undefined): boolean {
|
||||
if (isLoopbackAddress(ip)) return true;
|
||||
if (!ip) return false;
|
||||
if (isLoopbackAddress(ip)) {
|
||||
return true;
|
||||
}
|
||||
if (!ip) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeIPv4MappedAddress(ip.trim().toLowerCase());
|
||||
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
||||
if (tailnetIPv4 && normalized === tailnetIPv4.toLowerCase()) return true;
|
||||
if (tailnetIPv4 && normalized === tailnetIPv4.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
const tailnetIPv6 = pickPrimaryTailnetIPv6();
|
||||
if (tailnetIPv6 && ip.trim().toLowerCase() === tailnetIPv6.toLowerCase()) return true;
|
||||
if (tailnetIPv6 && ip.trim().toLowerCase() === tailnetIPv6.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -97,14 +135,20 @@ export async function resolveGatewayBindHost(
|
||||
|
||||
if (mode === "loopback") {
|
||||
// 127.0.0.1 rarely fails, but handle gracefully
|
||||
if (await canBindToHost("127.0.0.1")) return "127.0.0.1";
|
||||
if (await canBindToHost("127.0.0.1")) {
|
||||
return "127.0.0.1";
|
||||
}
|
||||
return "0.0.0.0"; // extreme fallback
|
||||
}
|
||||
|
||||
if (mode === "tailnet") {
|
||||
const tailnetIP = pickPrimaryTailnetIPv4();
|
||||
if (tailnetIP && (await canBindToHost(tailnetIP))) return tailnetIP;
|
||||
if (await canBindToHost("127.0.0.1")) return "127.0.0.1";
|
||||
if (tailnetIP && (await canBindToHost(tailnetIP))) {
|
||||
return tailnetIP;
|
||||
}
|
||||
if (await canBindToHost("127.0.0.1")) {
|
||||
return "127.0.0.1";
|
||||
}
|
||||
return "0.0.0.0";
|
||||
}
|
||||
|
||||
@@ -114,15 +158,21 @@ export async function resolveGatewayBindHost(
|
||||
|
||||
if (mode === "custom") {
|
||||
const host = customHost?.trim();
|
||||
if (!host) return "0.0.0.0"; // invalid config → fall back to all
|
||||
if (!host) {
|
||||
return "0.0.0.0";
|
||||
} // invalid config → fall back to all
|
||||
|
||||
if (isValidIPv4(host) && (await canBindToHost(host))) return host;
|
||||
if (isValidIPv4(host) && (await canBindToHost(host))) {
|
||||
return host;
|
||||
}
|
||||
// Custom IP failed → fall back to LAN
|
||||
return "0.0.0.0";
|
||||
}
|
||||
|
||||
if (mode === "auto") {
|
||||
if (await canBindToHost("127.0.0.1")) return "127.0.0.1";
|
||||
if (await canBindToHost("127.0.0.1")) {
|
||||
return "127.0.0.1";
|
||||
}
|
||||
return "0.0.0.0";
|
||||
}
|
||||
|
||||
@@ -155,9 +205,13 @@ export async function resolveGatewayListenHosts(
|
||||
bindHost: string,
|
||||
opts?: { canBindToHost?: (host: string) => Promise<boolean> },
|
||||
): Promise<string[]> {
|
||||
if (bindHost !== "127.0.0.1") return [bindHost];
|
||||
if (bindHost !== "127.0.0.1") {
|
||||
return [bindHost];
|
||||
}
|
||||
const canBind = opts?.canBindToHost ?? canBindToHost;
|
||||
if (await canBind("::1")) return [bindHost, "::1"];
|
||||
if (await canBind("::1")) {
|
||||
return [bindHost, "::1"];
|
||||
}
|
||||
return [bindHost];
|
||||
}
|
||||
|
||||
@@ -169,7 +223,9 @@ export async function resolveGatewayListenHosts(
|
||||
*/
|
||||
function isValidIPv4(host: string): boolean {
|
||||
const parts = host.split(".");
|
||||
if (parts.length !== 4) return false;
|
||||
if (parts.length !== 4) {
|
||||
return false;
|
||||
}
|
||||
return parts.every((part) => {
|
||||
const n = parseInt(part, 10);
|
||||
return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n);
|
||||
|
||||
@@ -59,18 +59,40 @@ const PLATFORM_DEFAULTS: Record<string, string[]> = {
|
||||
|
||||
function normalizePlatformId(platform?: string, deviceFamily?: string): string {
|
||||
const raw = (platform ?? "").trim().toLowerCase();
|
||||
if (raw.startsWith("ios")) return "ios";
|
||||
if (raw.startsWith("android")) return "android";
|
||||
if (raw.startsWith("mac")) return "macos";
|
||||
if (raw.startsWith("darwin")) return "macos";
|
||||
if (raw.startsWith("win")) return "windows";
|
||||
if (raw.startsWith("linux")) return "linux";
|
||||
if (raw.startsWith("ios")) {
|
||||
return "ios";
|
||||
}
|
||||
if (raw.startsWith("android")) {
|
||||
return "android";
|
||||
}
|
||||
if (raw.startsWith("mac")) {
|
||||
return "macos";
|
||||
}
|
||||
if (raw.startsWith("darwin")) {
|
||||
return "macos";
|
||||
}
|
||||
if (raw.startsWith("win")) {
|
||||
return "windows";
|
||||
}
|
||||
if (raw.startsWith("linux")) {
|
||||
return "linux";
|
||||
}
|
||||
const family = (deviceFamily ?? "").trim().toLowerCase();
|
||||
if (family.includes("iphone") || family.includes("ipad") || family.includes("ios")) return "ios";
|
||||
if (family.includes("android")) return "android";
|
||||
if (family.includes("mac")) return "macos";
|
||||
if (family.includes("windows")) return "windows";
|
||||
if (family.includes("linux")) return "linux";
|
||||
if (family.includes("iphone") || family.includes("ipad") || family.includes("ios")) {
|
||||
return "ios";
|
||||
}
|
||||
if (family.includes("android")) {
|
||||
return "android";
|
||||
}
|
||||
if (family.includes("mac")) {
|
||||
return "macos";
|
||||
}
|
||||
if (family.includes("windows")) {
|
||||
return "windows";
|
||||
}
|
||||
if (family.includes("linux")) {
|
||||
return "linux";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
@@ -85,7 +107,9 @@ export function resolveNodeCommandAllowlist(
|
||||
const allow = new Set([...base, ...extra].map((cmd) => cmd.trim()).filter(Boolean));
|
||||
for (const blocked of deny) {
|
||||
const trimmed = blocked.trim();
|
||||
if (trimmed) allow.delete(trimmed);
|
||||
if (trimmed) {
|
||||
allow.delete(trimmed);
|
||||
}
|
||||
}
|
||||
return allow;
|
||||
}
|
||||
@@ -96,7 +120,9 @@ export function isNodeCommandAllowed(params: {
|
||||
allowlist: Set<string>;
|
||||
}): { ok: true } | { ok: false; reason: string } {
|
||||
const command = params.command.trim();
|
||||
if (!command) return { ok: false, reason: "command required" };
|
||||
if (!command) {
|
||||
return { ok: false, reason: "command required" };
|
||||
}
|
||||
if (!params.allowlist.has(command)) {
|
||||
return { ok: false, reason: "command not allowlisted" };
|
||||
}
|
||||
|
||||
@@ -81,11 +81,15 @@ export class NodeRegistry {
|
||||
|
||||
unregister(connId: string): string | null {
|
||||
const nodeId = this.nodesByConn.get(connId);
|
||||
if (!nodeId) return null;
|
||||
if (!nodeId) {
|
||||
return null;
|
||||
}
|
||||
this.nodesByConn.delete(connId);
|
||||
this.nodesById.delete(nodeId);
|
||||
for (const [id, pending] of this.pendingInvokes.entries()) {
|
||||
if (pending.nodeId !== nodeId) continue;
|
||||
if (pending.nodeId !== nodeId) {
|
||||
continue;
|
||||
}
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error(`node disconnected (${pending.command})`));
|
||||
this.pendingInvokes.delete(id);
|
||||
@@ -160,8 +164,12 @@ export class NodeRegistry {
|
||||
error?: { code?: string; message?: string } | null;
|
||||
}): boolean {
|
||||
const pending = this.pendingInvokes.get(params.id);
|
||||
if (!pending) return false;
|
||||
if (pending.nodeId !== params.nodeId) return false;
|
||||
if (!pending) {
|
||||
return false;
|
||||
}
|
||||
if (pending.nodeId !== params.nodeId) {
|
||||
return false;
|
||||
}
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingInvokes.delete(params.id);
|
||||
pending.resolve({
|
||||
@@ -175,7 +183,9 @@ export class NodeRegistry {
|
||||
|
||||
sendEvent(nodeId: string, event: string, payload?: unknown): boolean {
|
||||
const node = this.nodesById.get(nodeId);
|
||||
if (!node) return false;
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
return this.sendEventToSession(node, event, payload);
|
||||
}
|
||||
|
||||
|
||||
+48
-16
@@ -45,17 +45,27 @@ function asMessages(val: unknown): OpenAiChatMessage[] {
|
||||
}
|
||||
|
||||
function extractTextContent(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part) => {
|
||||
if (!part || typeof part !== "object") return "";
|
||||
if (!part || typeof part !== "object") {
|
||||
return "";
|
||||
}
|
||||
const type = (part as { type?: unknown }).type;
|
||||
const text = (part as { text?: unknown }).text;
|
||||
const inputText = (part as { input_text?: unknown }).input_text;
|
||||
if (type === "text" && typeof text === "string") return text;
|
||||
if (type === "input_text" && typeof text === "string") return text;
|
||||
if (typeof inputText === "string") return inputText;
|
||||
if (type === "text" && typeof text === "string") {
|
||||
return text;
|
||||
}
|
||||
if (type === "input_text" && typeof text === "string") {
|
||||
return text;
|
||||
}
|
||||
if (typeof inputText === "string") {
|
||||
return inputText;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
@@ -75,10 +85,14 @@ function buildAgentPrompt(messagesUnknown: unknown): {
|
||||
[];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!msg || typeof msg !== "object") continue;
|
||||
if (!msg || typeof msg !== "object") {
|
||||
continue;
|
||||
}
|
||||
const role = typeof msg.role === "string" ? msg.role.trim() : "";
|
||||
const content = extractTextContent(msg.content).trim();
|
||||
if (!role || !content) continue;
|
||||
if (!role || !content) {
|
||||
continue;
|
||||
}
|
||||
if (role === "system" || role === "developer") {
|
||||
systemParts.push(content);
|
||||
continue;
|
||||
@@ -115,7 +129,9 @@ function buildAgentPrompt(messagesUnknown: unknown): {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (currentIndex < 0) currentIndex = conversationEntries.length - 1;
|
||||
if (currentIndex < 0) {
|
||||
currentIndex = conversationEntries.length - 1;
|
||||
}
|
||||
const currentEntry = conversationEntries[currentIndex]?.entry;
|
||||
if (currentEntry) {
|
||||
const historyEntries = conversationEntries.slice(0, currentIndex).map((entry) => entry.entry);
|
||||
@@ -147,7 +163,9 @@ function resolveOpenAiSessionKey(params: {
|
||||
}
|
||||
|
||||
function coerceRequest(val: unknown): OpenAiChatCompletionRequest {
|
||||
if (!val || typeof val !== "object") return {};
|
||||
if (!val || typeof val !== "object") {
|
||||
return {};
|
||||
}
|
||||
return val as OpenAiChatCompletionRequest;
|
||||
}
|
||||
|
||||
@@ -157,7 +175,9 @@ export async function handleOpenAiHttpRequest(
|
||||
opts: OpenAiHttpOptions,
|
||||
): Promise<boolean> {
|
||||
const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`);
|
||||
if (url.pathname !== "/v1/chat/completions") return false;
|
||||
if (url.pathname !== "/v1/chat/completions") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
sendMethodNotAllowed(res);
|
||||
@@ -177,7 +197,9 @@ export async function handleOpenAiHttpRequest(
|
||||
}
|
||||
|
||||
const body = await readJsonBodyOrError(req, res, opts.maxBodyBytes ?? 1024 * 1024);
|
||||
if (body === undefined) return true;
|
||||
if (body === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = coerceRequest(body);
|
||||
const stream = Boolean(payload.stream);
|
||||
@@ -254,14 +276,20 @@ export async function handleOpenAiHttpRequest(
|
||||
let closed = false;
|
||||
|
||||
const unsubscribe = onAgentEvent((evt) => {
|
||||
if (evt.runId !== runId) return;
|
||||
if (closed) return;
|
||||
if (evt.runId !== runId) {
|
||||
return;
|
||||
}
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.stream === "assistant") {
|
||||
const delta = evt.data?.delta;
|
||||
const text = evt.data?.text;
|
||||
const content = typeof delta === "string" ? delta : typeof text === "string" ? text : "";
|
||||
if (!content) return;
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wroteRole) {
|
||||
wroteRole = true;
|
||||
@@ -323,7 +351,9 @@ export async function handleOpenAiHttpRequest(
|
||||
deps,
|
||||
);
|
||||
|
||||
if (closed) return;
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sawAssistantDelta) {
|
||||
if (!wroteRole) {
|
||||
@@ -362,7 +392,9 @@ export async function handleOpenAiHttpRequest(
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (closed) return;
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
writeSse(res, {
|
||||
id: runId,
|
||||
object: "chat.completion.chunk",
|
||||
|
||||
@@ -73,7 +73,9 @@ function parseSseEvents(text: string): Array<{ event?: string; data: string }> {
|
||||
}
|
||||
|
||||
async function ensureResponseConsumed(res: Response) {
|
||||
if (res.bodyUsed) return;
|
||||
if (res.bodyUsed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await res.text();
|
||||
} catch {
|
||||
@@ -493,7 +495,9 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
||||
const typeText = await resTypeMatch.text();
|
||||
const typeEvents = parseSseEvents(typeText);
|
||||
for (const event of typeEvents) {
|
||||
if (event.data === "[DONE]") continue;
|
||||
if (event.data === "[DONE]") {
|
||||
continue;
|
||||
}
|
||||
const parsed = JSON.parse(event.data) as { type?: string };
|
||||
expect(event.event).toBe(parsed.type);
|
||||
}
|
||||
|
||||
@@ -71,11 +71,17 @@ function writeSseEvent(res: ServerResponse, event: StreamingEvent) {
|
||||
}
|
||||
|
||||
function extractTextContent(content: string | ContentPart[]): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
return content
|
||||
.map((part) => {
|
||||
if (part.type === "input_text") return part.text;
|
||||
if (part.type === "output_text") return part.text;
|
||||
if (part.type === "input_text") {
|
||||
return part.text;
|
||||
}
|
||||
if (part.type === "output_text") {
|
||||
return part.text;
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
@@ -127,7 +133,9 @@ function applyToolChoice(params: {
|
||||
toolChoice: CreateResponseBody["tool_choice"];
|
||||
}): { tools: ClientToolDefinition[]; extraSystemPrompt?: string } {
|
||||
const { tools, toolChoice } = params;
|
||||
if (!toolChoice) return { tools };
|
||||
if (!toolChoice) {
|
||||
return { tools };
|
||||
}
|
||||
|
||||
if (toolChoice === "none") {
|
||||
return { tools: [] };
|
||||
@@ -176,7 +184,9 @@ export function buildAgentPrompt(input: string | ItemParam[]): {
|
||||
for (const item of input) {
|
||||
if (item.type === "message") {
|
||||
const content = extractTextContent(item.content).trim();
|
||||
if (!content) continue;
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.role === "system" || item.role === "developer") {
|
||||
systemParts.push(content);
|
||||
@@ -210,7 +220,9 @@ export function buildAgentPrompt(input: string | ItemParam[]): {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (currentIndex < 0) currentIndex = conversationEntries.length - 1;
|
||||
if (currentIndex < 0) {
|
||||
currentIndex = conversationEntries.length - 1;
|
||||
}
|
||||
|
||||
const currentEntry = conversationEntries[currentIndex]?.entry;
|
||||
if (currentEntry) {
|
||||
@@ -257,7 +269,9 @@ function toUsage(
|
||||
}
|
||||
| undefined,
|
||||
): Usage {
|
||||
if (!value) return createEmptyUsage();
|
||||
if (!value) {
|
||||
return createEmptyUsage();
|
||||
}
|
||||
const input = value.input ?? 0;
|
||||
const output = value.output ?? 0;
|
||||
const cacheRead = value.cacheRead ?? 0;
|
||||
@@ -320,7 +334,9 @@ export async function handleOpenResponsesHttpRequest(
|
||||
opts: OpenResponsesHttpOptions,
|
||||
): Promise<boolean> {
|
||||
const url = new URL(req.url ?? "/", `http://${req.headers.host || "localhost"}`);
|
||||
if (url.pathname !== "/v1/responses") return false;
|
||||
if (url.pathname !== "/v1/responses") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
sendMethodNotAllowed(res);
|
||||
@@ -346,7 +362,9 @@ export async function handleOpenResponsesHttpRequest(
|
||||
? limits.maxBodyBytes
|
||||
: Math.max(limits.maxBodyBytes, limits.files.maxBytes * 2, limits.images.maxBytes * 2));
|
||||
const body = await readJsonBodyOrError(req, res, maxBodyBytes);
|
||||
if (body === undefined) return true;
|
||||
if (body === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Validate request body with Zod
|
||||
const parseResult = CreateResponseBodySchema.safeParse(body);
|
||||
@@ -592,9 +610,15 @@ export async function handleOpenResponsesHttpRequest(
|
||||
let finalizeRequested: { status: ResponseResource["status"]; text: string } | null = null;
|
||||
|
||||
const maybeFinalize = () => {
|
||||
if (closed) return;
|
||||
if (!finalizeRequested) return;
|
||||
if (!finalUsage) return;
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
if (!finalizeRequested) {
|
||||
return;
|
||||
}
|
||||
if (!finalUsage) {
|
||||
return;
|
||||
}
|
||||
const usage = finalUsage;
|
||||
|
||||
closed = true;
|
||||
@@ -642,7 +666,9 @@ export async function handleOpenResponsesHttpRequest(
|
||||
};
|
||||
|
||||
const requestFinalize = (status: ResponseResource["status"], text: string) => {
|
||||
if (finalizeRequested) return;
|
||||
if (finalizeRequested) {
|
||||
return;
|
||||
}
|
||||
finalizeRequested = { status, text };
|
||||
maybeFinalize();
|
||||
};
|
||||
@@ -681,14 +707,20 @@ export async function handleOpenResponsesHttpRequest(
|
||||
});
|
||||
|
||||
unsubscribe = onAgentEvent((evt) => {
|
||||
if (evt.runId !== responseId) return;
|
||||
if (closed) return;
|
||||
if (evt.runId !== responseId) {
|
||||
return;
|
||||
}
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (evt.stream === "assistant") {
|
||||
const delta = evt.data?.delta;
|
||||
const text = evt.data?.text;
|
||||
const content = typeof delta === "string" ? delta : typeof text === "string" ? text : "";
|
||||
if (!content) return;
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
sawAssistantDelta = true;
|
||||
accumulatedText += content;
|
||||
@@ -740,7 +772,9 @@ export async function handleOpenResponsesHttpRequest(
|
||||
finalUsage = extractUsageFromResult(result);
|
||||
maybeFinalize();
|
||||
|
||||
if (closed) return;
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: if no streaming deltas were received, send the full response
|
||||
if (!sawAssistantDelta) {
|
||||
@@ -845,7 +879,9 @@ export async function handleOpenResponsesHttpRequest(
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (closed) return;
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
finalUsage = finalUsage ?? createEmptyUsage();
|
||||
const errorResponse = createResponseResource({
|
||||
|
||||
@@ -28,7 +28,9 @@ export type GatewayProbeResult = {
|
||||
};
|
||||
|
||||
function formatError(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
@@ -46,7 +48,9 @@ export async function probeGateway(opts: {
|
||||
return await new Promise<GatewayProbeResult>((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (result: Omit<GatewayProbeResult, "url">) => {
|
||||
if (settled) return;
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
client.stop();
|
||||
|
||||
@@ -47,7 +47,9 @@ const GATEWAY_CLIENT_MODE_SET = new Set<GatewayClientMode>(Object.values(GATEWAY
|
||||
|
||||
export function normalizeGatewayClientId(raw?: string | null): GatewayClientId | undefined {
|
||||
const normalized = raw?.trim().toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return GATEWAY_CLIENT_ID_SET.has(normalized as GatewayClientId)
|
||||
? (normalized as GatewayClientId)
|
||||
: undefined;
|
||||
@@ -59,7 +61,9 @@ export function normalizeGatewayClientName(raw?: string | null): GatewayClientNa
|
||||
|
||||
export function normalizeGatewayClientMode(raw?: string | null): GatewayClientMode | undefined {
|
||||
const normalized = raw?.trim().toLowerCase();
|
||||
if (!normalized) return undefined;
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return GATEWAY_CLIENT_MODE_SET.has(normalized as GatewayClientMode)
|
||||
? (normalized as GatewayClientMode)
|
||||
: undefined;
|
||||
|
||||
@@ -321,7 +321,9 @@ export const validateWebLoginStartParams =
|
||||
export const validateWebLoginWaitParams = ajv.compile<WebLoginWaitParams>(WebLoginWaitParamsSchema);
|
||||
|
||||
export function formatValidationErrors(errors: ErrorObject[] | null | undefined) {
|
||||
if (!errors?.length) return "unknown validation error";
|
||||
if (!errors?.length) {
|
||||
return "unknown validation error";
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
|
||||
@@ -17,11 +17,17 @@ const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
|
||||
|
||||
function hasEventScope(client: GatewayWsClient, event: string): boolean {
|
||||
const required = EVENT_SCOPE_GUARDS[event];
|
||||
if (!required) return true;
|
||||
if (!required) {
|
||||
return true;
|
||||
}
|
||||
const role = client.connect.role ?? "operator";
|
||||
if (role !== "operator") return false;
|
||||
if (role !== "operator") {
|
||||
return false;
|
||||
}
|
||||
const scopes = Array.isArray(client.connect.scopes) ? client.connect.scopes : [];
|
||||
if (scopes.includes(ADMIN_SCOPE)) return true;
|
||||
if (scopes.includes(ADMIN_SCOPE)) {
|
||||
return true;
|
||||
}
|
||||
return required.some((scope) => scopes.includes(scope));
|
||||
}
|
||||
|
||||
@@ -56,9 +62,13 @@ export function createGatewayBroadcaster(params: { clients: Set<GatewayWsClient>
|
||||
}
|
||||
logWs("out", "event", logMeta);
|
||||
for (const c of params.clients) {
|
||||
if (!hasEventScope(c, event)) continue;
|
||||
if (!hasEventScope(c, event)) {
|
||||
continue;
|
||||
}
|
||||
const slow = c.socket.bufferedAmount > MAX_BUFFERED_BYTES;
|
||||
if (slow && opts?.dropIfSlow) continue;
|
||||
if (slow && opts?.dropIfSlow) {
|
||||
continue;
|
||||
}
|
||||
if (slow) {
|
||||
try {
|
||||
c.socket.close(1008, "slow consumer");
|
||||
|
||||
@@ -5,7 +5,9 @@ export type BrowserControlServer = {
|
||||
};
|
||||
|
||||
export async function startBrowserControlServerIfEnabled(): Promise<BrowserControlServer | null> {
|
||||
if (isTruthyEnvValue(process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER)) return null;
|
||||
if (isTruthyEnvValue(process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER)) {
|
||||
return null;
|
||||
}
|
||||
// Lazy import: keeps startup fast, but still bundles for the embedded
|
||||
// gateway (bun --compile) via the static specifier path.
|
||||
const override = process.env.OPENCLAW_BROWSER_CONTROL_MODULE?.trim();
|
||||
@@ -21,7 +23,9 @@ export async function startBrowserControlServerIfEnabled(): Promise<BrowserContr
|
||||
typeof (mod as { stopBrowserControlService?: unknown }).stopBrowserControlService === "function"
|
||||
? (mod as { stopBrowserControlService: () => Promise<void> }).stopBrowserControlService
|
||||
: (mod as { stopBrowserControlServer?: () => Promise<void> }).stopBrowserControlServer;
|
||||
if (!start) return null;
|
||||
if (!start) {
|
||||
return null;
|
||||
}
|
||||
await start();
|
||||
return { stop: stop ?? (async () => {}) };
|
||||
}
|
||||
|
||||
@@ -30,7 +30,9 @@ function createRuntimeStore(): ChannelRuntimeStore {
|
||||
}
|
||||
|
||||
function isAccountEnabled(account: unknown): boolean {
|
||||
if (!account || typeof account !== "object") return true;
|
||||
if (!account || typeof account !== "object") {
|
||||
return true;
|
||||
}
|
||||
const enabled = (account as { enabled?: boolean }).enabled;
|
||||
return enabled !== false;
|
||||
}
|
||||
@@ -66,7 +68,9 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
|
||||
|
||||
const getStore = (channelId: ChannelId): ChannelRuntimeStore => {
|
||||
const existing = channelStores.get(channelId);
|
||||
if (existing) return existing;
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const next = createRuntimeStore();
|
||||
channelStores.set(channelId, next);
|
||||
return next;
|
||||
@@ -92,16 +96,22 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
|
||||
const startChannel = async (channelId: ChannelId, accountId?: string) => {
|
||||
const plugin = getChannelPlugin(channelId);
|
||||
const startAccount = plugin?.gateway?.startAccount;
|
||||
if (!startAccount) return;
|
||||
if (!startAccount) {
|
||||
return;
|
||||
}
|
||||
const cfg = loadConfig();
|
||||
resetDirectoryCache({ channel: channelId, accountId });
|
||||
const store = getStore(channelId);
|
||||
const accountIds = accountId ? [accountId] : plugin.config.listAccountIds(cfg);
|
||||
if (accountIds.length === 0) return;
|
||||
if (accountIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
accountIds.map(async (id) => {
|
||||
if (store.tasks.has(id)) return;
|
||||
if (store.tasks.has(id)) {
|
||||
return;
|
||||
}
|
||||
const account = plugin.config.resolveAccount(cfg, id);
|
||||
const enabled = plugin.config.isEnabled
|
||||
? plugin.config.isEnabled(account, cfg)
|
||||
@@ -186,7 +196,9 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
|
||||
Array.from(knownIds.values()).map(async (id) => {
|
||||
const abort = store.aborts.get(id);
|
||||
const task = store.tasks.get(id);
|
||||
if (!abort && !task && !plugin?.gateway?.stopAccount) return;
|
||||
if (!abort && !task && !plugin?.gateway?.stopAccount) {
|
||||
return;
|
||||
}
|
||||
abort?.abort();
|
||||
if (plugin?.gateway?.stopAccount) {
|
||||
const account = plugin.config.resolveAccount(cfg, id);
|
||||
@@ -225,7 +237,9 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
|
||||
|
||||
const markChannelLoggedOut = (channelId: ChannelId, cleared: boolean, accountId?: string) => {
|
||||
const plugin = getChannelPlugin(channelId);
|
||||
if (!plugin) return;
|
||||
if (!plugin) {
|
||||
return;
|
||||
}
|
||||
const cfg = loadConfig();
|
||||
const resolvedId =
|
||||
accountId ??
|
||||
|
||||
+30
-10
@@ -11,7 +11,9 @@ import { formatForLog } from "./ws-log.js";
|
||||
*/
|
||||
function shouldSuppressHeartbeatBroadcast(runId: string): boolean {
|
||||
const runContext = getAgentRunContext(runId);
|
||||
if (!runContext?.isHeartbeat) return false;
|
||||
if (!runContext?.isHeartbeat) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
@@ -52,22 +54,32 @@ export function createChatRunRegistry(): ChatRunRegistry {
|
||||
|
||||
const shift = (sessionId: string) => {
|
||||
const queue = chatRunSessions.get(sessionId);
|
||||
if (!queue || queue.length === 0) return undefined;
|
||||
if (!queue || queue.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const entry = queue.shift();
|
||||
if (!queue.length) chatRunSessions.delete(sessionId);
|
||||
if (!queue.length) {
|
||||
chatRunSessions.delete(sessionId);
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
const remove = (sessionId: string, clientRunId: string, sessionKey?: string) => {
|
||||
const queue = chatRunSessions.get(sessionId);
|
||||
if (!queue || queue.length === 0) return undefined;
|
||||
if (!queue || queue.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const idx = queue.findIndex(
|
||||
(entry) =>
|
||||
entry.clientRunId === clientRunId && (sessionKey ? entry.sessionKey === sessionKey : true),
|
||||
);
|
||||
if (idx < 0) return undefined;
|
||||
if (idx < 0) {
|
||||
return undefined;
|
||||
}
|
||||
const [entry] = queue.splice(idx, 1);
|
||||
if (!queue.length) chatRunSessions.delete(sessionId);
|
||||
if (!queue.length) {
|
||||
chatRunSessions.delete(sessionId);
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
@@ -137,7 +149,9 @@ export function createAgentEventHandler({
|
||||
chatRunState.buffers.set(clientRunId, text);
|
||||
const now = Date.now();
|
||||
const last = chatRunState.deltaSentAt.get(clientRunId) ?? 0;
|
||||
if (now - last < 150) return;
|
||||
if (now - last < 150) {
|
||||
return;
|
||||
}
|
||||
chatRunState.deltaSentAt.set(clientRunId, now);
|
||||
const payload = {
|
||||
runId: clientRunId,
|
||||
@@ -202,12 +216,18 @@ export function createAgentEventHandler({
|
||||
const shouldEmitToolEvents = (runId: string, sessionKey?: string) => {
|
||||
const runContext = getAgentRunContext(runId);
|
||||
const runVerbose = normalizeVerboseLevel(runContext?.verboseLevel);
|
||||
if (runVerbose) return runVerbose === "on";
|
||||
if (!sessionKey) return false;
|
||||
if (runVerbose) {
|
||||
return runVerbose === "on";
|
||||
}
|
||||
if (!sessionKey) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const { cfg, entry } = loadSessionEntry(sessionKey);
|
||||
const sessionVerbose = normalizeVerboseLevel(entry?.verboseLevel);
|
||||
if (sessionVerbose) return sessionVerbose === "on";
|
||||
if (sessionVerbose) {
|
||||
return sessionVerbose === "on";
|
||||
}
|
||||
const defaultVerbose = normalizeVerboseLevel(cfg.agents?.defaults?.verboseDefault);
|
||||
return defaultVerbose === "on";
|
||||
} catch {
|
||||
|
||||
@@ -7,7 +7,9 @@ let maxChatHistoryMessagesBytes = DEFAULT_MAX_CHAT_HISTORY_MESSAGES_BYTES;
|
||||
export const getMaxChatHistoryMessagesBytes = () => maxChatHistoryMessagesBytes;
|
||||
|
||||
export const __setMaxChatHistoryMessagesBytesForTest = (value?: number) => {
|
||||
if (!process.env.VITEST && process.env.NODE_ENV !== "test") return;
|
||||
if (!process.env.VITEST && process.env.NODE_ENV !== "test") {
|
||||
return;
|
||||
}
|
||||
if (value === undefined) {
|
||||
maxChatHistoryMessagesBytes = DEFAULT_MAX_CHAT_HISTORY_MESSAGES_BYTES;
|
||||
return;
|
||||
@@ -20,7 +22,9 @@ export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
|
||||
export const getHandshakeTimeoutMs = () => {
|
||||
if (process.env.VITEST && process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS) {
|
||||
const parsed = Number(process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return DEFAULT_HANDSHAKE_TIMEOUT_MS;
|
||||
};
|
||||
|
||||
@@ -13,15 +13,21 @@ export type ResolveBonjourCliPathOptions = {
|
||||
|
||||
export function formatBonjourInstanceName(displayName: string) {
|
||||
const trimmed = displayName.trim();
|
||||
if (!trimmed) return "OpenClaw";
|
||||
if (/openclaw/i.test(trimmed)) return trimmed;
|
||||
if (!trimmed) {
|
||||
return "OpenClaw";
|
||||
}
|
||||
if (/openclaw/i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed} (OpenClaw)`;
|
||||
}
|
||||
|
||||
export function resolveBonjourCliPath(opts: ResolveBonjourCliPathOptions = {}): string | undefined {
|
||||
const env = opts.env ?? process.env;
|
||||
const envPath = env.OPENCLAW_CLI_PATH?.trim();
|
||||
if (envPath) return envPath;
|
||||
if (envPath) {
|
||||
return envPath;
|
||||
}
|
||||
|
||||
const statSync = opts.statSync ?? fs.statSync;
|
||||
const isFile = (candidate: string) => {
|
||||
@@ -35,7 +41,9 @@ export function resolveBonjourCliPath(opts: ResolveBonjourCliPathOptions = {}):
|
||||
const execPath = opts.execPath ?? process.execPath;
|
||||
const execDir = path.dirname(execPath);
|
||||
const siblingCli = path.join(execDir, "openclaw");
|
||||
if (isFile(siblingCli)) return siblingCli;
|
||||
if (isFile(siblingCli)) {
|
||||
return siblingCli;
|
||||
}
|
||||
|
||||
const argv = opts.argv ?? process.argv;
|
||||
const argvPath = argv[1];
|
||||
@@ -45,9 +53,13 @@ export function resolveBonjourCliPath(opts: ResolveBonjourCliPathOptions = {}):
|
||||
|
||||
const cwd = opts.cwd ?? process.cwd();
|
||||
const distCli = path.join(cwd, "dist", "index.js");
|
||||
if (isFile(distCli)) return distCli;
|
||||
if (isFile(distCli)) {
|
||||
return distCli;
|
||||
}
|
||||
const binCli = path.join(cwd, "bin", "openclaw");
|
||||
if (isFile(binCli)) return binCli;
|
||||
if (isFile(binCli)) {
|
||||
return binCli;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -60,8 +72,12 @@ export async function resolveTailnetDnsHint(opts?: {
|
||||
const env = opts?.env ?? process.env;
|
||||
const envRaw = env.OPENCLAW_TAILNET_DNS?.trim();
|
||||
const envValue = envRaw && envRaw.length > 0 ? envRaw.replace(/\.$/, "") : "";
|
||||
if (envValue) return envValue;
|
||||
if (opts?.enabled === false) return undefined;
|
||||
if (envValue) {
|
||||
return envValue;
|
||||
}
|
||||
if (opts?.enabled === false) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const exec =
|
||||
opts?.exec ??
|
||||
|
||||
+34
-13
@@ -69,7 +69,9 @@ export function createHooksRequestHandler(
|
||||
const { getHooksConfig, bindHost, port, logHooks, dispatchAgentHook, dispatchWakeHook } = opts;
|
||||
return async (req, res) => {
|
||||
const hooksConfig = getHooksConfig();
|
||||
if (!hooksConfig) return false;
|
||||
if (!hooksConfig) {
|
||||
return false;
|
||||
}
|
||||
const url = new URL(req.url ?? "/", `http://${bindHost}:${port}`);
|
||||
const basePath = hooksConfig.basePath;
|
||||
if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
|
||||
@@ -233,21 +235,30 @@ export function createGatewayHttpServer(opts: {
|
||||
|
||||
async function handleRequest(req: IncomingMessage, res: ServerResponse) {
|
||||
// Don't interfere with WebSocket upgrades; ws handles the 'upgrade' event.
|
||||
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") return;
|
||||
if (String(req.headers.upgrade ?? "").toLowerCase() === "websocket") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const configSnapshot = loadConfig();
|
||||
const trustedProxies = configSnapshot.gateway?.trustedProxies ?? [];
|
||||
if (await handleHooksRequest(req, res)) return;
|
||||
if (await handleHooksRequest(req, res)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
await handleToolsInvokeHttpRequest(req, res, {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
})
|
||||
)
|
||||
) {
|
||||
return;
|
||||
if (await handleSlackHttpRequest(req, res)) return;
|
||||
if (handlePluginRequest && (await handlePluginRequest(req, res))) return;
|
||||
}
|
||||
if (await handleSlackHttpRequest(req, res)) {
|
||||
return;
|
||||
}
|
||||
if (handlePluginRequest && (await handlePluginRequest(req, res))) {
|
||||
return;
|
||||
}
|
||||
if (openResponsesEnabled) {
|
||||
if (
|
||||
await handleOpenResponsesHttpRequest(req, res, {
|
||||
@@ -255,8 +266,9 @@ export function createGatewayHttpServer(opts: {
|
||||
config: openResponsesConfig,
|
||||
trustedProxies,
|
||||
})
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (openAiChatCompletionsEnabled) {
|
||||
if (
|
||||
@@ -264,12 +276,17 @@ export function createGatewayHttpServer(opts: {
|
||||
auth: resolvedAuth,
|
||||
trustedProxies,
|
||||
})
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (canvasHost) {
|
||||
if (await handleA2uiHttpRequest(req, res)) return;
|
||||
if (await canvasHost.handleHttpRequest(req, res)) return;
|
||||
if (await handleA2uiHttpRequest(req, res)) {
|
||||
return;
|
||||
}
|
||||
if (await canvasHost.handleHttpRequest(req, res)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (controlUiEnabled) {
|
||||
if (
|
||||
@@ -277,15 +294,17 @@ export function createGatewayHttpServer(opts: {
|
||||
basePath: controlUiBasePath,
|
||||
resolveAvatar: (agentId) => resolveAgentAvatar(configSnapshot, agentId),
|
||||
})
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
handleControlUiHttpRequest(req, res, {
|
||||
basePath: controlUiBasePath,
|
||||
config: configSnapshot,
|
||||
})
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
@@ -308,7 +327,9 @@ export function attachGatewayUpgradeHandler(opts: {
|
||||
}) {
|
||||
const { httpServer, wss, canvasHost } = opts;
|
||||
httpServer.on("upgrade", (req, socket, head) => {
|
||||
if (canvasHost?.handleUpgrade(req, socket, head)) return;
|
||||
if (canvasHost?.handleUpgrade(req, socket, head)) {
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
|
||||
@@ -75,7 +75,9 @@ export function startGatewayMaintenanceTimers(params: {
|
||||
const dedupeCleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of params.dedupe) {
|
||||
if (now - v.ts > DEDUPE_TTL_MS) params.dedupe.delete(k);
|
||||
if (now - v.ts > DEDUPE_TTL_MS) {
|
||||
params.dedupe.delete(k);
|
||||
}
|
||||
}
|
||||
if (params.dedupe.size > DEDUPE_MAX) {
|
||||
const entries = [...params.dedupe.entries()].toSorted((a, b) => a[1].ts - b[1].ts);
|
||||
@@ -85,7 +87,9 @@ export function startGatewayMaintenanceTimers(params: {
|
||||
}
|
||||
|
||||
for (const [runId, entry] of params.chatAbortControllers) {
|
||||
if (now <= entry.expiresAtMs) continue;
|
||||
if (now <= entry.expiresAtMs) {
|
||||
continue;
|
||||
}
|
||||
abortChatRunById(
|
||||
{
|
||||
chatAbortControllers: params.chatAbortControllers,
|
||||
@@ -103,7 +107,9 @@ export function startGatewayMaintenanceTimers(params: {
|
||||
|
||||
const ABORTED_RUN_TTL_MS = 60 * 60_000;
|
||||
for (const [runId, abortedAt] of params.chatRunState.abortedRuns) {
|
||||
if (now - abortedAt <= ABORTED_RUN_TTL_MS) continue;
|
||||
if (now - abortedAt <= ABORTED_RUN_TTL_MS) {
|
||||
continue;
|
||||
}
|
||||
params.chatRunState.abortedRuns.delete(runId);
|
||||
params.chatRunBuffers.delete(runId);
|
||||
params.chatDeltaSentAt.delete(runId);
|
||||
|
||||
@@ -91,11 +91,15 @@ const WRITE_METHODS = new Set([
|
||||
]);
|
||||
|
||||
function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) {
|
||||
if (!client?.connect) return null;
|
||||
if (!client?.connect) {
|
||||
return null;
|
||||
}
|
||||
const role = client.connect.role ?? "operator";
|
||||
const scopes = client.connect.scopes ?? [];
|
||||
if (NODE_ROLE_METHODS.has(method)) {
|
||||
if (role === "node") return null;
|
||||
if (role === "node") {
|
||||
return null;
|
||||
}
|
||||
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
|
||||
}
|
||||
if (role === "node") {
|
||||
@@ -104,7 +108,9 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c
|
||||
if (role !== "operator") {
|
||||
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
|
||||
}
|
||||
if (scopes.includes(ADMIN_SCOPE)) return null;
|
||||
if (scopes.includes(ADMIN_SCOPE)) {
|
||||
return null;
|
||||
}
|
||||
if (APPROVAL_METHODS.has(method) && !scopes.includes(APPROVALS_SCOPE)) {
|
||||
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.approvals");
|
||||
}
|
||||
@@ -117,10 +123,18 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c
|
||||
if (WRITE_METHODS.has(method) && !scopes.includes(WRITE_SCOPE)) {
|
||||
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.write");
|
||||
}
|
||||
if (APPROVAL_METHODS.has(method)) return null;
|
||||
if (PAIRING_METHODS.has(method)) return null;
|
||||
if (READ_METHODS.has(method)) return null;
|
||||
if (WRITE_METHODS.has(method)) return null;
|
||||
if (APPROVAL_METHODS.has(method)) {
|
||||
return null;
|
||||
}
|
||||
if (PAIRING_METHODS.has(method)) {
|
||||
return null;
|
||||
}
|
||||
if (READ_METHODS.has(method)) {
|
||||
return null;
|
||||
}
|
||||
if (WRITE_METHODS.has(method)) {
|
||||
return null;
|
||||
}
|
||||
if (ADMIN_METHOD_PREFIXES.some((prefix) => method.startsWith(prefix))) {
|
||||
return errorShape(ErrorCodes.INVALID_REQUEST, "missing scope: operator.admin");
|
||||
}
|
||||
|
||||
@@ -28,18 +28,26 @@ function recordAgentRunSnapshot(entry: AgentRunSnapshot) {
|
||||
}
|
||||
|
||||
function ensureAgentRunListener() {
|
||||
if (agentRunListenerStarted) return;
|
||||
if (agentRunListenerStarted) {
|
||||
return;
|
||||
}
|
||||
agentRunListenerStarted = true;
|
||||
onAgentEvent((evt) => {
|
||||
if (!evt) return;
|
||||
if (evt.stream !== "lifecycle") return;
|
||||
if (!evt) {
|
||||
return;
|
||||
}
|
||||
if (evt.stream !== "lifecycle") {
|
||||
return;
|
||||
}
|
||||
const phase = evt.data?.phase;
|
||||
if (phase === "start") {
|
||||
const startedAt = typeof evt.data?.startedAt === "number" ? evt.data.startedAt : undefined;
|
||||
agentRunStarts.set(evt.runId, startedAt ?? Date.now());
|
||||
return;
|
||||
}
|
||||
if (phase !== "end" && phase !== "error") return;
|
||||
if (phase !== "end" && phase !== "error") {
|
||||
return;
|
||||
}
|
||||
const startedAt =
|
||||
typeof evt.data?.startedAt === "number" ? evt.data.startedAt : agentRunStarts.get(evt.runId);
|
||||
const endedAt = typeof evt.data?.endedAt === "number" ? evt.data.endedAt : undefined;
|
||||
@@ -68,23 +76,35 @@ export async function waitForAgentJob(params: {
|
||||
const { runId, timeoutMs } = params;
|
||||
ensureAgentRunListener();
|
||||
const cached = getCachedAgentRun(runId);
|
||||
if (cached) return cached;
|
||||
if (timeoutMs <= 0) return null;
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
if (timeoutMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (entry: AgentRunSnapshot | null) => {
|
||||
if (settled) return;
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
unsubscribe();
|
||||
resolve(entry);
|
||||
};
|
||||
const unsubscribe = onAgentEvent((evt) => {
|
||||
if (!evt || evt.stream !== "lifecycle") return;
|
||||
if (evt.runId !== runId) return;
|
||||
if (!evt || evt.stream !== "lifecycle") {
|
||||
return;
|
||||
}
|
||||
if (evt.runId !== runId) {
|
||||
return;
|
||||
}
|
||||
const phase = evt.data?.phase;
|
||||
if (phase !== "end" && phase !== "error") return;
|
||||
if (phase !== "end" && phase !== "error") {
|
||||
return;
|
||||
}
|
||||
const cached = getCachedAgentRun(runId);
|
||||
if (cached) {
|
||||
finish(cached);
|
||||
|
||||
@@ -46,18 +46,32 @@ function normalizeNodeKey(value: string) {
|
||||
|
||||
function resolveBrowserNode(nodes: NodeSession[], query: string): NodeSession | null {
|
||||
const q = query.trim();
|
||||
if (!q) return null;
|
||||
if (!q) {
|
||||
return null;
|
||||
}
|
||||
const qNorm = normalizeNodeKey(q);
|
||||
const matches = nodes.filter((node) => {
|
||||
if (node.nodeId === q) return true;
|
||||
if (typeof node.remoteIp === "string" && node.remoteIp === q) return true;
|
||||
if (node.nodeId === q) {
|
||||
return true;
|
||||
}
|
||||
if (typeof node.remoteIp === "string" && node.remoteIp === q) {
|
||||
return true;
|
||||
}
|
||||
const name = typeof node.displayName === "string" ? node.displayName : "";
|
||||
if (name && normalizeNodeKey(name) === qNorm) return true;
|
||||
if (q.length >= 6 && node.nodeId.startsWith(q)) return true;
|
||||
if (name && normalizeNodeKey(name) === qNorm) {
|
||||
return true;
|
||||
}
|
||||
if (q.length >= 6 && node.nodeId.startsWith(q)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (matches.length === 1) return matches[0] ?? null;
|
||||
if (matches.length === 0) return null;
|
||||
if (matches.length === 1) {
|
||||
return matches[0] ?? null;
|
||||
}
|
||||
if (matches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(
|
||||
`ambiguous node: ${q} (matches: ${matches
|
||||
.map((node) => node.displayName || node.remoteIp || node.nodeId)
|
||||
@@ -71,7 +85,9 @@ function resolveBrowserNodeTarget(params: {
|
||||
}): NodeSession | null {
|
||||
const policy = params.cfg.gateway?.nodes?.browser;
|
||||
const mode = policy?.mode ?? "auto";
|
||||
if (mode === "off") return null;
|
||||
if (mode === "off") {
|
||||
return null;
|
||||
}
|
||||
const browserNodes = params.nodes.filter((node) => isBrowserNode(node));
|
||||
if (browserNodes.length === 0) {
|
||||
if (policy?.node?.trim()) {
|
||||
@@ -87,13 +103,19 @@ function resolveBrowserNodeTarget(params: {
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
if (mode === "manual") return null;
|
||||
if (browserNodes.length === 1) return browserNodes[0] ?? null;
|
||||
if (mode === "manual") {
|
||||
return null;
|
||||
}
|
||||
if (browserNodes.length === 1) {
|
||||
return browserNodes[0] ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function persistProxyFiles(files: BrowserProxyFile[] | undefined) {
|
||||
if (!files || files.length === 0) return new Map<string, string>();
|
||||
if (!files || files.length === 0) {
|
||||
return new Map<string, string>();
|
||||
}
|
||||
const mapping = new Map<string, string>();
|
||||
for (const file of files) {
|
||||
const buffer = Buffer.from(file.base64, "base64");
|
||||
@@ -104,7 +126,9 @@ async function persistProxyFiles(files: BrowserProxyFile[] | undefined) {
|
||||
}
|
||||
|
||||
function applyProxyPaths(result: unknown, mapping: Map<string, string>) {
|
||||
if (!result || typeof result !== "object") return;
|
||||
if (!result || typeof result !== "object") {
|
||||
return;
|
||||
}
|
||||
const obj = result as Record<string, unknown>;
|
||||
if (typeof obj.path === "string" && mapping.has(obj.path)) {
|
||||
obj.path = mapping.get(obj.path);
|
||||
|
||||
@@ -98,7 +98,9 @@ export const channelsHandlers: GatewayRequestHandlers = {
|
||||
const defaultRuntime = runtime.channels[channelId];
|
||||
const raw =
|
||||
accounts?.[accountId] ?? (accountId === defaultAccountId ? defaultRuntime : undefined);
|
||||
if (!raw) return undefined;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
return raw;
|
||||
};
|
||||
|
||||
@@ -171,7 +173,9 @@ export const channelsHandlers: GatewayRequestHandlers = {
|
||||
probe: probeResult,
|
||||
audit: auditResult,
|
||||
});
|
||||
if (lastProbeAt) snapshot.lastProbeAt = lastProbeAt;
|
||||
if (lastProbeAt) {
|
||||
snapshot.lastProbeAt = lastProbeAt;
|
||||
}
|
||||
const activity = getChannelActivity({
|
||||
channel: channelId as never,
|
||||
accountId,
|
||||
|
||||
@@ -56,8 +56,12 @@ function resolveTranscriptPath(params: {
|
||||
sessionFile?: string;
|
||||
}): string | null {
|
||||
const { sessionId, storePath, sessionFile } = params;
|
||||
if (sessionFile) return sessionFile;
|
||||
if (!storePath) return null;
|
||||
if (sessionFile) {
|
||||
return sessionFile;
|
||||
}
|
||||
if (!storePath) {
|
||||
return null;
|
||||
}
|
||||
return path.join(path.dirname(storePath), `${sessionId}.jsonl`);
|
||||
}
|
||||
|
||||
@@ -65,7 +69,9 @@ function ensureTranscriptFile(params: { transcriptPath: string; sessionId: strin
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
} {
|
||||
if (fs.existsSync(params.transcriptPath)) return { ok: true };
|
||||
if (fs.existsSync(params.transcriptPath)) {
|
||||
return { ok: true };
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(params.transcriptPath), { recursive: true });
|
||||
const header = {
|
||||
@@ -476,9 +482,13 @@ export const chatHandlers: GatewayRequestHandlers = {
|
||||
context.logGateway.warn(`webchat dispatch failed: ${formatForLog(err)}`);
|
||||
},
|
||||
deliver: async (payload, info) => {
|
||||
if (info.kind !== "final") return;
|
||||
if (info.kind !== "final") {
|
||||
return;
|
||||
}
|
||||
const text = payload.text?.trim() ?? "";
|
||||
if (!text) return;
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
finalReplyParts.push(text);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -33,7 +33,9 @@ import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
|
||||
function resolveBaseHash(params: unknown): string | null {
|
||||
const raw = (params as { baseHash?: unknown })?.baseHash;
|
||||
if (typeof raw !== "string") return null;
|
||||
if (typeof raw !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
@@ -43,7 +45,9 @@ function requireConfigBaseHash(
|
||||
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>,
|
||||
respond: RespondFn,
|
||||
): boolean {
|
||||
if (!snapshot.exists) return true;
|
||||
if (!snapshot.exists) {
|
||||
return true;
|
||||
}
|
||||
const snapshotHash = resolveConfigSnapshotHash(snapshot);
|
||||
if (!snapshotHash) {
|
||||
respond(
|
||||
|
||||
@@ -117,7 +117,9 @@ describe("exec approval handlers", () => {
|
||||
|
||||
const context = {
|
||||
broadcast: (event: string, payload: unknown) => {
|
||||
if (event !== "exec.approval.requested") return;
|
||||
if (event !== "exec.approval.requested") {
|
||||
return;
|
||||
}
|
||||
const id = (payload as { id?: string })?.id ?? "";
|
||||
void handlers["exec.approval.resolve"]({
|
||||
params: { id, decision: "allow-once" },
|
||||
|
||||
@@ -21,7 +21,9 @@ import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
|
||||
function resolveBaseHash(params: unknown): string | null {
|
||||
const raw = (params as { baseHash?: unknown })?.baseHash;
|
||||
if (typeof raw !== "string") return null;
|
||||
if (typeof raw !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
@@ -31,7 +33,9 @@ function requireApprovalsBaseHash(
|
||||
snapshot: ExecApprovalsSnapshot,
|
||||
respond: RespondFn,
|
||||
): boolean {
|
||||
if (!snapshot.exists) return true;
|
||||
if (!snapshot.exists) {
|
||||
return true;
|
||||
}
|
||||
if (!snapshot.hash) {
|
||||
respond(
|
||||
false,
|
||||
|
||||
@@ -25,12 +25,18 @@ function isRollingLogFile(file: string): boolean {
|
||||
|
||||
async function resolveLogFile(file: string): Promise<string> {
|
||||
const stat = await fs.stat(file).catch(() => null);
|
||||
if (stat) return file;
|
||||
if (!isRollingLogFile(file)) return file;
|
||||
if (stat) {
|
||||
return file;
|
||||
}
|
||||
if (!isRollingLogFile(file)) {
|
||||
return file;
|
||||
}
|
||||
|
||||
const dir = path.dirname(file);
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => null);
|
||||
if (!entries) return file;
|
||||
if (!entries) {
|
||||
return file;
|
||||
}
|
||||
|
||||
const candidates = await Promise.all(
|
||||
entries
|
||||
|
||||
@@ -38,9 +38,13 @@ export function uniqueSortedStrings(values: unknown[]) {
|
||||
}
|
||||
|
||||
export function safeParseJson(value: string | null | undefined): unknown {
|
||||
if (typeof value !== "string") return undefined;
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(trimmed) as unknown;
|
||||
} catch {
|
||||
|
||||
@@ -33,13 +33,19 @@ import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-comma
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
function isNodeEntry(entry: { role?: string; roles?: string[] }) {
|
||||
if (entry.role === "node") return true;
|
||||
if (Array.isArray(entry.roles) && entry.roles.includes("node")) return true;
|
||||
if (entry.role === "node") {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(entry.roles) && entry.roles.includes("node")) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeNodeInvokeResultParams(params: unknown): unknown {
|
||||
if (!params || typeof params !== "object") return params;
|
||||
if (!params || typeof params !== "object") {
|
||||
return params;
|
||||
}
|
||||
const raw = params as Record<string, unknown>;
|
||||
const normalized: Record<string, unknown> = { ...raw };
|
||||
if (normalized.payloadJSON === null) {
|
||||
@@ -284,11 +290,17 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
});
|
||||
|
||||
nodes.sort((a, b) => {
|
||||
if (a.connected !== b.connected) return a.connected ? -1 : 1;
|
||||
if (a.connected !== b.connected) {
|
||||
return a.connected ? -1 : 1;
|
||||
}
|
||||
const an = (a.displayName ?? a.nodeId).toLowerCase();
|
||||
const bn = (b.displayName ?? b.nodeId).toLowerCase();
|
||||
if (an < bn) return -1;
|
||||
if (an > bn) return 1;
|
||||
if (an < bn) {
|
||||
return -1;
|
||||
}
|
||||
if (an > bn) {
|
||||
return 1;
|
||||
}
|
||||
return a.nodeId.localeCompare(b.nodeId);
|
||||
});
|
||||
|
||||
|
||||
@@ -199,9 +199,15 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
messageId: result.messageId,
|
||||
channel,
|
||||
};
|
||||
if ("chatId" in result) payload.chatId = result.chatId;
|
||||
if ("channelId" in result) payload.channelId = result.channelId;
|
||||
if ("toJid" in result) payload.toJid = result.toJid;
|
||||
if ("chatId" in result) {
|
||||
payload.chatId = result.chatId;
|
||||
}
|
||||
if ("channelId" in result) {
|
||||
payload.channelId = result.channelId;
|
||||
}
|
||||
if ("toJid" in result) {
|
||||
payload.toJid = result.toJid;
|
||||
}
|
||||
if ("conversationId" in result) {
|
||||
payload.conversationId = result.conversationId;
|
||||
}
|
||||
@@ -324,10 +330,18 @@ export const sendHandlers: GatewayRequestHandlers = {
|
||||
messageId: result.messageId,
|
||||
channel,
|
||||
};
|
||||
if (result.toJid) payload.toJid = result.toJid;
|
||||
if (result.channelId) payload.channelId = result.channelId;
|
||||
if (result.conversationId) payload.conversationId = result.conversationId;
|
||||
if (result.pollId) payload.pollId = result.pollId;
|
||||
if (result.toJid) {
|
||||
payload.toJid = result.toJid;
|
||||
}
|
||||
if (result.channelId) {
|
||||
payload.channelId = result.channelId;
|
||||
}
|
||||
if (result.conversationId) {
|
||||
payload.conversationId = result.conversationId;
|
||||
}
|
||||
if (result.pollId) {
|
||||
payload.pollId = result.pollId;
|
||||
}
|
||||
context.dedupe.set(`poll:${idem}`, {
|
||||
ts: Date.now(),
|
||||
ok: true,
|
||||
|
||||
@@ -300,7 +300,9 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
const existed = Boolean(entry);
|
||||
const queueKeys = new Set<string>(target.storeKeys);
|
||||
queueKeys.add(target.canonicalKey);
|
||||
if (sessionId) queueKeys.add(sessionId);
|
||||
if (sessionId) {
|
||||
queueKeys.add(sessionId);
|
||||
}
|
||||
clearSessionQueues([...queueKeys]);
|
||||
stopSubagentsForRequester({ cfg, requesterSessionKey: target.canonicalKey });
|
||||
if (sessionId) {
|
||||
@@ -325,7 +327,9 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
store[primaryKey] = store[existingKey];
|
||||
delete store[existingKey];
|
||||
}
|
||||
if (store[primaryKey]) delete store[primaryKey];
|
||||
if (store[primaryKey]) {
|
||||
delete store[primaryKey];
|
||||
}
|
||||
});
|
||||
|
||||
const archived: string[] = [];
|
||||
@@ -336,7 +340,9 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
entry?.sessionFile,
|
||||
target.agentId,
|
||||
)) {
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
if (!fs.existsSync(candidate)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
archived.push(archiveFileOnDisk(candidate, "deleted"));
|
||||
} catch {
|
||||
@@ -443,7 +449,9 @@ export const sessionsHandlers: GatewayRequestHandlers = {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
const entryKey = compactTarget.primaryKey;
|
||||
const entryToUpdate = store[entryKey];
|
||||
if (!entryToUpdate) return;
|
||||
if (!entryToUpdate) {
|
||||
return;
|
||||
}
|
||||
delete entryToUpdate.inputTokens;
|
||||
delete entryToUpdate.outputTokens;
|
||||
delete entryToUpdate.totalTokens;
|
||||
|
||||
@@ -38,17 +38,23 @@ function collectSkillBins(entries: SkillEntry[]): string[] {
|
||||
const install = entry.metadata?.install ?? [];
|
||||
for (const bin of required) {
|
||||
const trimmed = bin.trim();
|
||||
if (trimmed) bins.add(trimmed);
|
||||
if (trimmed) {
|
||||
bins.add(trimmed);
|
||||
}
|
||||
}
|
||||
for (const bin of anyBins) {
|
||||
const trimmed = bin.trim();
|
||||
if (trimmed) bins.add(trimmed);
|
||||
if (trimmed) {
|
||||
bins.add(trimmed);
|
||||
}
|
||||
}
|
||||
for (const spec of install) {
|
||||
const specBins = spec?.bins ?? [];
|
||||
for (const bin of specBins) {
|
||||
const trimmed = String(bin).trim();
|
||||
if (trimmed) bins.add(trimmed);
|
||||
if (trimmed) {
|
||||
bins.add(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,7 +99,9 @@ export const skillsHandlers: GatewayRequestHandlers = {
|
||||
const bins = new Set<string>();
|
||||
for (const workspaceDir of workspaceDirs) {
|
||||
const entries = loadWorkspaceSkillEntries(workspaceDir, { config: cfg });
|
||||
for (const bin of collectSkillBins(entries)) bins.add(bin);
|
||||
for (const bin of collectSkillBins(entries)) {
|
||||
bins.add(bin);
|
||||
}
|
||||
}
|
||||
respond(true, { bins: [...bins].toSorted() }, undefined);
|
||||
},
|
||||
@@ -156,17 +164,25 @@ export const skillsHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
if (typeof p.apiKey === "string") {
|
||||
const trimmed = p.apiKey.trim();
|
||||
if (trimmed) current.apiKey = trimmed;
|
||||
else delete current.apiKey;
|
||||
if (trimmed) {
|
||||
current.apiKey = trimmed;
|
||||
} else {
|
||||
delete current.apiKey;
|
||||
}
|
||||
}
|
||||
if (p.env && typeof p.env === "object") {
|
||||
const nextEnv = current.env ? { ...current.env } : {};
|
||||
for (const [key, value] of Object.entries(p.env)) {
|
||||
const trimmedKey = key.trim();
|
||||
if (!trimmedKey) continue;
|
||||
if (!trimmedKey) {
|
||||
continue;
|
||||
}
|
||||
const trimmedVal = value.trim();
|
||||
if (!trimmedVal) delete nextEnv[trimmedKey];
|
||||
else nextEnv[trimmedKey] = trimmedVal;
|
||||
if (!trimmedVal) {
|
||||
delete nextEnv[trimmedKey];
|
||||
} else {
|
||||
nextEnv[trimmedKey] = trimmedVal;
|
||||
}
|
||||
}
|
||||
current.env = nextEnv;
|
||||
}
|
||||
|
||||
@@ -15,10 +15,14 @@ type CostUsageCacheEntry = {
|
||||
const costUsageCache = new Map<number, CostUsageCacheEntry>();
|
||||
|
||||
const parseDays = (raw: unknown): number => {
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) return Math.floor(raw);
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
return Math.floor(raw);
|
||||
}
|
||||
if (typeof raw === "string" && raw.trim() !== "") {
|
||||
const parsed = Number(raw);
|
||||
if (Number.isFinite(parsed)) return Math.floor(parsed);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
}
|
||||
return 30;
|
||||
};
|
||||
@@ -35,7 +39,9 @@ async function loadCostUsageSummaryCached(params: {
|
||||
}
|
||||
|
||||
if (cached?.inFlight) {
|
||||
if (cached.summary) return cached.summary;
|
||||
if (cached.summary) {
|
||||
return cached.summary;
|
||||
}
|
||||
return await cached.inFlight;
|
||||
}
|
||||
|
||||
@@ -46,7 +52,9 @@ async function loadCostUsageSummaryCached(params: {
|
||||
return summary;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (entry.summary) return entry.summary;
|
||||
if (entry.summary) {
|
||||
return entry.summary;
|
||||
}
|
||||
throw err;
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -60,7 +68,9 @@ async function loadCostUsageSummaryCached(params: {
|
||||
entry.inFlight = inFlight;
|
||||
costUsageCache.set(days, entry);
|
||||
|
||||
if (entry.summary) return entry.summary;
|
||||
if (entry.summary) {
|
||||
return entry.summary;
|
||||
}
|
||||
return await inFlight;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ import type { NodeRegistry } from "./node-registry.js";
|
||||
|
||||
const isMobilePlatform = (platform: unknown): boolean => {
|
||||
const p = typeof platform === "string" ? platform.trim().toLowerCase() : "";
|
||||
if (!p) return false;
|
||||
if (!p) {
|
||||
return false;
|
||||
}
|
||||
return p.startsWith("ios") || p.startsWith("ipados") || p.startsWith("android");
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ import { formatForLog } from "./ws-log.js";
|
||||
export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt: NodeEvent) => {
|
||||
switch (evt.event) {
|
||||
case "voice.transcript": {
|
||||
if (!evt.payloadJSON) return;
|
||||
if (!evt.payloadJSON) {
|
||||
return;
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(evt.payloadJSON) as unknown;
|
||||
@@ -24,8 +26,12 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
const obj =
|
||||
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;
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
if (text.length > 20_000) {
|
||||
return;
|
||||
}
|
||||
const sessionKeyRaw = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
|
||||
const cfg = loadConfig();
|
||||
const rawMainKey = normalizeMainKey(cfg.session?.mainKey);
|
||||
@@ -73,7 +79,9 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
return;
|
||||
}
|
||||
case "agent.request": {
|
||||
if (!evt.payloadJSON) return;
|
||||
if (!evt.payloadJSON) {
|
||||
return;
|
||||
}
|
||||
type AgentDeepLink = {
|
||||
message?: string;
|
||||
sessionKey?: string | null;
|
||||
@@ -91,8 +99,12 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
return;
|
||||
}
|
||||
const message = (link?.message ?? "").trim();
|
||||
if (!message) return;
|
||||
if (message.length > 20_000) return;
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
if (message.length > 20_000) {
|
||||
return;
|
||||
}
|
||||
|
||||
const channelRaw = typeof link?.channel === "string" ? link.channel.trim() : "";
|
||||
const channel = normalizeChannelId(channelRaw) ?? undefined;
|
||||
@@ -141,7 +153,9 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
return;
|
||||
}
|
||||
case "chat.subscribe": {
|
||||
if (!evt.payloadJSON) return;
|
||||
if (!evt.payloadJSON) {
|
||||
return;
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(evt.payloadJSON) as unknown;
|
||||
@@ -151,12 +165,16 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
const obj =
|
||||
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : {};
|
||||
const sessionKey = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
|
||||
if (!sessionKey) return;
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
ctx.nodeSubscribe(nodeId, sessionKey);
|
||||
return;
|
||||
}
|
||||
case "chat.unsubscribe": {
|
||||
if (!evt.payloadJSON) return;
|
||||
if (!evt.payloadJSON) {
|
||||
return;
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(evt.payloadJSON) as unknown;
|
||||
@@ -166,14 +184,18 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
const obj =
|
||||
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : {};
|
||||
const sessionKey = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
|
||||
if (!sessionKey) return;
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
ctx.nodeUnsubscribe(nodeId, sessionKey);
|
||||
return;
|
||||
}
|
||||
case "exec.started":
|
||||
case "exec.finished":
|
||||
case "exec.denied": {
|
||||
if (!evt.payloadJSON) return;
|
||||
if (!evt.payloadJSON) {
|
||||
return;
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(evt.payloadJSON) as unknown;
|
||||
@@ -184,7 +206,9 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : {};
|
||||
const sessionKey =
|
||||
typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : `node-${nodeId}`;
|
||||
if (!sessionKey) return;
|
||||
if (!sessionKey) {
|
||||
return;
|
||||
}
|
||||
const runId = typeof obj.runId === "string" ? obj.runId.trim() : "";
|
||||
const command = typeof obj.command === "string" ? obj.command.trim() : "";
|
||||
const exitCode =
|
||||
@@ -198,14 +222,20 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
let text = "";
|
||||
if (evt.event === "exec.started") {
|
||||
text = `Exec started (node=${nodeId}${runId ? ` id=${runId}` : ""})`;
|
||||
if (command) text += `: ${command}`;
|
||||
if (command) {
|
||||
text += `: ${command}`;
|
||||
}
|
||||
} else if (evt.event === "exec.finished") {
|
||||
const exitLabel = timedOut ? "timeout" : `code ${exitCode ?? "?"}`;
|
||||
text = `Exec finished (node=${nodeId}${runId ? ` id=${runId}` : ""}, ${exitLabel})`;
|
||||
if (output) text += `\n${output}`;
|
||||
if (output) {
|
||||
text += `\n${output}`;
|
||||
}
|
||||
} else {
|
||||
text = `Exec denied (node=${nodeId}${runId ? ` id=${runId}` : ""}${reason ? `, ${reason}` : ""})`;
|
||||
if (command) text += `: ${command}`;
|
||||
if (command) {
|
||||
text += `: ${command}`;
|
||||
}
|
||||
}
|
||||
|
||||
enqueueSystemEvent(text, { sessionKey, contextKey: runId ? `exec:${runId}` : "exec" });
|
||||
|
||||
@@ -39,14 +39,18 @@ export function createNodeSubscriptionManager(): NodeSubscriptionManager {
|
||||
const subscribe = (nodeId: string, sessionKey: string) => {
|
||||
const normalizedNodeId = nodeId.trim();
|
||||
const normalizedSessionKey = sessionKey.trim();
|
||||
if (!normalizedNodeId || !normalizedSessionKey) return;
|
||||
if (!normalizedNodeId || !normalizedSessionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nodeSet = nodeSubscriptions.get(normalizedNodeId);
|
||||
if (!nodeSet) {
|
||||
nodeSet = new Set<string>();
|
||||
nodeSubscriptions.set(normalizedNodeId, nodeSet);
|
||||
}
|
||||
if (nodeSet.has(normalizedSessionKey)) return;
|
||||
if (nodeSet.has(normalizedSessionKey)) {
|
||||
return;
|
||||
}
|
||||
nodeSet.add(normalizedSessionKey);
|
||||
|
||||
let sessionSet = sessionSubscribers.get(normalizedSessionKey);
|
||||
@@ -60,25 +64,35 @@ export function createNodeSubscriptionManager(): NodeSubscriptionManager {
|
||||
const unsubscribe = (nodeId: string, sessionKey: string) => {
|
||||
const normalizedNodeId = nodeId.trim();
|
||||
const normalizedSessionKey = sessionKey.trim();
|
||||
if (!normalizedNodeId || !normalizedSessionKey) return;
|
||||
if (!normalizedNodeId || !normalizedSessionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeSet = nodeSubscriptions.get(normalizedNodeId);
|
||||
nodeSet?.delete(normalizedSessionKey);
|
||||
if (nodeSet?.size === 0) nodeSubscriptions.delete(normalizedNodeId);
|
||||
if (nodeSet?.size === 0) {
|
||||
nodeSubscriptions.delete(normalizedNodeId);
|
||||
}
|
||||
|
||||
const sessionSet = sessionSubscribers.get(normalizedSessionKey);
|
||||
sessionSet?.delete(normalizedNodeId);
|
||||
if (sessionSet?.size === 0) sessionSubscribers.delete(normalizedSessionKey);
|
||||
if (sessionSet?.size === 0) {
|
||||
sessionSubscribers.delete(normalizedSessionKey);
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribeAll = (nodeId: string) => {
|
||||
const normalizedNodeId = nodeId.trim();
|
||||
const nodeSet = nodeSubscriptions.get(normalizedNodeId);
|
||||
if (!nodeSet) return;
|
||||
if (!nodeSet) {
|
||||
return;
|
||||
}
|
||||
for (const sessionKey of nodeSet) {
|
||||
const sessionSet = sessionSubscribers.get(sessionKey);
|
||||
sessionSet?.delete(normalizedNodeId);
|
||||
if (sessionSet?.size === 0) sessionSubscribers.delete(sessionKey);
|
||||
if (sessionSet?.size === 0) {
|
||||
sessionSubscribers.delete(sessionKey);
|
||||
}
|
||||
}
|
||||
nodeSubscriptions.delete(normalizedNodeId);
|
||||
};
|
||||
@@ -90,9 +104,13 @@ export function createNodeSubscriptionManager(): NodeSubscriptionManager {
|
||||
sendEvent?: NodeSendEventFn | null,
|
||||
) => {
|
||||
const normalizedSessionKey = sessionKey.trim();
|
||||
if (!normalizedSessionKey || !sendEvent) return;
|
||||
if (!normalizedSessionKey || !sendEvent) {
|
||||
return;
|
||||
}
|
||||
const subs = sessionSubscribers.get(normalizedSessionKey);
|
||||
if (!subs || subs.size === 0) return;
|
||||
if (!subs || subs.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadJSON = toPayloadJSON(payload);
|
||||
for (const nodeId of subs) {
|
||||
@@ -105,7 +123,9 @@ export function createNodeSubscriptionManager(): NodeSubscriptionManager {
|
||||
payload: unknown,
|
||||
sendEvent?: NodeSendEventFn | null,
|
||||
) => {
|
||||
if (!sendEvent) return;
|
||||
if (!sendEvent) {
|
||||
return;
|
||||
}
|
||||
const payloadJSON = toPayloadJSON(payload);
|
||||
for (const nodeId of nodeSubscriptions.keys()) {
|
||||
sendEvent({ nodeId, event, payloadJSON });
|
||||
@@ -118,7 +138,9 @@ export function createNodeSubscriptionManager(): NodeSubscriptionManager {
|
||||
listConnected?: NodeListConnectedFn | null,
|
||||
sendEvent?: NodeSendEventFn | null,
|
||||
) => {
|
||||
if (!sendEvent || !listConnected) return;
|
||||
if (!sendEvent || !listConnected) {
|
||||
return;
|
||||
}
|
||||
const payloadJSON = toPayloadJSON(payload);
|
||||
for (const node of listConnected()) {
|
||||
sendEvent({ nodeId: node.nodeId, event, payloadJSON });
|
||||
|
||||
@@ -16,7 +16,9 @@ import { loadSessionEntry } from "./session-utils.js";
|
||||
|
||||
export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
|
||||
const sentinel = await consumeRestartSentinel();
|
||||
if (!sentinel) return;
|
||||
if (!sentinel) {
|
||||
return;
|
||||
}
|
||||
const payload = sentinel.payload;
|
||||
const sessionKey = payload.sessionKey?.trim();
|
||||
const message = formatRestartSentinelMessage(payload);
|
||||
|
||||
@@ -129,7 +129,9 @@ export async function createGatewayRuntimeState(params: {
|
||||
httpServers.push(httpServer);
|
||||
httpBindHosts.push(host);
|
||||
} catch (err) {
|
||||
if (host === bindHosts[0]) throw err;
|
||||
if (host === bindHosts[0]) {
|
||||
throw err;
|
||||
}
|
||||
params.log.warn(
|
||||
`gateway: failed to bind loopback alias ${host}:${params.port} (${String(err)})`,
|
||||
);
|
||||
|
||||
@@ -5,7 +5,9 @@ import { toAgentRequestSessionKey } from "../routing/session-key.js";
|
||||
|
||||
export function resolveSessionKeyForRun(runId: string) {
|
||||
const cached = getAgentRunContext(runId)?.sessionKey;
|
||||
if (cached) return cached;
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const cfg = loadConfig();
|
||||
const storePath = resolveStorePath(cfg.session?.store);
|
||||
const store = loadSessionStore(storePath);
|
||||
|
||||
@@ -11,8 +11,12 @@ export function normalizeVoiceWakeTriggers(input: unknown): string[] {
|
||||
}
|
||||
|
||||
export function formatError(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === "string") return err;
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
const statusValue = (err as { status?: unknown })?.status;
|
||||
const codeValue = (err as { code?: unknown })?.code;
|
||||
const hasStatus = statusValue !== undefined;
|
||||
|
||||
@@ -5,15 +5,21 @@ export function createWizardSessionTracker() {
|
||||
|
||||
const findRunningWizard = (): string | null => {
|
||||
for (const [id, session] of wizardSessions) {
|
||||
if (session.getStatus() === "running") return id;
|
||||
if (session.getStatus() === "running") {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const purgeWizardSession = (id: string) => {
|
||||
const session = wizardSessions.get(id);
|
||||
if (!session) return;
|
||||
if (session.getStatus() === "running") return;
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
if (session.getStatus() === "running") {
|
||||
return;
|
||||
}
|
||||
wizardSessions.delete(id);
|
||||
};
|
||||
|
||||
|
||||
@@ -113,9 +113,13 @@ const createStubChannelPlugin = (params: {
|
||||
deliveryMode: "direct",
|
||||
resolveTarget: ({ to, allowFrom }) => {
|
||||
const trimmed = to?.trim() ?? "";
|
||||
if (trimmed) return { ok: true, to: trimmed };
|
||||
if (trimmed) {
|
||||
return { ok: true, to: trimmed };
|
||||
}
|
||||
const first = allowFrom?.[0];
|
||||
if (first) return { ok: true, to: String(first) };
|
||||
if (first) {
|
||||
return { ok: true, to: String(first) };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(`missing target for ${params.id}`),
|
||||
|
||||
@@ -422,7 +422,9 @@ describe("gateway server agent", () => {
|
||||
const finalChatP = onceMessage(
|
||||
webchatWs,
|
||||
(o) => {
|
||||
if (o.type !== "event" || o.event !== "chat") return false;
|
||||
if (o.type !== "event" || o.event !== "chat") {
|
||||
return false;
|
||||
}
|
||||
const payload = o.payload as { state?: unknown; runId?: unknown } | undefined;
|
||||
return payload?.state === "final" && payload.runId === "run-auto-1";
|
||||
},
|
||||
|
||||
@@ -17,7 +17,9 @@ import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-cha
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise<boolean> {
|
||||
if (ws.readyState === WebSocket.CLOSED) return true;
|
||||
if (ws.readyState === WebSocket.CLOSED) {
|
||||
return true;
|
||||
}
|
||||
return await new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolve(ws.readyState === WebSocket.CLOSED), timeoutMs);
|
||||
ws.once("close", () => {
|
||||
|
||||
@@ -19,7 +19,9 @@ installGatewayTestHooks({ scope: "suite" });
|
||||
async function waitFor(condition: () => boolean, timeoutMs = 1500) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (condition()) return;
|
||||
if (condition()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
throw new Error("timeout waiting for condition");
|
||||
@@ -127,8 +129,12 @@ describe("gateway server chat", () => {
|
||||
opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-1");
|
||||
const signal = opts?.abortSignal;
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!signal) return resolve();
|
||||
if (signal.aborted) return resolve();
|
||||
if (!signal) {
|
||||
return resolve();
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return resolve();
|
||||
}
|
||||
signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
});
|
||||
@@ -155,9 +161,12 @@ describe("gateway server chat", () => {
|
||||
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)
|
||||
if (spy.mock.calls.length > callsBefore) {
|
||||
return resolve();
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
return reject(new Error("timeout waiting for getReplyFromConfig"));
|
||||
}
|
||||
setTimeout(tick, 5);
|
||||
};
|
||||
tick();
|
||||
@@ -183,8 +192,12 @@ describe("gateway server chat", () => {
|
||||
opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-save-1");
|
||||
const signal = opts?.abortSignal;
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!signal) return resolve();
|
||||
if (signal.aborted) return resolve();
|
||||
if (!signal) {
|
||||
return resolve();
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return resolve();
|
||||
}
|
||||
signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
});
|
||||
@@ -222,8 +235,12 @@ describe("gateway server chat", () => {
|
||||
opts?.onAgentRunStart?.(opts.runId ?? "idem-stop-1");
|
||||
const signal = opts?.abortSignal;
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!signal) return resolve();
|
||||
if (signal.aborted) return resolve();
|
||||
if (!signal) {
|
||||
return resolve();
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return resolve();
|
||||
}
|
||||
signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
});
|
||||
@@ -303,8 +320,12 @@ describe("gateway server chat", () => {
|
||||
opts?.onAgentRunStart?.(opts.runId ?? "idem-abort-all-1");
|
||||
const signal = opts?.abortSignal;
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!signal) return resolve();
|
||||
if (signal.aborted) return resolve();
|
||||
if (!signal) {
|
||||
return resolve();
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return resolve();
|
||||
}
|
||||
signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
});
|
||||
@@ -369,8 +390,12 @@ describe("gateway server chat", () => {
|
||||
agentStartedResolve?.();
|
||||
const signal = opts?.abortSignal;
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!signal) return resolve();
|
||||
if (signal.aborted) return resolve();
|
||||
if (!signal) {
|
||||
return resolve();
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return resolve();
|
||||
}
|
||||
signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,9 @@ afterAll(async () => {
|
||||
async function waitFor(condition: () => boolean, timeoutMs = 1500) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (condition()) return;
|
||||
if (condition()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
throw new Error("timeout waiting for condition");
|
||||
@@ -273,11 +275,17 @@ describe("gateway server chat", () => {
|
||||
expect(defaultRes.ok).toBe(true);
|
||||
const defaultMsgs = defaultRes.payload?.messages ?? [];
|
||||
const firstContentText = (msg: unknown): string | undefined => {
|
||||
if (!msg || typeof msg !== "object") return undefined;
|
||||
if (!msg || typeof msg !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const content = (msg as { content?: unknown }).content;
|
||||
if (!Array.isArray(content) || content.length === 0) return undefined;
|
||||
if (!Array.isArray(content) || content.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const first = content[0];
|
||||
if (!first || typeof first !== "object") return undefined;
|
||||
if (!first || typeof first !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const text = (first as { text?: unknown }).text;
|
||||
return typeof text === "string" ? text : undefined;
|
||||
};
|
||||
@@ -287,7 +295,9 @@ describe("gateway server chat", () => {
|
||||
testState.agentConfig = undefined;
|
||||
testState.sessionStorePath = undefined;
|
||||
testState.sessionConfig = undefined;
|
||||
if (webchatWs) webchatWs.close();
|
||||
if (webchatWs) {
|
||||
webchatWs.close();
|
||||
}
|
||||
await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -27,8 +27,11 @@ beforeAll(async () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
if (previousToken === undefined) delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
else process.env.OPENCLAW_GATEWAY_TOKEN = previousToken;
|
||||
if (previousToken === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = previousToken;
|
||||
}
|
||||
});
|
||||
|
||||
const openClient = async () => {
|
||||
|
||||
@@ -39,7 +39,9 @@ async function waitForNonEmptyFile(pathname: string, timeoutMs = 2000) {
|
||||
const startedAt = process.hrtime.bigint();
|
||||
for (;;) {
|
||||
const raw = await fs.readFile(pathname, "utf-8").catch(() => "");
|
||||
if (raw.trim().length > 0) return raw;
|
||||
if (raw.trim().length > 0) {
|
||||
return raw;
|
||||
}
|
||||
const elapsedMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
|
||||
if (elapsedMs >= timeoutMs) {
|
||||
throw new Error(`timeout waiting for file ${pathname}`);
|
||||
|
||||
@@ -36,8 +36,11 @@ beforeAll(async () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
if (previousToken === undefined) delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
else process.env.OPENCLAW_GATEWAY_TOKEN = previousToken;
|
||||
if (previousToken === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = previousToken;
|
||||
}
|
||||
});
|
||||
|
||||
const openClient = async (opts?: Parameters<typeof connectOk>[1]) => {
|
||||
@@ -210,7 +213,9 @@ describe("gateway server health/presence", () => {
|
||||
expect(evt.payload?.presence?.length).toBeGreaterThan(0);
|
||||
expect(typeof evt.seq).toBe("number");
|
||||
}
|
||||
for (const c of clients) c.close();
|
||||
for (const c of clients) {
|
||||
c.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("presence includes client fingerprint", async () => {
|
||||
|
||||
@@ -366,8 +366,12 @@ export async function startGatewayServer(
|
||||
let skillsRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const skillsRefreshDelayMs = 30_000;
|
||||
const skillsChangeUnsub = registerSkillsChangeListener((event) => {
|
||||
if (event.reason === "remote-node") return;
|
||||
if (skillsRefreshTimer) clearTimeout(skillsRefreshTimer);
|
||||
if (event.reason === "remote-node") {
|
||||
return;
|
||||
}
|
||||
if (skillsRefreshTimer) {
|
||||
clearTimeout(skillsRefreshTimer);
|
||||
}
|
||||
skillsRefreshTimer = setTimeout(() => {
|
||||
skillsRefreshTimer = null;
|
||||
const latest = loadConfig();
|
||||
|
||||
@@ -376,7 +376,9 @@ describe("gateway server misc", () => {
|
||||
|
||||
test("auto-enables configured channel plugins on startup", async () => {
|
||||
const configPath = process.env.OPENCLAW_CONFIG_PATH;
|
||||
if (!configPath) throw new Error("Missing OPENCLAW_CONFIG_PATH");
|
||||
if (!configPath) {
|
||||
throw new Error("Missing OPENCLAW_CONFIG_PATH");
|
||||
}
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
|
||||
@@ -73,17 +73,23 @@ const connectNodeClient = async (params: {
|
||||
commands: params.commands,
|
||||
onEvent: params.onEvent,
|
||||
onHelloOk: () => {
|
||||
if (settled) return;
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolveReady?.();
|
||||
},
|
||||
onConnectError: (err) => {
|
||||
if (settled) return;
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
rejectReady?.(err);
|
||||
},
|
||||
onClose: (code, reason) => {
|
||||
if (settled) return;
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
rejectReady?.(new Error(`gateway closed (${code}): ${reason}`));
|
||||
},
|
||||
@@ -101,7 +107,9 @@ const connectNodeClient = async (params: {
|
||||
async function waitForSignal(check: () => boolean, timeoutMs = 2000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (check()) return;
|
||||
if (check()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error("timeout");
|
||||
|
||||
@@ -87,7 +87,9 @@ describe("sessions_send gateway loopback", () => {
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_send");
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_send tool");
|
||||
}
|
||||
|
||||
const result = await tool.execute("call-loopback", {
|
||||
sessionKey: "main",
|
||||
@@ -152,7 +154,9 @@ describe("sessions_send label lookup", () => {
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_send");
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_send tool");
|
||||
}
|
||||
|
||||
// Send using label instead of sessionKey
|
||||
const result = await tool.execute("call-by-label", {
|
||||
@@ -172,7 +176,9 @@ describe("sessions_send label lookup", () => {
|
||||
|
||||
it("returns error when label not found", { timeout: 60_000 }, async () => {
|
||||
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_send");
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_send tool");
|
||||
}
|
||||
|
||||
const result = await tool.execute("call-missing-label", {
|
||||
label: "nonexistent-label",
|
||||
@@ -186,7 +192,9 @@ describe("sessions_send label lookup", () => {
|
||||
|
||||
it("returns error when neither sessionKey nor label provided", { timeout: 60_000 }, async () => {
|
||||
const tool = createOpenClawTools().find((candidate) => candidate.name === "sessions_send");
|
||||
if (!tool) throw new Error("missing sessions_send tool");
|
||||
if (!tool) {
|
||||
throw new Error("missing sessions_send tool");
|
||||
}
|
||||
|
||||
const result = await tool.execute("call-no-key", {
|
||||
message: "hello",
|
||||
|
||||
@@ -56,8 +56,11 @@ beforeAll(async () => {
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
if (previousToken === undefined) delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
else process.env.OPENCLAW_GATEWAY_TOKEN = previousToken;
|
||||
if (previousToken === undefined) {
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = previousToken;
|
||||
}
|
||||
});
|
||||
|
||||
const openClient = async (opts?: Parameters<typeof connectOk>[1]) => {
|
||||
|
||||
@@ -3,8 +3,12 @@ import { Buffer } from "node:buffer";
|
||||
const CLOSE_REASON_MAX_BYTES = 120;
|
||||
|
||||
export function truncateCloseReason(reason: string, maxBytes = CLOSE_REASON_MAX_BYTES): string {
|
||||
if (!reason) return "invalid handshake";
|
||||
if (!reason) {
|
||||
return "invalid handshake";
|
||||
}
|
||||
const buf = Buffer.from(reason);
|
||||
if (buf.length <= maxBytes) return reason;
|
||||
if (buf.length <= maxBytes) {
|
||||
return reason;
|
||||
}
|
||||
return buf.subarray(0, maxBytes).toString();
|
||||
}
|
||||
|
||||
@@ -18,7 +18,9 @@ export function createGatewayPluginRequestHandler(params: {
|
||||
return async (req, res) => {
|
||||
const routes = registry.httpRoutes ?? [];
|
||||
const handlers = registry.httpHandlers ?? [];
|
||||
if (routes.length === 0 && handlers.length === 0) return false;
|
||||
if (routes.length === 0 && handlers.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (routes.length > 0) {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
@@ -42,7 +44,9 @@ export function createGatewayPluginRequestHandler(params: {
|
||||
for (const entry of handlers) {
|
||||
try {
|
||||
const handled = await entry.handler(req, res);
|
||||
if (handled) return true;
|
||||
if (handled) {
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn(`plugin http handler failed (${entry.pluginId}): ${String(err)}`);
|
||||
if (!res.headersSent) {
|
||||
|
||||
@@ -95,7 +95,9 @@ export function attachGatewayWsConnectionHandler(params: {
|
||||
let lastFrameId: string | undefined;
|
||||
|
||||
const setCloseCause = (cause: string, meta?: Record<string, unknown>) => {
|
||||
if (!closeCause) closeCause = cause;
|
||||
if (!closeCause) {
|
||||
closeCause = cause;
|
||||
}
|
||||
if (meta && Object.keys(meta).length > 0) {
|
||||
closeMeta = { ...closeMeta, ...meta };
|
||||
}
|
||||
@@ -125,10 +127,14 @@ export function attachGatewayWsConnectionHandler(params: {
|
||||
});
|
||||
|
||||
const close = (code = 1000, reason?: string) => {
|
||||
if (closed) return;
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
clearTimeout(handshakeTimer);
|
||||
if (client) clients.delete(client);
|
||||
if (client) {
|
||||
clients.delete(client);
|
||||
}
|
||||
try {
|
||||
socket.close(code, reason);
|
||||
} catch {
|
||||
|
||||
@@ -61,10 +61,14 @@ const DEVICE_SIGNATURE_SKEW_MS = 10 * 60 * 1000;
|
||||
|
||||
function resolveHostName(hostHeader?: string): string {
|
||||
const host = (hostHeader ?? "").trim().toLowerCase();
|
||||
if (!host) return "";
|
||||
if (!host) {
|
||||
return "";
|
||||
}
|
||||
if (host.startsWith("[")) {
|
||||
const end = host.indexOf("]");
|
||||
if (end !== -1) return host.slice(1, end);
|
||||
if (end !== -1) {
|
||||
return host.slice(1, end);
|
||||
}
|
||||
}
|
||||
const [name] = host.split(":");
|
||||
return name ?? "";
|
||||
@@ -229,7 +233,9 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
|
||||
|
||||
socket.on("message", async (data) => {
|
||||
if (isClosed()) return;
|
||||
if (isClosed()) {
|
||||
return;
|
||||
}
|
||||
const text = rawDataToString(data);
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
@@ -681,30 +687,40 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
const isPaired = paired?.publicKey === devicePublicKey;
|
||||
if (!isPaired) {
|
||||
const ok = await requirePairing("not-paired");
|
||||
if (!ok) return;
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const allowedRoles = new Set(
|
||||
Array.isArray(paired.roles) ? paired.roles : paired.role ? [paired.role] : [],
|
||||
);
|
||||
if (allowedRoles.size === 0) {
|
||||
const ok = await requirePairing("role-upgrade", paired);
|
||||
if (!ok) return;
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
} else if (!allowedRoles.has(role)) {
|
||||
const ok = await requirePairing("role-upgrade", paired);
|
||||
if (!ok) return;
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const pairedScopes = Array.isArray(paired.scopes) ? paired.scopes : [];
|
||||
if (scopes.length > 0) {
|
||||
if (pairedScopes.length === 0) {
|
||||
const ok = await requirePairing("scope-upgrade", paired);
|
||||
if (!ok) return;
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const allowedScopes = new Set(pairedScopes);
|
||||
const missingScope = scopes.find((scope) => !allowedScopes.has(scope));
|
||||
if (missingScope) {
|
||||
const ok = await requirePairing("scope-upgrade", paired);
|
||||
if (!ok) return;
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -828,7 +844,9 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
const instanceIdRaw = connectParams.client.instanceId;
|
||||
const instanceId = typeof instanceIdRaw === "string" ? instanceIdRaw.trim() : "";
|
||||
const nodeIdsForPairing = new Set<string>([nodeSession.nodeId]);
|
||||
if (instanceId) nodeIdsForPairing.add(instanceId);
|
||||
if (instanceId) {
|
||||
nodeIdsForPairing.add(instanceId);
|
||||
}
|
||||
for (const nodeId of nodeIdsForPairing) {
|
||||
void updatePairedNodeMetadata(nodeId, {
|
||||
lastConnectedAtMs: nodeSession.connectedAtMs,
|
||||
|
||||
@@ -14,12 +14,16 @@ export function readSessionMessages(
|
||||
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile);
|
||||
|
||||
const filePath = candidates.find((p) => fs.existsSync(p));
|
||||
if (!filePath) return [];
|
||||
if (!filePath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/);
|
||||
const messages: unknown[] = [];
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed?.message) {
|
||||
@@ -39,7 +43,9 @@ export function resolveSessionTranscriptCandidates(
|
||||
agentId?: string,
|
||||
): string[] {
|
||||
const candidates: string[] = [];
|
||||
if (sessionFile) candidates.push(sessionFile);
|
||||
if (sessionFile) {
|
||||
candidates.push(sessionFile);
|
||||
}
|
||||
if (storePath) {
|
||||
const dir = path.dirname(storePath);
|
||||
candidates.push(path.join(dir, `${sessionId}.jsonl`));
|
||||
@@ -71,7 +77,9 @@ export function capArrayByJsonBytes<T>(
|
||||
items: T[],
|
||||
maxBytes: number,
|
||||
): { items: T[]; bytes: number } {
|
||||
if (items.length === 0) return { items, bytes: 2 };
|
||||
if (items.length === 0) {
|
||||
return { items, bytes: 2 };
|
||||
}
|
||||
const parts = items.map((item) => jsonUtf8Bytes(item));
|
||||
let bytes = 2 + parts.reduce((a, b) => a + b, 0) + (items.length - 1);
|
||||
let start = 0;
|
||||
@@ -91,13 +99,21 @@ type TranscriptMessage = {
|
||||
};
|
||||
|
||||
function extractTextFromContent(content: TranscriptMessage["content"]): string | null {
|
||||
if (typeof content === "string") return content.trim() || null;
|
||||
if (!Array.isArray(content)) return null;
|
||||
if (typeof content === "string") {
|
||||
return content.trim() || null;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return null;
|
||||
}
|
||||
for (const part of content) {
|
||||
if (!part || typeof part.text !== "string") continue;
|
||||
if (!part || typeof part.text !== "string") {
|
||||
continue;
|
||||
}
|
||||
if (part.type === "text" || part.type === "output_text" || part.type === "input_text") {
|
||||
const trimmed = part.text.trim();
|
||||
if (trimmed) return trimmed;
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -111,25 +127,33 @@ export function readFirstUserMessageFromTranscript(
|
||||
): string | null {
|
||||
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId);
|
||||
const filePath = candidates.find((p) => fs.existsSync(p));
|
||||
if (!filePath) return null;
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
fd = fs.openSync(filePath, "r");
|
||||
const buf = Buffer.alloc(8192);
|
||||
const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
|
||||
if (bytesRead === 0) return null;
|
||||
if (bytesRead === 0) {
|
||||
return null;
|
||||
}
|
||||
const chunk = buf.toString("utf-8", 0, bytesRead);
|
||||
const lines = chunk.split(/\r?\n/).slice(0, MAX_LINES_TO_SCAN);
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
const msg = parsed?.message as TranscriptMessage | undefined;
|
||||
if (msg?.role === "user") {
|
||||
const text = extractTextFromContent(msg.content);
|
||||
if (text) return text;
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
@@ -138,7 +162,9 @@ export function readFirstUserMessageFromTranscript(
|
||||
} catch {
|
||||
// file read error
|
||||
} finally {
|
||||
if (fd !== null) fs.closeSync(fd);
|
||||
if (fd !== null) {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -154,14 +180,18 @@ export function readLastMessagePreviewFromTranscript(
|
||||
): string | null {
|
||||
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId);
|
||||
const filePath = candidates.find((p) => fs.existsSync(p));
|
||||
if (!filePath) return null;
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let fd: number | null = null;
|
||||
try {
|
||||
fd = fs.openSync(filePath, "r");
|
||||
const stat = fs.fstatSync(fd);
|
||||
const size = stat.size;
|
||||
if (size === 0) return null;
|
||||
if (size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const readStart = Math.max(0, size - LAST_MSG_MAX_BYTES);
|
||||
const readLen = Math.min(size, LAST_MSG_MAX_BYTES);
|
||||
@@ -179,7 +209,9 @@ export function readLastMessagePreviewFromTranscript(
|
||||
const msg = parsed?.message as TranscriptMessage | undefined;
|
||||
if (msg?.role === "user" || msg?.role === "assistant") {
|
||||
const text = extractTextFromContent(msg.content);
|
||||
if (text) return text;
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip malformed
|
||||
@@ -188,7 +220,9 @@ export function readLastMessagePreviewFromTranscript(
|
||||
} catch {
|
||||
// file error
|
||||
} finally {
|
||||
if (fd !== null) fs.closeSync(fd);
|
||||
if (fd !== null) {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -211,7 +245,9 @@ type TranscriptPreviewMessage = {
|
||||
};
|
||||
|
||||
function normalizeRole(role: string | undefined, isTool: boolean): SessionPreviewItem["role"] {
|
||||
if (isTool) return "tool";
|
||||
if (isTool) {
|
||||
return "tool";
|
||||
}
|
||||
switch ((role ?? "").toLowerCase()) {
|
||||
case "user":
|
||||
return "user";
|
||||
@@ -227,8 +263,12 @@ function normalizeRole(role: string | undefined, isTool: boolean): SessionPrevie
|
||||
}
|
||||
|
||||
function truncatePreviewText(text: string, maxChars: number): string {
|
||||
if (maxChars <= 0 || text.length <= maxChars) return text;
|
||||
if (maxChars <= 3) return text.slice(0, maxChars);
|
||||
if (maxChars <= 0 || text.length <= maxChars) {
|
||||
return text;
|
||||
}
|
||||
if (maxChars <= 3) {
|
||||
return text.slice(0, maxChars);
|
||||
}
|
||||
return `${text.slice(0, maxChars - 3)}...`;
|
||||
}
|
||||
|
||||
@@ -253,10 +293,16 @@ function extractPreviewText(message: TranscriptPreviewMessage): string | null {
|
||||
}
|
||||
|
||||
function isToolCall(message: TranscriptPreviewMessage): boolean {
|
||||
if (message.toolName || message.tool_name) return true;
|
||||
if (!Array.isArray(message.content)) return false;
|
||||
if (message.toolName || message.tool_name) {
|
||||
return true;
|
||||
}
|
||||
if (!Array.isArray(message.content)) {
|
||||
return false;
|
||||
}
|
||||
return message.content.some((entry) => {
|
||||
if (entry?.name) return true;
|
||||
if (entry?.name) {
|
||||
return true;
|
||||
}
|
||||
const raw = typeof entry?.type === "string" ? entry.type.toLowerCase() : "";
|
||||
return raw === "toolcall" || raw === "tool_call";
|
||||
});
|
||||
@@ -279,10 +325,14 @@ function extractToolNames(message: TranscriptPreviewMessage): string[] {
|
||||
}
|
||||
|
||||
function extractMediaSummary(message: TranscriptPreviewMessage): string | null {
|
||||
if (!Array.isArray(message.content)) return null;
|
||||
if (!Array.isArray(message.content)) {
|
||||
return null;
|
||||
}
|
||||
for (const entry of message.content) {
|
||||
const raw = typeof entry?.type === "string" ? entry.type.trim().toLowerCase() : "";
|
||||
if (!raw || raw === "text" || raw === "toolcall" || raw === "tool_call") continue;
|
||||
if (!raw || raw === "text" || raw === "toolcall" || raw === "tool_call") {
|
||||
continue;
|
||||
}
|
||||
return `[${raw}]`;
|
||||
}
|
||||
return null;
|
||||
@@ -304,15 +354,21 @@ function buildPreviewItems(
|
||||
const shown = toolNames.slice(0, 2);
|
||||
const overflow = toolNames.length - shown.length;
|
||||
text = `call ${shown.join(", ")}`;
|
||||
if (overflow > 0) text += ` +${overflow}`;
|
||||
if (overflow > 0) {
|
||||
text += ` +${overflow}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!text) {
|
||||
text = extractMediaSummary(message);
|
||||
}
|
||||
if (!text) continue;
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
let trimmed = text.trim();
|
||||
if (!trimmed) continue;
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
if (role === "user") {
|
||||
trimmed = stripEnvelope(trimmed);
|
||||
}
|
||||
@@ -320,7 +376,9 @@ function buildPreviewItems(
|
||||
items.push({ role, text: trimmed });
|
||||
}
|
||||
|
||||
if (items.length <= maxItems) return items;
|
||||
if (items.length <= maxItems) {
|
||||
return items;
|
||||
}
|
||||
return items.slice(-maxItems);
|
||||
}
|
||||
|
||||
@@ -334,7 +392,9 @@ function readRecentMessagesFromTranscript(
|
||||
fd = fs.openSync(filePath, "r");
|
||||
const stat = fs.fstatSync(fd);
|
||||
const size = stat.size;
|
||||
if (size === 0) return [];
|
||||
if (size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const readStart = Math.max(0, size - readBytes);
|
||||
const readLen = Math.min(size, readBytes);
|
||||
@@ -353,7 +413,9 @@ function readRecentMessagesFromTranscript(
|
||||
const msg = parsed?.message as TranscriptPreviewMessage | undefined;
|
||||
if (msg && typeof msg === "object") {
|
||||
collected.push(msg);
|
||||
if (collected.length >= maxMessages) break;
|
||||
if (collected.length >= maxMessages) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
@@ -363,7 +425,9 @@ function readRecentMessagesFromTranscript(
|
||||
} catch {
|
||||
return [];
|
||||
} finally {
|
||||
if (fd !== null) fs.closeSync(fd);
|
||||
if (fd !== null) {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,7 +441,9 @@ export function readSessionPreviewItemsFromTranscript(
|
||||
): SessionPreviewItem[] {
|
||||
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath, sessionFile, agentId);
|
||||
const filePath = candidates.find((p) => fs.existsSync(p));
|
||||
if (!filePath) return [];
|
||||
if (!filePath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const boundedItems = Math.max(1, Math.min(maxItems, 50));
|
||||
const boundedChars = Math.max(20, Math.min(maxChars, 2000));
|
||||
|
||||
+105
-35
@@ -78,9 +78,15 @@ function resolveAvatarMime(filePath: string): string {
|
||||
}
|
||||
|
||||
function isWorkspaceRelativePath(value: string): boolean {
|
||||
if (!value) return false;
|
||||
if (value.startsWith("~")) return false;
|
||||
if (AVATAR_SCHEME_RE.test(value) && !WINDOWS_ABS_RE.test(value)) return false;
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
if (value.startsWith("~")) {
|
||||
return false;
|
||||
}
|
||||
if (AVATAR_SCHEME_RE.test(value) && !WINDOWS_ABS_RE.test(value)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -89,19 +95,31 @@ function resolveIdentityAvatarUrl(
|
||||
agentId: string,
|
||||
avatar: string | undefined,
|
||||
): string | undefined {
|
||||
if (!avatar) return undefined;
|
||||
if (!avatar) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = avatar.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (AVATAR_DATA_RE.test(trimmed) || AVATAR_HTTP_RE.test(trimmed)) return trimmed;
|
||||
if (!isWorkspaceRelativePath(trimmed)) return undefined;
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (AVATAR_DATA_RE.test(trimmed) || AVATAR_HTTP_RE.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
if (!isWorkspaceRelativePath(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const workspaceRoot = path.resolve(workspaceDir);
|
||||
const resolved = path.resolve(workspaceRoot, trimmed);
|
||||
const relative = path.relative(workspaceRoot, resolved);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) return undefined;
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const stat = fs.statSync(resolved);
|
||||
if (!stat.isFile() || stat.size > AVATAR_MAX_BYTES) return undefined;
|
||||
if (!stat.isFile() || stat.size > AVATAR_MAX_BYTES) {
|
||||
return undefined;
|
||||
}
|
||||
const buffer = fs.readFileSync(resolved);
|
||||
const mime = resolveAvatarMime(resolved);
|
||||
return `data:${mime};base64,${buffer.toString("base64")}`;
|
||||
@@ -121,10 +139,14 @@ function formatSessionIdPrefix(sessionId: string, updatedAt?: number | null): st
|
||||
}
|
||||
|
||||
function truncateTitle(text: string, maxLen: number): string {
|
||||
if (text.length <= maxLen) return text;
|
||||
if (text.length <= maxLen) {
|
||||
return text;
|
||||
}
|
||||
const cut = text.slice(0, maxLen - 1);
|
||||
const lastSpace = cut.lastIndexOf(" ");
|
||||
if (lastSpace > maxLen * 0.6) return cut.slice(0, lastSpace) + "…";
|
||||
if (lastSpace > maxLen * 0.6) {
|
||||
return cut.slice(0, lastSpace) + "…";
|
||||
}
|
||||
return cut + "…";
|
||||
}
|
||||
|
||||
@@ -132,7 +154,9 @@ export function deriveSessionTitle(
|
||||
entry: SessionEntry | undefined,
|
||||
firstUserMessage?: string | null,
|
||||
): string | undefined {
|
||||
if (!entry) return undefined;
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (entry.displayName?.trim()) {
|
||||
return entry.displayName.trim();
|
||||
@@ -166,8 +190,12 @@ export function loadSessionEntry(sessionKey: string) {
|
||||
}
|
||||
|
||||
export function classifySessionKey(key: string, entry?: SessionEntry): GatewaySessionRow["kind"] {
|
||||
if (key === "global") return "global";
|
||||
if (key === "unknown") return "unknown";
|
||||
if (key === "global") {
|
||||
return "global";
|
||||
}
|
||||
if (key === "unknown") {
|
||||
return "unknown";
|
||||
}
|
||||
if (entry?.chatType === "group" || entry?.chatType === "channel") {
|
||||
return "group";
|
||||
}
|
||||
@@ -216,7 +244,9 @@ function listConfiguredAgentIds(cfg: OpenClawConfig): string[] {
|
||||
if (agents.length > 0) {
|
||||
const ids = new Set<string>();
|
||||
for (const entry of agents) {
|
||||
if (entry?.id) ids.add(normalizeAgentId(entry.id));
|
||||
if (entry?.id) {
|
||||
ids.add(normalizeAgentId(entry.id));
|
||||
}
|
||||
}
|
||||
const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg));
|
||||
ids.add(defaultId);
|
||||
@@ -230,7 +260,9 @@ function listConfiguredAgentIds(cfg: OpenClawConfig): string[] {
|
||||
const ids = new Set<string>();
|
||||
const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg));
|
||||
ids.add(defaultId);
|
||||
for (const id of listExistingAgentIdsFromDisk()) ids.add(id);
|
||||
for (const id of listExistingAgentIdsFromDisk()) {
|
||||
ids.add(id);
|
||||
}
|
||||
const sorted = Array.from(ids).filter(Boolean);
|
||||
sorted.sort((a, b) => a.localeCompare(b));
|
||||
if (sorted.includes(defaultId)) {
|
||||
@@ -253,7 +285,9 @@ export function listAgentsForGateway(cfg: OpenClawConfig): {
|
||||
{ name?: string; identity?: GatewayAgentRow["identity"] }
|
||||
>();
|
||||
for (const entry of cfg.agents?.list ?? []) {
|
||||
if (!entry?.id) continue;
|
||||
if (!entry?.id) {
|
||||
continue;
|
||||
}
|
||||
const identity = entry.identity
|
||||
? {
|
||||
name: entry.identity.name?.trim() || undefined,
|
||||
@@ -296,8 +330,12 @@ export function listAgentsForGateway(cfg: OpenClawConfig): {
|
||||
}
|
||||
|
||||
function canonicalizeSessionKeyForAgent(agentId: string, key: string): string {
|
||||
if (key === "global" || key === "unknown") return key;
|
||||
if (key.startsWith("agent:")) return key;
|
||||
if (key === "global" || key === "unknown") {
|
||||
return key;
|
||||
}
|
||||
if (key.startsWith("agent:")) {
|
||||
return key;
|
||||
}
|
||||
return `agent:${normalizeAgentId(agentId)}:${key}`;
|
||||
}
|
||||
|
||||
@@ -310,8 +348,12 @@ export function resolveSessionStoreKey(params: {
|
||||
sessionKey: string;
|
||||
}): string {
|
||||
const raw = params.sessionKey.trim();
|
||||
if (!raw) return raw;
|
||||
if (raw === "global" || raw === "unknown") return raw;
|
||||
if (!raw) {
|
||||
return raw;
|
||||
}
|
||||
if (raw === "global" || raw === "unknown") {
|
||||
return raw;
|
||||
}
|
||||
|
||||
const parsed = parseAgentSessionKey(raw);
|
||||
if (parsed) {
|
||||
@@ -321,7 +363,9 @@ export function resolveSessionStoreKey(params: {
|
||||
agentId,
|
||||
sessionKey: raw,
|
||||
});
|
||||
if (canonical !== raw) return canonical;
|
||||
if (canonical !== raw) {
|
||||
return canonical;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
@@ -338,15 +382,23 @@ function resolveSessionStoreAgentId(cfg: OpenClawConfig, canonicalKey: string):
|
||||
return resolveDefaultStoreAgentId(cfg);
|
||||
}
|
||||
const parsed = parseAgentSessionKey(canonicalKey);
|
||||
if (parsed?.agentId) return normalizeAgentId(parsed.agentId);
|
||||
if (parsed?.agentId) {
|
||||
return normalizeAgentId(parsed.agentId);
|
||||
}
|
||||
return resolveDefaultStoreAgentId(cfg);
|
||||
}
|
||||
|
||||
function canonicalizeSpawnedByForAgent(agentId: string, spawnedBy?: string): string | undefined {
|
||||
const raw = spawnedBy?.trim();
|
||||
if (!raw) return undefined;
|
||||
if (raw === "global" || raw === "unknown") return raw;
|
||||
if (raw.startsWith("agent:")) return raw;
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
if (raw === "global" || raw === "unknown") {
|
||||
return raw;
|
||||
}
|
||||
if (raw.startsWith("agent:")) {
|
||||
return raw;
|
||||
}
|
||||
return `agent:${normalizeAgentId(agentId)}:${raw}`;
|
||||
}
|
||||
|
||||
@@ -372,7 +424,9 @@ export function resolveGatewaySessionStoreTarget(params: { cfg: OpenClawConfig;
|
||||
|
||||
const storeKeys = new Set<string>();
|
||||
storeKeys.add(canonicalKey);
|
||||
if (key && key !== canonicalKey) storeKeys.add(key);
|
||||
if (key && key !== canonicalKey) {
|
||||
storeKeys.add(key);
|
||||
}
|
||||
return {
|
||||
agentId,
|
||||
storePath,
|
||||
@@ -509,23 +563,37 @@ export function listSessionsFromStore(params: {
|
||||
|
||||
let sessions = Object.entries(store)
|
||||
.filter(([key]) => {
|
||||
if (!includeGlobal && key === "global") return false;
|
||||
if (!includeUnknown && key === "unknown") return false;
|
||||
if (!includeGlobal && key === "global") {
|
||||
return false;
|
||||
}
|
||||
if (!includeUnknown && key === "unknown") {
|
||||
return false;
|
||||
}
|
||||
if (agentId) {
|
||||
if (key === "global" || key === "unknown") return false;
|
||||
if (key === "global" || key === "unknown") {
|
||||
return false;
|
||||
}
|
||||
const parsed = parseAgentSessionKey(key);
|
||||
if (!parsed) return false;
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
return normalizeAgentId(parsed.agentId) === agentId;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.filter(([key, entry]) => {
|
||||
if (!spawnedBy) return true;
|
||||
if (key === "unknown" || key === "global") return false;
|
||||
if (!spawnedBy) {
|
||||
return true;
|
||||
}
|
||||
if (key === "unknown" || key === "global") {
|
||||
return false;
|
||||
}
|
||||
return entry?.spawnedBy === spawnedBy;
|
||||
})
|
||||
.filter(([, entry]) => {
|
||||
if (!label) return true;
|
||||
if (!label) {
|
||||
return true;
|
||||
}
|
||||
return entry?.label === label;
|
||||
})
|
||||
.map(([key, entry]) => {
|
||||
@@ -628,7 +696,9 @@ export function listSessionsFromStore(params: {
|
||||
storePath,
|
||||
entry.sessionFile,
|
||||
);
|
||||
if (lastMsg) lastMessagePreview = lastMsg;
|
||||
if (lastMsg) {
|
||||
lastMessagePreview = lastMsg;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...rest, derivedTitle, lastMessagePreview } satisfies GatewaySessionRow;
|
||||
|
||||
@@ -13,7 +13,9 @@ describe("gateway sessions patch", () => {
|
||||
patch: { elevatedLevel: "off" },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
expect(res.entry.elevatedLevel).toBe("off");
|
||||
});
|
||||
|
||||
@@ -26,7 +28,9 @@ describe("gateway sessions patch", () => {
|
||||
patch: { elevatedLevel: "on" },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
expect(res.entry.elevatedLevel).toBe("on");
|
||||
});
|
||||
|
||||
@@ -41,7 +45,9 @@ describe("gateway sessions patch", () => {
|
||||
patch: { elevatedLevel: null },
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
expect(res.entry.elevatedLevel).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -54,7 +60,9 @@ describe("gateway sessions patch", () => {
|
||||
patch: { elevatedLevel: "maybe" },
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
if (res.ok) return;
|
||||
if (res.ok) {
|
||||
return;
|
||||
}
|
||||
expect(res.error.message).toContain("invalid elevatedLevel");
|
||||
});
|
||||
|
||||
@@ -78,7 +86,9 @@ describe("gateway sessions patch", () => {
|
||||
loadGatewayModelCatalog: async () => [{ provider: "openai", id: "gpt-5.2" }],
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) return;
|
||||
if (!res.ok) {
|
||||
return;
|
||||
}
|
||||
expect(res.entry.providerOverride).toBe("openai");
|
||||
expect(res.entry.modelOverride).toBe("gpt-5.2");
|
||||
expect(res.entry.authProfileOverride).toBeUndefined();
|
||||
|
||||
@@ -76,10 +76,14 @@ export async function applySessionsPatchToStore(params: {
|
||||
if ("spawnedBy" in patch) {
|
||||
const raw = patch.spawnedBy;
|
||||
if (raw === null) {
|
||||
if (existing?.spawnedBy) return invalid("spawnedBy cannot be cleared once set");
|
||||
if (existing?.spawnedBy) {
|
||||
return invalid("spawnedBy cannot be cleared once set");
|
||||
}
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) return invalid("invalid spawnedBy: empty");
|
||||
if (!trimmed) {
|
||||
return invalid("invalid spawnedBy: empty");
|
||||
}
|
||||
if (!isSubagentSessionKey(storeKey)) {
|
||||
return invalid("spawnedBy is only supported for subagent:* sessions");
|
||||
}
|
||||
@@ -96,9 +100,13 @@ export async function applySessionsPatchToStore(params: {
|
||||
delete next.label;
|
||||
} else if (raw !== undefined) {
|
||||
const parsed = parseSessionLabel(raw);
|
||||
if (!parsed.ok) return invalid(parsed.error);
|
||||
if (!parsed.ok) {
|
||||
return invalid(parsed.error);
|
||||
}
|
||||
for (const [key, entry] of Object.entries(store)) {
|
||||
if (key === storeKey) continue;
|
||||
if (key === storeKey) {
|
||||
continue;
|
||||
}
|
||||
if (entry?.label === parsed.label) {
|
||||
return invalid(`label already in use: ${parsed.label}`);
|
||||
}
|
||||
@@ -125,15 +133,20 @@ export async function applySessionsPatchToStore(params: {
|
||||
`invalid thinkingLevel (use ${formatThinkingLevels(hintProvider, hintModel, "|")})`,
|
||||
);
|
||||
}
|
||||
if (normalized === "off") delete next.thinkingLevel;
|
||||
else next.thinkingLevel = normalized;
|
||||
if (normalized === "off") {
|
||||
delete next.thinkingLevel;
|
||||
} else {
|
||||
next.thinkingLevel = normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ("verboseLevel" in patch) {
|
||||
const raw = patch.verboseLevel;
|
||||
const parsed = parseVerboseOverride(raw);
|
||||
if (!parsed.ok) return invalid(parsed.error);
|
||||
if (!parsed.ok) {
|
||||
return invalid(parsed.error);
|
||||
}
|
||||
applyVerboseOverride(next, parsed.value);
|
||||
}
|
||||
|
||||
@@ -146,8 +159,11 @@ export async function applySessionsPatchToStore(params: {
|
||||
if (!normalized) {
|
||||
return invalid('invalid reasoningLevel (use "on"|"off"|"stream")');
|
||||
}
|
||||
if (normalized === "off") delete next.reasoningLevel;
|
||||
else next.reasoningLevel = normalized;
|
||||
if (normalized === "off") {
|
||||
delete next.reasoningLevel;
|
||||
} else {
|
||||
next.reasoningLevel = normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,9 +173,14 @@ export async function applySessionsPatchToStore(params: {
|
||||
delete next.responseUsage;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeUsageDisplay(String(raw));
|
||||
if (!normalized) return invalid('invalid responseUsage (use "off"|"tokens"|"full")');
|
||||
if (normalized === "off") delete next.responseUsage;
|
||||
else next.responseUsage = normalized;
|
||||
if (!normalized) {
|
||||
return invalid('invalid responseUsage (use "off"|"tokens"|"full")');
|
||||
}
|
||||
if (normalized === "off") {
|
||||
delete next.responseUsage;
|
||||
} else {
|
||||
next.responseUsage = normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +190,9 @@ export async function applySessionsPatchToStore(params: {
|
||||
delete next.elevatedLevel;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeElevatedLevel(String(raw));
|
||||
if (!normalized) return invalid('invalid elevatedLevel (use "on"|"off"|"ask"|"full")');
|
||||
if (!normalized) {
|
||||
return invalid('invalid elevatedLevel (use "on"|"off"|"ask"|"full")');
|
||||
}
|
||||
// Persist "off" explicitly so patches can override defaults.
|
||||
next.elevatedLevel = normalized;
|
||||
}
|
||||
@@ -181,7 +204,9 @@ export async function applySessionsPatchToStore(params: {
|
||||
delete next.execHost;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeExecHost(String(raw));
|
||||
if (!normalized) return invalid('invalid execHost (use "sandbox"|"gateway"|"node")');
|
||||
if (!normalized) {
|
||||
return invalid('invalid execHost (use "sandbox"|"gateway"|"node")');
|
||||
}
|
||||
next.execHost = normalized;
|
||||
}
|
||||
}
|
||||
@@ -192,7 +217,9 @@ export async function applySessionsPatchToStore(params: {
|
||||
delete next.execSecurity;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeExecSecurity(String(raw));
|
||||
if (!normalized) return invalid('invalid execSecurity (use "deny"|"allowlist"|"full")');
|
||||
if (!normalized) {
|
||||
return invalid('invalid execSecurity (use "deny"|"allowlist"|"full")');
|
||||
}
|
||||
next.execSecurity = normalized;
|
||||
}
|
||||
}
|
||||
@@ -203,7 +230,9 @@ export async function applySessionsPatchToStore(params: {
|
||||
delete next.execAsk;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeExecAsk(String(raw));
|
||||
if (!normalized) return invalid('invalid execAsk (use "off"|"on-miss"|"always")');
|
||||
if (!normalized) {
|
||||
return invalid('invalid execAsk (use "off"|"on-miss"|"always")');
|
||||
}
|
||||
next.execAsk = normalized;
|
||||
}
|
||||
}
|
||||
@@ -214,7 +243,9 @@ export async function applySessionsPatchToStore(params: {
|
||||
delete next.execNode;
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) return invalid("invalid execNode: empty");
|
||||
if (!trimmed) {
|
||||
return invalid("invalid execNode: empty");
|
||||
}
|
||||
next.execNode = trimmed;
|
||||
}
|
||||
}
|
||||
@@ -237,7 +268,9 @@ export async function applySessionsPatchToStore(params: {
|
||||
});
|
||||
} else if (raw !== undefined) {
|
||||
const trimmed = String(raw).trim();
|
||||
if (!trimmed) return invalid("invalid model: empty");
|
||||
if (!trimmed) {
|
||||
return invalid("invalid model: empty");
|
||||
}
|
||||
if (!params.loadGatewayModelCatalog) {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -291,7 +324,9 @@ export async function applySessionsPatchToStore(params: {
|
||||
delete next.sendPolicy;
|
||||
} else if (raw !== undefined) {
|
||||
const normalized = normalizeSendPolicy(String(raw));
|
||||
if (!normalized) return invalid('invalid sendPolicy (use "allow"|"deny")');
|
||||
if (!normalized) {
|
||||
return invalid('invalid sendPolicy (use "allow"|"deny")');
|
||||
}
|
||||
next.sendPolicy = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +33,16 @@ export async function connectGatewayClient(params: {
|
||||
return await new Promise<InstanceType<typeof GatewayClient>>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const stop = (err?: Error, client?: InstanceType<typeof GatewayClient>) => {
|
||||
if (settled) return;
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (err) reject(err);
|
||||
else resolve(client as InstanceType<typeof GatewayClient>);
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(client as InstanceType<typeof GatewayClient>);
|
||||
}
|
||||
};
|
||||
const client = new GatewayClient({
|
||||
url: params.url,
|
||||
@@ -112,7 +117,9 @@ export async function connectDeviceAuthReq(params: { url: string; token?: string
|
||||
};
|
||||
const handler = (data: WebSocket.RawData) => {
|
||||
const obj = JSON.parse(rawDataToString(data)) as { type?: unknown; id?: unknown };
|
||||
if (obj?.type !== "res" || obj?.id !== "c1") return;
|
||||
if (obj?.type !== "res" || obj?.id !== "c1") {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
ws.off("message", handler);
|
||||
ws.off("close", closeHandler);
|
||||
|
||||
@@ -441,9 +441,12 @@ vi.mock("../config/config.js", async () => {
|
||||
...fileSession,
|
||||
mainKey: fileSession.mainKey ?? "main",
|
||||
};
|
||||
if (typeof testState.sessionStorePath === "string")
|
||||
if (typeof testState.sessionStorePath === "string") {
|
||||
session.store = testState.sessionStorePath;
|
||||
if (testState.sessionConfig) Object.assign(session, testState.sessionConfig);
|
||||
}
|
||||
if (testState.sessionConfig) {
|
||||
Object.assign(session, testState.sessionConfig);
|
||||
}
|
||||
|
||||
const fileGateway =
|
||||
fileConfig.gateway &&
|
||||
@@ -451,9 +454,15 @@ vi.mock("../config/config.js", async () => {
|
||||
!Array.isArray(fileConfig.gateway)
|
||||
? ({ ...(fileConfig.gateway as Record<string, unknown>) } as Record<string, unknown>)
|
||||
: {};
|
||||
if (testState.gatewayBind) fileGateway.bind = testState.gatewayBind;
|
||||
if (testState.gatewayAuth) fileGateway.auth = testState.gatewayAuth;
|
||||
if (testState.gatewayControlUi) fileGateway.controlUi = testState.gatewayControlUi;
|
||||
if (testState.gatewayBind) {
|
||||
fileGateway.bind = testState.gatewayBind;
|
||||
}
|
||||
if (testState.gatewayAuth) {
|
||||
fileGateway.auth = testState.gatewayAuth;
|
||||
}
|
||||
if (testState.gatewayControlUi) {
|
||||
fileGateway.controlUi = testState.gatewayControlUi;
|
||||
}
|
||||
const gateway = Object.keys(fileGateway).length > 0 ? fileGateway : undefined;
|
||||
|
||||
const fileCanvasHost =
|
||||
@@ -462,8 +471,9 @@ vi.mock("../config/config.js", async () => {
|
||||
!Array.isArray(fileConfig.canvasHost)
|
||||
? ({ ...(fileConfig.canvasHost as Record<string, unknown>) } as Record<string, unknown>)
|
||||
: {};
|
||||
if (typeof testState.canvasHostPort === "number")
|
||||
if (typeof testState.canvasHostPort === "number") {
|
||||
fileCanvasHost.port = testState.canvasHostPort;
|
||||
}
|
||||
const canvasHost = Object.keys(fileCanvasHost).length > 0 ? fileCanvasHost : undefined;
|
||||
|
||||
const hooks = testState.hooksConfig ?? (fileConfig.hooks as HooksConfig | undefined);
|
||||
@@ -472,8 +482,12 @@ vi.mock("../config/config.js", async () => {
|
||||
fileConfig.cron && typeof fileConfig.cron === "object" && !Array.isArray(fileConfig.cron)
|
||||
? ({ ...(fileConfig.cron as Record<string, unknown>) } as Record<string, unknown>)
|
||||
: {};
|
||||
if (typeof testState.cronEnabled === "boolean") fileCron.enabled = testState.cronEnabled;
|
||||
if (typeof testState.cronStorePath === "string") fileCron.store = testState.cronStorePath;
|
||||
if (typeof testState.cronEnabled === "boolean") {
|
||||
fileCron.enabled = testState.cronEnabled;
|
||||
}
|
||||
if (typeof testState.cronStorePath === "string") {
|
||||
fileCron.store = testState.cronStorePath;
|
||||
}
|
||||
const cron = Object.keys(fileCron).length > 0 ? fileCron : undefined;
|
||||
|
||||
const config = {
|
||||
|
||||
@@ -22,7 +22,9 @@ type OpenAIResponseStreamEvent =
|
||||
function extractLastUserText(input: unknown[]): string {
|
||||
for (let i = input.length - 1; i >= 0; i -= 1) {
|
||||
const item = input[i] as Record<string, unknown> | undefined;
|
||||
if (!item || item.role !== "user") continue;
|
||||
if (!item || item.role !== "user") {
|
||||
continue;
|
||||
}
|
||||
const content = item.content;
|
||||
if (Array.isArray(content)) {
|
||||
const text = content
|
||||
@@ -36,7 +38,9 @@ function extractLastUserText(input: unknown[]): string {
|
||||
.map((c) => c.text)
|
||||
.join("\n")
|
||||
.trim();
|
||||
if (text) return text;
|
||||
if (text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
@@ -45,7 +49,9 @@ function extractLastUserText(input: unknown[]): string {
|
||||
function extractToolOutput(input: unknown[]): string {
|
||||
for (const itemRaw of input) {
|
||||
const item = itemRaw as Record<string, unknown> | undefined;
|
||||
if (!item || item.type !== "function_call_output") continue;
|
||||
if (!item || item.type !== "function_call_output") {
|
||||
continue;
|
||||
}
|
||||
return typeof item.output === "string" ? item.output : "";
|
||||
}
|
||||
return "";
|
||||
@@ -128,10 +134,18 @@ async function* fakeOpenAIResponsesStream(
|
||||
}
|
||||
|
||||
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) {
|
||||
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");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,9 @@ export async function writeSessionStore(params: {
|
||||
mainKey?: string;
|
||||
}): Promise<void> {
|
||||
const storePath = params.storePath ?? testState.sessionStorePath;
|
||||
if (!storePath) throw new Error("writeSessionStore requires testState.sessionStorePath");
|
||||
if (!storePath) {
|
||||
throw new Error("writeSessionStore requires testState.sessionStorePath");
|
||||
}
|
||||
const agentId = params.agentId ?? DEFAULT_AGENT_ID;
|
||||
const store: Record<string, Partial<SessionEntry>> = {};
|
||||
for (const [requestKey, entry] of Object.entries(params.entries)) {
|
||||
@@ -148,21 +150,41 @@ async function cleanupGatewayTestHome(options: { restoreEnv: boolean }) {
|
||||
vi.useRealTimers();
|
||||
resetLogger();
|
||||
if (options.restoreEnv) {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
if (previousUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = previousUserProfile;
|
||||
if (previousStateDir === undefined) delete process.env.OPENCLAW_STATE_DIR;
|
||||
else process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
if (previousConfigPath === undefined) delete process.env.OPENCLAW_CONFIG_PATH;
|
||||
else process.env.OPENCLAW_CONFIG_PATH = previousConfigPath;
|
||||
if (previousSkipBrowserControl === undefined)
|
||||
if (previousHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = previousHome;
|
||||
}
|
||||
if (previousUserProfile === undefined) {
|
||||
delete process.env.USERPROFILE;
|
||||
} else {
|
||||
process.env.USERPROFILE = previousUserProfile;
|
||||
}
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
if (previousConfigPath === undefined) {
|
||||
delete process.env.OPENCLAW_CONFIG_PATH;
|
||||
} else {
|
||||
process.env.OPENCLAW_CONFIG_PATH = previousConfigPath;
|
||||
}
|
||||
if (previousSkipBrowserControl === undefined) {
|
||||
delete process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER;
|
||||
else process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = previousSkipBrowserControl;
|
||||
if (previousSkipGmailWatcher === undefined) delete process.env.OPENCLAW_SKIP_GMAIL_WATCHER;
|
||||
else process.env.OPENCLAW_SKIP_GMAIL_WATCHER = previousSkipGmailWatcher;
|
||||
if (previousSkipCanvasHost === undefined) delete process.env.OPENCLAW_SKIP_CANVAS_HOST;
|
||||
else process.env.OPENCLAW_SKIP_CANVAS_HOST = previousSkipCanvasHost;
|
||||
} else {
|
||||
process.env.OPENCLAW_SKIP_BROWSER_CONTROL_SERVER = previousSkipBrowserControl;
|
||||
}
|
||||
if (previousSkipGmailWatcher === undefined) {
|
||||
delete process.env.OPENCLAW_SKIP_GMAIL_WATCHER;
|
||||
} else {
|
||||
process.env.OPENCLAW_SKIP_GMAIL_WATCHER = previousSkipGmailWatcher;
|
||||
}
|
||||
if (previousSkipCanvasHost === undefined) {
|
||||
delete process.env.OPENCLAW_SKIP_CANVAS_HOST;
|
||||
} else {
|
||||
process.env.OPENCLAW_SKIP_CANVAS_HOST = previousSkipCanvasHost;
|
||||
}
|
||||
}
|
||||
if (options.restoreEnv && tempHome) {
|
||||
await fs.rm(tempHome, {
|
||||
@@ -281,7 +303,9 @@ export async function startServerWithClient(token?: string, opts?: GatewayServer
|
||||
break;
|
||||
} catch (err) {
|
||||
const code = (err as { cause?: { code?: string } }).cause?.code;
|
||||
if (code !== "EADDRINUSE") throw err;
|
||||
if (code !== "EADDRINUSE") {
|
||||
throw err;
|
||||
}
|
||||
port = await getFreePort();
|
||||
}
|
||||
}
|
||||
@@ -359,8 +383,12 @@ export async function connectReq(
|
||||
const password = opts?.password ?? defaultPassword;
|
||||
const requestedScopes = Array.isArray(opts?.scopes) ? opts?.scopes : [];
|
||||
const device = (() => {
|
||||
if (opts?.device === null) return undefined;
|
||||
if (opts?.device) return opts.device;
|
||||
if (opts?.device === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (opts?.device) {
|
||||
return opts.device;
|
||||
}
|
||||
const identity = loadOrCreateDeviceIdentity();
|
||||
const signedAtMs = Date.now();
|
||||
const payload = buildDeviceAuthPayload({
|
||||
@@ -406,7 +434,9 @@ export async function connectReq(
|
||||
}),
|
||||
);
|
||||
const isResponseForId = (o: unknown): boolean => {
|
||||
if (!o || typeof o !== "object" || Array.isArray(o)) return false;
|
||||
if (!o || typeof o !== "object" || Array.isArray(o)) {
|
||||
return false;
|
||||
}
|
||||
const rec = o as Record<string, unknown>;
|
||||
return rec.type === "res" && rec.id === id;
|
||||
};
|
||||
@@ -438,7 +468,9 @@ export async function rpcReq<T = unknown>(
|
||||
}>(
|
||||
ws,
|
||||
(o) => {
|
||||
if (!o || typeof o !== "object" || Array.isArray(o)) return false;
|
||||
if (!o || typeof o !== "object" || Array.isArray(o)) {
|
||||
return false;
|
||||
}
|
||||
const rec = o as Record<string, unknown>;
|
||||
return rec.type === "res" && rec.id === id;
|
||||
},
|
||||
@@ -451,7 +483,9 @@ export async function waitForSystemEvent(timeoutMs = 2000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const events = peekSystemEvents(sessionKey);
|
||||
if (events.length > 0) return events;
|
||||
if (events.length > 0) {
|
||||
return events;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error("timeout waiting for system event");
|
||||
|
||||
@@ -19,7 +19,9 @@ beforeEach(() => {
|
||||
|
||||
const resolveGatewayToken = (): string => {
|
||||
const token = (testState.gatewayAuth as { token?: string } | undefined)?.token;
|
||||
if (!token) throw new Error("test gateway token missing");
|
||||
if (!token) {
|
||||
throw new Error("test gateway token missing");
|
||||
}
|
||||
return token;
|
||||
};
|
||||
|
||||
|
||||
@@ -45,12 +45,16 @@ type ToolsInvokeBody = {
|
||||
};
|
||||
|
||||
function resolveSessionKeyFromBody(body: ToolsInvokeBody): string | undefined {
|
||||
if (typeof body.sessionKey === "string" && body.sessionKey.trim()) return body.sessionKey.trim();
|
||||
if (typeof body.sessionKey === "string" && body.sessionKey.trim()) {
|
||||
return body.sessionKey.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveMemoryToolDisableReasons(cfg: ReturnType<typeof loadConfig>): string[] {
|
||||
if (!process.env.VITEST) return [];
|
||||
if (!process.env.VITEST) {
|
||||
return [];
|
||||
}
|
||||
const reasons: string[] = [];
|
||||
const plugins = cfg.plugins;
|
||||
const slotRaw = plugins?.slots?.memory;
|
||||
@@ -59,7 +63,9 @@ function resolveMemoryToolDisableReasons(cfg: ReturnType<typeof loadConfig>): st
|
||||
const pluginsDisabled = plugins?.enabled === false;
|
||||
const defaultDisabled = isTestDefaultMemorySlotDisabled(cfg);
|
||||
|
||||
if (pluginsDisabled) reasons.push("plugins.enabled=false");
|
||||
if (pluginsDisabled) {
|
||||
reasons.push("plugins.enabled=false");
|
||||
}
|
||||
if (slotDisabled) {
|
||||
reasons.push(slotRaw === null ? "plugins.slots.memory=null" : 'plugins.slots.memory="none"');
|
||||
}
|
||||
@@ -75,8 +81,12 @@ function mergeActionIntoArgsIfSupported(params: {
|
||||
args: Record<string, unknown>;
|
||||
}): Record<string, unknown> {
|
||||
const { toolSchema, action, args } = params;
|
||||
if (!action) return args;
|
||||
if (args.action !== undefined) return args;
|
||||
if (!action) {
|
||||
return args;
|
||||
}
|
||||
if (args.action !== undefined) {
|
||||
return args;
|
||||
}
|
||||
// TypeBox schemas are plain objects; many tools define an `action` property.
|
||||
const schemaObj = toolSchema as { properties?: Record<string, unknown> } | null;
|
||||
const hasAction = Boolean(
|
||||
@@ -85,7 +95,9 @@ function mergeActionIntoArgsIfSupported(params: {
|
||||
schemaObj.properties &&
|
||||
"action" in schemaObj.properties,
|
||||
);
|
||||
if (!hasAction) return args;
|
||||
if (!hasAction) {
|
||||
return args;
|
||||
}
|
||||
return { ...args, action };
|
||||
}
|
||||
|
||||
@@ -95,7 +107,9 @@ export async function handleToolsInvokeHttpRequest(
|
||||
opts: { auth: ResolvedGatewayAuth; maxBodyBytes?: number; trustedProxies?: string[] },
|
||||
): Promise<boolean> {
|
||||
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
||||
if (url.pathname !== "/tools/invoke") return false;
|
||||
if (url.pathname !== "/tools/invoke") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
sendMethodNotAllowed(res, "POST");
|
||||
@@ -116,7 +130,9 @@ export async function handleToolsInvokeHttpRequest(
|
||||
}
|
||||
|
||||
const bodyUnknown = await readJsonBodyOrError(req, res, opts.maxBodyBytes ?? DEFAULT_BODY_BYTES);
|
||||
if (bodyUnknown === undefined) return true;
|
||||
if (bodyUnknown === undefined) {
|
||||
return true;
|
||||
}
|
||||
const body = (bodyUnknown ?? {}) as ToolsInvokeBody;
|
||||
|
||||
const toolName = typeof body.tool === "string" ? body.tool.trim() : "";
|
||||
@@ -175,7 +191,9 @@ export async function handleToolsInvokeHttpRequest(
|
||||
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
|
||||
|
||||
const mergeAlsoAllow = (policy: typeof profilePolicy, alsoAllow?: string[]) => {
|
||||
if (!policy?.allow || !Array.isArray(alsoAllow) || alsoAllow.length === 0) return policy;
|
||||
if (!policy?.allow || !Array.isArray(alsoAllow) || alsoAllow.length === 0) {
|
||||
return policy;
|
||||
}
|
||||
return { ...policy, allow: Array.from(new Set([...policy.allow, ...alsoAllow])) };
|
||||
};
|
||||
|
||||
|
||||
+129
-43
@@ -27,8 +27,12 @@ const wsLog = createSubsystemLogger("gateway/ws");
|
||||
|
||||
export function shortId(value: string): string {
|
||||
const s = value.trim();
|
||||
if (UUID_RE.test(s)) return `${s.slice(0, 8)}…${s.slice(-4)}`;
|
||||
if (s.length <= 24) return s;
|
||||
if (UUID_RE.test(s)) {
|
||||
return `${s.slice(0, 8)}…${s.slice(-4)}`;
|
||||
}
|
||||
if (s.length <= 24) {
|
||||
return s;
|
||||
}
|
||||
return `${s.slice(0, 12)}…${s.slice(-4)}`;
|
||||
}
|
||||
|
||||
@@ -36,13 +40,19 @@ export function formatForLog(value: unknown): string {
|
||||
try {
|
||||
if (value instanceof Error) {
|
||||
const parts: string[] = [];
|
||||
if (value.name) parts.push(value.name);
|
||||
if (value.message) parts.push(value.message);
|
||||
if (value.name) {
|
||||
parts.push(value.name);
|
||||
}
|
||||
if (value.message) {
|
||||
parts.push(value.message);
|
||||
}
|
||||
const code =
|
||||
"code" in value && (typeof value.code === "string" || typeof value.code === "number")
|
||||
? String(value.code)
|
||||
: "";
|
||||
if (code) parts.push(`code=${code}`);
|
||||
if (code) {
|
||||
parts.push(`code=${code}`);
|
||||
}
|
||||
const combined = parts.filter(Boolean).join(": ").trim();
|
||||
if (combined) {
|
||||
return combined.length > LOG_VALUE_LIMIT
|
||||
@@ -57,7 +67,9 @@ export function formatForLog(value: unknown): string {
|
||||
const code =
|
||||
typeof rec.code === "string" || typeof rec.code === "number" ? String(rec.code) : "";
|
||||
const parts = [name, rec.message.trim()].filter(Boolean);
|
||||
if (code) parts.push(`code=${code}`);
|
||||
if (code) {
|
||||
parts.push(`code=${code}`);
|
||||
}
|
||||
const combined = parts.join(": ").trim();
|
||||
return combined.length > LOG_VALUE_LIMIT
|
||||
? `${combined.slice(0, LOG_VALUE_LIMIT)}...`
|
||||
@@ -68,7 +80,9 @@ export function formatForLog(value: unknown): string {
|
||||
typeof value === "string" || typeof value === "number"
|
||||
? String(value)
|
||||
: JSON.stringify(value);
|
||||
if (!str) return "";
|
||||
if (!str) {
|
||||
return "";
|
||||
}
|
||||
const redacted = redactSensitiveText(str, WS_LOG_REDACT_OPTIONS);
|
||||
return redacted.length > LOG_VALUE_LIMIT
|
||||
? `${redacted.slice(0, LOG_VALUE_LIMIT)}...`
|
||||
@@ -80,12 +94,16 @@ export function formatForLog(value: unknown): string {
|
||||
|
||||
function compactPreview(input: string, maxLen = 160): string {
|
||||
const oneLine = input.replace(/\s+/g, " ").trim();
|
||||
if (oneLine.length <= maxLen) return oneLine;
|
||||
if (oneLine.length <= maxLen) {
|
||||
return oneLine;
|
||||
}
|
||||
return `${oneLine.slice(0, Math.max(0, maxLen - 1))}…`;
|
||||
}
|
||||
|
||||
export function summarizeAgentEventForWsLog(payload: unknown): Record<string, unknown> {
|
||||
if (!payload || typeof payload !== "object") return {};
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return {};
|
||||
}
|
||||
const rec = payload as Record<string, unknown>;
|
||||
const runId = typeof rec.runId === "string" ? rec.runId : undefined;
|
||||
const stream = typeof rec.stream === "string" ? rec.stream : undefined;
|
||||
@@ -95,7 +113,9 @@ export function summarizeAgentEventForWsLog(payload: unknown): Record<string, un
|
||||
rec.data && typeof rec.data === "object" ? (rec.data as Record<string, unknown>) : undefined;
|
||||
|
||||
const extra: Record<string, unknown> = {};
|
||||
if (runId) extra.run = shortId(runId);
|
||||
if (runId) {
|
||||
extra.run = shortId(runId);
|
||||
}
|
||||
if (sessionKey) {
|
||||
const parsed = parseAgentSessionKey(sessionKey);
|
||||
if (parsed) {
|
||||
@@ -105,47 +125,75 @@ export function summarizeAgentEventForWsLog(payload: unknown): Record<string, un
|
||||
extra.session = sessionKey;
|
||||
}
|
||||
}
|
||||
if (stream) extra.stream = stream;
|
||||
if (seq !== undefined) extra.aseq = seq;
|
||||
if (stream) {
|
||||
extra.stream = stream;
|
||||
}
|
||||
if (seq !== undefined) {
|
||||
extra.aseq = seq;
|
||||
}
|
||||
|
||||
if (!data) return extra;
|
||||
if (!data) {
|
||||
return extra;
|
||||
}
|
||||
|
||||
if (stream === "assistant") {
|
||||
const text = typeof data.text === "string" ? data.text : undefined;
|
||||
if (text?.trim()) extra.text = compactPreview(text);
|
||||
if (text?.trim()) {
|
||||
extra.text = compactPreview(text);
|
||||
}
|
||||
const mediaUrls = Array.isArray(data.mediaUrls) ? data.mediaUrls : undefined;
|
||||
if (mediaUrls && mediaUrls.length > 0) extra.media = mediaUrls.length;
|
||||
if (mediaUrls && mediaUrls.length > 0) {
|
||||
extra.media = mediaUrls.length;
|
||||
}
|
||||
return extra;
|
||||
}
|
||||
|
||||
if (stream === "tool") {
|
||||
const phase = typeof data.phase === "string" ? data.phase : undefined;
|
||||
const name = typeof data.name === "string" ? data.name : undefined;
|
||||
if (phase || name) extra.tool = `${phase ?? "?"}:${name ?? "?"}`;
|
||||
if (phase || name) {
|
||||
extra.tool = `${phase ?? "?"}:${name ?? "?"}`;
|
||||
}
|
||||
const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : undefined;
|
||||
if (toolCallId) extra.call = shortId(toolCallId);
|
||||
if (toolCallId) {
|
||||
extra.call = shortId(toolCallId);
|
||||
}
|
||||
const meta = typeof data.meta === "string" ? data.meta : undefined;
|
||||
if (meta?.trim()) extra.meta = meta;
|
||||
if (typeof data.isError === "boolean") extra.err = data.isError;
|
||||
if (meta?.trim()) {
|
||||
extra.meta = meta;
|
||||
}
|
||||
if (typeof data.isError === "boolean") {
|
||||
extra.err = data.isError;
|
||||
}
|
||||
return extra;
|
||||
}
|
||||
|
||||
if (stream === "lifecycle") {
|
||||
const phase = typeof data.phase === "string" ? data.phase : undefined;
|
||||
if (phase) extra.phase = phase;
|
||||
if (typeof data.aborted === "boolean") extra.aborted = data.aborted;
|
||||
if (phase) {
|
||||
extra.phase = phase;
|
||||
}
|
||||
if (typeof data.aborted === "boolean") {
|
||||
extra.aborted = data.aborted;
|
||||
}
|
||||
const error = typeof data.error === "string" ? data.error : undefined;
|
||||
if (error?.trim()) extra.error = compactPreview(error, 120);
|
||||
if (error?.trim()) {
|
||||
extra.error = compactPreview(error, 120);
|
||||
}
|
||||
return extra;
|
||||
}
|
||||
|
||||
const reason = typeof data.reason === "string" ? data.reason : undefined;
|
||||
if (reason?.trim()) extra.reason = reason;
|
||||
if (reason?.trim()) {
|
||||
extra.reason = reason;
|
||||
}
|
||||
return extra;
|
||||
}
|
||||
|
||||
export function logWs(direction: "in" | "out", kind: string, meta?: Record<string, unknown>) {
|
||||
if (!shouldLogSubsystemToConsole("gateway/ws")) return;
|
||||
if (!shouldLogSubsystemToConsole("gateway/ws")) {
|
||||
return;
|
||||
}
|
||||
const style = getGatewayWsLogStyle();
|
||||
if (!isVerbose()) {
|
||||
logWsOptimized(direction, kind, meta);
|
||||
@@ -172,7 +220,9 @@ export function logWs(direction: "in" | "out", kind: string, meta?: Record<strin
|
||||
direction === "out" && kind === "res" && inflightKey
|
||||
? (() => {
|
||||
const startedAt = wsInflightSince.get(inflightKey);
|
||||
if (startedAt === undefined) return undefined;
|
||||
if (startedAt === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
wsInflightSince.delete(inflightKey);
|
||||
return now - startedAt;
|
||||
})()
|
||||
@@ -201,10 +251,18 @@ export function logWs(direction: "in" | "out", kind: string, meta?: Record<strin
|
||||
const restMeta: string[] = [];
|
||||
if (meta) {
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
if (value === undefined) continue;
|
||||
if (key === "connId" || key === "id") continue;
|
||||
if (key === "method" || key === "ok") continue;
|
||||
if (key === "event") continue;
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (key === "connId" || key === "id") {
|
||||
continue;
|
||||
}
|
||||
if (key === "method" || key === "ok") {
|
||||
continue;
|
||||
}
|
||||
if (key === "event") {
|
||||
continue;
|
||||
}
|
||||
restMeta.push(`${chalk.dim(key)}=${formatForLog(value)}`);
|
||||
}
|
||||
}
|
||||
@@ -213,7 +271,9 @@ export function logWs(direction: "in" | "out", kind: string, meta?: Record<strin
|
||||
if (connId) {
|
||||
trailing.push(`${chalk.dim("conn")}=${chalk.gray(shortId(connId))}`);
|
||||
}
|
||||
if (id) trailing.push(`${chalk.dim("id")}=${chalk.gray(shortId(id))}`);
|
||||
if (id) {
|
||||
trailing.push(`${chalk.dim("id")}=${chalk.gray(shortId(id))}`);
|
||||
}
|
||||
|
||||
const tokens = [prefix, statusToken, headline, durationToken, ...restMeta, ...trailing].filter(
|
||||
(t): t is string => Boolean(t),
|
||||
@@ -232,7 +292,9 @@ function logWsOptimized(direction: "in" | "out", kind: string, meta?: Record<str
|
||||
|
||||
if (direction === "in" && kind === "req" && inflightKey) {
|
||||
wsInflightOptimized.set(inflightKey, Date.now());
|
||||
if (wsInflightOptimized.size > 2000) wsInflightOptimized.clear();
|
||||
if (wsInflightOptimized.size > 2000) {
|
||||
wsInflightOptimized.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -250,15 +312,21 @@ function logWsOptimized(direction: "in" | "out", kind: string, meta?: Record<str
|
||||
return;
|
||||
}
|
||||
|
||||
if (direction !== "out" || kind !== "res") return;
|
||||
if (direction !== "out" || kind !== "res") {
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = inflightKey ? wsInflightOptimized.get(inflightKey) : undefined;
|
||||
if (inflightKey) wsInflightOptimized.delete(inflightKey);
|
||||
if (inflightKey) {
|
||||
wsInflightOptimized.delete(inflightKey);
|
||||
}
|
||||
const durationMs = typeof startedAt === "number" ? Date.now() - startedAt : undefined;
|
||||
|
||||
const shouldLog =
|
||||
ok === false || (typeof durationMs === "number" && durationMs >= DEFAULT_WS_SLOW_MS);
|
||||
if (!shouldLog) return;
|
||||
if (!shouldLog) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusToken =
|
||||
ok === undefined ? undefined : ok ? chalk.greenBright("✓") : chalk.redBright("✗");
|
||||
@@ -267,9 +335,15 @@ function logWsOptimized(direction: "in" | "out", kind: string, meta?: Record<str
|
||||
const restMeta: string[] = [];
|
||||
if (meta) {
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
if (value === undefined) continue;
|
||||
if (key === "connId" || key === "id") continue;
|
||||
if (key === "method" || key === "ok") continue;
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (key === "connId" || key === "id") {
|
||||
continue;
|
||||
}
|
||||
if (key === "method" || key === "ok") {
|
||||
continue;
|
||||
}
|
||||
restMeta.push(`${chalk.dim(key)}=${formatForLog(value)}`);
|
||||
}
|
||||
}
|
||||
@@ -301,7 +375,9 @@ function logWsCompact(direction: "in" | "out", kind: string, meta?: Record<strin
|
||||
}
|
||||
|
||||
const compactArrow = (() => {
|
||||
if (kind === "req" || kind === "res") return "⇄";
|
||||
if (kind === "req" || kind === "res") {
|
||||
return "⇄";
|
||||
}
|
||||
return direction === "in" ? "←" : "→";
|
||||
})();
|
||||
const arrowColor =
|
||||
@@ -340,10 +416,18 @@ function logWsCompact(direction: "in" | "out", kind: string, meta?: Record<strin
|
||||
const restMeta: string[] = [];
|
||||
if (meta) {
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
if (value === undefined) continue;
|
||||
if (key === "connId" || key === "id") continue;
|
||||
if (key === "method" || key === "ok") continue;
|
||||
if (key === "event") continue;
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (key === "connId" || key === "id") {
|
||||
continue;
|
||||
}
|
||||
if (key === "method" || key === "ok") {
|
||||
continue;
|
||||
}
|
||||
if (key === "event") {
|
||||
continue;
|
||||
}
|
||||
restMeta.push(`${chalk.dim(key)}=${formatForLog(value)}`);
|
||||
}
|
||||
}
|
||||
@@ -353,7 +437,9 @@ function logWsCompact(direction: "in" | "out", kind: string, meta?: Record<strin
|
||||
trailing.push(`${chalk.dim("conn")}=${chalk.gray(shortId(connId))}`);
|
||||
wsLastCompactConnId = connId;
|
||||
}
|
||||
if (id) trailing.push(`${chalk.dim("id")}=${chalk.gray(shortId(id))}`);
|
||||
if (id) {
|
||||
trailing.push(`${chalk.dim("id")}=${chalk.gray(shortId(id))}`);
|
||||
}
|
||||
|
||||
const tokens = [prefix, statusToken, headline, durationToken, ...restMeta, ...trailing].filter(
|
||||
(t): t is string => Boolean(t),
|
||||
|
||||
Reference in New Issue
Block a user