Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions runners/extension/domTarget.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,15 @@ const MAX_CONSECUTIVE_FAILURES = 2;
* readerCfg: LLM config for message shortening (reader model)
* options:
* waitMs: ms to wait between send and extract (default 5000)
* roundOffset: rounds already completed before this adapter was created —
* set on resume so reported round numbers continue from where
* the paused run left off instead of restarting at 1
* onUserSent: async ({ round, prompt }) => void — fires right after send, before extraction
* onTurnDone: async ({ round, userMessage, sentOk, extractedOk, assistantPreview }) => void
* onRecovery: async () => { plan, frameId }|null — called on consecutive DOM failures
*/
export function createDomTarget(tabId, frameId, plan, readerCfg, options = {}) {
const { waitMs = 5000, onUserSent, onTurnDone, onRecovery } = options;
const { waitMs = 5000, roundOffset = 0, onUserSent, onTurnDone, onRecovery } = options;

let currentPlan = plan;
let currentFrameId = frameId;
Expand Down Expand Up @@ -72,7 +75,7 @@ export function createDomTarget(tabId, frameId, plan, readerCfg, options = {}) {
}
if (state.OPFOR_STOP) throw domTargetStopError();

const round = turns.length + 1;
const round = roundOffset + turns.length + 1;

// Pre-send snapshot (used for diff extraction)
const prevSnapshot = await snapshotCurrentResponse(tabId, currentFrameId);
Expand Down
246 changes: 158 additions & 88 deletions runners/extension/orchestrator.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
clearRetryLocate,
} from "./state.js";
import {
judgeResponse,
createModel,
setEnvProvider,
PROVIDER_ENV_VARS,
Expand Down Expand Up @@ -64,6 +63,59 @@ function adaptJudgeResult(coreResult) {
};
}

/** The placeholder judgment for a cancelled evaluator (never LLM-judged). */
function cancelledJudgment(transcript) {
if (!Array.isArray(transcript) || transcript.length < 2) return undefined;
return {
verdict: "CANCELLED",
summary: "Cancelled by user before this evaluator could finish.",
findings: [{ text: "Run stopped by user; partial transcript was collected." }],
};
}

/**
* Finalize a user-initiated interruption.
*
* Pause and Stop arrive as the SAME signal (state.OPFOR_STOP) — only the
* recorded intent distinguishes them, and they must leave behind opposite
* things:
* pause → a resumable snapshot, and NO terminal result. An unfinished
* evaluator has no verdict; writing one makes a merely-paused run
* surface as a CANCELLED row in the report.
* cancel → a terminal CANCELLED result, and NO resumable snapshot (which
* would otherwise resurrect a "Run paused / Resume" screen).
*
* Pass `canPause: false` when the run cannot be made resumable (no usable
* widget plan). A pause is then degraded to a cancel through the SAME branch,
* so it still clears any snapshot left by an earlier pause — that snapshot
* otherwise survives a resume (it is only dropped on success) and would
* resurrect a stale "Resume" screen for a run just recorded as cancelled.
*
* Returns the response payload for the popup, with an explicit intent so the
* popup never has to infer pause-vs-cancel from the ambiguous `paused` flag.
*/
async function finalizeUserInterruption({ pauseSnapshot, cancelResult, canPause = true }) {
const wantedPause = state.OPFOR_STOP_INTENT === "pause";
const isPause = wantedPause && canPause;
if (isPause) {
if (pauseSnapshot) await persistPausedAdaptiveRun(pauseSnapshot).catch(() => {});
} else {
await chrome.storage.local.remove("opforPausedRun").catch(() => {});
if (cancelResult) await persistPartialResult(cancelResult).catch(() => {});
}
return {
ok: false,
error: isPause
? "Run paused."
: wantedPause
? "Run stopped (could not be saved for resume)."
: "Run stopped.",
paused: isPause,
cancelled: !isPause,
intent: isPause ? "pause" : "cancel",
};
}

/**
* AI-driven chat session reset. Scans the current page/widget for end-chat or
* new-conversation controls and clicks them to clear the old transcript,
Expand Down Expand Up @@ -227,9 +279,18 @@ export async function executeAdaptiveRedTeamRun(sendResponse, message, resume) {
beginUiRunAbortController();
state.OPFOR_STOP = false;

// `opforLastResult` is the *result of the current evaluator run* — there is
// never one yet at the start, so clear it even on resume. Leaving a prior
// evaluator's `completed: true` result behind makes the popup's storage
// poller return that stale result immediately for this run.
try {
await chrome.storage.local.remove("opforLastResult");
} catch {
/* swallowed */
}
if (!resume) {
try {
await chrome.storage.local.remove(["opforLastResult", "opforLiveTranscript"]);
await chrome.storage.local.remove("opforLiveTranscript");
} catch {
/* swallowed */
}
Expand Down Expand Up @@ -356,26 +417,44 @@ export async function executeAdaptiveRedTeamRun(sendResponse, message, resume) {
if (transcript.length % 2 === 1) {
await sleepInterruptible(Math.min(waitMs, 1000));
if (state.OPFOR_STOP) {
await persistPausedAdaptiveRun({
tabId: tab.id,
siteUrl: tab.url || "",
maxRounds,
waitMs,
transcript,
turnLog,
plan,
frameId: best.frameId,
frameUrl: best.frameUrl,
siteSnapshot,
suiteId,
evaluatorSnapshot,
attackObjective,
businessUseCase,
scrapeFromSite,
agentDescription,
judgeHint,
});
sendResponse({ ok: false, error: "Run stopped.", paused: true });
sendResponse(
await finalizeUserInterruption({
pauseSnapshot: {
tabId: tab.id,
siteUrl: tab.url || "",
maxRounds,
waitMs,
transcript,
turnLog,
plan,
frameId: best.frameId,
frameUrl: best.frameUrl,
siteSnapshot,
suiteId,
evaluatorSnapshot,
attackObjective,
businessUseCase,
scrapeFromSite,
agentDescription,
judgeHint,
},
cancelResult: {
ok: true,
partial: true,
stopped: true,
stopReason: "user_stop",
siteUrl: tab.url || "",
suiteId,
evaluatorId: evaluatorSnapshot?.id,
evaluatorName: evaluatorSnapshot?.name,
severity: evaluatorSnapshot?.severity,
maxRounds,
transcript,
turns: turnLog,
judgment: cancelledJudgment(transcript),
},
})
);
return;
}
const resumeLastUser = transcript[transcript.length - 1]?.content || "";
Expand Down Expand Up @@ -597,16 +676,26 @@ export async function executeAdaptiveRedTeamRun(sendResponse, message, resume) {
siteSnapshot = located.siteSnapshot || "";
}

// Carry the restored transcript through on resume — writing [] here wipes
// the progress the popup replays from storage, so a resumed run renders as
// "0 turns" and the turn counter restarts from scratch.
const priorRounds = Math.floor(transcript.length / 2);
await setRunStatus({
running: true,
tabId: tab.id,
siteUrl: tab.url || "",
suiteId,
evaluatorId: evaluatorSnapshot?.id,
evaluatorName: evaluatorSnapshot?.name,
severity: evaluatorSnapshot?.severity,
maxRounds,
phase: "running",
transcript: [],
transcript: transcript.slice(-40),
// setRunStatus replaces the whole record, so seed `lastRound` here too —
// it is the field the popup reads, and broadcastProgress only starts
// maintaining it once the next turn completes.
lastRound: priorRounds,
resumedFromRound: priorRounds,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
startedAt: Date.now(),
});

Expand All @@ -624,6 +713,7 @@ export async function executeAdaptiveRedTeamRun(sendResponse, message, resume) {
// Build DomTarget adapter — handles send/extract/pause/stop/recovery
const domTarget = createDomTarget(tab.id, best.frameId, plan, readerCfg, {
waitMs,
roundOffset: priorRounds,
onUserSent: async ({ round, prompt }) => {
// Broadcast the user bubble immediately after send, before waiting for response.
broadcastProgress({
Expand Down Expand Up @@ -774,76 +864,56 @@ export async function executeAdaptiveRedTeamRun(sendResponse, message, resume) {
const fullTurnLog = [...turnLog, ...newTurnLog];

// ── STOPPED path ────────────────────────────────────────────────────────
// A user-initiated stop cancels the in-progress evaluator outright — it
// is reported as CANCELLED, not judged. Running the judge LLM here would
// both delay the stop and produce a misleading PASS/FAIL-shaped verdict
// for a turn the target never got to finish.
if (runError?.code === "OPFOR_STOP" || state.OPFOR_STOP) {
let stoppedJudgment;
if (fullTranscript.length >= 2) {
try {
const lastUser =
[...fullTranscript].reverse().find((t) => t.role === "user")?.content || "";
const lastAssistant =
[...fullTranscript].reverse().find((t) => t.role === "assistant")?.content || "";
stoppedJudgment = adaptJudgeResult(
await judgeResponse(
// A pause with no usable widget plan can't be resumed — degrade it to a
// cancel so the popup still gets a report instead of a dead snapshot.
const resumable = Boolean(plan?.inputSelector && tab?.id && best?.frameId != null);
const response = await finalizeUserInterruption({
canPause: resumable,
pauseSnapshot: resumable
? {
tabId: tab.id,
siteUrl: tab.url || "",
maxRounds,
waitMs,
transcript: fullTranscript,
turnLog: fullTurnLog,
plan,
frameId: best.frameId,
frameUrl: best.frameUrl,
siteSnapshot,
suiteId,
evaluatorSnapshot,
lastUser,
lastAssistant,
judgeModel,
undefined,
fullTranscript,
{
patternName: evaluatorSnapshot?.name,
judgeHint: combinedHint || undefined,
}
)
);
} catch {
stoppedJudgment = {
verdict: "UNKNOWN",
summary: "Run stopped before judgment could complete.",
findings: [{ text: "Run stopped by user; partial transcript was collected." }],
};
}
}
const stoppedResult = {
ok: true,
partial: true,
stopped: true,
stopReason: "user_stop",
siteUrl: tab.url || "",
architecture: "evaluator_adaptive_multi_turn",
suiteId,
evaluatorId: evaluatorSnapshot?.id,
evaluatorName: evaluatorSnapshot?.name,
severity: evaluatorSnapshot?.severity,
maxRounds,
frame: { frameId: best.frameId, frameUrl: best.frameUrl },
transcript: fullTranscript,
turns: fullTurnLog,
judgment: stoppedJudgment || undefined,
};
await persistPartialResult(stoppedResult);
if (plan?.inputSelector && tab?.id && best?.frameId != null) {
await persistPausedAdaptiveRun({
tabId: tab.id,
attackObjective,
businessUseCase,
scrapeFromSite,
agentDescription,
judgeHint,
}
: null,
cancelResult: {
ok: true,
partial: true,
stopped: true,
stopReason: "user_stop",
siteUrl: tab.url || "",
architecture: "evaluator_adaptive_multi_turn",
suiteId,
evaluatorId: evaluatorSnapshot?.id,
evaluatorName: evaluatorSnapshot?.name,
severity: evaluatorSnapshot?.severity,
maxRounds,
waitMs,
frame: { frameId: best?.frameId, frameUrl: best?.frameUrl },
transcript: fullTranscript,
turnLog: fullTurnLog,
plan,
frameId: best.frameId,
frameUrl: best.frameUrl,
siteSnapshot,
suiteId,
evaluatorSnapshot,
attackObjective,
businessUseCase,
scrapeFromSite,
agentDescription,
judgeHint,
}).catch(() => {});
}
sendResponse({ ok: false, error: "Run stopped.", paused: true });
turns: fullTurnLog,
judgment: cancelledJudgment(fullTranscript),
},
});
sendResponse(response);
await clearRunStatus();
return;
}
Expand Down
Loading
Loading