feat: SOCKS5 egress proxy + harden local-model path (#79)

Integrate SOCKS5 attack-traffic proxying, egress leak visibility, and local-model hardening. Preserve direct local/LAN model traffic while the proxy is active and reconcile the branch with current upstream.
This commit is contained in:
SiNaPsEr0x authored and GitHub committed 2026-07-20 11:54:29 -04:00
1 parent df08627cb9
commit 7b1d12a07c
9 files changed
+1708 -138

No files matched your search

+921 -100
View File
File diff suppressed because it is too large. Load diff
+35
View File
@@ -24,8 +24,10 @@
"gradient-string": "^2.0.2",
"inquirer": "^9.2.15",
"ora": "^8.0.1",
"socks": "^2.8.9",
"tree-sitter-wasms": "0.1.13",
"tsx": "^4.7.0",
"undici": "^8.7.0",
"web-tree-sitter": "0.25.10"
},
"bin": {
@@ -5507,6 +5509,30 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -5861,6 +5887,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici": {
"version": "8.8.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.8.0.tgz",
"integrity": "sha512-ubshXMXwF3MQIMF1y/WxZdNBnjEKeSg2wF5mcGUtU55YTw34tnVVpKRlLf7ruDXZ5344KokPVX4RBx1wJm64Bw==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+2
View File
@@ -122,8 +122,10 @@
"gradient-string": "^2.0.2",
"inquirer": "^9.2.15",
"ora": "^8.0.1",
"socks": "^2.8.9",
"tree-sitter-wasms": "0.1.13",
"tsx": "^4.7.0",
"undici": "^8.7.0",
"web-tree-sitter": "0.25.10"
},
"devDependencies": {
+33
View File
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it } from 'vitest';
import { createServer, type Server } from 'node:http';
import { configureProxy, fetchBypassingProxy } from '../net/proxy.js';
let server: Server | undefined;
afterEach(async () => {
configureProxy(null);
if (server) {
await new Promise<void>((resolve, reject) => server!.close(error => error ? reject(error) : resolve()));
server = undefined;
}
});
describe('local-model proxy bypass', () => {
it('uses a direct dispatcher while the attack-traffic SOCKS proxy is enabled', async () => {
server = createServer((_request, response) => {
response.writeHead(200, { 'content-type': 'application/json' });
response.end('{"ok":true}');
});
await new Promise<void>(resolve => server!.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('test server did not bind');
// Nothing listens here. A request inheriting the global proxy would fail.
const status = configureProxy('socks5://127.0.0.1:1');
expect(status.enabled).toBe(true);
const response = await fetchBypassingProxy(`http://127.0.0.1:${address.port}/model`);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
});
});
+6 -1
View File
@@ -439,7 +439,12 @@ export function runLocalAgent(
/** A cheap liveness probe — asks the agent to echo a token. SPENDS a tiny bit of the agent's quota. */
export function pingLocalAgent(id: string, prompt?: string, timeoutMs?: number): Promise<AgentRunResult> {
return runLocalAgent(id, prompt || 'Reply with exactly the single word: PONG', {
timeoutMs: timeoutMs ?? 90000,
// A local reasoning model spends tens of seconds "thinking" even on this trivial probe
// (and can hit finish_reason=length mid-think). A fixed 90s that ignores the env scaler
// falsely marks a slow-but-alive agent not-live, with no config knob to raise it — while
// real dispatches (runLocalAgent/localAgentChat) already scale up to 600s. Mirror them:
// a dedicated PING knob, falling back to the shared dispatch timeout, then 90s.
timeoutMs: timeoutMs ?? envTimeoutMs('T3MP3ST_LOCAL_AGENT_PING_TIMEOUT_MS', envTimeoutMs('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS', 90000)),
maxChars: 400,
});
}
+32 -1
View File
@@ -109,6 +109,10 @@ export interface TempestSettings {
colorOutput: boolean;
verboseLogging: boolean;
};
// Outbound SOCKS5 proxy for test/attack traffic (socks5://[user:pass@]host:port).
// Empty/unset = egress from the operator's own IP. See src/net/proxy.ts.
proxyUrl?: string;
}
// =============================================================================
@@ -186,6 +190,8 @@ const DEFAULT_SETTINGS: TempestSettings = {
colorOutput: true,
verboseLogging: false,
},
proxyUrl: '',
};
export function migrateLegacyDeepSeekSettings(
@@ -693,6 +699,21 @@ class ConfigManager {
this.config.set('apiKeys', apiKeys);
}
/**
* Outbound SOCKS5 proxy URL for test/attack traffic. Env TEMPEST_PROXY_URL wins over
* the saved value; returns '' when egress should leave from the operator's own IP.
*/
getProxyUrl(): string {
const env = process.env.TEMPEST_PROXY_URL?.trim();
if (env) return env;
return (this.config.get('proxyUrl') || '').trim();
}
/** Persist the proxy URL (pass '' to clear). Does NOT install it — see net/proxy. */
setProxyUrl(url: string): void {
this.config.set('proxyUrl', (url || '').trim());
}
/**
* Get an API key for a provider
*/
@@ -873,7 +894,17 @@ class ConfigManager {
baseUrl,
maxTokens: this.config.get('maxTokens'),
temperature: this.config.get('temperature'),
timeout: this.config.get('timeout'),
// Local inference is far slower than cloud APIs, so it must not inherit the cloud-tuned
// default timeout: floor it at 120s (matching the frontend llmTimeoutFor) and let the
// operator override via TEMPEST_LOCAL_TIMEOUT for very slow reasoning models.
timeout: actualProvider === 'local'
? ((): number => {
const parsed = Number(process.env.TEMPEST_LOCAL_TIMEOUT);
return Number.isFinite(parsed) && parsed > 0
? parsed
: Math.max(Number(this.config.get('timeout')) || 0, 120000);
})()
: this.config.get('timeout'),
fallbackChain: this.buildFallbackChain(actualProvider),
};
}
+109 -14
View File
@@ -19,6 +19,7 @@ import { join } from 'path';
import type { LLMConfig, LLMMessage, LLMResponse, LLMProvider, LLMToolDefinition, LLMToolCall, FallbackEntry } from '../types/index.js';
import { config } from '../config/index.js';
import { localAgentChat } from '../agent/local-agents.js';
import { fetchBypassingProxy } from '../net/proxy.js';
// =============================================================================
// LLM EVENTS
@@ -56,6 +57,8 @@ export interface ChatOptions {
systemPrompt?: string;
/** Tool definitions for function calling */
tools?: LLMToolDefinition[];
/** External cancellation — honored by adapters whose backend supports mid-flight abort (currently LocalAdapter). */
signal?: AbortSignal;
}
/**
@@ -115,6 +118,8 @@ interface AnthropicResponse {
interface OllamaResponse {
message?: {
content?: string;
reasoning_content?: string;
reasoning?: string;
/** Native function calls returned by the model (Ollama /api/chat with tools). */
tool_calls?: Array<{
function: {
@@ -126,6 +131,45 @@ interface OllamaResponse {
model?: string;
prompt_eval_count?: number;
eval_count?: number;
done_reason?: string;
}
/**
* Reasoning models (DeepSeek-R1 / V4-flash, QwQ, …) served by llama.cpp or Ollama
* split their <think> trace into a SEPARATE field — `reasoning_content` on the
* OpenAI wire, `reasoning` on Ollama — and leave `message.content` EMPTY whenever
* the final answer never lands. The dominant cause is token exhaustion: the model
* runs out of max_tokens mid-reasoning (finish_reason:"length"), so everything it
* produced sits in reasoning_content and content is "". Reading only `content`
* then returns '' and the caller wrongly reports an "empty/refused response",
* dead-ending an authorized task on output the model actually generated.
*
* Salvage order, most-preferred first:
* 1. a real `content` answer;
* 2. if the <think> block was left INLINE in content, the text after </think>;
* 3. the reasoning trace itself — for a benchmark/judge this is far better than a
* hard error, since the trace usually already contains the payload/flag.
*/
export function extractLocalContent(
msg?: { content?: string; reasoning_content?: string; reasoning?: string } | null
): string {
if (!msg) return '';
const raw = (msg.content || '').trim();
const reasoning = (msg.reasoning_content || msg.reasoning || '').trim();
if (raw) {
// Server didn't split the trace out — it's inline as <think>…</think>.
const closeIdx = raw.toLowerCase().lastIndexOf('</think>');
if (closeIdx >= 0) {
const after = raw.slice(closeIdx + '</think>'.length).trim();
if (after) return after; // the real answer after the trace
// Only a think block, no answer → salvage its inner text (or the split field).
const inner = raw.replace(/^[\s\S]*?<think>/i, '').replace(/<\/think>[\s\S]*$/i, '').trim();
return inner || reasoning || raw;
}
return raw;
}
// content empty → fall back to the separated reasoning trace so nothing is lost.
return reasoning;
}
// =============================================================================
@@ -901,6 +945,19 @@ class LocalAdapter implements LLMProviderAdapter {
: this.formatOllamaTools(options.tools);
}
// Combine our own timeout with an external signal (e.g. the Express request's 'close'
// event) so an operator cancelling mid-flight — or the client simply disconnecting —
// actually stops generation on the local server, instead of leaving it to grind through
// the full response on a single-slot backend like llama.cpp while nothing is listening.
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.config.timeout || 120000);
const externalSignal = options?.signal;
const onExternalAbort = () => controller.abort();
if (externalSignal) {
if (externalSignal.aborted) controller.abort();
else externalSignal.addEventListener('abort', onExternalAbort, { once: true });
}
try {
const headers = {
'Content-Type': 'application/json',
@@ -908,12 +965,11 @@ class LocalAdapter implements LLMProviderAdapter {
// expect *some* bearer — send a dummy unless the operator set a real key.
...(openaiWire ? { Authorization: `Bearer ${this.config.apiKey || 'local'}` } : {}),
};
const makeSignal = () => AbortSignal.timeout(this.config.timeout || 120000);
let response = await fetch(url, {
let response = await fetchBypassingProxy(url, {
method: 'POST',
headers,
body: JSON.stringify(requestBody),
signal: makeSignal(),
signal: controller.signal,
});
// Some local/OpenAI-compatible servers reject the `tools` field instead
@@ -922,23 +978,42 @@ class LocalAdapter implements LLMProviderAdapter {
if (!response.ok && tryNative && this.config.nativeTools !== true) {
delete requestBody.tools;
this.cacheProbeResult(false);
response = await fetch(url, {
response = await fetchBypassingProxy(url, {
method: 'POST',
headers,
body: JSON.stringify(requestBody),
signal: makeSignal(),
signal: controller.signal,
});
}
if (!response.ok) {
throw new Error(`Local LLM error: ${response.status}`);
// Surface a STRUCTURED error so the retry loop can classify it: a 401/403/404 from a
// local server (wrong bearer, missing model tag) is permanent and must skip the 3x retry
// instead of being re-fired as an opaque plain Error the `permanent` check can't recognize.
throw new LLMApiError(`Local LLM error: ${response.status}`, response.status);
}
const data = await response.json() as OllamaResponse & {
choices?: Array<{ message?: { content?: string; tool_calls?: Array<{ id: string; function: { name: string; arguments: string } }> } }>;
choices?: Array<{
message?: {
content?: string;
reasoning_content?: string;
reasoning?: string;
tool_calls?: Array<{ id: string; function: { name: string; arguments: string } }>;
};
finish_reason?: string;
}>;
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
};
const content = openaiWire ? (data.choices?.[0]?.message?.content || '') : (data.message?.content || '');
// Salvage reasoning-model output: extractLocalContent falls back to
// reasoning_content/reasoning (and unwraps inline <think>) so a model that
// exhausted its budget mid-reasoning isn't reported as an empty refusal.
const message = openaiWire ? data.choices?.[0]?.message : data.message;
const content = extractLocalContent(message);
// Preserve the model's REAL finish signal (OpenAI wire: finish_reason; Ollama: done_reason)
// instead of hardcoding 'stop'. Losing it hid 'length' (truncation) and 'content_filter'
// (a moderation-proxy refusal that isLikelyRefusal keys on) from every downstream consumer.
const wireFinishReason = openaiWire ? data.choices?.[0]?.finish_reason : data.done_reason;
// ── Native tool-call parsing (if we probed) ────────────────────────
let nativeToolCalls: LLMToolCall[] | undefined;
@@ -973,7 +1048,7 @@ class LocalAdapter implements LLMProviderAdapter {
content,
model: data.model || this.config.model,
usage,
finishReason: toolCalls?.length ? 'tool_calls' : 'stop',
finishReason: toolCalls?.length ? 'tool_calls' : (wireFinishReason || 'stop'),
toolCalls,
};
} catch (error) {
@@ -982,7 +1057,13 @@ class LocalAdapter implements LLMProviderAdapter {
'Could not connect to local LLM. Start Ollama (`ollama serve`), or point TEMPEST_LOCAL_BASE_URL at your OpenAI-compatible server (LM Studio / vLLM / llama.cpp).'
);
}
if (controller.signal.aborted) {
throw new Error(externalSignal?.aborted ? 'Cancelled by operator' : 'Local LLM request timed out');
}
throw error;
} finally {
clearTimeout(timer);
if (externalSignal) externalSignal.removeEventListener('abort', onExternalAbort);
}
}
}
@@ -1450,9 +1531,21 @@ export class LLMBackbone extends EventEmitter<LLMEvents> {
break;
} catch (error) {
lastError = error as Error;
// auth / forbidden / missing-model won't fix on retry — bail to next hop
const permanent = error instanceof LLMApiError &&
(error.status === 401 || error.status === 403 || error.status === 404);
// auth / forbidden / missing-model won't fix on retry — bail to next hop.
// An operator/client cancellation (options.signal aborted) won't fix on retry either —
// without this check the retry loop burns 1s+2s of backoff re-attempting a call whose
// signal is already aborted (LocalAdapter/OpenRouter self-abort instantly on each retry,
// so no duplicate request reaches the backend, but the delay pointlessly stalls the caller).
// A local backend's timeout is a DETERMINISTIC per-call cap on a single-slot server
// (llama.cpp/Ollama serve one inference at a time): re-firing the identical call just
// re-hits the same cap after 1s+2s of pointless backoff, then finally advances to a
// cloud fallback the operator may not want. Treat a local/local-agent timeout as
// permanent so the ladder advances straight to the next hop instead of retrying in place.
const isLocalProvider = hop.provider === 'local' || hop.provider === 'local-agent';
const permanent = (error instanceof LLMApiError &&
(error.status === 401 || error.status === 403 || error.status === 404)) ||
!!options?.signal?.aborted ||
(isLocalProvider && classifyErrorKind(error as Error) === 'timeout');
if (permanent || attempt >= this.retryAttempts) break;
let delayMs = this.retryDelayMs * Math.pow(2, attempt - 1);
if (error instanceof LLMApiError && error.retryAfterMs) {
@@ -1470,11 +1563,13 @@ export class LLMBackbone extends EventEmitter<LLMEvents> {
const reason = classifyErrorKind(lastError);
trail.push(`${hop.model}:${reason}`);
reframeNext = false;
if (hasNext) {
// An explicit cancellation means "stop", not "try a different model" — falling through to
// the next hop would silently start a fresh request the caller already asked to abandon.
if (hasNext && !options?.signal?.aborted) {
this.emit('request:fallback', { fromModel: hop.model, toModel: ladder[rung + 1].model, engaged: true, reason });
continue;
}
break; // bottom of the ladder, still failing → throw below
break; // bottom of the ladder, or cancelled — still failing → throw below
}
// ── soft failure on a 200: refusal or empty/contentless ──
+345
View File
@@ -0,0 +1,345 @@
/**
* T3MP3ST — Outbound SOCKS5 proxy for test/attack traffic
* ========================================================
*
* WHY: an operator often does not want probe/attack traffic to leave from their
* own IP (rate-limits, WAF bans, attribution, geo-testing). This module routes
* ALL of Node's global `fetch` (undici) through a SOCKS5 proxy so every scattered
* `fetch()` in the arsenal is covered by a single chokepoint — no per-call edits.
*
* HOW: undici's global dispatcher does not speak SOCKS natively (its ProxyAgent is
* HTTP-only), so we build an undici `Agent` with a custom `connect` that opens a
* SOCKS5 tunnel via the `socks` package, then hands the raw tunneled socket back to
* undici (`httpSocket`) so undici still performs the TLS upgrade for https targets.
*
* LOOPBACK BYPASS (critical): the local LLM (llama.cpp/Ollama on 127.0.0.1) and the
* server's own same-origin calls must NOT be tunneled — routing localhost through an
* external SOCKS proxy would break them. `isLoopbackHost()` sends those direct.
*
* LEAK CHECK: checkIp() fetches ifconfig.co both through the (possibly proxied)
* global dispatcher (the "exit" IP the targets see) and through a guaranteed-direct
* dispatcher (the real IP). If they match, the proxy is off or broken → the caller
* surfaces a "real IP exposed" warning.
*/
import { Agent, buildConnector, setGlobalDispatcher, fetch as undiciFetch } from 'undici';
import { SocksClient, type SocksProxy } from 'socks';
import tls from 'node:tls';
import { config } from '../config/index.js';
// ── parsed proxy shape ───────────────────────────────────────────────────────
interface ParsedProxy {
socks: SocksProxy;
host: string;
port: number;
type: 4 | 5;
hasAuth: boolean;
}
// ── module state ─────────────────────────────────────────────────────────────
// A dedicated DIRECT dispatcher is kept alive for two jobs: (1) it's what we install
// as the global dispatcher when the proxy is OFF, and (2) checkIp() uses it to learn
// the real IP even while the proxy is ON.
// autoSelectFamily mirrors Node's built-in fetch (Happy Eyeballs / dual-stack): without
// it a bare undici Agent only tries the first-resolved address family and fails on
// IPv6-first hosts like ifconfig.co.
const directDispatcher = new Agent({ connect: { timeout: 10_000, autoSelectFamily: true } });
let currentUrl: string | null = null;
let enabled = false;
/**
* Fetch without the configured attack-traffic proxy.
*
* Local model servers commonly live on RFC1918 addresses or private DNS names, not
* just localhost. Giving those calls an explicit direct dispatcher avoids both
* proxy leakage and brittle hostname heuristics/DNS races.
*/
export function directFetch(input: Parameters<typeof undiciFetch>[0], init: Parameters<typeof undiciFetch>[1] = {}) {
return undiciFetch(input, { ...init, dispatcher: directDispatcher });
}
/** Keep ordinary global fetch semantics unless a proxy is active (important for callers/tests that instrument fetch). */
export function fetchBypassingProxy(
input: Parameters<typeof undiciFetch>[0],
init: Parameters<typeof undiciFetch>[1] = {},
) {
return enabled ? directFetch(input, init) : globalThis.fetch(input as any, init as any);
}
// ── url parsing ──────────────────────────────────────────────────────────────
/**
* Parse socks5://[user:pass@]host:port (also socks5h:// and socks4://).
* socks5h = remote DNS resolution, which is what the SOCKS tunnel does anyway.
* Returns null on anything malformed so the caller can fail gracefully.
*/
export function parseProxyUrl(raw: string): ParsedProxy | null {
const s = (raw || '').trim();
if (!s) return null;
let u: URL;
try {
u = new URL(s);
} catch {
return null;
}
const scheme = u.protocol.replace(/:$/, '').toLowerCase();
const type: 4 | 5 = scheme === 'socks4' || scheme === 'socks4a' ? 4 : 5;
if (!['socks', 'socks5', 'socks5h', 'socks4', 'socks4a'].includes(scheme)) return null;
const host = u.hostname;
const port = Number(u.port);
if (!host || !port || !Number.isInteger(port) || port < 1 || port > 65535) return null;
const userId = u.username ? decodeURIComponent(u.username) : undefined;
const password = u.password ? decodeURIComponent(u.password) : undefined;
const socks: SocksProxy = {
host,
port,
type,
...(userId ? { userId } : {}),
...(password ? { password } : {}),
};
return { socks, host, port, type, hasAuth: Boolean(userId || password) };
}
// ── loopback detection (bypass list) ─────────────────────────────────────────
function isLoopbackHost(host: string | undefined): boolean {
if (!host) return false;
const h = host.toLowerCase().replace(/^\[|\]$/g, ''); // strip IPv6 brackets
if (h === 'localhost' || h.endsWith('.localhost')) return true;
if (h === '::1' || h === '0.0.0.0' || h === '::') return true;
if (h.startsWith('127.')) return true;
return false;
}
// ── SOCKS-aware undici connector ─────────────────────────────────────────────
/**
* Build the `connect` function undici calls for every new upstream socket. Loopback
* goes straight out; everything else is opened through the SOCKS tunnel and then the
* raw socket is handed to undici's own connector (via `httpSocket`) so TLS still gets
* negotiated for https:// destinations exactly as it would without a proxy.
*/
function makeConnect(proxy: SocksProxy) {
// Loopback still uses undici's stock connector (net + optional TLS) unchanged.
const direct = buildConnector({ timeout: 10_000 });
return function connect(opts: any, callback: (err: Error | null, socket: any) => void): void {
const hostname: string = opts.hostname;
if (isLoopbackHost(hostname)) {
direct(opts, callback);
return;
}
const isTls = opts.protocol === 'https:';
const destPort = Number(opts.port) || (isTls ? 443 : 80);
SocksClient.createConnection({
proxy,
command: 'connect',
destination: { host: hostname, port: destPort },
timeout: 15_000,
})
.then(({ socket }) => {
socket.setNoDelay(true);
if (!isTls) {
// Plain http: undici drives HTTP/1.1 directly over the raw tunnel.
callback(null, socket);
return;
}
// https: terminate TLS ourselves on top of the tunnel and hand undici a ready
// secure socket. (undici's httpSocket upgrade path does NOT reliably fire for a
// SOCKS-provided socket — it left the stream un-decrypted — so we do it explicitly.)
const servername = opts.servername || hostname;
const tlsSocket = tls.connect({
socket,
servername,
ALPNProtocols: ['http/1.1'],
});
const onError = (err: Error) => { tlsSocket.destroy(); callback(err, null); };
tlsSocket.once('error', onError);
tlsSocket.once('secureConnect', () => {
tlsSocket.removeListener('error', onError);
callback(null, tlsSocket);
});
})
.catch((err: Error) => callback(err, null));
};
}
// ── public API ───────────────────────────────────────────────────────────────
export interface ProxyStatus {
enabled: boolean;
/** Credential-redacted URL, safe to show in UI/logs. */
url: string | null;
host: string | null;
port: number | null;
type: 4 | 5 | null;
}
/** Redact user:pass out of a proxy URL for display. */
function redactUrl(raw: string): string {
try {
const u = new URL(raw);
if (u.username || u.password) {
u.username = u.username ? '***' : '';
u.password = u.password ? '***' : '';
}
return u.toString();
} catch {
return raw.replace(/\/\/[^@/]*@/, '//***@');
}
}
/**
* Enable/disable the outbound proxy. Pass a socks URL to enable, or null/'' to
* disable (restores the direct dispatcher). Returns the resulting status or an error.
*/
export function configureProxy(url: string | null): ProxyStatus & { error?: string } {
const wanted = (url || '').trim();
if (!wanted) {
setGlobalDispatcher(directDispatcher);
currentUrl = null;
enabled = false;
return getProxyStatus();
}
const parsed = parseProxyUrl(wanted);
if (!parsed) {
return { ...getProxyStatus(), error: `Invalid SOCKS proxy URL: ${redactUrl(wanted)} (expected socks5://[user:pass@]host:port)` };
}
const agent = new Agent({ connect: makeConnect(parsed.socks) });
// setGlobalDispatcher writes Symbol.for('undici.globalDispatcher.2') (undici >= 8).
// Node's built-in fetch reads that SAME symbol, so this transparently proxies every
// scattered global fetch() — VERIFIED e2e on Node 24.18 + undici 8.7. If a much older
// Node (whose internal undici used the '.1' symbol) is ever targeted, this alignment
// would break; pin/upgrade undici to match, or route arsenal fetch through undici's fetch.
setGlobalDispatcher(agent);
currentUrl = wanted;
enabled = true;
return getProxyStatus();
}
export function getProxyStatus(): ProxyStatus {
if (!enabled || !currentUrl) {
return { enabled: false, url: null, host: null, port: null, type: null };
}
const parsed = parseProxyUrl(currentUrl);
return {
enabled: true,
url: redactUrl(currentUrl),
host: parsed?.host ?? null,
port: parsed?.port ?? null,
type: parsed?.type ?? null,
};
}
/**
* Read the persisted/env proxy config (TEMPEST_PROXY_URL or saved settings) and
* install it. Safe to call at startup; a bad/missing URL just leaves us direct.
* Returns the effective status so the boot log can report it.
*/
export function initProxyFromConfig(): ProxyStatus & { error?: string } {
const url = config.getProxyUrl();
if (!url) return getProxyStatus();
return configureProxy(url);
}
// ── IP / leak checker ────────────────────────────────────────────────────────
export interface IpInfo {
ip: string | null;
country?: string | null;
city?: string | null;
asnOrg?: string | null;
source: string;
}
export interface IpCheckResult {
proxyEnabled: boolean;
proxy: ProxyStatus;
/** IP the world sees for outbound test traffic (through the proxy when on). */
exit: IpInfo;
/** The operator's real IP (always fetched direct, bypassing the proxy). */
direct: IpInfo;
/**
* True when the exit IP equals the real IP — i.e. either no proxy is configured,
* or the proxy is not actually changing our egress. Surface this as a leak warning.
*/
leak: boolean;
checkedAt: string;
}
const IP_TIMEOUT_MS = 12_000;
// ifconfig.co is the primary (as requested); ipify is a bare-IP fallback if it's down.
const IFCONFIG_JSON = 'https://ifconfig.co/json';
const IPIFY_FALLBACK = 'https://api.ipify.org?format=json';
/**
* Fetch outbound IP info. `direct=true` forces the guaranteed-direct dispatcher so we
* can read the real IP even while the global dispatcher is proxied.
*/
async function fetchIp(direct: boolean): Promise<IpInfo> {
// Use undici's fetch (not Node's global) so we can pass an explicit `dispatcher`:
// Node's built-in fetch throws UND_ERR_INVALID_ARG on a dispatcher from the separately
// installed undici package. `direct` forces the always-direct dispatcher (real IP);
// otherwise undici's global dispatcher is used (proxied when the proxy is on).
const opts: any = {
signal: AbortSignal.timeout(IP_TIMEOUT_MS),
// ifconfig.co returns HTML to browser UAs; a curl-style UA guarantees JSON.
headers: { 'User-Agent': 'curl/8.4.0', accept: 'application/json' },
};
if (direct) opts.dispatcher = directDispatcher;
try {
const res = await undiciFetch(IFCONFIG_JSON, opts);
if (res.ok) {
const j: any = await res.json();
return {
ip: j.ip ?? null,
country: j.country ?? null,
city: j.city ?? null,
asnOrg: j.asn_org ?? null,
source: 'ifconfig.co',
};
}
} catch {
/* fall through to fallback */
}
// Fallback: bare IP only.
const fbOpts: any = { signal: AbortSignal.timeout(IP_TIMEOUT_MS) };
if (direct) fbOpts.dispatcher = directDispatcher;
const res = await undiciFetch(IPIFY_FALLBACK, fbOpts);
const j: any = await res.json();
return { ip: j.ip ?? null, source: 'api.ipify.org' };
}
// Short cache so the UI badge can poll cheaply without hammering ifconfig.co.
let cache: { at: number; result: IpCheckResult } | null = null;
const CACHE_MS = 15_000;
/**
* Resolve exit + real IP and compute the leak flag. Cached for CACHE_MS; pass
* force=true (e.g. right after changing the proxy) to bypass the cache.
* Uses a monotonic-ish timestamp derived from the caller so we stay deterministic
* where possible; falls back to performance.now for cache aging.
*/
export async function checkIp(force = false): Promise<IpCheckResult> {
const now = performance.now();
if (!force && cache && now - cache.at < CACHE_MS) return cache.result;
const proxyOn = enabled;
// Exit uses the global dispatcher (proxied when on). Direct always bypasses.
const [exit, direct] = await Promise.all([
fetchIp(false).catch((e) => ({ ip: null, source: `error: ${String(e?.message || e)}` } as IpInfo)),
fetchIp(true).catch((e) => ({ ip: null, source: `error: ${String(e?.message || e)}` } as IpInfo)),
]);
const leak = Boolean(exit.ip && direct.ip && exit.ip === direct.ip);
const result: IpCheckResult = {
proxyEnabled: proxyOn,
proxy: getProxyStatus(),
exit,
direct,
leak,
checkedAt: new Date().toISOString(),
};
cache = { at: now, result };
return result;
}
/** Drop the IP cache (call after (re)configuring the proxy). */
export function invalidateIpCache(): void {
cache = null;
}
+225 -22
View File
@@ -18,6 +18,7 @@ import { promisify } from 'util';
import { createHash, randomUUID } from 'crypto';
import { config, AVAILABLE_MODELS } from './config/index.js';
import { resolveModels } from './config/provider-models.js';
import { initProxyFromConfig, configureProxy, getProxyStatus, checkIp, invalidateIpCache } from './net/proxy.js';
import { redactString, redactLedgerText, redactSecrets } from './redact.js';
import { LLMBackbone } from './llm/index.js';
import { TempestCommand } from './index.js';
@@ -318,7 +319,23 @@ function getTempestCommand(): TempestCommand | null {
return tempestCommand;
}
function createTempestCommandInstance(missionName: string, apiKey: string | undefined, provider: string, model: string): TempestCommand {
// Validate a client-supplied LOCAL model base URL. Any host:port is allowed — the
// feature exists to reach the operator's OWN local/LAN model server (llama.cpp / Ollama),
// and the loopback bind + origin guard already restrict callers to the local operator —
// so only the URL shape + scheme are enforced, blocking gopher:/file:/etc. Returns
// { value } where value is the trimmed URL, or null when nothing was supplied.
function sanitizeLocalBaseUrl(raw: unknown): { ok: true; value: string | null } | { ok: false; error: string } {
if (typeof raw !== 'string' || !raw.trim()) return { ok: true, value: null };
const v = raw.trim();
let parsed: URL;
try { parsed = new URL(v); } catch { return { ok: false, error: 'Invalid baseUrl' }; }
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { ok: false, error: 'baseUrl must be http(s)' };
}
return { ok: true, value: v };
}
function createTempestCommandInstance(missionName: string, apiKey: string | undefined, provider: string, model: string, baseUrl?: string): TempestCommand {
// Tear down previous instance
if (tempestCommand) {
tempestCommand.stop();
@@ -333,6 +350,10 @@ function createTempestCommandInstance(missionName: string, apiKey: string | unde
model,
apiKey,
baseUrl: baseConfig.baseUrl,
// Only a LOCAL provider honors a per-request base URL (the operator's own
// llama.cpp / Ollama host). Never set it for cloud providers — that would
// let a request redirect a cloud call to an attacker-chosen endpoint.
...(baseUrl && provider === 'local' ? { baseUrl } : {}),
maxTokens: 4096,
temperature: 0.7,
},
@@ -4854,6 +4875,42 @@ app.get('/api/mission-context/latest', (_req: Request, res: Response) => {
res.json(latestMissionContext());
});
// =============================================================================
// OUTBOUND PROXY (SOCKS5) + EGRESS IP CHECK
// The War Room routes probe/attack fetch() through a SOCKS5 proxy so tests don't
// leave from the operator's own IP. These endpoints drive the top-right IP badge
// and the Settings proxy field. See src/net/proxy.ts.
// =============================================================================
// Current proxy config (credential-redacted).
app.get('/api/net/proxy', (_req: Request, res: Response) => {
res.json({ ...getProxyStatus(), configured: Boolean(config.getProxyUrl()) });
});
// Set/clear the proxy. Body: { url: 'socks5://[user:pass@]host:port' } or { url: '' }.
// Persists to config and installs the global dispatcher live (no restart needed).
app.post('/api/net/proxy', (req: Request, res: Response): void => {
const url = typeof req.body?.url === 'string' ? req.body.url.trim() : '';
const result = configureProxy(url || null);
if (result.error) {
res.status(400).json({ error: result.error, ...result });
return;
}
config.setProxyUrl(url);
invalidateIpCache();
res.json({ ok: true, ...result });
});
// Egress IP + leak check (exit IP through proxy vs. real IP direct). Drives the badge.
app.get('/api/net/ip', async (req: Request, res: Response): Promise<void> => {
const force = /^(1|true|yes)$/i.test(String(req.query.force || ''));
try {
res.json(await checkIp(force));
} catch (e) {
res.status(502).json({ error: `IP check failed: ${String((e as Error)?.message || e)}` });
}
});
app.get('/api/arsenal/catalog', (req: Request, res: Response) => {
const family = typeof req.query.family === 'string' ? normalizeMissionFamily(req.query.family, 'web_api') : undefined;
const category = typeof req.query.category === 'string' ? req.query.category : '';
@@ -6215,6 +6272,7 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
apiKey,
provider,
model,
baseUrl, // local provider only: the operator's llama.cpp / Ollama host
// OPTIONAL white-box source: an absolute path to a LOCAL repo you own. When
// present, we ingest + security-rank it and hand the packed source to the
// command via setWhiteboxSource BEFORE start(), so operators reason over the
@@ -6227,7 +6285,9 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
// SECURITY NOTE: apiKey is read from the request body (Authorization header is
// preferred). Kept body-accepted for the same-origin UI; only reachable from
// the local operator (loopback bind + origin guard). Header move is out of scope.
const missionLLMConfig = resolveGeneralLLMConfig(provider, model, apiKey);
const missionLLMConfig = baseUrl === undefined
? resolveGeneralLLMConfig(provider, model, apiKey)
: resolveGeneralLLMConfig(provider, model, apiKey, baseUrl);
const effectiveKey = missionLLMConfig.apiKey;
if (providerNeedsApiKey(missionLLMConfig.provider) && !effectiveKey) {
res.status(400).json({ error: 'API key required — pass apiKey, configure one on the server, or connect a local agent (Claude Code / Codex / Hermes)' });
@@ -6266,7 +6326,13 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
}
try {
const cmd = createTempestCommandInstance(name, effectiveKey, missionLLMConfig.provider, missionLLMConfig.model);
const cmd = createTempestCommandInstance(
name,
effectiveKey,
missionLLMConfig.provider,
missionLLMConfig.model,
missionLLMConfig.baseUrl,
);
// Add targets
for (const t of targets) {
@@ -6741,7 +6807,7 @@ function readGeneralTimeoutEnv(): number | undefined {
// the key in the body, so we accept it to avoid breaking it. Moving to a header
// needs a coordinated UI change and is out of scope. The body key is only ever
// reachable from the local operator (loopback bind + origin guard).
function resolveGeneralLLMConfig(provider: string | undefined, model: string | undefined, apiKey: string | undefined): {
function resolveGeneralLLMConfig(provider: string | undefined, model: string | undefined, apiKey: string | undefined, baseUrl?: string): {
provider: any;
model: string;
apiKey?: string;
@@ -6766,7 +6832,21 @@ function resolveGeneralLLMConfig(provider: string | undefined, model: string | u
};
}
const baseConfig = config.getLLMConfig(selectedProvider as any, model);
const effectiveKey = apiKey || baseConfig.apiKey;
// A per-request base URL is honored ONLY for the local provider (the operator's own
// llama.cpp / Ollama host). For any cloud provider it is ignored — never let a request
// redirect a cloud call. A malformed/non-HTTP URL throws (callers already 400 on throw).
let localBaseUrl: string | null = null;
if (selectedProvider === 'local') {
const bu = sanitizeLocalBaseUrl(baseUrl);
if (!bu.ok) throw new Error(bu.error);
localBaseUrl = bu.value;
}
// SECURITY: when a client picks the local base URL, never fall back to the
// server-configured key (TEMPEST_LOCAL_API_KEY / ZAI_API_KEY / ZHIPUAI_API_KEY —
// possibly a real cloud bearer). Only a client-supplied key reaches a client-chosen host.
const effectiveKey = (selectedProvider === 'local' && localBaseUrl)
? (apiKey || undefined)
: (apiKey || baseConfig.apiKey);
if (providerNeedsApiKey(selectedProvider) && !effectiveKey) {
throw new Error('API key required — pass apiKey in body or configure on server');
}
@@ -6775,6 +6855,9 @@ function resolveGeneralLLMConfig(provider: string | undefined, model: string | u
model: model || baseConfig.model,
apiKey: effectiveKey,
baseUrl: baseConfig.baseUrl,
// Only override the configured URL for a local provider. Cloud providers keep
// their own provider URL and cannot be redirected by a request.
...(selectedProvider === 'local' && localBaseUrl ? { baseUrl: localBaseUrl } : {}),
maxTokens: 8192,
temperature: 0.4,
timeout: readGeneralTimeoutEnv() ?? 300000, // General planning needs room (was a hardcoded 60s); override via env
@@ -6789,9 +6872,9 @@ function resolveGeneralLLMConfig(provider: string | undefined, model: string | u
*/
function bringUpMissionFromPlan(
execConfig: { missionName: string; targets: string[]; operators: string[] },
generalConfig: { apiKey?: string; provider: any; model: string },
generalConfig: { apiKey?: string; provider: any; model: string; baseUrl?: string },
): { spawnedOps: Array<{ id: string; callsign: string; archetype: string }>; status: any } {
const cmd = createTempestCommandInstance(execConfig.missionName, generalConfig.apiKey, generalConfig.provider, generalConfig.model);
const cmd = createTempestCommandInstance(execConfig.missionName, generalConfig.apiKey, generalConfig.provider, generalConfig.model, generalConfig.baseUrl);
for (const target of execConfig.targets) {
if (target.startsWith('http://') || target.startsWith('https://')) cmd.targetEnv.addTarget(createTargetFromUrl(target));
else if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/.test(target)) cmd.targetEnv.addTarget(createTargetFromIP(target));
@@ -6928,6 +7011,102 @@ app.post('/api/codex/probe', async (_req: Request, res: Response): Promise<void>
}
});
// =============================================================================
// LOCAL LLM PROXY — the browser "AI" features POST here so they can use a local
// model (llama.cpp / Ollama / LM Studio / any OpenAI-compatible server) without
// hitting the browser's CORS wall. Reuses LLMBackbone -> LocalAdapter. Same-origin,
// loopback-bound and origin-guarded like every route. Local needs no API key
// (providerNeedsApiKey excludes 'local'); a real key is forwarded when the operator
// set one (e.g. llama.cpp --api-key).
//
// Body: { messages?: {role,content}[], prompt?: string, model?: string,
// baseUrl?: string, apiKey?: string, maxTokens?, temperature?, timeout? }
//
// NOTE: distinct path from the existing /api/llm/chat (which drives the default
// `llm` singleton and ignores per-request baseUrl/model/apiKey) — this one is a
// dedicated per-request LOCAL proxy the War Room's Local Model settings drive.
// =============================================================================
app.post('/api/llm/local', async (req: Request, res: Response): Promise<void> => {
const { messages, prompt, model, baseUrl, apiKey, maxTokens, temperature, timeout } = req.body || {};
// Accept either a messages[] array (preferred) or a single prompt string.
const msgs: Array<{ role: string; content: string }> = Array.isArray(messages)
? messages
: (typeof prompt === 'string' ? [{ role: 'user', content: prompt }] : []);
if (!msgs.length) {
res.status(400).json({ error: 'Provide messages[] or prompt' });
return;
}
const bu = sanitizeLocalBaseUrl(baseUrl);
if (!bu.ok) { res.status(400).json({ error: bu.error }); return; }
const clientBaseUrl = bu.value || '';
const clientApiKey = (typeof apiKey === 'string' && apiKey.trim()) ? apiKey.trim() : '';
try {
// Base config from server env (TEMPEST_LOCAL_*); the UI Settings override
// base URL / model / key per request.
const base = config.getLLMConfig('local', (typeof model === 'string' && model) ? model : undefined);
// SECURITY: never forward the server-configured apiKey (TEMPEST_LOCAL_API_KEY /
// ZAI_API_KEY / ZHIPUAI_API_KEY — potentially a real cloud bearer) to a
// client-CHOSEN destination. If the request overrides baseUrl, only a
// client-supplied key is used; the server secret is applied ONLY when the
// server's own default baseUrl is used. This prevents credential exfiltration
// via an attacker-picked baseUrl.
const effectiveApiKey = clientBaseUrl ? (clientApiKey || undefined) : (clientApiKey || base.apiKey);
const llmConfig = {
...base,
provider: 'local' as const,
model: (typeof model === 'string' && model) ? model : base.model,
baseUrl: clientBaseUrl || base.baseUrl,
apiKey: effectiveApiKey,
maxTokens: Number(maxTokens) > 0 ? Number(maxTokens) : 4096,
temperature: typeof temperature === 'number' ? temperature : 0.3,
timeout: Number(timeout) > 0 ? Number(timeout) : 120000,
};
// Propagate a client disconnect (browser abort / operator "stop") to the upstream
// local model call. Without this, the Node fetch to llama.cpp/Ollama keeps running
// to its own full timeout regardless of what the browser did, wasting the single
// inference slot local backends typically serve.
//
// Must listen on `res`, not `req`: the request stream ends (and auto-destroys,
// emitting 'close') as soon as express.json() finishes consuming the body — which
// happens before this handler even runs — so `req.on('close')` fired on every
// single call, aborting it instantly regardless of whether the client stayed
// connected. `res`'s 'close' event fires on both normal completion and premature
// disconnect; `writableEnded` disambiguates (false only means the client actually
// went away before we could respond).
const controller = new AbortController();
const onClose = () => { if (!res.writableEnded) controller.abort(); };
res.on('close', onClose);
const backbone = new LLMBackbone(llmConfig as any);
let result;
try {
result = await backbone.chat(msgs as any, {
maxTokens: llmConfig.maxTokens,
temperature: llmConfig.temperature,
signal: controller.signal,
});
} finally {
res.off('close', onClose);
}
// Return BOTH a bare `content` and an OpenRouter-shaped `choices[]`, so callers
// reading either shape keep working with no further change.
res.json({
content: result.content,
model: result.model,
usage: result.usage,
choices: [{ message: { role: 'assistant', content: result.content } }],
});
} catch (error: any) {
console.error('[T3MP3ST] /api/llm/chat failed:', error);
if (!res.headersSent) res.status(502).json({ error: error?.message || 'Local LLM request failed' });
}
});
/**
* POST /api/general/plan — Give the General a directive, get back an OpPlan
*
@@ -6945,6 +7124,7 @@ app.post('/api/general/plan', async (req: Request, res: Response): Promise<void>
apiKey,
provider,
model,
baseUrl, // local provider only: the operator's llama.cpp / Ollama host
} = req.body;
if (!objective) {
@@ -6953,7 +7133,7 @@ app.post('/api/general/plan', async (req: Request, res: Response): Promise<void>
}
try {
const generalConfig = resolveGeneralLLMConfig(provider, model, apiKey);
const generalConfig = resolveGeneralLLMConfig(provider, model, apiKey, baseUrl);
// Create a dedicated LLM backbone for the General
const generalLLM = new LLMBackbone(generalConfig);
@@ -7019,11 +7199,12 @@ app.post('/api/general/execute', async (req: Request, res: Response): Promise<vo
apiKey,
provider,
model,
baseUrl, // local provider only: the operator's llama.cpp / Ollama host
} = req.body;
let generalConfig;
try {
generalConfig = resolveGeneralLLMConfig(provider, model, apiKey);
generalConfig = resolveGeneralLLMConfig(provider, model, apiKey, baseUrl);
} catch (error: any) {
res.status(400).json({ error: error.message || 'API key required' });
return;
@@ -7051,7 +7232,8 @@ app.post('/api/general/execute', async (req: Request, res: Response): Promise<vo
execConfig.missionName,
generalConfig.apiKey,
generalConfig.provider,
generalConfig.model
generalConfig.model,
generalConfig.baseUrl
);
// Add targets from the plan
@@ -7132,6 +7314,7 @@ app.post('/api/general/auto', async (req: Request, res: Response): Promise<void>
apiKey,
provider,
model,
baseUrl, // local provider only: the operator's llama.cpp / Ollama host
} = req.body;
if (!objective) {
@@ -7141,7 +7324,7 @@ app.post('/api/general/auto', async (req: Request, res: Response): Promise<void>
let generalConfig;
try {
generalConfig = resolveGeneralLLMConfig(provider, model, apiKey);
generalConfig = resolveGeneralLLMConfig(provider, model, apiKey, baseUrl);
} catch (error: any) {
res.status(400).json({ error: error.message || 'API key required' });
return;
@@ -7196,7 +7379,8 @@ app.post('/api/general/auto', async (req: Request, res: Response): Promise<void>
execConfig.missionName,
generalConfig.apiKey,
generalConfig.provider,
generalConfig.model
generalConfig.model,
generalConfig.baseUrl
);
for (const target of execConfig.targets) {
@@ -7389,8 +7573,8 @@ import { Admiral, briefToDirective, type ChatMsg, type MissionBrief } from './ad
*/
app.post('/api/admiral/converse', async (req: Request, res: Response): Promise<void> => {
try {
const { messages, provider, model, apiKey } = req.body as {
messages: ChatMsg[]; provider?: string; model?: string; apiKey?: string;
const { messages, provider = 'openrouter', model, apiKey, baseUrl } = req.body as {
messages: ChatMsg[]; provider?: string; model?: string; apiKey?: string; baseUrl?: string;
};
if (!Array.isArray(messages) || messages.length === 0) {
res.status(400).json({ error: 'messages[] required' });
@@ -7400,7 +7584,7 @@ app.post('/api/admiral/converse', async (req: Request, res: Response): Promise<v
if (llm) {
admiralLLM = llm;
} else {
const cfg = resolveGeneralLLMConfig(provider, model, apiKey);
const cfg = resolveGeneralLLMConfig(provider, model, apiKey, baseUrl);
admiralLLM = new LLMBackbone(cfg);
}
const admiral = new Admiral(admiralLLM);
@@ -7419,8 +7603,8 @@ app.post('/api/admiral/converse', async (req: Request, res: Response): Promise<v
*/
app.post('/api/admiral/suggest', async (req: Request, res: Response): Promise<void> => {
try {
const { operatorPrompt, archetype, failureSignal, provider, model, apiKey } = req.body as {
operatorPrompt?: string; archetype?: string; failureSignal?: string; provider?: string; model?: string; apiKey?: string;
const { operatorPrompt, archetype, failureSignal, provider = 'openrouter', model, apiKey, baseUrl } = req.body as {
operatorPrompt?: string; archetype?: string; failureSignal?: string; provider?: string; model?: string; apiKey?: string; baseUrl?: string;
};
let prompt = typeof operatorPrompt === 'string' ? operatorPrompt : '';
if (!prompt && archetype) {
@@ -7434,7 +7618,7 @@ app.post('/api/admiral/suggest', async (req: Request, res: Response): Promise<vo
let admiralLLM: LLMBackbone;
if (llm) { admiralLLM = llm; }
else {
const cfg = resolveGeneralLLMConfig(provider, model, apiKey);
const cfg = resolveGeneralLLMConfig(provider, model, apiKey, baseUrl);
admiralLLM = new LLMBackbone(cfg);
}
const admiral = new Admiral(admiralLLM);
@@ -7454,8 +7638,8 @@ app.post('/api/admiral/suggest', async (req: Request, res: Response): Promise<vo
*/
app.post('/api/admiral/launch', async (req: Request, res: Response): Promise<void> => {
try {
const { brief, confirmed, provider, model, apiKey } = req.body as {
brief: MissionBrief; confirmed?: boolean; provider?: string; model?: string; apiKey?: string;
const { brief, confirmed, provider = 'openrouter', model, apiKey, baseUrl } = req.body as {
brief: MissionBrief; confirmed?: boolean; provider?: string; model?: string; apiKey?: string; baseUrl?: string;
};
if (!brief || !brief.objective || !brief.target) {
res.status(400).json({ error: 'brief with objective + target required' });
@@ -7471,7 +7655,7 @@ app.post('/api/admiral/launch', async (req: Request, res: Response): Promise<voi
let generalConfig;
try {
generalConfig = resolveGeneralLLMConfig(provider, model, apiKey);
generalConfig = resolveGeneralLLMConfig(provider, model, apiKey, baseUrl);
} catch (error: any) {
res.status(400).json({ error: error.message || 'API key required' });
return;
@@ -7637,7 +7821,14 @@ type ConnectedLocalAgent = {
};
const connectedLocalAgents = new Map<string, ConnectedLocalAgent>();
const LOCAL_AGENT_HEALTH_TTL_MS = 30_000;
const LOCAL_AGENT_HEALTH_TIMEOUT_MS = 45_000;
// A 15s liveness probe SIGKILLs a slow-but-alive local reasoning agent (which spends tens of
// seconds thinking even on the trivial PONG probe), making requireLiveLocalAgent falsely reject
// a healthy backend. Default to 90s and let the operator raise it via env for very slow models.
// Bounded (not the 600s dispatch timeout) so a genuinely hung agent can't stall a mission for minutes.
const LOCAL_AGENT_HEALTH_TIMEOUT_MS = (() => {
const parsed = Number(process.env.T3MP3ST_LOCAL_AGENT_HEALTH_TIMEOUT_MS);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 90_000;
})();
async function refreshConnectedLocalAgentHealth(force = false): Promise<void> {
const now = Date.now();
@@ -7788,6 +7979,18 @@ async function startServer() {
console.log('');
await loadPersistedState();
// Install the outbound SOCKS5 proxy (if TEMPEST_PROXY_URL / saved settings define one)
// BEFORE anything makes outbound calls, so all test/attack fetch() egress is covered.
const proxyStatus = initProxyFromConfig();
if (proxyStatus.enabled) {
console.log(`[T3MP3ST] Outbound proxy ENABLED → ${proxyStatus.url} (loopback bypassed)`);
} else if (proxyStatus.error) {
console.warn(`[T3MP3ST] Outbound proxy misconfigured, egress is DIRECT: ${proxyStatus.error}`);
} else {
console.log('[T3MP3ST] Outbound proxy disabled — egress from local IP.');
}
llm = await initLLM();
// Load multi-language grammars once, before any white-box ingest request.