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:
1 parent
df08627cb9
commit
7b1d12a07c
9 files changed
+1708
-138
No files matched your search
+921
-100
File diff suppressed because it is too large.
Load diff
Generated
+35
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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 ──
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user