Merge remote-tracking branch 'upstream/main' into pr-47

# Conflicts:
#	README.md
This commit is contained in:
Joseph Magly committed 2026-07-16 11:55:20 -04:00
commit 95868c3d37
75 files changed
+7949 -648

No files matched your search

+355 -44
View File
@@ -32,9 +32,6 @@ import type { OperatorArchetype } from './types/index.js';
import { listOperatorPrompts, setOperatorOverride, resetOperatorOverride, type OperatorOverride } from './operators/index.js';
import { ingestRepoToSourceContext, runWhiteboxAnalysis, resolveContainedRepoPath, RepoPathError } from './recon/whitebox.js';
import { redactCredential } from './evidence/index.js';
import dotenv from 'dotenv';
dotenv.config();
const execFileAsync = promisify(execFile);
@@ -185,6 +182,25 @@ function isLoopbackOrigin(originHeader: string | undefined): boolean {
}
}
// #84: a SAME-ORIGIN request — the caller's Origin (or Referer) host:port equals THIS server's
// own Host header — is by definition not a foreign-website drive-by. It is trusted ONLY when the
// operator has bound to a non-loopback address (see HOST_IS_LOOPBACK), i.e. they have explicitly
// opted into network access — the same decision that stands the anti-rebinding Host guard down
// below. That lets the operator's own LAN/custom-host UI (e.g. http://192.168.x.x:3333/ui) talk to
// its backend instead of being rejected as cross-origin, while a foreign origin (evil.com) still
// mismatches the Host and is refused. On a loopback bind this is never consulted: the strict
// loopback-only origin gate plus the Host guard remain fully in force, so no CSRF/rebinding gap.
// URL.host is "hostname:port" with the default port (80/443) omitted — exactly how browsers set
// BOTH the Origin and the Host header, so a genuine same-origin request compares equal.
function isSameOriginAsHost(source: string | undefined, hostHeader: string | undefined): boolean {
if (!source || !hostHeader) return false;
try {
return new URL(source).host.toLowerCase() === hostHeader.trim().toLowerCase();
} catch {
return false;
}
}
// CORS locked to the localhost UI origins ONLY. A same-origin fetch from the UI
// (or a tool with no Origin like curl/CLI) is allowed; any other website's
// Origin is rejected so the browser blocks it from reading our responses.
@@ -219,6 +235,8 @@ app.use((req: Request, res: Response, next: NextFunction) => {
// No Origin AND no Referer → trusted local caller (curl/CLI/MCP). Allow.
if (!source) return next();
if (isLoopbackOrigin(source)) return next();
// #84: on a non-loopback (opted-in network) bind, also trust the operator's own same-origin UI.
if (!HOST_IS_LOOPBACK && isSameOriginAsHost(source, req.headers.host)) return next();
res.status(403).json({
error: 'Cross-origin request rejected',
detail: 'This local API only accepts requests from the localhost UI, curl, or the CLI. A cross-origin (foreign-website) Origin/Referer was detected and blocked to prevent CSRF/drive-by command execution.',
@@ -345,6 +363,79 @@ interface ParsedCommand {
const SHELL_META = /[|&;$<>`\\]/;
const COMMAND_CONTROL = /[\x00-\x1F\x7F-\x9F\u2028\u2029]/;
const CURL_TRANSPORT_OVERRIDE_FLAGS = new Set([
'--resolve',
'--connect-to',
'--proxy',
'--preproxy',
'--socks4',
'--socks4a',
'--socks5',
'--socks5-hostname',
'--unix-socket',
'--abstract-unix-socket',
'--interface',
'--url',
'--config',
'--next',
'-x',
'-K',
]);
const CURL_VALUE_FLAGS = new Set([
'-A', '--user-agent',
'-b', '--cookie',
'-c', '--cookie-jar',
'-d', '--data', '--data-ascii', '--data-binary', '--data-raw', '--data-urlencode',
'-F', '--form',
'-H', '--header',
'-m', '--max-time',
'-o', '--output',
'-T', '--upload-file',
'-u', '--user',
'-X', '--request',
'--cacert', '--cert', '--connect-timeout', '--key', '--request-target', '--retry',
]);
function findCurlTransportOverrideFlag(args: string[]): string | undefined {
for (const arg of args) {
if (!arg) continue;
const flag = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
if (CURL_TRANSPORT_OVERRIDE_FLAGS.has(flag)) return flag;
if (arg.startsWith('-x') && arg !== '-X' && arg.length > 2) return '-x';
if (arg.startsWith('-K') && arg.length > 2) return '-K';
}
return undefined;
}
function looksLikeCurlUrlOperand(arg: string): boolean {
return /^[a-z][a-z0-9+.-]*:\/\/\S+/i.test(arg)
|| /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:[/?#].*)?$/i.test(arg)
|| /^(?:localhost|(?:\d{1,3}\.){3}\d{1,3})(?::\d+)?(?:[/?#].*)?$/i.test(arg)
|| /^\[[0-9a-f:]+\](?::\d+)?(?:[/?#].*)?$/i.test(arg);
}
function countCurlUrlOperands(args: string[]): number {
let count = 0;
let skipNext = false;
let endOfOptions = false;
for (const arg of args) {
if (!arg) continue;
if (skipNext) { skipNext = false; continue; }
if (!endOfOptions && arg === '--') { endOfOptions = true; continue; }
if (!endOfOptions && arg.startsWith('--')) {
const hasInlineValue = arg.includes('=');
const flag = hasInlineValue ? arg.slice(0, arg.indexOf('=')) : arg;
if (!hasInlineValue && CURL_VALUE_FLAGS.has(flag)) skipNext = true;
continue;
}
if (!endOfOptions && arg.startsWith('-')) {
if (arg.length === 2 && CURL_VALUE_FLAGS.has(arg)) skipNext = true;
continue;
}
if (looksLikeCurlUrlOperand(arg)) count += 1;
}
return count;
}
function parseCommand(command: string): ParsedCommand | { error: string } {
if (SHELL_META.test(command) || COMMAND_CONTROL.test(command)) return { error: 'Shell control characters are not allowed; use direct argv-style commands only.' };
@@ -356,6 +447,15 @@ function parseCommand(command: string): ParsedCommand | { error: string } {
if (adapter?.execution === 'catalog_only' || adapter?.execution === 'import_only') {
return { error: `Tool is catalog-only and cannot be executed directly: ${bin}` };
}
if (bin === 'curl') {
const overrideFlag = findCurlTransportOverrideFlag(args);
if (overrideFlag) {
return { error: `curl flag ${overrideFlag} changes the effective network destination and is not allowed through /api/tools/execute.` };
}
if (countCurlUrlOperands(args) > 1) {
return { error: 'curl commands with multiple URL operands are not allowed through /api/tools/execute; submit one transfer per approved target.' };
}
}
return { bin, args };
}
@@ -508,6 +608,7 @@ interface ApprovalRequest {
target: string;
reason: string;
status: ApprovalStatus;
wildcardOptIn?: boolean;
operationId?: string;
requestedBy: DraftSource | 'system';
createdAt: string;
@@ -1092,10 +1193,29 @@ function approvalIsFresh(approval: ApprovalRequest): boolean {
return Date.parse(approval.expiresAt) > Date.now();
}
function targetUsesWildcard(target: string): boolean {
const raw = normalizeTargetValue(target).toLowerCase();
if (raw === '*') return true;
const withoutScheme = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//, '');
return withoutScheme.startsWith('*.') || hostFromTarget(raw).startsWith('*.');
}
function wildcardHostFromTarget(target: string): string {
const raw = normalizeTargetValue(target).toLowerCase().replace(/^[a-z][a-z0-9+.-]*:\/\//, '');
return raw.replace(/\/.*$/, '').replace(/:\d+$/, '');
}
function approvalMatches(approval: ApprovalRequest, action: GuardAction, target: string): boolean {
if (approval.action !== action) return false;
if (approval.target === '*') return action === 'model_call' || action === 'autonomous_execution';
return hostFromTarget(approval.target) === hostFromTarget(target);
if (targetUsesWildcard(approval.target) && !approval.wildcardOptIn) return false;
const approvalHost = targetUsesWildcard(approval.target) ? wildcardHostFromTarget(approval.target) : hostFromTarget(approval.target);
const targetHost = hostFromTarget(target);
if (approvalHost.startsWith('*.')) {
const suffix = approvalHost.slice(1);
return targetHost.endsWith(suffix) && targetHost !== approvalHost.slice(2);
}
return approvalHost === targetHost;
}
function ensureExecTargetsWithinApprovedTarget(targets: string[], approvedTarget: string): string[] {
@@ -1105,6 +1225,15 @@ function ensureExecTargetsWithinApprovedTarget(targets: string[], approvedTarget
.filter(target => hostFromTarget(target) !== approvedHost);
}
function ensureExecTargetsAuthorized(
targets: string[],
approvedTarget: string,
body: Record<string, unknown>,
): string[] {
return ensureExecTargetsWithinApprovedTarget(targets, approvedTarget)
.filter(target => !findApproval(body, 'autonomous_execution', target));
}
function approvalMatchesGateScope(approval: ApprovalRequest, action: GuardAction, operationId: string, target: string): boolean {
if (!approvalMatches(approval, action, target)) return false;
if (approval.operationId && operationId && approval.operationId !== operationId) return false;
@@ -1112,10 +1241,17 @@ function approvalMatchesGateScope(approval: ApprovalRequest, action: GuardAction
}
function findApproval(body: Record<string, unknown>, action: GuardAction, target: string): ApprovalRequest | null {
const id = typeof body.approvalId === 'string' ? body.approvalId : '';
const approval = id ? approvalRequests.get(id) : undefined;
if (!approval || !approvalIsFresh(approval) || !approvalMatches(approval, action, target)) return null;
return approval;
const ids = [
typeof body.approvalId === 'string' ? body.approvalId : '',
...(Array.isArray(body.approvalIds) ? body.approvalIds.filter((id): id is string => typeof id === 'string') : []),
].filter(Boolean);
for (const id of ids) {
const approval = approvalRequests.get(id);
if (approval && approvalIsFresh(approval) && approvalMatches(approval, action, target)) return approval;
}
return null;
}
function createApprovalRequest(action: GuardAction, target: string, reason: string, body: Record<string, unknown>): ApprovalRequest {
@@ -1126,6 +1262,7 @@ function createApprovalRequest(action: GuardAction, target: string, reason: stri
target,
reason,
status: 'pending',
wildcardOptIn: body.allowWildcard === true && targetUsesWildcard(target),
operationId: typeof operationDraft?.operation_id === 'string' ? operationDraft.operation_id : undefined,
requestedBy: ['human', 'agent', 't3mp3st'].includes(String(body.source)) ? body.source as DraftSource : 'system',
createdAt: nowIso(),
@@ -4241,7 +4378,7 @@ function healthPayload(): Record<string, unknown> {
version: '0.2.1',
apiVersion: 'v1',
llm: {
configured: Boolean(llmConfig.apiKey) || llmConfig.provider === 'codex',
configured: Boolean(llmConfig.apiKey) || providerRunsKeyless(llmConfig.provider),
connected: Boolean(llm) || llmConfig.provider === 'codex',
provider: llmConfig.provider,
model: llmConfig.model,
@@ -4640,7 +4777,10 @@ export function broadcastEvent(event: string, data: Record<string, unknown>): vo
const MAX_SSE_CLIENTS = 64;
app.get('/api/events', (_req: Request, res: Response) => {
const origin = _req.get('origin');
if (origin && !isLoopbackOrigin(origin)) {
// #84: allow the operator's own same-origin UI on a non-loopback (opted-in) bind; foreign
// origins still fail the loopback check AND the Host match, so they remain rejected.
const sameOriginNetworkBind = !HOST_IS_LOOPBACK && isSameOriginAsHost(origin, _req.headers.host);
if (origin && !isLoopbackOrigin(origin) && !sameOriginNetworkBind) {
res.status(403).json({
error: 'Cross-origin event stream rejected',
detail: 'The SSE feed may contain live mission/task/finding metadata and is only available to the localhost UI.',
@@ -4803,6 +4943,15 @@ app.post('/api/approvals/request', (req: Request, res: Response) => {
});
return;
}
if (targetUsesWildcard(target) && target !== '*' && body.allowWildcard !== true) {
res.status(400).json({
error: 'Wildcard host approval requires explicit opt-in',
action,
target,
next: 'Send allowWildcard:true only when the operator intentionally approves the whole subdomain wildcard.',
});
return;
}
const reason = typeof body.reason === 'string' && body.reason.trim()
? body.reason.trim()
: `Approval requested for ${action} against ${target}`;
@@ -4826,6 +4975,68 @@ app.post('/api/approvals/:id/approve', (req: Request, res: Response) => {
res.json(approval);
});
app.post('/api/approvals/authorize-target', (req: Request, res: Response) => {
const body = req.body as Record<string, unknown>;
const target = normalizeTargetValue(body.target);
const approvalIds = [
typeof body.approvalId === 'string' ? body.approvalId : '',
...(Array.isArray(body.approvalIds) ? body.approvalIds.filter((id): id is string => typeof id === 'string') : []),
].filter(Boolean);
if (!approvalIds.length) {
res.status(400).json({
error: 'approvalId or approvalIds required',
next: 'Request a pending receipt first, then approve that exact receipt id.',
});
return;
}
if (target === '*') {
res.status(400).json({
error: 'Wildcard target authorization is not allowed for active actions',
next: 'Authorize one concrete host or URL so receipts remain target-scoped.',
});
return;
}
if (targetUsesWildcard(target) && target !== '*' && body.allowWildcard !== true) {
res.status(400).json({
error: 'Wildcard host authorization requires explicit opt-in',
next: 'Send allowWildcard:true only when the operator intentionally approves the whole subdomain wildcard.',
});
return;
}
const ttlMinutes = Math.max(1, Math.min(30, Number(body.ttlMinutes || 30)));
const expiresAt = new Date(Date.now() + ttlMinutes * 60_000).toISOString();
const approvals: ApprovalRequest[] = [];
for (const id of [...new Set(approvalIds)]) {
const approval = approvalRequests.get(id);
if (!approval) {
res.status(404).json({ error: 'Approval request not found', approvalId: id });
return;
}
if (approval.status !== 'pending') {
res.status(409).json({ error: 'Approval request is not pending', approvalId: id, status: approval.status });
return;
}
if (target && !approvalMatches(approval, approval.action, target)) {
res.status(400).json({
error: 'Approval target mismatch',
approvalId: id,
approvalTarget: approval.target,
target,
});
return;
}
approval.status = 'approved';
approval.approvedBy = typeof body.approvedBy === 'string' ? body.approvedBy : 'local-operator';
approval.expiresAt = expiresAt;
approval.updatedAt = nowIso();
emitContractEvent('approval.approved', { approvalId: approval.id, action: approval.action, target: approval.target });
approvals.push(approval);
}
res.json({ target: target || null, expiresAt, approvals });
});
app.post('/api/approvals/:id/reject', (req: Request, res: Response) => {
const approval = approvalRequests.get(req.params.id);
if (!approval) {
@@ -5872,7 +6083,8 @@ app.get('/api/llm/status', (_req: Request, res: Response) => {
connected: !!llm,
provider: llmConfig.provider,
model: llmConfig.model,
hasApiKey: !!llmConfig.apiKey
hasApiKey: !!llmConfig.apiKey,
configured: Boolean(llmConfig.apiKey) || providerRunsKeyless(llmConfig.provider),
});
});
@@ -5964,8 +6176,8 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
targets = [],
operators = [],
apiKey,
provider = 'openrouter',
model = 'anthropic/claude-sonnet-4',
provider,
model,
// OPTIONAL white-box source: an absolute path to a LOCAL repo you own. When
// present, we ingest + security-rank it and hand the packed source to the
// command via setWhiteboxSource BEFORE start(), so operators reason over the
@@ -5973,16 +6185,24 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
repoPath,
} = req.body;
// Use provided apiKey or fall back to server-configured one. Local-agent backends
// (Claude Code / Codex / Hermes) need NO key — the agent uses its own login.
// Use the request-selected backend, or fall back to the server's configured default.
// Local/local-agent backends need no hosted API key.
// SECURITY NOTE: apiKey is read from the request body (Authorization header is
// preferred). Kept body-accepted for the same-origin UI; only reachable from
// the local operator (loopback bind + origin guard). Header move is out of scope.
const effectiveKey = apiKey || config.getLLMConfig().apiKey;
if (providerNeedsApiKey(provider) && !effectiveKey) {
const missionLLMConfig = resolveGeneralLLMConfig(provider, model, apiKey);
const effectiveKey = missionLLMConfig.apiKey;
if (providerNeedsApiKey(missionLLMConfig.provider) && !effectiveKey) {
res.status(400).json({ error: 'API key required — pass apiKey, configure one on the server, or connect a local agent (Claude Code / Codex / Hermes)' });
return;
}
if (missionLLMConfig.provider === 'local-agent') {
const localAgent = await requireLiveLocalAgent(missionLLMConfig.model);
if (!localAgent.ok) {
res.status(503).json({ error: localAgent.error });
return;
}
}
if (targets.length === 0) {
res.status(400).json({ error: 'At least one target required' });
@@ -5995,6 +6215,14 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
if (!guard.allowed) { blockForApproval(res, guard); return; }
}
if (provider === 'local-agent') {
const localAgent = await requireLiveLocalAgent(model);
if (!localAgent.ok) {
res.status(503).json({ error: localAgent.error });
return;
}
}
// B-03: if an OPTIONAL white-box repoPath was supplied, containment-check it HERE
// (canonicalize + confine to the allowed root) before we build/start the command,
// so a bad/escaping path 400s cleanly instead of half-starting a run.
@@ -6013,7 +6241,7 @@ app.post('/api/mission/start', async (req: Request, res: Response): Promise<void
}
try {
const cmd = createTempestCommandInstance(name, effectiveKey, provider, model);
const cmd = createTempestCommandInstance(name, effectiveKey, missionLLMConfig.provider, missionLLMConfig.model);
// Add targets
for (const t of targets) {
@@ -6206,7 +6434,7 @@ app.post('/api/mission/resume', (_req: Request, res: Response) => {
app.get('/api/mission/status', (_req: Request, res: Response) => {
const cmd = getTempestCommand();
if (!cmd) {
res.json({ active: false });
res.json({ active: false, progress: [], tasks: [] });
return;
}
@@ -6218,6 +6446,7 @@ app.get('/api/mission/status', (_req: Request, res: Response) => {
res.json({
active: status.running,
paused: status.paused,
stallReason: status.stallReason,
name: status.name,
tickCount: status.tickCount,
mission: mission ? {
@@ -6235,6 +6464,8 @@ app.get('/api/mission/status', (_req: Request, res: Response) => {
targets: status.targets,
vault: status.vault,
opsec: status.opsec,
tasks: status.tasks,
progress: status.progress,
findings: findings.map(f => ({
id: f.id,
title: f.title,
@@ -6471,13 +6702,29 @@ function providerNeedsApiKey(provider: string): boolean {
return !['codex', 'mock', 'local', 'local-agent'].includes(provider);
}
function providerRunsKeyless(provider: string): boolean {
return !providerNeedsApiKey(provider);
}
function readPositiveTimeoutEnv(name: string): number | undefined {
const raw = process.env[name];
if (raw == null || raw.trim() === '') return undefined;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
function readGeneralTimeoutEnv(): number | undefined {
return readPositiveTimeoutEnv('TEMPEST_GENERAL_TIMEOUT_MS')
?? readPositiveTimeoutEnv('T3MP3ST_GENERAL_TIMEOUT_MS');
}
// SECURITY NOTE: `apiKey` is accepted from the request BODY here (and in the
// mission/general routes that call this). Sending secrets in the body is not
// ideal — an Authorization header is preferred — but the same-origin UI posts
// the key in the body, so we accept it to avoid breaking it. Moving to a header
// needs a coordinated UI change and is out of scope. The body key is only ever
// reachable from the local operator (loopback bind + origin guard).
function resolveGeneralLLMConfig(provider: string, model: string | undefined, apiKey: string | undefined): {
function resolveGeneralLLMConfig(provider: string | undefined, model: string | undefined, apiKey: string | undefined): {
provider: any;
model: string;
apiKey?: string;
@@ -6485,7 +6732,8 @@ function resolveGeneralLLMConfig(provider: string, model: string | undefined, ap
temperature: number;
timeout: number;
} {
const selectedProvider = provider || 'openrouter';
const defaultConfig = config.getLLMConfig();
const selectedProvider = provider || defaultConfig.provider;
// Local-agent backends (Claude Code / Codex / Hermes via the connector) need NO API key — the
// agent uses its own login. The agent id (codex|claude|hermes) travels in `model`.
if (selectedProvider === 'local-agent') {
@@ -6494,7 +6742,9 @@ function resolveGeneralLLMConfig(provider: string, model: string | undefined, ap
model: model || 'codex',
maxTokens: 8192,
temperature: 0.4,
timeout: Number(process.env.TEMPEST_GENERAL_TIMEOUT_MS) || 300000,
timeout: readGeneralTimeoutEnv()
?? readPositiveTimeoutEnv('T3MP3ST_LOCAL_AGENT_TIMEOUT_MS')
?? 600000,
};
}
const baseConfig = config.getLLMConfig(selectedProvider as any, model);
@@ -6508,7 +6758,7 @@ function resolveGeneralLLMConfig(provider: string, model: string | undefined, ap
apiKey: effectiveKey,
maxTokens: 8192,
temperature: 0.4,
timeout: Number(process.env.TEMPEST_GENERAL_TIMEOUT_MS) || 300000, // General planning needs room (was a hardcoded 60s); override via env
timeout: readGeneralTimeoutEnv() ?? 300000, // General planning needs room (was a hardcoded 60s); override via env
};
}
@@ -6544,8 +6794,6 @@ function bringUpMissionFromPlan(
async function runCodexExecReadinessProbe(command: string): Promise<{ stdout: string; stderr: string }> {
const marker = 'T3MP3ST_CODEX_READY';
const args = [
'--ask-for-approval',
'never',
'exec',
'-c',
'model_reasoning_effort="low"',
@@ -6571,7 +6819,7 @@ async function runCodexExecReadinessProbe(command: string): Promise<{ stdout: st
const timer = setTimeout(() => {
child.kill('SIGTERM');
reject(new Error('Codex exec readiness probe timed out'));
}, 30000);
}, 60000);
// Bounded accumulation so a runaway/verbose child can't grow these strings without limit
// before the 30s timer fires (matches the local-agent caps). A normal probe emits a tiny
@@ -6623,7 +6871,7 @@ app.get('/api/codex/status', async (_req: Request, res: Response): Promise<void>
tokenHandling: 'no token is accepted by or returned from T3MP3ST',
command,
version: stdout.trim(),
executionMode: 'codex exec --ephemeral --sandbox read-only --ask-for-approval never',
executionMode: 'codex exec --ephemeral --sandbox read-only',
execProbe: 'POST /api/codex/probe', // exec self-test moved off GET (B-04)
});
} catch (error: any) {
@@ -6643,7 +6891,7 @@ app.post('/api/codex/probe', async (_req: Request, res: Response): Promise<void>
provider: 'codex',
command,
version: stdout.trim(),
executionMode: 'codex exec --ephemeral --sandbox read-only --ask-for-approval never',
executionMode: 'codex exec --ephemeral --sandbox read-only',
};
try {
const probe = await runCodexExecReadinessProbe(command);
@@ -6676,8 +6924,8 @@ app.post('/api/general/plan', async (req: Request, res: Response): Promise<void>
urgency,
opsecPreference,
apiKey,
provider = 'openrouter',
model = 'anthropic/claude-sonnet-4',
provider,
model,
} = req.body;
if (!objective) {
@@ -6750,8 +6998,8 @@ app.post('/api/general/execute', async (req: Request, res: Response): Promise<vo
const {
apiKey,
provider = 'openrouter',
model = 'anthropic/claude-sonnet-4',
provider,
model,
} = req.body;
let generalConfig;
@@ -6863,8 +7111,8 @@ app.post('/api/general/auto', async (req: Request, res: Response): Promise<void>
urgency,
opsecPreference,
apiKey,
provider = 'openrouter',
model = 'anthropic/claude-sonnet-4',
provider,
model,
} = req.body;
if (!objective) {
@@ -7122,7 +7370,7 @@ import { Admiral, briefToDirective, type ChatMsg, type MissionBrief } from './ad
*/
app.post('/api/admiral/converse', async (req: Request, res: Response): Promise<void> => {
try {
const { messages, provider = 'openrouter', model, apiKey } = req.body as {
const { messages, provider, model, apiKey } = req.body as {
messages: ChatMsg[]; provider?: string; model?: string; apiKey?: string;
};
if (!Array.isArray(messages) || messages.length === 0) {
@@ -7152,7 +7400,7 @@ app.post('/api/admiral/converse', async (req: Request, res: Response): Promise<v
*/
app.post('/api/admiral/suggest', async (req: Request, res: Response): Promise<void> => {
try {
const { operatorPrompt, archetype, failureSignal, provider = 'openrouter', model, apiKey } = req.body as {
const { operatorPrompt, archetype, failureSignal, provider, model, apiKey } = req.body as {
operatorPrompt?: string; archetype?: string; failureSignal?: string; provider?: string; model?: string; apiKey?: string;
};
let prompt = typeof operatorPrompt === 'string' ? operatorPrompt : '';
@@ -7187,7 +7435,7 @@ app.post('/api/admiral/suggest', async (req: Request, res: Response): Promise<vo
*/
app.post('/api/admiral/launch', async (req: Request, res: Response): Promise<void> => {
try {
const { brief, confirmed, provider = 'openrouter', model, apiKey } = req.body as {
const { brief, confirmed, provider, model, apiKey } = req.body as {
brief: MissionBrief; confirmed?: boolean; provider?: string; model?: string; apiKey?: string;
};
if (!brief || !brief.objective || !brief.target) {
@@ -7242,12 +7490,20 @@ app.post('/api/admiral/launch', async (req: Request, res: Response): Promise<voi
res.status(409).json({ error: 'General plan gate is HOLD', mode: 'live', plan, review: execConfig.review });
return;
}
const outOfScopeTargets = ensureExecTargetsWithinApprovedTarget(execConfig.targets, brief.target);
const outOfScopeTargets = ensureExecTargetsAuthorized(execConfig.targets, brief.target, req.body as Record<string, unknown>);
if (outOfScopeTargets.length) {
const approvals = outOfScopeTargets.map(target => createApprovalRequest(
'autonomous_execution',
target,
`Admiral plan expanded execution scope from ${brief.target} to ${target}`,
req.body as Record<string, unknown>
));
res.status(403).json({
error: 'Admiral LIVE plan contains targets outside the approved brief target',
error: 'Admiral LIVE plan needs approval for expanded target scope',
approvedTarget: brief.target,
outOfScopeTargets,
approvals,
next: 'Approve the expanded target receipts, then retry /api/admiral/launch with approvalIds.',
});
return;
}
@@ -7351,8 +7607,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
// operators — no keys are entered or read (auth is detected by artifact PRESENCE only; see
// src/agent/local-agents.ts). In-memory registry of agents connected this session:
const connectedLocalAgents = new Map<string, { id: string; label: string; version?: string; connectedAt: number; lastPing?: AgentRunSummary | null }>();
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 = 45_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)
app.get('/api/agents/local/detect', async (_req: Request, res: Response): Promise<void> => {
@@ -7379,7 +7678,14 @@ app.post('/api/agents/local/connect', async (req: Request, res: Response): Promi
if (!d.authed) { results.push({ id, status: 'not-authed', version: d.version }); continue; }
let ping: AgentRunSummary | null = null;
if (doPing) ping = await pingLocalAgent(id);
connectedLocalAgents.set(id, { id, label: d.label, version: d.version, connectedAt: Date.now(), lastPing: ping });
connectedLocalAgents.set(id, {
id,
label: d.label,
version: d.version,
connectedAt: Date.now(),
lastPing: ping,
lastHealthCheckAt: ping ? Date.now() : undefined,
});
results.push({ id, status: 'active', label: d.label, version: d.version, authMethod: d.authMethod, ping });
}
syncLocalAgentSelection(connectedLocalAgents, ids, replace);
@@ -7392,7 +7698,10 @@ app.post('/api/agents/local/ping', async (req: Request, res: Response): Promise<
if (!body.id) { res.status(400).json({ error: 'id required' }); return; }
const r = await pingLocalAgent(body.id, body.prompt);
const entry = connectedLocalAgents.get(body.id);
if (entry) entry.lastPing = r;
if (entry) {
entry.lastPing = r;
entry.lastHealthCheckAt = Date.now();
}
res.json({ id: body.id, ...r });
});
@@ -7412,8 +7721,10 @@ app.post('/api/agents/local/disconnect', (req: Request, res: Response): void =>
res.json({ ok: true, connected: Array.from(connectedLocalAgents.keys()) });
});
// GET /api/agents/local/status — currently connected agents
app.get('/api/agents/local/status', (_req: Request, res: Response): void => {
// GET /api/agents/local/status — connected agents, optionally with a bounded live health check.
app.get('/api/agents/local/status', async (req: Request, res: Response): Promise<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()) });
});