Merge pull request #1 from 5stars217/codex/local-agent-home
[codex] Respect real home for local agent auth
This commit is contained in:
11 files changed
+240
-47
No files matched your search
@@ -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
@@ -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(' ')
|
? __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 '<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(' ')
|
||||||
: '<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;
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user