From 3cca2252a67fdd2d28ef2dd1dc8b84ed1c4f7fef Mon Sep 17 00:00:00 2001 From: decolua Date: Sat, 16 May 2026 10:54:41 +0700 Subject: [PATCH] chore: add buildOutput RTK filter, drop legacy cloud sync, internal cleanup - feat(rtk): buildOutput filter + autodetect for npm/yarn/cargo logs - chore: remove unused cloud sync module and related routes - ui: hide deprecated providers (qwen, iflow, antigravity) - chore: minor tray/cli/internal adjustments --- cli/cli.js | 33 +- cli/src/cli/tray/tray.js | 56 ++- open-sse/rtk/autodetect.js | 11 +- open-sse/rtk/constants.js | 3 +- open-sse/rtk/filters/buildOutput.js | 127 +++++++ .../(dashboard)/dashboard/providers/page.js | 14 +- src/app/api/cloud/auth/route.js | 50 --- src/app/api/cloud/credentials/update/route.js | 57 --- src/app/api/cloud/model/resolve/route.js | 50 --- src/app/api/cloud/models/alias/route.js | 72 ---- src/app/api/init/route.js | 5 +- src/app/api/models/test/route.js | 5 + .../api/providers/[id]/test-models/route.js | 12 +- src/app/api/providers/client/route.js | 50 ++- src/app/api/v1/chat/completions/route.js | 1 - src/app/layout.js | 1 - src/app/page.js | 2 - src/dashboardGuard.js | 8 + src/lib/initCloudSync.js | 31 -- src/shared/constants/providers.js | 16 +- src/shared/services/cloudSyncScheduler.js | 122 ------ src/shared/services/initializeCloudSync.js | 37 -- src/shared/utils/cloud.js | 40 -- src/shared/utils/machine.js | 14 +- tests/unit/buildOutputFilter.test.js | 232 ++++++++++++ .../unit/buildOutputFilterAdversarial.test.js | 357 ++++++++++++++++++ 26 files changed, 871 insertions(+), 535 deletions(-) create mode 100644 open-sse/rtk/filters/buildOutput.js delete mode 100644 src/app/api/cloud/auth/route.js delete mode 100644 src/app/api/cloud/credentials/update/route.js delete mode 100644 src/app/api/cloud/model/resolve/route.js delete mode 100644 src/app/api/cloud/models/alias/route.js delete mode 100644 src/lib/initCloudSync.js delete mode 100644 src/shared/services/cloudSyncScheduler.js delete mode 100644 src/shared/services/initializeCloudSync.js delete mode 100644 src/shared/utils/cloud.js create mode 100644 tests/unit/buildOutputFilter.test.js create mode 100644 tests/unit/buildOutputFilterAdversarial.test.js diff --git a/cli/cli.js b/cli/cli.js index aa6fee9..da5dbf8 100755 --- a/cli/cli.js +++ b/cli/cli.js @@ -219,8 +219,12 @@ function killAllAppProcesses(appPort) { }); const lines = output.split("\n").slice(1).filter(l => l.trim()); lines.forEach(line => { - const isAppProcess = line.toLowerCase().includes("9router") || - line.toLowerCase().includes("next-server"); + // Whitelist: real node process running 9router/cli.js, or next-server. + // Avoids killing editors/grep/strace/cursor that just have "9router" in cmdline. + const cmd = line.toLowerCase(); + const isAppProcess = + (cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("\\9router") || cmd.includes("/9router"))) + || cmd.includes("next-server"); if (isAppProcess) { const match = line.match(/^"(\d+)"/); if (match && match[1] && match[1] !== process.pid.toString()) { @@ -241,7 +245,12 @@ function killAllAppProcesses(appPort) { const lines = output.split('\n'); lines.forEach(line => { - const isAppProcess = line.includes("9router") || line.includes("next-server"); + // Whitelist: real node process running 9router/cli.js, or next-server. + // Avoids killing grep/strace/editors/cursor that incidentally match "9router". + const cmd = line.toLowerCase(); + const isAppProcess = + (cmd.includes("node") && cmd.includes("9router") && (cmd.includes("cli.js") || cmd.includes("/9router"))) + || cmd.includes("next-server"); if (isAppProcess) { const parts = line.trim().split(/\s+/); const pid = parts[1]; @@ -685,10 +694,10 @@ function startServer(latestVersion) { const { clearScreen } = require("./src/cli/utils/display"); clearScreen(); - // Kill current tray FIRST so the new bgProcess can register a fresh - // NSStatusItem on macOS without conflicting with the orphan binary - try { require("./src/cli/tray/tray").killTray(); } catch (e) { } - await new Promise(r => setTimeout(r, 300)); + // Kill current tray and AWAIT Go binary fully exit. macOS needs the + // old NSStatusItem released before a new tray process can register; + // otherwise the bgProcess tray silently fails ("works sometimes"). + try { await require("./src/cli/tray/tray").killTray(); } catch (e) { } // Enable auto startup on OS boot try { @@ -699,10 +708,18 @@ function startServer(latestVersion) { } } catch (e) { } + // Log bgProcess stderr to file so silent tray failures are debuggable. + // Previously stdio:"ignore" swallowed every error from systray2 init. + const logDir = path.join(getAppDataDir(), "logs"); + try { fs.mkdirSync(logDir, { recursive: true }); } catch (e) { } + const bgLogPath = path.join(logDir, "tray-bg.log"); + let bgLogFd = "ignore"; + try { bgLogFd = fs.openSync(bgLogPath, "a"); } catch (e) { } + // Spawn new detached process with --tray flag const bgProcess = spawn(process.execPath, [__filename, "--tray", "--skip-update", "-p", port.toString()], { detached: true, - stdio: "ignore", + stdio: ["ignore", bgLogFd, bgLogFd], windowsHide: true, env: { ...process.env } }); diff --git a/cli/src/cli/tray/tray.js b/cli/src/cli/tray/tray.js index acf74ac..db9b574 100644 --- a/cli/src/cli/tray/tray.js +++ b/cli/src/cli/tray/tray.js @@ -208,7 +208,7 @@ function initUnixTray(options) { items }; - trayInstance = new SysTray({ menu, debug: false, copyDir: false }); + trayInstance = new SysTray({ menu, debug: false, copyDir: true }); isWinTray = false; trayInstance.onClick((action) => { @@ -245,31 +245,49 @@ function initUnixTray(options) { } /** - * Kill/close system tray gracefully + * Kill tray, wait Go binary fully exit (returns Promise). + * Critical for hide-to-tray: macOS must release NSStatusItem before bgProcess + * spawns a new tray, otherwise the new icon silently fails to register. */ function killTray() { const instance = trayInstance; const wasWin = isWinTray; trayInstance = null; + if (!instance) return Promise.resolve(); - if (instance) { - try { - if (wasWin) instance.kill(); - else { - // systray2.kill(false) closes IPC but leaves the Go tray binary - // subprocess running, which keeps an orphan NSStatusItem on macOS - // and blocks a freshly spawned tray (e.g. hide-to-tray bgProcess) - // from registering. Kill the child PID directly first. - try { - const proc = instance._process || (typeof instance.process === "function" ? instance.process() : null); - if (proc && proc.pid) { - process.kill(proc.pid, "SIGKILL"); - } - } catch (e) {} - instance.kill(false); - } - } catch (e) {} + if (wasWin) { + try { instance.kill(); } catch (e) {} + return Promise.resolve(); } + + // Unix: get the Go tray child process handle, SIGKILL it, await "exit" + let proc = null; + try { + proc = instance._process || (typeof instance.process === "function" ? instance.process() : null); + } catch (e) {} + + // Always close IPC (best-effort, may throw if pipe already broken) + const closeIpc = () => { try { instance.kill(false); } catch (e) {} }; + + if (!proc || !proc.pid) { + closeIpc(); + return Promise.resolve(); + } + + return new Promise((resolve) => { + let done = false; + const finish = () => { if (done) return; done = true; closeIpc(); resolve(); }; + + proc.once("exit", finish); + try { proc.kill("SIGKILL"); } catch (e) {} + + // Fallback poll in case "exit" never fires (detached child, pipe closed) + const deadline = Date.now() + 3000; + const poll = setInterval(() => { + try { process.kill(proc.pid, 0); } catch { clearInterval(poll); finish(); return; } + if (Date.now() > deadline) { clearInterval(poll); finish(); } + }, 50); + }); } /** diff --git a/open-sse/rtk/autodetect.js b/open-sse/rtk/autodetect.js index 607a548..99ab6a7 100644 --- a/open-sse/rtk/autodetect.js +++ b/open-sse/rtk/autodetect.js @@ -1,9 +1,10 @@ // Port of auto_detect_filter (rtk/src/cmds/system/pipe_cmd.rs:132-188) + JS extras -// Order: git-diff → git-status → grep → find → tree → ls → search-list +// Order: git-diff → git-status → build-output → grep → find → tree → ls → search-list // → read-numbered → dedup-log → smart-truncate → null import { DETECT_WINDOW, READ_NUMBERED_MIN_HIT_RATIO, SMART_TRUNCATE_MIN_LINES } from "./constants.js"; import { gitDiff } from "./filters/gitDiff.js"; import { gitStatus } from "./filters/gitStatus.js"; +import { buildOutput } from "./filters/buildOutput.js"; import { grep } from "./filters/grep.js"; import { find } from "./filters/find.js"; import { dedupLog } from "./filters/dedupLog.js"; @@ -17,6 +18,7 @@ const RE_GIT_DIFF = /^diff --git /m; const RE_GIT_DIFF_HUNK = /^@@ /m; const RE_GIT_STATUS = /^On branch |^nothing to commit|^Changes (not |to be )|^Untracked files:/m; const RE_PORCELAIN = /^[ MADRCU?!][ MADRCU?!] \S/m; +const RE_BUILD_OUTPUT = /^(npm (warn|error|ERR!)|yarn (warn|error)|\s*Compiling\s+\S+|\s*Downloading\s+\S+|added \d+ package|\[ERROR\]|BUILD (SUCCESS|FAILED)|\s*Finished\s+|Successfully (installed|built)|ERROR:)/im; const RE_TREE_GLYPH = /[├└]──|│ /; const RE_LS_ROW = /^[-dlbcps][rwx-]{9}/m; const RE_LS_TOTAL = /^total \d+$/m; @@ -26,7 +28,12 @@ export function autoDetectFilter(text) { const head = text.length > DETECT_WINDOW ? text.slice(0, DETECT_WINDOW) : text; if (RE_GIT_DIFF.test(head) || RE_GIT_DIFF_HUNK.test(head)) return gitDiff; - if (RE_GIT_STATUS.test(head) || isMostlyPorcelain(head)) return gitStatus; + if (RE_GIT_STATUS.test(head)) return gitStatus; + + // Build output BEFORE porcelain check: prevents cargo "Compiling" misdetection as git-status + if (RE_BUILD_OUTPUT.test(head)) return buildOutput; + + if (isMostlyPorcelain(head)) return gitStatus; const lines = head.split("\n"); const nonEmpty = lines.filter(l => l.trim().length > 0); diff --git a/open-sse/rtk/constants.js b/open-sse/rtk/constants.js index 4966713..b62740c 100644 --- a/open-sse/rtk/constants.js +++ b/open-sse/rtk/constants.js @@ -50,5 +50,6 @@ export const FILTERS = { DEDUP_LOG: "dedup-log", SMART_TRUNCATE: "smart-truncate", READ_NUMBERED: "read-numbered", - SEARCH_LIST: "search-list" + SEARCH_LIST: "search-list", + BUILD_OUTPUT: "build-output" }; diff --git a/open-sse/rtk/filters/buildOutput.js b/open-sse/rtk/filters/buildOutput.js new file mode 100644 index 0000000..4d3dde9 --- /dev/null +++ b/open-sse/rtk/filters/buildOutput.js @@ -0,0 +1,127 @@ +// Compress build tool output (npm, cargo, pip, maven, gradle, etc.) +// Keeps: errors, warnings, final summary +// Strips: progress logs, verbose "Compiling X" lists, download logs + +// Cargo/rustc error continuation: " --> file:line", " |", "N | code", " = note: ..." +const RE_CARGO_ERR_CONT = /^\s*(-->|\||\d+\s*\||=)/; +const DEPRECATION_KEEP = 3; + +export function buildOutput(input) { + const lines = input.split("\n"); + if (lines.length === 0) return input; + + const errors = []; + const warnings = []; + const deprecations = []; + let summary = null; + let compilingCount = 0; + let downloadingCount = 0; + let inCargoError = false; + + for (const line of lines) { + const trimmed = line.trim(); + + // Continuation of cargo error block: keep verbatim while in block + if (inCargoError) { + if (!trimmed) { inCargoError = false; continue; } + if (RE_CARGO_ERR_CONT.test(line)) { errors.push(line); continue; } + inCargoError = false; + } + + if (!trimmed) continue; + + if (/^npm (ERR!|error)/i.test(trimmed) || /^yarn error/i.test(trimmed)) { + errors.push(line); + continue; + } + + if (/^npm warn deprecated/i.test(trimmed)) { + deprecations.push(line); + continue; + } + if (/^npm warn/i.test(trimmed) || /^yarn warn/i.test(trimmed)) { + warnings.push(line); + continue; + } + + if (/^error(\[|:)/i.test(trimmed) || trimmed.startsWith("error -->")) { + errors.push(line); + inCargoError = true; + continue; + } + + if (/^warning(\[|:)/i.test(trimmed) || trimmed.startsWith("warning -->")) { + warnings.push(line); + inCargoError = true; + continue; + } + + if (/^ERROR:/i.test(trimmed)) { + errors.push(line); + continue; + } + + if (/^\[ERROR\]/i.test(trimmed) || /^BUILD FAILED/i.test(trimmed)) { + errors.push(line); + continue; + } + + if (/^\[WARNING\]/i.test(trimmed)) { + warnings.push(line); + continue; + } + + if (/^\s*Compiling\s+\S+/i.test(trimmed)) { + compilingCount++; + continue; + } + if (/^\s*Downloading\s+\S+/i.test(trimmed) || /^Fetching\s+/i.test(trimmed)) { + downloadingCount++; + continue; + } + + if ( + /^(added|removed|changed|audited|installed)\s+\d+\s+package/i.test(trimmed) || + /^\s*Finished\s+/i.test(trimmed) || + /^BUILD SUCCESS/i.test(trimmed) || + /^\d+\s+(vulnerabilities|packages?|warnings?|errors?)/i.test(trimmed) || + /^Successfully (installed|built)/i.test(trimmed) || + /^To address .* issues/i.test(trimmed) || + /^Run `npm (audit|fund)`/i.test(trimmed) || + /packages are looking for funding/i.test(trimmed) + ) { + summary = summary ? `${summary}\n${line}` : line; + continue; + } + } + + let out = ""; + + // Keep first N deprecations verbatim (package name + reason), count the rest + const keepDep = deprecations.slice(0, DEPRECATION_KEEP); + for (const d of keepDep) out += `${d}\n`; + if (deprecations.length > DEPRECATION_KEEP) { + out += `... +${deprecations.length - DEPRECATION_KEEP} more deprecated packages\n`; + } + + if (compilingCount > 0) { + out += `Compiled ${compilingCount} packages\n`; + } + if (downloadingCount > 0) { + out += `Downloaded ${downloadingCount} packages\n`; + } + + for (const e of errors) out += `${e}\n`; + + const keepWarnings = warnings.slice(0, 5); + for (const w of keepWarnings) out += `${w}\n`; + if (warnings.length > 5) { + out += `... +${warnings.length - 5} more warnings\n`; + } + + if (summary) out += `${summary}\n`; + + return out.replace(/\n+$/, "") || input; +} + +buildOutput.filterName = "build-output"; diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index 43d4e30..8ca94ab 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -273,19 +273,21 @@ export default function ProvidersPage() { })) .filter((p) => matchSearch(p.name)); - const oauthEntries = Object.entries(OAUTH_PROVIDERS).filter(([, info]) => - matchSearch(info.name), + const oauthEntries = Object.entries(OAUTH_PROVIDERS).filter( + ([, info]) => !info.hidden && matchSearch(info.name), ); - const freeEntries = Object.entries(FREE_PROVIDERS).filter(([, info]) => - matchSearch(info.name), + const freeEntries = Object.entries(FREE_PROVIDERS).filter( + ([, info]) => !info.hidden && matchSearch(info.name), ); const freeTierEntries = Object.entries(FREE_TIER_PROVIDERS).filter( - ([, info]) => matchSearch(info.name), + ([, info]) => !info.hidden && matchSearch(info.name), ); const apikeyEntries = sortByPriority( Object.entries(APIKEY_PROVIDERS).filter( ([, info]) => - (info.serviceKinds ?? ["llm"]).includes("llm") && matchSearch(info.name), + !info.hidden && + (info.serviceKinds ?? ["llm"]).includes("llm") && + matchSearch(info.name), ), "apikey", ); diff --git a/src/app/api/cloud/auth/route.js b/src/app/api/cloud/auth/route.js deleted file mode 100644 index 52abd5f..0000000 --- a/src/app/api/cloud/auth/route.js +++ /dev/null @@ -1,50 +0,0 @@ -import { NextResponse } from "next/server"; -import { validateApiKey, getProviderConnections, getModelAliases } from "@/models"; - -// Verify API key and return provider credentials -export async function POST(request) { - try { - const authHeader = request.headers.get("Authorization"); - if (!authHeader?.startsWith("Bearer ")) { - return NextResponse.json({ error: "Missing API key" }, { status: 401 }); - } - - const apiKey = authHeader.slice(7); - - // Validate API key - const isValid = await validateApiKey(apiKey); - if (!isValid) { - return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); - } - - // Get active provider connections - const connections = await getProviderConnections({ isActive: true }); - - // Map connections - const mappedConnections = connections.map(conn => ({ - provider: conn.provider, - authType: conn.authType, - apiKey: conn.apiKey || null, - accessToken: conn.accessToken || null, - refreshToken: conn.refreshToken || null, - projectId: conn.projectId || null, - expiresAt: conn.expiresAt, - priority: conn.priority, - globalPriority: conn.globalPriority, - defaultModel: conn.defaultModel, - isActive: conn.isActive - })); - - // Get model aliases - const modelAliases = await getModelAliases(); - - return NextResponse.json({ - connections: mappedConnections, - modelAliases - }); - - } catch (error) { - console.log("Cloud auth error:", error); - return NextResponse.json({ error: "Internal error" }, { status: 500 }); - } -} diff --git a/src/app/api/cloud/credentials/update/route.js b/src/app/api/cloud/credentials/update/route.js deleted file mode 100644 index fa25ba3..0000000 --- a/src/app/api/cloud/credentials/update/route.js +++ /dev/null @@ -1,57 +0,0 @@ -import { NextResponse } from "next/server"; -import { validateApiKey, getProviderConnections, updateProviderConnection } from "@/models"; - -// Update provider credentials (for cloud token refresh) -export async function PUT(request) { - try { - const authHeader = request.headers.get("Authorization"); - if (!authHeader?.startsWith("Bearer ")) { - return NextResponse.json({ error: "Missing API key" }, { status: 401 }); - } - - const apiKey = authHeader.slice(7); - const body = await request.json(); - const { provider, credentials } = body; - - if (!provider || !credentials) { - return NextResponse.json({ error: "Provider and credentials required" }, { status: 400 }); - } - - // Validate API key - const isValid = await validateApiKey(apiKey); - if (!isValid) { - return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); - } - - // Find active connection for provider - const connections = await getProviderConnections({ provider, isActive: true }); - const connection = connections[0]; - - if (!connection) { - return NextResponse.json({ error: `No active connection found for provider: ${provider}` }, { status: 404 }); - } - - // Update credentials - const updateData = {}; - if (credentials.accessToken) { - updateData.accessToken = credentials.accessToken; - } - if (credentials.refreshToken) { - updateData.refreshToken = credentials.refreshToken; - } - if (credentials.expiresIn) { - updateData.expiresAt = new Date(Date.now() + credentials.expiresIn * 1000).toISOString(); - } - - await updateProviderConnection(connection.id, updateData); - - return NextResponse.json({ - success: true, - message: `Credentials updated for provider: ${provider}` - }); - - } catch (error) { - console.log("Update credentials error:", error); - return NextResponse.json({ error: "Failed to update credentials" }, { status: 500 }); - } -} diff --git a/src/app/api/cloud/model/resolve/route.js b/src/app/api/cloud/model/resolve/route.js deleted file mode 100644 index db6b64c..0000000 --- a/src/app/api/cloud/model/resolve/route.js +++ /dev/null @@ -1,50 +0,0 @@ -import { NextResponse } from "next/server"; -import { validateApiKey, getModelAliases } from "@/models"; - -// Resolve model alias to provider/model -export async function POST(request) { - try { - const authHeader = request.headers.get("Authorization"); - if (!authHeader?.startsWith("Bearer ")) { - return NextResponse.json({ error: "Missing API key" }, { status: 401 }); - } - - const apiKey = authHeader.slice(7); - - const body = await request.json(); - const { alias } = body; - - if (!alias) { - return NextResponse.json({ error: "Missing alias" }, { status: 400 }); - } - - // Validate API key - const isValid = await validateApiKey(apiKey); - if (!isValid) { - return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); - } - - // Get model aliases - const modelAliases = await getModelAliases(); - const resolved = modelAliases[alias]; - - if (resolved) { - // Parse provider/model - const firstSlash = resolved.indexOf("/"); - if (firstSlash > 0) { - return NextResponse.json({ - alias, - provider: resolved.slice(0, firstSlash), - model: resolved.slice(firstSlash + 1) - }); - } - } - - // Not found - return NextResponse.json({ error: "Alias not found" }, { status: 404 }); - - } catch (error) { - console.log("Model resolve error:", error); - return NextResponse.json({ error: "Internal error" }, { status: 500 }); - } -} diff --git a/src/app/api/cloud/models/alias/route.js b/src/app/api/cloud/models/alias/route.js deleted file mode 100644 index c4ef56e..0000000 --- a/src/app/api/cloud/models/alias/route.js +++ /dev/null @@ -1,72 +0,0 @@ -import { NextResponse } from "next/server"; -import { validateApiKey, getModelAliases, setModelAlias } from "@/models"; - -// PUT /api/cloud/models/alias - Set model alias (for cloud/CLI) -export async function PUT(request) { - try { - const authHeader = request.headers.get("authorization"); - const apiKey = authHeader?.replace("Bearer ", ""); - - if (!apiKey) { - return NextResponse.json({ error: "Missing API key" }, { status: 401 }); - } - - const isValid = await validateApiKey(apiKey); - if (!isValid) { - return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); - } - - const body = await request.json(); - const { model, alias } = body; - - if (!model || !alias) { - return NextResponse.json({ error: "Model and alias required" }, { status: 400 }); - } - - // Check if alias already exists for different model - const aliases = await getModelAliases(); - const existingModel = aliases[alias]; - if (existingModel && existingModel !== model) { - return NextResponse.json({ - error: `Alias '${alias}' already in use for model '${existingModel}'` - }, { status: 400 }); - } - - // Update alias - await setModelAlias(alias, model); - - return NextResponse.json({ - success: true, - model, - alias, - message: `Alias '${alias}' set for model '${model}'` - }); - } catch (error) { - console.log("Error updating alias:", error); - return NextResponse.json({ error: "Failed to update alias" }, { status: 500 }); - } -} - -// GET /api/cloud/models/alias - Get all aliases -export async function GET(request) { - try { - const authHeader = request.headers.get("authorization"); - const apiKey = authHeader?.replace("Bearer ", ""); - - if (!apiKey) { - return NextResponse.json({ error: "Missing API key" }, { status: 401 }); - } - - const isValid = await validateApiKey(apiKey); - if (!isValid) { - return NextResponse.json({ error: "Invalid API key" }, { status: 401 }); - } - - const aliases = await getModelAliases(); - - return NextResponse.json({ aliases }); - } catch (error) { - console.log("Error fetching aliases:", error); - return NextResponse.json({ error: "Failed to fetch aliases" }, { status: 500 }); - } -} diff --git a/src/app/api/init/route.js b/src/app/api/init/route.js index 2630bab..2e2b13f 100644 --- a/src/app/api/init/route.js +++ b/src/app/api/init/route.js @@ -1,7 +1,4 @@ -// Auto-initialize cloud sync when server starts -import "@/lib/initCloudSync"; - -// This API route is called automatically to initialize sync +// This API route is called automatically to initialize app export async function GET() { return new Response("Initialized", { status: 200 }); } diff --git a/src/app/api/models/test/route.js b/src/app/api/models/test/route.js index b2f7b76..bad3fe7 100644 --- a/src/app/api/models/test/route.js +++ b/src/app/api/models/test/route.js @@ -1,6 +1,9 @@ import { NextResponse } from "next/server"; import { getApiKeys } from "@/lib/localDb"; import { UPDATER_CONFIG } from "@/shared/constants/config"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; + +const CLI_TOKEN_SALT = "9r-cli-auth"; // POST /api/models/test - Ping a single model via internal completions or embeddings export async function POST(request) { @@ -19,6 +22,8 @@ export async function POST(request) { const headers = { "Content-Type": "application/json" }; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + // Bypass dashboardGuard for internal self-call via CLI token (machineId-based) + headers["x-9r-cli-token"] = await getConsistentMachineId(CLI_TOKEN_SALT); const start = Date.now(); diff --git a/src/app/api/providers/[id]/test-models/route.js b/src/app/api/providers/[id]/test-models/route.js index d7e9c54..f126727 100644 --- a/src/app/api/providers/[id]/test-models/route.js +++ b/src/app/api/providers/[id]/test-models/route.js @@ -3,6 +3,9 @@ import { getProviderConnectionById, getApiKeys } from "@/lib/localDb"; import { getProviderModels, PROVIDER_ID_TO_ALIAS } from "open-sse/config/providerModels.js"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; import { UPDATER_CONFIG } from "@/shared/constants/config"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; + +const CLI_TOKEN_SALT = "9r-cli-auth"; /** * Get an active API key to pass through auth when requireApiKey is enabled. @@ -16,11 +19,12 @@ async function getInternalApiKey() { * Ping a single model via internal completions endpoint (OpenAI format). * open-sse handles all provider translation automatically. */ -async function pingModel(modelId, baseUrl, apiKey) { +async function pingModel(modelId, baseUrl, apiKey, cliToken) { const start = Date.now(); try { const headers = { "Content-Type": "application/json" }; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; + if (cliToken) headers["x-9r-cli-token"] = cliToken; const res = await fetch(`${baseUrl}/api/v1/chat/completions`, { method: "POST", headers, @@ -83,17 +87,19 @@ export async function POST(request, { params }) { } const apiKey = await getInternalApiKey(); + // Bypass dashboardGuard for internal self-call via CLI token (machineId-based) + const cliToken = await getConsistentMachineId(CLI_TOKEN_SALT); // Warm up with first model to trigger token refresh (if needed) before parallel calls. // This prevents race condition where multiple requests concurrently refresh the same token. const [first, ...rest] = models; - const firstResult = await pingModel(`${alias}/${first.id}`, baseUrl, apiKey); + const firstResult = await pingModel(`${alias}/${first.id}`, baseUrl, apiKey, cliToken); const results = [{ modelId: first.id, name: first.name || first.id, ...firstResult }]; if (rest.length > 0) { const restResults = await Promise.all( rest.map(async (model) => { - const result = await pingModel(`${alias}/${model.id}`, baseUrl, apiKey); + const result = await pingModel(`${alias}/${model.id}`, baseUrl, apiKey, cliToken); return { modelId: model.id, name: model.name || model.id, ...result }; }) ); diff --git a/src/app/api/providers/client/route.js b/src/app/api/providers/client/route.js index 33a3112..a2bcb52 100644 --- a/src/app/api/providers/client/route.js +++ b/src/app/api/providers/client/route.js @@ -2,19 +2,51 @@ import { NextResponse } from "next/server"; import { getProviderConnections } from "@/lib/localDb"; import { backfillCodexEmails } from "@/lib/oauth/providers"; -// GET /api/providers/client - List all connections for client (includes sensitive fields for sync) +// Whitelist: only safe metadata fields exposed to UI +const SAFE_FIELDS = [ + "id", "provider", "authType", "name", "email", "displayName", + "priority", "globalPriority", "isActive", "defaultModel", + "testStatus", "lastError", "lastErrorAt", "errorCode", + "expiresAt", "lastUsedAt", "consecutiveUseCount", + "createdAt", "updatedAt", +]; + +// providerSpecificData fields safe to expose (non-secret config only) +const SAFE_PSD_FIELDS = [ + "baseUrl", "azureEndpoint", "deployment", "apiVersion", "accountId", + "region", "projectId", "resourceUrl", "proxyPoolId", + "connectionProxyEnabled", "connectionProxyUrl", "connectionNoProxy", + "githubLogin", "githubName", "githubEmail", "githubUserId", + "username", "firstName", "lastName", "authMethod", "authKind", +]; + +function maskName(name) { + if (typeof name !== "string" || name.length <= 16) return name; + // Names like "hahask-uDUOg90..." may embed API keys — mask if looks like key + if (/[a-zA-Z0-9_-]{32,}/.test(name)) return `${name.slice(0, 8)}***`; + return name; +} + +function sanitize(c) { + const safe = {}; + for (const f of SAFE_FIELDS) if (c[f] !== undefined) safe[f] = c[f]; + if (safe.name) safe.name = maskName(safe.name); + if (c.providerSpecificData) { + const psd = {}; + for (const f of SAFE_PSD_FIELDS) { + if (c.providerSpecificData[f] !== undefined) psd[f] = c.providerSpecificData[f]; + } + safe.providerSpecificData = psd; + } + return safe; +} + +// GET /api/providers/client - List connections for dashboard UI (whitelist only) export async function GET() { try { await backfillCodexEmails(); const connections = await getProviderConnections(); - - // Include sensitive fields for sync to cloud (only accessible from same origin) - const clientConnections = connections.map(c => ({ - ...c, - // Don't hide sensitive fields here since this is for internal sync - })); - - return NextResponse.json({ connections: clientConnections }); + return NextResponse.json({ connections: connections.map(sanitize) }); } catch (error) { console.log("Error fetching providers for client:", error); return NextResponse.json({ error: "Failed to fetch providers" }, { status: 500 }); diff --git a/src/app/api/v1/chat/completions/route.js b/src/app/api/v1/chat/completions/route.js index 9072ef2..ddb5512 100644 --- a/src/app/api/v1/chat/completions/route.js +++ b/src/app/api/v1/chat/completions/route.js @@ -1,4 +1,3 @@ -import { callCloudWithMachineId } from "@/shared/utils/cloud.js"; import { handleChat } from "@/sse/handlers/chat.js"; import { initTranslators } from "open-sse/translator/index.js"; diff --git a/src/app/layout.js b/src/app/layout.js index 43902e1..3e9d10e 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -2,7 +2,6 @@ import { Inter } from "next/font/google"; import "material-symbols/outlined.css"; import "./globals.css"; import { ThemeProvider } from "@/shared/components/ThemeProvider"; -import "@/lib/initCloudSync"; // Auto-initialize cloud sync import "@/lib/network/initOutboundProxy"; // Auto-initialize outbound proxy env import { initConsoleLogCapture } from "@/lib/consoleLogBuffer"; import { RuntimeI18nProvider } from "@/i18n/RuntimeI18nProvider"; diff --git a/src/app/page.js b/src/app/page.js index 54d0ee0..9654779 100644 --- a/src/app/page.js +++ b/src/app/page.js @@ -1,5 +1,3 @@ -// Auto-initialize cloud sync when server starts -import "@/lib/initCloudSync"; import { redirect } from "next/navigation"; export default function InitPage() { diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 6fd85aa..02a6006 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -68,8 +68,16 @@ const PROTECTED_API_PATHS = [ // Routes that spawn child processes or read host secrets — restrict to localhost. const LOCAL_ONLY_PATHS = [ "/api/cli-tools/cowork-settings", + "/api/cli-tools/antigravity-mitm", "/api/mcp/", "/api/tunnel/tailscale-install", + "/api/tunnel/tailscale-enable", + "/api/tunnel/tailscale-disable", + "/api/tunnel/tailscale-login", + "/api/tunnel/tailscale-start-daemon", + "/api/tunnel/tailscale-check", + "/api/tunnel/enable", + "/api/tunnel/disable", "/api/oauth/cursor/auto-import", "/api/oauth/kiro/auto-import", ]; diff --git a/src/lib/initCloudSync.js b/src/lib/initCloudSync.js deleted file mode 100644 index fd2c78e..0000000 --- a/src/lib/initCloudSync.js +++ /dev/null @@ -1,31 +0,0 @@ -import initializeApp from "@/shared/services/initializeApp"; - -// Survive Next.js HMR — module-level flag resets on reload, globalThis persists -const g = globalThis.__cloudSyncInit ??= { initialized: false, inProgress: null }; - -export async function ensureAppInitialized() { - if (g.initialized) return true; - if (g.inProgress) return g.inProgress; - g.inProgress = (async () => { - try { - await initializeApp(); - g.initialized = true; - } catch (error) { - console.error("[ServerInit] Error initializing app:", error); - } finally { - g.inProgress = null; - } - return g.initialized; - })(); - return g.inProgress; -} - -// Auto-initialize at runtime only, not during next build. -// Defer to next tick so HTTP server can accept connections before heavy init runs. -if (process.env.NEXT_PHASE !== "phase-production-build") { - setImmediate(() => { - ensureAppInitialized().catch(console.log); - }); -} - -export default ensureAppInitialized; diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index 2b56886..083c42b 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -1,14 +1,16 @@ // Provider definitions +const RISK_NOTICE = "⚠️ Risk Notice: This provider uses a subscription/OAuth session not officially licensed for proxy/router use. Account may be restricted or banned. Use at your own risk."; + // Free Providers (kiro first, iflow last) export const FREE_PROVIDERS = { kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35", website: "https://kiro.dev", notice: { signupUrl: "https://kiro.dev" } }, - qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981", mediaPriority: 999, deprecated: true, deprecationNotice: "Qwen OAuth free tier was discontinued by Alibaba on 2026-04-15. New connections will not work.", website: "https://chat.qwen.ai", notice: { signupUrl: "https://chat.qwen.ai" }, serviceKinds: ["llm", "tts"], ttsConfig: { baseUrl: "http://localhost:8000/v1/audio/speech", authType: "none", authHeader: "none", format: "openai", models: [{ id: "qwen3-tts", name: "Qwen3 TTS" }] } }, - "gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4", deprecated: true, deprecationNotice: "Gemini CLI is designed exclusively for Gemini CLI. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans.", website: "https://github.com/google-gemini/gemini-cli", notice: { signupUrl: "https://github.com/google-gemini/gemini-cli" } }, + qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981", mediaPriority: 999, hidden: true, deprecated: true, deprecationNotice: "Qwen OAuth free tier was discontinued by Alibaba on 2026-04-15. New connections will not work.", website: "https://chat.qwen.ai", notice: { signupUrl: "https://chat.qwen.ai" }, serviceKinds: ["llm", "tts"], ttsConfig: { baseUrl: "http://localhost:8000/v1/audio/speech", authType: "none", authHeader: "none", format: "openai", models: [{ id: "qwen3-tts", name: "Qwen3 TTS" }] } }, + "gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4", deprecated: true, deprecationNotice: RISK_NOTICE, website: "https://github.com/google-gemini/gemini-cli", notice: { signupUrl: "https://github.com/google-gemini/gemini-cli" } }, // gitlab: { id: "gitlab", alias: "gl", name: "GitLab Duo", icon: "code", color: "#FC6D26" }, // codebuddy: { id: "codebuddy", alias: "cb", name: "CodeBuddy", icon: "smart_toy", color: "#006EFF" }, // qoder: { id: "qoder", alias: "qd", name: "Qoder AI", icon: "water_drop", color: "#EC4899" }, - iflow: { id: "iflow", alias: "if", name: "iFlow AI", icon: "water_drop", color: "#6366F1", website: "https://iflow.cn", notice: { signupUrl: "https://iflow.cn" } }, + iflow: { id: "iflow", alias: "if", name: "iFlow AI", icon: "water_drop", color: "#6366F1", hidden: true, website: "https://iflow.cn", notice: { signupUrl: "https://iflow.cn" } }, opencode: { id: "opencode", alias: "oc", name: "OpenCode Free", icon: "terminal", color: "#E87040", textIcon: "OC", noAuth: true, passthroughModels: true, modelsFetcher: { url: "https://opencode.ai/zen/v1/models", type: "opencode-free" } }, }; @@ -53,10 +55,10 @@ const MINIMAX_TTS_MODELS = [ // OAuth Providers export const OAUTH_PROVIDERS = { - claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757", website: "https://claude.ai", notice: { signupUrl: "https://claude.ai" } }, - antigravity: { id: "antigravity", alias: "ag", name: "Antigravity", icon: "rocket_launch", color: "#F59E0B", deprecated: true, deprecationNotice: "AG is designed exclusively for Antigravity IDE. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans.", website: "https://antigravity.google", notice: { signupUrl: "https://antigravity.google" } }, - codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6", thinkingConfig: THINKING_CONFIG.effort, serviceKinds: ["llm", "image"], kindNotice: { image: "Requires a ChatGPT Plus (or higher) account. Free accounts are not supported for image generation." }, website: "https://chatgpt.com/codex", notice: { signupUrl: "https://chatgpt.com/codex" } }, - github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333", serviceKinds: ["llm", "embedding"], embeddingConfig: { baseUrl: "https://models.github.ai/inference/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "text-embedding-3-small", name: "Text Embedding 3 Small (GitHub)", dimensions: 1536 }, { id: "text-embedding-3-large", name: "Text Embedding 3 Large (GitHub)", dimensions: 3072 }] }, website: "https://github.com/features/copilot", notice: { signupUrl: "https://github.com/features/copilot" } }, + claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757", deprecated: true, deprecationNotice: RISK_NOTICE, website: "https://claude.ai", notice: { signupUrl: "https://claude.ai" } }, + antigravity: { id: "antigravity", alias: "ag", name: "Antigravity", icon: "rocket_launch", color: "#F59E0B", hidden: true, deprecated: true, deprecationNotice: "AG is designed exclusively for Antigravity IDE. Using it with other tools (OpenClaw, Claude, Codex...) may result in account restrictions or bans.", website: "https://antigravity.google", notice: { signupUrl: "https://antigravity.google" } }, + codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6", deprecated: true, deprecationNotice: RISK_NOTICE, thinkingConfig: THINKING_CONFIG.effort, serviceKinds: ["llm", "image"], kindNotice: { image: "Requires a ChatGPT Plus (or higher) account. Free accounts are not supported for image generation." }, website: "https://chatgpt.com/codex", notice: { signupUrl: "https://chatgpt.com/codex" } }, + github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333", deprecated: true, deprecationNotice: RISK_NOTICE, serviceKinds: ["llm", "embedding"], embeddingConfig: { baseUrl: "https://models.github.ai/inference/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "text-embedding-3-small", name: "Text Embedding 3 Small (GitHub)", dimensions: 1536 }, { id: "text-embedding-3-large", name: "Text Embedding 3 Large (GitHub)", dimensions: 3072 }] }, website: "https://github.com/features/copilot", notice: { signupUrl: "https://github.com/features/copilot" } }, cursor: { id: "cursor", alias: "cu", name: "Cursor IDE", icon: "edit_note", color: "#00D4AA", website: "https://cursor.com", notice: { signupUrl: "https://cursor.com" } }, // "kimi-coding": { id: "kimi-coding", alias: "kmc", name: "Kimi Coding", icon: "psychology", color: "#1E40AF", textIcon: "KC" }, kilocode: { id: "kilocode", alias: "kc", name: "Kilo Code", icon: "code", color: "#FF6B35", textIcon: "KC", website: "https://kilocode.ai", notice: { signupUrl: "https://kilocode.ai" } }, diff --git a/src/shared/services/cloudSyncScheduler.js b/src/shared/services/cloudSyncScheduler.js deleted file mode 100644 index 44de248..0000000 --- a/src/shared/services/cloudSyncScheduler.js +++ /dev/null @@ -1,122 +0,0 @@ -import { getConsistentMachineId } from "@/shared/utils/machineId"; -import { isCloudEnabled } from "@/lib/localDb"; - -const INTERNAL_BASE_URL = - process.env.BASE_URL || - process.env.NEXT_PUBLIC_BASE_URL || - "http://localhost:20128"; - -/** - * Cloud sync scheduler - */ -export class CloudSyncScheduler { - constructor(machineId = null, intervalMinutes = 15) { - this.machineId = machineId; - this.intervalMinutes = intervalMinutes; - this.intervalId = null; - } - - /** - * Initialize machine ID if not provided - */ - async initializeMachineId() { - if (!this.machineId) { - this.machineId = await getConsistentMachineId(); - } - } - - /** - * Start periodic sync (delays first sync to allow server to be ready) - */ - async start() { - if (this.intervalId) { - return; - } - - await this.initializeMachineId(); - - // Delay first sync by 30 seconds to ensure server is ready - setTimeout(() => { - this.syncWithRetry().catch(() => {}); - }, 30000); - - // Then sync periodically - this.intervalId = setInterval(() => { - this.syncWithRetry().catch(() => {}); - }, this.intervalMinutes * 60 * 1000); - } - - /** - * Stop periodic sync - */ - stop() { - if (this.intervalId) { - clearInterval(this.intervalId); - this.intervalId = null; - } - } - - /** - * Sync with retry logic (exponential backoff) - */ - async syncWithRetry(maxRetries = 1) { - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - const result = await this.sync(); - return result; - } catch (error) { - if (attempt === maxRetries) { - return null; - } - - const delay = Math.min(1000 * Math.pow(2, attempt), 10000); // Max 10s - await new Promise(resolve => setTimeout(resolve, delay)); - } - } - } - - /** - * Perform sync via internal API route (handles token update to db.json) - */ - async sync() { - // Check if cloud is enabled - const enabled = await isCloudEnabled(); - if (!enabled) { - return null; - } - - await this.initializeMachineId(); - - // Call internal API route which handles both sync and token update - const response = await fetch(`${INTERNAL_BASE_URL}/api/sync/cloud`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ machineId: this.machineId, action: "sync" }) - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || "Sync failed"); - } - - const result = await response.json(); - return result; - } - - /** - * Check if scheduler is running - */ - isRunning() { - return this.intervalId !== null; - } -} - -// Export a singleton instance if needed -let cloudSyncScheduler = null; - -export async function getCloudSyncScheduler(machineId = null, intervalMinutes = 15) { - if (!cloudSyncScheduler) { - cloudSyncScheduler = new CloudSyncScheduler(machineId, intervalMinutes); - } - return cloudSyncScheduler; -} diff --git a/src/shared/services/initializeCloudSync.js b/src/shared/services/initializeCloudSync.js deleted file mode 100644 index b26a7d5..0000000 --- a/src/shared/services/initializeCloudSync.js +++ /dev/null @@ -1,37 +0,0 @@ -/* ========== CLOUD SYNC — COMMENTED OUT (replaced by Tunnel) ========== -import { getCloudSyncScheduler } from "@/shared/services/cloudSyncScheduler"; -========== END CLOUD SYNC ========== */ -import { cleanupProviderConnections } from "@/lib/localDb"; - -/** - * Initialize cloud sync scheduler - * This should be called when the application starts - */ -export async function initializeCloudSync() { - try { - // Cleanup null fields from existing data - await cleanupProviderConnections(); - - /* ========== CLOUD SYNC — COMMENTED OUT (replaced by Tunnel) ========== - // Create scheduler instance with default 15-minute interval - const scheduler = await getCloudSyncScheduler(null, 15); - - // Start the scheduler - await scheduler.start(); - - return scheduler; - ========== END CLOUD SYNC ========== */ - return null; - } catch (error) { - console.error("[CloudSync] Error initializing scheduler:", error); - throw error; - } -} - -// For development/testing purposes -if (typeof require !== "undefined" && require.main === module) { - initializeCloudSync().catch(console.log); -} - -export default initializeCloudSync; - diff --git a/src/shared/utils/cloud.js b/src/shared/utils/cloud.js deleted file mode 100644 index 02c8ff0..0000000 --- a/src/shared/utils/cloud.js +++ /dev/null @@ -1,40 +0,0 @@ -import { getMachineId } from "@/shared/utils/machine"; - -// Function to get cloud URL with machine ID -export function getCloudUrl(machineId) { - // Get from environment or default to localhost:8787 - const cloudUrl = process.env.NEXT_PUBLIC_CLOUD_URL || "http://localhost:8787"; - return `${cloudUrl}/${machineId}/v1/chat/completions`; -} - -// Function to call cloud with machine ID -export async function callCloudWithMachineId(request) { - const machineId = await getMachineId(); - if (!machineId) { - throw new Error("Could not get machine ID"); - } - - const cloudUrl = getCloudUrl(machineId); - - // Get the original request body and headers - const body = await request.json(); - const headers = new Headers(request.headers); - - // Remove authorization header since cloud won't need it (uses machineId instead) - headers.delete("authorization"); - - // Call the cloud with machine ID - const response = await fetch(cloudUrl, { - method: "POST", - headers: headers, - body: JSON.stringify(body) - }); - - return response; -} - -// Function to periodically sync provider data to cloud (now a no-op) -export function startProviderSync(cloudUrl, intervalMs = 900000) { // Default 15 minutes - console.log("Frontend sync is disabled. Use backend sync instead."); - return null; -} diff --git a/src/shared/utils/machine.js b/src/shared/utils/machine.js index c2038a9..8979a14 100644 --- a/src/shared/utils/machine.js +++ b/src/shared/utils/machine.js @@ -1,18 +1,6 @@ -import { getConsistentMachineId } from './machineId'; +import { getConsistentMachineId } from "./machineId"; // Get machine ID using node-machine-id with salt export async function getMachineId() { return await getConsistentMachineId(); } - -// Keep sync functions for backward compatibility but make them no-ops -// (Frontend sync is disabled - use backend sync instead) -export async function syncProviderDataToCloud(cloudUrl) { - console.log("Frontend sync is disabled. Use backend sync instead."); - return Promise.resolve(true); -} - -export async function getProvidersNeedingRefresh() { - console.log("Frontend sync is disabled. Use backend sync instead."); - return Promise.resolve([]); -} diff --git a/tests/unit/buildOutputFilter.test.js b/tests/unit/buildOutputFilter.test.js new file mode 100644 index 0000000..cf217f1 --- /dev/null +++ b/tests/unit/buildOutputFilter.test.js @@ -0,0 +1,232 @@ +// Tests for PR #1175: build output filter + porcelain regex fix +// Covers edge cases: porcelain workdir-only, cargo misdetection, false positives, compression +import { describe, it, expect } from "vitest"; +import { autoDetectFilter } from "../../open-sse/rtk/autodetect.js"; +import { buildOutput } from "../../open-sse/rtk/filters/buildOutput.js"; +import { gitStatus } from "../../open-sse/rtk/filters/gitStatus.js"; + +describe("PR #1175 - buildOutput filter detection", () => { + it("detects npm install output", () => { + const input = [ + "npm warn deprecated har-validator@5.1.5: this library is no longer supported", + "npm warn deprecated uuid@3.4.0: uuid@10 and below is no longer supported", + "npm warn deprecated request@2.88.2: request has been deprecated", + "added 47 packages, and audited 48 packages in 13s", + "3 packages are looking for funding", + " run `npm fund` for details", + "4 vulnerabilities (2 moderate, 2 critical)", + "Run `npm audit` for details." + ].join("\n"); + const filter = autoDetectFilter(input); + expect(filter).toBe(buildOutput); + }); + + it("detects cargo build output (no longer misdetected as git-status)", () => { + const input = [ + " Compiling proc-macro2 v1.0.95", + " Compiling unicode-ident v1.0.18", + " Compiling quote v1.0.40", + " Compiling syn v2.0.104", + " Compiling my-project v0.1.0 (/home/user/my-project)", + " Finished `dev` profile [unoptimized + debuginfo] target(s) in 12.34s" + ].join("\n"); + const filter = autoDetectFilter(input); + expect(filter).toBe(buildOutput); + expect(filter).not.toBe(gitStatus); + }); +}); + +describe("PR #1175 - buildOutput compression behavior", () => { + it("compresses npm install with deprecations", () => { + const input = [ + "npm warn deprecated har-validator@5.1.5: this library is no longer supported", + "npm warn deprecated uuid@3.4.0: uuid@10 and below is no longer supported", + "npm warn deprecated request@2.88.2: request has been deprecated", + "added 47 packages, and audited 48 packages in 13s", + "3 packages are looking for funding", + " run `npm fund` for details", + "4 vulnerabilities (2 moderate, 2 critical)", + "Run `npm audit` for details." + ].join("\n"); + const out = buildOutput(input); + // Minimal fix: keep first 3 deprecations verbatim (no truncation needed since count == 3) + expect(out).toContain("har-validator@5.1.5"); + expect(out).toContain("uuid@3.4.0"); + expect(out).toContain("request@2.88.2"); + expect(out).toContain("added 47 packages"); + expect(out).toContain("4 vulnerabilities"); + // 3 deprecations kept verbatim → no size win on this small input + expect(out.length).toBeLessThanOrEqual(input.length); + }); + + it("compresses cargo build output", () => { + const input = [ + " Compiling proc-macro2 v1.0.95", + " Compiling unicode-ident v1.0.18", + " Compiling quote v1.0.40", + " Compiling syn v2.0.104", + " Compiling serde v1.0.219", + " Compiling serde_derive v1.0.219", + " Compiling serde_json v1.0.140", + " Compiling tokio v1.45.0", + " Compiling hyper v1.6.0", + " Compiling my-project v0.1.0 (/home/user/my-project)", + " Finished `dev` profile [unoptimized + debuginfo] target(s) in 12.34s" + ].join("\n"); + const out = buildOutput(input); + expect(out).toContain("Compiled 10 packages"); + expect(out).toContain("Finished"); + expect(out.length).toBeLessThan(input.length * 0.5); + }); + + it("keeps cargo errors verbatim", () => { + const input = [ + " Compiling foo v0.1.0", + "error[E0308]: mismatched types", + " --> src/main.rs:5:9", + " |", + "5 | let x: u32 = \"hello\";", + " | --- ^^^^^^^ expected `u32`, found `&str`", + "error: aborting due to previous error" + ].join("\n"); + const out = buildOutput(input); + expect(out).toContain("error[E0308]"); + expect(out).toContain("error: aborting"); + }); + + it("keeps maven BUILD FAILED as error", () => { + const input = [ + "[INFO] Scanning for projects...", + "[ERROR] Failed to execute goal", + "[ERROR] Could not resolve dependencies", + "BUILD FAILED" + ].join("\n"); + const out = buildOutput(input); + expect(out).toContain("[ERROR] Failed to execute goal"); + expect(out).toContain("BUILD FAILED"); + }); +}); + +describe("PR #1175 - porcelain regex fix edge cases", () => { + it("git status --porcelain workdir-only (space first char) STILL detects as gitStatus (minimal fix preserved old regex)", () => { + const input = [ + " M src/a.js", + " M src/b.js", + " D src/c.js", + "?? new.js" + ].join("\n"); + const filter = autoDetectFilter(input); + expect(filter).toBe(gitStatus); + }); + + it("git status --porcelain with staged (status code first char) still detects", () => { + const input = [ + "M src/a.js", + "A src/new.js", + "?? untracked.js", + "M src/b.js" + ].join("\n"); + const filter = autoDetectFilter(input); + expect(filter).toBe(gitStatus); + }); + + it("cargo Compiling lines NOT detected as git-status (porcelain false positive fix)", () => { + const input = [ + " Compiling proc-macro2 v1.0.95", + " Compiling unicode-ident v1.0.18", + " Compiling quote v1.0.40" + ].join("\n"); + const filter = autoDetectFilter(input); + expect(filter).not.toBe(gitStatus); + }); + + it("long-form git status with 'On branch' always detects", () => { + const input = [ + "On branch main", + "Your branch is up to date with 'origin/main'.", + "", + "Changes not staged for commit:", + "\tmodified: src/a.js" + ].join("\n"); + const filter = autoDetectFilter(input); + expect(filter).toBe(gitStatus); + }); +}); + +describe("PR #1175 - false positive risks", () => { + it("generic app log with 'ERROR:' triggers buildOutput (potential false positive)", () => { + const input = [ + "2026-05-16 10:00:00 INFO Server started on port 3000", + "2026-05-16 10:00:05 INFO Request received: GET /api/users", + "ERROR: Database connection timeout", + "2026-05-16 10:00:10 INFO Retrying connection", + "2026-05-16 10:00:15 INFO Connection restored" + ].join("\n"); + const filter = autoDetectFilter(input); + // Document actual behavior — may detect as buildOutput + console.log("[generic-error-log] detected:", filter?.filterName || "null"); + // Whatever the detection, the filter should NOT corrupt the data + if (filter === buildOutput) { + const out = buildOutput(input); + expect(out).toContain("ERROR: Database connection timeout"); + } + }); + + it("generic 'Compiling templates' (non-build context) triggers buildOutput", () => { + const input = [ + "[INFO] Starting application", + "[INFO] Compiling templates for view layer", + "[INFO] Compiling assets for production", + "[INFO] Application ready" + ].join("\n"); + const filter = autoDetectFilter(input); + console.log("[compiling-templates] detected:", filter?.filterName || "null"); + if (filter === buildOutput) { + const out = buildOutput(input); + // Should at least preserve structure (count compiling) + expect(out).toContain("Compiled"); + } + }); + + it("plain text with no patterns falls through (no false positive)", () => { + const input = [ + "Hello world", + "This is a normal message", + "Nothing special here" + ].join("\n"); + const filter = autoDetectFilter(input); + expect(filter).not.toBe(buildOutput); + }); +}); + +describe("PR #1175 - safety: no data corruption", () => { + it("empty input returns input", () => { + expect(buildOutput("")).toBe(""); + }); + + it("input with only errors preserves all errors", () => { + const input = "npm ERR! code ENOENT\nnpm ERR! syscall open\nnpm ERR! path /tmp/foo"; + const out = buildOutput(input); + expect(out).toContain("npm ERR! code ENOENT"); + expect(out).toContain("npm ERR! syscall open"); + expect(out).toContain("npm ERR! path /tmp/foo"); + }); + + it("input with no recognized patterns returns input (fallback)", () => { + const input = "random text\nmore random\nstill random"; + const out = buildOutput(input); + // When nothing matches, returns input via `|| input` + expect(out).toBe(input); + }); + + it("limits warnings to 5 + summary line", () => { + const warnings = []; + for (let i = 0; i < 10; i++) warnings.push(`npm warn config foo${i} something`); + const input = warnings.join("\n"); + const out = buildOutput(input); + expect(out).toContain("npm warn config foo0"); + expect(out).toContain("npm warn config foo4"); + expect(out).toContain("... +5 more warnings"); + expect(out).not.toContain("npm warn config foo9"); + }); +}); diff --git a/tests/unit/buildOutputFilterAdversarial.test.js b/tests/unit/buildOutputFilterAdversarial.test.js new file mode 100644 index 0000000..60193f8 --- /dev/null +++ b/tests/unit/buildOutputFilterAdversarial.test.js @@ -0,0 +1,357 @@ +// Adversarial / edge-case tests for PR #1175 +// Goals: find corruption, boundary bugs, false positives, integration regressions +import { describe, it, expect } from "vitest"; +import { autoDetectFilter } from "../../open-sse/rtk/autodetect.js"; +import { buildOutput } from "../../open-sse/rtk/filters/buildOutput.js"; +import { gitDiff } from "../../open-sse/rtk/filters/gitDiff.js"; +import { gitStatus } from "../../open-sse/rtk/filters/gitStatus.js"; +import { safeApply } from "../../open-sse/rtk/applyFilter.js"; +import { compressMessages } from "../../open-sse/rtk/index.js"; +import { DETECT_WINDOW, MIN_COMPRESS_SIZE } from "../../open-sse/rtk/constants.js"; + +// ============================================================ +// 1. PRIORITY / OVERLAPPING PATTERNS +// ============================================================ +describe("PR #1175 - priority with overlapping patterns", () => { + it("git-diff wins over buildOutput when both present", () => { + const input = [ + "diff --git a/Cargo.toml b/Cargo.toml", + "index abc..def 100644", + "--- a/Cargo.toml", + "+++ b/Cargo.toml", + "@@ -1,3 +1,3 @@", + "-version = \"0.1.0\"", + "+version = \"0.2.0\"", + " Compiling foo v0.1.0" + ].join("\n"); + expect(autoDetectFilter(input)).toBe(gitDiff); + }); + + it("git-status (long form) wins over buildOutput", () => { + const input = [ + "On branch main", + "Changes not staged for commit:", + "\tmodified: Cargo.toml", + " Compiling foo v0.1.0" + ].join("\n"); + expect(autoDetectFilter(input)).toBe(gitStatus); + }); +}); + +// ============================================================ +// 2. BOUNDARY: DETECT_WINDOW +// ============================================================ +describe("PR #1175 - DETECT_WINDOW boundary", () => { + it("build pattern beyond DETECT_WINDOW chars: NOT detected", () => { + const padding = "x".repeat(DETECT_WINDOW + 100); + const input = `${padding}\n Compiling foo v0.1.0\n Finished release in 1.2s`; + const filter = autoDetectFilter(input); + // Pattern lives past detection window — won't be seen + expect(filter).not.toBe(buildOutput); + }); + + it("build pattern at very start: detected", () => { + const input = " Compiling foo v0.1.0\n" + "y".repeat(2000); + expect(autoDetectFilter(input)).toBe(buildOutput); + }); +}); + +// ============================================================ +// 3. LINE ENDINGS / WHITESPACE QUIRKS +// ============================================================ +describe("PR #1175 - line endings & whitespace", () => { + it("CRLF line endings still detect", () => { + const input = "npm warn deprecated foo@1.0.0\r\nadded 5 packages in 2s\r\n"; + expect(autoDetectFilter(input)).toBe(buildOutput); + }); + + it("Tab-prefixed Compiling (real cargo output uses leading spaces, not tab)", () => { + const input = "\tCompiling foo v0.1.0\n\tCompiling bar v0.2.0\n\tFinished dev in 1s"; + const filter = autoDetectFilter(input); + // \s matches tab, so should detect + expect(filter).toBe(buildOutput); + }); + + it("Compiling without leading spaces", () => { + const input = "Compiling foo v0.1.0\nCompiling bar v0.2.0\nFinished dev in 1s"; + expect(autoDetectFilter(input)).toBe(buildOutput); + }); +}); + +// ============================================================ +// 4. ADVERSARIAL: USER CODE / STRING LITERALS +// ============================================================ +describe("PR #1175 - adversarial: user code containing build strings", () => { + it("user JS code with console.log('npm warn ...') triggers buildOutput", () => { + // This is a realistic case: LLM is reading a file with this code + const input = [ + "function logWarning() {", + " console.log('npm warn this is a warning');", + " return true;", + "}", + "function logError() {", + " console.log('npm error something bad');", + "}" + ].join("\n"); + const filter = autoDetectFilter(input); + // Regex uses `m` flag, so ^ matches line start — these are inside indented code + // BUT: regex uses 'i' so case-insensitive, and `^npm warn` requires line to START with it + console.log("[user-code-npm-warn] detected:", filter?.filterName || "null"); + // Expectation: should NOT detect (lines start with spaces) + expect(filter).not.toBe(buildOutput); + }); + + it("file content with 'BUILD SUCCESS' on its own line triggers buildOutput", () => { + const input = [ + "Here is the deployment script:", + "It outputs:", + "BUILD SUCCESS", + "when complete." + ].join("\n"); + const filter = autoDetectFilter(input); + console.log("[file-content-build-success] detected:", filter?.filterName || "null"); + // Document behavior — buildOutput should preserve non-pattern lines as fallback + if (filter === buildOutput) { + const out = buildOutput(input); + // BUILD SUCCESS preserved + expect(out).toContain("BUILD SUCCESS"); + } + }); + + it("real cargo error spanning multiple lines preserves context", () => { + const input = [ + " Compiling my-app v0.1.0", + "error[E0432]: unresolved import `foo::bar`", + " --> src/main.rs:2:5", + " |", + "2 | use foo::bar;", + " | ^^^^^^^^ no `bar` in `foo`", + "", + "error: aborting due to previous error", + "", + "For more information about this error, try `rustc --explain E0432`.", + "error: could not compile `my-app` (bin \"my-app\") due to previous error" + ].join("\n"); + const out = buildOutput(input); + expect(out).toContain("error[E0432]"); + expect(out).toContain("error: aborting"); + expect(out).toContain("error: could not compile"); + // Minimal fix: cargo error context lines now preserved + expect(out).toContain("use foo::bar"); + expect(out).toContain("no `bar`"); + }); +}); + +// ============================================================ +// 5. CORRUPTION / SAFETY: NO EMPTY OUTPUT +// ============================================================ +describe("PR #1175 - corruption safety", () => { + it("input with only progress lines (no errors/warnings/summary) returns input fallback", () => { + const input = [ + " Compiling a v0.1.0", + " Compiling b v0.1.0", + " Compiling c v0.1.0" + ].join("\n"); + const out = buildOutput(input); + // out = "Compiled 3 packages" (non-empty) + expect(out.length).toBeGreaterThan(0); + expect(out).toContain("Compiled 3 packages"); + }); + + it("input with only Downloading lines", () => { + const input = [ + " Downloading foo v0.1.0", + " Downloading bar v0.2.0", + "Fetching baz from registry" + ].join("\n"); + const out = buildOutput(input); + expect(out).toContain("Downloaded"); + }); + + it("input with ONLY a single ERROR: line", () => { + const input = "ERROR: Something failed"; + const out = buildOutput(input); + expect(out).toContain("ERROR: Something failed"); + }); + + it("unicode/emoji in deprecation warning preserved (minimal fix keeps first 3 verbatim)", () => { + const input = [ + "npm warn deprecated 📦 foo@1.0.0: 🚫 deprecated reason", + "added 1 package ✨", + "Run `npm audit` for details." + ].join("\n"); + const out = buildOutput(input); + expect(out).toContain("📦"); + expect(out).toContain("foo@1.0.0"); + expect(out).toContain("added 1 package ✨"); + }); + + it("more than 3 deprecations: keep first 3 verbatim + count rest", () => { + const input = [ + "npm warn deprecated a@1.0.0: reason A", + "npm warn deprecated b@1.0.0: reason B", + "npm warn deprecated c@1.0.0: reason C", + "npm warn deprecated d@1.0.0: reason D", + "npm warn deprecated e@1.0.0: reason E", + "added 5 packages" + ].join("\n"); + const out = buildOutput(input); + expect(out).toContain("a@1.0.0"); + expect(out).toContain("b@1.0.0"); + expect(out).toContain("c@1.0.0"); + expect(out).not.toContain("d@1.0.0"); + expect(out).not.toContain("e@1.0.0"); + expect(out).toContain("... +2 more deprecated packages"); + }); + + it("safeApply wraps buildOutput against panics", () => { + // Pass a non-string input via direct call — safeApply should catch + const out = safeApply(buildOutput, "npm warn deprecated foo\nadded 1 package\n"); + expect(typeof out).toBe("string"); + }); +}); + +// ============================================================ +// 6. INTEGRATION: compressMessages pipeline +// ============================================================ +describe("PR #1175 - integration with compressMessages", () => { + function buildBody(toolResultText) { + return { + messages: [ + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "id1", content: toolResultText } + ] + } + ] + }; + } + + it("npm install output above MIN_COMPRESS_SIZE → compressed", () => { + const padding = "npm warn deprecated foo@1.0.0: this is a deprecation warning\n".repeat(20); + const text = padding + "added 47 packages, and audited 48 packages in 13s\n4 vulnerabilities (2 moderate, 2 critical)\nRun `npm audit` for details."; + expect(text.length).toBeGreaterThan(MIN_COMPRESS_SIZE); + const body = buildBody(text); + const stats = compressMessages(body, true); + expect(stats).toBeTruthy(); + expect(stats.hits.length).toBe(1); + expect(stats.hits[0].filter).toBe("build-output"); + expect(stats.bytesAfter).toBeLessThan(stats.bytesBefore); + const compressed = body.messages[0].content[0].content; + expect(compressed).toContain("... +17 more deprecated packages"); + }); + + it("input below MIN_COMPRESS_SIZE → NOT compressed", () => { + const text = "npm warn deprecated foo\nadded 1 package"; + expect(text.length).toBeLessThan(MIN_COMPRESS_SIZE); + const body = buildBody(text); + const stats = compressMessages(body, true); + expect(stats.hits.length).toBe(0); + expect(body.messages[0].content[0].content).toBe(text); + }); + + it("compressed output never grows input (safety guard)", () => { + // Pathological: every line is something buildOutput keeps verbatim + const text = "npm ERR! error line 1\nnpm ERR! error line 2\nnpm ERR! error line 3\n".repeat(20); + const body = buildBody(text); + const stats = compressMessages(body, true); + // either no hit (grew) or hit and shrunk + const after = body.messages[0].content[0].content; + expect(after.length).toBeLessThanOrEqual(text.length); + }); + + it("tool_result with is_error:true is NOT compressed (preserve error traces)", () => { + const text = "npm warn deprecated foo@1.0.0\n".repeat(30) + "added 5 packages in 2s"; + const body = { + messages: [ + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "id1", content: text, is_error: true } + ] + } + ] + }; + const stats = compressMessages(body, true); + expect(stats.hits.length).toBe(0); + expect(body.messages[0].content[0].content).toBe(text); + }); +}); + +// ============================================================ +// 7. PORCELAIN REGRESSION DEEPER TESTS +// ============================================================ +describe("PR #1175 - porcelain regression deeper", () => { + it("mixed staged + workdir + untracked porcelain → detected (has status code first char)", () => { + const input = [ + "M src/staged.js", // staged modified + " M src/workdir.js", // workdir modified (space first) + "?? new.js", + "A src/added.js" + ].join("\n"); + const filter = autoDetectFilter(input); + // M and A and ?? lines have status code first → 4/4 lines hit? No — " M" has space first + // isMostlyPorcelain requires >= 60% hit. With new regex, hits = M/?/A = 3, total = 4, 75% ≥ 60% + expect(filter).toBe(gitStatus); + }); + + it("100% workdir-only porcelain → STILL detects gitStatus (minimal fix preserved old regex)", () => { + const input = [ + " M src/a.js", + " M src/b.js", + " M src/c.js", + " D src/d.js" + ].join("\n"); + const filter = autoDetectFilter(input); + expect(filter).toBe(gitStatus); + }); + + it("manual gitStatus() call on workdir-only porcelain still parses correctly", () => { + const input = [ + " M src/a.js", + " M src/b.js", + " D src/c.js" + ].join("\n"); + const out = gitStatus(input); + expect(out).toContain("Modified: 3 files"); + }); +}); + +// ============================================================ +// 8. PATHOLOGICAL INPUTS +// ============================================================ +describe("PR #1175 - pathological", () => { + it("very long single line (no newlines) with build pattern", () => { + const input = "npm warn deprecated foo@1.0.0: " + "x".repeat(5000); + const filter = autoDetectFilter(input); + // Pattern at start (within DETECT_WINDOW) + expect(filter).toBe(buildOutput); + // Should NOT crash + const out = buildOutput(input); + expect(typeof out).toBe("string"); + }); + + it("10000 Compiling lines don't crash", () => { + const lines = []; + for (let i = 0; i < 10000; i++) lines.push(` Compiling pkg${i} v0.1.0`); + lines.push(" Finished dev in 60s"); + const input = lines.join("\n"); + const out = buildOutput(input); + expect(out).toContain("Compiled 10000 packages"); + expect(out).toContain("Finished"); + expect(out.length).toBeLessThan(input.length / 100); + }); + + it("input with only newlines", () => { + const input = "\n\n\n\n"; + const out = buildOutput(input); + expect(typeof out).toBe("string"); + }); + + it("null/undefined safety via safeApply", () => { + // buildOutput would throw on null.split() — safeApply must catch + const out = safeApply(buildOutput, null); + expect(out).toBe(null); + }); +});