From fb0feb0028922d9777fa1d102b74b4bb49aca082 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 2 Jul 2026 16:20:22 -0400 Subject: [PATCH 1/8] Report stale local agent backends --- docs/index.html | 30 ++++++++++++++++++------------ src/server.ts | 49 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/docs/index.html b/docs/index.html index 895a5cc..2714e93 100755 --- a/docs/index.html +++ b/docs/index.html @@ -17921,7 +17921,7 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati // ── Local agents: fetch ONCE before the backbone label so both reflect the server's // actual agent state from the first poll (and t3mpPreferredAgent can route to a live one) ── let __agents = []; - try { const s = await (await fetch(base + '/api/agents/local/status')).json(); __agents = s.connected || []; } catch (_) {} + try { const s = await (await fetch(base + '/api/agents/local/status?check=1')).json(); __agents = s.connected || []; } catch (_) {} window.__t3mpServerAgents = __agents; window.__t3mpConnectedAgents = __agents.length; window.__t3mpLiveAgents = __agents.filter(a => a.lastPing && a.lastPing.ok).length; @@ -17931,10 +17931,12 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati const key = (typeof getApiKey === 'function') ? (getApiKey() || '') : ''; let txt, color; const serverLLM = !!(health && health.llm && health.llm.connected); + const staleAgents = __agents.filter(a => !a.lastPing || !a.lastPing.ok).length; const hasBackend = !!(agent || key || serverLLM); if (!key && agent) { txt = '⚡ ' + agent.toUpperCase() + ' · local, no key'; color = '#00ff88'; } else if (key) { txt = (key.startsWith('sk-ant-') ? 'Anthropic' : key.startsWith('sk-or-') ? 'OpenRouter' : key.startsWith('sk-') ? 'OpenAI' : 'OpenRouter') + ' · your key'; color = '#0088ff'; } else if (serverLLM) { txt = (health.llm.provider || 'server') + ' · server key'; color = '#0088ff'; } + else if (staleAgents) { txt = 'local agent stale — model unreachable'; color = '#ffaa00'; } else { txt = 'none — connect agent / add key'; color = '#ff6b6b'; } $('sysLLM').innerHTML = ''+txt+''; // Keyless hero callout: when NO backend is present (no agent, no key, no server LLM), @@ -17959,7 +17961,11 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati // ── Local agents cell (reuse the state fetched above; keeps the stale-count latch fresh) ── if ($('sysAgents')) { $('sysAgents').innerHTML = __agents.length - ? __agents.map(a => { const live = a.lastPing && a.lastPing.ok; return '●'+a.id+''; }).join('  ') + ? __agents.map(a => { + const live = a.lastPing && a.lastPing.ok; + const err = a.lastPing && a.lastPing.error ? ' title="'+String(a.lastPing.error).replace(/"/g,'"')+'"' : ''; + return '●'+a.id+''+(live?' live':' stale')+''; + }).join('  ') : 'none — click to connect'; } // ── Drive the SitRep pipeline from /api/mission/status (sysPhases/sysFindings status cells were consolidated out) ── @@ -24747,10 +24753,10 @@ Be specific and actionable. Format as structured JSON.`; else if (!a.authed) { status='Not authed'; color='amber'; } else if (stale) { status='⚠ Auth stale'; color='red'; } else if (live) { status='● Live · '+ps.latencyMs+'ms'; color='green'; } - else if (connected) { status='● Active'; color='green'; } + else if (connected) { status='● Connected · unchecked'; color='amber'; } else { status='Detected'; color='cyan'; } const disabled = !a.ready; - const borderColor = stale ? 'rgba(255,80,80,0.4)' : (connected||live) ? 'rgba(0,255,136,0.35)' : 'rgba(0,200,255,0.14)'; + const borderColor = stale ? 'rgba(255,80,80,0.4)' : live ? 'rgba(0,255,136,0.35)' : connected ? 'rgba(255,170,0,0.28)' : 'rgba(0,200,255,0.14)'; const sub = !a.installed ? 'binary not found on PATH' : stale ? ('⚠ '+(ps.error ? ps.error.slice(0,70) : 'authentication failed — re-login this CLI')) : (a.invokeHint || a.bin); @@ -24786,10 +24792,10 @@ Be specific and actionable. Format as structured JSON.`; const live = Object.keys(pingStatus).filter(function(k){return pingStatus[k].ok;}).length; const sum = document.getElementById('localAgentsSummary'); if (sum) sum.innerHTML = connectedIds.length+' active'+(live?' · '+live+' live':'')+' · '+ready+' ready · '+installed+'/'+agents.length+' installed'; - // expose for the War Room backend-gate, and dismiss the nudge once ANY agent is connected - // (a plain connect counts — not only a successful live-ping) + // expose live/connected counts separately. A plain connect is just a registration; only a + // fresh successful ping proves the local model is currently usable. window.__t3mpLiveAgents = live; window.__t3mpConnectedAgents = connectedIds.length; - if (connectedIds.length > 0 || live > 0) { const nb = document.getElementById('noBackendNudge'); if (nb) nb.style.display = 'none'; } + if (live > 0) { const nb = document.getElementById('noBackendNudge'); if (nb) nb.style.display = 'none'; } } // ── remember connected agents + silently re-establish them after a refresh ── @@ -24866,8 +24872,9 @@ Be specific and actionable. Format as structured JSON.`; // _isStandalone (no API server) is NOT a backend — client-side mode still needs an API key. // Counting it here let keyless standalone deploys bypass the "connect an agent / add a key" // gate and silently fall through to a failing OpenRouter pipeline. A backend = a real LLM: - // the server's own LLM (llmAvailable) or a connected local agent. - return !!(api.llmAvailable || window.__t3mpLiveAgents > 0 || window.__t3mpConnectedAgents > 0); + // the server's own LLM (llmAvailable) or a live local agent. A connected-but-stale + // agent is not enough, because the underlying local model may have gone down. + return !!(api.llmAvailable || window.__t3mpLiveAgents > 0); } window.t3mpHasBackend = hasBackend; // Pick the best connected agent to serve as the mission LLM backend: prefer a live-pinged one, and @@ -24876,15 +24883,14 @@ Be specific and actionable. Format as structured JSON.`; var order = ['codex','hermes','claude']; var connected = (connectedIds || []).slice(); var live = connected.filter(function(id){ return pingStatus[id] && pingStatus[id].ok; }); - var pool = live.length ? live : connected; + var pool = live; // Fall back to the SERVER's reported agents (/api/agents/local/status, stashed by // refreshSystemStatus). Without this, a War Room loaded without first visiting Settings has an // empty client connectedIds and would route missions to a key-provider even though the server // already has a live agent — the exact AI-backbone/mission-routing mismatch we want to avoid. if (!pool.length && Array.isArray(window.__t3mpServerAgents)) { var srv = window.__t3mpServerAgents.slice(); - var srvLive = srv.filter(function(a){ return a.lastPing && a.lastPing.ok; }); - pool = (srvLive.length ? srvLive : srv).map(function(a){ return a.id; }); + pool = srv.filter(function(a){ return a.lastPing && a.lastPing.ok; }).map(function(a){ return a.id; }); } pool.sort(function(a,b){ return order.indexOf(a) - order.indexOf(b); }); return pool[0] || null; diff --git a/src/server.ts b/src/server.ts index 1f16a79..53fb037 100755 --- a/src/server.ts +++ b/src/server.ts @@ -7350,8 +7350,35 @@ app.get('/api/bounty/credentials', (_req: Request, res: Response) => { // Detect agent CLIs that are already installed + logged-in on this machine and enlist them as // operators — no keys are entered or read (auth is detected by artifact PRESENCE only; see // src/agent/local-agents.ts). In-memory registry of agents connected this session: -const connectedLocalAgents = new Map(); type AgentRunSummary = { ok: boolean; latencyMs: number; output: string; error?: string }; +type ConnectedLocalAgent = { + id: string; + label: string; + version?: string; + connectedAt: number; + lastPing?: AgentRunSummary | null; + lastHealthCheckAt?: number; +}; +const connectedLocalAgents = new Map(); +const LOCAL_AGENT_HEALTH_TTL_MS = 30_000; +const LOCAL_AGENT_HEALTH_TIMEOUT_MS = 15_000; + +async function refreshConnectedLocalAgentHealth(force = false): Promise { + const now = Date.now(); + const due = Array.from(connectedLocalAgents.values()).filter((agent) => ( + force || + !agent.lastHealthCheckAt || + now - agent.lastHealthCheckAt > LOCAL_AGENT_HEALTH_TTL_MS + )); + + await Promise.all(due.map(async (agent) => { + const result = await pingLocalAgent(agent.id, undefined, LOCAL_AGENT_HEALTH_TIMEOUT_MS); + const current = connectedLocalAgents.get(agent.id); + if (!current) return; + current.lastPing = result; + current.lastHealthCheckAt = Date.now(); + })); +} // GET /api/agents/local/detect — which agents are installed / authed / ready (no tokens spent) app.get('/api/agents/local/detect', async (_req: Request, res: Response): Promise => { @@ -7378,7 +7405,14 @@ app.post('/api/agents/local/connect', async (req: Request, res: Response): Promi if (!d.authed) { results.push({ id, status: 'not-authed', version: d.version }); continue; } let ping: AgentRunSummary | null = null; if (doPing) ping = await pingLocalAgent(id); - connectedLocalAgents.set(id, { id, label: d.label, version: d.version, connectedAt: Date.now(), lastPing: ping }); + connectedLocalAgents.set(id, { + id, + label: d.label, + version: d.version, + connectedAt: Date.now(), + lastPing: ping, + lastHealthCheckAt: ping ? Date.now() : undefined, + }); results.push({ id, status: 'active', label: d.label, version: d.version, authMethod: d.authMethod, ping }); } syncLocalAgentSelection(connectedLocalAgents, ids, replace); @@ -7391,7 +7425,10 @@ app.post('/api/agents/local/ping', async (req: Request, res: Response): Promise< if (!body.id) { res.status(400).json({ error: 'id required' }); return; } const r = await pingLocalAgent(body.id, body.prompt); const entry = connectedLocalAgents.get(body.id); - if (entry) entry.lastPing = r; + if (entry) { + entry.lastPing = r; + entry.lastHealthCheckAt = Date.now(); + } res.json({ id: body.id, ...r }); }); @@ -7411,8 +7448,10 @@ app.post('/api/agents/local/disconnect', (req: Request, res: Response): void => res.json({ ok: true, connected: Array.from(connectedLocalAgents.keys()) }); }); -// GET /api/agents/local/status — currently connected agents -app.get('/api/agents/local/status', (_req: Request, res: Response): void => { +// GET /api/agents/local/status — connected agents, optionally with a bounded live health check. +app.get('/api/agents/local/status', async (req: Request, res: Response): Promise => { + const check = /^(1|true|yes|on)$/i.test(String(req.query.check || '')); + if (check) await refreshConnectedLocalAgentHealth(false); res.json({ connected: Array.from(connectedLocalAgents.values()) }); }); From f502bfbd42e3e7cf514c002062a302f8d8aed4ac Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 11:00:50 -0400 Subject: [PATCH 2/8] Treat local agent API failures as unhealthy --- src/agent/local-agents.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/agent/local-agents.ts b/src/agent/local-agents.ts index d2c86ae..fa6dd2e 100644 --- a/src/agent/local-agents.ts +++ b/src/agent/local-agents.ts @@ -236,6 +236,13 @@ export interface AgentRunResult { error?: string; } +function agentFailureOutput(output: string): string | null { + const text = output.trim(); + if (/^API call failed\b/i.test(text)) return text.slice(0, 300); + if (/connection error/i.test(text) && /failed/i.test(text)) return text.slice(0, 300); + return null; +} + /** * Drive a connected agent with a one-shot headless prompt (real round-trip — SPENDS the agent's quota). * Used by /ping (liveness proof) and /dispatch (actually using the operator). @@ -270,7 +277,9 @@ export function runLocalAgent( child.on('close', (code) => { const latencyMs = Date.now() - t0; const output = out.trim().slice(0, maxChars); - if (code === 0) finish({ ok: true, latencyMs, output }); + const semanticError = agentFailureOutput(output); + if (code === 0 && !semanticError) finish({ ok: true, latencyMs, output }); + else if (semanticError) finish({ ok: false, latencyMs, output, error: semanticError }); else finish({ ok: false, latencyMs, output, error: (errOut.trim() || `exited with code ${code}`).slice(0, 300) }); }); }); @@ -329,7 +338,9 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin let content = out.trim(); if (outFile) { try { content = (readFileSync(outFile, 'utf8').trim() || content); } catch { /* fall back to stdout */ } } finish(() => { - if (code === 0 && content) resolve(content); + const semanticError = agentFailureOutput(content); + if (code === 0 && content && !semanticError) resolve(content); + else if (semanticError) reject(new Error(semanticError)); else reject(new Error((errOut.trim() || content || `exited with code ${code}`).slice(0, 800))); }); }); From 49d08276500942c586664d746da7785be077cb36 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 11:13:35 -0400 Subject: [PATCH 3/8] Block phase progress without live backend --- src/index.ts | 31 ++++++++++++++++++++++++++++--- src/server.ts | 24 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index ee8bf98..2c0d6dc 100755 --- a/src/index.ts +++ b/src/index.ts @@ -786,6 +786,7 @@ export class TempestCommand extends EventEmitter { public resume(): void { if (!this.running || !this.paused) return; this.paused = false; + this.stallReason = null; this.emit('command:resumed'); } @@ -837,6 +838,9 @@ export class TempestCommand extends EventEmitter { /** Track whether we've seeded initial tasks for the current mission */ private taskSeeded: boolean = false; + /** Human-readable reason a mission was paused because required work failed. */ + private stallReason: string | null = null; + /** * Main tick loop — seeds tasks, dispatches to operators, advances phases */ @@ -889,13 +893,28 @@ export class TempestCommand extends EventEmitter { ); const inFlight = allMissionTasks.filter(t => this.activeDispatches.has(t.id)); - // If we have tasks, all are done, and nothing is in-flight → advance phase + // If we have tasks, all are done, and nothing is in-flight → advance phase. + // Failed required tasks are terminal, but they are not successful progress. + // Stall instead of walking the phase bar forward with no backend/model work. if (allMissionTasks.length > 0 && pendingOrActive.length === 0 && inFlight.length === 0) { + const failedCurrentPhase = allMissionTasks.filter( + t => t.phase === mission.currentPhase && t.status === 'failed' + ); + if (failedCurrentPhase.length > 0) { + const firstError = failedCurrentPhase[0].result?.error; + this.stallReason = `stalled in ${mission.currentPhase}: ${failedCurrentPhase.length} required task(s) failed` + + (firstError ? ` — ${firstError}` : ''); + this.paused = true; + this.emit('command:paused'); + return; + } + const phaseIndex = mission.phases.indexOf(mission.currentPhase); if (phaseIndex === -1) return; // Guard: phase not found (race condition) if (phaseIndex < mission.phases.length - 1) { // Advance to next phase and generate tasks this.mission.advancePhase(mission.id); + this.stallReason = null; const targets = this.targetEnv.getAllTargets(); for (const target of targets) { this.mission.generateNextPhaseTasks(target.address); @@ -972,8 +991,12 @@ export class TempestCommand extends EventEmitter { // double-fire the completion hook. if (!this.activeDispatches.has(task.id)) return; this.clearDispatch(task.id); - taskQueue.complete(task.id, result); - this.hooks.onTaskCompleted?.(task); + if (result.success === false) { + taskQueue.fail(task.id, result.error || result.output || 'task returned unsuccessful result'); + } else { + taskQueue.complete(task.id, result); + this.hooks.onTaskCompleted?.(task); + } }).catch((_error) => { if (!this.activeDispatches.has(task.id)) return; this.clearDispatch(task.id); @@ -1199,6 +1222,7 @@ export class TempestCommand extends EventEmitter { vault: ReturnType; opsec: ReturnType; activeMission: string | null; + stallReason: string | null; } { const activeMission = this.mission.getActiveMission(); @@ -1212,6 +1236,7 @@ export class TempestCommand extends EventEmitter { vault: this.vault.getStats(), opsec: this.opsec.getStats(), activeMission: activeMission?.id || null, + stallReason: this.stallReason, }; } diff --git a/src/server.ts b/src/server.ts index 53fb037..c6ce004 100755 --- a/src/server.ts +++ b/src/server.ts @@ -5982,6 +5982,13 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise { res.json({ active: status.running, paused: status.paused, + stallReason: status.stallReason, name: status.name, tickCount: status.tickCount, mission: mission ? { @@ -7380,6 +7388,22 @@ async function refreshConnectedLocalAgentHealth(force = false): Promise { })); } +async function requireLiveLocalAgent(model: string | undefined): Promise<{ ok: true; id: string } | { ok: false; error: string }> { + const id = String(model || 'codex').trim(); + const entry = connectedLocalAgents.get(id); + if (!entry) return { ok: false, error: `local agent "${id}" is not connected — connect it in Settings first` }; + + await refreshConnectedLocalAgentHealth(true); + const refreshed = connectedLocalAgents.get(id); + if (refreshed?.lastPing?.ok) return { ok: true, id }; + + return { + ok: false, + error: `local agent "${id}" is connected but not live` + + (refreshed?.lastPing?.error ? ` — ${refreshed.lastPing.error}` : ''), + }; +} + // GET /api/agents/local/detect — which agents are installed / authed / ready (no tokens spent) app.get('/api/agents/local/detect', async (_req: Request, res: Response): Promise => { try { From 73d888f4a18925458531d52cb5948f66e1879513 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 18:33:35 -0400 Subject: [PATCH 4/8] Expose stalled local agent failures --- docs/index.html | 16 ++++++++++++++++ src/__tests__/index.test.ts | 12 ++++++++---- src/agent/index.ts | 8 ++++++-- src/index.ts | 8 +++++++- src/operators/index.ts | 3 +++ src/target/index.ts | 8 +++++++- 6 files changed, 47 insertions(+), 8 deletions(-) diff --git a/docs/index.html b/docs/index.html index 2714e93..90f0e8a 100755 --- a/docs/index.html +++ b/docs/index.html @@ -6223,6 +6223,11 @@ tempest.operators.spawn('scan return; } + if (status.paused && status.stallReason) { + resolve(status); + return; + } + // Update kill chain from mission phase if (status.mission?.currentPhase) { const phaseMap = { @@ -18252,6 +18257,14 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati // Poll for mission completion (SSE provides real-time updates, poll for completion detection) const finalStatus = await BackendDispatch.pollUntilComplete((status) => { + if (status.paused && status.stallReason) { + const reason = status.stallReason; + document.getElementById('cmdMissionStatus').textContent = `Stalled — ${reason}`; + document.getElementById('cmdMissionStatus').style.color = '#ff6b6b'; + addMissionLog('error', reason); + addIntel('ERROR', reason, 'danger'); + return; + } // Periodic status update if (status.operators?.summary) { const ops = status.operators.summary; @@ -18269,6 +18282,9 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati }, 3000); // Mission complete + if (finalStatus.paused && finalStatus.stallReason) { + throw new Error(finalStatus.stallReason); + } const findingCount = finalStatus.findings?.length || 0; completeMission(null); addIntel('COMMAND', `Backend mission complete. ${findingCount} findings.`, 'success'); diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 5865e66..1fc8835 100755 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -419,7 +419,7 @@ describe('Wedged-dispatch timeout backstop', () => { expect(stopped).toBe(true); }); - it('a wedged mission reaches phase advancement once the per-dispatch backstop fires', async () => { + it('a wedged mission stalls instead of advancing after required recon dispatches time out', async () => { // Tiny backstop so the test is fast and deterministic. const prev = process.env.T3MP3ST_TASK_TIMEOUT_MS; process.env.T3MP3ST_TASK_TIMEOUT_MS = '30'; @@ -442,14 +442,18 @@ describe('Wedged-dispatch timeout backstop', () => { command.start(); // Poll (real timers): the 1s tick loop must dispatch, wedge, then reap the - // dispatch via the backstop and advance past RECON. + // dispatch via the backstop. Failed required recon work should stall instead + // of advancing the mission with no successful backend/model work. const deadline = Date.now() + 8000; - while (Date.now() < deadline && !phaseAdvanced) { + while (Date.now() < deadline && !command.getStatus().paused) { await new Promise(r => setTimeout(r, 100)); } + const status = command.getStatus(); command.stop(); - expect(phaseAdvanced).toBe(true); + expect(phaseAdvanced).toBe(false); + expect(status.paused).toBe(true); + expect(status.stallReason).toContain('stalled in reconnaissance'); // The wedged operator was reset back to idle (or re-tasked in a later phase), // never left stuck in 'executing' with no current task. const stuck = command.cell.getAllOperators().some( diff --git a/src/agent/index.ts b/src/agent/index.ts index 71fb532..092b55a 100755 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -65,6 +65,8 @@ export interface AgentResult { durationMs: number; /** Whether the agent hit the iteration limit */ hitLimit: boolean; + /** Error from the forced final-summary call after hitting limits, if any */ + finalSummaryError?: string; } export interface AgentEvents { @@ -267,6 +269,7 @@ export class AgentLoop extends EventEmitter { }); let summary = 'Agent reached iteration limit without producing a final summary.'; + let finalSummaryError: string | undefined; try { const finalResponse = await this.llm.chat(messages, { maxTokens: 2048 }); summary = finalResponse.content; @@ -275,8 +278,8 @@ export class AgentLoop extends EventEmitter { for (const f of this.parseFinalFindings(summary)) { if (!allFindings.some((x) => x.title === f.title)) allFindings.push(f); } - } catch { - // Use what we have + } catch (error) { + finalSummaryError = error instanceof Error ? error.message : String(error); } const result: AgentResult = { @@ -288,6 +291,7 @@ export class AgentLoop extends EventEmitter { tokensUsed, durationMs: Date.now() - startTime, hitLimit, + finalSummaryError, }; this.emit('agent:complete', result); diff --git a/src/index.ts b/src/index.ts index 2c0d6dc..665817e 100755 --- a/src/index.ts +++ b/src/index.ts @@ -268,6 +268,9 @@ import { // TEMPEST COMMAND // ============================================================================= +const DEFAULT_AGENT_MAX_ITERATIONS = 15; +const LOCAL_AGENT_MAX_ITERATIONS = Number(process.env.T3MP3ST_LOCAL_AGENT_MAX_ITERATIONS || 30); + /** * TEMPEST Command - Main orchestration controller */ @@ -1158,8 +1161,11 @@ export class TempestCommand extends EventEmitter { // Attach the agent loop scoped to this archetype's SPECIALIZED role toolkit (defaultTools = // the curated per-operator tool allowlist). toolCategories stays as a coarse fallback. const profile = ARCHETYPE_PROFILES[archetype]; + const maxIterations = this.llm.getProvider() === 'local-agent' + ? LOCAL_AGENT_MAX_ITERATIONS + : DEFAULT_AGENT_MAX_ITERATIONS; const agentLoop = new AgentLoop(this.llm, this.arsenal, { - maxIterations: 15, + maxIterations, maxTokens: 50000, toolCategories: profile.toolCategories, tools: profile.defaultTools, diff --git a/src/operators/index.ts b/src/operators/index.ts index 956d1c1..cdb70f5 100755 --- a/src/operators/index.ts +++ b/src/operators/index.ts @@ -461,6 +461,9 @@ export class OperatorAgent extends EventEmitter { return { success: result.success, output: result.summary, + error: result.finalSummaryError + ? `${result.summary} Final summary failed: ${result.finalSummaryError}` + : undefined, findings: result.findings.map(f => f.title), nextTasks: undefined, }; diff --git a/src/target/index.ts b/src/target/index.ts index 203c4ed..f447c66 100755 --- a/src/target/index.ts +++ b/src/target/index.ts @@ -271,6 +271,7 @@ export class TargetEnvironment extends EventEmitter { export function createTargetFromUrl(url: string, zone: TargetZone = 'external'): Target { const parsed = new URL(url); + const normalizedUrl = parsed.toString(); return { id: randomUUID(), @@ -278,12 +279,17 @@ export function createTargetFromUrl(url: string, zone: TargetZone = 'external'): type: 'web_application', zone, status: 'discovered', - address: url, + address: parsed.hostname, port: parsed.port ? parseInt(parsed.port) : (parsed.protocol === 'https:' ? 443 : 80), protocol: parsed.protocol.replace(':', ''), services: [], vulnerabilities: [], credentials: [], + metadata: { + url: normalizedUrl, + origin: parsed.origin, + path: `${parsed.pathname}${parsed.search}${parsed.hash}`, + }, discoveredAt: Date.now(), }; } From d9a4c472412d082fa660fb86347e958ead9b8a53 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 3 Jul 2026 20:59:50 -0400 Subject: [PATCH 5/8] Show backend failure reason in UI refusal --- docs/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.html b/docs/index.html index 90f0e8a..8a06e30 100755 --- a/docs/index.html +++ b/docs/index.html @@ -18308,7 +18308,7 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati setFidelity('off'); const why = isScopeBlock ? 'the scope gate refused this target (no valid approval/receipt for it)' - : 'the live-tools backend is unavailable'; + : `the live-tools backend failed: ${msg}`; addIntel('REFUSED', `Refusing to simulate against external target ${target} — ${why}. Authorize the target (mint a receipt) or point at a local/practice target.`, 'danger'); addMissionLog('error', `Refused: will not run an LLM-only simulation against external target ${target} (${why})`); toast(`Won't fake a run against ${target} — ${why}`, 'error'); From e53c61e7123cfb6c64e8f017d9ca56618f88f117 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 5 Jul 2026 21:27:19 -0400 Subject: [PATCH 6/8] Increase local agent timeouts --- src/agent/local-agents.ts | 9 +++++++-- src/index.ts | 10 ++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/agent/local-agents.ts b/src/agent/local-agents.ts index fa6dd2e..f6de5ec 100644 --- a/src/agent/local-agents.ts +++ b/src/agent/local-agents.ts @@ -243,6 +243,11 @@ function agentFailureOutput(output: string): string | null { return null; } +function envTimeoutMs(name: string, fallback: number): number { + const parsed = Number(process.env[name]); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + /** * Drive a connected agent with a one-shot headless prompt (real round-trip — SPENDS the agent's quota). * Used by /ping (liveness proof) and /dispatch (actually using the operator). @@ -256,7 +261,7 @@ export function runLocalAgent( const t0 = Date.now(); if (!spec) return Promise.resolve({ ok: false, latencyMs: 0, output: '', error: `unknown agent: ${id}` }); const args = spec.oneShot(prompt, opts.model); - const timeoutMs = opts.timeoutMs ?? 120000; + const timeoutMs = opts.timeoutMs ?? envTimeoutMs('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS', 600000); const maxChars = opts.maxChars ?? 4000; // child env: provider keys stripped + HOME pinned to the real agent home (see childEnv). const env = childEnv(); @@ -306,7 +311,7 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin // child env: provider keys stripped + HOME pinned to the real agent home (see childEnv). const env = childEnv(); const model = opts.model && opts.model !== 'codex-default' && opts.model !== id ? opts.model : undefined; - const timeoutMs = opts.timeoutMs ?? 240000; + const timeoutMs = opts.timeoutMs ?? envTimeoutMs('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS', 600000); let args: string[]; let viaStdin = true; diff --git a/src/index.ts b/src/index.ts index 665817e..e365562 100755 --- a/src/index.ts +++ b/src/index.ts @@ -208,6 +208,7 @@ import type { TempestConfig, LLMConfig, RuntimeHooks, + LLMProvider, OperatorArchetype, CommandEvents, Finding, @@ -325,6 +326,7 @@ export class TempestCommand extends EventEmitter { private tickInterval: NodeJS.Timeout | null = null; private tickCount: number = 0; private hooks: RuntimeHooks; + private readonly taskTimeoutMs: number; /** * White-box source context (security-prioritized code excerpt), set by the @@ -359,6 +361,7 @@ export class TempestCommand extends EventEmitter { super(); this.name = config.name; this.hooks = config.hooks || {}; + this.taskTimeoutMs = TempestCommand.resolveTaskTimeoutMs(config.llm.provider); // Initialize LLM backbone this.llm = new LLMBackbone(config.llm); @@ -822,20 +825,19 @@ export class TempestCommand extends EventEmitter { * legitimately-slow-but-working LLM task (e.g. opus planning ~165s); it only fires * on truly-hung dispatches. Override via T3MP3ST_TASK_TIMEOUT_MS. */ - private readonly taskTimeoutMs: number = TempestCommand.resolveTaskTimeoutMs(); - /** * Resolve the dispatch timeout from the environment, falling back to the 5-minute * default. Guards against a non-numeric / non-positive override. */ - private static resolveTaskTimeoutMs(): number { + private static resolveTaskTimeoutMs(provider?: LLMProvider): number { const DEFAULT_TASK_TIMEOUT_MS = 300000; // 5 minutes — generous backstop, not a deadline + const LOCAL_AGENT_TASK_TIMEOUT_MS = 1800000; // local CLI agents can need multiple slow turns const raw = process.env.T3MP3ST_TASK_TIMEOUT_MS; if (raw != null && raw.trim() !== '') { const parsed = Number(raw); if (Number.isFinite(parsed) && parsed > 0) return parsed; } - return DEFAULT_TASK_TIMEOUT_MS; + return provider === 'local-agent' ? LOCAL_AGENT_TASK_TIMEOUT_MS : DEFAULT_TASK_TIMEOUT_MS; } /** Track whether we've seeded initial tasks for the current mission */ From 75b7ca02ef28fd4713c2fc39d2ec2eedf599ddad Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 5 Jul 2026 21:27:58 -0400 Subject: [PATCH 7/8] Honor local agent timeout defaults --- src/index.ts | 15 ++++++++------- src/llm/index.ts | 3 ++- src/server.ts | 18 ++++++++++++++++-- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index e365562..e975767 100755 --- a/src/index.ts +++ b/src/index.ts @@ -821,13 +821,14 @@ export class TempestCommand extends EventEmitter { * GENEROUS per-dispatch wall-clock backstop (ms). If a single task dispatch stays * in-flight longer than this, the tick loop force-resolves it as a timeout so * pendingOrActive can reach 0 and the mission can advance/complete even when an - * operator promise wedges. Deliberately large (default 5 min) so it NEVER kills a - * legitimately-slow-but-working LLM task (e.g. opus planning ~165s); it only fires - * on truly-hung dispatches. Override via T3MP3ST_TASK_TIMEOUT_MS. + * operator promise wedges. Deliberately large (default 5 min for API models, + * 30 min for local-agent backends) so it does not kill legitimately slow local + * CLI work; it only fires on truly-hung dispatches. Override via + * T3MP3ST_TASK_TIMEOUT_MS. */ /** - * Resolve the dispatch timeout from the environment, falling back to the 5-minute - * default. Guards against a non-numeric / non-positive override. + * Resolve the dispatch timeout from the environment, falling back to the + * provider-specific default. Guards against a non-numeric / non-positive override. */ private static resolveTaskTimeoutMs(provider?: LLMProvider): number { const DEFAULT_TASK_TIMEOUT_MS = 300000; // 5 minutes — generous backstop, not a deadline @@ -1035,8 +1036,8 @@ export class TempestCommand extends EventEmitter { * never advances, and completeMission() is never called: the mission HANGS. * * On every tick we scan the in-flight dispatches and force-resolve any that have - * exceeded the GENEROUS wall-clock backstop (taskTimeoutMs, default 5 min), or - * that exhibit the exact wedge symptom (owning operator is `executing`/`tasked` + * exceeded the GENEROUS wall-clock backstop (taskTimeoutMs), or that exhibit + * the exact wedge symptom (owning operator is `executing`/`tasked` * but its currentTask is null — i.e. it has silently dropped the task). For each * we: (1) mark the task failed/timed-out in the queue, (2) clear its dispatch * bookkeeping, and (3) reset the owning operator back to idle so it can take new diff --git a/src/llm/index.ts b/src/llm/index.ts index d796e73..0b426f3 100755 --- a/src/llm/index.ts +++ b/src/llm/index.ts @@ -1070,7 +1070,8 @@ class LocalAgentAdapter implements LLMProviderAdapter { async chat(messages: LLMMessage[], options?: ChatOptions): Promise { const agentId = this.config.model || 'codex'; const prompt = this.formatPrompt(messages, options); - const content = (await localAgentChat(agentId, prompt, { timeoutMs: this.config.timeout || 240000 })).trim(); + const timeoutMs = typeof this.config.timeout === 'number' && this.config.timeout > 0 ? this.config.timeout : undefined; + const content = (await localAgentChat(agentId, prompt, { timeoutMs })).trim(); // Tool-calling over text: if the Arsenal was offered, parse the agent's tool requests so the // ReAct loop EXECUTES them instead of treating this planning turn as the (abstaining) final answer. const toolCalls = options?.tools?.length ? parseTextToolCalls(content) : undefined; diff --git a/src/server.ts b/src/server.ts index c6ce004..01b5fb2 100755 --- a/src/server.ts +++ b/src/server.ts @@ -6478,6 +6478,18 @@ function providerNeedsApiKey(provider: string): boolean { return !['codex', 'mock', 'local', 'local-agent'].includes(provider); } +function readPositiveTimeoutEnv(name: string): number | undefined { + const raw = process.env[name]; + if (raw == null || raw.trim() === '') return undefined; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +function readGeneralTimeoutEnv(): number | undefined { + return readPositiveTimeoutEnv('TEMPEST_GENERAL_TIMEOUT_MS') + ?? readPositiveTimeoutEnv('T3MP3ST_GENERAL_TIMEOUT_MS'); +} + // SECURITY NOTE: `apiKey` is accepted from the request BODY here (and in the // mission/general routes that call this). Sending secrets in the body is not // ideal — an Authorization header is preferred — but the same-origin UI posts @@ -6501,7 +6513,9 @@ function resolveGeneralLLMConfig(provider: string, model: string | undefined, ap model: model || 'codex', maxTokens: 8192, temperature: 0.4, - timeout: Number(process.env.TEMPEST_GENERAL_TIMEOUT_MS) || 300000, + timeout: readGeneralTimeoutEnv() + ?? readPositiveTimeoutEnv('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS') + ?? 600000, }; } const baseConfig = config.getLLMConfig(selectedProvider as any, model); @@ -6515,7 +6529,7 @@ function resolveGeneralLLMConfig(provider: string, model: string | undefined, ap apiKey: effectiveKey, maxTokens: 8192, temperature: 0.4, - timeout: Number(process.env.TEMPEST_GENERAL_TIMEOUT_MS) || 300000, // General planning needs room (was a hardcoded 60s); override via env + timeout: readGeneralTimeoutEnv() ?? 300000, // General planning needs room (was a hardcoded 60s); override via env }; } From 23713fa1778e92c3d73edbbe42b48ab8c27de4ec Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 5 Jul 2026 21:28:35 -0400 Subject: [PATCH 8/8] Document local agent timeout tuning --- README.md | 4 ++++ .../local-agent-tool-calling.test.ts | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/README.md b/README.md index 617e507..c7ef9eb 100755 --- a/README.md +++ b/README.md @@ -77,6 +77,10 @@ export OPENROUTER_API_KEY=... # or VENICE_API_KEY / ANTHROPIC_API_KEY / OPEN export XAI_API_KEY=... # Grok Build (grok-build-0.1) — xAI's coding model, native tool-calling ``` +Slow local agents can be given more room with `T3MP3ST_LOCAL_AGENT_TIMEOUT_MS` +for each CLI call, `T3MP3ST_TASK_TIMEOUT_MS` for mission tasks, and +`T3MP3ST_GENERAL_TIMEOUT_MS` for planning requests. Values are milliseconds. + Or run it **fully offline** on your own model — no key, no cloud. Defaults to Ollama; point it at any OpenAI-compatible server (LM Studio, vLLM, llama.cpp): ```bash diff --git a/src/__tests__/local-agent-tool-calling.test.ts b/src/__tests__/local-agent-tool-calling.test.ts index a5791d2..45a3fb4 100644 --- a/src/__tests__/local-agent-tool-calling.test.ts +++ b/src/__tests__/local-agent-tool-calling.test.ts @@ -71,6 +71,15 @@ describe('parseTextToolCalls — happy path + drift tolerance', () => { }); describe('local-agent backbone surfaces toolCalls (keyless-path fix)', () => { + it('describes the current ACTION CONTRACT to the local agent', async () => { + cli.mockResolvedValueOnce('Final debrief.'); + await localBackbone().chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never); + const prompt = String(cli.mock.calls.at(-1)?.[1] || ''); + expect(prompt).toContain('ACTION CONTRACT'); + expect(prompt).toContain('nmap_scan'); + expect(prompt).toContain('port scan a host'); + }); + it('returns toolCalls + finishReason=tool_calls when the agent requests a tool', async () => { cli.mockResolvedValueOnce('```json\n{"tool_calls":[{"name":"nmap_scan","arguments":{"target":"x"}}]}\n```'); const res = await localBackbone().chatWithTools([{ role: 'user', content: 'scan' }], TOOLS as never); @@ -83,6 +92,19 @@ describe('local-agent backbone surfaces toolCalls (keyless-path fix)', () => { expect(res.toolCalls).toBeUndefined(); expect(res.finishReason).toBe('stop'); }); + + it('does not force the old short timeout when no local-agent timeout is configured', async () => { + cli.mockResolvedValueOnce('done'); + await localBackbone().chat([{ role: 'user', content: 'hello' }]); + expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ timeoutMs: undefined }); + }); + + it('passes an explicit local-agent timeout through to the CLI runner', async () => { + cli.mockResolvedValueOnce('done'); + await new LLMBackbone({ provider: 'local-agent', model: 'codex', timeout: 900000 } as never) + .chat([{ role: 'user', content: 'hello' }]); + expect(cli.mock.calls.at(-1)?.[2]).toMatchObject({ timeoutMs: 900000 }); + }); }); describe('codex backbone surfaces toolCalls (guards the CodexAdapter half of the fix)', () => {