src/config.js
Rama master · réplica de lectura del repositorio original.
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 `<exe-dir>/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),
};