diff --git a/static/js/chatRenderer.js b/static/js/chatRenderer.js index 55ea2e04e..dceec9b24 100644 --- a/static/js/chatRenderer.js +++ b/static/js/chatRenderer.js @@ -1331,17 +1331,54 @@ const _COST_RUNS_KEY = 'ody-session-cost-runs'; const _MAX_COST_RUNS_PER_SESSION = 256; const _COST_LEDGER_LOCK = 'odysseus-session-cost-ledger'; +/** + * Decode one persisted ledger object into a Map. + * + * Ledger keys are external identifiers. Keeping them out of ordinary object + * property assignment means values such as "__proto__" can never interact + * with Object.prototype. The persisted JSON shape remains an ordinary object + * for backwards compatibility. + */ +function _readCostLedger(storageKey) { + const parsed = JSON.parse(localStorage.getItem(storageKey) || '{}'); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return new Map(); + return new Map(Object.entries(parsed)); +} + +function _writeCostLedger(storageKey, ledger) { + localStorage.setItem(storageKey, JSON.stringify(Object.fromEntries(ledger))); +} + +function _readCostRunLedger() { + const sessions = _readCostLedger(_COST_RUNS_KEY); + for (const [sid, runs] of sessions) { + sessions.set( + sid, + runs && typeof runs === 'object' && !Array.isArray(runs) + ? new Map(Object.entries(runs)) + : new Map(), + ); + } + return sessions; +} + +function _writeCostRunLedger(sessions) { + const serialized = new Map(); + for (const [sid, runs] of sessions) { + serialized.set(sid, Object.fromEntries(runs)); + } + _writeCostLedger(_COST_RUNS_KEY, serialized); +} + /** Return the accumulated cost for the current (or given) session. */ export function getSessionCost(sessionId) { const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId()); if (!sid) return 0; try { - const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); - const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}'); - const recordedRuns = runCosts[sid] && typeof runCosts[sid] === 'object' - ? Object.values(runCosts[sid]) - : []; - return (costs[sid] || 0) + recordedRuns.reduce( + const costs = _readCostLedger(_COST_KEY); + const runCosts = _readCostRunLedger(); + const recordedRuns = runCosts.get(sid) || new Map(); + return (costs.get(sid) || 0) + Array.from(recordedRuns.values()).reduce( (total, value) => total + (Number(value) || 0), 0, ); @@ -1353,12 +1390,12 @@ export function resetSessionCost(sessionId) { const sid = sessionId || (window.sessionModule && window.sessionModule.getCurrentSessionId()); if (!sid) return; try { - const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); - delete costs[sid]; - localStorage.setItem(_COST_KEY, JSON.stringify(costs)); - const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}'); - delete runCosts[sid]; - localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts)); + const costs = _readCostLedger(_COST_KEY); + costs.delete(sid); + _writeCostLedger(_COST_KEY, costs); + const runCosts = _readCostRunLedger(); + runCosts.delete(sid); + _writeCostRunLedger(runCosts); } catch (_e) { /* ignore */ } updateSessionCostUI(); } @@ -1407,34 +1444,34 @@ export function recordSessionMetricsCost(metrics, sessionId, selectedEndpointUrl const writeCost = () => { if (runId) { try { - const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}'); - const sessionRuns = runCosts[sid] && typeof runCosts[sid] === 'object' - ? runCosts[sid] - : {}; - // Assigning by detached-run identity is replay-idempotent even when a - // refresh produces a fresh metrics object. The Web Lock around this - // read/modify/write also keeps distinct runs from two tabs from - // overwriting one another's stale snapshot. - sessionRuns[runId] = cost; - const entries = Object.entries(sessionRuns); + const runCosts = _readCostRunLedger(); + const sessionRuns = runCosts.get(sid) || new Map(); + // Detached-run identity is replay-idempotent even when a refresh + // produces a fresh metrics object. Map keys also avoid all + // Object.prototype lookup and assignment semantics. + sessionRuns.set(runId, cost); + const entries = Array.from(sessionRuns.entries()); if (entries.length > _MAX_COST_RUNS_PER_SESSION) { const overflow = entries.slice(0, entries.length - _MAX_COST_RUNS_PER_SESSION); - const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); - costs[sid] = (costs[sid] || 0) + overflow.reduce( - (total, entry) => total + (Number(entry[1]) || 0), - 0, + const costs = _readCostLedger(_COST_KEY); + costs.set( + sid, + (costs.get(sid) || 0) + overflow.reduce( + (total, entry) => total + (Number(entry[1]) || 0), + 0, + ), ); - overflow.forEach(([oldRunId]) => delete sessionRuns[oldRunId]); - localStorage.setItem(_COST_KEY, JSON.stringify(costs)); + overflow.forEach(([oldRunId]) => sessionRuns.delete(oldRunId)); + _writeCostLedger(_COST_KEY, costs); } - runCosts[sid] = sessionRuns; - localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts)); + runCosts.set(sid, sessionRuns); + _writeCostRunLedger(runCosts); } catch (_e) { /* ignore */ } } else { try { - const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}'); - costs[sid] = (costs[sid] || 0) + cost; - localStorage.setItem(_COST_KEY, JSON.stringify(costs)); + const costs = _readCostLedger(_COST_KEY); + costs.set(sid, (costs.get(sid) || 0) + cost); + _writeCostLedger(_COST_KEY, costs); } catch (_e) { /* ignore */ } } metrics._costRecorded = true; diff --git a/tests/pr6503_security_browser.cjs b/tests/pr6503_security_browser.cjs index bacf903db..d0d276cba 100644 --- a/tests/pr6503_security_browser.cjs +++ b/tests/pr6503_security_browser.cjs @@ -114,7 +114,7 @@ function documentFunction(name, text = source) { await page.setContent(''); const ledger = await page.evaluate(async () => { - const { recordSessionMetricsCost, getSessionCost } = await import('/static/js/chatRenderer.js'); + const { recordSessionMetricsCost, getSessionCost, resetSessionCost } = await import('/static/js/chatRenderer.js'); const metrics = id => ({ model: 'gpt-4o', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true, _costRecordId: id }); const before = Object.getOwnPropertyDescriptors(Object.prototype); @@ -149,11 +149,37 @@ function documentFunction(name, text = source) { await navigator.locks.request('odysseus-session-cost-ledger', () => {}); for (let i = 0; i < 257; i++) recordSessionMetricsCost(metrics('run-' + i), 'overflow-session'); await navigator.locks.request('odysseus-session-cost-ledger', () => {}); + // Snapshot all ordinary-ledger results before the reserved-key fixture + // deliberately replaces localStorage below. + const replayCost = getSessionCost('safe-session'); + const legacyCost = getSessionCost('legacy-session'); + const dottedCost = getSessionCost('safe.__proto__.session'); + const overflowCost = getSessionCost('overflow-session'); + const runWire = JSON.parse(localStorage.getItem('ody-session-cost-runs') || '{}'); + const costWire = JSON.parse(localStorage.getItem('ody-session-cost') || '{}'); + const retainedRuns = Object.keys(runWire['overflow-session']).length; + const wireShape = !Array.isArray(runWire) + && !Array.isArray(costWire) + && !!runWire['overflow-session'] + && !Array.isArray(runWire['overflow-session']); + + // A persisted legacy/reserved key must remain data rather than becoming + // prototype state. Reading and removing it must also be side-effect free. + localStorage.setItem('ody-session-cost', '{"__proto__":1}'); + localStorage.setItem('ody-session-cost-runs', '{"__proto__":{"legacy-run":2}}'); + const legacyReservedCost = getSessionCost('__proto__'); + resetSessionCost('__proto__'); + const clearedReserved = !Object.hasOwn( + JSON.parse(localStorage.getItem('ody-session-cost') || '{}'), + '__proto__', + ) && !Object.hasOwn( + JSON.parse(localStorage.getItem('ody-session-cost-runs') || '{}'), + '__proto__', + ); + const after = Object.getOwnPropertyDescriptors(Object.prototype); - return { cost, replayCost: getSessionCost('safe-session'), legacyCost: getSessionCost('legacy-session'), - dottedCost: getSessionCost('safe.__proto__.session'), - overflowCost: getSessionCost('overflow-session'), - retainedRuns: Object.keys(JSON.parse(localStorage.getItem('ody-session-cost-runs'))['overflow-session']).length, + return { cost, replayCost, legacyCost, dottedCost, + overflowCost, retainedRuns, wireShape, legacyReservedCost, clearedReserved, unchanged: Reflect.ownKeys(before).length === Reflect.ownKeys(after).length && Reflect.ownKeys(before).every(key => Reflect.ownKeys(before[key]).every(field => before[key][field] === after[key]?.[field])) }; }); @@ -164,6 +190,9 @@ function documentFunction(name, text = source) { assert.equal(ledger.dottedCost, ledger.cost); assert(Math.abs(ledger.overflowCost - 257 * ledger.cost) < 1e-10); assert.equal(ledger.retainedRuns, 256); + assert.equal(ledger.wireShape, true); + assert.equal(ledger.legacyReservedCost, 3); + assert.equal(ledger.clearedReserved, true); const names = ['_unfoldEmailHeaderLines', '_parseEmailHeader', '_looksLikeWrappedEmailContent', '_decodeBase64EmailWrapper', '_sanitizeOutgoingEmailBody', '_emailHtmlToPlainText', '_aiReply', diff --git a/tests/test_agent_round_model_provenance_ui.py b/tests/test_agent_round_model_provenance_ui.py index dbdda3936..c6f406cee 100644 --- a/tests/test_agent_round_model_provenance_ui.py +++ b/tests/test_agent_round_model_provenance_ui.py @@ -122,6 +122,8 @@ def test_repeated_live_metrics_render_records_session_cost_once(): "function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }", _function_source("_billableCost"), _function_source("_metricsBillableCost"), + _function_source("_readCostLedger"), + _function_source("_writeCostLedger"), _function_source("recordSessionMetricsCost"), "const metrics = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true};", "recordSessionMetricsCost(metrics);", @@ -150,6 +152,10 @@ def test_replayed_metrics_use_run_identity_for_durable_cost_deduplication(): "function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }", _function_source("_billableCost"), _function_source("_metricsBillableCost"), + _function_source("_readCostLedger"), + _function_source("_writeCostLedger"), + _function_source("_readCostRunLedger"), + _function_source("_writeCostRunLedger"), _function_source("recordSessionMetricsCost"), _function_source("getSessionCost"), "const firstObject = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true, _costRecordId: 'run-1'};", @@ -180,6 +186,10 @@ def test_run_cost_ledger_sums_segments_and_updates_repeated_segment_metrics(): "function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }", _function_source("_billableCost"), _function_source("_metricsBillableCost"), + _function_source("_readCostLedger"), + _function_source("_writeCostLedger"), + _function_source("_readCostRunLedger"), + _function_source("_writeCostRunLedger"), _function_source("recordSessionMetricsCost"), _function_source("getSessionCost"), "recordSessionMetricsCost({model: 'student', input_tokens: 100, output_tokens: 10, _costRecordId: 'run:primary'});", @@ -207,6 +217,10 @@ def test_local_selected_endpoint_does_not_erase_paid_fallback_ledger(): "const badge = {style: {}, textContent: ''};", "const document = {getElementById() { return badge; }};", "const window = {sessionModule: {getCurrentSessionId() { return 'session'; }, getCurrentEndpointUrl() { return 'local'; }}};", + _function_source("_readCostLedger"), + _function_source("_writeCostLedger"), + _function_source("_readCostRunLedger"), + _function_source("_writeCostRunLedger"), _function_source("getSessionCost"), _function_source("updateSessionCostUI"), "updateSessionCostUI();",