import { readFileSync, existsSync, mkdirSync, writeFileSync, appendFileSync } from 'fs'; import { resolve, dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { randomBytes } from 'crypto'; import net from 'net'; import { restrictFileToOwnerSync } from './fs-atomic.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..'); // When packaged as a single .exe (pkg), __dirname/ROOT point INSIDE the // read-only snapshot (C:\snapshot\...), so .env can't be read from there and // DATA_DIR would try to mkdir in the snapshot (fails). The real writable // location is the folder the .exe sits in. Use that as the base for .env and // the default data dir so a double-clicked exe keeps its state in a `data/` // folder next to itself — cleanly isolated from the bundled Linux snapshot. const IS_PACKAGED = !!process.pkg; const EXE_DIR = IS_PACKAGED ? dirname(process.execPath) : ROOT; // Load .env file manually (zero dependencies) function loadEnv() { // Packaged: read the .env sitting next to the .exe, not the snapshot copy. const envPath = resolve(EXE_DIR, '.env'); if (!existsSync(envPath)) return; const content = readFileSync(envPath, 'utf-8'); for (const line of content.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const eqIdx = trimmed.indexOf('='); if (eqIdx === -1) continue; const key = trimmed.slice(0, eqIdx).trim(); let val = trimmed.slice(eqIdx + 1).trim(); if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { val = val.slice(1, -1); } else { // Inline comment: everything from a whitespace-preceded `#` on is dropped // (`PORT=3003 # port` -> `3003`). That is the standard dotenv rule, so it stays. // // What was wrong is that it happened SILENTLY. Measured 2026-09-13: a rotated // secret stored with a trailing note — // API_KEY=sk-live-abc123 # rotated 2026-09-01 // — parses to `sk-live-abc123` under every standard parser, so the dashboard // password still works, the chat API answers 401, and nothing in the logs names // the file the operator just edited. The value is not recoverable here (a bare // value and a commented one are genuinely ambiguous), so the fix is to say so // loudly and to document the quoting rule in .env.example. const commentIdx = val.indexOf(' #'); if (commentIdx !== -1) { const kept = val.slice(0, commentIdx).trim(); const dropped = val.slice(commentIdx).trim(); // Only warn when the dropped text is long enough to be a note rather than a // terse label: `PORT=3003 # port` is self-explanatory, a 20-char phrase after // a credential is exactly the shape that gets missed. if (kept && dropped.length > 12) { // The operator needs the fix, not the value: printing the kept or dropped // text put a rotated credential into stdout/container logs, which is the // one place a .env edit tends to end up. The hint is what the pinned test // (test/config-dotenv-inline-comment.test.js) actually requires. console.warn(`[config] .env ${key}: inline comment stripped (value omitted). If the '#' is part of the value, quote it: ${key}="…".`); } val = kept; } } if (!process.env[key]) { process.env[key] = val; } } } if (process.env.WINDSURFAPI_SKIP_DOTENV !== '1') { loadEnv(); } // Packaged-exe sane defaults (only when the user hasn't set them). The Windows // exe has no bundled Language Server (Linux-only) and is meant for local // single-user use, so: DEVIN_CONNECT=1 (pure-HTTP path, skip the LS entirely), // HOST=127.0.0.1 (loopback — don't expose an unauthenticated gateway to the // LAN on first run). Both stay overridable via .env / env. if (IS_PACKAGED) { if (!process.env.DEVIN_CONNECT && !process.env.DEVIN_ONLY) process.env.DEVIN_CONNECT = '1'; if (!process.env.HOST && !process.env.BIND_HOST) process.env.HOST = '127.0.0.1'; } // base64url token (no +/=): safe in .env / URL / header. Mirrors the strong // random keys deploy/windows/start.ps1 writes for the source install. function newSecret(bytes = 24) { return randomBytes(bytes).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } // Packaged first-run credential bootstrap. The source install gets random // API_KEY + DASHBOARD_PASSWORD from start.ps1; the double-click exe had no such // step, so it came up with NO auth. Fix: on a packaged run where these are // unset, generate strong random values, PERSIST them to the .env beside the exe // (so they're stable across restarts and the user can read them), and use them // this run. `_generatedCreds` is surfaced so index.js can print them once, // prominently, like start.ps1 does. let _generatedCreds = null; if (IS_PACKAGED) { const gen = {}; if (!process.env.API_KEY) { const v = 'sk-windsurf-' + newSecret(24); process.env.API_KEY = v; gen.API_KEY = v; } if (!process.env.DASHBOARD_PASSWORD) { const v = newSecret(18); process.env.DASHBOARD_PASSWORD = v; gen.DASHBOARD_PASSWORD = v; } if (Object.keys(gen).length) { const envPath = resolve(EXE_DIR, '.env'); try { const exists = existsSync(envPath); const lines = Object.entries(gen).map(([k, v]) => `${k}=${v}`); const block = (exists ? '\n' : '') + `# --- generated by windsurfapi.exe first run ${new Date().toISOString()} ---\n` + lines.join('\n') + '\n'; if (exists) appendFileSync(envPath, block); else writeFileSync(envPath, block, { mode: 0o600 }); // mode 0o600 is a no-op on Windows: the file inherits the directory ACL // (Users/Authenticated Users) and this .env carries the API key plus the // dashboard password. Best effort — a failure warns and must not turn a // successful credential bootstrap into a startup error. restrictFileToOwnerSync(envPath); _generatedCreds = { ...gen, envPath }; } catch (err) { // Couldn't persist (read-only dir?). Still use them in-memory this run so // the gateway isn't left unauthenticated; they'll just regenerate next run. _generatedCreds = { ...gen, envPath: null, persistError: err.code || err.message }; } } } // Disable Node's Happy Eyeballs (autoSelectFamily). On hosts where DNS returns // an unroutable IPv6 (e.g. ULA fd00:696e:: for server.codeium.com) before the // working IPv4, Node 20+ tries IPv6 first and the whole attempt dies with // ETIMEDOUT in ~270ms — no IPv4 fallback. curl survives via Happy Eyeballs, // but Node's implementation does not fall back when the IPv6 connect hangs. // Disabling autoSelectFamily makes DNS return IPv4 first (default order), // which connects cleanly. Zero-cost on hosts with no IPv6 — the flag only // changes the address-selection strategy, not the DNS query itself. if (typeof net.setDefaultAutoSelectFamily === 'function') { net.setDefaultAutoSelectFamily(false); } // `sharedDataDir` is the cluster-shared root: a single accounts.json lives // here so add-account writes from any replica are visible to every replica // after restart. `dataDir` is replica-local under REPLICA_ISOLATE=1 and is // safe to use for telemetry that does not need cross-replica visibility. // See issue #67 — when the two were collapsed into one path, every // docker-compose upgrade orphaned the user's accounts.json under a stale // `replica-${HOSTNAME}` subdir. // Base that relative DATA_DIR / default data dir resolve against. Packaged: // the .exe's folder (writable) — NEVER the snapshot ROOT. Default (DATA_DIR // unset) for a packaged exe is `/Windsurf_data` so a double-click // drops all state (accounts/stats/logs) into one tidy folder beside the // program, isolated from the bundled Linux snapshot. const DATA_BASE = IS_PACKAGED ? EXE_DIR : ROOT; const sharedDataDir = process.env.DATA_DIR ? resolve(DATA_BASE, process.env.DATA_DIR) : (IS_PACKAGED ? join(EXE_DIR, 'Windsurf_data') : ROOT); const dataDir = (() => { let base = sharedDataDir; if (process.env.REPLICA_ISOLATE === '1' && process.env.HOSTNAME) { base = join(base, `replica-${process.env.HOSTNAME}`); } return base; })(); // First-run detection (packaged only): capture whether the data folder exists // BEFORE we mkdir it, so index.js can decide to auto-open the dashboard only on // the very first launch (no Windsurf_data folder yet = fresh deploy). const isFirstRun = IS_PACKAGED && !existsSync(sharedDataDir); try { mkdirSync(sharedDataDir, { recursive: true }); mkdirSync(dataDir, { recursive: true }); } catch (err) { // Don't swallow this — a non-writable DATA_DIR means accounts.json / stats.json // / logs all fail to persist later with a confusing downstream error. Surface // the path + code now. (log isn't defined yet at module-init time; use console.) console.warn(`[WARN] Could not create data dir "${dataDir}" (${err.code || err.message}). ` + 'accounts/stats/logs may not persist — set DATA_DIR to a writable path.'); } export function defaultLsBinaryPath(platform = process.platform, arch = process.arch, home = process.env.HOME) { if (platform === 'darwin') { const name = arch === 'arm64' ? 'language_server_macos_arm' : 'language_server_macos_x64'; return `${home}/.windsurf/${name}`; } const name = arch === 'arm64' ? 'language_server_linux_arm' : 'language_server_linux_x64'; return `/opt/windsurf/${name}`; } export const config = { port: parseInt(process.env.PORT || '3003', 10), // Bind host. Defaults to all interfaces. Set HOST=127.0.0.1 (or BIND_HOST=) // for localhost-only deployments — when bound non-locally, missing API_KEY / // DASHBOARD_PASSWORD switches to fail-closed instead of default-allow. host: process.env.HOST || process.env.BIND_HOST || '0.0.0.0', apiKey: process.env.API_KEY || '', dataDir, sharedDataDir, codeiumAuthToken: process.env.CODEIUM_AUTH_TOKEN || '', codeiumApiKey: process.env.CODEIUM_API_KEY || '', codeiumEmail: process.env.CODEIUM_EMAIL || '', codeiumPassword: process.env.CODEIUM_PASSWORD || '', codeiumApiUrl: process.env.CODEIUM_API_URL || 'https://server.self-serve.windsurf.com', // Astraflow — OpenAI-compatible aggregation platform by UCloud (200+ models) // Global: https://api-us-ca.umodelverse.ai/v1 — signup: https://astraflow.ucloud-global.com // China: https://api.modelverse.cn/v1 — signup: https://astraflow.ucloud.cn astraflowApiKey: process.env.ASTRAFLOW_API_KEY || '', astraflowApiKeyCn: process.env.ASTRAFLOW_CN_API_KEY || '', astraflowApiUrl: 'https://api-us-ca.umodelverse.ai/v1', astraflowApiUrlCn: 'https://api.modelverse.cn/v1', // Fallback model when a request omits `model`. MUST be a name that resolves to // a live DEVIN_CONNECT catalog selector (mapped:true) — the old // 'claude-4.5-sonnet-thinking' is a legacy word-order alias that resolveModel // accepts but resolveConnectSelector maps to `{mapped:false}`, so on a // DEVIN_CONNECT deployment an omitted-model request was rejected 400 (strict // mode) or silently downgraded to the free tier. 'claude-sonnet-4.6' maps to // the live `claude-sonnet-4-6-thinking` selector. Override with DEFAULT_MODEL. defaultModel: process.env.DEFAULT_MODEL || 'claude-sonnet-4.6', maxTokens: parseInt(process.env.MAX_TOKENS || '8192', 10), logLevel: process.env.LOG_LEVEL || 'info', // Language server lsBinaryPath: process.env.LS_BINARY_PATH || defaultLsBinaryPath(), lsPort: parseInt(process.env.LS_PORT || '42100', 10), // Dashboard dashboardPassword: process.env.DASHBOARD_PASSWORD || '', // Proxy testing allowPrivateProxyHosts: process.env.ALLOW_PRIVATE_PROXY_HOSTS === '1', // True when running as a packaged single-exe (pkg). Used to enable the // double-click desktop UX: data folder beside the exe, auto-open dashboard. isPackaged: IS_PACKAGED, // True on the very first packaged launch (Windsurf_data didn't exist yet). // index.js opens the dashboard in the browser only on this first deploy. isFirstRun, // Credentials auto-generated on a packaged run (null if none). index.js prints // them once, prominently, so the user can log in / call the API. generatedCreds: _generatedCreds, }; const levels = { debug: 0, info: 1, warn: 2, error: 3 }; const currentLevel = levels[config.logLevel] ?? 1; export const log = { debug: (...args) => currentLevel <= 0 && console.log('[DEBUG]', ...args), info: (...args) => currentLevel <= 1 && console.log('[INFO]', ...args), warn: (...args) => currentLevel <= 2 && console.warn('[WARN]', ...args), error: (...args) => currentLevel <= 3 && console.error('[ERROR]', ...args), };