fix: harden terminal action endpoints
This commit is contained in:
@@ -8,6 +8,8 @@ AUTO_OPEN_BROWSER=false
|
|||||||
STALE_DATA_MAX_AGE_MINUTES=60
|
STALE_DATA_MAX_AGE_MINUTES=60
|
||||||
TERMINAL_ACTIONS_ENABLED=true
|
TERMINAL_ACTIONS_ENABLED=true
|
||||||
SWEEP_TOKEN=
|
SWEEP_TOKEN=
|
||||||
|
TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000
|
||||||
|
TERMINAL_ACTION_RATE_LIMIT_MAX=10
|
||||||
BRIEF_VERBOSITY=standard
|
BRIEF_VERBOSITY=standard
|
||||||
|
|
||||||
# LLM layer
|
# LLM layer
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -135,8 +135,10 @@ PORT=3117
|
|||||||
REFRESH_INTERVAL_MINUTES=15
|
REFRESH_INTERVAL_MINUTES=15
|
||||||
AUTO_OPEN_BROWSER=false
|
AUTO_OPEN_BROWSER=false
|
||||||
STALE_DATA_MAX_AGE_MINUTES=60
|
STALE_DATA_MAX_AGE_MINUTES=60
|
||||||
TERMINAL_ACTIONS_ENABLED=true
|
|
||||||
SWEEP_TOKEN=
|
SWEEP_TOKEN=
|
||||||
|
TERMINAL_ACTIONS_ENABLED=true
|
||||||
|
TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS=60000
|
||||||
|
TERMINAL_ACTION_RATE_LIMIT_MAX=10
|
||||||
BRIEF_VERBOSITY=standard
|
BRIEF_VERBOSITY=standard
|
||||||
|
|
||||||
LLM_PROVIDER=openrouter
|
LLM_PROVIDER=openrouter
|
||||||
@@ -188,7 +190,20 @@ LLM_MODEL=your-model
|
|||||||
|
|
||||||
For Pangolin or another reverse proxy, forward HTTP traffic to `intelligence-terminal:3117` (or the `PORT` you set). Missing API keys do not crash sweeps; affected sources are reported as degraded in `/api/health`.
|
For Pangolin or another reverse proxy, forward HTTP traffic to `intelligence-terminal:3117` (or the `PORT` you set). Missing API keys do not crash sweeps; affected sources are reported as degraded in `/api/health`.
|
||||||
|
|
||||||
The dashboard Terminal Actions panel can trigger `status`, `sweep`, and `brief` through `/api/action`. Leave `TERMINAL_ACTIONS_ENABLED=true` for a private home-server deployment. For an internet-exposed deployment, set `SWEEP_TOKEN` and pass it through trusted automation, or set `TERMINAL_ACTIONS_ENABLED=false` to disable browser-triggered actions. If you protect actions with `SWEEP_TOKEN`, the browser can send it from `localStorage.crucix_sweep_token`.
|
#### Terminal Action Exposure
|
||||||
|
|
||||||
|
`POST /api/action` and `POST /api/sweep` can trigger operational actions such as manual sweeps. The dashboard has a **SET TOKEN** control that stores your `SWEEP_TOKEN` in browser local storage and sends it as the `x-crucix-token` header; do not put action tokens in URLs.
|
||||||
|
|
||||||
|
Recommended settings:
|
||||||
|
|
||||||
|
| Deployment | Settings |
|
||||||
|
| --- | --- |
|
||||||
|
| Private local machine | `NODE_ENV=development`, optional `SWEEP_TOKEN`, optional `TERMINAL_ACTIONS_ENABLED=true`. Localhost can run actions without a token for development. |
|
||||||
|
| Private LAN / Dockge | Set a strong `SWEEP_TOKEN`, keep `TERMINAL_ACTIONS_ENABLED=true`, expose only to trusted clients. |
|
||||||
|
| Pangolin-authenticated reverse proxy | Set a strong `SWEEP_TOKEN`, keep Pangolin auth in front, use the dashboard **SET TOKEN** flow once per browser. |
|
||||||
|
| Public internet | Do not expose Terminal Actions directly. If exposure is unavoidable, require `SWEEP_TOKEN`, keep proxy authentication enabled, lower `TERMINAL_ACTION_RATE_LIMIT_MAX`, and monitor server audit logs. |
|
||||||
|
|
||||||
|
Action endpoints reject cross-origin POST origins, apply a small in-memory per-IP rate limit, and write sanitized audit lines without logging the token.
|
||||||
|
|
||||||
#### Build And Publish Your Gitea Image
|
#### Build And Publish Your Gitea Image
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ export default {
|
|||||||
autoOpenBrowser: boolEnv('AUTO_OPEN_BROWSER', false),
|
autoOpenBrowser: boolEnv('AUTO_OPEN_BROWSER', false),
|
||||||
staleDataMaxAgeMinutes: intEnv('STALE_DATA_MAX_AGE_MINUTES', 60),
|
staleDataMaxAgeMinutes: intEnv('STALE_DATA_MAX_AGE_MINUTES', 60),
|
||||||
sweepToken: process.env.SWEEP_TOKEN || null,
|
sweepToken: process.env.SWEEP_TOKEN || null,
|
||||||
terminalActionsEnabled: boolEnv('TERMINAL_ACTIONS_ENABLED', true),
|
terminalActionsEnabled: boolEnv('TERMINAL_ACTIONS_ENABLED', !!process.env.SWEEP_TOKEN || process.env.NODE_ENV !== 'production'),
|
||||||
|
terminalActionRateLimitWindowMs: intEnv('TERMINAL_ACTION_RATE_LIMIT_WINDOW_MS', 60_000),
|
||||||
|
terminalActionRateLimitMax: intEnv('TERMINAL_ACTION_RATE_LIMIT_MAX', 10),
|
||||||
|
|
||||||
llm: {
|
llm: {
|
||||||
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok
|
provider: process.env.LLM_PROVIDER || null, // anthropic | openai | gemini | codex | openrouter | minimax | mistral | ollama | grok
|
||||||
|
|||||||
@@ -415,6 +415,7 @@ let terminalOutput = 'Ready. Live data is loaded from /api/data in server mode.'
|
|||||||
let terminalBusy = false;
|
let terminalBusy = false;
|
||||||
let currentRegion = 'world';
|
let currentRegion = 'world';
|
||||||
let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH;
|
let flatSvg, flatProjection, flatPath, flatG, flatZoom, flatW, flatH;
|
||||||
|
const terminalActionTokenKey = 'crucix_sweep_token';
|
||||||
|
|
||||||
const layerTypeMap = {
|
const layerTypeMap = {
|
||||||
air: ['air'],
|
air: ['air'],
|
||||||
@@ -615,6 +616,7 @@ function renderTopbar(){
|
|||||||
const ts = new Date(D.meta.timestamp);
|
const ts = new Date(D.meta.timestamp);
|
||||||
const d = ts.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}).toUpperCase();
|
const d = ts.toLocaleDateString('en-US',{month:'short',day:'numeric',year:'numeric'}).toUpperCase();
|
||||||
const timeStr = ts.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true});
|
const timeStr = ts.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit',hour12:true});
|
||||||
|
const hasActionToken = !!getTerminalActionToken();
|
||||||
document.getElementById('topbar').innerHTML=`
|
document.getElementById('topbar').innerHTML=`
|
||||||
<div class="top-left">
|
<div class="top-left">
|
||||||
<span class="brand">CRUCIX MONITOR</span>
|
<span class="brand">CRUCIX MONITOR</span>
|
||||||
@@ -627,12 +629,26 @@ function renderTopbar(){
|
|||||||
<span class="meta-pill">${d} <span class="v">${timeStr}</span></span>
|
<span class="meta-pill">${d} <span class="v">${timeStr}</span></span>
|
||||||
<span class="meta-pill">${t('dashboard.sources','SOURCES')} <span class="v">${D.meta.sourcesOk}/${D.meta.sourcesQueried}</span></span>
|
<span class="meta-pill">${t('dashboard.sources','SOURCES')} <span class="v">${D.meta.sourcesOk}/${D.meta.sourcesQueried}</span></span>
|
||||||
${D.delta?.summary ? `<span class="meta-pill">${t('dashboard.delta','DELTA')} <span class="v">${D.delta.summary.direction==='risk-off'?'▲ '+t('dashboard.riskOff','RISK-OFF'):D.delta.summary.direction==='risk-on'?'▼ '+t('dashboard.riskOn','RISK-ON'):'◆ '+t('dashboard.mixed','MIXED')}</span></span>` : ''}
|
${D.delta?.summary ? `<span class="meta-pill">${t('dashboard.delta','DELTA')} <span class="v">${D.delta.summary.direction==='risk-off'?'▲ '+t('dashboard.riskOff','RISK-OFF'):D.delta.summary.direction==='risk-on'?'▼ '+t('dashboard.riskOn','RISK-ON'):'◆ '+t('dashboard.mixed','MIXED')}</span></span>` : ''}
|
||||||
|
<button class="guide-btn" onclick="configureTerminalActionToken()" title="Configure SWEEP_TOKEN for protected terminal actions">${hasActionToken?'TOKEN SET':'SET TOKEN'}</button>
|
||||||
<button class="guide-btn" onclick="openGlossary()">${t('dashboard.guideBtn','What Signals Mean')}</button>
|
<button class="guide-btn" onclick="openGlossary()">${t('dashboard.guideBtn','What Signals Mean')}</button>
|
||||||
<span class="alert-badge">${t('dashboard.highAlert','HIGH ALERT')}</span>
|
<span class="alert-badge">${t('dashboard.highAlert','HIGH ALERT')}</span>
|
||||||
</div>`;
|
</div>`;
|
||||||
renderRegionControls();
|
renderRegionControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTerminalActionToken(){
|
||||||
|
return localStorage.getItem(terminalActionTokenKey) || localStorage.getItem('crucix_terminal_action_token') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureTerminalActionToken(){
|
||||||
|
const next = window.prompt('Terminal action token (SWEEP_TOKEN). Leave empty to clear.', getTerminalActionToken());
|
||||||
|
if(next === null) return;
|
||||||
|
const clean = next.trim();
|
||||||
|
if(clean) localStorage.setItem(terminalActionTokenKey, clean);
|
||||||
|
else localStorage.removeItem(terminalActionTokenKey);
|
||||||
|
renderTopbar();
|
||||||
|
}
|
||||||
|
|
||||||
// === LEFT RAIL ===
|
// === LEFT RAIL ===
|
||||||
function layerMode(key){ return layerModes[key] || 'normal'; }
|
function layerMode(key){ return layerModes[key] || 'normal'; }
|
||||||
function layerModeLabel(key){ return layerMode(key) === 'focus' ? 'focused' : layerMode(key) === 'hidden' ? 'hidden' : 'normal'; }
|
function layerModeLabel(key){ return layerMode(key) === 'focus' ? 'focused' : layerMode(key) === 'hidden' ? 'hidden' : 'normal'; }
|
||||||
@@ -1575,6 +1591,12 @@ function renderLower(){
|
|||||||
|
|
||||||
async function runTerminalAction(action){
|
async function runTerminalAction(action){
|
||||||
if(terminalBusy) return;
|
if(terminalBusy) return;
|
||||||
|
let token = getTerminalActionToken();
|
||||||
|
if(!token && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1'){
|
||||||
|
configureTerminalActionToken();
|
||||||
|
token = getTerminalActionToken();
|
||||||
|
if(!token) return;
|
||||||
|
}
|
||||||
terminalBusy = true;
|
terminalBusy = true;
|
||||||
terminalOutput = `> ${action}\nRunning...`;
|
terminalOutput = `> ${action}\nRunning...`;
|
||||||
renderRight();
|
renderRight();
|
||||||
@@ -1583,7 +1605,7 @@ async function runTerminalAction(action){
|
|||||||
method:'POST',
|
method:'POST',
|
||||||
headers:{
|
headers:{
|
||||||
'Content-Type':'application/json',
|
'Content-Type':'application/json',
|
||||||
...(localStorage.getItem('crucix_sweep_token') ? {'x-crucix-token': localStorage.getItem('crucix_sweep_token')} : {})
|
...(token ? {'x-crucix-token': token} : {})
|
||||||
},
|
},
|
||||||
body:JSON.stringify({action})
|
body:JSON.stringify({action})
|
||||||
});
|
});
|
||||||
@@ -1661,6 +1683,7 @@ function renderRight(){
|
|||||||
<button class="action-btn" ${terminalBusy?'disabled':''} onclick="runTerminalAction('sweep')">Sweep</button>
|
<button class="action-btn" ${terminalBusy?'disabled':''} onclick="runTerminalAction('sweep')">Sweep</button>
|
||||||
<button class="action-btn" ${terminalBusy?'disabled':''} onclick="runTerminalAction('brief')">Brief</button>
|
<button class="action-btn" ${terminalBusy?'disabled':''} onclick="runTerminalAction('brief')">Brief</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="mini-btn" style="margin-bottom:8px" onclick="configureTerminalActionToken()">Configure token</button>
|
||||||
<div class="terminal-output">${terminalOutput.replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])).replace(/\n/g,'<br>')}</div>
|
<div class="terminal-output">${terminalOutput.replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c])).replace(/\n/g,'<br>')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="g-panel right-signals">
|
<div class="g-panel right-signals">
|
||||||
|
|||||||
151
server.mjs
151
server.mjs
@@ -39,6 +39,7 @@ let sweepStartedAt = null; // Timestamp when current/last sweep started
|
|||||||
let sweepInProgress = false;
|
let sweepInProgress = false;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const sseClients = new Set();
|
const sseClients = new Set();
|
||||||
|
const terminalActionBuckets = new Map();
|
||||||
|
|
||||||
// === Delta/Memory ===
|
// === Delta/Memory ===
|
||||||
const memory = new MemoryManager(RUNS_DIR);
|
const memory = new MemoryManager(RUNS_DIR);
|
||||||
@@ -289,28 +290,35 @@ app.get('/api/metrics', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/sweep', express.json(), (req, res) => {
|
app.post('/api/sweep', express.json(), (req, res) => {
|
||||||
if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Terminal actions disabled or unauthorized' });
|
const guard = authorizeTerminalAction(req, res, 'sweep');
|
||||||
triggerSweep(res);
|
if (!guard.ok) return;
|
||||||
|
triggerSweepAction(req, res, 'sweep');
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/action', express.json(), async (req, res) => {
|
app.post('/api/action', express.json(), (req, res) => {
|
||||||
if (!canRunTerminalAction(req)) return res.status(403).json({ error: 'Terminal actions disabled or unauthorized' });
|
const action = String(req.body?.action || req.body?.command || '').trim().toLowerCase();
|
||||||
const action = String(req.body?.action || req.query.action || '').toLowerCase();
|
const guard = authorizeTerminalAction(req, res, action || 'unknown');
|
||||||
|
if (!guard.ok) return;
|
||||||
|
|
||||||
if (action === 'status') {
|
if (action === 'status') {
|
||||||
return res.json({ ok: true, action, health: buildHealth() });
|
auditTerminalAction(req, 'status', 'ok');
|
||||||
|
return res.json({ ok: true, action, status: 'ok', health: buildHealth() });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'brief') {
|
if (action === 'brief') {
|
||||||
if (!currentData) return res.status(503).json({ ok: false, action, error: 'No data yet — first sweep in progress' });
|
if (!currentData) {
|
||||||
return res.json({ ok: true, action, text: buildBrief(currentData) });
|
auditTerminalAction(req, 'brief', 'rejected', 'no_data');
|
||||||
|
return res.status(503).json({ ok: false, action, error: 'No data yet - first sweep in progress' });
|
||||||
|
}
|
||||||
|
auditTerminalAction(req, 'brief', 'ok');
|
||||||
|
const brief = buildBrief(currentData);
|
||||||
|
return res.json({ ok: true, action, status: 'ok', brief, text: brief });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === 'sweep') {
|
if (action === 'sweep') return triggerSweepAction(req, res, 'action:sweep');
|
||||||
return triggerSweep(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(400).json({ ok: false, error: 'Unknown action', actions: ['status', 'brief', 'sweep'] });
|
auditTerminalAction(req, action || 'unknown', 'rejected', 'unknown_action');
|
||||||
|
return res.status(400).json({ ok: false, error: 'Unknown action', allowed: ['status', 'brief', 'sweep'], actions: ['status', 'brief', 'sweep'] });
|
||||||
});
|
});
|
||||||
|
|
||||||
// API: available locales
|
// API: available locales
|
||||||
@@ -341,26 +349,114 @@ function broadcast(data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestIp(req) {
|
||||||
|
return req.ip || req.socket?.remoteAddress || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalRequest(req) {
|
||||||
|
const remote = requestIp(req);
|
||||||
|
return remote === '::1'
|
||||||
|
|| remote === '127.0.0.1'
|
||||||
|
|| remote === '::ffff:127.0.0.1'
|
||||||
|
|| remote.startsWith('127.')
|
||||||
|
|| remote === 'localhost';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameOriginPost(req) {
|
||||||
|
const origin = req.get('origin');
|
||||||
|
if (!origin) return true;
|
||||||
|
try {
|
||||||
|
const originUrl = new URL(origin);
|
||||||
|
const host = req.get('host');
|
||||||
|
return host && originUrl.host === host;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionToken(req) {
|
||||||
|
return req.get('x-crucix-token') || req.body?.token || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditTerminalAction(req, action, outcome, detail = null) {
|
||||||
|
const suffix = detail ? ` detail=${detail}` : '';
|
||||||
|
console.log(`[Crucix][audit] terminal_action action=${action || 'unknown'} outcome=${outcome} ip=${requestIp(req)}${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rateLimitTerminalAction(req, action) {
|
||||||
|
const now = Date.now();
|
||||||
|
const windowMs = Math.max(1000, config.terminalActionRateLimitWindowMs || 60_000);
|
||||||
|
const max = Math.max(1, config.terminalActionRateLimitMax || 10);
|
||||||
|
const key = `${requestIp(req)}:${action}`;
|
||||||
|
const bucket = terminalActionBuckets.get(key);
|
||||||
|
if (!bucket || now > bucket.resetAt) {
|
||||||
|
terminalActionBuckets.set(key, { count: 1, resetAt: now + windowMs });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
bucket.count += 1;
|
||||||
|
if (bucket.count > max) {
|
||||||
|
return { ok: false, retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000) };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function authorizeTerminalAction(req, res, action) {
|
||||||
|
const rate = rateLimitTerminalAction(req, action);
|
||||||
|
if (!rate.ok) {
|
||||||
|
auditTerminalAction(req, action, 'rejected', 'rate_limited');
|
||||||
|
res.set('Retry-After', String(rate.retryAfterSeconds));
|
||||||
|
res.status(429).json({ error: 'Too many terminal actions', retryAfterSeconds: rate.retryAfterSeconds });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sameOriginPost(req)) {
|
||||||
|
auditTerminalAction(req, action, 'rejected', 'csrf_origin');
|
||||||
|
res.status(403).json({ error: 'Origin mismatch' });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const local = isLocalRequest(req);
|
||||||
|
const token = actionToken(req);
|
||||||
|
if (!config.terminalActionsEnabled) {
|
||||||
|
auditTerminalAction(req, action, 'rejected', 'disabled');
|
||||||
|
res.status(403).json({ error: 'Terminal actions are disabled' });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.sweepToken) {
|
||||||
|
if (token !== config.sweepToken) {
|
||||||
|
auditTerminalAction(req, action, 'rejected', 'invalid_token');
|
||||||
|
res.status(401).json({ error: 'Invalid terminal action token' });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!local) {
|
||||||
|
auditTerminalAction(req, action, 'rejected', 'missing_token');
|
||||||
|
res.status(403).json({ error: 'Terminal actions are local-only unless SWEEP_TOKEN is set' });
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerSweepAction(req, res, auditAction) {
|
||||||
|
if (sweepInProgress) {
|
||||||
|
auditTerminalAction(req, auditAction, 'rejected', 'already_running');
|
||||||
|
return res.status(409).json({ ok: true, status: 'already_running', sweepStartedAt });
|
||||||
|
}
|
||||||
|
auditTerminalAction(req, auditAction, 'accepted');
|
||||||
|
runSweepCycle().catch(err => console.error('[Crucix] API-triggered sweep failed:', err.message));
|
||||||
|
return res.status(202).json({ ok: true, status: 'accepted' });
|
||||||
|
}
|
||||||
|
|
||||||
function dataAgeMs() {
|
function dataAgeMs() {
|
||||||
const ts = currentData?.meta?.timestamp || lastSuccessfulSweepTime || lastSweepTime;
|
const ts = currentData?.meta?.timestamp || lastSuccessfulSweepTime || lastSweepTime;
|
||||||
const ms = ts ? Date.now() - new Date(ts).getTime() : null;
|
const ms = ts ? Date.now() - new Date(ts).getTime() : null;
|
||||||
return Number.isFinite(ms) ? ms : null;
|
return Number.isFinite(ms) ? ms : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function canRunTerminalAction(req) {
|
|
||||||
const remote = req.ip || '';
|
|
||||||
const local = remote.includes('127.0.0.1') || remote === '::1' || remote === '::ffff:127.0.0.1';
|
|
||||||
const token = req.get('x-crucix-token') || req.query.token || req.body?.token;
|
|
||||||
if (config.sweepToken) return token === config.sweepToken;
|
|
||||||
return Boolean(config.terminalActionsEnabled || local);
|
|
||||||
}
|
|
||||||
|
|
||||||
function triggerSweep(res) {
|
|
||||||
if (sweepInProgress) return res.status(409).json({ ok: true, status: 'already_running', sweepStartedAt });
|
|
||||||
runSweepCycle().catch(err => console.error('[Crucix] API-triggered sweep failed:', err.message));
|
|
||||||
return res.status(202).json({ ok: true, status: 'accepted' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLLMStatus() {
|
function getLLMStatus() {
|
||||||
if (!config.llm.provider) return { state: 'disabled' };
|
if (!config.llm.provider) return { state: 'disabled' };
|
||||||
if (!llmProvider) return { state: 'misconfigured', provider: config.llm.provider };
|
if (!llmProvider) return { state: 'misconfigured', provider: config.llm.provider };
|
||||||
@@ -404,7 +500,8 @@ function buildHealth() {
|
|||||||
llm: getLLMStatus(),
|
llm: getLLMStatus(),
|
||||||
telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId),
|
telegramEnabled: !!(config.telegram.botToken && config.telegram.chatId),
|
||||||
discordEnabled: !!(config.discord?.botToken || config.discord?.webhookUrl),
|
discordEnabled: !!(config.discord?.botToken || config.discord?.webhookUrl),
|
||||||
terminalActionsEnabled: Boolean(config.terminalActionsEnabled || config.sweepToken),
|
terminalActionsEnabled: config.terminalActionsEnabled,
|
||||||
|
terminalActionsTokenRequired: !!config.sweepToken,
|
||||||
refreshIntervalMinutes: config.refreshIntervalMinutes,
|
refreshIntervalMinutes: config.refreshIntervalMinutes,
|
||||||
language: currentLanguage,
|
language: currentLanguage,
|
||||||
memory: intelligenceStore.status(),
|
memory: intelligenceStore.status(),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
import { safeFetch, safeFetchText, getFetchMetrics } from '../apis/utils/fetch.mjs';
|
import { safeFetch, safeFetchText, getFetchMetrics } from '../apis/utils/fetch.mjs';
|
||||||
|
|
||||||
test('safeFetch reports HTML as degraded JSON response', async () => {
|
test('safeFetch reports HTML as degraded JSON response', async () => {
|
||||||
@@ -34,3 +35,22 @@ test('safeFetchText returns text and byte count', async () => {
|
|||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('terminal action endpoints avoid URL tokens and include hardening gates', () => {
|
||||||
|
const server = readFileSync(new URL('../server.mjs', import.meta.url), 'utf8');
|
||||||
|
assert.match(server, /app\.post\('\/api\/action'/);
|
||||||
|
assert.match(server, /app\.post\('\/api\/sweep'/);
|
||||||
|
assert.match(server, /x-crucix-token/);
|
||||||
|
assert.match(server, /sameOriginPost/);
|
||||||
|
assert.match(server, /rateLimitTerminalAction/);
|
||||||
|
assert.match(server, /auditTerminalAction/);
|
||||||
|
assert.doesNotMatch(server, /req\.query\.token/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard exposes token configuration flow without devtools edits', () => {
|
||||||
|
const html = readFileSync(new URL('../dashboard/public/jarvis.html', import.meta.url), 'utf8');
|
||||||
|
assert.match(html, /configureTerminalActionToken/);
|
||||||
|
assert.match(html, /crucix_sweep_token/);
|
||||||
|
assert.match(html, /x-crucix-token/);
|
||||||
|
assert.match(html, /SET TOKEN/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user