Skip to content
Merged
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
24 changes: 23 additions & 1 deletion public/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,28 @@ function getActiveStepKey() {
}

// Render step detail content as HTML
// Server-authoritative "edited via intercept" banner. The proxy persists the
// as-sent request as canonical and exposes req.edited, req.editSummary (the diff
// lines), and req.original (the pre-edit body from the forensic received
// sidecar). Dumb renderer — no client-side diffing.
function renderEditedBanner(req, msgIdx) {
if (!req || !req.edited) return '';
const summary = (req.editSummary || [])
.map(l => '<div class="edit-summary-line">' + escapeHtml(l) + '</div>').join('');
// Show the "original before edit" toggle only when THIS message actually
// changed — otherwise an unedited message would show an identical "original".
const origMsg = req.original && Array.isArray(req.original.messages) ? req.original.messages[msgIdx] : null;
const curMsg = req.messages ? req.messages[msgIdx] : null;
const changed = origMsg && curMsg && JSON.stringify(origMsg) !== JSON.stringify(curMsg);
const original = changed
? '<details class="edit-original"><summary>Original before edit</summary>'
+ renderSingleMessage(origMsg, null, msgIdx) + '</details>'
: '';
return '<div class="edit-banner"><span class="edit-badge">&#9998; EDITED</span>'
+ (summary ? '<div class="edit-summary">' + summary + '</div>' : '')
+ original + '</div>';
}

function renderStepDetailHtml(req, tok) {
if (selectedMessageIdx < 0) return '';
const selection = typeof getSelectedStepSelection === 'function' ? getSelectedStepSelection() : null;
Expand All @@ -561,7 +583,7 @@ function renderStepDetailHtml(req, tok) {
if (step.type === 'human') {
const msgIdx = step.msgIndices[0];
const msg = req?.messages?.[msgIdx];
return msg ? '<div class="detail-content">' + renderSingleMessage(msg, tok?.perMessage?.[msgIdx], msgIdx) + '</div>' : '<div class="col-empty">No message</div>';
return msg ? '<div class="detail-content">' + renderEditedBanner(req, msgIdx) + renderSingleMessage(msg, tok?.perMessage?.[msgIdx], msgIdx) + '</div>' : '<div class="col-empty">No message</div>';
} else if (step.type === 'assistant-text') {
return '<div class="detail-content"><pre>' + highlightCredentials(step.text || '') + '</pre></div>';
} else if (step.type === 'tool-group') {
Expand Down
8 changes: 8 additions & 0 deletions public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -643,3 +643,11 @@
.detail-copy-btn:hover { color: var(--text); background: var(--surface); }
.detail-input-details summary { cursor: pointer; font-size: 11px; color: var(--yellow); margin-bottom: 2px; }
.detail-input-details { margin-bottom: 6px; padding: 6px 8px; background: var(--bg); border-radius: 4px; }

/* Intercept-edited request banner (server-authoritative; see renderEditedBanner) */
.edit-banner { margin-bottom: 8px; padding: 6px 8px; background: var(--bg); border: 1px solid var(--border); border-left: 3px solid var(--yellow); border-radius: 4px; }
.edit-badge { display: inline-block; font-size: 10px; font-weight: 600; letter-spacing: 0.04em; color: var(--bg); background: var(--yellow); padding: 1px 6px; border-radius: 3px; }
.edit-summary { margin-top: 4px; }
.edit-summary-line { font-size: 11px; color: var(--dim); white-space: pre-wrap; word-break: break-word; }
.edit-original { margin-top: 6px; }
.edit-original summary { cursor: pointer; font-size: 11px; color: var(--yellow); }
22 changes: 21 additions & 1 deletion server/delta-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,24 @@ function findSharedPrefixFromLast(prevLastMsg, prevCount, currMsgs) {
return prevCount;
}

module.exports = { msgNorm, findSharedPrefix, findSharedPrefixFromLast };
// Build a FULL-format _req.json record from an edited (intercept-approved) body.
// The edited turn is always persisted full — never delta — because the edit may
// touch a message inside what would have been the shared prefix, and a delta
// stores only the suffix, so a delta record could silently serve original
// content for an edited prefix index on the read path. Writing full sidesteps
// that and makes the edited turn a fresh chain anchor. system/tools stay
// content-addressed via sysHash/toolsHash (the caller writes the shared files
// when the hashes changed).
function buildEditedReqRecord(parsedBody, { sysHash = null, toolsHash = null, sessionId = null } = {}) {
const messages = Array.isArray(parsedBody && parsedBody.messages) ? parsedBody.messages : [];
return {
model: parsedBody ? parsedBody.model : undefined,
max_tokens: parsedBody ? parsedBody.max_tokens : undefined,
messages,
sysHash,
toolsHash,
...(sessionId ? { metadata: { session_id: sessionId } } : {}),
};
}

module.exports = { msgNorm, findSharedPrefix, findSharedPrefixFromLast, buildEditedReqRecord };
1 change: 1 addition & 0 deletions server/entry.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const INDEX_FIELDS = [
'isSubagent','sessionInferred','cwd','isSSE','usage','cost','maxContext','responseMetadata',
'stopReason','title','thinkingDuration','toolFail','elapsed','status','receivedAt',
'sysHash','toolsHash','coreHash','thinkingStripped','hasCredential','toolSources',
'edited','editSummary',
];

function buildIndexLine(entry) {
Expand Down
173 changes: 152 additions & 21 deletions server/forward.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
const https = require('https');
const http = require('http');
const tls = require('tls');
const crypto = require('crypto');
const config = require('./config');
const store = require('./store');
const { buildEditedReqRecord } = require('./delta-helpers');
const { calculateCost } = require('./pricing');
const helpers = require('./helpers');
const { broadcast, broadcastSessionStatus, broadcastSessionTitleUpdate } = require('./sse-broadcast');
Expand Down Expand Up @@ -242,6 +244,130 @@ function stripInjectedStats(parsedBody) {
return modified;
}

// Build the human-readable diff lines injected into the response stream when a
// request was edited via dashboard intercept. Pure function so it can be tested
// independently of the SSE plumbing. Returns an array of one-line strings.
function buildEditSummary(orig, mod, opts) {
const MAX_SHOWN = (opts && opts.maxShown) || 5;
const MAX_LEN = (opts && opts.maxLen) || 60;
const diffs = [];
if (!orig || !mod) return diffs;

const snippet = (c) => {
if (c == null) return '';
const s = typeof c === 'string' ? c : JSON.stringify(c);
const flat = s.replace(/\s+/g, ' ').trim();
return flat.length > MAX_LEN ? flat.slice(0, MAX_LEN) + '…' : flat;
};

if (orig.model !== mod.model) diffs.push('Model: ' + orig.model + ' → ' + mod.model);

const origMsgs = orig.messages || [];
const modMsgs = mod.messages || [];
if (origMsgs.length !== modMsgs.length) {
diffs.push('Messages: ' + origMsgs.length + ' → ' + modMsgs.length);
}

// Per-message edits: show old → new snippet, not just a count, so the CLI
// notice tells the user what actually changed.
const edited = [];
for (let i = 0; i < modMsgs.length; i++) {
const o = origMsgs[i];
if (!o) continue;
const oStr = typeof o.content === 'string' ? o.content : JSON.stringify(o.content);
const mStr = typeof modMsgs[i].content === 'string' ? modMsgs[i].content : JSON.stringify(modMsgs[i].content);
if (oStr !== mStr) {
edited.push((modMsgs[i].role || 'msg') + '[' + i + ']: "' + snippet(o.content) + '" → "' + snippet(modMsgs[i].content) + '"');
}
}
for (const line of edited.slice(0, MAX_SHOWN)) diffs.push(line);
if (edited.length > MAX_SHOWN) diffs.push('…and ' + (edited.length - MAX_SHOWN) + ' more message(s) edited');

const origToolLen = (orig.tools || []).length;
const modToolLen = (mod.tools || []).length;
if (origToolLen !== modToolLen) {
diffs.push('Tools: ' + origToolLen + ' → ' + modToolLen + ' (' + (modToolLen - origToolLen >= 0 ? '+' : '') + (modToolLen - origToolLen) + ')');
}

const origSys = typeof orig.system === 'string' ? orig.system : JSON.stringify(orig.system);
const modSys = typeof mod.system === 'string' ? mod.system : JSON.stringify(mod.system);
if (origSys !== modSys) diffs.push('System prompt: "' + snippet(orig.system) + '" → "' + snippet(mod.system) + '"');

return diffs;
}

// ── Intercept-edit persistence ───────────────────────────────────────
// sessionLastReq (the per-session delta anchor) lives privately in index.js.
// It injects a narrow recorder so this module can re-anchor the chain after an
// edited rewrite (messages = clone of edited) or CLEAR it on failure (messages
// = null → next turn re-anchors full). Keeps mutable session state encapsulated.
let sessionAnchorRecorder = null;
function setSessionAnchorRecorder(fn) { sessionAnchorRecorder = fn; }

function sha12(value) {
return value == null ? null
: crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 12);
}

// Persist an intercept-edited request. _req.json was written at receipt time
// (index.js) from the ORIGINAL body; this rewrites it as-sent so the dashboard
// shows what actually went upstream, while preserving the original in a
// non-authoritative `_req.received.json` sidecar (forensics + the "original
// before edit" view). Order matters: write the received sidecar and edited
// shared sys/tools FIRST, then overwrite the canonical _req.json, so a lazy load
// never observes a half-applied edit. On full success re-anchor the delta chain
// from a CLONE of the edited messages; on ANY failure clear the anchor so the
// next turn re-anchors full (no split-brain between disk and the in-memory anchor).
// Returns a promise; the caller folds it into ctx.reqWritePromise.
async function persistEditedRequest(ctx) {
const { id, parsedBody, originalBody, reqSessionId } = ctx;
if (!parsedBody) return;

ctx.edited = true;
ctx.editSummary = ctx.editSummary || buildEditSummary(originalBody, parsedBody);

try {
// 1. Forensic original, preserved before _req.json is overwritten.
if (originalBody) {
await config.storage.write(id, '_req.received.json', JSON.stringify(originalBody));
}

// 2. Edited system/tools, content-addressed. Write the shared file only when
// the edit actually changed it (compare against the ORIGINAL hash, not
// ctx.sysHash, which the forward gate may have already set to the edited
// value). Update ctx so entry/index metadata point at the edited content.
const editedSysHash = parsedBody.system ? sha12(parsedBody.system) : null;
const editedToolsHash = parsedBody.tools ? sha12(parsedBody.tools) : null;
const origSysHash = originalBody && originalBody.system ? sha12(originalBody.system) : null;
const origToolsHash = originalBody && originalBody.tools ? sha12(originalBody.tools) : null;
if (editedSysHash && editedSysHash !== origSysHash) {
await config.storage.writeSharedIfAbsent(`sys_${editedSysHash}.json`, JSON.stringify(parsedBody.system));
}
if (editedToolsHash && editedToolsHash !== origToolsHash) {
await config.storage.writeSharedIfAbsent(`tools_${editedToolsHash}.json`, JSON.stringify(parsedBody.tools));
}
ctx.sysHash = editedSysHash;
ctx.toolsHash = editedToolsHash;

// 3. Canonical _req.json, as-sent, full format (no prevId/msgOffset).
const record = buildEditedReqRecord(parsedBody, {
sysHash: editedSysHash, toolsHash: editedToolsHash, sessionId: reqSessionId,
});
await config.storage.write(id, '_req.json', JSON.stringify(record));

// 4. Re-anchor the delta chain from a CLONE (never the live parsedBody array),
// only for delta-eligible turns (explicit session + delta-capable storage).
if (reqSessionId && config.storage.supportsDelta && sessionAnchorRecorder) {
sessionAnchorRecorder(reqSessionId, id, JSON.parse(JSON.stringify(record.messages)));
}
} catch (e) {
console.error('Edited request persistence failed:', e.message);
// Split-brain mitigation: disk is uncertain → clear the anchor so the next
// turn writes FULL rather than a delta against an unrecoverable base.
if (reqSessionId && sessionAnchorRecorder) sessionAnchorRecorder(reqSessionId, id, null);
}
}

// ── Forward request to Anthropic ─────────────────────────────────────
function forwardRequest(ctx) {
const { id, ts, startTime, parsedBody, rawBody, clientReq, clientRes, fwdHeaders, reqSessionId } = ctx;
Expand Down Expand Up @@ -291,6 +417,26 @@ function forwardRequest(ctx) {
const modelPrefixed = applyModelPrefix(parsedBody, config.REWRITE_MODEL_PREFIX);
const bodyToSend = (ctx.bodyModified || statsStripped || modelPrefixed) ? Buffer.from(JSON.stringify(parsedBody)) : rawBody;

// Intercept-edited body: persist it as-sent (rewrite _req.json) + a forensic
// _req.received.json. This runs AFTER stripInjectedStats/applyModelPrefix so
// the persisted record matches the bytes actually sent. Chain after the
// original receipt-time write (never Promise.all — the original must not land
// last and restore stale bytes); loadEntryReqRes awaits entry._writePromise,
// which bundles ctx.reqWritePromise, so the read path observes the rewrite.
if (ctx.bodyModified && provider === 'anthropic' && !ctx.skipEntry) {
// Set edited state + as-sent hashes synchronously so the entry built in the
// response handler (and its index line) reference the edited content
// regardless of when the async persist runs. persist decides shared-file
// writes by comparing against the ORIGINAL hashes (from originalBody), so
// mutating ctx.sysHash/toolsHash here does not affect that decision.
ctx.edited = true;
ctx.editSummary = buildEditSummary(ctx.originalBody, parsedBody);
ctx.sysHash = parsedBody.system ? sha12(parsedBody.system) : null;
ctx.toolsHash = parsedBody.tools ? sha12(parsedBody.tools) : null;
const prior = ctx.reqWritePromise || Promise.resolve();
ctx.reqWritePromise = prior.then(() => persistEditedRequest(ctx), () => persistEditedRequest(ctx));
}

const transport = upstream.protocol === 'http' ? http : https;
const tunnelAgent = getTunnelAgent(upstream);
const proxyReq = transport.request({
Expand Down Expand Up @@ -471,27 +617,7 @@ function handleSSEResponse(ctx, proxyRes, clientRes) {

// Inject intercept modification summary
if (ctx.bodyModified && ctx.originalBody) {
const orig = ctx.originalBody;
const mod = parsedBody;
const diffs = [];
if (orig.model !== mod.model) diffs.push('Model: ' + orig.model + ' → ' + mod.model);
const origMsgLen = (orig.messages || []).length;
const modMsgLen = (mod.messages || []).length;
if (origMsgLen !== modMsgLen) diffs.push('Messages: ' + origMsgLen + ' → ' + modMsgLen);
const msgEdits = (mod.messages || []).reduce((cnt, m, i) => {
const o = (orig.messages || [])[i];
if (!o) return cnt;
const oStr = typeof o.content === 'string' ? o.content : JSON.stringify(o.content);
const mStr = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
return oStr !== mStr ? cnt + 1 : cnt;
}, 0);
if (msgEdits > 0) diffs.push(msgEdits + ' message(s) edited');
const origToolLen = (orig.tools || []).length;
const modToolLen = (mod.tools || []).length;
if (origToolLen !== modToolLen) diffs.push('Tools: ' + origToolLen + ' → ' + modToolLen + ' (' + (modToolLen - origToolLen) + ')');
const origSys = typeof orig.system === 'string' ? orig.system : JSON.stringify(orig.system);
const modSys = typeof mod.system === 'string' ? mod.system : JSON.stringify(mod.system);
if (origSys !== modSys) diffs.push('System prompt: modified');
const diffs = buildEditSummary(ctx.originalBody, parsedBody);
if (diffs.length > 0) {
const interceptIdx = maxBlockIndex + (usage && totalCtx && stopReason !== 'tool_use' ? 2 : 1);
const iText = '\n\n---\n🔀 Request was modified by dashboard intercept:\n ' + diffs.join('\n ');
Expand Down Expand Up @@ -532,6 +658,7 @@ function handleSSEResponse(ctx, proxyRes, clientRes) {
req: parsedBody, res: events,
elapsed, status: proxyRes.statusCode, isSSE: true,
receivedAt: startTime,
edited: ctx.edited, editSummary: ctx.editSummary,
tokens: helpers.tokenizeRequest(parsedBody),
duplicateToolCalls: helpers.extractDuplicateToolCalls(parsedBody?.messages),
...getParser('anthropic').buildEntryFields({
Expand Down Expand Up @@ -750,6 +877,7 @@ function handleNonSSEResponse(ctx, proxyRes, clientRes) {
req: parsedBody, res: resData,
elapsed, status: proxyRes.statusCode, isSSE: false,
receivedAt: startTime,
edited: ctx.edited, editSummary: ctx.editSummary,
tokens: helpers.tokenizeRequest(parsedBody),
duplicateToolCalls: helpers.extractDuplicateToolCalls(parsedBody?.messages),
...getParser('anthropic').buildEntryFields({
Expand Down Expand Up @@ -797,9 +925,12 @@ function handleNonSSEResponse(ctx, proxyRes, clientRes) {

module.exports = {
forwardRequest,
persistEditedRequest,
setSessionAnchorRecorder,
resolveProxyAgent,
applyModelPrefix,
stripInjectedStats,
buildEditSummary,
setStatusLineEnabled,
getStatusLineEnabled,
parseSSEFrame,
Expand Down
14 changes: 13 additions & 1 deletion server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const helpers = require('./helpers');
const { fetchPricing } = require('./pricing');
const { restoreFromLogs, pruneLogs } = require('./restore');
const { warmUp: warmUpCosts } = require('./cost-budget');
const { forwardRequest, setStatusLineEnabled, getStatusLineEnabled } = require('./forward');
const { forwardRequest, setStatusLineEnabled, getStatusLineEnabled, setSessionAnchorRecorder } = require('./forward');
const { readSettings } = require('./settings');
const { broadcastSessionStatus, broadcastPendingRequest } = require('./sse-broadcast');
const { dispatch, mintAutoOpenUrl, formatAutoOpenUrl } = require('./auth');
Expand Down Expand Up @@ -86,6 +86,18 @@ if (agentMode || hubMode) console.log = () => {};
// Only populated for sessions with explicit session_id (main orchestrator turns).
const sessionLastReq = new Map(); // sessionId → { id, messages, deltaCount }

// Narrow recorder injected into forward.js so the intercept-edit rewrite can
// re-anchor this private map without owning it. messages == null clears the
// anchor (delete) so the next turn re-anchors FULL instead of writing a delta
// against an unrecoverable base (split-brain mitigation on rewrite failure);
// otherwise it sets the edited turn as a fresh full anchor (deltaCount 0).
function recordSessionAnchor(sessionId, anchorId, messages) {
if (!sessionId) return;
if (messages == null) { sessionLastReq.delete(sessionId); return; }
sessionLastReq.set(sessionId, { id: anchorId, messages, deltaCount: 0 });
}
setSessionAnchorRecorder(recordSessionAnchor);

// Route handlers
const { handleSSERoute } = require('./routes/sse');
const { handleApiRoutes } = require('./routes/api');
Expand Down
Loading
Loading