/* * WindsurfAPI · DevinAPI — 把 Windsurf / Devin 的云端模型变成 OpenAI / Anthropic / Gemini 标准 API。 * 零 npm 运行时依赖。 * * 项目完整历史(提交/版本/PR/issue/自伤账本)可视化: * https://dwgx.github.io/WindsurfAPI/HISTORY-LEDGER-VIZ.html * 总账: docs/HISTORY-LEDGER.md · 逐条账: docs/HISTORY-LEDGER-2026-*.md */ // Logger must be imported first to patch log functions before other modules use them import './dashboard/logger.js'; import { initAuth, isAuthenticated } from './auth.js'; import { configureLanguageServer, startLanguageServer, waitForReady, isLanguageServerRunning, stopLanguageServer, stopLanguageServerAndWait, cleanupOrphanLanguageServers, shouldPrewarmDefaultLs } from './langserver.js'; import { startServer } from './server.js'; import { config, log } from './config.js'; import { existsSync, mkdirSync, readdirSync, rmSync } from 'fs'; import { tmpdir } from 'os'; import { execSync } from 'child_process'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { VERSION, BRAND } from './version.js'; import { abortActiveSse } from './sse-registry.js'; import { registerServer } from './server-registry.js'; import { buildSubprocessEnv } from './log-safety.js'; export { VERSION, BRAND }; // v2.0.146 (audit F-1): last-resort process safety nets. This service must // not crash — it has fire-and-forget async paths (LS warmup, restart driver, // self-update timers) where a stray rejection or a throw inside an async // callback would otherwise terminate the process on Node >=15. A rejection is // logged and the process keeps serving; an uncaught exception is logged and we // exit non-zero so the supervisor (systemd) restarts from a clean state rather // than continuing in an unknown one. Registered here (after the logger import, // before main()) so the nets are live before any other module does async work. let _crashNetsInstalled = false; function installProcessCrashNets() { if (_crashNetsInstalled) return; _crashNetsInstalled = true; process.on('unhandledRejection', (reason) => { try { log.error('unhandledRejection:', reason instanceof Error ? (reason.stack || reason.message) : reason); } catch { console.error('unhandledRejection:', reason); } }); process.on('uncaughtException', (err) => { try { log.error('uncaughtException — exiting for clean restart:', err?.stack || err); } catch { console.error('uncaughtException:', err); } process.exit(1); }); } installProcessCrashNets(); function workspaceBase() { const tmpDir = process.env.TEMP || process.env.TMP || tmpdir(); const suffix = process.env.HOSTNAME ? `-${process.env.HOSTNAME}` : ''; return join(tmpDir, `windsurf-workspace${suffix}`); } function resetWorkspace() { // Wipe the workspace on every startup. If we don't, files created by // previous chat sessions (e.g. Claude "editing" config.yaml/lru_cache.py // via the baked-in Cascade tool prompts) persist and pollute the next // request — the model sees them at session init and starts narrating // edits to files the caller never mentioned. // // Using Node fs APIs instead of an `execSync('mkdir -p ... && rm -rf')` // shell pipeline so this is correct on Windows, macOS, and Linux without // depending on a POSIX shell. const wsBase = workspaceBase(); try { mkdirSync(wsBase, { recursive: true }); for (const name of readdirSync(wsBase)) { try { rmSync(join(wsBase, name), { recursive: true, force: true }); } catch {} } } catch {} } async function main() { const banner = ` _ _ _ _ __ _ ____ ___ | | | (_) | | / _| / \\ | _ \\_ _| | | | |_ _ __ __| |___ _ _ _ __ _| |_ / _ \\ | |_) | | | |/\\| | | '_ \\ / _\` / __| | | | '__|_ _|/ ___ \\| __/| | \\ /\\ / | | | | (_| \\__ \\ |_| | | |_| /_/ \\_\\_| |___| \\/ \\/|_|_| |_|\\__,_|___/\\__,_|_| ${BRAND} v${VERSION} `; console.log(banner); console.log(` OpenAI-compatible proxy for Windsurf — by dwgx1337\n`); // Start language server binary. // Auto-install if missing — users repeatedly miss the manual install step // and open "request crashes" issues (see #18), so we just do it ourselves. // Skipped on Windows (LS is Linux-only) and when install-ls.sh isn't present. // // DEVIN_CONNECT / DEVIN_ONLY are the binary-less pivot backends (pure HTTP to // Devin cloud — no language_server needed). In those modes the whole LS block // is pure friction: a fresh Docker boot would otherwise shell out to download // a ~100MB+ binary the deploy never uses, delaying startup. Skip it entirely. const lsBackendUnused = String(process.env.DEVIN_CONNECT || '').trim() === '1' || String(process.env.DEVIN_ONLY || '').trim() === '1'; const binaryPath = config.lsBinaryPath; if (lsBackendUnused) { log.info('DEVIN_CONNECT/DEVIN_ONLY enabled — skipping language server startup (binary-less backend).'); } else if (!existsSync(binaryPath) && process.platform === 'win32') { log.warn('Windows detected: the Language Server binary is Linux/macOS only.'); log.warn('Options: (1) Use Docker (see docker-compose.yml), (2) Use WSL2, or'); log.warn('(3) Point LS_BINARY_PATH to a Windsurf desktop app language_server binary.'); } if (!lsBackendUnused && !existsSync(binaryPath) && process.platform !== 'win32') { const scriptPath = (() => { try { const here = dirname(fileURLToPath(import.meta.url)); return join(here, '..', 'install-ls.sh'); } catch { return null; } })(); if (scriptPath && existsSync(scriptPath)) { log.info(`Language server binary missing at ${binaryPath}`); log.info(`Auto-installing via ${scriptPath} — this runs once.`); try { // Bounded so a slow/black-holed network can't hang boot forever. The // upstream desktop archive is ~345 MB; install-ls.sh allows 60 s for // the manifest plus 900 s for the archive, so leave headroom for // extraction and hashing. execSync(`bash "${scriptPath}"`, { stdio: 'inherit', // Denylist env (L3b env-leak fix): the installer needs the general // env — PATH for curl/node/sha256sum, HOME, TMPDIR, proxy + CA-trust // vars for the downloads, WINDSURFAPI_LS_* overrides — but not the // proxy's credentials. Nothing install-ls.sh reads is secret-shaped. env: { ...buildSubprocessEnv(), LS_INSTALL_PATH: binaryPath }, timeout: 1020000, }); log.info('Language server binary installed.'); } catch (err) { log.error(`Auto-install failed: ${err.message}`); log.error('Run manually: bash install-ls.sh (or set LS_BINARY_PATH to point at an existing binary)'); } } } if (!lsBackendUnused && existsSync(binaryPath)) { resetWorkspace(); // v2.0.85 (#127 123cek): kill any leftover language_server_linux_x64 // processes from prior runs (PM2 SIGKILL / dashboard self-update via // process.exit() / earlier crash) before we start ours. Otherwise // they keep their LS pool ports occupied and accumulate over self- // update cycles. Setting WINDSURFAPI_SKIP_LS_CLEANUP=1 disables // (e.g. multi-WindsurfAPI on a shared host). if (process.env.WINDSURFAPI_SKIP_LS_CLEANUP !== '1') { try { const r = cleanupOrphanLanguageServers(); if (r.killed > 0) log.info(`LS cleanup: scanned ${r.scanned} candidate(s), killed ${r.killed} orphan(s)`); } catch (e) { log.warn(`LS cleanup error (non-fatal): ${e.message}`); } } const lsConfig = { binaryPath, port: config.lsPort, apiServerUrl: config.codeiumApiUrl, }; configureLanguageServer(lsConfig); if (shouldPrewarmDefaultLs()) { await startLanguageServer(lsConfig); try { await waitForReady(30000); // v2.0.93: if default LS started but proxy-LS crashed, give the // manage child (port 42101) a moment to restart before syncing models. log.info('LS ready — fetching model catalog'); } catch (err) { log.error(`Language server failed to start: ${err.message}`); log.error('Chat completions will not work without the language server.'); log.error('Run: bash install-ls.sh (pinned upstream manifest; no fallback source)'); } } else { log.info('LS default prewarm disabled (LS_PREWARM_DEFAULT=0 or LS_MAX_INSTANCES=1); LS starts lazily on first request'); } } else if (!lsBackendUnused) { log.warn(`Language server binary not found at ${binaryPath}`); log.warn('Install it with: download Windsurf Linux tarball and extract language_server_linux_x64'); } // Init auth pool await initAuth(); if (!isAuthenticated()) { log.warn('No accounts configured. Add via:'); log.warn(' POST /auth/login {"token":"..."}'); log.warn(' POST /auth/login {"api_key":"..."}'); } const server = startServer(); // Expose the live server so dashboard-triggered restart / self-update paths // in api.js can drain in-flight requests before exiting (they otherwise have // no reference to it). Mirrors the sse-registry pattern. registerServer(server); // Packaged first-run: print the auto-generated credentials prominently (like // start.ps1 does for the source install) so the user can log in / call the // API. They're already persisted to the .env beside the exe. if (config.generatedCreds) { const c = config.generatedCreds; log.warn('################################################################'); log.warn('# 已为你自动生成密钥(请截图妥善保存) #'); log.warn('################################################################'); // D6-LOGS (audit 2026-09-22): only these two lines carry generated credentials, // and `log.warn` is patched by dashboard/logger.js to copy every call into the // dashboard ring buffer, the live SSE subscribers and logs/{app,error}-.jsonl // — sinks that outlive the session and travel into bug reports, container logs and // backups. The first-run display exists for the operator in front of the console, // so it goes straight to console.warn; the banner, the .env path and the failure // explanation below stay on the logger, where operators expect to find them. if (c.DASHBOARD_PASSWORD) console.warn(` 面板登录密码 (DASHBOARD_PASSWORD): ${c.DASHBOARD_PASSWORD}`); if (c.API_KEY) console.warn(` 网关调用密钥 (API_KEY): ${c.API_KEY}`); if (c.envPath) log.warn(` 已写入 ${c.envPath} — 下次启动沿用,无需重设。`); else log.warn(` (写入 .env 失败${c.persistError ? ': ' + c.persistError : ''} — 本次有效,重启会重新生成)`); log.warn('################################################################'); } // Packaged single-exe desktop UX: on the FIRST deploy (no Windsurf_data folder // existed yet), pop the dashboard in the default browser so the initial // double-click lands on a usable setup page. Subsequent launches don't reopen // it. Best effort — a headless/CI run (WINDSURFAPI_NO_OPEN=1) or a missing // opener just skips it. Delayed slightly so the "Server on ..." line prints. if (config.isFirstRun && process.env.WINDSURFAPI_NO_OPEN !== '1') { const url = `http://127.0.0.1:${config.port}/dashboard`; setTimeout(() => { try { // Windows: `cmd /c start "" ` opens the default browser without a // shell window lingering. execFile avoids spawning a persistent shell. import('child_process').then(({ execFile }) => { if (process.platform === 'win32') { execFile('cmd', ['/c', 'start', '', url], { windowsHide: true }, () => {}); } else if (process.platform === 'darwin') { execFile('open', [url], () => {}); } else { execFile('xdg-open', [url], () => {}); } }).catch(() => {}); log.info(`First run — opening dashboard in your browser: ${url}`); } catch { /* opener unavailable — user can open the URL manually */ } }, 800); } let shuttingDown = false; const shutdown = (signal) => { if (shuttingDown) return; shuttingDown = true; const inflight = server.getActiveRequests?.() ?? '?'; log.info(`${signal} received — draining ${inflight} in-flight requests (up to 30s)...`); const abortedSse = abortActiveSse('server shutting down'); if (abortedSse) log.warn(`Aborted ${abortedSse} active SSE stream(s): server shutting down`); // Detach P1: entries are in-memory and hold no LS lease, but a drain must // refuse new admissions and drop what exists so a dying process does not // leave claims behind while the LS pool is being stopped. import('./detached-registry.js').then(m => m.beginDetachDrain()).catch(() => {}); if (typeof server.closeIdleConnections === 'function') server.closeIdleConnections(); // v2.0.146 (audit F-2): await LS children actually exiting before // process.exit, mirroring the self-update path. A synchronous // stopLanguageServer() + immediate exit reparents still-living LS // children to PID 1, leaving them holding pool ports (42100, 42101…) // that the freshly-restarted replica then races (the H-4 orphan race). const finalize = async (reason) => { // K7: shutdown DRAINS, it does not write the pool back. The periodic // dirty-flush keeps accounts.json current (≤30s stale) and status flips // save immediately, so a shutdown rewrite buys nothing — and it used to // clobber an operator's external accounts.json write, forcing the // "stop → wait for the flush hook → write → start" deploy dance. Removing // it lets an external write land safely once the process is stopped. try { await stopLanguageServerAndWait({ perProcessTimeoutMs: 1500 }); } catch { try { stopLanguageServer(); } catch {} } log.info(`Shutdown complete (${reason})`); process.exit(0); }; server.close(() => { log.info('HTTP server closed, stopping language server'); void finalize('drained'); }); setTimeout(() => { log.warn('Drain timeout, forcing exit'); void finalize('drain-timeout'); }, 30_000); }; process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM')); } main().catch(err => { console.error('Fatal:', err); process.exit(1); });