feat(web): persist jobs + resume an interrupted run where it left off

The web server's job state was in-memory only, so a node restart/crash lost the
live run (the harness still checkpointed to .neurosploit/active_run.json, but
the console couldn't see or re-attach it).

- persist each job to .neurosploit/web-jobs/<id>.json (snapshot + feed tail +
  sanitized launch params; NO api keys or creds contents), throttled, on
  findings/phase/done
- loadPersistedJobs() on boot: a job that was live becomes `interrupted`, and
  `resumable` when it was a REPL-backed run/whitebox/greybox
- POST /api/exploit/:id/resume: relaunch the REPL (NEUROSPLOIT_AUTO_RESUME=1),
  which recovers the on-disk checkpoint and /continue's it, carrying findings
  forward; reuses the same job id so the live view resumes streaming
- API-key jobs need the provider key re-entered after a full restart (kept only
  in memory) — resume returns a clear 409 saying which; subscription resumes clean
- frontend: boot offers a dismissible "N interrupted run(s) — Resume" banner

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUPandClaude Opus 4.8 committed 2026-10-02 18:49:10 -03:00
1 parent 01eac8f0c3
commit a916abaf75
3 files changed
+225

No files matched your search

+40
View File
@@ -2642,6 +2642,46 @@ async function boot() {
await Promise.all([loadAgents(), loadProviders()]);
await refreshRuns();
await tryResumeActiveJob(); // survive an F5 while watching a live run
await offerInterruptedJobs(); // a run the server lost on restart/crash
setInterval(refreshRuns, 6000);
}
boot();
// A job that was live when the server stopped is reloaded from disk as
// `interrupted`. When it is resumable (a REPL-backed run/whitebox/greybox, the
// ones the harness checkpoints), offer a one-click resume: the server relaunches
// the REPL, which auto-recovers the on-disk checkpoint and /continue's it.
async function offerInterruptedJobs() {
let jobs;
try { jobs = await api('/api/exploit'); } catch { return; }
const resumable = (jobs || []).filter((j) => j.interrupted && j.resumable);
if (!resumable.length) return;
const bar = document.createElement('div');
bar.className = 'resume-bar';
bar.innerHTML = `
<span class="resume-ico">↻</span>
<span class="resume-text">${resumable.length} interrupted run(s) — the server restarted mid-engagement. Findings are saved; resume to continue where it left off.</span>
<span class="resume-actions"></span>
<button class="resume-x" title="Dismiss">✕</button>`;
const actions = bar.querySelector('.resume-actions');
for (const j of resumable) {
const b = document.createElement('button');
b.className = 'btn btn-sm btn-primary';
b.textContent = `Resume ${j.name || j.target || j.id.slice(0, 8)}`;
b.addEventListener('click', async () => {
b.disabled = true; b.textContent = 'Resuming…';
try {
await api(`/api/exploit/${j.id}/resume`, { method: 'POST' });
localStorage.setItem(ACTIVE_JOB_KEY, j.id);
attachLiveJob(j.id, j.target, j.name, j.pinnedAgents || []);
bar.remove();
} catch (e) {
b.disabled = false; b.textContent = 'Retry';
toast(`Couldn't resume: ${e.message}`, 'error', 9000);
}
});
actions.appendChild(b);
}
bar.querySelector('.resume-x').addEventListener('click', () => bar.remove());
document.body.insertBefore(bar, document.body.firstChild);
}
+8
View File
@@ -798,3 +798,11 @@ body.resizing-ns { user-select: none; cursor: ns-resize; }
/* Scoping/Guardrails UI accents */
.pill { display:inline-block; padding:1px 7px; border-radius: var(--radius-pill); background:var(--sev-medium-bg); color:var(--sev-medium-fg); font-weight:600; letter-spacing:.02em; }
.req { color:var(--text-dim); font-weight:400; font-size:.9em; }
/* Interrupted-run resume banner (web persistence). */
.resume-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-4); background: var(--accent-soft); border-bottom: 1px solid var(--border-strong); font-size: var(--fs-sm); color: var(--text); z-index: var(--z-header); }
.resume-bar .resume-ico { color: var(--accent); font-weight: 700; }
.resume-bar .resume-text { flex: 1; min-width: 0; }
.resume-bar .resume-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
.resume-bar .resume-x { background: transparent; border: none; color: var(--text-dim); cursor: pointer; font-size: var(--fs-sm); padding: 2px 6px; border-radius: var(--radius-xs); }
.resume-bar .resume-x:hover { background: var(--surface-3); color: var(--text); }