Merge pull request #1 from 5stars217/codex/local-agent-home

[codex] Respect real home for local agent auth
This commit is contained in:
pliny authored and GitHub committed 2026-07-08 10:25:45 -04:00
commit f94ca45b1c
11 files changed
+240 -47

No files matched your search

+4
View File
@@ -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 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): 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 ```bash
+35 -13
View File
@@ -6223,6 +6223,11 @@ tempest.operators.<span class="function">spawn</span>(<span class="string">'scan
return; return;
} }
if (status.paused && status.stallReason) {
resolve(status);
return;
}
// Update kill chain from mission phase // Update kill chain from mission phase
if (status.mission?.currentPhase) { if (status.mission?.currentPhase) {
const phaseMap = { const phaseMap = {
@@ -17921,7 +17926,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 // ── 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) ── // actual agent state from the first poll (and t3mpPreferredAgent can route to a live one) ──
let __agents = []; 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.__t3mpServerAgents = __agents;
window.__t3mpConnectedAgents = __agents.length; window.__t3mpConnectedAgents = __agents.length;
window.__t3mpLiveAgents = __agents.filter(a => a.lastPing && a.lastPing.ok).length; window.__t3mpLiveAgents = __agents.filter(a => a.lastPing && a.lastPing.ok).length;
@@ -17931,10 +17936,12 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati
const key = (typeof getApiKey === 'function') ? (getApiKey() || '') : ''; const key = (typeof getApiKey === 'function') ? (getApiKey() || '') : '';
let txt, color; let txt, color;
const serverLLM = !!(health && health.llm && health.llm.connected); const serverLLM = !!(health && health.llm && health.llm.connected);
const staleAgents = __agents.filter(a => !a.lastPing || !a.lastPing.ok).length;
const hasBackend = !!(agent || key || serverLLM); const hasBackend = !!(agent || key || serverLLM);
if (!key && agent) { txt = '⚡ ' + agent.toUpperCase() + ' · local, no key'; color = '#00ff88'; } 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 (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 (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'; } else { txt = 'none — connect agent / add key'; color = '#ff6b6b'; }
$('sysLLM').innerHTML = '<span style="color:'+color+'">'+txt+'</span>'; $('sysLLM').innerHTML = '<span style="color:'+color+'">'+txt+'</span>';
// Keyless hero callout: when NO backend is present (no agent, no key, no server LLM), // Keyless hero callout: when NO backend is present (no agent, no key, no server LLM),
@@ -17959,7 +17966,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) ── // ── Local agents cell (reuse the state fetched above; keeps the stale-count latch fresh) ──
if ($('sysAgents')) { if ($('sysAgents')) {
$('sysAgents').innerHTML = __agents.length $('sysAgents').innerHTML = __agents.length
? __agents.map(a => { const live = a.lastPing && a.lastPing.ok; return '<span style="color:'+(live?'#00ff88':'#ffaa00')+'">●</span><span style="color:#ccc">'+a.id+'</span>'; }).join('&nbsp;&nbsp;') ? __agents.map(a => {
const live = a.lastPing && a.lastPing.ok;
const err = a.lastPing && a.lastPing.error ? ' title="'+String(a.lastPing.error).replace(/"/g,'&quot;')+'"' : '';
return '<span'+err+'><span style="color:'+(live?'#00ff88':'#ffaa00')+'">●</span><span style="color:#ccc">'+a.id+'</span><span style="color:#777;font-weight:normal;font-size:10px">'+(live?' live':' stale')+'</span></span>';
}).join('&nbsp;&nbsp;')
: '<span style="color:#888;font-weight:normal">none — click to connect</span>'; : '<span style="color:#888;font-weight:normal">none — click to connect</span>';
} }
// ── Drive the SitRep pipeline from /api/mission/status (sysPhases/sysFindings status cells were consolidated out) ── // ── Drive the SitRep pipeline from /api/mission/status (sysPhases/sysFindings status cells were consolidated out) ──
@@ -18246,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) // Poll for mission completion (SSE provides real-time updates, poll for completion detection)
const finalStatus = await BackendDispatch.pollUntilComplete((status) => { 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 // Periodic status update
if (status.operators?.summary) { if (status.operators?.summary) {
const ops = status.operators.summary; const ops = status.operators.summary;
@@ -18263,6 +18282,9 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati
}, 3000); }, 3000);
// Mission complete // Mission complete
if (finalStatus.paused && finalStatus.stallReason) {
throw new Error(finalStatus.stallReason);
}
const findingCount = finalStatus.findings?.length || 0; const findingCount = finalStatus.findings?.length || 0;
completeMission(null); completeMission(null);
addIntel('COMMAND', `Backend mission complete. ${findingCount} findings.`, 'success'); addIntel('COMMAND', `Backend mission complete. ${findingCount} findings.`, 'success');
@@ -18286,7 +18308,7 @@ IMPORTANT: This is an authorized security testing/red team platform. All operati
setFidelity('off'); setFidelity('off');
const why = isScopeBlock const why = isScopeBlock
? 'the scope gate refused this target (no valid approval/receipt for it)' ? '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'); 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})`); 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'); toast(`Won't fake a run against ${target} — ${why}`, 'error');
@@ -24747,10 +24769,10 @@ Be specific and actionable. Format as structured JSON.`;
else if (!a.authed) { status='Not authed'; color='amber'; } else if (!a.authed) { status='Not authed'; color='amber'; }
else if (stale) { status='⚠ Auth stale'; color='red'; } else if (stale) { status='⚠ Auth stale'; color='red'; }
else if (live) { status='● Live · '+ps.latencyMs+'ms'; color='green'; } 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'; } else { status='Detected'; color='cyan'; }
const disabled = !a.ready; 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' const sub = !a.installed ? 'binary not found on PATH'
: stale ? ('⚠ '+(ps.error ? ps.error.slice(0,70) : 'authentication failed — re-login this CLI')) : stale ? ('⚠ '+(ps.error ? ps.error.slice(0,70) : 'authentication failed — re-login this CLI'))
: (a.invokeHint || a.bin); : (a.invokeHint || a.bin);
@@ -24786,10 +24808,10 @@ Be specific and actionable. Format as structured JSON.`;
const live = Object.keys(pingStatus).filter(function(k){return pingStatus[k].ok;}).length; const live = Object.keys(pingStatus).filter(function(k){return pingStatus[k].ok;}).length;
const sum = document.getElementById('localAgentsSummary'); const sum = document.getElementById('localAgentsSummary');
if (sum) sum.innerHTML = connectedIds.length+' active'+(live?' · '+live+' live':'')+' · '+ready+' ready · '+installed+'/'+agents.length+' installed'; 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 // expose live/connected counts separately. A plain connect is just a registration; only a
// (a plain connect counts — not only a successful live-ping) // fresh successful ping proves the local model is currently usable.
window.__t3mpLiveAgents = live; window.__t3mpConnectedAgents = connectedIds.length; 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 ── // ── remember connected agents + silently re-establish them after a refresh ──
@@ -24866,8 +24888,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. // _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" // 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: // 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. // the server's own LLM (llmAvailable) or a live local agent. A connected-but-stale
return !!(api.llmAvailable || window.__t3mpLiveAgents > 0 || window.__t3mpConnectedAgents > 0); // agent is not enough, because the underlying local model may have gone down.
return !!(api.llmAvailable || window.__t3mpLiveAgents > 0);
} }
window.t3mpHasBackend = hasBackend; window.t3mpHasBackend = hasBackend;
// Pick the best connected agent to serve as the mission LLM backend: prefer a live-pinged one, and // Pick the best connected agent to serve as the mission LLM backend: prefer a live-pinged one, and
@@ -24876,15 +24899,14 @@ Be specific and actionable. Format as structured JSON.`;
var order = ['codex','hermes','claude']; var order = ['codex','hermes','claude'];
var connected = (connectedIds || []).slice(); var connected = (connectedIds || []).slice();
var live = connected.filter(function(id){ return pingStatus[id] && pingStatus[id].ok; }); 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 // 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 // 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 // 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. // already has a live agent — the exact AI-backbone/mission-routing mismatch we want to avoid.
if (!pool.length && Array.isArray(window.__t3mpServerAgents)) { if (!pool.length && Array.isArray(window.__t3mpServerAgents)) {
var srv = window.__t3mpServerAgents.slice(); var srv = window.__t3mpServerAgents.slice();
var srvLive = srv.filter(function(a){ return a.lastPing && a.lastPing.ok; }); pool = srv.filter(function(a){ return a.lastPing && a.lastPing.ok; }).map(function(a){ return a.id; });
pool = (srvLive.length ? srvLive : srv).map(function(a){ return a.id; });
} }
pool.sort(function(a,b){ return order.indexOf(a) - order.indexOf(b); }); pool.sort(function(a,b){ return order.indexOf(a) - order.indexOf(b); });
return pool[0] || null; return pool[0] || null;
+8 -4
View File
@@ -419,7 +419,7 @@ describe('Wedged-dispatch timeout backstop', () => {
expect(stopped).toBe(true); 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. // Tiny backstop so the test is fast and deterministic.
const prev = process.env.T3MP3ST_TASK_TIMEOUT_MS; const prev = process.env.T3MP3ST_TASK_TIMEOUT_MS;
process.env.T3MP3ST_TASK_TIMEOUT_MS = '30'; process.env.T3MP3ST_TASK_TIMEOUT_MS = '30';
@@ -442,14 +442,18 @@ describe('Wedged-dispatch timeout backstop', () => {
command.start(); command.start();
// Poll (real timers): the 1s tick loop must dispatch, wedge, then reap the // 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; const deadline = Date.now() + 8000;
while (Date.now() < deadline && !phaseAdvanced) { while (Date.now() < deadline && !command.getStatus().paused) {
await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 100));
} }
const status = command.getStatus();
command.stop(); 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), // The wedged operator was reset back to idle (or re-tasked in a later phase),
// never left stuck in 'executing' with no current task. // never left stuck in 'executing' with no current task.
const stuck = command.cell.getAllOperators().some( const stuck = command.cell.getAllOperators().some(
@@ -71,6 +71,15 @@ describe('parseTextToolCalls — happy path + drift tolerance', () => {
}); });
describe('local-agent backbone surfaces toolCalls (keyless-path fix)', () => { 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 () => { 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```'); 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); 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.toolCalls).toBeUndefined();
expect(res.finishReason).toBe('stop'); 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)', () => { describe('codex backbone surfaces toolCalls (guards the CodexAdapter half of the fix)', () => {
+6 -2
View File
@@ -65,6 +65,8 @@ export interface AgentResult {
durationMs: number; durationMs: number;
/** Whether the agent hit the iteration limit */ /** Whether the agent hit the iteration limit */
hitLimit: boolean; hitLimit: boolean;
/** Error from the forced final-summary call after hitting limits, if any */
finalSummaryError?: string;
} }
export interface AgentEvents { export interface AgentEvents {
@@ -267,6 +269,7 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
}); });
let summary = 'Agent reached iteration limit without producing a final summary.'; let summary = 'Agent reached iteration limit without producing a final summary.';
let finalSummaryError: string | undefined;
try { try {
const finalResponse = await this.llm.chat(messages, { maxTokens: 2048 }); const finalResponse = await this.llm.chat(messages, { maxTokens: 2048 });
summary = finalResponse.content; summary = finalResponse.content;
@@ -275,8 +278,8 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
for (const f of this.parseFinalFindings(summary)) { for (const f of this.parseFinalFindings(summary)) {
if (!allFindings.some((x) => x.title === f.title)) allFindings.push(f); if (!allFindings.some((x) => x.title === f.title)) allFindings.push(f);
} }
} catch { } catch (error) {
// Use what we have finalSummaryError = error instanceof Error ? error.message : String(error);
} }
const result: AgentResult = { const result: AgentResult = {
@@ -288,6 +291,7 @@ export class AgentLoop extends EventEmitter<AgentEvents> {
tokensUsed, tokensUsed,
durationMs: Date.now() - startTime, durationMs: Date.now() - startTime,
hitLimit, hitLimit,
finalSummaryError,
}; };
this.emit('agent:complete', result); this.emit('agent:complete', result);
+20 -4
View File
@@ -236,6 +236,18 @@ export interface AgentRunResult {
error?: string; 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;
}
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). * 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). * Used by /ping (liveness proof) and /dispatch (actually using the operator).
@@ -249,7 +261,7 @@ export function runLocalAgent(
const t0 = Date.now(); const t0 = Date.now();
if (!spec) return Promise.resolve({ ok: false, latencyMs: 0, output: '', error: `unknown agent: ${id}` }); if (!spec) return Promise.resolve({ ok: false, latencyMs: 0, output: '', error: `unknown agent: ${id}` });
const args = spec.oneShot(prompt, opts.model); 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; const maxChars = opts.maxChars ?? 4000;
// child env: provider keys stripped + HOME pinned to the real agent home (see childEnv). // child env: provider keys stripped + HOME pinned to the real agent home (see childEnv).
const env = childEnv(); const env = childEnv();
@@ -270,7 +282,9 @@ export function runLocalAgent(
child.on('close', (code) => { child.on('close', (code) => {
const latencyMs = Date.now() - t0; const latencyMs = Date.now() - t0;
const output = out.trim().slice(0, maxChars); 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) }); else finish({ ok: false, latencyMs, output, error: (errOut.trim() || `exited with code ${code}`).slice(0, 300) });
}); });
}); });
@@ -297,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). // child env: provider keys stripped + HOME pinned to the real agent home (see childEnv).
const env = childEnv(); const env = childEnv();
const model = opts.model && opts.model !== 'codex-default' && opts.model !== id ? opts.model : undefined; 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 args: string[];
let viaStdin = true; let viaStdin = true;
@@ -329,7 +343,9 @@ export function localAgentChat(id: string, prompt: string, opts: { model?: strin
let content = out.trim(); let content = out.trim();
if (outFile) { try { content = (readFileSync(outFile, 'utf8').trim() || content); } catch { /* fall back to stdout */ } } if (outFile) { try { content = (readFileSync(outFile, 'utf8').trim() || content); } catch { /* fall back to stdout */ } }
finish(() => { 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))); else reject(new Error((errOut.trim() || content || `exited with code ${code}`).slice(0, 800)));
}); });
}); });
+49 -15
View File
@@ -208,6 +208,7 @@ import type {
TempestConfig, TempestConfig,
LLMConfig, LLMConfig,
RuntimeHooks, RuntimeHooks,
LLMProvider,
OperatorArchetype, OperatorArchetype,
CommandEvents, CommandEvents,
Finding, Finding,
@@ -268,6 +269,9 @@ import {
// TEMPEST COMMAND // 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 * TEMPEST Command - Main orchestration controller
*/ */
@@ -322,6 +326,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
private tickInterval: NodeJS.Timeout | null = null; private tickInterval: NodeJS.Timeout | null = null;
private tickCount: number = 0; private tickCount: number = 0;
private hooks: RuntimeHooks; private hooks: RuntimeHooks;
private readonly taskTimeoutMs: number;
/** /**
* White-box source context (security-prioritized code excerpt), set by the * White-box source context (security-prioritized code excerpt), set by the
@@ -356,6 +361,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
super(); super();
this.name = config.name; this.name = config.name;
this.hooks = config.hooks || {}; this.hooks = config.hooks || {};
this.taskTimeoutMs = TempestCommand.resolveTaskTimeoutMs(config.llm.provider);
// Initialize LLM backbone // Initialize LLM backbone
this.llm = new LLMBackbone(config.llm); this.llm = new LLMBackbone(config.llm);
@@ -786,6 +792,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
public resume(): void { public resume(): void {
if (!this.running || !this.paused) return; if (!this.running || !this.paused) return;
this.paused = false; this.paused = false;
this.stallReason = null;
this.emit('command:resumed'); this.emit('command:resumed');
} }
@@ -814,29 +821,32 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
* GENEROUS per-dispatch wall-clock backstop (ms). If a single task dispatch stays * 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 * 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 * 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 * operator promise wedges. Deliberately large (default 5 min for API models,
* legitimately-slow-but-working LLM task (e.g. opus planning ~165s); it only fires * 30 min for local-agent backends) so it does not kill legitimately slow local
* on truly-hung dispatches. Override via T3MP3ST_TASK_TIMEOUT_MS. * CLI work; 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 * Resolve the dispatch timeout from the environment, falling back to the
* default. Guards against a non-numeric / non-positive override. * provider-specific 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 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; const raw = process.env.T3MP3ST_TASK_TIMEOUT_MS;
if (raw != null && raw.trim() !== '') { if (raw != null && raw.trim() !== '') {
const parsed = Number(raw); const parsed = Number(raw);
if (Number.isFinite(parsed) && parsed > 0) return parsed; 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 */ /** Track whether we've seeded initial tasks for the current mission */
private taskSeeded: boolean = false; 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 * Main tick loop — seeds tasks, dispatches to operators, advances phases
*/ */
@@ -889,13 +899,28 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
); );
const inFlight = allMissionTasks.filter(t => this.activeDispatches.has(t.id)); 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) { 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); const phaseIndex = mission.phases.indexOf(mission.currentPhase);
if (phaseIndex === -1) return; // Guard: phase not found (race condition) if (phaseIndex === -1) return; // Guard: phase not found (race condition)
if (phaseIndex < mission.phases.length - 1) { if (phaseIndex < mission.phases.length - 1) {
// Advance to next phase and generate tasks // Advance to next phase and generate tasks
this.mission.advancePhase(mission.id); this.mission.advancePhase(mission.id);
this.stallReason = null;
const targets = this.targetEnv.getAllTargets(); const targets = this.targetEnv.getAllTargets();
for (const target of targets) { for (const target of targets) {
this.mission.generateNextPhaseTasks(target.address); this.mission.generateNextPhaseTasks(target.address);
@@ -972,8 +997,12 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
// double-fire the completion hook. // double-fire the completion hook.
if (!this.activeDispatches.has(task.id)) return; if (!this.activeDispatches.has(task.id)) return;
this.clearDispatch(task.id); this.clearDispatch(task.id);
taskQueue.complete(task.id, result); if (result.success === false) {
this.hooks.onTaskCompleted?.(task); taskQueue.fail(task.id, result.error || result.output || 'task returned unsuccessful result');
} else {
taskQueue.complete(task.id, result);
this.hooks.onTaskCompleted?.(task);
}
}).catch((_error) => { }).catch((_error) => {
if (!this.activeDispatches.has(task.id)) return; if (!this.activeDispatches.has(task.id)) return;
this.clearDispatch(task.id); this.clearDispatch(task.id);
@@ -1007,8 +1036,8 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
* never advances, and completeMission() is never called: the mission HANGS. * 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 * 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 * exceeded the GENEROUS wall-clock backstop (taskTimeoutMs), or that exhibit
* that exhibit the exact wedge symptom (owning operator is `executing`/`tasked` * the exact wedge symptom (owning operator is `executing`/`tasked`
* but its currentTask is null — i.e. it has silently dropped the task). For each * 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 * 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 * bookkeeping, and (3) reset the owning operator back to idle so it can take new
@@ -1135,8 +1164,11 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
// Attach the agent loop scoped to this archetype's SPECIALIZED role toolkit (defaultTools = // 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. // the curated per-operator tool allowlist). toolCategories stays as a coarse fallback.
const profile = ARCHETYPE_PROFILES[archetype]; 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, { const agentLoop = new AgentLoop(this.llm, this.arsenal, {
maxIterations: 15, maxIterations,
maxTokens: 50000, maxTokens: 50000,
toolCategories: profile.toolCategories, toolCategories: profile.toolCategories,
tools: profile.defaultTools, tools: profile.defaultTools,
@@ -1199,6 +1231,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
vault: ReturnType<EvidenceVault['getStats']>; vault: ReturnType<EvidenceVault['getStats']>;
opsec: ReturnType<OpsecController['getStats']>; opsec: ReturnType<OpsecController['getStats']>;
activeMission: string | null; activeMission: string | null;
stallReason: string | null;
} { } {
const activeMission = this.mission.getActiveMission(); const activeMission = this.mission.getActiveMission();
@@ -1212,6 +1245,7 @@ export class TempestCommand extends EventEmitter<CommandEvents> {
vault: this.vault.getStats(), vault: this.vault.getStats(),
opsec: this.opsec.getStats(), opsec: this.opsec.getStats(),
activeMission: activeMission?.id || null, activeMission: activeMission?.id || null,
stallReason: this.stallReason,
}; };
} }
+2 -1
View File
@@ -1070,7 +1070,8 @@ class LocalAgentAdapter implements LLMProviderAdapter {
async chat(messages: LLMMessage[], options?: ChatOptions): Promise<LLMResponse> { async chat(messages: LLMMessage[], options?: ChatOptions): Promise<LLMResponse> {
const agentId = this.config.model || 'codex'; const agentId = this.config.model || 'codex';
const prompt = this.formatPrompt(messages, options); 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 // 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. // ReAct loop EXECUTES them instead of treating this planning turn as the (abstaining) final answer.
const toolCalls = options?.tools?.length ? parseTextToolCalls(content) : undefined; const toolCalls = options?.tools?.length ? parseTextToolCalls(content) : undefined;
+3
View File
@@ -461,6 +461,9 @@ export class OperatorAgent extends EventEmitter<OperatorEvents> {
return { return {
success: result.success, success: result.success,
output: result.summary, output: result.summary,
error: result.finalSummaryError
? `${result.summary} Final summary failed: ${result.finalSummaryError}`
: undefined,
findings: result.findings.map(f => f.title), findings: result.findings.map(f => f.title),
nextTasks: undefined, nextTasks: undefined,
}; };
+84 -7
View File
@@ -5982,6 +5982,13 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
res.status(400).json({ error: 'API key required — pass apiKey, configure one on the server, or connect a local agent (Claude Code / Codex / Hermes)' }); res.status(400).json({ error: 'API key required — pass apiKey, configure one on the server, or connect a local agent (Claude Code / Codex / Hermes)' });
return; return;
} }
if (provider === 'local-agent') {
const localAgent = await requireLiveLocalAgent(model);
if (!localAgent.ok) {
res.status(503).json({ error: localAgent.error });
return;
}
}
if (targets.length === 0) { if (targets.length === 0) {
res.status(400).json({ error: 'At least one target required' }); res.status(400).json({ error: 'At least one target required' });
@@ -6217,6 +6224,7 @@ app.get('/api/mission/status', (_req: Request, res: Response) => {
res.json({ res.json({
active: status.running, active: status.running,
paused: status.paused, paused: status.paused,
stallReason: status.stallReason,
name: status.name, name: status.name,
tickCount: status.tickCount, tickCount: status.tickCount,
mission: mission ? { mission: mission ? {
@@ -6470,6 +6478,18 @@ function providerNeedsApiKey(provider: string): boolean {
return !['codex', 'mock', 'local', 'local-agent'].includes(provider); 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 // 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 // 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 // ideal — an Authorization header is preferred — but the same-origin UI posts
@@ -6493,7 +6513,9 @@ function resolveGeneralLLMConfig(provider: string, model: string | undefined, ap
model: model || 'codex', model: model || 'codex',
maxTokens: 8192, maxTokens: 8192,
temperature: 0.4, 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); const baseConfig = config.getLLMConfig(selectedProvider as any, model);
@@ -6507,7 +6529,7 @@ function resolveGeneralLLMConfig(provider: string, model: string | undefined, ap
apiKey: effectiveKey, apiKey: effectiveKey,
maxTokens: 8192, maxTokens: 8192,
temperature: 0.4, 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
}; };
} }
@@ -7350,8 +7372,51 @@ 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 // 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 // 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: // src/agent/local-agents.ts). In-memory registry of agents connected this session:
const connectedLocalAgents = new Map<string, { id: string; label: string; version?: string; connectedAt: number; lastPing?: AgentRunSummary | null }>();
type AgentRunSummary = { ok: boolean; latencyMs: number; output: string; error?: string }; 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<string, ConnectedLocalAgent>();
const LOCAL_AGENT_HEALTH_TTL_MS = 30_000;
const LOCAL_AGENT_HEALTH_TIMEOUT_MS = 15_000;
async function refreshConnectedLocalAgentHealth(force = false): Promise<void> {
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();
}));
}
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) // 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<void> => { app.get('/api/agents/local/detect', async (_req: Request, res: Response): Promise<void> => {
@@ -7378,7 +7443,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; } if (!d.authed) { results.push({ id, status: 'not-authed', version: d.version }); continue; }
let ping: AgentRunSummary | null = null; let ping: AgentRunSummary | null = null;
if (doPing) ping = await pingLocalAgent(id); 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 }); results.push({ id, status: 'active', label: d.label, version: d.version, authMethod: d.authMethod, ping });
} }
syncLocalAgentSelection(connectedLocalAgents, ids, replace); syncLocalAgentSelection(connectedLocalAgents, ids, replace);
@@ -7391,7 +7463,10 @@ app.post('/api/agents/local/ping', async (req: Request, res: Response): Promise<
if (!body.id) { res.status(400).json({ error: 'id required' }); return; } if (!body.id) { res.status(400).json({ error: 'id required' }); return; }
const r = await pingLocalAgent(body.id, body.prompt); const r = await pingLocalAgent(body.id, body.prompt);
const entry = connectedLocalAgents.get(body.id); 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 }); res.json({ id: body.id, ...r });
}); });
@@ -7411,8 +7486,10 @@ app.post('/api/agents/local/disconnect', (req: Request, res: Response): void =>
res.json({ ok: true, connected: Array.from(connectedLocalAgents.keys()) }); res.json({ ok: true, connected: Array.from(connectedLocalAgents.keys()) });
}); });
// GET /api/agents/local/status — currently connected agents // GET /api/agents/local/status — connected agents, optionally with a bounded live health check.
app.get('/api/agents/local/status', (_req: Request, res: Response): void => { app.get('/api/agents/local/status', async (req: Request, res: Response): Promise<void> => {
const check = /^(1|true|yes|on)$/i.test(String(req.query.check || ''));
if (check) await refreshConnectedLocalAgentHealth(false);
res.json({ connected: Array.from(connectedLocalAgents.values()) }); res.json({ connected: Array.from(connectedLocalAgents.values()) });
}); });
+7 -1
View File
@@ -271,6 +271,7 @@ export class TargetEnvironment extends EventEmitter<TargetEvents> {
export function createTargetFromUrl(url: string, zone: TargetZone = 'external'): Target { export function createTargetFromUrl(url: string, zone: TargetZone = 'external'): Target {
const parsed = new URL(url); const parsed = new URL(url);
const normalizedUrl = parsed.toString();
return { return {
id: randomUUID(), id: randomUUID(),
@@ -278,12 +279,17 @@ export function createTargetFromUrl(url: string, zone: TargetZone = 'external'):
type: 'web_application', type: 'web_application',
zone, zone,
status: 'discovered', status: 'discovered',
address: url, address: parsed.hostname,
port: parsed.port ? parseInt(parsed.port) : (parsed.protocol === 'https:' ? 443 : 80), port: parsed.port ? parseInt(parsed.port) : (parsed.protocol === 'https:' ? 443 : 80),
protocol: parsed.protocol.replace(':', ''), protocol: parsed.protocol.replace(':', ''),
services: [], services: [],
vulnerabilities: [], vulnerabilities: [],
credentials: [], credentials: [],
metadata: {
url: normalizedUrl,
origin: parsed.origin,
path: `${parsed.pathname}${parsed.search}${parsed.hash}`,
},
discoveredAt: Date.now(), discoveredAt: Date.now(),
}; };
} }