From d7c15240ec6c2af0f06576c13cef0e567070df8d Mon Sep 17 00:00:00 2001 From: xmanrui <841206367@qq.com> Date: Mon, 9 Mar 2026 03:11:21 +0800 Subject: [PATCH] Fix Windows OpenClaw path and session test compatibility --- app/api/activity-heatmap/route.ts | 3 +- app/api/agent-activity/route.ts | 10 +- app/api/agent-status/route.ts | 3 +- app/api/alerts/check/route.ts | 7 +- app/api/alerts/route.ts | 3 +- app/api/config/route.ts | 4 +- app/api/gateway-health/route.ts | 117 ++++++++++++++++++------ app/api/pixel-office/idle-rank/route.ts | 3 +- app/api/pixel-office/layout/route.ts | 4 +- app/api/sessions/[agentId]/route.ts | 3 +- app/api/skills/route.ts | 11 +-- app/api/stats-all/route.ts | 3 +- app/api/stats-models/route.ts | 3 +- app/api/stats/[agentId]/route.ts | 3 +- app/api/test-agents/route.ts | 5 +- app/api/test-dm-sessions/route.ts | 17 +++- app/api/test-platforms/route.ts | 66 ++----------- app/api/test-session/route.ts | 22 +++-- app/api/test-sessions/route.ts | 18 +++- lib/json.ts | 17 ++++ lib/model-probe.ts | 45 ++++++--- lib/openclaw-cli.ts | 63 +++++++++++++ lib/openclaw-paths.ts | 17 ++++ lib/session-test-fallback.ts | 61 ++++++++++++ 24 files changed, 352 insertions(+), 156 deletions(-) create mode 100644 lib/json.ts create mode 100644 lib/openclaw-cli.ts create mode 100644 lib/openclaw-paths.ts create mode 100644 lib/session-test-fallback.ts diff --git a/app/api/activity-heatmap/route.ts b/app/api/activity-heatmap/route.ts index d0119f0..c1b4420 100644 --- a/app/api/activity-heatmap/route.ts +++ b/app/api/activity-heatmap/route.ts @@ -1,8 +1,7 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; - -const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw"); +import { OPENCLAW_HOME } from "@/lib/openclaw-paths"; // Server-side cache: 5 min TTL let cache: { data: any; ts: number } | null = null; diff --git a/app/api/agent-activity/route.ts b/app/api/agent-activity/route.ts index 082118b..be7dfea 100644 --- a/app/api/agent-activity/route.ts +++ b/app/api/agent-activity/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from 'next/server' import { promises as fs, existsSync } from 'fs' import path from 'path' -import os from 'os' +import { parseJsonText } from '@/lib/json' +import { OPENCLAW_AGENTS_DIR, OPENCLAW_CONFIG_PATH } from '@/lib/openclaw-paths' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -483,16 +484,15 @@ async function parseSubagents(agentSessionsDir: string, agentId: string): Promis } export async function GET() { - const openclawDir = path.join(os.homedir(), '.openclaw') - const configPath = path.join(openclawDir, 'openclaw.json') - const agentsDir = path.join(openclawDir, 'agents') + const configPath = OPENCLAW_CONFIG_PATH + const agentsDir = OPENCLAW_AGENTS_DIR const agents: AgentActivity[] = [] try { if (existsSync(configPath)) { const configContent = await fs.readFile(configPath, 'utf8') - const config = JSON.parse(configContent) + const config = parseJsonText(configContent) const agentList = Array.isArray(config.agents) ? config.agents : config.agents?.list if (agentList && Array.isArray(agentList)) { diff --git a/app/api/agent-status/route.ts b/app/api/agent-status/route.ts index 09db6e5..d5c4d9c 100644 --- a/app/api/agent-status/route.ts +++ b/app/api/agent-status/route.ts @@ -1,8 +1,7 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; - -const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw"); +import { OPENCLAW_HOME } from "@/lib/openclaw-paths"; // 状态: working(2分钟内有assistant消息) / online(10分钟内) / idle(24小时内) / offline(超过24小时) type AgentState = "working" | "online" | "idle" | "offline"; diff --git a/app/api/alerts/check/route.ts b/app/api/alerts/check/route.ts index 7a6505b..dca8f99 100644 --- a/app/api/alerts/check/route.ts +++ b/app/api/alerts/check/route.ts @@ -1,8 +1,7 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; - -const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw"); +import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths"; const ALERTS_CONFIG_PATH = path.join(OPENCLAW_HOME, "alerts.json"); interface AlertRule { @@ -37,7 +36,7 @@ function getAlertConfig(): AlertConfig { } function getOpenclawConfig() { - const configPath = path.join(OPENCLAW_HOME, "openclaw.json"); + const configPath = OPENCLAW_CONFIG_PATH; try { const raw = fs.readFileSync(configPath, "utf-8"); return JSON.parse(raw); @@ -55,7 +54,7 @@ function saveAlertConfig(config: AlertConfig): void { } function getGatewayConfig() { - const configPath = path.join(OPENCLAW_HOME, "openclaw.json"); + const configPath = OPENCLAW_CONFIG_PATH; try { const raw = fs.readFileSync(configPath, "utf-8"); const config = JSON.parse(raw); diff --git a/app/api/alerts/route.ts b/app/api/alerts/route.ts index 0d75b16..5e75875 100644 --- a/app/api/alerts/route.ts +++ b/app/api/alerts/route.ts @@ -1,8 +1,7 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; - -const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw"); +import { OPENCLAW_HOME } from "@/lib/openclaw-paths"; const ALERTS_CONFIG_PATH = path.join(OPENCLAW_HOME, "alerts.json"); interface AlertRule { diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 7f329d2..9728315 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -1,10 +1,10 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; +import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths"; // 配置文件路径:优先使用 OPENCLAW_HOME 环境变量,否则默认 ~/.openclaw -const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw"); -const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json"); +const CONFIG_PATH = OPENCLAW_CONFIG_PATH; const OPENCLAW_DIR = OPENCLAW_HOME; // 30秒内存缓存 diff --git a/app/api/gateway-health/route.ts b/app/api/gateway-health/route.ts index dd66ea4..1adfd53 100644 --- a/app/api/gateway-health/route.ts +++ b/app/api/gateway-health/route.ts @@ -1,15 +1,39 @@ import { NextResponse } from "next/server"; -import fs from "fs"; import path from "path"; -import { execFile } from "child_process"; +import { exec, execFile } from "child_process"; import { promisify } from "util"; +import { readJsonFileSync } from "@/lib/json"; +import { OPENCLAW_CONFIG_PATH } from "@/lib/openclaw-paths"; -const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw"); -const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json"); +const CONFIG_PATH = OPENCLAW_CONFIG_PATH; const DEGRADED_LATENCY_MS = 1500; const execFileAsync = promisify(execFile); +const execAsync = promisify(exec); let cachedOpenclawVersion: { value: string | null; expiresAt: number } | null = null; +function quoteShellArg(arg: string): string { + if (/^[A-Za-z0-9_./:=@-]+$/.test(arg)) return arg; + return `"${arg.replace(/"/g, '""')}"`; +} + +async function execOpenclaw(args: string[]): Promise<{ stdout: string; stderr: string }> { + const env = { ...process.env, FORCE_COLOR: "0" }; + + if (process.platform !== "win32") { + return execFileAsync("openclaw", args, { + maxBuffer: 10 * 1024 * 1024, + env, + }); + } + + const command = `openclaw ${args.map(quoteShellArg).join(" ")}`; + return execAsync(command, { + maxBuffer: 10 * 1024 * 1024, + env, + shell: "cmd.exe", + }); +} + function parseJsonFromMixedOutput(output: string): any { for (let i = 0; i < output.length; i++) { if (output[i] !== "{") continue; @@ -49,10 +73,7 @@ async function probeGatewayViaCli(token: string, timeoutMs = 5000): Promise<{ ok try { const args = ["gateway", "status", "--json", "--timeout", String(timeoutMs)]; if (token) args.push("--token", token); - const { stdout, stderr } = await execFileAsync("openclaw", args, { - maxBuffer: 10 * 1024 * 1024, - env: { ...process.env, FORCE_COLOR: "0" }, - }); + const { stdout, stderr } = await execOpenclaw(args); const parsed = parseJsonFromMixedOutput(`${stdout}\n${stderr || ""}`); const ok = parsed?.rpc?.ok === true; const error = @@ -66,16 +87,31 @@ async function probeGatewayViaCli(token: string, timeoutMs = 5000): Promise<{ ok } } +async function probeGatewayViaWeb(port: number, token: string, timeoutMs = 5000): Promise<{ ok: boolean; error?: string }> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const resp = await fetch( + `http://localhost:${port}/chat${token ? `?token=${encodeURIComponent(token)}` : ""}`, + { signal: controller.signal, cache: "no-store", redirect: "manual" }, + ); + return resp.status >= 200 && resp.status < 400 + ? { ok: true } + : { ok: false, error: `HTTP ${resp.status}` }; + } catch (err: any) { + return { ok: false, error: err?.message || "Failed to probe gateway web UI" }; + } finally { + clearTimeout(timeout); + } +} + async function getOpenclawVersion(): Promise { const now = Date.now(); if (cachedOpenclawVersion && cachedOpenclawVersion.expiresAt > now) { return cachedOpenclawVersion.value || undefined; } try { - const { stdout } = await execFileAsync("openclaw", ["--version"], { - maxBuffer: 1024 * 1024, - env: { ...process.env, FORCE_COLOR: "0" }, - }); + const { stdout } = await execOpenclaw(["--version"]); const version = stdout.trim().split(/\s+/)[0] || null; cachedOpenclawVersion = { value: version, expiresAt: now + 60 * 60 * 1000 }; return version || undefined; @@ -89,10 +125,10 @@ export async function GET() { const startedAt = Date.now(); try { const openclawVersion = await getOpenclawVersion(); - const raw = fs.readFileSync(CONFIG_PATH, "utf-8"); - const config = JSON.parse(raw); + const config = readJsonFileSync(CONFIG_PATH); const port = config.gateway?.port || 18789; const token = config.gateway?.auth?.token || ""; + const webUrl = `http://localhost:${port}/chat${token ? '?token=' + encodeURIComponent(token) : ''}`; const url = `http://localhost:${port}/api/health`; const headers: Record = {}; @@ -114,7 +150,22 @@ export async function GET() { status: responseMs > DEGRADED_LATENCY_MS ? "degraded" : "healthy", checkedAt, responseMs, - webUrl: `http://localhost:${port}/chat${token ? '?token=' + encodeURIComponent(token) : ''}`, + webUrl, + }); + } + + const web = await probeGatewayViaWeb(port, token, 5000); + if (web.ok) { + const checkedAt = Date.now(); + const responseMs = checkedAt - startedAt; + return NextResponse.json({ + ok: true, + data: null, + openclawVersion, + status: resp.status === 404 ? "healthy" : "degraded", + checkedAt, + responseMs, + webUrl, }); } @@ -130,7 +181,7 @@ export async function GET() { status: "healthy", checkedAt, responseMs, - webUrl: `http://localhost:${port}/chat${token ? '?token=' + encodeURIComponent(token) : ''}`, + webUrl, }); } @@ -152,26 +203,38 @@ export async function GET() { : err.message; const token = (() => { try { - const rawCfg = fs.readFileSync(CONFIG_PATH, "utf-8"); - const cfg = JSON.parse(rawCfg); + const cfg = readJsonFileSync(CONFIG_PATH); return cfg.gateway?.auth?.token || ""; } catch { return ""; } })(); + const port = (() => { + try { + const cfg = readJsonFileSync(CONFIG_PATH); + return cfg.gateway?.port || 18789; + } catch { + return 18789; + } + })(); + const web = await probeGatewayViaWeb(port, token, 5000); + if (web.ok) { + const checkedAt = Date.now(); + const responseMs = checkedAt - startedAt; + return NextResponse.json({ + ok: true, + data: null, + openclawVersion, + status: "degraded", + checkedAt, + responseMs, + webUrl: `http://localhost:${port}/chat${token ? '?token=' + encodeURIComponent(token) : ''}`, + }); + } const cli = await probeGatewayViaCli(token, 5000); const checkedAt = Date.now(); const responseMs = checkedAt - startedAt; if (cli.ok) { - const port = (() => { - try { - const rawCfg = fs.readFileSync(CONFIG_PATH, "utf-8"); - const cfg = JSON.parse(rawCfg); - return cfg.gateway?.port || 18789; - } catch { - return 18789; - } - })(); return NextResponse.json({ ok: true, data: null, diff --git a/app/api/pixel-office/idle-rank/route.ts b/app/api/pixel-office/idle-rank/route.ts index 0ee1a06..6501c7d 100644 --- a/app/api/pixel-office/idle-rank/route.ts +++ b/app/api/pixel-office/idle-rank/route.ts @@ -1,8 +1,7 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; - -const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw"); +import { OPENCLAW_HOME } from "@/lib/openclaw-paths"; const ACTIVE_GAP_MS = 2 * 60 * 1000; // 2 minutes — gaps longer than this count as idle // Server-side cache: 5 min TTL diff --git a/app/api/pixel-office/layout/route.ts b/app/api/pixel-office/layout/route.ts index 41a424d..53e41f3 100644 --- a/app/api/pixel-office/layout/route.ts +++ b/app/api/pixel-office/layout/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from 'next/server' import fs from 'fs' import path from 'path' -import os from 'os' +import { OPENCLAW_PIXEL_OFFICE_DIR } from '@/lib/openclaw-paths' -const LAYOUT_DIR = path.join(os.homedir(), '.openclaw', 'pixel-office') +const LAYOUT_DIR = OPENCLAW_PIXEL_OFFICE_DIR const LAYOUT_FILE = path.join(LAYOUT_DIR, 'layout.json') export async function GET() { diff --git a/app/api/sessions/[agentId]/route.ts b/app/api/sessions/[agentId]/route.ts index 06aa893..e2ce3a2 100644 --- a/app/api/sessions/[agentId]/route.ts +++ b/app/api/sessions/[agentId]/route.ts @@ -1,8 +1,7 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; - -const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw"); +import { OPENCLAW_HOME } from "@/lib/openclaw-paths"; export async function GET(_req: Request, { params }: { params: Promise<{ agentId: string }> }) { try { diff --git a/app/api/skills/route.ts b/app/api/skills/route.ts index c821010..ece7c1f 100644 --- a/app/api/skills/route.ts +++ b/app/api/skills/route.ts @@ -1,17 +1,12 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; - -const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw"); +import { getOpenclawPackageCandidates, OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths"; // Find OpenClaw package directory function findOpenClawPkg(): string { // Check common locations - const candidates = [ - path.join(process.env.HOME || "", ".nvm/versions/node", process.version, "lib/node_modules/openclaw"), - "/usr/local/lib/node_modules/openclaw", - "/usr/lib/node_modules/openclaw", - ]; + const candidates = getOpenclawPackageCandidates(); for (const c of candidates) { if (fs.existsSync(path.join(c, "package.json"))) return c; } @@ -148,7 +143,7 @@ export async function GET() { } // 5. Get agent info for display - const configPath = path.join(OPENCLAW_HOME, "openclaw.json"); + const configPath = OPENCLAW_CONFIG_PATH; const config = JSON.parse(fs.readFileSync(configPath, "utf-8")); const agentList = config.agents?.list || []; const agentMap: Record = {}; diff --git a/app/api/stats-all/route.ts b/app/api/stats-all/route.ts index 8bebc78..69c2775 100644 --- a/app/api/stats-all/route.ts +++ b/app/api/stats-all/route.ts @@ -1,8 +1,7 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; - -const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw"); +import { OPENCLAW_HOME } from "@/lib/openclaw-paths"; // 30秒内存缓存 let statsCache: { data: any; ts: number } | null = null; diff --git a/app/api/stats-models/route.ts b/app/api/stats-models/route.ts index 510a284..7589763 100644 --- a/app/api/stats-models/route.ts +++ b/app/api/stats-models/route.ts @@ -1,8 +1,7 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; - -const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw"); +import { OPENCLAW_HOME } from "@/lib/openclaw-paths"; // 30秒内存缓存 let statsCache: { data: any; ts: number } | null = null; diff --git a/app/api/stats/[agentId]/route.ts b/app/api/stats/[agentId]/route.ts index 07035a4..bcd7c51 100644 --- a/app/api/stats/[agentId]/route.ts +++ b/app/api/stats/[agentId]/route.ts @@ -1,8 +1,7 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; - -const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw"); +import { OPENCLAW_HOME } from "@/lib/openclaw-paths"; interface DayStat { date: string; // YYYY-MM-DD diff --git a/app/api/test-agents/route.ts b/app/api/test-agents/route.ts index d926200..ebb9a2f 100644 --- a/app/api/test-agents/route.ts +++ b/app/api/test-agents/route.ts @@ -2,9 +2,9 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; import { DEFAULT_MODEL_PROBE_TIMEOUT_MS, parseModelRef, probeModel } from "@/lib/model-probe"; +import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths"; -const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw"); -const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json"); +const CONFIG_PATH = OPENCLAW_CONFIG_PATH; const PROBE_TIMEOUT_MS = DEFAULT_MODEL_PROBE_TIMEOUT_MS; type AgentConfig = { @@ -98,4 +98,3 @@ export async function POST() { export async function GET() { return POST(); } - diff --git a/app/api/test-dm-sessions/route.ts b/app/api/test-dm-sessions/route.ts index eb445d2..de39215 100644 --- a/app/api/test-dm-sessions/route.ts +++ b/app/api/test-dm-sessions/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; - -const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw"); -const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json"); +import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths"; +import { parseApiJsonSafely, shouldFallbackToCli, testSessionViaCli } from "@/lib/session-test-fallback"; +const CONFIG_PATH = OPENCLAW_CONFIG_PATH; interface DmSessionResult { agentId: string; @@ -65,11 +65,18 @@ async function testDmSession( signal: AbortSignal.timeout(100000), }); - const data = await resp.json(); + const rawText = await resp.text(); + const data = parseApiJsonSafely(rawText); const elapsed = Date.now() - startTime; if (!resp.ok) { - return { agentId, platform, ok: false, error: data.error?.message || JSON.stringify(data), elapsed }; + if (shouldFallbackToCli(resp, rawText)) { + const fallback = await testSessionViaCli(agentId); + return fallback.ok + ? { agentId, platform, ok: true, detail: `${fallback.reply || "OK"} · DM fallback`, elapsed: fallback.elapsed } + : { agentId, platform, ok: false, error: fallback.error || "Gateway route not found", elapsed: fallback.elapsed }; + } + return { agentId, platform, ok: false, error: data?.error?.message || rawText || JSON.stringify(data), elapsed }; } const reply = data.choices?.[0]?.message?.content || ""; diff --git a/app/api/test-platforms/route.ts b/app/api/test-platforms/route.ts index 919f758..dee1a9c 100644 --- a/app/api/test-platforms/route.ts +++ b/app/api/test-platforms/route.ts @@ -1,9 +1,8 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; - -const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw"); -const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json"); +import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths"; +const CONFIG_PATH = OPENCLAW_CONFIG_PATH; const QQBOT_TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken"; const QQBOT_API_BASE = "https://api.sgroup.qq.com"; @@ -641,53 +640,6 @@ async function testQqbot( } } -// Agent session test: use Gateway chatCompletions API to send health check -// When sessionKey is provided, message routes to that session (e.g. feishu DM session) -async function testAgentSession(agentId: string, sessionKey: string | undefined, gatewayPort: number, gatewayToken: string): Promise<{ agentId: string; ok: boolean; reply?: string; error?: string; elapsed: number }> { - const startTime = Date.now(); - try { - const headers: Record = { - "Content-Type": "application/json", - "Authorization": `Bearer ${gatewayToken}`, - "x-openclaw-agent-id": agentId, - }; - if (sessionKey) { - headers["x-openclaw-session-key"] = sessionKey; - } - - const resp = await fetch(`http://127.0.0.1:${gatewayPort}/v1/chat/completions`, { - method: "POST", - headers, - body: JSON.stringify({ - model: `openclaw:${agentId}`, - messages: [{ role: "user", content: "Health check: reply with OK" }], - max_tokens: 64, - }), - signal: AbortSignal.timeout(100000), - }); - - const data = await resp.json(); - const elapsed = Date.now() - startTime; - - if (!resp.ok) { - return { agentId, ok: false, error: data.error?.message || JSON.stringify(data), elapsed }; - } - - const reply = data.choices?.[0]?.message?.content || ""; - return { - agentId, ok: true, - reply: reply.slice(0, 200) || "(no reply)", - elapsed, - }; - } catch (err: any) { - return { - agentId, ok: false, - error: err.message, - elapsed: Date.now() - startTime, - }; - } -} - export async function POST() { try { const raw = fs.readFileSync(CONFIG_PATH, "utf-8"); @@ -725,12 +677,10 @@ export async function POST() { // Phase 1: Platform API tests (parallel) const platformTests: Promise[] = []; - const agentIds: string[] = []; const testedFeishuAccounts = new Set(); for (const agent of agentList) { const id = agent.id; - agentIds.push(id); // Feishu const feishuBinding = bindings.find( @@ -764,21 +714,21 @@ export async function POST() { // WhatsApp: only test once, via gateway if (id === "main" && whatsappConfig && whatsappConfig.enabled !== false) { - const sessionUser = getWhatsappDmUser(id); + const recentDmUser = getWhatsappDmUser(id); const allowFromUser = getWhatsappAllowlistUser(whatsappConfig); - const whatsappTestUser = sessionUser || allowFromUser || null; + const whatsappTestUser = recentDmUser || allowFromUser || null; const source: "session" | "allowFrom" | "none" = - sessionUser ? "session" : (allowFromUser ? "allowFrom" : "none"); + recentDmUser ? "session" : (allowFromUser ? "allowFrom" : "none"); platformTests.push(testWhatsapp(id, gatewayPort, gatewayToken, whatsappTestUser, source)); } // QQBot: only test once, via `openclaw message send` if (id === "main" && qqbotConfig && qqbotConfig.enabled !== false) { - const sessionUser = normalizeQqbotTarget(getQqbotDmUser(id)); + const recentDmUser = normalizeQqbotTarget(getQqbotDmUser(id)); const allowFromUser = normalizeQqbotTarget(getQqbotAllowlistUser(qqbotConfig)); - const qqbotTestUser = sessionUser || allowFromUser || null; + const qqbotTestUser = recentDmUser || allowFromUser || null; const source: "session" | "allowFrom" | "none" = - sessionUser ? "session" : (allowFromUser ? "allowFrom" : "none"); + recentDmUser ? "session" : (allowFromUser ? "allowFrom" : "none"); platformTests.push(testQqbot(id, qqbotConfig, qqbotTestUser, source)); } } diff --git a/app/api/test-session/route.ts b/app/api/test-session/route.ts index 7dffa09..7df3491 100644 --- a/app/api/test-session/route.ts +++ b/app/api/test-session/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; - -const OPENCLAW_HOME = path.join(process.env.HOME || "/root", ".openclaw"); -const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json"); +import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths"; +import { parseApiJsonSafely, shouldFallbackToCli, testSessionViaCli } from "@/lib/session-test-fallback"; +const CONFIG_PATH = OPENCLAW_CONFIG_PATH; export async function POST(req: Request) { try { @@ -38,16 +38,26 @@ export async function POST(req: Request) { }), signal: AbortSignal.timeout(100000), }); - - const data = await resp.json(); + const rawText = await resp.text(); + const data = parseApiJsonSafely(rawText); const elapsed = Date.now() - startTime; if (!resp.ok) { + if (shouldFallbackToCli(resp, rawText)) { + const fallback = await testSessionViaCli(agentId); + return NextResponse.json({ + status: fallback.ok ? "ok" : "error", + sessionKey, + elapsed: fallback.elapsed, + reply: fallback.reply, + error: fallback.error, + }); + } return NextResponse.json({ status: "error", sessionKey, elapsed, - error: data.error?.message || JSON.stringify(data), + error: data?.error?.message || rawText || JSON.stringify(data), }); } diff --git a/app/api/test-sessions/route.ts b/app/api/test-sessions/route.ts index e3ec043..ba11eda 100644 --- a/app/api/test-sessions/route.ts +++ b/app/api/test-sessions/route.ts @@ -1,9 +1,9 @@ import { NextResponse } from "next/server"; import fs from "fs"; import path from "path"; - -const OPENCLAW_HOME = path.join(process.env.HOME || "/root", ".openclaw"); -const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json"); +import { OPENCLAW_CONFIG_PATH, OPENCLAW_HOME } from "@/lib/openclaw-paths"; +import { parseApiJsonSafely, shouldFallbackToCli, testSessionViaCli } from "@/lib/session-test-fallback"; +const CONFIG_PATH = OPENCLAW_CONFIG_PATH; function hasEmbeddedHttpError(reply: string): boolean { // Some providers return error text in content while gateway still returns HTTP 200. @@ -50,10 +50,18 @@ export async function POST() { }), signal: AbortSignal.timeout(100000), }); - const data = await resp.json(); + const rawText = await resp.text(); + const data = parseApiJsonSafely(rawText); const elapsed = Date.now() - startTime; if (!resp.ok) { - results.push({ agentId, ok: false, error: data.error?.message || "API error", elapsed }); + if (shouldFallbackToCli(resp, rawText)) { + const fallback = await testSessionViaCli(agentId); + results.push(fallback.ok + ? { agentId, ok: true, reply: fallback.reply, elapsed: fallback.elapsed } + : { agentId, ok: false, error: fallback.error || "Gateway route not found", elapsed: fallback.elapsed }); + } else { + results.push({ agentId, ok: false, error: data?.error?.message || rawText || "API error", elapsed }); + } } else { const reply = data.choices?.[0]?.message?.content || ""; const clippedReply = reply.slice(0, 200) || "(no reply)"; diff --git a/lib/json.ts b/lib/json.ts new file mode 100644 index 0000000..c809265 --- /dev/null +++ b/lib/json.ts @@ -0,0 +1,17 @@ +import fs, { promises as fsPromises } from "fs"; + +export function stripUtf8Bom(text: string): string { + return text.replace(/^\uFEFF/, ""); +} + +export function parseJsonText(text: string): T { + return JSON.parse(stripUtf8Bom(text)) as T; +} + +export function readJsonFileSync(filePath: string): T { + return parseJsonText(fs.readFileSync(filePath, "utf-8")); +} + +export async function readJsonFile(filePath: string): Promise { + return parseJsonText(await fsPromises.readFile(filePath, "utf-8")); +} diff --git a/lib/model-probe.ts b/lib/model-probe.ts index c36ccee..9a6978d 100644 --- a/lib/model-probe.ts +++ b/lib/model-probe.ts @@ -1,9 +1,11 @@ -import fs from "fs"; import path from "path"; -import { execFile } from "child_process"; +import { exec, execFile } from "child_process"; import { promisify } from "util"; +import { readJsonFileSync } from "@/lib/json"; +import { OPENCLAW_HOME } from "@/lib/openclaw-paths"; const execFileAsync = promisify(execFile); +const execAsync = promisify(exec); export const DEFAULT_MODEL_PROBE_TIMEOUT_MS = 15000; @@ -55,9 +57,31 @@ interface ProbeModelParams { timeoutMs?: number; } -const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw"); const MODELS_PATH = path.join(OPENCLAW_HOME, "agents", "main", "agent", "models.json"); +function quoteShellArg(arg: string): string { + if (/^[A-Za-z0-9_./:=@-]+$/.test(arg)) return arg; + return `"${arg.replace(/"/g, '""')}"`; +} + +async function execOpenclaw(args: string[]): Promise<{ stdout: string; stderr: string }> { + const env = { ...process.env, FORCE_COLOR: "0" }; + + if (process.platform !== "win32") { + return execFileAsync("openclaw", args, { + maxBuffer: 10 * 1024 * 1024, + env, + }); + } + + const command = `openclaw ${args.map(quoteShellArg).join(" ")}`; + return execAsync(command, { + maxBuffer: 10 * 1024 * 1024, + env, + shell: "cmd.exe", + }); +} + function parseJsonFromMixedOutput(output: string): any { for (let i = 0; i < output.length; i++) { if (output[i] !== "{") continue; @@ -95,8 +119,7 @@ function parseJsonFromMixedOutput(output: string): any { function loadProviderConfig(providerId: string): ProviderConfig | null { try { - const raw = fs.readFileSync(MODELS_PATH, "utf-8"); - const parsed = JSON.parse(raw); + const parsed = readJsonFileSync(MODELS_PATH); const providers = parsed?.providers; if (!providers || typeof providers !== "object") return null; const exact = providers[providerId]; @@ -281,9 +304,7 @@ async function probeModelDirect(params: ProbeModelParams): Promise { const timeoutMs = params.timeoutMs ?? DEFAULT_MODEL_PROBE_TIMEOUT_MS; const startedAt = Date.now(); - const { stdout, stderr } = await execFileAsync( - "openclaw", - [ + const { stdout, stderr } = await execOpenclaw([ "models", "status", "--probe", @@ -292,12 +313,7 @@ async function probeProviderViaOpenclaw(params: ProbeModelParams): Promise { + const env = { ...process.env, FORCE_COLOR: "0" }; + + if (process.platform !== "win32") { + return execFileAsync("openclaw", args, { + maxBuffer: 10 * 1024 * 1024, + env, + }); + } + + const command = `openclaw ${args.map(quoteShellArg).join(" ")}`; + return execAsync(command, { + maxBuffer: 10 * 1024 * 1024, + env, + shell: "cmd.exe", + }); +} + +export function parseJsonFromMixedOutput(output: string): any { + for (let i = 0; i < output.length; i++) { + if (output[i] !== "{") continue; + let depth = 0; + let inString = false; + let escaped = false; + for (let j = i; j < output.length; j++) { + const ch = output[j]; + if (inString) { + if (escaped) escaped = false; + else if (ch === "\\") escaped = true; + else if (ch === "\"") inString = false; + continue; + } + if (ch === "\"") { + inString = true; + continue; + } + if (ch === "{") depth++; + else if (ch === "}") { + depth--; + if (depth === 0) { + const candidate = output.slice(i, j + 1).trim(); + try { + return JSON.parse(candidate); + } catch { + break; + } + } + } + } + } + return null; +} diff --git a/lib/openclaw-paths.ts b/lib/openclaw-paths.ts new file mode 100644 index 0000000..4d55320 --- /dev/null +++ b/lib/openclaw-paths.ts @@ -0,0 +1,17 @@ +import os from "os"; +import path from "path"; + +export const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(os.homedir(), ".openclaw"); +export const OPENCLAW_CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json"); +export const OPENCLAW_AGENTS_DIR = path.join(OPENCLAW_HOME, "agents"); +export const OPENCLAW_PIXEL_OFFICE_DIR = path.join(OPENCLAW_HOME, "pixel-office"); + +export function getOpenclawPackageCandidates(version = process.version): string[] { + const home = os.homedir(); + return [ + path.join(home, ".nvm", "versions", "node", version, "lib", "node_modules", "openclaw"), + path.join(home, "AppData", "Roaming", "npm", "node_modules", "openclaw"), + "/usr/local/lib/node_modules/openclaw", + "/usr/lib/node_modules/openclaw", + ]; +} diff --git a/lib/session-test-fallback.ts b/lib/session-test-fallback.ts new file mode 100644 index 0000000..b451c44 --- /dev/null +++ b/lib/session-test-fallback.ts @@ -0,0 +1,61 @@ +import { execOpenclaw, parseJsonFromMixedOutput } from "@/lib/openclaw-cli"; + +function extractCliReply(parsed: any, stdout: string): string { + const candidates = [ + parsed?.reply, + parsed?.text, + parsed?.outputText, + parsed?.result?.reply, + parsed?.result?.text, + parsed?.response?.text, + parsed?.response?.output_text, + parsed?.message, + ]; + const hit = candidates.find((value) => typeof value === "string" && value.trim()); + if (hit) return hit.trim().slice(0, 200); + if (parsed?.status === "ok") { + const summary = typeof parsed?.summary === "string" && parsed.summary.trim() ? parsed.summary.trim() : "completed"; + return `OK (${summary}, CLI fallback)`; + } + return (stdout || "(no reply)").trim().slice(0, 200); +} + +export async function testSessionViaCli(agentId: string): Promise<{ ok: boolean; reply?: string; error?: string; elapsed: number }> { + const startTime = Date.now(); + try { + const { stdout, stderr } = await execOpenclaw([ + "agent", + "--agent", + agentId, + "--message", + "Health check: reply with OK", + "--json", + "--timeout", + "100", + ]); + const elapsed = Date.now() - startTime; + const parsed = parseJsonFromMixedOutput(`${stdout}\n${stderr || ""}`); + const error = parsed?.error?.message || parsed?.error; + if (typeof error === "string" && error.trim()) { + return { ok: false, error: error.trim().slice(0, 300), elapsed }; + } + return { ok: true, reply: extractCliReply(parsed, stdout), elapsed }; + } catch (err: any) { + const elapsed = Date.now() - startTime; + return { ok: false, error: (err?.message || "CLI fallback failed").slice(0, 300), elapsed }; + } +} + +export function shouldFallbackToCli(resp: Response, rawText: string): boolean { + const text = rawText.trim(); + return resp.status === 404 || text === "Not Found"; +} + +export function parseApiJsonSafely(rawText: string): any { + if (!rawText) return null; + try { + return JSON.parse(rawText); + } catch { + return null; + } +}