diff --git a/docs/index.html b/docs/index.html index 8a06e30..8b33885 100755 --- a/docs/index.html +++ b/docs/index.html @@ -5714,6 +5714,7 @@ tempest.operators.spawn('scan connected: false, llmAvailable: false, + serverLLM: null, // Check server health async checkHealth() { @@ -5747,6 +5748,7 @@ tempest.operators.spawn('scan this.connected = data.status === 'operational'; // /api/health returns llm as an object ({connected:true,...}); accept that or the legacy string this.llmAvailable = data.llm === 'connected' || !!(data.llm && data.llm.connected); + this.serverLLM = data.llm && typeof data.llm === 'object' ? data.llm : null; // Keep the dispatch latch honest: BackendDispatch.available is otherwise write-only-true. // A server that's down / lost mission support must clear it; a healthy one re-arms it. if (typeof BackendDispatch !== 'undefined') { @@ -15725,12 +15727,14 @@ ACTION_INPUT: {"output":"Found confirmed SQLi on /login endpoint..."}`; function getGeneralConfig() { const apiKey = getApiKey(); - const provider = localStorage.getItem('t3mp3st_general_provider') || (T3MP3ST_API?.connected ? 'codex' : 'openrouter'); - const model = provider === 'codex' ? 'codex-default' : (state.settings?.selectedModel || 'anthropic/claude-sonnet-4'); + const savedProvider = localStorage.getItem('t3mp3st_general_provider'); + const provider = savedProvider || (T3MP3ST_API?.connected && !T3MP3ST_API?.llmAvailable ? 'codex' : undefined); + const model = provider === 'codex' ? 'codex-default' : (provider ? (state.settings?.selectedModel || 'anthropic/claude-sonnet-4') : undefined); return { apiKey, provider, model }; } function _generalNeedsApiKey(provider) { + if (!provider) return false; return !['codex', 'mock', 'local'].includes(provider); } @@ -18181,19 +18185,27 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati // Determine the LLM backend. Priority: a configured API key → its provider; else a // connected local agent (Claude Code / Codex / Hermes) which needs NO key (uses its own // login); else fall through to the server's own configured LLM. - let provider = 'openrouter'; - let model = state.settings.selectedModel || 'anthropic/claude-sonnet-4'; + let provider; + let model; const agentBackend = (!apiKey && typeof window.t3mpPreferredAgent === 'function') ? window.t3mpPreferredAgent() : null; if (!apiKey && agentBackend) { provider = 'local-agent'; model = agentBackend; // the agent id (codex|claude|hermes) travels in `model` addIntel('BACKEND', `No API key — routing the mission through your connected agent: ${agentBackend.toUpperCase()}`, 'success'); addMissionLog('info', `LLM backend: local agent (${agentBackend}) — no API key needed`); + } else if (!apiKey) { + addIntel('BACKEND', 'No browser API key — using the server-configured LLM backend', 'success'); + addMissionLog('info', 'LLM backend: server default — no browser API key sent'); } else if (apiKey && apiKey.startsWith('sk-ant-')) { provider = 'anthropic'; + model = state.settings.selectedModel || 'anthropic/claude-sonnet-4'; model = model.replace('anthropic/', ''); // Anthropic API doesn't use prefix } else if (apiKey && apiKey.startsWith('sk-') && !apiKey.startsWith('sk-ant-') && !apiKey.startsWith('sk-or-')) { provider = 'openai'; + model = state.settings.selectedModel || 'anthropic/claude-sonnet-4'; + } else { + provider = 'openrouter'; + model = state.settings.selectedModel || 'anthropic/claude-sonnet-4'; } try { @@ -25104,10 +25116,11 @@ Be specific and actionable. Format as structured JSON.`; function _admBackbone(){ const apiKey = (typeof getApiKey === 'function') ? (getApiKey() || '') : ''; const agent = (!apiKey && typeof window.t3mpPreferredAgent === 'function') ? window.t3mpPreferredAgent() : null; - let provider = 'openrouter', model = ((typeof state !== 'undefined' && state.settings && state.settings.selectedModel) || 'anthropic/claude-sonnet-4'); + let provider, model; if (!apiKey && agent) { provider = 'local-agent'; model = agent; } - else if (apiKey && apiKey.startsWith('sk-ant-')) { provider = 'anthropic'; model = model.replace('anthropic/', ''); } - else if (apiKey && apiKey.startsWith('sk-') && !apiKey.startsWith('sk-or-')) { provider = 'openai'; } + else if (apiKey && apiKey.startsWith('sk-ant-')) { provider = 'anthropic'; model = ((typeof state !== 'undefined' && state.settings && state.settings.selectedModel) || 'anthropic/claude-sonnet-4').replace('anthropic/', ''); } + else if (apiKey && apiKey.startsWith('sk-') && !apiKey.startsWith('sk-or-')) { provider = 'openai'; model = ((typeof state !== 'undefined' && state.settings && state.settings.selectedModel) || 'anthropic/claude-sonnet-4'); } + else if (apiKey) { provider = 'openrouter'; model = ((typeof state !== 'undefined' && state.settings && state.settings.selectedModel) || 'anthropic/claude-sonnet-4'); } return { provider: provider, model: model, apiKey: apiKey || undefined }; } window.admiralPreviewPlan = async function(){ @@ -25297,7 +25310,7 @@ Be specific and actionable. Format as structured JSON.`; const cur = (document.getElementById('opPromptEdit')||{}).value || ''; const apiKey = (typeof getApiKey==='function')?(getApiKey()||''):''; const agent = (!apiKey && typeof window.t3mpPreferredAgent==='function')?window.t3mpPreferredAgent():null; - let provider='openrouter', model; if(!apiKey&&agent){provider='local-agent';model=agent;} + let provider, model; if(!apiKey&&agent){provider='local-agent';model=agent;} else if(apiKey){provider='openrouter';} try { const d = await (await fetch(base()+'/api/admiral/suggest',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({operatorPrompt:cur,archetype:SELECTED,provider,model,apiKey:apiKey||undefined})})).json(); if(d.error) throw new Error(d.error); ADVICE=d; renderAdvice(); } catch(e){ if(box) box.innerHTML='
Suggestion failed: '+esc(e.message)+'
'; } diff --git a/src/__tests__/local-api-hardening-static.test.ts b/src/__tests__/local-api-hardening-static.test.ts index 6eb68a2..0b3fb85 100644 --- a/src/__tests__/local-api-hardening-static.test.ts +++ b/src/__tests__/local-api-hardening-static.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'fs'; import { join } from 'path'; const serverSource = readFileSync(join(process.cwd(), 'src/server.ts'), 'utf8'); +const uiSource = readFileSync(join(process.cwd(), 'docs/index.html'), 'utf8'); function sourceBlock(startMarker: string, endMarker: string): string { const start = serverSource.indexOf(startMarker); @@ -17,6 +18,34 @@ function routeBlock(startMarker: string, endMarker: string): string { } describe('local API authorization hardening invariants', () => { + it('server mission/general routes default to the configured LLM instead of hardcoded OpenRouter', () => { + const resolver = routeBlock('function resolveGeneralLLMConfig(', '/**\n * Bring a planned mission UP for real'); + const missionRoute = routeBlock("app.post('/api/mission/start'", "app.get('/api/mission/report'"); + const generalRoutes = routeBlock("app.post('/api/general/plan'", '// =============================================================================\n// THE ADMIRAL'); + + expect(resolver).toContain('config.getLLMConfig()'); + expect(resolver).toContain('provider || defaultConfig.provider'); + expect(missionRoute).toContain('resolveGeneralLLMConfig(provider, model, apiKey)'); + expect(`${missionRoute}\n${generalRoutes}`).not.toMatch(/provider\s*=\s*['"]openrouter['"]/); + expect(`${missionRoute}\n${generalRoutes}`).not.toMatch(/model\s*=\s*['"]anthropic\/claude-sonnet-4['"]/); + }); + + it('health and LLM status count keyless local backends as configured', () => { + const health = routeBlock('function healthPayload()', '// =============================================================================\n// API ENDPOINTS - HEALTH & STATUS'); + const llmStatus = routeBlock("app.get('/api/llm/status'", '// =============================================================================\n// TOOL EXECUTION ENDPOINTS'); + + expect(health).toContain('providerRunsKeyless(llmConfig.provider)'); + expect(llmStatus).toContain('providerRunsKeyless(llmConfig.provider)'); + }); + + it('War Room no-key/no-agent backend dispatch defers to the server-configured LLM', () => { + expect(uiSource).toContain("serverLLM: null"); + expect(uiSource).toContain('this.serverLLM = data.llm'); + expect(uiSource).toContain('No browser API key — using the server-configured LLM backend'); + expect(uiSource).toContain('LLM backend: server default — no browser API key sent'); + expect(uiSource).not.toMatch(/let provider = ['"]openrouter['"];\s*\n\s*let model = state\.settings\.selectedModel/); + }); + it('/api/events does not grant wildcard CORS and rejects foreign browser origins before opening SSE', () => { const route = routeBlock("app.get('/api/events'", '// =============================================================================\n// API ENDPOINTS - HEALTH & STATUS'); diff --git a/src/__tests__/local-openai-wire-static.test.ts b/src/__tests__/local-openai-wire-static.test.ts index ce03bc6..c28421d 100644 --- a/src/__tests__/local-openai-wire-static.test.ts +++ b/src/__tests__/local-openai-wire-static.test.ts @@ -43,6 +43,14 @@ describe('local OpenAI-compatible provider hardening', () => { expect(localCase).toMatch(/ZHIPUAI_API_KEY/); }); + it('getLLMConfig treats UI local placeholders as unset model names', () => { + const localCase = block(configSource, "case 'local':", 'break;'); + + expect(localCase).toContain('local-model'); + expect(localCase).toContain('local/ollama'); + expect(localCase).toContain('TEMPEST_LOCAL_MODEL'); + }); + it('getApiKey resolves a local key from env without persisting it', () => { // The local key is read in-memory from env, matching the env-key hardening // invariant (env keys are never written to the store). diff --git a/src/config/index.ts b/src/config/index.ts index c710051..7db3088 100755 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -726,9 +726,10 @@ class ConfigManager { // Some OpenAI-compatible servers require a real bearer (Zhipu, Together, etc.) — // TEMPEST_LOCAL_API_KEY (or a provider-specific env like ZAI_API_KEY) provides it. baseUrl = process.env.TEMPEST_LOCAL_BASE_URL?.trim() || 'http://localhost:11434/api'; - // 'local-model' is the placeholder id from AVAILABLE_MODELS — treat it as unset so - // TEMPEST_LOCAL_MODEL (or the llama3 default) wins; a real tag passed in still takes priority. - actualModel = (model && model !== 'local-model' ? model : undefined) || process.env.TEMPEST_LOCAL_MODEL?.trim() || 'llama3'; + // Placeholder ids from AVAILABLE_MODELS / the static UI are not real served model tags. + // Treat them as unset so TEMPEST_LOCAL_MODEL (or the llama3 default) wins; a real tag + // passed in still takes priority. + actualModel = (model && !['local-model', 'local/ollama'].includes(model) ? model : undefined) || process.env.TEMPEST_LOCAL_MODEL?.trim() || 'llama3'; apiKey = process.env.TEMPEST_LOCAL_API_KEY?.trim() || process.env.ZAI_API_KEY?.trim() || process.env.ZHIPUAI_API_KEY?.trim(); break; default: diff --git a/src/server.ts b/src/server.ts index a29b32e..3343e44 100755 --- a/src/server.ts +++ b/src/server.ts @@ -4322,7 +4322,7 @@ function healthPayload(): Record { version: '0.2.1', apiVersion: 'v1', llm: { - configured: Boolean(llmConfig.apiKey) || llmConfig.provider === 'codex', + configured: Boolean(llmConfig.apiKey) || providerRunsKeyless(llmConfig.provider), connected: Boolean(llm) || llmConfig.provider === 'codex', provider: llmConfig.provider, model: llmConfig.model, @@ -5953,7 +5953,8 @@ app.get('/api/llm/status', (_req: Request, res: Response) => { connected: !!llm, provider: llmConfig.provider, model: llmConfig.model, - hasApiKey: !!llmConfig.apiKey + hasApiKey: !!llmConfig.apiKey, + configured: Boolean(llmConfig.apiKey) || providerRunsKeyless(llmConfig.provider), }); }); @@ -6045,8 +6046,8 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise urgency, opsecPreference, apiKey, - provider = 'openrouter', - model = 'anthropic/claude-sonnet-4', + provider, + model, } = req.body; if (!objective) { @@ -6853,8 +6860,8 @@ app.post('/api/general/execute', async (req: Request, res: Response): Promise urgency, opsecPreference, apiKey, - provider = 'openrouter', - model = 'anthropic/claude-sonnet-4', + provider, + model, } = req.body; if (!objective) { @@ -7225,7 +7232,7 @@ import { Admiral, briefToDirective, type ChatMsg, type MissionBrief } from './ad */ app.post('/api/admiral/converse', async (req: Request, res: Response): Promise => { try { - const { messages, provider = 'openrouter', model, apiKey } = req.body as { + const { messages, provider, model, apiKey } = req.body as { messages: ChatMsg[]; provider?: string; model?: string; apiKey?: string; }; if (!Array.isArray(messages) || messages.length === 0) { @@ -7255,7 +7262,7 @@ app.post('/api/admiral/converse', async (req: Request, res: Response): Promise => { try { - const { operatorPrompt, archetype, failureSignal, provider = 'openrouter', model, apiKey } = req.body as { + const { operatorPrompt, archetype, failureSignal, provider, model, apiKey } = req.body as { operatorPrompt?: string; archetype?: string; failureSignal?: string; provider?: string; model?: string; apiKey?: string; }; let prompt = typeof operatorPrompt === 'string' ? operatorPrompt : ''; @@ -7290,7 +7297,7 @@ app.post('/api/admiral/suggest', async (req: Request, res: Response): Promise => { try { - const { brief, confirmed, provider = 'openrouter', model, apiKey } = req.body as { + const { brief, confirmed, provider, model, apiKey } = req.body as { brief: MissionBrief; confirmed?: boolean; provider?: string; model?: string; apiKey?: string; }; if (!brief || !brief.objective || !brief.target) {