server.js
Rama main · réplica de lectura del repositorio original.
'use strict';
// Zero-dependency local server. Binds loopback only; it holds real OAuth tokens.
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const { URL } = require('node:url');
const P = require('./lib/paths');
const store = require('./lib/store');
const oauth = require('./lib/oauth');
const usage = require('./lib/usage');
const swap = require('./lib/swap');
const credentials = require('./lib/credentials');
const targets = require('./lib/targets');
const terminal = require('./lib/terminal');
const auto = require('./lib/auto');
// Codex lives in its own package, with its own store: nothing below this line ever sees it.
const codexRoutes = require('./lib/codex/routes');
const codexSwap = require('./lib/codex/swap');
const codexPaseo = require('./lib/codex/paseo');
const codexDaemon = require('./lib/codex/daemon');
// The host the BROWSER uses, and the only one the Host-header allowlist accepts. Kept separate
// from the bind address on purpose: a container has to listen on all of its own interfaces to be
// reachable at all, but that must not widen what this server accepts as a legitimate origin.
const HOST = '127.0.0.1';
// Where the socket actually binds. Loopback everywhere except inside a container, where the
// Dockerfile sets 0.0.0.0. The loopback guarantee then moves outward, to how the port is
// published: -p 127.0.0.1:7373:7373 - see the README.
const BIND = process.env.SWAPPER_BIND || HOST;
const BASE_PORT = Number(process.env.PORT) || 7373;
const PUBLIC_DIR = path.join(__dirname, 'public');
const MAX_BODY = 1024 * 1024;
const deps = { store, oauth, usage, swap };
const MIME = {
'.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8',
'.js': 'text/javascript; charset=utf-8', '.json': 'application/json; charset=utf-8',
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.png': 'image/png', '.woff2': 'font/woff2',
};
function send(res, status, body, headers = {}) {
const payload = typeof body === 'string' ? body : JSON.stringify(body);
res.writeHead(status, {
'Content-Type': typeof body === 'string' ? 'text/html; charset=utf-8' : 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(payload),
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
...headers,
});
res.end(payload);
}
const fail = (res, status, message) => send(res, status, { ok: false, error: oauth.scrub(message) });
function readBody(req) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
req.on('data', (c) => {
size += c.length;
if (size > MAX_BODY) { reject(new Error('Cuerpo de la petición demasiado grande')); req.destroy(); return; }
chunks.push(c);
});
req.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8').trim();
if (!text) return resolve({});
try { resolve(JSON.parse(text)); } catch { reject(new Error('JSON inválido en el cuerpo')); }
});
req.on('error', reject);
});
}
/**
* Two guards against a random website in the user's browser driving this API:
* a same-origin check, and a custom header that no cross-site form, <img> or <script>
* can set. The header covers the whole API, GET included: read-only is not the same as
* harmless here. /api/health spawns a process per call (a page could pin the event loop
* with a loop of <img> tags), and /api/usage spends the app's entire request budget for
* the whole 5-minute window.
*/
// Loopback by NAME. The port is deliberately not part of this: a container listens on 7373 and
// is published as whatever the user chose, so the Host the browser sends carries the PUBLISHED
// port, which this process has no way of knowing. Pinning the port there rejected every
// containerised request with "Host no permitido".
//
// Dropping it costs nothing, because the port was never what defended anything. The attack this
// guards against is DNS rebinding: a page on evil.com whose domain resolves to 127.0.0.1, so the
// browser really does connect to this socket. What gives it away is the Host header - it says
// "evil.com", because the browser fills it from the URL the page used. A hostname check catches
// that; the port never entered into it. An ordinary cross-origin fetch is stopped twice over,
// by the Origin check below and by the X-Swapper header a cross-site request cannot set.
const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
/** "127.0.0.1:27387" -> "127.0.0.1", "[::1]:7373" -> "[::1]". Empty when unparseable. */
function hostnameOf(value) {
const s = String(value == null ? '' : value).trim();
if (!s) return '';
if (s[0] === '[') { const end = s.indexOf(']'); return end < 0 ? '' : s.slice(0, end + 1); }
return s.split(':')[0];
}
// SWAPPER_ALLOWED_HOSTS ensancha ese conjunto, separado por comas: "192.168.1.160,mi-pc".
// Existe para abrir el panel a otro dispositivo de la red local, viene apagado y hay que
// escribirlo a proposito, porque entrega el panel a cualquiera que sepa enrutar hasta esta
// maquina: aqui no hay login, asi que quien llegue puede cambiar, renombrar y borrar cuentas.
// Solo en una red en la que confies.
const ALLOWED_HOSTS = new Set([
...LOOPBACK,
...String(process.env.SWAPPER_ALLOWED_HOSTS || '')
.split(',')
.map((s) => hostnameOf(s.trim().toLowerCase()))
.filter(Boolean),
]);
// Ya no se llama isLoopback: con SWAPPER_ALLOWED_HOSTS puesto devuelve true a proposito para una
// direccion de LAN, y un predicado cuyo nombre niega lo que hace es como se equivoca el siguiente
// que lo lea. Los nombres de host no distinguen mayusculas, de ahi el toLowerCase.
const isAllowedHost = (value) => ALLOWED_HOSTS.has(hostnameOf(String(value == null ? '' : value).toLowerCase()));
function guard(req, res, pathname) {
if (!isAllowedHost(req.headers.host)) { fail(res, 403, 'Host no permitido'); return false; }
const origin = req.headers.origin;
if (origin) {
let ok = false;
try {
const u = new URL(origin);
ok = (u.protocol === 'http:' || u.protocol === 'https:') && isAllowedHost(u.host);
} catch { ok = false; }
if (!ok) { fail(res, 403, 'Origen no permitido'); return false; }
}
// Static assets are exempt: the browser loads /style.css with no say in its headers.
if (pathname.startsWith('/api/') && req.headers['x-swapper'] !== '1') {
fail(res, 403, 'Falta la cabecera X-Swapper'); return false;
}
return true;
}
function serveStatic(res, pathname) {
const rel = pathname === '/' ? 'index.html' : decodeURIComponent(pathname).replace(/^\/+/, '');
const target = path.resolve(PUBLIC_DIR, rel);
// Traversal guard: the resolved path must still sit inside public/.
if (target !== PUBLIC_DIR && !target.startsWith(PUBLIC_DIR + path.sep)) return fail(res, 403, 'Prohibido');
fs.readFile(target, (err, data) => {
if (err) return fail(res, 404, 'No encontrado');
res.writeHead(200, {
'Content-Type': MIME[path.extname(target).toLowerCase()] || 'application/octet-stream',
'Content-Length': data.length,
'Cache-Control': 'no-store',
});
res.end(data);
});
}
/**
* Credentials that OUTRANK the file this app writes.
*
* Verified by pointing Claude Code at a local server and reading the headers it sent: with any of
* these set it never touches ~/.claude/.credentials.json, so every swap becomes a silent no-op -
* the panel reports success, the account changes on disk, and the CLI keeps using the variable.
* That failure is invisible from inside the app, which is exactly why it is worth naming.
*/
const OVERRIDING_ENV = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'];
const overridingEnv = () => OVERRIDING_ENV.filter((k) => process.env[k]);
/** Turn a set of tokens into a stored account, using the live profile as the source of truth. */
async function adoptTokens(tokenOauth, existingUserID) {
const { email, profile } = await oauth.fetchProfile(tokenOauth.accessToken);
// A full login says its plan (organizationRateLimitTier, or the rateLimitTier beside the
// tokens), so it is stored rather than asked. null when it says nothing: the tier on file stays.
const tier = store.tierFromRateLimit({ profile, oauth: tokenOauth });
return store.add({ email, oauth: tokenOauth, profile, userID: existingUserID || null, tier });
}
// The four plans a tier may name, as the error text lists them.
const TIER_LIST = store.TIERS.join(', ');
const BUILD = (() => {
try { return fs.readFileSync(path.join(__dirname, '.build'), 'utf8').trim(); } catch { return ''; }
})();
async function handleApi(req, res, url, port) {
const { pathname } = url;
const method = req.method;
if (pathname.startsWith('/api/codex/')) {
if (await codexRoutes.handle(req, res, url, { send, fail, readBody })) return;
return fail(res, 404, 'Endpoint desconocido');
}
if (pathname === '/api/health' && method === 'GET') {
const procs = swap.detectClaudeProcesses();
return send(res, 200, {
ok: true, claudeRunning: procs.running, pids: procs.pids, node: process.version,
platform: process.platform,
credentialsBackend: credentials.describeBackend(),
overridingEnv: overridingEnv(),
// What this process genuinely cannot do from where it is running. The UI shows it rather
// than silently degrading, because "0 procesos" and "no puedo verlos" look identical on a
// screen and mean opposite things.
container: P.inContainer(),
unavailable: P.inContainer() ? ['processDetection', 'wslTargets'] : [],
// Stamped by the Dockerfile. Lets the panel say which build is running, because a
// `docker compose up -d` without --build keeps serving the previous image in silence.
build: BUILD,
// The bind-mounted ~/.claude.json no longer being the host's file (Linux hosts only).
staleMount: P.inContainer() && P.isDetachedMount(P.claudeJsonPath()),
paths: { claudeJson: P.claudeJsonPath(), data: P.dataDir() },
});
}
// Environments a swap can target: the host, plus any WSL distro with Claude installed.
// Each reports which account is active there and whether Claude is running in it.
if (pathname === '/api/targets' && method === 'GET') {
const force = url.searchParams.get('force') === '1';
const list = targets.list({ force });
return send(res, 200, {
targets: list.map((t) => ({
id: t.id,
kind: t.kind,
label: t.label,
// What is live first: a `claude /login` outside the panel moves it without telling the store.
activeId: swap.detectActiveId(t.id, store) || store.activeFor(t.id),
running: targets.detectRunning(t).running,
})),
});
}
if (pathname === '/api/accounts' && method === 'GET') {
const targetId = url.searchParams.get('target') || 'host';
const view = store.publicView(targetId);
// The account REALLY live in that environment, read from its config, wins over the store's
// record: never swapped there, or moved by a `claude /login` outside the panel. Nothing is written.
const det = swap.detectActiveId(targetId, store);
if (det && det !== view.activeId) { view.activeId = det; for (const a of view.accounts) a.isActive = a.id === det; }
return send(res, 200, view);
}
if (pathname === '/api/usage/all' && method === 'GET') {
const force = url.searchParams.get('force') === '1';
return send(res, 200, await usage.fetchAll(store.list(), { force }));
}
if (pathname === '/api/usage' && method === 'GET') {
const account = store.get(url.searchParams.get('id'));
if (!account) return fail(res, 404, 'Cuenta no encontrada');
return send(res, 200, await usage.fetchFor(account, { force: url.searchParams.get('force') === '1' }));
}
if (pathname === '/api/swap' && method === 'POST') {
const { id, target } = await readBody(req);
if (!id) return fail(res, 400, 'Falta el id de cuenta');
try {
return send(res, 200, await swap.swapTo(id, deps, target));
} catch (err) {
// 400 for a target that does not resolve, 409 for a swap already in flight; 500 otherwise.
return fail(res, err.status || 500, err.message);
}
}
if (pathname === '/api/swap/dryrun' && method === 'POST') {
const { id, target } = await readBody(req);
try { return send(res, 200, await swap.dryRun(id, deps, target)); }
catch (err) { return fail(res, 400, err.message); }
}
if (pathname === '/api/accounts/import' && method === 'POST') {
const { configDir, target } = await readBody(req);
const tgId = target || 'host';
if (!targets.resolve(tgId)) return fail(res, 400, `Target desconocido: ${tgId}`);
const identity = swap.readCurrentIdentity(configDir, tgId);
if (!identity) {
const tg = targets.resolve(tgId);
const where = tg && tg.kind === 'wsl' ? ` en ${tg.label}` : '';
return fail(res, 400, configDir
? `No se encontró ninguna sesión en ${configDir}`
: `No hay ninguna sesión de Claude Code activa que importar${where}. Ejecuta "claude", haz /login y vuelve a pulsar import.`);
}
try {
const account = await adoptTokens(identity.oauth, identity.userID);
// The live config of that target reflects the account actually in use there.
if (!configDir) store.setActive(account.id, tgId);
return send(res, 200, { ok: true, account: store.publicAccount(account.id, tgId) });
} catch (err) {
return fail(res, 502, `No se pudo verificar la cuenta actual: ${err.message}`);
}
}
/**
* Add an account by PASTING a long-lived token, with no login and nothing imported.
*
* `claude setup-token` mints a token that is valid for a year but carries only user:inference,
* so it can never tell us who it belongs to. probeToken sorts that out without spending
* inference and without touching the rate-limited usage endpoint:
* 403 + scope complaint -> a real setup-token. Stored with honest scopes; no usage meters.
* 200 -> a full-scope token. We fetch the profile and it behaves like an
* imported account, except it can never be renewed.
* 401 -> rejected, and nothing is written.
*
* `tier` is the plan, which an inference-only token cannot reveal, so the form asks for it.
* Absent (or null) is "unknown" for older clients, and never erases a tier already on file.
*/
if (pathname === '/api/accounts/token' && method === 'POST') {
const body = await readBody(req);
const token = typeof body.token === 'string' ? body.token.trim() : '';
const label = typeof body.label === 'string' ? body.label.trim().slice(0, 60) : '';
if (!token) return fail(res, 400, 'Pega un token para añadir la cuenta');
const tier = body.tier === undefined || body.tier === null ? null : body.tier;
// Refused before the probe: a typo in the plan must not spend a request or write anything.
if (tier !== null && !store.isTier(tier)) return fail(res, 400, `Tipo de cuenta no válido: usa ${TIER_LIST}`);
try {
const probe = await oauth.probeToken(token);
let email = null;
let profile = null;
let scopes = oauth.INFERENCE_ONLY_SCOPES;
if (probe.kind === 'full') {
const who = await oauth.fetchProfile(token);
email = who.email;
profile = who.profile;
scopes = oauth.SCOPES;
}
// Pasting over an account that could renew itself is a real downgrade, and it happens
// silently inside store.add. Detect it BEFORE the write so the answer can say so.
const priorId = profile || email ? store.idFor(profile, email) : store.idForToken(token);
const prior = store.get(priorId);
const losesRenewal = !!(prior && prior.oauth && prior.oauth.refreshToken);
const account = store.add({
label: label || null,
tier,
email,
profile,
oauth: {
accessToken: token,
// Never '' - Claude Code reads an empty refreshToken as a dead-token sentinel.
refreshToken: null,
// Optimistic by construction: we stamp a year from NOW because the token itself does
// not say when it was minted. A token pasted late in its life will therefore look
// healthier than it is; the swap's own verification is what actually catches a dead
// one, by rolling back on a 401.
expiresAt: Date.now() + oauth.LONG_LIVED_MS,
refreshTokenExpiresAt: null,
scopes,
subscriptionType: null,
rateLimitTier: null,
},
});
const warnings = [];
if (probe.kind === 'inference') {
warnings.push('Token solo de inferencia: el swap funciona, pero esta cuenta no puede mostrar consumo.');
}
if (losesRenewal) {
warnings.push('Esta cuenta ya estaba guardada con un token renovable; el token pegado lo sustituye y ya no se renovará sola.');
}
return send(res, 200, { ok: true, kind: probe.kind, warnings, account: store.publicAccount(account.id) });
} catch (err) {
const status = err.malformed ? 400 : err.status === 401 ? 401 : 502;
return fail(res, status, err.message);
}
}
/**
* Abrir una terminal con `claude setup-token`, para no obligar a buscar una y teclearlo.
*
* No recibe NADA del cliente: el comando es una constante en lib/terminal.js y viaja como argv,
* nunca como una cadena que un shell tenga que reinterpretar. El cuerpo de la peticion se
* ignora a proposito - no hay ningun parametro que un atacante pudiera doblar.
*
* Las dos negativas se dan antes de lanzar nada, porque una ventana que parpadea y muere
* enseñando "command not found" es peor que un mensaje claro.
*/
if (pathname === '/api/token/terminal' && method === 'POST') {
if (P.inContainer()) {
return fail(res, 409, 'Dentro de un contenedor no hay terminal que abrir. '
+ 'Ejecuta "claude setup-token" en tu máquina y pega el token aquí.');
}
if (!terminal.claudeInstalled()) {
return fail(res, 409, 'No encuentro el comando "claude" en el PATH de este proceso. '
+ 'Instala Claude Code, o ejecuta "claude setup-token" donde lo tengas y pega el token.');
}
try {
return send(res, 200, { ok: true, how: terminal.openSetupToken() });
} catch (err) {
return fail(res, 500, err.message);
}
}
// Automatic rotation: on/off + status. GET reports current + next account without
// spending any API budget (cached readings only); POST toggles it and the same status.
if (pathname === '/api/auto' && method === 'GET') {
return send(res, 200, await auto.status(deps));
}
if (pathname === '/api/auto' && method === 'POST') {
const body = await readBody(req);
const patch = {};
if (typeof body.enabled === 'boolean') patch.enabled = body.enabled;
if (typeof body.target === 'string' && body.target) patch.target = body.target;
if (Number.isFinite(body.threshold)) patch.threshold = body.threshold;
auto.set(patch);
return send(res, 200, await auto.status(deps));
}
const idMatch = pathname.match(/^\/api\/accounts\/([A-Za-z0-9_]+)$/);
if (idMatch) {
const id = idMatch[1];
if (method === 'PATCH') {
const body = await readBody(req);
const patch = {};
if (typeof body.label === 'string' && body.label.trim()) patch.label = body.label.trim().slice(0, 60);
if (typeof body.color === 'string' && /^#[0-9a-f]{6}$/i.test(body.color)) patch.color = body.color;
// The plan picked in the panel; null clears it back to "unknown" (and to what Anthropic reports).
if (Object.prototype.hasOwnProperty.call(body, 'tier')) {
if (body.tier !== null && !store.isTier(body.tier)) return fail(res, 400, `Tipo de cuenta no válido: usa ${TIER_LIST} o null`);
patch.tier = body.tier;
}
if (!Object.keys(patch).length) return fail(res, 400, 'Nada que actualizar');
const updated = store.update(id, patch);
if (!updated) return fail(res, 404, 'Cuenta no encontrada');
return send(res, 200, { ok: true, account: store.publicAccount(id) });
}
if (method === 'DELETE') {
usage.invalidate(id);
return store.remove(id) ? send(res, 200, { ok: true }) : fail(res, 404, 'Cuenta no encontrada');
}
}
return fail(res, 404, 'Endpoint desconocido');
}
/**
* The whole point of this app is never logging in again. Access tokens last ~8h and
* refresh tokens ~29 days, rotating on each use - so an account left untouched for a
* month would die and need a real login. This keeps every stored account alive in the
* background, whether or not you ever swap to it.
* Uses the token endpoint, NOT the rate-limited usage endpoint.
*/
const KEEPALIVE_EVERY_MS = 6 * 60 * 60 * 1000; // every 6h
const REFRESH_WHEN_UNDER_MS = 24 * 60 * 60 * 1000; // renew if under a day of life left
// How often the auto-rotation monitor looks. The active-account usage read is cache-gated
// (a real call only every ~4 min), so a 3-minute look is cheap and reacts promptly.
const AUTO_EVERY_MS = 3 * 60 * 1000;
async function keepTokensAlive() {
// First, pick up a rotation Claude Code performed on its own, in every environment. Renewing
// with a token it already replaced would fail with invalid_grant and strand the account.
const listed = targets.list();
for (const t of listed) {
const healed = swap.adoptLiveTokens(store, t);
if (healed) {
const who = (store.get(healed) || {}).email || healed;
console.log(` sesión viva adoptada: ${who}${t.kind === 'host' ? '' : ` en ${t.label}`} (Claude Code había rotado su token)`);
}
}
const active = store.load().active || {};
for (const account of store.list()) {
const o = account.oauth;
if (!o || !o.refreshToken) continue;
const life = (o.expiresAt || 0) - Date.now();
if (life > REFRESH_WHEN_UNDER_MS) continue;
// Live in a distro that is stopped: its copy there cannot be updated, and renewing would kill
// it. Its own Claude Code renews it when it runs, and the adoption above picks that up.
if (Object.entries(active).some(([t, v]) => v === account.id && !listed.some((x) => x.id === t))) continue;
if (store.sessionOver(account)) {
// Renewing never extends a login (store.sessionOver), and past its end Anthropic only answers
// invalid_grant. Say so plainly instead of calling it again every 6 hours.
console.warn(` sesión terminada: ${account.email} (el login caducó el ${new Date(o.refreshTokenExpiresAt).toLocaleString('es-ES')}; renovar no lo alarga). Vuelve a entrar con esa cuenta e impórtala.`);
continue;
}
try {
const fresh = oauth.toStoredOauth(await oauth.refresh(o.refreshToken), o);
store.update(account.id, { oauth: fresh });
// Refreshing ROTATES the refresh token and kills the old one, so the live session
// may now be holding a dead token. syncLiveCredentials decides whether it is this
// account's session to update, and writes through the credentials BACKEND - on
// macOS that is the Keychain, and a path-based write would land in a file Claude
// Code never reads, leaving it with the dead token for real.
// Every environment still holding the old pair gets the new one, the WSL distros included:
// the refresh just killed their refresh token too.
// A write that fails here is healed by the next adoption, which hands the file the newer pair.
const synced = listed.filter((t) => { try { return swap.syncLiveCredentials(o.refreshToken, fresh, t.kind === 'host' ? null : t); } catch { return false; } });
console.log(` token renovado: ${account.email}${synced.length ? ` (y sincronizado con Claude Code en ${synced.map((t) => t.label).join(', ')})` : ''}`);
} catch (err) {
// Do not delete anything - a transient network failure must not cost an account.
// invalid_grant is the one case that is not transient and that the user can act on:
// something rotated this account's tokens outside the store (Claude Code renews its
// own session too), so the stored refresh token is dead and only a re-import fixes it.
const dead = /invalid_grant/.test(err.message);
console.warn(` no se pudo renovar ${account.email}: ${dead
? 'el refresh token guardado ya no vale. Entra con esa cuenta (claude, /login) y pulsa import.'
: oauth.scrub(err.message)}`);
}
}
}
// store.list() reads accounts.json, which can throw on a corrupt or briefly locked file -
// outside the try above, and in a promise nobody awaits. Unhandled, that kills the process
// and with it the only thing keeping the stored accounts from expiring.
const keepAliveTick = () => keepTokensAlive().catch((err) => {
console.warn(` keep-alive: ${oauth.scrub((err && err.message) || err)}`);
});
// Same idea for Codex, with its own rules (lib/codex/swap.js): adopt what Codex rotated in each
// environment, renew only the idle accounts - the panel is their only holder.
const codexKeepAlive = () => codexSwap.keepAliveTick().then((r) => {
for (const who of r.adopted) console.log(` sesión Codex adoptada: ${who} (Codex había rotado su token)`);
for (const who of r.refreshed) console.log(` token Codex renovado: ${who}`);
for (const f of r.failed) console.warn(` no se pudo renovar Codex ${f.email || f.target}: ${oauth.scrub(f.error)}`);
}).catch((err) => {
console.warn(` keep-alive Codex: ${oauth.scrub((err && err.message) || err)}`);
});
function createServer(port) {
return http.createServer(async (req, res) => {
let url;
try {
url = new URL(req.url, `http://${HOST}:${port}`);
} catch {
return fail(res, 400, 'URL inválida');
}
try {
if (!guard(req, res, url.pathname)) return;
if (url.pathname.startsWith('/api/')) return await handleApi(req, res, url, port);
if (req.method !== 'GET') return fail(res, 405, 'Método no permitido');
return serveStatic(res, url.pathname);
} catch (err) {
if (!res.headersSent) fail(res, 500, err && err.message ? err.message : 'Error interno');
}
});
}
function listen(port) {
const server = createServer(port);
server.on('error', (err) => {
// Deliberately NOT hopping to the next free port. The rate floor that keeps this app
// inside the usage endpoint's budget is per process, so a second instance quietly
// running on 7374 would double the outbound rate and rate-limit both of them.
if (err.code === 'EADDRINUSE') {
const url = `http://${HOST}:${port}`;
console.log(`\n Ya hay algo escuchando en ${url} - probablemente otro LLMSwapper.`);
console.log(` Ábrelo ahí, o arranca en otro puerto: PORT=7400 node server.js\n`);
if (!process.env.NO_OPEN) oauth.openBrowser(url);
process.exit(0);
}
console.error(`No se pudo abrir el puerto ${port}: ${err.message}`);
process.exit(1);
});
server.listen(port, BIND, () => {
const url = `http://${HOST}:${port}`;
// Dated: data\server.log keeps every start, and a crash is only explained by when it happened.
console.log(`\n LLMSwapper -> ${url} (${new Date().toLocaleString('es-ES')})`);
console.log(` datos: ${P.dataDir()}`);
// Inherited from the shell, this silently redirects every read and write to a throwaway
// config - and the README teaches people to set it for an isolated login.
if (P.inContainer()) {
console.log(` contenedor: sin detección de procesos ni targets WSL (frontera del contenedor)${BUILD ? ` · build ${BUILD}` : ''}`);
}
if (BIND !== HOST) {
console.log(` AVISO: escuchando en ${BIND}, no solo en el loopback.`);
console.log(' Publica el puerto solo en 127.0.0.1 del host, o lo expones a tu red.');
}
if (ALLOWED_HOSTS.size > LOOPBACK.size) {
const extra = [...ALLOWED_HOSTS].filter((h) => !LOOPBACK.has(h));
console.log(` AVISO: el panel acepta tambien ${extra.join(', ')}, no solo el loopback.`);
console.log(' No hay login: quien llegue a esa direccion maneja tus cuentas.');
}
const overriding = overridingEnv();
if (overriding.length) {
console.log(` AVISO: ${overriding.join(', ')} está definido y GANA al fichero de credenciales.`);
console.log(' Mientras siga así, los swaps no tendrán efecto en Claude Code.');
}
if (process.env.CLAUDE_CONFIG_DIR) {
console.log(` AVISO: CLAUDE_CONFIG_DIR está definido, se opera sobre ${P.claudeJsonPath()}`);
}
if (process.env.CODEX_ACCESS_TOKEN) {
console.log(' AVISO: CODEX_ACCESS_TOKEN está definido y GANA a auth.json.');
console.log(' Mientras siga así, los swaps de Codex no tendrán efecto.');
}
console.log(' Ctrl+C para salir\n');
if (!process.env.NO_OPEN) oauth.openBrowser(url);
keepAliveTick();
setInterval(keepAliveTick, KEEPALIVE_EVERY_MS).unref();
codexKeepAlive();
setInterval(codexKeepAlive, KEEPALIVE_EVERY_MS).unref();
// Paseo agents that were answering during a Codex swap are reloaded as soon as they finish.
setInterval(() => void codexPaseo.tick().catch(() => undefined), 5000).unref();
// The background server terminal sessions use follows auth.json: account and renewed tokens.
setInterval(() => { if (!codexSwap.swapping()) void codexDaemon.tick().catch(() => undefined); }, 5000).unref();
// Auto-rotation monitor. Cheap when idle (reads the active account's cached usage);
// only swaps when it has genuinely crossed the threshold - the week's, for an Ultra account,
// which has no 5-hour window. Off unless the user enabled it.
const autoState = auto.load();
if (autoState.enabled) console.log(` rotación automática: ON (${autoState.target}, umbral ${autoState.threshold}%)`);
const autoTick = () => auto.tick(deps).then((r) => {
if (r && r.rotated) console.log(` rotación automática: ${r.from} -> ${r.to} (${r.window === 'weekly' ? 'semana' : 'sesión'} ${r.at}% en ${r.target})`);
}).catch((err) => console.warn(` rotación automática: ${oauth.scrub((err && err.message) || err)}`));
setInterval(autoTick, AUTO_EVERY_MS).unref();
});
const bye = () => { server.close(); process.exit(0); };
process.on('SIGINT', bye);
process.on('SIGTERM', bye);
return server;
}
if (require.main === module) {
P.ensureDirs();
listen(BASE_PORT);
}
module.exports = { createServer, listen };