/** * Dashboard API route handlers. * All routes are under /dashboard/api/*. */ import { config, log } from '../config.js'; import { accountRef, buildSubprocessEnv } from '../log-safety.js'; import { existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync, } from 'node:fs'; import { randomUUID } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; import { getAccountList, getAccountCount, getAccountListStats, getAccountPublic, addAccountByKey, addAccountByPastedSecret, countAccountList, ACCOUNT_STATE_VALUES, ACCOUNT_SORT_VALUES, removeAccount, setAccountStatus, resetAccountErrors, updateAccountLabel, markAccountReauthenticated, requestReauthCheck, setAccountCredentialEmail, isAuthenticated, probeAccount, ensureLsForAccount, refreshCredits, searchWebForAccount, setAccountBlockedModels, setAccountTokens, setAccountTier, setAccountSpendPolicy, getAccountInternal, isLocalBindHost, maskApiKey, safeEqualString, checkLockout, failedAuthAttempt, successfulAuthAttempt, getDroughtSummary, getPoolHealthWindow, getRateLimitHistory, getAccountRequests, getConnectSelectorPricing, isConnectSelectorBlockedByDrought, sanitizeLabelText, } from '../auth.js'; import { restartLsForProxy } from '../langserver.js'; import { getLsStatus, stopLanguageServerAndWait, startLanguageServer, isLanguageServerRunning, getLsAdmissionStatus } from '../langserver.js'; import { getStats, getRequestLog, resetStats, recordRequest, exportStats, importStats } from './stats.js'; import { getConnectMetrics, resetConnectMetrics } from '../devin-connect-metrics.js'; import { getCascadeImageMetrics, resetCascadeImageMetrics } from '../cascade-image-metrics.js'; import { detachedRegistry, cascadeDetachEnabled } from '../detached-registry.js'; import { getStickyStats, isStickyEnabled } from '../account/sticky-session.js'; import { getResponseStoreStats } from '../response-store.js'; import { cacheStats, cacheClear } from '../cache.js'; import { getExperimental, setExperimental, getTunables, setTunables, getPrefs, setPrefs, getSystemPrompts, setSystemPrompts, resetSystemPrompt, getCredentials, setRuntimeCredentials, verifyPassword, getEffectiveApiKey, getEffectiveDashboardPasswordStored, getBackendSwitch, getBackendSwitchOverrides, setBackendSwitches, getBreakerTunables, getBreakerOverrides, setBreakerTunables, getQuotaTunables, getQuotaOverrides, setQuotaTunables, } from '../runtime-config.js'; import { poolStats as convPoolStats, poolClear as convPoolClear } from '../conversation-pool.js'; import { getClineCompatStats } from '../handlers/cline-compat.js'; import { getCcCompatStats } from '../handlers/cc-compat.js'; import { getLogs, subscribeToLogs, unsubscribeFromLogs } from './logger.js'; import { getProxyConfig, getProxyConfigMasked, setGlobalProxy, setAccountProxy, removeProxy, getEffectiveProxy } from './proxy-config.js'; import { MODELS, MODEL_TIER_ACCESS as _TIER_TABLE, getTierModels as _getTierModels, filterModelKeysByCloudCatalog, resolveModel } from '../models.js'; import { buildConnectReachability } from '../handlers/models.js'; import { FREE_REACHABLE_SELECTORS, getLiveCatalog, getSnapshotSelectors, resolveConnectSelector } from '../devin-connect-models.js'; import { buildCatalogDiff } from './catalog-diff.js'; import { accountState, cooldownState } from './pool-events.js'; import { probeOutcomeEntry, summarizeProbeResults, startFleetJob, awaitFleetJob, getFleetJob, cancelFleetJob, publicFleetJob, } from './fleet-jobs.js'; import { peekBillingFacts } from './devin-read.js'; import { readQuotaBurnDown } from './quota-burn-down.js'; import { readAcuUsageByOrg } from './acu-usage.js'; import { readBillingHealth } from './billing-health.js'; import { readModelReconciliation } from './model-reconcile.js'; import { buildAccountCards, preflightCapacity } from './devin-cards.js'; import { buildDevinPanel, buildQuotaSection, buildRuntimeSection, buildSessionsSection, buildTurnsSection, MAX_SCAN_ACCOUNTS, RUNTIME_SWITCH_KEYS, } from './devin-panel.js'; import { listRequests, requestRingStats } from './request-ring.js'; import { windsurfLogin, refreshFirebaseToken, reRegisterWithCodeium, probeLoginMethod, emailLoginStart, emailLoginComplete, checkEmailLocked, assertLoginIdentity } from './windsurf-login.js'; import { getModelAccessConfig, setModelAccessMode, setModelAccessList, addModelToList, removeModelFromList, setDefaultModel } from './model-access.js'; import { checkMessageRateLimit } from '../windsurf-api.js'; import { getNativeBridgeConfigStatus } from '../cascade-native-bridge.js'; import { getNativeBridgeStats } from '../native-bridge-stats.js'; import { assertPublicUrlHost } from '../image.js'; import { validateHostFormat, resolveProxyConnectHost, trustedClientIp } from '../net-safety.js'; import { discoverWindsurfCredentials, isLoopbackAddress } from './local-windsurf.js'; import { parseAccountText, classifyToken } from './account-text-parser.js'; import { DOCKER_DEPLOYER_WINDOW_MS, detectDockerSelfUpdate, reconcileDockerSelfUpdateState, runDockerSelfUpdate, } from './docker-self-update.js'; import { BatchImportParseError, parseBatchImportInput } from './import-parser.js'; import { DEFAULT_ATTESTATION_REPOSITORY, normalizeAttestationMode, parseGitHubRepository, verifyReleaseAttestation, } from '../release-attestation.js'; import { detectSupervisor, gracefulRestart } from '../restart.js'; import { getActiveServer } from '../server-registry.js'; import { abortActiveSse } from '../sse-registry.js'; import { renameSyncWithRetry, writeJsonAtomic } from '../fs-atomic.js'; import { getVersionInfo } from '../version.js'; // Statuses an operator may write through PATCH /accounts/:id. Deliberately NOT // ACCOUNT_STATE_VALUES (the read-side vocabulary): that list also carries the // derived/evidence-gated states, and only these three are operator intent. const PATCHABLE_ACCOUNT_STATUSES = Object.freeze(['active', 'disabled', 'error']); // Test-only seam for exercising the route-level LS integrity gate without a // real installer. Never set from production code. let _langserverInstallScriptOverride = null; export function __setLangserverInstallScriptForTests(path) { _langserverInstallScriptOverride = path ? String(path) : null; } function shouldPrewarmLsOnAccountAdd() { return process.env.LS_PREWARM_ON_ACCOUNT_ADD === '1' || process.env.LS_PREWARM_PROXIES === '1'; } async function runAccountWarmup(accountId, { probe = true, requireOptIn = true } = {}) { if (requireOptIn && !shouldPrewarmLsOnAccountAdd()) return { ok: true, skipped: true, reason: 'prewarm_disabled' }; const ls = await ensureLsForAccount(accountId); if (!ls?.ok) return { ok: false, skipped: false, reason: ls?.errorType || 'ls_start_failed', ls }; if (!probe) return { ok: true, skipped: false, ls }; try { const probeResult = await probeAccount(accountId); return { ok: true, skipped: false, ls, probe: probeResult }; } catch (e) { return { ok: false, skipped: false, reason: e?.message || 'probe_failed', ls }; } } function scheduleAccountWarmup(accountId, { probe = true } = {}) { if (!shouldPrewarmLsOnAccountAdd()) return; runAccountWarmup(accountId, { probe, requireOptIn: false }) .then(r => { if (r && !r.ok) log.warn(`LS warmup failed for account ${accountId}: ${r.reason || 'unknown_error'}`); return null; }) .catch(e => log.warn(`LS ensure failed: ${e.message}`)); } // One row per hot-swappable backend switch: effective value + whether it came // from runtime-config.json or is following the deployment env. Shared by // GET/PUT /runtime-env-status and the Devin panel so the two can never report // a different value for the same switch. function runtimeSwitchRows() { const overrides = getBackendSwitchOverrides(); const rows = {}; for (const key of RUNTIME_SWITCH_KEYS) { rows[key] = { value: getBackendSwitch(key), override: overrides[key] === undefined ? null : overrides[key], source: overrides[key] === null || overrides[key] === undefined ? 'env' : 'config', }; } return rows; } export function parseProxyUrl(proxy) { // Normalize whitespace so "socks5 127.0.0.1 1089" and // "socks5://127.0.0.1:1089" both parse correctly. const s = String(proxy).replace(/\s+/g, ' ').trim(); // Try canonical URL form first: protocol://[user:pass@]host:port // Host must not contain spaces — otherwise "http 1.2.3.4:8080" would // greedily capture "http 1.2.3.4" as the host. let m = s.match(/^(?:(\w+):\/\/)?(?:([^\s:]+):([^\s@]+)@)?([^\s:]+):(\d+)$/); // Fallback: "type host port" (space-separated, no :// and no colon) if (!m) m = s.match(/^(\w+)\s+([^\s:]+)\s+(\d+)$/); // Fallback: "type host:port" (type prefix, no ://) if (!m) m = s.match(/^(\w+)\s+([^\s:]+):(\d+)$/); if (!m) return null; if (m.length === 4) { // space-or-type-separated form: [type] host port return { type: m[1], host: m[2], port: parseInt(m[3]), username: '', password: '', }; } return { type: m[1] || 'http', host: m[4], port: parseInt(m[5]), username: m[2] || '', password: m[3] || '', }; } export async function validateProxyHost(proxy) { if (!proxy?.host) return; if (config.allowPrivateProxyHosts) { await validateHostFormat(proxy.host); } else { await assertPublicUrlHost(proxy.host); } } export function buildBatchProxyBinding(result, proxy) { const accountId = result?.account?.id || null; if (!result?.success || !proxy || !accountId) return null; const parsed = parseProxyUrl(proxy); if (!parsed) return null; return { accountId, proxy: parsed, }; } // ─── Batch-import diagnostics (V-25 / audit A24) ────────────────────────── // // A batch-import response used to echo the submitted line (`raw`) and the // proxy string including its userinfo (`proxyRaw`). A failed import therefore // returned the password / token / API key the operator had just pasted, in a // JSON body the browser may cache — and the dashboard copied it into // localStorage login history. Responses now carry structured diagnostics // derived from the PARSED item (kind, line number, error code) plus a // credential-free proxy label. Nothing here is a substring of the input. /** `type://host:port` — never the username/password the proxy URL carried. */ function redactedProxyLabel(proxy) { if (!proxy || typeof proxy !== 'object' || !proxy.host) return null; const type = typeof proxy.type === 'string' && proxy.type ? proxy.type : 'http'; const port = Number.isInteger(proxy.port) && proxy.port > 0 ? proxy.port : null; return `${type}://${proxy.host}${port ? `:${port}` : ''}`; } const IMPORT_KIND_LABELS = Object.freeze({ email_password: 'email+password', token: 'token', api_key: 'api key', issue: 'unrecognized', }); /** One-line human diagnostic that names the shape, not the secret. */ function describeImportItem(item) { const kind = IMPORT_KIND_LABELS[item?.kind] || IMPORT_KIND_LABELS.issue; const proxy = redactedProxyLabel(item?.proxy); return proxy ? `${kind} · proxy ${proxy}` : kind; } // AUTH-1 CORS hardening: the dashboard used to answer every request (and // every OPTIONS preflight) with `Access-Control-Allow-Origin: *` while also // advertising the X-Dashboard-Password header, which invites any web origin // to script authenticated dashboard calls from a victim's browser. We now // reflect only origins the operator explicitly allowlists via // DASHBOARD_CORS_ORIGINS (csv). Unset ⇒ no ACAO header at all (same-origin // dashboard use needs none; cross-origin is denied by the browser). The // literal `*` is still honoured but only when the operator opts in by // listing `*` in DASHBOARD_CORS_ORIGINS. function resolveCorsOrigin(req) { const origin = req?.headers?.origin || ''; if (!origin) return ''; const allow = String(process.env.DASHBOARD_CORS_ORIGINS || '') .split(',') .map(s => s.trim()) .filter(Boolean); if (!allow.length) return ''; if (allow.includes('*')) return '*'; return allow.includes(origin) ? origin : ''; } // ─── A04: mutation request gate ───────────────────────── // // Open-local dashboards (DASHBOARD_ALLOW_NO_AUTH=1 on a loopback bind) have no // secret for a CSRF attacker to steal, so the only thing that stood between the // operator's browser and any other web page was the CORS *response* policy — // which does not stop a cross-site "simple" POST (`Content-Type: text/plain`) // from reaching the handler. The handler used to accept that body as JSON and // run the mutation. Every state-changing dashboard request now has to prove it // is an operator mutation before any side effect: // // 1. JSON content type — a cross-origin request with this type needs a CORS // preflight, which the dashboard never grants to an unlisted origin. // 2. Same-origin Origin/Host agreement — catches simple form/fetch posts // that bypass preflight. // 3. Fetch Metadata (`Sec-Fetch-Site`/`Mode`/`Dest`) — rejects cross-site and // navigation/form requests even when Origin is missing. // 4. Strict Host validation — on a loopback bind only loopback Host names // are accepted (DNS-rebinding defence); non-loopback names require // DASHBOARD_ALLOWED_HOSTS (a trusted X-Forwarded-Host is honoured only // when TRUST_PROXY_X_FORWARDED_FOR=1, but still has to pass that list). // // Non-browser clients (curl, scripts, the CLI) send no Origin/Sec-Fetch and // remain usable after auth, but they must still declare a JSON content type // when they carry a body. `/auth` is a credential probe, not a mutation, and // stays outside the gate. const SAFE_DASHBOARD_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); const JSON_CONTENT_TYPE_RE = /^application\/(?:[a-z0-9!#$&^_.+-]+\+)?json(?:\s*;|$)/i; function dashboardHeader(req, name) { const value = req?.headers?.[name]; if (Array.isArray(value)) return String(value[0] ?? ''); return typeof value === 'string' ? value : ''; } function isJsonContentType(value) { return JSON_CONTENT_TYPE_RE.test(String(value ?? '').trim()); } function stripHostBrackets(hostname) { return String(hostname ?? '').trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, ''); } // Parse a Host header (name[:port], [v6]:port) without accepting URL syntax. function splitHostHeader(value) { const raw = String(value ?? '').trim(); if (!raw || /[\s/\\@?#]/.test(raw)) return null; let hostname = ''; let port = ''; if (raw.startsWith('[')) { const end = raw.indexOf(']'); if (end < 0) return null; hostname = raw.slice(1, end); const rest = raw.slice(end + 1); if (rest) { if (!rest.startsWith(':') || !/^\d{1,5}$/.test(rest.slice(1))) return null; port = rest.slice(1); } } else { const firstColon = raw.indexOf(':'); const lastColon = raw.lastIndexOf(':'); if (firstColon !== -1 && firstColon !== lastColon) return null; // bare IPv6 is not a valid Host if (lastColon !== -1) { hostname = raw.slice(0, lastColon); port = raw.slice(lastColon + 1); if (!/^\d{1,5}$/.test(port)) return null; } else { hostname = raw; } } hostname = stripHostBrackets(hostname); if (!hostname || hostname.length > 253) return null; if (!/^[a-z0-9._:-]+$/i.test(hostname)) return null; return { hostname, port }; } function parseOriginHeader(value) { const raw = String(value ?? '').trim(); if (!raw || raw === 'null') return null; try { const url = new URL(raw); if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; if (!url.hostname) return null; return { protocol: url.protocol, hostname: stripHostBrackets(url.hostname), port: url.port, }; } catch { return null; } } function originMatchesHost(origin, host) { if (!origin || !host) return false; if (origin.hostname !== host.hostname) return false; const defaultPort = origin.protocol === 'https:' ? '443' : '80'; return (origin.port || defaultPort) === (host.port || defaultPort); } function isLoopbackHostname(hostname) { const host = stripHostBrackets(hostname); if (host === 'localhost' || host === '::1' || host === '::ffff:7f00:1') return true; if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true; if (/^::ffff:127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) return true; return false; } function explicitDashboardHosts() { const hosts = new Set(); const configured = stripHostBrackets(config.host); if (configured && configured !== '0.0.0.0' && configured !== '::') hosts.add(configured); for (const entry of String(process.env.DASHBOARD_ALLOWED_HOSTS || '').split(',')) { const host = stripHostBrackets(entry); if (host) hosts.add(host); } return hosts; } function dashboardHostAllowed(hostname) { const host = stripHostBrackets(hostname); if (!host) return false; if (isLoopbackHostname(host)) return true; if (explicitDashboardHosts().has(host)) return true; // Loopback bind + a non-loopback Host is the DNS-rebinding case: the // browser resolved attacker.example to 127.0.0.1 and now sends // `Host: attacker.example` / `Origin: http://attacker.example`. Reject it // unless an operator explicitly allowlisted the name (checked above) or a // trusted proxy supplied X-Forwarded-Host (checked by the caller). if (isLocalBindHost()) return false; // Public bind: IP literals are unambiguous. A DNS name must either match // HOST/DASHBOARD_ALLOWED_HOSTS or, for backwards compatibility, is accepted // only when no explicit host allowlist was configured. if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(':')) return true; return explicitDashboardHosts().size === 0; } function explicitlyAllowedCorsOrigin(origin) { const allow = String(process.env.DASHBOARD_CORS_ORIGINS || '') .split(',') .map(s => s.trim()) .filter(s => s && s !== '*'); return allow.includes(origin); } // Validate the Host / X-Forwarded-Host of EVERY dashboard API request (not // just mutations): DNS rebinding can otherwise read cleartext account keys // through GET /accounts/import-local with Host: attacker.example. Returns // { host, trustProxy } or null after writing 403. // // A trusted proxy's X-Forwarded-Host is honoured only when the operator opted // into TRUST_PROXY_X_FORWARDED_FOR AND supplied X-Forwarded-For, but the // forwarded name must still pass the SAME allowlist as the raw Host. It used // to short-circuit DASHBOARD_ALLOWED_HOSTS for every non-loopback value, which // let any client that could reach the app directly set the header itself and // walk past the list. X-Forwarded-Host may be a comma list; the right-most // entry is the one appended by the nearest trusted proxy (same right-counting // rule as XFF). export function validateDashboardRequestHost(req, res) { const hostHeader = dashboardHeader(req, 'host'); const forwardedHostRaw = dashboardHeader(req, 'x-forwarded-host'); const forwardedFor = dashboardHeader(req, 'x-forwarded-for'); // A trusted proxy is only trusted when the operator opted in AND actually // supplied the header the proxy appends. A browser cannot set either header // on a simple cross-site request. const trustProxy = process.env.TRUST_PROXY_X_FORWARDED_FOR === '1' && !!forwardedFor; const trustForwardedHost = trustProxy && !!forwardedHostRaw; const forwardedHost = trustForwardedHost ? forwardedHostRaw.split(',').map(s => s.trim()).filter(Boolean).pop() || '' : ''; const effectiveHostValue = forwardedHost || hostHeader; let host = null; if (effectiveHostValue) { host = splitHostHeader(effectiveHostValue); if (!host || !dashboardHostAllowed(host.hostname)) { json(res, 403, { error: 'ERR_DASHBOARD_HOST_REJECTED', message: 'Request Host is not allowed for the dashboard', }); return null; } } return { host, trustProxy }; } // Returns true when the request may proceed; otherwise writes the 403/415 // response itself and returns false. Host validation runs for every method; // the remaining origin/content-type/fetch-metadata checks are mutation-only. function enforceDashboardMutationGate(method, subpath, req, res) { const hostState = validateDashboardRequestHost(req, res); if (!hostState) return false; const verb = String(method || '').toUpperCase(); if (SAFE_DASHBOARD_METHODS.has(verb)) return true; if (subpath === '/auth') return true; // credential probe, no state change const headers = req?.headers || {}; const contentType = dashboardHeader(req, 'content-type'); // A real IncomingMessage always carries method/httpVersion/Host. Bare // handler-level unit fixtures (no HTTP shape) predate the gate and stay // callable; the moment they declare a content type or HTTP fields, the // gate applies exactly as it does in production. const looksLikeHttp = !!(req && (req.method || req.httpVersion || headers.host)); const bodyMethod = verb === 'POST' || verb === 'PUT' || verb === 'PATCH'; const contentLength = Number(dashboardHeader(req, 'content-length') || 0); const hasDeclaredBody = (Number.isFinite(contentLength) && contentLength > 0) || !!dashboardHeader(req, 'transfer-encoding'); // Audit wording is "for bodies": an empty-body POST (e.g. the dashboard's // /self-update button before it had a payload) has nothing to parse, and // Node/HTTP still gives it a Content-Length of 0. Cross-site empty POSTs // are already stopped by Origin/Sec-Fetch-Site below; a declared or // present content type must always be JSON. const needsJsonContentType = !!contentType || (bodyMethod && looksLikeHttp && hasDeclaredBody); if (needsJsonContentType && !isJsonContentType(contentType)) { json(res, 415, { error: 'ERR_UNSUPPORTED_MEDIA_TYPE', message: 'Dashboard mutations require Content-Type: application/json', }); return false; } let host = hostState.host; const originRaw = dashboardHeader(req, 'origin'); const trustedCorsOrigin = !!originRaw && explicitlyAllowedCorsOrigin(originRaw); let origin = null; if (originRaw) { origin = parseOriginHeader(originRaw); if (!origin) { json(res, 403, { error: 'ERR_DASHBOARD_ORIGIN_REJECTED', message: 'Request Origin is invalid for a dashboard mutation', }); return false; } if (!trustedCorsOrigin) { // A missing Host on a real HTTP/1.1 request is rejected by Node before // we get here. For handler-level fixtures, let a loopback Origin stand // in for the Host so same-origin tests do not need to fake transport // metadata. if (!host && isLoopbackHostname(origin.hostname)) { host = { hostname: origin.hostname, port: origin.port }; } if (!originMatchesHost(origin, host)) { // A trusted reverse proxy may rewrite Host to the loopback upstream // while preserving the browser's public Origin. Only allow that when // the proxy actually supplied X-Forwarded-For (browser simple // requests cannot set it) and the Host is the loopback upstream. const proxyRewroteHost = hostState.trustProxy && host && isLoopbackHostname(host.hostname); if (!proxyRewroteHost) { json(res, 403, { error: 'ERR_DASHBOARD_ORIGIN_REJECTED', message: 'Cross-origin dashboard mutations are not allowed', }); return false; } } } } const fetchSite = dashboardHeader(req, 'sec-fetch-site').toLowerCase(); // An operator-allowlisted cross-origin dashboard is trusted for mutations; // its browser still sends Sec-Fetch-Site: cross-site by definition. if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none' && !trustedCorsOrigin) { json(res, 403, { error: 'ERR_DASHBOARD_FETCH_METADATA_REJECTED', message: 'Cross-site dashboard mutations are not allowed', }); return false; } const fetchMode = dashboardHeader(req, 'sec-fetch-mode').toLowerCase(); if (fetchMode === 'no-cors' || fetchMode === 'navigate') { json(res, 403, { error: 'ERR_DASHBOARD_FETCH_METADATA_REJECTED', message: 'Simple-form dashboard mutations are not allowed', }); return false; } const fetchDest = dashboardHeader(req, 'sec-fetch-dest').toLowerCase(); if (fetchDest && fetchDest !== 'empty') { json(res, 403, { error: 'ERR_DASHBOARD_FETCH_METADATA_REJECTED', message: 'Navigation dashboard mutations are not allowed', }); return false; } return true; } function json(res, status, body, extraHeaders = null) { // Error envelopes carry `success: false`, the same key 2xx bodies use for // `success: true`, so a client can tell "this request failed" apart from // "this payload happens to have no success key" (#257). Scoped to non-2xx // statuses so no successful response body changes shape. const payload = status >= 400 && body && typeof body === 'object' && !Array.isArray(body) && !Object.prototype.hasOwnProperty.call(body, 'success') ? { ...body, success: false } : body; const data = JSON.stringify(payload); const headers = { 'Content-Type': 'application/json', 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, X-Dashboard-Password', }; // Authed card payloads (billing, models, pool) opt into `Cache-Control: // no-store`; the server-side TTL cache is independent of the browser cache. if (extraHeaders) Object.assign(headers, extraHeaders); // Origin resolved once per request at the top of handleDashboardApi and // stashed on res. Only emit ACAO when an origin is actually allowed; a // specific echoed origin also needs Vary: Origin so caches don't serve // one origin's CORS decision to another. const acao = res?._dashboardCorsOrigin || ''; if (acao) { headers['Access-Control-Allow-Origin'] = acao; if (acao !== '*') headers['Vary'] = 'Origin'; } res.writeHead(status, headers); res.end(data); } function dashboardUrl(req) { return new URL(req?.url || '/', 'http://localhost'); } // Authed billing/model/pool JSON must never be stored by the browser. const NO_STORE = Object.freeze({ 'Cache-Control': 'no-store' }); /** * Price annotation for the per-model usage table. * * The table is keyed by whatever model string the client sent, which is exactly * what a bill shows — but the PRICE belongs to the selector that string resolves * to, and only the live billing sync knows whether that selector is free right * now. Resolving server-side keeps the UI from re-implementing the alias ladder, * and keeps "0 credits" (a real free promo) distinct from "unknown" (no live * data): `freeNow: null` renders no badge at all rather than a paid one. */ export function buildModelBillingIndex(modelCounts = {}) { const out = {}; for (const model of Object.keys(modelCounts || {})) { const key = resolveModel(model) || model; const resolution = resolveConnectSelector(key, { warnOnFallback: false }); const selector = resolution?.mapped ? resolution.selector : null; const pricing = selector ? getConnectSelectorPricing(selector) : null; out[model] = { selector, freeNow: pricing ? pricing.freeNow : null, freeReason: pricing ? pricing.freeReason : null, costTier: pricing ? pricing.costTier : null, promoEndsAt: pricing ? pricing.promoEndsAt : null, promoLabel: pricing ? pricing.promoLabel : null, pricingFetchedAt: pricing ? pricing.pricingFetchedAt : null, pricingStale: pricing ? pricing.pricingStale : false, }; } return out; } function parseBoundedInt(value, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) { const n = Number.parseInt(String(value ?? ''), 10); if (!Number.isFinite(n)) return fallback; return Math.min(max, Math.max(min, n)); } function getAccountsPayload(req) { const url = dashboardUrl(req); const qs = url.searchParams; const wantsPaged = qs.has('page') || qs.has('pageSize') || qs.has('offset') || qs.has('limit') || qs.has('view') || qs.has('filter') || qs.has('tier') || qs.has('q') || qs.has('status') || qs.has('sort') || qs.has('order'); if (!wantsPaged) return { accounts: getAccountList() }; const view = qs.get('view') === 'summary' ? 'summary' : 'full'; const pageSize = parseBoundedInt(qs.get('pageSize') ?? qs.get('limit'), 50, { min: 1, max: 1000 }); const page = parseBoundedInt(qs.get('page'), 1, { min: 1, max: 1000000 }); const explicitOffset = qs.has('offset') ? parseBoundedInt(qs.get('offset'), 0, { min: 0, max: Number.MAX_SAFE_INTEGER }) : null; const offset = explicitOffset ?? ((page - 1) * pageSize); const filter = qs.get('filter') === 'flagged' ? 'flagged' : ''; const tier = ['pro', 'free', 'unknown', 'expired'].includes(qs.get('tier')) ? qs.get('tier') : ''; // Unknown values are ignored, never a 400: a stale bookmark must still list // the pool instead of blanking the table. `q` is a literal substring (no SQL // LIKE), so % and _ are ordinary characters. 200 chars bounds the work. const q = String(qs.get('q') ?? '').trim().slice(0, 200); const status = ACCOUNT_STATE_VALUES.includes(qs.get('status')) ? qs.get('status') : ''; const sort = ACCOUNT_SORT_VALUES.includes(qs.get('sort')) ? qs.get('sort') : ''; const order = qs.get('order') === 'asc' || qs.get('order') === 'desc' ? qs.get('order') : ''; const stats = getAccountListStats(); // D10: total comes from the SAME predicate the rows use (filter + tier + q + // status), not from a single stat that silently drops the other filters. const total = countAccountList({ filter, tier, q, status }); const accounts = getAccountList({ view, offset, limit: pageSize, filter, tier, q, status, sort, order }); return { accounts, page, pageSize, offset, total, hasMore: offset + accounts.length < total, view, filter, tier, q, status, sort, order, stats, }; } // Query params shared by GET /accounts and GET /accounts/ids. Kept in one place // so "select all N results" fetches exactly the rows the table is showing. function accountFiltersFromQuery(qs) { return { filter: qs.get('filter') === 'flagged' ? 'flagged' : '', tier: ['pro', 'free', 'unknown', 'expired'].includes(qs.get('tier')) ? qs.get('tier') : '', q: String(qs.get('q') ?? '').trim().slice(0, 200), status: ACCOUNT_STATE_VALUES.includes(qs.get('status')) ? qs.get('status') : '', sort: ACCOUNT_SORT_VALUES.includes(qs.get('sort')) ? qs.get('sort') : '', order: qs.get('order') === 'asc' || qs.get('order') === 'desc' ? qs.get('order') : '', }; } // Client IP for the dashboard lockout bucket + audit logs. Delegates to // net-safety.js:trustedClientIp — the single source of truth shared with // src/caller-key.js so the dashboard lockout and the caller-key fingerprint // can't drift apart (audit S4; previously two byte-identical private copies // kept in sync only by a comment). See trustedClientIp for the audit-H2 // (default-ignore XFF) and XFF-1 (count from the right) rationale. function dashboardClientIp(req) { return trustedClientIp(req); } function checkAuth(req) { // Header-only auth. logs/stream switched from EventSource to fetch + // ReadableStream months ago, so the EventSource exception is gone and // ?pwd= query passwords would only leak into URL access logs and // browser history without any callers needing them. // // v2.0.55 (audit H1): on non-local binds we no longer fall back to // `config.apiKey` as the dashboard password. That fallback turned // every chat-API caller into a service operator (list accounts, // reveal-key, change proxy, trigger LS / docker self-update). Public // bind WITHOUT DASHBOARD_PASSWORD now fails closed; operators must // set DASHBOARD_PASSWORD explicitly. Localhost-only deployments keep // the convenience fallback so single-user `docker-compose up` doesn't // suddenly require an extra env. // // v2.0.56: dashboardPassword now comes from runtime-config (settable // from the dashboard) before falling back to env. apiKey fallback // (localhost only) also honours the runtime override. const pw = req.headers['x-dashboard-password'] || ''; const storedDashboardPw = getEffectiveDashboardPasswordStored(); if (storedDashboardPw) { // SEC-01 (audit 2026-10-22): an empty/blank header cannot match any // configured password, yet it used to reach verifyPassword → scryptSync // (N=2^14, ~20 ms of blocked event loop). Unlike a wrong NON-empty guess it // also never advanced the brute-force lockout, so unauthenticated callers // had a free CPU DoS. Reject before hashing; real guesses keep the exact // same verifyPassword path. if (!String(pw ?? '').trim()) return false; return verifyPassword(pw, storedDashboardPw); } // audit #1: the localhost API-key fallback (and the no-auth opt-in) let a // caller present the shared chat API key as the dashboard password. Gating on // isLocalBindHost() alone is unsafe: behind a same-host reverse proxy // (openresty → 127.0.0.1) the bind IS local for everyone. Gating on the raw // socket peer is ALSO unsafe there — the proxy connects from 127.0.0.1, so a // remote user proxied in still looks loopback. Use the TRUSTED client IP // (dashboardClientIp: real client when TRUST_PROXY_X_FORWARDED_FOR + hops are // configured, else the socket peer) so the convenience is genuinely local: // - XFF trust configured (the recommended proxy setup, see the #7 warning): // a remote client's real IP is non-loopback → fallback denied. // - No proxy, genuine localhost: client IP is loopback → fallback allowed. // - Proxy present but XFF trust OFF: can't verify the client, so the operator // is told (startup #7 warning) to set TRUST_PROXY_X_FORWARDED_FOR=1 or a // real DASHBOARD_PASSWORD; until then this convenience should not be relied // on for isolation. const clientIp = dashboardClientIp(req); if (isLocalBindHost() && isLoopbackAddress(clientIp)) { const effectiveApiKey = getEffectiveApiKey(); // H2 (Grok audit): the "chat API key doubles as the dashboard password" // convenience is now OPT-IN (default OFF). Even from a verified-local // client it's a footgun — a shared chat key becomes full operator access, // and a reverse-proxy misconfig (XFF trust off) can make a remote client // look loopback. Operators who genuinely want the single-secret local // convenience must set DASHBOARD_ALLOW_API_KEY_AS_PASSWORD=1. Otherwise the // dashboard requires a real DASHBOARD_PASSWORD. if (effectiveApiKey && process.env.DASHBOARD_ALLOW_API_KEY_AS_PASSWORD === '1') { return safeEqualString(pw, effectiveApiKey); } // AUTH-1: localhost with NO secret configured used to be treated as // authenticated for every caller. Default is now fail-closed; operators who // relied on the old open-local convenience must opt in with // DASHBOARD_ALLOW_NO_AUTH=1 (and even then only from a verified-local client). if (!effectiveApiKey) return process.env.DASHBOARD_ALLOW_NO_AUTH === '1'; return false; } return false; } // H1 (Grok audit): reusable "is this an authenticated dashboard operator?" // check for the account-management endpoints in server.js (/auth/login, // /auth/accounts, DELETE /auth/accounts/:id). Those used to sit behind only the // chat API key, so any chat client could add/list/delete pool accounts. They // now require the SAME auth the dashboard does (dashboard password, or the // opt-in localhost apiKey fallback) — a shared chat key alone is no longer // enough to act as operator. Thin wrapper so the resolution order stays single- // sourced in checkAuth. export function verifyAdminRequest(req) { return checkAuth(req); } // AUTH-1: explicit re-auth for the reveal-key endpoint. The caller must // present the dashboard password AGAIN in this request even though ambient // dashboard auth already passed — via body.password / body.dashboardPassword // or the X-Dashboard-Password-Confirm header. Mirrors checkAuth()'s secret // resolution order (runtime/env dashboard password, then localhost API-key // fallback). When no secret is configured at all the re-auth is only accepted // on an opt-in open-local dashboard (DASHBOARD_ALLOW_NO_AUTH=1) — otherwise // checkAuth would already have failed closed before we get here. function confirmReauth(req, body) { const proof = (body && typeof body === 'object' && (typeof body.password === 'string' ? body.password : typeof body.dashboardPassword === 'string' ? body.dashboardPassword : '')) || req?.headers?.['x-dashboard-password-confirm'] || ''; const storedDashboardPw = getEffectiveDashboardPasswordStored(); if (storedDashboardPw) { // SEC-01: same empty-proof guard as checkAuth. A blank re-auth proof is // never valid and must not pay for scrypt before being refused. if (!String(proof ?? '').trim()) return false; return verifyPassword(proof, storedDashboardPw); } // audit #1: same gate as checkAuth — use the TRUSTED client IP, not the raw // socket peer (which is the proxy's 127.0.0.1 for everyone behind a same-host // reverse proxy), so a remote proxied caller can't reauth with the shared chat // key. const clientIp = dashboardClientIp(req); if (isLocalBindHost() && isLoopbackAddress(clientIp)) { const effectiveApiKey = getEffectiveApiKey(); if (effectiveApiKey) return safeEqualString(proof, effectiveApiKey); return process.env.DASHBOARD_ALLOW_NO_AUTH === '1'; } return false; } // Mask an email for logs: keep first char + domain, redact the rest of the // local part (a@b.com -> a***@b.com). Never log the full address. function maskEmail(email) { const s = String(email || ''); const at = s.indexOf('@'); if (at <= 0) return s ? s[0] + '***' : ''; return s[0] + '***' + s.slice(at); } // Pull the auth token out of whatever the user pasted after finishing the // Windsurf sign-in: a full redirect URL (token in query or #fragment), or the // bare token string itself. Returns '' if nothing token-shaped is found. The // caller must never log the raw input (it may contain a live token). // // Two diagnostic cases throw an ERR_* instead of returning '' so the user gets // an actionable reason rather than a generic "no token found": // - ERR_INTERMEDIATE_CALLBACK: the pasted URL is a federated *intermediate* // callback (has ?code= on a /auth//callback path). That code can // only be exchanged by windsurf.com's backend (holds the client_secret) — // the user pasted too early; they must wait for the show-auth-token page and // paste the token shown there. // - ERR_OAUTH_UPSTREAM:[:]: the provider redirected back an // ?error= (e.g. access_denied) — surface the real reason. // Exported for unit testing (also used by the /oauth/callback endpoint). // // ANY absolute URL scheme is accepted, not just http(s): the Windsurf desktop // app's sign-in callback is `windsurf://codeium.windsurf?access_token=…` (F4). // Testing only ^https?:// returned that whole 88-char URL as the "token", which // then failed RegisterUser upstream. const ABSOLUTE_URL_RE = /^[a-z][a-z0-9+.\-]*:\/\//i; export function extractOAuthToken(input) { const raw = String(input || '').trim(); if (!raw) return ''; const pick = (params) => params.get('access_token') || params.get('token') || params.get('auth_token') || ''; if (ABSOLUTE_URL_RE.test(raw)) { try { const u = new URL(raw); const fromQuery = pick(u.searchParams); if (fromQuery) return fromQuery.trim(); // token may live in the #fragment (implicit flow): #access_token=...&... const frag = u.hash.startsWith('#') ? u.hash.slice(1) : u.hash; const fragParams = frag ? new URLSearchParams(frag) : null; if (fragParams) { const fromHash = pick(fragParams); if (fromHash) return fromHash.trim(); } // No token present — before giving up, distinguish an explicit upstream // error or an intermediate callback so the caller can explain what to do. const upstreamErr = u.searchParams.get('error') || (fragParams && fragParams.get('error')) || ''; if (upstreamErr) { const desc = u.searchParams.get('error_description') || (fragParams && fragParams.get('error_description')) || ''; const e = new Error('ERR_OAUTH_UPSTREAM:' + upstreamErr + (desc ? ':' + desc : '')); e.code = 'ERR_OAUTH_UPSTREAM'; throw e; } if (u.searchParams.get('code') && /\/auth\/[^/]+\/callback/i.test(u.pathname)) { const e = new Error('ERR_INTERMEDIATE_CALLBACK'); e.code = 'ERR_INTERMEDIATE_CALLBACK'; throw e; } } catch (e) { // Propagate our own diagnostic ERR_* codes; swallow URL-parse failures // (malformed input) and fall through to bare-token handling. if (e && typeof e.message === 'string' && e.message.startsWith('ERR_')) throw e; } return ''; } // Bare paste: accept a raw token (session/auth token), reject anything with // whitespace (that's not a token). if (/\s/.test(raw)) return ''; return raw; } // Decide whether to persist a login password into the encrypted cred store so a // dead session can auto-relogin. Storing a user's password is privacy-sensitive, // so on a PUBLIC bind it requires BOTH an operator opt-in env AND a per-request // opt-in (the dashboard checkbox). The "local" fast-path requires the request to // ALSO come from a trusted loopback client — not just a loopback BIND host. Behind a // reverse proxy (openresty → app on 127.0.0.1) isLocalBindHost() is always true, // so checking bind alone would let any authenticated REMOTE user silently // persist their plaintext password, defeating the DEVIN_CONNECT_ALLOW_REMOTE_CRED_STORE // gate. Mirror the /accounts/import-local double gate. Store is still a no-op // unless DEVIN_CONNECT_CRED_KEY is set. function credStoreGateOpen(wantStore, req) { if (wantStore !== true) return false; // Consistency with v2.0.55 AUTH-1 and checkAuth: the proxy is not the client. const remote = dashboardClientIp(req); if (isLocalBindHost() && isLoopbackAddress(remote)) return true; return process.env.DEVIN_CONNECT_ALLOW_REMOTE_CRED_STORE === '1'; } async function processWindsurfLogin({ email, password, loginProxy, autoAdd, storeCredential: wantStore, req, accountId = null }) { if (!email || !password) { const err = new Error('ERR_EMAIL_PASSWORD_REQUIRED'); err.statusCode = 400; err.code = 'ERR_EMAIL_PASSWORD_REQUIRED'; throw err; } // Use provided proxy, or global proxy const proxy = loginProxy?.host ? loginProxy : getProxyConfig().global; const result = await windsurfLogin(email, password, proxy); return finalizeWindsurfLogin(result, { password, loginProxy, autoAdd, storeCredential: wantStore, req, accountId }); } // Shared tail of every successful login (password, email code, pasted token): // add the account to the pool — or, when `accountId` is set, REPLACE that // account's token — persist the per-account proxy, optionally persist the // password for auto-relogin, and shape the response. `password` is null for the // email-code flow — there is nothing to store. // // `accountId` is the re-auth binding (SessionReauth): the operator proved // control of an existing account by logging in, so the fresh token lands on // that row. Adding it again would create a duplicate for the same person and // leave the dead one in the list. async function finalizeWindsurfLogin(result, { password, loginProxy, autoAdd, storeCredential: wantStore, req, accountId = null }) { const email = result.email || ''; let account = null; let reauthenticated = false; // Whether the password the caller asked to store actually landed. null = not // asked / store unavailable, true = encrypted and persisted, false = the save // failed (busy store, missing key, record loss) and auto-relogin stays disarmed. let credentialStored = null; if (accountId) { const existing = getAccountInternal(accountId); if (!existing) throw Object.assign(new Error('ERR_ACCOUNT_NOT_FOUND'), { code: 'ERR_ACCOUNT_NOT_FOUND', statusCode: 404 }); if (!result.apiKey) throw Object.assign(new Error('ERR_LOGIN_FAILED'), { code: 'ERR_LOGIN_FAILED', statusCode: 502 }); // V-24 (audit A23): a bound login REPLACES this row's credential, so the // login must prove it belongs to this row's identity. This runs before // markAccountReauthenticated() — a token for someone else must not rotate // the key, clear error/ban state, or flip `reauth` back to `active`. assertLoginIdentity(result, existing.email); // A successful login IS the proof that the new key works, so a same-key // re-login is legitimate (`allowSameKey`). The refusal exists for the // operator asserting an UNCHANGED key without logging in. markAccountReauthenticated(accountId, { apiKey: result.apiKey, via: result.viaEmailCode ? 'email_code' : 'password', allowSameKey: true, }); account = existing; reauthenticated = true; } else if (autoAdd !== false) { // `result.name` is upstream-supplied: sanitize (never assert) so a hostile // login response cannot fail the whole login via the caller-facing label // boundary inside addAccountByKey. account = addAccountByKey(result.apiKey, sanitizeLabelText(result.name) || email); } if (account) { // Persist refresh token via the setter so it survives restart and // the background Firebase-renewal loop can find it. if (result.refreshToken) { setAccountTokens(account.id, { refreshToken: result.refreshToken, idToken: result.idToken }); } // Persist the per-account proxy we used for login so chat requests // also egress through the same IP, then warm up a matching LS. if (loginProxy?.host) setAccountProxy(account.id, loginProxy); scheduleAccountWarmup(account.id); // Gap A+B: persist the password (encrypted) so a dead session_id can // auto-relogin (reLoginAccount). Gated by credStoreGateOpen — on a public // bind this needs both DEVIN_CONNECT_ALLOW_REMOTE_CRED_STORE=1 and the // request's storeCredential:true. Best-effort: a store failure must never // break the login that already succeeded. Plaintext password is never // logged (only the masked email). if (password && credStoreGateOpen(wantStore, req)) { // True only after a credential was actually persisted. It used to start // true, so a store that is disabled (no DEVIN_CONNECT_CRED_KEY) reported // "stored" without ever calling the store (2026-09-17 review, F3). credentialStored = false; try { const { storeCredential, isCredStoreEnabled } = await import('../devin-connect-credentials.js'); if (isCredStoreEnabled()) { credentialStored = storeCredential(email, password) === true; // Remember WHICH address the record is keyed by: the row can be // renamed, and re-login / removal must still find the secret. if (credentialStored) setAccountCredentialEmail(account.id, email); if (credentialStored) log.info(`Credential stored for auto-relogin: ${maskEmail(email)}`); else log.warn(`Credential NOT stored for ${maskEmail(email)} — auto-relogin stays disarmed for this account`); } else { log.warn(`Credential NOT stored for ${maskEmail(email)} — credential storage is disabled (no DEVIN_CONNECT_CRED_KEY); auto-relogin stays disarmed for this account`); } } catch (e) { // A failed save used to be invisible in the response: the user asked for // storage and got success back. Surface it instead of only warning. credentialStored = false; log.warn(`Could not persist credential for re-login: ${e.code || e.message}`); } } } return { success: true, // When autoAdd:false, the caller is doing a one-time login to retrieve the // upstream key without storing it (e.g. external tooling that wants the // raw key) — they need the full apiKey. When the key is in the pool the // response only echoes a masked form (the dashboard never needs the raw key // in the listing path; the explicit reveal-key endpoint covers the rare // per-account export case). A bound re-auth always masks: the row already // holds the key. ...(autoAdd === false && !reauthenticated ? { apiKey: result.apiKey } : { apiKey_masked: maskApiKey(result.apiKey) }), name: result.name, email: result.email, apiServerUrl: result.apiServerUrl, account: account ? { id: account.id, email: account.email, status: account.status } : null, // How the session was obtained, so the UI can show it instead of making // the operator guess which path ran. method: result.viaEmailCode ? 'email_code' : 'password', // The login replaced an existing account's token instead of adding a row. ...(reauthenticated ? { reauthenticated: true } : {}), ...(result.viaHost ? { viaHost: result.viaHost } : {}), ...(wantStore ? { credentialStored: credentialStored === true } : {}), }; } /** * Install a pasted token on an EXISTING account (SessionReauth). * * The only accepted shape is a `devin-session-token$…` session token — that is * what the upstream login mints, and it is the only thing this pool routes with. * An `auth1_` value is a PostAuth intermediate and is rejected with its own * code; anything else is not a pool credential. * * Errors are thrown with `statusCode` so both routes answer with the same * contract: 404 unknown id, 409 wrong state, 400 unusable token. */ function applyReauthToken(id, rawToken, { via = 'token' } = {}) { const account = getAccountInternal(id); if (!account) throw Object.assign(new Error('ERR_ACCOUNT_NOT_FOUND'), { code: 'ERR_ACCOUNT_NOT_FOUND', statusCode: 404 }); if (account.status === 'disabled' || account.status === 'banned') { throw Object.assign(new Error('ERR_REAUTH_NOT_APPLICABLE'), { code: 'ERR_REAUTH_NOT_APPLICABLE', statusCode: 409 }); } const token = extractOAuthToken(String(rawToken ?? '')); if (!token) throw Object.assign(new Error('ERR_NO_TOKEN_IN_INPUT'), { code: 'ERR_NO_TOKEN_IN_INPUT', statusCode: 400 }); const kind = classifyToken(token); if (kind === 'auth1') throw Object.assign(new Error('ERR_AUTH1_NOT_A_POOL_KEY'), { code: 'ERR_AUTH1_NOT_A_POOL_KEY', statusCode: 400 }); if (kind !== 'session') throw Object.assign(new Error('ERR_REAUTH_TOKEN_INVALID'), { code: 'ERR_REAUTH_TOKEN_INVALID', statusCode: 400 }); // A PASTED token is not proof of anything — unlike a completed login, the // operator can paste the very token upstream just retired. Installing the // same key on a reauth account is refused (409) so the state cannot be // "cleared" without a new credential; a different key is a real rotation. markAccountReauthenticated(id, { apiKey: token, via }); return { success: true, reauthenticated: true, account: { id: account.id, email: account.email, method: account.method, status: account.status }, apiKey_masked: maskApiKey(account.apiKey), }; } // Marker set on the IncomingMessage by preflightDashboardRequest(). A Symbol // (not a string key) so a handler-level fixture cannot forge it and an HTTP // client can never set it: request headers live under req.headers. const DASHBOARD_PREFLIGHT_PASSED = Symbol('dashboardPreflightPassed'); /** * Host/mutation/lockout/auth gate shared by `preflightDashboardRequest` and * `handleDashboardApi`. Returns true when the request may proceed; otherwise * writes the 403/415/429/401 response itself and returns false. * * Extracted from handleDashboardApi unchanged (S03/G05) so server.js can run * it BEFORE reading a /dashboard/api/* body: an unauthenticated mutation must * not be able to make the process buffer and JSON-parse up to the 10 MiB body * cap. Keeping one implementation is what keeps the two call sites from * drifting apart. */ function runDashboardRequestGate(method, subpath, req, res) { // A04: reject cross-site/simple-form mutations before auth counters, body // handling, or any route side effect. This is deliberately before the // lockout so a hostile page cannot spend a victim's auth attempts. if (!enforceDashboardMutationGate(method, subpath, req, res)) return false; // v2.0.56: brute-force lockout — apply BEFORE the auth check so the // password comparison itself can't be used as an oracle once the IP is // banned. /auth route is exempt (unauthenticated probe used by the UI // to learn whether auth is required) but still feeds the lockout when // it serves as a credential-verification endpoint. const clientIp = dashboardClientIp(req); const lock = checkLockout(clientIp); if (lock.blocked) { res.setHeader?.('Retry-After', String(Math.ceil(lock.retryAfterMs / 1000))); json(res, 429, { error: `Too many failed attempts. IP banned for ${Math.ceil(lock.retryAfterMs / 1000)}s.`, retryAfterMs: lock.retryAfterMs, }); return false; } // Auth check (except for auth verification endpoint) if (subpath !== '/auth' && !checkAuth(req)) { // Only count this toward the brute-force lockout when a NON-EMPTY password // was actually submitted — a wrong guess. A missing/empty header is the // normal pre-login state: the dashboard fires ~a dozen authed API calls // (overview/stats/accounts/…) on page load before the user has typed // anything, and counting each empty-password 401 as a "failed attempt" // banned the IP the instant the page opened. Mirrors the /auth probe below. if (String(req.headers['x-dashboard-password'] || '')) failedAuthAttempt(clientIp); json(res, 401, { error: 'Unauthorized. Set X-Dashboard-Password header.' }); return false; } if (subpath !== '/auth') successfulAuthAttempt(clientIp); return true; } /** * Pre-body gate for /dashboard/api/*. * * server.js calls this BEFORE readBody() so an unauthenticated mutation is * rejected at the Host/mutation/lockout/auth boundary without touching the * request stream (S03/G05: the body read used to run first, letting an * anonymous caller force up to MAX_BODY_SIZE of buffering + JSON parsing per * request). Reuses the same gate handleDashboardApi runs. * * Returns true when the caller may proceed (and, for a mutation, read the * body); false when the rejection response was already written. OPTIONS * carries no body and is answered by handleDashboardApi's early 204 exactly * as before, so it passes through untouched. */ export function preflightDashboardRequest(method, subpath, req, res) { if (res) res._dashboardCorsOrigin = resolveCorsOrigin(req); if (method === 'OPTIONS') return true; if (!runDashboardRequestGate(method, subpath, req, res)) return false; if (req && typeof req === 'object') req[DASHBOARD_PREFLIGHT_PASSED] = true; return true; } /** * Handle all /dashboard/api/* requests. */ export async function handleDashboardApi(method, subpath, body, req, res) { // Resolve the CORS origin once (allowlist-gated, default none) so every // json() response — including the OPTIONS preflight below — reflects the // same decision instead of a blanket `*`. if (res) res._dashboardCorsOrigin = resolveCorsOrigin(req); if (method === 'OPTIONS') return json(res, 204, ''); // server.js already ran preflightDashboardRequest() before reading the body; // running the gate again would re-validate the Host and double-count the // successful-auth reset. Direct callers (unit tests, in-process handlers) // never set the marker and still get the full gate here, exactly as before. if (!(req && req[DASHBOARD_PREFLIGHT_PASSED] === true)) { if (!runDashboardRequestGate(method, subpath, req, res)) return; } // ─── Auth ───────────────────────────────────────────── if (subpath === '/auth') { // The gate that used to declare clientIp now lives in // runDashboardRequestGate (and may have already run as the server.js // preflight). Re-resolve the same pure value for the probe's counter // updates — no lockout/auth side effect is repeated. const clientIp = dashboardClientIp(req); const storedPw = getEffectiveDashboardPasswordStored(); const effectiveApiKey = getEffectiveApiKey(); const hasSecret = !!(storedPw || effectiveApiKey); if (hasSecret) { const ok = checkAuth(req); // /auth is the credential-probe endpoint dashboards call before // showing the rest of the UI — count failures here so a brute-force // script doesn't get unlimited attempts via /auth alone. if (ok) successfulAuthAttempt(clientIp); else if (req.headers['x-dashboard-password']) failedAuthAttempt(clientIp); return json(res, 200, { required: true, valid: ok }); } // No secret configured. AUTH-1: the open-local convenience is now // opt-in — only report the dashboard as open (required:false) on a // localhost bind when the operator set DASHBOARD_ALLOW_NO_AUTH=1, which // mirrors checkAuth(). Otherwise (default, and on public binds) checkAuth // fails closed, so the UI must show it as required-but-unconfigurable and // prompt the operator to set DASHBOARD_PASSWORD or API_KEY. if (isLocalBindHost() && process.env.DASHBOARD_ALLOW_NO_AUTH === '1') { return json(res, 200, { required: false }); } return json(res, 200, { required: true, valid: false, locked: true }); } // ─── Overview ───────────────────────────────────────── if (subpath === '/overview' && method === 'GET') { const stats = getStats(); return json(res, 200, { uptime: process.uptime(), startedAt: stats.startedAt, accounts: getAccountCount(), authenticated: isAuthenticated(), langServer: getLsStatus(), totalRequests: stats.totalRequests, successCount: stats.successCount, errorCount: stats.errorCount, successRate: stats.totalRequests > 0 ? ((stats.successCount / stats.totalRequests) * 100).toFixed(1) : '0.0', cache: cacheStats(), nativeBridge: getNativeBridgeStats(), nativeBridgeConfig: getNativeBridgeConfigStatus(), // Gate 1 (design §7.4) reads `admit` and `serve` from this block for the // two-week measurement. `enabled` separates "switch off" from "switch on // with no traffic"; the counters are process-lifetime and reset on // restart, so a scrape must accumulate deltas (docs/DETACH-ROLLOUT.md). detach: { enabled: cascadeDetachEnabled(), ...detachedRegistry.stats(), }, poolBalance: (() => { const s = getAccountListStats(); return { total: s.balanceTotal, accounts: s.balanceAccounts }; })(), }); } // Lightweight request ring for the dashboard's request-level view. Rows are // metadata-only (no headers/prompt/upstream body); the caller is a short hash. if (subpath === '/requests' && method === 'GET') { const u = new URL(req?.url || '/', 'http://localhost'); const filter = {}; for (const key of ['model', 'status', 'class']) { const value = u.searchParams.get(key); if (value !== null && value !== '') filter[key] = value; } const page = listRequests({ cursor: u.searchParams.get('cursor'), limit: u.searchParams.get('limit'), filter, }); return json(res, 200, { ...page, ring: requestRingStats() }); } // Lightweight pool snapshot for the dashboard's pool-event notifier and the // cooldown card. Reads the in-memory pool only (zero upstream cost) and returns // the per-account state flags the client diffs into transition toasts // (healthy ↔ cooling ↔ capacity ↔ quota ↔ disabled). `ref` is a hash — never // the token prefix — and `upstream` is a cache-only billing overlay, so the // 20 s poll never triggers an upstream call. if (subpath === '/pool-events' && method === 'GET') { const now = Date.now(); const accounts = getAccountList({ view: 'summary' }).map((a) => { const cooldown = cooldownState({ rateLimitedUntil: a.rateLimitedUntil, quotaResetAt: a.quotaResetAt, capacityThrottled: a.capacityThrottled, now, }); const token = getAccountInternal(a.id)?.apiKey || ''; return { id: a.id, ref: accountRef(a.id), status: a.status, state: accountState({ status: a.status, rateLimited: cooldown.rateLimited, capacityThrottled: cooldown.capacityThrottled, quotaCooled: cooldown.quotaCooled, }), tier: a.tier || 'unknown', balance: a.balance ?? null, errorCount: a.errorCount || 0, rateLimited: cooldown.rateLimited, capacityThrottled: cooldown.capacityThrottled, quotaCooled: cooldown.quotaCooled, cooldownUntil: cooldown.cooldownUntil, cooldownReason: cooldown.cooldownReason, cooldownRemainingMs: cooldown.cooldownRemainingMs, upstream: peekBillingFacts({ accountId: a.id, token }), }; }); return json(res, 200, { accounts, ts: now }, { 'Cache-Control': 'no-store' }); } // ─── Experimental features ──────────────────────────── if (subpath === '/experimental' && method === 'GET') { return json(res, 200, { flags: getExperimental(), tunables: getTunables(), conversationPool: convPoolStats() }); } if (subpath === '/experimental' && method === 'PUT') { // Numeric tunables (e.g. droughtThresholdPercent) ride the same endpoint // but must NOT go through setExperimental, which coerces every key to bool. const patch = { ...(body || {}) }; let tunables; if (patch.tunables && typeof patch.tunables === 'object') { tunables = setTunables(patch.tunables); } delete patch.tunables; const flags = setExperimental(patch); // Dropping the toggle should also drop any live entries so nothing // resumes against a disabled feature on the next request. if (!flags.cascadeConversationReuse) convPoolClear(); return json(res, 200, { success: true, flags, tunables: tunables ?? getTunables() }); } if (subpath === '/experimental/conversation-pool' && method === 'DELETE') { const n = convPoolClear(); return json(res, 200, { success: true, cleared: n }); } // ─── Cline compatibility layer status ──────────────────── // enabled = the master experimental toggle (detection path); stats.argRepairs = // how many tool-call arguments the compat layer has normalized since boot (both // the /v1/cline/* namespace and the detection path feed this counter). The // dedicated namespace works regardless of the toggle, so a nonzero counter with // enabled:false just means a partner is hitting /v1/cline/*. if (subpath === '/cline-compat' && method === 'GET') { return json(res, 200, { enabled: !!getExperimental().clineCompat, stats: getClineCompatStats() }); } // ─── Claude Code compatibility layer status ────────────── // enabled = the master experimental toggle (detection path). stats counts what // the CC layer has done since boot: schemaNormalized (top-level combinators // stripped off tool schemas) and identityNeutralized (reserved). The dedicated // /v1/cc/* namespace works regardless of the toggle, so nonzero counts with // enabled:false just mean a Claude Code user is hitting /v1/cc/*. if (subpath === '/cc-compat' && method === 'GET') { return json(res, 200, { enabled: !!getExperimental().ccCompat, stats: getCcCompatStats() }); } // ─── System prompts (tool reinforcement, communication) ── if (subpath === '/system-prompts' && method === 'GET') { return json(res, 200, { prompts: getSystemPrompts() }); } if (subpath === '/system-prompts' && method === 'PUT') { const prompts = setSystemPrompts(body || {}); return json(res, 200, { success: true, prompts }); } if (subpath.match(/^\/system-prompts\/[^/]+$/) && method === 'DELETE') { const key = subpath.split('/').pop(); const prompts = resetSystemPrompt(key); return json(res, 200, { success: true, prompts }); } // ─── Runtime backend switches (hot-flippable) + security status (read-only) ── // The six routing switches are migrated to runtime-config: an operator can // flip them here without a redeploy. GET reports each switch's EFFECTIVE value // plus its SOURCE ('config' when an override is set, else 'env' — which also // covers the historical default). The two credential-store env switches stay // READ-ONLY: they are a security boundary (a writable remote-cred-store toggle // would let a compromised dashboard self-authorize password storage), so they // are surfaced as booleans but never accepted on PUT. Secrets are reported as // a presence flag only, never the value. if (subpath === '/runtime-env-status' && method === 'GET') { const on = (v) => String(v || '').trim() === '1'; const switches = runtimeSwitchRows(); return json(res, 200, { // Flat effective values (back-compat with the prior read-only card shape). devinConnect: switches.devinConnect.value, devinOnly: switches.devinOnly.value, devinCliMode: switches.devinCliMode.value, allowClientTools: switches.allowClientTools.value, loginHostFallback: switches.loginHostFallback.value, autoRelogin: switches.autoRelogin.value, // Per-switch detail (effective value + override + source) for the toggle UI. switches, // Security boundary — env-only, read-only, never writable from here. remoteCredStore: on(process.env.DEVIN_CONNECT_ALLOW_REMOTE_CRED_STORE), credStoreEnabled: !!(process.env.DEVIN_CONNECT_CRED_KEY || '').trim(), }); } // PUT — flip one or more backend switches. Whitelisted keys only (the setter // rejects everything else, including remoteCredStore/credStoreEnabled). A key // set to null CLEARS the override (falls back to env). devinCliMode accepts // only 'acp'|'print'; the rest are booleans. if (subpath === '/runtime-env-status' && method === 'PUT') { setBackendSwitches(body || {}); return json(res, 200, { success: true, switches: runtimeSwitchRows() }); } // ─── Circuit-breaker / rate-limit tunables (hot-flippable) ── const breakerPayload = () => { const eff = getBreakerTunables(), ov = getBreakerOverrides(), knobs = {}; for (const k of Object.keys(eff)) knobs[k] = { value: eff[k], override: ov[k], source: ov[k] === null ? 'env' : 'config' }; return { knobs }; }; if (subpath === '/breaker-config' && method === 'GET') return json(res, 200, breakerPayload()); if (subpath === '/breaker-config' && method === 'PUT') { setBreakerTunables(body || {}); return json(res, 200, { success: true, ...breakerPayload() }); } // ─── Quota / on-demand spend tunables (global defaults, hot-flippable) ── // spendOnDemand + onDemandReserveUsd are the pool-wide defaults; each account // can still override them via PATCH /accounts/:id. const quotaPayload = () => { const eff = getQuotaTunables(), ov = getQuotaOverrides(), knobs = {}; for (const k of Object.keys(eff)) knobs[k] = { value: eff[k], override: ov[k], source: ov[k] === null ? 'env' : 'config' }; return { knobs }; }; if (subpath === '/quota-config' && method === 'GET') return json(res, 200, quotaPayload()); if (subpath === '/quota-config' && method === 'PUT') { setQuotaTunables(body || {}); return json(res, 200, { success: true, ...quotaPayload() }); } // ─── Proxy test — try an HTTP CONNECT through the given proxy ── if (subpath === '/test-proxy' && method === 'POST') { const { host, port, username, password, type = 'http' } = body || {}; if (!host || !port) return json(res, 400, { ok: false, error: 'ERR_HOST_PORT_REQUIRED' }); const startTime = Date.now(); try { const result = await testProxy({ host, port: Number(port), username, password, type }); return json(res, 200, { ok: true, ...result, latencyMs: Date.now() - startTime }); } catch (err) { return json(res, 200, { ok: false, error: err.message, latencyMs: Date.now() - startTime }); } } // ─── Self-update: pull latest code + request supervisor restart ────── if (subpath === '/self-update/check' && method === 'GET') { // D6: a pending journal is confirmed only when the RUNNING process reports // the expected commit — the same identity /health publishes. Confirmation // is what clears the rollback point; anything else keeps the retry // evidence and reports the pending/failed state. const selfUpdate = reconcileSelfUpdateJournal(); // Docker updates record the previous image before the deployer starts and // reconcile the deployer's exit code after the restart. No journal means // no Docker call at all. let dockerSelfUpdate = null; try { dockerSelfUpdate = await reconcileDockerSelfUpdateState(); } catch (err) { log.warn(`self-update: docker state reconcile failed: ${err.message}`); } const withJournal = (payload) => ({ ...payload, ...(selfUpdate ? { selfUpdate } : {}), ...(dockerSelfUpdate ? { dockerSelfUpdate } : {}), }); try { const info = await gitStatus(); if (info.updateError) { return json(res, 200, withJournal({ ok: false, mode: 'git', ...info, error: info.updateError, })); } return json(res, 200, withJournal({ ok: true, mode: 'git', ...info })); } catch (err) { if (isSelfUpdateUnavailableError(err)) { // Git path is unavailable (most often docker). Fall back to // docker self-update via /var/run/docker.sock if the user // mounted it into the container; otherwise report the same // "manual command" hint we did before. const docker = await detectDockerSelfUpdateForRequest(); if (docker.available) { return json(res, 200, withJournal({ ok: true, mode: 'docker', image: docker.image, project: docker.project, workingDir: docker.workingDir, })); } return json(res, 200, withJournal({ ok: false, available: false, reason: err.reason, error: err.code, dockerReason: docker.reason, dockerDetail: docker.detail, })); } return json(res, 200, withJournal({ ok: false, error: err.message })); } } if (subpath === '/self-update/rollback' && method === 'POST') { let releaseMutation; try { releaseMutation = acquireSelfUpdateMutation(); } catch (err) { return json(res, 200, { ok: false, error: err.code || 'ERR_UPDATE_LOCK_FAILED', message: err.message }); } if (!releaseMutation) { return json(res, 200, { ok: false, error: SELF_UPDATE_IN_PROGRESS, message: 'Another update or rollback is already in progress.', }); } let mutationLockHandedOff = false; // 失败回滚:reset 到最近一次 /self-update 记录的 before commit,重启。 // 与 forceReset 同级保护(AUTH-1):本地领先或脏树时同样拒绝。 try { try { // A previous rollback that is already confirmed by the running commit // has had its point cleared; reconcile first so this attempt reports // "no rollback point" instead of resetting to the same commit again. reconcileSelfUpdateJournal(); const isCommitSha = value => /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value); const { readFile } = await import('node:fs/promises'); const paths = rollbackPointPaths(); const readPoint = async (path) => { try { return JSON.parse(await readFile(path, 'utf8')); } catch { return {}; } }; let beforeJson = await readPoint(paths.final); const pendingJson = await readPoint(paths.pending); let currentHead = ''; if (isCommitSha(pendingJson?.commit) && isCommitSha(pendingJson?.after)) { currentHead = (await runGit(['rev-parse', 'HEAD'])).trim(); if (currentHead === pendingJson.after) { // A crash can land after reset/merge but before pending→final rename. // The durable pending record is authoritative when its `after` SHA // matches current HEAD; promote best-effort and allow rollback now. beforeJson = pendingJson; try { promotePendingRollbackPoint(); } catch (err) { log.warn(err?.renamePublished ? `self-update: rollback point final file is visible, but its directory fsync failed during pending recovery: ${err.message}` : `self-update: pending rollback point is valid but promotion failed; pending remains available: ${err.message}`); } } else { // HEAD never reached the pending target, so this is an abandoned // pre-mutation record. Preserve the previous final rollback point. discardPendingRollbackPoint(); } } else if (Object.keys(pendingJson || {}).length) { discardPendingRollbackPoint(); } const beforeCommit = beforeJson?.commit; const afterCommit = beforeJson?.after; if (!beforeCommit) { return json(res, 200, { ok: false, error: 'ERR_NO_ROLLBACK_POINT', message: 'No previous update recorded.' }); } if (!isCommitSha(beforeCommit) || !isCommitSha(afterCommit)) { return json(res, 200, { ok: false, error: 'ERR_INVALID_ROLLBACK_POINT', message: 'Rollback metadata must contain both the pre-update and post-update commit SHAs; refusing an unsafe legacy rollback point.', }); } // M2: 校验当前 HEAD 仍是更新后的 commit(before.json 记录了 after)—— // 若操作者更新后又提交了新代码,reset 会抹掉它,拒绝回滚。 currentHead ||= (await runGit(['rev-parse', 'HEAD'])).trim(); if (currentHead !== afterCommit) { return json(res, 200, { ok: false, error: 'ERR_HEAD_MOVED', message: 'HEAD moved since the update — refusing rollback that would discard newer commits.' }); } // Supervisor detection is read-only and must happen before stash/reset. // A rejected rollback must leave the operator's working tree untouched. const sup = detectSelfUpdateSupervisor(); if (!sup.supervised) { return json(res, 200, { ok: false, error: 'ERR_NO_SUPERVISOR', message: 'No process supervisor detected. Rollback would stop the gateway without relaunch.' }); } const ignoredConflicts = await findIgnoredTargetPathConflicts(beforeCommit); if (ignoredConflicts.length) { return json(res, 200, { ok: false, error: 'ERR_IGNORED_PATH_CONFLICT', message: 'Rollback would overwrite ignored runtime data. Move or back up the listed paths manually before retrying.', conflictPaths: ignoredConflicts.slice(0, 20), }); } // Rollback uses reset --hard, which can delete an untracked file or // directory that obstructs a path tracked by the target commit. Unlike // ordinary update checks, this destructive path must see untracked files // and require the same explicit force+stash protection. const dirty = (await runGit(['status', '--porcelain', '--untracked-files=all'])).trim(); // D6: record the rollback intent durably BEFORE any mutation (stash or // reset). If the process dies between here and the restart, the journal // plus the untouched rollback point still describe what was attempted // and the rollback remains retryable. A journal write failure aborts // while the worktree is still unmodified. try { writeSelfUpdateJournal({ version: 1, operation: 'rollback', state: 'pending-restart', phase: 'pre-reset', expectedCommit: beforeCommit, afterCommit, rollbackPointRetained: true, requestedAt: Date.now(), }); } catch (journalErr) { return json(res, 200, { ok: false, error: 'ERR_UPDATE_JOURNAL_WRITE', message: `Could not durably record the rollback intent; nothing was changed: ${journalErr.message}`, }); } const markJournalFailed = (reason, extra = {}) => { try { updateSelfUpdateJournal({ state: 'failed', reason, ...extra }); } catch (journalErr) { log.warn(`self-update rollback: could not record ${reason}: ${journalErr.message}`); } }; let protectiveStashOid = ''; if (dirty) { const allowForce = !!(body && body.forceReset); if (!allowForce) { return json(res, 200, { ok: false, dirty: true, error: 'ERR_UNCOMMITTED_CHANGES', dirtyFiles: dirty.split('\n').slice(0, 20) }); } try { protectiveStashOid = await createProtectiveStash('rollback-forceReset'); } catch (err) { markJournalFailed('stash-failed', { error: err.message }); return json(res, 200, { ok: false, error: 'ERR_STASH_FAILED', message: `Could not preserve local changes; rollback was not attempted: ${err.message}`, }); } } let resetError = null; try { await runGit(['reset', '--hard', beforeCommit]); } catch (err) { resetError = err; } let rolledBackHead = ''; let trackedAfter = ''; try { rolledBackHead = (await runGit(['rev-parse', 'HEAD'])).trim(); trackedAfter = (await runGit(['status', '--porcelain', '--untracked-files=no', '--ignore-submodules=none'])).trim(); } catch (inspectErr) { markJournalFailed('state-unknown', { error: inspectErr.message }); return json(res, 200, { ok: false, error: 'ERR_ROLLBACK_STATE_UNKNOWN', message: `Rollback state could not be verified; the rollback point and any protective stash were preserved: ${inspectErr.message}`, stashed: !!protectiveStashOid, }); } if (rolledBackHead !== beforeCommit || trackedAfter) { let restored = false; const unchangedAndClean = rolledBackHead === currentHead && !trackedAfter; if (protectiveStashOid && unchangedAndClean) { try { await restoreProtectiveStash(protectiveStashOid); restored = true; } catch (restoreErr) { log.warn(`self-update rollback: reset failed and exact protective stash ${protectiveStashOid.slice(0, 12)} could not be applied: ${restoreErr.message}`); } } markJournalFailed('reset-not-verified', { actual: rolledBackHead, trackedDirty: !!trackedAfter, }); return json(res, 200, { ok: false, error: trackedAfter ? 'ERR_ROLLBACK_PARTIAL' : resetError ? (unchangedAndClean ? 'ERR_GIT_ROLLBACK_FAILED' : 'ERR_ROLLBACK_PARTIAL') : 'ERR_ROLLBACK_TARGET_MISMATCH', message: resetError ? `Git could not reset cleanly to the recorded rollback commit: ${resetError.message}` : `Git reported success but rollback state is HEAD ${rolledBackHead.slice(0, 12)} with ${trackedAfter ? 'tracked changes' : 'a different commit'}.`, expected: beforeCommit, actual: rolledBackHead, dirtyFiles: trackedAfter ? trackedAfter.split('\n').slice(0, 20) : undefined, stashed: !!protectiveStashOid, stashRestored: restored || undefined, }); } if (resetError) { log.warn(`self-update rollback: Git returned an error after reaching the verified rollback state: ${resetError.message}`); } // D6: the rollback point is NOT deleted here. It is retry evidence // until the restarted process proves it serves beforeCommit; the // journal reconcile step clears it then. Deleting it before the // restart made a scheduler failure indistinguishable from a verified // healthy rollback. try { updateSelfUpdateJournal({ phase: 'reset-verified' }); } catch (err) { log.warn(`self-update rollback: could not record the verified reset: ${err.message}`); } const restartScheduled = scheduleSelfUpdateRestart(() => { return Promise.resolve(gracefulRestart({ reason: 'self-update-rollback', drainMs: 10000, server: getActiveServer(), abortSse: abortActiveSse, stopLs: () => stopLanguageServerAndWait({ perProcessTimeoutMs: 1500 }), exitCode: selfUpdateRestartExitCode(), })).catch((err) => { // The process may survive a failed restart; leave durable evidence // instead of an unhandled rejection, and keep the rollback point. try { updateSelfUpdateJournal({ state: 'restart-failed', restartScheduled: true, error: String(err?.message || err), }); } catch (journalErr) { log.warn(`self-update rollback: could not record the restart failure: ${journalErr.message}`); } throw err; }); }, releaseMutation); mutationLockHandedOff = restartScheduled === true; if (mutationLockHandedOff) { try { updateSelfUpdateJournal({ state: 'pending-restart', phase: 'restart-scheduled', restartScheduled: true }); } catch (err) { log.warn(`self-update rollback: could not record the scheduled restart: ${err.message}`); } return json(res, 200, { ok: true, state: 'pending-restart', verified: false, restartScheduled: true, rollbackPointRetained: true, expectedCommit: beforeCommit, rolledBackTo: beforeCommit.slice(0, 7), }); } // The scheduler declined: the reset is on disk but nothing will // relaunch the gateway. That is NOT a successful rollback — say so, // and leave the point plus journal so it can be retried. try { updateSelfUpdateJournal({ state: 'restart-not-scheduled', restartScheduled: false }); } catch (err) { log.warn(`self-update rollback: could not record the unscheduled restart: ${err.message}`); } return json(res, 200, { ok: false, error: 'ERR_RESTART_NOT_SCHEDULED', message: 'The rollback reset was applied, but no restart could be scheduled; the running process still serves the previous code. The rollback point was kept so this rollback can be retried.', state: 'restart-not-scheduled', verified: false, restartScheduled: false, rollbackPointRetained: true, expectedCommit: beforeCommit, rolledBackTo: beforeCommit.slice(0, 7), }); } catch (err) { if (isSelfUpdateUnavailableError(err)) return json(res, 200, { ok: false, error: err.code || 'ERR_GIT_UNAVAILABLE' }); return json(res, 200, { ok: false, error: err.code || err.message, message: err.code ? err.message : undefined }); } } finally { if (!mutationLockHandedOff) releaseMutation(); } } if (subpath === '/self-update' && method === 'POST') { let releaseMutation; try { releaseMutation = acquireSelfUpdateMutation(); } catch (err) { return json(res, 200, { ok: false, error: err.code || 'ERR_UPDATE_LOCK_FAILED', message: err.message }); } if (!releaseMutation) { return json(res, 200, { ok: false, error: SELF_UPDATE_IN_PROGRESS, message: 'Another update or rollback is already in progress.', }); } let mutationLockHandedOff = false; try { try { const before = await gitStatus(); reconcilePendingRollbackPoint(before.commitFull); // M1: resolve the update target before any write (including forceReset). // Normal OTA follows the newest RELEASE TAG, not origin/ HEAD: // release notes, generated assets, and next-release work commonly land // after a tag. Treating any such commit as ERR_UNRELEASED made the // published release itself impossible to install as soon as the first // post-tag commit landed. forceUpdate remains the explicit escape hatch // for operators who intentionally want the untagged branch head. const safeBranch = safeUpdateBranch(before.branch); // A failed fetch is not permission to operate on stale refs. The docker // fallback still activates when git itself is unavailable; every source // checkout otherwise fails closed until branch and tag refs are current. await runGit(['fetch', '--quiet', '--no-tags', 'origin', safeBranch]); const forceGate = !!(body && body.forceUpdate); const targetState = await releaseUpdateState(safeBranch, before.commitFull, { followUnreleased: forceGate, }); if (targetState.diverged) { return json(res, 200, { ok: false, error: 'ERR_DIVERGED', message: `Current HEAD and update target ${targetState.targetCommit.slice(0, 7)} have diverged; refusing a non-fast-forward update.`, }); } // Guard: working tree must be clean (ignoring untracked files like // accounts.json, stats.json, runtime-config.json which live in the // repo root but aren't checked in). If tracked files were edited, // surface a friendly error instead of a raw git failure. const dirty = (await runGit(['status', '--porcelain', '-uno'])).trim(); let resetTarget = before.commitFull; if (dirty) { const allowForce = !!(body && body.forceReset); if (!allowForce) { return json(res, 200, { ok: false, dirty: true, error: 'ERR_UNCOMMITTED_CHANGES', dirtyFiles: dirty.split('\n').slice(0, 20), }); } // AUTH-1: forceReset authorizes cleaning the working tree, not silently // deleting local commits. Failure to establish the ahead count is also // fail-closed: "unknown" must never be treated as zero before reset. let ahead; try { ahead = parseGitNonnegativeCount( await runGit(['rev-list', '--count', `origin/${safeBranch}..HEAD`]), `local commits ahead of origin/${safeBranch}`, ); } catch (err) { return json(res, 200, { ok: false, dirty: true, error: 'ERR_LOCAL_COMMIT_CHECK_FAILED', message: `Could not verify whether local commits would be lost; reset was not attempted: ${err.message}`, dirtyFiles: dirty.split('\n').slice(0, 20), }); } const allowHardReset = process.env.DASHBOARD_ALLOW_HARD_RESET === '1'; if (ahead > 0 && !allowHardReset) { return json(res, 200, { ok: false, dirty: true, unpushedCommits: ahead, error: 'ERR_UNPUSHED_COMMITS', message: `Refusing hard-reset: ${ahead} local commit(s) not on origin/${safeBranch} would be lost. Set DASHBOARD_ALLOW_HARD_RESET=1 to override.`, dirtyFiles: dirty.split('\n').slice(0, 20), }); } // A checkout can legitimately be past the latest release tag. Cleaning // its worktree must not downgrade it unless the separate hard-reset // opt-in explicitly authorizes dropping local commits. resetTarget = targetState.updateAvailable || (ahead > 0 && allowHardReset) ? targetState.targetCommit : before.commitFull; } const headTarget = dirty ? resetTarget : targetState.updateAvailable ? targetState.targetCommit : before.commitFull; const willChangeHead = headTarget !== before.commitFull; const willMutateWorktree = !!dirty || targetState.updateAvailable; if (willMutateWorktree) { const sup = detectSelfUpdateSupervisor(); if (!sup.supervised) { return json(res, 200, { ok: false, error: 'ERR_NO_SUPERVISOR', message: 'No process supervisor detected. Update was not attempted because the gateway could not relaunch itself afterward.', }); } } if (willMutateWorktree) { const ignoredConflicts = await findIgnoredTargetPathConflicts(headTarget); if (ignoredConflicts.length) { return json(res, 200, { ok: false, error: 'ERR_IGNORED_PATH_CONFLICT', message: 'The update would overwrite ignored runtime data. Move or back up the listed paths manually before retrying.', conflictPaths: ignoredConflicts.slice(0, 20), }); } } if (willChangeHead) { try { // Write a durable PENDING point before stash/reset/merge while keeping // the previous successful final point intact. It is promoted only // after Git proves HEAD reached the exact expected commit. prepareRollbackPoint({ commit: before.commitFull, after: headTarget, ts: Date.now() }); } catch (err) { return json(res, 200, { ok: false, error: 'ERR_ROLLBACK_POINT_WRITE', message: `Could not durably record the rollback point; update was not attempted: ${err.message}`, stashed: false, }); } } let protectiveStashOid = ''; if (dirty) { try { protectiveStashOid = await createProtectiveStash('self-update-forceReset'); } catch (err) { if (willChangeHead) discardPendingRollbackPoint(); return json(res, 200, { ok: false, error: 'ERR_STASH_FAILED', message: `Could not preserve local changes; reset was not attempted: ${err.message}`, stashed: false, }); } } // execFile can't do `2>&1`; runGit includes stderr in thrown errors and // returns stdout for successful commands, which is what the UI displays. let pull; let mutationError = null; try { pull = dirty ? await runGit(['reset', '--hard', resetTarget]).then(() => 'hard-reset applied') : targetState.updateAvailable ? await runGit(['merge', '--ff-only', targetState.targetCommit]) : `Already at latest published release ${targetState.latestTag || targetState.targetCommit.slice(0, 7)}`; } catch (err) { mutationError = err; pull = ''; } let actualHead = before.commitFull; let trackedAfter = ''; if (willMutateWorktree) { try { actualHead = (await runGit(['rev-parse', 'HEAD'])).trim(); trackedAfter = (await runGit(['status', '--porcelain', '--untracked-files=no', '--ignore-submodules=none'])).trim(); } catch (inspectErr) { return json(res, 200, { ok: false, error: 'ERR_UPDATE_STATE_UNKNOWN', message: `Update state could not be verified; the rollback point and any protective stash were preserved: ${inspectErr.message}`, expected: headTarget, stashed: !!protectiveStashOid, }); } } const reachedTargetClean = actualHead === headTarget && !trackedAfter; if (!reachedTargetClean) { const unchangedAndClean = actualHead === before.commitFull && !trackedAfter; let restored = false; if (unchangedAndClean) { if (willChangeHead) discardPendingRollbackPoint(); if (protectiveStashOid) { try { await restoreProtectiveStash(protectiveStashOid); restored = true; } catch (restoreErr) { log.warn(`self-update: failed mutation could not apply exact protective stash ${protectiveStashOid.slice(0, 12)}: ${restoreErr.message}`); } } } let rollbackPointError = ''; if (willChangeHead && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(actualHead) && actualHead !== before.commitFull) { try { // Exit status alone cannot establish ref state. Preserve an honest // rollback point for the commit Git actually reached, and never // apply a pre-mutation stash onto that changed commit. prepareRollbackPoint({ commit: before.commitFull, after: actualHead, ts: Date.now(), expected: headTarget }); promotePendingRollbackPoint(); } catch (pointErr) { rollbackPointError = pointErr.message; log.warn(pointErr?.renamePublished ? `self-update: honest partial-update rollback point is visible, but its directory fsync failed: ${pointErr.message}` : `self-update: failed to publish the honest partial-update rollback point; pending remains available: ${pointErr.message}`); } } const partial = !!trackedAfter || !unchangedAndClean; return json(res, 200, { ok: false, error: partial ? 'ERR_UPDATE_PARTIAL' : mutationError ? 'ERR_GIT_UPDATE_FAILED' : 'ERR_UPDATE_TARGET_MISMATCH', message: mutationError ? `Git did not reach a verified clean update state: ${mutationError.message}` : `Git reported success but the verified update state does not match ${headTarget.slice(0, 12)}.`, expected: headTarget, actual: actualHead || undefined, dirtyFiles: trackedAfter ? trackedAfter.split('\n').slice(0, 20) : undefined, rollbackPointError: rollbackPointError || undefined, stashed: !!protectiveStashOid, stashRestored: restored || undefined, }); } if (mutationError) { log.warn(`self-update: Git returned an error after reaching the verified update state: ${mutationError.message}`); pull = dirty ? 'hard-reset applied (verified after Git error)' : 'fast-forward applied (verified after Git error)'; } if (willChangeHead) { try { promotePendingRollbackPoint(); } catch (err) { // The pending point is already durable and rollback knows how to // recover it after restart. Do not hide a successful Git update. log.warn(err?.renamePublished ? `self-update: rollback point final file is visible, but its directory fsync failed after successful update: ${err.message}` : `self-update: rollback point remains pending after successful update: ${err.message}`); } } const changed = willMutateWorktree; // Schedule process exit so the service supervisor restarts us. This is // port/env-agnostic compared to spawning update.sh (which hardcodes // PORT=3003 default). // // v2.0.85 (#127 123cek): graceful-stop the LS pool before exit so // a supervisor hard-kill doesn't leave orphan language_server_linux_x64 // processes holding ports. Startup-time cleanup also runs as a // backstop, but stopping cleanly here means the next process won't // even need cleanup most of the time. if (changed) { // D6: same durable journal as rollback. `{ok:true}` below now carries // state:'pending-restart' + verified:false until the restarted process // reports this commit on /health; a declined scheduler is reported as // restart-not-scheduled instead of a silent success. try { writeSelfUpdateJournal({ version: 1, operation: 'update', state: 'pending-restart', phase: 'pre-restart', expectedCommit: actualHead, beforeCommit: before.commitFull, requestedAt: Date.now(), }); } catch (err) { log.warn(`self-update: could not durably record the update journal: ${err.message}`); } const restartScheduled = scheduleSelfUpdateRestart(() => { // v3.4.x: drain in-flight requests before the LS stop + exit, via the // shared gracefulRestart path. The HTTP server handle is now reachable // from api.js through server-registry.js (registered in index.js), so // self-update no longer exits abruptly mid-request. Behaviour is // otherwise unchanged: same await-and-wait LS stop (audit H-4 — SIGTERM // lands before exit reparents surviving children to init) and the same // EX_TEMPFAIL exit code the supervisor relaunches on. If the server // handle is somehow unregistered, gracefulRestart simply skips the // drain step and still stops the LS and exits. log.info('self-update: exiting with restart-requested status for service supervisor'); return Promise.resolve(gracefulRestart({ reason: 'self-update', drainMs: 10000, server: getActiveServer(), abortSse: abortActiveSse, stopLs: () => stopLanguageServerAndWait({ perProcessTimeoutMs: 1500 }), exitCode: selfUpdateRestartExitCode(), })).catch((err) => { try { updateSelfUpdateJournal({ state: 'restart-failed', restartScheduled: true, error: String(err?.message || err), }); } catch (journalErr) { log.warn(`self-update: could not record the restart failure: ${journalErr.message}`); } throw err; }); }, releaseMutation); mutationLockHandedOff = restartScheduled === true; try { updateSelfUpdateJournal({ state: mutationLockHandedOff ? 'pending-restart' : 'restart-not-scheduled', phase: mutationLockHandedOff ? 'restart-scheduled' : 'pre-restart', restartScheduled: mutationLockHandedOff, }); } catch (err) { log.warn(`self-update: could not record the restart scheduling result: ${err.message}`); } } return json(res, 200, { ok: true, changed, state: changed ? (mutationLockHandedOff ? 'pending-restart' : 'restart-not-scheduled') : 'unchanged', verified: !changed, restartScheduled: changed ? mutationLockHandedOff : false, before: before.commit, after: actualHead.slice(0, 7), pullOutput: pull.trim(), restarting: changed, }); } catch (err) { if (isSelfUpdateUnavailableError(err)) { // Same fallback as /self-update/check: when git is unavailable // (docker), try the docker socket path. The dashboard may already // have called /self-update/check first and routed the user // straight to a docker-mode confirmation, but supporting fallback // here too keeps `POST /self-update` self-contained for scripts. const docker = await detectDockerSelfUpdateForRequest(); if (docker.available) { const result = await runDockerSelfUpdateForRequest(); if (result.ok) { mutationLockHandedOff = scheduleDockerSelfUpdateMutationRelease(releaseMutation) === true; } return json(res, 200, { mode: 'docker', ...result }); } return json(res, 200, { ok: false, available: false, reason: err.reason, error: err.code, dockerReason: docker.reason, dockerDetail: docker.detail, }); } return json(res, 200, { ok: false, error: err.code || err.message, message: err.code ? err.message : undefined, }); } } finally { if (!mutationLockHandedOff) releaseMutation(); } } // ─── One-click restart ──────────────────────────────── // Gracefully drain in-flight requests then exit with the supervisor-relaunch // code (75, same as self-update) so systemd/PM2/docker restart us. This sits // under the same ambient dashboard-auth gate as /self-update (the checkAuth() // guard at the top of this handler covers every non-/auth mutation) — a // mutating operator action, so a chat API key alone is not enough. if (subpath === '/restart' && method === 'POST') { // Precheck: refuse to exit if no supervisor will relaunch us. Exiting into // nothing would take the gateway down permanently on one dashboard click. const sup = detectSupervisor(); if (!sup.supervised) { return json(res, 200, { ok: false, error: 'ERR_NO_SUPERVISOR', message: 'No process supervisor (systemd/PM2/docker) detected. Restart would stop the gateway without relaunch. Run under a supervisor or set WINDSURFAPI_RESTART_SUPERVISED=1.', }); } // Respond FIRST so the client sees the ack before we start tearing down, // THEN trigger the drain+exit on a short delay so the response flushes — // mirrors how /self-update schedules its exit via setTimeout. log.info(`restart: dashboard requested restart (supervisor=${sup.kind})`); setTimeout(() => { void gracefulRestart({ reason: `dashboard /restart (supervisor=${sup.kind})`, drainMs: 10000, server: getActiveServer(), abortSse: abortActiveSse, // await-and-wait variant so SIGTERM lands before process.exit reparents // surviving LS children to init (the H-4 orphan race). stopLs: () => stopLanguageServerAndWait({ perProcessTimeoutMs: 1500 }), exitCode: selfUpdateRestartExitCode(), }); }, 500); return json(res, 200, { ok: true, restarting: true, supervisor: sup.kind }); } // ─── Cache ──────────────────────────────────────────── if (subpath === '/cache' && method === 'GET') { return json(res, 200, cacheStats()); } if (subpath === '/cache' && method === 'DELETE') { cacheClear(); return json(res, 200, { success: true }); } // ─── Accounts ───────────────────────────────────────── if (subpath === '/accounts' && method === 'GET') { return json(res, 200, getAccountsPayload(req)); } // GET /accounts/ids — the id list for "select all N results". Same filters as // the list, no paging: the client must never guess that "all" means "page 1". if (subpath === '/accounts/ids' && method === 'GET') { const f = accountFiltersFromQuery(dashboardUrl(req).searchParams); const ids = getAccountList({ view: 'summary', ...f, limit: Infinity }).map((a) => a.id); return json(res, 200, { ids, total: ids.length }); } // POST /accounts/fleet-jobs — start an observable probe/refresh run. // The run is serial server-side; what the browser gets is a job id it can // poll for `done/total`, cancel, and read failures from after the fact. if (subpath === '/accounts/fleet-jobs' && method === 'POST') { const op = String(body?.op || ''); if (op !== 'probe' && op !== 'refresh') return json(res, 400, { error: 'ERR_FLEET_OP_INVALID' }); const rawIds = Array.isArray(body?.ids) ? body.ids : null; if (rawIds && rawIds.length > 1000) return json(res, 400, { error: 'ERR_FLEET_TOO_LARGE', max: 1000 }); const ids = rawIds ? [...new Set(rawIds.filter((id) => typeof id === 'string' && id))] : null; const force = body?.force === true || body?.allowLsStart === true; const job = startFleetJob({ op, ids, force }); return json(res, 200, { success: true, job: publicFleetJob(job, { since: 0 }) }); } // GET /accounts/fleet-jobs/:id?since=N — delta poll. `since` is the item // count the caller already has, so a long run does not resend every row. const fleetJobMatch = subpath.match(/^\/accounts\/fleet-jobs\/([^/]+)$/); if (fleetJobMatch && method === 'GET') { const job = getFleetJob(fleetJobMatch[1]); if (!job) return json(res, 404, { error: 'ERR_FLEET_JOB_NOT_FOUND' }); const since = Number(dashboardUrl(req).searchParams.get('since') ?? 0); return json(res, 200, { success: true, job: publicFleetJob(job, { since }) }); } // POST /accounts/fleet-jobs/:id/cancel — stop scheduling new accounts. const fleetCancelMatch = subpath.match(/^\/accounts\/fleet-jobs\/([^/]+)\/cancel$/); if (fleetCancelMatch && method === 'POST') { const job = cancelFleetJob(fleetCancelMatch[1]); if (!job) return json(res, 404, { error: 'ERR_FLEET_JOB_NOT_FOUND' }); return json(res, 200, { success: true, job: publicFleetJob(job, { since: 0 }) }); } // POST /accounts/batch — one request for a bulk operation, per-item results. // Serial by design: parallel writes to the same pool trip the upstream // brute-force guard. Partial failure is a 200 with `failed[]`, never a 500 // that hides the items that did succeed. if (subpath === '/accounts/batch' && method === 'POST') { const op = String(body?.op || ''); if (!['enable', 'disable', 'probe', 'refresh', 'reset', 'delete'].includes(op)) { return json(res, 400, { error: 'ERR_BATCH_OP_INVALID' }); } const rawIds = Array.isArray(body?.ids) ? body.ids : []; const ids = [...new Set(rawIds.filter((id) => typeof id === 'string' && id))]; if (ids.length === 0) return json(res, 400, { error: 'ERR_BATCH_IDS_EMPTY' }); if (ids.length > 1000) return json(res, 400, { error: 'ERR_BATCH_TOO_LARGE', max: 1000 }); const results = []; for (const id of ids) { try { if (op === 'probe') { const r = await probeAccount(id, { allowLsStart: false, canary: false }); results.push(probeOutcomeEntry(id, r)); } else if (op === 'refresh') { const r = await refreshCredits(id); results.push(r?.ok ? { id, ok: true } : { id, ok: false, error: r?.error || 'ERR_REFRESH_FAILED' }); } else { const ok = op === 'enable' ? setAccountStatus(id, 'active') : op === 'disable' ? setAccountStatus(id, 'disabled') : op === 'reset' ? resetAccountErrors(id) : removeAccount(id); results.push(ok ? { id, ok: true } : { id, ok: false, error: 'ERR_ACCOUNT_NOT_FOUND' }); } } catch (err) { results.push({ id, ok: false, error: err.message || 'ERR_BATCH_ITEM_FAILED' }); } } const failed = results.filter((r) => !r.ok && !r.skipped); const skipped = results.filter((r) => r.skipped); if (op === 'delete') log.info(`dashboard accounts/batch delete: ${results.length - failed.length}/${ids.length} removed`); return json(res, 200, { success: true, op, results, failed, skipped, ...summarizeProbeResults(results), }); } if (subpath === '/accounts' && method === 'POST') { try { const apiServerUrl = body.apiServerUrl || body.api_server_url || ''; if (!body.api_key && !body.token) { return json(res, 400, { error: 'Provide api_key or token' }); } // SessionReauth: a bound paste replaces an existing account's token. Same // helper as POST /accounts/:id/reauth {action:'token'} so the two entry // points cannot drift into different validation. if (body.accountId) { return json(res, 200, applyReauthToken(body.accountId, body.api_key || body.token, { via: 'token' })); } let parsedProxy = null; if (body.proxy) { parsedProxy = parseProxyUrl(body.proxy); if (!parsedProxy) { return json(res, 400, { error: 'ERR_PROXY_FORMAT_INVALID' }); } try { await validateProxyHost(parsedProxy); } catch (e) { return json(res, 400, { error: e.message || 'ERR_PROXY_INVALID' }); } } const account = body.api_key ? await addAccountByPastedSecret(body.api_key, body.label, apiServerUrl, { unknownAsKey: true }) : await addAccountByPastedSecret(body.token, body.label, apiServerUrl); if (parsedProxy) { setAccountProxy(account.id, parsedProxy); } // Fire-and-forget LS warmup is opt-in. Current LS builds are heavy, // so adding many proxied accounts must not spawn many LSPs up front. scheduleAccountWarmup(account.id); return json(res, 200, { success: true, account: { id: account.id, email: account.email, method: account.method, status: account.status }, ...getAccountCount(), }); } catch (err) { // A bound re-auth can fail with 404/409/400; the unbound add path only // ever produced 400. Keep the code so the UI can react to it. return json(res, err.statusCode || 400, { error: err.code || err.message }); } } // POST /accounts/import-text — smart bulk import. Paste mixed token formats // (session / show-auth-token URL / refresh-JWT / labeled pairs); the parser // classifies each and adds via addAccountByPastedSecret. auth1_ is skipped // (PostAuth intermediate, not a pool key). SECURITY: never logs the pasted // text or any token; results carry only id/email/kind, never raw token. if (subpath === '/accounts/import-text' && method === 'POST') { try { const text = String(body.text || ''); if (!text.trim()) return json(res, 400, { error: 'ERR_IMPORT_TEXT_EMPTY' }); const parsed = parseAccountText(text); const results = { added: [], skipped: [], failed: [] }; // A devin-session-token$ is itself the apiKey (addAccountByKey); other // token shapes run RegisterUser (addAccountByToken). Route by classified // kind so a pasted session token doesn't fail against the Firebase-only // RegisterUser path. for (const raw of (parsed.tokens || [])) { const kind = classifyToken(raw); // auth1_ is a PostAuth intermediate, not a pool key. if (kind === 'auth1') { results.skipped.push({ kind, reason: 'auth1_not_a_pool_key' }); continue; } // F3: everything else is a candidate. A show-auth-token / // OneTimeAuthToken value is a 47-char opaque string that classifyToken // cannot name; the old `kind === 'unknown'` skip dropped it silently, // so the documented "paste the token" import added nothing. The adder // routes a session token to the pool key and everything else through // RegisterUser, and reports a rejection per token instead of hiding it. try { const acc = await addAccountByPastedSecret(raw, ''); results.added.push({ id: acc.id, email: acc.email, kind }); } catch (e) { results.failed.push({ kind, error: e.message }); } } for (const pair of (parsed.tokenPairs || [])) { const pairKind = classifyToken(pair.token); if (pairKind === 'auth1') { results.skipped.push({ kind: pairKind, email: pair.email, reason: 'auth1_not_a_pool_key' }); continue; } try { const acc = await addByKind(pair.token, pair.email || ''); results.added.push({ id: acc.id, email: acc.email || pair.email, kind: pairKind }); } catch (e) { results.failed.push({ email: pair.email, error: e.message }); } } // Email/password accounts need the login flow (batch 2+); record as skipped. for (const a of (parsed.accounts || [])) { results.skipped.push({ kind: 'email_password', email: a.email, reason: 'use_login_flow' }); } return json(res, 200, { success: true, ...results, ...getAccountCount() }); } catch (err) { return json(res, 400, { error: err.message }); } } // dashboard can hide / disable the "Import from local Windsurf" button on // public binds *before* the user clicks it. Returns the same gates the // import endpoint enforces, plus a friendly explanation. if (subpath === '/accounts/import-local-availability' && method === 'GET') { const remote = dashboardClientIp(req); const localBind = isLocalBindHost(); const loopback = isLoopbackAddress(remote); let available = true; let reason = ''; if (!localBind) { available = false; reason = 'public_bind'; } else if (!loopback) { available = false; reason = 'non_loopback_caller'; } return json(res, 200, { available, reason, bindHost: process.env.HOST || process.env.BIND_HOST || '0.0.0.0', remoteAddress: remote, hint: available ? '' : (reason === 'public_bind' ? '此实例绑定在公网/0.0.0.0 上 — "本地" Windsurf 是远端服务器上的,不是你电脑里的,所以这个功能被拒绝(设计如此)。要导入本机 Windsurf 凭证请用 localhost 部署。' : '只接受来自 127.0.0.1 的请求;当前调用来自 ' + (remote || '?') + '。'), }); } // GET /accounts/import-local — discover Windsurf desktop client credentials // Local-only hardening: both the bind and trusted client must be loopback. // Use the same client identity as checkAuth, so same-host proxies cannot // expose local desktop credentials. if (subpath === '/accounts/import-local' && method === 'GET') { if (!isLocalBindHost()) { log.warn('local-windsurf import refused: dashboard not bound to loopback host'); return json(res, 403, { error: 'ERR_LOCAL_IMPORT_NOT_AVAILABLE_PUBLIC_BIND' }); } const remote = dashboardClientIp(req); if (!isLoopbackAddress(remote)) { log.warn(`local-windsurf import refused: non-loopback caller ${remote}`); return json(res, 403, { error: 'ERR_LOCAL_IMPORT_LOOPBACK_ONLY', message: 'Local Windsurf import only available from 127.0.0.1' }); } try { const result = await discoverWindsurfCredentials(); log.info(`local-windsurf import: found ${result.accounts.length} account(s) across ${result.sources.filter(s => s.ok).length} source(s)`); return json(res, 200, { success: true, accounts: result.accounts.map(a => ({ method: a.method, apiKey: a.apiKey, apiKeyMasked: a.apiKeyMasked, email: a.email, name: a.name, apiServerUrl: a.apiServerUrl, label: a.label, source: a.source, })), sources: result.sources, sqliteSupport: result.sqliteSupport, platform: result.platform, }); } catch (e) { log.warn(`local-windsurf import failed: ${e.message}`); return json(res, 500, { error: 'ERR_LOCAL_IMPORT_FAILED', message: e.message }); } } // POST /accounts/probe-all — legacy blocking form. Kept for API clients and // the smoke matrix; the dashboard now uses /accounts/fleet-jobs so the run is // observable and cancellable. Same loop, one implementation. if (subpath === '/accounts/probe-all' && method === 'POST') { const force = body?.force === true || body?.allowLsStart === true; const started = startFleetJob({ op: 'probe', force }); const job = await awaitFleetJob(started.id); const results = job?.items || []; return json(res, 200, { success: true, total: job?.total || 0, ...summarizeProbeResults(results), results }); } // POST /accounts/:id/reauth — SessionReauth actions (never returns the key). // {action:'probe'} → one free GetUserStatus probe. alive clears the state, // UNAUTHORIZED keeps it, anything else (CAPACITY/UPSTREAM_INTERNAL) is // transient and changes nothing. The operator-facing "Check token" button // forces past the 60 s de-dupe. // {action:'token'} → install a pasted session token on THIS account. const accountReauth = subpath.match(/^\/accounts\/([^/]+)\/reauth$/); if (accountReauth && method === 'POST') { const id = accountReauth[1]; const action = String(body?.action || 'probe'); try { if (action === 'probe') { if (!getAccountInternal(id)) return json(res, 404, { error: 'ERR_ACCOUNT_NOT_FOUND' }); const result = await requestReauthCheck(id, { source: 'manual', force: body?.force !== false }); if (result.reason === 'NOT_ELIGIBLE') return json(res, 409, { error: 'ERR_REAUTH_NOT_APPLICABLE' }); return json(res, 200, result); } if (action === 'token') { return json(res, 200, applyReauthToken(id, body?.token ?? body?.api_key, { via: 'manual' })); } return json(res, 400, { error: 'ERR_REAUTH_ACTION_INVALID' }); } catch (err) { return json(res, err.statusCode || 400, { error: err.code || err.message }); } } // POST /accounts/:id/probe — manually trigger capability probe const accountProbe = subpath.match(/^\/accounts\/([^/]+)\/probe$/); if (accountProbe && method === 'POST') { try { const force = body?.force === true || body?.allowLsStart === true; // Billable canary sweep is opt-in: only when the caller explicitly asks // for a deep probe. A plain probe runs GetUserStatus only (free) and // never spends the account's prompt credits. (homecloud 2026-06-29: // a default canary sweep flipped a working free account to "expired".) const canary = body?.canary === true || body?.deep === true; const result = await probeAccount(accountProbe[1], { allowLsStart: force, canary }); if (!result) return json(res, 404, { error: 'Account not found' }); return json(res, 200, { success: true, ...result }); } catch (err) { return json(res, 500, { error: err.message }); } } // POST /accounts/refresh-credits — legacy blocking form of the refresh run. // The dashboard uses /accounts/fleet-jobs (progress + cancel); this stays for // API clients and the smoke matrix and shares the same loop. if (subpath === '/accounts/refresh-credits' && method === 'POST') { const started = startFleetJob({ op: 'refresh' }); const job = await awaitFleetJob(started.id); const results = (job?.items || []).map((item) => ({ id: item.id, email: item.email, ok: item.ok, ...(item.ok ? {} : { error: item.error || 'ERR_REFRESH_FAILED' }), })); return json(res, 200, { success: true, results }); } // POST /accounts/:id/refresh-credits — single-account refresh const creditRefresh = subpath.match(/^\/accounts\/([^/]+)\/refresh-credits$/); if (creditRefresh && method === 'POST') { const r = await refreshCredits(creditRefresh[1]); return json(res, r.ok ? 200 : 400, r); } // POST /accounts/:id/web-search — run one upstream web search on this account. // Exposes GetWebSearchResults, which the repo has implemented and tested since it // was reverse-engineered but never called from anywhere. Costs no model credits: // it rides the account's own session token. // // Authentication is inherited, not re-implemented: every /dashboard/api/* route // except /auth passes through the checkAuth gate above, so this endpoint is only // reachable by an authenticated dashboard session. const webSearch = subpath.match(/^\/accounts\/([^/]+)\/web-search$/); if (webSearch && method === 'POST') { const r = await searchWebForAccount(webSearch[1], { query: body?.query, limit: body?.limit, domain: body?.domain, }); return json(res, r.ok ? 200 : 400, r); } const accountGet = subpath.match(/^\/accounts\/([^/]+)$/); if (accountGet && method === 'GET') { const account = getAccountPublic(accountGet[1], { view: 'full' }); if (!account) return json(res, 404, { error: 'Account not found' }); return json(res, 200, { account }); } // PATCH /accounts/:id const accountPatch = subpath.match(/^\/accounts\/([^/]+)$/); if (accountPatch && method === 'PATCH') { const id = accountPatch[1]; // The operator may only write the three statuses they can act on. `banned` // and `reauth` are evidence-gated states — the ban detector and the // UNAUTHORIZED probe put an account there, and letting a hand-written string // forge that evidence would bypass the two-signal rule (a `banned` written // this way also skips bannedReason/bannedAt). Any other value is a typo that // used to be persisted verbatim: selection filters on `status === 'active'` // while resolveAccountState maps unknown statuses to 'active', so the account // became invisible-but-unroutable and the value survived restart. if (body.status !== undefined && !PATCHABLE_ACCOUNT_STATUSES.includes(body.status)) { return json(res, 400, { error: 'ERR_ACCOUNT_STATUS_INVALID' }); } try { // `force:true` is the operator's explicit override of the reauth guard // (they replaced the token out-of-band); without it the refusal is a 409 // so the UI can point at the re-authenticate action instead of silently // putting a retired key back in rotation. if (body.status !== undefined) setAccountStatus(id, body.status, { force: body.force === true }); if (body.label) updateAccountLabel(id, body.label); if (body.resetErrors) resetAccountErrors(id); if (Array.isArray(body.blockedModels)) setAccountBlockedModels(id, body.blockedModels); if (body.tier) setAccountTier(id, body.tier); // v3.0.3: per-account on-demand spend policy. Only the keys present are // touched (each can be set alone). spendOnDemand: true | false | null(inherit); // onDemandReserve: number | null/''(inherit). if ('spendOnDemand' in body || 'onDemandReserve' in body) { const patch = {}; if ('spendOnDemand' in body) patch.spendOnDemand = body.spendOnDemand; if ('onDemandReserve' in body) patch.reserve = body.onDemandReserve; setAccountSpendPolicy(id, patch); } } catch (err) { return json(res, err.statusCode || 400, { error: err.code || err.message }); } return json(res, 200, { success: true }); } // GET /tier-access — hardcoded FREE/PRO model entitlement tables. // The dashboard uses this to render the full per-account model grid // (every row in the tier's list is shown, blocked models are dimmed). if (subpath === '/tier-access' && method === 'GET') { return json(res, 200, { free: _TIER_TABLE.free, pro: _TIER_TABLE.pro, unknown: _TIER_TABLE.unknown, expired: _TIER_TABLE.expired, allModels: filterModelKeysByCloudCatalog(), }); } // DELETE /accounts/:id const accountDel = subpath.match(/^\/accounts\/([^/]+)$/); if (accountDel && method === 'DELETE') { const ok = removeAccount(accountDel[1]); return json(res, ok ? 200 : 404, { success: ok }); } // ─── Stats ──────────────────────────────────────────── if (subpath === '/stats' && method === 'GET') { const stats = getStats(); return json(res, 200, { ...stats, modelBilling: buildModelBillingIndex(stats.modelCounts) }); } // GET /stats/log — newest-first request-log page + aggregates over the whole // filtered window. Filters are server-side and every aggregate is computed // from the same rows the table pages through, so the numbers reconcile. // `from`/`to` are epoch milliseconds (the browser sends them); limit is // clamped to 200, offset >= 0. Retention is stated in the response. if (subpath === '/stats/log' && method === 'GET') { const u = dashboardUrl(req); const num = (key) => { const raw = u.searchParams.get(key); if (raw == null || raw === '') return null; const n = Number(raw); return Number.isFinite(n) ? n : null; }; return json(res, 200, getRequestLog({ from: num('from'), to: num('to'), model: u.searchParams.get('model') || null, account: u.searchParams.get('account') || null, status: u.searchParams.has('status') ? num('status') : null, limit: num('limit') ?? 50, offset: num('offset') ?? 0, }), NO_STORE); } // Account usage from app.devin.ai (quota burn-down card). GET-only reads // through the shared dashboard cache; `?account=` selects a pool account; // with no id it uses the first active account, then the env-token deploy. // `?refresh=1` bypasses freshness but shares any in-flight request. if (subpath === '/usage' && method === 'GET') { const u = dashboardUrl(req); try { const result = await readQuotaBurnDown({ id: u.searchParams.get('account') || undefined, force: u.searchParams.get('refresh') === '1', }); const { httpStatus, ...payload } = result; // Preserve the upstream status marker for the browser. Without it a // forwarded upstream 401/429 is indistinguishable from the dashboard's // own password/IP-lock rejection, so the UI would clear a valid admin // session (or start a lockout) on a provider quota error. if (!result.ok) payload.httpStatus = httpStatus || 502; return json(res, result.ok ? 200 : (httpStatus || 502), payload, NO_STORE); } catch (err) { log.warn(`Dashboard usage read failed: ${err?.kind || err?.name || 'unexpected'}`); return json(res, 502, { ok: false, error: 'upstream' }, NO_STORE); } } // GET /usage/orgs — org-scoped ACU & usage (Q3). Resolves every active // Devin session-token account to its org, reads the ledger ONCE per org, and // returns a hash-only org handle. Accounts that share an org are grouped, so // the same ACUs are never counted twice. Per-org failures stay per-org; the // panel renders the orgs that answered. if (subpath === '/usage/orgs' && method === 'GET') { const u = dashboardUrl(req); try { const result = await readAcuUsageByOrg({ force: u.searchParams.get('refresh') === '1', }); const { httpStatus, ...payload } = result; if (!result.ok) payload.httpStatus = httpStatus || 502; return json(res, result.ok ? 200 : (httpStatus || 502), payload, NO_STORE); } catch (err) { log.warn(`Dashboard ACU usage read failed: ${err?.kind || err?.name || 'unexpected'}`); return json(res, 502, { ok: false, error: 'upstream', orgs: [] }, NO_STORE); } } // Billing health / pause banner (P0-2). Shares the `status` and `quota` cache // entries with /usage; a 403 is a gray "no permission", never a stale state. if (subpath === '/billing/health' && method === 'GET') { const u = dashboardUrl(req); try { const result = await readBillingHealth({ id: u.searchParams.get('account') || undefined, force: u.searchParams.get('refresh') === '1', }); const { httpStatus, ...payload } = result; // Preserve the upstream status marker for the browser. Without it a // forwarded upstream 401/429 is indistinguishable from the dashboard's // own password/IP-lock rejection, so the UI would clear a valid admin // session (or start a lockout) on a provider quota error. if (!result.ok) payload.httpStatus = httpStatus || 502; return json(res, result.ok ? 200 : (httpStatus || 502), payload, NO_STORE); } catch (err) { log.warn(`Dashboard billing health read failed: ${err?.kind || err?.name || 'unexpected'}`); return json(res, 502, { ok: false, error: 'upstream' }, NO_STORE); } } if (subpath === '/stats' && method === 'DELETE') { resetStats(); return json(res, 200, { success: true }); } // v2.0.148 — export the full stats snapshot (credits + per-request detail) for // backup/migration. GET returns JSON the client can download as a file. if (subpath === '/stats/export' && method === 'GET') { return json(res, 200, exportStats()); } // v2.0.148 — import a previously exported snapshot. Body: the snapshot object, // optional { mode: 'merge'|'replace' } (default merge). Merge sums counts. if (subpath === '/stats/import' && method === 'POST') { const mode = body && body.mode === 'replace' ? 'replace' : 'merge'; const snap = body && body.snapshot ? body.snapshot : body; const result = importStats(snap, { mode }); return json(res, result.ok ? 200 : 400, result); } // DEVIN_CONNECT recovery health: re-login / failover / dead-token / cooldown // counters + credential-store decrypt health, so an operator can watch the // self-heal machinery instead of grepping logs (#36, #37). if (subpath === '/connect-metrics' && method === 'GET') { // C5: fold in the persisted per-account rolling-hour health window so an // operator sees not just fleet-wide counters but which accounts are // currently throwing throttles/errors/dead-tokens over the last hour. // Sticky-session affinity counters ride along so an operator can verify // STICKY_SESSION_ENABLED is actually binding (hits/creates > 0) instead // of counting log lines — the only way to check before this. return json(res, 200, { ...getConnectMetrics(), poolHealth: getPoolHealthWindow(), sticky: { enabled: isStickyEnabled(), ...getStickyStats() }, responseStore: getResponseStoreStats(), // Cascade replayed-history images: how many historical images rode field 6 // vs how many were dropped (cap / bytes / truncated / no vision mode). The // drop path also logs `cascade_image_history_dropped`; this makes it // countable instead of grep-only. cascadeImages: getCascadeImageMetrics(), }); } if (subpath === '/connect-metrics' && method === 'DELETE') { resetConnectMetrics(); resetCascadeImageMetrics(); return json(res, 200, { success: true }); } // ─── Rate-limit history ─────────────────────────────── // Bounded ring of the most recent markRateLimited events (upstream window vs // the clamped window that was actually applied), so an operator can see what // a lockout was really waiting for instead of grepping logs. `?limit=n` // returns the n most recent; the ring itself is capped at 500. if (subpath === '/rate-limit-history' && method === 'GET') { const url = dashboardUrl(req); const limit = parseBoundedInt(url.searchParams.get('limit'), 0, { min: 0, max: 500 }); return json(res, 200, { events: getRateLimitHistory({ limit }) }); } // ─── Logs ───────────────────────────────────────────── if (subpath === '/logs' && method === 'GET') { const url = new URL(req.url, 'http://localhost'); const since = parseInt(url.searchParams.get('since') || '0', 10); const level = url.searchParams.get('level') || null; return json(res, 200, { logs: getLogs(since, level) }); } // GET /logs/export — v2.0.60: download recent logs as JSONL or // pretty-text. Filterable by `type` (all / system / api), `level` // (debug/info/warn/error/all), `since` (unix ms). Designed for the // "give me logs to attach to a GitHub issue" flow so users don't have // to copy-paste from the streaming view. Returns Content-Disposition // so browsers download instead of preview. if (subpath === '/logs/export' && method === 'GET') { const url = new URL(req.url, 'http://localhost'); const type = (url.searchParams.get('type') || 'all').toLowerCase(); const level = url.searchParams.get('level') || null; const since = parseInt(url.searchParams.get('since') || '0', 10); const fmt = (url.searchParams.get('format') || 'jsonl').toLowerCase(); let entries = getLogs(since, level); if (type === 'api') { // API path: any log emitted by request handlers — Probe[id] / Chat[id] // / Cascade / ToolGuard / drought etc., plus any entry with a ctx // requestId. This is the "what happened to my request" view. entries = entries.filter(e => { if (e.ctx && (e.ctx.requestId || e.ctx.reqId)) return true; const m = e.msg || ''; return /^(?:Probe|Chat|Cascade|ToolGuard|ToolParser|drought|Workspace|Settings):|\[Probe |\[Chat /i.test(m); }); } else if (type === 'system') { // System path: everything that's NOT obviously per-request — auth // pool, LS lifecycle, cron jobs, etc. entries = entries.filter(e => { if (e.ctx && (e.ctx.requestId || e.ctx.reqId)) return false; const m = e.msg || ''; return !/^(?:Probe|Chat|Cascade|ToolGuard|ToolParser):|\[Probe |\[Chat /i.test(m); }); } const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const filename = `windsurf-api-logs-${type}-${stamp}.${fmt === 'txt' ? 'log' : 'jsonl'}`; let body; if (fmt === 'txt' || fmt === 'log') { body = entries.map(e => { const ts = new Date(e.ts).toISOString(); const ctx = e.ctx ? ' ' + Object.entries(e.ctx).map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`).join(' ') : ''; return `${ts} [${e.level.toUpperCase()}] ${e.msg}${ctx}`; }).join('\n') + '\n'; } else { body = entries.map(e => JSON.stringify(e)).join('\n') + '\n'; } res.writeHead(200, { 'Content-Type': fmt === 'txt' || fmt === 'log' ? 'text/plain; charset=utf-8' : 'application/x-ndjson; charset=utf-8', 'Content-Disposition': `attachment; filename="${filename}"`, 'Cache-Control': 'no-store', }); res.end(body); return; } if (subpath === '/logs/stream' && method === 'GET') { req.socket.setKeepAlive(true); req.setTimeout(0); const streamHeaders = { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', }; // Same allowlist-gated CORS as json(): only emit ACAO for an allowed // origin, never a blanket `*` (see resolveCorsOrigin / AUTH-1). const streamAcao = res?._dashboardCorsOrigin || ''; if (streamAcao) { streamHeaders['Access-Control-Allow-Origin'] = streamAcao; if (streamAcao !== '*') streamHeaders['Vary'] = 'Origin'; } res.writeHead(200, streamHeaders); res.write('retry: 3000\n\n'); // Send existing logs first const existing = getLogs(); for (const entry of existing.slice(-50)) { res.write(`data: ${JSON.stringify(entry)}\n\n`); } const heartbeat = setInterval(() => { if (!res.writableEnded) res.write(': heartbeat\n\n'); }, 15000); const cb = (entry) => { if (!res.writableEnded) res.write(`data: ${JSON.stringify(entry)}\n\n`); }; subscribeToLogs(cb); req.on('close', () => { clearInterval(heartbeat); unsubscribeFromLogs(cb); }); return; } // ─── Proxy ──────────────────────────────────────────── // Always return the masked view over the API — plaintext passwords // would otherwise end up in dashboard network logs, HAR files, proxy // access logs, etc. The UI posts the sentinel back to preserve the // stored password when editing other fields (see mergePassword). // ─── Drought summary (v2.0.57 Fix 5) ────────────────── if (subpath === '/drought' && method === 'GET') { return json(res, 200, getDroughtSummary()); } // ─── Upstream endpoints (v2.0.60 — show migration status) ── // Surfaces which Windsurf upstream paths the proxy is currently // wired to talk to. Lets the operator confirm at a glance that we're // on the new register.windsurf.com / windsurf.com/_backend hosts and // that the legacy fallbacks are still in place. Read-only — the // actual switching happens automatically per-request based on // network success / 5xx response. if (subpath === '/upstream-endpoints' && method === 'GET') { return json(res, 200, { registerUser: { primary: 'register.windsurf.com/exa.seat_management_pb.SeatManagementService/RegisterUser', fallback: 'api.codeium.com/register_user/', protocol: 'Connect-RPC (primary) / REST (fallback)', migratedSince: 'v2.0.57', }, postAuth: { primary: 'windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/WindsurfPostAuth', fallback: 'server.self-serve.windsurf.com/exa.seat_management_pb.SeatManagementService/WindsurfPostAuth', protocol: 'Connect-RPC', migratedSince: 'v2.0.57', }, oneTimeAuthToken: { primary: 'windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/GetOneTimeAuthToken', fallback: 'server.self-serve.windsurf.com/exa.seat_management_pb.SeatManagementService/GetOneTimeAuthToken', protocol: 'Connect-RPC', migratedSince: 'v2.0.57', }, checkUserLoginMethod: { primary: 'windsurf.com/_backend/exa.seat_management_pb.SeatManagementService/CheckUserLoginMethod', fallback: 'windsurf.com/_devin-auth/connections', protocol: 'Connect-RPC', migratedSince: 'v2.0.39', }, getUserStatus: { primary: 'server.codeium.com/exa.seat_management_pb.SeatManagementService/GetUserStatus', fallback: 'server.self-serve.windsurf.com/exa.seat_management_pb.SeatManagementService/GetUserStatus', protocol: 'Connect-RPC', note: '内置 daily/weekly% 解析;wam-bundle 用的 GetPlanStatus 是同一 service 的另一个 RPC,返回字段被 GetUserStatus.planStatus 嵌套覆盖。', }, getCascadeModelConfigs: { primary: 'server.codeium.com/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs', fallback: 'server.self-serve.windsurf.com/exa.api_server_pb.ApiServerService/GetCascadeModelConfigs', protocol: 'Connect-RPC', }, firebaseAuth: { primary: 'identitytoolkit.googleapis.com/v1/accounts:signInWithPassword', refreshUrl: 'securetoken.googleapis.com/v1/token', note: 'Windsurf project Firebase API key 直连 — 同 WindsurfSwitch / wam-bundle 路径。', }, }); } // ─── Credentials (v2.0.56 — runtime-rotatable API_KEY + DASHBOARD_PASSWORD) ───── // GET /settings/credentials — masked snapshot. The plaintext API key is // never returned (use revealApiKey if/when added), but we expose // - source: 'runtime' | 'env' | 'unset' // - masked: 'sk-12...3456' for the API key // - dashboardPasswordSet: bool // - dashboardPasswordSource: 'runtime' | 'env' | 'unset' if (subpath === '/settings/credentials' && method === 'GET') { const creds = getCredentials(); const effectiveApiKey = getEffectiveApiKey(); const apiKeySource = creds.apiKey ? 'runtime' : (config.apiKey ? 'env' : 'unset'); const dashboardPasswordSource = creds.dashboardPasswordHash ? 'runtime' : (config.dashboardPassword ? 'env' : 'unset'); return json(res, 200, { apiKey_masked: maskApiKey(effectiveApiKey), apiKeySource, dashboardPasswordSet: !!getEffectiveDashboardPasswordStored(), dashboardPasswordSource, }); } // PUT /settings/credentials — rotate one or both credentials. Body: // { apiKey?: string, dashboardPassword?: string } // Empty string clears the runtime override (env value takes over). // dashboardPassword is trimmed before validation and hashing; a non-empty // value that trims to nothing is rejected as blank (SEC-04). // Requires the caller to re-authenticate with the NEW dashboard // password on the next request — no session cookies, so the UI just // re-prompts. We don't accept the old-password proof here because the // caller already passed dashboard auth at the top of this function. // // V-21 (audit A22): validation AND commit are one transaction. The old // order validated+persisted `apiKey` and only then looked at // `dashboardPassword`, so a request answered with 400 had already rotated // the chat key. setRuntimeCredentials() validates every field first and // writes both in one atomic replace. if (subpath === '/settings/credentials' && method === 'PUT') { if (!body || typeof body !== 'object') { return json(res, 400, { error: 'Body must be a JSON object' }); } const hasApiKey = Object.prototype.hasOwnProperty.call(body, 'apiKey'); const hasPassword = Object.prototype.hasOwnProperty.call(body, 'dashboardPassword'); let committed; try { committed = setRuntimeCredentials({ ...(hasApiKey ? { apiKey: body.apiKey } : {}), ...(hasPassword ? { dashboardPassword: body.dashboardPassword } : {}), }); } catch (err) { if (err?.code === 'ERR_CREDENTIAL_PERSIST_FAILED') { // The file write failed and both fields were rolled back; saying // "success" here would leave the operator with credentials that only // exist until the next restart. log.warn(`Settings: credential rotation was NOT persisted: ${err.cause?.message || err.message}`); return json(res, 500, { error: 'ERR_CREDENTIAL_PERSIST_FAILED' }); } return json(res, 400, { error: err.message }); } const out = {}; if (hasApiKey) { out.apiKeyUpdated = true; out.apiKey_masked = maskApiKey(committed.apiKey || getEffectiveApiKey()); } if (hasPassword) out.dashboardPasswordUpdated = true; log.info(`Settings: credentials rotated from ${dashboardClientIp(req) || 'unknown'} (apiKey=${!!out.apiKeyUpdated}, dashboardPassword=${!!out.dashboardPasswordUpdated})`); return json(res, 200, { success: true, ...out }); } // GET /settings/prefs — dashboard UI preferences (booleans, shared across // browsers via runtime-config). PUT to change one or more. if (subpath === '/settings/prefs' && method === 'GET') { return json(res, 200, { prefs: getPrefs() }); } if (subpath === '/settings/prefs' && method === 'PUT') { if (!body || typeof body !== 'object') { return json(res, 400, { error: 'Body must be a JSON object' }); } return json(res, 200, { success: true, prefs: setPrefs(body) }); } if (subpath === '/proxy' && method === 'GET') { return json(res, 200, getProxyConfigMasked()); } if (subpath === '/proxy/global' && method === 'PUT') { // v2.0.55 (audit H3): wire this PUT through the same private-host // gate the add-account path uses, otherwise a dashboard-authenticated // caller can pin the global proxy at 127.0.0.1 / 169.254.169.254 / // any internal socket, then upstream egress flows through it. Skip // when the operator explicitly allows private hosts, only validate // host format. Empty body / no host = clearing the global proxy via // empty PUT. if (body && typeof body === 'object' && body.host) { try { await validateProxyHost(body); } catch (e) { return json(res, 400, { error: e?.message || 'ERR_PROXY_PRIVATE_HOST' }); } } setGlobalProxy(body); return json(res, 200, { success: true, config: getProxyConfigMasked() }); } if (subpath === '/proxy/global' && method === 'DELETE') { removeProxy('global'); return json(res, 200, { success: true }); } const proxyAccount = subpath.match(/^\/proxy\/accounts\/([^/]+)$/); if (proxyAccount && method === 'PUT') { // Same H3 gate as /proxy/global PUT — per-account proxies were the // other half of the bypass. Empty body / no host = clearing the // proxy, leave it unvalidated. if (body && typeof body === 'object' && body.host) { try { await validateProxyHost(body); } catch (e) { return json(res, 400, { error: e?.message || 'ERR_PROXY_PRIVATE_HOST' }); } } setAccountProxy(proxyAccount[1], body); const admission = getLsAdmissionStatus(body?.host ? body : null); const warmupRequested = body?.warmup === true || body?.prewarm === true || shouldPrewarmLsOnAccountAdd(); let warmup = { ok: true, skipped: true, reason: 'warmup_not_requested' }; if (warmupRequested) { warmup = await runAccountWarmup(proxyAccount[1], { probe: false, requireOptIn: false }); } return json(res, 200, { success: true, admission, warmup }); } if (proxyAccount && method === 'DELETE') { removeProxy('account', proxyAccount[1]); return json(res, 200, { success: true }); } // ─── Config ─────────────────────────────────────────── if (subpath === '/config' && method === 'GET') { return json(res, 200, { port: config.port, defaultModel: config.defaultModel, maxTokens: config.maxTokens, logLevel: config.logLevel, lsBinaryPath: config.lsBinaryPath, lsPort: config.lsPort, codeiumApiUrl: config.codeiumApiUrl, hasApiKey: !!config.apiKey, hasDashboardPassword: !!config.dashboardPassword, }); } // ─── Language Server binary inspect / update ───────── // GET → current LS binary stat (size, mtime, sha256 prefix). UI uses // this to show "binary installed at X, version sha:abcd1234, age 21d". if (subpath === '/langserver/binary' && method === 'GET') { const binPath = config.lsBinaryPath; try { const { statSync } = await import('node:fs'); const { createReadStream } = await import('node:fs'); const { createHash } = await import('node:crypto'); const stat = statSync(binPath); const sha = await new Promise((resolve, reject) => { const h = createHash('sha256'); createReadStream(binPath) .on('data', c => h.update(c)) .on('end', () => resolve(h.digest('hex'))) .on('error', reject); }); return json(res, 200, { ok: true, path: binPath, sizeBytes: stat.size, mtime: stat.mtime.toISOString(), sha256: sha.slice(0, 16), }); } catch (err) { return json(res, 200, { ok: false, path: binPath, error: err.code || err.message, }); } } // POST → run install-ls.sh to download the latest binary, then restart // every LS pool entry so requests pick up the new binary on next call. // Body: { url?: string } — optional override (e.g. desktop-extracted // binary URL); falls back to `install-ls.sh` manifest mode (pinned // upstream release from ls-pins.json). if (subpath === '/langserver/update' && method === 'POST') { const { spawn } = await import('node:child_process'); const { fileURLToPath } = await import('node:url'); const { dirname, join: pjoin } = await import('node:path'); // install-ls.sh ships at the repo root, two levels above this file // (src/dashboard/api.js). Resolving by import.meta.url avoids any // dependence on cwd, so the endpoint works whether started from /app // (Docker), the repo root, or a deeper subdir. const here = dirname(fileURLToPath(import.meta.url)); const scriptPath = _langserverInstallScriptOverride || pjoin(here, '..', '..', 'install-ls.sh'); if (!existsSync(scriptPath)) { return json(res, 500, { ok: false, error: `install-ls.sh not found at ${scriptPath}`, }); } const url = body && typeof body.url === 'string' ? body.url.trim() : ''; const expectedSha256 = body && typeof body.sha256 === 'string' ? body.sha256.trim().toLowerCase() : ''; // Defence-in-depth: only allow https URLs from a small allowlist of // hosts. Without this, an attacker who got past dashboard auth could // pipe an arbitrary URL into the install script and have curl write // bytes to LS_BINARY_PATH which is then chmod +x and exec'd as the // node user. Still gated by checkAuth above; this is a second layer. const allowedHosts = [ 'github.com', 'objects.githubusercontent.com', 'release-assets.githubusercontent.com', 'api.github.com', ]; if (url) { let parsed; try { parsed = new URL(url); } catch { return json(res, 400, { ok: false, error: 'invalid url' }); } if (parsed.protocol !== 'https:') { return json(res, 400, { ok: false, error: 'url must be https' }); } if (!allowedHosts.includes(parsed.hostname)) { return json(res, 400, { ok: false, error: `url host not allowed; permitted: ${allowedHosts.join(', ')}`, }); } // A05: a custom URL is an unpinned download. Without an operator- // supplied hash there is no trust anchor at all (the old route simply // omitted --sha256 and install-ls.sh warned then installed whatever // arrived). Require the 64-hex digest before spawning anything. if (!/^[0-9a-f]{64}$/.test(expectedSha256)) { return json(res, 400, { ok: false, error: 'ERR_LS_SHA256_REQUIRED', message: 'Custom URL updates require the expected SHA-256 (64 hex characters) in body.sha256', }); } } else if (expectedSha256) { return json(res, 400, { ok: false, error: 'ERR_LS_SHA256_WITHOUT_URL', message: 'body.sha256 is only valid together with body.url', }); } // --allowed-host is repeated so install-ls.sh revalidates EVERY redirect // hop against the same allowlist, not just the first URL. const args = url ? ['--url', url, '--sha256', expectedSha256, ...allowedHosts.flatMap(host => ['--allowed-host', host])] : []; // A05: a custom URL installs into a sibling staging path first. The route // re-hashes those bytes and only then renames them onto config.lsBinaryPath // and restarts LS. A buggy/swapped installer that exits 0 with wrong bytes // therefore cannot publish or execute them. const staged = !!url; const installTarget = staged ? `${config.lsBinaryPath}.staged.${process.pid}.${randomUUID()}` : config.lsBinaryPath; const cleanupStaged = () => { if (!staged) return; try { unlinkSync(installTarget); } catch { /* already gone */ } }; const env = { ...buildSubprocessEnv(), LS_INSTALL_PATH: config.lsBinaryPath, }; if (staged) env.LS_INSTALL_STAGE_PATH = installTarget; // Snapshot sha256 BEFORE the install. install-ls.sh will atomic- // rename the binary into place even if the download is byte- // identical to what's already there (no upstream change), so // without comparing before/after the dashboard can't tell whether // "Update LS" actually replaced anything — leading to user reports // like "LS update has no effect". Returning both lets the toast // distinguish "binary changed" from "binary already up to date". let beforeSha = null; try { const { createReadStream } = await import('node:fs'); const { createHash } = await import('node:crypto'); beforeSha = await new Promise((resolve, reject) => { const h = createHash('sha256'); createReadStream(config.lsBinaryPath) .on('data', c => h.update(c)) .on('end', () => resolve(h.digest('hex'))) .on('error', () => resolve(null)); }); } catch { /* missing or unreadable — that's fine, treat as null */ } const child = spawn('bash', [scriptPath, ...args], { env }); let stdout = ''; let stderr = ''; child.stdout.on('data', c => { stdout += c.toString(); }); child.stderr.on('data', c => { stderr += c.toString(); }); const exitCode = await new Promise(resolve => { child.on('close', resolve); child.on('error', () => resolve(-1)); }); if (exitCode !== 0) { cleanupStaged(); return json(res, 200, { ok: false, exitCode, stdout: stdout.slice(-4000), stderr: stderr.slice(-4000), }); } let afterSha = null; try { const { createReadStream } = await import('node:fs'); const { createHash } = await import('node:crypto'); afterSha = await new Promise((resolve) => { const h = createHash('sha256'); createReadStream(installTarget) .on('data', c => h.update(c)) .on('end', () => resolve(h.digest('hex'))) .on('error', () => resolve(null)); }); } catch { /* keep null */ } // A05: never publish/restart on an unverified binary. install-ls.sh already // checks the digest before its atomic rename, so this is a second, // independent gate at the process boundary: a bug or a swapped script // must not turn into an LS restart that executes attacker bytes. if (staged && afterSha !== expectedSha256) { cleanupStaged(); return json(res, 409, { ok: false, error: 'ERR_LS_INTEGRITY_FAILED', message: 'Installed binary does not match the expected SHA-256; refusing to restart the language server', expectedSha256, actualSha256: afterSha, beforeSha: beforeSha ? beforeSha.slice(0, 16) : null, }); } if (staged) { try { renameSyncWithRetry(installTarget, config.lsBinaryPath); } catch (err) { cleanupStaged(); return json(res, 500, { ok: false, error: 'ERR_LS_INSTALL_COMMIT_FAILED', message: `Could not publish the verified language server: ${err.message}`, }); } // A custom binary has no manifest metadata; drop the previous install's // sidecar so it cannot describe the new binary with stale hashes. try { unlinkSync(`${config.lsBinaryPath}.version.json`); } catch { /* none */ } } const binaryChanged = !!(beforeSha && afterSha && beforeSha !== afterSha); // Restart every LS pool entry. The pool is keyed by proxy; iterating // through the live list catches per-account proxies as well as the // default no-proxy LS. Errors during restart get surfaced so the user // knows whether they need to bounce the container. const { _poolKeys, restartLsForProxy: doRestart, getProxyByKey } = await import('../langserver.js'); let restarted = 0; let restartErrors = []; try { const keys = typeof _poolKeys === 'function' ? _poolKeys() : ['default']; for (const key of keys) { try { const proxy = typeof getProxyByKey === 'function' ? getProxyByKey(key) : null; await doRestart(proxy); restarted++; } catch (e) { restartErrors.push(`${key}: ${e.message}`); } } } catch (e) { restartErrors.push(e.message); } return json(res, 200, { ok: true, stdout: stdout.slice(-4000), restarted, restartErrors, // 16-char prefix matches what /langserver/binary returns so the // dashboard can string-compare against its current shown stat. beforeSha: beforeSha ? beforeSha.slice(0, 16) : null, afterSha: afterSha ? afterSha.slice(0, 16) : null, binaryChanged, // poolEmpty distinguishes "no live LS to restart" (cold proxy, // restart will happen on next request) from "all LS restart // attempts failed" (real problem). Without this the toast counts // both as "restarted 0". poolEmpty: restarted === 0 && restartErrors.length === 0, }); } // ─── Language Server ────────────────────────────────── if (subpath === '/langserver/restart' && method === 'POST') { if (!body.confirm) { return json(res, 400, { error: 'Send { confirm: true } to restart language server' }); } Promise.resolve().then(async () => { try { await stopLanguageServerAndWait({ perProcessTimeoutMs: 1500 }); await startLanguageServer({ binaryPath: config.lsBinaryPath, port: config.lsPort, apiServerUrl: config.codeiumApiUrl, }); } catch (e) { log.error(`Language server restart failed: ${e.message}`); } }); return json(res, 200, { success: true, message: 'Restarting language server...' }); } // ─── Models list ────────────────────────────────────── // // Each row carries `reachable`: can this deployment actually serve it right now. On a // DEVIN_CONNECT deployment that answer comes from the SAME predicate /v1/models uses // (buildConnectReachability), because the two used to disagree completely — on a // free-only pool this panel listed 163 models the account could not call and omitted // `swe-1-6-slow`, the only one it could. Measured zero overlap between the two views // (#234's last acceptance criterion). // // Rows are ANNOTATED, never dropped. The panel renders the allow/deny chips from this // list alone, so narrowing it would make an existing allow-list entry invisible AND // unremovable — the operator could not click a chip that is no longer rendered. // Annotating also answers #235 ("which models actually cost me quota"), which narrowing // would not: the reporter needs to SEE the paid ones, greyed, not have them vanish. if (subpath === '/models' && method === 'GET') { const isReachable = buildConnectReachability(); // Pool-wide quota state, computed once per request from the same inputs the // drought banner uses. `dailyDry` is what turns a bare "QUOTA" chip into // "daily quota exhausted" — the state a Pro account actually sat in while the // panel called its only working models paid and a phantom selector free. const drought = getDroughtSummary(); const dailyDry = typeof drought.lowestDailyPercent === 'number' && drought.lowestDailyPercent <= 0; // One resolver for every row producer below, so a synthesized row and a MODELS // row can never disagree about price or catalog membership. // // quotaState answers a DIFFERENT question from freeNow and that split is the // fix: freeNow is "would this debit quota", quotaState is "would the upstream // accept it right now". The old single `currentlyFree` boolean collapsed them, // which is why a model the catalog omits (swe-1-6-slow) could read as FREE. const pricingFields = (selector) => { const pricing = getConnectSelectorPricing(selector); let quotaState = null; if (!selector) quotaState = null; // not a Connect-namespace row else if (pricing.inLiveCatalog === false) quotaState = 'not_in_catalog'; else if (pricing.freeNow === true) quotaState = 'free'; else if (dailyDry) quotaState = 'blocked_daily'; else if (isConnectSelectorBlockedByDrought(selector)) quotaState = 'blocked_weekly'; else quotaState = 'unknown'; return { // Upstream cost tier ("MODEL_COST_TIER_FREE" | LOW | MEDIUM | HIGH), null // when the account's Cascade catalog has not reported it. costTier: pricing.costTier, // ISO end of the active promotion, when the upstream sent one. promoEndsAt: pricing.promoEndsAt, // Tri-state price: true free, false billable, null no data. Never a guess. freeNow: pricing.freeNow, // Why freeNow is true: cost_tier_free | rate_zero | fallback_whitelist. freeReason: pricing.freeReason, // Upstream's own promo text ("83% off"), null when it sent none. promoLabel: pricing.promoLabel, // When the live pricing behind these fields was last synced, and whether // that is older than the staleness budget. The panel shows it next to a // FREE badge so "free" from a stopped sync is visibly old. pricingFetchedAt: pricing.pricingFetchedAt, pricingStale: pricing.pricingStale, // free | blocked_daily | blocked_weekly | not_in_catalog | unknown | null. quotaState, }; }; const models = filterModelKeysByCloudCatalog().map((id) => { const info = MODELS[id]; const { reachable, selector } = isReachable(id); return { id, name: info.name, provider: info.provider, credit: typeof info.credit === 'number' ? info.credit : null, reachable, // The resolved upstream selector, or null when the name maps to nothing on this // transport. Surfaced for diagnosis: "unreachable" and "does not resolve at all" // look identical in the UI otherwise. connectSelector: selector, ...pricingFields(selector), }; }); // Selectors that ARE serveable but have no MODELS row (`swe-1-6-slow` is in neither the // frozen snapshot nor the live catalog, so it appears in no Cascade-derived list). They // are the whole point of the panel on a free deployment, so synthesize them here the // way /v1/models' third producer does. Name/provider fall back to the selector because // there is no MODELS entry to read them from. // // Gated on the transport: these are CONNECT-namespace selectors. Injecting one into a // Cascade deployment's panel advertises a model that transport cannot route, which is // the same category of error as the mismatch this whole route fixes — just pointing the // other way. Caught by the "backend off" case in // test/dashboard-models-connect-parity.test.js. if (getBackendSwitch('devinConnect')) { const seen = new Set(models.map((m) => m.id)); const representedSelectors = new Set(models .map((m) => m.connectSelector) .filter(Boolean)); // /v1/models has a second producer for selectors present in the live Connect // catalog but absent from the shared MODELS table. Mirror it here; otherwise // the public model API can advertise a live-only selector that the Dashboard // cannot display, price, or manage. Keep the row annotated rather than // filtering it out: a restricted account still needs to see that the selector // exists but is currently unreachable for its pool. for (const row of getLiveCatalog()) { const id = (typeof row === 'string' ? row : row?.selector)?.trim(); if (!id || seen.has(id) || representedSelectors.has(id)) continue; seen.add(id); const { reachable, selector } = isReachable(id); if (selector) representedSelectors.add(selector); models.push({ id, name: row?.label || id, provider: row?.provider || 'windsurf', credit: null, reachable, connectSelector: selector, ...pricingFields(selector), }); } for (const selector of FREE_REACHABLE_SELECTORS) { if (seen.has(selector)) continue; seen.add(selector); // Reachability is resolved, not asserted. The static floor is unpaid-tier // ENTITLEMENT; whether this account's live catalog actually contains the // selector is a separate fact, and `swe-1-6-slow` fails it on the measured // Pro deployment (absent from the 240-selector catalog, 502 quota on call). // The row stays visible — an operator must be able to see and remove an // allow-list entry — but it is annotated as not-in-catalog instead of // being advertised as reachable+free, which is how the panel ended up // pointing clients at the one selector that could not run. const { reachable } = isReachable(selector); models.push({ id: selector, name: selector, provider: 'windsurf', credit: null, reachable, connectSelector: selector, ...pricingFields(selector), }); } } return json(res, 200, { models }); } // Live-vs-snapshot catalog diff for the dashboard. Read-only; it reports set // differences and alias resolution, never entitlement. if (subpath === '/models/catalog' && method === 'GET') { return json(res, 200, buildCatalogDiff({ live: getLiveCatalog(), snapshot: getSnapshotSelectors(), resolve: resolveConnectSelector, })); } // Live model reconciliation (P0-3): local /v1/models rows vs the live catalog // vs the committed snapshot, plus counts-only views of the account-scoped // model sources. Upstream failures degrade to `partial:true`; only an // in-process handleModels() failure is a 502. if (subpath === '/models/reconcile' && method === 'GET') { const u = dashboardUrl(req); try { const result = await readModelReconciliation({ id: u.searchParams.get('account') || undefined, force: u.searchParams.get('refresh') === '1', }); const { httpStatus, ...payload } = result; // Preserve the upstream status marker for the browser. Without it a // forwarded upstream 401/429 is indistinguishable from the dashboard's // own password/IP-lock rejection, so the UI would clear a valid admin // session (or start a lockout) on a provider quota error. if (!result.ok) payload.httpStatus = httpStatus || 502; return json(res, result.ok ? 200 : (httpStatus || 502), payload, NO_STORE); } catch (err) { log.warn(`Dashboard model reconciliation failed: ${err?.kind || err?.name || 'unexpected'}`); return json(res, 502, { ok: false, error: 'upstream' }, NO_STORE); } } // ─── Model Access Control ────────────────────────────── if (subpath === '/model-access' && method === 'GET') { return json(res, 200, getModelAccessConfig()); } if (subpath === '/model-access' && method === 'PUT') { if (body.mode) setModelAccessMode(body.mode); if (body.list) setModelAccessList(body.list); if (body.defaultModel !== undefined) setDefaultModel(body.defaultModel); return json(res, 200, { success: true, config: getModelAccessConfig() }); } if (subpath === '/model-access/add' && method === 'POST') { if (!body.model) return json(res, 400, { error: 'model is required' }); addModelToList(body.model); return json(res, 200, { success: true, config: getModelAccessConfig() }); } if (subpath === '/model-access/remove' && method === 'POST') { if (!body.model) return json(res, 400, { error: 'model is required' }); removeModelFromList(body.model); return json(res, 200, { success: true, config: getModelAccessConfig() }); } // ─── Windsurf Login ──────────────────────────────────── if (subpath === '/windsurf-login' && method === 'POST') { try { const { email, password, proxy: loginProxy, autoAdd, storeCredential } = body || {}; return json(res, 200, await processWindsurfLogin({ email, password, loginProxy, autoAdd, storeCredential, req, accountId: body?.accountId || null })); } catch (err) { // The UI needs `method` to switch the form (email code vs OAuth-only) // without a second probe, and `probeUnknown` to offer the alternatives // when the password was rejected but the login method was never // confirmed — the exact shape that made a passwordless account read as // "wrong password" on 3.9.42. return json(res, err.statusCode || 400, { error: err.code || err.message, isAuthFail: !!err.isAuthFail, firebaseCode: err.firebaseCode, ...(err.method ? { method: err.method } : {}), ...(err.oauthProviders?.length ? { oauthProviders: err.oauthProviders } : {}), ...(err.detail ? { detail: err.detail } : {}), ...(err.probeUnknown ? { probeUnknown: true } : {}), ...(err.alternatives ? { alternatives: err.alternatives } : {}), }); } } // Login-method probe. The UI calls this BEFORE showing a password field: // `hasPassword:false` means the account can only log in with an emailed code // or OAuth, so a password attempt would be a guaranteed, misleading 401. // Dashboard auth is enforced above; this route never sees a credential. if (subpath === '/windsurf-login/probe' && method === 'POST') { const email = String(body?.email || '').trim(); if (!email) return json(res, 400, { error: 'ERR_EMAIL_REQUIRED' }); // Per-email rate limit, shared with the login path. 400 (not 429): the // dashboard renders 429 as an IP-wide ban overlay, and this lock is // per-email. const lockMs = checkEmailLocked(email); if (lockMs != null) return json(res, 400, { error: 'ERR_EMAIL_LOCKED', retryAfterMs: lockMs }); try { const loginProxy = body?.proxy?.host ? body.proxy : getProxyConfig().global; const probe = await probeLoginMethod(email, loginProxy); return json(res, 200, { ok: true, email, userExists: probe.userExists, hasPassword: probe.hasPassword, method: probe.method, emailCode: probe.emailCode, oauth: probe.oauth, source: probe.source, // Which login host answered (`windsurf` | `devin` | null). The email // flow starts on the same host instead of guessing. host: probe.host || null, }, NO_STORE); } catch (err) { // A malformed address is the caller's error (400); anything else is an // upstream availability failure (502). return json(res, err.statusCode || 502, { ok: false, error: err.code || 'ERR_LOGIN_PROBE_FAILED', ...(err.detail ? { detail: err.detail } : {}), }, NO_STORE); } } // Ask Windsurf to email a login code. The upstream verification token stays // server-side, keyed by the returned opaque flowId — only {flowId, sent} is // returned, and neither the token nor the code is ever logged. // // `host:'devin'` is an explicit operator choice ("the windsurf.com mail never // arrived — retry on app.devin.ai"). It is allowlisted, never free text, and // it is not the automatic cross-host fallback (that stays behind // DEVIN_CONNECT_LOGIN_HOST_FALLBACK). if (subpath === '/windsurf-login/email/start' && method === 'POST') { const email = String(body?.email || '').trim(); if (!email) return json(res, 400, { error: 'ERR_EMAIL_REQUIRED' }); const host = body?.host === 'devin' ? 'devin' : (body?.host === 'windsurf' ? 'windsurf' : undefined); try { const loginProxy = body?.proxy?.host ? body.proxy : getProxyConfig().global; return json(res, 200, await emailLoginStart(email, loginProxy, { host }), NO_STORE); } catch (err) { // Stable code + machine-readable detail (F8). A transport failure used to // escape as a raw English message with no code; the UI then printed the // upstream text verbatim and the operator could not tell whether the mail // had been sent. return json(res, err.statusCode || 400, { error: err.code || err.message, ...(err.detail ? { detail: err.detail } : {}), ...(err.retryAfterMs ? { retryAfterMs: err.retryAfterMs } : {}), ...(err.retryable ? { retryable: true } : {}), }, NO_STORE); } } // Verify the emailed code and finish the login. Same response shape as // /windsurf-login, including the auto-add + credential-store tail. if (subpath === '/windsurf-login/email/complete' && method === 'POST') { const { flowId, code, proxy: loginProxy, autoAdd } = body || {}; if (!flowId || !code) return json(res, 400, { error: 'ERR_EMAIL_FLOW_CODE_REQUIRED' }); try { // V-24: when the caller binds the exchange to an account, the flow's own // identity must match it BEFORE the single-use code is spent. finalize // re-checks before any mutation; this early check keeps a mismatch from // costing the operator a second email. const boundAccount = body?.accountId ? getAccountInternal(body.accountId) : null; const result = await emailLoginComplete( flowId, code, loginProxy?.host ? loginProxy : getProxyConfig().global, { expectedIdentityEmail: boundAccount?.email }, ); // No password exists in this flow, so there is nothing for the credential // store to persist — pass storeCredential:false regardless of the request. return json(res, 200, await finalizeWindsurfLogin(result, { password: null, loginProxy, autoAdd, storeCredential: false, req, accountId: body?.accountId || null, })); } catch (err) { return json(res, err.statusCode || 400, { error: err.code || err.message, ...(err.detail ? { detail: err.detail } : {}), ...(err.retryAfterMs ? { retryAfterMs: err.retryAfterMs } : {}), ...(err.attemptsRemaining != null ? { attemptsRemaining: err.attemptsRemaining } : {}), ...(err.retryable ? { retryable: true } : {}), isAuthFail: !!err.isAuthFail, }); } } // Import a token captured from the official sign-in page // (windsurf.com/editor/show-auth-token) or from any OAuth / deep-link // redirect, in ONE step. // // Stateless on purpose. `/oauth/callback` is the state-bound flow for CLI and // remote-onboarding clients; requiring a pending session here meant a valid // token failed with ERR_INVALID_STATE whenever the page had been opened by a // different button (or the session had expired). The token itself is the // credential — the state adds nothing to this path. // // `devin-session-token$…` is stored as the pool key; a Firebase idToken / // show-auth-token value goes through RegisterUser. `auth1_` is an // intermediate and is rejected. The raw input is NEVER echoed or logged. if (subpath === '/windsurf-login/token' && method === 'POST') { const raw = body?.token ?? body?.redirect_url ?? ''; const label = String(body?.label || '').trim(); if (!raw) return json(res, 400, { error: 'ERR_NO_TOKEN_IN_INPUT' }); try { // extractOAuthToken keeps the actionable diagnostics (ERR_OAUTH_UPSTREAM, // ERR_INTERMEDIATE_CALLBACK) and returns a bare token unchanged. const token = extractOAuthToken(raw); if (!token) return json(res, 400, { error: 'ERR_NO_TOKEN_IN_INPUT' }); // SessionReauth: a bound paste replaces the account's token instead of // adding a second row for the same person. if (body?.accountId) { const applied = applyReauthToken(body.accountId, token, { via: 'token' }); return json(res, 200, { ...applied, method: 'token' }, NO_STORE); } const account = await addAccountByPastedSecret(token, label); scheduleAccountWarmup(account.id); return json(res, 200, { success: true, method: 'token', account: { id: account.id, email: account.email, status: account.status }, }, NO_STORE); } catch (err) { return json(res, err.statusCode || 400, { error: err.code || err.message }); } } if (subpath === '/windsurf-login/batch' && method === 'POST') { try { const { accounts, proxy: loginProxy, autoAdd, storeCredential } = body || {}; if (!Array.isArray(accounts) || !accounts.length) { return json(res, 400, { error: 'ERR_ACCOUNTS_REQUIRED' }); } const results = []; for (const acct of accounts) { const email = String(acct?.email || '').trim(); const password = String(acct?.password || '').trim(); try { const result = await processWindsurfLogin({ email, password, loginProxy, autoAdd, storeCredential, req }); // V-25: /windsurf-login returns the raw key for a one-time export // when autoAdd:false. A BATCH response has no such contract — every // row is keyed by an address whose password the caller just sent, so // a raw key here is only a leak. Mask it and keep the same shape. if (result && result.apiKey) { const { apiKey, ...rest } = result; results.push({ ...rest, apiKey_masked: maskApiKey(apiKey) }); } else { results.push(result); } } catch (err) { results.push({ success: false, email, error: err.code || String(err.message || 'ERR_LOGIN_FAILED').slice(0, 200), isAuthFail: !!err.isAuthFail, firebaseCode: err.firebaseCode, }); } } const successCount = results.filter(r => r.success).length; const failCount = results.length - successCount; return json(res, 200, { success: true, total: results.length, successCount, failCount, results, }, NO_STORE); } catch (err) { return json(res, 400, { error: err.message }); } } // ─── Batch proxy + account import ───────────────────── // POST /batch-import — each line: "proxy email password" or "email password" if (subpath === '/batch-import' && method === 'POST') { try { const { text, autoAdd = true, storeCredential } = body || {}; if (!text || typeof text !== 'string') return json(res, 400, { error: 'ERR_TEXT_REQUIRED' }); const parsed = parseBatchImportInput(text, parseProxyUrl); if (!parsed.items.length) return json(res, 400, { error: 'ERR_NO_VALID_LINES' }); const results = []; let skippedCount = 0; for (const item of parsed.items) { if (item.kind === 'issue') { skippedCount++; results.push({ success: false, skipped: true, email: item.label, error: item.error, lineNumber: item.lineNumber, // V-25: never `item.raw` — that string is the operator's pasted // line (password/token included). The kind label is derived from // the parsed shape, so it cannot contain any of the input. detail: describeImportItem(item), }); continue; } try { if (item.proxy) await validateProxyHost(item.proxy); let result; if (item.kind === 'email_password') { const loginProxy = item.proxy || getProxyConfig().global; result = await processWindsurfLogin({ email: item.email, password: item.password, loginProxy, autoAdd, storeCredential, req, }); const binding = buildBatchProxyBinding(result, item.proxyRaw); if (binding) { setAccountProxy(binding.accountId, binding.proxy); scheduleAccountWarmup(binding.accountId); } } else if (item.kind === 'token') { const account = await addAccountByPastedSecret(item.token, item.label); if (item.proxy) { setAccountProxy(account.id, item.proxy); scheduleAccountWarmup(account.id); } result = { success: true, email: account.email, apiKey_masked: maskApiKey(account.apiKey), account: { id: account.id, email: account.email, status: account.status }, }; } else if (item.kind === 'api_key') { const account = await addAccountByPastedSecret(item.apiKey, item.label, item.apiServerUrl || '', { unknownAsKey: true }); if (item.proxy) { setAccountProxy(account.id, item.proxy); scheduleAccountWarmup(account.id); } result = { success: true, email: account.email, apiKey_masked: maskApiKey(account.apiKey), account: { id: account.id, email: account.email, status: account.status }, }; } else { throw new Error('ERR_UNSUPPORTED_IMPORT_ITEM'); } // V-25: `proxyRaw` carried user:pass@; the label drops the userinfo // and is what the dashboard shows / stores in login history. result.proxy = redactedProxyLabel(item.proxy); result.importKind = item.kind; results.push(result); } catch (err) { results.push({ success: false, skipped: false, email: item.email || item.label || '', // Prefer the stable code; a bare Error message can carry upstream // prose, so it is bounded as a last resort. error: err.code || String(err.message || 'ERR_IMPORT_ITEM_FAILED').slice(0, 200), lineNumber: item.lineNumber, proxy: redactedProxyLabel(item.proxy), importKind: item.kind, detail: describeImportItem(item), }); } } const successCount = results.filter(r => r.success).length; const failCount = results.filter(r => !r.success && !r.skipped).length; return json(res, 200, { success: true, mode: parsed.mode, total: results.length, successCount, failCount, skippedCount, results, }, NO_STORE); } catch (err) { if (err instanceof BatchImportParseError) { return json(res, 400, { error: err.code }); } return json(res, 400, { error: err.message }); } } // ─── OAuth login (Google / GitHub via Firebase) ──────── // POST /oauth-login — accepts Firebase idToken from client-side OAuth if (subpath === '/oauth-login' && method === 'POST') { try { const { idToken, refreshToken, email, provider, autoAdd } = body; if (!idToken) return json(res, 400, { error: 'ERR_IDTOKEN_REQUIRED' }); const proxy = getProxyConfig().global; const { apiKey, name } = await reRegisterWithCodeium(idToken, proxy); let account = null; if (autoAdd !== false) { account = addAccountByKey(apiKey, sanitizeLabelText(name) || email || provider || 'OAuth'); if (refreshToken) { setAccountTokens(account.id, { refreshToken, idToken }); } scheduleAccountWarmup(account.id); } return json(res, 200, { success: true, // Same one-time-export contract as /windsurf-login: raw key returned // only when autoAdd:false (caller takes the key themselves and we do // not persist it). Otherwise mask for listings. ...(autoAdd === false ? { apiKey } : { apiKey_masked: maskApiKey(apiKey) }), name, email: email || '', account: account ? { id: account.id, email: account.email, status: account.status } : null, }); } catch (err) { return json(res, 400, { error: err.message }); } } // ─── OAuth remote-onboarding state machine ────────────────────── // Lets a PUBLIC deploy onboard via the official Windsurf sign-in page without // the server ever receiving a localhost callback: start -> user logs in in // their own browser -> they paste the whole redirect URL back -> server pulls // the token out of it -> poll for the result. Ported (in-memory) from // CLIProxyAPI's oauth_sessions/oauth_callback. All three inherit checkAuth. if (subpath === '/oauth/start' && method === 'POST') { const { registerSession } = await import('./oauth-sessions.js'); const state = registerSession('windsurf'); // Windsurf editor backup-token signin URL (response_type=token). state rides // the OAuth state param so we can correlate the pasted callback. const url = 'https://windsurf.com/windsurf/signin?response_type=token' + '&client_id=3GUryQ7ldAeKEuD2obYnppsnmj58eP5u&redirect_uri=show-auth-token' + '&state=' + encodeURIComponent(state) + '&prompt=login&redirect_parameters_type=query&workflow='; return json(res, 200, { success: true, url, state }); } if (subpath === '/oauth/callback' && method === 'POST') { const { validateState, getSession, completeSession, failSession } = await import('./oauth-sessions.js'); const { state, redirect_url } = body || {}; if (!validateState(state)) return json(res, 400, { error: 'ERR_INVALID_STATE' }); const session = getSession(state); if (!session || session.status !== 'pending') return json(res, 409, { error: 'ERR_SESSION_NOT_PENDING' }); try { // Extract the token from whatever the user pasted: a full redirect URL // (query or #hash), or the bare token itself. NEVER log redirect_url. const token = extractOAuthToken(redirect_url); if (!token) throw new Error('ERR_NO_TOKEN_IN_INPUT'); // A devin-session-token$ IS the apiKey — it must go through addAccountByKey, // NOT addAccountByToken (which runs RegisterUser and only accepts a Firebase // idToken). Route by prefix so both token shapes onboard correctly. const account = await addAccountByPastedSecret(token, ''); scheduleAccountWarmup(account.id); completeSession(state); return json(res, 200, { success: true, account: { id: account.id, email: account.email, status: account.status } }); } catch (err) { failSession(state, err.message); return json(res, 400, { error: err.message }); } } if (subpath === '/oauth/status' && method === 'GET') { const { getStatus } = await import('./oauth-sessions.js'); const st = new URL(req.url, 'http://localhost').searchParams.get('state') || ''; return json(res, 200, getStatus(st)); } // ─── Email OTP Login — SEALED 2026-07-06 ──────────────────────── // These endpoints are intact and the Connect-RPC wiring is verified, but the // flow is dormant: it needs a Cloudflare Turnstile token whose sitekey is // domain-bound to windsurf.com, so a self-hosted dashboard can't produce one. // The UI panel is hidden (index.html data-sealed). Endpoints remain reachable // for a future upstream change / a manually-supplied turnstile token. // ─── Email OTP Login (Step 1: Send verification code) ──── if (subpath === '/email-otp/send' && method === 'POST') { try { const { email, firstName, turnstileToken } = body || {}; if (!email || !firstName || !turnstileToken) { return json(res, 400, { error: 'ERR_EMAIL_FIRSTNAME_TURNSTILE_REQUIRED' }); } const { sendEmailVerification } = await import('./email-otp-login.js'); const proxy = getProxyConfig().global; await sendEmailVerification(email, firstName, turnstileToken, proxy); return json(res, 200, { success: true, email, message: 'Verification code sent. Check your email.', }); } catch (err) { return json(res, err.statusCode || 400, { error: err.message }); } } // ─── Email OTP Login (Step 2: Complete registration) ──── if (subpath === '/email-otp/complete' && method === 'POST') { try { const { email, otpCode, turnstileToken, firstName, lastName, autoAdd } = body || {}; if (!email || !otpCode || !turnstileToken) { return json(res, 400, { error: 'ERR_EMAIL_OTP_TURNSTILE_REQUIRED' }); } const { registerUserWithOtp } = await import('./email-otp-login.js'); const proxy = getProxyConfig().global; const { apiKey, name, apiServerUrl } = await registerUserWithOtp( email, otpCode, turnstileToken, firstName || '', lastName || '', proxy ); let account = null; if (autoAdd !== false) { account = addAccountByKey(apiKey, sanitizeLabelText(name) || email || 'OTP'); scheduleAccountWarmup(account.id); } return json(res, 200, { success: true, ...(autoAdd === false ? { apiKey } : { apiKey_masked: maskApiKey(apiKey) }), name, email, apiServerUrl, account: account ? { id: account.id, email: account.email, status: account.status } : null, }); } catch (err) { return json(res, err.statusCode || 400, { error: err.message }); } } // ─── Rate Limit Check ────────────────────────────────── // POST /accounts/:id/rate-limit — check capacity for a single account const rateLimitCheck = subpath.match(/^\/accounts\/([^/]+)\/rate-limit$/); if (rateLimitCheck && method === 'POST') { const acct = getAccountPublic(rateLimitCheck[1], { view: 'summary' }); if (!acct) return json(res, 404, { error: 'Account not found' }); const secret = getAccountInternal(acct.id); try { const proxy = getEffectiveProxy(acct.id) || null; const result = await checkMessageRateLimit(secret.apiKey, proxy); return json(res, 200, { success: true, account: acct.email, ...result }); } catch (err) { return json(res, 500, { error: err.message }); } } // ─── DevIn panel (page-level snapshot) ───────────────── // GET /devin/panel — one in-process snapshot for the Devin panel page: // runtime switches + LS/detach state, the merged per-account billed-turn // rings, the drought decision, and the quota/spend knobs. No upstream call // and no key material, so opening the page costs no quota. Sources the proxy // cannot read (Cascade trajectories, ACP sessions) are declared unavailable // with a machine-readable reason by the builder — never returned as empty // lists, which would read as "no sessions". if (subpath === '/devin/panel' && method === 'GET') { const now = Date.now(); // Newest-used first, so MAX_SCAN_ACCOUNTS only drops accounts that never // served a turn. `lastUsed` is an ISO string (or null) on the summary view. const allAccounts = getAccountList({ view: 'summary' }); const accounts = allAccounts .map((a) => ({ id: a.id, ref: accountRef(a.id), lastUsed: a.lastUsed ? Date.parse(a.lastUsed) : 0 })) .sort((a, b) => (b.lastUsed || 0) - (a.lastUsed || 0)) .slice(0, MAX_SCAN_ACCOUNTS); const runtime = buildRuntimeSection({ uptimeSec: Math.round(process.uptime()), startedAt: getStats().startedAt, switches: runtimeSwitchRows(), credStoreEnabled: !!(process.env.DEVIN_CONNECT_CRED_KEY || '').trim(), remoteCredStore: String(process.env.DEVIN_CONNECT_ALLOW_REMOTE_CRED_STORE || '').trim() === '1', langServer: getLsStatus(), detach: { enabled: cascadeDetachEnabled(), ...detachedRegistry.stats() }, }); const sessions = buildSessionsSection({ turns: buildTurnsSection({ accounts, totalAccounts: allAccounts.length, readRequests: getAccountRequests, now }), }); const quota = buildQuotaSection({ drought: getDroughtSummary(), tunables: getQuotaTunables(), overrides: getQuotaOverrides(), }); return json(res, 200, buildDevinPanel({ now, runtime, sessions, quota }), NO_STORE); } // ─── DevinMap cards (account drawer) ────────────────── // GET /accounts/:id/devin-cards — the three account-local cards (ACU ledger, // model entitlement, key rotation). No network: every field is read from the // stored account record plus the in-process credit ledger, so opening the // drawer never costs an upstream call. Auth is inherited from the gate above. const devinCards = subpath.match(/^\/accounts\/([^/]+)\/devin-cards$/); if (devinCards && method === 'GET') { const payload = buildAccountCards(devinCards[1]); return json(res, payload.ok ? 200 : (payload.httpStatus || 404), payload); } // GET /accounts/:id/requests — the account's own request history: billed // requests plus failed attempts attributed to it. Read-only, process-local, // no upstream call and no key material: rows carry model + token counts + // cost, or model + outcome + status for a failure. `total` is the ring size // (billed + failed), and `billed`/`failed` split it, so the drawer can print // "50 of 100" instead of implying the page is the whole history. const accountRequests = subpath.match(/^\/accounts\/([^/]+)\/requests$/); if (accountRequests && method === 'GET') { const acct = getAccountPublic(accountRequests[1], { view: 'summary' }); if (!acct) return json(res, 404, { error: 'Account not found' }); const u = new URL(req?.url || '/', 'http://localhost'); const payload = getAccountRequests(acct.id, { limit: u.searchParams.get('limit') }); return json(res, 200, { ok: true, accountRef: accountRef(acct.id), ...payload }); } // POST /accounts/:id/capacity-preflight — on-demand selector capacity check // before a run. One upstream CheckUserMessageRateLimit call, memoized 60 s per // account+selector (DevinMap §6.4: never on page load). The local half is // always computed, so an upstream failure still explains the pool-side state. const capacityPreflight = subpath.match(/^\/accounts\/([^/]+)\/capacity-preflight$/); if (capacityPreflight && method === 'POST') { const result = await preflightCapacity({ id: capacityPreflight[1], model: typeof body?.model === 'string' ? body.model : '', force: body?.force === true, }); return json(res, result.ok ? 200 : (result.httpStatus || 400), result); } const revealKey = subpath.match(/^\/account\/([^/]+)\/reveal-key$/); if (revealKey && method === 'POST') { const acct = getAccountInternal(revealKey[1]); if (!acct) return json(res, 404, { error: 'Account not found' }); // AUTH-1: revealing an upstream key in cleartext is the single most // sensitive read the dashboard offers. Passing ambient dashboard auth is // no longer enough — require the operator to re-prove the dashboard // password in THIS request (body.password / body.dashboardPassword or the // X-Dashboard-Password-Confirm header), and audit-log the reveal. This // blocks a drive-by (CSRF / an already-open localhost dashboard) from // silently exfiltrating keys, and forces a deliberate confirmation step. if (!confirmReauth(req, body)) { log.warn(`reveal-key REFUSED for account ${revealKey[1]} from ${dashboardClientIp(req) || 'unknown'}: missing/invalid re-auth confirmation`); return json(res, 401, { error: 'ERR_REVEAL_REAUTH_REQUIRED', message: 'Re-enter the dashboard password to reveal this key.' }); } log.info(`reveal-key: account ${revealKey[1]} (${maskEmail(acct.email) || 'no-email'}) revealed from ${dashboardClientIp(req) || 'unknown'}`); return json(res, 200, { success: true, apiKey: acct.apiKey }); } // ─── Firebase Token Refresh ─────────────────────────────── // POST /accounts/:id/refresh-token — manually refresh Firebase token const tokenRefresh = subpath.match(/^\/accounts\/([^/]+)\/refresh-token$/); if (tokenRefresh && method === 'POST') { const acct = getAccountInternal(tokenRefresh[1]); if (!acct) return json(res, 404, { error: 'Account not found' }); if (!acct.refreshToken) return json(res, 400, { error: 'Account has no refresh token' }); try { const proxy = getEffectiveProxy(acct.id) || null; const { idToken, refreshToken: newRefresh } = await refreshFirebaseToken(acct.refreshToken, proxy); const { apiKey } = await reRegisterWithCodeium(idToken, proxy); const keyChanged = apiKey && apiKey !== acct.apiKey; // Persist the fresh credentials back onto the account. Without this, the // in-memory apiKey stays on the now-stale value until the next server // restart — every subsequent request from this account will fail auth. setAccountTokens(acct.id, { apiKey: apiKey || acct.apiKey, refreshToken: newRefresh || acct.refreshToken, idToken }); return json(res, 200, { success: true, keyChanged, email: acct.email }); } catch (err) { return json(res, 400, { error: err.message }); } } json(res, 404, { error: `Dashboard API: ${method} ${subpath} not found` }); } // ─── Proxy connectivity test ────────────────────────────── // HTTP CONNECT tunnel to api.ipify.org:443 → GET / → the returned IP is the // proxy's egress IP. Confirms the proxy works AND that auth is accepted. // ─── Self-update helpers ─────────────────────────────── // // execFile (not exec) for every invocation: no shell is spawned, so // metacharacters in any future argument source are data, not commands. // Belt-and-braces with the branch-name regex in /self-update — if a // future refactor drops the regex, execFile still denies injection. const SELF_UPDATE_UNAVAILABLE = 'ERR_SELF_UPDATE_UNAVAILABLE'; const SELF_UPDATE_IN_PROGRESS = 'ERR_UPDATE_IN_PROGRESS'; const NO_RELEASE_TAG = 'ERR_NO_RELEASE_TAG'; const RELEASE_TAG_LOOKUP_FAILED = 'ERR_RELEASE_TAG_LOOKUP'; let gitExecFileForTest = null; let rollbackPointWriterForTest = null; let selfUpdateSupervisorDetectorForTest = null; let selfUpdateRestartSchedulerForTest = null; let dockerSelfUpdateOverridesForTest = null; let releaseAttestationVerifierForTest = null; let selfUpdateMutationInFlight = false; // Hold the mutation lock for the whole out-of-band deployer window (flush // delay + recreate + health gate + possible rollback + second gate), so a // retry cannot start while the deployer is still mutating the stack. const DOCKER_SELF_UPDATE_MUTATION_HOLD_MS = DOCKER_DEPLOYER_WINDOW_MS; // EX_TEMPFAIL: non-zero is intentional. systemd Restart=on-failure and PM2 // autorestart both relaunch after this self-update-only exit, while normal // SIGINT/SIGTERM shutdown keeps its separate successful exit path in index.js. export function selfUpdateRestartExitCode() { return 75; } export function setGitExecFileForTest(execFile) { gitExecFileForTest = execFile; } export function setRollbackPointWriterForTest(writer) { rollbackPointWriterForTest = typeof writer === 'function' ? writer : null; } export function setSelfUpdateSupervisorDetectorForTest(detector) { selfUpdateSupervisorDetectorForTest = typeof detector === 'function' ? detector : null; } export function setSelfUpdateRestartSchedulerForTest(scheduler) { selfUpdateRestartSchedulerForTest = typeof scheduler === 'function' ? scheduler : null; } export function setDockerSelfUpdateForTest(overrides) { dockerSelfUpdateOverridesForTest = overrides && typeof overrides === 'object' ? overrides : null; } export function setReleaseAttestationVerifierForTest(verifier) { releaseAttestationVerifierForTest = typeof verifier === 'function' ? verifier : null; } function detectSelfUpdateSupervisor() { return (selfUpdateSupervisorDetectorForTest || detectSupervisor)(); } function scheduleSelfUpdateRestart(callback, releaseMutation) { if (selfUpdateRestartSchedulerForTest) { return selfUpdateRestartSchedulerForTest(callback, releaseMutation); } const releaseOnExit = () => releaseMutation(); process.once('exit', releaseOnExit); setTimeout(() => { Promise.resolve() .then(callback) .catch((err) => { // A rejecting restart must not become an unhandled rejection that // crashes the process after the HTTP response already went out. The // durable journal (written by the caller) is the retry evidence. log.warn(`self-update: restart callback failed: ${err?.message || err}`); }) .finally(() => { process.off('exit', releaseOnExit); releaseMutation(); }); }, 800); return true; } function detectDockerSelfUpdateForRequest() { return (dockerSelfUpdateOverridesForTest?.detect || detectDockerSelfUpdate)(); } function runDockerSelfUpdateForRequest() { return (dockerSelfUpdateOverridesForTest?.run || runDockerSelfUpdate)(); } function scheduleDockerSelfUpdateMutationRelease(releaseMutation) { if (typeof dockerSelfUpdateOverridesForTest?.scheduleRelease === 'function') { return dockerSelfUpdateOverridesForTest.scheduleRelease(releaseMutation); } // The deployer sleeps for eight seconds and then runs compose out-of-band. // Keep both the in-process guard and repository lock owned across that // window. If compose fails and this process survives, a bounded timeout // eventually permits an operator retry; a successful recreate exits this // process first and releases through the exit hook. let released = false; const releaseOnce = () => { if (released) return; released = true; clearTimeout(timer); process.off('exit', releaseOnce); releaseMutation(); }; const timer = setTimeout(releaseOnce, DOCKER_SELF_UPDATE_MUTATION_HOLD_MS); timer.unref?.(); process.once('exit', releaseOnce); return true; } function makeSelfUpdateError(code, message = code) { const err = new Error(message); err.code = code; return err; } const SELF_UPDATE_LOCK_DIRNAME = 'windsurfapi-update.lock'; const ROLLBACK_POINT_FILENAME = 'self-update-before.json'; const ROLLBACK_POINT_PENDING_FILENAME = 'self-update-before.pending.json'; function repositoryGitDir(cwd = process.cwd()) { const dotGit = join(cwd, '.git'); if (!existsSync(dotGit)) return ''; try { if (statSync(dotGit).isDirectory()) return dotGit; const match = readFileSync(dotGit, 'utf8').trim().match(/^gitdir:\s*(.+)$/i); return match ? resolve(dirname(dotGit), match[1]) : ''; } catch { return ''; } } function processIsAlive(pid) { try { process.kill(pid, 0); return true; } catch (err) { // EPERM still means the process exists; only ESRCH proves a stale owner. return err?.code !== 'ESRCH'; } } function readUpdateLockOwner(lockDir) { try { const raw = readFileSync(join(lockDir, 'owner'), 'utf8').trim(); const match = raw.match(/^(\d+)\s+([A-Za-z0-9._-]+)$/); if (!match) return null; const pid = Number(match[1]); return Number.isSafeInteger(pid) && pid > 0 ? { pid, token: match[2], legacy: false } : null; } catch {} // Backward compatibility for locks created by v3.9.23/update.sh. A live // legacy PID still blocks; a dead one can be recovered through the same // exclusive recovery-claim protocol below. try { const raw = readFileSync(join(lockDir, 'pid'), 'utf8').trim(); if (!/^\d+$/.test(raw)) return null; const pid = Number(raw); return Number.isSafeInteger(pid) && pid > 0 ? { pid, token: '', legacy: true } : null; } catch { return null; } } function sameUpdateLockOwner(left, right) { return !!left && !!right && left.pid === right.pid && left.token === right.token && left.legacy === right.legacy; } function removeUpdateLockDirectory(lockDir, ownerToken = null) { if (ownerToken !== null) { const owner = readUpdateLockOwner(lockDir); if (!owner || owner.legacy || owner.pid !== process.pid || owner.token !== ownerToken) return false; } try { unlinkSync(join(lockDir, 'recovery')); } catch {} try { unlinkSync(join(lockDir, 'owner')); } catch {} try { unlinkSync(join(lockDir, 'pid')); } catch {} try { rmdirSync(lockDir); return true; } catch { return false; } } function recoverStaleUpdateLock(lockDir) { const observed = readUpdateLockOwner(lockDir); if (!observed || processIsAlive(observed.pid)) return false; // Claim recovery *inside the still-existing stale directory*. O_EXCL makes // exactly one contender the recovery owner; losers cannot unlink/recreate a // lock that another contender has already acquired (the stale-lock ABA). const claimToken = `${process.pid}-${randomUUID()}`; const claimPath = join(lockDir, 'recovery'); try { writeFileSync(claimPath, `${process.pid} ${claimToken}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 }); } catch { return false; } const quarantine = `${lockDir}.stale-${claimToken}`; try { if (!sameUpdateLockOwner(readUpdateLockOwner(lockDir), observed)) return false; renameSync(lockDir, quarantine); } catch { return false; } finally { // Before rename this removes only our own claim. After rename the old path // no longer exists, so cleanup continues against the quarantine below. try { unlinkSync(claimPath); } catch {} } removeUpdateLockDirectory(quarantine); return true; } function acquireRepositoryUpdateLock() { const gitDir = repositoryGitDir(); if (!gitDir) return () => {}; const lockDir = join(gitDir, SELF_UPDATE_LOCK_DIRNAME); const create = () => { try { mkdirSync(lockDir, { mode: 0o700 }); return true; } catch (err) { if (err?.code === 'EEXIST') return false; throw makeSelfUpdateError('ERR_UPDATE_LOCK_FAILED', `Could not create update lock: ${err.message}`); } }; if (!create()) { if (!recoverStaleUpdateLock(lockDir) || !create()) return null; } const ownerToken = randomUUID(); try { writeFileSync(join(lockDir, 'owner'), `${process.pid} ${ownerToken}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 }); // Keep the numeric pid file for one-release interoperability with older // update.sh versions; ownership and release authority come from `owner`. writeFileSync(join(lockDir, 'pid'), `${process.pid}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600 }); } catch (err) { removeUpdateLockDirectory(lockDir, ownerToken) || removeUpdateLockDirectory(lockDir); throw makeSelfUpdateError('ERR_UPDATE_LOCK_FAILED', `Could not initialize update lock: ${err.message}`); } let released = false; return () => { if (released) return; released = true; removeUpdateLockDirectory(lockDir, ownerToken); }; } function acquireSelfUpdateMutation() { if (selfUpdateMutationInFlight) return null; const releaseRepositoryLock = acquireRepositoryUpdateLock(); if (!releaseRepositoryLock) return null; selfUpdateMutationInFlight = true; let released = false; return () => { if (released) return; released = true; selfUpdateMutationInFlight = false; releaseRepositoryLock(); }; } function rollbackPointPaths({ ensureDir = false } = {}) { const dataDir = join(process.cwd(), 'data'); if (ensureDir) mkdirSync(dataDir, { recursive: true }); return { final: join(dataDir, ROLLBACK_POINT_FILENAME), pending: join(dataDir, ROLLBACK_POINT_PENDING_FILENAME), }; } // ─── Durable self-update journal (D6) ───────────────────── // // `{ok:true}` used to mean only "git reset applied / deployer sidecar // started". The restart (source) or recreate (docker) happens afterwards, and // a scheduler/deployer failure was indistinguishable from a verified healthy // rollback. This journal is written BEFORE the mutating restart is requested, // survives a crash, and is confirmed only when the running process reports the // commit `/health` would publish. The rollback point is retained until then, // so a failed or never-scheduled restart stays retryable. const SELF_UPDATE_JOURNAL_FILENAME = 'self-update-journal.json'; const SELF_UPDATE_PENDING_STATES = new Set([ 'pending-restart', 'restart-not-scheduled', 'restart-failed', ]); function selfUpdateJournalPath() { return join(process.cwd(), 'data', SELF_UPDATE_JOURNAL_FILENAME); } function readSelfUpdateJournal() { try { const value = JSON.parse(readFileSync(selfUpdateJournalPath(), 'utf8')); return value && typeof value === 'object' && !Array.isArray(value) ? value : null; } catch { return null; } } function writeSelfUpdateJournal(record) { const path = selfUpdateJournalPath(); mkdirSync(dirname(path), { recursive: true }); writeJsonAtomic(path, record); } function updateSelfUpdateJournal(patch) { const next = { ...(readSelfUpdateJournal() || {}), ...patch, updatedAt: Date.now() }; writeSelfUpdateJournal(next); return next; } let selfUpdateRunningCommitForTest = null; export function setSelfUpdateRunningCommitForTest(fn) { selfUpdateRunningCommitForTest = typeof fn === 'function' ? fn : null; } // The commit the restarted process would serve on /health. Defaults to the // build identity of the RUNNING process, which is exactly what /health // publishes (server.js VERSION_INFO.commit). function selfUpdateRunningCommit() { if (selfUpdateRunningCommitForTest) { try { return String(selfUpdateRunningCommitForTest() || ''); } catch { return ''; } } try { return String(getVersionInfo().commit || ''); } catch { return ''; } } function commitsCorrespond(left, right) { const a = String(left || '').trim().toLowerCase(); const b = String(right || '').trim().toLowerCase(); if (!a || !b) return false; const shortest = Math.min(a.length, b.length); if (shortest < 7) return false; return (a.startsWith(b) || b.startsWith(a)); } /** * Confirm a pending self-update journal against the running commit. * * Called on /self-update/check (and before a rollback attempt), i.e. after a * restart. A rollback is only "confirmed" when the process that answers now is * the commit the rollback reset to; that confirmation is what deletes the * rollback point. Anything else keeps both the journal and the point intact so * the operation remains retryable and its failure stays visible. */ export function reconcileSelfUpdateJournal() { const journal = readSelfUpdateJournal(); if (!journal || !SELF_UPDATE_PENDING_STATES.has(journal.state)) return journal; const expected = journal.expectedCommit || journal.beforeCommit; const running = selfUpdateRunningCommit(); if (!commitsCorrespond(running, expected)) return journal; const confirmed = { ...journal, state: 'confirmed', confirmedCommit: running, confirmedAt: Date.now(), updatedAt: Date.now(), }; try { writeSelfUpdateJournal(confirmed); } catch (err) { log.warn(`self-update: could not record journal confirmation: ${err.message}`); return journal; } if (journal.operation === 'rollback') { const paths = rollbackPointPaths(); try { unlinkSync(paths.final); } catch {} try { unlinkSync(paths.pending); } catch {} } return confirmed; } function nulSeparatedGitPaths(output) { return String(output || '').split('\0').filter(Boolean); } function gitPathsConflict(left, right) { // Be conservative on case-insensitive worktrees: a differently-cased local // ignored path can still be the same filesystem object reset --hard replaces. const a = left.replace(/\/$/, '').toLowerCase(); const b = right.replace(/\/$/, '').toLowerCase(); return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`); } async function findIgnoredTargetPathConflicts(targetCommit) { const treeOutput = await runGit(['ls-tree', '-r', '-z', '--name-only', targetCommit]); const targetPaths = nulSeparatedGitPaths(treeOutput); if (!targetPaths.length) return []; // First ask Git for a collapsed inventory of ignored roots. `--directory` // avoids descending into wholly ignored trees such as node_modules, while // still exposing partially ignored paths (for example data/accounts.json). // Matching roots case-insensitively here is deliberately independent of // core.ignorecase: the filesystem may be case-insensitive even when that // config is absent or stale. const targetRoots = [...new Set(targetPaths.map(path => path.split('/')[0]).filter(Boolean))]; const targetRootsFolded = new Set(targetRoots.map(root => root.toLowerCase())); const inventoryOutput = await runGit([ 'ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z', ]); const localRoots = [...new Set(nulSeparatedGitPaths(inventoryOutput) .map(path => path.replace(/\/$/, '').split('/')[0]) .filter(root => root && targetRootsFolded.has(root.toLowerCase())))]; if (!localRoots.length) return []; // Now enumerate only the actual local root spellings that can collide. This // keeps the precise ancestor/descendant test without scanning unrelated // ignored runtime data. const ignoredOutput = await runGit([ 'ls-files', '--others', '--ignored', '--exclude-standard', '-z', '--', ...localRoots.map(root => `:(top,literal)${root}`), ]); const ignoredPaths = nulSeparatedGitPaths(ignoredOutput); return [...new Set(ignoredPaths.filter(ignored => targetPaths.some(target => gitPathsConflict(ignored, target))))]; } function validGitObjectId(value) { return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(String(value || '')); } function parseGitNonnegativeCount(output, label) { const value = String(output ?? '').trim(); if (!/^(?:0|[1-9]\d*)$/.test(value)) { throw makeSelfUpdateError( 'ERR_INVALID_GIT_COUNT', `Git returned an invalid count while checking ${label}; refusing to infer zero.`, ); } const count = Number(value); if (!Number.isSafeInteger(count)) { throw makeSelfUpdateError( 'ERR_INVALID_GIT_COUNT', `Git returned an unsafe count while checking ${label}; refusing to continue.`, ); } return count; } async function currentStashTip() { const output = (await runGit(['for-each-ref', '--format=%(objectname)', 'refs/stash'])).trim(); if (!output) return ''; if (!validGitObjectId(output)) { throw new Error('refs/stash did not resolve to one valid object ID'); } return output; } function ownedStashOid(listing, marker) { const matches = String(listing || '').split(/\r?\n/).filter(Boolean).flatMap((line) => { const tab = line.indexOf('\t'); if (tab < 0 || !line.slice(tab + 1).includes(marker)) return []; const oid = line.slice(0, tab); if (!validGitObjectId(oid)) throw new Error('protective stash has an invalid object ID'); return [oid]; }); if (matches.length > 1) throw new Error('protective stash ownership is ambiguous'); return matches[0] || ''; } async function createProtectiveStash(label) { const beforeTip = await currentStashTip(); const marker = `windsurfapi-${label}-${randomUUID()}`; await runGit(['stash', 'push', '--include-untracked', '-m', marker]); const listing = await runGit(['stash', 'list', '--format=%H%x09%gs']); const oid = ownedStashOid(listing, marker); if (oid) return oid; // `git stash push` legitimately exits zero with "No local changes to save" // when another actor cleans the tree between the dirty probe and this call. // In that case no stash belongs to us. If refs/stash changed anyway, a // concurrent/user stash won the race; abort rather than ever treating it as // our recovery object. const afterTip = await currentStashTip(); if (afterTip !== beforeTip) { throw new Error('refs/stash changed, but the updater could not identify its own protective stash'); } return ''; } async function restoreProtectiveStash(oid) { if (!validGitObjectId(oid)) throw new Error('protective stash object ID is invalid'); // Apply the exact object. Never `stash pop` the current top: normal Git // commands are not serialized by the updater lock, so a user may have added // another stash after ours. Keep the owned stash as a recoverable backup. await runGit(['stash', 'apply', oid]); } function prepareRollbackPoint(value) { const { pending } = rollbackPointPaths({ ensureDir: true }); (rollbackPointWriterForTest || writeJsonAtomic)(pending, value); } function discardPendingRollbackPoint() { const { pending } = rollbackPointPaths(); try { unlinkSync(pending); } catch {} } function promotePendingRollbackPoint() { const { pending, final } = rollbackPointPaths(); renameSyncWithRetry(pending, final); } function reconcilePendingRollbackPoint(currentCommit) { const { pending } = rollbackPointPaths(); let point; try { point = JSON.parse(readFileSync(pending, 'utf8')); } catch { return; } const validSha = value => /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value); if (!validSha(point?.commit) || !validSha(point?.after) || point.after !== currentCommit) { discardPendingRollbackPoint(); return; } try { promotePendingRollbackPoint(); } catch (err) { if (err?.renamePublished) { log.warn(`self-update: recovered rollback point final file is visible, but its directory fsync failed: ${err.message}`); return; } throw makeSelfUpdateError( 'ERR_ROLLBACK_POINT_RECOVERY_FAILED', `A pending rollback point matches current HEAD but could not be recovered: ${err.message}`, ); } } function makeSelfUpdateUnavailableError() { const err = new Error(SELF_UPDATE_UNAVAILABLE); err.code = SELF_UPDATE_UNAVAILABLE; err.reason = 'docker'; return err; } function isSelfUpdateUnavailableError(err) { return err?.code === SELF_UPDATE_UNAVAILABLE || err?.message === SELF_UPDATE_UNAVAILABLE; } function hasGitMetadata(cwd = process.cwd()) { return existsSync(join(cwd, '.git')); } async function getGitExecFile() { if (gitExecFileForTest) return gitExecFileForTest; const { execFile } = await import('node:child_process'); return execFile; } export function runGit(args, opts = {}) { return new Promise((resolve, reject) => { if (!hasGitMetadata(opts.cwd)) return reject(makeSelfUpdateUnavailableError()); getGitExecFile().then((execFile) => { execFile('git', args, { timeout: 30_000, maxBuffer: 1024 * 1024, ...opts }, (err, stdout, stderr) => { if (err?.code === 'ENOENT') return reject(makeSelfUpdateUnavailableError()); if (err) { const failure = new Error((stderr || err.message).toString().slice(0, 500)); // `git merge-base --is-ancestor` uses exit 1 for the ordinary // "not an ancestor" answer and other exit codes for indeterminate // failures. Preserve the numeric status so release discovery can skip // an off-branch tag without turning a real Git/protocol failure into a // false negative. if (Number.isInteger(err.code)) failure.gitExitCode = err.code; return reject(failure); } resolve(stdout.toString()); }); }).catch(reject); }); } function stableReleaseVersion(tag) { const match = String(tag || '').match(/^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/); return match ? match.slice(1) : null; } function compareDecimalComponents(left, right) { if (left.length !== right.length) return left.length > right.length ? 1 : -1; if (left === right) return 0; return left > right ? 1 : -1; } function compareStableReleaseTags(left, right) { const a = stableReleaseVersion(left); const b = stableReleaseVersion(right); for (let i = 0; i < 3; i++) { const compared = compareDecimalComponents(a[i], b[i]); if (compared) return compared; } return 0; } function validRemoteTagName(tag) { return !!tag && !tag.startsWith('.') && !tag.endsWith('.') && !tag.startsWith('/') && !tag.endsWith('/') && !tag.includes('..') && !tag.includes('//') && !tag.includes('@{') && !/[\x00-\x20\x7f~^:?*[\\]/.test(tag); } function parseRemoteStableReleaseTags(output) { const byName = new Map(); for (const line of String(output || '').split(/\r?\n/).filter(Boolean)) { const match = line.match(/^([0-9a-f]{40}|[0-9a-f]{64})\trefs\/tags\/(.+)$/i); if (!match) throw new Error('remote tag enumeration returned a malformed record'); const oid = match[1].toLowerCase(); const peeled = match[2].endsWith('^{}'); const name = peeled ? match[2].slice(0, -3) : match[2]; if (!validRemoteTagName(name)) throw new Error('remote tag enumeration returned an invalid ref name'); if (!stableReleaseVersion(name)) continue; const entry = byName.get(name) || { name, object: '', peeled: '' }; const field = peeled ? 'peeled' : 'object'; if (entry[field]) throw new Error(`remote tag enumeration duplicated ${name}${peeled ? '^{}' : ''}`); entry[field] = oid; byName.set(name, entry); } const candidates = [...byName.values()]; for (const candidate of candidates) { if (!candidate.object) throw new Error(`remote tag ${candidate.name} has a peeled record without its tag ref`); } candidates.sort((left, right) => compareStableReleaseTags(right.name, left.name)); return candidates; } async function materializeRemoteReleaseTag(candidate) { // Fetch the exact remote ref only into FETCH_HEAD. This retrieves annotated // tag objects when needed but never creates, overwrites, or prunes a local tag: // operator-owned and previously fetched tags remain untouched. await runGit(['fetch', '--quiet', '--no-tags', 'origin', `refs/tags/${candidate.name}`]); const fetchedObject = (await runGit(['rev-parse', 'FETCH_HEAD'])).trim().toLowerCase(); if (!validGitObjectId(fetchedObject) || fetchedObject !== candidate.object) { throw new Error(`remote tag ${candidate.name} changed during verification`); } const fetchedCommit = (await runGit(['rev-parse', 'FETCH_HEAD^{commit}'])).trim().toLowerCase(); if (!validGitObjectId(fetchedCommit)) throw new Error(`remote tag ${candidate.name} did not peel to a commit`); const objectType = (await runGit(['cat-file', '-t', fetchedObject])).trim(); if (candidate.peeled) { if (objectType !== 'tag' || fetchedCommit !== candidate.peeled) { throw new Error(`annotated remote tag ${candidate.name} failed object/peeled-commit correspondence`); } } else if (objectType !== 'commit' || fetchedCommit !== fetchedObject) { // A standards-compliant ls-remote includes ^{} for an annotated tag. Treat // an omitted/mismatched peeled record as a protocol anomaly, not as a // lightweight release. throw new Error(`lightweight remote tag ${candidate.name} did not name one commit object`); } return { ...candidate, commit: fetchedCommit }; } async function gitCommitIsAncestor(commit, ref) { try { await runGit(['merge-base', '--is-ancestor', commit, ref]); return true; } catch (err) { if (err?.gitExitCode === 1) return false; throw err; } } async function latestReleaseTag(branch) { // Query the remote tag namespace directly. `git fetch --tags` is additive: // when a mistaken/revoked release tag is deleted upstream, its local ref // survives and `git tag --list` keeps selecting it indefinitely. Remote refs // are the publication authority; local tags are neither read nor mutated. try { const output = await runGit(['ls-remote', '--tags', 'origin', 'refs/tags/v[0-9]*']); const remoteRef = `origin/${branch}`; for (const advertised of parseRemoteStableReleaseTags(output)) { const candidate = await materializeRemoteReleaseTag(advertised); if (await gitCommitIsAncestor(candidate.commit, remoteRef)) return candidate; } return { name: '', commit: '' }; } catch (cause) { const err = makeSelfUpdateError( RELEASE_TAG_LOOKUP_FAILED, 'Unable to verify published release tags; refusing an unverified update target.', ); err.cause = cause; throw err; } } /** * D9 source-OTA attestation: verify the tag `releaseUpdateState` selected. * The candidate has already been materialized and proven reachable; this binds * its peeled commit to the signed release-attestation.json before any mutation * target is returned. `warn` is the default rollout so existing installs keep * updating; `required` makes missing material fatal. */ async function verifySelectedReleaseAttestation(release) { const mode = normalizeAttestationMode(process.env.WINDSURFAPI_UPDATE_ATTESTATION); let repository = ''; try { const originUrl = (await runGit(['remote', 'get-url', 'origin'])).trim(); const parsed = parseGitHubRepository(originUrl); if (parsed && parsed.toLowerCase() === DEFAULT_ATTESTATION_REPOSITORY.toLowerCase()) { repository = DEFAULT_ATTESTATION_REPOSITORY; } } catch { // A missing/unreadable origin is not a policy failure by itself: without a // canonical repository there is no attestation to download, and the mode // decides whether that absence is fatal (required) or a warning (warn). repository = ''; } const options = { tag: release.name, commit: release.commit, mode, dir: process.env.WINDSURFAPI_ATTESTATION_DIR || '', repository, }; const result = releaseAttestationVerifierForTest ? await releaseAttestationVerifierForTest(options) : await verifyReleaseAttestation(options); if (result?.warning) log.warn(`self-update attestation: ${result.warning}`); return result; } function safeUpdateBranch(branch) { const candidate = String(branch || '').trim(); // `git rev-parse --abbrev-ref HEAD` returns the literal "HEAD" for a // detached checkout, which is normal for tag-based deployments. origin/HEAD // is not guaranteed to exist locally, so use this repository's canonical // release branch instead. return candidate !== 'HEAD' && /^[\w.\-\/]+$/.test(candidate) ? candidate : 'master'; } async function releaseUpdateState(branch, currentCommit, { followUnreleased = false } = {}) { const remoteRef = `origin/${branch}`; const remoteHead = (await runGit(['rev-parse', remoteRef])).trim(); const release = await latestReleaseTag(branch); const latestTag = release.name; if (!latestTag && !followUnreleased) { throw makeSelfUpdateError( NO_RELEASE_TAG, 'No stable release tag is reachable from the update branch; use forceUpdate only if you intentionally want the branch head.', ); } // D9: bind the selected release tag's peeled commit to the signed release // attestation before `releasedCommit` can become a mutation target. if (latestTag) await verifySelectedReleaseAttestation(release); const releasedCommit = release.commit; const targetCommit = followUnreleased ? remoteHead : releasedCommit; let unreleasedCount = 0; if (latestTag) { unreleasedCount = parseGitNonnegativeCount(await runGit([ 'rev-list', '--count', `${releasedCommit}..${remoteHead}`, ]), 'commits after the latest release'); } const commitsToTarget = parseGitNonnegativeCount(await runGit([ 'rev-list', '--count', `${currentCommit}..${targetCommit}`, ]), 'commits from the current checkout to the update target'); const commitsPastTarget = parseGitNonnegativeCount(await runGit([ 'rev-list', '--count', `${targetCommit}..${currentCommit}`, ]), 'commits past the update target'); return { remoteHead, latestTag, releasedCommit, targetCommit, unreleasedCount, commitsToTarget, commitsPastTarget, updateAvailable: commitsToTarget > 0 && commitsPastTarget === 0, diverged: commitsToTarget > 0 && commitsPastTarget > 0, }; } async function gitStatus() { const commit = (await runGit(['rev-parse', 'HEAD'])).trim(); const branch = (await runGit(['rev-parse', '--abbrev-ref', 'HEAD'])).trim(); let update = null; let updateError = ''; try { const safeBranch = safeUpdateBranch(branch); await runGit(['fetch', '--quiet', '--no-tags', 'origin', safeBranch]); update = await releaseUpdateState(safeBranch, commit); } catch (err) { if (isSelfUpdateUnavailableError(err)) throw err; updateError = err?.code || err?.message || 'ERR_UPDATE_CHECK_FAILED'; } const localMsg = (await runGit(['log', '-1', '--pretty=format:%s'])).trim(); const target = update?.targetCommit || ''; const remoteMsg = update?.updateAvailable ? (await runGit(['log', '-1', '--pretty=format:%s', target]).catch(() => '')).trim() : ''; return { commit: commit.slice(0, 7), commitFull: commit, branch, localMessage: localMsg, // "remote" in the dashboard means the safe OTA target: latest release // tag by default. remoteHeadCommit is diagnostic only and may contain // release notes/generated assets/next-release work after that tag. remoteCommit: target ? target.slice(0, 7) : '', remoteHeadCommit: update?.remoteHead ? update.remoteHead.slice(0, 7) : '', remoteMessage: remoteMsg, behind: !!update?.updateAvailable, diverged: !!update?.diverged, latestTag: update?.latestTag || '', published: !!update?.latestTag, updateError, unreleasedCount: update?.unreleasedCount || 0, }; } // Exported (and timeout-parameterised) so the RATE-RISK red→green test can pin // the TLS-stage deadline in ms instead of waiting out the 10s default; the // dashboard route below calls it with the default. export async function testProxy({ host, port, username, password, type }, tlsTimeoutMs = 10_000) { if (config.allowPrivateProxyHosts) { await validateHostFormat(host); } else { await assertPublicUrlHost(host); } const { isSocks, createSocksTunnel } = await import('../socks.js'); const tls = await import('node:tls'); const targetHost = 'api.ipify.org'; const targetPort = 443; const proxy = { host, port, username, password, type }; // Get a raw TCP socket — either via SOCKS5 or HTTP CONNECT let socket; if (isSocks(proxy)) { socket = await createSocksTunnel(proxy, targetHost, targetPort, 10000); } else { const http = await import('node:http'); // #11: pin the proxy host to a vetted IP literal so this CONNECT does not // re-resolve the (already validated) hostname via a second, rebindable lookup. const dialHost = await resolveProxyConnectHost(host, { allowPrivate: config.allowPrivateProxyHosts }); socket = await new Promise((resolve, reject) => { const authHeader = username ? { 'Proxy-Authorization': 'Basic ' + Buffer.from(`${username}:${password || ''}`).toString('base64') } : {}; const req = http.request({ host: dialHost, port, method: 'CONNECT', path: `${targetHost}:${targetPort}`, headers: { Host: `${targetHost}:${targetPort}`, ...authHeader }, timeout: 10000, }); req.on('connect', (res, sock) => { if (res.statusCode !== 200) { sock.destroy(); return reject(new Error(`ERR_PROXY_HTTP_ERROR:${res.statusCode}`)); } resolve(sock); }); req.on('error', (err) => reject(new Error(`ERR_CONNECTION_FAILED:${err.message}`))); req.on('timeout', () => { req.destroy(); reject(new Error('ERR_TIMEOUT')); }); req.end(); }); } // TLS handshake + GET to verify the tunnel works. SEC-W5: verify the cert — // targetHost is a fixed, well-known host (api.ipify.org) with a valid public // certificate, so there is no reason to accept an unverified cert. Leaving // rejectUnauthorized:false would let a MITM (or a malicious proxy itself) // present any cert and still pass the "tunnel works" check. return new Promise((resolve, reject) => { const tlsSock = tls.connect({ socket, servername: targetHost, rejectUnauthorized: true }, () => { tlsSock.write(`GET / HTTP/1.1\r\nHost: ${targetHost}\r\nConnection: close\r\nUser-Agent: WindsurfAPI/ProxyTest\r\n\r\n`); }); // R-5 (RATE-RETRY audit): the CONNECT stage has a 10s timer, but the TLS // stage had none — a target that completes the tunnel and then goes // silent left this promise pending forever (the dashboard button spun). // destroy() alone emits no 'error' on a raw socket, so settle explicitly. tlsSock.setTimeout(tlsTimeoutMs, () => { tlsSock.destroy(); reject(new Error('ERR_TIMEOUT')); }); const chunks = []; tlsSock.on('data', c => chunks.push(c)); tlsSock.on('end', () => { const body = Buffer.concat(chunks).toString('utf-8'); const match = body.match(/\r\n\r\n([^\r\n]+)/); const ip = match ? match[1].trim() : ''; tlsSock.destroy(); if (!ip || !/^\d+\.\d+\.\d+\.\d+$/.test(ip)) { return reject(new Error('ERR_TLS_TUNNEL_ERROR')); } resolve({ egressIp: ip, type }); }); tlsSock.on('error', (err) => reject(new Error(`ERR_TLS_FAILED:${err.message}`))); }); }