From 721e28269f93dcaba38f78a22046efc4ab80580e Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 7 Jul 2026 05:58:30 -0500 Subject: [PATCH 01/26] feat(quick-260707-7id): record MCP agent sessions into logs/history/replay/memory - new extension/utils/mcp-session-recorder.js: sessions keyed agentId+tabId, birthed by the visualSession sidecar, closed on isFinal or 60s sliding idle, persisted via direct automationLogger.saveSession + extractAndStoreMemories globals with mode 'mcp-agent' and replay-shape actionHistory - sibling recordDispatch hooks in BOTH dispatcher finally blocks (message route gated by !_mcpMetricsSuppressInner; metrics Test 9 pins undisturbed) - eviction survival via versioned fsbMcpSessionBuffer chrome.storage.session envelope; key-targeted sensitive-param redaction, replay values raw - run_task and pure read-only bursts never recorded - automation-logger carries mode + mcpClient through saveSession and the session index; sidepanel history badge (MCP vs Autopilot) - lattice importScripts tally bumped 309->310 mentions / 305->306 call sites - tests/mcp-session-recorder.test.js: 10 locked cases + eviction restore + source-pin guards (94 assertions) --- extension/background.js | 5 + extension/ui/sidepanel.js | 3 + extension/utils/automation-logger.js | 10 + extension/utils/mcp-session-recorder.js | 697 ++++++++++++++++++++ extension/ws/mcp-tool-dispatcher.js | 44 ++ tests/lattice-provider-bridge-smoke.test.js | 7 +- tests/mcp-session-recorder.test.js | 623 +++++++++++++++++ 7 files changed, 1387 insertions(+), 2 deletions(-) create mode 100644 extension/utils/mcp-session-recorder.js create mode 100644 tests/mcp-session-recorder.test.js diff --git a/extension/background.js b/extension/background.js index cbf759295..8aa8021ef 100644 --- a/extension/background.js +++ b/extension/background.js @@ -72,6 +72,11 @@ try { importScripts('utils/mcp-pricing.js'); } catch (e) { console.error('[FSB] // (it calls fsbMcpPricing) and AFTER mcp-tool-dispatcher.js (dispatcher hooks // call globalThis.fsbMcpMetricsRecorder). try { importScripts('utils/mcp-metrics-recorder.js'); } catch (e) { console.error('[FSB] Failed to load mcp-metrics-recorder.js:', e.message); } +// Quick 260707-7id -- MCP session recorder. Loaded AFTER the dispatcher and +// the metrics recorder: the dispatcher's finally-block sibling hooks call +// globalThis.fsbMcpSessionRecorder lazily, and this placement keeps the +// global ready before any WS dispatch can fire. +try { importScripts('utils/mcp-session-recorder.js'); } catch (e) { console.error('[FSB] Failed to load mcp-session-recorder.js:', e.message); } // Phase 272 / v0.9.69 -- TelemetryCollector. Loaded AFTER mcp-metrics-recorder // (collector reads the rows the recorder writes to fsbUsageData). The alarm // handler in chrome.alarms.onAlarm + the install_announce setTimeout in diff --git a/extension/ui/sidepanel.js b/extension/ui/sidepanel.js index 19c14a83a..bf39c7972 100644 --- a/extension/ui/sidepanel.js +++ b/extension/ui/sidepanel.js @@ -3454,6 +3454,9 @@ async function loadHistoryList() { '' + formatSessionDate(session.startTime) + '' + '' + (session.actionCount || 0) + ' actions' + costDisplay + + (session.mode === 'mcp-agent' + ? 'MCP · ' + escapeHtml(session.mcpClient || 'Agent') + '' + : 'Autopilot') + '' + escapeHtml(session.status || 'unknown') + '' + '' + '' + diff --git a/extension/utils/automation-logger.js b/extension/utils/automation-logger.js index 1a8526c6b..796d94de1 100644 --- a/extension/utils/automation-logger.js +++ b/extension/utils/automation-logger.js @@ -737,6 +737,9 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { existing.totalCost = sessionData.totalCost || existing.totalCost || 0; existing.totalInputTokens = sessionData.totalInputTokens || existing.totalInputTokens || 0; existing.totalOutputTokens = sessionData.totalOutputTokens || existing.totalOutputTokens || 0; + // Quick 260707-7id: session source discriminator + MCP client label + existing.mode = sessionData.mode || existing.mode || 'autopilot'; + existing.mcpClient = sessionData.mcpClient || existing.mcpClient || null; existing.outcome = normalizedOutcome.outcome; existing.outcomeDetails = normalizedOutcome.outcomeDetails; existing.result = normalizedOutcome.result; @@ -771,6 +774,9 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { endTime: Date.now(), status: sessionData.status || 'completed', tabId: sessionData.tabId || null, + // Quick 260707-7id: session source discriminator + MCP client label + mode: sessionData.mode || 'autopilot', + mcpClient: sessionData.mcpClient || null, actionCount: sessionData.actionHistory?.length || 0, iterationCount: sessionData.iterationCount || 0, conversationId: metadata.conversationId, @@ -808,6 +814,10 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { id: sessionId, task: savedSession.task, startTime: savedSession.startTime, endTime: savedSession.endTime, status: savedSession.status, actionCount: savedSession.actionCount, domSnapshotCount: snapshotCount, + // Quick 260707-7id: source badge fields (entries predating this + // change lack them and default to Autopilot in the UI) + mode: savedSession.mode || 'autopilot', + mcpClient: savedSession.mcpClient || null, totalCost: savedSession.totalCost || 0, outcome: savedSession.outcome || null, outcomeDetails: savedSession.outcomeDetails || null, diff --git a/extension/utils/mcp-session-recorder.js b/extension/utils/mcp-session-recorder.js new file mode 100644 index 000000000..945e55e56 --- /dev/null +++ b/extension/utils/mcp-session-recorder.js @@ -0,0 +1,697 @@ +/** + * MCP Session Recorder -- assembles MCP-agent browsing sessions and lands + * them in the SAME logs/history/replay/memory pipeline autopilot runs use. + * + * Quick task 260707-7id. Sibling of the Phase 271 metrics recorder at the two + * dispatcher choke points in extension/ws/mcp-tool-dispatcher.js + * (dispatchMcpToolRoute + dispatchMcpMessageRoute finally blocks). Each + * recordDispatch() call folds one resolved MCP dispatch into an in-memory + * open-session record keyed agentId+tabId; the visualSession sidecar drives + * the lifecycle (birth on first action tool, close on isFinal or 60s idle). + * + * On close the assembled session flows through DIRECT service-worker + * globals -- NEVER chrome.runtime.sendMessage, which does not loop back + * inside the SW: + * - globalThis.automationLogger.saveSession(sessionId, session) -- the + * fsbSessionLogs/fsbSessionIndex history store (mode + mcpClient badge + * fields carried by this task's automation-logger.js change). + * - extractAndStoreMemories(sessionId, session) -- background.js memory + * handoff; verified to tolerate a missing AI instance. + * - createSession(overrides) -- ai/session-schema.js factory when present + * (SW runtime); a manual same-keys object under bare Node. + * + * Persisted actionHistory entries are {tool, params, result, timestamp} so + * the existing replay engine (background.js loadReplayableSession / + * executeReplaySequence) consumes MCP sessions unmodified. params get a + * KEY-TARGETED sensitive-value redaction (password/secret/token/credential/ + * api-key/authorization -- threat T-q7id-01) while replay-critical values + * (url, selector, text) persist raw. + * + * Open sessions survive MV3 SW eviction via a chrome.storage.session + * versioned envelope (key fsbMcpSessionBuffer, v1 -- envelope pattern + * mirrors utils/mcp-task-store.js: canonical empty on missing/mismatched/ + * malformed; storage key removed when records is empty). + * + * Never-record rules: + * - run_task dispatches are skipped entirely (they alias to + * mcp:start-automation and the automation engine already records that + * run -- recording here would double sessions). + * - Read-only bursts (agentId but never a visualSession sidecar) never + * birth sessions; sidecar-less calls only JOIN an already-open session + * for the same agentId. + * + * recordDispatch NEVER throws and never returns a meaningful value + * (fire-and-forget contract, threat T-q7id-02) -- the dispatcher further + * insulates the call sites in their own try/catch as defence in depth. + * + * Loading: background.js pulls this file as a classic script right after + * utils/mcp-metrics-recorder.js. Lazy globalThis.chrome access keeps the + * module require()-able under plain Node for the test harness at + * tests/mcp-session-recorder.test.js. + * + * @module extension/utils/mcp-session-recorder + */ + +(function () { + 'use strict'; + + // ---- Constants ---------------------------------------------------------- + + var FSB_MCP_SESSION_BUFFER_KEY = 'fsbMcpSessionBuffer'; + var FSB_MCP_SESSION_BUFFER_VERSION = 1; + + // Mirrors MCP_VISUAL_LIFECYCLE_DEATH_MS + // (extension/utils/mcp-visual-session-lifecycle.js:79) -- a sliding 60s + // idle window re-armed on every recorded dispatch. Deliberately NOT read + // from that module: the recorder keeps its OWN timer so the two + // lifecycles stay decoupled. + var MCP_SESSION_IDLE_DEATH_MS = 60000; + + // In-memory actionHistory cap -- matches the saveSession persistence cap + // (automation-logger.js slice(-100)) so memory cannot grow unbounded on a + // long-lived agent session. + var MCP_SESSION_ACTION_HISTORY_CAP = 100; + + // Key-targeted redaction pattern (threat T-q7id-01). VALUES of matching + // keys are replaced before any persist; everything else persists raw for + // replay fidelity. Note ownershipToken never enters actionHistory because + // only payload.params is recorded, but the pattern also catches a + // params-level token if one ever appears. + var SENSITIVE_KEY_PATTERN = /pass(word)?|secret|token|credential|api[-_]?key|authorization/i; + + // ---- In-memory state ---------------------------------------------------- + + // key = agentId + '::' + tabKey; value = open-session record. + var _openSessions = new Map(); + // key -> idle-timer handle (kept out of the session record so records + // serialize cleanly into the storage envelope). + var _idleTimers = new Map(); + // Monotonic guard for the autopilot-format session id (session_): + // same-ms births within THIS recorder get last+1. Cross-engine same-ms + // collision accepted per locked design. + var _lastGeneratedSessionTs = 0; + + // ---- Test seams (mirroring mcp-metrics-recorder.js) --------------------- + + var _storageShim = null; + var _timeShim = null; + + function _setStorageShim(shim) { + _storageShim = shim; + } + + // shim = { now, setTimeout, clearTimeout } (any subset). Pass null to + // restore real time. + function _setTimeShim(shim) { + _timeShim = shim || null; + } + + function _now() { + if (_timeShim && typeof _timeShim.now === 'function') return _timeShim.now(); + return Date.now(); + } + + function _setIdleTimeout(fn, ms) { + if (_timeShim && typeof _timeShim.setTimeout === 'function') return _timeShim.setTimeout(fn, ms); + return setTimeout(fn, ms); + } + + function _clearIdleTimeout(handle) { + if (_timeShim && typeof _timeShim.clearTimeout === 'function') return _timeShim.clearTimeout(handle); + return clearTimeout(handle); + } + + // ---- Lazy global accessors ---------------------------------------------- + + function _getChrome() { + return (typeof globalThis !== 'undefined' && globalThis.chrome) ? globalThis.chrome : null; + } + + function _resolveSessionStorage() { + if (_storageShim) return _storageShim; + var c = _getChrome(); + if (c && c.storage && c.storage.session) return c.storage.session; + return null; + } + + function _getAutomationLogger() { + return (typeof globalThis !== 'undefined' && globalThis.automationLogger) ? globalThis.automationLogger : null; + } + + // ---- Redaction ----------------------------------------------------------- + + // Replace the VALUE under a sensitive key. Uses the shape-only + // globalThis.redactForLog helper when available (lazy guard exactly like + // audit-log.js), else the literal '[REDACTED]'. NEVER shape-redacts whole + // params -- that would destroy replay fidelity. + function _redactValue(value) { + try { + if (typeof globalThis !== 'undefined' && typeof globalThis.redactForLog === 'function') { + return globalThis.redactForLog(value); + } + } catch (_e) { /* fall through to literal */ } + return '[REDACTED]'; + } + + function _redactSensitiveKeysInPlace(node) { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + for (var i = 0; i < node.length; i++) { + _redactSensitiveKeysInPlace(node[i]); + } + return; + } + var keys = Object.keys(node); + for (var k = 0; k < keys.length; k++) { + var key = keys[k]; + if (SENSITIVE_KEY_PATTERN.test(key)) { + node[key] = _redactValue(node[key]); + } else { + _redactSensitiveKeysInPlace(node[key]); + } + } + } + + // Deep-clone via JSON round-trip (try/catch fallback to {}), then walk the + // clone replacing values under sensitive keys. url/selector/text persist + // raw -- the replay engine consumes recorded params unmodified. + function redactParams(params) { + var clone; + try { + clone = JSON.parse(JSON.stringify(params === undefined ? null : params)); + } catch (_e) { + return {}; + } + if (!clone || typeof clone !== 'object') return {}; + _redactSensitiveKeysInPlace(clone); + return clone; + } + + // ---- Open-session buffer persistence (eviction survival) ---------------- + + // Serialized through a small promise-chain lock like _withRecordLock in + // mcp-metrics-recorder.js so two persists cannot interleave their + // read-modify-write cycles. + var _persistLock = Promise.resolve(); + + function _withPersistLock(fn) { + var next = _persistLock.then(fn, fn); + _persistLock = next.catch(function () { /* keep chain alive */ }); + return next; + } + + async function _readBufferEnvelope() { + var storage = _resolveSessionStorage(); + if (!storage || typeof storage.get !== 'function') { + return { v: FSB_MCP_SESSION_BUFFER_VERSION, records: {} }; + } + try { + var stored = await storage.get([FSB_MCP_SESSION_BUFFER_KEY]); + var payload = stored ? stored[FSB_MCP_SESSION_BUFFER_KEY] : null; + if (!payload || typeof payload !== 'object') { + return { v: FSB_MCP_SESSION_BUFFER_VERSION, records: {} }; + } + if (payload.v !== FSB_MCP_SESSION_BUFFER_VERSION) { + return { v: FSB_MCP_SESSION_BUFFER_VERSION, records: {} }; + } + if (!payload.records || typeof payload.records !== 'object') { + return { v: FSB_MCP_SESSION_BUFFER_VERSION, records: {} }; + } + return payload; + } catch (_e) { + return { v: FSB_MCP_SESSION_BUFFER_VERSION, records: {} }; + } + } + + async function _writeBufferEnvelope(records) { + var storage = _resolveSessionStorage(); + if (!storage) return; + try { + var nextRecords = (records && typeof records === 'object') ? records : {}; + if (Object.keys(nextRecords).length === 0) { + // Remove the storage key when records is empty (mcp-task-store.js + // pattern) -- no stale envelope sitting in storage forever. + if (typeof storage.remove === 'function') { + await storage.remove(FSB_MCP_SESSION_BUFFER_KEY); + } + return; + } + if (typeof storage.set !== 'function') return; + var toWrite = {}; + toWrite[FSB_MCP_SESSION_BUFFER_KEY] = { + v: FSB_MCP_SESSION_BUFFER_VERSION, + records: nextRecords + }; + await storage.set(toWrite); + } catch (_e) { /* best-effort -- persistence must never break recording */ } + } + + function _serializeRecord(session) { + return { + sessionId: session.sessionId, + agentId: session.agentId, + tabId: session.tabId, + task: session.task, + client: session.client, + startTime: session.startTime, + lastActivityAt: session.lastActivityAt, + deadlineAt: session.deadlineAt, + lastUrl: session.lastUrl, + visualReasons: session.visualReasons.slice(), + actionHistory: session.actionHistory.slice(), + sawActionTool: session.sawActionTool === true + }; + } + + // Fire-and-forget snapshot of every open session into the versioned + // envelope. Called after every state mutation. + function _persistOpenSessions() { + try { + _withPersistLock(async function () { + var records = {}; + _openSessions.forEach(function (session, key) { + records[key] = _serializeRecord(session); + }); + await _writeBufferEnvelope(records); + }); + } catch (_e) { /* best-effort */ } + } + + // ---- Session identity helpers ------------------------------------------- + + function _generateSessionId() { + // Autopilot session id format (background.js: `session_${Date.now()}`) + // with a monotonic same-ms guard scoped to this recorder. + var ts = _now(); + if (ts <= _lastGeneratedSessionTs) { + ts = _lastGeneratedSessionTs + 1; + } + _lastGeneratedSessionTs = ts; + return 'session_' + ts; + } + + function _numericTabId(raw) { + if (typeof raw === 'number' && isFinite(raw)) return raw; + if (typeof raw === 'string' && /^\d+$/.test(raw)) return parseInt(raw, 10); + return null; + } + + function _tabKeyPart(numericTabId) { + return numericTabId === null ? 'none' : String(numericTabId); + } + + // JOIN attribution fallback: the open session with matching agentId that + // has the most recent lastActivityAt, any tabId -- mirrors + // resolveMcpClientLabel's per-agent semantics. + function _findMostRecentSessionForAgent(agentId) { + var best = null; + _openSessions.forEach(function (session) { + if (session.agentId !== agentId) return; + if (!best || session.lastActivityAt > best.lastActivityAt) best = session; + }); + return best; + } + + // ---- Idle timer (sliding 60s window) ------------------------------------ + + function _disarmIdleTimer(key) { + var handle = _idleTimers.get(key); + if (handle !== undefined) { + try { _clearIdleTimeout(handle); } catch (_e) { /* ignore */ } + _idleTimers.delete(key); + } + } + + function _armIdleTimer(session) { + var key = session.key; + _disarmIdleTimer(key); + var delay = session.deadlineAt - _now(); + if (delay < 0) delay = 0; + try { + var handle = _setIdleTimeout(function () { + try { + _idleTimers.delete(key); + var live = _openSessions.get(key); + if (!live) return; + if (live.deadlineAt <= _now()) { + closeSession(key, 'expired'); + } else { + // Stale timer (deadline slid forward without a re-arm) -- re-arm + // for the remaining window instead of closing early. + _armIdleTimer(live); + } + } catch (_e) { /* never throw out of a timer */ } + }, delay); + _idleTimers.set(key, handle); + } catch (_e) { /* timers unavailable -- lazy sweep still closes expired */ } + } + + // Lazy sweep: close any open session whose deadline has passed. Runs at + // the top of every recordDispatch so expiry works even if timers were + // lost (SW eviction between dispatches). + function _sweepExpired(now) { + var expiredKeys = []; + _openSessions.forEach(function (session, key) { + if (session.deadlineAt <= now) expiredKeys.push(key); + }); + for (var i = 0; i < expiredKeys.length; i++) { + closeSession(expiredKeys[i], 'expired'); + } + } + + // ---- Session close -------------------------------------------------------- + + /** + * Close an open session: remove it from the map, then (if it saw at least + * one action tool and holds at least one actionHistory entry) build the + * schema session object and hand it to the history + memory pipeline via + * DIRECT globals. Never throws. + * + * @param {string} key - agentId::tabKey map key. + * @param {string} _reason - 'final' | 'expired' (diagnostic only). + */ + function closeSession(key, _reason) { + try { + var session = _openSessions.get(key); + if (!session) return; + _openSessions.delete(key); + _disarmIdleTimer(key); + _persistOpenSessions(); + + // >=1-action persistence gate (defence in depth -- the JOIN rule + // already prevents sidecar-less births). + if (session.sawActionTool !== true || session.actionHistory.length < 1) return; + + var endTime = _now(); + var overrides = { + id: session.sessionId, + task: session.task, + status: 'completed', + startTime: session.startTime, + endTime: endTime, + tabId: session.tabId, + actionHistory: session.actionHistory, + iterationCount: session.actionHistory.length, + lastUrl: session.lastUrl, + mode: 'mcp-agent', + mcpClient: session.client + }; + + // createSession is a SW global at runtime (ai/session-schema.js loads + // as a classic script) but absent in bare Node and absent at this + // module's load time -- always lazy-guard. + var sessionObject = (typeof createSession === 'function') + ? createSession(overrides) + : Object.assign({}, overrides); + + var logger = _getAutomationLogger(); + if (logger) { + // saveSession gates on session-bound logs (automation-logger.js:709 + // returns false when getSessionLogs(sessionId) is empty). Birth + // seeds logs via logSessionStart, but a session restored after SW + // eviction has an EMPTY in-memory log buffer -- re-seed one + // session-bound entry so the gate passes. + try { + if (typeof logger.getSessionLogs === 'function' && + typeof logger.logSessionStart === 'function') { + var existingLogs = logger.getSessionLogs(session.sessionId); + if (!existingLogs || existingLogs.length === 0) { + logger.logSessionStart(session.sessionId, session.task, session.tabId); + } + } + } catch (_e) { /* best-effort */ } + + // DIRECT global call -- chrome.runtime.sendMessage does NOT loop + // back inside the SW, so message-passing here would silently drop. + try { + if (typeof logger.saveSession === 'function') { + var saveResult = logger.saveSession(session.sessionId, sessionObject); + if (saveResult && typeof saveResult.catch === 'function') { + saveResult.catch(function () { /* best-effort */ }); + } + } + } catch (_e) { /* never let history persistence break close */ } + } + + // Memory handoff -- background.js extractAndStoreMemories tolerates a + // missing AI instance and calls memoryManager.add unconditionally. + // Absent under bare Node: skip silently. + try { + if (typeof extractAndStoreMemories === 'function') { + var memoryResult = extractAndStoreMemories(session.sessionId, sessionObject); + if (memoryResult && typeof memoryResult.catch === 'function') { + memoryResult.catch(function () { /* fire-and-forget */ }); + } + } + } catch (_e) { /* never let memory handoff break close */ } + } catch (_outerErr) { + try { + if (typeof console !== 'undefined' && typeof console.debug === 'function') { + console.debug('[FSB MCP Session Recorder] close failed:', + _outerErr && _outerErr.message ? _outerErr.message : _outerErr); + } + } catch (_e) { /* ignore */ } + } + } + + // ---- recordDispatch (public surface) ------------------------------------- + + /** + * Record a single resolved MCP dispatch. Fire-and-forget: NEVER throws, + * never returns a meaningful value, never alters the dispatcher's resolved + * value or thrown error (threat T-q7id-02). + * + * Entry shape mirrors the metrics recorder hook exactly: + * { client, tool, requestPayload, response, success, dispatcher_route } + * + * @param {object} entry - The dispatch context from the dispatcher finally. + */ + function recordDispatch(entry) { + try { + if (!entry || typeof entry !== 'object') return; + var payload = (entry.requestPayload && typeof entry.requestPayload === 'object') + ? entry.requestPayload + : {}; + var agentId = payload.agentId; + if (typeof agentId !== 'string' || agentId.length === 0) return; + + // run_task aliases to mcp:start-automation and the automation engine + // already records that run -- recording it here would double sessions. + if (entry.tool === 'run_task') return; + + var now = _now(); + _sweepExpired(now); + + var params = (payload.params && typeof payload.params === 'object') ? payload.params : {}; + var numericTabId = _numericTabId(params.tabId); + var sidecar = (payload.visualSession && typeof payload.visualSession === 'object') + ? payload.visualSession + : null; + + var session = null; + var key = null; + + if (sidecar) { + // Action tool call -- the sidecar exists ONLY on mutating action + // tools. Look up (or birth) the session keyed agentId+tabId. + key = agentId + '::' + _tabKeyPart(numericTabId); + session = _openSessions.get(key) || null; + if (!session) { + var sessionId = _generateSessionId(); + var task = (typeof sidecar.visualReason === 'string' && sidecar.visualReason.length > 0) + ? sidecar.visualReason + : String(entry.tool); + var client = (typeof sidecar.client === 'string' && sidecar.client.length > 0) + ? sidecar.client + : ((typeof entry.client === 'string' && entry.client.length > 0) ? entry.client : 'unknown'); + session = { + key: key, + sessionId: sessionId, + agentId: agentId, + tabId: numericTabId, + task: task, + client: client, + startTime: now, + lastActivityAt: now, + deadlineAt: now + MCP_SESSION_IDLE_DEATH_MS, + lastUrl: null, + visualReasons: [], + actionHistory: [], + sawActionTool: false + }; + _openSessions.set(key, session); + // Seed session-bound logs so saveSession's empty-logs gate passes. + var birthLogger = _getAutomationLogger(); + if (birthLogger && typeof birthLogger.logSessionStart === 'function') { + try { birthLogger.logSessionStart(sessionId, task, numericTabId); } catch (_e) { /* best-effort */ } + } + } + session.sawActionTool = true; + } else { + // Read-only tool route or message route -- JOIN the most recently + // active open session for this agentId. No open session -> ignore + // (this structurally enforces the >=1-action persistence gate: + // read-only calls never birth sessions). + session = _findMostRecentSessionForAgent(agentId); + if (!session) return; + key = session.key; + } + + // Append the action in replay shape {tool, params, result, timestamp}. + var redactedParams = redactParams(params); + session.actionHistory.push({ + tool: entry.tool, + params: redactedParams, + result: entry.response, + timestamp: now + }); + if (session.actionHistory.length > MCP_SESSION_ACTION_HISTORY_CAP) { + session.actionHistory.splice(0, session.actionHistory.length - MCP_SESSION_ACTION_HISTORY_CAP); + } + + // lastUrl feeds extractAndStoreMemories' domain fallback. + if (entry.tool === 'navigate' && typeof params.url === 'string' && params.url.length > 0 && entry.success) { + session.lastUrl = params.url; + } + + if (sidecar && typeof sidecar.visualReason === 'string' && sidecar.visualReason.length > 0 && + session.visualReasons.indexOf(sidecar.visualReason) === -1) { + session.visualReasons.push(sidecar.visualReason); + } + + var logger = _getAutomationLogger(); + if (logger && typeof logger.logAction === 'function') { + try { + logger.logAction(session.sessionId, { tool: entry.tool, params: redactedParams }, entry.response); + } catch (_e) { /* best-effort */ } + } + + // Sliding 60s idle window (mirrors mcp-visual-session-lifecycle + // semantics): every recorded call re-arms the deadline. + session.lastActivityAt = now; + session.deadlineAt = now + MCP_SESSION_IDLE_DEATH_MS; + + // Tolerate both isFinal (wire spec) and snake_case is_final. + var isFinal = sidecar !== null && (sidecar.isFinal === true || sidecar.is_final === true); + if (isFinal) { + // Close AFTER the append so the final action is part of the history. + // closeSession persists the buffer update itself. + closeSession(key, 'final'); + } else { + _armIdleTimer(session); + _persistOpenSessions(); + } + } catch (_outerErr) { + // Whole-body safety net -- never throw out of the recorder. + try { + if (typeof console !== 'undefined' && typeof console.debug === 'function') { + console.debug('[FSB MCP Session Recorder]', + _outerErr && _outerErr.message ? _outerErr.message : _outerErr); + } + } catch (_e) { /* ignore */ } + } + } + + // ---- Eviction restore ----------------------------------------------------- + + /** + * Rehydrate open sessions from the storage envelope. Sessions whose + * deadline already passed close (and persist) immediately; live ones + * re-arm for the remaining window. Fire-and-forget at module load; + * exposed underscored so tests can drive the path deterministically. + * + * @returns {Promise} + */ + function _restoreFromBuffer() { + return (async function () { + try { + var envelope = await _readBufferEnvelope(); + var records = envelope.records || {}; + var keys = Object.keys(records); + if (keys.length === 0) return; + var now = _now(); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + var record = records[key]; + if (!record || typeof record !== 'object' || typeof record.sessionId !== 'string') continue; + var session = { + key: key, + sessionId: record.sessionId, + agentId: (typeof record.agentId === 'string') ? record.agentId : '', + tabId: (typeof record.tabId === 'number' && isFinite(record.tabId)) ? record.tabId : null, + task: (typeof record.task === 'string' && record.task.length > 0) ? record.task : 'MCP agent session', + client: (typeof record.client === 'string' && record.client.length > 0) ? record.client : 'unknown', + startTime: (typeof record.startTime === 'number') ? record.startTime : now, + lastActivityAt: (typeof record.lastActivityAt === 'number') ? record.lastActivityAt : now, + deadlineAt: (typeof record.deadlineAt === 'number') ? record.deadlineAt : 0, + lastUrl: (typeof record.lastUrl === 'string') ? record.lastUrl : null, + visualReasons: Array.isArray(record.visualReasons) ? record.visualReasons : [], + actionHistory: Array.isArray(record.actionHistory) ? record.actionHistory : [], + sawActionTool: record.sawActionTool === true + }; + _openSessions.set(key, session); + if (session.deadlineAt <= now) { + closeSession(key, 'expired'); + } else { + _armIdleTimer(session); + } + } + // Sync the envelope with whatever survived the restore pass. + _persistOpenSessions(); + } catch (_e) { /* best-effort */ } + })(); + } + + // ---- Test seams ----------------------------------------------------------- + + function _peekOpenSessions() { + var out = {}; + _openSessions.forEach(function (session, key) { + out[key] = _serializeRecord(session); + }); + return out; + } + + function _resetForTests() { + _openSessions.forEach(function (_session, key) { + _disarmIdleTimer(key); + }); + _openSessions.clear(); + _idleTimers.clear(); + _lastGeneratedSessionTs = 0; + } + + // ---- Registration --------------------------------------------------------- + + var _api = { + recordDispatch: recordDispatch, + redactParams: redactParams, + FSB_MCP_SESSION_BUFFER_KEY: FSB_MCP_SESSION_BUFFER_KEY, + MCP_SESSION_IDLE_DEATH_MS: MCP_SESSION_IDLE_DEATH_MS, + _setStorageShim: _setStorageShim, + _setTimeShim: _setTimeShim, + _peekOpenSessions: _peekOpenSessions, + _resetForTests: _resetForTests, + _restoreFromBuffer: _restoreFromBuffer + }; + + // Service-worker classic-script surface (object-literal registration + // mirroring mcp-metrics-recorder.js). + globalThis.fsbMcpSessionRecorder = _api; + + // Node CommonJS surface for the test harness. + if (typeof module !== 'undefined' && module.exports) { + module.exports = _api; + } + + // Fire-and-forget eviction restore at module load. Async storage read + // resolves AFTER the SW's synchronous startup script completes, so + // automationLogger / createSession / extractAndStoreMemories globals are + // all present by the time any expired session closes. + try { + var _restorePromise = _restoreFromBuffer(); + if (_restorePromise && typeof _restorePromise.catch === 'function') { + _restorePromise.catch(function () { /* best-effort */ }); + } + } catch (_e) { /* best-effort */ } +})(); diff --git a/extension/ws/mcp-tool-dispatcher.js b/extension/ws/mcp-tool-dispatcher.js index 272b755f9..fe4bcf0b5 100644 --- a/extension/ws/mcp-tool-dispatcher.js +++ b/extension/ws/mcp-tool-dispatcher.js @@ -576,6 +576,27 @@ async function dispatchMcpToolRoute({ tool, params = {}, client = null, tab = nu }); } } catch (_e) { /* defence in depth -- never let metrics break dispatch */ } + // Quick 260707-7id -- MCP session recorder sibling hook. SEPARATE + // try/catch statement placed AFTER the metrics block so the Test 9 + // regex spans over fsbMcpMetricsRecorder stay undisturbed. Same + // fire-and-forget contract: NOT awaited, never alters the dispatcher's + // resolved value or thrown error. + try { + if ( + typeof globalThis !== 'undefined' && + globalThis.fsbMcpSessionRecorder && + typeof globalThis.fsbMcpSessionRecorder.recordDispatch === 'function' + ) { + globalThis.fsbMcpSessionRecorder.recordDispatch({ + client: resolveMcpClientLabel(payload), + tool, + requestPayload: payload, + response, + success, + dispatcher_route: 'tool' + }); + } + } catch (_e) { /* defence in depth -- never let session recording break dispatch */ } } } @@ -660,6 +681,29 @@ async function dispatchMcpMessageRoute({ type, payload = {}, client = null, mcpM }); } } catch (_e) { /* defence in depth -- never let metrics break dispatch */ } + // Quick 260707-7id -- MCP session recorder sibling hook (message + // surface). INSIDE the !_mcpMetricsSuppressInner gate: alias-routed + // tools (run_task, read_page, ...) are recorded once by the outer + // dispatchMcpToolRoute, so the suppression flag MUST gate the session + // recorder too or aliased dispatches would be recorded twice. + // Separate sibling try/catch AFTER the metrics block (Test 9 regex + // spans undisturbed); fire-and-forget, NOT awaited, no return. + try { + if ( + typeof globalThis !== 'undefined' && + globalThis.fsbMcpSessionRecorder && + typeof globalThis.fsbMcpSessionRecorder.recordDispatch === 'function' + ) { + globalThis.fsbMcpSessionRecorder.recordDispatch({ + client: resolveMcpClientLabel(payload), + tool: type, + requestPayload: payload, + response, + success, + dispatcher_route: 'message' + }); + } + } catch (_e) { /* defence in depth -- never let session recording break dispatch */ } } } } diff --git a/tests/lattice-provider-bridge-smoke.test.js b/tests/lattice-provider-bridge-smoke.test.js index cf59648ac..b5af6ec7e 100644 --- a/tests/lattice-provider-bridge-smoke.test.js +++ b/tests/lattice-provider-bridge-smoke.test.js @@ -615,8 +615,10 @@ async function loadOffscreenHandlerSource(chromeMock) { // landed handlers). // FINT-13 "actually load the adapter in SW" (commit a3c03e6a) adds 1 mention // (the ai/lattice-runtime-adapter.js importScripts() call) -> 309. + // Quick 260707-7id: 310 mentions (+1 new line for utils/mcp-session-recorder.js + // -- MCP session recorder; comment lines deliberately token-free). const importScriptsCount = (bgSource.match(/importScripts/g) || []).length; - passAssertEqual(importScriptsCount, 309, 'background.js importScripts count = 309 (current head set including Google Cloud, parallel T1 handlers, and the FINT-13 lattice-runtime-adapter load)'); + passAssertEqual(importScriptsCount, 310, 'background.js importScripts count = 310 (current head set + the Quick 260707-7id mcp-session-recorder load)'); // Companion call-site-only count (regex requires open paren): Phase 5 baseline // was 150 actual importScripts() calls; Phase 6 adds 1 -> 151; Phase 8 adds 1 -> 152; // Phase 14 adds 2 (trigger-store + trigger-lifecycle) -> 154; Phase 15 adds 2 @@ -649,8 +651,9 @@ async function loadOffscreenHandlerSource(chromeMock) { // (+14 handler importScripts() calls vs. the pre-review 290 pin). // FINT-13 "actually load the adapter in SW" (commit a3c03e6a) adds 1 call site // (ai/lattice-runtime-adapter.js) -> 305. + // Quick 260707-7id adds 1 call site (utils/mcp-session-recorder.js) -> 306. const importScriptsCallSites = (bgSource.match(/importScripts\(/g) || []).length; - passAssertEqual(importScriptsCallSites, 305, 'background.js importScripts() call sites = 305 (current head set including Google Cloud, parallel T1 handlers, and the FINT-13 lattice-runtime-adapter load)'); + passAssertEqual(importScriptsCallSites, 306, 'background.js importScripts() call sites = 306 (current head set + the Quick 260707-7id mcp-session-recorder load)'); const lineCli = bgLines.findIndex(l => /importScripts\(['"]ai\/cli-parser\.js['"]\)/.test(l)); const lineBridge = bgLines.findIndex(l => /importScripts\(['"]ai\/lattice-provider-bridge\.js['"]\)/.test(l)); diff --git a/tests/mcp-session-recorder.test.js b/tests/mcp-session-recorder.test.js new file mode 100644 index 000000000..c65cf675c --- /dev/null +++ b/tests/mcp-session-recorder.test.js @@ -0,0 +1,623 @@ +/** + * Regression suite for extension/utils/mcp-session-recorder.js + * (quick task 260707-7id -- record MCP agent sessions into the SAME + * logs/history/replay/memory pipeline autopilot runs use). + * + * Covers the 10 locked cases: + * 1. Birth on first sidecar action (keyed agentId+tabId; logSessionStart + * seeds the saveSession empty-logs gate; task = first visualReason; + * sessionId matches the autopilot /^session_\d+$/ format). + * 2. actionHistory accumulation in replay shape {tool, params, result, + * timestamp}, in dispatch order. + * 3. Read-only JOIN by agentId (sidecar-less dispatch appends to the open + * session; unknown agentId creates nothing). + * 4. isFinal close -> saveSession exactly once with mode 'mcp-agent', + * task = first visualReason, final action included, mcpClient set; + * extractAndStoreMemories called with the same sessionId + session. + * Snake_case is_final tolerated. + * 5. 60s idle expiry with sliding re-arm (fake clock/timers -- no real + * waiting; no premature close before the re-armed deadline). + * 6. run_task skipped entirely (automation engine already records it). + * 7. >=1-action persistence gate: pure read-only bursts never birth + * sessions, saveSession never fires. + * 8. Key-targeted redaction: url persists RAW (replay-critical), password + * and nested apiKey values replaced; lazy globalThis.redactForLog + * branch used when the helper is present. + * 9. Recorder never throws (throwing storage + throwing logger shims); + * dispatcher hook sites each wrapped in their own try/catch. + * 10. Source-pin guards: exactly 2 fsbMcpSessionRecorder.recordDispatch + * sites in mcp-tool-dispatcher.js, each using + * resolveMcpClientLabel(payload); the message-route site gated by + * !_mcpMetricsSuppressInner; the original fsbMcpMetricsRecorder + * pattern still matches exactly 2 sites; background.js loads the + * recorder on exactly one line. + * + * Plus the eviction-restore machinery: a v:1 fsbMcpSessionBuffer envelope + * with one expired + one live session restores correctly (_restoreFromBuffer); + * a malformed/wrong-version envelope is treated as empty. + * + * Run: node tests/mcp-session-recorder.test.js + * + * Harness pattern mirrors tests/mcp-dispatcher-client-label.test.js: plain + * Node script, no framework, passed/failed counters, process.exit(0|1). + */ + +'use strict'; + +const path = require('path'); +const fs = require('fs'); + +const RECORDER_PATH = path.resolve(__dirname, '..', 'extension', 'utils', 'mcp-session-recorder.js'); +const DISPATCHER_PATH = path.resolve(__dirname, '..', 'extension', 'ws', 'mcp-tool-dispatcher.js'); +const BACKGROUND_PATH = path.resolve(__dirname, '..', 'extension', 'background.js'); + +const recorder = require(RECORDER_PATH); + +let passed = 0; +let failed = 0; + +function passAssert(cond, msg) { + if (cond) { passed++; console.log(' PASS:', msg); } + else { failed++; console.error(' FAIL:', msg); } +} + +function passAssertEqual(actual, expected, msg) { + passAssert(actual === expected, + msg + ' (expected: ' + JSON.stringify(expected) + ', got: ' + JSON.stringify(actual) + ')'); +} + +async function drainMicrotasks() { + for (let i = 0; i < 10; i++) await Promise.resolve(); + await new Promise(function (r) { setImmediate(r); }); +} + +// --------------------------------------------------------------------------- +// Shim factories +// --------------------------------------------------------------------------- + +function makeLoggerStub(opts) { + opts = opts || {}; + const calls = { logSessionStart: [], logAction: [], saveSession: [] }; + const logs = []; + return { + calls, + logSessionStart(sessionId, task, tabId) { + if (opts.throwing) throw new Error('logger boom'); + calls.logSessionStart.push({ sessionId, task, tabId }); + logs.push({ data: { sessionId } }); + }, + logAction(sessionId, action, result) { + if (opts.throwing) throw new Error('logger boom'); + calls.logAction.push({ sessionId, action, result }); + logs.push({ data: { sessionId } }); + }, + getSessionLogs(sessionId) { + if (opts.throwing) throw new Error('logger boom'); + return logs.filter((l) => l.data && l.data.sessionId === sessionId); + }, + saveSession(sessionId, sessionData) { + if (opts.throwing) throw new Error('logger boom'); + calls.saveSession.push({ sessionId, sessionData }); + return Promise.resolve(true); + } + }; +} + +function makeMemoriesStub() { + const calls = []; + function extractAndStoreMemoriesStub(sessionId, session) { + calls.push({ sessionId, session }); + return Promise.resolve([]); + } + extractAndStoreMemoriesStub.calls = calls; + return extractAndStoreMemoriesStub; +} + +function makeStorageShim() { + const store = {}; + return { + store, + get(keys) { + return Promise.resolve().then(function () { + const ks = Array.isArray(keys) ? keys : [keys]; + const out = {}; + for (const k of ks) { + if (Object.prototype.hasOwnProperty.call(store, k)) out[k] = store[k]; + } + return out; + }); + }, + set(obj) { + return Promise.resolve().then(function () { Object.assign(store, obj); }); + }, + remove(key) { + return Promise.resolve().then(function () { + const ks = Array.isArray(key) ? key : [key]; + for (const k of ks) delete store[k]; + }); + } + }; +} + +function makeThrowingStorageShim() { + return { + get() { throw new Error('storage boom'); }, + set() { throw new Error('storage boom'); }, + remove() { throw new Error('storage boom'); } + }; +} + +function makeTimeShim(startMs) { + let nowMs = startMs; + let nextId = 1; + const timers = new Map(); + const shim = { + now: function () { return nowMs; }, + setTimeout: function (fn, ms) { + const id = nextId++; + timers.set(id, { fireAt: nowMs + ms, fn }); + return id; + }, + clearTimeout: function (id) { timers.delete(id); } + }; + return { + shim, + timers, + advance(ms) { + nowMs += ms; + let fired = true; + while (fired) { + fired = false; + const due = [...timers.entries()] + .filter(([, t]) => t.fireAt <= nowMs) + .sort((a, b) => a[1].fireAt - b[1].fireAt); + if (due.length > 0) { + const [id, t] = due[0]; + timers.delete(id); + t.fn(); + fired = true; + } + } + }, + now: function () { return nowMs; } + }; +} + +// Fresh section state: reset recorder, install fresh stubs, return handles. +function freshSection(startMs) { + recorder._resetForTests(); + const storage = makeStorageShim(); + recorder._setStorageShim(storage); + const time = makeTimeShim(startMs || 1750000000000); + recorder._setTimeShim(time.shim); + const logger = makeLoggerStub(); + globalThis.automationLogger = logger; + const memories = makeMemoriesStub(); + globalThis.extractAndStoreMemories = memories; + return { storage, time, logger, memories }; +} + +// Dispatch-entry builders (exact shape the dispatcher finally blocks emit). +function actionDispatch(o) { + const visualSession = { visualReason: o.visualReason, client: o.client }; + if (o.isFinal !== undefined) visualSession.isFinal = o.isFinal; + if (o.is_final !== undefined) visualSession.is_final = o.is_final; + return { + client: o.client || 'unknown', + tool: o.tool, + requestPayload: { tool: o.tool, params: o.params || {}, agentId: o.agentId, visualSession }, + response: o.response === undefined ? { success: true } : o.response, + success: o.success === undefined ? true : o.success, + dispatcher_route: 'tool' + }; +} + +function readDispatch(o) { + return { + client: o.client || 'unknown', + tool: o.tool, + requestPayload: { tool: o.tool, params: o.params || {}, agentId: o.agentId }, + response: o.response === undefined ? { success: true } : o.response, + success: o.success === undefined ? true : o.success, + dispatcher_route: o.route || 'message' + }; +} + +(async function main() { + + // -- Test 1: birth on first sidecar action -------------------------------- + console.log('--- Test 1: birth on first sidecar action ---'); + { + const { storage, logger } = freshSection(); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-1', tool: 'click', params: { tabId: 42, selector: '#buy' }, + visualReason: 'Book a flight to Berlin', client: 'Claude' + })); + const open = recorder._peekOpenSessions(); + const keys = Object.keys(open); + passAssertEqual(keys.length, 1, 'exactly one open session after birth'); + passAssertEqual(keys[0], 'agent-1::42', 'session keyed agentId::tabId'); + const rec = open['agent-1::42']; + passAssert(/^session_\d+$/.test(rec.sessionId), 'sessionId matches autopilot format session_ (got ' + rec.sessionId + ')'); + passAssertEqual(rec.task, 'Book a flight to Berlin', 'task seeded from first visualReason'); + passAssertEqual(rec.client, 'Claude', 'client label captured from sidecar'); + passAssertEqual(rec.sawActionTool, true, 'sawActionTool set on sidecar action'); + passAssertEqual(logger.calls.logSessionStart.length, 1, 'logSessionStart called once (seeds saveSession empty-logs gate)'); + passAssertEqual(logger.calls.logSessionStart[0].sessionId, rec.sessionId, 'logSessionStart got the session id'); + passAssertEqual(logger.calls.logSessionStart[0].task, 'Book a flight to Berlin', 'logSessionStart got the task'); + passAssertEqual(logger.calls.logSessionStart[0].tabId, 42, 'logSessionStart got the numeric tabId'); + await drainMicrotasks(); + const envelope = storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY]; + passAssert(envelope && envelope.v === 1, 'open-session buffer persisted with versioned envelope v=1'); + passAssert(envelope && envelope.records && envelope.records['agent-1::42'] && + envelope.records['agent-1::42'].sessionId === rec.sessionId, + 'persisted envelope carries the open session record'); + } + + // -- Test 2: actionHistory accumulation ------------------------------------ + console.log('\n--- Test 2: actionHistory accumulation in replay shape ---'); + { + const { logger } = freshSection(); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-1', tool: 'click', params: { tabId: 42, selector: '#compose' }, + visualReason: 'Compose an email', client: 'Claude' + })); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-1', tool: 'type_text', params: { tabId: 42, selector: '#to', text: 'a@b.c' }, + visualReason: 'Compose an email', client: 'Claude' + })); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-1', tool: 'press_enter', params: { tabId: 42 }, + visualReason: 'Compose an email', client: 'Claude' + })); + const rec = recorder._peekOpenSessions()['agent-1::42']; + passAssertEqual(rec.actionHistory.length, 3, 'three dispatches -> three actionHistory entries'); + passAssertEqual(rec.actionHistory[0].tool, 'click', 'entry 1 tool in order'); + passAssertEqual(rec.actionHistory[1].tool, 'type_text', 'entry 2 tool in order'); + passAssertEqual(rec.actionHistory[2].tool, 'press_enter', 'entry 3 tool in order'); + const shapesOk = rec.actionHistory.every(function (a) { + return a && typeof a.tool === 'string' && a.params && typeof a.params === 'object' && + 'result' in a && typeof a.timestamp === 'number'; + }); + passAssert(shapesOk, 'every entry is {tool, params, result, timestamp} (replay shape)'); + passAssertEqual(rec.actionHistory[1].params.text, 'a@b.c', 'replay-critical text param persists raw'); + passAssertEqual(logger.calls.logAction.length, 3, 'logAction emitted per dispatch (session-bound logs)'); + } + + // -- Test 3: read-only JOIN by agentId -------------------------------------- + console.log('\n--- Test 3: read-only JOIN by agentId ---'); + { + const { logger } = freshSection(); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-1', tool: 'navigate', params: { tabId: 42, url: 'https://example.com/inbox' }, + visualReason: 'Open the inbox', client: 'Claude' + })); + recorder.recordDispatch(readDispatch({ agentId: 'agent-1', tool: 'mcp:read-page' })); + const rec = recorder._peekOpenSessions()['agent-1::42']; + passAssertEqual(rec.actionHistory.length, 2, 'sidecar-less dispatch with same agentId JOINS the open session'); + passAssertEqual(rec.actionHistory[1].tool, 'mcp:read-page', 'joined entry appended in order'); + passAssertEqual(rec.lastUrl, 'https://example.com/inbox', 'successful navigate sets lastUrl (memory domain fallback)'); + recorder.recordDispatch(readDispatch({ agentId: 'agent-UNKNOWN', tool: 'mcp:get-tabs' })); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 1, + 'sidecar-less dispatch with unknown agentId creates NO session'); + passAssertEqual(logger.calls.saveSession.length, 0, 'no close happened during joins'); + } + + // -- Test 4: isFinal close -> history + memory pipeline --------------------- + console.log('\n--- Test 4: isFinal close fires saveSession + extractAndStoreMemories ---'); + { + const { logger, memories } = freshSection(); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-1', tool: 'navigate', params: { tabId: 7, url: 'https://example.com/report' }, + visualReason: 'Compose the weekly report', client: 'Claude' + })); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-1', tool: 'type_text', params: { tabId: 7, selector: '#body', text: 'hello' }, + visualReason: 'Compose the weekly report', client: 'Claude' + })); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-1', tool: 'click', params: { tabId: 7, selector: '#send' }, + visualReason: 'Send the report', client: 'Claude', isFinal: true + })); + passAssertEqual(logger.calls.saveSession.length, 1, 'saveSession invoked exactly once on isFinal'); + const saved = logger.calls.saveSession[0]; + passAssert(/^session_\d+$/.test(saved.sessionId), 'saveSession got the session id'); + passAssertEqual(saved.sessionData.mode, 'mcp-agent', "session.mode === 'mcp-agent' (locked schema value)"); + passAssertEqual(saved.sessionData.task, 'Compose the weekly report', 'session.task === FIRST visualReason'); + passAssertEqual(saved.sessionData.mcpClient, 'Claude', 'session.mcpClient carries the client label'); + passAssertEqual(saved.sessionData.status, 'completed', "session.status === 'completed'"); + passAssertEqual(saved.sessionData.tabId, 7, 'session.tabId numeric'); + passAssertEqual(saved.sessionData.actionHistory.length, 3, 'final action is part of the history (close AFTER append)'); + passAssertEqual(saved.sessionData.actionHistory[2].tool, 'click', 'last entry is the final action'); + passAssertEqual(saved.sessionData.iterationCount, 3, 'iterationCount = actionHistory length'); + passAssertEqual(saved.sessionData.lastUrl, 'https://example.com/report', 'lastUrl carried onto the session'); + passAssertEqual(memories.calls.length, 1, 'extractAndStoreMemories called once'); + passAssertEqual(memories.calls[0].sessionId, saved.sessionId, 'memory handoff got the same sessionId'); + passAssert(memories.calls[0].session === saved.sessionData, 'memory handoff got the SAME session object'); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, 'session removed from the open map'); + + // Snake_case tolerance: is_final on the very first action closes a + // 1-action session. + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-2', tool: 'click', params: { tabId: 9 }, + visualReason: 'One-shot action', client: 'Codex', is_final: true + })); + passAssertEqual(logger.calls.saveSession.length, 2, 'snake_case is_final also closes (wire-spec tolerance)'); + passAssertEqual(logger.calls.saveSession[1].sessionData.actionHistory.length, 1, + 'one-shot session persisted with its single action'); + passAssertEqual(logger.calls.saveSession[1].sessionData.mcpClient, 'Codex', + 'second session carries its own client label'); + } + + // -- Test 5: 60s idle expiry with sliding re-arm ---------------------------- + console.log('\n--- Test 5: 60s idle expiry (sliding window, fake clock) ---'); + { + const { time, logger } = freshSection(1750000000000); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-3', tool: 'click', params: { tabId: 5 }, + visualReason: 'Sort the inbox', client: 'Claude' + })); + time.advance(30000); // t+30s: inside the window + passAssertEqual(logger.calls.saveSession.length, 0, 'no close before the 60s deadline'); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-3', tool: 'click', params: { tabId: 5 }, + visualReason: 'Sort the inbox', client: 'Claude' + })); // re-arms deadline to t+90s + time.advance(45000); // t+75s: past the ORIGINAL deadline (t+60s) but inside the re-armed one + passAssertEqual(logger.calls.saveSession.length, 0, + 'fresh action re-armed the window -- no premature close at t+75s'); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 1, 'session still open at t+75s'); + time.advance(20000); // t+95s: past the re-armed deadline (t+90s) + passAssertEqual(logger.calls.saveSession.length, 1, 'idle expiry closed and persisted the session'); + passAssertEqual(logger.calls.saveSession[0].sessionData.actionHistory.length, 2, + 'expired session kept both recorded actions'); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, 'expired session removed from the map'); + } + + // -- Test 6: run_task skipped ------------------------------------------------ + console.log('\n--- Test 6: run_task dispatches are never recorded ---'); + { + const { logger } = freshSection(); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-4', tool: 'run_task', params: { tabId: 3, task: 'do things' }, + visualReason: 'Autopilot handoff', client: 'Claude' + })); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, + 'run_task (even with a sidecar) creates no session'); + passAssertEqual(logger.calls.logSessionStart.length, 0, 'run_task seeds no session logs'); + passAssertEqual(logger.calls.saveSession.length, 0, 'run_task persists nothing'); + } + + // -- Test 7: >=1-action persistence gate ------------------------------------- + console.log('\n--- Test 7: pure read-only bursts never create sessions ---'); + { + const { logger } = freshSection(); + recorder.recordDispatch(readDispatch({ agentId: 'agent-ro', tool: 'mcp:get-tabs' })); + recorder.recordDispatch(readDispatch({ agentId: 'agent-ro', tool: 'mcp:read-page' })); + recorder.recordDispatch(readDispatch({ agentId: 'agent-ro', tool: 'read_page', route: 'tool' })); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, + 'read-only burst (agentId, never a sidecar) births no session'); + passAssertEqual(logger.calls.saveSession.length, 0, 'saveSession never called for the burst'); + } + + // -- Test 8: key-targeted redaction ------------------------------------------- + console.log('\n--- Test 8: sensitive-key redaction, replay values raw ---'); + { + const { logger } = freshSection(); + delete globalThis.redactForLog; // exercise the literal-fallback branch + const originalParams = { + url: 'https://example.com/x', + password: 'hunter2', + nested: { apiKey: 'k' }, + tabId: 11 + }; + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-5', tool: 'navigate', params: originalParams, + visualReason: 'Log in', client: 'Claude', isFinal: true + })); + passAssertEqual(logger.calls.saveSession.length, 1, 'redaction session closed and saved'); + const savedHistory = logger.calls.saveSession[0].sessionData.actionHistory; + passAssertEqual(savedHistory[0].params.url, 'https://example.com/x', + 'url persists EXACTLY raw (replay-critical)'); + passAssert(savedHistory[0].params.password !== 'hunter2', 'password value replaced'); + passAssertEqual(savedHistory[0].params.password, '[REDACTED]', + 'literal [REDACTED] used when redactForLog is absent'); + passAssert(savedHistory[0].params.nested.apiKey !== 'k', 'nested apiKey value replaced'); + passAssertEqual(savedHistory[0].params.nested.apiKey, '[REDACTED]', + 'nested sensitive key redacted recursively'); + const historyJson = JSON.stringify(savedHistory); + passAssert(historyJson.indexOf('hunter2') === -1, 'original password absent from stored actionHistory JSON'); + passAssert(historyJson.indexOf('"apiKey":"k"') === -1, 'original apiKey value absent from stored actionHistory JSON'); + passAssertEqual(originalParams.password, 'hunter2', + 'caller params object NOT mutated (deep-clone before redaction)'); + + // Lazy-guard branch: with globalThis.redactForLog present, its shape + // output is used instead of the literal. + globalThis.redactForLog = function (value) { + return { kind: 'shimmed', length: String(value).length }; + }; + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-6', tool: 'type_text', params: { tabId: 12, text: 'ok', password: 'hunter2' }, + visualReason: 'Second login', client: 'Claude', isFinal: true + })); + const shimmed = logger.calls.saveSession[1].sessionData.actionHistory[0].params.password; + passAssert(shimmed && shimmed.kind === 'shimmed' && shimmed.length === 7, + 'globalThis.redactForLog used via lazy guard when available'); + delete globalThis.redactForLog; + } + + // -- Test 9: recorder never throws -------------------------------------------- + console.log('\n--- Test 9: recorder never throws (throwing storage + logger) ---'); + { + recorder._resetForTests(); + recorder._setStorageShim(makeThrowingStorageShim()); + const time = makeTimeShim(1750000000000); + recorder._setTimeShim(time.shim); + globalThis.automationLogger = makeLoggerStub({ throwing: true }); + globalThis.extractAndStoreMemories = makeMemoriesStub(); + let threw = false; + let returned = 'sentinel'; + try { + returned = recorder.recordDispatch(actionDispatch({ + agentId: 'agent-7', tool: 'click', params: { tabId: 1 }, + visualReason: 'Hostile shims', client: 'Claude' + })); + recorder.recordDispatch(actionDispatch({ + agentId: 'agent-7', tool: 'click', params: { tabId: 1 }, + visualReason: 'Hostile shims', client: 'Claude', isFinal: true + })); + recorder.recordDispatch(null); + recorder.recordDispatch(42); + recorder.recordDispatch({}); + recorder.recordDispatch({ tool: 'click', requestPayload: null }); + } catch (_e) { + threw = true; + } + passAssertEqual(threw, false, 'recordDispatch never throws under throwing storage/logger shims'); + passAssertEqual(returned, undefined, 'recordDispatch returns undefined (fire-and-forget contract)'); + + // Dispatcher-side contract: both hook sites wrapped in their OWN try/catch. + const dispatcherSrc = fs.readFileSync(DISPATCHER_PATH, 'utf8'); + const guardCount = (dispatcherSrc.match(/never let session recording break dispatch/g) || []).length; + passAssertEqual(guardCount, 2, 'both dispatcher hook sites carry their own defence-in-depth catch'); + } + + // -- Test 10: source-pin guards ------------------------------------------------- + console.log('\n--- Test 10: source-pin guards (dispatcher hooks + SW load line) ---'); + { + const dispatcherSrc = fs.readFileSync(DISPATCHER_PATH, 'utf8'); + + const sessionSitePattern = /globalThis\.fsbMcpSessionRecorder\.recordDispatch\(\{[\s\S]*?\}\);/g; + const sessionSites = dispatcherSrc.match(sessionSitePattern) || []; + passAssertEqual(sessionSites.length, 2, + 'exactly 2 fsbMcpSessionRecorder.recordDispatch sites in mcp-tool-dispatcher.js'); + for (let i = 0; i < sessionSites.length; i++) { + passAssert(sessionSites[i].includes('resolveMcpClientLabel(payload)'), + 'session-recorder site #' + (i + 1) + ' calls resolveMcpClientLabel(payload)'); + passAssert(!/[\s,]client,\s/.test(sessionSites[i]), + 'session-recorder site #' + (i + 1) + ' does NOT pass a bare `client` arg'); + } + + // Message-route site sits inside the !_mcpMetricsSuppressInner gate: + // substring order tool-route-site < gate < message-route-site. + const gateNeedle = 'if (!_mcpMetricsSuppressInner) {'; + const gateCount = dispatcherSrc.split(gateNeedle).length - 1; + passAssertEqual(gateCount, 1, 'exactly one !_mcpMetricsSuppressInner gate in the dispatcher'); + const gateIdx = dispatcherSrc.indexOf(gateNeedle); + // Match the CALL form (with the opening brace) -- each hook site also + // mentions the same dotted path inside its typeof guard. + const sessionCallNeedle = 'globalThis.fsbMcpSessionRecorder.recordDispatch({'; + const firstSessionIdx = dispatcherSrc.indexOf(sessionCallNeedle); + const secondSessionIdx = dispatcherSrc.indexOf(sessionCallNeedle, firstSessionIdx + 1); + passAssert(firstSessionIdx !== -1 && firstSessionIdx < gateIdx, + 'tool-route session-recorder site appears BEFORE the message-route suppression gate'); + passAssert(secondSessionIdx > gateIdx, + 'message-route session-recorder site appears AFTER (inside) the !_mcpMetricsSuppressInner gate'); + + // The original metrics pins are undisturbed (Test 9 of + // mcp-dispatcher-client-label.test.js must keep matching exactly 2). + const metricsSitePattern = /globalThis\.fsbMcpMetricsRecorder\.recordDispatch\(\{[\s\S]*?\}\);/g; + const metricsSites = dispatcherSrc.match(metricsSitePattern) || []; + passAssertEqual(metricsSites.length, 2, 'fsbMcpMetricsRecorder pattern still matches exactly 2 sites'); + for (let i = 0; i < metricsSites.length; i++) { + passAssert(metricsSites[i].includes('resolveMcpClientLabel(payload)'), + 'metrics site #' + (i + 1) + ' still calls resolveMcpClientLabel(payload)'); + passAssert(!metricsSites[i].includes('fsbMcpSessionRecorder'), + 'metrics site #' + (i + 1) + ' span does NOT swallow the session-recorder call'); + } + + const bgSource = fs.readFileSync(BACKGROUND_PATH, 'utf8'); + const loadLines = bgSource.split('\n').filter(function (line) { + return line.includes("importScripts('utils/mcp-session-recorder.js')"); + }); + passAssertEqual(loadLines.length, 1, 'background.js loads utils/mcp-session-recorder.js on exactly one line'); + } + + // -- Test 11: eviction restore (expired closes, live rehydrates) ---------------- + console.log('\n--- Test 11: eviction restore from the fsbMcpSessionBuffer envelope ---'); + { + const { storage, time, logger } = freshSection(1750000100000); + const T = time.now(); + storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY] = { + v: 1, + records: { + 'agent-x::1': { + sessionId: 'session_100', agentId: 'agent-x', tabId: 1, + task: 'Old task', client: 'Codex', + startTime: T - 120000, lastActivityAt: T - 70000, deadlineAt: T - 10000, + lastUrl: 'https://old.example.com/a', + visualReasons: ['Old task'], + actionHistory: [{ tool: 'click', params: {}, result: { success: true }, timestamp: T - 70000 }], + sawActionTool: true + }, + 'agent-y::2': { + sessionId: 'session_200', agentId: 'agent-y', tabId: 2, + task: 'Live task', client: 'Claude', + startTime: T - 40000, lastActivityAt: T - 30000, deadlineAt: T + 30000, + lastUrl: null, + visualReasons: ['Live task'], + actionHistory: [{ tool: 'click', params: {}, result: { success: true }, timestamp: T - 30000 }], + sawActionTool: true + } + } + }; + await recorder._restoreFromBuffer(); + await drainMicrotasks(); + + passAssertEqual(logger.calls.saveSession.length, 1, 'expired session closed (saveSession called) on restore'); + passAssertEqual(logger.calls.saveSession[0].sessionId, 'session_100', 'the EXPIRED session is the one persisted'); + passAssertEqual(logger.calls.saveSession[0].sessionData.mode, 'mcp-agent', 'restored close keeps mcp-agent mode'); + passAssertEqual(logger.calls.saveSession[0].sessionData.mcpClient, 'Codex', 'restored close keeps the client label'); + passAssertEqual(logger.calls.logSessionStart.length, 1, + 'empty post-eviction log buffer re-seeded so the saveSession gate passes'); + const open = recorder._peekOpenSessions(); + passAssertEqual(Object.keys(open).length, 1, 'live session rehydrated into the map'); + passAssert(open['agent-y::2'] && open['agent-y::2'].sessionId === 'session_200', + 'live session record intact after restore'); + passAssert(time.timers.size >= 1, 'idle timer re-armed for the live session'); + + time.advance(31000); // past the live session's remaining window + passAssertEqual(logger.calls.saveSession.length, 2, 'live session closes when its restored deadline passes'); + passAssertEqual(logger.calls.saveSession[1].sessionId, 'session_200', 'live session persisted on expiry'); + await drainMicrotasks(); + passAssertEqual(storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY], undefined, + 'buffer storage key REMOVED once no open sessions remain'); + } + + // -- Test 12: malformed / wrong-version envelope treated as empty ---------------- + console.log('\n--- Test 12: malformed envelope collapses to canonical empty ---'); + { + const { storage, logger } = freshSection(); + storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY] = { + v: 99, + records: { + 'agent-z::1': { + sessionId: 'session_300', agentId: 'agent-z', tabId: 1, + task: 'Wrong version', client: 'Claude', + startTime: 1, lastActivityAt: 1, deadlineAt: 1, + visualReasons: [], actionHistory: [{ tool: 'click', params: {}, result: { success: true }, timestamp: 1 }], + sawActionTool: true + } + } + }; + await recorder._restoreFromBuffer(); + await drainMicrotasks(); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, + 'wrong-version envelope restores nothing'); + passAssertEqual(logger.calls.saveSession.length, 0, 'wrong-version envelope persists nothing'); + + storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY] = 'not-an-object'; + await recorder._restoreFromBuffer(); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, + 'malformed (non-object) envelope restores nothing'); + } + + // -- Wrap up --------------------------------------------------------------------- + recorder._resetForTests(); + recorder._setTimeShim(null); + console.log('\n=== Results: ' + passed + ' passed, ' + failed + ' failed ==='); + process.exit(failed > 0 ? 1 : 0); +})().catch(function (e) { + console.error('FATAL: mcp-session-recorder test harness threw:', e && e.stack ? e.stack : e); + process.exit(2); +}); From c54596c3aa577c6c777feaad3948a1a657d23c49 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 7 Jul 2026 06:02:19 -0500 Subject: [PATCH 02/26] docs(quick-260707-7id): Record MCP agent sessions into logs, history, replay, and memory like autopilot runs --- .planning/STATE.md | 3 +- .../260707-7id-PLAN.md | 272 ++++++++++++++++++ .../260707-7id-SUMMARY.md | 130 +++++++++ 3 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 .planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-PLAN.md create mode 100644 .planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-SUMMARY.md diff --git a/.planning/STATE.md b/.planning/STATE.md index 4ac30ba26..030108b47 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -33,7 +33,7 @@ See: .planning/milestones/v1.1.0-ROADMAP.md, .planning/milestones/v1.1.0-REQUIRE Phase: none active Plan: none active Status: v1.1.0 milestone complete, audited, and archived -Last activity: 2026-07-01 - Completed quick task 260701-iz0: Implement this Steam to be T1 ready +Last activity: 2026-07-07 - Completed quick task 260707-7id: Record MCP agent sessions into logs, history, replay, and memory like autopilot runs Progress: [##########] 100% @@ -154,6 +154,7 @@ None active. | # | Description | Date | Commit | Directory | |---|-------------|------|--------|-----------| +| 260707-7id | Record MCP agent sessions into logs, history, replay, and memory like autopilot runs | 2026-07-07 | 721e2826 | [260707-7id-record-mcp-agent-sessions-into-logs-hist](./quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/) | | 260701-e6c | Implement Supabase to be T1 ready | 2026-07-01 | 2619d949 | [260701-e6c-implement-this-supabase-to-be-t1-ready-u](./quick/260701-e6c-implement-this-supabase-to-be-t1-ready-u/) | | 260701-e6d | Implement Microsoft Teams to be T1 ready | 2026-07-01 | working-tree | [260701-e6d-implement-this-microsoft-teams-to-be-t1-](./quick/260701-e6d-implement-this-microsoft-teams-to-be-t1-/) | | 260701-e69 | Implement Steam T1 readiness as blocked-policy terminal coverage | 2026-07-01 | blocked-working-tree | [260701-e69-implement-steam-to-be-t1-ready-using-gsd](./quick/260701-e69-implement-steam-to-be-t1-ready-using-gsd/) | diff --git a/.planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-PLAN.md b/.planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-PLAN.md new file mode 100644 index 000000000..1eab27cfc --- /dev/null +++ b/.planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-PLAN.md @@ -0,0 +1,272 @@ +--- +phase: quick-260707-7id +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - extension/utils/mcp-session-recorder.js + - extension/ws/mcp-tool-dispatcher.js + - extension/background.js + - extension/utils/automation-logger.js + - extension/ui/sidepanel.js + - extension/ui/options.js + - tests/lattice-provider-bridge-smoke.test.js + - tests/mcp-session-recorder.test.js + - package.json +autonomous: true +requirements: [QUICK-260707-7id] + +must_haves: + truths: + - "An MCP agent driving the browser (sidecar action calls, then is_final) produces a saved session in fsbSessionLogs/fsbSessionIndex with mode 'mcp-agent' and task seeded from the first visual_reason" + - "The saved session's actionHistory entries are {tool, params, result, timestamp} so the existing replay engine (background.js loadReplayableSession/executeReplaySequence) replays MCP sessions unmodified" + - "Session close fires the memory handoff (extractAndStoreMemories -> memoryManager.add) and it works without an AI instance" + - "A session left idle for 60s closes and persists by itself (sliding window, mirroring mcp-visual-session-lifecycle semantics)" + - "Open sessions survive MV3 service-worker eviction via a chrome.storage.session versioned envelope" + - "run_task dispatches are never recorded (automation engine already records those runs); pure read-only bursts never create sessions" + - "Recorder failures never alter the dispatcher's resolved value or thrown error" + - "History lists (sidepanel + options) show a source badge: 'MCP · ' for mcp-agent sessions, 'Autopilot' otherwise" + - "Recorded params contain no credentials/secrets (sensitive-key redaction) while replay-critical values (urls, selectors, text) persist raw" + artifacts: + - path: "extension/utils/mcp-session-recorder.js" + provides: "Session assembly keyed agentId+tabId, driven by visualSession sidecar lifecycle; registers globalThis.fsbMcpSessionRecorder" + min_lines: 150 + - path: "tests/mcp-session-recorder.test.js" + provides: "Plain-Node regression suite covering the 10 locked cases + source-pin guards" + min_lines: 150 + key_links: + - from: "extension/ws/mcp-tool-dispatcher.js" + to: "globalThis.fsbMcpSessionRecorder" + via: "sibling hook in BOTH finally blocks (dispatchMcpToolRoute + dispatchMcpMessageRoute)" + pattern: "fsbMcpSessionRecorder\\.recordDispatch" + - from: "extension/utils/mcp-session-recorder.js" + to: "globalThis.automationLogger.saveSession" + via: "direct global call on session close (NEVER chrome.runtime.sendMessage -- in-SW sendMessage does not loop back)" + pattern: "automationLogger.*saveSession" + - from: "extension/utils/mcp-session-recorder.js" + to: "extractAndStoreMemories" + via: "lazy global call on session close, fire-and-forget with .catch" + pattern: "extractAndStoreMemories" + - from: "extension/background.js" + to: "extension/utils/mcp-session-recorder.js" + via: "one new load line placed after utils/mcp-metrics-recorder.js" + pattern: "utils/mcp-session-recorder\\.js" + - from: "extension/utils/automation-logger.js" + to: "extension/ui/sidepanel.js" + via: "indexEntry.mode + indexEntry.mcpClient consumed by loadHistoryList badge" + pattern: "mcpClient" +--- + + +Record MCP agent sessions (agents driving the browser through MCP tools over the WebSocket bridge) into the SAME logs/history/replay/memory pipeline that autopilot runs use today. Extension-side only; ZERO changes under mcp/. + +Purpose: today only automation-engine (autopilot) runs land in fsbSessionLogs/fsbSessionIndex, replay, and memory. MCP-driven work is invisible in history. This closes that gap using the locked design (recorder sibling of the Phase 271 metrics recorder at the two dispatcher choke points). +Output: new extension/utils/mcp-session-recorder.js + dispatcher hooks + badge plumbing + regression test. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-PLAN.md + +Exploration note: graphify-out/graph.json exists — run `graphify query ""` before opening files for orientation. The file:line references in this plan are pre-verified (2026-07-07); reading those exact locations directly is fine. + +Working-tree caution (verified via git status): extension/ui/options.css, extension/ui/options.js, and package.json carry PRE-EXISTING uncommitted local edits, and tests/settings-card-select-clipping.test.js + tests/control-panel-scroll-containment.test.js are untracked local files that the local package.json test chain already references. Do NOT revert or commit those pre-existing edits. Commit rules are in Task 3. + + + + + +1. Dispatcher choke points — extension/ws/mcp-tool-dispatcher.js: + - `dispatchMcpToolRoute({ tool, params, client, tab, payload })` at line 514. Its finally block (560-579) calls, inside its own try/catch, NOT awaited: + `globalThis.fsbMcpMetricsRecorder.recordDispatch({ client: resolveMcpClientLabel(payload), tool, requestPayload: payload, response, success, dispatcher_route: 'tool' })` + - `dispatchMcpMessageRoute({ type, payload, client, mcpMsgId, _mcpMetricsSuppressInner })` at line 582. Its finally block (632-664) makes the same call with `tool: type, dispatcher_route: 'message'`, gated by `if (!_mcpMetricsSuppressInner)` (line 646). The gate comment at 641-645 warns: NEVER `return` from inside a finally. + - `resolveMcpClientLabel(payload)` (line 456) is synchronous, returns a string label, maintains a per-agent cache persisted to chrome.storage.session key fsbAgentClientLabels. + - `run_task` alias at line 73: `run_task: { routeFamily: 'autopilot', messageType: 'mcp:start-automation', handler: handleToolAliasRoute }`. + - Wire payload shape: `{ tool, params, agentId, ownershipToken, connectionId, visualSession?: { visualReason, client, isFinal } }`. The visualSession sidecar exists ONLY on mutating action tools; read-only routes carry agentId but no sidecar. + +2. Source pin (tests/mcp-dispatcher-client-label.test.js Test 9, lines 211-236): + - Pattern `/globalThis\.fsbMcpMetricsRecorder\.recordDispatch\(\{[\s\S]*?\}\);/g` must keep matching EXACTLY 2 sites, each containing `resolveMcpClientLabel(payload)` and no bare `client,` arg. The pattern is fsbMcpMetricsRecorder-specific, so `globalThis.fsbMcpSessionRecorder.recordDispatch({...});` calls placed OUTSIDE those matched spans (separate sibling try/catch statements) do not disturb it. + +3. automation-logger — extension/utils/automation-logger.js (global: `globalThis.automationLogger` set at line 990): + - `async saveSession(sessionId, sessionData)` at 703. GATE at 709: returns false when `getSessionLogs(sessionId)` is empty — the recorder MUST emit session-bound logs before calling saveSession. + - `getSessionLogs(sessionId)` at 638 filters `log.data?.sessionId === sessionId`. + - Log emitters: `logSessionStart(sessionId, task, tabId)` (225), `logAction(sessionId, action, result)` (239) — both stamp data.sessionId. + - saveSession NEW mode (765-799) builds the stored session from sessionData; `task` comes from `buildPersistedSessionMetadata` line 167: `sessionData.task || ...`. actionHistory persists `(sessionData.actionHistory||[]).filter(a => a.result?.success).slice(-100).map(a => ({tool, params, result, timestamp}))`. + - indexEntry (807-826) currently has NO mode/client fields. Index capped at 50 (830-834). Key storage: fsbSessionLogs + fsbSessionIndex in chrome.storage.local. + +4. Session schema — extension/ai/session-schema.js: `mode` field at 359-363, type 'string (autopilot|mcp-manual|mcp-agent|dashboard-remote)', default 'autopilot'. `createSession(overrides)` factory at 379. Loaded into the SW at background.js:539 as a classic script, so `createSession` is a SW global at runtime (but NOT yet defined when mcp-session-recorder.js loads at ~line 75 — always lazy-guard with `typeof createSession === 'function'`). + +5. Memory handoff — background.js `extractAndStoreMemories(sessionId, session)` at 659-709: tolerates a missing AI instance (`const ai = sessionAIInstances.get(sessionId); if (ai) {...}` — enrichment skipped), then derives domain from `session.actionHistory[].params?.url` with `session.lastUrl` fallback, then `memoryManager.add(session, { domain })` unconditionally. Existing call convention: `extractAndStoreMemories(sessionId, session).catch(() => {})`. It is a top-level function declaration in background.js, therefore a SW global — lazy-guard `typeof extractAndStoreMemories === 'function'`. + +6. Autopilot session id format — background.js:9121 and 9415: `` const sessionId = `session_${Date.now()}` ``. + +7. storage.session envelope pattern — extension/utils/mcp-task-store.js: key const at 51, `FSB_RUN_TASK_REGISTRY_PAYLOAD_VERSION = 1` at 52; reads return canonical `{ v: 1, records: {} }` on missing/version-mismatch/malformed (64-89); the storage key is REMOVED when records is empty (94). Lazy `globalThis.chrome` access so the file is require()-able in Node tests. + +8. Redaction — extension/utils/redactForLog.js exposes `globalThis.redactForLog(value, hint)` (line 29): SHAPE-ONLY (strings -> {kind,length} or {kind:'url',origin}; objects -> {kind:'object',keys:N}). audit-log.js uses it via a lazy guard (`typeof globalThis.redactForLog === 'function'`, audit-log.js:68-71). + +9. Metrics recorder template — extension/utils/mcp-metrics-recorder.js: IIFE, registers `globalThis.fsbMcpMetricsRecorder = {...}` at tail; test seam `_setStorageShim(shim)`; `_resolveStorage()` lazily picks chrome.storage.local (170-175); `_withRecordLock` serializes writes (226); recordDispatch at 261 never throws. + +10. Loading — background.js:65 `try { importScripts('ws/mcp-tool-dispatcher.js'); } catch ...`; line 74 same for utils/mcp-metrics-recorder.js (comments at 66-73 document the load-order contract). TRIPWIRE: tests/lattice-provider-bridge-smoke.test.js:568+ pins `grep -c "importScripts"` on background.js as a running tally with per-phase provenance comments (568-584+); every new token mention (code OR comment) must bump the pinned count. + +11. Visual lifecycle — extension/utils/mcp-visual-session-lifecycle.js:79 `MCP_VISUAL_LIFECYCLE_DEATH_MS = 60000`, sliding re-arm on every action call. The recorder mirrors the 60s sliding-window semantics with its OWN idle timer; do not couple to that module. + +12. UI list surfaces: + - extension/ui/sidepanel.js `loadHistoryList()` at 3430; meta spans built at 3453-3458 inside `'
'`; `escapeHtml` available. + - extension/ui/options.js `loadSessionList()` at 2767; meta spans at 2791-2795 inside `session-item-meta`; `escapeHtml` available. FILE HAS PRE-EXISTING LOCAL EDITS. + +13. Test harness template — tests/mcp-dispatcher-client-label.test.js: fs.readFileSync of extension sources, chrome shim install (installChromeShim, line 263), drainMicrotasks (303), passAssert/passAssertEqual with passed/failed counters, `--- Test N: ---` section logs, process.exit(0|1). npm test chain lives in package.json line 17; it runs `node tests/mcp-dispatcher-client-label.test.js` right after `node tests/mcp-metrics-recorder.test.js`. + + + + + + Task 1: Create mcp-session-recorder.js and hook it into the dispatcher and SW load order + extension/utils/mcp-session-recorder.js, extension/ws/mcp-tool-dispatcher.js, extension/background.js, tests/lattice-provider-bridge-smoke.test.js + +**A. Create extension/utils/mcp-session-recorder.js** (new file, IIFE + 'use strict', lazy `globalThis.chrome` access everywhere so the file loads under plain Node require() like mcp-task-store.js). Registers `globalThis.fsbMcpSessionRecorder` at the tail (object-literal registration mirroring mcp-metrics-recorder.js). + +Public API: `recordDispatch(entry)` where entry has the exact shape the metrics recorder receives: `{ client, tool, requestPayload, response, success, dispatcher_route }` (see interfaces item 1). Entire body wrapped in try/catch — it must NEVER throw and never return a meaningful value (fire-and-forget contract). + +Internal state: an in-memory Map of open sessions keyed `agentId + '::' + tabId` (tabId from `requestPayload.params?.tabId`, `'none'` when absent). Each record: `{ sessionId, agentId, tabId, task, client, startTime, lastActivityAt, deadlineAt, lastUrl, visualReasons: [], actionHistory: [], sawActionTool }`. + +recordDispatch logic, in order: +1. `payload = entry.requestPayload || {}`; `agentId = payload.agentId`. No agentId (non-string/empty) -> ignore silently. +2. Skip `entry.tool === 'run_task'` entirely — it aliases to mcp:start-automation and the automation engine already records that run; recording it here would double sessions. +3. Lazy sweep: close any open session whose `deadlineAt <= now` (reason 'expired') before processing the new call. +4. Sidecar present (`payload.visualSession` is an object) -> this is an action tool call: + - Look up session by agentId+tabId key. If none, BIRTH: sessionId = `` `session_${Date.now()}` `` (autopilot format, interfaces item 6) with a monotonic guard — if the generated timestamp is <= the last one this recorder generated, use last+1 (prevents same-ms collisions between concurrently-born recorder sessions; cross-engine same-ms collision accepted per locked design). task = sidecar `visualReason` (fallback: `String(entry.tool)`); client = sidecar `client` or `entry.client`. Call `automationLogger.logSessionStart(sessionId, task, tabId)` via a lazy `globalThis.automationLogger` guard — this seeds session-bound logs so saveSession's empty-logs gate at automation-logger.js:709 passes. + - Set `sawActionTool = true`. +5. No sidecar (read-only tool route or message route) -> JOIN: find the open session with matching agentId that has the most recent lastActivityAt (any tabId — attribution fallback mirrors resolveMcpClientLabel's per-agent semantics). No open session for that agentId -> ignore (this is what structurally enforces the >=1-action persistence gate: read-only calls never birth sessions). +6. Append to actionHistory: `{ tool: entry.tool, params: redactParams(payload.params), result: entry.response, timestamp: Date.now() }`; cap the in-memory array at 100 (drop oldest — matches the saveSession persistence cap). If `entry.tool === 'navigate'` and `payload.params?.url` and `entry.success` -> `session.lastUrl = payload.params.url` (feeds extractAndStoreMemories' domain fallback). Push visualReason into visualReasons when new. Call `automationLogger.logAction(sessionId, { tool: entry.tool, params: }, entry.response)` (lazy guard). +7. Update `lastActivityAt = now`, `deadlineAt = now + 60000` (mirror MCP_VISUAL_LIFECYCLE_DEATH_MS = 60000 sliding semantics, interfaces item 11) and re-arm the session's idle timer via injectable timer functions (see test seams below): on fire -> closeSession(key, 'expired'). +8. `payload.visualSession.isFinal === true` (also tolerate snake_case `is_final === true` — the wire spec has used both spellings) -> closeSession(key, 'final') AFTER the append, so the final action is part of the history. +9. After every state mutation, persist the open-session buffer (fire-and-forget, writes serialized through a small promise-chain lock like _withRecordLock in mcp-metrics-recorder.js). + +closeSession(key, reason): +- Remove from map, clear its timer, persist the buffer update. +- Guard: `sawActionTool` must be true AND actionHistory.length >= 1, else drop without persisting (defence in depth for the >=1-action gate). +- Build the session object: use lazy `typeof createSession === 'function' ? createSession(overrides) : { ...manual object with the same keys }` (interfaces item 4 — createSession is a SW global at runtime but absent in bare Node and absent at recorder load time). Overrides: `{ id: sessionId, task, status: 'completed', startTime, endTime: Date.now(), tabId: numeric tabId or null, actionHistory, iterationCount: actionHistory.length, lastUrl, mode: 'mcp-agent', mcpClient: client }`. `mode: 'mcp-agent'` is the locked schema value (session-schema.js:359-363). +- Call `globalThis.automationLogger.saveSession(sessionId, session)` (lazy guard; direct global call — NEVER chrome.runtime.sendMessage, which does not loop back inside the SW). +- Memory handoff: `if (typeof extractAndStoreMemories === 'function') extractAndStoreMemories(sessionId, session).catch(function(){})` — verified to tolerate the missing AI instance (background.js:664-679 `if (ai)` guard) and to call memoryManager.add unconditionally. If the global is absent (bare-Node tests), skip silently. +- OPTIONAL (only if trivially reachable): AI title/summary synthesis from the joined visualReasons chain via an existing global provider helper, fire-and-forget, then re-call saveSession with the updated `task` (APPEND mode picks up sessionData.task via automation-logger.js:167). If no single-call global helper exists, SKIP — first-visualReason seeding is the locked default. Do not build provider plumbing for this. + +redactParams(params): deep-clone (JSON round-trip with try/catch fallback to `{}`); recursively replace the VALUE of any key matching `/pass(word)?|secret|token|credential|api[-_]?key|authorization/i` using `globalThis.redactForLog(value)` when available (lazy guard exactly like audit-log.js:68-71) else the literal string `'[REDACTED]'`. Do NOT shape-redact whole params: redactForLog is shape-only (interfaces item 8) and would destroy replay fidelity — locked design point 3 requires the replay engine to consume recorded params unmodified, so replay-critical values (url, selector, text) persist raw. This key-targeted pass is the reconciliation of locked points 3 and 7. Note: `ownershipToken` never enters actionHistory because only `payload.params` is recorded, but the key pattern above also catches a params-level token if one ever appears. + +Eviction survival: persist open sessions to chrome.storage.session under key `fsbMcpSessionBuffer` with the versioned envelope pattern of mcp-task-store.js (interfaces item 7): `{ v: 1, records: { [key]: serializedSession } }`; canonical empty envelope on missing/mismatched/malformed; REMOVE the storage key when records is empty. On module load, fire-and-forget restore: read the envelope; sessions with `deadlineAt <= now` -> closeSession immediately (reason 'expired'); others rehydrate into the map and re-arm timers for the remaining window. + +Test seams (underscored, mirroring mcp-metrics-recorder.js): `_setStorageShim(shim)` (replaces the chrome.storage.session accessor), `_setTimeShim({ now, setTimeout, clearTimeout })` (injectable clock/timers so the 60s expiry test needs no real waiting), `_peekOpenSessions()`, `_resetForTests()`, `_restoreFromBuffer()` (exposed so tests can drive the eviction-restore path deterministically). + +**B. Hook the dispatcher** — extension/ws/mcp-tool-dispatcher.js. In BOTH finally blocks, add a SEPARATE sibling try/catch statement placed AFTER the existing metrics-recorder try/catch (never nested inside it, never between `globalThis.fsbMcpMetricsRecorder.recordDispatch({` and its closing `});` — Test 9's regex spans, interfaces item 2): +- In `dispatchMcpToolRoute`'s finally (after line 578's closing `catch`): guard `typeof globalThis !== 'undefined' && globalThis.fsbMcpSessionRecorder && typeof globalThis.fsbMcpSessionRecorder.recordDispatch === 'function'`, then call NOT awaited: `globalThis.fsbMcpSessionRecorder.recordDispatch({ client: resolveMcpClientLabel(payload), tool, requestPayload: payload, response, success, dispatcher_route: 'tool' });` wrapped in `try { ... } catch (_e) { /* defence in depth -- never let session recording break dispatch */ }`. +- In `dispatchMcpMessageRoute`'s finally: the same sibling block INSIDE the existing `if (!_mcpMetricsSuppressInner) { ... }` body (after the metrics try/catch, before the `if` closes) with `tool: type, dispatcher_route: 'message'` — the alias suppression flag MUST gate the session recorder too, or alias-routed tools (run_task, read_page, ...) would be recorded twice. Do NOT add any `return` inside the finally blocks (comment at 641-645). +- Use `client: resolveMcpClientLabel(payload)` in both new calls; never a bare `client,` (Test 9 hygiene, and the recorder needs the resolved string label). + +**C. Wire the SW load** — extension/background.js: add EXACTLY ONE new line directly after line 74 (the utils/mcp-metrics-recorder.js load): `try { importScripts('utils/mcp-session-recorder.js'); } catch (e) { console.error('[FSB] Failed to load mcp-session-recorder.js:', e.message); }`. If you add an explanatory comment line, it MUST NOT contain the token "importScripts" (source-pin tripwire: even comment mentions are counted). Load order requirement satisfied by placement: after ws/mcp-tool-dispatcher.js (line 65) and utils/mcp-metrics-recorder.js (line 74). + +**D. Bump the pinned count** — tests/lattice-provider-bridge-smoke.test.js: the running-tally block starting at line 568 documents the expected `grep -c "importScripts"` count for background.js per phase. Run `grep -c "importScripts" extension/background.js` after edit C, find the current expected-count assertion below the comment block (below line 584), set it to the new count (+1 if you added only the single code line with no token-bearing comment), and append one provenance comment line following the existing convention, e.g. `// Quick 260707-7id: mentions (+1 new line for utils/mcp-session-recorder.js -- MCP session recorder).` + + + node -e "require('./extension/utils/mcp-session-recorder.js'); const r = globalThis.fsbMcpSessionRecorder; if (!r || typeof r.recordDispatch !== 'function') { console.error('recorder global missing'); process.exit(1); } console.log('recorder loads under Node and registers global');" && node tests/mcp-dispatcher-client-label.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-tool-routing-contract.test.js + + Recorder file exists, loads under bare Node, and registers globalThis.fsbMcpSessionRecorder. Both dispatcher finally blocks carry the sibling session-recorder hook (message route inside the !_mcpMetricsSuppressInner gate). background.js loads the recorder after the metrics recorder. Test 9 pin (exactly 2 fsbMcpMetricsRecorder sites) and the lattice importScripts tally both still pass. + + + + Task 2: Carry mode + client through saveSession/index and render source badges in both history lists + extension/utils/automation-logger.js, extension/ui/sidepanel.js, extension/ui/options.js + +**A. extension/utils/automation-logger.js — persist mode + client:** +- NEW mode session object (the literal at 765-799): add `mode: sessionData.mode || 'autopilot',` and `mcpClient: sessionData.mcpClient || null,` (place near `tabId`). +- APPEND mode block (717-760): add `existing.mode = sessionData.mode || existing.mode || 'autopilot';` and `existing.mcpClient = sessionData.mcpClient || existing.mcpClient || null;` alongside the other field carries. +- indexEntry (807-826): add `mode: savedSession.mode || 'autopilot',` and `mcpClient: savedSession.mcpClient || null,`. Index entries predating this change simply lack the fields and default to Autopilot in the UI. + +**B. extension/ui/sidepanel.js — loadHistoryList badge (3446-3468):** inside the `history-item-meta` div (before the existing status span at 3457), add one span: when `session.mode === 'mcp-agent'` render `'MCP · ' + escapeHtml(session.mcpClient || 'Agent') + ''`, else `'Autopilot'`. Plain meta-row span, consistent with the sibling spans — NO new CSS files, no redesign, no filter control (locked scope: badge only; note the skipped optional filter in the summary). + +**C. extension/ui/options.js — loadSessionList badge (2787-2812):** same conditional span appended to the `session-item-meta` div (class `session-source-badge` + `mcp` modifier), using the template-literal style of that function. CAUTION: options.js has PRE-EXISTING uncommitted local edits — make surgical edits at this one insertion point only; do not touch or reflow anything else in the file. + + + node -e "const fs=require('fs'); const al=fs.readFileSync('extension/utils/automation-logger.js','utf8'); const sp=fs.readFileSync('extension/ui/sidepanel.js','utf8'); const op=fs.readFileSync('extension/ui/options.js','utf8'); const checks=[[/mcpClient: savedSession\.mcpClient \|\| null/.test(al),'indexEntry.mcpClient'],[/mode: savedSession\.mode \|\| 'autopilot'/.test(al),'indexEntry.mode'],[/history-source-badge/.test(sp),'sidepanel badge'],[/session-source-badge/.test(op),'options badge'],[/MCP · /.test(sp) && /MCP · /.test(op),'MCP · label form']]; let bad=checks.filter(c=>!c[0]); if(bad.length){console.error('missing: '+bad.map(c=>c[1]).join(', ')); process.exit(1);} console.log('badge plumbing present');" && node tests/settings-card-select-clipping.test.js && node tests/control-panel-scroll-containment.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js + + saveSession stores and indexes mode + mcpClient (both NEW and APPEND paths). Sidepanel history items and options session items show 'MCP · ' for mcp-agent sessions and 'Autopilot' otherwise. Pre-existing local edits in options.js remain intact (verify with git diff — only the badge insertion added). The local options/sidepanel pin tests still pass. + + + + Task 3: Regression test suite, npm test registration, full-suite tripwire sweep, commit + tests/mcp-session-recorder.test.js, package.json + +**A. Create tests/mcp-session-recorder.test.js** in the repo's plain-Node no-framework style, mirroring tests/mcp-dispatcher-client-label.test.js (interfaces item 13): fs.readFileSync + require/eval of extension/utils/mcp-session-recorder.js, a chrome shim (storage.session via `_setStorageShim`), stubs installed on globalThis for `automationLogger` (capturing logSessionStart/logAction/saveSession invocations) and `extractAndStoreMemories` (capturing calls, returning a Promise), `_setTimeShim` fake clock, drainMicrotasks helper, passAssert/passAssertEqual with passed/failed counters, `--- Test N: ---` section logs, and `process.exit(failed > 0 ? 1 : 0)`. Use `_resetForTests()` between sections. + +Feed the recorder through its public surface: `globalThis.fsbMcpSessionRecorder.recordDispatch({ client, tool, requestPayload, response, success, dispatcher_route })` with realistic payloads (`{ tool, params, agentId, visualSession: { visualReason, client, isFinal } }`). + +Cover the 10 locked cases: +1. Birth on first sidecar action: session created keyed agentId+tabId; logSessionStart called; task === first visualReason; client label captured; sessionId matches /^session_\d+$/. +2. actionHistory accumulation: three dispatches -> three `{tool, params, result, timestamp}` entries in order. +3. Read-only join by agentId: a no-sidecar dispatch with the same agentId joins the open session (entry appended); a no-sidecar dispatch with an UNKNOWN agentId creates nothing. +4. isFinal close: dispatch with `visualSession.isFinal: true` -> saveSession invoked exactly once with (sessionId, session) where session.mode === 'mcp-agent', session.task === first visualReason, session.actionHistory includes the final action, session.mcpClient set; extractAndStoreMemories stub called with the same sessionId+session. +5. 60s idle expiry: advance the fake clock past deadlineAt (and/or fire the shimmed timer) -> session closes, saveSession called; a fresh action within 60s re-arms (no premature close — assert saveSession NOT called before expiry). +6. run_task skipped: a run_task dispatch (even with a sidecar) creates no session and appends nothing. +7. >=1-action persistence gate: pure read-only burst (agentId but never a sidecar) -> no session born, saveSession never called. +8. Redaction: an action with `params: { url: 'https://example.com/x', password: 'hunter2', nested: { apiKey: 'k' } }` -> persisted entry has url EXACTLY 'https://example.com/x' (raw, replay-critical), password and nested.apiKey NOT equal to their originals and not present anywhere in JSON.stringify of the stored actionHistory. +9. Recorder never throws: install throwing automationLogger/storage shims -> recordDispatch returns undefined without throwing (wrap in explicit try/catch and assert no throw). Also assert the dispatcher-side contract via source: both hook sites are wrapped in their own try/catch. +10. Source-pin guards (mirroring Test 9 of mcp-dispatcher-client-label.test.js): read extension/ws/mcp-tool-dispatcher.js and assert (a) exactly 2 matches of /globalThis\.fsbMcpSessionRecorder\.recordDispatch\(\{[\s\S]*?\}\);/g, (b) each contains `resolveMcpClientLabel(payload)`, (c) the message-route site appears inside the `if (!_mcpMetricsSuppressInner)` block (assert the substring order: the gate line appears before the fsbMcpSessionRecorder call within the second finally), (d) the original fsbMcpMetricsRecorder pattern still matches exactly 2 sites; read extension/background.js and assert exactly one line loads 'utils/mcp-session-recorder.js'. + +Also cover eviction restore (part of case 5's machinery): seed the storage shim with a v:1 envelope containing one expired and one live session, call `_restoreFromBuffer()`, assert the expired one closed (saveSession called) and the live one is back in `_peekOpenSessions()`. Assert a malformed envelope (wrong `v`) is treated as empty. + +**B. Register in package.json:** insert `&& node tests/mcp-session-recorder.test.js` into the test chain (line 17) immediately after `node tests/mcp-dispatcher-client-label.test.js`. SURGICAL string insertion only — package.json carries pre-existing local edits that must remain byte-identical elsewhere. + +**C. Full-suite tripwire sweep:** run `npm test` (includes `npm --prefix mcp run build` — fine; mcp/ sources are untouched). Fix ONLY breakages caused by this task's edits (token-count/substring pins over background.js, mcp-tool-dispatcher.js, automation-logger.js, sidepanel.js, options.js — e.g. any test counting tokens in files we touched). Update such pins following each test's documented convention (as done for the lattice tally in Task 1). Do NOT fix pre-existing failures unrelated to this task; report them in the summary instead. + +**D. Commit** (message style: `feat(quick-260707-7id): record MCP agent sessions into logs/history/replay/memory`; NO AI/Co-Authored-By attribution lines): stage ONLY extension/utils/mcp-session-recorder.js, extension/ws/mcp-tool-dispatcher.js, extension/background.js, extension/utils/automation-logger.js, extension/ui/sidepanel.js, tests/lattice-provider-bridge-smoke.test.js, tests/mcp-session-recorder.test.js. LEAVE UNCOMMITTED: extension/ui/options.js and package.json (both carry pre-existing local edits entangled with this task's surgical additions — and the local package.json chain references untracked test files that are not ours to commit; committing it would break CI). Flag both left-out files prominently in the SUMMARY so the user commits them together with their own pending work. + + + node tests/mcp-session-recorder.test.js && npm test + + tests/mcp-session-recorder.test.js passes all 10 locked cases plus eviction-restore, is registered in the package.json chain, full npm test exits 0 (or only pre-existing unrelated failures remain, documented), and the commit contains exactly the seven clean files with options.js + package.json left as flagged local edits. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| MCP client -> WS bridge -> dispatcher | untrusted tool params (may embed credentials typed into pages) cross into persisted session logs | +| chrome.storage.local session logs -> UI | recorded params rendered in history/log viewers | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-q7id-01 | Information Disclosure | mcp-session-recorder redactParams | mitigate | sensitive-key redaction (password/secret/token/credential/apikey/authorization) via lazy globalThis.redactForLog before any persist; test case 8 proves secrets absent from stored actionHistory | +| T-q7id-02 | Denial of Service | dispatcher finally hooks | mitigate | fire-and-forget, double try/catch, no await, no return-in-finally — recorder can never alter or delay dispatcher responses (test case 9 + pin guards) | +| T-q7id-03 | Tampering | fsbMcpSessionBuffer envelope | mitigate | versioned envelope; wrong version/malformed payload collapses to canonical empty (no code paths consume unvalidated shapes); chrome.storage.session is extension-private | +| T-q7id-04 | Spoofing | badge client label | accept | client label is self-reported by the MCP client (same trust level as the existing resolveMcpClientLabel telemetry path); display-only, escapeHtml'd at render | +| T-q7id-SC | Tampering | package installs | accept | no new packages installed by this task (extension-side plain JS only) | + + + +1. `node tests/mcp-session-recorder.test.js` — all 10 locked cases + eviction restore green. +2. `node tests/mcp-dispatcher-client-label.test.js` — Test 9 metrics pins undisturbed (exactly 2 fsbMcpMetricsRecorder sites). +3. `node tests/lattice-provider-bridge-smoke.test.js` — background.js importScripts tally updated correctly. +4. Full `npm test` exits 0 (mcp/ build included; zero mcp/ source diffs — confirm with `git status mcp/`). +5. `git diff extension/ui/options.js` shows ONLY the badge insertion beyond the pre-existing local edits; `git log -1 --stat` shows exactly the seven staged files. + + + +- MCP agent activity (sidecar action -> follow-ups -> is_final or 60s idle) produces a session in fsbSessionLogs/fsbSessionIndex with mode 'mcp-agent', task from first visual_reason, replayable actionHistory, and memory extraction — the same pipeline autopilot uses. +- run_task and pure read-only bursts produce no sessions; aliased tools record once (suppression flag honored). +- Open sessions survive SW eviction via the fsbMcpSessionBuffer versioned envelope. +- History lists show 'MCP · ' vs 'Autopilot' badges in sidepanel and options. +- Zero changes under mcp/; no tool schema changes; dispatcher behavior byte-compatible for callers. +- Full test suite green with the new regression file registered; no unrelated local edits committed. + + + +Create `.planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-SUMMARY.md` when done. Must flag: (a) options.js and package.json left uncommitted with mixed local + task edits, (b) whether the optional AI title synthesis was implemented or skipped, (c) any pre-existing test failures encountered. + diff --git a/.planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-SUMMARY.md b/.planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-SUMMARY.md new file mode 100644 index 000000000..7bed2ad7d --- /dev/null +++ b/.planning/quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/260707-7id-SUMMARY.md @@ -0,0 +1,130 @@ +--- +phase: quick-260707-7id +plan: 01 +subsystem: mcp-bridge / session-history +tags: [mcp, session-recording, replay, memory, history-badges, mv3-eviction] +requires: + - extension/ws/mcp-tool-dispatcher.js dispatch choke points (Phase 271 metrics-recorder pattern) + - extension/utils/automation-logger.js saveSession + fsbSessionLogs/fsbSessionIndex + - background.js extractAndStoreMemories + session-schema createSession +provides: + - globalThis.fsbMcpSessionRecorder (extension/utils/mcp-session-recorder.js) + - fsbSessionIndex entries with mode + mcpClient fields + - MCP/Autopilot source badges in sidepanel + options history lists +affects: + - extension/ws/mcp-tool-dispatcher.js (both finally blocks) + - extension/background.js (SW load order) + - extension/utils/automation-logger.js (NEW/APPEND/index paths) +tech-stack: + added: [] + patterns: + - versioned chrome.storage.session envelope (mcp-task-store.js pattern, key fsbMcpSessionBuffer v1) + - promise-chain write lock (_withRecordLock pattern) + - lazy-global direct calls in SW (never chrome.runtime.sendMessage in-SW) +key-files: + created: + - extension/utils/mcp-session-recorder.js + - tests/mcp-session-recorder.test.js + modified: + - extension/ws/mcp-tool-dispatcher.js + - extension/background.js + - extension/utils/automation-logger.js + - extension/ui/sidepanel.js + - tests/lattice-provider-bridge-smoke.test.js + - extension/ui/options.js (LEFT UNCOMMITTED -- mixed with pre-existing local edits) + - package.json (LEFT UNCOMMITTED -- mixed with pre-existing local edits) +decisions: + - "Optional AI title/summary synthesis SKIPPED (locked default: task seeded from first visualReason; no single-call global provider helper exists)" + - "Single commit at Task 3 with exactly the seven clean files, per the plan's explicit commit protocol (options.js + package.json left as flagged local edits)" + - "closeSession re-seeds one session-bound log entry when getSessionLogs(sessionId) is empty so the saveSession empty-logs gate passes on post-eviction restore closes" +metrics: + duration: "20m 19s" + completed: "2026-07-07T10:59:31Z" + tests: "npm test exit 0; new suite 94 passed / 0 failed" + commit: 721e2826 +--- + +# Quick Task 260707-7id: Record MCP Agent Sessions into Logs/History/Replay/Memory Summary + +**One-liner:** MCP-agent browsing sessions (sidecar action calls through is_final or 60s idle) now land in fsbSessionLogs/fsbSessionIndex with mode 'mcp-agent', replay-shape actionHistory, and memory extraction via a dispatcher-choke-point sibling recorder that survives MV3 SW eviction through a versioned fsbMcpSessionBuffer envelope. + +## What Was Built + +1. **extension/utils/mcp-session-recorder.js** (new, 697 lines) -- IIFE classic script, lazy `globalThis.chrome`, registers `globalThis.fsbMcpSessionRecorder` plus a CommonJS mirror. Sessions keyed `agentId::tabId`; birth on first `visualSession` sidecar action (autopilot-format `session_` id with same-ms monotonic guard, task from first `visualReason`, `logSessionStart` seeds the saveSession log gate); sidecar-less calls JOIN the agent's most recently active session (unknown agentId ignored -- structurally enforces the >=1-action gate); `run_task` skipped entirely; close on `isFinal`/`is_final` or the 60s sliding idle window (own injectable timer + lazy sweep, mirroring mcp-visual-session-lifecycle semantics). Close builds the session via lazy `createSession` (manual same-keys object under Node) with `mode: 'mcp-agent'` + `mcpClient`, then calls `automationLogger.saveSession` and `extractAndStoreMemories` through DIRECT globals (never in-SW sendMessage), both fire-and-forget. Key-targeted redaction (`pass(word)?|secret|token|credential|api[-_]?key|authorization`) via lazy `redactForLog` (literal `[REDACTED]` fallback); url/selector/text persist raw for replay. Open sessions persist to `chrome.storage.session.fsbMcpSessionBuffer` `{v:1, records}` (canonical-empty on mismatch, key removed when empty); module-load restore closes expired sessions and re-arms live ones. + +2. **Dispatcher hooks** -- separate sibling try/catch blocks AFTER the metrics recorder blocks in BOTH finally blocks of `dispatchMcpToolRoute` (`dispatcher_route: 'tool'`) and `dispatchMcpMessageRoute` (`dispatcher_route: 'message'`, INSIDE the `!_mcpMetricsSuppressInner` gate so alias-routed tools record once). Both use `client: resolveMcpClientLabel(payload)`, not awaited, no return-in-finally. Test 9's fsbMcpMetricsRecorder regex spans verified undisturbed (still exactly 2). + +3. **SW load** -- one new line in background.js directly after the mcp-metrics-recorder load (comment lines deliberately token-free for the tally pin). + +4. **automation-logger.js** -- `mode` (default 'autopilot') + `mcpClient` (default null) carried through the NEW session literal, the APPEND field carries, and the indexEntry; pre-existing index entries default to Autopilot in the UI. + +5. **Badges** -- sidepanel `loadHistoryList` and options `loadSessionList` meta rows render `MCP · ` (`history-source-badge mcp` / `session-source-badge mcp`) for mcp-agent sessions, `Autopilot` otherwise; escapeHtml'd, no new CSS files, no filter control (locked scope: badge only -- optional history filter skipped). + +6. **tests/mcp-session-recorder.test.js** (new, 623 lines, 94 assertions) -- the 10 locked cases plus eviction restore (expired closes + live rehydrates + buffer key removal) and malformed/wrong-version envelope collapse; registered in the package.json chain right after mcp-dispatcher-client-label. + +## Verification Results + +- `node tests/mcp-session-recorder.test.js`: **94 passed, 0 failed** (all 10 locked cases + eviction restore + source-pin guards). +- `node tests/mcp-dispatcher-client-label.test.js`: 51 passed, 0 failed (Test 9 metrics pins undisturbed). +- `node tests/lattice-provider-bridge-smoke.test.js`: 110 passed, 0 failed (tally bumped 309 -> 310 mentions, 305 -> 306 call sites, provenance comments added per convention). +- Full `npm test`: **exit 0**. Aggregate across the two dominant reporter formats: 2,817 passed / 0 failed ("Results:" format, 49 suites) + 1,081 passed / 0 failed ("PASS/FAIL" format, 28 suites) = 3,898 assertions, 0 failures; remaining suites use bespoke reporters and all passed (chain is `&&`-fatal). +- `git status mcp/`: clean -- zero changes under mcp/, no tool-schema changes. +- `git log -1 --stat`: exactly the seven staged files (1,387 insertions, 2 deletions -- the two replaced lattice pin lines); no file deletions. +- `git diff extension/ui/options.js`: 5 hunks -- 4 pre-existing (fsbSelect work, lines ~1067-1167) + exactly 1 new (the badge at ~2792). + +## Commit + +- **721e2826** `feat(quick-260707-7id): record MCP agent sessions into logs/history/replay/memory` -- extension/utils/mcp-session-recorder.js, extension/ws/mcp-tool-dispatcher.js, extension/background.js, extension/utils/automation-logger.js, extension/ui/sidepanel.js, tests/lattice-provider-bridge-smoke.test.js, tests/mcp-session-recorder.test.js. + +## FLAGGED: Files Left Uncommitted (user action needed) + +Per the plan's commit protocol, these two files carry BOTH pre-existing local edits AND this task's surgical additions -- commit them together with your pending work: + +1. **extension/ui/options.js** -- pre-existing fsbSelect edits (~lines 1067-1167) + this task's badge insertion in `loadSessionList` (~line 2792, the `session-source-badge` span). +2. **package.json** -- pre-existing test-chain insertion (`settings-card-select-clipping` + `control-panel-scroll-containment`, which reference your untracked local test files) + this task's insertion of `node tests/mcp-session-recorder.test.js` after `node tests/mcp-dispatcher-client-label.test.js`. + +Also untouched and still local: extension/ui/options.css (pre-existing), tests/control-panel-scroll-containment.test.js + tests/settings-card-select-clipping.test.js (pre-existing untracked). + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking environment] Fresh worktree had no installed dependencies** +- **Found during:** Task 1 verification (lattice test: `Cannot find package 'lattice'`), then Task 3 full-suite run (`better-sqlite3`, `ng` missing). +- **Fix:** `npm ci` (lockfile-exact restore, no new packages, no lockfile changes) in four locations: repo root, mcp/, showcase/server/, showcase/angular/. +- **Files modified:** none tracked (node_modules only; package-lock.json byte-identical). + +**2. [Rule 2 - Missing critical functionality] Companion call-site pin also updated** +- **Found during:** Task 1 D. The plan cited only the `grep -c "importScripts"` mention tally, but lattice-provider-bridge-smoke.test.js ALSO pins the `importScripts(` call-site count (305), which my one new line increments. +- **Fix:** updated both assertions (310 mentions / 306 call sites) with provenance comments per the test's documented convention. +- **Commit:** 721e2826. + +**3. [Rule 2 - Missing critical functionality] Post-eviction saveSession gate re-seed** +- **Found during:** Task 1 design of the restore path. After SW eviction the automationLogger in-memory log buffer is empty, so a restored-then-expired session would hit the saveSession empty-logs gate (automation-logger.js:709) and be silently dropped. +- **Fix:** closeSession re-seeds one session-bound log entry via `logSessionStart` when `getSessionLogs(sessionId)` is empty, before calling saveSession. Proven by Test 11 ("empty post-eviction log buffer re-seeded"). +- **Commit:** 721e2826. + +### Notes (not deviations) + +- Plan's line citations for the lattice tally (568-584, older baselines) had drifted in this workspace (assertions now at ~618/652 with baselines 309/305 after parallel head work); handled per the test's own convention. +- Test 10's substring-order assertion initially matched the `typeof` guard instead of the call site during authoring; pinned to the call form (`recordDispatch({`) before the suite was first registered -- never committed broken. +- Orchestrator's generic per-task-commit rule was superseded by the plan's explicit Task 3 commit protocol (single commit, exactly seven files) which plan verification item 5 depends on. + +## Optional AI Title Synthesis: SKIPPED + +Per the plan's locked default: no single-call global provider helper exists in the SW, so `task` is seeded from the first `visualReason` (first-visualReason seeding). No provider plumbing was built. + +## Pre-existing Test Failures + +None. After dependency restore, the full suite is green (exit 0). No unrelated failures were encountered or left behind. + +## Known Stubs + +None -- all recorded fields are wired to real data sources; badges render live index fields. + +## Self-Check: PASSED + +- extension/utils/mcp-session-recorder.js: FOUND (committed) +- tests/mcp-session-recorder.test.js: FOUND (committed) +- Commit 721e2826: FOUND on refinements +- npm test exit 0: CONFIRMED From 054a04fb402d045be0edaf23119654de534e7c5c Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 7 Jul 2026 12:30:03 -0500 Subject: [PATCH 03/26] fix(mcp-session-recorder): record all action tools at the bridge with resolved tab ids and replay-compatible names Review findings on 721e2826 plus the root defect beneath them: - content/cdp-routed action tools (click, type, pressEnter, ...) never reached the dispatcher choke points, so the recorder only ever saw background-routed actions. New MCPBridgeClient._recordMcpSessionAction tap fires from all three _handleExecuteAction branches (main path, open_tab/switch_tab bootstrap, navigate NO_OWNED_TAB recovery) with the ownership-resolved tabId; the dispatcher tool-route session hook is removed (its action traffic all originates in _handleExecuteAction -- keeping it would double-count background actions). The message-route join hook and both metrics hooks are untouched. - finding #1: session keying read only params.tabId while wire params carry snake_case tab_id -- multi-tab agents collapsed onto agentId::none. Keying now prefers the explicit resolved tabId, then params.tab_id, then params.tabId (resolveAgentTabOrError order). - finding #2: stored tool names must satisfy the replay whitelist. Wire verbs are already replay-compatible for content tools; the two real mismatches map at append time (go_back->goBack, go_forward->goForward); non-replayable verbs store verbatim. - resolveMcpClientLabel exported to globalThis so the bridge tap resolves the canonical client label. - test fixtures now simulate the production shapes (bridge recordAction with wire verbs + resolved tabId); pins updated (1 dispatcher session site inside the suppression gate, 1 bridge recordAction site, 3 tap invocations, metrics still exactly 2, resolver within 4500 chars of _handleExecuteAction); new cases for tab_id keying/no-collapse, bootstrap birth, name mapping, and failure/sidecar-less semantics (125 assertions). --- extension/utils/mcp-session-recorder.js | 85 ++++- extension/ws/mcp-bridge-client.js | 38 +++ extension/ws/mcp-tool-dispatcher.js | 41 +-- tests/mcp-session-recorder.test.js | 398 ++++++++++++++++++------ 4 files changed, 438 insertions(+), 124 deletions(-) diff --git a/extension/utils/mcp-session-recorder.js b/extension/utils/mcp-session-recorder.js index 945e55e56..e9ffeddb2 100644 --- a/extension/utils/mcp-session-recorder.js +++ b/extension/utils/mcp-session-recorder.js @@ -2,10 +2,17 @@ * MCP Session Recorder -- assembles MCP-agent browsing sessions and lands * them in the SAME logs/history/replay/memory pipeline autopilot runs use. * - * Quick task 260707-7id. Sibling of the Phase 271 metrics recorder at the two - * dispatcher choke points in extension/ws/mcp-tool-dispatcher.js - * (dispatchMcpToolRoute + dispatchMcpMessageRoute finally blocks). Each - * recordDispatch() call folds one resolved MCP dispatch into an in-memory + * Quick task 260707-7id (+ review-fix follow-up). Two hook points feed it: + * - Bridge-level ACTION tap: MCPBridgeClient._recordMcpSessionAction + * (extension/ws/mcp-bridge-client.js, called from _handleExecuteAction) + * invokes recordAction() for EVERY action tool -- content, cdp, and + * background routes alike -- carrying the ownership-resolved tabId. The + * dispatcher tool route only ever saw background-routed actions, so the + * tap lives where all three routes converge. + * - Dispatcher message-route hook: dispatchMcpMessageRoute's finally block + * in extension/ws/mcp-tool-dispatcher.js calls recordDispatch() so + * read-only/message traffic JOINs open sessions by agentId. + * Each recorded call folds one resolved MCP dispatch into an in-memory * open-session record keyed agentId+tabId; the visualSession sidecar drives * the lifecycle (birth on first action tool, close on isFinal or 60s idle). * @@ -79,6 +86,20 @@ // params-level token if one ever appears. var SENSITIVE_KEY_PATTERN = /pass(word)?|secret|token|credential|api[-_]?key|authorization/i; + // Wire-verb -> legacy replay-name map. The replay engine's whitelist + // (background.js loadReplayableSession replayableTools) speaks the content + // script's camelCase verb namespace, and almost every action tool's wire + // verb already matches it (type_text ships as 'type', press_enter as + // 'pressEnter', ...). The only replay-worthy mismatches are the + // background-routed history tools, whose wire verb is their snake_case + // tool name. Non-replayable verbs (cdp*, dragdrop, siteSearch, tab ops) + // store verbatim -- the replay filter drops them naturally, exactly as it + // does for autopilot sessions. + var MCP_REPLAY_TOOL_NAME_MAP = Object.freeze({ + go_back: 'goBack', + go_forward: 'goForward' + }); + // ---- In-memory state ---------------------------------------------------- // key = agentId + '::' + tabKey; value = open-session record. @@ -300,6 +321,20 @@ return numericTabId === null ? 'none' : String(numericTabId); } + // Tab identity for session keying. Precedence mirrors the boundary + // conventions end to end: an explicitly resolved tabId from the bridge + // action tap wins; otherwise wire params carry snake_case tab_id (the MCP + // schema field, preserved by PARAM_TRANSFORMS -- same order as + // resolveAgentTabOrError in utils/agent-tab-resolver.js); camelCase tabId + // last for back-compat with dispatcher-injected routeParams. + function _resolveNumericTabId(entry, params) { + var explicit = _numericTabId(entry.tabId); + if (explicit !== null) return explicit; + var snake = _numericTabId(params.tab_id); + if (snake !== null) return snake; + return _numericTabId(params.tabId); + } + // JOIN attribution fallback: the open session with matching agentId that // has the most recent lastActivityAt, any tabId -- mirrors // resolveMcpClientLabel's per-agent semantics. @@ -461,8 +496,10 @@ * never returns a meaningful value, never alters the dispatcher's resolved * value or thrown error (threat T-q7id-02). * - * Entry shape mirrors the metrics recorder hook exactly: - * { client, tool, requestPayload, response, success, dispatcher_route } + * Entry shape mirrors the metrics recorder hook exactly, plus an optional + * resolved tab identity supplied by the bridge action tap: + * { client, tool, requestPayload, response, success, dispatcher_route, + * tabId? } * * @param {object} entry - The dispatch context from the dispatcher finally. */ @@ -483,7 +520,7 @@ _sweepExpired(now); var params = (payload.params && typeof payload.params === 'object') ? payload.params : {}; - var numericTabId = _numericTabId(params.tabId); + var numericTabId = _resolveNumericTabId(entry, params); var sidecar = (payload.visualSession && typeof payload.visualSession === 'object') ? payload.visualSession : null; @@ -538,9 +575,12 @@ } // Append the action in replay shape {tool, params, result, timestamp}. + // storedTool applies the replay-name map; the guards below (navigate + // lastUrl) keep matching on the raw wire verb. + var storedTool = MCP_REPLAY_TOOL_NAME_MAP[entry.tool] || entry.tool; var redactedParams = redactParams(params); session.actionHistory.push({ - tool: entry.tool, + tool: storedTool, params: redactedParams, result: entry.response, timestamp: now @@ -562,7 +602,7 @@ var logger = _getAutomationLogger(); if (logger && typeof logger.logAction === 'function') { try { - logger.logAction(session.sessionId, { tool: entry.tool, params: redactedParams }, entry.response); + logger.logAction(session.sessionId, { tool: storedTool, params: redactedParams }, entry.response); } catch (_e) { /* best-effort */ } } @@ -592,6 +632,32 @@ } } + /** + * Bridge-level action entry point (MCPBridgeClient._recordMcpSessionAction). + * Thin adapter onto recordDispatch: same fire-and-forget contract, plus the + * ownership-resolved tabId so session keying never depends on re-parsing + * caller params. + * + * @param {object} input - { client, tool, params, payload, response, + * success, tabId } + */ + function recordAction(input) { + try { + if (!input || typeof input !== 'object') return; + recordDispatch({ + client: input.client, + tool: input.tool, + requestPayload: (input.payload && typeof input.payload === 'object') + ? input.payload + : { tool: input.tool, params: input.params || {}, agentId: input.agentId }, + response: input.response, + success: input.success !== false, + dispatcher_route: 'bridge-action', + tabId: input.tabId + }); + } catch (_e) { /* fire-and-forget */ } + } + // ---- Eviction restore ----------------------------------------------------- /** @@ -665,6 +731,7 @@ var _api = { recordDispatch: recordDispatch, + recordAction: recordAction, redactParams: redactParams, FSB_MCP_SESSION_BUFFER_KEY: FSB_MCP_SESSION_BUFFER_KEY, MCP_SESSION_IDLE_DEATH_MS: MCP_SESSION_IDLE_DEATH_MS, diff --git a/extension/ws/mcp-bridge-client.js b/extension/ws/mcp-bridge-client.js index f71d491d2..c4046afbf 100644 --- a/extension/ws/mcp-bridge-client.js +++ b/extension/ws/mcp-bridge-client.js @@ -701,6 +701,37 @@ class MCPBridgeClient { } } + /** + * Session-recorder tap at the bridge level so content- and cdp-routed + * action tools are recorded too -- the dispatcher choke points only ever + * see background-routed actions (executeFn's default branch sends straight + * to the content script). Fire-and-forget with the metrics-recorder + * discipline: NOT awaited, whole body guarded, never alters the action + * result. Placed ABOVE _handleExecuteAction so the 4500-char source gates + * in tests/action-tool-agent-scoped.test.js and + * tests/ownership-error-codes.test.js keep resolveAgentTabOrError in view. + */ + _recordMcpSessionAction(payload, response, resolvedTabId) { + try { + if (typeof globalThis === 'undefined' || + !globalThis.fsbMcpSessionRecorder || + typeof globalThis.fsbMcpSessionRecorder.recordAction !== 'function') { + return; + } + globalThis.fsbMcpSessionRecorder.recordAction({ + client: (typeof globalThis.resolveMcpClientLabel === 'function') + ? globalThis.resolveMcpClientLabel(payload) + : null, + tool: payload && payload.tool, + params: (payload && payload.params) || {}, + payload: payload, + response: response, + success: !(response && typeof response === 'object' && response.success === false), + tabId: Number.isFinite(resolvedTabId) ? resolvedTabId : null + }); + } catch (_e) { /* never let session recording break the action */ } + } + async _handleExecuteAction(payload) { // Phase 246 D-13: resolver replaces _getActiveTab; legacy:* surfaces fall // through to active-tab via the resolver's first-line branch. @@ -757,6 +788,7 @@ class MCPBridgeClient { if (dispatched && dispatched.success === true) { const resolvedTabId = Number.isFinite(dispatched && dispatched.tabId) ? dispatched.tabId : null; if (resolvedTabId !== null) { + this._recordMcpSessionAction(payload, dispatched, resolvedTabId); await this._recordVisualSessionTickIfPresent(resolvedTabId, agentId, payload); // Phase 257 -- explicit completion. When the caller marks this // bootstrap call as the final action of the task, clear the visual @@ -782,6 +814,7 @@ class MCPBridgeClient { if (dispatched && dispatched.success === true) { const resolvedTabId = Number.isFinite(dispatched && dispatched.tabId) ? dispatched.tabId : null; if (resolvedTabId !== null) { + this._recordMcpSessionAction(payload, dispatched, resolvedTabId); await this._recordVisualSessionTickIfPresent(resolvedTabId, agentId, payload); await this._clearVisualSessionIfFinal(resolvedTabId, agentId, payload); } @@ -845,6 +878,11 @@ class MCPBridgeClient { actionResult = await executeFn(); } + // Session-recorder action tap: all three routes (content, cdp, + // background) converge here with the resolver-approved tab identity. + // Resolver-failure returns above record nothing (nothing executed). + this._recordMcpSessionAction(payload, actionResult, tabId); + // Phase 257 -- explicit completion. When the caller marks this action as // the final action of the task, clear the visual session immediately // rather than waiting for the 60s sliding-window timer. Fires AFTER diff --git a/extension/ws/mcp-tool-dispatcher.js b/extension/ws/mcp-tool-dispatcher.js index fe4bcf0b5..59dafb7dd 100644 --- a/extension/ws/mcp-tool-dispatcher.js +++ b/extension/ws/mcp-tool-dispatcher.js @@ -576,27 +576,11 @@ async function dispatchMcpToolRoute({ tool, params = {}, client = null, tab = nu }); } } catch (_e) { /* defence in depth -- never let metrics break dispatch */ } - // Quick 260707-7id -- MCP session recorder sibling hook. SEPARATE - // try/catch statement placed AFTER the metrics block so the Test 9 - // regex spans over fsbMcpMetricsRecorder stay undisturbed. Same - // fire-and-forget contract: NOT awaited, never alters the dispatcher's - // resolved value or thrown error. - try { - if ( - typeof globalThis !== 'undefined' && - globalThis.fsbMcpSessionRecorder && - typeof globalThis.fsbMcpSessionRecorder.recordDispatch === 'function' - ) { - globalThis.fsbMcpSessionRecorder.recordDispatch({ - client: resolveMcpClientLabel(payload), - tool, - requestPayload: payload, - response, - success, - dispatcher_route: 'tool' - }); - } - } catch (_e) { /* defence in depth -- never let session recording break dispatch */ } + // NOTE (260707-7id review fix): the session-recorder sibling hook that + // lived here moved to MCPBridgeClient._recordMcpSessionAction -- this + // route only ever carries background-routed actions (all originating in + // _handleExecuteAction, which now records them with the resolved tabId), + // so recording here again would double-count every background action. } } @@ -681,11 +665,12 @@ async function dispatchMcpMessageRoute({ type, payload = {}, client = null, mcpM }); } } catch (_e) { /* defence in depth -- never let metrics break dispatch */ } - // Quick 260707-7id -- MCP session recorder sibling hook (message - // surface). INSIDE the !_mcpMetricsSuppressInner gate: alias-routed - // tools (run_task, read_page, ...) are recorded once by the outer - // dispatchMcpToolRoute, so the suppression flag MUST gate the session - // recorder too or aliased dispatches would be recorded twice. + // Quick 260707-7id -- MCP session recorder hook (message surface): + // read-only/message traffic JOINs open sessions by agentId. The + // action-tool sibling lives in MCPBridgeClient._recordMcpSessionAction + // (bridge level, all routes). INSIDE the !_mcpMetricsSuppressInner + // gate so alias-routed dispatches (run_task, read_page, ...) cannot be + // recorded twice should an action alias ever record upstream. // Separate sibling try/catch AFTER the metrics block (Test 9 regex // spans undisturbed); fire-and-forget, NOT awaited, no return. try { @@ -3310,6 +3295,10 @@ if (typeof globalThis !== 'undefined') { // client can clear it on every fresh _ws.onopen (different MCP client // attaching on the same port must not inherit the prior client's label). globalThis.clearLastKnownMcpClientLabel = clearLastKnownMcpClientLabel; + // 260707-7id review fix: the bridge-level session tap + // (MCPBridgeClient._recordMcpSessionAction) resolves the canonical MCP + // client label the same way the dispatcher hooks do. + globalThis.resolveMcpClientLabel = resolveMcpClientLabel; } if (typeof module !== 'undefined' && module.exports) { diff --git a/tests/mcp-session-recorder.test.js b/tests/mcp-session-recorder.test.js index c65cf675c..2a32aa067 100644 --- a/tests/mcp-session-recorder.test.js +++ b/tests/mcp-session-recorder.test.js @@ -1,14 +1,26 @@ /** * Regression suite for extension/utils/mcp-session-recorder.js * (quick task 260707-7id -- record MCP agent sessions into the SAME - * logs/history/replay/memory pipeline autopilot runs use). + * logs/history/replay/memory pipeline autopilot runs use -- plus the + * review-fix follow-up that moved the action tap to the bridge). * - * Covers the 10 locked cases: - * 1. Birth on first sidecar action (keyed agentId+tabId; logSessionStart - * seeds the saveSession empty-logs gate; task = first visualReason; - * sessionId matches the autopilot /^session_\d+$/ format). + * Production entry points simulated by the fixtures: + * - recordAction(): the bridge-level tap + * (MCPBridgeClient._recordMcpSessionAction in mcp-bridge-client.js, + * called from _handleExecuteAction) -- carries the FULL bridge payload + * plus the ownership-resolved tabId. Tool names are WIRE VERBS + * (fsbVerb = _contentVerb || _cdpVerb || name): type_text ships as + * 'type', press_enter as 'pressEnter', go_back as 'go_back', ... + * - recordDispatch(): the dispatcher message-route hook + * (dispatchMcpMessageRoute finally block) -- read-only/message traffic + * that JOINs open sessions by agentId. + * + * Covers the locked cases: + * 1. Birth on first sidecar action (keyed agentId+resolved tabId; + * logSessionStart seeds the saveSession empty-logs gate; task = first + * visualReason; sessionId matches the autopilot /^session_\d+$/ format). * 2. actionHistory accumulation in replay shape {tool, params, result, - * timestamp}, in dispatch order. + * timestamp}, in dispatch order, wire verbs stored replay-compatibly. * 3. Read-only JOIN by agentId (sidecar-less dispatch appends to the open * session; unknown agentId creates nothing). * 4. isFinal close -> saveSession exactly once with mode 'mcp-agent', @@ -24,17 +36,36 @@ * and nested apiKey values replaced; lazy globalThis.redactForLog * branch used when the helper is present. * 9. Recorder never throws (throwing storage + throwing logger shims); - * dispatcher hook sites each wrapped in their own try/catch. - * 10. Source-pin guards: exactly 2 fsbMcpSessionRecorder.recordDispatch - * sites in mcp-tool-dispatcher.js, each using - * resolveMcpClientLabel(payload); the message-route site gated by - * !_mcpMetricsSuppressInner; the original fsbMcpMetricsRecorder - * pattern still matches exactly 2 sites; background.js loads the - * recorder on exactly one line. - * - * Plus the eviction-restore machinery: a v:1 fsbMcpSessionBuffer envelope - * with one expired + one live session restores correctly (_restoreFromBuffer); - * a malformed/wrong-version envelope is treated as empty. + * each hook site wrapped in its own try/catch -- dispatcher guard + * string x1 (message route), bridge guard string x1 (action tap). + * 10. Source-pin guards: exactly 1 fsbMcpSessionRecorder.recordDispatch + * site in mcp-tool-dispatcher.js (message route, inside the + * !_mcpMetricsSuppressInner gate, after dispatchMcpMessageRoute -- the + * tool route stays session-clean or background actions double-count); + * exactly 1 fsbMcpSessionRecorder.recordAction site and exactly 3 + * this._recordMcpSessionAction( invocations in mcp-bridge-client.js; + * the fsbMcpMetricsRecorder pattern still matches exactly 2 sites; + * background.js loads the recorder on exactly one line; + * resolveAgentTabOrError stays within 4500 chars of + * _handleExecuteAction (action-tool-agent-scoped / ownership-error- + * codes source gates). + * 11. Eviction restore: a v:1 fsbMcpSessionBuffer envelope with one + * expired + one live session restores correctly (_restoreFromBuffer). + * 12. Malformed / wrong-version envelope treated as canonical empty. + * 13. Tab identity (review finding #1): params.tab_id (wire snake_case) + * keys sessions -- no agentId::none collapse; explicit resolved tabId + * wins over params; camelCase back-compat; same agent on two tabs + * yields two distinct sessions. + * 14. Bootstrap birth (open_tab/switch_tab): empty params + post-dispatch + * resolved tabId key the session; non-replayable wire verb stored + * verbatim. + * 15. Replay-name map (review finding #2): go_back/go_forward stored as + * goBack/goForward (the whitelist names in background.js + * loadReplayableSession); cdp verbs stored verbatim; whitelist literal + * pinned to contain the mapped names. + * 16. Failure semantics: failed actions still append (result.success === + * false -- the replay filter excludes them downstream); sidecar-less + * recordAction calls JOIN, never birth. * * Run: node tests/mcp-session-recorder.test.js * @@ -49,6 +80,7 @@ const fs = require('fs'); const RECORDER_PATH = path.resolve(__dirname, '..', 'extension', 'utils', 'mcp-session-recorder.js'); const DISPATCHER_PATH = path.resolve(__dirname, '..', 'extension', 'ws', 'mcp-tool-dispatcher.js'); +const BRIDGE_CLIENT_PATH = path.resolve(__dirname, '..', 'extension', 'ws', 'mcp-bridge-client.js'); const BACKGROUND_PATH = path.resolve(__dirname, '..', 'extension', 'background.js'); const recorder = require(RECORDER_PATH); @@ -197,21 +229,32 @@ function freshSection(startMs) { return { storage, time, logger, memories }; } -// Dispatch-entry builders (exact shape the dispatcher finally blocks emit). -function actionDispatch(o) { - const visualSession = { visualReason: o.visualReason, client: o.client }; - if (o.isFinal !== undefined) visualSession.isFinal = o.isFinal; - if (o.is_final !== undefined) visualSession.is_final = o.is_final; +// Bridge-tap entry builder -- the exact shape +// MCPBridgeClient._recordMcpSessionAction emits: the FULL bridge payload +// (wire verb, wire params with snake_case tab_id, agentId, visualSession +// sidecar) plus the ownership-resolved tabId. Tool names here are WIRE +// VERBS: 'type' (type_text), 'pressEnter' (press_enter), 'go_back', ... +function bridgeAction(o) { + const payload = { tool: o.tool, params: o.params || {}, agentId: o.agentId }; + if (!o.noSidecar) { + const visualSession = { visualReason: o.visualReason, client: o.client }; + if (o.isFinal !== undefined) visualSession.isFinal = o.isFinal; + if (o.is_final !== undefined) visualSession.is_final = o.is_final; + payload.visualSession = visualSession; + } return { - client: o.client || 'unknown', + client: o.client || null, tool: o.tool, - requestPayload: { tool: o.tool, params: o.params || {}, agentId: o.agentId, visualSession }, + params: o.params || {}, + payload, response: o.response === undefined ? { success: true } : o.response, success: o.success === undefined ? true : o.success, - dispatcher_route: 'tool' + tabId: o.tabId === undefined ? null : o.tabId }; } +// Dispatcher message-route entry builder (exact shape the +// dispatchMcpMessageRoute finally block emits). function readDispatch(o) { return { client: o.client || 'unknown', @@ -226,17 +269,17 @@ function readDispatch(o) { (async function main() { // -- Test 1: birth on first sidecar action -------------------------------- - console.log('--- Test 1: birth on first sidecar action ---'); + console.log('--- Test 1: birth on first sidecar action (bridge tap) ---'); { const { storage, logger } = freshSection(); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-1', tool: 'click', params: { tabId: 42, selector: '#buy' }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-1', tool: 'click', params: { tab_id: 42, selector: '#buy' }, tabId: 42, visualReason: 'Book a flight to Berlin', client: 'Claude' })); const open = recorder._peekOpenSessions(); const keys = Object.keys(open); passAssertEqual(keys.length, 1, 'exactly one open session after birth'); - passAssertEqual(keys[0], 'agent-1::42', 'session keyed agentId::tabId'); + passAssertEqual(keys[0], 'agent-1::42', 'session keyed agentId::resolvedTabId'); const rec = open['agent-1::42']; passAssert(/^session_\d+$/.test(rec.sessionId), 'sessionId matches autopilot format session_ (got ' + rec.sessionId + ')'); passAssertEqual(rec.task, 'Book a flight to Berlin', 'task seeded from first visualReason'); @@ -255,26 +298,26 @@ function readDispatch(o) { } // -- Test 2: actionHistory accumulation ------------------------------------ - console.log('\n--- Test 2: actionHistory accumulation in replay shape ---'); + console.log('\n--- Test 2: actionHistory accumulation in replay shape (wire verbs) ---'); { const { logger } = freshSection(); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-1', tool: 'click', params: { tabId: 42, selector: '#compose' }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-1', tool: 'click', params: { tab_id: 42, selector: '#compose' }, tabId: 42, visualReason: 'Compose an email', client: 'Claude' })); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-1', tool: 'type_text', params: { tabId: 42, selector: '#to', text: 'a@b.c' }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-1', tool: 'type', params: { tab_id: 42, selector: '#to', text: 'a@b.c' }, tabId: 42, visualReason: 'Compose an email', client: 'Claude' })); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-1', tool: 'press_enter', params: { tabId: 42 }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-1', tool: 'pressEnter', params: { tab_id: 42 }, tabId: 42, visualReason: 'Compose an email', client: 'Claude' })); const rec = recorder._peekOpenSessions()['agent-1::42']; passAssertEqual(rec.actionHistory.length, 3, 'three dispatches -> three actionHistory entries'); - passAssertEqual(rec.actionHistory[0].tool, 'click', 'entry 1 tool in order'); - passAssertEqual(rec.actionHistory[1].tool, 'type_text', 'entry 2 tool in order'); - passAssertEqual(rec.actionHistory[2].tool, 'press_enter', 'entry 3 tool in order'); + passAssertEqual(rec.actionHistory[0].tool, 'click', 'entry 1 tool in order (replay whitelist name)'); + passAssertEqual(rec.actionHistory[1].tool, 'type', 'entry 2 tool in order (type_text wire verb = replay name)'); + passAssertEqual(rec.actionHistory[2].tool, 'pressEnter', 'entry 3 tool in order (press_enter wire verb = replay name)'); const shapesOk = rec.actionHistory.every(function (a) { return a && typeof a.tool === 'string' && a.params && typeof a.params === 'object' && 'result' in a && typeof a.timestamp === 'number'; @@ -288,8 +331,8 @@ function readDispatch(o) { console.log('\n--- Test 3: read-only JOIN by agentId ---'); { const { logger } = freshSection(); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-1', tool: 'navigate', params: { tabId: 42, url: 'https://example.com/inbox' }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-1', tool: 'navigate', params: { tab_id: 42, url: 'https://example.com/inbox' }, tabId: 42, visualReason: 'Open the inbox', client: 'Claude' })); recorder.recordDispatch(readDispatch({ agentId: 'agent-1', tool: 'mcp:read-page' })); @@ -307,16 +350,16 @@ function readDispatch(o) { console.log('\n--- Test 4: isFinal close fires saveSession + extractAndStoreMemories ---'); { const { logger, memories } = freshSection(); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-1', tool: 'navigate', params: { tabId: 7, url: 'https://example.com/report' }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-1', tool: 'navigate', params: { tab_id: 7, url: 'https://example.com/report' }, tabId: 7, visualReason: 'Compose the weekly report', client: 'Claude' })); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-1', tool: 'type_text', params: { tabId: 7, selector: '#body', text: 'hello' }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-1', tool: 'type', params: { tab_id: 7, selector: '#body', text: 'hello' }, tabId: 7, visualReason: 'Compose the weekly report', client: 'Claude' })); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-1', tool: 'click', params: { tabId: 7, selector: '#send' }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-1', tool: 'click', params: { tab_id: 7, selector: '#send' }, tabId: 7, visualReason: 'Send the report', client: 'Claude', isFinal: true })); passAssertEqual(logger.calls.saveSession.length, 1, 'saveSession invoked exactly once on isFinal'); @@ -338,8 +381,8 @@ function readDispatch(o) { // Snake_case tolerance: is_final on the very first action closes a // 1-action session. - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-2', tool: 'click', params: { tabId: 9 }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-2', tool: 'click', params: { tab_id: 9 }, tabId: 9, visualReason: 'One-shot action', client: 'Codex', is_final: true })); passAssertEqual(logger.calls.saveSession.length, 2, 'snake_case is_final also closes (wire-spec tolerance)'); @@ -353,14 +396,14 @@ function readDispatch(o) { console.log('\n--- Test 5: 60s idle expiry (sliding window, fake clock) ---'); { const { time, logger } = freshSection(1750000000000); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-3', tool: 'click', params: { tabId: 5 }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-3', tool: 'click', params: { tab_id: 5 }, tabId: 5, visualReason: 'Sort the inbox', client: 'Claude' })); time.advance(30000); // t+30s: inside the window passAssertEqual(logger.calls.saveSession.length, 0, 'no close before the 60s deadline'); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-3', tool: 'click', params: { tabId: 5 }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-3', tool: 'click', params: { tab_id: 5 }, tabId: 5, visualReason: 'Sort the inbox', client: 'Claude' })); // re-arms deadline to t+90s time.advance(45000); // t+75s: past the ORIGINAL deadline (t+60s) but inside the re-armed one @@ -378,8 +421,8 @@ function readDispatch(o) { console.log('\n--- Test 6: run_task dispatches are never recorded ---'); { const { logger } = freshSection(); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-4', tool: 'run_task', params: { tabId: 3, task: 'do things' }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-4', tool: 'run_task', params: { tab_id: 3, task: 'do things' }, tabId: 3, visualReason: 'Autopilot handoff', client: 'Claude' })); passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, @@ -409,10 +452,10 @@ function readDispatch(o) { url: 'https://example.com/x', password: 'hunter2', nested: { apiKey: 'k' }, - tabId: 11 + tab_id: 11 }; - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-5', tool: 'navigate', params: originalParams, + recorder.recordAction(bridgeAction({ + agentId: 'agent-5', tool: 'navigate', params: originalParams, tabId: 11, visualReason: 'Log in', client: 'Claude', isFinal: true })); passAssertEqual(logger.calls.saveSession.length, 1, 'redaction session closed and saved'); @@ -436,8 +479,8 @@ function readDispatch(o) { globalThis.redactForLog = function (value) { return { kind: 'shimmed', length: String(value).length }; }; - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-6', tool: 'type_text', params: { tabId: 12, text: 'ok', password: 'hunter2' }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-6', tool: 'type', params: { tab_id: 12, text: 'ok', password: 'hunter2' }, tabId: 12, visualReason: 'Second login', client: 'Claude', isFinal: true })); const shimmed = logger.calls.saveSession[1].sessionData.actionHistory[0].params.password; @@ -457,15 +500,20 @@ function readDispatch(o) { globalThis.extractAndStoreMemories = makeMemoriesStub(); let threw = false; let returned = 'sentinel'; + let returnedAction = 'sentinel'; try { - returned = recorder.recordDispatch(actionDispatch({ - agentId: 'agent-7', tool: 'click', params: { tabId: 1 }, + returnedAction = recorder.recordAction(bridgeAction({ + agentId: 'agent-7', tool: 'click', params: { tab_id: 1 }, tabId: 1, visualReason: 'Hostile shims', client: 'Claude' })); - recorder.recordDispatch(actionDispatch({ - agentId: 'agent-7', tool: 'click', params: { tabId: 1 }, + recorder.recordAction(bridgeAction({ + agentId: 'agent-7', tool: 'click', params: { tab_id: 1 }, tabId: 1, visualReason: 'Hostile shims', client: 'Claude', isFinal: true })); + recorder.recordAction(null); + recorder.recordAction(42); + recorder.recordAction({}); + returned = recorder.recordDispatch(readDispatch({ agentId: 'agent-7', tool: 'mcp:read-page' })); recorder.recordDispatch(null); recorder.recordDispatch(42); recorder.recordDispatch({}); @@ -473,46 +521,51 @@ function readDispatch(o) { } catch (_e) { threw = true; } - passAssertEqual(threw, false, 'recordDispatch never throws under throwing storage/logger shims'); + passAssertEqual(threw, false, 'recordAction/recordDispatch never throw under throwing storage/logger shims'); passAssertEqual(returned, undefined, 'recordDispatch returns undefined (fire-and-forget contract)'); + passAssertEqual(returnedAction, undefined, 'recordAction returns undefined (fire-and-forget contract)'); - // Dispatcher-side contract: both hook sites wrapped in their OWN try/catch. + // Hook-site contract: each site wrapped in its OWN try/catch. const dispatcherSrc = fs.readFileSync(DISPATCHER_PATH, 'utf8'); - const guardCount = (dispatcherSrc.match(/never let session recording break dispatch/g) || []).length; - passAssertEqual(guardCount, 2, 'both dispatcher hook sites carry their own defence-in-depth catch'); + const dispatcherGuards = (dispatcherSrc.match(/never let session recording break dispatch/g) || []).length; + passAssertEqual(dispatcherGuards, 1, + 'the dispatcher message-route hook carries its own defence-in-depth catch'); + const bridgeSrc = fs.readFileSync(BRIDGE_CLIENT_PATH, 'utf8'); + const bridgeGuards = (bridgeSrc.match(/never let session recording break the action/g) || []).length; + passAssertEqual(bridgeGuards, 1, + 'the bridge action tap carries its own defence-in-depth catch'); } // -- Test 10: source-pin guards ------------------------------------------------- - console.log('\n--- Test 10: source-pin guards (dispatcher hooks + SW load line) ---'); + console.log('\n--- Test 10: source-pin guards (bridge tap + dispatcher hook + SW load line) ---'); { const dispatcherSrc = fs.readFileSync(DISPATCHER_PATH, 'utf8'); + const bridgeSrc = fs.readFileSync(BRIDGE_CLIENT_PATH, 'utf8'); + // Dispatcher: exactly ONE session-recorder site -- the message route. + // The tool route must stay session-clean: all of its action traffic + // originates in _handleExecuteAction (which records at the bridge tap), + // so a second site here would double-count every background action. const sessionSitePattern = /globalThis\.fsbMcpSessionRecorder\.recordDispatch\(\{[\s\S]*?\}\);/g; const sessionSites = dispatcherSrc.match(sessionSitePattern) || []; - passAssertEqual(sessionSites.length, 2, - 'exactly 2 fsbMcpSessionRecorder.recordDispatch sites in mcp-tool-dispatcher.js'); - for (let i = 0; i < sessionSites.length; i++) { - passAssert(sessionSites[i].includes('resolveMcpClientLabel(payload)'), - 'session-recorder site #' + (i + 1) + ' calls resolveMcpClientLabel(payload)'); - passAssert(!/[\s,]client,\s/.test(sessionSites[i]), - 'session-recorder site #' + (i + 1) + ' does NOT pass a bare `client` arg'); - } + passAssertEqual(sessionSites.length, 1, + 'exactly 1 fsbMcpSessionRecorder.recordDispatch site in mcp-tool-dispatcher.js (message route only)'); + passAssert(sessionSites.length === 1 && sessionSites[0].includes('resolveMcpClientLabel(payload)'), + 'the message-route session site calls resolveMcpClientLabel(payload)'); + + const msgRouteIdx = dispatcherSrc.indexOf('async function dispatchMcpMessageRoute'); + passAssert(msgRouteIdx !== -1, 'dispatchMcpMessageRoute found in the dispatcher'); + const firstSessionMention = dispatcherSrc.indexOf('fsbMcpSessionRecorder.recordDispatch'); + passAssert(firstSessionMention > msgRouteIdx, + 'no session-recorder recordDispatch before dispatchMcpMessageRoute (tool route is session-clean)'); - // Message-route site sits inside the !_mcpMetricsSuppressInner gate: - // substring order tool-route-site < gate < message-route-site. const gateNeedle = 'if (!_mcpMetricsSuppressInner) {'; const gateCount = dispatcherSrc.split(gateNeedle).length - 1; passAssertEqual(gateCount, 1, 'exactly one !_mcpMetricsSuppressInner gate in the dispatcher'); const gateIdx = dispatcherSrc.indexOf(gateNeedle); - // Match the CALL form (with the opening brace) -- each hook site also - // mentions the same dotted path inside its typeof guard. - const sessionCallNeedle = 'globalThis.fsbMcpSessionRecorder.recordDispatch({'; - const firstSessionIdx = dispatcherSrc.indexOf(sessionCallNeedle); - const secondSessionIdx = dispatcherSrc.indexOf(sessionCallNeedle, firstSessionIdx + 1); - passAssert(firstSessionIdx !== -1 && firstSessionIdx < gateIdx, - 'tool-route session-recorder site appears BEFORE the message-route suppression gate'); - passAssert(secondSessionIdx > gateIdx, - 'message-route session-recorder site appears AFTER (inside) the !_mcpMetricsSuppressInner gate'); + const sessionCallIdx = dispatcherSrc.indexOf('globalThis.fsbMcpSessionRecorder.recordDispatch({'); + passAssert(sessionCallIdx > gateIdx, + 'message-route session site sits INSIDE the !_mcpMetricsSuppressInner gate (alias double-count guard)'); // The original metrics pins are undisturbed (Test 9 of // mcp-dispatcher-client-label.test.js must keep matching exactly 2). @@ -523,9 +576,32 @@ function readDispatch(o) { passAssert(metricsSites[i].includes('resolveMcpClientLabel(payload)'), 'metrics site #' + (i + 1) + ' still calls resolveMcpClientLabel(payload)'); passAssert(!metricsSites[i].includes('fsbMcpSessionRecorder'), - 'metrics site #' + (i + 1) + ' span does NOT swallow the session-recorder call'); + 'metrics site #' + (i + 1) + ' span does NOT swallow a session-recorder call'); } + // Bridge tap: exactly ONE recordAction call site, reached from exactly + // THREE _handleExecuteAction branches (main path + open_tab/switch_tab + // bootstrap + navigate NO_OWNED_TAB recovery). + const recordActionSites = bridgeSrc.match(/globalThis\.fsbMcpSessionRecorder\.recordAction\(\{[\s\S]*?\}\);/g) || []; + passAssertEqual(recordActionSites.length, 1, + 'exactly 1 fsbMcpSessionRecorder.recordAction site in mcp-bridge-client.js'); + passAssert(recordActionSites.length === 1 && recordActionSites[0].includes('resolveMcpClientLabel(payload)'), + 'the bridge tap resolves the canonical client label'); + const tapInvocations = (bridgeSrc.match(/this\._recordMcpSessionAction\(/g) || []).length; + passAssertEqual(tapInvocations, 3, + 'the tap fires from exactly 3 _handleExecuteAction branches'); + + // The 4500-char source gates in tests/action-tool-agent-scoped.test.js + // and tests/ownership-error-codes.test.js slice from + // 'async _handleExecuteAction' and require resolveAgentTabOrError inside; + // insertions ahead of the resolver eat that budget. Fail HERE, loudly, + // instead of letting those suites degrade to silent skips. + const eaStart = bridgeSrc.indexOf('async _handleExecuteAction'); + passAssert(eaStart !== -1, '_handleExecuteAction found in mcp-bridge-client.js'); + const resolverOffset = bridgeSrc.indexOf('resolveAgentTabOrError', eaStart) - eaStart; + passAssert(resolverOffset > -1 && resolverOffset < 4500, + 'resolveAgentTabOrError within 4500 chars of _handleExecuteAction (offset ' + resolverOffset + ')'); + const bgSource = fs.readFileSync(BACKGROUND_PATH, 'utf8'); const loadLines = bgSource.split('\n').filter(function (line) { return line.includes("importScripts('utils/mcp-session-recorder.js')"); @@ -612,6 +688,150 @@ function readDispatch(o) { 'malformed (non-object) envelope restores nothing'); } + // -- Test 13: tab identity precedence (review finding #1) ------------------------ + console.log('\n--- Test 13: tab identity -- snake_case tab_id, explicit precedence, no collapse ---'); + { + freshSection(); + // Wire snake_case tab_id keys the session even with NO explicit tabId + // (e.g. a future recordDispatch caller without the resolved id). + recorder.recordAction(bridgeAction({ + agentId: 'agent-8', tool: 'click', params: { tab_id: 42, selector: '#a' }, + visualReason: 'Wire snake_case tab', client: 'Claude' + })); + let keys = Object.keys(recorder._peekOpenSessions()); + passAssertEqual(keys.length, 1, 'snake_case-only dispatch opened one session'); + passAssertEqual(keys[0], 'agent-8::42', 'params.tab_id keys the session -- no agentId::none collapse'); + + // Explicit resolved tabId wins over params. + freshSection(); + recorder.recordAction(bridgeAction({ + agentId: 'agent-8', tool: 'click', params: { tab_id: 99 }, tabId: 42, + visualReason: 'Resolver beats raw params', client: 'Claude' + })); + keys = Object.keys(recorder._peekOpenSessions()); + passAssertEqual(keys[0], 'agent-8::42', 'explicit resolved tabId takes precedence over params.tab_id'); + + // camelCase back-compat (dispatcher-injected routeParams shape). + freshSection(); + recorder.recordAction(bridgeAction({ + agentId: 'agent-8', tool: 'click', params: { tabId: 13 }, + visualReason: 'Legacy camelCase param', client: 'Claude' + })); + keys = Object.keys(recorder._peekOpenSessions()); + passAssertEqual(keys[0], 'agent-8::13', 'camelCase params.tabId still keys the session (back-compat)'); + + // The review's headline scenario: one agent, two tabs -> two sessions. + const { logger } = freshSection(); + recorder.recordAction(bridgeAction({ + agentId: 'agent-m', tool: 'click', params: { tab_id: 1 }, tabId: 1, + visualReason: 'Tab one work', client: 'Claude' + })); + recorder.recordAction(bridgeAction({ + agentId: 'agent-m', tool: 'click', params: { tab_id: 2 }, tabId: 2, + visualReason: 'Tab two work', client: 'Claude' + })); + const open = recorder._peekOpenSessions(); + passAssertEqual(Object.keys(open).length, 2, + 'same agent driving two tabs holds two DISTINCT open sessions (no merge)'); + passAssert(open['agent-m::1'] && open['agent-m::2'], 'sessions keyed agent-m::1 and agent-m::2'); + passAssertEqual(open['agent-m::1'].actionHistory.length, 1, 'tab-1 session holds only its own action'); + passAssertEqual(logger.calls.saveSession.length, 0, 'no spurious close while both tabs are active'); + } + + // -- Test 14: bootstrap birth (open_tab/switch_tab post-dispatch tabId) ---------- + console.log('\n--- Test 14: bootstrap birth with post-dispatch resolved tabId ---'); + { + const { logger } = freshSection(); + recorder.recordAction(bridgeAction({ + agentId: 'agent-b', tool: 'open_tab', params: {}, + visualReason: 'Open a fresh tab for research', client: 'Claude', + response: { success: true, tabId: 7 }, tabId: 7 + })); + const open = recorder._peekOpenSessions(); + const keys = Object.keys(open); + passAssertEqual(keys.length, 1, 'bootstrap action opened one session'); + passAssertEqual(keys[0], 'agent-b::7', + 'empty wire params + post-dispatch tabId key the session (no agentId::none)'); + passAssertEqual(logger.calls.logSessionStart[0].tabId, 7, 'logSessionStart got the post-dispatch tabId'); + passAssertEqual(open['agent-b::7'].actionHistory[0].tool, 'open_tab', + 'non-replayable wire verb stored verbatim (replay filter drops it downstream)'); + } + + // -- Test 15: replay-name map (review finding #2) --------------------------------- + console.log('\n--- Test 15: go_back/go_forward stored as replay whitelist names ---'); + { + const { logger } = freshSection(); + recorder.recordAction(bridgeAction({ + agentId: 'agent-h', tool: 'go_back', params: { tab_id: 4 }, tabId: 4, + visualReason: 'Back to the results list', client: 'Claude' + })); + recorder.recordAction(bridgeAction({ + agentId: 'agent-h', tool: 'go_forward', params: { tab_id: 4 }, tabId: 4, + visualReason: 'Forward again', client: 'Claude' + })); + recorder.recordAction(bridgeAction({ + agentId: 'agent-h', tool: 'cdpClickAt', params: { x: 10, y: 20, tab_id: 4 }, tabId: 4, + visualReason: 'Click the canvas point', client: 'Claude' + })); + recorder.recordAction(bridgeAction({ + agentId: 'agent-h', tool: 'navigate', params: { tab_id: 4, url: 'https://example.com/done' }, tabId: 4, + visualReason: 'Wrap up', client: 'Claude', isFinal: true + })); + passAssertEqual(logger.calls.saveSession.length, 1, 'mapping session closed and saved'); + const tools = logger.calls.saveSession[0].sessionData.actionHistory.map(function (a) { return a.tool; }); + passAssertEqual(tools[0], 'goBack', "wire 'go_back' stored as replay name 'goBack'"); + passAssertEqual(tools[1], 'goForward', "wire 'go_forward' stored as replay name 'goForward'"); + passAssertEqual(tools[2], 'cdpClickAt', 'cdp verb stored verbatim (legitimately non-replayable)'); + passAssertEqual(tools[3], 'navigate', 'already-compatible wire verb stored verbatim'); + passAssertEqual(logger.calls.logAction[0].action.tool, 'goBack', + 'logAction agrees with actionHistory on the mapped name'); + + // Pin the whitelist side of the contract: the mapped names exist in + // background.js loadReplayableSession's set literal, the wire names do + // not (which is exactly why the map is required). + const bgSource = fs.readFileSync(BACKGROUND_PATH, 'utf8'); + const wlMatch = bgSource.match(/replayableTools = new Set\(\[[\s\S]*?\]\)/); + passAssert(!!wlMatch, 'replayableTools set literal found in background.js'); + passAssert(!!wlMatch && wlMatch[0].indexOf("'goBack'") !== -1 && wlMatch[0].indexOf("'goForward'") !== -1, + 'replay whitelist contains the mapped names goBack/goForward'); + passAssert(!!wlMatch && wlMatch[0].indexOf("'go_back'") === -1, + 'replay whitelist does NOT contain wire name go_back (mapping is required)'); + } + + // -- Test 16: failure semantics + sidecar-less action calls ----------------------- + console.log('\n--- Test 16: failed actions append; sidecar-less recordAction joins, never births ---'); + { + const { logger } = freshSection(); + recorder.recordAction(bridgeAction({ + agentId: 'agent-f', tool: 'click', params: { tab_id: 3 }, tabId: 3, + visualReason: 'Try a flaky button', client: 'Claude' + })); + recorder.recordAction(bridgeAction({ + agentId: 'agent-f', tool: 'click', params: { tab_id: 3 }, tabId: 3, + visualReason: 'Try a flaky button', client: 'Claude', + response: { success: false, error: 'element not found' }, success: false + })); + let rec = recorder._peekOpenSessions()['agent-f::3']; + passAssertEqual(rec.actionHistory.length, 2, 'failed action still appended to the history'); + passAssertEqual(rec.actionHistory[1].result.success, false, + 'failure result preserved (replay filter excludes it downstream)'); + + // Sidecar-less action-path call (defensive shape) JOINs the open session. + recorder.recordAction(bridgeAction({ + agentId: 'agent-f', tool: 'get_text', params: { tab_id: 3 }, tabId: 3, noSidecar: true + })); + rec = recorder._peekOpenSessions()['agent-f::3']; + passAssertEqual(rec.actionHistory.length, 3, 'sidecar-less action-path call JOINs the open session'); + + // ...but never births for an agent with no open session. + recorder.recordAction(bridgeAction({ + agentId: 'agent-fresh', tool: 'get_text', params: {}, tabId: 5, noSidecar: true + })); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 1, + 'sidecar-less call for an unknown agent births nothing'); + passAssertEqual(logger.calls.saveSession.length, 0, 'no close fired during Test 16'); + } + // -- Wrap up --------------------------------------------------------------------- recorder._resetForTests(); recorder._setTimeShim(null); From f4992f8fa11508b6d8a56bcc02d9c31d08c66366 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 7 Jul 2026 21:36:11 -0500 Subject: [PATCH 04/26] chore(mcp-session-recorder): wire session-recorder test into npm test script tests/mcp-session-recorder.test.js landed in the prior mcp-session-recorder commits but was never added to the npm test script, so it wasn't running. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index c2f41eb07..a0ba59da0 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "scripts": { "build": "node esbuild.config.js", "build:cfworker": "node_modules/.bin/esbuild node_modules/@cfworker/json-schema/dist/esm/index.js --bundle --format=iife --global-name=CfworkerJsonSchema --platform=browser --target=chrome120 --legal-comments=none --outfile=extension/lib/cfworker-json-schema.min.js", - "test": "node tests/test-overlay-state.js && node tests/cost-tracker-ordering.test.js && node tests/runtime-contracts.test.js && node tests/ai-integration-analytics.test.js && node tests/dashboard-runtime-state.test.js && node tests/task-router.test.js && npm --prefix mcp run build && node tests/mcp-bridge-client-lifecycle.test.js && node tests/mcp-bridge-topology.test.js && node tests/mcp-bridge-background-dispatch.test.js && node tests/mcp-tool-routing-contract.test.js && node tests/mcp-visual-session-contract.test.js && node tests/mcp-restricted-tab.test.js && node tests/mcp-recovery-messaging.test.js && node tests/mcp-in-flight-session-lookup.test.js && node tests/mcp-version-parity.test.js && node tests/mcp-diagnostics-status.test.js && node tests/mcp-install-platforms.test.js && node tests/mcp-setup-guidance.test.js && node tests/turn-result.test.js && node tests/action-history.test.js && node tests/universal-provider-lmstudio.test.js && node tests/config-lmstudio.test.js && node tests/cost-tracker.test.js && node tests/install-identity.test.js && node tests/mcp-pricing.test.js && node tests/mcp-pricing-data-parity.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-dispatcher-client-label.test.js && node tests/mcp-metrics-no-pii-leak.test.js && node tests/telemetry-collector.test.js && node tests/telemetry-collector-strips-internal-fields.test.js && node tests/telemetry-payload-allowlist.test.js && node tests/extension-record-dispatch-serializes.test.js && node tests/server-trust-proxy.test.js && node tests/server-telemetry-salt-rotation.test.js && node tests/server-telemetry-rate-limit.test.js && node tests/server-telemetry-body-cap.test.js && node tests/server-telemetry-batch-cap.test.js && node tests/server-telemetry-allowlist.test.js && node tests/server-telemetry-event-id-dedup.test.js && node tests/server-telemetry-timestamp-tolerance.test.js && node tests/server-telemetry-daily-budget.test.js && node tests/server-telemetry-sec-gpc.test.js && node tests/server-telemetry-optout-forget.test.js && node tests/server-telemetry-housekeeper.test.js && node tests/server-public-stats-headline.test.js && node tests/server-public-stats-series.test.js && node tests/server-public-stats-cache.test.js && node tests/server-public-stats-no-auth.test.js && node tests/server-github-cache.test.js && node tests/server-github-poller.test.js && node tests/fsb-telemetry-service.test.js && node tests/showcase-build-smoke.test.js && node tests/stats-chart-overhaul.test.js && node scripts/verify-store-listing.mjs && node tests/verify-store-listing.test.js && node tests/cumulative-commits-aggregator.test.js && node tests/dashboard-stream-readiness-ping.test.js && node tests/dashboard-stream-pending-intent.test.js && node tests/dashboard-stream-recovery-parity.test.js && node tests/server-ws-backpressure.test.js && node tests/server-ws-phantomstream-relay-compat.test.js && node tests/showcase-privacy-page.test.js && node tests/server-no-ip-leak.test.js && node tests/server-ip-geo.test.js && node tests/server-ip-geo-merge.test.js && node tests/server-ip-geo-scale.test.js && node tests/server-region-aggregation.test.js && node tests/transcript-store.test.js && node tests/hook-pipeline.test.js && node tests/session-schema.test.js && node tests/state-emitter.test.js && node tests/secure-config-credential-vault.test.js && node tests/qr-pairing.test.js && node tests/ws-client-decompress.test.js && node tests/dashboard-task-direct-start.test.js && node tests/phantom-stream-protocol-envelope.test.js && node tests/phantom-stream-differential-parity.test.js && node tests/phantom-stream-remote-control-parity.test.js && node tests/phantom-stream-public-package.test.js && node tests/phantom-stream-exports.test.js && node tests/phantom-stream-content-bundle.test.js && node tests/phantom-stream-capture-adapter.test.js && node tests/phantom-stream-sidechannels.test.js && node tests/phantom-stream-security-masking.test.js && node tests/phantom-stream-dashboard-viewer-bundle.test.js && node tests/phantom-stream-static-viewer.test.js && node tests/phantom-stream-dashboard-parity.test.js && node tests/phantom-stream-dashboard-sidechannels.test.js && node tests/phantom-stream-media-sync.test.js && node tests/phantom-stream-media-wiring.test.js && node tests/dom-stream-perf.test.js && node tests/extension-content-script-files-completeness.test.js && node tests/dom-analysis-viewport-priority.test.js && node tests/redact-for-log.test.js && node tests/diagnostics-ring-buffer.test.js && node tests/agent-sunset-back-end.test.js && node tests/agent-sunset-control-panel.test.js && node tests/agent-sunset-showcase.test.js && node tests/sync-tab-runtime.test.js && node tests/remote-control-rebrand.test.js && node tests/server-accept-language.test.js && node tests/metrics-wireup.test.js && node tests/dashboard-metrics-render.test.js && node tests/stuck-action-repetition.test.js && node tests/goal-progress-tracker.test.js && node tests/model-discovery.test.js && node tests/model-discovery-ui.test.js && node tests/model-combobox-ui.test.js && node tests/model-discovery-runtime.test.js && node tests/overlay-stability-cadence.test.js && node tests/overlay-content-audit.test.js && node tests/meta-cognitive-tracker.test.js && node tests/agent-cap-recommendation.test.js && node tests/agent-cap-storage.test.js && node tests/agent-cap-ui.test.js && node tests/agent-registry.test.js && node tests/selected-tab-state.test.js && node tests/change-report-builder.test.js && node tests/change-report-size-cap.test.js && node tests/change-report-cross-origin.test.js && node tests/change-report-dispatcher.test.js && node tests/change-report-read-tools-excluded.test.js && node tests/change-report-toggle.test.js && node tests/change-report-settings-ui.test.js && node tests/agent-tab-resolver.test.js && node tests/read-tool-tab-resolution.test.js && node tests/open-tab-background-default.test.js && node tests/visual-session-agent-scoped.test.js && node tests/action-tool-agent-scoped.test.js && node tests/recovery-tools-bootstrap.test.js && node tests/legacy-agent-synthesis.test.js && node tests/visual-session-reentry.test.js && node tests/ownership-error-codes.test.js && node tests/multi-agent-regression.test.js && node tests/tool-definitions-parity.test.js && node tests/mcp-numeric-param-coercion.test.js && node tests/visual-session-schema-lock.test.js && node tests/mcp-visual-tick-lifecycle.test.js && node tests/agent-id-threading.test.js && node tests/skill-fsb-spec.test.js && node tests/agent-loop-empty-contents.test.js && node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js && node tests/mcp-philosophy-parity-smoke.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js && node tests/sidepanel-mcpvisualsession-listener.test.js && node tests/sidepanel-tab-scoping-fix-redo-smoke.test.js && node tests/sidepanel-progress-tick-setter-routing.test.js && node tests/sidepanel-background-tab-completion.test.js && node tests/sidepanel-multi-document-fanout.test.js && node tests/trigger-store.test.js && node tests/trigger-lifecycle.test.js && node tests/value-extractor.test.js && node tests/trigger-manager.test.js && node tests/trigger-cap.test.js && node tests/trigger-observe.test.js && node tests/trigger-observe-pulse.test.js && node tests/trigger-refresh-poll.test.js && node tests/trigger-tool-dispatcher.test.js && node tests/trigger-blocking-reporting.test.js && node tests/capability-recipe-schema.test.js && node tests/capability-interpreter.test.js && node tests/recipe-path-guard.test.js && node tests/capability-fetch.test.js && node tests/capability-search-eval.test.js && node tests/capability-mcp-surface.test.js && node tests/capability-router.test.js && node tests/capability-autopilot-parity.test.js && node tests/capability-head-handlers.test.js && node tests/consent-policy-store.test.js && node tests/consent-gate.test.js && node tests/consent-mutation-gate.test.js && node tests/consent-chokepoint.test.js && node tests/audit-log.test.js && node tests/audit-log-no-secret.test.js && node tests/recipe-signature.test.js && node tests/recipe-signature-interpreter-hook.test.js && node tests/service-denylist.test.js && node tests/classification-gate.test.js && node tests/provenance-scaffold.test.js && node tests/upload-file-route.test.js && node tests/upload-path-denylist.test.js && node tests/consent-audit-settings-ui.test.js && node tests/agent-loop-iterator-guard.test.js && node tests/network-capture.test.js && node tests/network-capture-consent.test.js && node tests/network-capture-redaction.test.js && node tests/recipe-synthesizer.test.js && node tests/learned-recipe-store.test.js && node tests/learned-search-add.test.js && node tests/learned-t2-outranking.test.js && node tests/learned-promote-after-replay.test.js && node tests/discovery-seeds-load.test.js && node tests/seed-resolve-t2.test.js && node tests/recipe-learn-pending-affordance.test.js && node tests/learned-local-provenance-exempt.test.js && node tests/provider-parity.test.js && node tests/recipe-schema-lock.test.js && node tests/capability-rot-detector.test.js && node tests/relearn-coalescing.test.js && node tests/rot-recurrence-classify.test.js && node tests/app-degraded-surfacing.test.js && node tests/relearn-router-wiring.test.js && node tests/import-extraction.test.js && node tests/import-forbidden-prescan.test.js && node tests/import-classify-gate-call.test.js && node tests/catalog-crosscheck.test.js && node tests/no-dead-entry.test.js && node tests/catalog-inline-shape.test.js && node tests/head-handler-cap.test.js && node tests/head-handler-upgrade.test.js && node tests/confluence-t1-handler.test.js && node tests/verify-origin-classification.test.js && node tests/guarded-write-failclosed.test.js && node tests/breadth-search-return.test.js && node tests/breadth-batch-gate.test.js && node tests/backing-status-annotation.test.js && node tests/t1-port-contract.test.js && node tests/t1-port-contract-gate.test.js && node tests/dom-only-default.test.js && node tests/sensitive-write-import-gate.test.js && node tests/coverage-report.test.js && node tests/payment-op-guard.test.js && node tests/vendor-augment-preserved.test.js && node tests/no-duplicate-stem.test.js && node tests/full-corpus-screen.test.js && node tests/full-corpus-scale.test.js && node tests/keyboard-attach-robustness.test.js && node --import tsx tests/no-orphan-descriptor.test.js", + "test": "node tests/test-overlay-state.js && node tests/cost-tracker-ordering.test.js && node tests/runtime-contracts.test.js && node tests/ai-integration-analytics.test.js && node tests/dashboard-runtime-state.test.js && node tests/task-router.test.js && npm --prefix mcp run build && node tests/mcp-bridge-client-lifecycle.test.js && node tests/mcp-bridge-topology.test.js && node tests/mcp-bridge-background-dispatch.test.js && node tests/mcp-tool-routing-contract.test.js && node tests/mcp-visual-session-contract.test.js && node tests/mcp-restricted-tab.test.js && node tests/mcp-recovery-messaging.test.js && node tests/mcp-in-flight-session-lookup.test.js && node tests/mcp-version-parity.test.js && node tests/mcp-diagnostics-status.test.js && node tests/mcp-install-platforms.test.js && node tests/mcp-setup-guidance.test.js && node tests/turn-result.test.js && node tests/action-history.test.js && node tests/universal-provider-lmstudio.test.js && node tests/config-lmstudio.test.js && node tests/cost-tracker.test.js && node tests/install-identity.test.js && node tests/mcp-pricing.test.js && node tests/mcp-pricing-data-parity.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-dispatcher-client-label.test.js && node tests/mcp-session-recorder.test.js && node tests/mcp-metrics-no-pii-leak.test.js && node tests/telemetry-collector.test.js && node tests/telemetry-collector-strips-internal-fields.test.js && node tests/telemetry-payload-allowlist.test.js && node tests/extension-record-dispatch-serializes.test.js && node tests/server-trust-proxy.test.js && node tests/server-telemetry-salt-rotation.test.js && node tests/server-telemetry-rate-limit.test.js && node tests/server-telemetry-body-cap.test.js && node tests/server-telemetry-batch-cap.test.js && node tests/server-telemetry-allowlist.test.js && node tests/server-telemetry-event-id-dedup.test.js && node tests/server-telemetry-timestamp-tolerance.test.js && node tests/server-telemetry-daily-budget.test.js && node tests/server-telemetry-sec-gpc.test.js && node tests/server-telemetry-optout-forget.test.js && node tests/server-telemetry-housekeeper.test.js && node tests/server-public-stats-headline.test.js && node tests/server-public-stats-series.test.js && node tests/server-public-stats-cache.test.js && node tests/server-public-stats-no-auth.test.js && node tests/server-github-cache.test.js && node tests/server-github-poller.test.js && node tests/fsb-telemetry-service.test.js && node tests/showcase-build-smoke.test.js && node tests/stats-chart-overhaul.test.js && node scripts/verify-store-listing.mjs && node tests/verify-store-listing.test.js && node tests/cumulative-commits-aggregator.test.js && node tests/dashboard-stream-readiness-ping.test.js && node tests/dashboard-stream-pending-intent.test.js && node tests/dashboard-stream-recovery-parity.test.js && node tests/server-ws-backpressure.test.js && node tests/server-ws-phantomstream-relay-compat.test.js && node tests/showcase-privacy-page.test.js && node tests/server-no-ip-leak.test.js && node tests/server-ip-geo.test.js && node tests/server-ip-geo-merge.test.js && node tests/server-ip-geo-scale.test.js && node tests/server-region-aggregation.test.js && node tests/transcript-store.test.js && node tests/hook-pipeline.test.js && node tests/session-schema.test.js && node tests/state-emitter.test.js && node tests/secure-config-credential-vault.test.js && node tests/qr-pairing.test.js && node tests/ws-client-decompress.test.js && node tests/dashboard-task-direct-start.test.js && node tests/phantom-stream-protocol-envelope.test.js && node tests/phantom-stream-differential-parity.test.js && node tests/phantom-stream-remote-control-parity.test.js && node tests/phantom-stream-public-package.test.js && node tests/phantom-stream-exports.test.js && node tests/phantom-stream-content-bundle.test.js && node tests/phantom-stream-capture-adapter.test.js && node tests/phantom-stream-sidechannels.test.js && node tests/phantom-stream-security-masking.test.js && node tests/phantom-stream-dashboard-viewer-bundle.test.js && node tests/phantom-stream-static-viewer.test.js && node tests/phantom-stream-dashboard-parity.test.js && node tests/phantom-stream-dashboard-sidechannels.test.js && node tests/phantom-stream-media-sync.test.js && node tests/phantom-stream-media-wiring.test.js && node tests/dom-stream-perf.test.js && node tests/extension-content-script-files-completeness.test.js && node tests/dom-analysis-viewport-priority.test.js && node tests/redact-for-log.test.js && node tests/diagnostics-ring-buffer.test.js && node tests/agent-sunset-back-end.test.js && node tests/agent-sunset-control-panel.test.js && node tests/agent-sunset-showcase.test.js && node tests/sync-tab-runtime.test.js && node tests/remote-control-rebrand.test.js && node tests/server-accept-language.test.js && node tests/metrics-wireup.test.js && node tests/dashboard-metrics-render.test.js && node tests/stuck-action-repetition.test.js && node tests/goal-progress-tracker.test.js && node tests/model-discovery.test.js && node tests/model-discovery-ui.test.js && node tests/model-combobox-ui.test.js && node tests/model-discovery-runtime.test.js && node tests/overlay-stability-cadence.test.js && node tests/overlay-content-audit.test.js && node tests/meta-cognitive-tracker.test.js && node tests/agent-cap-recommendation.test.js && node tests/agent-cap-storage.test.js && node tests/agent-cap-ui.test.js && node tests/agent-registry.test.js && node tests/selected-tab-state.test.js && node tests/change-report-builder.test.js && node tests/change-report-size-cap.test.js && node tests/change-report-cross-origin.test.js && node tests/change-report-dispatcher.test.js && node tests/change-report-read-tools-excluded.test.js && node tests/change-report-toggle.test.js && node tests/change-report-settings-ui.test.js && node tests/agent-tab-resolver.test.js && node tests/read-tool-tab-resolution.test.js && node tests/open-tab-background-default.test.js && node tests/visual-session-agent-scoped.test.js && node tests/action-tool-agent-scoped.test.js && node tests/recovery-tools-bootstrap.test.js && node tests/legacy-agent-synthesis.test.js && node tests/visual-session-reentry.test.js && node tests/ownership-error-codes.test.js && node tests/multi-agent-regression.test.js && node tests/tool-definitions-parity.test.js && node tests/mcp-numeric-param-coercion.test.js && node tests/visual-session-schema-lock.test.js && node tests/mcp-visual-tick-lifecycle.test.js && node tests/agent-id-threading.test.js && node tests/skill-fsb-spec.test.js && node tests/agent-loop-empty-contents.test.js && node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js && node tests/mcp-philosophy-parity-smoke.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js && node tests/sidepanel-mcpvisualsession-listener.test.js && node tests/sidepanel-tab-scoping-fix-redo-smoke.test.js && node tests/sidepanel-progress-tick-setter-routing.test.js && node tests/sidepanel-background-tab-completion.test.js && node tests/sidepanel-multi-document-fanout.test.js && node tests/trigger-store.test.js && node tests/trigger-lifecycle.test.js && node tests/value-extractor.test.js && node tests/trigger-manager.test.js && node tests/trigger-cap.test.js && node tests/trigger-observe.test.js && node tests/trigger-observe-pulse.test.js && node tests/trigger-refresh-poll.test.js && node tests/trigger-tool-dispatcher.test.js && node tests/trigger-blocking-reporting.test.js && node tests/capability-recipe-schema.test.js && node tests/capability-interpreter.test.js && node tests/recipe-path-guard.test.js && node tests/capability-fetch.test.js && node tests/capability-search-eval.test.js && node tests/capability-mcp-surface.test.js && node tests/capability-router.test.js && node tests/capability-autopilot-parity.test.js && node tests/capability-head-handlers.test.js && node tests/consent-policy-store.test.js && node tests/consent-gate.test.js && node tests/consent-mutation-gate.test.js && node tests/consent-chokepoint.test.js && node tests/audit-log.test.js && node tests/audit-log-no-secret.test.js && node tests/recipe-signature.test.js && node tests/recipe-signature-interpreter-hook.test.js && node tests/service-denylist.test.js && node tests/classification-gate.test.js && node tests/provenance-scaffold.test.js && node tests/upload-file-route.test.js && node tests/upload-path-denylist.test.js && node tests/consent-audit-settings-ui.test.js && node tests/agent-loop-iterator-guard.test.js && node tests/network-capture.test.js && node tests/network-capture-consent.test.js && node tests/network-capture-redaction.test.js && node tests/recipe-synthesizer.test.js && node tests/learned-recipe-store.test.js && node tests/learned-search-add.test.js && node tests/learned-t2-outranking.test.js && node tests/learned-promote-after-replay.test.js && node tests/discovery-seeds-load.test.js && node tests/seed-resolve-t2.test.js && node tests/recipe-learn-pending-affordance.test.js && node tests/learned-local-provenance-exempt.test.js && node tests/provider-parity.test.js && node tests/recipe-schema-lock.test.js && node tests/capability-rot-detector.test.js && node tests/relearn-coalescing.test.js && node tests/rot-recurrence-classify.test.js && node tests/app-degraded-surfacing.test.js && node tests/relearn-router-wiring.test.js && node tests/import-extraction.test.js && node tests/import-forbidden-prescan.test.js && node tests/import-classify-gate-call.test.js && node tests/catalog-crosscheck.test.js && node tests/no-dead-entry.test.js && node tests/catalog-inline-shape.test.js && node tests/head-handler-cap.test.js && node tests/head-handler-upgrade.test.js && node tests/confluence-t1-handler.test.js && node tests/verify-origin-classification.test.js && node tests/guarded-write-failclosed.test.js && node tests/breadth-search-return.test.js && node tests/breadth-batch-gate.test.js && node tests/backing-status-annotation.test.js && node tests/t1-port-contract.test.js && node tests/t1-port-contract-gate.test.js && node tests/dom-only-default.test.js && node tests/sensitive-write-import-gate.test.js && node tests/coverage-report.test.js && node tests/payment-op-guard.test.js && node tests/vendor-augment-preserved.test.js && node tests/no-duplicate-stem.test.js && node tests/full-corpus-screen.test.js && node tests/full-corpus-scale.test.js && node tests/keyboard-attach-robustness.test.js && node --import tsx tests/no-orphan-descriptor.test.js", "test:lattice": "node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js", "test:mcp-smoke:lifecycle": "npm --prefix mcp run build && node tests/mcp-lifecycle-smoke.test.js", "test:mcp-smoke:tools": "npm --prefix mcp run build && node tests/mcp-tool-smoke.test.js", From 7f59a3cffc30c2a01a063fef395a246c40bf79ae Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 7 Jul 2026 21:36:35 -0500 Subject: [PATCH 05/26] feat(control-panel): show MCP/Autopilot source badge in session list Mirrors the history-source-badge sidepanel.js already renders for MCP agent vs autopilot sessions, so the control panel's session list carries the same source indicator. --- extension/ui/options.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/extension/ui/options.js b/extension/ui/options.js index 2e09ff612..a57844e09 100644 --- a/extension/ui/options.js +++ b/extension/ui/options.js @@ -2781,6 +2781,9 @@ async function loadSessionList() { ${formatSessionDate(session.startTime)} ${session.actionCount || 0} actions ${formatSessionDuration(session.startTime, session.endTime)} + ${session.mode === 'mcp-agent' + ? `MCP · ${escapeHtml(session.mcpClient || 'Agent')}` + : `Autopilot`}
From 2ec42070829ea7c9e18a13237c0ceb2df1763fc1 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 7 Jul 2026 21:37:24 -0500 Subject: [PATCH 06/26] fix(control-panel): keep open select menus from clipping inside settings cards .settings-card clips overflow for its rounded-card styling, which cut off the custom .fsb-select dropdown menu whenever it opened near a card edge. Toggle a .settings-card--select-open class (overflow: visible, elevated z-index) on the host card for as long as its select is open, routed through shared setFsbSelectCardOpen/closeFsbSelect helpers so every close path (option pick, outside click, sibling open) clears it consistently. --- extension/ui/options.css | 6 +++ extension/ui/options.js | 25 +++++++---- package.json | 2 +- tests/settings-card-select-clipping.test.js | 46 +++++++++++++++++++++ 4 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 tests/settings-card-select-clipping.test.js diff --git a/extension/ui/options.css b/extension/ui/options.css index 2d5cf9bf6..279bb1dff 100644 --- a/extension/ui/options.css +++ b/extension/ui/options.css @@ -934,6 +934,12 @@ textarea.form-input { transition: all var(--transition-base); } +.settings-card.settings-card--select-open { + overflow: visible; + position: relative; + z-index: 20; +} + .settings-card:hover { border-color: var(--border-hover); box-shadow: var(--shadow-md); diff --git a/extension/ui/options.js b/extension/ui/options.js index a57844e09..15a789ced 100644 --- a/extension/ui/options.js +++ b/extension/ui/options.js @@ -1067,6 +1067,19 @@ function syncFsbSelectLabels() { }); } +function setFsbSelectCardOpen(wrap, open) { + const card = typeof wrap.closest === 'function' ? wrap.closest('.settings-card') : null; + if (card) card.classList.toggle('settings-card--select-open', open); +} + +function closeFsbSelect(wrap) { + const menu = wrap.querySelector('.fsb-select__menu'); if (menu) menu.hidden = true; + const btn = wrap.querySelector('.fsb-select__btn'); if (btn) btn.classList.remove('is-open'); + const chev = wrap.querySelector('.fsb-select__chev'); + if (chev) { chev.classList.add('fa-chevron-down'); chev.classList.remove('fa-chevron-up'); } + setFsbSelectCardOpen(wrap, false); +} + function initFsbSelects() { document.querySelectorAll('.form-select, .chart-select').forEach((sel) => { if (sel.getAttribute('data-enh') === '1' || sel.classList.contains('model-combobox__native')) return; @@ -1096,6 +1109,7 @@ function initFsbSelects() { btn.classList.toggle('is-open', open); chev.classList.toggle('fa-chevron-up', open); chev.classList.toggle('fa-chevron-down', !open); + setFsbSelectCardOpen(wrap, open); } function sync() { const v = sel.value; @@ -1137,8 +1151,9 @@ function initFsbSelects() { btn.addEventListener('click', (e) => { e.stopPropagation(); const willOpen = menu.hidden; - document.querySelectorAll('.fsb-select__menu').forEach((m) => { if (m !== menu) m.hidden = true; }); - document.querySelectorAll('.fsb-select__btn').forEach((b) => { if (b !== btn) b.classList.remove('is-open'); }); + document.querySelectorAll('.fsb-select').forEach((w) => { + if (w !== wrap) closeFsbSelect(w); + }); setOpen(willOpen); }); btn.addEventListener('keydown', (e) => { @@ -1152,11 +1167,7 @@ function initFsbSelects() { _fsbSelectOutsideClickBound = true; document.addEventListener('click', (e) => { document.querySelectorAll('.fsb-select').forEach((w) => { - if (w.contains(e.target)) return; - const m = w.querySelector('.fsb-select__menu'); if (m) m.hidden = true; - const b = w.querySelector('.fsb-select__btn'); if (b) b.classList.remove('is-open'); - const c = w.querySelector('.fsb-select__chev'); - if (c) { c.classList.add('fa-chevron-down'); c.classList.remove('fa-chevron-up'); } + if (!w.contains(e.target)) closeFsbSelect(w); }); }); } diff --git a/package.json b/package.json index a0ba59da0..0ffa2cdd3 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "scripts": { "build": "node esbuild.config.js", "build:cfworker": "node_modules/.bin/esbuild node_modules/@cfworker/json-schema/dist/esm/index.js --bundle --format=iife --global-name=CfworkerJsonSchema --platform=browser --target=chrome120 --legal-comments=none --outfile=extension/lib/cfworker-json-schema.min.js", - "test": "node tests/test-overlay-state.js && node tests/cost-tracker-ordering.test.js && node tests/runtime-contracts.test.js && node tests/ai-integration-analytics.test.js && node tests/dashboard-runtime-state.test.js && node tests/task-router.test.js && npm --prefix mcp run build && node tests/mcp-bridge-client-lifecycle.test.js && node tests/mcp-bridge-topology.test.js && node tests/mcp-bridge-background-dispatch.test.js && node tests/mcp-tool-routing-contract.test.js && node tests/mcp-visual-session-contract.test.js && node tests/mcp-restricted-tab.test.js && node tests/mcp-recovery-messaging.test.js && node tests/mcp-in-flight-session-lookup.test.js && node tests/mcp-version-parity.test.js && node tests/mcp-diagnostics-status.test.js && node tests/mcp-install-platforms.test.js && node tests/mcp-setup-guidance.test.js && node tests/turn-result.test.js && node tests/action-history.test.js && node tests/universal-provider-lmstudio.test.js && node tests/config-lmstudio.test.js && node tests/cost-tracker.test.js && node tests/install-identity.test.js && node tests/mcp-pricing.test.js && node tests/mcp-pricing-data-parity.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-dispatcher-client-label.test.js && node tests/mcp-session-recorder.test.js && node tests/mcp-metrics-no-pii-leak.test.js && node tests/telemetry-collector.test.js && node tests/telemetry-collector-strips-internal-fields.test.js && node tests/telemetry-payload-allowlist.test.js && node tests/extension-record-dispatch-serializes.test.js && node tests/server-trust-proxy.test.js && node tests/server-telemetry-salt-rotation.test.js && node tests/server-telemetry-rate-limit.test.js && node tests/server-telemetry-body-cap.test.js && node tests/server-telemetry-batch-cap.test.js && node tests/server-telemetry-allowlist.test.js && node tests/server-telemetry-event-id-dedup.test.js && node tests/server-telemetry-timestamp-tolerance.test.js && node tests/server-telemetry-daily-budget.test.js && node tests/server-telemetry-sec-gpc.test.js && node tests/server-telemetry-optout-forget.test.js && node tests/server-telemetry-housekeeper.test.js && node tests/server-public-stats-headline.test.js && node tests/server-public-stats-series.test.js && node tests/server-public-stats-cache.test.js && node tests/server-public-stats-no-auth.test.js && node tests/server-github-cache.test.js && node tests/server-github-poller.test.js && node tests/fsb-telemetry-service.test.js && node tests/showcase-build-smoke.test.js && node tests/stats-chart-overhaul.test.js && node scripts/verify-store-listing.mjs && node tests/verify-store-listing.test.js && node tests/cumulative-commits-aggregator.test.js && node tests/dashboard-stream-readiness-ping.test.js && node tests/dashboard-stream-pending-intent.test.js && node tests/dashboard-stream-recovery-parity.test.js && node tests/server-ws-backpressure.test.js && node tests/server-ws-phantomstream-relay-compat.test.js && node tests/showcase-privacy-page.test.js && node tests/server-no-ip-leak.test.js && node tests/server-ip-geo.test.js && node tests/server-ip-geo-merge.test.js && node tests/server-ip-geo-scale.test.js && node tests/server-region-aggregation.test.js && node tests/transcript-store.test.js && node tests/hook-pipeline.test.js && node tests/session-schema.test.js && node tests/state-emitter.test.js && node tests/secure-config-credential-vault.test.js && node tests/qr-pairing.test.js && node tests/ws-client-decompress.test.js && node tests/dashboard-task-direct-start.test.js && node tests/phantom-stream-protocol-envelope.test.js && node tests/phantom-stream-differential-parity.test.js && node tests/phantom-stream-remote-control-parity.test.js && node tests/phantom-stream-public-package.test.js && node tests/phantom-stream-exports.test.js && node tests/phantom-stream-content-bundle.test.js && node tests/phantom-stream-capture-adapter.test.js && node tests/phantom-stream-sidechannels.test.js && node tests/phantom-stream-security-masking.test.js && node tests/phantom-stream-dashboard-viewer-bundle.test.js && node tests/phantom-stream-static-viewer.test.js && node tests/phantom-stream-dashboard-parity.test.js && node tests/phantom-stream-dashboard-sidechannels.test.js && node tests/phantom-stream-media-sync.test.js && node tests/phantom-stream-media-wiring.test.js && node tests/dom-stream-perf.test.js && node tests/extension-content-script-files-completeness.test.js && node tests/dom-analysis-viewport-priority.test.js && node tests/redact-for-log.test.js && node tests/diagnostics-ring-buffer.test.js && node tests/agent-sunset-back-end.test.js && node tests/agent-sunset-control-panel.test.js && node tests/agent-sunset-showcase.test.js && node tests/sync-tab-runtime.test.js && node tests/remote-control-rebrand.test.js && node tests/server-accept-language.test.js && node tests/metrics-wireup.test.js && node tests/dashboard-metrics-render.test.js && node tests/stuck-action-repetition.test.js && node tests/goal-progress-tracker.test.js && node tests/model-discovery.test.js && node tests/model-discovery-ui.test.js && node tests/model-combobox-ui.test.js && node tests/model-discovery-runtime.test.js && node tests/overlay-stability-cadence.test.js && node tests/overlay-content-audit.test.js && node tests/meta-cognitive-tracker.test.js && node tests/agent-cap-recommendation.test.js && node tests/agent-cap-storage.test.js && node tests/agent-cap-ui.test.js && node tests/agent-registry.test.js && node tests/selected-tab-state.test.js && node tests/change-report-builder.test.js && node tests/change-report-size-cap.test.js && node tests/change-report-cross-origin.test.js && node tests/change-report-dispatcher.test.js && node tests/change-report-read-tools-excluded.test.js && node tests/change-report-toggle.test.js && node tests/change-report-settings-ui.test.js && node tests/agent-tab-resolver.test.js && node tests/read-tool-tab-resolution.test.js && node tests/open-tab-background-default.test.js && node tests/visual-session-agent-scoped.test.js && node tests/action-tool-agent-scoped.test.js && node tests/recovery-tools-bootstrap.test.js && node tests/legacy-agent-synthesis.test.js && node tests/visual-session-reentry.test.js && node tests/ownership-error-codes.test.js && node tests/multi-agent-regression.test.js && node tests/tool-definitions-parity.test.js && node tests/mcp-numeric-param-coercion.test.js && node tests/visual-session-schema-lock.test.js && node tests/mcp-visual-tick-lifecycle.test.js && node tests/agent-id-threading.test.js && node tests/skill-fsb-spec.test.js && node tests/agent-loop-empty-contents.test.js && node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js && node tests/mcp-philosophy-parity-smoke.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js && node tests/sidepanel-mcpvisualsession-listener.test.js && node tests/sidepanel-tab-scoping-fix-redo-smoke.test.js && node tests/sidepanel-progress-tick-setter-routing.test.js && node tests/sidepanel-background-tab-completion.test.js && node tests/sidepanel-multi-document-fanout.test.js && node tests/trigger-store.test.js && node tests/trigger-lifecycle.test.js && node tests/value-extractor.test.js && node tests/trigger-manager.test.js && node tests/trigger-cap.test.js && node tests/trigger-observe.test.js && node tests/trigger-observe-pulse.test.js && node tests/trigger-refresh-poll.test.js && node tests/trigger-tool-dispatcher.test.js && node tests/trigger-blocking-reporting.test.js && node tests/capability-recipe-schema.test.js && node tests/capability-interpreter.test.js && node tests/recipe-path-guard.test.js && node tests/capability-fetch.test.js && node tests/capability-search-eval.test.js && node tests/capability-mcp-surface.test.js && node tests/capability-router.test.js && node tests/capability-autopilot-parity.test.js && node tests/capability-head-handlers.test.js && node tests/consent-policy-store.test.js && node tests/consent-gate.test.js && node tests/consent-mutation-gate.test.js && node tests/consent-chokepoint.test.js && node tests/audit-log.test.js && node tests/audit-log-no-secret.test.js && node tests/recipe-signature.test.js && node tests/recipe-signature-interpreter-hook.test.js && node tests/service-denylist.test.js && node tests/classification-gate.test.js && node tests/provenance-scaffold.test.js && node tests/upload-file-route.test.js && node tests/upload-path-denylist.test.js && node tests/consent-audit-settings-ui.test.js && node tests/agent-loop-iterator-guard.test.js && node tests/network-capture.test.js && node tests/network-capture-consent.test.js && node tests/network-capture-redaction.test.js && node tests/recipe-synthesizer.test.js && node tests/learned-recipe-store.test.js && node tests/learned-search-add.test.js && node tests/learned-t2-outranking.test.js && node tests/learned-promote-after-replay.test.js && node tests/discovery-seeds-load.test.js && node tests/seed-resolve-t2.test.js && node tests/recipe-learn-pending-affordance.test.js && node tests/learned-local-provenance-exempt.test.js && node tests/provider-parity.test.js && node tests/recipe-schema-lock.test.js && node tests/capability-rot-detector.test.js && node tests/relearn-coalescing.test.js && node tests/rot-recurrence-classify.test.js && node tests/app-degraded-surfacing.test.js && node tests/relearn-router-wiring.test.js && node tests/import-extraction.test.js && node tests/import-forbidden-prescan.test.js && node tests/import-classify-gate-call.test.js && node tests/catalog-crosscheck.test.js && node tests/no-dead-entry.test.js && node tests/catalog-inline-shape.test.js && node tests/head-handler-cap.test.js && node tests/head-handler-upgrade.test.js && node tests/confluence-t1-handler.test.js && node tests/verify-origin-classification.test.js && node tests/guarded-write-failclosed.test.js && node tests/breadth-search-return.test.js && node tests/breadth-batch-gate.test.js && node tests/backing-status-annotation.test.js && node tests/t1-port-contract.test.js && node tests/t1-port-contract-gate.test.js && node tests/dom-only-default.test.js && node tests/sensitive-write-import-gate.test.js && node tests/coverage-report.test.js && node tests/payment-op-guard.test.js && node tests/vendor-augment-preserved.test.js && node tests/no-duplicate-stem.test.js && node tests/full-corpus-screen.test.js && node tests/full-corpus-scale.test.js && node tests/keyboard-attach-robustness.test.js && node --import tsx tests/no-orphan-descriptor.test.js", + "test": "node tests/test-overlay-state.js && node tests/cost-tracker-ordering.test.js && node tests/runtime-contracts.test.js && node tests/ai-integration-analytics.test.js && node tests/dashboard-runtime-state.test.js && node tests/task-router.test.js && npm --prefix mcp run build && node tests/mcp-bridge-client-lifecycle.test.js && node tests/mcp-bridge-topology.test.js && node tests/mcp-bridge-background-dispatch.test.js && node tests/mcp-tool-routing-contract.test.js && node tests/mcp-visual-session-contract.test.js && node tests/mcp-restricted-tab.test.js && node tests/mcp-recovery-messaging.test.js && node tests/mcp-in-flight-session-lookup.test.js && node tests/mcp-version-parity.test.js && node tests/mcp-diagnostics-status.test.js && node tests/mcp-install-platforms.test.js && node tests/mcp-setup-guidance.test.js && node tests/turn-result.test.js && node tests/action-history.test.js && node tests/universal-provider-lmstudio.test.js && node tests/config-lmstudio.test.js && node tests/cost-tracker.test.js && node tests/install-identity.test.js && node tests/mcp-pricing.test.js && node tests/mcp-pricing-data-parity.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-dispatcher-client-label.test.js && node tests/mcp-session-recorder.test.js && node tests/mcp-metrics-no-pii-leak.test.js && node tests/telemetry-collector.test.js && node tests/telemetry-collector-strips-internal-fields.test.js && node tests/telemetry-payload-allowlist.test.js && node tests/extension-record-dispatch-serializes.test.js && node tests/server-trust-proxy.test.js && node tests/server-telemetry-salt-rotation.test.js && node tests/server-telemetry-rate-limit.test.js && node tests/server-telemetry-body-cap.test.js && node tests/server-telemetry-batch-cap.test.js && node tests/server-telemetry-allowlist.test.js && node tests/server-telemetry-event-id-dedup.test.js && node tests/server-telemetry-timestamp-tolerance.test.js && node tests/server-telemetry-daily-budget.test.js && node tests/server-telemetry-sec-gpc.test.js && node tests/server-telemetry-optout-forget.test.js && node tests/server-telemetry-housekeeper.test.js && node tests/server-public-stats-headline.test.js && node tests/server-public-stats-series.test.js && node tests/server-public-stats-cache.test.js && node tests/server-public-stats-no-auth.test.js && node tests/server-github-cache.test.js && node tests/server-github-poller.test.js && node tests/fsb-telemetry-service.test.js && node tests/showcase-build-smoke.test.js && node tests/stats-chart-overhaul.test.js && node scripts/verify-store-listing.mjs && node tests/verify-store-listing.test.js && node tests/cumulative-commits-aggregator.test.js && node tests/dashboard-stream-readiness-ping.test.js && node tests/dashboard-stream-pending-intent.test.js && node tests/dashboard-stream-recovery-parity.test.js && node tests/server-ws-backpressure.test.js && node tests/server-ws-phantomstream-relay-compat.test.js && node tests/showcase-privacy-page.test.js && node tests/server-no-ip-leak.test.js && node tests/server-ip-geo.test.js && node tests/server-ip-geo-merge.test.js && node tests/server-ip-geo-scale.test.js && node tests/server-region-aggregation.test.js && node tests/transcript-store.test.js && node tests/hook-pipeline.test.js && node tests/session-schema.test.js && node tests/state-emitter.test.js && node tests/secure-config-credential-vault.test.js && node tests/qr-pairing.test.js && node tests/ws-client-decompress.test.js && node tests/dashboard-task-direct-start.test.js && node tests/phantom-stream-protocol-envelope.test.js && node tests/phantom-stream-differential-parity.test.js && node tests/phantom-stream-remote-control-parity.test.js && node tests/phantom-stream-public-package.test.js && node tests/phantom-stream-exports.test.js && node tests/phantom-stream-content-bundle.test.js && node tests/phantom-stream-capture-adapter.test.js && node tests/phantom-stream-sidechannels.test.js && node tests/phantom-stream-security-masking.test.js && node tests/phantom-stream-dashboard-viewer-bundle.test.js && node tests/phantom-stream-static-viewer.test.js && node tests/phantom-stream-dashboard-parity.test.js && node tests/phantom-stream-dashboard-sidechannels.test.js && node tests/phantom-stream-media-sync.test.js && node tests/phantom-stream-media-wiring.test.js && node tests/dom-stream-perf.test.js && node tests/extension-content-script-files-completeness.test.js && node tests/dom-analysis-viewport-priority.test.js && node tests/redact-for-log.test.js && node tests/diagnostics-ring-buffer.test.js && node tests/agent-sunset-back-end.test.js && node tests/agent-sunset-control-panel.test.js && node tests/agent-sunset-showcase.test.js && node tests/sync-tab-runtime.test.js && node tests/remote-control-rebrand.test.js && node tests/server-accept-language.test.js && node tests/metrics-wireup.test.js && node tests/dashboard-metrics-render.test.js && node tests/stuck-action-repetition.test.js && node tests/goal-progress-tracker.test.js && node tests/model-discovery.test.js && node tests/model-discovery-ui.test.js && node tests/model-combobox-ui.test.js && node tests/settings-card-select-clipping.test.js && node tests/model-discovery-runtime.test.js && node tests/overlay-stability-cadence.test.js && node tests/overlay-content-audit.test.js && node tests/meta-cognitive-tracker.test.js && node tests/agent-cap-recommendation.test.js && node tests/agent-cap-storage.test.js && node tests/agent-cap-ui.test.js && node tests/agent-registry.test.js && node tests/selected-tab-state.test.js && node tests/change-report-builder.test.js && node tests/change-report-size-cap.test.js && node tests/change-report-cross-origin.test.js && node tests/change-report-dispatcher.test.js && node tests/change-report-read-tools-excluded.test.js && node tests/change-report-toggle.test.js && node tests/change-report-settings-ui.test.js && node tests/agent-tab-resolver.test.js && node tests/read-tool-tab-resolution.test.js && node tests/open-tab-background-default.test.js && node tests/visual-session-agent-scoped.test.js && node tests/action-tool-agent-scoped.test.js && node tests/recovery-tools-bootstrap.test.js && node tests/legacy-agent-synthesis.test.js && node tests/visual-session-reentry.test.js && node tests/ownership-error-codes.test.js && node tests/multi-agent-regression.test.js && node tests/tool-definitions-parity.test.js && node tests/mcp-numeric-param-coercion.test.js && node tests/visual-session-schema-lock.test.js && node tests/mcp-visual-tick-lifecycle.test.js && node tests/agent-id-threading.test.js && node tests/skill-fsb-spec.test.js && node tests/agent-loop-empty-contents.test.js && node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js && node tests/mcp-philosophy-parity-smoke.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js && node tests/sidepanel-mcpvisualsession-listener.test.js && node tests/sidepanel-tab-scoping-fix-redo-smoke.test.js && node tests/sidepanel-progress-tick-setter-routing.test.js && node tests/sidepanel-background-tab-completion.test.js && node tests/sidepanel-multi-document-fanout.test.js && node tests/trigger-store.test.js && node tests/trigger-lifecycle.test.js && node tests/value-extractor.test.js && node tests/trigger-manager.test.js && node tests/trigger-cap.test.js && node tests/trigger-observe.test.js && node tests/trigger-observe-pulse.test.js && node tests/trigger-refresh-poll.test.js && node tests/trigger-tool-dispatcher.test.js && node tests/trigger-blocking-reporting.test.js && node tests/capability-recipe-schema.test.js && node tests/capability-interpreter.test.js && node tests/recipe-path-guard.test.js && node tests/capability-fetch.test.js && node tests/capability-search-eval.test.js && node tests/capability-mcp-surface.test.js && node tests/capability-router.test.js && node tests/capability-autopilot-parity.test.js && node tests/capability-head-handlers.test.js && node tests/consent-policy-store.test.js && node tests/consent-gate.test.js && node tests/consent-mutation-gate.test.js && node tests/consent-chokepoint.test.js && node tests/audit-log.test.js && node tests/audit-log-no-secret.test.js && node tests/recipe-signature.test.js && node tests/recipe-signature-interpreter-hook.test.js && node tests/service-denylist.test.js && node tests/classification-gate.test.js && node tests/provenance-scaffold.test.js && node tests/upload-file-route.test.js && node tests/upload-path-denylist.test.js && node tests/consent-audit-settings-ui.test.js && node tests/agent-loop-iterator-guard.test.js && node tests/network-capture.test.js && node tests/network-capture-consent.test.js && node tests/network-capture-redaction.test.js && node tests/recipe-synthesizer.test.js && node tests/learned-recipe-store.test.js && node tests/learned-search-add.test.js && node tests/learned-t2-outranking.test.js && node tests/learned-promote-after-replay.test.js && node tests/discovery-seeds-load.test.js && node tests/seed-resolve-t2.test.js && node tests/recipe-learn-pending-affordance.test.js && node tests/learned-local-provenance-exempt.test.js && node tests/provider-parity.test.js && node tests/recipe-schema-lock.test.js && node tests/capability-rot-detector.test.js && node tests/relearn-coalescing.test.js && node tests/rot-recurrence-classify.test.js && node tests/app-degraded-surfacing.test.js && node tests/relearn-router-wiring.test.js && node tests/import-extraction.test.js && node tests/import-forbidden-prescan.test.js && node tests/import-classify-gate-call.test.js && node tests/catalog-crosscheck.test.js && node tests/no-dead-entry.test.js && node tests/catalog-inline-shape.test.js && node tests/head-handler-cap.test.js && node tests/head-handler-upgrade.test.js && node tests/confluence-t1-handler.test.js && node tests/verify-origin-classification.test.js && node tests/guarded-write-failclosed.test.js && node tests/breadth-search-return.test.js && node tests/breadth-batch-gate.test.js && node tests/backing-status-annotation.test.js && node tests/t1-port-contract.test.js && node tests/t1-port-contract-gate.test.js && node tests/dom-only-default.test.js && node tests/sensitive-write-import-gate.test.js && node tests/coverage-report.test.js && node tests/payment-op-guard.test.js && node tests/vendor-augment-preserved.test.js && node tests/no-duplicate-stem.test.js && node tests/full-corpus-screen.test.js && node tests/full-corpus-scale.test.js && node tests/keyboard-attach-robustness.test.js && node --import tsx tests/no-orphan-descriptor.test.js", "test:lattice": "node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js", "test:mcp-smoke:lifecycle": "npm --prefix mcp run build && node tests/mcp-lifecycle-smoke.test.js", "test:mcp-smoke:tools": "npm --prefix mcp run build && node tests/mcp-tool-smoke.test.js", diff --git a/tests/settings-card-select-clipping.test.js b/tests/settings-card-select-clipping.test.js new file mode 100644 index 000000000..98b68d885 --- /dev/null +++ b/tests/settings-card-select-clipping.test.js @@ -0,0 +1,46 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.resolve(__dirname, '..'); +const css = fs.readFileSync(path.join(repoRoot, 'extension', 'ui', 'options.css'), 'utf8'); +const js = fs.readFileSync(path.join(repoRoot, 'extension', 'ui', 'options.js'), 'utf8'); + +function getCssRule(selector) { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = css.match(new RegExp(escaped + '\\s*\\{([\\s\\S]*?)\\}', 'm')); + return match ? match[1] : ''; +} + +console.log('--- Test: settings-card custom select dropdown clipping guard ---'); + +const cardRule = getCssRule('.settings-card'); +assert(/overflow\s*:\s*hidden\s*;/.test(cardRule), + 'settings cards remain clipped by default for rounded-card styling'); + +const openCardRule = getCssRule('.settings-card.settings-card--select-open'); +assert(/overflow\s*:\s*visible\s*;/.test(openCardRule), + 'open settings-card select state allows dropdown overflow'); +assert(/position\s*:\s*relative\s*;/.test(openCardRule), + 'open settings-card select state creates a stacking context anchor'); +assert(/z-index\s*:\s*(?:[1-9]\d*)\s*;/.test(openCardRule), + 'open settings-card select state is elevated above neighboring cards'); + +assert(/wrap\.closest\(['"]\.settings-card['"]\)/.test(js), + 'custom select locates the nearest settings card host'); +assert(/classList\.toggle\(['"]settings-card--select-open['"],\s*open\)/.test(js), + 'shared helper toggles the open settings-card state'); +assert(/function\s+closeFsbSelect\(wrap\)[\s\S]*setFsbSelectCardOpen\(wrap,\s*false\)/.test(js), + 'shared close helper clears the open settings-card state'); +assert(/setOpen\(willOpen\)/.test(js), + 'button click path opens and closes through setOpen'); +assert(/setOpen\(false\)/.test(js), + 'option pick and keyboard close paths close through setOpen'); +assert(/if\s*\(w\s*!==\s*wrap\)\s*closeFsbSelect\(w\)/.test(js), + 'sibling-select close path delegates to the shared close helper'); +assert(/if\s*\(!w\.contains\(e\.target\)\)\s*closeFsbSelect\(w\)/.test(js), + 'outside-click close path delegates to the shared close helper'); + +console.log('PASS settings-card custom select dropdown clipping guard'); From 7427825ba80f52ba898a62888f9000fa32db29d1 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 7 Jul 2026 21:37:58 -0500 Subject: [PATCH 07/26] fix(control-panel): contain dashboard scroll to the content pane html/body could grow taller than the viewport and scroll as a second scroller alongside .dashboard-content, and flex children without min-height: 0 wouldn't shrink to fit, letting page-level scroll fight the intended single scroll pane. Pin html/body/.dashboard-container to 100vh with overflow: hidden, let .dashboard-main/.dashboard-content shrink via min-height: 0, and add overscroll-behavior: contain so .dashboard-content stays the one scroller and doesn't chain scroll past its own edges. --- extension/ui/options.css | 12 +++- package.json | 2 +- .../control-panel-scroll-containment.test.js | 56 +++++++++++++++++++ 3 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 tests/control-panel-scroll-containment.test.js diff --git a/extension/ui/options.css b/extension/ui/options.css index 279bb1dff..7b0135f36 100644 --- a/extension/ui/options.css +++ b/extension/ui/options.css @@ -159,6 +159,7 @@ html { font-size: 16px; scroll-behavior: smooth; height: 100vh; + overflow: hidden; } body { @@ -169,6 +170,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; height: 100vh; + overflow: hidden; margin: 0; padding: 0; } @@ -205,7 +207,9 @@ body { --dashboard-help-icon-size: 2.75rem; --dashboard-help-icon-glyph-size: 1.3rem; --dashboard-branding-logo-height: 260px; + height: 100vh; min-height: 100vh; + overflow: hidden; display: flex; flex-direction: column; font-size: 14px; @@ -340,7 +344,9 @@ body { /* Main Layout */ .dashboard-main { display: flex; - flex: 1; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; } /* Floating Sidebar */ @@ -488,9 +494,11 @@ body { /* Content Area */ .dashboard-content { - flex: 1; + flex: 1 1 auto; + min-height: 0; overflow-y: auto; overflow-x: hidden; + overscroll-behavior: contain; padding: var(--space-lg); margin-left: calc(var(--dashboard-sidebar-width) + 26px) !important; padding-left: 16px; diff --git a/package.json b/package.json index 0ffa2cdd3..874994e63 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "scripts": { "build": "node esbuild.config.js", "build:cfworker": "node_modules/.bin/esbuild node_modules/@cfworker/json-schema/dist/esm/index.js --bundle --format=iife --global-name=CfworkerJsonSchema --platform=browser --target=chrome120 --legal-comments=none --outfile=extension/lib/cfworker-json-schema.min.js", - "test": "node tests/test-overlay-state.js && node tests/cost-tracker-ordering.test.js && node tests/runtime-contracts.test.js && node tests/ai-integration-analytics.test.js && node tests/dashboard-runtime-state.test.js && node tests/task-router.test.js && npm --prefix mcp run build && node tests/mcp-bridge-client-lifecycle.test.js && node tests/mcp-bridge-topology.test.js && node tests/mcp-bridge-background-dispatch.test.js && node tests/mcp-tool-routing-contract.test.js && node tests/mcp-visual-session-contract.test.js && node tests/mcp-restricted-tab.test.js && node tests/mcp-recovery-messaging.test.js && node tests/mcp-in-flight-session-lookup.test.js && node tests/mcp-version-parity.test.js && node tests/mcp-diagnostics-status.test.js && node tests/mcp-install-platforms.test.js && node tests/mcp-setup-guidance.test.js && node tests/turn-result.test.js && node tests/action-history.test.js && node tests/universal-provider-lmstudio.test.js && node tests/config-lmstudio.test.js && node tests/cost-tracker.test.js && node tests/install-identity.test.js && node tests/mcp-pricing.test.js && node tests/mcp-pricing-data-parity.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-dispatcher-client-label.test.js && node tests/mcp-session-recorder.test.js && node tests/mcp-metrics-no-pii-leak.test.js && node tests/telemetry-collector.test.js && node tests/telemetry-collector-strips-internal-fields.test.js && node tests/telemetry-payload-allowlist.test.js && node tests/extension-record-dispatch-serializes.test.js && node tests/server-trust-proxy.test.js && node tests/server-telemetry-salt-rotation.test.js && node tests/server-telemetry-rate-limit.test.js && node tests/server-telemetry-body-cap.test.js && node tests/server-telemetry-batch-cap.test.js && node tests/server-telemetry-allowlist.test.js && node tests/server-telemetry-event-id-dedup.test.js && node tests/server-telemetry-timestamp-tolerance.test.js && node tests/server-telemetry-daily-budget.test.js && node tests/server-telemetry-sec-gpc.test.js && node tests/server-telemetry-optout-forget.test.js && node tests/server-telemetry-housekeeper.test.js && node tests/server-public-stats-headline.test.js && node tests/server-public-stats-series.test.js && node tests/server-public-stats-cache.test.js && node tests/server-public-stats-no-auth.test.js && node tests/server-github-cache.test.js && node tests/server-github-poller.test.js && node tests/fsb-telemetry-service.test.js && node tests/showcase-build-smoke.test.js && node tests/stats-chart-overhaul.test.js && node scripts/verify-store-listing.mjs && node tests/verify-store-listing.test.js && node tests/cumulative-commits-aggregator.test.js && node tests/dashboard-stream-readiness-ping.test.js && node tests/dashboard-stream-pending-intent.test.js && node tests/dashboard-stream-recovery-parity.test.js && node tests/server-ws-backpressure.test.js && node tests/server-ws-phantomstream-relay-compat.test.js && node tests/showcase-privacy-page.test.js && node tests/server-no-ip-leak.test.js && node tests/server-ip-geo.test.js && node tests/server-ip-geo-merge.test.js && node tests/server-ip-geo-scale.test.js && node tests/server-region-aggregation.test.js && node tests/transcript-store.test.js && node tests/hook-pipeline.test.js && node tests/session-schema.test.js && node tests/state-emitter.test.js && node tests/secure-config-credential-vault.test.js && node tests/qr-pairing.test.js && node tests/ws-client-decompress.test.js && node tests/dashboard-task-direct-start.test.js && node tests/phantom-stream-protocol-envelope.test.js && node tests/phantom-stream-differential-parity.test.js && node tests/phantom-stream-remote-control-parity.test.js && node tests/phantom-stream-public-package.test.js && node tests/phantom-stream-exports.test.js && node tests/phantom-stream-content-bundle.test.js && node tests/phantom-stream-capture-adapter.test.js && node tests/phantom-stream-sidechannels.test.js && node tests/phantom-stream-security-masking.test.js && node tests/phantom-stream-dashboard-viewer-bundle.test.js && node tests/phantom-stream-static-viewer.test.js && node tests/phantom-stream-dashboard-parity.test.js && node tests/phantom-stream-dashboard-sidechannels.test.js && node tests/phantom-stream-media-sync.test.js && node tests/phantom-stream-media-wiring.test.js && node tests/dom-stream-perf.test.js && node tests/extension-content-script-files-completeness.test.js && node tests/dom-analysis-viewport-priority.test.js && node tests/redact-for-log.test.js && node tests/diagnostics-ring-buffer.test.js && node tests/agent-sunset-back-end.test.js && node tests/agent-sunset-control-panel.test.js && node tests/agent-sunset-showcase.test.js && node tests/sync-tab-runtime.test.js && node tests/remote-control-rebrand.test.js && node tests/server-accept-language.test.js && node tests/metrics-wireup.test.js && node tests/dashboard-metrics-render.test.js && node tests/stuck-action-repetition.test.js && node tests/goal-progress-tracker.test.js && node tests/model-discovery.test.js && node tests/model-discovery-ui.test.js && node tests/model-combobox-ui.test.js && node tests/settings-card-select-clipping.test.js && node tests/model-discovery-runtime.test.js && node tests/overlay-stability-cadence.test.js && node tests/overlay-content-audit.test.js && node tests/meta-cognitive-tracker.test.js && node tests/agent-cap-recommendation.test.js && node tests/agent-cap-storage.test.js && node tests/agent-cap-ui.test.js && node tests/agent-registry.test.js && node tests/selected-tab-state.test.js && node tests/change-report-builder.test.js && node tests/change-report-size-cap.test.js && node tests/change-report-cross-origin.test.js && node tests/change-report-dispatcher.test.js && node tests/change-report-read-tools-excluded.test.js && node tests/change-report-toggle.test.js && node tests/change-report-settings-ui.test.js && node tests/agent-tab-resolver.test.js && node tests/read-tool-tab-resolution.test.js && node tests/open-tab-background-default.test.js && node tests/visual-session-agent-scoped.test.js && node tests/action-tool-agent-scoped.test.js && node tests/recovery-tools-bootstrap.test.js && node tests/legacy-agent-synthesis.test.js && node tests/visual-session-reentry.test.js && node tests/ownership-error-codes.test.js && node tests/multi-agent-regression.test.js && node tests/tool-definitions-parity.test.js && node tests/mcp-numeric-param-coercion.test.js && node tests/visual-session-schema-lock.test.js && node tests/mcp-visual-tick-lifecycle.test.js && node tests/agent-id-threading.test.js && node tests/skill-fsb-spec.test.js && node tests/agent-loop-empty-contents.test.js && node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js && node tests/mcp-philosophy-parity-smoke.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js && node tests/sidepanel-mcpvisualsession-listener.test.js && node tests/sidepanel-tab-scoping-fix-redo-smoke.test.js && node tests/sidepanel-progress-tick-setter-routing.test.js && node tests/sidepanel-background-tab-completion.test.js && node tests/sidepanel-multi-document-fanout.test.js && node tests/trigger-store.test.js && node tests/trigger-lifecycle.test.js && node tests/value-extractor.test.js && node tests/trigger-manager.test.js && node tests/trigger-cap.test.js && node tests/trigger-observe.test.js && node tests/trigger-observe-pulse.test.js && node tests/trigger-refresh-poll.test.js && node tests/trigger-tool-dispatcher.test.js && node tests/trigger-blocking-reporting.test.js && node tests/capability-recipe-schema.test.js && node tests/capability-interpreter.test.js && node tests/recipe-path-guard.test.js && node tests/capability-fetch.test.js && node tests/capability-search-eval.test.js && node tests/capability-mcp-surface.test.js && node tests/capability-router.test.js && node tests/capability-autopilot-parity.test.js && node tests/capability-head-handlers.test.js && node tests/consent-policy-store.test.js && node tests/consent-gate.test.js && node tests/consent-mutation-gate.test.js && node tests/consent-chokepoint.test.js && node tests/audit-log.test.js && node tests/audit-log-no-secret.test.js && node tests/recipe-signature.test.js && node tests/recipe-signature-interpreter-hook.test.js && node tests/service-denylist.test.js && node tests/classification-gate.test.js && node tests/provenance-scaffold.test.js && node tests/upload-file-route.test.js && node tests/upload-path-denylist.test.js && node tests/consent-audit-settings-ui.test.js && node tests/agent-loop-iterator-guard.test.js && node tests/network-capture.test.js && node tests/network-capture-consent.test.js && node tests/network-capture-redaction.test.js && node tests/recipe-synthesizer.test.js && node tests/learned-recipe-store.test.js && node tests/learned-search-add.test.js && node tests/learned-t2-outranking.test.js && node tests/learned-promote-after-replay.test.js && node tests/discovery-seeds-load.test.js && node tests/seed-resolve-t2.test.js && node tests/recipe-learn-pending-affordance.test.js && node tests/learned-local-provenance-exempt.test.js && node tests/provider-parity.test.js && node tests/recipe-schema-lock.test.js && node tests/capability-rot-detector.test.js && node tests/relearn-coalescing.test.js && node tests/rot-recurrence-classify.test.js && node tests/app-degraded-surfacing.test.js && node tests/relearn-router-wiring.test.js && node tests/import-extraction.test.js && node tests/import-forbidden-prescan.test.js && node tests/import-classify-gate-call.test.js && node tests/catalog-crosscheck.test.js && node tests/no-dead-entry.test.js && node tests/catalog-inline-shape.test.js && node tests/head-handler-cap.test.js && node tests/head-handler-upgrade.test.js && node tests/confluence-t1-handler.test.js && node tests/verify-origin-classification.test.js && node tests/guarded-write-failclosed.test.js && node tests/breadth-search-return.test.js && node tests/breadth-batch-gate.test.js && node tests/backing-status-annotation.test.js && node tests/t1-port-contract.test.js && node tests/t1-port-contract-gate.test.js && node tests/dom-only-default.test.js && node tests/sensitive-write-import-gate.test.js && node tests/coverage-report.test.js && node tests/payment-op-guard.test.js && node tests/vendor-augment-preserved.test.js && node tests/no-duplicate-stem.test.js && node tests/full-corpus-screen.test.js && node tests/full-corpus-scale.test.js && node tests/keyboard-attach-robustness.test.js && node --import tsx tests/no-orphan-descriptor.test.js", + "test": "node tests/test-overlay-state.js && node tests/cost-tracker-ordering.test.js && node tests/runtime-contracts.test.js && node tests/ai-integration-analytics.test.js && node tests/dashboard-runtime-state.test.js && node tests/task-router.test.js && npm --prefix mcp run build && node tests/mcp-bridge-client-lifecycle.test.js && node tests/mcp-bridge-topology.test.js && node tests/mcp-bridge-background-dispatch.test.js && node tests/mcp-tool-routing-contract.test.js && node tests/mcp-visual-session-contract.test.js && node tests/mcp-restricted-tab.test.js && node tests/mcp-recovery-messaging.test.js && node tests/mcp-in-flight-session-lookup.test.js && node tests/mcp-version-parity.test.js && node tests/mcp-diagnostics-status.test.js && node tests/mcp-install-platforms.test.js && node tests/mcp-setup-guidance.test.js && node tests/turn-result.test.js && node tests/action-history.test.js && node tests/universal-provider-lmstudio.test.js && node tests/config-lmstudio.test.js && node tests/cost-tracker.test.js && node tests/install-identity.test.js && node tests/mcp-pricing.test.js && node tests/mcp-pricing-data-parity.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-dispatcher-client-label.test.js && node tests/mcp-session-recorder.test.js && node tests/mcp-metrics-no-pii-leak.test.js && node tests/telemetry-collector.test.js && node tests/telemetry-collector-strips-internal-fields.test.js && node tests/telemetry-payload-allowlist.test.js && node tests/extension-record-dispatch-serializes.test.js && node tests/server-trust-proxy.test.js && node tests/server-telemetry-salt-rotation.test.js && node tests/server-telemetry-rate-limit.test.js && node tests/server-telemetry-body-cap.test.js && node tests/server-telemetry-batch-cap.test.js && node tests/server-telemetry-allowlist.test.js && node tests/server-telemetry-event-id-dedup.test.js && node tests/server-telemetry-timestamp-tolerance.test.js && node tests/server-telemetry-daily-budget.test.js && node tests/server-telemetry-sec-gpc.test.js && node tests/server-telemetry-optout-forget.test.js && node tests/server-telemetry-housekeeper.test.js && node tests/server-public-stats-headline.test.js && node tests/server-public-stats-series.test.js && node tests/server-public-stats-cache.test.js && node tests/server-public-stats-no-auth.test.js && node tests/server-github-cache.test.js && node tests/server-github-poller.test.js && node tests/fsb-telemetry-service.test.js && node tests/showcase-build-smoke.test.js && node tests/stats-chart-overhaul.test.js && node scripts/verify-store-listing.mjs && node tests/verify-store-listing.test.js && node tests/cumulative-commits-aggregator.test.js && node tests/dashboard-stream-readiness-ping.test.js && node tests/dashboard-stream-pending-intent.test.js && node tests/dashboard-stream-recovery-parity.test.js && node tests/server-ws-backpressure.test.js && node tests/server-ws-phantomstream-relay-compat.test.js && node tests/showcase-privacy-page.test.js && node tests/server-no-ip-leak.test.js && node tests/server-ip-geo.test.js && node tests/server-ip-geo-merge.test.js && node tests/server-ip-geo-scale.test.js && node tests/server-region-aggregation.test.js && node tests/transcript-store.test.js && node tests/hook-pipeline.test.js && node tests/session-schema.test.js && node tests/state-emitter.test.js && node tests/secure-config-credential-vault.test.js && node tests/qr-pairing.test.js && node tests/ws-client-decompress.test.js && node tests/dashboard-task-direct-start.test.js && node tests/phantom-stream-protocol-envelope.test.js && node tests/phantom-stream-differential-parity.test.js && node tests/phantom-stream-remote-control-parity.test.js && node tests/phantom-stream-public-package.test.js && node tests/phantom-stream-exports.test.js && node tests/phantom-stream-content-bundle.test.js && node tests/phantom-stream-capture-adapter.test.js && node tests/phantom-stream-sidechannels.test.js && node tests/phantom-stream-security-masking.test.js && node tests/phantom-stream-dashboard-viewer-bundle.test.js && node tests/phantom-stream-static-viewer.test.js && node tests/phantom-stream-dashboard-parity.test.js && node tests/phantom-stream-dashboard-sidechannels.test.js && node tests/phantom-stream-media-sync.test.js && node tests/phantom-stream-media-wiring.test.js && node tests/dom-stream-perf.test.js && node tests/extension-content-script-files-completeness.test.js && node tests/dom-analysis-viewport-priority.test.js && node tests/redact-for-log.test.js && node tests/diagnostics-ring-buffer.test.js && node tests/agent-sunset-back-end.test.js && node tests/agent-sunset-control-panel.test.js && node tests/agent-sunset-showcase.test.js && node tests/sync-tab-runtime.test.js && node tests/remote-control-rebrand.test.js && node tests/server-accept-language.test.js && node tests/metrics-wireup.test.js && node tests/dashboard-metrics-render.test.js && node tests/stuck-action-repetition.test.js && node tests/goal-progress-tracker.test.js && node tests/model-discovery.test.js && node tests/model-discovery-ui.test.js && node tests/model-combobox-ui.test.js && node tests/settings-card-select-clipping.test.js && node tests/control-panel-scroll-containment.test.js && node tests/model-discovery-runtime.test.js && node tests/overlay-stability-cadence.test.js && node tests/overlay-content-audit.test.js && node tests/meta-cognitive-tracker.test.js && node tests/agent-cap-recommendation.test.js && node tests/agent-cap-storage.test.js && node tests/agent-cap-ui.test.js && node tests/agent-registry.test.js && node tests/selected-tab-state.test.js && node tests/change-report-builder.test.js && node tests/change-report-size-cap.test.js && node tests/change-report-cross-origin.test.js && node tests/change-report-dispatcher.test.js && node tests/change-report-read-tools-excluded.test.js && node tests/change-report-toggle.test.js && node tests/change-report-settings-ui.test.js && node tests/agent-tab-resolver.test.js && node tests/read-tool-tab-resolution.test.js && node tests/open-tab-background-default.test.js && node tests/visual-session-agent-scoped.test.js && node tests/action-tool-agent-scoped.test.js && node tests/recovery-tools-bootstrap.test.js && node tests/legacy-agent-synthesis.test.js && node tests/visual-session-reentry.test.js && node tests/ownership-error-codes.test.js && node tests/multi-agent-regression.test.js && node tests/tool-definitions-parity.test.js && node tests/mcp-numeric-param-coercion.test.js && node tests/visual-session-schema-lock.test.js && node tests/mcp-visual-tick-lifecycle.test.js && node tests/agent-id-threading.test.js && node tests/skill-fsb-spec.test.js && node tests/agent-loop-empty-contents.test.js && node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js && node tests/mcp-philosophy-parity-smoke.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js && node tests/sidepanel-mcpvisualsession-listener.test.js && node tests/sidepanel-tab-scoping-fix-redo-smoke.test.js && node tests/sidepanel-progress-tick-setter-routing.test.js && node tests/sidepanel-background-tab-completion.test.js && node tests/sidepanel-multi-document-fanout.test.js && node tests/trigger-store.test.js && node tests/trigger-lifecycle.test.js && node tests/value-extractor.test.js && node tests/trigger-manager.test.js && node tests/trigger-cap.test.js && node tests/trigger-observe.test.js && node tests/trigger-observe-pulse.test.js && node tests/trigger-refresh-poll.test.js && node tests/trigger-tool-dispatcher.test.js && node tests/trigger-blocking-reporting.test.js && node tests/capability-recipe-schema.test.js && node tests/capability-interpreter.test.js && node tests/recipe-path-guard.test.js && node tests/capability-fetch.test.js && node tests/capability-search-eval.test.js && node tests/capability-mcp-surface.test.js && node tests/capability-router.test.js && node tests/capability-autopilot-parity.test.js && node tests/capability-head-handlers.test.js && node tests/consent-policy-store.test.js && node tests/consent-gate.test.js && node tests/consent-mutation-gate.test.js && node tests/consent-chokepoint.test.js && node tests/audit-log.test.js && node tests/audit-log-no-secret.test.js && node tests/recipe-signature.test.js && node tests/recipe-signature-interpreter-hook.test.js && node tests/service-denylist.test.js && node tests/classification-gate.test.js && node tests/provenance-scaffold.test.js && node tests/upload-file-route.test.js && node tests/upload-path-denylist.test.js && node tests/consent-audit-settings-ui.test.js && node tests/agent-loop-iterator-guard.test.js && node tests/network-capture.test.js && node tests/network-capture-consent.test.js && node tests/network-capture-redaction.test.js && node tests/recipe-synthesizer.test.js && node tests/learned-recipe-store.test.js && node tests/learned-search-add.test.js && node tests/learned-t2-outranking.test.js && node tests/learned-promote-after-replay.test.js && node tests/discovery-seeds-load.test.js && node tests/seed-resolve-t2.test.js && node tests/recipe-learn-pending-affordance.test.js && node tests/learned-local-provenance-exempt.test.js && node tests/provider-parity.test.js && node tests/recipe-schema-lock.test.js && node tests/capability-rot-detector.test.js && node tests/relearn-coalescing.test.js && node tests/rot-recurrence-classify.test.js && node tests/app-degraded-surfacing.test.js && node tests/relearn-router-wiring.test.js && node tests/import-extraction.test.js && node tests/import-forbidden-prescan.test.js && node tests/import-classify-gate-call.test.js && node tests/catalog-crosscheck.test.js && node tests/no-dead-entry.test.js && node tests/catalog-inline-shape.test.js && node tests/head-handler-cap.test.js && node tests/head-handler-upgrade.test.js && node tests/confluence-t1-handler.test.js && node tests/verify-origin-classification.test.js && node tests/guarded-write-failclosed.test.js && node tests/breadth-search-return.test.js && node tests/breadth-batch-gate.test.js && node tests/backing-status-annotation.test.js && node tests/t1-port-contract.test.js && node tests/t1-port-contract-gate.test.js && node tests/dom-only-default.test.js && node tests/sensitive-write-import-gate.test.js && node tests/coverage-report.test.js && node tests/payment-op-guard.test.js && node tests/vendor-augment-preserved.test.js && node tests/no-duplicate-stem.test.js && node tests/full-corpus-screen.test.js && node tests/full-corpus-scale.test.js && node tests/keyboard-attach-robustness.test.js && node --import tsx tests/no-orphan-descriptor.test.js", "test:lattice": "node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js", "test:mcp-smoke:lifecycle": "npm --prefix mcp run build && node tests/mcp-lifecycle-smoke.test.js", "test:mcp-smoke:tools": "npm --prefix mcp run build && node tests/mcp-tool-smoke.test.js", diff --git a/tests/control-panel-scroll-containment.test.js b/tests/control-panel-scroll-containment.test.js new file mode 100644 index 000000000..79133fb03 --- /dev/null +++ b/tests/control-panel-scroll-containment.test.js @@ -0,0 +1,56 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const repoRoot = path.resolve(__dirname, '..'); +const css = fs.readFileSync(path.join(repoRoot, 'extension', 'ui', 'options.css'), 'utf8'); +const html = fs.readFileSync(path.join(repoRoot, 'extension', 'ui', 'control_panel.html'), 'utf8'); + +function getCssRule(selector) { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = css.match(new RegExp(escaped + '\\s*\\{([\\s\\S]*?)\\}', 'm')); + return match ? match[1] : ''; +} + +console.log('--- Test: control panel scroll containment guard ---'); + +const htmlRule = getCssRule('html'); +assert(/height\s*:\s*100vh\s*;/.test(htmlRule), + 'html is fixed to the viewport height'); +assert(/overflow\s*:\s*hidden\s*;/.test(htmlRule), + 'html cannot become a secondary vertical scroller'); + +const bodyRule = getCssRule('body'); +assert(/height\s*:\s*100vh\s*;/.test(bodyRule), + 'body is fixed to the viewport height'); +assert(/overflow\s*:\s*hidden\s*;/.test(bodyRule), + 'body cannot become a secondary vertical scroller'); + +const containerRule = getCssRule('.dashboard-container'); +assert(/height\s*:\s*100vh\s*;/.test(containerRule), + 'dashboard container is viewport-bound'); +assert(/overflow\s*:\s*hidden\s*;/.test(containerRule), + 'dashboard container clips shell overflow'); + +const mainRule = getCssRule('.dashboard-main'); +assert(/min-height\s*:\s*0\s*;/.test(mainRule), + 'dashboard main can shrink inside the viewport-bound shell'); +assert(/overflow\s*:\s*hidden\s*;/.test(mainRule), + 'dashboard main does not become a competing scroller'); + +const contentRule = getCssRule('.dashboard-content'); +assert(/flex\s*:\s*1\s+1\s+auto\s*;/.test(contentRule), + 'dashboard content owns the remaining shell height'); +assert(/min-height\s*:\s*0\s*;/.test(contentRule), + 'dashboard content can shrink enough for its own scroller to engage'); +assert(/overflow-y\s*:\s*auto\s*;/.test(contentRule), + 'dashboard content remains the vertical scroller'); +assert(/overscroll-behavior\s*:\s*contain\s*;/.test(contentRule), + 'dashboard content contains wheel/trackpad scroll chaining at its edges'); + +assert(/]*id=["']branding["'][\s\S]*?▽0\.9\.90[\s\S]*?<\/section>/.test(html), + 'version footer remains present at the end of the control panel content'); + +console.log('PASS control panel scroll containment guard'); From f6f4d90a462268b382439d2edb8c7a9ade11c4f3 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 7 Jul 2026 21:38:21 -0500 Subject: [PATCH 08/26] feat(knowledge-graph): rework 3D graph into category overview with expandable detail Overview now renders one node per category instead of flattening straight to individual sites, with Full Detail (relabeled "Expanded" in the UI) branching each category out to its sites in a cap around the category's outward direction so they visibly fan off their parent. Adds animated zoom-to-fit when switching detail levels, stronger highlight emphasis (glow ring, bolder label, thicker link) on search matches, and drops the now-redundant third detail tier and unused learned-pattern aggregation from task-memory sites. --- .../lib/visualization/knowledge-graph.js | 436 +++++++++++------- extension/ui/control_panel.html | 2 +- extension/ui/site-guides-viewer.js | 2 +- 3 files changed, 264 insertions(+), 176 deletions(-) diff --git a/extension/lib/visualization/knowledge-graph.js b/extension/lib/visualization/knowledge-graph.js index fa4bb4559..b1a189b44 100644 --- a/extension/lib/visualization/knowledge-graph.js +++ b/extension/lib/visualization/knowledge-graph.js @@ -60,6 +60,8 @@ const KnowledgeGraph = (function () { // --------------------------------------------------------------- var _state = null; // active render state var _taskMemories = []; // task memory data for knowledge graph integration + var DIMMED_NODE_ALPHA = 0.38; + var DIMMED_LINK_ALPHA = 0.32; // --------------------------------------------------------------- // Data consolidation -- free-floating sites, color-coded by category @@ -81,115 +83,102 @@ const KnowledgeGraph = (function () { x3d: 0, y3d: 0, z3d: 0 }); - // Determine category order (for color assignment only) + // Category order (drives color + layout). Overview shows categories; + // Full Detail expands each category out to its actual sites. var orderedCats = CATEGORY_ORDER.filter(function (c) { return grouped[c]; }); var extraCats = Object.keys(grouped).filter(function (c) { return CATEGORY_ORDER.indexOf(c) === -1; }); var allCats = orderedCats.concat(extraCats); - // Build a flat list of all sites with their category color - var allSites = []; - for (var ci = 0; ci < allCats.length; ci++) { - var catName = allCats[ci]; - var guides = grouped[catName] || []; - var catColor = COLORS[ci % COLORS.length]; - for (var si = 0; si < guides.length; si++) { - allSites.push({ guide: guides[si], color: catColor, catName: catName, ci: ci, si: si }); - } - } - - // Distribute all sites freely on a sphere using golden-angle spiral - var R_SITE = 280; // site sphere radius - var R_DETAIL = 380; // detail leaf radius + var R_CAT = 180; // category sphere radius + var R_SITE = 350; // site shell radius var goldenAngle = Math.PI * (3 - Math.sqrt(5)); - var totalSites = allSites.length; + var nCat = allCats.length; + var expanded = detailLevel === 'full'; - for (var idx = 0; idx < totalSites; idx++) { - var entry = allSites[idx]; - var guide = entry.guide; - var siteId = 'site:' + entry.ci + ':' + entry.si; - var siteName = guide.site || 'Unknown'; - - // Golden-angle spiral placement - var theta = goldenAngle * idx; - var phi = Math.acos(1 - 2 * (idx + 0.5) / totalSites); - - var sx = R_SITE * Math.sin(phi) * Math.cos(theta); - var sy = R_SITE * Math.cos(phi); - var sz = R_SITE * Math.sin(phi) * Math.sin(theta); + for (var ci = 0; ci < nCat; ci++) { + var catName = allCats[ci]; + var guides = grouped[catName] || []; + var colorIdx = ci % COLORS.length; + var catColor = COLORS[colorIdx]; - var selectorCount = guide.selectors ? Object.keys(guide.selectors).length : 0; - var workflowCount = guide.workflows ? Object.keys(guide.workflows).length : 0; - var warningCount = guide.warnings ? guide.warnings.length : 0; + // Category node placed on inner sphere via golden-angle spiral + var cTheta = goldenAngle * ci; + var cPhi = Math.acos(1 - 2 * (ci + 0.5) / nCat); + var ccx = R_CAT * Math.sin(cPhi) * Math.cos(cTheta); + var ccy = R_CAT * Math.cos(cPhi); + var ccz = R_CAT * Math.sin(cPhi) * Math.sin(cTheta); + var catId = 'cat:' + ci; nodes.push({ - id: siteId, - label: siteName, - fullLabel: entry.catName, + id: catId, + label: catName, + fullLabel: [catName].concat(guides.map(function (g) { return g.site || ''; })).join(' '), depth: 1, type: 'site', - color: entry.color, - colorIndex: entry.ci % COLORS.length, - categoryName: entry.catName, - selectorCount: selectorCount, - workflowCount: workflowCount, - warningCount: warningCount, - x3d: sx, y3d: sy, z3d: sz + isCat: true, + color: catColor, + colorIndex: colorIdx, + categoryName: catName, + siteCount: guides.length, + selectorCount: 0, + workflowCount: 0, + warningCount: 0, + x3d: ccx, y3d: ccy, z3d: ccz }); - links.push({ source: 'root', target: siteId }); - - // Detail nodes (Full mode only) - if (detailLevel === 'full') { - var detailItems = []; - if (guide.selectors) { - var selKeys = Object.keys(guide.selectors).slice(0, 3); - for (var k = 0; k < selKeys.length; k++) { - detailItems.push({ label: selKeys[k], dtype: 'selector' }); - } - } - if (guide.workflows) { - var wfKeys = Object.keys(guide.workflows).slice(0, 2); - for (var k = 0; k < wfKeys.length; k++) { - detailItems.push({ label: wfKeys[k], dtype: 'workflow' }); - } - } + links.push({ source: 'root', target: catId }); + + if (!expanded || guides.length === 0) continue; + + // Expanded: scatter this category's sites in a cap around the + // category's outward direction so they branch off their parent. + var dir = normalize({ x: ccx, y: ccy, z: ccz }); + var perp1 = crossProduct(dir, { x: 0, y: 1, z: 0 }); + if (vecLen(perp1) < 0.01) perp1 = crossProduct(dir, { x: 1, y: 0, z: 0 }); + perp1 = normalize(perp1); + var perp2 = normalize(crossProduct(dir, perp1)); + var k = guides.length; + var capR = Math.min(165, 30 + k * 7); + + for (var si = 0; si < k; si++) { + var g = guides[si]; + var a = goldenAngle * si; + var rr = capR * Math.sqrt((si + 0.5) / k); + var ox = (perp1.x * Math.cos(a) + perp2.x * Math.sin(a)) * rr; + var oy = (perp1.y * Math.cos(a) + perp2.y * Math.sin(a)) * rr; + var oz = (perp1.z * Math.cos(a) + perp2.z * Math.sin(a)) * rr; + var sx = dir.x * R_SITE + ox; + var sy = dir.y * R_SITE + oy; + var sz = dir.z * R_SITE + oz; - // Direction from center for this site - var sDir = normalize({ x: sx, y: sy, z: sz }); - var perp1 = crossProduct(sDir, { x: 0, y: 1, z: 0 }); - if (vecLen(perp1) < 0.01) perp1 = crossProduct(sDir, { x: 1, y: 0, z: 0 }); - perp1 = normalize(perp1); - var perp2 = normalize(crossProduct(sDir, perp1)); - - for (var di = 0; di < detailItems.length; di++) { - var detId = 'det:' + entry.ci + ':' + entry.si + ':' + di; - var detAngle = (2 * Math.PI * di) / Math.max(detailItems.length, 1); - var spread = 40; - - var dx = sDir.x * R_DETAIL + (perp1.x * Math.cos(detAngle) + perp2.x * Math.sin(detAngle)) * spread; - var dy = sDir.y * R_DETAIL + (perp1.y * Math.cos(detAngle) + perp2.y * Math.sin(detAngle)) * spread; - var dz = sDir.z * R_DETAIL + (perp1.z * Math.cos(detAngle) + perp2.z * Math.sin(detAngle)) * spread; - - nodes.push({ - id: detId, - label: detailItems[di].label, - depth: 2, - type: 'detail', - detailType: detailItems[di].dtype, - color: entry.color, - colorIndex: entry.ci % COLORS.length, - x3d: dx, y3d: dy, z3d: dz - }); - links.push({ source: siteId, target: detId }); - } + nodes.push({ + id: 'site:' + ci + ':' + si, + label: g.site || 'Unknown', + fullLabel: [g.site || 'Unknown', catName].join(' '), + depth: 2, + type: 'site', + color: catColor, + colorIndex: colorIdx, + categoryName: catName, + selectorCount: g.selectors ? Object.keys(g.selectors).length : 0, + workflowCount: g.workflows ? Object.keys(g.workflows).length : 0, + warningCount: g.warnings ? g.warnings.length : 0, + x3d: sx, y3d: sy, z3d: sz + }); + links.push({ source: catId, target: 'site:' + ci + ':' + si }); } } // ---- Task Memory discovered sites ---- + // Extension-only: surfaces domains the user has visited that aren't already + // part of the built-in site guides, as root-level siblings of category + // nodes (their own pseudo-category). One tier only, same as built-in sites + // stopping at one tier below their category. if (_taskMemories.length > 0) { - // Collect existing site labels to avoid duplicates + // Collect existing site labels to avoid duplicates (category nodes reuse + // type 'site' too, so exclude those via isCat) var existingLabels = {}; for (var n = 0; n < nodes.length; n++) { - if (nodes[n].type === 'site') { + if (nodes[n].type === 'site' && !nodes[n].isCat) { existingLabels[nodes[n].label.toLowerCase()] = true; } } @@ -202,7 +191,7 @@ const KnowledgeGraph = (function () { (tm.metadata && tm.metadata.domain) || ''; if (!tmDomain) continue; if (!domainMap[tmDomain]) { - domainMap[tmDomain] = { selectors: [], patterns: [] }; + domainMap[tmDomain] = { selectors: [] }; } var tmLearned = (tm.typeData && tm.typeData.learned) || {}; if (tmLearned.selectors) { @@ -212,32 +201,26 @@ const KnowledgeGraph = (function () { } } } - if (tmLearned.patterns) { - for (var p = 0; p < tmLearned.patterns.length; p++) { - if (domainMap[tmDomain].patterns.indexOf(tmLearned.patterns[p]) === -1) { - domainMap[tmDomain].patterns.push(tmLearned.patterns[p]); - } - } - } } - // Add task-site nodes for domains not already in site guides + // Add task-site nodes (root-level siblings of category nodes) for + // domains not already covered by the built-in site guides var taskDomains = Object.keys(domainMap); - var taskStartIdx = totalSites; // continue golden-angle indexing + var taskStartIdx = nCat; // continue golden-angle indexing after categories + var tTotal = nCat + taskDomains.length; for (var ti = 0; ti < taskDomains.length; ti++) { var tdName = taskDomains[ti]; if (existingLabels[tdName.toLowerCase()]) continue; var tsId = 'task-site:' + ti; var tsIdx = taskStartIdx + ti; - var tTotal = totalSites + taskDomains.length; var tTheta = goldenAngle * tsIdx; var tPhi = Math.acos(1 - 2 * (tsIdx + 0.5) / tTotal); - var tsx = R_SITE * Math.sin(tPhi) * Math.cos(tTheta); - var tsy = R_SITE * Math.cos(tPhi); - var tsz = R_SITE * Math.sin(tPhi) * Math.sin(tTheta); + var tsx = R_CAT * Math.sin(tPhi) * Math.cos(tTheta); + var tsy = R_CAT * Math.cos(tPhi); + var tsz = R_CAT * Math.sin(tPhi) * Math.sin(tTheta); var domData = domainMap[tdName]; nodes.push({ @@ -254,48 +237,6 @@ const KnowledgeGraph = (function () { x3d: tsx, y3d: tsy, z3d: tsz }); links.push({ source: 'root', target: tsId }); - - // Detail nodes (full mode) -- selectors and patterns - if (detailLevel === 'full') { - var taskDetails = []; - var maxSel = Math.min(domData.selectors.length, 3); - for (var ds = 0; ds < maxSel; ds++) { - taskDetails.push({ label: domData.selectors[ds], dtype: 'selector' }); - } - var maxPat = Math.min(domData.patterns.length, 2); - for (var dp = 0; dp < maxPat; dp++) { - taskDetails.push({ label: domData.patterns[dp], dtype: 'pattern' }); - } - - if (taskDetails.length > 0) { - var tsDir = normalize({ x: tsx, y: tsy, z: tsz }); - var tsPerp1 = crossProduct(tsDir, { x: 0, y: 1, z: 0 }); - if (vecLen(tsPerp1) < 0.01) tsPerp1 = crossProduct(tsDir, { x: 1, y: 0, z: 0 }); - tsPerp1 = normalize(tsPerp1); - var tsPerp2 = normalize(crossProduct(tsDir, tsPerp1)); - - for (var tdi = 0; tdi < taskDetails.length; tdi++) { - var tdId = 'tdet:' + ti + ':' + tdi; - var tdAngle = (2 * Math.PI * tdi) / Math.max(taskDetails.length, 1); - var tSpread = 40; - - var tdx = tsDir.x * R_DETAIL + (tsPerp1.x * Math.cos(tdAngle) + tsPerp2.x * Math.sin(tdAngle)) * tSpread; - var tdy = tsDir.y * R_DETAIL + (tsPerp1.y * Math.cos(tdAngle) + tsPerp2.y * Math.sin(tdAngle)) * tSpread; - var tdz = tsDir.z * R_DETAIL + (tsPerp1.z * Math.cos(tdAngle) + tsPerp2.z * Math.sin(tdAngle)) * tSpread; - - nodes.push({ - id: tdId, - label: taskDetails[tdi].label, - depth: 2, - type: 'detail', - detailType: taskDetails[tdi].dtype, - color: TASK_SITE_COLOR, - x3d: tdx, y3d: tdy, z3d: tdz - }); - links.push({ source: tsId, target: tdId }); - } - } - } } } @@ -373,19 +314,25 @@ const KnowledgeGraph = (function () { var links = state.links; var t = state.time; - // Read CSS variables for theming - var computedStyle = getComputedStyle(state.container); - var bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#ffffff'; - var textColor = computedStyle.getPropertyValue('--text-primary').trim() || '#171717'; - var textSecondary = computedStyle.getPropertyValue('--text-secondary').trim() || '#525252'; - var borderColor = computedStyle.getPropertyValue('--border-color').trim() || '#e5e5e5'; - var isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + // Read CSS variables for theming (cached; only recomputed when theme changes) + var themeEl = state._themeEl || (state._themeEl = (state.container.closest && state.container.closest('[data-theme]')) || document.documentElement); + var themeKey = (themeEl && themeEl.getAttribute('data-theme')) || ''; + if (!state.colors || state._themeKey !== themeKey) { + var cs = getComputedStyle(state.container); + state.colors = { + text: cs.getPropertyValue('--text-primary').trim() || '#171717', + text2: cs.getPropertyValue('--text-secondary').trim() || '#525252' + }; + state._themeKey = themeKey; + } + var textColor = state.colors.text; + var textSecondary = state.colors.text2; + var isDark = themeKey === 'dark'; - // Clear + // Clear (transparent — let the themed CSS background of the container show through) ctx.save(); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - ctx.fillStyle = bgColor; - ctx.fillRect(0, 0, w, h); + ctx.clearRect(0, 0, w, h); // Project all nodes var projected = []; @@ -422,13 +369,19 @@ const KnowledgeGraph = (function () { // Depth-based opacity var avgZ = (src.z + tgt.z) / 2; var depthFactor = CAMERA_DISTANCE / (CAMERA_DISTANCE + avgZ); - var alpha = Math.max(0.08, Math.min(0.45, depthFactor * 0.5)); + var alpha = Math.max(0.14, Math.min(0.58, depthFactor * 0.58)); + var lineBoost = 1; // Dimming for highlight if (state.highlightQuery) { var srcMatch = isHighlighted(src.node, state.highlightQuery); var tgtMatch = isHighlighted(tgt.node, state.highlightQuery); - if (!srcMatch && !tgtMatch) alpha *= 0.15; + if (!srcMatch && !tgtMatch) { + alpha *= DIMMED_LINK_ALPHA; + } else { + alpha = Math.max(alpha, isDark ? 0.58 : 0.52); + lineBoost = 1.35; + } } var color = resolveColor(tgt.node, isDark); @@ -437,7 +390,7 @@ const KnowledgeGraph = (function () { ctx.lineTo(tgt.x, tgt.y); ctx.strokeStyle = color; ctx.globalAlpha = alpha; - ctx.lineWidth = Math.max(0.5, 1.5 * depthFactor); + ctx.lineWidth = Math.max(0.75, 1.65 * depthFactor * lineBoost); ctx.stroke(); ctx.globalAlpha = 1; } @@ -458,15 +411,21 @@ const KnowledgeGraph = (function () { var highlighted = true; if (state.highlightQuery) { highlighted = isHighlighted(n, state.highlightQuery); - if (!highlighted) depthAlpha *= 0.15; + if (n.type === 'root') { + depthAlpha = Math.max(depthAlpha * 0.72, 0.55); + } else if (!highlighted) { + depthAlpha *= DIMMED_NODE_ALPHA; + } else { + depthAlpha = Math.max(depthAlpha, 0.92); + } } if (n.type === 'root') { drawRootNode(ctx, p.x, p.y, s, n, depthAlpha, textColor); } else if (n.type === 'site') { - drawSiteNode(ctx, p.x, p.y, s, n, depthAlpha, textColor, textSecondary, isDark); + drawSiteNode(ctx, p.x, p.y, s, n, depthAlpha, textColor, textSecondary, isDark, highlighted && !!state.highlightQuery); } else if (n.type === 'task-site') { - drawTaskSiteNode(ctx, p.x, p.y, s, n, depthAlpha, textColor, textSecondary, isDark); + drawTaskSiteNode(ctx, p.x, p.y, s, n, depthAlpha, textColor, textSecondary, isDark, highlighted && !!state.highlightQuery); } else if (n.type === 'detail') { drawDetailNode(ctx, p.x, p.y, s, n, depthAlpha, isDark); } @@ -504,28 +463,36 @@ const KnowledgeGraph = (function () { ctx.globalAlpha = 1; } - function drawSiteNode(ctx, x, y, scale, node, alpha, textColor, textSecondary, isDark) { - var r = 8 * scale; + function drawSiteNode(ctx, x, y, scale, node, alpha, textColor, textSecondary, isDark, emphasized) { + var isCat = node.isCat; + var r = (isCat ? 13 : 7) * scale * (emphasized ? 1.12 : 1); var color = resolveColor(node, isDark); ctx.globalAlpha = alpha; + if (emphasized) { + ctx.beginPath(); + ctx.arc(x, y, r + 7 * scale, 0, Math.PI * 2); + ctx.fillStyle = hexToRgba(color, isDark ? 0.18 : 0.12); + ctx.fill(); + } + // Filled circle with category color ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); - ctx.fillStyle = hexToRgba(color, 0.35); + ctx.fillStyle = hexToRgba(color, isCat ? 0.68 : 0.42); ctx.fill(); ctx.strokeStyle = color; - ctx.lineWidth = Math.max(1.5, 2 * scale); + ctx.lineWidth = Math.max(1.5, (isCat ? 2.7 : 1.9) * scale * (emphasized ? 1.25 : 1)); ctx.stroke(); // Label below - var fontSize = Math.max(8, Math.round(10 * scale)); + var fontSize = Math.max(isCat ? 10 : 8, Math.round((isCat ? 13 : 9.5) * scale * (emphasized ? 1.04 : 1))); ctx.fillStyle = textColor; - ctx.font = '500 ' + fontSize + 'px -apple-system, BlinkMacSystemFont, sans-serif'; + ctx.font = (isCat || emphasized ? '700 ' : '500 ') + fontSize + 'px -apple-system, BlinkMacSystemFont, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - ctx.fillText(truncate(node.label, 16), x, y + r + 3 * scale); + ctx.fillText(truncate(node.label, isCat ? 22 : 16), x, y + r + 3 * scale); ctx.globalAlpha = 1; } @@ -542,12 +509,19 @@ const KnowledgeGraph = (function () { ctx.globalAlpha = 1; } - function drawTaskSiteNode(ctx, x, y, scale, node, alpha, textColor, textSecondary, isDark) { - var r = 8 * scale; + function drawTaskSiteNode(ctx, x, y, scale, node, alpha, textColor, textSecondary, isDark, emphasized) { + var r = 8 * scale * (emphasized ? 1.12 : 1); var color = isDark ? TASK_SITE_COLOR_DARK : TASK_SITE_COLOR; ctx.globalAlpha = alpha; + if (emphasized) { + ctx.beginPath(); + ctx.arc(x, y, r + 6 * scale, 0, Math.PI * 2); + ctx.fillStyle = hexToRgba(color, isDark ? 0.18 : 0.12); + ctx.fill(); + } + // Filled circle with teal color and dashed border ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); @@ -586,7 +560,7 @@ const KnowledgeGraph = (function () { function truncate(str, maxLen) { if (!str) return ''; if (str.length <= maxLen) return str; - return str.substring(0, maxLen - 1) + '\u2026'; + return str.substring(0, maxLen - 1) + '…'; } function resolveColor(node, isDark) { @@ -617,6 +591,98 @@ const KnowledgeGraph = (function () { return label.indexOf(q) !== -1 || full.indexOf(q) !== -1 || cat.indexOf(q) !== -1; } + function getDefaultZoom(container, detailLevel) { + if (!container || !container.getBoundingClientRect) return 1; + var rect = container.getBoundingClientRect(); + var w = rect.width || container.clientWidth || 940; + var h = rect.height || container.clientHeight || 520; + var expanded = detailLevel === 'full'; + + // Desktop keeps the reference framing. Smaller viewports pull the 3D + // shell back so the graph reads as a map instead of clipped fragments. + if (w >= 760 && h >= 460) return expanded ? 0.82 : 1; + + var targetW = expanded ? 760 : 520; + var targetH = expanded ? 560 : 430; + var minZoom = expanded ? 0.46 : 0.62; + var zoom = Math.min(w / targetW, h / targetH); + return Math.max(minZoom, Math.min(1, zoom)); + } + + function getFitZoom(state, detailLevel) { + var defaultZoom = getDefaultZoom(state.container, detailLevel); + if (!state || detailLevel !== 'full' || !state.canvas || !state.nodes || state.nodes.length === 0) { + return defaultZoom; + } + + var w = state.canvas.width / state.dpr; + var h = state.canvas.height / state.dpr; + if (!w || !h) return defaultZoom; + + var cx = w / 2; + var cy = h / 2; + var minX = Infinity; + var minY = Infinity; + var maxX = -Infinity; + var maxY = -Infinity; + + for (var i = 0; i < state.nodes.length; i++) { + var n = state.nodes[i]; + var p = project(n.x3d, n.y3d, n.z3d, state.rotY, state.rotX, 1, cx, cy); + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x); + maxY = Math.max(maxY, p.y); + } + + if (!isFinite(minX) || !isFinite(minY) || !isFinite(maxX) || !isFinite(maxY)) { + return defaultZoom; + } + + var halfGraphW = Math.max(maxX - cx, cx - minX); + var halfGraphH = Math.max(maxY - cy, cy - minY); + if (halfGraphW <= 0 || halfGraphH <= 0) return defaultZoom; + + var marginX = Math.min(72, Math.max(36, w * 0.07)); + var marginY = Math.min(58, Math.max(32, h * 0.09)); + var fitZoom = Math.min((w / 2 - marginX) / halfGraphW, (h / 2 - marginY) / halfGraphH); + var minZoom = w >= 760 && h >= 460 ? 0.6 : 0.42; + return Math.max(minZoom, Math.min(defaultZoom, fitZoom)); + } + + function easeOutCubic(t) { + return 1 - Math.pow(1 - t, 3); + } + + function animateZoomTo(state, targetZoom, duration) { + if (!state) return; + targetZoom = Math.max(0.3, Math.min(3.0, targetZoom)); + if (state.zoomAnimId) { + cancelAnimationFrame(state.zoomAnimId); + state.zoomAnimId = null; + } + + var startZoom = state.zoom; + if (Math.abs(startZoom - targetZoom) < 0.005) { + state.zoom = targetZoom; + return; + } + + var startTime = performance.now(); + function step(now) { + if (!state.running) return; + var t = Math.min(1, (now - startTime) / duration); + state.zoom = startZoom + (targetZoom - startZoom) * easeOutCubic(t); + if (t < 1) { + state.zoomAnimId = requestAnimationFrame(step); + } else { + state.zoom = targetZoom; + state.zoomAnimId = null; + } + } + state.zoomAnimId = requestAnimationFrame(step); + } + // --------------------------------------------------------------- // Animation loop // --------------------------------------------------------------- @@ -704,8 +770,15 @@ const KnowledgeGraph = (function () { // Scroll zoom canvas.addEventListener('wheel', function (e) { e.preventDefault(); + // User zoom takes over: cancel any in-flight detail-level fit animation + // so its step() stops overwriting state.zoom. + if (state.zoomAnimId) { + cancelAnimationFrame(state.zoomAnimId); + state.zoomAnimId = null; + } var delta = e.deltaY > 0 ? -0.08 : 0.08; state.zoom = Math.max(0.3, Math.min(3.0, state.zoom + delta)); + state.userZoomed = true; }, { passive: false }); // Touch support @@ -784,7 +857,10 @@ const KnowledgeGraph = (function () { if (!tooltip) return; var html = ''; - if (node.type === 'site' || node.type === 'task-site') { + if (node.isCat) { + html = '' + escapeHtml(node.label) + ''; + html += '
' + (node.siteCount || 0) + ' sites'; + } else if (node.type === 'site' || node.type === 'task-site') { html = '' + escapeHtml(node.label) + ''; if (node.categoryName) html += '
' + escapeHtml(node.categoryName) + ''; var meta = []; @@ -870,7 +946,8 @@ const KnowledgeGraph = (function () { detailLevel: opts.detailLevel, rotY: 0, rotX: 0.15, - zoom: 1.0, + zoom: typeof opts.initialZoom === 'number' ? opts.initialZoom : getDefaultZoom(container, opts.detailLevel), + userZoomed: typeof opts.initialZoom === 'number', isDragging: false, dragStartX: 0, dragStartY: 0, @@ -880,6 +957,7 @@ const KnowledgeGraph = (function () { lastDragY: 0, momentumX: 0, momentumY: 0, + zoomAnimId: null, time: 0, running: true, animId: null, @@ -893,7 +971,10 @@ const KnowledgeGraph = (function () { // Handle resize -- re-sync canvas buffer to CSS layout state._syncCanvasSize = syncCanvasSize; - state._resizeHandler = function () { syncCanvasSize(); }; + state._resizeHandler = function () { + syncCanvasSize(); + if (!state.userZoomed) state.zoom = getDefaultZoom(container, state.detailLevel); + }; window.addEventListener('resize', state._resizeHandler); setupInteraction(state); @@ -906,6 +987,7 @@ const KnowledgeGraph = (function () { if (state) { state.running = false; if (state.animId) cancelAnimationFrame(state.animId); + if (state.zoomAnimId) cancelAnimationFrame(state.zoomAnimId); if (state._resizeHandler) window.removeEventListener('resize', state._resizeHandler); if (state.canvas) state.canvas.remove(); if (state.tooltip) state.tooltip.remove(); @@ -927,6 +1009,12 @@ const KnowledgeGraph = (function () { var data = buildKnowledgeGraphData(level); _state.nodes = data.nodes; _state.links = data.links; + if (level === 'full') { + _state.userZoomed = false; + animateZoomTo(_state, getFitZoom(_state, level), 650); + } else if (!_state.userZoomed) { + animateZoomTo(_state, getDefaultZoom(_state.container, level), 420); + } } function highlight(query) { diff --git a/extension/ui/control_panel.html b/extension/ui/control_panel.html index 6f079dcef..0d675c498 100644 --- a/extension/ui/control_panel.html +++ b/extension/ui/control_panel.html @@ -1023,7 +1023,7 @@

- +
diff --git a/extension/ui/site-guides-viewer.js b/extension/ui/site-guides-viewer.js index 0c5624103..00769ca9b 100644 --- a/extension/ui/site-guides-viewer.js +++ b/extension/ui/site-guides-viewer.js @@ -44,7 +44,7 @@ }); } - // Wire detail toggle (Overview / Full Detail) + // Wire detail toggle (Overview / Expanded) var toggleContainer = document.getElementById('knowledgeDetailToggle'); if (toggleContainer) { toggleContainer.addEventListener('click', function (e) { From fed0acc6d1048db334712b578cd63b30ed29d92e Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 7 Jul 2026 21:38:30 -0500 Subject: [PATCH 09/26] chore(showcase): sync knowledge graph categories with full site-guide list CATEGORY_ORDER only listed 6 of the built-in site-guide categories, so the showcase demo undercounted what the extension's knowledge graph actually covers. Add the missing categories and drop the stale hardcoded 9-categories/43+-sites figure from the header comment. --- showcase/js/knowledge-graph.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/showcase/js/knowledge-graph.js b/showcase/js/knowledge-graph.js index 0325781ed..73d1e6904 100644 --- a/showcase/js/knowledge-graph.js +++ b/showcase/js/knowledge-graph.js @@ -2,7 +2,7 @@ * KnowledgeGraph -- Consolidated 3D mind map of FSB's built-in site knowledge * * Pure Canvas with 3D projection. No external dependencies. - * Renders all 9 categories and 43+ sites as a single rotating 3D graph. + * Renders all built-in site-guide categories and sites as a single rotating 3D graph. * * Public API: * KnowledgeGraph.render(container, options) - Create canvas, build data, start animation @@ -41,7 +41,15 @@ const KnowledgeGraph = (function () { 'Coding Platforms', 'Career & Job Search', 'Gaming Platforms', - 'Productivity Tools' + 'Productivity Tools', + 'Design & Whiteboard', + 'Games', + 'Media', + 'Music', + 'News', + 'Reference', + 'Sports', + 'Utilities' ]; // Task-discovered site color (teal/cyan -- distinct from built-in categories) From 42f6ea02f10519f8c6723c9275c59ede2dc05e0e Mon Sep 17 00:00:00 2001 From: Lakshman Date: Thu, 9 Jul 2026 16:51:17 -0500 Subject: [PATCH 10/26] docs: add community guidelines --- CODE_OF_CONDUCT.md | 25 +++++++++++++++++++++++++ CONTRIBUTING.md | 31 +++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..0070a5542 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,25 @@ +# Code of Conduct + +## Our Commitment + +FSB aims to be a welcoming, respectful, and professional project for everyone who participates. + +## Expected Behavior + +1. Treat people with respect and assume good intent. +2. Offer constructive feedback and keep discussion focused on the project. +3. Respect differing experiences, perspectives, and skill levels. + +## Unacceptable Behavior + +1. Harassment, discrimination, threats, or personal attacks. +2. Deliberate disruption of discussions, Issues, or pull requests. +3. Publishing private information without permission. + +## Enforcement + +Maintainers may edit or remove content, reject contributions, or limit participation when behavior conflicts with this policy. + +## Reporting + +Report conduct concerns by opening a [GitHub Issue](https://github.com/fullselfbrowsing/FSB/issues). Include enough context for maintainers to review the concern. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..56097ff9a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing to FSB + +Thank you for contributing to FSB. + +## Start With an Issue + +Open a GitHub Issue before beginning a substantial feature, behavior change, or architectural change. This lets maintainers confirm the direction and avoid duplicate work. + +Small documentation corrections and focused fixes can be submitted directly as a pull request. + +## Set Up Locally + +Follow the Local Development Setup section in the README. It covers prerequisites, installation, and how to run FSB during development. + +## Before Opening a Pull Request + +1. Keep changes focused and explain the user visible impact. +2. Update relevant documentation when behavior or configuration changes. +3. Run the checks that cover the change. For general changes, run: + +```sh +npm run validate:extension +npm test +npm run test:mcp-smoke +``` + +4. Link the related Issue and describe what you tested in the pull request. + +## Security + +Do not report security vulnerabilities in public Issues or pull requests. Contact a maintainer privately through GitHub so that responsible disclosure can be arranged. From 8e46173a7a15854626a325ad79b3179ea24f32b2 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Wed, 15 Jul 2026 06:48:02 -0500 Subject: [PATCH 11/26] feat(sheets): add bounded OAuth API client --- docs/google-sheets-api.md | 31 +++ extension/background.js | 38 +++ extension/manifest.json | 8 + extension/ui/control_panel.html | 27 ++ extension/ui/options.js | 86 ++++++ extension/utils/google-sheets-api.js | 382 +++++++++++++++++++++++++++ tests/google-sheets-api.test.js | 186 +++++++++++++ 7 files changed, 758 insertions(+) create mode 100644 docs/google-sheets-api.md create mode 100644 extension/utils/google-sheets-api.js create mode 100644 tests/google-sheets-api.test.js diff --git a/docs/google-sheets-api.md b/docs/google-sheets-api.md new file mode 100644 index 000000000..6a748c631 --- /dev/null +++ b/docs/google-sheets-api.md @@ -0,0 +1,31 @@ +# Google Sheets API integration + +FSB includes a bounded Google Sheets v4 integration for reading spreadsheet metadata and values, updating or appending values, and clearing a range. It uses Chrome Identity for OAuth and never exposes a generic HTTP request surface. + +## One-time Google Cloud setup + +The repository intentionally ships with a nonfunctional OAuth client placeholder. A release owner must: + +1. Enable the Google Sheets API in a Google Cloud project. +2. Configure the OAuth consent screen for that project. +3. Create an OAuth client of type **Chrome Extension**, tied to the stable ID of the packaged FSB extension. +4. Replace `REPLACE_WITH_FSB_GOOGLE_OAUTH_CLIENT_ID.apps.googleusercontent.com` in `extension/manifest.json` with that client ID. +5. Reload the extension, open the FSB control panel, and click **Connect Google Sheets**. + +Until step 4 is complete, the runtime fails closed with `GOOGLE_SHEETS_OAUTH_NOT_CONFIGURED` and does not open a Google authorization prompt. + +## Access and behavior + +The extension requests only `https://www.googleapis.com/auth/spreadsheets`. Capability calls obtain cached authorization non-interactively; only the control-panel Connect button may start an interactive OAuth flow. Access tokens remain in Chrome Identity's cache and are never stored, logged, returned to callers, or placed in errors. + +The API facade permits only these operations: + +- `gsheets.get_spreadsheet` +- `gsheets.get_values` +- `gsheets.update_values` +- `gsheets.append_values` +- `gsheets.clear_values` + +Requests are restricted to `https://sheets.googleapis.com/v4`, validate spreadsheet IDs and ranges, cap request and response sizes, time out, and retry once only after an HTTP 401. Google Sheets is classified as a sensitive origin, so write and destructive operations remain behind FSB's consent policy. + +The existing `fill_sheet` and `read_sheet` browser-automation tools remain available as a fallback. Spreadsheet API and fallback-tool session records are reduced to shape-only diagnostics before recording; spreadsheet IDs, ranges, sheet names, values, formulas, and response bodies are discarded. diff --git a/extension/background.js b/extension/background.js index 8aa8021ef..78fe2268a 100644 --- a/extension/background.js +++ b/extension/background.js @@ -190,6 +190,7 @@ try { // before the router's post-fetch classify hook runs. try { importScripts('utils/capability-rot-detector.js'); } catch (e) { console.error('[FSB] Failed to load capability-rot-detector.js:', e.message); } try { importScripts('utils/capability-catalog.js'); } catch (e) { console.error('[FSB] Failed to load capability-catalog.js:', e.message); } +try { importScripts('utils/google-sheets-api.js'); } catch (e) { console.error('[FSB] Failed to load google-sheets-api.js:', e.message); } try { importScripts('utils/capability-router.js'); } catch (e) { console.error('[FSB] Failed to load capability-router.js:', e.message); } // Phase 29 Plan 03 (v0.9.99 CAT-02): the bundled-head T1a handler modules. These @@ -7625,6 +7626,43 @@ const fsbHandleRuntimeMessage = (request, sender, sendResponse) => { automationLogger.logComm(null, 'receive', request.action || 'unknown', true, { tabId: sender.tab?.id }); switch (request.action) { + case 'google-sheets:get-status': { + var sheetsStatusApi = globalThis && globalThis.FsbGoogleSheetsApi; + if (!sheetsStatusApi || typeof sheetsStatusApi.status !== 'function') { + sendResponse({ success: false, code: 'GOOGLE_SHEETS_IDENTITY_UNAVAILABLE', errorCode: 'GOOGLE_SHEETS_IDENTITY_UNAVAILABLE', error: 'Google Sheets integration is unavailable.' }); + return false; + } + Promise.resolve(sheetsStatusApi.status()).then(sendResponse, function () { + sendResponse({ success: false, code: 'GOOGLE_SHEETS_API_ERROR', errorCode: 'GOOGLE_SHEETS_API_ERROR', error: 'Google Sheets status check failed.' }); + }); + return true; + } + + case 'google-sheets:connect': + case 'google-sheets:disconnect': { + var controlPanelUrl = chrome.runtime.getURL('ui/control_panel.html'); + var senderUrl = sender && typeof sender.url === 'string' ? sender.url : ''; + var fromControlPanel = !sender.tab && ( + senderUrl === controlPanelUrl || + senderUrl.indexOf(controlPanelUrl + '#') === 0 || + senderUrl.indexOf(controlPanelUrl + '?') === 0 + ); + if (!fromControlPanel || request.userInitiated !== true) { + sendResponse({ success: false, code: 'GOOGLE_SHEETS_AUTH_FAILED', errorCode: 'GOOGLE_SHEETS_AUTH_FAILED', error: 'Google Sheets connection changes require a control-panel click.' }); + return false; + } + var sheetsAuthApi = globalThis && globalThis.FsbGoogleSheetsApi; + var authMethod = request.action === 'google-sheets:connect' ? 'connect' : 'disconnect'; + if (!sheetsAuthApi || typeof sheetsAuthApi[authMethod] !== 'function') { + sendResponse({ success: false, code: 'GOOGLE_SHEETS_IDENTITY_UNAVAILABLE', errorCode: 'GOOGLE_SHEETS_IDENTITY_UNAVAILABLE', error: 'Google Sheets integration is unavailable.' }); + return false; + } + Promise.resolve(sheetsAuthApi[authMethod]()).then(sendResponse, function () { + sendResponse({ success: false, code: 'GOOGLE_SHEETS_AUTH_FAILED', errorCode: 'GOOGLE_SHEETS_AUTH_FAILED', error: 'Google Sheets connection change failed.' }); + }); + return true; + } + case 'fsbAuditLogClearAndExport': { // Race-safe clear-and-export delegated from a page realm (control // panel / sidepanel). Running the read/set here serializes it against diff --git a/extension/manifest.json b/extension/manifest.json index 31079d974..3c49a5eed 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -6,6 +6,8 @@ "homepage_url": "https://full-selfbrowsing.com", "permissions": [ "activeTab", + "identity", + "identity.email", "scripting", "storage", "unlimitedStorage", @@ -25,6 +27,12 @@ "background": { "service_worker": "background.js" }, + "oauth2": { + "client_id": "REPLACE_WITH_FSB_GOOGLE_OAUTH_CLIENT_ID.apps.googleusercontent.com", + "scopes": [ + "https://www.googleapis.com/auth/spreadsheets" + ] + }, "icons": { "16": "assets/icon16.png", "48": "assets/icon48.png", diff --git a/extension/ui/control_panel.html b/extension/ui/control_panel.html index 0d675c498..05195afaa 100644 --- a/extension/ui/control_panel.html +++ b/extension/ui/control_panel.html @@ -358,6 +358,33 @@

CAPTCHA Solver

+ +
+
+
+
+

Google Sheets

+

Connect Chrome Identity for bounded Sheets API access.

+
+
+
+
+
+
+
Checking connection...
+
Authorization prompts only open after you click Connect.
+
+
+
+
+ + +
+
diff --git a/extension/ui/options.js b/extension/ui/options.js index 15a789ced..f19c99d62 100644 --- a/extension/ui/options.js +++ b/extension/ui/options.js @@ -145,6 +145,9 @@ function initializeDashboard() { // Setup event listeners setupEventListeners(); + // Check cached Sheets authorization without opening an OAuth prompt. + refreshGoogleSheetsStatus(); + // Wire up minus/value/plus numeric steppers initFsbSteppers(); @@ -266,6 +269,11 @@ function cacheElements() { // API Status Card elements.apiStatusCard = document.getElementById('apiStatusCard'); + elements.googleSheetsStatusCard = document.getElementById('googleSheetsStatusCard'); + elements.googleSheetsStatus = document.getElementById('googleSheetsStatus'); + elements.googleSheetsStatusDetail = document.getElementById('googleSheetsStatusDetail'); + elements.googleSheetsConnectBtn = document.getElementById('googleSheetsConnectBtn'); + elements.googleSheetsDisconnectBtn = document.getElementById('googleSheetsDisconnectBtn'); // Logs elements.logsDisplay = document.getElementById('logsDisplay'); @@ -735,6 +743,13 @@ function setupEventListeners() { if (elements.fullApiTest) { elements.fullApiTest.addEventListener('click', runFullApiTest); } + + if (elements.googleSheetsConnectBtn) { + elements.googleSheetsConnectBtn.addEventListener('click', connectGoogleSheets); + } + if (elements.googleSheetsDisconnectBtn) { + elements.googleSheetsDisconnectBtn.addEventListener('click', disconnectGoogleSheets); + } // Save bar if (elements.saveBtn) { @@ -1869,6 +1884,77 @@ function updateApiStatusCard(status, title, detail) { } } +function renderGoogleSheetsStatus(result) { + const connected = !!(result && result.connected === true); + const configured = !!(result && result.configured === true); + const cardState = connected ? 'connected' : (configured ? 'disconnected' : 'error'); + const title = connected + ? `Connected${result.email ? ` as ${result.email}` : ''}` + : (configured ? 'Not connected' : 'OAuth setup required'); + const detail = connected + ? 'Sheets capabilities use cached authorization without prompting.' + : (configured + ? 'Click Connect to authorize Google Sheets.' + : ((result && result.message) || (result && result.error) || 'A release owner must configure the Chrome OAuth client ID.')); + + if (elements.googleSheetsStatusCard) { + elements.googleSheetsStatusCard.className = `api-status-card ${cardState}`; + const icon = elements.googleSheetsStatusCard.querySelector('.status-icon i'); + if (icon) { + icon.className = connected ? 'fas fa-check-circle' : (configured ? 'fas fa-circle-minus' : 'fas fa-triangle-exclamation'); + } + } + if (elements.googleSheetsStatus) elements.googleSheetsStatus.textContent = title; + if (elements.googleSheetsStatusDetail) elements.googleSheetsStatusDetail.textContent = detail; + if (elements.googleSheetsConnectBtn) { + elements.googleSheetsConnectBtn.hidden = connected; + elements.googleSheetsConnectBtn.disabled = !configured; + } + if (elements.googleSheetsDisconnectBtn) elements.googleSheetsDisconnectBtn.hidden = !connected; +} + +async function refreshGoogleSheetsStatus() { + try { + const result = await chrome.runtime.sendMessage({ action: 'google-sheets:get-status' }); + renderGoogleSheetsStatus(result || { configured: false, connected: false }); + } catch (_error) { + renderGoogleSheetsStatus({ configured: false, connected: false, error: 'Google Sheets status is unavailable.' }); + } +} + +async function connectGoogleSheets() { + if (!elements.googleSheetsConnectBtn || elements.googleSheetsConnectBtn.disabled) return; + elements.googleSheetsConnectBtn.disabled = true; + try { + const result = await chrome.runtime.sendMessage({ + action: 'google-sheets:connect', + userInitiated: true + }); + renderGoogleSheetsStatus(result || { configured: true, connected: false }); + showToast(result && result.connected ? 'Google Sheets connected' : ((result && result.error) || 'Google Sheets authorization failed'), result && result.connected ? 'success' : 'error'); + } catch (_error) { + showToast('Google Sheets authorization failed', 'error'); + await refreshGoogleSheetsStatus(); + } +} + +async function disconnectGoogleSheets() { + if (!elements.googleSheetsDisconnectBtn) return; + elements.googleSheetsDisconnectBtn.disabled = true; + try { + const result = await chrome.runtime.sendMessage({ + action: 'google-sheets:disconnect', + userInitiated: true + }); + renderGoogleSheetsStatus(result || { configured: true, connected: false }); + showToast(result && result.success ? 'Google Sheets disconnected' : 'Could not disconnect Google Sheets', result && result.success ? 'success' : 'error'); + } catch (_error) { + showToast('Could not disconnect Google Sheets', 'error'); + } finally { + elements.googleSheetsDisconnectBtn.disabled = false; + } +} + async function testApiConnection() { if (dashboardState.isApiTesting) return; diff --git a/extension/utils/google-sheets-api.js b/extension/utils/google-sheets-api.js new file mode 100644 index 000000000..04eaadde0 --- /dev/null +++ b/extension/utils/google-sheets-api.js @@ -0,0 +1,382 @@ +(function (global) { + 'use strict'; + + var SHEETS_BASE_URL = 'https://sheets.googleapis.com/v4'; + var SHEETS_SCOPE = 'https://www.googleapis.com/auth/spreadsheets'; + var REQUEST_TIMEOUT_MS = 15000; + var MAX_REQUEST_BODY_BYTES = 1024 * 1024; + var MAX_RESPONSE_BODY_BYTES = 5 * 1024 * 1024; + var PLACEHOLDER_CLIENT_ID = /(?:REPLACE|PLACEHOLDER|YOUR[_-]|INVALID|EXAMPLE)/i; + var SPREADSHEET_ID = /^[A-Za-z0-9_-]{10,200}$/; + var SAFE_RANGE_MAX = 500; + + var ERROR_MESSAGES = { + GOOGLE_SHEETS_OAUTH_NOT_CONFIGURED: 'Google Sheets OAuth is not configured for this extension build.', + GOOGLE_SHEETS_IDENTITY_UNAVAILABLE: 'Chrome Identity is unavailable.', + GOOGLE_SHEETS_NOT_CONNECTED: 'Google Sheets is not connected. Connect it from the FSB control panel.', + GOOGLE_SHEETS_AUTH_FAILED: 'Google Sheets authorization failed.', + GOOGLE_SHEETS_INVALID_ARGUMENT: 'A Google Sheets request argument is invalid.', + GOOGLE_SHEETS_REQUEST_TOO_LARGE: 'The Google Sheets request is too large.', + GOOGLE_SHEETS_TIMEOUT: 'The Google Sheets request timed out.', + GOOGLE_SHEETS_NETWORK_ERROR: 'The Google Sheets API could not be reached.', + GOOGLE_SHEETS_ACCESS_DENIED: 'Google denied access to this spreadsheet.', + GOOGLE_SHEETS_NOT_FOUND: 'The requested spreadsheet or range was not found.', + GOOGLE_SHEETS_RATE_LIMITED: 'Google Sheets temporarily rate-limited this request.', + GOOGLE_SHEETS_API_ERROR: 'The Google Sheets API request failed.', + GOOGLE_SHEETS_RESPONSE_TOO_LARGE: 'The Google Sheets API response was too large.' + }; + + function typedError(code, extra) { + var out = { + success: false, + code: code, + errorCode: code, + error: ERROR_MESSAGES[code] || ERROR_MESSAGES.GOOGLE_SHEETS_API_ERROR + }; + if (extra && Number.isFinite(extra.status)) { out.status = extra.status; } + return out; + } + + function encodePathSegment(value) { + return encodeURIComponent(String(value)).replace(/[!'()*]/g, function (character) { + return '%' + character.charCodeAt(0).toString(16).toUpperCase(); + }); + } + + function isConfigured(chromeApi) { + try { + var manifest = chromeApi && chromeApi.runtime && chromeApi.runtime.getManifest + ? chromeApi.runtime.getManifest() + : null; + var oauth = manifest && manifest.oauth2; + var clientId = oauth && typeof oauth.client_id === 'string' ? oauth.client_id.trim() : ''; + var scopes = oauth && Array.isArray(oauth.scopes) ? oauth.scopes : []; + return !!clientId && !PLACEHOLDER_CLIENT_ID.test(clientId) && scopes.indexOf(SHEETS_SCOPE) !== -1; + } catch (_e) { + return false; + } + } + + function runtimeError(chromeApi) { + try { + return chromeApi && chromeApi.runtime && chromeApi.runtime.lastError + ? chromeApi.runtime.lastError + : null; + } catch (_e) { + return null; + } + } + + function createClient(deps) { + deps = deps || {}; + var chromeApi = deps.chrome || global.chrome; + var fetchFn = deps.fetch || global.fetch; + var AbortControllerCtor = deps.AbortController || global.AbortController; + var setTimer = deps.setTimeout || global.setTimeout; + var clearTimer = deps.clearTimeout || global.clearTimeout; + + function configurationError() { + if (!isConfigured(chromeApi)) { + return typedError('GOOGLE_SHEETS_OAUTH_NOT_CONFIGURED'); + } + if (!chromeApi || !chromeApi.identity || typeof chromeApi.identity.getAuthToken !== 'function') { + return typedError('GOOGLE_SHEETS_IDENTITY_UNAVAILABLE'); + } + return null; + } + + function getToken(interactive) { + var configError = configurationError(); + if (configError) { return Promise.reject(configError); } + return new Promise(function (resolve, reject) { + try { + chromeApi.identity.getAuthToken({ interactive: interactive === true }, function (token) { + var lastError = runtimeError(chromeApi); + if (lastError || typeof token !== 'string' || !token) { + reject(typedError(interactive ? 'GOOGLE_SHEETS_AUTH_FAILED' : 'GOOGLE_SHEETS_NOT_CONNECTED')); + return; + } + resolve(token); + }); + } catch (_e) { + reject(typedError(interactive ? 'GOOGLE_SHEETS_AUTH_FAILED' : 'GOOGLE_SHEETS_NOT_CONNECTED')); + } + }); + } + + function removeCachedToken(token) { + if (!token || !chromeApi || !chromeApi.identity || typeof chromeApi.identity.removeCachedAuthToken !== 'function') { + return Promise.resolve(); + } + return new Promise(function (resolve) { + try { + chromeApi.identity.removeCachedAuthToken({ token: token }, function () { resolve(); }); + } catch (_e) { + resolve(); + } + }); + } + + function profile() { + if (!chromeApi || !chromeApi.identity || typeof chromeApi.identity.getProfileUserInfo !== 'function') { + return Promise.resolve({}); + } + return new Promise(function (resolve) { + try { + chromeApi.identity.getProfileUserInfo({ accountStatus: 'ANY' }, function (info) { + if (runtimeError(chromeApi)) { resolve({}); return; } + resolve(info && typeof info === 'object' ? info : {}); + }); + } catch (_e) { + resolve({}); + } + }); + } + + async function status() { + var configError = configurationError(); + if (configError) { + return { + success: true, + configured: false, + connected: false, + code: configError.code, + message: configError.error + }; + } + try { + await getToken(false); + var info = await profile(); + return { + success: true, + configured: true, + connected: true, + email: typeof info.email === 'string' ? info.email : '' + }; + } catch (_e) { + return { success: true, configured: true, connected: false }; + } + } + + async function connect() { + var configError = configurationError(); + if (configError) { return configError; } + try { + await getToken(true); + var info = await profile(); + return { + success: true, + configured: true, + connected: true, + email: typeof info.email === 'string' ? info.email : '' + }; + } catch (error) { + return error && error.code ? error : typedError('GOOGLE_SHEETS_AUTH_FAILED'); + } + } + + async function disconnect() { + var configError = configurationError(); + if (configError) { return configError; } + try { + var token = await getToken(false); + await removeCachedToken(token); + } catch (_e) { + // Already disconnected is a successful terminal state. + } + return { success: true, configured: true, connected: false }; + } + + function validateSpreadsheetId(value) { + if (typeof value !== 'string' || !SPREADSHEET_ID.test(value)) { + throw typedError('GOOGLE_SHEETS_INVALID_ARGUMENT'); + } + return value; + } + + function validateRange(value) { + if (typeof value !== 'string' || !value || value.length > SAFE_RANGE_MAX || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) { + throw typedError('GOOGLE_SHEETS_INVALID_ARGUMENT'); + } + return value; + } + + function enumValue(value, allowed, fallback) { + var candidate = value === undefined || value === null || value === '' ? fallback : String(value); + if (allowed.indexOf(candidate) === -1) { throw typedError('GOOGLE_SHEETS_INVALID_ARGUMENT'); } + return candidate; + } + + function validateValues(values) { + if (!Array.isArray(values) || values.length === 0 || values.length > 10000) { + throw typedError('GOOGLE_SHEETS_INVALID_ARGUMENT'); + } + for (var rowIndex = 0; rowIndex < values.length; rowIndex++) { + var row = values[rowIndex]; + if (!Array.isArray(row) || row.length > 10000) { + throw typedError('GOOGLE_SHEETS_INVALID_ARGUMENT'); + } + for (var colIndex = 0; colIndex < row.length; colIndex++) { + var cell = row[colIndex]; + if (cell !== null && typeof cell !== 'string' && typeof cell !== 'boolean' && !(typeof cell === 'number' && Number.isFinite(cell))) { + throw typedError('GOOGLE_SHEETS_INVALID_ARGUMENT'); + } + } + } + return values; + } + + function requestSpec(operation, params) { + params = params || {}; + var id = validateSpreadsheetId(params.spreadsheetId); + var idPart = encodePathSegment(id); + var range; + var query = new URLSearchParams(); + var spec = { method: 'GET', path: '/spreadsheets/' + idPart, body: null }; + + if (operation === 'getSpreadsheet') { + query.set('includeGridData', 'false'); + query.set('fields', 'spreadsheetId,properties(title,locale,timeZone),sheets(properties(sheetId,title,index,sheetType,gridProperties))'); + } else if (operation === 'getValues') { + range = validateRange(params.range); + spec.path += '/values/' + encodePathSegment(range); + query.set('majorDimension', enumValue(params.majorDimension, ['ROWS', 'COLUMNS'], 'ROWS')); + query.set('valueRenderOption', enumValue(params.valueRenderOption, ['FORMATTED_VALUE', 'UNFORMATTED_VALUE', 'FORMULA'], 'FORMATTED_VALUE')); + query.set('dateTimeRenderOption', enumValue(params.dateTimeRenderOption, ['SERIAL_NUMBER', 'FORMATTED_STRING'], 'FORMATTED_STRING')); + } else if (operation === 'updateValues') { + range = validateRange(params.range); + spec.method = 'PUT'; + spec.path += '/values/' + encodePathSegment(range); + query.set('valueInputOption', enumValue(params.valueInputOption, ['RAW', 'USER_ENTERED'], 'USER_ENTERED')); + query.set('includeValuesInResponse', 'false'); + spec.body = { range: range, majorDimension: 'ROWS', values: validateValues(params.values) }; + } else if (operation === 'appendValues') { + range = validateRange(params.range); + spec.method = 'POST'; + spec.path += '/values/' + encodePathSegment(range) + ':append'; + query.set('valueInputOption', enumValue(params.valueInputOption, ['RAW', 'USER_ENTERED'], 'USER_ENTERED')); + query.set('insertDataOption', enumValue(params.insertDataOption, ['OVERWRITE', 'INSERT_ROWS'], 'INSERT_ROWS')); + query.set('includeValuesInResponse', 'false'); + spec.body = { range: range, majorDimension: 'ROWS', values: validateValues(params.values) }; + } else if (operation === 'clearValues') { + range = validateRange(params.range); + spec.method = 'POST'; + spec.path += '/values/' + encodePathSegment(range) + ':clear'; + spec.body = {}; + } else { + throw typedError('GOOGLE_SHEETS_INVALID_ARGUMENT'); + } + + var bodyText = spec.body === null ? null : JSON.stringify(spec.body); + if (bodyText && new TextEncoder().encode(bodyText).byteLength > MAX_REQUEST_BODY_BYTES) { + throw typedError('GOOGLE_SHEETS_REQUEST_TOO_LARGE'); + } + spec.url = SHEETS_BASE_URL + spec.path + (query.toString() ? '?' + query.toString() : ''); + spec.bodyText = bodyText; + return spec; + } + + function statusError(statusCode) { + if (statusCode === 403) { return typedError('GOOGLE_SHEETS_ACCESS_DENIED', { status: statusCode }); } + if (statusCode === 404) { return typedError('GOOGLE_SHEETS_NOT_FOUND', { status: statusCode }); } + if (statusCode === 429) { return typedError('GOOGLE_SHEETS_RATE_LIMITED', { status: statusCode }); } + return typedError('GOOGLE_SHEETS_API_ERROR', { status: statusCode }); + } + + async function fetchOnce(spec, token) { + if (typeof fetchFn !== 'function') { return typedError('GOOGLE_SHEETS_NETWORK_ERROR'); } + var controller = AbortControllerCtor ? new AbortControllerCtor() : null; + var timer = null; + if (controller && typeof setTimer === 'function') { + timer = setTimer(function () { controller.abort(); }, REQUEST_TIMEOUT_MS); + } + try { + var headers = { Authorization: 'Bearer ' + token, Accept: 'application/json' }; + if (spec.bodyText !== null) { headers['Content-Type'] = 'application/json'; } + var response = await fetchFn(spec.url, { + method: spec.method, + headers: headers, + body: spec.bodyText, + signal: controller ? controller.signal : undefined, + credentials: 'omit', + redirect: 'error' + }); + if (response.status === 401) { return { success: false, authRejected: true, status: 401 }; } + if (!response.ok) { return statusError(response.status); } + var contentLength = Number(response.headers && response.headers.get ? response.headers.get('content-length') : 0); + if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BODY_BYTES) { + return typedError('GOOGLE_SHEETS_RESPONSE_TOO_LARGE'); + } + var text = await response.text(); + if (new TextEncoder().encode(text).byteLength > MAX_RESPONSE_BODY_BYTES) { + return typedError('GOOGLE_SHEETS_RESPONSE_TOO_LARGE'); + } + var data = text ? JSON.parse(text) : {}; + return { success: true, status: response.status, data: data }; + } catch (error) { + if (error && (error.name === 'AbortError' || (controller && controller.signal && controller.signal.aborted))) { + return typedError('GOOGLE_SHEETS_TIMEOUT'); + } + return typedError('GOOGLE_SHEETS_NETWORK_ERROR'); + } finally { + if (timer !== null && typeof clearTimer === 'function') { clearTimer(timer); } + } + } + + async function execute(operation, params) { + var spec; + try { + spec = requestSpec(operation, params); + } catch (error) { + return error && error.code ? error : typedError('GOOGLE_SHEETS_INVALID_ARGUMENT'); + } + var token; + try { + token = await getToken(false); + } catch (error) { + return error && error.code ? error : typedError('GOOGLE_SHEETS_NOT_CONNECTED'); + } + var first = await fetchOnce(spec, token); + if (!first || first.authRejected !== true) { return first; } + await removeCachedToken(token); + try { + token = await getToken(false); + } catch (_e) { + return typedError('GOOGLE_SHEETS_NOT_CONNECTED'); + } + var second = await fetchOnce(spec, token); + return second && second.authRejected === true + ? typedError('GOOGLE_SHEETS_AUTH_FAILED', { status: 401 }) + : second; + } + + var client = { + connect: connect, + status: status, + disconnect: disconnect, + getSpreadsheet: function (params) { return execute('getSpreadsheet', params); }, + getValues: function (params) { return execute('getValues', params); }, + updateValues: function (params) { return execute('updateValues', params); }, + appendValues: function (params) { return execute('appendValues', params); }, + clearValues: function (params) { return execute('clearValues', params); } + }; + Object.defineProperty(client, '__test', { + value: { requestSpec: requestSpec, isConfigured: function () { return isConfigured(chromeApi); } }, + enumerable: false + }); + return client; + } + + var api = createClient(); + + api.createClient = createClient; + api.constants = Object.freeze({ + baseUrl: SHEETS_BASE_URL, + scope: SHEETS_SCOPE, + requestTimeoutMs: REQUEST_TIMEOUT_MS, + maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES + }); + Object.freeze(api); + global.FsbGoogleSheetsApi = api; + if (typeof module !== 'undefined' && module.exports) { module.exports = api; } +})(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/tests/google-sheets-api.test.js b/tests/google-sheets-api.test.js new file mode 100644 index 000000000..982a4a8aa --- /dev/null +++ b/tests/google-sheets-api.test.js @@ -0,0 +1,186 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const apiModule = require('../extension/utils/google-sheets-api.js'); +const REAL_CLIENT_ID = '123456789012-abcdefghijklmnopqrstuvwxyz.apps.googleusercontent.com'; +const SCOPE = 'https://www.googleapis.com/auth/spreadsheets'; + +function response(status, data, headers) { + return { + status, + ok: status >= 200 && status < 300, + headers: { get(name) { return (headers || {})[name.toLowerCase()] || null; } }, + async text() { return data === undefined ? '' : JSON.stringify(data); } + }; +} + +function chromeStub(options) { + options = options || {}; + const authCalls = []; + const removed = []; + const tokens = (options.tokens || ['secret-access-token']).slice(); + const chromeApi = { + runtime: { + lastError: null, + getManifest() { + return { + oauth2: { + client_id: options.clientId || REAL_CLIENT_ID, + scopes: options.scopes || [SCOPE] + } + }; + } + }, + identity: { + getAuthToken(details, callback) { + authCalls.push(details); + const token = tokens.length ? tokens.shift() : 'secret-access-token'; + if (token instanceof Error) { + chromeApi.runtime.lastError = { message: token.message }; + callback(undefined); + chromeApi.runtime.lastError = null; + return; + } + callback(token); + }, + removeCachedAuthToken(details, callback) { + removed.push(details.token); + callback(); + }, + getProfileUserInfo(_details, callback) { + callback({ email: 'user@example.com', id: 'profile-id' }); + } + } + }; + return { chromeApi, authCalls, removed }; +} + +test('placeholder OAuth configuration fails closed before Chrome Identity', async () => { + const stub = chromeStub({ clientId: 'REPLACE_WITH_FSB_GOOGLE_OAUTH_CLIENT_ID.apps.googleusercontent.com' }); + const client = apiModule.createClient({ chrome: stub.chromeApi, fetch: async () => response(200, {}) }); + const connected = await client.connect(); + const read = await client.getSpreadsheet({ spreadsheetId: 'abcdefghijklmnopqrstuvwxyz123456' }); + assert.equal(connected.code, 'GOOGLE_SHEETS_OAUTH_NOT_CONFIGURED'); + assert.equal(read.code, 'GOOGLE_SHEETS_OAUTH_NOT_CONFIGURED'); + assert.equal(stub.authCalls.length, 0); +}); + +test('only connect requests an interactive token; API calls are noninteractive', async () => { + const stub = chromeStub({ tokens: ['connect-token', 'read-token'] }); + const client = apiModule.createClient({ + chrome: stub.chromeApi, + fetch: async () => response(200, { spreadsheetId: 'abcdefghijklmnopqrstuvwxyz123456' }) + }); + const connected = await client.connect(); + const read = await client.getSpreadsheet({ spreadsheetId: 'abcdefghijklmnopqrstuvwxyz123456' }); + assert.equal(connected.connected, true); + assert.equal(read.success, true); + assert.deepEqual(stub.authCalls.map(call => call.interactive), [true, false]); +}); + +test('constructs only encoded Sheets v4 requests with fixed methods and safe bodies', async () => { + const stub = chromeStub({ tokens: ['read-token', 'write-token', 'append-token', 'clear-token'] }); + const calls = []; + const client = apiModule.createClient({ + chrome: stub.chromeApi, + fetch: async (url, init) => { + calls.push({ url, init }); + return response(200, { ok: true }); + } + }); + const id = 'abcdefghijklmnopqrstuvwxyz123456'; + await client.getValues({ spreadsheetId: id, range: "Data 2026!A1:B2", valueRenderOption: 'FORMULA' }); + await client.updateValues({ spreadsheetId: id, range: 'A1:B1', values: [['name', '=1+1']], valueInputOption: 'RAW' }); + await client.appendValues({ spreadsheetId: id, range: 'A:B', values: [['x', 2]], insertDataOption: 'INSERT_ROWS' }); + await client.clearValues({ spreadsheetId: id, range: 'Archive!A2:Z' }); + + assert.equal(calls.length, 4); + assert.ok(calls.every(call => call.url.startsWith('https://sheets.googleapis.com/v4/spreadsheets/'))); + assert.deepEqual(calls.map(call => call.init.method), ['GET', 'PUT', 'POST', 'POST']); + assert.match(calls[0].url, /Data%202026%21A1%3AB2/); + assert.deepEqual(JSON.parse(calls[1].init.body).values, [['name', '=1+1']]); + assert.match(calls[2].url, /A%3AB:append\?/); + assert.match(calls[3].url, /A2%3AZ:clear$/); + assert.ok(calls.every(call => call.init.credentials === 'omit' && call.init.redirect === 'error')); +}); + +test('rejects invalid IDs, ranges, and oversized bodies before auth or fetch', async () => { + const stub = chromeStub(); + let fetchCount = 0; + const client = apiModule.createClient({ + chrome: stub.chromeApi, + fetch: async () => { fetchCount++; return response(200, {}); } + }); + assert.equal((await client.getValues({ spreadsheetId: '../bad', range: 'A1' })).code, 'GOOGLE_SHEETS_INVALID_ARGUMENT'); + assert.equal((await client.getValues({ spreadsheetId: 'abcdefghijklmnopqrstuvwxyz123456', range: 'A1\nAuthorization: secret' })).code, 'GOOGLE_SHEETS_INVALID_ARGUMENT'); + const huge = 'x'.repeat(apiModule.constants.maxRequestBodyBytes + 1); + assert.equal((await client.updateValues({ spreadsheetId: 'abcdefghijklmnopqrstuvwxyz123456', range: 'A1', values: [[huge]] })).code, 'GOOGLE_SHEETS_REQUEST_TOO_LARGE'); + assert.equal(fetchCount, 0); + assert.equal(stub.authCalls.length, 0); +}); + +test('normalizes Google failures and never discloses tokens or response details', async () => { + const token = 'TOP-SECRET-OAUTH-TOKEN'; + const stub = chromeStub({ tokens: [token] }); + const client = apiModule.createClient({ + chrome: stub.chromeApi, + fetch: async () => response(403, { error: { message: 'private server detail ' + token } }) + }); + const out = await client.getSpreadsheet({ spreadsheetId: 'abcdefghijklmnopqrstuvwxyz123456' }); + assert.equal(out.code, 'GOOGLE_SHEETS_ACCESS_DENIED'); + assert.equal(out.status, 403); + assert.equal(JSON.stringify(out).includes(token), false); + assert.equal(JSON.stringify(out).includes('private server detail'), false); +}); + +test('evicts a rejected token and retries once noninteractively', async () => { + const stub = chromeStub({ tokens: ['expired-token', 'fresh-token'] }); + const authHeaders = []; + const client = apiModule.createClient({ + chrome: stub.chromeApi, + fetch: async (_url, init) => { + authHeaders.push(init.headers.Authorization); + return authHeaders.length === 1 ? response(401, {}) : response(200, { values: [['ok']] }); + } + }); + const out = await client.getValues({ spreadsheetId: 'abcdefghijklmnopqrstuvwxyz123456', range: 'A1' }); + assert.equal(out.success, true); + assert.deepEqual(stub.authCalls.map(call => call.interactive), [false, false]); + assert.deepEqual(stub.removed, ['expired-token']); + assert.equal(authHeaders.length, 2); +}); + +test('disconnect evicts cached auth without returning the token', async () => { + const stub = chromeStub({ tokens: ['disconnect-secret'] }); + const client = apiModule.createClient({ chrome: stub.chromeApi }); + const out = await client.disconnect(); + assert.deepEqual(stub.removed, ['disconnect-secret']); + assert.equal(out.connected, false); + assert.equal(JSON.stringify(out).includes('disconnect-secret'), false); +}); + +test('timeout errors are typed and safe', async () => { + const stub = chromeStub({ tokens: ['timeout-token'] }); + class Controller { + constructor() { this.signal = { aborted: false }; } + abort() { this.signal.aborted = true; } + } + let timerFn; + const client = apiModule.createClient({ + chrome: stub.chromeApi, + AbortController: Controller, + setTimeout(fn) { timerFn = fn; return 1; }, + clearTimeout() {}, + fetch: async (_url, init) => { + timerFn(); + const error = new Error('secret timeout detail'); + error.name = 'AbortError'; + throw error; + } + }); + const out = await client.getSpreadsheet({ spreadsheetId: 'abcdefghijklmnopqrstuvwxyz123456' }); + assert.equal(out.code, 'GOOGLE_SHEETS_TIMEOUT'); + assert.equal(JSON.stringify(out).includes('secret timeout detail'), false); +}); From 23badd3adfeef7dec25314826313d6c64dfc7841 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Wed, 15 Jul 2026 06:52:42 -0500 Subject: [PATCH 12/26] feat(sheets): add typed Sheets capabilities --- .../descriptors/gsheets__append_values.json | 22 ++ .../descriptors/gsheets__clear_values.json | 19 ++ .../descriptors/gsheets__get_spreadsheet.json | 17 + catalog/descriptors/gsheets__get_values.json | 22 ++ .../descriptors/gsheets__update_values.json | 21 ++ catalog/handlers/gsheets.js | 183 +++++++++++ extension/background.js | 1 + extension/catalog/handlers/gsheets.js | 183 +++++++++++ extension/catalog/recipe-index.generated.js | 303 ++++++++++++++++++ extension/config/service-denylist.json | 1 + extension/utils/capability-catalog.js | 1 + extension/utils/capability-router.js | 19 +- scripts/verify-recipe-path-guard.mjs | 1 + tests/google-sheets-wiring.test.js | 89 +++++ tests/gsheets-handler.test.js | 97 ++++++ tests/head-handler-cap.test.js | 7 +- 16 files changed, 982 insertions(+), 4 deletions(-) create mode 100644 catalog/descriptors/gsheets__append_values.json create mode 100644 catalog/descriptors/gsheets__clear_values.json create mode 100644 catalog/descriptors/gsheets__get_spreadsheet.json create mode 100644 catalog/descriptors/gsheets__get_values.json create mode 100644 catalog/descriptors/gsheets__update_values.json create mode 100644 catalog/handlers/gsheets.js create mode 100644 extension/catalog/handlers/gsheets.js create mode 100644 tests/google-sheets-wiring.test.js create mode 100644 tests/gsheets-handler.test.js diff --git a/catalog/descriptors/gsheets__append_values.json b/catalog/descriptors/gsheets__append_values.json new file mode 100644 index 000000000..3ac4843ac --- /dev/null +++ b/catalog/descriptors/gsheets__append_values.json @@ -0,0 +1,22 @@ +{ + "slug": "gsheets.append_values", + "service": "docs.google.com", + "intentSynonyms": ["append rows to google sheets", "add spreadsheet values", "insert rows in sheets"], + "description": "Append rows after the table found in an explicit A1 notation range.", + "actionVerb": "append", + "sideEffectClass": "write", + "params": { + "type": "object", + "properties": { + "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." }, + "range": { "type": "string", "minLength": 1, "maxLength": 500, "description": "A1 notation range." }, + "values": { "type": "array", "minItems": 1, "maxItems": 10000, "items": { "type": "array", "maxItems": 10000, "items": { "anyOf": [{ "type": "string" }, { "type": "number" }, { "type": "boolean" }, { "type": "null" }] } }, "description": "Two-dimensional rows of JSON scalar cell values." }, + "valueInputOption": { "type": "string", "enum": ["RAW", "USER_ENTERED"] }, + "insertDataOption": { "type": "string", "enum": ["OVERWRITE", "INSERT_ROWS"] } + }, + "required": ["range", "values"], + "additionalProperties": false + }, + "backing": "handler", + "provenance": { "source": "fsb", "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { "transportHelper": "chrome-identity-sheets-v4", "httpMethod": "POST", "opNameVerb": "append" } } +} diff --git a/catalog/descriptors/gsheets__clear_values.json b/catalog/descriptors/gsheets__clear_values.json new file mode 100644 index 000000000..8c192beb4 --- /dev/null +++ b/catalog/descriptors/gsheets__clear_values.json @@ -0,0 +1,19 @@ +{ + "slug": "gsheets.clear_values", + "service": "docs.google.com", + "intentSynonyms": ["clear google sheet cells", "erase a spreadsheet range", "delete values in sheets"], + "description": "Clear values from an explicit A1 notation range while preserving formatting.", + "actionVerb": "clear", + "sideEffectClass": "destructive", + "params": { + "type": "object", + "properties": { + "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." }, + "range": { "type": "string", "minLength": 1, "maxLength": 500, "description": "A1 notation range." } + }, + "required": ["range"], + "additionalProperties": false + }, + "backing": "handler", + "provenance": { "source": "fsb", "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { "transportHelper": "chrome-identity-sheets-v4", "httpMethod": "POST", "opNameVerb": "clear" } } +} diff --git a/catalog/descriptors/gsheets__get_spreadsheet.json b/catalog/descriptors/gsheets__get_spreadsheet.json new file mode 100644 index 000000000..e1b8af5cc --- /dev/null +++ b/catalog/descriptors/gsheets__get_spreadsheet.json @@ -0,0 +1,17 @@ +{ + "slug": "gsheets.get_spreadsheet", + "service": "docs.google.com", + "intentSynonyms": ["get spreadsheet metadata", "inspect a google sheet", "list sheets in a spreadsheet"], + "description": "Get spreadsheet properties and sheet metadata without loading cell grids.", + "actionVerb": "get", + "sideEffectClass": "read", + "params": { + "type": "object", + "properties": { + "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." } + }, + "additionalProperties": false + }, + "backing": "handler", + "provenance": { "source": "fsb", "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { "transportHelper": "chrome-identity-sheets-v4", "httpMethod": "GET", "opNameVerb": "get" } } +} diff --git a/catalog/descriptors/gsheets__get_values.json b/catalog/descriptors/gsheets__get_values.json new file mode 100644 index 000000000..0d512ef47 --- /dev/null +++ b/catalog/descriptors/gsheets__get_values.json @@ -0,0 +1,22 @@ +{ + "slug": "gsheets.get_values", + "service": "docs.google.com", + "intentSynonyms": ["read google sheet cells", "get spreadsheet values", "read a range in sheets"], + "description": "Read values from an explicit A1 notation range.", + "actionVerb": "get", + "sideEffectClass": "read", + "params": { + "type": "object", + "properties": { + "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." }, + "range": { "type": "string", "minLength": 1, "maxLength": 500, "description": "A1 notation range." }, + "majorDimension": { "type": "string", "enum": ["ROWS", "COLUMNS"] }, + "valueRenderOption": { "type": "string", "enum": ["FORMATTED_VALUE", "UNFORMATTED_VALUE", "FORMULA"] }, + "dateTimeRenderOption": { "type": "string", "enum": ["SERIAL_NUMBER", "FORMATTED_STRING"] } + }, + "required": ["range"], + "additionalProperties": false + }, + "backing": "handler", + "provenance": { "source": "fsb", "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { "transportHelper": "chrome-identity-sheets-v4", "httpMethod": "GET", "opNameVerb": "get" } } +} diff --git a/catalog/descriptors/gsheets__update_values.json b/catalog/descriptors/gsheets__update_values.json new file mode 100644 index 000000000..e814e228a --- /dev/null +++ b/catalog/descriptors/gsheets__update_values.json @@ -0,0 +1,21 @@ +{ + "slug": "gsheets.update_values", + "service": "docs.google.com", + "intentSynonyms": ["write google sheet cells", "update spreadsheet values", "set a range in sheets"], + "description": "Replace values in an explicit A1 notation range.", + "actionVerb": "update", + "sideEffectClass": "write", + "params": { + "type": "object", + "properties": { + "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." }, + "range": { "type": "string", "minLength": 1, "maxLength": 500, "description": "A1 notation range." }, + "values": { "type": "array", "minItems": 1, "maxItems": 10000, "items": { "type": "array", "maxItems": 10000, "items": { "anyOf": [{ "type": "string" }, { "type": "number" }, { "type": "boolean" }, { "type": "null" }] } }, "description": "Two-dimensional rows of JSON scalar cell values." }, + "valueInputOption": { "type": "string", "enum": ["RAW", "USER_ENTERED"] } + }, + "required": ["range", "values"], + "additionalProperties": false + }, + "backing": "handler", + "provenance": { "source": "fsb", "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { "transportHelper": "chrome-identity-sheets-v4", "httpMethod": "PUT", "opNameVerb": "update" } } +} diff --git a/catalog/handlers/gsheets.js b/catalog/handlers/gsheets.js new file mode 100644 index 000000000..58b940fc6 --- /dev/null +++ b/catalog/handlers/gsheets.js @@ -0,0 +1,183 @@ +(function (global) { + 'use strict'; + + var ORIGIN = 'https://docs.google.com'; + var SERVICE = 'docs.google.com'; + var ID_PATTERN = '^[A-Za-z0-9_-]{10,200}$'; + var ID_RE = /^[A-Za-z0-9_-]{10,200}$/; + + function schema(properties, required) { + var out = { type: 'object', properties: properties, additionalProperties: false }; + if (required && required.length) { out.required = required; } + return out; + } + + var ID = { + type: 'string', + pattern: ID_PATTERN, + description: 'Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab.' + }; + var RANGE = { type: 'string', minLength: 1, maxLength: 500, description: 'A1 notation range.' }; + var VALUES = { + type: 'array', + minItems: 1, + maxItems: 10000, + items: { + type: 'array', + maxItems: 10000, + items: { + anyOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'null' } + ] + } + }, + description: 'Two-dimensional rows of JSON scalar cell values.' + }; + var VALUE_INPUT_OPTION = { type: 'string', enum: ['RAW', 'USER_ENTERED'] }; + + var GET_SPREADSHEET_PARAMS = schema({ spreadsheetId: ID }, []); + var GET_VALUES_PARAMS = schema({ + spreadsheetId: ID, + range: RANGE, + majorDimension: { type: 'string', enum: ['ROWS', 'COLUMNS'] }, + valueRenderOption: { type: 'string', enum: ['FORMATTED_VALUE', 'UNFORMATTED_VALUE', 'FORMULA'] }, + dateTimeRenderOption: { type: 'string', enum: ['SERIAL_NUMBER', 'FORMATTED_STRING'] } + }, ['range']); + var UPDATE_VALUES_PARAMS = schema({ + spreadsheetId: ID, + range: RANGE, + values: VALUES, + valueInputOption: VALUE_INPUT_OPTION + }, ['range', 'values']); + var APPEND_VALUES_PARAMS = schema({ + spreadsheetId: ID, + range: RANGE, + values: VALUES, + valueInputOption: VALUE_INPUT_OPTION, + insertDataOption: { type: 'string', enum: ['OVERWRITE', 'INSERT_ROWS'] } + }, ['range', 'values']); + var CLEAR_VALUES_PARAMS = schema({ spreadsheetId: ID, range: RANGE }, ['range']); + + function typedError(code) { + return { success: false, code: code, errorCode: code, error: code }; + } + + function spreadsheetIdFromUrl(ctx) { + var url = ctx && typeof ctx.url === 'string' ? ctx.url : ''; + if (!url) { return ''; } + try { + var parsed = new URL(url); + if (parsed.origin !== ORIGIN) { return ''; } + var match = parsed.pathname.match(/^\/spreadsheets\/d\/([A-Za-z0-9_-]{10,200})(?:\/|$)/); + return match ? match[1] : ''; + } catch (_e) { + return ''; + } + } + + function resolveSpreadsheetId(args, ctx) { + var explicit = args && args.spreadsheetId; + if (explicit !== undefined && explicit !== null && explicit !== '') { + return typeof explicit === 'string' && ID_RE.test(explicit) ? explicit : ''; + } + return spreadsheetIdFromUrl(ctx); + } + + async function call(method, args, ctx, buildParams) { + var client = ctx && ctx.googleSheets; + if (!client || typeof client[method] !== 'function') { + return typedError('GOOGLE_SHEETS_API_UNAVAILABLE'); + } + var spreadsheetId = resolveSpreadsheetId(args, ctx); + if (!spreadsheetId) { return typedError('GOOGLE_SHEETS_SPREADSHEET_REQUIRED'); } + try { + return await client[method](buildParams(spreadsheetId, args || {})); + } catch (_e) { + return typedError('GOOGLE_SHEETS_API_ERROR'); + } + } + + var handlers = { + 'gsheets.get_spreadsheet': { + tier: 'T1a', origin: ORIGIN, sideEffectClass: 'read', params: GET_SPREADSHEET_PARAMS, + handle: function (args, ctx) { + return call('getSpreadsheet', args, ctx, function (spreadsheetId) { + return { spreadsheetId: spreadsheetId }; + }); + } + }, + 'gsheets.get_values': { + tier: 'T1a', origin: ORIGIN, sideEffectClass: 'read', params: GET_VALUES_PARAMS, + handle: function (args, ctx) { + return call('getValues', args, ctx, function (spreadsheetId, input) { + return { + spreadsheetId: spreadsheetId, + range: input.range, + majorDimension: input.majorDimension, + valueRenderOption: input.valueRenderOption, + dateTimeRenderOption: input.dateTimeRenderOption + }; + }); + } + }, + 'gsheets.update_values': { + tier: 'T1a', origin: ORIGIN, sideEffectClass: 'write', params: UPDATE_VALUES_PARAMS, + handle: function (args, ctx) { + return call('updateValues', args, ctx, function (spreadsheetId, input) { + return { + spreadsheetId: spreadsheetId, + range: input.range, + values: input.values, + valueInputOption: input.valueInputOption + }; + }); + } + }, + 'gsheets.append_values': { + tier: 'T1a', origin: ORIGIN, sideEffectClass: 'write', params: APPEND_VALUES_PARAMS, + handle: function (args, ctx) { + return call('appendValues', args, ctx, function (spreadsheetId, input) { + return { + spreadsheetId: spreadsheetId, + range: input.range, + values: input.values, + valueInputOption: input.valueInputOption, + insertDataOption: input.insertDataOption + }; + }); + } + }, + 'gsheets.clear_values': { + tier: 'T1a', origin: ORIGIN, sideEffectClass: 'destructive', params: CLEAR_VALUES_PARAMS, + handle: function (args, ctx) { + return call('clearValues', args, ctx, function (spreadsheetId, input) { + return { spreadsheetId: spreadsheetId, range: input.range }; + }); + } + } + }; + + if (global.FsbCapabilityCatalog && typeof global.FsbCapabilityCatalog.registerHandler === 'function') { + for (var slug in handlers) { + if (!Object.prototype.hasOwnProperty.call(handlers, slug)) { continue; } + global.FsbCapabilityCatalog.registerHandler(slug, { + tier: 'T1a', + handler: handlers[slug], + origin: ORIGIN, + params: handlers[slug].params, + descriptor: { + slug: slug, + service: SERVICE, + sideEffectClass: handlers[slug].sideEffectClass, + params: handlers[slug].params + } + }); + } + } + + global.FsbHandlerGsheets = handlers; + if (typeof module !== 'undefined' && module.exports) { module.exports = handlers; } +})(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/extension/background.js b/extension/background.js index 78fe2268a..b07b04466 100644 --- a/extension/background.js +++ b/extension/background.js @@ -299,6 +299,7 @@ try { importScripts('catalog/handlers/ganalytics.js'); } catch (e) { console.err try { importScripts('catalog/handlers/figma.js'); } catch (e) { console.error('[FSB] Failed to load handlers/figma.js:', e.message); } try { importScripts('catalog/handlers/gdrive.js'); } catch (e) { console.error('[FSB] Failed to load handlers/gdrive.js:', e.message); } try { importScripts('catalog/handlers/gdocs.js'); } catch (e) { console.error('[FSB] Failed to load handlers/gdocs.js:', e.message); } +try { importScripts('catalog/handlers/gsheets.js'); } catch (e) { console.error('[FSB] Failed to load handlers/gsheets.js:', e.message); } try { importScripts('catalog/handlers/powerpoint.js'); } catch (e) { console.error('[FSB] Failed to load handlers/powerpoint.js:', e.message); } try { importScripts('catalog/handlers/outlook.js'); } catch (e) { console.error('[FSB] Failed to load handlers/outlook.js:', e.message); } try { importScripts('catalog/handlers/teams.js'); } catch (e) { console.error('[FSB] Failed to load handlers/teams.js:', e.message); } diff --git a/extension/catalog/handlers/gsheets.js b/extension/catalog/handlers/gsheets.js new file mode 100644 index 000000000..58b940fc6 --- /dev/null +++ b/extension/catalog/handlers/gsheets.js @@ -0,0 +1,183 @@ +(function (global) { + 'use strict'; + + var ORIGIN = 'https://docs.google.com'; + var SERVICE = 'docs.google.com'; + var ID_PATTERN = '^[A-Za-z0-9_-]{10,200}$'; + var ID_RE = /^[A-Za-z0-9_-]{10,200}$/; + + function schema(properties, required) { + var out = { type: 'object', properties: properties, additionalProperties: false }; + if (required && required.length) { out.required = required; } + return out; + } + + var ID = { + type: 'string', + pattern: ID_PATTERN, + description: 'Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab.' + }; + var RANGE = { type: 'string', minLength: 1, maxLength: 500, description: 'A1 notation range.' }; + var VALUES = { + type: 'array', + minItems: 1, + maxItems: 10000, + items: { + type: 'array', + maxItems: 10000, + items: { + anyOf: [ + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'null' } + ] + } + }, + description: 'Two-dimensional rows of JSON scalar cell values.' + }; + var VALUE_INPUT_OPTION = { type: 'string', enum: ['RAW', 'USER_ENTERED'] }; + + var GET_SPREADSHEET_PARAMS = schema({ spreadsheetId: ID }, []); + var GET_VALUES_PARAMS = schema({ + spreadsheetId: ID, + range: RANGE, + majorDimension: { type: 'string', enum: ['ROWS', 'COLUMNS'] }, + valueRenderOption: { type: 'string', enum: ['FORMATTED_VALUE', 'UNFORMATTED_VALUE', 'FORMULA'] }, + dateTimeRenderOption: { type: 'string', enum: ['SERIAL_NUMBER', 'FORMATTED_STRING'] } + }, ['range']); + var UPDATE_VALUES_PARAMS = schema({ + spreadsheetId: ID, + range: RANGE, + values: VALUES, + valueInputOption: VALUE_INPUT_OPTION + }, ['range', 'values']); + var APPEND_VALUES_PARAMS = schema({ + spreadsheetId: ID, + range: RANGE, + values: VALUES, + valueInputOption: VALUE_INPUT_OPTION, + insertDataOption: { type: 'string', enum: ['OVERWRITE', 'INSERT_ROWS'] } + }, ['range', 'values']); + var CLEAR_VALUES_PARAMS = schema({ spreadsheetId: ID, range: RANGE }, ['range']); + + function typedError(code) { + return { success: false, code: code, errorCode: code, error: code }; + } + + function spreadsheetIdFromUrl(ctx) { + var url = ctx && typeof ctx.url === 'string' ? ctx.url : ''; + if (!url) { return ''; } + try { + var parsed = new URL(url); + if (parsed.origin !== ORIGIN) { return ''; } + var match = parsed.pathname.match(/^\/spreadsheets\/d\/([A-Za-z0-9_-]{10,200})(?:\/|$)/); + return match ? match[1] : ''; + } catch (_e) { + return ''; + } + } + + function resolveSpreadsheetId(args, ctx) { + var explicit = args && args.spreadsheetId; + if (explicit !== undefined && explicit !== null && explicit !== '') { + return typeof explicit === 'string' && ID_RE.test(explicit) ? explicit : ''; + } + return spreadsheetIdFromUrl(ctx); + } + + async function call(method, args, ctx, buildParams) { + var client = ctx && ctx.googleSheets; + if (!client || typeof client[method] !== 'function') { + return typedError('GOOGLE_SHEETS_API_UNAVAILABLE'); + } + var spreadsheetId = resolveSpreadsheetId(args, ctx); + if (!spreadsheetId) { return typedError('GOOGLE_SHEETS_SPREADSHEET_REQUIRED'); } + try { + return await client[method](buildParams(spreadsheetId, args || {})); + } catch (_e) { + return typedError('GOOGLE_SHEETS_API_ERROR'); + } + } + + var handlers = { + 'gsheets.get_spreadsheet': { + tier: 'T1a', origin: ORIGIN, sideEffectClass: 'read', params: GET_SPREADSHEET_PARAMS, + handle: function (args, ctx) { + return call('getSpreadsheet', args, ctx, function (spreadsheetId) { + return { spreadsheetId: spreadsheetId }; + }); + } + }, + 'gsheets.get_values': { + tier: 'T1a', origin: ORIGIN, sideEffectClass: 'read', params: GET_VALUES_PARAMS, + handle: function (args, ctx) { + return call('getValues', args, ctx, function (spreadsheetId, input) { + return { + spreadsheetId: spreadsheetId, + range: input.range, + majorDimension: input.majorDimension, + valueRenderOption: input.valueRenderOption, + dateTimeRenderOption: input.dateTimeRenderOption + }; + }); + } + }, + 'gsheets.update_values': { + tier: 'T1a', origin: ORIGIN, sideEffectClass: 'write', params: UPDATE_VALUES_PARAMS, + handle: function (args, ctx) { + return call('updateValues', args, ctx, function (spreadsheetId, input) { + return { + spreadsheetId: spreadsheetId, + range: input.range, + values: input.values, + valueInputOption: input.valueInputOption + }; + }); + } + }, + 'gsheets.append_values': { + tier: 'T1a', origin: ORIGIN, sideEffectClass: 'write', params: APPEND_VALUES_PARAMS, + handle: function (args, ctx) { + return call('appendValues', args, ctx, function (spreadsheetId, input) { + return { + spreadsheetId: spreadsheetId, + range: input.range, + values: input.values, + valueInputOption: input.valueInputOption, + insertDataOption: input.insertDataOption + }; + }); + } + }, + 'gsheets.clear_values': { + tier: 'T1a', origin: ORIGIN, sideEffectClass: 'destructive', params: CLEAR_VALUES_PARAMS, + handle: function (args, ctx) { + return call('clearValues', args, ctx, function (spreadsheetId, input) { + return { spreadsheetId: spreadsheetId, range: input.range }; + }); + } + } + }; + + if (global.FsbCapabilityCatalog && typeof global.FsbCapabilityCatalog.registerHandler === 'function') { + for (var slug in handlers) { + if (!Object.prototype.hasOwnProperty.call(handlers, slug)) { continue; } + global.FsbCapabilityCatalog.registerHandler(slug, { + tier: 'T1a', + handler: handlers[slug], + origin: ORIGIN, + params: handlers[slug].params, + descriptor: { + slug: slug, + service: SERVICE, + sideEffectClass: handlers[slug].sideEffectClass, + params: handlers[slug].params + } + }); + } + } + + global.FsbHandlerGsheets = handlers; + if (typeof module !== 'undefined' && module.exports) { module.exports = handlers; } +})(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/extension/catalog/recipe-index.generated.js b/extension/catalog/recipe-index.generated.js index 8f2c487b1..694e9391a 100644 --- a/extension/catalog/recipe-index.generated.js +++ b/extension/catalog/recipe-index.generated.js @@ -161,6 +161,309 @@ "sideEffectClass": "read", "backing": "recipe" }, + { + "slug": "gsheets.append_values", + "service": "docs.google.com", + "intentSynonyms": [ + "append rows to google sheets", + "add spreadsheet values", + "insert rows in sheets" + ], + "description": "Append rows after the table found in an explicit A1 notation range.", + "actionVerb": "append", + "sideEffectClass": "write", + "params": { + "type": "object", + "properties": { + "spreadsheetId": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{10,200}$", + "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." + }, + "range": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "A1 notation range." + }, + "values": { + "type": "array", + "minItems": 1, + "maxItems": 10000, + "items": { + "type": "array", + "maxItems": 10000, + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "description": "Two-dimensional rows of JSON scalar cell values." + }, + "valueInputOption": { + "type": "string", + "enum": [ + "RAW", + "USER_ENTERED" + ] + }, + "insertDataOption": { + "type": "string", + "enum": [ + "OVERWRITE", + "INSERT_ROWS" + ] + } + }, + "required": [ + "range", + "values" + ], + "additionalProperties": false + }, + "backing": "handler", + "provenance": { + "source": "fsb", + "sourcePath": "catalog/handlers/gsheets.js", + "license": "Apache-2.0", + "signals": { + "transportHelper": "chrome-identity-sheets-v4", + "httpMethod": "POST", + "opNameVerb": "append" + } + } + }, + { + "slug": "gsheets.clear_values", + "service": "docs.google.com", + "intentSynonyms": [ + "clear google sheet cells", + "erase a spreadsheet range", + "delete values in sheets" + ], + "description": "Clear values from an explicit A1 notation range while preserving formatting.", + "actionVerb": "clear", + "sideEffectClass": "destructive", + "params": { + "type": "object", + "properties": { + "spreadsheetId": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{10,200}$", + "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." + }, + "range": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "A1 notation range." + } + }, + "required": [ + "range" + ], + "additionalProperties": false + }, + "backing": "handler", + "provenance": { + "source": "fsb", + "sourcePath": "catalog/handlers/gsheets.js", + "license": "Apache-2.0", + "signals": { + "transportHelper": "chrome-identity-sheets-v4", + "httpMethod": "POST", + "opNameVerb": "clear" + } + } + }, + { + "slug": "gsheets.get_spreadsheet", + "service": "docs.google.com", + "intentSynonyms": [ + "get spreadsheet metadata", + "inspect a google sheet", + "list sheets in a spreadsheet" + ], + "description": "Get spreadsheet properties and sheet metadata without loading cell grids.", + "actionVerb": "get", + "sideEffectClass": "read", + "params": { + "type": "object", + "properties": { + "spreadsheetId": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{10,200}$", + "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." + } + }, + "additionalProperties": false + }, + "backing": "handler", + "provenance": { + "source": "fsb", + "sourcePath": "catalog/handlers/gsheets.js", + "license": "Apache-2.0", + "signals": { + "transportHelper": "chrome-identity-sheets-v4", + "httpMethod": "GET", + "opNameVerb": "get" + } + } + }, + { + "slug": "gsheets.get_values", + "service": "docs.google.com", + "intentSynonyms": [ + "read google sheet cells", + "get spreadsheet values", + "read a range in sheets" + ], + "description": "Read values from an explicit A1 notation range.", + "actionVerb": "get", + "sideEffectClass": "read", + "params": { + "type": "object", + "properties": { + "spreadsheetId": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{10,200}$", + "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." + }, + "range": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "A1 notation range." + }, + "majorDimension": { + "type": "string", + "enum": [ + "ROWS", + "COLUMNS" + ] + }, + "valueRenderOption": { + "type": "string", + "enum": [ + "FORMATTED_VALUE", + "UNFORMATTED_VALUE", + "FORMULA" + ] + }, + "dateTimeRenderOption": { + "type": "string", + "enum": [ + "SERIAL_NUMBER", + "FORMATTED_STRING" + ] + } + }, + "required": [ + "range" + ], + "additionalProperties": false + }, + "backing": "handler", + "provenance": { + "source": "fsb", + "sourcePath": "catalog/handlers/gsheets.js", + "license": "Apache-2.0", + "signals": { + "transportHelper": "chrome-identity-sheets-v4", + "httpMethod": "GET", + "opNameVerb": "get" + } + } + }, + { + "slug": "gsheets.update_values", + "service": "docs.google.com", + "intentSynonyms": [ + "write google sheet cells", + "update spreadsheet values", + "set a range in sheets" + ], + "description": "Replace values in an explicit A1 notation range.", + "actionVerb": "update", + "sideEffectClass": "write", + "params": { + "type": "object", + "properties": { + "spreadsheetId": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{10,200}$", + "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." + }, + "range": { + "type": "string", + "minLength": 1, + "maxLength": 500, + "description": "A1 notation range." + }, + "values": { + "type": "array", + "minItems": 1, + "maxItems": 10000, + "items": { + "type": "array", + "maxItems": 10000, + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "description": "Two-dimensional rows of JSON scalar cell values." + }, + "valueInputOption": { + "type": "string", + "enum": [ + "RAW", + "USER_ENTERED" + ] + } + }, + "required": [ + "range", + "values" + ], + "additionalProperties": false + }, + "backing": "handler", + "provenance": { + "source": "fsb", + "sourcePath": "catalog/handlers/gsheets.js", + "license": "Apache-2.0", + "signals": { + "transportHelper": "chrome-identity-sheets-v4", + "httpMethod": "PUT", + "opNameVerb": "update" + } + } + }, { "slug": "notion.loadPage", "service": "app.notion.com", diff --git a/extension/config/service-denylist.json b/extension/config/service-denylist.json index 5a77af7b6..9a099f8f6 100644 --- a/extension/config/service-denylist.json +++ b/extension/config/service-denylist.json @@ -11,6 +11,7 @@ "https://*.bankofamerica.com", "https://*.wellsfargo.com", "https://mail.google.com", + "https://docs.google.com", "https://outlook.live.com", "https://outlook.office.com", "https://outlook.cloud.microsoft", diff --git a/extension/utils/capability-catalog.js b/extension/utils/capability-catalog.js index a618c54a2..1f29cb2de 100644 --- a/extension/utils/capability-catalog.js +++ b/extension/utils/capability-catalog.js @@ -378,6 +378,7 @@ { global: 'FsbHandlerFigma', service: 'figma.com', origin: 'https://www.figma.com' }, { global: 'FsbHandlerGdrive', service: 'drive.google.com', origin: 'https://drive.google.com' }, { global: 'FsbHandlerGdocs', service: 'docs.google.com', origin: 'https://docs.google.com' }, + { global: 'FsbHandlerGsheets', service: 'docs.google.com', origin: 'https://docs.google.com' }, { global: 'FsbHandlerPowerpoint', service: 'powerpoint.cloud.microsoft', origin: 'https://powerpoint.cloud.microsoft' }, { global: 'FsbHandlerOutlook', service: 'outlook.cloud.microsoft', origin: 'https://outlook.cloud.microsoft' }, { global: 'FsbHandlerTeams', service: 'teams.live.com', origin: 'https://teams.live.com' }, diff --git a/extension/utils/capability-router.js b/extension/utils/capability-router.js index 8ae978d89..a740d3e07 100644 --- a/extension/utils/capability-router.js +++ b/extension/utils/capability-router.js @@ -143,6 +143,22 @@ function _fetchPrimitive() { return (typeof FsbCapabilityFetch !== 'undefined' && FsbCapabilityFetch) ? FsbCapabilityFetch : null; } + function _googleSheetsContext() { + var api = (typeof FsbGoogleSheetsApi !== 'undefined' && FsbGoogleSheetsApi) + ? FsbGoogleSheetsApi + : ((typeof globalThis !== 'undefined' && globalThis.FsbGoogleSheetsApi) ? globalThis.FsbGoogleSheetsApi : null); + if (!api) { return null; } + var methods = ['getSpreadsheet', 'getValues', 'updateValues', 'appendValues', 'clearValues']; + var narrow = {}; + for (var i = 0; i < methods.length; i++) { + (function (method) { + if (typeof api[method] === 'function') { + narrow[method] = function (params) { return api[method](params); }; + } + })(methods[i]); + } + return Object.freeze(narrow); + } // ---- Phase 32 (HEAL-01/HEAL-03/HEAL-04) self-healing collaborator accessors -- // The post-executeBoundSpec rot classifier, the learned-recipe quarantine @@ -705,7 +721,8 @@ url: ctx && ctx.url, executeBoundSpec: primitive ? primitive.executeBoundSpec : undefined, executeBoundPageRead: primitive ? primitive.executeBoundPageRead : undefined, - interpretRecipe: interp ? interp.interpretRecipe : undefined + interpretRecipe: interp ? interp.interpretRecipe : undefined, + googleSheets: _googleSheetsContext() }; var out = await handler.handle(args || {}, handlerCtx); diff --git a/scripts/verify-recipe-path-guard.mjs b/scripts/verify-recipe-path-guard.mjs index 198214959..cdc91e2e8 100644 --- a/scripts/verify-recipe-path-guard.mjs +++ b/scripts/verify-recipe-path-guard.mjs @@ -215,6 +215,7 @@ const RECIPE_PATH_ALLOWLIST = [ 'catalog/handlers/minimax.js', 'catalog/handlers/figma.js', 'catalog/handlers/gdrive.js', + 'catalog/handlers/gsheets.js', 'catalog/handlers/outlook.js', 'catalog/handlers/teams.js', 'catalog/handlers/onenote.js', diff --git a/tests/google-sheets-wiring.test.js b/tests/google-sheets-wiring.test.js new file mode 100644 index 000000000..f9cda1afd --- /dev/null +++ b/tests/google-sheets-wiring.test.js @@ -0,0 +1,89 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const ROOT = path.resolve(__dirname, '..'); +const SLUGS = [ + 'gsheets.get_spreadsheet', + 'gsheets.get_values', + 'gsheets.update_values', + 'gsheets.append_values', + 'gsheets.clear_values' +]; + +function read(relative) { + return fs.readFileSync(path.join(ROOT, relative), 'utf8'); +} + +test('manifest and background wire fail-closed Sheets OAuth and handler modules', () => { + const manifest = JSON.parse(read('extension/manifest.json')); + assert.ok(manifest.permissions.includes('identity')); + assert.ok(manifest.permissions.includes('identity.email')); + assert.deepEqual(manifest.oauth2.scopes, ['https://www.googleapis.com/auth/spreadsheets']); + assert.match(manifest.oauth2.client_id, /^REPLACE_/); + + const background = read('extension/background.js'); + assert.match(background, /importScripts\('utils\/google-sheets-api\.js'\)/); + assert.match(background, /importScripts\('catalog\/handlers\/gsheets\.js'\)/); + assert.match(background, /case 'google-sheets:connect'/); + assert.match(background, /!sender\.tab/); + assert.match(background, /getURL\('ui\/control_panel\.html'\)/); +}); + +test('router exposes only the five-operation Sheets facade to handlers', () => { + const router = read('extension/utils/capability-router.js'); + assert.match(router, /googleSheets: _googleSheetsContext\(\)/); + const contextBody = router.match(/function _googleSheetsContext\(\) \{([\s\S]*?)\n \}/); + assert.ok(contextBody); + for (const method of ['getSpreadsheet', 'getValues', 'updateValues', 'appendValues', 'clearValues']) { + assert.ok(contextBody[1].includes(`'${method}'`)); + } + for (const forbidden of ['connect', 'disconnect', 'status', 'fetch', 'request']) { + assert.equal(contextBody[1].includes(`'${forbidden}'`), false); + } +}); + +test('catalog descriptors are strict, handler-backed, discoverable, and classified', () => { + const expectedClasses = ['read', 'read', 'write', 'write', 'destructive']; + const index = require('../extension/catalog/recipe-index.generated.js'); + for (let i = 0; i < SLUGS.length; i++) { + const descriptor = index.descriptors.find(row => row.slug === SLUGS[i]); + assert.ok(descriptor, `${SLUGS[i]} is in generated catalog`); + assert.equal(descriptor.backing, 'handler'); + assert.equal(descriptor.service, 'docs.google.com'); + assert.equal(descriptor.sideEffectClass, expectedClasses[i]); + assert.equal(descriptor.params.additionalProperties, false); + assert.ok(descriptor.intentSynonyms.length >= 3); + } +}); + +test('canonical and packaged handler copies match and catalog seeds every slug', () => { + assert.equal(read('catalog/handlers/gsheets.js'), read('extension/catalog/handlers/gsheets.js')); + const catalogSource = read('extension/utils/capability-catalog.js'); + assert.match(catalogSource, /global: 'FsbHandlerGsheets'/); + + delete require.cache[require.resolve('../extension/utils/capability-catalog.js')]; + const catalog = require('../extension/utils/capability-catalog.js'); + globalThis.FsbCapabilityCatalog = catalog; + delete require.cache[require.resolve('../catalog/handlers/gsheets.js')]; + require('../catalog/handlers/gsheets.js'); + catalog.seedHeadHandlers(); + for (const slug of SLUGS) { + const entry = catalog.resolve(slug, 'https://docs.google.com'); + assert.ok(entry); + assert.equal(entry.tier, 'T1a'); + assert.equal(typeof entry.handler.handle, 'function'); + } +}); + +test('Sheets is sensitive, security guard covers its handler, and legacy fallback remains', () => { + const policy = JSON.parse(read('extension/config/service-denylist.json')); + assert.ok(policy.sensitiveOrigins.includes('https://docs.google.com')); + assert.match(read('scripts/verify-recipe-path-guard.mjs'), /'catalog\/handlers\/gsheets\.js'/); + const tools = read('extension/ai/tool-definitions.js'); + assert.match(tools, /name: 'fill_sheet'/); + assert.match(tools, /name: 'read_sheet'/); +}); diff --git a/tests/gsheets-handler.test.js b/tests/gsheets-handler.test.js new file mode 100644 index 000000000..2c653a51f --- /dev/null +++ b/tests/gsheets-handler.test.js @@ -0,0 +1,97 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const handlers = require('../catalog/handlers/gsheets.js'); + +const ID = 'abcdefghijklmnopqrstuvwxyz123456'; +const ACTIVE_URL = `https://docs.google.com/spreadsheets/d/${ID}/edit#gid=0`; +const EXPECTED = { + 'gsheets.get_spreadsheet': ['getSpreadsheet', 'read'], + 'gsheets.get_values': ['getValues', 'read'], + 'gsheets.update_values': ['updateValues', 'write'], + 'gsheets.append_values': ['appendValues', 'write'], + 'gsheets.clear_values': ['clearValues', 'destructive'] +}; + +function context(calls, url) { + const client = {}; + for (const [method] of Object.values(EXPECTED)) { + client[method] = async params => { + calls.push({ method, params }); + return { success: true, status: 200, data: { accepted: true } }; + }; + } + return { url: url === undefined ? ACTIVE_URL : url, googleSheets: client }; +} + +test('exports five T1a handlers with explicit side-effect classifications and strict schemas', () => { + assert.deepEqual(Object.keys(handlers).sort(), Object.keys(EXPECTED).sort()); + for (const [slug, [_method, sideEffectClass]] of Object.entries(EXPECTED)) { + const handler = handlers[slug]; + assert.equal(handler.tier, 'T1a'); + assert.equal(handler.origin, 'https://docs.google.com'); + assert.equal(handler.sideEffectClass, sideEffectClass); + assert.equal(handler.params.type, 'object'); + assert.equal(handler.params.additionalProperties, false); + assert.equal(typeof handler.handle, 'function'); + } + assert.deepEqual(handlers['gsheets.get_values'].params.required, ['range']); + assert.deepEqual(handlers['gsheets.update_values'].params.required, ['range', 'values']); + assert.deepEqual(handlers['gsheets.append_values'].params.required, ['range', 'values']); + assert.deepEqual(handlers['gsheets.clear_values'].params.required, ['range']); +}); + +test('derives a spreadsheet ID only from the active Google Sheets URL', async () => { + const calls = []; + const out = await handlers['gsheets.get_spreadsheet'].handle({}, context(calls)); + assert.equal(out.success, true); + assert.deepEqual(calls, [{ method: 'getSpreadsheet', params: { spreadsheetId: ID } }]); + + const wrongOrigin = await handlers['gsheets.get_spreadsheet'].handle({}, context([], `https://evil.example/spreadsheets/d/${ID}/edit`)); + assert.equal(wrongOrigin.code, 'GOOGLE_SHEETS_SPREADSHEET_REQUIRED'); +}); + +test('routes each operation to exactly one narrow client method', async () => { + const cases = [ + ['gsheets.get_values', { range: 'Data!A1:B2', valueRenderOption: 'FORMULA' }, 'getValues'], + ['gsheets.update_values', { range: 'A1:B1', values: [['name', 1]], valueInputOption: 'RAW' }, 'updateValues'], + ['gsheets.append_values', { range: 'A:B', values: [['x', true]], insertDataOption: 'INSERT_ROWS' }, 'appendValues'], + ['gsheets.clear_values', { range: 'Archive!A2:Z' }, 'clearValues'] + ]; + for (const [slug, args, expectedMethod] of cases) { + const calls = []; + const out = await handlers[slug].handle(args, context(calls)); + assert.equal(out.success, true); + assert.equal(calls.length, 1); + assert.equal(calls[0].method, expectedMethod); + assert.equal(calls[0].params.spreadsheetId, ID); + } +}); + +test('explicit spreadsheet ID wins and arbitrary transport fields are never forwarded', async () => { + const calls = []; + await handlers['gsheets.update_values'].handle({ + spreadsheetId: 'explicitSpreadsheetId1234567890', + range: 'A1', + values: [['safe']], + url: 'https://attacker.example', + method: 'DELETE', + headers: { Authorization: 'secret' }, + token: 'secret' + }, context(calls)); + assert.deepEqual(calls[0].params, { + spreadsheetId: 'explicitSpreadsheetId1234567890', + range: 'A1', + values: [['safe']], + valueInputOption: undefined + }); +}); + +test('fails closed when the API facade or spreadsheet target is unavailable', async () => { + const noClient = await handlers['gsheets.get_values'].handle({ spreadsheetId: ID, range: 'A1' }, {}); + const noTarget = await handlers['gsheets.get_values'].handle({ range: 'A1' }, { googleSheets: { getValues() {} } }); + assert.equal(noClient.code, 'GOOGLE_SHEETS_API_UNAVAILABLE'); + assert.equal(noTarget.code, 'GOOGLE_SHEETS_SPREADSHEET_REQUIRED'); +}); diff --git a/tests/head-handler-cap.test.js b/tests/head-handler-cap.test.js index e95b44f26..bf3e20d71 100644 --- a/tests/head-handler-cap.test.js +++ b/tests/head-handler-cap.test.js @@ -31,7 +31,7 @@ const path = require('path'); const REPO_ROOT = path.resolve(__dirname, '..'); const CATALOG_MODULE_PATH = path.join(REPO_ROOT, 'extension', 'utils', 'capability-catalog.js'); -const CAP = 123; +const CAP = 124; // The head globals expected today. Locking the identities (not just the count) // catches a silent SWAP that keeps the count stable but changes which handlers ship. const EXPECTED_HEAD_GLOBALS = [ @@ -131,6 +131,7 @@ const EXPECTED_HEAD_GLOBALS = [ 'FsbHandlerFigma', 'FsbHandlerGdrive', 'FsbHandlerGdocs', + 'FsbHandlerGsheets', 'FsbHandlerPowerpoint', 'FsbHandlerOutlook', 'FsbHandlerTeams', @@ -201,8 +202,8 @@ check(headEntryCount <= CAP, `HEAD_HANDLER_MODULES.length ${headEntryCount} <= CAP ${CAP} (the head stays descriptors-only; breadth never sprawls)`); // ---- Today's exact head -- breadth adds DATA, depth adds narrow same-origin heads -- -check(headEntryCount === 123, - `HEAD_HANDLER_MODULES has exactly 123 entries (current T1 head set); got ${headEntryCount}`); +check(headEntryCount === 124, + `HEAD_HANDLER_MODULES has exactly 124 entries (current T1 head set); got ${headEntryCount}`); const missingGlobals = EXPECTED_HEAD_GLOBALS.filter((g) => !foundGlobals.includes(g)); check(missingGlobals.length === 0, `the expected head globals are present (missing: [${missingGlobals.join(', ') || 'none'}])`); From a83d21b82e7ff79726b896502f3e4ac4d99b40c2 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Wed, 15 Jul 2026 07:12:34 -0500 Subject: [PATCH 13/26] fix(privacy): redact spreadsheet session data --- catalog/handlers/gsheets.js | 59 ++--- catalog/write-activation-evidence.json | 45 ++++ docs/google-sheets-api.md | 2 + extension/background.js | 1 + extension/catalog/handlers/gsheets.js | 59 ++--- extension/utils/capability-search.js | 5 + .../utils/spreadsheet-record-redaction.js | 212 ++++++++++++++++++ extension/ws/mcp-bridge-client.js | 17 +- extension/ws/mcp-tool-dispatcher.js | 18 +- scripts/report-t1-readiness.mjs | 4 + scripts/verify-origin-classification.mjs | 113 +++++++++- scripts/verify-t1-port-contract.mjs | 1 + tests/gsheets-handler.test.js | 34 ++- tests/mcp-session-recorder.test.js | 30 ++- tests/spreadsheet-record-redaction.test.js | 210 +++++++++++++++++ tests/verify-origin-classification.test.js | 11 +- tests/write-activation-evidence.test.js | 4 +- 17 files changed, 726 insertions(+), 99 deletions(-) create mode 100644 extension/utils/spreadsheet-record-redaction.js create mode 100644 tests/spreadsheet-record-redaction.test.js diff --git a/catalog/handlers/gsheets.js b/catalog/handlers/gsheets.js index 58b940fc6..2dd989534 100644 --- a/catalog/handlers/gsheets.js +++ b/catalog/handlers/gsheets.js @@ -3,6 +3,7 @@ var ORIGIN = 'https://docs.google.com'; var SERVICE = 'docs.google.com'; + var FALLBACK_CODE = 'RECIPE_DOM_FALLBACK_PENDING'; var ID_PATTERN = '^[A-Za-z0-9_-]{10,200}$'; var ID_RE = /^[A-Za-z0-9_-]{10,200}$/; @@ -65,6 +66,26 @@ return { success: false, code: code, errorCode: code, error: code }; } + function guarded(slug, sideEffectClass, params) { + return { + tier: 'T1a', + origin: ORIGIN, + sideEffectClass: sideEffectClass, + params: params, + async handle() { + return { + success: false, + code: FALLBACK_CODE, + errorCode: FALLBACK_CODE, + error: FALLBACK_CODE, + slug: slug, + reason: 'google-sheets-live-mutation-uat-required', + fellBackToDom: true + }; + } + }; + } + function spreadsheetIdFromUrl(ctx) { var url = ctx && typeof ctx.url === 'string' ? ctx.url : ''; if (!url) { return ''; } @@ -123,41 +144,9 @@ }); } }, - 'gsheets.update_values': { - tier: 'T1a', origin: ORIGIN, sideEffectClass: 'write', params: UPDATE_VALUES_PARAMS, - handle: function (args, ctx) { - return call('updateValues', args, ctx, function (spreadsheetId, input) { - return { - spreadsheetId: spreadsheetId, - range: input.range, - values: input.values, - valueInputOption: input.valueInputOption - }; - }); - } - }, - 'gsheets.append_values': { - tier: 'T1a', origin: ORIGIN, sideEffectClass: 'write', params: APPEND_VALUES_PARAMS, - handle: function (args, ctx) { - return call('appendValues', args, ctx, function (spreadsheetId, input) { - return { - spreadsheetId: spreadsheetId, - range: input.range, - values: input.values, - valueInputOption: input.valueInputOption, - insertDataOption: input.insertDataOption - }; - }); - } - }, - 'gsheets.clear_values': { - tier: 'T1a', origin: ORIGIN, sideEffectClass: 'destructive', params: CLEAR_VALUES_PARAMS, - handle: function (args, ctx) { - return call('clearValues', args, ctx, function (spreadsheetId, input) { - return { spreadsheetId: spreadsheetId, range: input.range }; - }); - } - } + 'gsheets.update_values': guarded('gsheets.update_values', 'write', UPDATE_VALUES_PARAMS), + 'gsheets.append_values': guarded('gsheets.append_values', 'write', APPEND_VALUES_PARAMS), + 'gsheets.clear_values': guarded('gsheets.clear_values', 'destructive', CLEAR_VALUES_PARAMS) }; if (global.FsbCapabilityCatalog && typeof global.FsbCapabilityCatalog.registerHandler === 'function') { diff --git a/catalog/write-activation-evidence.json b/catalog/write-activation-evidence.json index b85fb3cc3..fcaa2ae1f 100644 --- a/catalog/write-activation-evidence.json +++ b/catalog/write-activation-evidence.json @@ -2391,6 +2391,51 @@ "loadedExtensionSmoke" ] }, + { + "slug": "gsheets.update_values", + "status": "guarded-fail-closed", + "failClosedReason": "unverified-google-sheets-update-values-mutation", + "templateRef": ".planning/phases/49-guarded-writes-activation-pipeline/49-LIVE-UAT-TEMPLATE.md", + "requiredEvidence": [ + "method", + "path", + "bodyShape", + "authShape", + "consentProof", + "auditRedactionProof", + "loadedExtensionSmoke" + ] + }, + { + "slug": "gsheets.append_values", + "status": "guarded-fail-closed", + "failClosedReason": "unverified-google-sheets-append-values-mutation", + "templateRef": ".planning/phases/49-guarded-writes-activation-pipeline/49-LIVE-UAT-TEMPLATE.md", + "requiredEvidence": [ + "method", + "path", + "bodyShape", + "authShape", + "consentProof", + "auditRedactionProof", + "loadedExtensionSmoke" + ] + }, + { + "slug": "gsheets.clear_values", + "status": "guarded-fail-closed", + "failClosedReason": "unverified-google-sheets-clear-values-mutation", + "templateRef": ".planning/phases/49-guarded-writes-activation-pipeline/49-LIVE-UAT-TEMPLATE.md", + "requiredEvidence": [ + "method", + "path", + "bodyShape", + "authShape", + "consentProof", + "auditRedactionProof", + "loadedExtensionSmoke" + ] + }, { "slug": "gemini.create_conversation", "status": "guarded-fail-closed", diff --git a/docs/google-sheets-api.md b/docs/google-sheets-api.md index 6a748c631..4f0915ff5 100644 --- a/docs/google-sheets-api.md +++ b/docs/google-sheets-api.md @@ -28,4 +28,6 @@ The API facade permits only these operations: Requests are restricted to `https://sheets.googleapis.com/v4`, validate spreadsheet IDs and ranges, cap request and response sizes, time out, and retry once only after an HTTP 401. Google Sheets is classified as a sensitive origin, so write and destructive operations remain behind FSB's consent policy. +After OAuth setup, the two read capabilities are executable. The update, append, and clear capabilities are discoverable and fully typed but intentionally return `RECIPE_DOM_FALLBACK_PENDING` without calling Google until the repository's live mutation-UAT activation record is completed. This preserves the project's fail-closed write policy; configuring OAuth alone does not activate spreadsheet mutations. + The existing `fill_sheet` and `read_sheet` browser-automation tools remain available as a fallback. Spreadsheet API and fallback-tool session records are reduced to shape-only diagnostics before recording; spreadsheet IDs, ranges, sheet names, values, formulas, and response bodies are discarded. diff --git a/extension/background.js b/extension/background.js index b07b04466..700c70ac5 100644 --- a/extension/background.js +++ b/extension/background.js @@ -62,6 +62,7 @@ try { importScripts('utils/trigger-lifecycle.js'); } catch (e) { console.error(' // remain DOM-bound but are not invoked here -- SW reaches them via // chrome.scripting.executeScript injection inside wrapWithChangeReport. try { importScripts('utils/action-verification.js'); } catch (e) { console.error('[FSB] Failed to load action-verification.js:', e.message); } +try { importScripts('utils/spreadsheet-record-redaction.js'); } catch (e) { console.error('[FSB] Failed to load spreadsheet-record-redaction.js:', e.message); } try { importScripts('ws/mcp-tool-dispatcher.js'); } catch (e) { console.error('[FSB] Failed to load mcp-tool-dispatcher.js:', e.message); } // Phase 270 / v0.9.69 -- price resolver. Must load BEFORE mcp-metrics-recorder // so the recorder's try/catch can call globalThis.fsbMcpPricing.estimateMcpCost. diff --git a/extension/catalog/handlers/gsheets.js b/extension/catalog/handlers/gsheets.js index 58b940fc6..2dd989534 100644 --- a/extension/catalog/handlers/gsheets.js +++ b/extension/catalog/handlers/gsheets.js @@ -3,6 +3,7 @@ var ORIGIN = 'https://docs.google.com'; var SERVICE = 'docs.google.com'; + var FALLBACK_CODE = 'RECIPE_DOM_FALLBACK_PENDING'; var ID_PATTERN = '^[A-Za-z0-9_-]{10,200}$'; var ID_RE = /^[A-Za-z0-9_-]{10,200}$/; @@ -65,6 +66,26 @@ return { success: false, code: code, errorCode: code, error: code }; } + function guarded(slug, sideEffectClass, params) { + return { + tier: 'T1a', + origin: ORIGIN, + sideEffectClass: sideEffectClass, + params: params, + async handle() { + return { + success: false, + code: FALLBACK_CODE, + errorCode: FALLBACK_CODE, + error: FALLBACK_CODE, + slug: slug, + reason: 'google-sheets-live-mutation-uat-required', + fellBackToDom: true + }; + } + }; + } + function spreadsheetIdFromUrl(ctx) { var url = ctx && typeof ctx.url === 'string' ? ctx.url : ''; if (!url) { return ''; } @@ -123,41 +144,9 @@ }); } }, - 'gsheets.update_values': { - tier: 'T1a', origin: ORIGIN, sideEffectClass: 'write', params: UPDATE_VALUES_PARAMS, - handle: function (args, ctx) { - return call('updateValues', args, ctx, function (spreadsheetId, input) { - return { - spreadsheetId: spreadsheetId, - range: input.range, - values: input.values, - valueInputOption: input.valueInputOption - }; - }); - } - }, - 'gsheets.append_values': { - tier: 'T1a', origin: ORIGIN, sideEffectClass: 'write', params: APPEND_VALUES_PARAMS, - handle: function (args, ctx) { - return call('appendValues', args, ctx, function (spreadsheetId, input) { - return { - spreadsheetId: spreadsheetId, - range: input.range, - values: input.values, - valueInputOption: input.valueInputOption, - insertDataOption: input.insertDataOption - }; - }); - } - }, - 'gsheets.clear_values': { - tier: 'T1a', origin: ORIGIN, sideEffectClass: 'destructive', params: CLEAR_VALUES_PARAMS, - handle: function (args, ctx) { - return call('clearValues', args, ctx, function (spreadsheetId, input) { - return { spreadsheetId: spreadsheetId, range: input.range }; - }); - } - } + 'gsheets.update_values': guarded('gsheets.update_values', 'write', UPDATE_VALUES_PARAMS), + 'gsheets.append_values': guarded('gsheets.append_values', 'write', APPEND_VALUES_PARAMS), + 'gsheets.clear_values': guarded('gsheets.clear_values', 'destructive', CLEAR_VALUES_PARAMS) }; if (global.FsbCapabilityCatalog && typeof global.FsbCapabilityCatalog.registerHandler === 'function') { diff --git a/extension/utils/capability-search.js b/extension/utils/capability-search.js index e824a67fc..8d98380ee 100644 --- a/extension/utils/capability-search.js +++ b/extension/utils/capability-search.js @@ -677,6 +677,8 @@ 'gdrive.list_files': true, 'gdrive.list_permissions': true, 'gdrive.search_files': true, + 'gsheets.get_spreadsheet': true, + 'gsheets.get_values': true, 'gemini.get_conversation': true, 'gemini.get_current_user': true, 'gemini.list_conversations': true, @@ -1437,6 +1439,9 @@ 'gitlab.create_issue': true, 'gitlab.create_merge_request': true, 'gitlab.create_note': true, + 'gsheets.update_values': true, + 'gsheets.append_values': true, + 'gsheets.clear_values': true, 'slack.send_message': true, 'supabase.create_secrets': true, 'supabase.delete_function': true, diff --git a/extension/utils/spreadsheet-record-redaction.js b/extension/utils/spreadsheet-record-redaction.js new file mode 100644 index 000000000..5750b5a68 --- /dev/null +++ b/extension/utils/spreadsheet-record-redaction.js @@ -0,0 +1,212 @@ +(function (global) { + 'use strict'; + + var KNOWN_CAPABILITIES = Object.freeze({ + 'gsheets.get_spreadsheet': true, + 'gsheets.get_values': true, + 'gsheets.update_values': true, + 'gsheets.append_values': true, + 'gsheets.clear_values': true + }); + var LEGACY_TOOLS = Object.freeze({ fill_sheet: true, read_sheet: true }); + var SAFE_ERROR_CODE = /^(?:GOOGLE_SHEETS|RECIPE)_[A-Z0-9_]{1,64}$/; + + function object(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + } + + function classify(entry) { + if (!entry || typeof entry !== 'object') { return null; } + if (LEGACY_TOOLS[entry.tool]) { + return { operation: entry.tool, actionShape: true }; + } + var payload = object(entry.requestPayload || entry.payload); + if (entry.tool === 'mcp:capabilities-invoke' && typeof payload.slug === 'string' && payload.slug.indexOf('gsheets.') === 0) { + return { + operation: KNOWN_CAPABILITIES[payload.slug] ? payload.slug : 'gsheets.unknown', + actionShape: false + }; + } + return null; + } + + function arrayShape(values) { + if (!Array.isArray(values)) { return null; } + var rows = values; + if (rows.length > 0 && !Array.isArray(rows[0])) { rows = [rows]; } + var rowCount = rows.length; + var columnCount = 0; + var valueCount = 0; + for (var i = 0; i < rows.length; i++) { + if (!Array.isArray(rows[i])) { continue; } + if (rows[i].length > columnCount) { columnCount = rows[i].length; } + valueCount += rows[i].length; + } + return { rowCount: rowCount, columnCount: columnCount, valueCount: valueCount }; + } + + function csvShape(csv) { + if (typeof csv !== 'string' || csv.length === 0) { + return { rowCount: 0, columnCount: 0, valueCount: 0 }; + } + var rowCount = 1; + var currentColumns = 1; + var columnCount = 1; + var valueCount = 0; + var quoted = false; + for (var i = 0; i < csv.length; i++) { + var ch = csv[i]; + if (ch === '"') { + if (quoted && csv[i + 1] === '"') { i++; } + else { quoted = !quoted; } + } else if (!quoted && ch === ',') { + currentColumns++; + } else if (!quoted && (ch === '\n' || ch === '\r')) { + if (ch === '\r' && csv[i + 1] === '\n') { i++; } + valueCount += currentColumns; + if (currentColumns > columnCount) { columnCount = currentColumns; } + rowCount++; + currentColumns = 1; + } + } + valueCount += currentColumns; + if (currentColumns > columnCount) { columnCount = currentColumns; } + return { rowCount: rowCount, columnCount: columnCount, valueCount: valueCount }; + } + + function numericCounts(source) { + source = object(source); + var out = {}; + var fields = ['updatedRows', 'updatedColumns', 'updatedCells', 'updatedSheets']; + for (var i = 0; i < fields.length; i++) { + var value = source[fields[i]]; + if (Number.isFinite(value) && value >= 0) { out[fields[i]] = Math.floor(value); } + } + return out; + } + + function mergeShape(primary, counts) { + var out = { rowCount: 0, columnCount: 0, valueCount: 0 }; + if (primary) { + out.rowCount = primary.rowCount; + out.columnCount = primary.columnCount; + out.valueCount = primary.valueCount; + } + var keys = Object.keys(counts || {}); + for (var i = 0; i < keys.length; i++) { out[keys[i]] = counts[keys[i]]; } + return out; + } + + function requestShape(params) { + params = object(params); + if (Array.isArray(params.values)) { return arrayShape(params.values); } + if (typeof params.csvData === 'string') { return csvShape(params.csvData); } + return { rowCount: 0, columnCount: 0, valueCount: 0 }; + } + + function responseShape(response) { + var root = response; + var rootObject = object(response); + var rawData = rootObject.data; + var data = object(rawData); + var updates = object(data.updates || rootObject.updates); + var candidates = [ + Array.isArray(root) ? root : null, + rootObject.values, + Array.isArray(rawData) ? rawData : null, + data.values, + data.rows + ]; + var shape = null; + for (var i = 0; i < candidates.length; i++) { + if (Array.isArray(candidates[i])) { shape = arrayShape(candidates[i]); break; } + } + var counts = numericCounts(object(root)); + var dataCounts = numericCounts(data); + var updateCounts = numericCounts(updates); + var key; + for (key in dataCounts) { if (Object.prototype.hasOwnProperty.call(dataCounts, key)) { counts[key] = dataCounts[key]; } } + for (key in updateCounts) { if (Object.prototype.hasOwnProperty.call(updateCounts, key)) { counts[key] = updateCounts[key]; } } + return mergeShape(shape, counts); + } + + function safePayload(source, classification, params) { + source = object(source); + var out = { + params: { + operation: classification.operation, + shape: requestShape(params) + } + }; + if (classification.operation.indexOf('gsheets.') === 0) { out.slug = classification.operation; } + if (typeof source.agentId === 'string' && source.agentId.length > 0 && source.agentId.length <= 256) { + out.agentId = source.agentId; + } + if (Number.isFinite(source.tab_id)) { out.tab_id = source.tab_id; } + if (Number.isFinite(source.tabId)) { out.tabId = source.tabId; } + if (source.visualSession && typeof source.visualSession === 'object') { + out.visualSession = { isFinal: source.visualSession.isFinal === true }; + } + if (classification.actionShape) { out.tool = classification.operation; } + return out; + } + + function safeResponse(source, success) { + var value = object(source); + var out = { + success: success === true, + shape: responseShape(source) + }; + if (Number.isFinite(value.status) && value.status >= 100 && value.status <= 599) { + out.status = Math.floor(value.status); + } + var code = typeof value.errorCode === 'string' ? value.errorCode : value.code; + if (typeof code === 'string' && SAFE_ERROR_CODE.test(code)) { out.errorCode = code; } + return out; + } + + function baseFields(entry) { + var out = { + client: entry.client, + tool: entry.tool, + success: entry.success === true, + tabId: Number.isFinite(entry.tabId) ? entry.tabId : null + }; + if (typeof entry.dispatcher_route === 'string') { out.dispatcher_route = entry.dispatcher_route; } + return out; + } + + function sanitizeEntry(entry) { + var classification = classify(entry); + if (!classification) { return entry; } + var sourcePayload = object(entry.requestPayload || entry.payload); + var sourceParams = object(entry.params || sourcePayload.params); + var out = baseFields(entry); + var payload = safePayload(sourcePayload, classification, sourceParams); + var response = safeResponse(entry.response, entry.success === true); + + if (Object.prototype.hasOwnProperty.call(entry, 'requestPayload')) { + out.requestPayload = payload; + } else { + out.params = payload.params; + out.payload = payload; + } + out.response = response; + return out; + } + + function recordSafely(recorder, method, entry) { + if (!recorder || typeof recorder[method] !== 'function') { return false; } + try { + recorder[method](sanitizeEntry(entry)); + return true; + } catch (_e) { + // Recording is diagnostic-only. A sanitization failure drops the entry. + return false; + } + } + + var api = Object.freeze({ sanitizeEntry: sanitizeEntry, classify: classify, recordSafely: recordSafely }); + global.FsbSpreadsheetRecordRedaction = api; + if (typeof module !== 'undefined' && module.exports) { module.exports = api; } +})(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/extension/ws/mcp-bridge-client.js b/extension/ws/mcp-bridge-client.js index c4046afbf..2c14e41a9 100644 --- a/extension/ws/mcp-bridge-client.js +++ b/extension/ws/mcp-bridge-client.js @@ -718,7 +718,7 @@ class MCPBridgeClient { typeof globalThis.fsbMcpSessionRecorder.recordAction !== 'function') { return; } - globalThis.fsbMcpSessionRecorder.recordAction({ + let sessionRecordEntry = { client: (typeof globalThis.resolveMcpClientLabel === 'function') ? globalThis.resolveMcpClientLabel(payload) : null, @@ -728,7 +728,20 @@ class MCPBridgeClient { response: response, success: !(response && typeof response === 'object' && response.success === false), tabId: Number.isFinite(resolvedTabId) ? resolvedTabId : null - }); + }; + const spreadsheetRedactor = globalThis.FsbSpreadsheetRecordRedaction; + const spreadsheetTool = payload && (payload.tool === 'fill_sheet' || payload.tool === 'read_sheet'); + if (!spreadsheetRedactor || typeof spreadsheetRedactor.recordSafely !== 'function') { + if (!spreadsheetTool) { + globalThis.fsbMcpSessionRecorder.recordAction(sessionRecordEntry); + } + } else { + spreadsheetRedactor.recordSafely( + globalThis.fsbMcpSessionRecorder, + 'recordAction', + sessionRecordEntry + ); + } } catch (_e) { /* never let session recording break the action */ } } diff --git a/extension/ws/mcp-tool-dispatcher.js b/extension/ws/mcp-tool-dispatcher.js index 59dafb7dd..a18a6ca83 100644 --- a/extension/ws/mcp-tool-dispatcher.js +++ b/extension/ws/mcp-tool-dispatcher.js @@ -679,14 +679,28 @@ async function dispatchMcpMessageRoute({ type, payload = {}, client = null, mcpM globalThis.fsbMcpSessionRecorder && typeof globalThis.fsbMcpSessionRecorder.recordDispatch === 'function' ) { - globalThis.fsbMcpSessionRecorder.recordDispatch({ + var sessionRecordEntry = { client: resolveMcpClientLabel(payload), tool: type, requestPayload: payload, response, success, dispatcher_route: 'message' - }); + }; + var spreadsheetRedactor = globalThis.FsbSpreadsheetRecordRedaction; + var spreadsheetInvoke = type === 'mcp:capabilities-invoke' + && payload && typeof payload.slug === 'string' && payload.slug.indexOf('gsheets.') === 0; + if (!spreadsheetRedactor || typeof spreadsheetRedactor.recordSafely !== 'function') { + if (!spreadsheetInvoke) { + globalThis.fsbMcpSessionRecorder.recordDispatch(sessionRecordEntry); + } + } else { + spreadsheetRedactor.recordSafely( + globalThis.fsbMcpSessionRecorder, + 'recordDispatch', + sessionRecordEntry + ); + } } } catch (_e) { /* defence in depth -- never let session recording break dispatch */ } } diff --git a/scripts/report-t1-readiness.mjs b/scripts/report-t1-readiness.mjs index 3e1a35eea..645f6dad6 100644 --- a/scripts/report-t1-readiness.mjs +++ b/scripts/report-t1-readiness.mjs @@ -550,6 +550,9 @@ export const GUARDED_FAIL_CLOSED_SLUGS = [ 'gdocs.restore_document', 'gdocs.trash_document', 'gdocs.update_document_title', + 'gsheets.update_values', + 'gsheets.append_values', + 'gsheets.clear_values', 'linear.add_issue_label', 'linear.add_issue_subscriber', 'linear.archive_issue', @@ -702,6 +705,7 @@ const HANDLER_MODULES = [ 'figma.js', 'gdrive.js', 'gdocs.js', + 'gsheets.js', 'powerpoint.js', 'outlook.js', 'teams.js', diff --git a/scripts/verify-origin-classification.mjs b/scripts/verify-origin-classification.mjs index 967b7b1a2..d0911f90b 100644 --- a/scripts/verify-origin-classification.mjs +++ b/scripts/verify-origin-classification.mjs @@ -545,6 +545,11 @@ const HEAD_APP_MAP = { fallbackBaseUrl: 'https://docs.google.com', relativeRuntimeBaseUrl: '/drive/v3' }, + FsbHandlerGsheets: { + app: 'google-sheets', + fallbackBaseUrl: 'https://docs.google.com', + chromeIdentityApiBaseUrl: 'https://sheets.googleapis.com/v4' + }, FsbHandlerWebflow: { app: 'webflow', fallbackBaseUrl: 'https://webflow.com', @@ -725,6 +730,25 @@ export function classifyOriginPattern(handlerOrigin, apiBaseUrl, opts) { 'a head whose origin cannot be verified must be demoted to T3-DOM' }; } + // ---- Chrome Identity Google Sheets API accommodation: exact endpoints, ASSERTED ---- + if (options.chromeIdentitySheetsApi) { + const same = hOrigin === 'https://docs.google.com' + && aOrigin === 'https://sheets.googleapis.com'; + return { + sameOrigin: same, + separate: !same, + apiOrigin: aOrigin, + handlerOrigin: hOrigin, + reason: same + ? 'CHROME_IDENTITY_SHEETS_API: head origin https://docs.google.com routes through ' + + 'the reviewed extension-owned Google Sheets v4 facade at sheets.googleapis.com. ' + + 'Chrome Identity owns OAuth tokens; the handler receives only five fixed methods, ' + + 'the facade accepts no arbitrary URL/method/headers, and write/destructive calls ' + + 'remain subject to the sensitive-origin consent gate.' + : 'CORS_SEPARATE_ORIGIN: Chrome Identity Sheets accommodation is limited to ' + + 'docs.google.com -> sheets.googleapis.com; got head ' + hOrigin + ', API ' + aOrigin + }; + } // ---- Dynamic-workspace accommodation (slack): same-registrable-domain, ASSERTED ---- if (options.dynamicWorkspace) { const hReg = registrableDomain(hOrigin); @@ -4448,6 +4472,67 @@ function readGlamaPageStateRuntimeBase(app, runtimeBaseUrl) { : null; } +function readChromeIdentitySheetsApiBase(mapping) { + const expectedBase = 'https://sheets.googleapis.com/v4'; + if (!mapping || mapping.app !== 'google-sheets' || mapping.chromeIdentityApiBaseUrl !== expectedBase) { + return null; + } + const apiPath = join(ROOT, 'extension', 'utils', 'google-sheets-api.js'); + const handlerPath = join(ROOT, 'catalog', 'handlers', 'gsheets.js'); + const routerPath = join(ROOT, 'extension', 'utils', 'capability-router.js'); + const manifestPath = join(ROOT, 'extension', 'manifest.json'); + if (!existsSync(apiPath) || !existsSync(handlerPath) || !existsSync(routerPath) || !existsSync(manifestPath)) { + return null; + } + + const apiText = readFileSync(apiPath, 'utf8'); + const handlerText = readFileSync(handlerPath, 'utf8'); + const routerText = readFileSync(routerPath, 'utf8'); + let manifest; + try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); } catch (_e) { return null; } + + const scope = 'https://www.googleapis.com/auth/spreadsheets'; + const manifestOk = Array.isArray(manifest.permissions) + && manifest.permissions.includes('identity') + && manifest.oauth2 && Array.isArray(manifest.oauth2.scopes) + && manifest.oauth2.scopes.length === 1 && manifest.oauth2.scopes[0] === scope; + const facadeOk = /SHEETS_BASE_URL\s*=\s*['"]https:\/\/sheets\.googleapis\.com\/v4['"]/.test(apiText) + && /SHEETS_SCOPE\s*=\s*['"]https:\/\/www\.googleapis\.com\/auth\/spreadsheets['"]/.test(apiText) + && /chromeApi\.identity\.getAuthToken\(\{ interactive: interactive === true \}/.test(apiText) + && /fetchFn\(spec\.url,\s*\{/.test(apiText) + && /credentials:\s*['"]omit['"]/.test(apiText) + && /redirect:\s*['"]error['"]/.test(apiText) + && /PLACEHOLDER_CLIENT_ID\.test\(clientId\)/.test(apiText) + && !/params\.(?:url|method|headers|token)\b/.test(apiText) + && !/(?:localStorage|sessionStorage|chromeApi\.storage)/.test(apiText); + const slugs = [ + 'gsheets.get_spreadsheet', + 'gsheets.get_values', + 'gsheets.update_values', + 'gsheets.append_values', + 'gsheets.clear_values' + ]; + const handlerOk = /var\s+ORIGIN\s*=\s*['"]https:\/\/docs\.google\.com['"]/.test(handlerText) + && slugs.every(function(slug) { return handlerText.indexOf("'" + slug + "'") !== -1; }) + && /ctx\s*&&\s*ctx\.googleSheets/.test(handlerText) + && !/\bfetch\s*\(|chrome\.|Authorization|Bearer|getAuthToken/.test(handlerText); + const narrowMethods = ['getSpreadsheet', 'getValues', 'updateValues', 'appendValues', 'clearValues']; + const routerOk = /googleSheets:\s*_googleSheetsContext\(\)/.test(routerText) + && narrowMethods.every(function(method) { return routerText.indexOf("'" + method + "'") !== -1; }); + const expectedClasses = ['read', 'read', 'write', 'write', 'destructive']; + const descriptorsOk = slugs.every(function(slug, index) { + const descriptorPath = join(ROOT, 'catalog', 'descriptors', slug.replace('.', '__') + '.json'); + if (!existsSync(descriptorPath)) { return false; } + try { + const descriptor = JSON.parse(readFileSync(descriptorPath, 'utf8')); + return descriptor.backing === 'handler' && descriptor.sideEffectClass === expectedClasses[index]; + } catch (_e) { + return false; + } + }); + return manifestOk && facadeOk && handlerOk && routerOk && descriptorsOk ? expectedBase : null; +} + /** * checkOriginClassification(headsOverride, opts) -> { results, failures } * @@ -4509,7 +4594,22 @@ export function checkOriginClassification(headsOverride, opts) { let apiBaseUrl; let classifyOpts; - if (mapping.graphBearerRuntimeBaseUrl) { + if (mapping.chromeIdentityApiBaseUrl) { + const sheetsBase = readChromeIdentitySheetsApiBase(mapping); + if (!sheetsBase) { + const reason = 'CORS_CHROME_IDENTITY_SHEETS_MISMATCH: head ' + head.global + + ' requested Google Sheets API base "' + String(mapping.chromeIdentityApiBaseUrl) + + '" but the manifest, OAuth facade, narrow router context, handler, or descriptors ' + + 'did not match the reviewed fixed-endpoint contract -- refusing a Chrome Identity ' + + 'cross-origin accommodation that is not explicitly pinned'; + results.push({ global: head.global, handlerOrigin: head.origin, apiBaseUrl: null, + classification: { sameOrigin: false, separate: true, reason: reason } }); + failures.push(reason); + continue; + } + apiBaseUrl = sheetsBase; + classifyOpts = { chromeIdentitySheetsApi: true }; + } else if (mapping.graphBearerRuntimeBaseUrl) { let graphBase = null; if (mapping.pageBearerGraphApp === 'excel') { graphBase = readExcelGraphBearerRuntimeBase(mapping.app, mapping.graphBearerRuntimeBaseUrl); @@ -4890,6 +4990,8 @@ function runCli() { && reason.indexOf('PAGE_BEARER_GRAPH_READ') === 0; const isGapiPageBridge = typeof reason === 'string' && reason.indexOf('PAGE_GAPI_CLIENT_READ') === 0; + const isChromeIdentitySheets = typeof reason === 'string' + && reason.indexOf('CHROME_IDENTITY_SHEETS_API') === 0; const isGlamaPageStateRuntime = typeof reason === 'string' && reason.indexOf('GLAMA_PAGE_STATE_RUNTIME_READ') === 0; const verdict = r.classification.sameOrigin @@ -4909,7 +5011,9 @@ function runCli() { ? 'PAGE-BEARER-GRAPH' : (isGapiPageBridge ? 'PAGE-GAPI-CLIENT' - : (isGlamaPageStateRuntime ? 'PAGE-STATE-RUNTIME' : 'SAME-ORIGIN'))))))))) + : (isChromeIdentitySheets + ? 'CHROME-IDENTITY-API' + : (isGlamaPageStateRuntime ? 'PAGE-STATE-RUNTIME' : 'SAME-ORIGIN')))))))))) : 'SEPARATE'; console.log(' ' + verdict + ' ' + r.global + ' head=' + String(r.handlerOrigin) + ' api=' + String(r.apiBaseUrl)); @@ -4977,6 +5081,10 @@ function runCli() { const reason = r.classification && r.classification.reason; return typeof reason === 'string' && reason.indexOf('GLAMA_PAGE_STATE_RUNTIME_READ') === 0; }).length; + const chromeIdentitySheetsApis = results.filter(function(r) { + const reason = r.classification && r.classification.reason; + return typeof reason === 'string' && reason.indexOf('CHROME_IDENTITY_SHEETS_API') === 0; + }).length; console.log( 'verify-origin-classification: PASS (' + results.length + ' shipped head(s); ' + publicCorsReads + ' explicit public no-auth CORS read accommodation(s); ' + @@ -4987,6 +5095,7 @@ function runCli() { guardedOnlyHeads + ' guarded-only no-execution head(s); ' + pageBearerGraphReads + ' page-bearer Graph read accommodation(s); ' + gapiPageBridgeReads + ' page GAPI client read accommodation(s); ' + + chromeIdentitySheetsApis + ' Chrome Identity Sheets API accommodation(s); ' + glamaPageStateRuntimeReads + ' Glama page-state runtime read accommodation(s); linear ' + 'separate-origin negative-control classifies separate; 0 silent cross-origin ports)' ); diff --git a/scripts/verify-t1-port-contract.mjs b/scripts/verify-t1-port-contract.mjs index 97a5f462c..186a33971 100644 --- a/scripts/verify-t1-port-contract.mjs +++ b/scripts/verify-t1-port-contract.mjs @@ -127,6 +127,7 @@ export const HANDLER_BY_APP = Object.freeze({ figma: 'figma.js', gdrive: 'gdrive.js', gdocs: 'gdocs.js', + gsheets: 'gsheets.js', powerpoint: 'powerpoint.js', outlook: 'outlook.js', teams: 'teams.js', diff --git a/tests/gsheets-handler.test.js b/tests/gsheets-handler.test.js index 2c653a51f..21aead3c2 100644 --- a/tests/gsheets-handler.test.js +++ b/tests/gsheets-handler.test.js @@ -53,12 +53,10 @@ test('derives a spreadsheet ID only from the active Google Sheets URL', async () assert.equal(wrongOrigin.code, 'GOOGLE_SHEETS_SPREADSHEET_REQUIRED'); }); -test('routes each operation to exactly one narrow client method', async () => { +test('routes read operations to exactly one narrow client method', async () => { const cases = [ - ['gsheets.get_values', { range: 'Data!A1:B2', valueRenderOption: 'FORMULA' }, 'getValues'], - ['gsheets.update_values', { range: 'A1:B1', values: [['name', 1]], valueInputOption: 'RAW' }, 'updateValues'], - ['gsheets.append_values', { range: 'A:B', values: [['x', true]], insertDataOption: 'INSERT_ROWS' }, 'appendValues'], - ['gsheets.clear_values', { range: 'Archive!A2:Z' }, 'clearValues'] + ['gsheets.get_spreadsheet', {}, 'getSpreadsheet'], + ['gsheets.get_values', { range: 'Data!A1:B2', valueRenderOption: 'FORMULA' }, 'getValues'] ]; for (const [slug, args, expectedMethod] of cases) { const calls = []; @@ -70,12 +68,29 @@ test('routes each operation to exactly one narrow client method', async () => { } }); +test('write and destructive operations are runtime guarded until live UAT activation', async () => { + const cases = [ + ['gsheets.update_values', { range: 'A1:B1', values: [['name', 1]], valueInputOption: 'RAW' }], + ['gsheets.append_values', { range: 'A:B', values: [['x', true]], insertDataOption: 'INSERT_ROWS' }], + ['gsheets.clear_values', { range: 'Archive!A2:Z' }] + ]; + for (const [slug, args] of cases) { + const calls = []; + const out = await handlers[slug].handle(args, context(calls)); + assert.equal(out.success, false); + assert.equal(out.code, 'RECIPE_DOM_FALLBACK_PENDING'); + assert.equal(out.slug, slug); + assert.equal(out.reason, 'google-sheets-live-mutation-uat-required'); + assert.equal(out.fellBackToDom, true); + assert.deepEqual(calls, []); + } +}); + test('explicit spreadsheet ID wins and arbitrary transport fields are never forwarded', async () => { const calls = []; - await handlers['gsheets.update_values'].handle({ + await handlers['gsheets.get_values'].handle({ spreadsheetId: 'explicitSpreadsheetId1234567890', range: 'A1', - values: [['safe']], url: 'https://attacker.example', method: 'DELETE', headers: { Authorization: 'secret' }, @@ -84,8 +99,9 @@ test('explicit spreadsheet ID wins and arbitrary transport fields are never forw assert.deepEqual(calls[0].params, { spreadsheetId: 'explicitSpreadsheetId1234567890', range: 'A1', - values: [['safe']], - valueInputOption: undefined + majorDimension: undefined, + valueRenderOption: undefined, + dateTimeRenderOption: undefined }); }); diff --git a/tests/mcp-session-recorder.test.js b/tests/mcp-session-recorder.test.js index 2a32aa067..0339c4dff 100644 --- a/tests/mcp-session-recorder.test.js +++ b/tests/mcp-session-recorder.test.js @@ -38,11 +38,11 @@ * 9. Recorder never throws (throwing storage + throwing logger shims); * each hook site wrapped in its own try/catch -- dispatcher guard * string x1 (message route), bridge guard string x1 (action tap). - * 10. Source-pin guards: exactly 1 fsbMcpSessionRecorder.recordDispatch - * site in mcp-tool-dispatcher.js (message route, inside the + * 10. Source-pin guards: exactly 1 shared-redactor recordDispatch ingress + * in mcp-tool-dispatcher.js (message route, inside the * !_mcpMetricsSuppressInner gate, after dispatchMcpMessageRoute -- the * tool route stays session-clean or background actions double-count); - * exactly 1 fsbMcpSessionRecorder.recordAction site and exactly 3 + * exactly 1 shared-redactor recordAction ingress and exactly 3 * this._recordMcpSessionAction( invocations in mcp-bridge-client.js; * the fsbMcpMetricsRecorder pattern still matches exactly 2 sites; * background.js loads the recorder on exactly one line; @@ -546,11 +546,16 @@ function readDispatch(o) { // The tool route must stay session-clean: all of its action traffic // originates in _handleExecuteAction (which records at the bridge tap), // so a second site here would double-count every background action. - const sessionSitePattern = /globalThis\.fsbMcpSessionRecorder\.recordDispatch\(\{[\s\S]*?\}\);/g; + const sessionSitePattern = /spreadsheetRedactor\.recordSafely\([\s\S]*?globalThis\.fsbMcpSessionRecorder,[\s\S]*?'recordDispatch',[\s\S]*?sessionRecordEntry[\s\S]*?\);/g; const sessionSites = dispatcherSrc.match(sessionSitePattern) || []; passAssertEqual(sessionSites.length, 1, - 'exactly 1 fsbMcpSessionRecorder.recordDispatch site in mcp-tool-dispatcher.js (message route only)'); - passAssert(sessionSites.length === 1 && sessionSites[0].includes('resolveMcpClientLabel(payload)'), + 'exactly 1 redacted recordDispatch ingress in mcp-tool-dispatcher.js (message route only)'); + const sessionEntryStart = dispatcherSrc.indexOf('var sessionRecordEntry = {'); + const sessionIngressEnd = dispatcherSrc.indexOf("'recordDispatch'", sessionEntryStart); + const sessionIngressSpan = sessionEntryStart === -1 || sessionIngressEnd === -1 + ? '' + : dispatcherSrc.slice(sessionEntryStart, sessionIngressEnd); + passAssert(sessionIngressSpan.includes('resolveMcpClientLabel(payload)'), 'the message-route session site calls resolveMcpClientLabel(payload)'); const msgRouteIdx = dispatcherSrc.indexOf('async function dispatchMcpMessageRoute'); @@ -563,7 +568,7 @@ function readDispatch(o) { const gateCount = dispatcherSrc.split(gateNeedle).length - 1; passAssertEqual(gateCount, 1, 'exactly one !_mcpMetricsSuppressInner gate in the dispatcher'); const gateIdx = dispatcherSrc.indexOf(gateNeedle); - const sessionCallIdx = dispatcherSrc.indexOf('globalThis.fsbMcpSessionRecorder.recordDispatch({'); + const sessionCallIdx = dispatcherSrc.indexOf('spreadsheetRedactor.recordSafely('); passAssert(sessionCallIdx > gateIdx, 'message-route session site sits INSIDE the !_mcpMetricsSuppressInner gate (alias double-count guard)'); @@ -582,10 +587,15 @@ function readDispatch(o) { // Bridge tap: exactly ONE recordAction call site, reached from exactly // THREE _handleExecuteAction branches (main path + open_tab/switch_tab // bootstrap + navigate NO_OWNED_TAB recovery). - const recordActionSites = bridgeSrc.match(/globalThis\.fsbMcpSessionRecorder\.recordAction\(\{[\s\S]*?\}\);/g) || []; + const recordActionSites = bridgeSrc.match(/spreadsheetRedactor\.recordSafely\([\s\S]*?globalThis\.fsbMcpSessionRecorder,[\s\S]*?'recordAction',[\s\S]*?sessionRecordEntry[\s\S]*?\);/g) || []; passAssertEqual(recordActionSites.length, 1, - 'exactly 1 fsbMcpSessionRecorder.recordAction site in mcp-bridge-client.js'); - passAssert(recordActionSites.length === 1 && recordActionSites[0].includes('resolveMcpClientLabel(payload)'), + 'exactly 1 redacted recordAction ingress in mcp-bridge-client.js'); + const actionEntryStart = bridgeSrc.indexOf('let sessionRecordEntry = {'); + const actionIngressEnd = bridgeSrc.indexOf("'recordAction'", actionEntryStart); + const actionIngressSpan = actionEntryStart === -1 || actionIngressEnd === -1 + ? '' + : bridgeSrc.slice(actionEntryStart, actionIngressEnd); + passAssert(actionIngressSpan.includes('resolveMcpClientLabel(payload)'), 'the bridge tap resolves the canonical client label'); const tapInvocations = (bridgeSrc.match(/this\._recordMcpSessionAction\(/g) || []).length; passAssertEqual(tapInvocations, 3, diff --git a/tests/spreadsheet-record-redaction.test.js b/tests/spreadsheet-record-redaction.test.js new file mode 100644 index 000000000..7c24ae8f3 --- /dev/null +++ b/tests/spreadsheet-record-redaction.test.js @@ -0,0 +1,210 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const redaction = require('../extension/utils/spreadsheet-record-redaction.js'); +const ROOT = path.resolve(__dirname, '..'); +const SENTINEL = 'PRIVATE_SHEET_SENTINEL_9f61'; +const ID = 'privateSpreadsheetIdentifier123456789'; +const RANGE = `'${SENTINEL} tab'!A1:Z99`; + +function recorder(method) { + const entries = []; + return { + entries, + target: { [method](entry) { entries.push(entry); } } + }; +} + +function assertNoContent(entry) { + const serialized = JSON.stringify(entry); + assert.equal(serialized.includes(SENTINEL), false); + assert.equal(serialized.includes(ID), false); + assert.equal(serialized.includes(RANGE), false); + assert.equal(serialized.includes(`=${SENTINEL}!A1`), false); +} + +function capabilityEntry(slug, params, response, success = true) { + return { + client: 'test-client', + tool: 'mcp:capabilities-invoke', + requestPayload: { + slug, + params: { + spreadsheetId: ID, + range: RANGE, + title: SENTINEL, + ...params + }, + agentId: 'agent:test', + tab_id: 42, + ownershipToken: SENTINEL + }, + response, + success, + dispatcher_route: 'message' + }; +} + +test('recordDispatch ingress strips metadata and values from every gsheets capability', () => { + const cases = [ + capabilityEntry('gsheets.get_spreadsheet', {}, { + success: true, + status: 200, + data: { spreadsheetId: ID, properties: { title: SENTINEL }, sheets: [{ properties: { title: SENTINEL } }] } + }), + capabilityEntry('gsheets.get_values', {}, { + success: true, + status: 200, + data: { range: RANGE, values: [[SENTINEL, `=${SENTINEL}!A1`], ['safe', 2]] } + }), + capabilityEntry('gsheets.update_values', { values: [[SENTINEL, `=${SENTINEL}!A1`]] }, { + success: true, + status: 200, + data: { updatedRange: RANGE, updatedRows: 1, updatedColumns: 2, updatedCells: 2 } + }), + capabilityEntry('gsheets.append_values', { values: [[SENTINEL], ['safe']] }, { + success: true, + status: 200, + data: { tableRange: RANGE, updates: { updatedRange: RANGE, updatedRows: 2, updatedColumns: 1, updatedCells: 2 } } + }), + capabilityEntry('gsheets.clear_values', {}, { + success: true, + status: 200, + data: { clearedRange: RANGE, echoed: SENTINEL } + }) + ]; + + for (const source of cases) { + const sink = recorder('recordDispatch'); + assert.equal(redaction.recordSafely(sink.target, 'recordDispatch', source), true); + assert.equal(sink.entries.length, 1); + const recorded = sink.entries[0]; + assertNoContent(recorded); + assert.equal(recorded.requestPayload.params.operation, source.requestPayload.slug); + assert.deepEqual(Object.keys(recorded.requestPayload.params).sort(), ['operation', 'shape']); + assert.deepEqual(Object.keys(recorded.response).sort().filter(key => !['status', 'errorCode'].includes(key)), ['shape', 'success']); + } +}); + +test('retains only numeric request/result shape facts', () => { + const source = capabilityEntry('gsheets.append_values', { + values: [[SENTINEL, 1], ['x'], [true, false, `=${SENTINEL}!A1`]] + }, { + success: true, + status: 200, + data: { updates: { updatedRows: 3, updatedColumns: 3, updatedCells: 6, updatedRange: RANGE } } + }); + const sink = recorder('recordDispatch'); + redaction.recordSafely(sink.target, 'recordDispatch', source); + const recorded = sink.entries[0]; + assert.deepEqual(recorded.requestPayload.params.shape, { rowCount: 3, columnCount: 3, valueCount: 6 }); + assert.deepEqual(recorded.response.shape, { + rowCount: 0, + columnCount: 0, + valueCount: 0, + updatedRows: 3, + updatedColumns: 3, + updatedCells: 6 + }); + assertNoContent(recorded); +}); + +test('failure records keep a safe typed code but drop raw errors', () => { + const source = capabilityEntry('gsheets.get_values', {}, { + success: false, + status: 403, + errorCode: 'GOOGLE_SHEETS_ACCESS_DENIED', + error: `Google exposed ${SENTINEL} from ${ID}`, + details: { range: RANGE, value: SENTINEL } + }, false); + const sink = recorder('recordDispatch'); + redaction.recordSafely(sink.target, 'recordDispatch', source); + const recorded = sink.entries[0]; + assert.equal(recorded.response.success, false); + assert.equal(recorded.response.status, 403); + assert.equal(recorded.response.errorCode, 'GOOGLE_SHEETS_ACCESS_DENIED'); + assert.equal(recorded.response.error, undefined); + assertNoContent(recorded); + + source.response.errorCode = SENTINEL.toUpperCase(); + const second = recorder('recordDispatch'); + redaction.recordSafely(second.target, 'recordDispatch', source); + assert.equal(second.entries[0].response.errorCode, undefined); +}); + +test('recordAction ingress redacts legacy fill_sheet and read_sheet payloads', () => { + const fill = { + client: 'test-client', + tool: 'fill_sheet', + params: { startCell: RANGE, sheetName: SENTINEL, csvData: `"${SENTINEL}\ninside",2\n3,=${SENTINEL}!A1` }, + payload: { + tool: 'fill_sheet', + params: { startCell: RANGE, sheetName: SENTINEL, csvData: `${SENTINEL},2` }, + agentId: 'agent:legacy', + visualSession: { visualReason: `Fill ${SENTINEL}`, client: 'test-client', isFinal: true }, + ownershipToken: SENTINEL + }, + response: { success: true, message: `Filled ${RANGE} with ${SENTINEL}` }, + success: true, + tabId: 8 + }; + const fillSink = recorder('recordAction'); + redaction.recordSafely(fillSink.target, 'recordAction', fill); + const fillRecorded = fillSink.entries[0]; + assert.deepEqual(fillRecorded.params.shape, { rowCount: 2, columnCount: 2, valueCount: 4 }); + assert.deepEqual(fillRecorded.payload.visualSession, { isFinal: true }); + assert.equal(fillRecorded.payload.agentId, 'agent:legacy'); + assertNoContent(fillRecorded); + + const read = { + client: 'test-client', + tool: 'read_sheet', + params: { range: RANGE }, + payload: { tool: 'read_sheet', params: { range: RANGE }, agentId: 'agent:legacy' }, + response: { success: true, data: [[SENTINEL, 'x'], [`=${SENTINEL}!A1`]] }, + success: true, + tabId: 8 + }; + const readSink = recorder('recordAction'); + redaction.recordSafely(readSink.target, 'recordAction', read); + assert.deepEqual(readSink.entries[0].response.shape, { rowCount: 2, columnCount: 2, valueCount: 3 }); + assertNoContent(readSink.entries[0]); +}); + +test('unknown gsheets slugs are recognized and fail closed without retaining the raw slug', () => { + const source = capabilityEntry(`gsheets.${SENTINEL}`, { values: [[SENTINEL]] }, { success: true, data: SENTINEL }); + const sink = recorder('recordDispatch'); + redaction.recordSafely(sink.target, 'recordDispatch', source); + assert.equal(sink.entries[0].requestPayload.slug, 'gsheets.unknown'); + assert.equal(sink.entries[0].requestPayload.params.operation, 'gsheets.unknown'); + assertNoContent(sink.entries[0]); +}); + +test('unrelated records pass through unchanged', () => { + const source = { + client: 'test-client', + tool: 'read_page', + requestPayload: { params: { selector: '#content', value: SENTINEL } }, + response: { success: true, text: SENTINEL }, + success: true + }; + const sink = recorder('recordDispatch'); + redaction.recordSafely(sink.target, 'recordDispatch', source); + assert.strictEqual(sink.entries[0], source); +}); + +test('both recording hooks call the shared ingress sanitizer and fail closed for Sheets', () => { + const background = fs.readFileSync(path.join(ROOT, 'extension/background.js'), 'utf8'); + const dispatcher = fs.readFileSync(path.join(ROOT, 'extension/ws/mcp-tool-dispatcher.js'), 'utf8'); + const bridge = fs.readFileSync(path.join(ROOT, 'extension/ws/mcp-bridge-client.js'), 'utf8'); + assert.ok(background.indexOf("importScripts('utils/spreadsheet-record-redaction.js')") + < background.indexOf("importScripts('ws/mcp-tool-dispatcher.js')")); + assert.match(dispatcher, /spreadsheetInvoke[\s\S]*recordSafely\([\s\S]*'recordDispatch'/); + assert.match(bridge, /spreadsheetTool[\s\S]*recordSafely\([\s\S]*'recordAction'/); + assert.match(dispatcher, /if \(!spreadsheetInvoke\)[\s\S]*recordDispatch\(sessionRecordEntry\)/); + assert.match(bridge, /if \(!spreadsheetTool\)[\s\S]*recordAction\(sessionRecordEntry\)/); +}); diff --git a/tests/verify-origin-classification.test.js b/tests/verify-origin-classification.test.js index 82aca8201..30ab8eae0 100644 --- a/tests/verify-origin-classification.test.js +++ b/tests/verify-origin-classification.test.js @@ -67,8 +67,8 @@ function check(cond, msg) { check(fs.existsSync(CATALOG_PATH), '(a) capability-catalog.js exists (the head manifest source)'); const catalogSrc = fs.existsSync(CATALOG_PATH) ? fs.readFileSync(CATALOG_PATH, 'utf8') : ''; const realHeads = gate.parseHeadModules(catalogSrc) || []; - check(realHeads.length === 123, - '(a) parseHeadModules returns exactly 123 heads from the real catalog source; got ' + realHeads.length); + check(realHeads.length === 124, + '(a) parseHeadModules returns exactly 124 heads from the real catalog source; got ' + realHeads.length); const byGlobal = {}; for (const h of realHeads) { byGlobal[h.global] = h.origin; } check(byGlobal.FsbHandlerGithub === 'https://github.com', @@ -183,6 +183,8 @@ function check(cond, msg) { '(a) FsbHandlerClaude origin parsed as https://claude.ai'); check(byGlobal.FsbHandlerGemini === 'https://gemini.google.com', '(a) FsbHandlerGemini origin parsed as https://gemini.google.com'); + check(byGlobal.FsbHandlerGsheets === 'https://docs.google.com', + '(a) FsbHandlerGsheets origin parsed as https://docs.google.com'); check(byGlobal.FsbHandlerPowerpoint === 'https://powerpoint.cloud.microsoft', '(a) FsbHandlerPowerpoint origin parsed as https://powerpoint.cloud.microsoft'); check(byGlobal.FsbHandlerOutlook === 'https://outlook.cloud.microsoft', @@ -605,6 +607,11 @@ function check(cond, msg) { check(!!realGemini && realGemini.apiBaseUrl === 'https://gemini.google.com' && realGemini.classification && realGemini.classification.sameOrigin === true, '(b) the REAL Gemini head classifies the reviewed UI/RPC path as same-origin on gemini.google.com'); + const realGsheets = real && real.results ? real.results.find((r) => r.global === 'FsbHandlerGsheets') : null; + check(realGsheets && realGsheets.apiBaseUrl === 'https://sheets.googleapis.com/v4' && + realGsheets.classification && realGsheets.classification.sameOrigin === true && + String(realGsheets.classification.reason || '').startsWith('CHROME_IDENTITY_SHEETS_API'), + '(b) the REAL Google Sheets head uses the exact source-verified Chrome Identity API accommodation'); const realPowerpoint = real && real.results ? real.results.find((r) => r.global === 'FsbHandlerPowerpoint') : null; check(!!realPowerpoint && realPowerpoint.apiBaseUrl === 'https://graph.microsoft.com/v1.0' && realPowerpoint.classification && realPowerpoint.classification.sameOrigin === true diff --git a/tests/write-activation-evidence.test.js b/tests/write-activation-evidence.test.js index 73ef18a67..6764fba5c 100644 --- a/tests/write-activation-evidence.test.js +++ b/tests/write-activation-evidence.test.js @@ -41,8 +41,8 @@ function clone(value) { const rows = gate.writeRowsFromReport(report); check(rows.activeWrites.length === 5, 'current readiness report has exactly 5 active write rows'); - check(rows.guardedWrites.length === 557, - 'current readiness report has exactly 557 guarded fail-closed write rows'); + check(rows.guardedWrites.length === 560, + 'current readiness report has exactly 560 guarded fail-closed write rows'); const current = gate.validateWriteActivationEvidence(evidence, report); check(current.failures.length === 0, From 6e8d40d16facdac3a0336e45567c566ab65b19dd Mon Sep 17 00:00:00 2001 From: Lakshman Date: Wed, 15 Jul 2026 07:15:14 -0500 Subject: [PATCH 14/26] docs(quick-260715-8wh): implement production-safe Google Sheets API MVP --- .planning/STATE.md | 3 +- .../260715-8wh-PLAN.md | 120 ++++++++++++++++++ .../260715-8wh-SUMMARY.md | 49 +++++++ 3 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 .planning/quick/260715-8wh-implement-production-safe-google-sheets-/260715-8wh-PLAN.md create mode 100644 .planning/quick/260715-8wh-implement-production-safe-google-sheets-/260715-8wh-SUMMARY.md diff --git a/.planning/STATE.md b/.planning/STATE.md index 030108b47..757d22b10 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -33,7 +33,7 @@ See: .planning/milestones/v1.1.0-ROADMAP.md, .planning/milestones/v1.1.0-REQUIRE Phase: none active Plan: none active Status: v1.1.0 milestone complete, audited, and archived -Last activity: 2026-07-07 - Completed quick task 260707-7id: Record MCP agent sessions into logs, history, replay, and memory like autopilot runs +Last activity: 2026-07-15 - Completed quick task 260715-8wh: Implement production-safe Google Sheets API MVP with Chrome OAuth, typed read/write capabilities, and spreadsheet-data recording redaction Progress: [##########] 100% @@ -154,6 +154,7 @@ None active. | # | Description | Date | Commit | Directory | |---|-------------|------|--------|-----------| +| 260715-8wh | Implement production-safe Google Sheets API MVP with Chrome OAuth, typed read/write capabilities, and spreadsheet-data recording redaction | 2026-07-15 | a83d21b8 | [260715-8wh-implement-production-safe-google-sheets-](./quick/260715-8wh-implement-production-safe-google-sheets-/) | | 260707-7id | Record MCP agent sessions into logs, history, replay, and memory like autopilot runs | 2026-07-07 | 721e2826 | [260707-7id-record-mcp-agent-sessions-into-logs-hist](./quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/) | | 260701-e6c | Implement Supabase to be T1 ready | 2026-07-01 | 2619d949 | [260701-e6c-implement-this-supabase-to-be-t1-ready-u](./quick/260701-e6c-implement-this-supabase-to-be-t1-ready-u/) | | 260701-e6d | Implement Microsoft Teams to be T1 ready | 2026-07-01 | working-tree | [260701-e6d-implement-this-microsoft-teams-to-be-t1-](./quick/260701-e6d-implement-this-microsoft-teams-to-be-t1-/) | diff --git a/.planning/quick/260715-8wh-implement-production-safe-google-sheets-/260715-8wh-PLAN.md b/.planning/quick/260715-8wh-implement-production-safe-google-sheets-/260715-8wh-PLAN.md new file mode 100644 index 000000000..c2964fe00 --- /dev/null +++ b/.planning/quick/260715-8wh-implement-production-safe-google-sheets-/260715-8wh-PLAN.md @@ -0,0 +1,120 @@ +--- +quick: 260715-8wh +plan: 01 +type: execute +wave: 1 +depends_on: [] +autonomous: true +files_modified: + - extension/manifest.json + - extension/background.js + - extension/ui/control_panel.html + - extension/ui/options.js + - extension/utils/google-sheets-api.js + - extension/utils/capability-router.js + - extension/utils/capability-catalog.js + - extension/config/service-denylist.json + - catalog/handlers/gsheets.js + - catalog/descriptors/gsheets__get_spreadsheet.json + - catalog/descriptors/gsheets__get_values.json + - catalog/descriptors/gsheets__update_values.json + - catalog/descriptors/gsheets__append_values.json + - catalog/descriptors/gsheets__clear_values.json + - extension/catalog/handlers/gsheets.js + - extension/catalog/recipe-index.generated.js + - scripts/verify-recipe-path-guard.mjs + - extension/utils/spreadsheet-record-redaction.js + - extension/ws/mcp-tool-dispatcher.js + - extension/ws/mcp-bridge-client.js + - docs/google-sheets-api.md + - tests/google-sheets-api.test.js + - tests/gsheets-handler.test.js + - tests/google-sheets-wiring.test.js + - tests/spreadsheet-record-redaction.test.js +requirements: + - QT-SHEETS-01 + - QT-SHEETS-02 + - QT-SHEETS-03 +user_setup: + - Enable the Google Sheets API in a Google Cloud project. + - Create a Chrome Extension OAuth client tied to the stable packaged extension ID and replace the fail-closed manifest placeholder before live use. +--- + +# Quick 260715-8wh Plan + +## Objective + +Add a production-safe Google Sheets API MVP to the extension: explicit user-initiated OAuth, a bounded REST facade, five discoverable typed capabilities, and mandatory shape-only session-recording redaction. Keep the existing `fill_sheet` and `read_sheet` UI automation tools unchanged as a fallback. + +## Constraints + +- The worktree contains substantial unrelated user changes, including changes in several files this task must touch. Preserve all of them and stage only task-owned hunks; never stage an entire already-dirty file without verifying its diff against `HEAD`. +- Do not ship or depend on Google's Developer Preview remote Sheets MCP server. +- Never expose, log, return, or persist an OAuth access token. +- Do not add an arbitrary HTTP proxy. The Sheets API facade must expose only the five named operations, validate inputs, cap request size, apply timeouts, and retry only once after an authentication rejection. +- Live OAuth cannot be provisioned from this repository. Use an unmistakable placeholder client ID and fail closed with an actionable typed error until the user supplies a real Chrome Extension OAuth client. + +## Tasks + +### 1. Add fail-closed Chrome OAuth, bounded Sheets REST access, and a click-initiated connection surface + +**Requirements:** QT-SHEETS-01 + +**Files:** `extension/manifest.json`, `extension/background.js`, `extension/ui/control_panel.html`, `extension/ui/options.js`, `extension/utils/google-sheets-api.js`, `extension/utils/capability-router.js`, `docs/google-sheets-api.md`, `tests/google-sheets-api.test.js` + +**Action:** + +- Add Chrome Identity permission and manifest OAuth configuration for the single `https://www.googleapis.com/auth/spreadsheets` scope. The checked-in client ID must be a clearly invalid placeholder that the runtime detects and rejects before calling Google. +- Implement a classic-script/background-compatible `FsbGoogleSheetsApi` module. Provide connect/status/disconnect runtime actions plus a narrow handler context with only `getSpreadsheet`, `getValues`, `updateValues`, `appendValues`, and `clearValues`. +- Require `interactive: true` only from the control-panel Connect button; capability execution must request a token non-interactively. Disconnect must remove cached auth state. Do not store tokens or include them in errors/results. +- Restrict requests to the Sheets v4 base URL, validate spreadsheet IDs and A1 ranges, enforce a conservative JSON body-size cap and timeout, encode URL components, normalize Google/network/timeout failures into typed safe errors, and on HTTP 401 remove the cached token and retry once. +- Add a small control-panel status/connect/disconnect surface. Report missing OAuth configuration clearly and do not claim connection merely because configuration exists. +- Document the Google Cloud setup needed for live use, the exact capabilities and scope, the fallback tools, and the privacy behavior. +- Add focused unit tests for placeholder fail-closed behavior, noninteractive versus interactive auth, bounded request construction, safe typed errors, body limits, and token non-disclosure. + +**Verify:** `node --test tests/google-sheets-api.test.js` + +**Commit:** `feat(sheets): add bounded OAuth API client` + +### 2. Register five typed, discoverable `gsheets.*` capabilities + +**Requirements:** QT-SHEETS-02 + +**Files:** `catalog/handlers/gsheets.js`, `catalog/descriptors/gsheets__get_spreadsheet.json`, `catalog/descriptors/gsheets__get_values.json`, `catalog/descriptors/gsheets__update_values.json`, `catalog/descriptors/gsheets__append_values.json`, `catalog/descriptors/gsheets__clear_values.json`, `extension/catalog/handlers/gsheets.js`, `extension/catalog/recipe-index.generated.js`, `extension/background.js`, `extension/utils/capability-catalog.js`, `extension/config/service-denylist.json`, `scripts/verify-recipe-path-guard.mjs`, `tests/gsheets-handler.test.js`, `tests/google-sheets-wiring.test.js` + +**Action:** + +- Create handler-backed capabilities `gsheets.get_spreadsheet`, `gsheets.get_values`, `gsheets.update_values`, `gsheets.append_values`, and `gsheets.clear_values` for `https://docs.google.com`. +- Give every capability a strict JSON schema and explicit side-effect metadata. Make `spreadsheetId` optional only when it can be safely derived from the active `/spreadsheets/d/` URL. Require an explicit range and structured two-dimensional values where applicable. +- Route handlers only through the narrow Sheets API context from Task 1. Never accept a URL, method, headers, token, or generic request body from a capability invocation. +- Register/import the handler through all explicit catalog/background/package verification paths, regenerate packaged catalog artifacts with the repository packaging script, and classify Google Sheets as a sensitive origin so mutating capabilities retain consent protections. +- Preserve `fill_sheet` and `read_sheet` exactly as existing fallback tools. +- Test parameter validation, ID derivation, operation-to-client routing, write/destructive classifications, catalog discoverability, packaged handler presence, and legacy fallback availability. + +**Verify:** `node --test tests/gsheets-handler.test.js tests/google-sheets-wiring.test.js && node scripts/package-extension.mjs` + +**Commit:** `feat(sheets): add typed Sheets capabilities` + +### 3. Redact spreadsheet content before MCP session recording + +**Requirements:** QT-SHEETS-03 + +**Files:** `extension/utils/spreadsheet-record-redaction.js`, `extension/background.js`, `extension/ws/mcp-tool-dispatcher.js`, `extension/ws/mcp-bridge-client.js`, `tests/spreadsheet-record-redaction.test.js` + +**Action:** + +- Add a pure, fail-safe sanitizer that recognizes direct legacy `fill_sheet`/`read_sheet` calls and `mcp:capabilities-invoke` requests whose slug starts with `gsheets.`. +- Apply the sanitizer at both recording ingress points before the session recorder sees the entry. Retain only non-content shape/operation facts needed for diagnostics, such as operation name, row/column/value counts, success/status, and a safe error code. +- Strip spreadsheet IDs, sheet names, ranges, cell values, formulas, rendered results, raw errors, and any nested request/response content. If sanitization cannot confidently classify the data, drop the spreadsheet payload rather than recording it. +- Prove with sentinel tests that sensitive strings never appear in serialized recorded entries for reads, writes, appends, clears, failures, or legacy fallbacks, while unrelated tool records remain unchanged. + +**Verify:** `node --test tests/spreadsheet-record-redaction.test.js && npm run validate:extension` + +**Commit:** `fix(privacy): redact spreadsheet session data` + +## Final Verification + +- Run all four focused test files together. +- Run `node scripts/package-extension.mjs` and `npm run validate:extension`. +- Inspect `git diff` and each task commit to confirm only task-owned hunks were committed and all unrelated pre-existing changes remain in the worktree. +- Write `260715-8wh-SUMMARY.md` with implemented behavior, verification evidence, commit hashes, and the remaining external OAuth setup requirement. diff --git a/.planning/quick/260715-8wh-implement-production-safe-google-sheets-/260715-8wh-SUMMARY.md b/.planning/quick/260715-8wh-implement-production-safe-google-sheets-/260715-8wh-SUMMARY.md new file mode 100644 index 000000000..b491645a0 --- /dev/null +++ b/.planning/quick/260715-8wh-implement-production-safe-google-sheets-/260715-8wh-SUMMARY.md @@ -0,0 +1,49 @@ +--- +quick_id: 260715-8wh +slug: implement-production-safe-google-sheets +status: complete +completed: 2026-07-15 +commits: + - 8e46173a + - 23badd3a + - a83d21b8 +--- + +# Quick Task 260715-8wh Summary: Production-safe Google Sheets API MVP + +## Outcome + +FSB now has a bounded Google Sheets v4 integration with an explicit Chrome Identity connection surface, five strict typed capabilities, and shape-only spreadsheet session recording. + +- OAuth uses only `https://www.googleapis.com/auth/spreadsheets`, starts interactively only from the control-panel Connect click, and remains fail-closed while the checked-in client ID is the unmistakable placeholder. +- The API facade accepts only five fixed operations, validates spreadsheet IDs/A1 ranges, caps request and response sizes, times out, retries exactly once after HTTP 401, and never exposes, logs, returns, or persists an access token. +- `gsheets.get_spreadsheet` and `gsheets.get_values` are executable after valid OAuth setup. +- `gsheets.update_values`, `gsheets.append_values`, and `gsheets.clear_values` are implemented, typed, discoverable, and classified correctly, but their handlers return `RECIPE_DOM_FALLBACK_PENDING` without calling Google until live mutation UAT and activation evidence exist. OAuth configuration alone cannot activate writes. +- Google Sheets uses the exact, source-verified `docs.google.com` to `sheets.googleapis.com/v4` Chrome Identity transport accommodation and remains a sensitive origin. +- Both MCP recording ingress points reduce Sheets API calls and legacy `fill_sheet`/`read_sheet` calls to operation/shape/status facts before the session recorder receives them. Spreadsheet IDs, ranges, sheet names, values, formulas, response bodies, and raw errors are discarded. +- Existing `fill_sheet` and `read_sheet` browser-automation fallbacks remain available. + +## Commits + +- `8e46173a` — `feat(sheets): add bounded OAuth API client` +- `23badd3a` — `feat(sheets): add typed Sheets capabilities` +- `a83d21b8` — `fix(privacy): redact spreadsheet session data` + +## Verification + +- `node --test tests/google-sheets-api.test.js tests/gsheets-handler.test.js tests/google-sheets-wiring.test.js tests/spreadsheet-record-redaction.test.js` — PASS, 26/26. +- `node tests/mcp-session-recorder.test.js` — PASS, 194/194. +- `node tests/verify-origin-classification.test.js` — PASS, 202/202. +- `npm run validate:extension` — PASS end-to-end, including manifest/JS parsing, recipe guard, classification, catalog, origin, readiness, terminal-state, T1 port-contract, and write-activation evidence gates. +- `node scripts/package-extension.mjs` — PASS; generated 6 recipes and 2319 descriptors, copied 124 handler modules, and wrote `dist/fsb-extension-v0.9.91.zip`. + +## External setup required + +1. Enable the Google Sheets API in a Google Cloud project. +2. Configure the OAuth consent screen. +3. Create a Chrome Extension OAuth client tied to the stable packaged FSB extension ID. +4. Replace `REPLACE_WITH_FSB_GOOGLE_OAUTH_CLIENT_ID.apps.googleusercontent.com` in `extension/manifest.json` with that client ID. +5. Reload the extension and connect from the FSB control panel. +6. Separately complete the repository's live mutation-UAT and activation-evidence process before enabling update, append, or clear handlers. + +No live OAuth prompt or external Google read/write was performed during implementation. Substantial unrelated pre-existing worktree changes were preserved; each commit staged only task-owned files or task-owned hunks. From 3d096b99fae1c723c5cd1b375c903ebf0dc8d943 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Wed, 15 Jul 2026 13:18:40 -0500 Subject: [PATCH 15/26] feat(sheets): replace OAuth with signed-in session --- catalog/handlers/gsheets.js | 37 ++- extension/background.js | 39 +-- extension/catalog/handlers/gsheets.js | 37 ++- extension/manifest.json | 8 - extension/ui/control_panel.html | 26 -- extension/ui/options.js | 86 ----- extension/utils/capability-router.js | 33 +- extension/utils/google-sheets-api.js | 382 ----------------------- extension/utils/google-sheets-session.js | 334 ++++++++++++++++++++ tests/google-sheets-api.test.js | 186 ----------- tests/google-sheets-session.test.js | 160 ++++++++++ tests/google-sheets-wiring.test.js | 24 +- tests/gsheets-handler.test.js | 20 +- 13 files changed, 592 insertions(+), 780 deletions(-) delete mode 100644 extension/utils/google-sheets-api.js create mode 100644 extension/utils/google-sheets-session.js delete mode 100644 tests/google-sheets-api.test.js create mode 100644 tests/google-sheets-session.test.js diff --git a/catalog/handlers/gsheets.js b/catalog/handlers/gsheets.js index 2dd989534..36df8cdef 100644 --- a/catalog/handlers/gsheets.js +++ b/catalog/handlers/gsheets.js @@ -16,7 +16,7 @@ var ID = { type: 'string', pattern: ID_PATTERN, - description: 'Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab.' + description: 'Spreadsheet ID. When provided, it must match the spreadsheet open in the agent-owned Google Sheets tab.' }; var RANGE = { type: 'string', minLength: 1, maxLength: 500, description: 'A1 notation range.' }; var VALUES = { @@ -62,8 +62,14 @@ }, ['range', 'values']); var CLEAR_VALUES_PARAMS = schema({ spreadsheetId: ID, range: RANGE }, ['range']); - function typedError(code) { - return { success: false, code: code, errorCode: code, error: code }; + function typedError(code, extra) { + var out = { success: false, code: code, errorCode: code, error: code }; + if (extra) { + for (var key in extra) { + if (Object.prototype.hasOwnProperty.call(extra, key)) { out[key] = extra[key]; } + } + } + return out; } function guarded(slug, sideEffectClass, params) { @@ -71,6 +77,7 @@ tier: 'T1a', origin: ORIGIN, sideEffectClass: sideEffectClass, + rotClassifiable: false, params: params, async handle() { return { @@ -100,30 +107,36 @@ } function resolveSpreadsheetId(args, ctx) { + var active = spreadsheetIdFromUrl(ctx); + if (!active) { return typedError('GOOGLE_SHEETS_ACTIVE_TAB_REQUIRED'); } var explicit = args && args.spreadsheetId; if (explicit !== undefined && explicit !== null && explicit !== '') { - return typeof explicit === 'string' && ID_RE.test(explicit) ? explicit : ''; + if (typeof explicit !== 'string' || !ID_RE.test(explicit) || explicit !== active) { + return typedError('GOOGLE_SHEETS_TARGET_MISMATCH'); + } } - return spreadsheetIdFromUrl(ctx); + return { success: true, spreadsheetId: active }; } async function call(method, args, ctx, buildParams) { var client = ctx && ctx.googleSheets; if (!client || typeof client[method] !== 'function') { - return typedError('GOOGLE_SHEETS_API_UNAVAILABLE'); + return typedError('GOOGLE_SHEETS_SESSION_UNAVAILABLE'); } - var spreadsheetId = resolveSpreadsheetId(args, ctx); - if (!spreadsheetId) { return typedError('GOOGLE_SHEETS_SPREADSHEET_REQUIRED'); } + var target = resolveSpreadsheetId(args, ctx); + if (!target.success) { return target; } try { - return await client[method](buildParams(spreadsheetId, args || {})); + return await client[method](buildParams(target.spreadsheetId, args || {})); } catch (_e) { - return typedError('GOOGLE_SHEETS_API_ERROR'); + return typedError(method === 'getSpreadsheet' || method === 'getValues' + ? 'GOOGLE_SHEETS_SESSION_UNAVAILABLE' + : 'RECOVERY_AMBIGUOUS'); } } var handlers = { 'gsheets.get_spreadsheet': { - tier: 'T1a', origin: ORIGIN, sideEffectClass: 'read', params: GET_SPREADSHEET_PARAMS, + tier: 'T1a', origin: ORIGIN, sideEffectClass: 'read', rotClassifiable: false, params: GET_SPREADSHEET_PARAMS, handle: function (args, ctx) { return call('getSpreadsheet', args, ctx, function (spreadsheetId) { return { spreadsheetId: spreadsheetId }; @@ -131,7 +144,7 @@ } }, 'gsheets.get_values': { - tier: 'T1a', origin: ORIGIN, sideEffectClass: 'read', params: GET_VALUES_PARAMS, + tier: 'T1a', origin: ORIGIN, sideEffectClass: 'read', rotClassifiable: false, params: GET_VALUES_PARAMS, handle: function (args, ctx) { return call('getValues', args, ctx, function (spreadsheetId, input) { return { diff --git a/extension/background.js b/extension/background.js index 700c70ac5..bcc41177c 100644 --- a/extension/background.js +++ b/extension/background.js @@ -191,7 +191,7 @@ try { // before the router's post-fetch classify hook runs. try { importScripts('utils/capability-rot-detector.js'); } catch (e) { console.error('[FSB] Failed to load capability-rot-detector.js:', e.message); } try { importScripts('utils/capability-catalog.js'); } catch (e) { console.error('[FSB] Failed to load capability-catalog.js:', e.message); } -try { importScripts('utils/google-sheets-api.js'); } catch (e) { console.error('[FSB] Failed to load google-sheets-api.js:', e.message); } +try { importScripts('utils/google-sheets-session.js'); } catch (e) { console.error('[FSB] Failed to load google-sheets-session.js:', e.message); } try { importScripts('utils/capability-router.js'); } catch (e) { console.error('[FSB] Failed to load capability-router.js:', e.message); } // Phase 29 Plan 03 (v0.9.99 CAT-02): the bundled-head T1a handler modules. These @@ -7628,43 +7628,6 @@ const fsbHandleRuntimeMessage = (request, sender, sendResponse) => { automationLogger.logComm(null, 'receive', request.action || 'unknown', true, { tabId: sender.tab?.id }); switch (request.action) { - case 'google-sheets:get-status': { - var sheetsStatusApi = globalThis && globalThis.FsbGoogleSheetsApi; - if (!sheetsStatusApi || typeof sheetsStatusApi.status !== 'function') { - sendResponse({ success: false, code: 'GOOGLE_SHEETS_IDENTITY_UNAVAILABLE', errorCode: 'GOOGLE_SHEETS_IDENTITY_UNAVAILABLE', error: 'Google Sheets integration is unavailable.' }); - return false; - } - Promise.resolve(sheetsStatusApi.status()).then(sendResponse, function () { - sendResponse({ success: false, code: 'GOOGLE_SHEETS_API_ERROR', errorCode: 'GOOGLE_SHEETS_API_ERROR', error: 'Google Sheets status check failed.' }); - }); - return true; - } - - case 'google-sheets:connect': - case 'google-sheets:disconnect': { - var controlPanelUrl = chrome.runtime.getURL('ui/control_panel.html'); - var senderUrl = sender && typeof sender.url === 'string' ? sender.url : ''; - var fromControlPanel = !sender.tab && ( - senderUrl === controlPanelUrl || - senderUrl.indexOf(controlPanelUrl + '#') === 0 || - senderUrl.indexOf(controlPanelUrl + '?') === 0 - ); - if (!fromControlPanel || request.userInitiated !== true) { - sendResponse({ success: false, code: 'GOOGLE_SHEETS_AUTH_FAILED', errorCode: 'GOOGLE_SHEETS_AUTH_FAILED', error: 'Google Sheets connection changes require a control-panel click.' }); - return false; - } - var sheetsAuthApi = globalThis && globalThis.FsbGoogleSheetsApi; - var authMethod = request.action === 'google-sheets:connect' ? 'connect' : 'disconnect'; - if (!sheetsAuthApi || typeof sheetsAuthApi[authMethod] !== 'function') { - sendResponse({ success: false, code: 'GOOGLE_SHEETS_IDENTITY_UNAVAILABLE', errorCode: 'GOOGLE_SHEETS_IDENTITY_UNAVAILABLE', error: 'Google Sheets integration is unavailable.' }); - return false; - } - Promise.resolve(sheetsAuthApi[authMethod]()).then(sendResponse, function () { - sendResponse({ success: false, code: 'GOOGLE_SHEETS_AUTH_FAILED', errorCode: 'GOOGLE_SHEETS_AUTH_FAILED', error: 'Google Sheets connection change failed.' }); - }); - return true; - } - case 'fsbAuditLogClearAndExport': { // Race-safe clear-and-export delegated from a page realm (control // panel / sidepanel). Running the read/set here serializes it against diff --git a/extension/catalog/handlers/gsheets.js b/extension/catalog/handlers/gsheets.js index 2dd989534..36df8cdef 100644 --- a/extension/catalog/handlers/gsheets.js +++ b/extension/catalog/handlers/gsheets.js @@ -16,7 +16,7 @@ var ID = { type: 'string', pattern: ID_PATTERN, - description: 'Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab.' + description: 'Spreadsheet ID. When provided, it must match the spreadsheet open in the agent-owned Google Sheets tab.' }; var RANGE = { type: 'string', minLength: 1, maxLength: 500, description: 'A1 notation range.' }; var VALUES = { @@ -62,8 +62,14 @@ }, ['range', 'values']); var CLEAR_VALUES_PARAMS = schema({ spreadsheetId: ID, range: RANGE }, ['range']); - function typedError(code) { - return { success: false, code: code, errorCode: code, error: code }; + function typedError(code, extra) { + var out = { success: false, code: code, errorCode: code, error: code }; + if (extra) { + for (var key in extra) { + if (Object.prototype.hasOwnProperty.call(extra, key)) { out[key] = extra[key]; } + } + } + return out; } function guarded(slug, sideEffectClass, params) { @@ -71,6 +77,7 @@ tier: 'T1a', origin: ORIGIN, sideEffectClass: sideEffectClass, + rotClassifiable: false, params: params, async handle() { return { @@ -100,30 +107,36 @@ } function resolveSpreadsheetId(args, ctx) { + var active = spreadsheetIdFromUrl(ctx); + if (!active) { return typedError('GOOGLE_SHEETS_ACTIVE_TAB_REQUIRED'); } var explicit = args && args.spreadsheetId; if (explicit !== undefined && explicit !== null && explicit !== '') { - return typeof explicit === 'string' && ID_RE.test(explicit) ? explicit : ''; + if (typeof explicit !== 'string' || !ID_RE.test(explicit) || explicit !== active) { + return typedError('GOOGLE_SHEETS_TARGET_MISMATCH'); + } } - return spreadsheetIdFromUrl(ctx); + return { success: true, spreadsheetId: active }; } async function call(method, args, ctx, buildParams) { var client = ctx && ctx.googleSheets; if (!client || typeof client[method] !== 'function') { - return typedError('GOOGLE_SHEETS_API_UNAVAILABLE'); + return typedError('GOOGLE_SHEETS_SESSION_UNAVAILABLE'); } - var spreadsheetId = resolveSpreadsheetId(args, ctx); - if (!spreadsheetId) { return typedError('GOOGLE_SHEETS_SPREADSHEET_REQUIRED'); } + var target = resolveSpreadsheetId(args, ctx); + if (!target.success) { return target; } try { - return await client[method](buildParams(spreadsheetId, args || {})); + return await client[method](buildParams(target.spreadsheetId, args || {})); } catch (_e) { - return typedError('GOOGLE_SHEETS_API_ERROR'); + return typedError(method === 'getSpreadsheet' || method === 'getValues' + ? 'GOOGLE_SHEETS_SESSION_UNAVAILABLE' + : 'RECOVERY_AMBIGUOUS'); } } var handlers = { 'gsheets.get_spreadsheet': { - tier: 'T1a', origin: ORIGIN, sideEffectClass: 'read', params: GET_SPREADSHEET_PARAMS, + tier: 'T1a', origin: ORIGIN, sideEffectClass: 'read', rotClassifiable: false, params: GET_SPREADSHEET_PARAMS, handle: function (args, ctx) { return call('getSpreadsheet', args, ctx, function (spreadsheetId) { return { spreadsheetId: spreadsheetId }; @@ -131,7 +144,7 @@ } }, 'gsheets.get_values': { - tier: 'T1a', origin: ORIGIN, sideEffectClass: 'read', params: GET_VALUES_PARAMS, + tier: 'T1a', origin: ORIGIN, sideEffectClass: 'read', rotClassifiable: false, params: GET_VALUES_PARAMS, handle: function (args, ctx) { return call('getValues', args, ctx, function (spreadsheetId, input) { return { diff --git a/extension/manifest.json b/extension/manifest.json index 3c49a5eed..31079d974 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -6,8 +6,6 @@ "homepage_url": "https://full-selfbrowsing.com", "permissions": [ "activeTab", - "identity", - "identity.email", "scripting", "storage", "unlimitedStorage", @@ -27,12 +25,6 @@ "background": { "service_worker": "background.js" }, - "oauth2": { - "client_id": "REPLACE_WITH_FSB_GOOGLE_OAUTH_CLIENT_ID.apps.googleusercontent.com", - "scopes": [ - "https://www.googleapis.com/auth/spreadsheets" - ] - }, "icons": { "16": "assets/icon16.png", "48": "assets/icon48.png", diff --git a/extension/ui/control_panel.html b/extension/ui/control_panel.html index 05195afaa..7bc676798 100644 --- a/extension/ui/control_panel.html +++ b/extension/ui/control_panel.html @@ -359,32 +359,6 @@

CAPTCHA Solver

-
-
-
-
-

Google Sheets

-

Connect Chrome Identity for bounded Sheets API access.

-
-
-
-
-
-
-
Checking connection...
-
Authorization prompts only open after you click Connect.
-
-
-
-
- - -
-
diff --git a/extension/ui/options.js b/extension/ui/options.js index f19c99d62..9d3a1d2d9 100644 --- a/extension/ui/options.js +++ b/extension/ui/options.js @@ -145,9 +145,6 @@ function initializeDashboard() { // Setup event listeners setupEventListeners(); - // Check cached Sheets authorization without opening an OAuth prompt. - refreshGoogleSheetsStatus(); - // Wire up minus/value/plus numeric steppers initFsbSteppers(); @@ -269,11 +266,6 @@ function cacheElements() { // API Status Card elements.apiStatusCard = document.getElementById('apiStatusCard'); - elements.googleSheetsStatusCard = document.getElementById('googleSheetsStatusCard'); - elements.googleSheetsStatus = document.getElementById('googleSheetsStatus'); - elements.googleSheetsStatusDetail = document.getElementById('googleSheetsStatusDetail'); - elements.googleSheetsConnectBtn = document.getElementById('googleSheetsConnectBtn'); - elements.googleSheetsDisconnectBtn = document.getElementById('googleSheetsDisconnectBtn'); // Logs elements.logsDisplay = document.getElementById('logsDisplay'); @@ -744,13 +736,6 @@ function setupEventListeners() { elements.fullApiTest.addEventListener('click', runFullApiTest); } - if (elements.googleSheetsConnectBtn) { - elements.googleSheetsConnectBtn.addEventListener('click', connectGoogleSheets); - } - if (elements.googleSheetsDisconnectBtn) { - elements.googleSheetsDisconnectBtn.addEventListener('click', disconnectGoogleSheets); - } - // Save bar if (elements.saveBtn) { elements.saveBtn.addEventListener('click', saveSettings); @@ -1884,77 +1869,6 @@ function updateApiStatusCard(status, title, detail) { } } -function renderGoogleSheetsStatus(result) { - const connected = !!(result && result.connected === true); - const configured = !!(result && result.configured === true); - const cardState = connected ? 'connected' : (configured ? 'disconnected' : 'error'); - const title = connected - ? `Connected${result.email ? ` as ${result.email}` : ''}` - : (configured ? 'Not connected' : 'OAuth setup required'); - const detail = connected - ? 'Sheets capabilities use cached authorization without prompting.' - : (configured - ? 'Click Connect to authorize Google Sheets.' - : ((result && result.message) || (result && result.error) || 'A release owner must configure the Chrome OAuth client ID.')); - - if (elements.googleSheetsStatusCard) { - elements.googleSheetsStatusCard.className = `api-status-card ${cardState}`; - const icon = elements.googleSheetsStatusCard.querySelector('.status-icon i'); - if (icon) { - icon.className = connected ? 'fas fa-check-circle' : (configured ? 'fas fa-circle-minus' : 'fas fa-triangle-exclamation'); - } - } - if (elements.googleSheetsStatus) elements.googleSheetsStatus.textContent = title; - if (elements.googleSheetsStatusDetail) elements.googleSheetsStatusDetail.textContent = detail; - if (elements.googleSheetsConnectBtn) { - elements.googleSheetsConnectBtn.hidden = connected; - elements.googleSheetsConnectBtn.disabled = !configured; - } - if (elements.googleSheetsDisconnectBtn) elements.googleSheetsDisconnectBtn.hidden = !connected; -} - -async function refreshGoogleSheetsStatus() { - try { - const result = await chrome.runtime.sendMessage({ action: 'google-sheets:get-status' }); - renderGoogleSheetsStatus(result || { configured: false, connected: false }); - } catch (_error) { - renderGoogleSheetsStatus({ configured: false, connected: false, error: 'Google Sheets status is unavailable.' }); - } -} - -async function connectGoogleSheets() { - if (!elements.googleSheetsConnectBtn || elements.googleSheetsConnectBtn.disabled) return; - elements.googleSheetsConnectBtn.disabled = true; - try { - const result = await chrome.runtime.sendMessage({ - action: 'google-sheets:connect', - userInitiated: true - }); - renderGoogleSheetsStatus(result || { configured: true, connected: false }); - showToast(result && result.connected ? 'Google Sheets connected' : ((result && result.error) || 'Google Sheets authorization failed'), result && result.connected ? 'success' : 'error'); - } catch (_error) { - showToast('Google Sheets authorization failed', 'error'); - await refreshGoogleSheetsStatus(); - } -} - -async function disconnectGoogleSheets() { - if (!elements.googleSheetsDisconnectBtn) return; - elements.googleSheetsDisconnectBtn.disabled = true; - try { - const result = await chrome.runtime.sendMessage({ - action: 'google-sheets:disconnect', - userInitiated: true - }); - renderGoogleSheetsStatus(result || { configured: true, connected: false }); - showToast(result && result.success ? 'Google Sheets disconnected' : 'Could not disconnect Google Sheets', result && result.success ? 'success' : 'error'); - } catch (_error) { - showToast('Could not disconnect Google Sheets', 'error'); - } finally { - elements.googleSheetsDisconnectBtn.disabled = false; - } -} - async function testApiConnection() { if (dashboardState.isApiTesting) return; diff --git a/extension/utils/capability-router.js b/extension/utils/capability-router.js index a740d3e07..3c9622058 100644 --- a/extension/utils/capability-router.js +++ b/extension/utils/capability-router.js @@ -143,17 +143,23 @@ function _fetchPrimitive() { return (typeof FsbCapabilityFetch !== 'undefined' && FsbCapabilityFetch) ? FsbCapabilityFetch : null; } - function _googleSheetsContext() { - var api = (typeof FsbGoogleSheetsApi !== 'undefined' && FsbGoogleSheetsApi) - ? FsbGoogleSheetsApi - : ((typeof globalThis !== 'undefined' && globalThis.FsbGoogleSheetsApi) ? globalThis.FsbGoogleSheetsApi : null); - if (!api) { return null; } + function _googleSheetsContext(ctx) { + var session = (typeof FsbGoogleSheetsSession !== 'undefined' && FsbGoogleSheetsSession) + ? FsbGoogleSheetsSession + : ((typeof globalThis !== 'undefined' && globalThis.FsbGoogleSheetsSession) ? globalThis.FsbGoogleSheetsSession : null); + if (!session) { return null; } var methods = ['getSpreadsheet', 'getValues', 'updateValues', 'appendValues', 'clearValues']; var narrow = {}; for (var i = 0; i < methods.length; i++) { (function (method) { - if (typeof api[method] === 'function') { - narrow[method] = function (params) { return api[method](params); }; + if (typeof session[method] === 'function') { + narrow[method] = function (params) { + return session[method](params, { + origin: ctx && ctx.origin, + tabId: ctx && ctx.tabId, + url: ctx && ctx.url + }); + }; } })(methods[i]); } @@ -722,7 +728,7 @@ executeBoundSpec: primitive ? primitive.executeBoundSpec : undefined, executeBoundPageRead: primitive ? primitive.executeBoundPageRead : undefined, interpretRecipe: interp ? interp.interpretRecipe : undefined, - googleSheets: _googleSheetsContext() + googleSheets: _googleSheetsContext(ctx) }; var out = await handler.handle(args || {}, handlerCtx); @@ -741,11 +747,14 @@ // T1a endpoint that rots to a 200-with-wrong-shape body is NOT caught here -- it // surfaces on the next break -- which is the safe direction (under-detect, never // mis-heal). A T1a is a BUNDLED slug, so a broken verdict quarantines via the - // catalog (session-only). The handler's OWN typed RECIPE_* security error (e.g. - // the pin's RECIPE_ORIGIN_MISMATCH) classifies as NOT broken (typed-passthrough) - // and is returned verbatim below -- never healed (Pitfall 3). + // catalog (session-only). API-backed handlers opt out with + // `rotClassifiable: false` because their operational failures do not describe a + // page recipe. The remaining handlers' typed RECIPE_* security errors (e.g. the + // pin's RECIPE_ORIGIN_MISMATCH) classify as NOT broken (typed-passthrough) and + // are returned verbatim below -- never healed (Pitfall 3). var detectorH = _rotDetector(); - if (detectorH && typeof detectorH.classifyRecipeBroken === 'function') { + var rotClassifiable = handler.rotClassifiable !== false; + if (rotClassifiable && detectorH && typeof detectorH.classifyRecipeBroken === 'function') { // Always null for a T1a entry (see the contract note above); passed for shape // parity with the declarative tier's classify call, not because a recipe exists. var recipeH = (entry && entry.recipe) ? entry.recipe : null; diff --git a/extension/utils/google-sheets-api.js b/extension/utils/google-sheets-api.js deleted file mode 100644 index 04eaadde0..000000000 --- a/extension/utils/google-sheets-api.js +++ /dev/null @@ -1,382 +0,0 @@ -(function (global) { - 'use strict'; - - var SHEETS_BASE_URL = 'https://sheets.googleapis.com/v4'; - var SHEETS_SCOPE = 'https://www.googleapis.com/auth/spreadsheets'; - var REQUEST_TIMEOUT_MS = 15000; - var MAX_REQUEST_BODY_BYTES = 1024 * 1024; - var MAX_RESPONSE_BODY_BYTES = 5 * 1024 * 1024; - var PLACEHOLDER_CLIENT_ID = /(?:REPLACE|PLACEHOLDER|YOUR[_-]|INVALID|EXAMPLE)/i; - var SPREADSHEET_ID = /^[A-Za-z0-9_-]{10,200}$/; - var SAFE_RANGE_MAX = 500; - - var ERROR_MESSAGES = { - GOOGLE_SHEETS_OAUTH_NOT_CONFIGURED: 'Google Sheets OAuth is not configured for this extension build.', - GOOGLE_SHEETS_IDENTITY_UNAVAILABLE: 'Chrome Identity is unavailable.', - GOOGLE_SHEETS_NOT_CONNECTED: 'Google Sheets is not connected. Connect it from the FSB control panel.', - GOOGLE_SHEETS_AUTH_FAILED: 'Google Sheets authorization failed.', - GOOGLE_SHEETS_INVALID_ARGUMENT: 'A Google Sheets request argument is invalid.', - GOOGLE_SHEETS_REQUEST_TOO_LARGE: 'The Google Sheets request is too large.', - GOOGLE_SHEETS_TIMEOUT: 'The Google Sheets request timed out.', - GOOGLE_SHEETS_NETWORK_ERROR: 'The Google Sheets API could not be reached.', - GOOGLE_SHEETS_ACCESS_DENIED: 'Google denied access to this spreadsheet.', - GOOGLE_SHEETS_NOT_FOUND: 'The requested spreadsheet or range was not found.', - GOOGLE_SHEETS_RATE_LIMITED: 'Google Sheets temporarily rate-limited this request.', - GOOGLE_SHEETS_API_ERROR: 'The Google Sheets API request failed.', - GOOGLE_SHEETS_RESPONSE_TOO_LARGE: 'The Google Sheets API response was too large.' - }; - - function typedError(code, extra) { - var out = { - success: false, - code: code, - errorCode: code, - error: ERROR_MESSAGES[code] || ERROR_MESSAGES.GOOGLE_SHEETS_API_ERROR - }; - if (extra && Number.isFinite(extra.status)) { out.status = extra.status; } - return out; - } - - function encodePathSegment(value) { - return encodeURIComponent(String(value)).replace(/[!'()*]/g, function (character) { - return '%' + character.charCodeAt(0).toString(16).toUpperCase(); - }); - } - - function isConfigured(chromeApi) { - try { - var manifest = chromeApi && chromeApi.runtime && chromeApi.runtime.getManifest - ? chromeApi.runtime.getManifest() - : null; - var oauth = manifest && manifest.oauth2; - var clientId = oauth && typeof oauth.client_id === 'string' ? oauth.client_id.trim() : ''; - var scopes = oauth && Array.isArray(oauth.scopes) ? oauth.scopes : []; - return !!clientId && !PLACEHOLDER_CLIENT_ID.test(clientId) && scopes.indexOf(SHEETS_SCOPE) !== -1; - } catch (_e) { - return false; - } - } - - function runtimeError(chromeApi) { - try { - return chromeApi && chromeApi.runtime && chromeApi.runtime.lastError - ? chromeApi.runtime.lastError - : null; - } catch (_e) { - return null; - } - } - - function createClient(deps) { - deps = deps || {}; - var chromeApi = deps.chrome || global.chrome; - var fetchFn = deps.fetch || global.fetch; - var AbortControllerCtor = deps.AbortController || global.AbortController; - var setTimer = deps.setTimeout || global.setTimeout; - var clearTimer = deps.clearTimeout || global.clearTimeout; - - function configurationError() { - if (!isConfigured(chromeApi)) { - return typedError('GOOGLE_SHEETS_OAUTH_NOT_CONFIGURED'); - } - if (!chromeApi || !chromeApi.identity || typeof chromeApi.identity.getAuthToken !== 'function') { - return typedError('GOOGLE_SHEETS_IDENTITY_UNAVAILABLE'); - } - return null; - } - - function getToken(interactive) { - var configError = configurationError(); - if (configError) { return Promise.reject(configError); } - return new Promise(function (resolve, reject) { - try { - chromeApi.identity.getAuthToken({ interactive: interactive === true }, function (token) { - var lastError = runtimeError(chromeApi); - if (lastError || typeof token !== 'string' || !token) { - reject(typedError(interactive ? 'GOOGLE_SHEETS_AUTH_FAILED' : 'GOOGLE_SHEETS_NOT_CONNECTED')); - return; - } - resolve(token); - }); - } catch (_e) { - reject(typedError(interactive ? 'GOOGLE_SHEETS_AUTH_FAILED' : 'GOOGLE_SHEETS_NOT_CONNECTED')); - } - }); - } - - function removeCachedToken(token) { - if (!token || !chromeApi || !chromeApi.identity || typeof chromeApi.identity.removeCachedAuthToken !== 'function') { - return Promise.resolve(); - } - return new Promise(function (resolve) { - try { - chromeApi.identity.removeCachedAuthToken({ token: token }, function () { resolve(); }); - } catch (_e) { - resolve(); - } - }); - } - - function profile() { - if (!chromeApi || !chromeApi.identity || typeof chromeApi.identity.getProfileUserInfo !== 'function') { - return Promise.resolve({}); - } - return new Promise(function (resolve) { - try { - chromeApi.identity.getProfileUserInfo({ accountStatus: 'ANY' }, function (info) { - if (runtimeError(chromeApi)) { resolve({}); return; } - resolve(info && typeof info === 'object' ? info : {}); - }); - } catch (_e) { - resolve({}); - } - }); - } - - async function status() { - var configError = configurationError(); - if (configError) { - return { - success: true, - configured: false, - connected: false, - code: configError.code, - message: configError.error - }; - } - try { - await getToken(false); - var info = await profile(); - return { - success: true, - configured: true, - connected: true, - email: typeof info.email === 'string' ? info.email : '' - }; - } catch (_e) { - return { success: true, configured: true, connected: false }; - } - } - - async function connect() { - var configError = configurationError(); - if (configError) { return configError; } - try { - await getToken(true); - var info = await profile(); - return { - success: true, - configured: true, - connected: true, - email: typeof info.email === 'string' ? info.email : '' - }; - } catch (error) { - return error && error.code ? error : typedError('GOOGLE_SHEETS_AUTH_FAILED'); - } - } - - async function disconnect() { - var configError = configurationError(); - if (configError) { return configError; } - try { - var token = await getToken(false); - await removeCachedToken(token); - } catch (_e) { - // Already disconnected is a successful terminal state. - } - return { success: true, configured: true, connected: false }; - } - - function validateSpreadsheetId(value) { - if (typeof value !== 'string' || !SPREADSHEET_ID.test(value)) { - throw typedError('GOOGLE_SHEETS_INVALID_ARGUMENT'); - } - return value; - } - - function validateRange(value) { - if (typeof value !== 'string' || !value || value.length > SAFE_RANGE_MAX || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) { - throw typedError('GOOGLE_SHEETS_INVALID_ARGUMENT'); - } - return value; - } - - function enumValue(value, allowed, fallback) { - var candidate = value === undefined || value === null || value === '' ? fallback : String(value); - if (allowed.indexOf(candidate) === -1) { throw typedError('GOOGLE_SHEETS_INVALID_ARGUMENT'); } - return candidate; - } - - function validateValues(values) { - if (!Array.isArray(values) || values.length === 0 || values.length > 10000) { - throw typedError('GOOGLE_SHEETS_INVALID_ARGUMENT'); - } - for (var rowIndex = 0; rowIndex < values.length; rowIndex++) { - var row = values[rowIndex]; - if (!Array.isArray(row) || row.length > 10000) { - throw typedError('GOOGLE_SHEETS_INVALID_ARGUMENT'); - } - for (var colIndex = 0; colIndex < row.length; colIndex++) { - var cell = row[colIndex]; - if (cell !== null && typeof cell !== 'string' && typeof cell !== 'boolean' && !(typeof cell === 'number' && Number.isFinite(cell))) { - throw typedError('GOOGLE_SHEETS_INVALID_ARGUMENT'); - } - } - } - return values; - } - - function requestSpec(operation, params) { - params = params || {}; - var id = validateSpreadsheetId(params.spreadsheetId); - var idPart = encodePathSegment(id); - var range; - var query = new URLSearchParams(); - var spec = { method: 'GET', path: '/spreadsheets/' + idPart, body: null }; - - if (operation === 'getSpreadsheet') { - query.set('includeGridData', 'false'); - query.set('fields', 'spreadsheetId,properties(title,locale,timeZone),sheets(properties(sheetId,title,index,sheetType,gridProperties))'); - } else if (operation === 'getValues') { - range = validateRange(params.range); - spec.path += '/values/' + encodePathSegment(range); - query.set('majorDimension', enumValue(params.majorDimension, ['ROWS', 'COLUMNS'], 'ROWS')); - query.set('valueRenderOption', enumValue(params.valueRenderOption, ['FORMATTED_VALUE', 'UNFORMATTED_VALUE', 'FORMULA'], 'FORMATTED_VALUE')); - query.set('dateTimeRenderOption', enumValue(params.dateTimeRenderOption, ['SERIAL_NUMBER', 'FORMATTED_STRING'], 'FORMATTED_STRING')); - } else if (operation === 'updateValues') { - range = validateRange(params.range); - spec.method = 'PUT'; - spec.path += '/values/' + encodePathSegment(range); - query.set('valueInputOption', enumValue(params.valueInputOption, ['RAW', 'USER_ENTERED'], 'USER_ENTERED')); - query.set('includeValuesInResponse', 'false'); - spec.body = { range: range, majorDimension: 'ROWS', values: validateValues(params.values) }; - } else if (operation === 'appendValues') { - range = validateRange(params.range); - spec.method = 'POST'; - spec.path += '/values/' + encodePathSegment(range) + ':append'; - query.set('valueInputOption', enumValue(params.valueInputOption, ['RAW', 'USER_ENTERED'], 'USER_ENTERED')); - query.set('insertDataOption', enumValue(params.insertDataOption, ['OVERWRITE', 'INSERT_ROWS'], 'INSERT_ROWS')); - query.set('includeValuesInResponse', 'false'); - spec.body = { range: range, majorDimension: 'ROWS', values: validateValues(params.values) }; - } else if (operation === 'clearValues') { - range = validateRange(params.range); - spec.method = 'POST'; - spec.path += '/values/' + encodePathSegment(range) + ':clear'; - spec.body = {}; - } else { - throw typedError('GOOGLE_SHEETS_INVALID_ARGUMENT'); - } - - var bodyText = spec.body === null ? null : JSON.stringify(spec.body); - if (bodyText && new TextEncoder().encode(bodyText).byteLength > MAX_REQUEST_BODY_BYTES) { - throw typedError('GOOGLE_SHEETS_REQUEST_TOO_LARGE'); - } - spec.url = SHEETS_BASE_URL + spec.path + (query.toString() ? '?' + query.toString() : ''); - spec.bodyText = bodyText; - return spec; - } - - function statusError(statusCode) { - if (statusCode === 403) { return typedError('GOOGLE_SHEETS_ACCESS_DENIED', { status: statusCode }); } - if (statusCode === 404) { return typedError('GOOGLE_SHEETS_NOT_FOUND', { status: statusCode }); } - if (statusCode === 429) { return typedError('GOOGLE_SHEETS_RATE_LIMITED', { status: statusCode }); } - return typedError('GOOGLE_SHEETS_API_ERROR', { status: statusCode }); - } - - async function fetchOnce(spec, token) { - if (typeof fetchFn !== 'function') { return typedError('GOOGLE_SHEETS_NETWORK_ERROR'); } - var controller = AbortControllerCtor ? new AbortControllerCtor() : null; - var timer = null; - if (controller && typeof setTimer === 'function') { - timer = setTimer(function () { controller.abort(); }, REQUEST_TIMEOUT_MS); - } - try { - var headers = { Authorization: 'Bearer ' + token, Accept: 'application/json' }; - if (spec.bodyText !== null) { headers['Content-Type'] = 'application/json'; } - var response = await fetchFn(spec.url, { - method: spec.method, - headers: headers, - body: spec.bodyText, - signal: controller ? controller.signal : undefined, - credentials: 'omit', - redirect: 'error' - }); - if (response.status === 401) { return { success: false, authRejected: true, status: 401 }; } - if (!response.ok) { return statusError(response.status); } - var contentLength = Number(response.headers && response.headers.get ? response.headers.get('content-length') : 0); - if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BODY_BYTES) { - return typedError('GOOGLE_SHEETS_RESPONSE_TOO_LARGE'); - } - var text = await response.text(); - if (new TextEncoder().encode(text).byteLength > MAX_RESPONSE_BODY_BYTES) { - return typedError('GOOGLE_SHEETS_RESPONSE_TOO_LARGE'); - } - var data = text ? JSON.parse(text) : {}; - return { success: true, status: response.status, data: data }; - } catch (error) { - if (error && (error.name === 'AbortError' || (controller && controller.signal && controller.signal.aborted))) { - return typedError('GOOGLE_SHEETS_TIMEOUT'); - } - return typedError('GOOGLE_SHEETS_NETWORK_ERROR'); - } finally { - if (timer !== null && typeof clearTimer === 'function') { clearTimer(timer); } - } - } - - async function execute(operation, params) { - var spec; - try { - spec = requestSpec(operation, params); - } catch (error) { - return error && error.code ? error : typedError('GOOGLE_SHEETS_INVALID_ARGUMENT'); - } - var token; - try { - token = await getToken(false); - } catch (error) { - return error && error.code ? error : typedError('GOOGLE_SHEETS_NOT_CONNECTED'); - } - var first = await fetchOnce(spec, token); - if (!first || first.authRejected !== true) { return first; } - await removeCachedToken(token); - try { - token = await getToken(false); - } catch (_e) { - return typedError('GOOGLE_SHEETS_NOT_CONNECTED'); - } - var second = await fetchOnce(spec, token); - return second && second.authRejected === true - ? typedError('GOOGLE_SHEETS_AUTH_FAILED', { status: 401 }) - : second; - } - - var client = { - connect: connect, - status: status, - disconnect: disconnect, - getSpreadsheet: function (params) { return execute('getSpreadsheet', params); }, - getValues: function (params) { return execute('getValues', params); }, - updateValues: function (params) { return execute('updateValues', params); }, - appendValues: function (params) { return execute('appendValues', params); }, - clearValues: function (params) { return execute('clearValues', params); } - }; - Object.defineProperty(client, '__test', { - value: { requestSpec: requestSpec, isConfigured: function () { return isConfigured(chromeApi); } }, - enumerable: false - }); - return client; - } - - var api = createClient(); - - api.createClient = createClient; - api.constants = Object.freeze({ - baseUrl: SHEETS_BASE_URL, - scope: SHEETS_SCOPE, - requestTimeoutMs: REQUEST_TIMEOUT_MS, - maxRequestBodyBytes: MAX_REQUEST_BODY_BYTES - }); - Object.freeze(api); - global.FsbGoogleSheetsApi = api; - if (typeof module !== 'undefined' && module.exports) { module.exports = api; } -})(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/extension/utils/google-sheets-session.js b/extension/utils/google-sheets-session.js new file mode 100644 index 000000000..16b738108 --- /dev/null +++ b/extension/utils/google-sheets-session.js @@ -0,0 +1,334 @@ +(function (global) { + 'use strict'; + + var SHEETS_ORIGIN = 'https://docs.google.com'; + var SHEETS_API_BASE = 'https://sheets.googleapis.com/v4'; + var REQUEST_TIMEOUT_MS = 15000; + var SPREADSHEET_ID_RE = /^[A-Za-z0-9_-]{10,200}$/; + var SHEETS_URL_RE = /^\/spreadsheets\/d\/([A-Za-z0-9_-]{10,200})(?:\/|$)/; + var MUTATIONS = { updateValues: true, appendValues: true, clearValues: true }; + + var ERROR_MESSAGES = { + GOOGLE_SHEETS_ACTIVE_TAB_REQUIRED: 'Open the target spreadsheet in the agent-owned Google Sheets tab.', + GOOGLE_SHEETS_TARGET_MISMATCH: 'The requested spreadsheet does not match the agent-owned Google Sheets tab.', + GOOGLE_SHEETS_SESSION_UNAVAILABLE: 'The signed-in Google Sheets page session is unavailable.', + RECIPE_DOM_FALLBACK_PENDING: 'The requested Sheets operation is not safely available through the UI fallback.', + RECOVERY_AMBIGUOUS: 'The Sheets mutation may have taken effect. It was not retried.' + }; + + function typedError(code, extra) { + var out = { + success: false, + code: code, + errorCode: code, + error: ERROR_MESSAGES[code] || code + }; + if (extra) { + for (var key in extra) { + if (Object.prototype.hasOwnProperty.call(extra, key)) { out[key] = extra[key]; } + } + } + return out; + } + + function spreadsheetIdFromUrl(value) { + try { + var parsed = new URL(String(value || '')); + if (parsed.origin !== SHEETS_ORIGIN) { return ''; } + var match = parsed.pathname.match(SHEETS_URL_RE); + return match ? match[1] : ''; + } catch (_e) { + return ''; + } + } + + function pageClientOperation(request) { + return (async function () { + function error(code, extra) { + var out = { success: false, code: code, errorCode: code, error: code }; + if (extra) { + for (var key in extra) { + if (Object.prototype.hasOwnProperty.call(extra, key)) { out[key] = extra[key]; } + } + } + return out; + } + function encode(value) { + return encodeURIComponent(String(value)).replace(/[!'()*]/g, function (character) { + return '%' + character.charCodeAt(0).toString(16).toUpperCase(); + }); + } + function addParam(list, key, value) { + if (value !== undefined && value !== null && value !== '') { + list.push(encode(key) + '=' + encode(value)); + } + } + function statusFromFailure(failure) { + var status = Number(failure && failure.status); + if (!isFinite(status)) { status = Number(failure && failure.result && failure.result.error && failure.result.error.code); } + return isFinite(status) ? status : 0; + } + function responseData(response) { + if (response && response.result && typeof response.result === 'object') { return response.result; } + if (response && typeof response.body === 'string' && response.body) { + try { return JSON.parse(response.body); } catch (_e) { return {}; } + } + return response && typeof response === 'object' ? response : {}; + } + + var operation = String(request && request.operation || ''); + var args = request && request.args && typeof request.args === 'object' ? request.args : {}; + var spreadsheetId = String(request && request.spreadsheetId || ''); + var mutating = operation === 'updateValues' || operation === 'appendValues' || operation === 'clearValues'; + var gapiClient = globalThis.gapi && globalThis.gapi.client; + if (!gapiClient || typeof gapiClient.request !== 'function') { + return error('GOOGLE_SHEETS_SESSION_UNAVAILABLE', { + reason: 'page-gapi-client-unavailable', + safeToFallback: true, + requestSent: false + }); + } + + var path = 'https://sheets.googleapis.com/v4/spreadsheets/' + encode(spreadsheetId); + var method = 'GET'; + var params = {}; + var body; + if (operation === 'getSpreadsheet') { + params.fields = 'spreadsheetId,properties(title,locale,timeZone),sheets(properties(sheetId,title,index,sheetType,gridProperties))'; + } else if (operation === 'getValues') { + path += '/values/' + encode(args.range); + if (args.majorDimension) { params.majorDimension = args.majorDimension; } + if (args.valueRenderOption) { params.valueRenderOption = args.valueRenderOption; } + if (args.dateTimeRenderOption) { params.dateTimeRenderOption = args.dateTimeRenderOption; } + } else if (operation === 'updateValues') { + path += '/values/' + encode(args.range); + method = 'PUT'; + params.valueInputOption = args.valueInputOption || 'RAW'; + body = { values: args.values }; + } else if (operation === 'appendValues') { + path += '/values/' + encode(args.range) + ':append'; + method = 'POST'; + params.valueInputOption = args.valueInputOption || 'RAW'; + params.insertDataOption = args.insertDataOption || 'OVERWRITE'; + body = { values: args.values }; + } else if (operation === 'clearValues') { + path += '/values/' + encode(args.range) + ':clear'; + method = 'POST'; + body = {}; + } else { + return error('RECIPE_DOM_FALLBACK_PENDING', { reason: 'unsupported-sheets-operation' }); + } + + var pending; + try { + pending = gapiClient.request({ path: path, method: method, params: params, body: body }); + } catch (_syncError) { + return error('GOOGLE_SHEETS_SESSION_UNAVAILABLE', { + reason: 'page-gapi-request-not-started', + safeToFallback: true, + requestSent: false + }); + } + + var timeoutId; + var timeout = new Promise(function (resolve) { + timeoutId = setTimeout(function () { resolve({ timedOut: true }); }, Number(request.timeoutMs) || 15000); + }); + var settled = await Promise.race([ + Promise.resolve(pending).then(function (value) { return { value: value }; }, function (failure) { return { failure: failure }; }), + timeout + ]); + clearTimeout(timeoutId); + + if (settled.timedOut) { + return mutating + ? error('RECOVERY_AMBIGUOUS', { reason: 'page-gapi-timeout', requestSent: true }) + : error('GOOGLE_SHEETS_SESSION_UNAVAILABLE', { reason: 'page-gapi-timeout', safeToFallback: true, requestSent: true }); + } + if (settled.failure) { + var status = statusFromFailure(settled.failure); + if (status === 401 || status === 403) { + return error('GOOGLE_SHEETS_SESSION_UNAVAILABLE', { + reason: 'page-gapi-session-rejected', + status: status, + safeToFallback: true, + requestSent: true, + knownNoEffect: true + }); + } + if (mutating && !status) { + return error('RECOVERY_AMBIGUOUS', { reason: 'page-gapi-unknown-failure', requestSent: true }); + } + return error('RECIPE_DOM_FALLBACK_PENDING', { + reason: status ? 'page-gapi-request-rejected' : 'page-gapi-request-failed', + status: status || undefined, + requestSent: true + }); + } + + var response = settled.value || {}; + var responseStatus = Number(response.status) || 200; + if (responseStatus >= 400) { + if (responseStatus === 401 || responseStatus === 403) { + return error('GOOGLE_SHEETS_SESSION_UNAVAILABLE', { + reason: 'page-gapi-session-rejected', + status: responseStatus, + safeToFallback: true, + requestSent: true, + knownNoEffect: true + }); + } + return error('RECIPE_DOM_FALLBACK_PENDING', { + reason: 'page-gapi-request-rejected', + status: responseStatus, + requestSent: true + }); + } + return { + success: true, + status: responseStatus, + data: responseData(response), + transport: 'page-client' + }; + })(); + } + + function createSession(deps) { + deps = deps || {}; + var chromeApi = deps.chrome || global.chrome; + var timeoutMs = deps.requestTimeoutMs || REQUEST_TIMEOUT_MS; + + async function resolveTarget(params, context) { + params = params || {}; + context = context || {}; + var tabId = Number(context.tabId); + if (!Number.isInteger(tabId) || !chromeApi || !chromeApi.tabs || typeof chromeApi.tabs.get !== 'function') { + return typedError('GOOGLE_SHEETS_ACTIVE_TAB_REQUIRED'); + } + var tab; + try { tab = await chromeApi.tabs.get(tabId); } catch (_e) { tab = null; } + var spreadsheetId = spreadsheetIdFromUrl(tab && tab.url); + if (!spreadsheetId) { return typedError('GOOGLE_SHEETS_ACTIVE_TAB_REQUIRED'); } + var explicit = params.spreadsheetId; + if (explicit !== undefined && explicit !== null && explicit !== '') { + if (typeof explicit !== 'string' || !SPREADSHEET_ID_RE.test(explicit) || explicit !== spreadsheetId) { + return typedError('GOOGLE_SHEETS_TARGET_MISMATCH'); + } + } + return { success: true, tabId: tabId, spreadsheetId: spreadsheetId, url: tab.url }; + } + + function safeArgs(operation, params) { + params = params || {}; + if (operation === 'getSpreadsheet') { return {}; } + if (operation === 'getValues') { + return { + range: params.range, + majorDimension: params.majorDimension, + valueRenderOption: params.valueRenderOption, + dateTimeRenderOption: params.dateTimeRenderOption + }; + } + if (operation === 'updateValues') { + return { range: params.range, values: params.values, valueInputOption: params.valueInputOption || 'RAW' }; + } + if (operation === 'appendValues') { + return { + range: params.range, + values: params.values, + valueInputOption: params.valueInputOption || 'RAW', + insertDataOption: params.insertDataOption || 'OVERWRITE' + }; + } + return { range: params.range }; + } + + async function runPageClient(target, operation, args) { + if (!chromeApi || !chromeApi.scripting || typeof chromeApi.scripting.executeScript !== 'function') { + return typedError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', { + reason: 'page-script-unavailable', safeToFallback: true, requestSent: false + }); + } + var results; + try { + results = await chromeApi.scripting.executeScript({ + target: { tabId: target.tabId }, + world: 'MAIN', + func: pageClientOperation, + args: [{ operation: operation, spreadsheetId: target.spreadsheetId, args: args, timeoutMs: timeoutMs }] + }); + } catch (_e) { + return MUTATIONS[operation] + ? typedError('RECOVERY_AMBIGUOUS', { reason: 'page-script-outcome-unknown' }) + : typedError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', { reason: 'page-script-failed', safeToFallback: true }); + } + var result = results && results[0] && results[0].result; + if (!result) { + return MUTATIONS[operation] + ? typedError('RECOVERY_AMBIGUOUS', { reason: 'page-script-no-result' }) + : typedError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', { reason: 'page-script-no-result', safeToFallback: true }); + } + return result; + } + + async function runUi(target, operation, args) { + if (!chromeApi || !chromeApi.tabs || typeof chromeApi.tabs.sendMessage !== 'function') { + return typedError('GOOGLE_SHEETS_SESSION_UNAVAILABLE'); + } + var result; + try { + result = await chromeApi.tabs.sendMessage(target.tabId, { + action: 'executeAction', + tool: 'sheetsSession', + params: { operation: operation, spreadsheetId: target.spreadsheetId, args: args }, + source: 'capability-session' + }); + } catch (error) { + var message = String(error && error.message || ''); + var noReceiver = /receiving end does not exist|could not establish connection/i.test(message); + if (MUTATIONS[operation] && !noReceiver) { + return typedError('RECOVERY_AMBIGUOUS', { reason: 'ui-message-outcome-unknown' }); + } + return typedError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', { reason: 'sheets-ui-unavailable' }); + } + if (!result || result.success !== true) { + if (result && result.code) { return result; } + return MUTATIONS[operation] && result && result.mutationStarted + ? typedError('RECOVERY_AMBIGUOUS', { reason: 'ui-mutation-outcome-unknown' }) + : typedError('RECIPE_DOM_FALLBACK_PENDING', { reason: result && result.reason || 'sheets-ui-operation-unavailable' }); + } + return result; + } + + async function execute(operation, params, context) { + var target = await resolveTarget(params, context); + if (!target.success) { return target; } + var args = safeArgs(operation, params); + var pageResult = await runPageClient(target, operation, args); + if (pageResult && pageResult.success === true) { return pageResult; } + if (!pageResult || pageResult.safeToFallback !== true) { return pageResult; } + return runUi(target, operation, args); + } + + return { + getSpreadsheet: function (params, context) { return execute('getSpreadsheet', params, context); }, + getValues: function (params, context) { return execute('getValues', params, context); }, + updateValues: function (params, context) { return execute('updateValues', params, context); }, + appendValues: function (params, context) { return execute('appendValues', params, context); }, + clearValues: function (params, context) { return execute('clearValues', params, context); } + }; + } + + var session = createSession(); + session.createSession = createSession; + session.pageClientOperation = pageClientOperation; + session.spreadsheetIdFromUrl = spreadsheetIdFromUrl; + session.constants = Object.freeze({ + origin: SHEETS_ORIGIN, + apiBaseUrl: SHEETS_API_BASE, + requestTimeoutMs: REQUEST_TIMEOUT_MS + }); + Object.freeze(session); + global.FsbGoogleSheetsSession = session; + if (typeof module !== 'undefined' && module.exports) { module.exports = session; } +})(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/tests/google-sheets-api.test.js b/tests/google-sheets-api.test.js deleted file mode 100644 index 982a4a8aa..000000000 --- a/tests/google-sheets-api.test.js +++ /dev/null @@ -1,186 +0,0 @@ -'use strict'; - -const assert = require('node:assert/strict'); -const test = require('node:test'); - -const apiModule = require('../extension/utils/google-sheets-api.js'); -const REAL_CLIENT_ID = '123456789012-abcdefghijklmnopqrstuvwxyz.apps.googleusercontent.com'; -const SCOPE = 'https://www.googleapis.com/auth/spreadsheets'; - -function response(status, data, headers) { - return { - status, - ok: status >= 200 && status < 300, - headers: { get(name) { return (headers || {})[name.toLowerCase()] || null; } }, - async text() { return data === undefined ? '' : JSON.stringify(data); } - }; -} - -function chromeStub(options) { - options = options || {}; - const authCalls = []; - const removed = []; - const tokens = (options.tokens || ['secret-access-token']).slice(); - const chromeApi = { - runtime: { - lastError: null, - getManifest() { - return { - oauth2: { - client_id: options.clientId || REAL_CLIENT_ID, - scopes: options.scopes || [SCOPE] - } - }; - } - }, - identity: { - getAuthToken(details, callback) { - authCalls.push(details); - const token = tokens.length ? tokens.shift() : 'secret-access-token'; - if (token instanceof Error) { - chromeApi.runtime.lastError = { message: token.message }; - callback(undefined); - chromeApi.runtime.lastError = null; - return; - } - callback(token); - }, - removeCachedAuthToken(details, callback) { - removed.push(details.token); - callback(); - }, - getProfileUserInfo(_details, callback) { - callback({ email: 'user@example.com', id: 'profile-id' }); - } - } - }; - return { chromeApi, authCalls, removed }; -} - -test('placeholder OAuth configuration fails closed before Chrome Identity', async () => { - const stub = chromeStub({ clientId: 'REPLACE_WITH_FSB_GOOGLE_OAUTH_CLIENT_ID.apps.googleusercontent.com' }); - const client = apiModule.createClient({ chrome: stub.chromeApi, fetch: async () => response(200, {}) }); - const connected = await client.connect(); - const read = await client.getSpreadsheet({ spreadsheetId: 'abcdefghijklmnopqrstuvwxyz123456' }); - assert.equal(connected.code, 'GOOGLE_SHEETS_OAUTH_NOT_CONFIGURED'); - assert.equal(read.code, 'GOOGLE_SHEETS_OAUTH_NOT_CONFIGURED'); - assert.equal(stub.authCalls.length, 0); -}); - -test('only connect requests an interactive token; API calls are noninteractive', async () => { - const stub = chromeStub({ tokens: ['connect-token', 'read-token'] }); - const client = apiModule.createClient({ - chrome: stub.chromeApi, - fetch: async () => response(200, { spreadsheetId: 'abcdefghijklmnopqrstuvwxyz123456' }) - }); - const connected = await client.connect(); - const read = await client.getSpreadsheet({ spreadsheetId: 'abcdefghijklmnopqrstuvwxyz123456' }); - assert.equal(connected.connected, true); - assert.equal(read.success, true); - assert.deepEqual(stub.authCalls.map(call => call.interactive), [true, false]); -}); - -test('constructs only encoded Sheets v4 requests with fixed methods and safe bodies', async () => { - const stub = chromeStub({ tokens: ['read-token', 'write-token', 'append-token', 'clear-token'] }); - const calls = []; - const client = apiModule.createClient({ - chrome: stub.chromeApi, - fetch: async (url, init) => { - calls.push({ url, init }); - return response(200, { ok: true }); - } - }); - const id = 'abcdefghijklmnopqrstuvwxyz123456'; - await client.getValues({ spreadsheetId: id, range: "Data 2026!A1:B2", valueRenderOption: 'FORMULA' }); - await client.updateValues({ spreadsheetId: id, range: 'A1:B1', values: [['name', '=1+1']], valueInputOption: 'RAW' }); - await client.appendValues({ spreadsheetId: id, range: 'A:B', values: [['x', 2]], insertDataOption: 'INSERT_ROWS' }); - await client.clearValues({ spreadsheetId: id, range: 'Archive!A2:Z' }); - - assert.equal(calls.length, 4); - assert.ok(calls.every(call => call.url.startsWith('https://sheets.googleapis.com/v4/spreadsheets/'))); - assert.deepEqual(calls.map(call => call.init.method), ['GET', 'PUT', 'POST', 'POST']); - assert.match(calls[0].url, /Data%202026%21A1%3AB2/); - assert.deepEqual(JSON.parse(calls[1].init.body).values, [['name', '=1+1']]); - assert.match(calls[2].url, /A%3AB:append\?/); - assert.match(calls[3].url, /A2%3AZ:clear$/); - assert.ok(calls.every(call => call.init.credentials === 'omit' && call.init.redirect === 'error')); -}); - -test('rejects invalid IDs, ranges, and oversized bodies before auth or fetch', async () => { - const stub = chromeStub(); - let fetchCount = 0; - const client = apiModule.createClient({ - chrome: stub.chromeApi, - fetch: async () => { fetchCount++; return response(200, {}); } - }); - assert.equal((await client.getValues({ spreadsheetId: '../bad', range: 'A1' })).code, 'GOOGLE_SHEETS_INVALID_ARGUMENT'); - assert.equal((await client.getValues({ spreadsheetId: 'abcdefghijklmnopqrstuvwxyz123456', range: 'A1\nAuthorization: secret' })).code, 'GOOGLE_SHEETS_INVALID_ARGUMENT'); - const huge = 'x'.repeat(apiModule.constants.maxRequestBodyBytes + 1); - assert.equal((await client.updateValues({ spreadsheetId: 'abcdefghijklmnopqrstuvwxyz123456', range: 'A1', values: [[huge]] })).code, 'GOOGLE_SHEETS_REQUEST_TOO_LARGE'); - assert.equal(fetchCount, 0); - assert.equal(stub.authCalls.length, 0); -}); - -test('normalizes Google failures and never discloses tokens or response details', async () => { - const token = 'TOP-SECRET-OAUTH-TOKEN'; - const stub = chromeStub({ tokens: [token] }); - const client = apiModule.createClient({ - chrome: stub.chromeApi, - fetch: async () => response(403, { error: { message: 'private server detail ' + token } }) - }); - const out = await client.getSpreadsheet({ spreadsheetId: 'abcdefghijklmnopqrstuvwxyz123456' }); - assert.equal(out.code, 'GOOGLE_SHEETS_ACCESS_DENIED'); - assert.equal(out.status, 403); - assert.equal(JSON.stringify(out).includes(token), false); - assert.equal(JSON.stringify(out).includes('private server detail'), false); -}); - -test('evicts a rejected token and retries once noninteractively', async () => { - const stub = chromeStub({ tokens: ['expired-token', 'fresh-token'] }); - const authHeaders = []; - const client = apiModule.createClient({ - chrome: stub.chromeApi, - fetch: async (_url, init) => { - authHeaders.push(init.headers.Authorization); - return authHeaders.length === 1 ? response(401, {}) : response(200, { values: [['ok']] }); - } - }); - const out = await client.getValues({ spreadsheetId: 'abcdefghijklmnopqrstuvwxyz123456', range: 'A1' }); - assert.equal(out.success, true); - assert.deepEqual(stub.authCalls.map(call => call.interactive), [false, false]); - assert.deepEqual(stub.removed, ['expired-token']); - assert.equal(authHeaders.length, 2); -}); - -test('disconnect evicts cached auth without returning the token', async () => { - const stub = chromeStub({ tokens: ['disconnect-secret'] }); - const client = apiModule.createClient({ chrome: stub.chromeApi }); - const out = await client.disconnect(); - assert.deepEqual(stub.removed, ['disconnect-secret']); - assert.equal(out.connected, false); - assert.equal(JSON.stringify(out).includes('disconnect-secret'), false); -}); - -test('timeout errors are typed and safe', async () => { - const stub = chromeStub({ tokens: ['timeout-token'] }); - class Controller { - constructor() { this.signal = { aborted: false }; } - abort() { this.signal.aborted = true; } - } - let timerFn; - const client = apiModule.createClient({ - chrome: stub.chromeApi, - AbortController: Controller, - setTimeout(fn) { timerFn = fn; return 1; }, - clearTimeout() {}, - fetch: async (_url, init) => { - timerFn(); - const error = new Error('secret timeout detail'); - error.name = 'AbortError'; - throw error; - } - }); - const out = await client.getSpreadsheet({ spreadsheetId: 'abcdefghijklmnopqrstuvwxyz123456' }); - assert.equal(out.code, 'GOOGLE_SHEETS_TIMEOUT'); - assert.equal(JSON.stringify(out).includes('secret timeout detail'), false); -}); diff --git a/tests/google-sheets-session.test.js b/tests/google-sheets-session.test.js new file mode 100644 index 000000000..be79dc657 --- /dev/null +++ b/tests/google-sheets-session.test.js @@ -0,0 +1,160 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const sessionModule = require('../extension/utils/google-sheets-session.js'); + +const ID = 'abcdefghijklmnopqrstuvwxyz123456'; +const OTHER_ID = 'zyxwvutsrqponmlkjihgfedcba654321'; +const URL = `https://docs.google.com/spreadsheets/d/${ID}/edit#gid=0`; +const CONTEXT = { tabId: 17, origin: 'https://docs.google.com', url: URL }; + +function chromeStub(options = {}) { + const pageCalls = []; + const uiCalls = []; + const chrome = { + tabs: { + async get(tabId) { + if (options.tabError) throw options.tabError; + return { id: tabId, url: options.tabUrl === undefined ? URL : options.tabUrl }; + }, + async sendMessage(tabId, message) { + uiCalls.push({ tabId, message }); + if (options.uiError) throw options.uiError; + return options.uiResult || { + success: true, + transport: 'ui', + data: { values: [['ui-value']] }, + renderSemantics: 'formula-bar' + }; + } + }, + scripting: { + async executeScript(details) { + pageCalls.push(details); + if (options.scriptError) throw options.scriptError; + return [{ result: options.pageResult === undefined + ? { success: true, status: 200, data: { spreadsheetId: ID }, transport: 'page-client' } + : options.pageResult }]; + } + } + }; + return { chrome, pageCalls, uiCalls }; +} + +test('pins every operation to the caller-owned active Sheets tab', async () => { + const stub = chromeStub(); + const client = sessionModule.createSession({ chrome: stub.chrome }); + const out = await client.getSpreadsheet({}, CONTEXT); + assert.equal(out.success, true); + assert.equal(out.transport, 'page-client'); + assert.equal(stub.pageCalls.length, 1); + assert.equal(stub.pageCalls[0].target.tabId, 17); + assert.equal(stub.pageCalls[0].world, 'MAIN'); + assert.equal(stub.pageCalls[0].args[0].spreadsheetId, ID); +}); + +test('requires a Sheets tab and rejects explicit target mismatches before execution', async () => { + const missing = chromeStub({ tabUrl: 'https://docs.google.com/document/d/not-a-sheet/edit' }); + const missingClient = sessionModule.createSession({ chrome: missing.chrome }); + assert.equal((await missingClient.getValues({ range: 'A1' }, CONTEXT)).code, 'GOOGLE_SHEETS_ACTIVE_TAB_REQUIRED'); + assert.equal(missing.pageCalls.length, 0); + + const mismatch = chromeStub(); + const mismatchClient = sessionModule.createSession({ chrome: mismatch.chrome }); + assert.equal((await mismatchClient.getValues({ spreadsheetId: OTHER_ID, range: 'A1' }, CONTEXT)).code, 'GOOGLE_SHEETS_TARGET_MISMATCH'); + assert.equal(mismatch.pageCalls.length, 0); +}); + +test('falls back to the fixed UI action only for page-session outcomes known safe', async () => { + const stub = chromeStub({ + pageResult: { + success: false, + code: 'GOOGLE_SHEETS_SESSION_UNAVAILABLE', + errorCode: 'GOOGLE_SHEETS_SESSION_UNAVAILABLE', + error: 'GOOGLE_SHEETS_SESSION_UNAVAILABLE', + safeToFallback: true, + knownNoEffect: true + } + }); + const client = sessionModule.createSession({ chrome: stub.chrome }); + const out = await client.getValues({ range: 'Sheet1!A1:B2' }, CONTEXT); + assert.equal(out.success, true); + assert.equal(out.transport, 'ui'); + assert.equal(stub.uiCalls.length, 1); + assert.equal(stub.uiCalls[0].message.tool, 'sheetsSession'); + assert.deepEqual(Object.keys(stub.uiCalls[0].message.params).sort(), ['args', 'operation', 'spreadsheetId']); +}); + +test('never retries or falls back after an ambiguous mutation outcome', async () => { + const stub = chromeStub({ + pageResult: { + success: false, + code: 'RECOVERY_AMBIGUOUS', + errorCode: 'RECOVERY_AMBIGUOUS', + error: 'RECOVERY_AMBIGUOUS' + } + }); + const client = sessionModule.createSession({ chrome: stub.chrome }); + const out = await client.updateValues({ range: 'A1', values: [['x']] }, CONTEXT); + assert.equal(out.code, 'RECOVERY_AMBIGUOUS'); + assert.equal(stub.pageCalls.length, 1); + assert.equal(stub.uiCalls.length, 0); +}); + +test('MAIN-world bridge builds only fixed Sheets requests and uses no supplied transport fields', async () => { + const previous = globalThis.gapi; + const calls = []; + globalThis.gapi = { + client: { + request(spec) { + calls.push(spec); + return Promise.resolve({ status: 200, result: { values: [['ok']] } }); + } + } + }; + try { + const out = await sessionModule.pageClientOperation({ + operation: 'getValues', + spreadsheetId: ID, + timeoutMs: 100, + args: { + range: 'Data!A1:B2', + valueRenderOption: 'FORMULA', + url: 'https://attacker.example', + method: 'DELETE', + headers: { Authorization: 'secret' } + } + }); + assert.equal(out.success, true); + assert.equal(calls.length, 1); + assert.equal(calls[0].method, 'GET'); + assert.match(calls[0].path, /^https:\/\/sheets\.googleapis\.com\/v4\/spreadsheets\//); + assert.match(calls[0].path, /Data%21A1%3AB2$/); + assert.equal(JSON.stringify(calls[0]).includes('attacker.example'), false); + assert.equal(JSON.stringify(calls[0]).includes('secret'), false); + } finally { + globalThis.gapi = previous; + } +}); + +test('session source has no OAuth, Chrome Identity, credential storage, or arbitrary network primitive', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../extension/utils/google-sheets-session.js'), 'utf8'); + for (const forbidden of [ + /chrome(?:Api)?\.identity/, + /getAuthToken/, + /oauth2/i, + /client_id/i, + /document\.cookie/, + /localStorage/, + /sessionStorage/, + /Authorization\s*:/, + /\bBearer\b/, + /\bfetch\s*\(/ + ]) { + assert.doesNotMatch(source, forbidden); + } +}); diff --git a/tests/google-sheets-wiring.test.js b/tests/google-sheets-wiring.test.js index f9cda1afd..4dbb52a46 100644 --- a/tests/google-sheets-wiring.test.js +++ b/tests/google-sheets-wiring.test.js @@ -18,26 +18,27 @@ function read(relative) { return fs.readFileSync(path.join(ROOT, relative), 'utf8'); } -test('manifest and background wire fail-closed Sheets OAuth and handler modules', () => { +test('manifest and background wire the signed-in Sheets session without OAuth surfaces', () => { const manifest = JSON.parse(read('extension/manifest.json')); - assert.ok(manifest.permissions.includes('identity')); - assert.ok(manifest.permissions.includes('identity.email')); - assert.deepEqual(manifest.oauth2.scopes, ['https://www.googleapis.com/auth/spreadsheets']); - assert.match(manifest.oauth2.client_id, /^REPLACE_/); + assert.equal(manifest.permissions.includes('identity'), false); + assert.equal(manifest.permissions.includes('identity.email'), false); + assert.equal(Object.prototype.hasOwnProperty.call(manifest, 'oauth2'), false); const background = read('extension/background.js'); - assert.match(background, /importScripts\('utils\/google-sheets-api\.js'\)/); + assert.match(background, /importScripts\('utils\/google-sheets-session\.js'\)/); assert.match(background, /importScripts\('catalog\/handlers\/gsheets\.js'\)/); - assert.match(background, /case 'google-sheets:connect'/); - assert.match(background, /!sender\.tab/); - assert.match(background, /getURL\('ui\/control_panel\.html'\)/); + assert.doesNotMatch(background, /google-sheets:(?:connect|disconnect|get-status)/); + assert.doesNotMatch(read('extension/ui/control_panel.html'), /googleSheetsConnectBtn|googleSheetsDisconnectBtn|googleSheetsConnectionCard/); + assert.doesNotMatch(read('extension/ui/options.js'), /connectGoogleSheets|disconnectGoogleSheets|refreshGoogleSheetsStatus/); }); test('router exposes only the five-operation Sheets facade to handlers', () => { const router = read('extension/utils/capability-router.js'); - assert.match(router, /googleSheets: _googleSheetsContext\(\)/); - const contextBody = router.match(/function _googleSheetsContext\(\) \{([\s\S]*?)\n \}/); + assert.match(router, /googleSheets: _googleSheetsContext\(ctx\)/); + const contextBody = router.match(/function _googleSheetsContext\(ctx\) \{([\s\S]*?)\n \}/); assert.ok(contextBody); + assert.match(contextBody[1], /FsbGoogleSheetsSession/); + assert.match(contextBody[1], /tabId: ctx && ctx\.tabId/); for (const method of ['getSpreadsheet', 'getValues', 'updateValues', 'appendValues', 'clearValues']) { assert.ok(contextBody[1].includes(`'${method}'`)); } @@ -75,6 +76,7 @@ test('canonical and packaged handler copies match and catalog seeds every slug', const entry = catalog.resolve(slug, 'https://docs.google.com'); assert.ok(entry); assert.equal(entry.tier, 'T1a'); + assert.equal(entry.handler.rotClassifiable, false); assert.equal(typeof entry.handler.handle, 'function'); } }); diff --git a/tests/gsheets-handler.test.js b/tests/gsheets-handler.test.js index 21aead3c2..53ddd740d 100644 --- a/tests/gsheets-handler.test.js +++ b/tests/gsheets-handler.test.js @@ -50,7 +50,7 @@ test('derives a spreadsheet ID only from the active Google Sheets URL', async () assert.deepEqual(calls, [{ method: 'getSpreadsheet', params: { spreadsheetId: ID } }]); const wrongOrigin = await handlers['gsheets.get_spreadsheet'].handle({}, context([], `https://evil.example/spreadsheets/d/${ID}/edit`)); - assert.equal(wrongOrigin.code, 'GOOGLE_SHEETS_SPREADSHEET_REQUIRED'); + assert.equal(wrongOrigin.code, 'GOOGLE_SHEETS_ACTIVE_TAB_REQUIRED'); }); test('routes read operations to exactly one narrow client method', async () => { @@ -86,10 +86,10 @@ test('write and destructive operations are runtime guarded until live UAT activa } }); -test('explicit spreadsheet ID wins and arbitrary transport fields are never forwarded', async () => { +test('matching explicit spreadsheet ID is accepted and arbitrary transport fields are never forwarded', async () => { const calls = []; await handlers['gsheets.get_values'].handle({ - spreadsheetId: 'explicitSpreadsheetId1234567890', + spreadsheetId: ID, range: 'A1', url: 'https://attacker.example', method: 'DELETE', @@ -97,17 +97,23 @@ test('explicit spreadsheet ID wins and arbitrary transport fields are never forw token: 'secret' }, context(calls)); assert.deepEqual(calls[0].params, { - spreadsheetId: 'explicitSpreadsheetId1234567890', + spreadsheetId: ID, range: 'A1', majorDimension: undefined, valueRenderOption: undefined, dateTimeRenderOption: undefined }); + + const mismatch = await handlers['gsheets.get_values'].handle({ + spreadsheetId: 'explicitSpreadsheetId1234567890', + range: 'A1' + }, context([])); + assert.equal(mismatch.code, 'GOOGLE_SHEETS_TARGET_MISMATCH'); }); -test('fails closed when the API facade or spreadsheet target is unavailable', async () => { +test('fails closed when the session facade or active spreadsheet target is unavailable', async () => { const noClient = await handlers['gsheets.get_values'].handle({ spreadsheetId: ID, range: 'A1' }, {}); const noTarget = await handlers['gsheets.get_values'].handle({ range: 'A1' }, { googleSheets: { getValues() {} } }); - assert.equal(noClient.code, 'GOOGLE_SHEETS_API_UNAVAILABLE'); - assert.equal(noTarget.code, 'GOOGLE_SHEETS_SPREADSHEET_REQUIRED'); + assert.equal(noClient.code, 'GOOGLE_SHEETS_SESSION_UNAVAILABLE'); + assert.equal(noTarget.code, 'GOOGLE_SHEETS_ACTIVE_TAB_REQUIRED'); }); From 7895fced2e57b4505e4743eeb96a60e7c53957e8 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Wed, 15 Jul 2026 13:34:24 -0500 Subject: [PATCH 16/26] feat(sheets): add bounded signed-in UI fallback --- .../descriptors/gsheets__append_values.json | 4 +- .../descriptors/gsheets__clear_values.json | 4 +- .../descriptors/gsheets__get_spreadsheet.json | 4 +- catalog/descriptors/gsheets__get_values.json | 4 +- .../descriptors/gsheets__update_values.json | 4 +- extension/background.js | 1 + extension/catalog/recipe-index.generated.js | 20 +- extension/content/actions.js | 347 ++++++++++++++++++ extension/content/messaging.js | 10 +- extension/utils/google-sheets-session.js | 14 + extension/utils/google-sheets-ui.js | 267 ++++++++++++++ extension/ws/ws-client.js | 1 + scripts/verify-origin-classification.mjs | 220 +++++++---- tests/capability-router.test.js | 63 ++++ ...-content-script-files-completeness.test.js | 1 + tests/google-sheets-content-actions.test.js | 101 +++++ tests/google-sheets-session.test.js | 59 +++ tests/lattice-provider-bridge-smoke.test.js | 8 +- tests/verify-origin-classification.test.js | 56 ++- 19 files changed, 1098 insertions(+), 90 deletions(-) create mode 100644 extension/utils/google-sheets-ui.js create mode 100644 tests/google-sheets-content-actions.test.js diff --git a/catalog/descriptors/gsheets__append_values.json b/catalog/descriptors/gsheets__append_values.json index 3ac4843ac..f5f561d61 100644 --- a/catalog/descriptors/gsheets__append_values.json +++ b/catalog/descriptors/gsheets__append_values.json @@ -8,7 +8,7 @@ "params": { "type": "object", "properties": { - "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." }, + "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", "description": "Spreadsheet ID. When provided, it must match the spreadsheet open in the agent-owned Google Sheets tab." }, "range": { "type": "string", "minLength": 1, "maxLength": 500, "description": "A1 notation range." }, "values": { "type": "array", "minItems": 1, "maxItems": 10000, "items": { "type": "array", "maxItems": 10000, "items": { "anyOf": [{ "type": "string" }, { "type": "number" }, { "type": "boolean" }, { "type": "null" }] } }, "description": "Two-dimensional rows of JSON scalar cell values." }, "valueInputOption": { "type": "string", "enum": ["RAW", "USER_ENTERED"] }, @@ -18,5 +18,5 @@ "additionalProperties": false }, "backing": "handler", - "provenance": { "source": "fsb", "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { "transportHelper": "chrome-identity-sheets-v4", "httpMethod": "POST", "opNameVerb": "append" } } + "provenance": { "source": "fsb", "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { "transportHelper": "page-gapi-ui-sheets-session", "httpMethod": "POST", "opNameVerb": "append" } } } diff --git a/catalog/descriptors/gsheets__clear_values.json b/catalog/descriptors/gsheets__clear_values.json index 8c192beb4..1bf86acf9 100644 --- a/catalog/descriptors/gsheets__clear_values.json +++ b/catalog/descriptors/gsheets__clear_values.json @@ -8,12 +8,12 @@ "params": { "type": "object", "properties": { - "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." }, + "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", "description": "Spreadsheet ID. When provided, it must match the spreadsheet open in the agent-owned Google Sheets tab." }, "range": { "type": "string", "minLength": 1, "maxLength": 500, "description": "A1 notation range." } }, "required": ["range"], "additionalProperties": false }, "backing": "handler", - "provenance": { "source": "fsb", "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { "transportHelper": "chrome-identity-sheets-v4", "httpMethod": "POST", "opNameVerb": "clear" } } + "provenance": { "source": "fsb", "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { "transportHelper": "page-gapi-ui-sheets-session", "httpMethod": "POST", "opNameVerb": "clear" } } } diff --git a/catalog/descriptors/gsheets__get_spreadsheet.json b/catalog/descriptors/gsheets__get_spreadsheet.json index e1b8af5cc..228ec1d47 100644 --- a/catalog/descriptors/gsheets__get_spreadsheet.json +++ b/catalog/descriptors/gsheets__get_spreadsheet.json @@ -8,10 +8,10 @@ "params": { "type": "object", "properties": { - "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." } + "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", "description": "Spreadsheet ID. When provided, it must match the spreadsheet open in the agent-owned Google Sheets tab." } }, "additionalProperties": false }, "backing": "handler", - "provenance": { "source": "fsb", "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { "transportHelper": "chrome-identity-sheets-v4", "httpMethod": "GET", "opNameVerb": "get" } } + "provenance": { "source": "fsb", "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { "transportHelper": "page-gapi-ui-sheets-session", "httpMethod": "GET", "opNameVerb": "get" } } } diff --git a/catalog/descriptors/gsheets__get_values.json b/catalog/descriptors/gsheets__get_values.json index 0d512ef47..20c624235 100644 --- a/catalog/descriptors/gsheets__get_values.json +++ b/catalog/descriptors/gsheets__get_values.json @@ -8,7 +8,7 @@ "params": { "type": "object", "properties": { - "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." }, + "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", "description": "Spreadsheet ID. When provided, it must match the spreadsheet open in the agent-owned Google Sheets tab." }, "range": { "type": "string", "minLength": 1, "maxLength": 500, "description": "A1 notation range." }, "majorDimension": { "type": "string", "enum": ["ROWS", "COLUMNS"] }, "valueRenderOption": { "type": "string", "enum": ["FORMATTED_VALUE", "UNFORMATTED_VALUE", "FORMULA"] }, @@ -18,5 +18,5 @@ "additionalProperties": false }, "backing": "handler", - "provenance": { "source": "fsb", "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { "transportHelper": "chrome-identity-sheets-v4", "httpMethod": "GET", "opNameVerb": "get" } } + "provenance": { "source": "fsb", "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { "transportHelper": "page-gapi-ui-sheets-session", "httpMethod": "GET", "opNameVerb": "get" } } } diff --git a/catalog/descriptors/gsheets__update_values.json b/catalog/descriptors/gsheets__update_values.json index e814e228a..7b569dfb6 100644 --- a/catalog/descriptors/gsheets__update_values.json +++ b/catalog/descriptors/gsheets__update_values.json @@ -8,7 +8,7 @@ "params": { "type": "object", "properties": { - "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." }, + "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", "description": "Spreadsheet ID. When provided, it must match the spreadsheet open in the agent-owned Google Sheets tab." }, "range": { "type": "string", "minLength": 1, "maxLength": 500, "description": "A1 notation range." }, "values": { "type": "array", "minItems": 1, "maxItems": 10000, "items": { "type": "array", "maxItems": 10000, "items": { "anyOf": [{ "type": "string" }, { "type": "number" }, { "type": "boolean" }, { "type": "null" }] } }, "description": "Two-dimensional rows of JSON scalar cell values." }, "valueInputOption": { "type": "string", "enum": ["RAW", "USER_ENTERED"] } @@ -17,5 +17,5 @@ "additionalProperties": false }, "backing": "handler", - "provenance": { "source": "fsb", "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { "transportHelper": "chrome-identity-sheets-v4", "httpMethod": "PUT", "opNameVerb": "update" } } + "provenance": { "source": "fsb", "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { "transportHelper": "page-gapi-ui-sheets-session", "httpMethod": "PUT", "opNameVerb": "update" } } } diff --git a/extension/background.js b/extension/background.js index bcc41177c..0ceb3522d 100644 --- a/extension/background.js +++ b/extension/background.js @@ -590,6 +590,7 @@ const CONTENT_SCRIPT_FILES = [ 'content/dom-stream.js', 'content/trigger-observe.js', 'content/accessibility.js', + 'utils/google-sheets-ui.js', 'content/actions.js', 'content/dom-analysis.js', 'content/messaging.js', diff --git a/extension/catalog/recipe-index.generated.js b/extension/catalog/recipe-index.generated.js index 694e9391a..e8ba0863d 100644 --- a/extension/catalog/recipe-index.generated.js +++ b/extension/catalog/recipe-index.generated.js @@ -178,7 +178,7 @@ "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", - "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." + "description": "Spreadsheet ID. When provided, it must match the spreadsheet open in the agent-owned Google Sheets tab." }, "range": { "type": "string", @@ -239,7 +239,7 @@ "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { - "transportHelper": "chrome-identity-sheets-v4", + "transportHelper": "page-gapi-ui-sheets-session", "httpMethod": "POST", "opNameVerb": "append" } @@ -262,7 +262,7 @@ "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", - "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." + "description": "Spreadsheet ID. When provided, it must match the spreadsheet open in the agent-owned Google Sheets tab." }, "range": { "type": "string", @@ -282,7 +282,7 @@ "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { - "transportHelper": "chrome-identity-sheets-v4", + "transportHelper": "page-gapi-ui-sheets-session", "httpMethod": "POST", "opNameVerb": "clear" } @@ -305,7 +305,7 @@ "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", - "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." + "description": "Spreadsheet ID. When provided, it must match the spreadsheet open in the agent-owned Google Sheets tab." } }, "additionalProperties": false @@ -316,7 +316,7 @@ "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { - "transportHelper": "chrome-identity-sheets-v4", + "transportHelper": "page-gapi-ui-sheets-session", "httpMethod": "GET", "opNameVerb": "get" } @@ -339,7 +339,7 @@ "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", - "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." + "description": "Spreadsheet ID. When provided, it must match the spreadsheet open in the agent-owned Google Sheets tab." }, "range": { "type": "string", @@ -381,7 +381,7 @@ "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { - "transportHelper": "chrome-identity-sheets-v4", + "transportHelper": "page-gapi-ui-sheets-session", "httpMethod": "GET", "opNameVerb": "get" } @@ -404,7 +404,7 @@ "spreadsheetId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{10,200}$", - "description": "Spreadsheet ID. Defaults to the spreadsheet open in the active Google Sheets tab." + "description": "Spreadsheet ID. When provided, it must match the spreadsheet open in the agent-owned Google Sheets tab." }, "range": { "type": "string", @@ -458,7 +458,7 @@ "sourcePath": "catalog/handlers/gsheets.js", "license": "Apache-2.0", "signals": { - "transportHelper": "chrome-identity-sheets-v4", + "transportHelper": "page-gapi-ui-sheets-session", "httpMethod": "PUT", "opNameVerb": "update" } diff --git a/extension/content/actions.js b/extension/content/actions.js index c31e94138..6f97ca928 100644 --- a/extension/content/actions.js +++ b/extension/content/actions.js @@ -1520,6 +1520,295 @@ function detectSiteSearchInput() { return null; } +// Google Sheets signed-in-tab UI transport. Pure range/value shaping lives in +// FsbGoogleSheetsUi; these helpers own only the fixed, trusted page gestures. +const sheetsUi = globalThis.FsbGoogleSheetsUi || null; + +function sheetsError(code, reason, mutationStarted = false) { + return { + success: false, + code, + errorCode: code, + error: code, + reason, + mutationStarted: mutationStarted === true + }; +} + +function isGoogleSheetsPage() { + return window.location.hostname === 'docs.google.com' && + /^\/spreadsheets\/d\/[A-Za-z0-9_-]{10,200}(?:\/|$)/.test(window.location.pathname); +} + +function sheetsSpreadsheetId() { + const match = window.location.pathname.match(/^\/spreadsheets\/d\/([A-Za-z0-9_-]{10,200})(?:\/|$)/); + return match ? match[1] : ''; +} + +function sheetsDelay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function sheetsNameBox() { + return document.querySelector('#t-name-box'); +} + +async function sheetsNavigate(reference, settleMs = 80) { + const nameBox = sheetsNameBox(); + if (!nameBox) return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'name-box-unavailable'); + try { + nameBox.focus(); + nameBox.click(); + await sheetsDelay(25); + await tools.keyPress({ key: 'a', ctrlKey: true, useDebuggerAPI: true }); + await tools.typeWithKeys({ text: String(reference), clearFirst: false, delay: 5 }); + await tools.keyPress({ key: 'Enter', useDebuggerAPI: true }); + await sheetsDelay(settleMs); + return { success: true }; + } catch (_error) { + return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'name-box-navigation-failed'); + } +} + +function sheetsFormulaBarValue() { + const selectors = ['#t-formula-bar-input', '.cell-input', '[aria-label="Formula bar"]']; + for (const selector of selectors) { + const element = document.querySelector(selector); + if (!element) continue; + const editable = element.querySelector?.('[contenteditable="true"]'); + const candidate = editable || element; + const value = candidate.value !== undefined ? candidate.value : (candidate.innerText ?? candidate.textContent ?? ''); + if (value !== undefined && value !== null) return String(value).replace(/\u200B/g, ''); + } + return ''; +} + +function sheetsActiveAddress() { + const box = sheetsNameBox(); + return box ? String(box.value || box.textContent || '').trim() : ''; +} + +async function sheetsReadValues(range, majorDimension) { + if (!sheetsUi) return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'sheets-ui-helper-unavailable'); + const parsed = sheetsUi.parseA1Range(range); + if (!parsed || parsed.columnOnly) return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-read-requires-bounded-a1-range'); + if (parsed.rows > sheetsUi.limits.maxReadRows || parsed.columns > sheetsUi.limits.maxReadColumns) { + return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-read-range-limit-exceeded'); + } + try { + await tools.keyPress({ key: 'Escape', useDebuggerAPI: true }); + const values = []; + for (let row = parsed.startRow; row <= parsed.endRow; row++) { + const outputRow = []; + for (let column = parsed.startColumn; column <= parsed.endColumn; column++) { + const navigation = await sheetsNavigate(sheetsUi.cellReference(parsed, column, row), 55); + if (!navigation.success) return navigation; + outputRow.push(sheetsFormulaBarValue()); + } + values.push(outputRow); + } + const dimension = majorDimension === 'COLUMNS' ? 'COLUMNS' : 'ROWS'; + return { + success: true, + status: 200, + transport: 'ui', + renderSemantics: 'formula-bar', + data: { + range, + majorDimension: dimension, + values: dimension === 'COLUMNS' ? sheetsUi.transpose(values) : values + } + }; + } catch (_error) { + return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'ui-read-failed'); + } +} + +async function sheetsWriteClipboard(text) { + try { + if (!navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') { + return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'clipboard-write-unavailable'); + } + await navigator.clipboard.writeText(text); + return { success: true }; + } catch (_error) { + return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'clipboard-write-failed'); + } +} + +async function sheetsPasteEncoded(parsed, encoded, startRow) { + let completedChunks = 0; + let mutationStarted = false; + for (const chunk of encoded.chunks) { + const clipboard = await sheetsWriteClipboard(chunk.text); + if (!clipboard.success) { + return completedChunks > 0 + ? sheetsError('RECOVERY_AMBIGUOUS', 'ui-paste-partially-completed', true) + : clipboard; + } + const target = sheetsUi.cellReference(parsed, parsed.startColumn, startRow + chunk.rowOffset); + const navigation = await sheetsNavigate(target, 60); + if (!navigation.success) { + return completedChunks > 0 + ? sheetsError('RECOVERY_AMBIGUOUS', 'ui-paste-target-lost', true) + : navigation; + } + mutationStarted = true; + try { + const pasted = await tools.keyPress({ key: 'v', ctrlKey: true, useDebuggerAPI: true }); + if (!pasted || pasted.success !== true) { + return sheetsError('RECOVERY_AMBIGUOUS', 'ui-paste-outcome-unknown', mutationStarted); + } + await sheetsDelay(180); + completedChunks++; + } catch (_error) { + return sheetsError('RECOVERY_AMBIGUOUS', 'ui-paste-outcome-unknown', mutationStarted); + } + } + return { + success: true, + status: 200, + transport: 'ui', + updatedRows: encoded.rows, + updatedColumns: encoded.columns, + updatedCells: encoded.cells, + chunks: completedChunks, + mutationStarted + }; +} + +async function sheetsUpdateValues(range, values, valueInputOption) { + if (!sheetsUi) return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'sheets-ui-helper-unavailable'); + const parsed = sheetsUi.parseA1Range(range); + if (!parsed || parsed.columnOnly) return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-update-requires-a1-start-cell'); + const encoded = sheetsUi.encodeValues(values, valueInputOption || 'RAW'); + if (!encoded.success) return sheetsError('RECIPE_DOM_FALLBACK_PENDING', encoded.reason); + return sheetsPasteEncoded(parsed, encoded, parsed.startRow); +} + +async function sheetsFindAppendRow(parsed) { + const firstCell = sheetsUi.cellReference(parsed, parsed.startColumn, parsed.startRow || 1); + const navigation = await sheetsNavigate(firstCell, 70); + if (!navigation.success) return navigation; + const startValue = sheetsFormulaBarValue(); + if (startValue === '') return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-append-boundary-ambiguous'); + try { + await tools.keyPress({ key: 'ArrowDown', ctrlKey: true, useDebuggerAPI: true }); + await sheetsDelay(90); + } catch (_error) { + return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-append-boundary-navigation-failed'); + } + const boundary = sheetsUi.appendRowFromBoundary( + parsed, + startValue, + sheetsActiveAddress(), + sheetsFormulaBarValue() + ); + return boundary.success + ? boundary + : sheetsError('RECIPE_DOM_FALLBACK_PENDING', boundary.reason); +} + +async function sheetsAppendValues(range, values, valueInputOption, insertDataOption) { + if (!sheetsUi) return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'sheets-ui-helper-unavailable'); + const parsed = sheetsUi.parseA1Range(range); + if (!parsed) return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-append-requires-a1-range'); + const encoded = sheetsUi.encodeValues(values, valueInputOption || 'RAW'); + if (!encoded.success) return sheetsError('RECIPE_DOM_FALLBACK_PENDING', encoded.reason); + const boundary = await sheetsFindAppendRow(parsed); + if (!boundary.success) return boundary; + let mutationStarted = false; + if ((insertDataOption || 'OVERWRITE') === 'INSERT_ROWS') { + const lastRow = boundary.row + encoded.rows - 1; + const selected = await sheetsNavigate(`${parsed.sheetPrefix}${boundary.row}:${lastRow}`, 60); + if (!selected.success) return selected; + mutationStarted = true; + try { + const inserted = await tools.keyPress({ key: '=', ctrlKey: true, altKey: true, useDebuggerAPI: true }); + if (!inserted || inserted.success !== true) return sheetsError('RECOVERY_AMBIGUOUS', 'ui-row-insert-outcome-unknown', true); + await sheetsDelay(150); + } catch (_error) { + return sheetsError('RECOVERY_AMBIGUOUS', 'ui-row-insert-outcome-unknown', true); + } + } + const pasted = await sheetsPasteEncoded(parsed, encoded, boundary.row); + if (!pasted.success && mutationStarted && !pasted.mutationStarted) { + return sheetsError('RECOVERY_AMBIGUOUS', 'ui-append-partially-completed', true); + } + if (pasted.success) pasted.appendedRows = encoded.rows; + return pasted; +} + +async function sheetsClearValues(range) { + if (!sheetsUi) return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'sheets-ui-helper-unavailable'); + const parsed = sheetsUi.parseA1Range(range); + if (!parsed || parsed.columnOnly) return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-clear-requires-bounded-a1-range'); + const cells = parsed.rows * parsed.columns; + if (cells > sheetsUi.limits.maxClearCells) return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-clear-verification-limit-exceeded'); + const selected = await sheetsNavigate(range, 70); + if (!selected.success) return selected; + try { + const deleted = await tools.keyPress({ key: 'Delete', useDebuggerAPI: true }); + if (!deleted || deleted.success !== true) return sheetsError('RECOVERY_AMBIGUOUS', 'ui-clear-outcome-unknown', true); + await sheetsDelay(160); + } catch (_error) { + return sheetsError('RECOVERY_AMBIGUOUS', 'ui-clear-outcome-unknown', true); + } + const verification = await sheetsReadValues(range, 'ROWS'); + if (!verification.success) return sheetsError('RECOVERY_AMBIGUOUS', 'ui-clear-verification-failed', true); + if (!sheetsUi.valuesAreEmpty(verification.data.values)) { + return sheetsError('RECOVERY_AMBIGUOUS', 'ui-clear-verification-mismatch', true); + } + return { + success: true, + status: 200, + transport: 'ui', + clearedCells: cells, + mutationStarted: true, + verified: true + }; +} + +function sheetsSpreadsheetMetadata() { + const titleInput = document.querySelector('.docs-title-input, #docs-title-input, .docs-title-widget input'); + const title = String(titleInput?.value || document.title || '').replace(/\s+-\s+Google Sheets\s*$/i, ''); + const tabNodes = Array.from(document.querySelectorAll('.docs-sheet-tab-name, .docs-sheet-tab [role="tab"], [role="tab"][aria-label]')); + const seen = new Set(); + const sheets = []; + for (const node of tabNodes) { + const sheetTitle = String(node.textContent || node.getAttribute?.('aria-label') || '').trim(); + if (!sheetTitle || seen.has(sheetTitle)) continue; + seen.add(sheetTitle); + sheets.push({ properties: { title: sheetTitle, index: sheets.length } }); + } + return { + success: true, + status: 200, + transport: 'ui', + data: { + spreadsheetId: sheetsSpreadsheetId(), + properties: { title }, + sheets + } + }; +} + +async function sheetsRenameSpreadsheet(title) { + if (!title) return; + const titleInput = document.querySelector('.docs-title-input, #docs-title-input, input[aria-label*="Rename" i], .docs-title-widget input'); + if (!titleInput) return; + try { + titleInput.focus(); + titleInput.click(); + await sheetsDelay(80); + if (typeof titleInput.select === 'function') titleInput.select(); + else await tools.keyPress({ key: 'a', ctrlKey: true, useDebuggerAPI: true }); + await tools.typeWithKeys({ text: String(title), clearFirst: false, delay: 8 }); + await tools.keyPress({ key: 'Enter', useDebuggerAPI: true }); + await sheetsDelay(180); + } catch (_error) { /* legacy rename remains best-effort */ } +} + // Tool functions for browser automation const tools = { // Scroll the page - supports direction ("up"/"down") or raw amount @@ -4732,6 +5021,26 @@ const tools = { arrowLeft: async (params = {}) => { return await tools.keyPress({ key: 'ArrowLeft', useDebuggerAPI: true, ...params }); }, arrowRight: async (params = {}) => { return await tools.keyPress({ key: 'ArrowRight', useDebuggerAPI: true, ...params }); }, + // Fixed Google Sheets signed-in-tab operation surface used by the capability + // session adapter. It intentionally accepts operation keys, never requests. + sheetsSession: async (params = {}) => { + if (!isGoogleSheetsPage()) return sheetsError('GOOGLE_SHEETS_ACTIVE_TAB_REQUIRED', 'active-sheets-tab-required'); + const activeId = sheetsSpreadsheetId(); + if (params.spreadsheetId && params.spreadsheetId !== activeId) { + return sheetsError('GOOGLE_SHEETS_TARGET_MISMATCH', 'active-spreadsheet-id-mismatch'); + } + const operation = String(params.operation || ''); + const args = params.args && typeof params.args === 'object' ? params.args : {}; + if (operation === 'getSpreadsheet') return sheetsSpreadsheetMetadata(); + if (operation === 'getValues') return sheetsReadValues(args.range, args.majorDimension); + if (operation === 'updateValues') return sheetsUpdateValues(args.range, args.values, args.valueInputOption); + if (operation === 'appendValues') { + return sheetsAppendValues(args.range, args.values, args.valueInputOption, args.insertDataOption); + } + if (operation === 'clearValues') return sheetsClearValues(args.range); + return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'unsupported-sheets-ui-operation'); + }, + // ========================================================================= // GOOGLE SHEETS: fillsheet — mechanical CSV data entry // AI generates data, this tool handles the cell-by-cell typing deterministically @@ -4750,6 +5059,28 @@ const tools = { return { success: false, error: 'fillsheet only works on Google Sheets (canvas-based editor not detected)' }; } + // The shared signed-in-tab path uses bounded clipboard chunks. Keep the + // historical cell-by-cell implementation below as a compatibility fallback + // for partial/older content-script injection sets where the helper is absent. + if (sheetsUi) { + const sharedRows = sheetsUi.csvToValues(data); + if (!sharedRows.length) return { success: false, error: 'No data rows parsed from CSV' }; + await sheetsRenameSpreadsheet(sheetName); + const sharedResult = await sheetsUpdateValues(startCell, sharedRows, 'RAW'); + if (!sharedResult.success) return { ...sharedResult, action: 'fillsheet' }; + return { + success: true, + action: 'fillsheet', + startCell, + rows: sharedRows.length, + cols: sharedRows[0].length, + cellsFilled: sharedResult.updatedCells, + chunks: sharedResult.chunks, + transport: 'ui', + hadEffect: true + }; + } + const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); // Parse CSV with basic quoting support @@ -4972,6 +5303,22 @@ const tools = { return { success: false, error: 'readsheet only works on Google Sheets' }; } + if (sheetsUi) { + const sharedResult = await sheetsReadValues(range, 'ROWS'); + if (!sharedResult.success) return { ...sharedResult, action: 'readsheet' }; + return { + success: true, + action: 'readsheet', + range, + rows: sharedResult.data.values.length, + cols: sharedResult.data.values[0]?.length || 0, + data: sheetsUi.valuesToCsv(sharedResult.data.values), + transport: 'ui', + renderSemantics: 'formula-bar', + hadEffect: false + }; + } + const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); // Parse range like "A1:C5" into start/end diff --git a/extension/content/messaging.js b/extension/content/messaging.js index 898d36519..57ff1e587 100644 --- a/extension/content/messaging.js +++ b/extension/content/messaging.js @@ -886,7 +886,13 @@ case 'executeAction': const { tool, params, visualContext, source } = request; const isManualMCP = source === 'mcp-manual'; - const SENSITIVE_TOOLS = new Set(['fillCredentialFields', 'fillPaymentFields']); + const SENSITIVE_TOOLS = new Set([ + 'fillCredentialFields', + 'fillPaymentFields', + 'fillsheet', + 'readsheet', + 'sheetsSession' + ]); const safeParams = SENSITIVE_TOOLS.has(tool) ? '***' : params; logger.logActionExecution(FSB.sessionId, tool, 'start', safeParams); @@ -988,7 +994,7 @@ } // Timeout wrapper - const longTimeoutTools = ['solveCaptcha', 'fillsheet', 'readsheet']; + const longTimeoutTools = ['solveCaptcha', 'fillsheet', 'readsheet', 'sheetsSession']; const actionTimeout = longTimeoutTools.includes(tool) ? 120000 : 10000; const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error(`Action ${tool} timed out after ${actionTimeout / 1000} seconds`)), actionTimeout); diff --git a/extension/utils/google-sheets-session.js b/extension/utils/google-sheets-session.js index 16b738108..76c007dbb 100644 --- a/extension/utils/google-sheets-session.js +++ b/extension/utils/google-sheets-session.js @@ -156,6 +156,13 @@ knownNoEffect: true }); } + if (mutating && status >= 500 && status <= 599) { + return error('RECOVERY_AMBIGUOUS', { + reason: 'page-gapi-server-failure', + status: status, + requestSent: true + }); + } if (mutating && !status) { return error('RECOVERY_AMBIGUOUS', { reason: 'page-gapi-unknown-failure', requestSent: true }); } @@ -178,6 +185,13 @@ knownNoEffect: true }); } + if (mutating && responseStatus >= 500 && responseStatus <= 599) { + return error('RECOVERY_AMBIGUOUS', { + reason: 'page-gapi-server-failure', + status: responseStatus, + requestSent: true + }); + } return error('RECIPE_DOM_FALLBACK_PENDING', { reason: 'page-gapi-request-rejected', status: responseStatus, diff --git a/extension/utils/google-sheets-ui.js b/extension/utils/google-sheets-ui.js new file mode 100644 index 000000000..aec1fd96e --- /dev/null +++ b/extension/utils/google-sheets-ui.js @@ -0,0 +1,267 @@ +(function (global) { + 'use strict'; + + var MAX_READ_ROWS = 50; + var MAX_READ_COLUMNS = 26; + var MAX_UI_CELLS = 10000; + var MAX_CLEAR_CELLS = 100; + var MAX_CHUNK_CELLS = 5000; + var MAX_TOTAL_BYTES = 1024 * 1024; + var MAX_CHUNK_BYTES = 256 * 1024; + + function columnToNumber(column) { + var value = String(column || '').toUpperCase(); + if (!/^[A-Z]+$/.test(value)) { return 0; } + var number = 0; + for (var i = 0; i < value.length; i++) { + number = number * 26 + value.charCodeAt(i) - 64; + } + return number; + } + + function numberToColumn(number) { + number = Number(number); + if (!Number.isInteger(number) || number < 1) { return ''; } + var out = ''; + while (number > 0) { + var remainder = (number - 1) % 26; + out = String.fromCharCode(65 + remainder) + out; + number = Math.floor((number - 1) / 26); + } + return out; + } + + function splitSheetPrefix(value) { + var input = String(value || '').trim(); + var bang = -1; + var quoted = false; + for (var i = 0; i < input.length; i++) { + if (input[i] === "'") { + if (quoted && input[i + 1] === "'") { i++; continue; } + quoted = !quoted; + } else if (input[i] === '!' && !quoted) { + bang = i; + } + } + return bang === -1 + ? { sheetPrefix: '', address: input } + : { sheetPrefix: input.slice(0, bang + 1), address: input.slice(bang + 1) }; + } + + function parseA1Range(value) { + var parts = splitSheetPrefix(value); + var address = parts.address.replace(/\$/g, ''); + var cellMatch = address.match(/^([A-Za-z]+)(\d+)(?::([A-Za-z]+)(\d+))?$/); + if (cellMatch) { + var startColumn = columnToNumber(cellMatch[1]); + var endColumn = columnToNumber(cellMatch[3] || cellMatch[1]); + var startRow = Number(cellMatch[2]); + var endRow = Number(cellMatch[4] || cellMatch[2]); + if (!startColumn || !endColumn || startRow < 1 || endRow < startRow || endColumn < startColumn) { return null; } + return { + sheetPrefix: parts.sheetPrefix, + startColumn: startColumn, + endColumn: endColumn, + startRow: startRow, + endRow: endRow, + rows: endRow - startRow + 1, + columns: endColumn - startColumn + 1, + columnOnly: false + }; + } + var columnMatch = address.match(/^([A-Za-z]+)(?::([A-Za-z]+))?$/); + if (!columnMatch) { return null; } + var firstColumn = columnToNumber(columnMatch[1]); + var lastColumn = columnToNumber(columnMatch[2] || columnMatch[1]); + if (!firstColumn || lastColumn < firstColumn) { return null; } + return { + sheetPrefix: parts.sheetPrefix, + startColumn: firstColumn, + endColumn: lastColumn, + startRow: 1, + endRow: null, + rows: null, + columns: lastColumn - firstColumn + 1, + columnOnly: true + }; + } + + function cellReference(parsed, column, row) { + return (parsed && parsed.sheetPrefix || '') + numberToColumn(column) + String(row); + } + + function byteLength(value) { + var text = String(value || ''); + if (typeof TextEncoder !== 'undefined') { return new TextEncoder().encode(text).byteLength; } + return unescape(encodeURIComponent(text)).length; + } + + function normalizeCell(value, valueInputOption) { + if (value === null || value === undefined) { return { error: 'null-values-not-lossless' }; } + if (typeof value === 'number') { + return Number.isFinite(value) ? { value: String(value) } : { error: 'non-finite-number' }; + } + if (typeof value === 'boolean') { return { value: value ? 'TRUE' : 'FALSE' }; } + if (typeof value !== 'string') { return { error: 'unsupported-cell-type' }; } + if (/[\t\r\n]/.test(value)) { return { error: 'multiline-or-tab-cell-not-lossless' }; } + if ((valueInputOption || 'RAW') === 'RAW' && value !== '') { + // Pasting bare strings lets Sheets coerce values such as 001, TRUE, and + // dates. A leading apostrophe is Sheets' literal-text marker; doubling an + // existing leading apostrophe preserves that character too. + return { value: "'" + value }; + } + return { value: value }; + } + + function encodeValues(values, valueInputOption) { + if (!Array.isArray(values) || values.length === 0 || !Array.isArray(values[0]) || values[0].length === 0) { + return { success: false, reason: 'values-must-be-a-non-empty-matrix' }; + } + var columns = values[0].length; + var totalCells = values.length * columns; + if (totalCells > MAX_UI_CELLS) { return { success: false, reason: 'ui-cell-limit-exceeded' }; } + var encodedRows = []; + for (var rowIndex = 0; rowIndex < values.length; rowIndex++) { + var row = values[rowIndex]; + if (!Array.isArray(row) || row.length !== columns) { + return { success: false, reason: 'ragged-values-not-lossless' }; + } + var encodedRow = []; + for (var columnIndex = 0; columnIndex < row.length; columnIndex++) { + var normalized = normalizeCell(row[columnIndex], valueInputOption); + if (normalized.error) { return { success: false, reason: normalized.error }; } + encodedRow.push(normalized.value); + } + encodedRows.push(encodedRow.join('\t')); + } + var allText = encodedRows.join('\n'); + if (byteLength(allText) > MAX_TOTAL_BYTES) { return { success: false, reason: 'ui-payload-limit-exceeded' }; } + + var chunks = []; + var currentRows = []; + var currentCells = 0; + var currentBytes = 0; + var rowOffset = 0; + for (var i = 0; i < encodedRows.length; i++) { + var rowText = encodedRows[i]; + var rowBytes = byteLength(rowText) + (currentRows.length ? 1 : 0); + if (columns > MAX_CHUNK_CELLS || rowBytes > MAX_CHUNK_BYTES) { + return { success: false, reason: 'ui-row-limit-exceeded' }; + } + if (currentRows.length && (currentCells + columns > MAX_CHUNK_CELLS || currentBytes + rowBytes > MAX_CHUNK_BYTES)) { + chunks.push({ rowOffset: rowOffset, rows: currentRows.length, columns: columns, text: currentRows.join('\n') }); + rowOffset += currentRows.length; + currentRows = []; + currentCells = 0; + currentBytes = 0; + rowBytes = byteLength(rowText); + } + currentRows.push(rowText); + currentCells += columns; + currentBytes += rowBytes; + } + if (currentRows.length) { + chunks.push({ rowOffset: rowOffset, rows: currentRows.length, columns: columns, text: currentRows.join('\n') }); + } + return { + success: true, + rows: values.length, + columns: columns, + cells: totalCells, + chunks: chunks + }; + } + + function transpose(values) { + if (!Array.isArray(values) || !values.length) { return []; } + var width = values.reduce(function (max, row) { return Math.max(max, Array.isArray(row) ? row.length : 0); }, 0); + var output = []; + for (var column = 0; column < width; column++) { + var next = []; + for (var row = 0; row < values.length; row++) { next.push(values[row] && values[row][column] !== undefined ? values[row][column] : ''); } + output.push(next); + } + return output; + } + + function appendRowFromBoundary(parsed, startValue, activeAddress, activeValue) { + if (!parsed || String(startValue || '') === '') { + return { success: false, reason: 'ui-append-boundary-ambiguous' }; + } + var active = parseA1Range(activeAddress); + if (!active || active.columnOnly || active.startColumn !== parsed.startColumn || + active.startRow < (parsed.startRow || 1) || active.startRow > 100000 || + String(activeValue || '') === '') { + return { success: false, reason: 'ui-append-boundary-ambiguous' }; + } + return { success: true, row: active.startRow + 1 }; + } + + function valuesAreEmpty(values) { + return Array.isArray(values) && values.every(function (row) { + return Array.isArray(row) && row.every(function (value) { + return value === '' || value === null || value === undefined; + }); + }); + } + + function csvToValues(csvText) { + var input = String(csvText || ''); + var rows = []; + var row = []; + var cell = ''; + var quoted = false; + for (var i = 0; i < input.length; i++) { + var character = input[i]; + if (character === '"') { + if (quoted && input[i + 1] === '"') { cell += '"'; i++; } + else { quoted = !quoted; } + } else if (character === ',' && !quoted) { + row.push(cell.trim()); cell = ''; + } else if ((character === '\n' || character === '\r') && !quoted) { + if (character === '\r' && input[i + 1] === '\n') { i++; } + row.push(cell.trim()); + if (row.some(function (value) { return value !== ''; })) { rows.push(row); } + row = []; cell = ''; + } else { + cell += character; + } + } + row.push(cell.trim()); + if (row.some(function (value) { return value !== ''; })) { rows.push(row); } + return rows; + } + + function valuesToCsv(values) { + return (values || []).map(function (row) { + return (row || []).map(function (value) { + var text = value === undefined || value === null ? '' : String(value); + return /[",\r\n]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text; + }).join(','); + }).join('\n'); + } + + var api = Object.freeze({ + parseA1Range: parseA1Range, + columnToNumber: columnToNumber, + numberToColumn: numberToColumn, + cellReference: cellReference, + encodeValues: encodeValues, + transpose: transpose, + appendRowFromBoundary: appendRowFromBoundary, + valuesAreEmpty: valuesAreEmpty, + csvToValues: csvToValues, + valuesToCsv: valuesToCsv, + limits: Object.freeze({ + maxReadRows: MAX_READ_ROWS, + maxReadColumns: MAX_READ_COLUMNS, + maxUiCells: MAX_UI_CELLS, + maxClearCells: MAX_CLEAR_CELLS, + maxChunkCells: MAX_CHUNK_CELLS, + maxTotalBytes: MAX_TOTAL_BYTES, + maxChunkBytes: MAX_CHUNK_BYTES + }) + }); + global.FsbGoogleSheetsUi = api; + if (typeof module !== 'undefined' && module.exports) { module.exports = api; } +})(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/extension/ws/ws-client.js b/extension/ws/ws-client.js index 40407a635..c1eb34377 100644 --- a/extension/ws/ws-client.js +++ b/extension/ws/ws-client.js @@ -371,6 +371,7 @@ function _getContentScriptFilesForInjection() { 'content/badge-combine.js', 'content/visual-feedback.js', 'content/accessibility.js', + 'utils/google-sheets-ui.js', 'content/actions.js', 'content/dom-analysis.js', 'content/dom-stream.js', diff --git a/scripts/verify-origin-classification.mjs b/scripts/verify-origin-classification.mjs index d0911f90b..158a37dd1 100644 --- a/scripts/verify-origin-classification.mjs +++ b/scripts/verify-origin-classification.mjs @@ -548,7 +548,7 @@ const HEAD_APP_MAP = { FsbHandlerGsheets: { app: 'google-sheets', fallbackBaseUrl: 'https://docs.google.com', - chromeIdentityApiBaseUrl: 'https://sheets.googleapis.com/v4' + pageGapiUiSheetsSessionBaseUrl: 'https://sheets.googleapis.com/v4' }, FsbHandlerWebflow: { app: 'webflow', @@ -730,23 +730,26 @@ export function classifyOriginPattern(handlerOrigin, apiBaseUrl, opts) { 'a head whose origin cannot be verified must be demoted to T3-DOM' }; } - // ---- Chrome Identity Google Sheets API accommodation: exact endpoints, ASSERTED ---- - if (options.chromeIdentitySheetsApi) { + // ---- Signed-in page-gapi/UI Google Sheets session: exact endpoints, ASSERTED ---- + if (options.pageGapiUiSheetsSession) { + const expectedBase = 'https://sheets.googleapis.com/v4'; const same = hOrigin === 'https://docs.google.com' - && aOrigin === 'https://sheets.googleapis.com'; + && aOrigin === 'https://sheets.googleapis.com' + && apiBaseUrl === expectedBase; return { sameOrigin: same, separate: !same, apiOrigin: aOrigin, handlerOrigin: hOrigin, reason: same - ? 'CHROME_IDENTITY_SHEETS_API: head origin https://docs.google.com routes through ' + - 'the reviewed extension-owned Google Sheets v4 facade at sheets.googleapis.com. ' + - 'Chrome Identity owns OAuth tokens; the handler receives only five fixed methods, ' + - 'the facade accepts no arbitrary URL/method/headers, and write/destructive calls ' + - 'remain subject to the sensitive-origin consent gate.' - : 'CORS_SEPARATE_ORIGIN: Chrome Identity Sheets accommodation is limited to ' + - 'docs.google.com -> sheets.googleapis.com; got head ' + hOrigin + ', API ' + aOrigin + ? 'PAGE_GAPI_UI_SHEETS_SESSION: head origin https://docs.google.com uses only the ' + + 'already signed-in, agent-owned Sheets tab through the reviewed fixed five-method ' + + 'session. The MAIN-world path calls only gapi.client.request against ' + expectedBase + + ', the fallback is the fixed sheetsSession UI action, no auth or credential source ' + + 'is available, and write/destructive slugs remain guarded fail-closed.' + : 'CORS_SEPARATE_ORIGIN: page-gapi/UI Sheets session accommodation is limited to ' + + 'exactly docs.google.com -> ' + expectedBase + '; got head ' + hOrigin + + ', API base ' + String(apiBaseUrl) }; } // ---- Dynamic-workspace accommodation (slack): same-registrable-domain, ASSERTED ---- @@ -4472,39 +4475,98 @@ function readGlamaPageStateRuntimeBase(app, runtimeBaseUrl) { : null; } -function readChromeIdentitySheetsApiBase(mapping) { +export function verifyPageGapiUiSheetsSessionSources(sourceOverrides) { + const overrides = sourceOverrides || {}; const expectedBase = 'https://sheets.googleapis.com/v4'; - if (!mapping || mapping.app !== 'google-sheets' || mapping.chromeIdentityApiBaseUrl !== expectedBase) { - return null; - } - const apiPath = join(ROOT, 'extension', 'utils', 'google-sheets-api.js'); + const sessionPath = join(ROOT, 'extension', 'utils', 'google-sheets-session.js'); const handlerPath = join(ROOT, 'catalog', 'handlers', 'gsheets.js'); - const routerPath = join(ROOT, 'extension', 'utils', 'capability-router.js'); + const contentActionsPath = join(ROOT, 'extension', 'content', 'actions.js'); const manifestPath = join(ROOT, 'extension', 'manifest.json'); - if (!existsSync(apiPath) || !existsSync(handlerPath) || !existsSync(routerPath) || !existsSync(manifestPath)) { - return null; + const failures = []; + + let manifest = overrides.manifest; + if (manifest === undefined) { + if (!existsSync(manifestPath)) { + manifest = null; + } else { + try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); } catch (_e) { manifest = null; } + } } + const permissions = manifest && Array.isArray(manifest.permissions) ? manifest.permissions : []; + const manifestOk = !!manifest + && !permissions.includes('identity') + && !Object.prototype.hasOwnProperty.call(manifest, 'oauth2'); + if (!manifestOk) { failures.push('MANIFEST_IDENTITY_OR_OAUTH2_PRESENT'); } + + const readSource = function(overrideKey, filePath) { + if (Object.prototype.hasOwnProperty.call(overrides, overrideKey)) { + return typeof overrides[overrideKey] === 'string' ? overrides[overrideKey] : ''; + } + return existsSync(filePath) ? readFileSync(filePath, 'utf8') : ''; + }; + const sessionText = readSource('sessionText', sessionPath); + const handlerText = readSource('handlerText', handlerPath); + const contentActionsText = readSource('contentActionsText', contentActionsPath); + if (!sessionText) { failures.push('SHEETS_SESSION_HELPER_MISSING'); } + if (!handlerText) { failures.push('SHEETS_HANDLER_MISSING'); } + if (!contentActionsText) { failures.push('SHEETS_CONTENT_ACTIONS_MISSING'); } + + const fixedMethods = ['getSpreadsheet', 'getValues', 'updateValues', 'appendValues', 'clearValues']; + const sessionReturn = sessionText.match( + /return\s*\{([\s\S]*?)\};\s*\}\s*var\s+session\s*=\s*createSession\s*\(\s*\)/ + ); + const exposedMethods = sessionReturn + ? Array.from(sessionReturn[1].matchAll(/^\s*([A-Za-z_$][\w$]*)\s*:\s*function\b/gm), function(match) { return match[1]; }) + : []; + const fixedMethodSurfaceOk = exposedMethods.length === fixedMethods.length + && fixedMethods.every(function(method, index) { return exposedMethods[index] === method; }); + if (!fixedMethodSurfaceOk) { failures.push('SHEETS_SESSION_METHOD_SURFACE_NOT_FIXED'); } + + const exactActiveTabPinOk = /SHEETS_ORIGIN\s*=\s*['"]https:\/\/docs\.google\.com['"]/.test(sessionText) + && /SHEETS_URL_RE\s*=\s*\/\^\\\/spreadsheets\\\/d\\\//.test(sessionText) + && /var\s+tabId\s*=\s*Number\(context\.tabId\)/.test(sessionText) + && /chromeApi\.tabs\.get\(tabId\)/.test(sessionText) + && /spreadsheetIdFromUrl\(tab\s*&&\s*tab\.url\)/.test(sessionText) + && /explicit\s*!==\s*spreadsheetId/.test(sessionText) + && /target:\s*\{\s*tabId:\s*target\.tabId\s*\}/.test(sessionText) + && /sendMessage\(target\.tabId,\s*\{/.test(sessionText) + && !/chromeApi\.tabs\.(?:query|create|update)\s*\(/.test(sessionText); + if (!exactActiveTabPinOk) { failures.push('SHEETS_ACTIVE_TAB_PIN_NOT_EXACT'); } + + const requestCalls = sessionText.match( + /\b[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\.request\s*\(/g + ) || []; + const sourceUrls = sessionText.match(/https:\/\/[A-Za-z0-9._~:/?#\[\]@!$&()*+,=%-]+/g) || []; + const allowedUrls = new Set([ + 'https://docs.google.com', + expectedBase, + expectedBase + '/spreadsheets/' + ]); + const fixedGapiRequestOk = /SHEETS_API_BASE\s*=\s*['"]https:\/\/sheets\.googleapis\.com\/v4['"]/.test(sessionText) + && /var\s+gapiClient\s*=\s*globalThis\.gapi\s*&&\s*globalThis\.gapi\.client/.test(sessionText) + && /gapiClient\.request\(\{\s*path:\s*path,\s*method:\s*method,\s*params:\s*params,\s*body:\s*body\s*\}\)/.test(sessionText) + && requestCalls.length === 1 + && /^gapiClient\.request\s*\($/.test(requestCalls[0]) + && sourceUrls.length > 0 + && sourceUrls.every(function(url) { return allowedUrls.has(url); }) + && !/(?:request|args|params)\.(?:url|path|method|headers|body)\b/.test(sessionText); + if (!fixedGapiRequestOk) { failures.push('SHEETS_GAPI_REQUEST_NOT_FIXED'); } + + const forbiddenSheetsSource = sessionText + '\n' + handlerText; + const hasForbiddenCredentialOrNetworkSource = + /chrome(?:Api)?\.identity\b|\bgetAuthToken\b/i.test(forbiddenSheetsSource) + || /\bgapi(?:Client)?\.(?:auth|load|init)\b|\bgoogle\.accounts\b|\binit(?:Token|Code)Client\b|\brequestAccessToken\b/i.test(forbiddenSheetsSource) + || /\b(?:accessToken|authToken|refreshToken|idToken|token)\b/i.test(forbiddenSheetsSource) + || /\bdocument\.cookie\b|chrome(?:Api)?\.cookies\b|\bcookies?\.(?:get|set|remove)\s*\(/i.test(forbiddenSheetsSource) + || /\b(?:localStorage|sessionStorage|chrome(?:Api)?\.storage)\b/i.test(forbiddenSheetsSource) + || /\bAuthorization\b|\bBearer\b/i.test(forbiddenSheetsSource) + || /\bfetch\s*\(|\bXMLHttpRequest\b|\bsendBeacon\s*\(|\bWebSocket\s*\(|\bEventSource\s*\(/i.test(forbiddenSheetsSource); + if (hasForbiddenCredentialOrNetworkSource) { failures.push('FORBIDDEN_SHEETS_CREDENTIAL_OR_NETWORK_SOURCE'); } + + const contentActionOk = /\bsheetsSession\s*:\s*async\b/.test(contentActionsText) + || /\btools\.sheetsSession\s*=\s*async\b/.test(contentActionsText); + if (!contentActionOk) { failures.push('SHEETS_SESSION_CONTENT_ACTION_MISSING'); } - const apiText = readFileSync(apiPath, 'utf8'); - const handlerText = readFileSync(handlerPath, 'utf8'); - const routerText = readFileSync(routerPath, 'utf8'); - let manifest; - try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); } catch (_e) { return null; } - - const scope = 'https://www.googleapis.com/auth/spreadsheets'; - const manifestOk = Array.isArray(manifest.permissions) - && manifest.permissions.includes('identity') - && manifest.oauth2 && Array.isArray(manifest.oauth2.scopes) - && manifest.oauth2.scopes.length === 1 && manifest.oauth2.scopes[0] === scope; - const facadeOk = /SHEETS_BASE_URL\s*=\s*['"]https:\/\/sheets\.googleapis\.com\/v4['"]/.test(apiText) - && /SHEETS_SCOPE\s*=\s*['"]https:\/\/www\.googleapis\.com\/auth\/spreadsheets['"]/.test(apiText) - && /chromeApi\.identity\.getAuthToken\(\{ interactive: interactive === true \}/.test(apiText) - && /fetchFn\(spec\.url,\s*\{/.test(apiText) - && /credentials:\s*['"]omit['"]/.test(apiText) - && /redirect:\s*['"]error['"]/.test(apiText) - && /PLACEHOLDER_CLIENT_ID\.test\(clientId\)/.test(apiText) - && !/params\.(?:url|method|headers|token)\b/.test(apiText) - && !/(?:localStorage|sessionStorage|chromeApi\.storage)/.test(apiText); const slugs = [ 'gsheets.get_spreadsheet', 'gsheets.get_values', @@ -4514,23 +4576,52 @@ function readChromeIdentitySheetsApiBase(mapping) { ]; const handlerOk = /var\s+ORIGIN\s*=\s*['"]https:\/\/docs\.google\.com['"]/.test(handlerText) && slugs.every(function(slug) { return handlerText.indexOf("'" + slug + "'") !== -1; }) - && /ctx\s*&&\s*ctx\.googleSheets/.test(handlerText) - && !/\bfetch\s*\(|chrome\.|Authorization|Bearer|getAuthToken/.test(handlerText); - const narrowMethods = ['getSpreadsheet', 'getValues', 'updateValues', 'appendValues', 'clearValues']; - const routerOk = /googleSheets:\s*_googleSheetsContext\(\)/.test(routerText) - && narrowMethods.every(function(method) { return routerText.indexOf("'" + method + "'") !== -1; }); + && /ctx\s*&&\s*ctx\.googleSheets/.test(handlerText); + if (!handlerOk) { failures.push('SHEETS_HANDLER_SESSION_CONTRACT_MISMATCH'); } + + const guardedWritesOk = + /['"]gsheets\.update_values['"]\s*:\s*guarded\(\s*['"]gsheets\.update_values['"]\s*,\s*['"]write['"]/.test(handlerText) + && /['"]gsheets\.append_values['"]\s*:\s*guarded\(\s*['"]gsheets\.append_values['"]\s*,\s*['"]write['"]/.test(handlerText) + && /['"]gsheets\.clear_values['"]\s*:\s*guarded\(\s*['"]gsheets\.clear_values['"]\s*,\s*['"]destructive['"]/.test(handlerText) + && /function\s+guarded\s*\([\s\S]*?code:\s*FALLBACK_CODE[\s\S]*?google-sheets-live-mutation-uat-required/.test(handlerText); + if (!guardedWritesOk) { failures.push('SHEETS_WRITE_SLUGS_NOT_GUARDED'); } + const expectedClasses = ['read', 'read', 'write', 'write', 'destructive']; const descriptorsOk = slugs.every(function(slug, index) { - const descriptorPath = join(ROOT, 'catalog', 'descriptors', slug.replace('.', '__') + '.json'); - if (!existsSync(descriptorPath)) { return false; } + let descriptor; + if (overrides.descriptors && Object.prototype.hasOwnProperty.call(overrides.descriptors, slug)) { + descriptor = overrides.descriptors[slug]; + } else { + const descriptorPath = join(ROOT, 'catalog', 'descriptors', slug.replace('.', '__') + '.json'); + if (!existsSync(descriptorPath)) { return false; } + try { descriptor = JSON.parse(readFileSync(descriptorPath, 'utf8')); } catch (_e) { return false; } + } try { - const descriptor = JSON.parse(readFileSync(descriptorPath, 'utf8')); - return descriptor.backing === 'handler' && descriptor.sideEffectClass === expectedClasses[index]; + const idDescription = descriptor.params && descriptor.params.properties + && descriptor.params.properties.spreadsheetId + && descriptor.params.properties.spreadsheetId.description; + return descriptor.backing === 'handler' + && descriptor.sideEffectClass === expectedClasses[index] + && descriptor.provenance && descriptor.provenance.signals + && descriptor.provenance.signals.transportHelper === 'page-gapi-ui-sheets-session' + && idDescription === 'Spreadsheet ID. When provided, it must match the spreadsheet open in the agent-owned Google Sheets tab.'; } catch (_e) { return false; } }); - return manifestOk && facadeOk && handlerOk && routerOk && descriptorsOk ? expectedBase : null; + if (!descriptorsOk) { failures.push('SHEETS_DESCRIPTORS_CONTRACT_MISMATCH'); } + + return { ok: failures.length === 0, failures: failures }; +} + +function readPageGapiUiSheetsSessionBase(mapping) { + const expectedBase = 'https://sheets.googleapis.com/v4'; + if (!mapping || mapping.app !== 'google-sheets' + || mapping.pageGapiUiSheetsSessionBaseUrl !== expectedBase) { + return null; + } + const verified = verifyPageGapiUiSheetsSessionSources(); + return verified.ok ? expectedBase : null; } /** @@ -4594,21 +4685,22 @@ export function checkOriginClassification(headsOverride, opts) { let apiBaseUrl; let classifyOpts; - if (mapping.chromeIdentityApiBaseUrl) { - const sheetsBase = readChromeIdentitySheetsApiBase(mapping); + if (mapping.pageGapiUiSheetsSessionBaseUrl) { + const sheetsBase = readPageGapiUiSheetsSessionBase(mapping); if (!sheetsBase) { - const reason = 'CORS_CHROME_IDENTITY_SHEETS_MISMATCH: head ' + head.global + - ' requested Google Sheets API base "' + String(mapping.chromeIdentityApiBaseUrl) + - '" but the manifest, OAuth facade, narrow router context, handler, or descriptors ' + - 'did not match the reviewed fixed-endpoint contract -- refusing a Chrome Identity ' + - 'cross-origin accommodation that is not explicitly pinned'; + const reason = 'CORS_PAGE_GAPI_UI_SHEETS_SESSION_MISMATCH: head ' + head.global + + ' requested Google Sheets API base "' + String(mapping.pageGapiUiSheetsSessionBaseUrl) + + '" but the no-Identity manifest, fixed five-method session, active-tab pin, fixed ' + + 'gapi.client.request base, sheetsSession UI action, guarded writes, or descriptors ' + + 'did not match the reviewed source contract -- refusing a broader cross-origin ' + + 'accommodation'; results.push({ global: head.global, handlerOrigin: head.origin, apiBaseUrl: null, classification: { sameOrigin: false, separate: true, reason: reason } }); failures.push(reason); continue; } apiBaseUrl = sheetsBase; - classifyOpts = { chromeIdentitySheetsApi: true }; + classifyOpts = { pageGapiUiSheetsSession: true }; } else if (mapping.graphBearerRuntimeBaseUrl) { let graphBase = null; if (mapping.pageBearerGraphApp === 'excel') { @@ -4990,8 +5082,8 @@ function runCli() { && reason.indexOf('PAGE_BEARER_GRAPH_READ') === 0; const isGapiPageBridge = typeof reason === 'string' && reason.indexOf('PAGE_GAPI_CLIENT_READ') === 0; - const isChromeIdentitySheets = typeof reason === 'string' - && reason.indexOf('CHROME_IDENTITY_SHEETS_API') === 0; + const isPageGapiUiSheetsSession = typeof reason === 'string' + && reason.indexOf('PAGE_GAPI_UI_SHEETS_SESSION') === 0; const isGlamaPageStateRuntime = typeof reason === 'string' && reason.indexOf('GLAMA_PAGE_STATE_RUNTIME_READ') === 0; const verdict = r.classification.sameOrigin @@ -5011,8 +5103,8 @@ function runCli() { ? 'PAGE-BEARER-GRAPH' : (isGapiPageBridge ? 'PAGE-GAPI-CLIENT' - : (isChromeIdentitySheets - ? 'CHROME-IDENTITY-API' + : (isPageGapiUiSheetsSession + ? 'PAGE-GAPI/UI-SHEETS-SESSION' : (isGlamaPageStateRuntime ? 'PAGE-STATE-RUNTIME' : 'SAME-ORIGIN')))))))))) : 'SEPARATE'; console.log(' ' + verdict + ' ' + r.global + ' head=' + String(r.handlerOrigin) + @@ -5081,9 +5173,9 @@ function runCli() { const reason = r.classification && r.classification.reason; return typeof reason === 'string' && reason.indexOf('GLAMA_PAGE_STATE_RUNTIME_READ') === 0; }).length; - const chromeIdentitySheetsApis = results.filter(function(r) { + const pageGapiUiSheetsSessions = results.filter(function(r) { const reason = r.classification && r.classification.reason; - return typeof reason === 'string' && reason.indexOf('CHROME_IDENTITY_SHEETS_API') === 0; + return typeof reason === 'string' && reason.indexOf('PAGE_GAPI_UI_SHEETS_SESSION') === 0; }).length; console.log( 'verify-origin-classification: PASS (' + results.length + ' shipped head(s); ' + @@ -5095,7 +5187,7 @@ function runCli() { guardedOnlyHeads + ' guarded-only no-execution head(s); ' + pageBearerGraphReads + ' page-bearer Graph read accommodation(s); ' + gapiPageBridgeReads + ' page GAPI client read accommodation(s); ' + - chromeIdentitySheetsApis + ' Chrome Identity Sheets API accommodation(s); ' + + pageGapiUiSheetsSessions + ' signed-in page-gapi/UI Sheets session accommodation(s); ' + glamaPageStateRuntimeReads + ' Glama page-state runtime read accommodation(s); linear ' + 'separate-origin negative-control classifies separate; 0 silent cross-origin ports)' ); diff --git a/tests/capability-router.test.js b/tests/capability-router.test.js index a40cc2033..5f5fbe6b0 100644 --- a/tests/capability-router.test.js +++ b/tests/capability-router.test.js @@ -488,6 +488,69 @@ const GITHUB_RECIPE = { clearCatalog(); delete globalThis.FsbCapabilityFetch; + // ----------------------------------------------------------------------- + // Session-backed T1a handlers opt out of recipe-rot classification. Operational + // Google Sheets tab/session errors must remain actionable and never quarantine the + // capability for the rest of the service-worker session. + // ----------------------------------------------------------------------- + const sheetsCodes = [ + 'GOOGLE_SHEETS_ACTIVE_TAB_REQUIRED', + 'GOOGLE_SHEETS_TARGET_MISMATCH', + 'GOOGLE_SHEETS_SESSION_UNAVAILABLE' + ]; + let sheetsAttempt = 0; + const sheetsQuarantineCalls = []; + const sheetsHandler = { + tier: 'T1a', + origin: 'https://docs.google.com', + sideEffectClass: 'read', + rotClassifiable: false, + async handle() { + if (sheetsAttempt < sheetsCodes.length) { + const code = sheetsCodes[sheetsAttempt++]; + return { success: false, code, errorCode: code, error: code }; + } + return { success: true, data: { values: [['recovered']] } }; + } + }; + installCatalog({ 'gsheets.get_values': { tier: 'T1a', handler: sheetsHandler } }); + globalThis.FsbCapabilityCatalog.quarantineBundled = function (slug) { + sheetsQuarantineCalls.push(slug); + }; + for (const code of sheetsCodes) { + const operational = await ROUTER.invoke('gsheets.get_values', {}, { + origin: 'https://docs.google.com', tabId: 18 + }); + check(operational && operational.code === code && operational.errorCode === code, + 'session-backed T1a handler preserves operational error ' + code); + } + const sheetsRetry = await ROUTER.invoke('gsheets.get_values', {}, { + origin: 'https://docs.google.com', tabId: 18 + }); + check(sheetsQuarantineCalls.length === 0, + 'session-backed T1a operational failures never quarantine the capability'); + check(sheetsRetry && sheetsRetry.success === true && sheetsRetry.tier === 'T1a', + 'session-backed T1a capability remains available for a successful retry'); + clearCatalog(); + + const ordinaryQuarantineCalls = []; + const ordinaryHandler = { + tier: 'T1a', origin: 'https://example.com', sideEffectClass: 'read', + async handle() { return { success: false, error: 'page fetch failed' }; } + }; + installCatalog({ 'example.page.read': { tier: 'T1a', handler: ordinaryHandler } }); + globalThis.FsbCapabilityCatalog.quarantineBundled = function (slug) { + ordinaryQuarantineCalls.push(slug); + }; + const ordinaryFailure = await ROUTER.invoke('example.page.read', {}, { + origin: 'https://example.com', tabId: 19 + }); + check(ordinaryFailure && ordinaryFailure.code === 'RECIPE_DOM_FALLBACK_PENDING', + 'unmarked page-backed T1a failures still route through recipe-rot fallback'); + check(ordinaryQuarantineCalls.length === 1 && ordinaryQuarantineCalls[0] === 'example.page.read', + 'unmarked page-backed T1a failures still quarantine the broken capability'); + clearCatalog(); + // ----------------------------------------------------------------------- // T1a params gate: handler-backed capabilities with descriptor/handler // schemas reject malformed args before handler.handle or executeBoundSpec. diff --git a/tests/extension-content-script-files-completeness.test.js b/tests/extension-content-script-files-completeness.test.js index ed1c6e965..780836f17 100644 --- a/tests/extension-content-script-files-completeness.test.js +++ b/tests/extension-content-script-files-completeness.test.js @@ -40,6 +40,7 @@ const required = [ 'content/badge-combine.js', 'content/visual-feedback.js', 'content/accessibility.js', + 'utils/google-sheets-ui.js', 'content/actions.js', 'content/dom-analysis.js', 'content/dom-stream.js', diff --git a/tests/google-sheets-content-actions.test.js b/tests/google-sheets-content-actions.test.js new file mode 100644 index 000000000..cad168b99 --- /dev/null +++ b/tests/google-sheets-content-actions.test.js @@ -0,0 +1,101 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const ui = require('../extension/utils/google-sheets-ui.js'); +const ROOT = path.resolve(__dirname, '..'); + +test('parses bounded A1, quoted-sheet, single-cell, and column append ranges', () => { + assert.deepEqual(ui.parseA1Range('A1:B2'), { + sheetPrefix: '', startColumn: 1, endColumn: 2, startRow: 1, endRow: 2, + rows: 2, columns: 2, columnOnly: false + }); + const quoted = ui.parseA1Range("'Data 2026'!$C$4:$D$8"); + assert.equal(quoted.sheetPrefix, "'Data 2026'!"); + assert.equal(quoted.startColumn, 3); + assert.equal(quoted.startRow, 4); + assert.equal(quoted.rows, 5); + assert.deepEqual(ui.parseA1Range('Z9'), { + sheetPrefix: '', startColumn: 26, endColumn: 26, startRow: 9, endRow: 9, + rows: 1, columns: 1, columnOnly: false + }); + const columns = ui.parseA1Range('Archive!B:D'); + assert.equal(columns.columnOnly, true); + assert.equal(columns.columns, 3); + assert.equal(ui.parseA1Range('A0:B2'), null); + assert.equal(ui.parseA1Range('https://attacker.example'), null); +}); + +test('encodes RAW strings as literal text and preserves USER_ENTERED formulas', () => { + const raw = ui.encodeValues([['=1+1', '+2', '-3', '@name'], [true, 2, 'ok', '']], 'RAW'); + assert.equal(raw.success, true); + assert.equal(raw.chunks.length, 1); + assert.equal(raw.chunks[0].text, "'=1+1\t'+2\t'-3\t'@name\nTRUE\t2\t'ok\t"); + + const rawStrings = ui.encodeValues([['001', 'TRUE', '2026-07-15', "'quoted"]], 'RAW'); + assert.equal(rawStrings.chunks[0].text, "'001\t'TRUE\t'2026-07-15\t''quoted"); + + const entered = ui.encodeValues([['=1+1']], 'USER_ENTERED'); + assert.equal(entered.success, true); + assert.equal(entered.chunks[0].text, '=1+1'); +}); + +test('fails closed for lossy matrices and row-chunks large bounded writes', () => { + assert.equal(ui.encodeValues([['a'], ['b', 'c']], 'RAW').reason, 'ragged-values-not-lossless'); + assert.equal(ui.encodeValues([[null]], 'RAW').reason, 'null-values-not-lossless'); + assert.equal(ui.encodeValues([['line\nbreak']], 'RAW').reason, 'multiline-or-tab-cell-not-lossless'); + assert.equal(ui.encodeValues([[Infinity]], 'RAW').reason, 'non-finite-number'); + + const values = Array.from({ length: 6000 }, (_, index) => [String(index)]); + const chunked = ui.encodeValues(values, 'RAW'); + assert.equal(chunked.success, true); + assert.equal(chunked.cells, 6000); + assert.equal(chunked.chunks.length, 2); + assert.deepEqual(chunked.chunks.map(chunk => chunk.rowOffset), [0, 5000]); +}); + +test('legacy CSV conversion and read transpose reuse the shared value helpers', () => { + const values = ui.csvToValues('name,note\nAda,"hello, world"\nBob,"quote ""inside"""'); + assert.deepEqual(values, [['name', 'note'], ['Ada', 'hello, world'], ['Bob', 'quote "inside"']]); + assert.equal(ui.valuesToCsv(values), 'name,note\nAda,"hello, world"\nBob,"quote ""inside"""'); + assert.deepEqual(ui.transpose([['a', 'b'], ['c', 'd']]), [['a', 'c'], ['b', 'd']]); +}); + +test('append boundaries and clear readback fail closed when UI state is ambiguous', () => { + const parsed = ui.parseA1Range('Data!A:C'); + assert.deepEqual(ui.appendRowFromBoundary(parsed, '', 'A1', ''), { + success: false, + reason: 'ui-append-boundary-ambiguous' + }); + assert.equal(ui.appendRowFromBoundary(parsed, 'header', 'B8', 'value').success, false); + assert.deepEqual(ui.appendRowFromBoundary(parsed, 'header', 'A8', 'last row'), { + success: true, + row: 9 + }); + assert.equal(ui.valuesAreEmpty([['', ''], ['', '']]), true); + assert.equal(ui.valuesAreEmpty([[''], ['still present']]), false); + assert.equal(ui.valuesAreEmpty([[0]]), false); +}); + +test('content action exposes only the fixed Sheets UI operations and protects value-bearing logs', () => { + const actions = fs.readFileSync(path.join(ROOT, 'extension/content/actions.js'), 'utf8'); + const messaging = fs.readFileSync(path.join(ROOT, 'extension/content/messaging.js'), 'utf8'); + const background = fs.readFileSync(path.join(ROOT, 'extension/background.js'), 'utf8'); + const wsClient = fs.readFileSync(path.join(ROOT, 'extension/ws/ws-client.js'), 'utf8'); + + assert.match(actions, /sheetsSession:\s*async/); + for (const operation of ['getSpreadsheet', 'getValues', 'updateValues', 'appendValues', 'clearValues']) { + assert.match(actions, new RegExp(`operation === '${operation}'`)); + } + assert.match(actions, /RECOVERY_AMBIGUOUS/); + assert.match(actions, /renderSemantics:\s*'formula-bar'/); + assert.match(actions, /sheetsUi\.csvToValues\(data\)/); + assert.match(actions, /sheetsReadValues\(range, 'ROWS'\)/); + assert.match(messaging, /'fillsheet'[\s\S]*'readsheet'[\s\S]*'sheetsSession'/); + assert.match(messaging, /longTimeoutTools = \[[^\]]*'sheetsSession'/); + assert.match(background, /'utils\/google-sheets-ui\.js'[\s\S]*'content\/actions\.js'/); + assert.match(wsClient, /'utils\/google-sheets-ui\.js'[\s\S]*'content\/actions\.js'/); +}); diff --git a/tests/google-sheets-session.test.js b/tests/google-sheets-session.test.js index be79dc657..b2768674d 100644 --- a/tests/google-sheets-session.test.js +++ b/tests/google-sheets-session.test.js @@ -105,6 +105,65 @@ test('never retries or falls back after an ambiguous mutation outcome', async () assert.equal(stub.uiCalls.length, 0); }); +test('classifies mutation timeouts, network failures, and 5xx responses as ambiguous', async () => { + const previous = globalThis.gapi; + try { + globalThis.gapi = { client: { request: () => Promise.reject({ status: 503 }) } }; + const rejected = await sessionModule.pageClientOperation({ + operation: 'updateValues', spreadsheetId: ID, timeoutMs: 100, + args: { range: 'A1', values: [['x']], valueInputOption: 'RAW' } + }); + assert.equal(rejected.code, 'RECOVERY_AMBIGUOUS'); + assert.equal(rejected.status, 503); + + globalThis.gapi = { client: { request: () => Promise.reject(new Error('network down')) } }; + const network = await sessionModule.pageClientOperation({ + operation: 'updateValues', spreadsheetId: ID, timeoutMs: 100, + args: { range: 'A1', values: [['x']], valueInputOption: 'RAW' } + }); + assert.equal(network.code, 'RECOVERY_AMBIGUOUS'); + + globalThis.gapi = { client: { request: () => Promise.resolve({ status: 502, result: {} }) } }; + const response = await sessionModule.pageClientOperation({ + operation: 'clearValues', spreadsheetId: ID, timeoutMs: 100, args: { range: 'A1' } + }); + assert.equal(response.code, 'RECOVERY_AMBIGUOUS'); + assert.equal(response.status, 502); + + globalThis.gapi = { client: { request: () => new Promise(() => {}) } }; + const timedOut = await sessionModule.pageClientOperation({ + operation: 'appendValues', spreadsheetId: ID, timeoutMs: 5, + args: { range: 'A:C', values: [['x']] } + }); + assert.equal(timedOut.code, 'RECOVERY_AMBIGUOUS'); + } finally { + globalThis.gapi = previous; + } +}); + +test('surfaces a logged-out UI state as session unavailable', async () => { + const stub = chromeStub({ + pageResult: { + success: false, + code: 'GOOGLE_SHEETS_SESSION_UNAVAILABLE', + safeToFallback: true, + knownNoEffect: true + }, + uiResult: { + success: false, + code: 'GOOGLE_SHEETS_SESSION_UNAVAILABLE', + errorCode: 'GOOGLE_SHEETS_SESSION_UNAVAILABLE', + error: 'GOOGLE_SHEETS_SESSION_UNAVAILABLE', + reason: 'name-box-unavailable' + } + }); + const client = sessionModule.createSession({ chrome: stub.chrome }); + const out = await client.getSpreadsheet({}, CONTEXT); + assert.equal(out.code, 'GOOGLE_SHEETS_SESSION_UNAVAILABLE'); + assert.equal(stub.pageCalls.length, 1); + assert.equal(stub.uiCalls.length, 1); +}); + test('MAIN-world bridge builds only fixed Sheets requests and uses no supplied transport fields', async () => { const previous = globalThis.gapi; const calls = []; diff --git a/tests/lattice-provider-bridge-smoke.test.js b/tests/lattice-provider-bridge-smoke.test.js index b5af6ec7e..bd2c45cba 100644 --- a/tests/lattice-provider-bridge-smoke.test.js +++ b/tests/lattice-provider-bridge-smoke.test.js @@ -617,8 +617,10 @@ async function loadOffscreenHandlerSource(chromeMock) { // (the ai/lattice-runtime-adapter.js importScripts() call) -> 309. // Quick 260707-7id: 310 mentions (+1 new line for utils/mcp-session-recorder.js // -- MCP session recorder; comment lines deliberately token-free). + // Google Sheets support adds 3 mentions (+1 each for utils/spreadsheet-record-redaction.js, + // utils/google-sheets-session.js, and catalog/handlers/gsheets.js) -> 313. const importScriptsCount = (bgSource.match(/importScripts/g) || []).length; - passAssertEqual(importScriptsCount, 310, 'background.js importScripts count = 310 (current head set + the Quick 260707-7id mcp-session-recorder load)'); + passAssertEqual(importScriptsCount, 313, 'background.js importScripts count = 313 (current head set + 3 Google Sheets support loads)'); // Companion call-site-only count (regex requires open paren): Phase 5 baseline // was 150 actual importScripts() calls; Phase 6 adds 1 -> 151; Phase 8 adds 1 -> 152; // Phase 14 adds 2 (trigger-store + trigger-lifecycle) -> 154; Phase 15 adds 2 @@ -652,8 +654,10 @@ async function loadOffscreenHandlerSource(chromeMock) { // FINT-13 "actually load the adapter in SW" (commit a3c03e6a) adds 1 call site // (ai/lattice-runtime-adapter.js) -> 305. // Quick 260707-7id adds 1 call site (utils/mcp-session-recorder.js) -> 306. + // Google Sheets support adds 3 call sites (utils/spreadsheet-record-redaction.js, + // utils/google-sheets-session.js, and catalog/handlers/gsheets.js) -> 309. const importScriptsCallSites = (bgSource.match(/importScripts\(/g) || []).length; - passAssertEqual(importScriptsCallSites, 306, 'background.js importScripts() call sites = 306 (current head set + the Quick 260707-7id mcp-session-recorder load)'); + passAssertEqual(importScriptsCallSites, 309, 'background.js importScripts() call sites = 309 (current head set + 3 Google Sheets support loads)'); const lineCli = bgLines.findIndex(l => /importScripts\(['"]ai\/cli-parser\.js['"]\)/.test(l)); const lineBridge = bgLines.findIndex(l => /importScripts\(['"]ai\/lattice-provider-bridge\.js['"]\)/.test(l)); diff --git a/tests/verify-origin-classification.test.js b/tests/verify-origin-classification.test.js index 30ab8eae0..d133614c4 100644 --- a/tests/verify-origin-classification.test.js +++ b/tests/verify-origin-classification.test.js @@ -61,6 +61,8 @@ function check(cond, msg) { 'checkOriginClassification is a named export of the real gate'); check(typeof gate.parseHeadModules === 'function', 'parseHeadModules is a named export of the real gate (driven directly, IN-01 coverage)'); + check(typeof gate.verifyPageGapiUiSheetsSessionSources === 'function', + 'verifyPageGapiUiSheetsSessionSources is a named export of the real gate'); // ===== (a) parseHeadModules on the REAL catalog source ===================== const CATALOG_PATH = path.join(ROOT, 'extension', 'utils', 'capability-catalog.js'); @@ -364,6 +366,56 @@ function check(cond, msg) { && twitchPageBearer.reason.indexOf('SAME_REGISTRABLE_DOMAIN_PAGE_BEARER_READ') === 0, '(b) www.twitch.tv vs gql.twitch.tv page-bearer GraphQL -> sameOrigin:true through the explicit page-bearer read accommodation'); + const sheetsSession = gate.classifyOriginPattern( + 'https://docs.google.com', + 'https://sheets.googleapis.com/v4', + { pageGapiUiSheetsSession: true } + ); + check(sheetsSession.sameOrigin === true && sheetsSession.separate === false + && typeof sheetsSession.reason === 'string' + && sheetsSession.reason.indexOf('PAGE_GAPI_UI_SHEETS_SESSION') === 0, + '(b) docs.google.com -> exact sheets.googleapis.com/v4 passes only through the explicit page-gapi/UI Sheets session accommodation'); + const sheetsWrongHead = gate.classifyOriginPattern( + 'https://drive.google.com', + 'https://sheets.googleapis.com/v4', + { pageGapiUiSheetsSession: true } + ); + check(sheetsWrongHead.sameOrigin === false && sheetsWrongHead.separate === true + && String(sheetsWrongHead.reason || '').indexOf('CORS_SEPARATE_ORIGIN') === 0, + '(b) the page-gapi/UI Sheets accommodation rejects a non-docs.google.com head origin'); + const sheetsWrongApi = gate.classifyOriginPattern( + 'https://docs.google.com', + 'https://content.googleapis.com/v4', + { pageGapiUiSheetsSession: true } + ); + check(sheetsWrongApi.sameOrigin === false && sheetsWrongApi.separate === true + && String(sheetsWrongApi.reason || '').indexOf('CORS_SEPARATE_ORIGIN') === 0, + '(b) the page-gapi/UI Sheets accommodation rejects a non-sheets.googleapis.com API origin'); + const sheetsBroaderPath = gate.classifyOriginPattern( + 'https://docs.google.com', + 'https://sheets.googleapis.com/v5', + { pageGapiUiSheetsSession: true } + ); + check(sheetsBroaderPath.sameOrigin === false && sheetsBroaderPath.separate === true + && String(sheetsBroaderPath.reason || '').indexOf('CORS_SEPARATE_ORIGIN') === 0, + '(b) the page-gapi/UI Sheets accommodation rejects a broader path on the otherwise-correct API origin'); + + const liveSheetsSources = gate.verifyPageGapiUiSheetsSessionSources(); + check(liveSheetsSources && liveSheetsSources.ok === true + && Array.isArray(liveSheetsSources.failures) && liveSheetsSources.failures.length === 0, + '(b) the live Sheets manifest/session/action/handler/descriptors satisfy the exact source-verified no-auth session contract'); + const sheetsSessionSource = fs.readFileSync( + path.join(ROOT, 'extension', 'utils', 'google-sheets-session.js'), + 'utf8' + ); + const broadenedSheetsSources = gate.verifyPageGapiUiSheetsSessionSources({ + sessionText: sheetsSessionSource + '\nfetch(request.url);' + }); + check(broadenedSheetsSources && broadenedSheetsSources.ok === false + && broadenedSheetsSources.failures.includes('SHEETS_GAPI_REQUEST_NOT_FIXED') + && broadenedSheetsSources.failures.includes('FORBIDDEN_SHEETS_CREDENTIAL_OR_NETWORK_SOURCE'), + '(b) negative control: adding caller-selected fetch(request.url) fails both the fixed-gapi and forbidden-network source checks'); + // (b) REAL end-to-end: checkOriginClassification() over the LIVE catalog + vendored // slack-api.ts -- proves the real heads all pass and slack rides the dynamic // accommodation against the genuinely-extracted vendored dynamic form (not a stub). @@ -610,8 +662,8 @@ function check(cond, msg) { const realGsheets = real && real.results ? real.results.find((r) => r.global === 'FsbHandlerGsheets') : null; check(realGsheets && realGsheets.apiBaseUrl === 'https://sheets.googleapis.com/v4' && realGsheets.classification && realGsheets.classification.sameOrigin === true && - String(realGsheets.classification.reason || '').startsWith('CHROME_IDENTITY_SHEETS_API'), - '(b) the REAL Google Sheets head uses the exact source-verified Chrome Identity API accommodation'); + String(realGsheets.classification.reason || '').startsWith('PAGE_GAPI_UI_SHEETS_SESSION'), + '(b) the REAL Google Sheets head uses the exact source-verified signed-in page-gapi/UI session accommodation'); const realPowerpoint = real && real.results ? real.results.find((r) => r.global === 'FsbHandlerPowerpoint') : null; check(!!realPowerpoint && realPowerpoint.apiBaseUrl === 'https://graph.microsoft.com/v1.0' && realPowerpoint.classification && realPowerpoint.classification.sameOrigin === true From c1d9ed6c2f73673c96a1597a8ba0338e40482753 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Wed, 15 Jul 2026 13:56:17 -0500 Subject: [PATCH 17/26] fix(sheets): enforce fail-closed UI recovery --- extension/content/actions.js | 118 +++++++--- extension/utils/google-sheets-session.js | 46 +++- extension/utils/google-sheets-ui.js | 72 +++++-- .../utils/spreadsheet-record-redaction.js | 15 +- extension/ws/mcp-bridge-client.js | 5 +- scripts/verify-origin-classification.mjs | 25 ++- tests/google-sheets-content-actions.test.js | 202 +++++++++++++++++- tests/google-sheets-session.test.js | 104 +++++++++ tests/spreadsheet-record-redaction.test.js | 114 ++++++++++ tests/verify-origin-classification.test.js | 13 ++ 10 files changed, 649 insertions(+), 65 deletions(-) diff --git a/extension/content/actions.js b/extension/content/actions.js index 6f97ca928..2f4ff54ac 100644 --- a/extension/content/actions.js +++ b/extension/content/actions.js @@ -1553,6 +1553,17 @@ function sheetsNameBox() { return document.querySelector('#t-name-box'); } +function sheetsComparableReference(reference) { + const text = String(reference || '').trim(); + const delimiter = text.lastIndexOf('!'); + return (delimiter === -1 ? text : text.slice(delimiter + 1)).replace(/\$/g, '').toUpperCase(); +} + +function sheetsTrustedKey(result, reason) { + if (result && result.success === true && result.method === 'debuggerAPI') return { success: true }; + return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', reason || 'trusted-keyboard-unavailable'); +} + async function sheetsNavigate(reference, settleMs = 80) { const nameBox = sheetsNameBox(); if (!nameBox) return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'name-box-unavailable'); @@ -1560,17 +1571,31 @@ async function sheetsNavigate(reference, settleMs = 80) { nameBox.focus(); nameBox.click(); await sheetsDelay(25); - await tools.keyPress({ key: 'a', ctrlKey: true, useDebuggerAPI: true }); - await tools.typeWithKeys({ text: String(reference), clearFirst: false, delay: 5 }); - await tools.keyPress({ key: 'Enter', useDebuggerAPI: true }); + const selected = sheetsTrustedKey( + await tools.keyPress({ key: 'a', ctrlKey: true, useDebuggerAPI: true }), + 'name-box-select-not-trusted' + ); + if (!selected.success) return selected; + const typed = await tools.typeWithKeys({ text: String(reference), clearFirst: false, delay: 5 }); + if (!typed || typed.success !== true || typed.method !== 'debuggerAPI') { + return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'name-box-type-not-trusted'); + } + const entered = sheetsTrustedKey( + await tools.keyPress({ key: 'Enter', useDebuggerAPI: true }), + 'name-box-enter-not-trusted' + ); + if (!entered.success) return entered; await sheetsDelay(settleMs); - return { success: true }; + if (sheetsComparableReference(sheetsActiveAddress()) !== sheetsComparableReference(reference)) { + return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'name-box-navigation-not-confirmed'); + } + return { success: true, address: sheetsActiveAddress() }; } catch (_error) { return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'name-box-navigation-failed'); } } -function sheetsFormulaBarValue() { +function sheetsFormulaBarState() { const selectors = ['#t-formula-bar-input', '.cell-input', '[aria-label="Formula bar"]']; for (const selector of selectors) { const element = document.querySelector(selector); @@ -1578,9 +1603,11 @@ function sheetsFormulaBarValue() { const editable = element.querySelector?.('[contenteditable="true"]'); const candidate = editable || element; const value = candidate.value !== undefined ? candidate.value : (candidate.innerText ?? candidate.textContent ?? ''); - if (value !== undefined && value !== null) return String(value).replace(/\u200B/g, ''); + if (value !== undefined && value !== null) { + return { success: true, value: String(value).replace(/\u200B/g, '') }; + } } - return ''; + return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'formula-bar-unavailable'); } function sheetsActiveAddress() { @@ -1596,14 +1623,20 @@ async function sheetsReadValues(range, majorDimension) { return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-read-range-limit-exceeded'); } try { - await tools.keyPress({ key: 'Escape', useDebuggerAPI: true }); + const escaped = sheetsTrustedKey( + await tools.keyPress({ key: 'Escape', useDebuggerAPI: true }), + 'grid-escape-not-trusted' + ); + if (!escaped.success) return escaped; const values = []; for (let row = parsed.startRow; row <= parsed.endRow; row++) { const outputRow = []; for (let column = parsed.startColumn; column <= parsed.endColumn; column++) { const navigation = await sheetsNavigate(sheetsUi.cellReference(parsed, column, row), 55); if (!navigation.success) return navigation; - outputRow.push(sheetsFormulaBarValue()); + const formula = sheetsFormulaBarState(); + if (!formula.success) return formula; + outputRow.push(formula.value); } values.push(outputRow); } @@ -1656,7 +1689,7 @@ async function sheetsPasteEncoded(parsed, encoded, startRow) { mutationStarted = true; try { const pasted = await tools.keyPress({ key: 'v', ctrlKey: true, useDebuggerAPI: true }); - if (!pasted || pasted.success !== true) { + if (!pasted || pasted.success !== true || pasted.method !== 'debuggerAPI') { return sheetsError('RECOVERY_AMBIGUOUS', 'ui-paste-outcome-unknown', mutationStarted); } await sheetsDelay(180); @@ -1681,28 +1714,33 @@ async function sheetsUpdateValues(range, values, valueInputOption) { if (!sheetsUi) return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'sheets-ui-helper-unavailable'); const parsed = sheetsUi.parseA1Range(range); if (!parsed || parsed.columnOnly) return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-update-requires-a1-start-cell'); - const encoded = sheetsUi.encodeValues(values, valueInputOption || 'RAW'); + const inputOption = valueInputOption || 'RAW'; + if (inputOption !== 'RAW' && inputOption !== 'USER_ENTERED') { + return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'unsupported-value-input-option'); + } + const encoded = sheetsUi.encodeValues(values, inputOption); if (!encoded.success) return sheetsError('RECIPE_DOM_FALLBACK_PENDING', encoded.reason); return sheetsPasteEncoded(parsed, encoded, parsed.startRow); } -async function sheetsFindAppendRow(parsed) { - const firstCell = sheetsUi.cellReference(parsed, parsed.startColumn, parsed.startRow || 1); - const navigation = await sheetsNavigate(firstCell, 70); - if (!navigation.success) return navigation; - const startValue = sheetsFormulaBarValue(); - if (startValue === '') return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-append-boundary-ambiguous'); - try { - await tools.keyPress({ key: 'ArrowDown', ctrlKey: true, useDebuggerAPI: true }); - await sheetsDelay(90); - } catch (_error) { - return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-append-boundary-navigation-failed'); +async function sheetsFindAppendRow(parsed, rowsNeeded, insertDataOption) { + if (parsed.columns > sheetsUi.limits.maxAppendColumns) { + return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-append-column-limit-exceeded'); } - const boundary = sheetsUi.appendRowFromBoundary( + const mode = insertDataOption || 'OVERWRITE'; + const scanRows = sheetsUi.limits.maxAppendTableRows + (mode === 'OVERWRITE' ? rowsNeeded : 1); + const startRow = parsed.startRow || 1; + const candidateEnd = startRow + scanRows - 1; + const endRow = parsed.endRow === null ? candidateEnd : Math.min(parsed.endRow, candidateEnd); + const scanRange = `${parsed.sheetPrefix}${sheetsUi.numberToColumn(parsed.startColumn)}${startRow}:` + + `${sheetsUi.numberToColumn(parsed.endColumn)}${endRow}`; + const scan = await sheetsReadValues(scanRange, 'ROWS'); + if (!scan.success) return scan; + const boundary = sheetsUi.appendRowFromTable( parsed, - startValue, - sheetsActiveAddress(), - sheetsFormulaBarValue() + scan.data.values, + rowsNeeded, + mode ); return boundary.success ? boundary @@ -1713,19 +1751,32 @@ async function sheetsAppendValues(range, values, valueInputOption, insertDataOpt if (!sheetsUi) return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'sheets-ui-helper-unavailable'); const parsed = sheetsUi.parseA1Range(range); if (!parsed) return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-append-requires-a1-range'); - const encoded = sheetsUi.encodeValues(values, valueInputOption || 'RAW'); + const inputOption = valueInputOption || 'RAW'; + const dataOption = insertDataOption || 'OVERWRITE'; + if (inputOption !== 'RAW' && inputOption !== 'USER_ENTERED') { + return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'unsupported-value-input-option'); + } + if (dataOption !== 'OVERWRITE' && dataOption !== 'INSERT_ROWS') { + return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'unsupported-insert-data-option'); + } + const encoded = sheetsUi.encodeValues(values, inputOption); if (!encoded.success) return sheetsError('RECIPE_DOM_FALLBACK_PENDING', encoded.reason); - const boundary = await sheetsFindAppendRow(parsed); + if (encoded.rows > sheetsUi.limits.maxAppendRows || encoded.columns > parsed.columns) { + return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-append-shape-limit-exceeded'); + } + const boundary = await sheetsFindAppendRow(parsed, encoded.rows, dataOption); if (!boundary.success) return boundary; let mutationStarted = false; - if ((insertDataOption || 'OVERWRITE') === 'INSERT_ROWS') { + if (dataOption === 'INSERT_ROWS') { const lastRow = boundary.row + encoded.rows - 1; const selected = await sheetsNavigate(`${parsed.sheetPrefix}${boundary.row}:${lastRow}`, 60); if (!selected.success) return selected; mutationStarted = true; try { const inserted = await tools.keyPress({ key: '=', ctrlKey: true, altKey: true, useDebuggerAPI: true }); - if (!inserted || inserted.success !== true) return sheetsError('RECOVERY_AMBIGUOUS', 'ui-row-insert-outcome-unknown', true); + if (!inserted || inserted.success !== true || inserted.method !== 'debuggerAPI') { + return sheetsError('RECOVERY_AMBIGUOUS', 'ui-row-insert-outcome-unknown', true); + } await sheetsDelay(150); } catch (_error) { return sheetsError('RECOVERY_AMBIGUOUS', 'ui-row-insert-outcome-unknown', true); @@ -1749,7 +1800,9 @@ async function sheetsClearValues(range) { if (!selected.success) return selected; try { const deleted = await tools.keyPress({ key: 'Delete', useDebuggerAPI: true }); - if (!deleted || deleted.success !== true) return sheetsError('RECOVERY_AMBIGUOUS', 'ui-clear-outcome-unknown', true); + if (!deleted || deleted.success !== true || deleted.method !== 'debuggerAPI') { + return sheetsError('RECOVERY_AMBIGUOUS', 'ui-clear-outcome-unknown', true); + } await sheetsDelay(160); } catch (_error) { return sheetsError('RECOVERY_AMBIGUOUS', 'ui-clear-outcome-unknown', true); @@ -1770,6 +1823,9 @@ async function sheetsClearValues(range) { } function sheetsSpreadsheetMetadata() { + if (!sheetsNameBox()) return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'name-box-unavailable'); + const formula = sheetsFormulaBarState(); + if (!formula.success) return formula; const titleInput = document.querySelector('.docs-title-input, #docs-title-input, .docs-title-widget input'); const title = String(titleInput?.value || document.title || '').replace(/\s+-\s+Google Sheets\s*$/i, ''); const tabNodes = Array.from(document.querySelectorAll('.docs-sheet-tab-name, .docs-sheet-tab [role="tab"], [role="tab"][aria-label]')); diff --git a/extension/utils/google-sheets-session.js b/extension/utils/google-sheets-session.js index 76c007dbb..4badcf318 100644 --- a/extension/utils/google-sheets-session.js +++ b/extension/utils/google-sheets-session.js @@ -80,6 +80,16 @@ var args = request && request.args && typeof request.args === 'object' ? request.args : {}; var spreadsheetId = String(request && request.spreadsheetId || ''); var mutating = operation === 'updateValues' || operation === 'appendValues' || operation === 'clearValues'; + var pageLocation = globalThis.location; + var pageMatch = pageLocation && pageLocation.origin === 'https://docs.google.com' + ? String(pageLocation.pathname || '').match(/^\/spreadsheets\/d\/([A-Za-z0-9_-]{10,200})(?:\/|$)/) + : null; + if (!pageMatch || pageMatch[1] !== spreadsheetId) { + return error('GOOGLE_SHEETS_TARGET_MISMATCH', { + reason: 'page-spreadsheet-changed-before-request', + requestSent: false + }); + } var gapiClient = globalThis.gapi && globalThis.gapi.client; if (!gapiClient || typeof gapiClient.request !== 'function') { return error('GOOGLE_SHEETS_SESSION_UNAVAILABLE', { @@ -156,7 +166,7 @@ knownNoEffect: true }); } - if (mutating && status >= 500 && status <= 599) { + if (mutating && (status === 408 || (status >= 500 && status <= 599))) { return error('RECOVERY_AMBIGUOUS', { reason: 'page-gapi-server-failure', status: status, @@ -185,7 +195,7 @@ knownNoEffect: true }); } - if (mutating && responseStatus >= 500 && responseStatus <= 599) { + if (mutating && (responseStatus === 408 || (responseStatus >= 500 && responseStatus <= 599))) { return error('RECOVERY_AMBIGUOUS', { reason: 'page-gapi-server-failure', status: responseStatus, @@ -211,6 +221,7 @@ deps = deps || {}; var chromeApi = deps.chrome || global.chrome; var timeoutMs = deps.requestTimeoutMs || REQUEST_TIMEOUT_MS; + var mutationChains = new Map(); async function resolveTarget(params, context) { params = params || {}; @@ -307,23 +318,44 @@ } if (!result || result.success !== true) { if (result && result.code) { return result; } - return MUTATIONS[operation] && result && result.mutationStarted + return MUTATIONS[operation] ? typedError('RECOVERY_AMBIGUOUS', { reason: 'ui-mutation-outcome-unknown' }) : typedError('RECIPE_DOM_FALLBACK_PENDING', { reason: result && result.reason || 'sheets-ui-operation-unavailable' }); } return result; } - async function execute(operation, params, context) { - var target = await resolveTarget(params, context); - if (!target.success) { return target; } - var args = safeArgs(operation, params); + async function executeTransport(target, operation, args) { var pageResult = await runPageClient(target, operation, args); if (pageResult && pageResult.success === true) { return pageResult; } if (!pageResult || pageResult.safeToFallback !== true) { return pageResult; } + if (MUTATIONS[operation] && pageResult.requestSent !== false && pageResult.knownNoEffect !== true) { + return typedError('RECOVERY_AMBIGUOUS', { reason: 'page-fallback-effect-not-proven' }); + } return runUi(target, operation, args); } + function queued(tabId, task) { + var prior = mutationChains.get(tabId) || Promise.resolve(); + var current = prior.catch(function() {}).then(task); + mutationChains.set(tabId, current); + return current.finally(function() { + if (mutationChains.get(tabId) === current) { mutationChains.delete(tabId); } + }); + } + + async function execute(operation, params, context) { + var target = await resolveTarget(params, context); + if (!target.success) { return target; } + var args = safeArgs(operation, params); + if (MUTATIONS[operation]) { + return queued(target.tabId, function() { return executeTransport(target, operation, args); }); + } + var activeMutation = mutationChains.get(target.tabId); + if (activeMutation) { await activeMutation.catch(function() {}); } + return executeTransport(target, operation, args); + } + return { getSpreadsheet: function (params, context) { return execute('getSpreadsheet', params, context); }, getValues: function (params, context) { return execute('getValues', params, context); }, diff --git a/extension/utils/google-sheets-ui.js b/extension/utils/google-sheets-ui.js index aec1fd96e..5894e5990 100644 --- a/extension/utils/google-sheets-ui.js +++ b/extension/utils/google-sheets-ui.js @@ -8,20 +8,25 @@ var MAX_CHUNK_CELLS = 5000; var MAX_TOTAL_BYTES = 1024 * 1024; var MAX_CHUNK_BYTES = 256 * 1024; + var MAX_SHEETS_COLUMNS = 18278; + var MAX_SHEETS_ROWS = 10000000; + var MAX_APPEND_TABLE_ROWS = 25; + var MAX_APPEND_COLUMNS = 10; + var MAX_APPEND_ROWS = 25; function columnToNumber(column) { var value = String(column || '').toUpperCase(); - if (!/^[A-Z]+$/.test(value)) { return 0; } + if (!/^[A-Z]{1,3}$/.test(value)) { return 0; } var number = 0; for (var i = 0; i < value.length; i++) { number = number * 26 + value.charCodeAt(i) - 64; } - return number; + return number <= MAX_SHEETS_COLUMNS ? number : 0; } function numberToColumn(number) { number = Number(number); - if (!Number.isInteger(number) || number < 1) { return ''; } + if (!Number.isInteger(number) || number < 1 || number > MAX_SHEETS_COLUMNS) { return ''; } var out = ''; while (number > 0) { var remainder = (number - 1) % 26; @@ -57,7 +62,8 @@ var endColumn = columnToNumber(cellMatch[3] || cellMatch[1]); var startRow = Number(cellMatch[2]); var endRow = Number(cellMatch[4] || cellMatch[2]); - if (!startColumn || !endColumn || startRow < 1 || endRow < startRow || endColumn < startColumn) { return null; } + if (!startColumn || !endColumn || !Number.isSafeInteger(startRow) || !Number.isSafeInteger(endRow) || + startRow < 1 || endRow > MAX_SHEETS_ROWS || endRow < startRow || endColumn < startColumn) { return null; } return { sheetPrefix: parts.sheetPrefix, startColumn: startColumn, @@ -114,6 +120,10 @@ } function encodeValues(values, valueInputOption) { + var inputOption = valueInputOption || 'RAW'; + if (inputOption !== 'RAW' && inputOption !== 'USER_ENTERED') { + return { success: false, reason: 'unsupported-value-input-option' }; + } if (!Array.isArray(values) || values.length === 0 || !Array.isArray(values[0]) || values[0].length === 0) { return { success: false, reason: 'values-must-be-a-non-empty-matrix' }; } @@ -128,7 +138,7 @@ } var encodedRow = []; for (var columnIndex = 0; columnIndex < row.length; columnIndex++) { - var normalized = normalizeCell(row[columnIndex], valueInputOption); + var normalized = normalizeCell(row[columnIndex], inputOption); if (normalized.error) { return { success: false, reason: normalized.error }; } encodedRow.push(normalized.value); } @@ -184,17 +194,44 @@ return output; } - function appendRowFromBoundary(parsed, startValue, activeAddress, activeValue) { - if (!parsed || String(startValue || '') === '') { + function isEmptyCell(value) { + return value === '' || value === null || value === undefined; + } + + function appendRowFromTable(parsed, values, rowsNeeded, insertDataOption) { + var requiredRows = Number(rowsNeeded); + var mode = insertDataOption || 'OVERWRITE'; + if (!parsed || !Array.isArray(values) || !Number.isInteger(requiredRows) || + requiredRows < 1 || requiredRows > MAX_APPEND_ROWS || + (mode !== 'OVERWRITE' && mode !== 'INSERT_ROWS')) { return { success: false, reason: 'ui-append-boundary-ambiguous' }; } - var active = parseA1Range(activeAddress); - if (!active || active.columnOnly || active.startColumn !== parsed.startColumn || - active.startRow < (parsed.startRow || 1) || active.startRow > 100000 || - String(activeValue || '') === '') { - return { success: false, reason: 'ui-append-boundary-ambiguous' }; + var boundaryIndex = -1; + for (var index = 0; index < values.length; index++) { + var row = values[index]; + if (!Array.isArray(row) || row.length !== parsed.columns) { + return { success: false, reason: 'ui-append-boundary-ambiguous' }; + } + var empty = row.every(isEmptyCell); + var full = row.every(function(value) { return !isEmptyCell(value); }); + if (boundaryIndex === -1) { + if (full && index < MAX_APPEND_TABLE_ROWS) { continue; } + if (!empty || index === 0 || index > MAX_APPEND_TABLE_ROWS) { + return { success: false, reason: 'ui-append-boundary-ambiguous' }; + } + boundaryIndex = index; + if (mode === 'INSERT_ROWS') { + return { success: true, row: (parsed.startRow || 1) + boundaryIndex }; + } + } + if (index < boundaryIndex + requiredRows && !empty) { + return { success: false, reason: 'ui-append-target-not-empty' }; + } + if (index === boundaryIndex + requiredRows - 1) { + return { success: true, row: (parsed.startRow || 1) + boundaryIndex }; + } } - return { success: true, row: active.startRow + 1 }; + return { success: false, reason: 'ui-append-boundary-ambiguous' }; } function valuesAreEmpty(values) { @@ -248,7 +285,7 @@ cellReference: cellReference, encodeValues: encodeValues, transpose: transpose, - appendRowFromBoundary: appendRowFromBoundary, + appendRowFromTable: appendRowFromTable, valuesAreEmpty: valuesAreEmpty, csvToValues: csvToValues, valuesToCsv: valuesToCsv, @@ -259,7 +296,12 @@ maxClearCells: MAX_CLEAR_CELLS, maxChunkCells: MAX_CHUNK_CELLS, maxTotalBytes: MAX_TOTAL_BYTES, - maxChunkBytes: MAX_CHUNK_BYTES + maxChunkBytes: MAX_CHUNK_BYTES, + maxSheetsColumns: MAX_SHEETS_COLUMNS, + maxSheetsRows: MAX_SHEETS_ROWS, + maxAppendTableRows: MAX_APPEND_TABLE_ROWS, + maxAppendColumns: MAX_APPEND_COLUMNS, + maxAppendRows: MAX_APPEND_ROWS }) }); global.FsbGoogleSheetsUi = api; diff --git a/extension/utils/spreadsheet-record-redaction.js b/extension/utils/spreadsheet-record-redaction.js index 5750b5a68..3bc613b64 100644 --- a/extension/utils/spreadsheet-record-redaction.js +++ b/extension/utils/spreadsheet-record-redaction.js @@ -8,8 +8,14 @@ 'gsheets.append_values': true, 'gsheets.clear_values': true }); - var LEGACY_TOOLS = Object.freeze({ fill_sheet: true, read_sheet: true }); + var LEGACY_TOOLS = Object.freeze({ + fill_sheet: true, + read_sheet: true, + fillsheet: true, + readsheet: true + }); var SAFE_ERROR_CODE = /^(?:GOOGLE_SHEETS|RECIPE)_[A-Z0-9_]{1,64}$/; + var SAFE_EXACT_ERROR_CODES = Object.freeze({ RECOVERY_AMBIGUOUS: true }); function object(value) { return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; @@ -101,6 +107,7 @@ params = object(params); if (Array.isArray(params.values)) { return arrayShape(params.values); } if (typeof params.csvData === 'string') { return csvShape(params.csvData); } + if (typeof params.data === 'string') { return csvShape(params.data); } return { rowCount: 0, columnCount: 0, valueCount: 0 }; } @@ -117,7 +124,7 @@ data.values, data.rows ]; - var shape = null; + var shape = typeof rawData === 'string' ? csvShape(rawData) : null; for (var i = 0; i < candidates.length; i++) { if (Array.isArray(candidates[i])) { shape = arrayShape(candidates[i]); break; } } @@ -161,7 +168,9 @@ out.status = Math.floor(value.status); } var code = typeof value.errorCode === 'string' ? value.errorCode : value.code; - if (typeof code === 'string' && SAFE_ERROR_CODE.test(code)) { out.errorCode = code; } + if (typeof code === 'string' && (SAFE_ERROR_CODE.test(code) || SAFE_EXACT_ERROR_CODES[code])) { + out.errorCode = code; + } return out; } diff --git a/extension/ws/mcp-bridge-client.js b/extension/ws/mcp-bridge-client.js index 2c14e41a9..0beb0d57a 100644 --- a/extension/ws/mcp-bridge-client.js +++ b/extension/ws/mcp-bridge-client.js @@ -730,7 +730,10 @@ class MCPBridgeClient { tabId: Number.isFinite(resolvedTabId) ? resolvedTabId : null }; const spreadsheetRedactor = globalThis.FsbSpreadsheetRecordRedaction; - const spreadsheetTool = payload && (payload.tool === 'fill_sheet' || payload.tool === 'read_sheet'); + const spreadsheetTool = payload && ( + payload.tool === 'fill_sheet' || payload.tool === 'read_sheet' || + payload.tool === 'fillsheet' || payload.tool === 'readsheet' + ); if (!spreadsheetRedactor || typeof spreadsheetRedactor.recordSafely !== 'function') { if (!spreadsheetTool) { globalThis.fsbMcpSessionRecorder.recordAction(sessionRecordEntry); diff --git a/scripts/verify-origin-classification.mjs b/scripts/verify-origin-classification.mjs index 158a37dd1..ca865045d 100644 --- a/scripts/verify-origin-classification.mjs +++ b/scripts/verify-origin-classification.mjs @@ -4511,6 +4511,17 @@ export function verifyPageGapiUiSheetsSessionSources(sourceOverrides) { if (!handlerText) { failures.push('SHEETS_HANDLER_MISSING'); } if (!contentActionsText) { failures.push('SHEETS_CONTENT_ACTIONS_MISSING'); } + const contentHelpersStart = contentActionsText.indexOf('// Google Sheets signed-in-tab UI transport'); + const contentHelpersEnd = contentActionsText.indexOf('// Tool functions for browser automation', contentHelpersStart); + const contentActionStart = contentActionsText.search(/\bsheetsSession\s*:\s*async\b/); + const contentActionEnd = contentActionsText.indexOf('// GOOGLE SHEETS: fillsheet', contentActionStart); + const sheetsContentText = contentHelpersStart !== -1 && contentHelpersEnd > contentHelpersStart + && contentActionStart !== -1 && contentActionEnd > contentActionStart + ? contentActionsText.slice(contentHelpersStart, contentHelpersEnd) + + '\n' + contentActionsText.slice(contentActionStart, contentActionEnd) + : ''; + if (!sheetsContentText) { failures.push('SHEETS_CONTENT_ACTION_NOT_FIXED'); } + const fixedMethods = ['getSpreadsheet', 'getValues', 'updateValues', 'appendValues', 'clearValues']; const sessionReturn = sessionText.match( /return\s*\{([\s\S]*?)\};\s*\}\s*var\s+session\s*=\s*createSession\s*\(\s*\)/ @@ -4528,6 +4539,8 @@ export function verifyPageGapiUiSheetsSessionSources(sourceOverrides) { && /chromeApi\.tabs\.get\(tabId\)/.test(sessionText) && /spreadsheetIdFromUrl\(tab\s*&&\s*tab\.url\)/.test(sessionText) && /explicit\s*!==\s*spreadsheetId/.test(sessionText) + && /pageLocation\.origin\s*===\s*['"]https:\/\/docs\.google\.com['"]/.test(sessionText) + && /pageMatch\[1\]\s*!==\s*spreadsheetId/.test(sessionText) && /target:\s*\{\s*tabId:\s*target\.tabId\s*\}/.test(sessionText) && /sendMessage\(target\.tabId,\s*\{/.test(sessionText) && !/chromeApi\.tabs\.(?:query|create|update)\s*\(/.test(sessionText); @@ -4552,7 +4565,7 @@ export function verifyPageGapiUiSheetsSessionSources(sourceOverrides) { && !/(?:request|args|params)\.(?:url|path|method|headers|body)\b/.test(sessionText); if (!fixedGapiRequestOk) { failures.push('SHEETS_GAPI_REQUEST_NOT_FIXED'); } - const forbiddenSheetsSource = sessionText + '\n' + handlerText; + const forbiddenSheetsSource = sessionText + '\n' + handlerText + '\n' + sheetsContentText; const hasForbiddenCredentialOrNetworkSource = /chrome(?:Api)?\.identity\b|\bgetAuthToken\b/i.test(forbiddenSheetsSource) || /\bgapi(?:Client)?\.(?:auth|load|init)\b|\bgoogle\.accounts\b|\binit(?:Token|Code)Client\b|\brequestAccessToken\b/i.test(forbiddenSheetsSource) @@ -4563,8 +4576,14 @@ export function verifyPageGapiUiSheetsSessionSources(sourceOverrides) { || /\bfetch\s*\(|\bXMLHttpRequest\b|\bsendBeacon\s*\(|\bWebSocket\s*\(|\bEventSource\s*\(/i.test(forbiddenSheetsSource); if (hasForbiddenCredentialOrNetworkSource) { failures.push('FORBIDDEN_SHEETS_CREDENTIAL_OR_NETWORK_SOURCE'); } - const contentActionOk = /\bsheetsSession\s*:\s*async\b/.test(contentActionsText) - || /\btools\.sheetsSession\s*=\s*async\b/.test(contentActionsText); + const contentActionOk = !!sheetsContentText + && /\bsheetsSession\s*:\s*async\b/.test(sheetsContentText) + && /isGoogleSheetsPage\(\)/.test(sheetsContentText) + && /params\.spreadsheetId\s*&&\s*params\.spreadsheetId\s*!==\s*activeId/.test(sheetsContentText) + && ['getSpreadsheet', 'getValues', 'updateValues', 'appendValues', 'clearValues'].every(function(operation) { + return sheetsContentText.indexOf("operation === '" + operation + "'") !== -1; + }) + && !/(?:params|args)\.(?:url|path|method|headers|body)\b/.test(sheetsContentText); if (!contentActionOk) { failures.push('SHEETS_SESSION_CONTENT_ACTION_MISSING'); } const slugs = [ diff --git a/tests/google-sheets-content-actions.test.js b/tests/google-sheets-content-actions.test.js index cad168b99..0d4905e92 100644 --- a/tests/google-sheets-content-actions.test.js +++ b/tests/google-sheets-content-actions.test.js @@ -4,9 +4,118 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); +const vm = require('node:vm'); const ui = require('../extension/utils/google-sheets-ui.js'); const ROOT = path.resolve(__dirname, '..'); +const ACTIONS_SOURCE = fs.readFileSync(path.join(ROOT, 'extension/content/actions.js'), 'utf8'); + +function sheetsDomHarness(options = {}) { + const start = ACTIONS_SOURCE.indexOf('// Google Sheets signed-in-tab UI transport'); + const end = ACTIONS_SOURCE.indexOf('// Tool functions for browser automation', start); + assert.ok(start >= 0 && end > start, 'Sheets content helper source region exists'); + + const cells = new Map(Object.entries(options.cells || {})); + let selectedAddress = options.initialAddress || 'A1'; + let pendingAddress = selectedAddress; + let pasteCalls = 0; + let deleteCalls = 0; + const nameBox = options.missingNameBox ? null : { + value: selectedAddress, + textContent: selectedAddress, + focus() {}, + click() {} + }; + const formulaBar = options.missingFormulaBar ? null : { + querySelector() { return null; }, + get value() { return cells.get(selectedAddress) ?? ''; } + }; + const trusted = { success: true, method: 'debuggerAPI' }; + const tools = { + async keyPress(params) { + if (typeof options.keyPress === 'function') { + const override = await options.keyPress(params, { selectedAddress, cells }); + if (override) return override; + } + if (params.key === 'Enter') { + if (options.confirmNavigation !== false) { + selectedAddress = pendingAddress.replace(/^.*!/, '').replace(/\$/g, '').toUpperCase(); + } + if (nameBox) nameBox.value = selectedAddress; + } + if (params.key === 'v' && params.ctrlKey) pasteCalls++; + if (params.key === 'Delete') { + deleteCalls++; + if (options.deleteHasEffect !== false) cells.set(selectedAddress, ''); + } + return trusted; + }, + async typeWithKeys(params) { + if (options.typeResult) return options.typeResult; + pendingAddress = String(params.text); + if (nameBox) nameBox.value = pendingAddress; + return { success: true, method: 'debuggerAPI' }; + } + }; + const document = { + title: options.title || 'Disposable - Google Sheets', + querySelector(selector) { + if (selector === '#t-name-box') return nameBox; + if (selector === '#t-formula-bar-input' || selector === '.cell-input' || selector === '[aria-label="Formula bar"]') { + return formulaBar; + } + if (selector.includes('docs-title-input')) { + return options.missingTitle ? null : { value: options.title || 'Disposable' }; + } + return null; + }, + querySelectorAll() { return options.tabNodes || []; } + }; + const context = { + console, + FsbGoogleSheetsUi: ui, + __tools: tools, + document, + navigator: { + clipboard: { + async writeText(text) { + if (options.clipboardError) throw options.clipboardError; + context.__clipboard = text; + } + } + }, + window: { + location: { + hostname: options.hostname || 'docs.google.com', + pathname: options.pathname || '/spreadsheets/d/abcdefghijklmnopqrstuvwxyz123456/edit' + } + }, + setTimeout(callback) { callback(); return 1; }, + clearTimeout() {}, + globalThis: null + }; + context.globalThis = context; + const exports = [ + 'sheetsNavigate', + 'sheetsReadValues', + 'sheetsUpdateValues', + 'sheetsAppendValues', + 'sheetsClearValues', + 'sheetsSpreadsheetMetadata' + ]; + vm.runInNewContext( + `${ACTIONS_SOURCE.slice(start, end)}\nconst tools = globalThis.__tools;\n` + + `globalThis.__sheetsInternals = { ${exports.join(', ')} };`, + context, + { filename: 'extension/content/actions.sheets-region.js' } + ); + return { + api: context.__sheetsInternals, + cells, + get pasteCalls() { return pasteCalls; }, + get deleteCalls() { return deleteCalls; } + }; +} test('parses bounded A1, quoted-sheet, single-cell, and column append ranges', () => { assert.deepEqual(ui.parseA1Range('A1:B2'), { @@ -27,6 +136,10 @@ test('parses bounded A1, quoted-sheet, single-cell, and column append ranges', ( assert.equal(columns.columns, 3); assert.equal(ui.parseA1Range('A0:B2'), null); assert.equal(ui.parseA1Range('https://attacker.example'), null); + assert.equal(ui.parseA1Range(`${'Z'.repeat(400)}1`), null); + assert.equal(ui.parseA1Range(`A${'9'.repeat(400)}`), null); + assert.equal(ui.parseA1Range('ZZZ10000000').endRow, 10000000); + assert.equal(ui.parseA1Range('AAAA1'), null); }); test('encodes RAW strings as literal text and preserves USER_ENTERED formulas', () => { @@ -41,6 +154,7 @@ test('encodes RAW strings as literal text and preserves USER_ENTERED formulas', const entered = ui.encodeValues([['=1+1']], 'USER_ENTERED'); assert.equal(entered.success, true); assert.equal(entered.chunks[0].text, '=1+1'); + assert.equal(ui.encodeValues([['=1+1']], 'FORMULA').reason, 'unsupported-value-input-option'); }); test('fails closed for lossy matrices and row-chunks large bounded writes', () => { @@ -66,22 +180,100 @@ test('legacy CSV conversion and read transpose reuse the shared value helpers', test('append boundaries and clear readback fail closed when UI state is ambiguous', () => { const parsed = ui.parseA1Range('Data!A:C'); - assert.deepEqual(ui.appendRowFromBoundary(parsed, '', 'A1', ''), { + assert.deepEqual(ui.appendRowFromTable(parsed, [ + ['', '', ''], + ['', '', ''] + ], 1, 'OVERWRITE'), { success: false, reason: 'ui-append-boundary-ambiguous' }); - assert.equal(ui.appendRowFromBoundary(parsed, 'header', 'B8', 'value').success, false); - assert.deepEqual(ui.appendRowFromBoundary(parsed, 'header', 'A8', 'last row'), { + assert.equal(ui.appendRowFromTable(parsed, [ + ['h1', 'h2', 'h3'], + ['a', '', 'c'], + ['', '', ''] + ], 1, 'OVERWRITE').success, false); + assert.equal(ui.appendRowFromTable(parsed, [ + ['h1', 'h2', 'h3'], + ['', 'orphan', ''], + ['', '', ''] + ], 1, 'OVERWRITE').success, false); + assert.deepEqual(ui.appendRowFromTable(parsed, [ + ['h1', 'h2', 'h3'], + ['a', 'b', 'c'], + ['', '', ''], + ['', '', ''] + ], 2, 'OVERWRITE'), { success: true, - row: 9 + row: 3 }); + assert.equal(ui.appendRowFromTable(parsed, [ + ['h1', 'h2', 'h3'], + ['', '', ''], + ['later', 'data', 'row'] + ], 2, 'OVERWRITE').reason, 'ui-append-target-not-empty'); assert.equal(ui.valuesAreEmpty([['', ''], ['', '']]), true); assert.equal(ui.valuesAreEmpty([[''], ['still present']]), false); assert.equal(ui.valuesAreEmpty([[0]]), false); }); +test('DOM fallback requires trusted keys, confirmed addresses, and a real formula bar', async () => { + const untrusted = sheetsDomHarness({ + keyPress() { return { success: true, method: 'domEvents', trusted: false }; } + }); + const untrustedResult = await untrusted.api.sheetsUpdateValues('A1', [['x']], 'RAW'); + assert.equal(untrustedResult.code, 'GOOGLE_SHEETS_SESSION_UNAVAILABLE'); + assert.equal(untrusted.pasteCalls, 0); + + const wrongAddress = sheetsDomHarness({ confirmNavigation: false }); + const wrongAddressResult = await wrongAddress.api.sheetsNavigate('B2'); + assert.equal(wrongAddressResult.code, 'GOOGLE_SHEETS_SESSION_UNAVAILABLE'); + assert.equal(wrongAddressResult.reason, 'name-box-navigation-not-confirmed'); + + const noFormula = sheetsDomHarness({ missingFormulaBar: true }); + const read = await noFormula.api.sheetsReadValues('A1', 'ROWS'); + assert.equal(read.code, 'GOOGLE_SHEETS_SESSION_UNAVAILABLE'); + assert.equal(read.reason, 'formula-bar-unavailable'); + assert.equal(noFormula.api.sheetsSpreadsheetMetadata().code, 'GOOGLE_SHEETS_SESSION_UNAVAILABLE'); +}); + +test('DOM append scans a rectangular table and clear verifies actual readback', async () => { + const append = sheetsDomHarness({ + cells: { + A1: 'h1', B1: 'h2', C1: 'h3', + A2: '', B2: 'orphan', C2: '' + } + }); + const appendResult = await append.api.sheetsAppendValues('A:C', [['x', 'y', 'z']], 'RAW', 'OVERWRITE'); + assert.equal(appendResult.code, 'RECIPE_DOM_FALLBACK_PENDING'); + assert.equal(appendResult.reason, 'ui-append-boundary-ambiguous'); + assert.equal(append.pasteCalls, 0); + + const clearNoEffect = sheetsDomHarness({ cells: { A1: 'still here' }, deleteHasEffect: false }); + const clearResult = await clearNoEffect.api.sheetsClearValues('A1'); + assert.equal(clearResult.code, 'RECOVERY_AMBIGUOUS'); + assert.equal(clearResult.reason, 'ui-clear-verification-mismatch'); + assert.equal(clearNoEffect.deleteCalls, 1); + + const clearWrongTarget = sheetsDomHarness({ cells: { A1: 'keep' }, confirmNavigation: false }); + const wrongTargetResult = await clearWrongTarget.api.sheetsClearValues('B2'); + assert.equal(wrongTargetResult.code, 'GOOGLE_SHEETS_SESSION_UNAVAILABLE'); + assert.equal(clearWrongTarget.deleteCalls, 0); +}); + +test('fixed DOM actions reject caller-supplied option values outside the typed enums', async () => { + const harness = sheetsDomHarness({ cells: { A1: 'header' } }); + assert.equal( + (await harness.api.sheetsUpdateValues('A1', [['=unsafe']], 'FORMULA')).reason, + 'unsupported-value-input-option' + ); + assert.equal( + (await harness.api.sheetsAppendValues('A:C', [['x']], 'RAW', 'SHIFT_DOWN')).reason, + 'unsupported-insert-data-option' + ); +}); + test('content action exposes only the fixed Sheets UI operations and protects value-bearing logs', () => { - const actions = fs.readFileSync(path.join(ROOT, 'extension/content/actions.js'), 'utf8'); + const actions = ACTIONS_SOURCE; const messaging = fs.readFileSync(path.join(ROOT, 'extension/content/messaging.js'), 'utf8'); const background = fs.readFileSync(path.join(ROOT, 'extension/background.js'), 'utf8'); const wsClient = fs.readFileSync(path.join(ROOT, 'extension/ws/ws-client.js'), 'utf8'); diff --git a/tests/google-sheets-session.test.js b/tests/google-sheets-session.test.js index b2768674d..5f7bb8bc3 100644 --- a/tests/google-sheets-session.test.js +++ b/tests/google-sheets-session.test.js @@ -12,6 +12,21 @@ const OTHER_ID = 'zyxwvutsrqponmlkjihgfedcba654321'; const URL = `https://docs.google.com/spreadsheets/d/${ID}/edit#gid=0`; const CONTEXT = { tabId: 17, origin: 'https://docs.google.com', url: URL }; +function installPageLocation(spreadsheetId = ID) { + const previous = Object.getOwnPropertyDescriptor(globalThis, 'location'); + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: { + origin: 'https://docs.google.com', + pathname: `/spreadsheets/d/${spreadsheetId}/edit` + } + }); + return function restore() { + if (previous) Object.defineProperty(globalThis, 'location', previous); + else delete globalThis.location; + }; +} + function chromeStub(options = {}) { const pageCalls = []; const uiCalls = []; @@ -105,8 +120,76 @@ test('never retries or falls back after an ambiguous mutation outcome', async () assert.equal(stub.uiCalls.length, 0); }); +test('requires independent no-effect proof before a mutation may use UI fallback', async () => { + const stub = chromeStub({ + pageResult: { + success: false, + code: 'GOOGLE_SHEETS_SESSION_UNAVAILABLE', + safeToFallback: true, + requestSent: true + } + }); + const client = sessionModule.createSession({ chrome: stub.chrome }); + const out = await client.updateValues({ range: 'A1', values: [['x']] }, CONTEXT); + assert.equal(out.code, 'RECOVERY_AMBIGUOUS'); + assert.equal(out.reason, 'page-fallback-effect-not-proven'); + assert.equal(stub.uiCalls.length, 0); +}); + +test('treats an untyped UI mutation timeout as ambiguous after dispatch', async () => { + const stub = chromeStub({ + pageResult: { + success: false, + code: 'GOOGLE_SHEETS_SESSION_UNAVAILABLE', + safeToFallback: true, + requestSent: false + }, + uiResult: { success: false, error: 'Action sheetsSession timed out after 120000ms' } + }); + const client = sessionModule.createSession({ chrome: stub.chrome }); + const out = await client.clearValues({ range: 'A1' }, CONTEXT); + assert.equal(out.code, 'RECOVERY_AMBIGUOUS'); + assert.equal(stub.uiCalls.length, 1); +}); + +test('serializes mutations per tab before dispatching page or UI work', async () => { + let concurrent = 0; + let maxConcurrent = 0; + const chrome = { + tabs: { + async get(tabId) { return { id: tabId, url: URL }; }, + async sendMessage() { + concurrent++; + maxConcurrent = Math.max(maxConcurrent, concurrent); + await new Promise(resolve => setTimeout(resolve, 10)); + concurrent--; + return { success: true, transport: 'ui' }; + } + }, + scripting: { + async executeScript() { + return [{ result: { + success: false, + code: 'GOOGLE_SHEETS_SESSION_UNAVAILABLE', + safeToFallback: true, + requestSent: false + } }]; + } + } + }; + const client = sessionModule.createSession({ chrome }); + const [updated, cleared] = await Promise.all([ + client.updateValues({ range: 'A1', values: [['x']] }, CONTEXT), + client.clearValues({ range: 'B1' }, CONTEXT) + ]); + assert.equal(updated.success, true); + assert.equal(cleared.success, true); + assert.equal(maxConcurrent, 1); +}); + test('classifies mutation timeouts, network failures, and 5xx responses as ambiguous', async () => { const previous = globalThis.gapi; + const restoreLocation = installPageLocation(); try { globalThis.gapi = { client: { request: () => Promise.reject({ status: 503 }) } }; const rejected = await sessionModule.pageClientOperation({ @@ -138,6 +221,7 @@ test('classifies mutation timeouts, network failures, and 5xx responses as ambig assert.equal(timedOut.code, 'RECOVERY_AMBIGUOUS'); } finally { globalThis.gapi = previous; + restoreLocation(); } }); @@ -166,6 +250,7 @@ test('surfaces a logged-out UI state as session unavailable', async () => { test('MAIN-world bridge builds only fixed Sheets requests and uses no supplied transport fields', async () => { const previous = globalThis.gapi; + const restoreLocation = installPageLocation(); const calls = []; globalThis.gapi = { client: { @@ -197,6 +282,25 @@ test('MAIN-world bridge builds only fixed Sheets requests and uses no supplied t assert.equal(JSON.stringify(calls[0]).includes('secret'), false); } finally { globalThis.gapi = previous; + restoreLocation(); + } +}); + +test('MAIN-world bridge re-pins location immediately before the page request', async () => { + const previous = globalThis.gapi; + const restoreLocation = installPageLocation(OTHER_ID); + let calls = 0; + globalThis.gapi = { client: { request() { calls++; return Promise.resolve({ status: 200 }); } } }; + try { + const out = await sessionModule.pageClientOperation({ + operation: 'getValues', spreadsheetId: ID, timeoutMs: 100, args: { range: 'A1' } + }); + assert.equal(out.code, 'GOOGLE_SHEETS_TARGET_MISMATCH'); + assert.equal(out.requestSent, false); + assert.equal(calls, 0); + } finally { + globalThis.gapi = previous; + restoreLocation(); } }); diff --git a/tests/spreadsheet-record-redaction.test.js b/tests/spreadsheet-record-redaction.test.js index 7c24ae8f3..a984375b7 100644 --- a/tests/spreadsheet-record-redaction.test.js +++ b/tests/spreadsheet-record-redaction.test.js @@ -4,6 +4,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const test = require('node:test'); +const vm = require('node:vm'); const redaction = require('../extension/utils/spreadsheet-record-redaction.js'); const ROOT = path.resolve(__dirname, '..'); @@ -19,6 +20,27 @@ function recorder(method) { }; } +function bridgeHarness(spreadsheetRedactor = redaction) { + const entries = []; + const context = { + console, + fsbMcpSessionRecorder: { + recordAction(entry) { entries.push(entry); } + }, + FsbSpreadsheetRecordRedaction: spreadsheetRedactor, + resolveMcpClientLabel() { return 'test-client'; }, + globalThis: null + }; + context.globalThis = context; + const source = fs.readFileSync(path.join(ROOT, 'extension/ws/mcp-bridge-client.js'), 'utf8'); + vm.runInNewContext( + `${source}\nthis.__spreadsheetBridgeClient = mcpBridgeClient;`, + context, + { filename: 'extension/ws/mcp-bridge-client.js' } + ); + return { client: context.__spreadsheetBridgeClient, entries }; +} + function assertNoContent(entry) { const serialized = JSON.stringify(entry); assert.equal(serialized.includes(SENTINEL), false); @@ -134,6 +156,11 @@ test('failure records keep a safe typed code but drop raw errors', () => { const second = recorder('recordDispatch'); redaction.recordSafely(second.target, 'recordDispatch', source); assert.equal(second.entries[0].response.errorCode, undefined); + + source.response.errorCode = 'RECOVERY_AMBIGUOUS'; + const ambiguous = recorder('recordDispatch'); + redaction.recordSafely(ambiguous.target, 'recordDispatch', source); + assert.equal(ambiguous.entries[0].response.errorCode, 'RECOVERY_AMBIGUOUS'); }); test('recordAction ingress redacts legacy fill_sheet and read_sheet payloads', () => { @@ -175,6 +202,93 @@ test('recordAction ingress redacts legacy fill_sheet and read_sheet payloads', ( assertNoContent(readSink.entries[0]); }); +test('real bridge fillsheet and readsheet payloads are shape-only before recordAction', () => { + const fillCsv = `"${SENTINEL}\ninside",2\n3,=${SENTINEL}!A1`; + const fillPayload = { + tool: 'fillsheet', + params: { startCell: 'A1', data: fillCsv, sheetName: SENTINEL, tab_id: 8 }, + agentId: 'agent:wire', + visualSession: { visualReason: `Fill ${SENTINEL}`, client: 'test-client', isFinal: true }, + ownershipToken: SENTINEL + }; + const fillResponse = { + success: true, + action: 'fillsheet', + startCell: 'A1', + rows: 2, + cols: 2, + cellsFilled: 4, + hadEffect: true + }; + const readPayload = { + tool: 'readsheet', + params: { range: RANGE, tab_id: 8 }, + agentId: 'agent:wire', + ownershipToken: SENTINEL + }; + const readResponse = { + success: true, + action: 'readsheet', + range: RANGE, + rows: 2, + cols: 2, + data: `${SENTINEL},x\n=${SENTINEL}!A1,2`, + hadEffect: false + }; + + const harness = bridgeHarness(); + harness.client._recordMcpSessionAction(fillPayload, fillResponse, 8); + harness.client._recordMcpSessionAction(readPayload, readResponse, 8); + + assert.equal(harness.entries.length, 2); + const [fillRecorded, readRecorded] = harness.entries; + assert.equal(fillRecorded.tool, 'fillsheet'); + assert.equal(fillRecorded.payload.tool, 'fillsheet'); + assert.deepEqual(fillRecorded.params, { + operation: 'fillsheet', + shape: { rowCount: 2, columnCount: 2, valueCount: 4 } + }); + assert.deepEqual(fillRecorded.payload.params, fillRecorded.params); + assert.deepEqual(fillRecorded.payload.visualSession, { isFinal: true }); + assert.deepEqual(fillRecorded.response, { + success: true, + shape: { rowCount: 0, columnCount: 0, valueCount: 0 } + }); + assertNoContent(fillRecorded); + + assert.equal(readRecorded.tool, 'readsheet'); + assert.equal(readRecorded.payload.tool, 'readsheet'); + assert.deepEqual(readRecorded.params, { + operation: 'readsheet', + shape: { rowCount: 0, columnCount: 0, valueCount: 0 } + }); + assert.deepEqual(readRecorded.response, { + success: true, + shape: { rowCount: 2, columnCount: 2, valueCount: 4 } + }); + assertNoContent(readRecorded); +}); + +test('bridge drops every spreadsheet alias when the shared redactor is unavailable', () => { + const harness = bridgeHarness(null); + for (const tool of ['fill_sheet', 'read_sheet', 'fillsheet', 'readsheet']) { + harness.client._recordMcpSessionAction({ + tool, + params: { data: SENTINEL, range: RANGE, sheetName: SENTINEL }, + agentId: 'agent:wire' + }, { success: true, data: SENTINEL }, 8); + } + assert.equal(harness.entries.length, 0); + + harness.client._recordMcpSessionAction({ + tool: 'click', + params: { selector: '#safe' }, + agentId: 'agent:wire' + }, { success: true }, 8); + assert.equal(harness.entries.length, 1); + assert.equal(harness.entries[0].tool, 'click'); +}); + test('unknown gsheets slugs are recognized and fail closed without retaining the raw slug', () => { const source = capabilityEntry(`gsheets.${SENTINEL}`, { values: [[SENTINEL]] }, { success: true, data: SENTINEL }); const sink = recorder('recordDispatch'); diff --git a/tests/verify-origin-classification.test.js b/tests/verify-origin-classification.test.js index d133614c4..2f1b5cb6d 100644 --- a/tests/verify-origin-classification.test.js +++ b/tests/verify-origin-classification.test.js @@ -415,6 +415,19 @@ function check(cond, msg) { && broadenedSheetsSources.failures.includes('SHEETS_GAPI_REQUEST_NOT_FIXED') && broadenedSheetsSources.failures.includes('FORBIDDEN_SHEETS_CREDENTIAL_OR_NETWORK_SOURCE'), '(b) negative control: adding caller-selected fetch(request.url) fails both the fixed-gapi and forbidden-network source checks'); + const sheetsContentSource = fs.readFileSync( + path.join(ROOT, 'extension', 'content', 'actions.js'), + 'utf8' + ); + const broadenedContentSources = gate.verifyPageGapiUiSheetsSessionSources({ + contentActionsText: sheetsContentSource.replace( + 'sheetsSession: async (params = {}) => {', + 'sheetsSession: async (params = {}) => { fetch(params.url);' + ) + }); + check(broadenedContentSources && broadenedContentSources.ok === false + && broadenedContentSources.failures.includes('FORBIDDEN_SHEETS_CREDENTIAL_OR_NETWORK_SOURCE'), + '(b) negative control: a caller-selected content-action fetch fails the fixed Sheets UI source contract'); // (b) REAL end-to-end: checkOriginClassification() over the LIVE catalog + vendored // slack-api.ts -- proves the real heads all pass and slack rides the dynamic From 4c1d38876caf9fe77071f06671520f0765acf0d9 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Wed, 15 Jul 2026 14:05:22 -0500 Subject: [PATCH 18/26] fix(sheets): close UI session edge cases --- extension/content/actions.js | 416 +++----------------- extension/utils/google-sheets-session.js | 31 +- extension/utils/google-sheets-ui.js | 20 +- scripts/verify-origin-classification.mjs | 49 ++- tests/google-sheets-content-actions.test.js | 82 +++- tests/google-sheets-session.test.js | 118 ++++++ tests/verify-origin-classification.test.js | 59 ++- 7 files changed, 392 insertions(+), 383 deletions(-) diff --git a/extension/content/actions.js b/extension/content/actions.js index 2f4ff54ac..d01ad6615 100644 --- a/extension/content/actions.js +++ b/extension/content/actions.js @@ -1553,6 +1553,41 @@ function sheetsNameBox() { return document.querySelector('#t-name-box'); } +let sheetsUiOperationChain = Promise.resolve(); + +function sheetsWithUiLock(operation) { + const run = sheetsUiOperationChain.catch(() => undefined).then(operation); + sheetsUiOperationChain = run.catch(() => undefined); + return run; +} + +function sheetsWorksheetFromReference(reference) { + const parsed = sheetsUi?.parseA1Range(reference); + if (!parsed?.sheetPrefix) return ''; + const raw = parsed.sheetPrefix.slice(0, -1); + const unquoted = raw.startsWith("'") && raw.endsWith("'") + ? raw.slice(1, -1).replace(/''/g, "'") + : raw; + return unquoted.normalize('NFC'); +} + +function sheetsActiveWorksheetName() { + const selectors = [ + '.docs-sheet-tab.docs-sheet-active-tab .docs-sheet-tab-name', + '.docs-sheet-tab[aria-selected="true"] .docs-sheet-tab-name', + '[role="tab"][aria-selected="true"] .docs-sheet-tab-name', + '.docs-sheet-tab.docs-sheet-active-tab', + '.docs-sheet-tab[aria-selected="true"]', + '[role="tab"][aria-selected="true"]' + ]; + for (const selector of selectors) { + const node = document.querySelector(selector); + const title = String(node?.textContent || '').trim(); + if (title) return title.normalize('NFC'); + } + return ''; +} + function sheetsComparableReference(reference) { const text = String(reference || '').trim(); const delimiter = text.lastIndexOf('!'); @@ -1589,6 +1624,10 @@ async function sheetsNavigate(reference, settleMs = 80) { if (sheetsComparableReference(sheetsActiveAddress()) !== sheetsComparableReference(reference)) { return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'name-box-navigation-not-confirmed'); } + const requestedWorksheet = sheetsWorksheetFromReference(reference); + if (requestedWorksheet && sheetsActiveWorksheetName() !== requestedWorksheet) { + return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'worksheet-navigation-not-confirmed'); + } return { success: true, address: sheetsActiveAddress() }; } catch (_error) { return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'name-box-navigation-failed'); @@ -1759,6 +1798,9 @@ async function sheetsAppendValues(range, values, valueInputOption, insertDataOpt if (dataOption !== 'OVERWRITE' && dataOption !== 'INSERT_ROWS') { return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'unsupported-insert-data-option'); } + if (dataOption === 'INSERT_ROWS') { + return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-insert-rows-unverified'); + } const encoded = sheetsUi.encodeValues(values, inputOption); if (!encoded.success) return sheetsError('RECIPE_DOM_FALLBACK_PENDING', encoded.reason); if (encoded.rows > sheetsUi.limits.maxAppendRows || encoded.columns > parsed.columns) { @@ -1766,26 +1808,7 @@ async function sheetsAppendValues(range, values, valueInputOption, insertDataOpt } const boundary = await sheetsFindAppendRow(parsed, encoded.rows, dataOption); if (!boundary.success) return boundary; - let mutationStarted = false; - if (dataOption === 'INSERT_ROWS') { - const lastRow = boundary.row + encoded.rows - 1; - const selected = await sheetsNavigate(`${parsed.sheetPrefix}${boundary.row}:${lastRow}`, 60); - if (!selected.success) return selected; - mutationStarted = true; - try { - const inserted = await tools.keyPress({ key: '=', ctrlKey: true, altKey: true, useDebuggerAPI: true }); - if (!inserted || inserted.success !== true || inserted.method !== 'debuggerAPI') { - return sheetsError('RECOVERY_AMBIGUOUS', 'ui-row-insert-outcome-unknown', true); - } - await sheetsDelay(150); - } catch (_error) { - return sheetsError('RECOVERY_AMBIGUOUS', 'ui-row-insert-outcome-unknown', true); - } - } const pasted = await sheetsPasteEncoded(parsed, encoded, boundary.row); - if (!pasted.success && mutationStarted && !pasted.mutationStarted) { - return sheetsError('RECOVERY_AMBIGUOUS', 'ui-append-partially-completed', true); - } if (pasted.success) pasted.appendedRows = encoded.rows; return pasted; } @@ -5087,14 +5110,16 @@ const tools = { } const operation = String(params.operation || ''); const args = params.args && typeof params.args === 'object' ? params.args : {}; - if (operation === 'getSpreadsheet') return sheetsSpreadsheetMetadata(); - if (operation === 'getValues') return sheetsReadValues(args.range, args.majorDimension); - if (operation === 'updateValues') return sheetsUpdateValues(args.range, args.values, args.valueInputOption); - if (operation === 'appendValues') { - return sheetsAppendValues(args.range, args.values, args.valueInputOption, args.insertDataOption); - } - if (operation === 'clearValues') return sheetsClearValues(args.range); - return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'unsupported-sheets-ui-operation'); + return sheetsWithUiLock(async () => { + if (operation === 'getSpreadsheet') return sheetsSpreadsheetMetadata(); + if (operation === 'getValues') return sheetsReadValues(args.range, args.majorDimension); + if (operation === 'updateValues') return sheetsUpdateValues(args.range, args.values, args.valueInputOption); + if (operation === 'appendValues') { + return sheetsAppendValues(args.range, args.values, args.valueInputOption, args.insertDataOption); + } + if (operation === 'clearValues') return sheetsClearValues(args.range); + return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'unsupported-sheets-ui-operation'); + }); }, // ========================================================================= @@ -5115,10 +5140,9 @@ const tools = { return { success: false, error: 'fillsheet only works on Google Sheets (canvas-based editor not detected)' }; } - // The shared signed-in-tab path uses bounded clipboard chunks. Keep the - // historical cell-by-cell implementation below as a compatibility fallback - // for partial/older content-script injection sets where the helper is absent. - if (sheetsUi) { + // Legacy callers use the same bounded signed-in-tab adapter as typed Sheets calls. + if (!sheetsUi) return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'sheets-ui-helper-unavailable'); + return sheetsWithUiLock(async () => { const sharedRows = sheetsUi.csvToValues(data); if (!sharedRows.length) return { success: false, error: 'No data rows parsed from CSV' }; await sheetsRenameSpreadsheet(sheetName); @@ -5135,214 +5159,7 @@ const tools = { transport: 'ui', hadEffect: true }; - } - - const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); - - // Parse CSV with basic quoting support - function parseCSV(csvText) { - const rows = []; - let current = ''; - let inQuotes = false; - const chars = csvText.split(''); - let row = []; - - for (let i = 0; i < chars.length; i++) { - const ch = chars[i]; - if (ch === '"' && (i === 0 || chars[i - 1] !== '\\')) { - inQuotes = !inQuotes; - } else if (ch === ',' && !inQuotes) { - row.push(current.trim()); - current = ''; - } else if (ch === '\n' && !inQuotes) { - row.push(current.trim()); - if (row.length > 0 && row.some(c => c !== '')) rows.push(row); - row = []; - current = ''; - } else if (ch === '\\' && i + 1 < chars.length && chars[i + 1] === 'n' && !inQuotes) { - // Handle literal \n in unquoted context as newline - row.push(current.trim()); - if (row.length > 0 && row.some(c => c !== '')) rows.push(row); - row = []; - current = ''; - i++; // skip 'n' - } else { - current += ch; - } - } - // Last cell - row.push(current.trim()); - if (row.length > 0 && row.some(c => c !== '')) rows.push(row); - return rows; - } - - const rows = parseCSV(data); - if (rows.length === 0) { - return { success: false, error: 'No data rows parsed from CSV' }; - } - - const totalCells = rows.reduce((sum, r) => sum + r.length, 0); - console.info(`[fillsheet] Starting: ${rows.length} rows, ${rows[0]?.length || 0} cols, ${totalCells} cells from ${startCell}`); - - try { - // Step 1: Exit any edit mode - await tools.keyPress({ key: 'Escape', useDebuggerAPI: true }); - await delay(100); - - // Step 2: Navigate to start cell via Name Box - const nameBox = document.querySelector('#t-name-box'); - if (!nameBox) { - return { success: false, error: 'Name Box (#t-name-box) not found — not on a Google Sheets page?' }; - } - - // Click Name Box - nameBox.focus(); - nameBox.click(); - await delay(100); - - // Select all text in Name Box and type the cell reference - await tools.keyPress({ key: 'a', ctrlKey: true, useDebuggerAPI: true }); - await delay(50); - await tools.typeWithKeys({ text: startCell, clearFirst: false, delay: 20 }); - await delay(50); - - // Press Enter to navigate - await tools.keyPress({ key: 'Enter', useDebuggerAPI: true }); - await delay(200); - - // Step 2b: Rename spreadsheet if sheetName provided - if (sheetName) { - try { - const titleEl = document.querySelector('.docs-title-input, #docs-title-input, input[aria-label*="Rename" i], .docs-title-widget input'); - if (titleEl) { - // Focus then click to enter edit mode (both needed for Sheets title input) - titleEl.focus(); - await delay(100); - titleEl.click(); - await delay(300); - // Use native .select() for input elements — more reliable than Ctrl+A via Debugger API - if (typeof titleEl.select === 'function') { - titleEl.select(); - } else { - await tools.keyPress({ key: 'a', ctrlKey: true, useDebuggerAPI: true }); - } - await delay(100); - await tools.typeWithKeys({ text: sheetName, clearFirst: false, delay: 20 }); - await delay(100); - await tools.keyPress({ key: 'Enter', useDebuggerAPI: true }); - await delay(500); - // Re-navigate to start cell after rename (focus may have shifted) - nameBox.focus(); - nameBox.click(); - await delay(100); - await tools.keyPress({ key: 'a', ctrlKey: true, useDebuggerAPI: true }); - await delay(50); - await tools.typeWithKeys({ text: startCell, clearFirst: false, delay: 20 }); - await delay(50); - await tools.keyPress({ key: 'Enter', useDebuggerAPI: true }); - await delay(200); - } - } catch (nameErr) { - console.warn('[fillsheet] Sheet rename failed (non-fatal):', nameErr.message); - } - } - - // Step 3: Type data cell by cell - let cellsFilled = 0; - let errors = []; - - for (let r = 0; r < rows.length; r++) { - const row = rows[r]; - for (let c = 0; c < row.length; c++) { - const value = row[c]; - if (value !== '') { - // Sanitize: prefix with space if starts with = + - @ to prevent formula injection - const safeValue = /^[=+\-@]/.test(value) ? ' ' + value : value; - const typeResult = await tools.typeWithKeys({ text: safeValue, clearFirst: false, delay: 15 }); - if (!typeResult.success) { - errors.push({ row: r, col: c, value, error: typeResult.error }); - } - } - cellsFilled++; - - // Move to next cell - if (c < row.length - 1) { - // Tab = next column - await tools.keyPress({ key: 'Tab', useDebuggerAPI: true }); - await delay(30); - } - } - // Enter = next row (Sheets moves to column A of next row after Enter from last Tab position) - // First press Enter to confirm current cell, then position for next row - if (r < rows.length - 1) { - await tools.keyPress({ key: 'Enter', useDebuggerAPI: true }); - await delay(50); - - // Navigate to the start column of the next row via Name Box - // Calculate the next row's start cell - const colLetter = startCell.replace(/[0-9]/g, ''); - const startRow = parseInt(startCell.replace(/[A-Za-z]/g, ''), 10); - const nextCell = colLetter + (startRow + r + 1); - nameBox.focus(); - nameBox.click(); - await delay(80); - await tools.keyPress({ key: 'a', ctrlKey: true, useDebuggerAPI: true }); - await delay(30); - await tools.typeWithKeys({ text: nextCell, clearFirst: false, delay: 20 }); - await delay(30); - await tools.keyPress({ key: 'Enter', useDebuggerAPI: true }); - await delay(150); - } - } - - // Final Enter to confirm last cell - await tools.keyPress({ key: 'Enter', useDebuggerAPI: true }); - await delay(100); - - console.info(`[fillsheet] Complete: ${cellsFilled} cells filled, ${errors.length} errors`); - - // Step 4: Auto-format header row (bold) if there are headers + data rows - if (rows.length > 1) { - try { - // Navigate back to start cell - nameBox.focus(); - nameBox.click(); - await delay(80); - await tools.keyPress({ key: 'a', ctrlKey: true, useDebuggerAPI: true }); - await delay(30); - await tools.typeWithKeys({ text: startCell, clearFirst: false, delay: 20 }); - await delay(30); - await tools.keyPress({ key: 'Enter', useDebuggerAPI: true }); - await delay(150); - - // Select header row (Shift+Ctrl+Right to select to last data column) - await tools.keyPress({ key: 'ArrowRight', shiftKey: true, ctrlKey: true, useDebuggerAPI: true }); - await delay(50); - - // Bold the selection - await tools.keyPress({ key: 'b', ctrlKey: true, useDebuggerAPI: true }); - await delay(50); - - // Press Escape to deselect - await tools.keyPress({ key: 'Escape', useDebuggerAPI: true }); - } catch (fmtErr) { - console.warn('[fillsheet] Header formatting failed (non-fatal):', fmtErr.message); - } - } - - return { - success: errors.length === 0, - action: 'fillsheet', - startCell, - rows: rows.length, - cols: rows[0]?.length || 0, - cellsFilled, - errors: errors.length > 0 ? errors : undefined, - hadEffect: true - }; - } catch (error) { - return { success: false, error: error.message, action: 'fillsheet' }; - } + }); }, // ========================================================================= @@ -5359,7 +5176,8 @@ const tools = { return { success: false, error: 'readsheet only works on Google Sheets' }; } - if (sheetsUi) { + if (!sheetsUi) return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'sheets-ui-helper-unavailable'); + return sheetsWithUiLock(async () => { const sharedResult = await sheetsReadValues(range, 'ROWS'); if (!sharedResult.success) return { ...sharedResult, action: 'readsheet' }; return { @@ -5373,123 +5191,7 @@ const tools = { renderSemantics: 'formula-bar', hadEffect: false }; - } - - const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); - - // Parse range like "A1:C5" into start/end - const rangeMatch = range.match(/^([A-Za-z]+)(\d+):([A-Za-z]+)(\d+)$/); - if (!rangeMatch) { - return { success: false, error: 'Invalid range format. Use "A1:C5" style.' }; - } - - function colToNum(col) { - let num = 0; - for (let i = 0; i < col.length; i++) { - num = num * 26 + (col.toUpperCase().charCodeAt(i) - 64); - } - return num; - } - - function numToCol(num) { - let col = ''; - while (num > 0) { - const rem = (num - 1) % 26; - col = String.fromCharCode(65 + rem) + col; - num = Math.floor((num - 1) / 26); - } - return col; - } - - const startCol = colToNum(rangeMatch[1]); - const startRow = parseInt(rangeMatch[2], 10); - const endCol = colToNum(rangeMatch[3]); - const endRow = parseInt(rangeMatch[4], 10); - - if (endRow - startRow > 50 || endCol - startCol > 26) { - return { success: false, error: 'Range too large. Max 50 rows x 26 columns.' }; - } - - const nameBox = document.querySelector('#t-name-box'); - if (!nameBox) { - return { success: false, error: 'Name Box not found' }; - } - - // Find formula bar element for reading values - const formulaBarSelectors = ['#t-formula-bar-input', '.cell-input', '[aria-label="Formula bar"]']; - - function getFormulaBarContent() { - for (const sel of formulaBarSelectors) { - const el = document.querySelector(sel); - if (el) { - // Try multiple reading strategies - const editable = el.querySelector('[contenteditable="true"]'); - if (editable) { - const text = (editable.innerText || editable.textContent || '').trim(); - if (text) return text; - } - const direct = (el.innerText || el.textContent || '').trim(); - if (direct) return direct; - // Check parent for display siblings - const parent = el.parentElement; - if (parent) { - const display = parent.querySelector('.cell-input, [aria-label*="formula"]'); - if (display && display !== el) { - return (display.innerText || display.textContent || '').trim(); - } - } - } - } - return ''; - } - - try { - // Exit edit mode first - await tools.keyPress({ key: 'Escape', useDebuggerAPI: true }); - await delay(100); - - const csvRows = []; - const totalCells = (endRow - startRow + 1) * (endCol - startCol + 1); - console.info(`[readsheet] Reading ${range}: ${endRow - startRow + 1} rows x ${endCol - startCol + 1} cols = ${totalCells} cells`); - - for (let r = startRow; r <= endRow; r++) { - const rowValues = []; - for (let c = startCol; c <= endCol; c++) { - const cellRef = numToCol(c) + r; - - // Navigate to cell via Name Box - nameBox.focus(); - nameBox.click(); - await delay(60); - await tools.keyPress({ key: 'a', ctrlKey: true, useDebuggerAPI: true }); - await delay(30); - await tools.typeWithKeys({ text: cellRef, clearFirst: false, delay: 15 }); - await delay(30); - await tools.keyPress({ key: 'Enter', useDebuggerAPI: true }); - await delay(150); - - // Read formula bar content - const value = getFormulaBarContent(); - rowValues.push(value); - } - csvRows.push(rowValues.join(',')); - } - - const csvResult = csvRows.join('\n'); - console.info(`[readsheet] Complete: read ${totalCells} cells`); - - return { - success: true, - action: 'readsheet', - range, - rows: endRow - startRow + 1, - cols: endCol - startCol + 1, - data: csvResult, - hadEffect: false - }; - } catch (error) { - return { success: false, error: error.message, action: 'readsheet' }; - } + }); }, // ========================================================================= diff --git a/extension/utils/google-sheets-session.js b/extension/utils/google-sheets-session.js index 4badcf318..e38618620 100644 --- a/extension/utils/google-sheets-session.js +++ b/extension/utils/google-sheets-session.js @@ -222,6 +222,7 @@ var chromeApi = deps.chrome || global.chrome; var timeoutMs = deps.requestTimeoutMs || REQUEST_TIMEOUT_MS; var mutationChains = new Map(); + var uiChains = new Map(); async function resolveTarget(params, context) { params = params || {}; @@ -296,7 +297,7 @@ return result; } - async function runUi(target, operation, args) { + async function dispatchUi(target, operation, args) { if (!chromeApi || !chromeApi.tabs || typeof chromeApi.tabs.sendMessage !== 'function') { return typedError('GOOGLE_SHEETS_SESSION_UNAVAILABLE'); } @@ -325,6 +326,21 @@ return result; } + function queued(chains, key, task) { + var prior = chains.get(key) || Promise.resolve(); + var current = prior.catch(function() {}).then(task); + chains.set(key, current); + return current.finally(function() { + if (chains.get(key) === current) { chains.delete(key); } + }); + } + + function runUi(target, operation, args) { + return queued(uiChains, target.tabId, function() { + return dispatchUi(target, operation, args); + }); + } + async function executeTransport(target, operation, args) { var pageResult = await runPageClient(target, operation, args); if (pageResult && pageResult.success === true) { return pageResult; } @@ -335,24 +351,13 @@ return runUi(target, operation, args); } - function queued(tabId, task) { - var prior = mutationChains.get(tabId) || Promise.resolve(); - var current = prior.catch(function() {}).then(task); - mutationChains.set(tabId, current); - return current.finally(function() { - if (mutationChains.get(tabId) === current) { mutationChains.delete(tabId); } - }); - } - async function execute(operation, params, context) { var target = await resolveTarget(params, context); if (!target.success) { return target; } var args = safeArgs(operation, params); if (MUTATIONS[operation]) { - return queued(target.tabId, function() { return executeTransport(target, operation, args); }); + return queued(mutationChains, target.tabId, function() { return executeTransport(target, operation, args); }); } - var activeMutation = mutationChains.get(target.tabId); - if (activeMutation) { await activeMutation.catch(function() {}); } return executeTransport(target, operation, args); } diff --git a/extension/utils/google-sheets-ui.js b/extension/utils/google-sheets-ui.js index 5894e5990..42fd3fbaf 100644 --- a/extension/utils/google-sheets-ui.js +++ b/extension/utils/google-sheets-ui.js @@ -220,18 +220,20 @@ return { success: false, reason: 'ui-append-boundary-ambiguous' }; } boundaryIndex = index; - if (mode === 'INSERT_ROWS') { - return { success: true, row: (parsed.startRow || 1) + boundaryIndex }; - } - } - if (index < boundaryIndex + requiredRows && !empty) { - return { success: false, reason: 'ui-append-target-not-empty' }; } - if (index === boundaryIndex + requiredRows - 1) { - return { success: true, row: (parsed.startRow || 1) + boundaryIndex }; + if (boundaryIndex !== -1 && !empty) { + return { + success: false, + reason: index < boundaryIndex + requiredRows + ? 'ui-append-target-not-empty' + : 'ui-append-boundary-ambiguous' + }; } } - return { success: false, reason: 'ui-append-boundary-ambiguous' }; + if (boundaryIndex === -1 || boundaryIndex + requiredRows > values.length) { + return { success: false, reason: 'ui-append-boundary-ambiguous' }; + } + return { success: true, row: (parsed.startRow || 1) + boundaryIndex }; } function valuesAreEmpty(values) { diff --git a/scripts/verify-origin-classification.mjs b/scripts/verify-origin-classification.mjs index ca865045d..ff4e228d4 100644 --- a/scripts/verify-origin-classification.mjs +++ b/scripts/verify-origin-classification.mjs @@ -4475,6 +4475,52 @@ function readGlamaPageStateRuntimeBase(app, runtimeBaseUrl) { : null; } +function hasForbiddenSheetsNetworkPrimitive(source) { + const text = String(source || ''); + return /\b(?:fetch|XMLHttpRequest|sendBeacon|WebSocket|EventSource|Worker|SharedWorker|importScripts|axios|superagent)\b/i.test(text); +} + +// The Sheets content action is a reviewed UI-only boundary. Keep this stricter than +// the session helper: the latter owns the one fixed page-gapi request, while content +// code may only inspect the pinned Sheets DOM and issue trusted keyboard gestures. +// Reject the primitive names themselves so aliases and bracket access cannot turn a +// harmless-looking operation argument into a generic authenticated network proxy. +function hasForbiddenSheetsContentNetworkSource(source) { + const text = String(source || ''); + const dynamicOrProxyCall = + /\b(?:globalThis|window|self|document|navigator|tools|chrome(?:Api)?|browser)\s*\[/i.test(text) + || /\]\s*\(/.test(text) + || /\.\s*request\s*\(/i.test(text) + || /\b(?:gapi|postMessage|MessageChannel|BroadcastChannel)\b/i.test(text) + || /\b(?:chrome(?:Api)?|browser)\s*\.\s*(?:runtime|tabs|scripting|webRequest|cookies|identity)\b/i.test(text) + || /\bReflect\s*\.\s*(?:get|set|apply|construct)\s*\(/i.test(text); + + const urlLiteralOrBuilder = + /['"`]\s*(?:https?|wss?|ftp|data|blob)\s*:/i.test(text) + || /['"`]\s*\/\/(?![/*])/i.test(text) + || /\b(?:url|uri|endpoint|baseUrl)\b/i.test(text) + || /\b(?:encodeURI|decodeURI)(?:Component)?\s*\(/i.test(text) + || /\b(?:params|args|request|payload|input)\s*(?:\.\s*(?:url|uri|href|src|endpoint|origin|host|path)\b|\[\s*['"`](?:url|uri|href|src|endpoint|origin|host|path)['"`]\s*\])/i.test(text); + + const domNetworkSink = + /\b(?:new\s+)?(?:Image|Audio)\s*\(/i.test(text) + || /\bdocument\s*\.\s*createElement(?:NS)?\s*\([\s\S]*?['"`](?:a|audio|embed|form|iframe|image|img|link|meta|object|script|source|track|video)\b/i.test(text) + || /(?:\.\s*(?:src|srcdoc|srcset|href|action|formAction|poster|data)\b|\[\s*['"`](?:src|srcdoc|srcset|href|action|formAction|poster|data)['"`]\s*\])\s*=/i.test(text) + || /\[[^\]\r\n]+\]\s*=/.test(text) + || /\.\s*setAttribute(?:NS)?\s*\(/i.test(text) + || /\bObject\s*\.\s*(?:assign|defineProperty|defineProperties)\s*\(/i.test(text) + || /\.\s*(?:innerHTML|outerHTML|cssText|background|backgroundImage|borderImage|content|cursor|listStyle|listStyleImage|mask|maskImage)\s*=/i.test(text) + || /\.\s*(?:createContextualFragment|insertAdjacentHTML|submit|requestSubmit|setProperty)\s*\(/i.test(text) + || /\b(?:window|globalThis|self)\s*\.\s*open\s*\(/i.test(text) + || /\b(?:window\s*\.\s*)?(?:document\s*\.\s*)?location\s*(?:\.\s*href\s*)?=/i.test(text) + || /\blocation\s*\.\s*(?:assign|replace|reload)\s*\(/i.test(text); + + return hasForbiddenSheetsNetworkPrimitive(text) + || dynamicOrProxyCall + || urlLiteralOrBuilder + || domNetworkSink; +} + export function verifyPageGapiUiSheetsSessionSources(sourceOverrides) { const overrides = sourceOverrides || {}; const expectedBase = 'https://sheets.googleapis.com/v4'; @@ -4573,7 +4619,8 @@ export function verifyPageGapiUiSheetsSessionSources(sourceOverrides) { || /\bdocument\.cookie\b|chrome(?:Api)?\.cookies\b|\bcookies?\.(?:get|set|remove)\s*\(/i.test(forbiddenSheetsSource) || /\b(?:localStorage|sessionStorage|chrome(?:Api)?\.storage)\b/i.test(forbiddenSheetsSource) || /\bAuthorization\b|\bBearer\b/i.test(forbiddenSheetsSource) - || /\bfetch\s*\(|\bXMLHttpRequest\b|\bsendBeacon\s*\(|\bWebSocket\s*\(|\bEventSource\s*\(/i.test(forbiddenSheetsSource); + || hasForbiddenSheetsNetworkPrimitive(forbiddenSheetsSource) + || hasForbiddenSheetsContentNetworkSource(sheetsContentText); if (hasForbiddenCredentialOrNetworkSource) { failures.push('FORBIDDEN_SHEETS_CREDENTIAL_OR_NETWORK_SOURCE'); } const contentActionOk = !!sheetsContentText diff --git a/tests/google-sheets-content-actions.test.js b/tests/google-sheets-content-actions.test.js index 0d4905e92..159658f89 100644 --- a/tests/google-sheets-content-actions.test.js +++ b/tests/google-sheets-content-actions.test.js @@ -18,8 +18,10 @@ function sheetsDomHarness(options = {}) { const cells = new Map(Object.entries(options.cells || {})); let selectedAddress = options.initialAddress || 'A1'; let pendingAddress = selectedAddress; + let activeWorksheet = options.initialWorksheet || 'Sheet1'; let pasteCalls = 0; let deleteCalls = 0; + let insertCalls = 0; const nameBox = options.missingNameBox ? null : { value: selectedAddress, textContent: selectedAddress, @@ -41,9 +43,16 @@ function sheetsDomHarness(options = {}) { if (options.confirmNavigation !== false) { selectedAddress = pendingAddress.replace(/^.*!/, '').replace(/\$/g, '').toUpperCase(); } + if (options.confirmWorksheetNavigation !== false && pendingAddress.includes('!')) { + const rawSheet = pendingAddress.slice(0, pendingAddress.lastIndexOf('!')); + activeWorksheet = rawSheet.startsWith("'") && rawSheet.endsWith("'") + ? rawSheet.slice(1, -1).replace(/''/g, "'") + : rawSheet; + } if (nameBox) nameBox.value = selectedAddress; } if (params.key === 'v' && params.ctrlKey) pasteCalls++; + if (params.key === '=' && (params.ctrlKey || params.metaKey) && params.altKey) insertCalls++; if (params.key === 'Delete') { deleteCalls++; if (options.deleteHasEffect !== false) cells.set(selectedAddress, ''); @@ -67,6 +76,9 @@ function sheetsDomHarness(options = {}) { if (selector.includes('docs-title-input')) { return options.missingTitle ? null : { value: options.title || 'Disposable' }; } + if (selector.includes('docs-sheet-active-tab') || selector.includes('aria-selected="true"')) { + return options.missingActiveWorksheet ? null : { textContent: activeWorksheet }; + } return null; }, querySelectorAll() { return options.tabNodes || []; } @@ -97,6 +109,7 @@ function sheetsDomHarness(options = {}) { context.globalThis = context; const exports = [ 'sheetsNavigate', + 'sheetsWithUiLock', 'sheetsReadValues', 'sheetsUpdateValues', 'sheetsAppendValues', @@ -113,7 +126,8 @@ function sheetsDomHarness(options = {}) { api: context.__sheetsInternals, cells, get pasteCalls() { return pasteCalls; }, - get deleteCalls() { return deleteCalls; } + get deleteCalls() { return deleteCalls; }, + get insertCalls() { return insertCalls; } }; } @@ -211,6 +225,16 @@ test('append boundaries and clear readback fail closed when UI state is ambiguou ['', '', ''], ['later', 'data', 'row'] ], 2, 'OVERWRITE').reason, 'ui-append-target-not-empty'); + assert.equal(ui.appendRowFromTable(parsed, [ + ['h1', 'h2', 'h3'], + ['', '', ''], + ['later', 'data', 'row'] + ], 1, 'OVERWRITE').reason, 'ui-append-boundary-ambiguous'); + assert.equal(ui.appendRowFromTable(parsed, [ + ['h1', 'h2', 'h3'], + ['', '', ''], + ['later', 'data', 'row'] + ], 1, 'INSERT_ROWS').reason, 'ui-append-boundary-ambiguous'); assert.equal(ui.valuesAreEmpty([['', ''], ['', '']]), true); assert.equal(ui.valuesAreEmpty([[''], ['still present']]), false); assert.equal(ui.valuesAreEmpty([[0]]), false); @@ -229,6 +253,19 @@ test('DOM fallback requires trusted keys, confirmed addresses, and a real formul assert.equal(wrongAddressResult.code, 'GOOGLE_SHEETS_SESSION_UNAVAILABLE'); assert.equal(wrongAddressResult.reason, 'name-box-navigation-not-confirmed'); + const wrongWorksheet = sheetsDomHarness({ confirmWorksheetNavigation: false }); + const wrongWorksheetResult = await wrongWorksheet.api.sheetsUpdateValues( + "'Other Sheet'!A1", + [['must not paste']], + 'RAW' + ); + assert.equal(wrongWorksheetResult.code, 'GOOGLE_SHEETS_SESSION_UNAVAILABLE'); + assert.equal(wrongWorksheetResult.reason, 'worksheet-navigation-not-confirmed'); + assert.equal(wrongWorksheet.pasteCalls, 0); + + const qualified = sheetsDomHarness(); + assert.equal((await qualified.api.sheetsNavigate("'Other Sheet'!A1")).success, true); + const noFormula = sheetsDomHarness({ missingFormulaBar: true }); const read = await noFormula.api.sheetsReadValues('A1', 'ROWS'); assert.equal(read.code, 'GOOGLE_SHEETS_SESSION_UNAVAILABLE'); @@ -248,6 +285,13 @@ test('DOM append scans a rectangular table and clear verifies actual readback', assert.equal(appendResult.reason, 'ui-append-boundary-ambiguous'); assert.equal(append.pasteCalls, 0); + const insertRows = sheetsDomHarness({ cells: { A1: 'header', A3: 'later data' } }); + const insertResult = await insertRows.api.sheetsAppendValues('A:A', [['new']], 'RAW', 'INSERT_ROWS'); + assert.equal(insertResult.code, 'RECIPE_DOM_FALLBACK_PENDING'); + assert.equal(insertResult.reason, 'ui-insert-rows-unverified'); + assert.equal(insertRows.insertCalls, 0); + assert.equal(insertRows.pasteCalls, 0); + const clearNoEffect = sheetsDomHarness({ cells: { A1: 'still here' }, deleteHasEffect: false }); const clearResult = await clearNoEffect.api.sheetsClearValues('A1'); assert.equal(clearResult.code, 'RECOVERY_AMBIGUOUS'); @@ -260,6 +304,34 @@ test('DOM append scans a rectangular table and clear verifies actual readback', assert.equal(clearWrongTarget.deleteCalls, 0); }); +test('content-level Sheets UI lock serializes read and mutation gestures', async () => { + const harness = sheetsDomHarness(); + let releaseFirst; + const gate = new Promise(resolve => { releaseFirst = resolve; }); + const events = []; + let active = 0; + let maxConcurrent = 0; + const operation = (name, wait) => harness.api.sheetsWithUiLock(async () => { + events.push(`start:${name}`); + active++; + maxConcurrent = Math.max(maxConcurrent, active); + if (wait) await gate; + active--; + events.push(`end:${name}`); + return name; + }); + + const read = operation('read', true); + await Promise.resolve(); + const mutation = operation('mutation', false); + await Promise.resolve(); + assert.deepEqual(events, ['start:read']); + releaseFirst(); + assert.deepEqual(await Promise.all([read, mutation]), ['read', 'mutation']); + assert.equal(maxConcurrent, 1); + assert.deepEqual(events, ['start:read', 'end:read', 'start:mutation', 'end:mutation']); +}); + test('fixed DOM actions reject caller-supplied option values outside the typed enums', async () => { const harness = sheetsDomHarness({ cells: { A1: 'header' } }); assert.equal( @@ -277,8 +349,15 @@ test('content action exposes only the fixed Sheets UI operations and protects va const messaging = fs.readFileSync(path.join(ROOT, 'extension/content/messaging.js'), 'utf8'); const background = fs.readFileSync(path.join(ROOT, 'extension/background.js'), 'utf8'); const wsClient = fs.readFileSync(path.join(ROOT, 'extension/ws/ws-client.js'), 'utf8'); + const legacySheetsActions = actions.slice( + actions.indexOf('fillsheet: async'), + actions.indexOf('dragdrop: async') + ); assert.match(actions, /sheetsSession:\s*async/); + assert.match(actions, /sheetsSession:\s*async[\s\S]{0,1000}return sheetsWithUiLock/); + assert.match(actions, /fillsheet:\s*async[\s\S]{0,1500}return sheetsWithUiLock/); + assert.match(actions, /readsheet:\s*async[\s\S]{0,1000}return sheetsWithUiLock/); for (const operation of ['getSpreadsheet', 'getValues', 'updateValues', 'appendValues', 'clearValues']) { assert.match(actions, new RegExp(`operation === '${operation}'`)); } @@ -286,6 +365,7 @@ test('content action exposes only the fixed Sheets UI operations and protects va assert.match(actions, /renderSemantics:\s*'formula-bar'/); assert.match(actions, /sheetsUi\.csvToValues\(data\)/); assert.match(actions, /sheetsReadValues\(range, 'ROWS'\)/); + assert.doesNotMatch(legacySheetsActions, /document\.querySelector\('#t-name-box'\)|tools\.keyPress\(/); assert.match(messaging, /'fillsheet'[\s\S]*'readsheet'[\s\S]*'sheetsSession'/); assert.match(messaging, /longTimeoutTools = \[[^\]]*'sheetsSession'/); assert.match(background, /'utils\/google-sheets-ui\.js'[\s\S]*'content\/actions\.js'/); diff --git a/tests/google-sheets-session.test.js b/tests/google-sheets-session.test.js index 5f7bb8bc3..3cf367143 100644 --- a/tests/google-sheets-session.test.js +++ b/tests/google-sheets-session.test.js @@ -187,6 +187,124 @@ test('serializes mutations per tab before dispatching page or UI work', async () assert.equal(maxConcurrent, 1); }); +test('serializes concurrent UI reads per tab without serializing their page-client attempts', async () => { + let pageCalls = 0; + let uiCalls = 0; + let concurrentUi = 0; + let maxConcurrentUi = 0; + let releaseFirstUi; + let signalFirstUi; + let signalSecondUi; + const firstUiStarted = new Promise(resolve => { signalFirstUi = resolve; }); + const secondUiStarted = new Promise(resolve => { signalSecondUi = resolve; }); + const firstUiGate = new Promise(resolve => { releaseFirstUi = resolve; }); + const chrome = { + tabs: { + async get(tabId) { return { id: tabId, url: URL }; }, + async sendMessage() { + uiCalls++; + concurrentUi++; + maxConcurrentUi = Math.max(maxConcurrentUi, concurrentUi); + if (uiCalls === 1) { + signalFirstUi(); + await firstUiGate; + } else { + signalSecondUi(); + } + concurrentUi--; + return { success: true, transport: 'ui', data: { values: [['ok']] } }; + } + }, + scripting: { + async executeScript() { + pageCalls++; + return [{ result: { + success: false, + code: 'GOOGLE_SHEETS_SESSION_UNAVAILABLE', + safeToFallback: true, + requestSent: false + } }]; + } + } + }; + const client = sessionModule.createSession({ chrome }); + const first = client.getValues({ range: 'A1' }, CONTEXT); + const second = client.getValues({ range: 'B1' }, CONTEXT); + + await firstUiStarted; + await new Promise(resolve => setImmediate(resolve)); + assert.equal(pageCalls, 2); + assert.equal(uiCalls, 1); + assert.equal(maxConcurrentUi, 1); + + releaseFirstUi(); + await secondUiStarted; + const results = await Promise.all([first, second]); + assert.equal(results[0].success, true); + assert.equal(results[1].success, true); + assert.equal(uiCalls, 2); + assert.equal(maxConcurrentUi, 1); +}); + +test('serializes a UI read behind a UI mutation while allowing its page-client attempt', async () => { + let pageCalls = 0; + let uiCalls = 0; + let concurrentUi = 0; + let maxConcurrentUi = 0; + let releaseMutationUi; + let signalMutationUi; + let signalReadUi; + const mutationUiStarted = new Promise(resolve => { signalMutationUi = resolve; }); + const readUiStarted = new Promise(resolve => { signalReadUi = resolve; }); + const mutationUiGate = new Promise(resolve => { releaseMutationUi = resolve; }); + const chrome = { + tabs: { + async get(tabId) { return { id: tabId, url: URL }; }, + async sendMessage(_tabId, message) { + uiCalls++; + concurrentUi++; + maxConcurrentUi = Math.max(maxConcurrentUi, concurrentUi); + if (message.params.operation === 'updateValues') { + signalMutationUi(); + await mutationUiGate; + } else { + signalReadUi(); + } + concurrentUi--; + return { success: true, transport: 'ui', data: { values: [['ok']] } }; + } + }, + scripting: { + async executeScript() { + pageCalls++; + return [{ result: { + success: false, + code: 'GOOGLE_SHEETS_SESSION_UNAVAILABLE', + safeToFallback: true, + requestSent: false + } }]; + } + } + }; + const client = sessionModule.createSession({ chrome }); + const mutation = client.updateValues({ range: 'A1', values: [['x']] }, CONTEXT); + await mutationUiStarted; + + const read = client.getValues({ range: 'B1' }, CONTEXT); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(pageCalls, 2); + assert.equal(uiCalls, 1); + assert.equal(maxConcurrentUi, 1); + + releaseMutationUi(); + await readUiStarted; + const results = await Promise.all([mutation, read]); + assert.equal(results[0].success, true); + assert.equal(results[1].success, true); + assert.equal(uiCalls, 2); + assert.equal(maxConcurrentUi, 1); +}); + test('classifies mutation timeouts, network failures, and 5xx responses as ambiguous', async () => { const previous = globalThis.gapi; const restoreLocation = installPageLocation(); diff --git a/tests/verify-origin-classification.test.js b/tests/verify-origin-classification.test.js index 2f1b5cb6d..35a465a50 100644 --- a/tests/verify-origin-classification.test.js +++ b/tests/verify-origin-classification.test.js @@ -419,16 +419,71 @@ function check(cond, msg) { path.join(ROOT, 'extension', 'content', 'actions.js'), 'utf8' ); - const broadenedContentSources = gate.verifyPageGapiUiSheetsSessionSources({ + const verifyInjectedSheetsContent = (injectedSource) => gate.verifyPageGapiUiSheetsSessionSources({ contentActionsText: sheetsContentSource.replace( 'sheetsSession: async (params = {}) => {', - 'sheetsSession: async (params = {}) => { fetch(params.url);' + 'sheetsSession: async (params = {}) => { ' + injectedSource ) }); + const broadenedContentSources = verifyInjectedSheetsContent('fetch(params.url);'); check(broadenedContentSources && broadenedContentSources.ok === false && broadenedContentSources.failures.includes('FORBIDDEN_SHEETS_CREDENTIAL_OR_NETWORK_SOURCE'), '(b) negative control: a caller-selected content-action fetch fails the fixed Sheets UI source contract'); + const bracketFetchContent = verifyInjectedSheetsContent("globalThis['fetch']('/relative');"); + check(bracketFetchContent && bracketFetchContent.ok === false + && bracketFetchContent.failures.includes('FORBIDDEN_SHEETS_CREDENTIAL_OR_NETWORK_SOURCE'), + '(b) negative control: bracket-property fetch in the content action fails the UI-only source contract'); + + const dynamicFetchContent = verifyInjectedSheetsContent( + "const networkKey = ['fet', 'ch'].join(''); globalThis[networkKey]('/relative');" + ); + check(dynamicFetchContent && dynamicFetchContent.ok === false + && dynamicFetchContent.failures.includes('FORBIDDEN_SHEETS_CREDENTIAL_OR_NETWORK_SOURCE'), + '(b) negative control: dynamically-named global network calls fail even without a contiguous fetch token'); + + const gapiContent = verifyInjectedSheetsContent( + "globalThis.gapi.client.request({ path: '/v4/spreadsheets/probe' });" + ); + check(gapiContent && gapiContent.ok === false + && gapiContent.failures.includes('FORBIDDEN_SHEETS_CREDENTIAL_OR_NETWORK_SOURCE'), + '(b) negative control: every gapi.client.request in the content action fails; only the pinned MAIN-world session may request'); + + const urlLiteralContent = verifyInjectedSheetsContent( + "const forbiddenRemote = 'https://evil.example.invalid/session';" + ); + check(urlLiteralContent && urlLiteralContent.ok === false + && urlLiteralContent.failures.includes('FORBIDDEN_SHEETS_CREDENTIAL_OR_NETWORK_SOURCE'), + '(b) negative control: an absolute URL literal in the content action fails even when no direct fetch call is present'); + + const dynamicUrlContent = verifyInjectedSheetsContent( + "const target = new URL('/session', window.location.origin);" + ); + check(dynamicUrlContent && dynamicUrlContent.ok === false + && dynamicUrlContent.failures.includes('FORBIDDEN_SHEETS_CREDENTIAL_OR_NETWORK_SOURCE'), + '(b) negative control: dynamic URL construction in the content action fails the fixed UI-only contract'); + + const imageSinkContent = verifyInjectedSheetsContent( + "const pixel = new Image(); pixel.src = '/session';" + ); + check(imageSinkContent && imageSinkContent.ok === false + && imageSinkContent.failures.includes('FORBIDDEN_SHEETS_CREDENTIAL_OR_NETWORK_SOURCE'), + '(b) negative control: Image/src DOM network sinks fail the fixed UI-only contract'); + + const dynamicDomSinkContent = verifyInjectedSheetsContent( + "const pixel = document.querySelector('img'); const sink = 'src'; pixel[sink] = '/session';" + ); + check(dynamicDomSinkContent && dynamicDomSinkContent.ok === false + && dynamicDomSinkContent.failures.includes('FORBIDDEN_SHEETS_CREDENTIAL_OR_NETWORK_SOURCE'), + '(b) negative control: computed DOM property assignment cannot hide a network-loading sink'); + + const runtimeProxyContent = verifyInjectedSheetsContent( + "chrome.runtime.sendMessage({ type: 'SESSION_ACTION', path: '/session' });" + ); + check(runtimeProxyContent && runtimeProxyContent.ok === false + && runtimeProxyContent.failures.includes('FORBIDDEN_SHEETS_CREDENTIAL_OR_NETWORK_SOURCE'), + '(b) negative control: a content-action runtime message cannot become a generic authenticated network proxy'); + // (b) REAL end-to-end: checkOriginClassification() over the LIVE catalog + vendored // slack-api.ts -- proves the real heads all pass and slack rides the dynamic // accommodation against the genuinely-extracted vendored dynamic form (not a stub). From d56e4942743dd5331aa9bf3dcf92ffcb1a037252 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Wed, 15 Jul 2026 14:08:31 -0500 Subject: [PATCH 19/26] docs(sheets): document signed-in session UAT --- docs/google-sheets-api.md | 71 ++++++++++++++++++++++++++++++--------- extension/README.md | 4 +++ package.json | 2 +- 3 files changed, 60 insertions(+), 17 deletions(-) diff --git a/docs/google-sheets-api.md b/docs/google-sheets-api.md index 4f0915ff5..b551d100b 100644 --- a/docs/google-sheets-api.md +++ b/docs/google-sheets-api.md @@ -1,24 +1,22 @@ -# Google Sheets API integration +# Google Sheets signed-in session integration -FSB includes a bounded Google Sheets v4 integration for reading spreadsheet metadata and values, updating or appending values, and clearing a range. It uses Chrome Identity for OAuth and never exposes a generic HTTP request surface. +FSB exposes five bounded Google Sheets capabilities without adding another authentication flow. The user signs in to Google Sheets normally; FSB reuses only the page session in the already-open, agent-owned spreadsheet tab. The extension does not request Google consent, configure a Cloud client, manage access tokens, inspect cookies, or store Google credentials. -## One-time Google Cloud setup +Private spreadsheet data still requires a Google-authenticated browser session. “No auth” here means no additional extension or API authorization beyond the user's existing Google Sheets login. -The repository intentionally ships with a nonfunctional OAuth client placeholder. A release owner must: +## Preconditions -1. Enable the Google Sheets API in a Google Cloud project. -2. Configure the OAuth consent screen for that project. -3. Create an OAuth client of type **Chrome Extension**, tied to the stable ID of the packaged FSB extension. -4. Replace `REPLACE_WITH_FSB_GOOGLE_OAUTH_CLIENT_ID.apps.googleusercontent.com` in `extension/manifest.json` with that client ID. -5. Reload the extension, open the FSB control panel, and click **Connect Google Sheets**. +Before invoking a Sheets capability: -Until step 4 is complete, the runtime fails closed with `GOOGLE_SHEETS_OAUTH_NOT_CONFIGURED` and does not open a Google authorization prompt. +1. Sign in to Google Sheets in the same browser profile as FSB. +2. Open the target spreadsheet at `https://docs.google.com/spreadsheets/d//...`. +3. Make that tab the agent-owned target and ensure the signed-in user has the required read or edit access. -## Access and behavior +FSB never logs in, creates a spreadsheet, opens a consent screen, or navigates to a caller-supplied spreadsheet. If `spreadsheetId` is supplied, it must match the ID in the owned tab's current URL. -The extension requests only `https://www.googleapis.com/auth/spreadsheets`. Capability calls obtain cached authorization non-interactively; only the control-panel Connect button may start an interactive OAuth flow. Access tokens remain in Chrome Identity's cache and are never stored, logged, returned to callers, or placed in errors. +## Capability contract -The API facade permits only these operations: +The existing generic MCP capability flow discovers and invokes exactly these typed slugs: - `gsheets.get_spreadsheet` - `gsheets.get_values` @@ -26,8 +24,49 @@ The API facade permits only these operations: - `gsheets.append_values` - `gsheets.clear_values` -Requests are restricted to `https://sheets.googleapis.com/v4`, validate spreadsheet IDs and ranges, cap request and response sizes, time out, and retry once only after an HTTP 401. Google Sheets is classified as a sensitive origin, so write and destructive operations remain behind FSB's consent policy. +No Sheets-specific MCP server method or package update is required. `search_capabilities` returns the dynamic descriptors and `invoke_capability` routes the selected slug to the extension. -After OAuth setup, the two read capabilities are executable. The update, append, and clear capabilities are discoverable and fully typed but intentionally return `RECIPE_DOM_FALLBACK_PENDING` without calling Google until the repository's live mutation-UAT activation record is completed. This preserves the project's fail-closed write policy; configuring OAuth alone does not activate spreadsheet mutations. +For each operation, the extension re-reads the owned tab URL and pins the request to that tab. It first tries an already-initialized, page-owned `gapi.client.request` with a fixed Sheets v4 method and path. It does not initialize gapi or authentication. If that page client is unavailable, the extension may use a fixed Google Sheets UI operation; callers cannot supply a URL, HTTP method, headers, or credentials. -The existing `fill_sheet` and `read_sheet` browser-automation tools remain available as a fallback. Spreadsheet API and fallback-tool session records are reduced to shape-only diagnostics before recording; spreadsheet IDs, ranges, sheet names, values, formulas, and response bodies are discarded. +For a sheet-qualified A1 range, UI fallback also verifies the selected worksheet tab after Name Box navigation. Matching cell coordinates on a different worksheet are treated as a session failure. + +Successful responses identify the transport as `page-client` or `ui`. UI reads also return `renderSemantics: "formula-bar"` because they read the bounded range cell by cell from the Sheets formula bar rather than reproducing every Sheets API render option. + +## UI fallback limits + +- UI reads are limited to 50 rows by 26 columns. +- UI writes accept only non-empty rectangular matrices that can be represented unambiguously as tab-separated paste data. Ragged rows, null values, tabs, newlines, unsupported objects, non-finite numbers, and oversized payloads fail closed. +- `RAW` treats every non-empty string as literal text, including formula-like, numeric-looking, boolean-looking, date-like, and apostrophe-prefixed strings. `USER_ENTERED` permits Google Sheets to interpret formulas and other entered values. +- Large bounded writes are split only on row boundaries. The extension may temporarily replace the clipboard while pasting. +- UI append scans a bounded rectangular table (at most 25 existing rows by 10 columns, with at most 25 appended rows) and proceeds only when it can prove a contiguous table boundary and every remaining scanned row is empty. Gaps, orphaned sibling cells, or any ambiguous boundary are rejected without writing. UI fallback supports `OVERWRITE`; `INSERT_ROWS` uses the page client and fails closed if that transport is unavailable because a swallowed row-insert shortcut cannot be proven safe before paste. +- UI clear is limited to 100 cells, selects the exact range, deletes it, and verifies an empty formula-bar readback. + +The legacy `fill_sheet` and `read_sheet` tools reuse the same range, value, paste, and formula-bar helpers so Sheets automation has one primary implementation path. + +## Recovery and errors + +The normalized failure codes are: + +- `GOOGLE_SHEETS_ACTIVE_TAB_REQUIRED`: the owned tab is not an open spreadsheet. +- `GOOGLE_SHEETS_TARGET_MISMATCH`: an explicit ID does not match that tab. +- `GOOGLE_SHEETS_SESSION_UNAVAILABLE`: the signed-in page or required UI controls are unavailable. +- `RECIPE_DOM_FALLBACK_PENDING`: the requested operation cannot be represented safely by the bounded fallback. +- `RECOVERY_AMBIGUOUS`: a mutation may have taken effect, so FSB does not retry it. + +UI mutation fallback occurs only when the page request was never sent or was explicitly rejected with no effect. A timeout, network failure, 5xx response, or otherwise unknown mutation outcome returns `RECOVERY_AMBIGUOUS` and is never replayed automatically. Typed mutations are serialized per tab, and every UI operation—including reads and legacy aliases—shares the tab's UI lock. + +UI navigation and paste/delete keystrokes require the trusted debugger-backed input path. A caller cannot downgrade them to synthetic DOM key events. + +Google Sheets remains a sensitive origin. Consent gates, mutation serialization, origin pinning, and shape-only spreadsheet record redaction remain active. Spreadsheet IDs, ranges, sheet names, values, formulas, response bodies, and raw errors are excluded from session records. + +## Write activation and UAT + +The three mutation slugs remain runtime-guarded until a real disposable-sheet UAT passes. Static and unit tests do not count as activation evidence. + +For live UAT: + +1. Rebuild the extension with `npm run package:extension` and reload the unpacked `extension/` directory. +2. Open a disposable Sheet while signed in and assign that tab to the agent. +3. Reconnect the existing MCP bridge; do not install or update the MCP package. +4. Invoke all five slugs through `invoke_capability`, covering RAW and USER_ENTERED updates, `OVERWRITE` and `INSERT_ROWS` appends, clear/readback, target mismatch, and an ambiguous-recovery case with no duplicate write. +5. Record only redacted evidence. Activate the mutation handlers only after every readback passes; otherwise leave the guards in place and report the exact gap. diff --git a/extension/README.md b/extension/README.md index 4de009653..aba29c105 100644 --- a/extension/README.md +++ b/extension/README.md @@ -13,6 +13,10 @@ After code changes, reload the extension from `chrome://extensions` and refresh any open tabs so content scripts re-inject. +## Google Sheets Development + +Google Sheets capabilities reuse an already signed-in, agent-owned spreadsheet tab. They require no Google Cloud client ID, consent prompt, token setup, or Sheets-specific MCP update. Reload the unpacked extension after changes, refresh the open Sheet, and reconnect the existing MCP bridge. See [Google Sheets signed-in session integration](../docs/google-sheets-api.md) for the bounded operation contract and UAT gates. + ## Key Entry Points | Path | Role | diff --git a/package.json b/package.json index 874994e63..9fd74952a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "scripts": { "build": "node esbuild.config.js", "build:cfworker": "node_modules/.bin/esbuild node_modules/@cfworker/json-schema/dist/esm/index.js --bundle --format=iife --global-name=CfworkerJsonSchema --platform=browser --target=chrome120 --legal-comments=none --outfile=extension/lib/cfworker-json-schema.min.js", - "test": "node tests/test-overlay-state.js && node tests/cost-tracker-ordering.test.js && node tests/runtime-contracts.test.js && node tests/ai-integration-analytics.test.js && node tests/dashboard-runtime-state.test.js && node tests/task-router.test.js && npm --prefix mcp run build && node tests/mcp-bridge-client-lifecycle.test.js && node tests/mcp-bridge-topology.test.js && node tests/mcp-bridge-background-dispatch.test.js && node tests/mcp-tool-routing-contract.test.js && node tests/mcp-visual-session-contract.test.js && node tests/mcp-restricted-tab.test.js && node tests/mcp-recovery-messaging.test.js && node tests/mcp-in-flight-session-lookup.test.js && node tests/mcp-version-parity.test.js && node tests/mcp-diagnostics-status.test.js && node tests/mcp-install-platforms.test.js && node tests/mcp-setup-guidance.test.js && node tests/turn-result.test.js && node tests/action-history.test.js && node tests/universal-provider-lmstudio.test.js && node tests/config-lmstudio.test.js && node tests/cost-tracker.test.js && node tests/install-identity.test.js && node tests/mcp-pricing.test.js && node tests/mcp-pricing-data-parity.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-dispatcher-client-label.test.js && node tests/mcp-session-recorder.test.js && node tests/mcp-metrics-no-pii-leak.test.js && node tests/telemetry-collector.test.js && node tests/telemetry-collector-strips-internal-fields.test.js && node tests/telemetry-payload-allowlist.test.js && node tests/extension-record-dispatch-serializes.test.js && node tests/server-trust-proxy.test.js && node tests/server-telemetry-salt-rotation.test.js && node tests/server-telemetry-rate-limit.test.js && node tests/server-telemetry-body-cap.test.js && node tests/server-telemetry-batch-cap.test.js && node tests/server-telemetry-allowlist.test.js && node tests/server-telemetry-event-id-dedup.test.js && node tests/server-telemetry-timestamp-tolerance.test.js && node tests/server-telemetry-daily-budget.test.js && node tests/server-telemetry-sec-gpc.test.js && node tests/server-telemetry-optout-forget.test.js && node tests/server-telemetry-housekeeper.test.js && node tests/server-public-stats-headline.test.js && node tests/server-public-stats-series.test.js && node tests/server-public-stats-cache.test.js && node tests/server-public-stats-no-auth.test.js && node tests/server-github-cache.test.js && node tests/server-github-poller.test.js && node tests/fsb-telemetry-service.test.js && node tests/showcase-build-smoke.test.js && node tests/stats-chart-overhaul.test.js && node scripts/verify-store-listing.mjs && node tests/verify-store-listing.test.js && node tests/cumulative-commits-aggregator.test.js && node tests/dashboard-stream-readiness-ping.test.js && node tests/dashboard-stream-pending-intent.test.js && node tests/dashboard-stream-recovery-parity.test.js && node tests/server-ws-backpressure.test.js && node tests/server-ws-phantomstream-relay-compat.test.js && node tests/showcase-privacy-page.test.js && node tests/server-no-ip-leak.test.js && node tests/server-ip-geo.test.js && node tests/server-ip-geo-merge.test.js && node tests/server-ip-geo-scale.test.js && node tests/server-region-aggregation.test.js && node tests/transcript-store.test.js && node tests/hook-pipeline.test.js && node tests/session-schema.test.js && node tests/state-emitter.test.js && node tests/secure-config-credential-vault.test.js && node tests/qr-pairing.test.js && node tests/ws-client-decompress.test.js && node tests/dashboard-task-direct-start.test.js && node tests/phantom-stream-protocol-envelope.test.js && node tests/phantom-stream-differential-parity.test.js && node tests/phantom-stream-remote-control-parity.test.js && node tests/phantom-stream-public-package.test.js && node tests/phantom-stream-exports.test.js && node tests/phantom-stream-content-bundle.test.js && node tests/phantom-stream-capture-adapter.test.js && node tests/phantom-stream-sidechannels.test.js && node tests/phantom-stream-security-masking.test.js && node tests/phantom-stream-dashboard-viewer-bundle.test.js && node tests/phantom-stream-static-viewer.test.js && node tests/phantom-stream-dashboard-parity.test.js && node tests/phantom-stream-dashboard-sidechannels.test.js && node tests/phantom-stream-media-sync.test.js && node tests/phantom-stream-media-wiring.test.js && node tests/dom-stream-perf.test.js && node tests/extension-content-script-files-completeness.test.js && node tests/dom-analysis-viewport-priority.test.js && node tests/redact-for-log.test.js && node tests/diagnostics-ring-buffer.test.js && node tests/agent-sunset-back-end.test.js && node tests/agent-sunset-control-panel.test.js && node tests/agent-sunset-showcase.test.js && node tests/sync-tab-runtime.test.js && node tests/remote-control-rebrand.test.js && node tests/server-accept-language.test.js && node tests/metrics-wireup.test.js && node tests/dashboard-metrics-render.test.js && node tests/stuck-action-repetition.test.js && node tests/goal-progress-tracker.test.js && node tests/model-discovery.test.js && node tests/model-discovery-ui.test.js && node tests/model-combobox-ui.test.js && node tests/settings-card-select-clipping.test.js && node tests/control-panel-scroll-containment.test.js && node tests/model-discovery-runtime.test.js && node tests/overlay-stability-cadence.test.js && node tests/overlay-content-audit.test.js && node tests/meta-cognitive-tracker.test.js && node tests/agent-cap-recommendation.test.js && node tests/agent-cap-storage.test.js && node tests/agent-cap-ui.test.js && node tests/agent-registry.test.js && node tests/selected-tab-state.test.js && node tests/change-report-builder.test.js && node tests/change-report-size-cap.test.js && node tests/change-report-cross-origin.test.js && node tests/change-report-dispatcher.test.js && node tests/change-report-read-tools-excluded.test.js && node tests/change-report-toggle.test.js && node tests/change-report-settings-ui.test.js && node tests/agent-tab-resolver.test.js && node tests/read-tool-tab-resolution.test.js && node tests/open-tab-background-default.test.js && node tests/visual-session-agent-scoped.test.js && node tests/action-tool-agent-scoped.test.js && node tests/recovery-tools-bootstrap.test.js && node tests/legacy-agent-synthesis.test.js && node tests/visual-session-reentry.test.js && node tests/ownership-error-codes.test.js && node tests/multi-agent-regression.test.js && node tests/tool-definitions-parity.test.js && node tests/mcp-numeric-param-coercion.test.js && node tests/visual-session-schema-lock.test.js && node tests/mcp-visual-tick-lifecycle.test.js && node tests/agent-id-threading.test.js && node tests/skill-fsb-spec.test.js && node tests/agent-loop-empty-contents.test.js && node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js && node tests/mcp-philosophy-parity-smoke.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js && node tests/sidepanel-mcpvisualsession-listener.test.js && node tests/sidepanel-tab-scoping-fix-redo-smoke.test.js && node tests/sidepanel-progress-tick-setter-routing.test.js && node tests/sidepanel-background-tab-completion.test.js && node tests/sidepanel-multi-document-fanout.test.js && node tests/trigger-store.test.js && node tests/trigger-lifecycle.test.js && node tests/value-extractor.test.js && node tests/trigger-manager.test.js && node tests/trigger-cap.test.js && node tests/trigger-observe.test.js && node tests/trigger-observe-pulse.test.js && node tests/trigger-refresh-poll.test.js && node tests/trigger-tool-dispatcher.test.js && node tests/trigger-blocking-reporting.test.js && node tests/capability-recipe-schema.test.js && node tests/capability-interpreter.test.js && node tests/recipe-path-guard.test.js && node tests/capability-fetch.test.js && node tests/capability-search-eval.test.js && node tests/capability-mcp-surface.test.js && node tests/capability-router.test.js && node tests/capability-autopilot-parity.test.js && node tests/capability-head-handlers.test.js && node tests/consent-policy-store.test.js && node tests/consent-gate.test.js && node tests/consent-mutation-gate.test.js && node tests/consent-chokepoint.test.js && node tests/audit-log.test.js && node tests/audit-log-no-secret.test.js && node tests/recipe-signature.test.js && node tests/recipe-signature-interpreter-hook.test.js && node tests/service-denylist.test.js && node tests/classification-gate.test.js && node tests/provenance-scaffold.test.js && node tests/upload-file-route.test.js && node tests/upload-path-denylist.test.js && node tests/consent-audit-settings-ui.test.js && node tests/agent-loop-iterator-guard.test.js && node tests/network-capture.test.js && node tests/network-capture-consent.test.js && node tests/network-capture-redaction.test.js && node tests/recipe-synthesizer.test.js && node tests/learned-recipe-store.test.js && node tests/learned-search-add.test.js && node tests/learned-t2-outranking.test.js && node tests/learned-promote-after-replay.test.js && node tests/discovery-seeds-load.test.js && node tests/seed-resolve-t2.test.js && node tests/recipe-learn-pending-affordance.test.js && node tests/learned-local-provenance-exempt.test.js && node tests/provider-parity.test.js && node tests/recipe-schema-lock.test.js && node tests/capability-rot-detector.test.js && node tests/relearn-coalescing.test.js && node tests/rot-recurrence-classify.test.js && node tests/app-degraded-surfacing.test.js && node tests/relearn-router-wiring.test.js && node tests/import-extraction.test.js && node tests/import-forbidden-prescan.test.js && node tests/import-classify-gate-call.test.js && node tests/catalog-crosscheck.test.js && node tests/no-dead-entry.test.js && node tests/catalog-inline-shape.test.js && node tests/head-handler-cap.test.js && node tests/head-handler-upgrade.test.js && node tests/confluence-t1-handler.test.js && node tests/verify-origin-classification.test.js && node tests/guarded-write-failclosed.test.js && node tests/breadth-search-return.test.js && node tests/breadth-batch-gate.test.js && node tests/backing-status-annotation.test.js && node tests/t1-port-contract.test.js && node tests/t1-port-contract-gate.test.js && node tests/dom-only-default.test.js && node tests/sensitive-write-import-gate.test.js && node tests/coverage-report.test.js && node tests/payment-op-guard.test.js && node tests/vendor-augment-preserved.test.js && node tests/no-duplicate-stem.test.js && node tests/full-corpus-screen.test.js && node tests/full-corpus-scale.test.js && node tests/keyboard-attach-robustness.test.js && node --import tsx tests/no-orphan-descriptor.test.js", + "test": "node tests/test-overlay-state.js && node tests/cost-tracker-ordering.test.js && node tests/runtime-contracts.test.js && node tests/ai-integration-analytics.test.js && node tests/dashboard-runtime-state.test.js && node tests/task-router.test.js && npm --prefix mcp run build && node tests/mcp-bridge-client-lifecycle.test.js && node tests/mcp-bridge-topology.test.js && node tests/mcp-bridge-background-dispatch.test.js && node tests/mcp-tool-routing-contract.test.js && node tests/mcp-visual-session-contract.test.js && node tests/mcp-restricted-tab.test.js && node tests/mcp-recovery-messaging.test.js && node tests/mcp-in-flight-session-lookup.test.js && node tests/mcp-version-parity.test.js && node tests/mcp-diagnostics-status.test.js && node tests/mcp-install-platforms.test.js && node tests/mcp-setup-guidance.test.js && node tests/turn-result.test.js && node tests/action-history.test.js && node tests/universal-provider-lmstudio.test.js && node tests/config-lmstudio.test.js && node tests/cost-tracker.test.js && node tests/install-identity.test.js && node tests/mcp-pricing.test.js && node tests/mcp-pricing-data-parity.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-dispatcher-client-label.test.js && node tests/mcp-session-recorder.test.js && node tests/mcp-metrics-no-pii-leak.test.js && node tests/telemetry-collector.test.js && node tests/telemetry-collector-strips-internal-fields.test.js && node tests/telemetry-payload-allowlist.test.js && node tests/extension-record-dispatch-serializes.test.js && node tests/server-trust-proxy.test.js && node tests/server-telemetry-salt-rotation.test.js && node tests/server-telemetry-rate-limit.test.js && node tests/server-telemetry-body-cap.test.js && node tests/server-telemetry-batch-cap.test.js && node tests/server-telemetry-allowlist.test.js && node tests/server-telemetry-event-id-dedup.test.js && node tests/server-telemetry-timestamp-tolerance.test.js && node tests/server-telemetry-daily-budget.test.js && node tests/server-telemetry-sec-gpc.test.js && node tests/server-telemetry-optout-forget.test.js && node tests/server-telemetry-housekeeper.test.js && node tests/server-public-stats-headline.test.js && node tests/server-public-stats-series.test.js && node tests/server-public-stats-cache.test.js && node tests/server-public-stats-no-auth.test.js && node tests/server-github-cache.test.js && node tests/server-github-poller.test.js && node tests/fsb-telemetry-service.test.js && node tests/showcase-build-smoke.test.js && node tests/stats-chart-overhaul.test.js && node scripts/verify-store-listing.mjs && node tests/verify-store-listing.test.js && node tests/cumulative-commits-aggregator.test.js && node tests/dashboard-stream-readiness-ping.test.js && node tests/dashboard-stream-pending-intent.test.js && node tests/dashboard-stream-recovery-parity.test.js && node tests/server-ws-backpressure.test.js && node tests/server-ws-phantomstream-relay-compat.test.js && node tests/showcase-privacy-page.test.js && node tests/server-no-ip-leak.test.js && node tests/server-ip-geo.test.js && node tests/server-ip-geo-merge.test.js && node tests/server-ip-geo-scale.test.js && node tests/server-region-aggregation.test.js && node tests/transcript-store.test.js && node tests/hook-pipeline.test.js && node tests/session-schema.test.js && node tests/state-emitter.test.js && node tests/secure-config-credential-vault.test.js && node tests/qr-pairing.test.js && node tests/ws-client-decompress.test.js && node tests/dashboard-task-direct-start.test.js && node tests/phantom-stream-protocol-envelope.test.js && node tests/phantom-stream-differential-parity.test.js && node tests/phantom-stream-remote-control-parity.test.js && node tests/phantom-stream-public-package.test.js && node tests/phantom-stream-exports.test.js && node tests/phantom-stream-content-bundle.test.js && node tests/phantom-stream-capture-adapter.test.js && node tests/phantom-stream-sidechannels.test.js && node tests/phantom-stream-security-masking.test.js && node tests/phantom-stream-dashboard-viewer-bundle.test.js && node tests/phantom-stream-static-viewer.test.js && node tests/phantom-stream-dashboard-parity.test.js && node tests/phantom-stream-dashboard-sidechannels.test.js && node tests/phantom-stream-media-sync.test.js && node tests/phantom-stream-media-wiring.test.js && node tests/dom-stream-perf.test.js && node tests/extension-content-script-files-completeness.test.js && node tests/dom-analysis-viewport-priority.test.js && node tests/redact-for-log.test.js && node tests/diagnostics-ring-buffer.test.js && node tests/agent-sunset-back-end.test.js && node tests/agent-sunset-control-panel.test.js && node tests/agent-sunset-showcase.test.js && node tests/sync-tab-runtime.test.js && node tests/remote-control-rebrand.test.js && node tests/server-accept-language.test.js && node tests/metrics-wireup.test.js && node tests/dashboard-metrics-render.test.js && node tests/stuck-action-repetition.test.js && node tests/goal-progress-tracker.test.js && node tests/model-discovery.test.js && node tests/model-discovery-ui.test.js && node tests/model-combobox-ui.test.js && node tests/settings-card-select-clipping.test.js && node tests/control-panel-scroll-containment.test.js && node tests/model-discovery-runtime.test.js && node tests/overlay-stability-cadence.test.js && node tests/overlay-content-audit.test.js && node tests/meta-cognitive-tracker.test.js && node tests/agent-cap-recommendation.test.js && node tests/agent-cap-storage.test.js && node tests/agent-cap-ui.test.js && node tests/agent-registry.test.js && node tests/selected-tab-state.test.js && node tests/change-report-builder.test.js && node tests/change-report-size-cap.test.js && node tests/change-report-cross-origin.test.js && node tests/change-report-dispatcher.test.js && node tests/change-report-read-tools-excluded.test.js && node tests/change-report-toggle.test.js && node tests/change-report-settings-ui.test.js && node tests/agent-tab-resolver.test.js && node tests/read-tool-tab-resolution.test.js && node tests/open-tab-background-default.test.js && node tests/visual-session-agent-scoped.test.js && node tests/action-tool-agent-scoped.test.js && node tests/recovery-tools-bootstrap.test.js && node tests/legacy-agent-synthesis.test.js && node tests/visual-session-reentry.test.js && node tests/ownership-error-codes.test.js && node tests/multi-agent-regression.test.js && node tests/tool-definitions-parity.test.js && node tests/mcp-numeric-param-coercion.test.js && node tests/visual-session-schema-lock.test.js && node tests/mcp-visual-tick-lifecycle.test.js && node tests/agent-id-threading.test.js && node tests/skill-fsb-spec.test.js && node tests/agent-loop-empty-contents.test.js && node --test tests/google-sheets-session.test.js tests/google-sheets-content-actions.test.js tests/gsheets-handler.test.js tests/google-sheets-wiring.test.js tests/spreadsheet-record-redaction.test.js && node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js && node tests/mcp-philosophy-parity-smoke.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js && node tests/sidepanel-mcpvisualsession-listener.test.js && node tests/sidepanel-tab-scoping-fix-redo-smoke.test.js && node tests/sidepanel-progress-tick-setter-routing.test.js && node tests/sidepanel-background-tab-completion.test.js && node tests/sidepanel-multi-document-fanout.test.js && node tests/trigger-store.test.js && node tests/trigger-lifecycle.test.js && node tests/value-extractor.test.js && node tests/trigger-manager.test.js && node tests/trigger-cap.test.js && node tests/trigger-observe.test.js && node tests/trigger-observe-pulse.test.js && node tests/trigger-refresh-poll.test.js && node tests/trigger-tool-dispatcher.test.js && node tests/trigger-blocking-reporting.test.js && node tests/capability-recipe-schema.test.js && node tests/capability-interpreter.test.js && node tests/recipe-path-guard.test.js && node tests/capability-fetch.test.js && node tests/capability-search-eval.test.js && node tests/capability-mcp-surface.test.js && node tests/capability-router.test.js && node tests/capability-autopilot-parity.test.js && node tests/capability-head-handlers.test.js && node tests/consent-policy-store.test.js && node tests/consent-gate.test.js && node tests/consent-mutation-gate.test.js && node tests/consent-chokepoint.test.js && node tests/audit-log.test.js && node tests/audit-log-no-secret.test.js && node tests/recipe-signature.test.js && node tests/recipe-signature-interpreter-hook.test.js && node tests/service-denylist.test.js && node tests/classification-gate.test.js && node tests/provenance-scaffold.test.js && node tests/upload-file-route.test.js && node tests/upload-path-denylist.test.js && node tests/consent-audit-settings-ui.test.js && node tests/agent-loop-iterator-guard.test.js && node tests/network-capture.test.js && node tests/network-capture-consent.test.js && node tests/network-capture-redaction.test.js && node tests/recipe-synthesizer.test.js && node tests/learned-recipe-store.test.js && node tests/learned-search-add.test.js && node tests/learned-t2-outranking.test.js && node tests/learned-promote-after-replay.test.js && node tests/discovery-seeds-load.test.js && node tests/seed-resolve-t2.test.js && node tests/recipe-learn-pending-affordance.test.js && node tests/learned-local-provenance-exempt.test.js && node tests/provider-parity.test.js && node tests/recipe-schema-lock.test.js && node tests/capability-rot-detector.test.js && node tests/relearn-coalescing.test.js && node tests/rot-recurrence-classify.test.js && node tests/app-degraded-surfacing.test.js && node tests/relearn-router-wiring.test.js && node tests/import-extraction.test.js && node tests/import-forbidden-prescan.test.js && node tests/import-classify-gate-call.test.js && node tests/catalog-crosscheck.test.js && node tests/no-dead-entry.test.js && node tests/catalog-inline-shape.test.js && node tests/head-handler-cap.test.js && node tests/head-handler-upgrade.test.js && node tests/confluence-t1-handler.test.js && node tests/verify-origin-classification.test.js && node tests/guarded-write-failclosed.test.js && node tests/breadth-search-return.test.js && node tests/breadth-batch-gate.test.js && node tests/backing-status-annotation.test.js && node tests/t1-port-contract.test.js && node tests/t1-port-contract-gate.test.js && node tests/dom-only-default.test.js && node tests/sensitive-write-import-gate.test.js && node tests/coverage-report.test.js && node tests/payment-op-guard.test.js && node tests/vendor-augment-preserved.test.js && node tests/no-duplicate-stem.test.js && node tests/full-corpus-screen.test.js && node tests/full-corpus-scale.test.js && node tests/keyboard-attach-robustness.test.js && node --import tsx tests/no-orphan-descriptor.test.js", "test:lattice": "node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js", "test:mcp-smoke:lifecycle": "npm --prefix mcp run build && node tests/mcp-lifecycle-smoke.test.js", "test:mcp-smoke:tools": "npm --prefix mcp run build && node tests/mcp-tool-smoke.test.js", From 9ab3d40d000e2fab0c5d0efacc1560a65fa69426 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Wed, 15 Jul 2026 14:10:28 -0500 Subject: [PATCH 20/26] fix(sheets): block dynamic UI network loaders --- scripts/verify-origin-classification.mjs | 5 +++++ tests/verify-origin-classification.test.js | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/scripts/verify-origin-classification.mjs b/scripts/verify-origin-classification.mjs index ff4e228d4..e0befba7f 100644 --- a/scripts/verify-origin-classification.mjs +++ b/scripts/verify-origin-classification.mjs @@ -4490,8 +4490,12 @@ function hasForbiddenSheetsContentNetworkSource(source) { const dynamicOrProxyCall = /\b(?:globalThis|window|self|document|navigator|tools|chrome(?:Api)?|browser)\s*\[/i.test(text) || /\]\s*\(/.test(text) + || /\bimport\s*\(/i.test(text) + || /\beval\b|\bFunction\b/.test(text) || /\.\s*request\s*\(/i.test(text) || /\b(?:gapi|postMessage|MessageChannel|BroadcastChannel)\b/i.test(text) + || /\b(?:serviceWorker|sharedStorage|audioWorklet|paintWorklet|layoutWorklet)\b/i.test(text) + || /\.\s*addModule\s*\(/i.test(text) || /\b(?:chrome(?:Api)?|browser)\s*\.\s*(?:runtime|tabs|scripting|webRequest|cookies|identity)\b/i.test(text) || /\bReflect\s*\.\s*(?:get|set|apply|construct)\s*\(/i.test(text); @@ -4511,6 +4515,7 @@ function hasForbiddenSheetsContentNetworkSource(source) { || /\bObject\s*\.\s*(?:assign|defineProperty|defineProperties)\s*\(/i.test(text) || /\.\s*(?:innerHTML|outerHTML|cssText|background|backgroundImage|borderImage|content|cursor|listStyle|listStyleImage|mask|maskImage)\s*=/i.test(text) || /\.\s*(?:createContextualFragment|insertAdjacentHTML|submit|requestSubmit|setProperty)\s*\(/i.test(text) + || /\bdocument\s*\.\s*(?:write|writeln)\s*\(/i.test(text) || /\b(?:window|globalThis|self)\s*\.\s*open\s*\(/i.test(text) || /\b(?:window\s*\.\s*)?(?:document\s*\.\s*)?location\s*(?:\.\s*href\s*)?=/i.test(text) || /\blocation\s*\.\s*(?:assign|replace|reload)\s*\(/i.test(text); diff --git a/tests/verify-origin-classification.test.js b/tests/verify-origin-classification.test.js index 35a465a50..b4217f7f7 100644 --- a/tests/verify-origin-classification.test.js +++ b/tests/verify-origin-classification.test.js @@ -484,6 +484,25 @@ function check(cond, msg) { && runtimeProxyContent.failures.includes('FORBIDDEN_SHEETS_CREDENTIAL_OR_NETWORK_SOURCE'), '(b) negative control: a content-action runtime message cannot become a generic authenticated network proxy'); + const dynamicImportContent = verifyInjectedSheetsContent('await import(params.range);'); + check(dynamicImportContent && dynamicImportContent.ok === false + && dynamicImportContent.failures.includes('FORBIDDEN_SHEETS_CREDENTIAL_OR_NETWORK_SOURCE'), + '(b) negative control: dynamic module loading cannot turn a Sheets argument into a network target'); + + const serviceWorkerContent = verifyInjectedSheetsContent( + 'await navigator.serviceWorker.register(params.range);' + ); + check(serviceWorkerContent && serviceWorkerContent.ok === false + && serviceWorkerContent.failures.includes('FORBIDDEN_SHEETS_CREDENTIAL_OR_NETWORK_SOURCE'), + '(b) negative control: service-worker and worklet loaders are forbidden in the UI-only action'); + + const dynamicCodeContent = verifyInjectedSheetsContent( + 'const generated = Function(params.range); generated();' + ); + check(dynamicCodeContent && dynamicCodeContent.ok === false + && dynamicCodeContent.failures.includes('FORBIDDEN_SHEETS_CREDENTIAL_OR_NETWORK_SOURCE'), + '(b) negative control: generated code cannot synthesize a network primitive in the Sheets action'); + // (b) REAL end-to-end: checkOriginClassification() over the LIVE catalog + vendored // slack-api.ts -- proves the real heads all pass and slack rides the dynamic // accommodation against the genuinely-extracted vendored dynamic form (not a stub). From 55f14777e32b1c2576ce86de8ed40951ecac5684 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Wed, 15 Jul 2026 14:17:39 -0500 Subject: [PATCH 21/26] docs(quick-260715-hs1): zero-extra-auth Google Sheets --- .planning/STATE.md | 251 +++++++++--------- .../260715-hs1-PLAN.md | 85 ++++++ .../260715-hs1-SUMMARY.md | 57 ++++ .../260715-hs1-VERIFICATION.md | 37 +++ 4 files changed, 305 insertions(+), 125 deletions(-) create mode 100644 .planning/quick/260715-hs1-replace-google-sheets-oauth-with-a-signe/260715-hs1-PLAN.md create mode 100644 .planning/quick/260715-hs1-replace-google-sheets-oauth-with-a-signe/260715-hs1-SUMMARY.md create mode 100644 .planning/quick/260715-hs1-replace-google-sheets-oauth-with-a-signe/260715-hs1-VERIFICATION.md diff --git a/.planning/STATE.md b/.planning/STATE.md index 757d22b10..c128af3ec 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,8 +4,8 @@ milestone: v1.1.0 milestone_name: T1 App Execution Expansion status: completed stopped_at: "v1.1.0 T1 App Execution Expansion complete, audited, and archived. No active milestone is currently defined." -last_updated: "2026-07-01T13:44:05-05:00" -last_activity: 2026-07-01 +last_updated: "2026-07-15T14:16:18-05:00" +last_activity: 2026-07-15 progress: total_phases: 8 completed_phases: 8 @@ -152,129 +152,130 @@ None active. ### Quick Tasks Completed -| # | Description | Date | Commit | Directory | -|---|-------------|------|--------|-----------| -| 260715-8wh | Implement production-safe Google Sheets API MVP with Chrome OAuth, typed read/write capabilities, and spreadsheet-data recording redaction | 2026-07-15 | a83d21b8 | [260715-8wh-implement-production-safe-google-sheets-](./quick/260715-8wh-implement-production-safe-google-sheets-/) | -| 260707-7id | Record MCP agent sessions into logs, history, replay, and memory like autopilot runs | 2026-07-07 | 721e2826 | [260707-7id-record-mcp-agent-sessions-into-logs-hist](./quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/) | -| 260701-e6c | Implement Supabase to be T1 ready | 2026-07-01 | 2619d949 | [260701-e6c-implement-this-supabase-to-be-t1-ready-u](./quick/260701-e6c-implement-this-supabase-to-be-t1-ready-u/) | -| 260701-e6d | Implement Microsoft Teams to be T1 ready | 2026-07-01 | working-tree | [260701-e6d-implement-this-microsoft-teams-to-be-t1-](./quick/260701-e6d-implement-this-microsoft-teams-to-be-t1-/) | -| 260701-e69 | Implement Steam T1 readiness as blocked-policy terminal coverage | 2026-07-01 | blocked-working-tree | [260701-e69-implement-steam-to-be-t1-ready-using-gsd](./quick/260701-e69-implement-steam-to-be-t1-ready-using-gsd/) | -| 260701-iz0 | Implement this Steam to be T1 ready | 2026-07-01 | 8d64b761 | [260701-iz0-implement-this-steam-to-be-t1-ready](./quick/260701-iz0-implement-this-steam-to-be-t1-ready/) | -| 260701-e5j | Implement ClickUp to be T1 ready | 2026-07-01 | 999f07b0 | [260701-e5j-implement-this-clickup-to-be-t1-ready-wo](./quick/260701-e5j-implement-this-clickup-to-be-t1-ready-wo/) | -| 260701-e5y | Implement Glama to be T1 ready | 2026-07-01 | working-tree | [260701-e5y-implement-glama-to-be-t1-ready](./quick/260701-e5y-implement-glama-to-be-t1-ready/) | -| 260701-e60 | Implement LinkedIn to be T1 ready | 2026-07-01 | working-tree | [260701-e60-implement-this-linkedin-to-be-t1-ready](./quick/260701-e60-implement-this-linkedin-to-be-t1-ready/) | -| 260701-e5v | Implement this Google Maps to be T1 ready | 2026-07-01 | working-tree | [260701-e5v-implement-this-google-maps-to-be-t1-read](./quick/260701-e5v-implement-this-google-maps-to-be-t1-read/) | -| 260701-e5i | Implement Confluence to be T1 ready | 2026-07-01 | b8cd262a | [260701-e5i-implement-this-confluence-to-be-t1-ready](./quick/260701-e5i-implement-this-confluence-to-be-t1-ready/) | -| 260701-e5j | Implement Fiverr to be T1 ready | 2026-07-01 | 41d5befd | [260701-e5j-implement-this-fiverr-to-be-t1-ready](./quick/260701-e5j-implement-this-fiverr-to-be-t1-ready/) | -| 260701-e5y | Implement Microsoft Word to be T1 ready | 2026-07-01 | working-tree | [260701-e5y-implement-this-microsoft-word-to-be-t1-r](./quick/260701-e5y-implement-this-microsoft-word-to-be-t1-r/) | -| 260701-e5m | Implement Fidelity to be T1 ready | 2026-07-01 | 72423b87 | [260701-e5m-implement-fidelity-to-be-t1-ready](./quick/260701-e5m-implement-fidelity-to-be-t1-ready/) | -| 260701-e76 | Implement Uber rideshare to be T1 ready | 2026-07-01 | working-tree | [260701-e76-implement-uber-rideshare-to-be-t1-ready](./quick/260701-e76-implement-uber-rideshare-to-be-t1-ready/) | -| 260701-e5v | Implement this Lyft to be T1 ready. | 2026-07-01 | working-tree | [260701-e5v-implement-this-lyft-to-be-t1-ready](./quick/260701-e5v-implement-this-lyft-to-be-t1-ready/) | -| 260701-e6l | Implement Robinhood to be T1 ready | 2026-07-01 | working-tree | [260701-e6l-implement-robinhood-to-be-t1-ready](./quick/260701-e6l-implement-robinhood-to-be-t1-ready/) | -| 260701-e74 | Fourth-agent T1 readiness integration pass for Uber, YouTube, and YouTube Music | 2026-07-01 | working-tree | [260701-e74-fourth-agent-t1-readiness-integration-pa](./quick/260701-e74-fourth-agent-t1-readiness-integration-pa/) | -| 260701-e61 | Implement Netflix T1 readiness as blocked-policy terminal coverage | 2026-07-01 | working-tree | [260701-e61-implement-this-netflix-to-be-t1-ready-ow](./quick/260701-e61-implement-this-netflix-to-be-t1-ready-ow/) | -| 260701-2m5 | Make Linear T1 ready | 2026-07-01 | working-tree | [260701-2m5-make-linear-t1-ready](./quick/260701-2m5-make-linear-t1-ready/) | -| 260701-2m4 | Implement Sentry to be T1 ready | 2026-07-01 | working-tree | [260701-2m4-implement-sentry-to-be-t1-ready](./quick/260701-2m4-implement-sentry-to-be-t1-ready/) | -| 260701-2mh | Make Uber Eats implementation T1 ready | 2026-07-01 | d4ab8789 | [260701-2mh-make-uber-eats-implementation-t1-ready](./quick/260701-2mh-make-uber-eats-implementation-t1-ready/) | -| 260701-2sl | Implement PostHog to be T1 ready | 2026-07-01 | working-tree | [260701-2sl-implement-posthog-to-be-t1-ready](./quick/260701-2sl-implement-posthog-to-be-t1-ready/) | -| 260701-2o4 | Implement Tinder to be T1 ready | 2026-07-01 | working-tree | [260701-2o4-implement-tinder-to-be-t1-ready](./quick/260701-2o4-implement-tinder-to-be-t1-ready/) | -| 260701-2nw | Implement TikTok T1 readiness | 2026-07-01 | working-tree | [260701-2nw-implement-this-tiktok-to-be-t1-ready](./quick/260701-2nw-implement-this-tiktok-to-be-t1-ready/) | -| 260701-2ln | Implement Eventbrite to be T1 ready | 2026-07-01 | working-tree | [260701-2ln-implement-eventbrite-to-be-t1-ready](./quick/260701-2ln-implement-eventbrite-to-be-t1-ready/) | -| 260701-2lr | Implement Datadog to be T1 ready | 2026-07-01 | working-tree | [260701-2lr-implement-datadog-to-be-t1-ready](./quick/260701-2lr-implement-datadog-to-be-t1-ready/) | -| 260701-2m8 | Make Kayak T1 ready | 2026-07-01 | working-tree | [260701-2m8-make-kayak-t1-ready](./quick/260701-2m8-make-kayak-t1-ready/) | -| 260701-2mc | Implement Zendesk to be T1 ready | 2026-07-01 | working-tree | [260701-2mc-implement-zendesk-to-be-t1-ready](./quick/260701-2mc-implement-zendesk-to-be-t1-ready/) | -| 260701-2ki | Implement AWS T1 readiness | 2026-07-01 | working-tree | [260701-2ki-implement-aws-to-be-t1-ready](./quick/260701-2ki-implement-aws-to-be-t1-ready/) | -| 260701-2m5 | Implement Threads T1 readiness | 2026-07-01 | working-tree | [260701-2m5-implement-this-threads-to-be-t1-ready](./quick/260701-2m5-implement-this-threads-to-be-t1-ready/) | -| 260701-2km | Implement Airtable to be T1 ready | 2026-07-01 | working-tree | [260701-2km-implement-airtable-to-be-t1-ready-using-](./quick/260701-2km-implement-airtable-to-be-t1-ready-using-/) | -| 260701-2lo | Make OneNote T1-ready | 2026-07-01 | working-tree | [260701-2lo-make-onenote-t1-ready](./quick/260701-2lo-make-onenote-t1-ready/) | -| 260701-2m6 | Implement Telegram to be T1 ready | 2026-07-01 | working-tree | [260701-2m6-implement-telegram-to-be-t1-ready](./quick/260701-2m6-implement-telegram-to-be-t1-ready/) | -| 260701-2lx | Make Google Calendar T1 ready | 2026-07-01 | working-tree | [260701-2lx-make-google-calendar-t1-ready](./quick/260701-2lx-make-google-calendar-t1-ready/) | -| 260701-2lw | Make this app Etsy T1-ready | 2026-07-01 | working-tree | [260701-2lw-make-this-app-etsy-t1-ready](./quick/260701-2lw-make-this-app-etsy-t1-ready/) | -| 260701-2q3 | Implement OpenTable T1 readiness | 2026-07-01 | working-tree | [260701-2q3-implement-opentable-to-be-t1-ready](./quick/260701-2q3-implement-opentable-to-be-t1-ready/) | -| 260701-2ll | Implement eBay to be T1 ready | 2026-07-01 | working-tree | [260701-2ll-implement-ebay-to-be-t1-ready](./quick/260701-2ll-implement-ebay-to-be-t1-ready/) | -| 260701-2md | Make Mastodon T1-ready | 2026-07-01 | working-tree | [260701-2md-make-mastodon-t1-ready](./quick/260701-2md-make-mastodon-t1-ready/) | -| 260701-2ku | Implement Claude T1 readiness | 2026-07-01 | working-tree | [260701-2ku-implement-claude-to-be-t1-ready-using-gs](./quick/260701-2ku-implement-claude-to-be-t1-ready-using-gs/) | -| 260701-2lo | Implement Craigslist to be T1 ready | 2026-07-01 | working-tree | [260701-2lo-implement-craigslist-to-be-t1-ready](./quick/260701-2lo-implement-craigslist-to-be-t1-ready/) | -| 260701-2lm | Make DoorDash T1-ready | 2026-07-01 | working-tree | [260701-2lm-make-doordash-t1-ready](./quick/260701-2lm-make-doordash-t1-ready/) | -| 260701-2kj | Implement Amazon T1 readiness | 2026-07-01 | working-tree | [260701-2kj-implement-amazon-to-be-t1-ready](./quick/260701-2kj-implement-amazon-to-be-t1-ready/) | -| 260701-2lr | Implement MiniMax T1 readiness | 2026-07-01 | working-tree | [260701-2lr-implement-this-minimax-to-be-t1-ready](./quick/260701-2lr-implement-this-minimax-to-be-t1-ready/) | -| 260701-2mc | Implement Robinhood to be T1 ready | 2026-07-01 | 1dcb49a | [260701-2mc-implement-robinhood-to-be-t1-ready](./quick/260701-2mc-implement-robinhood-to-be-t1-ready/) | -| 260701-2nu | Make this app Jira T1-ready | 2026-07-01 | working-tree | [260701-2nu-make-this-app-jira-t1-ready](./quick/260701-2nu-make-this-app-jira-t1-ready/) | -| 260701-2km | Implement Carta to be T1 ready | 2026-07-01 | working-tree | [260701-2km-implement-carta-to-be-t1-ready](./quick/260701-2km-implement-carta-to-be-t1-ready/) | -| 260701-2m2 | Implement Temporal to be T1 ready | 2026-07-01 | working-tree | [260701-2m2-implement-this-temporal-to-be-t1-ready](./quick/260701-2m2-implement-this-temporal-to-be-t1-ready/) | -| 260701-2ml | Implement YouTube Music to be T1 ready | 2026-07-01 | 51666789 | [260701-2ml-implement-youtube-music-to-be-t1-ready](./quick/260701-2ml-implement-youtube-music-to-be-t1-ready/) | -| 260701-e78 | Repair YouTube Music blocked-policy T1 readiness | 2026-07-01 | ce60e8cb | [260701-e78-implement-youtube-music-to-be-t1-ready](./quick/260701-e78-implement-youtube-music-to-be-t1-ready/) | -| 260701-2no | Make YouTube T1-ready | 2026-07-01 | working-tree | [260701-2no-make-youtube-t1-ready](./quick/260701-2no-make-youtube-t1-ready/) | -| 260701-j08 | Implement this YouTube to be T1 ready | 2026-07-01 | ce10b54d | [260701-j08-implement-this-youtube-to-be-t1-ready](./quick/260701-j08-implement-this-youtube-to-be-t1-ready/) | -| 260701-2lz | Make this app OnlyFans T1 ready | 2026-07-01 | working-tree | [260701-2lz-make-this-app-onlyfans-t1-ready](./quick/260701-2lz-make-this-app-onlyfans-t1-ready/) | -| 260701-2du | Fix CDP keyboard attach degradation that silently drops trusted keystrokes into cross-origin iframes (Stripe CVC) | 2026-07-01 | 293162d9 | [260701-2du-fix-cdp-keyboard-attach-degradation-that](./quick/260701-2du-fix-cdp-keyboard-attach-degradation-that/) | -| 260701-2lu | Implement Netflix to be T1 ready | 2026-07-01 | working-tree | [260701-2lu-implement-netflix-to-be-t1-ready](./quick/260701-2lu-implement-netflix-to-be-t1-ready/) | -| 260701-1nd | Make this app Calendly T1-ready | 2026-07-01 | working-tree | [260701-1nd-make-this-app-calendly-t1-ready](./quick/260701-1nd-make-this-app-calendly-t1-ready/) | -| 260701-1kg | Make this app Walmart T1-ready | 2026-07-01 | working-tree | [260701-1kg-make-this-app-walmart-t1-ready](./quick/260701-1kg-make-this-app-walmart-t1-ready/) | -| 260701-1kv | Make this app DockerHub T1-ready | 2026-07-01 | working-tree | [260701-1kv-make-this-app-dockerhub-t1-ready](./quick/260701-1kv-make-this-app-dockerhub-t1-ready/) | -| 260701-1ka | Make this app Figma T1-ready | 2026-07-01 | working-tree | [260701-1ka-make-this-app-figma-t1-ready](./quick/260701-1ka-make-this-app-figma-t1-ready/) | -| 260701-1kj | Make this app Instacart T1-ready | 2026-07-01 | working-tree | [260701-1kj-make-this-app-instacart-t1-ready](./quick/260701-1kj-make-this-app-instacart-t1-ready/) | -| 260701-1tu | Make this app ClickHouse T1-ready | 2026-07-01 | working-tree | [260701-1tu-make-this-app-clickhouse-t1-ready](./quick/260701-1tu-make-this-app-clickhouse-t1-ready/) | -| 260701-1lj | Make this app Slack T1-ready | 2026-07-01 | working-tree | [260701-1lj-make-this-app-slack-t1-ready](./quick/260701-1lj-make-this-app-slack-t1-ready/) | -| 260630-vog | Make this app Panda Express T1-ready | 2026-07-01 | working-tree | [260630-vog-make-this-app-pandaexpress-t1-ready](./quick/260630-vog-make-this-app-pandaexpress-t1-ready/) | -| 260630-vnj | Make this app Facebook T1-ready | 2026-07-01 | working-tree | [260630-vnj-make-this-app-facebook-t1-ready](./quick/260630-vnj-make-this-app-facebook-t1-ready/) | -| 260630-vnw | Make this app New Relic T1-ready | 2026-07-01 | working-tree | [260630-vnw-make-this-app-newrelic-t1-ready](./quick/260630-vnw-make-this-app-newrelic-t1-ready/) | -| 260630-vop | Make this app Redfin T1-ready | 2026-07-01 | working-tree | [260630-vop-make-this-app-redfin-t1-ready](./quick/260630-vop-make-this-app-redfin-t1-ready/) | -| 260630-vq5 | Make this app Coinbase T1-ready | 2026-07-01 | working-tree | [260630-vq5-make-this-app-coinbase-t1-ready](./quick/260630-vq5-make-this-app-coinbase-t1-ready/) | -| 260630-vns | Make this app Booking T1-ready | 2026-07-01 | working-tree | [260630-vns-make-this-app-booking-t1-ready](./quick/260630-vns-make-this-app-booking-t1-ready/) | -| 260630-vo5 | Make this app CircleCI T1-ready | 2026-07-01 | working-tree | [260630-vo5-make-this-app-circleci-t1-ready](./quick/260630-vo5-make-this-app-circleci-t1-ready/) | -| 260630-vpd | Make this app GitLab T1-ready | 2026-07-01 | working-tree | [260630-vpd-make-this-app-gitlab-t1-ready](./quick/260630-vpd-make-this-app-gitlab-t1-ready/) | -| 260630-u7d | Make this app Airbnb T1-ready | 2026-07-01 | working-tree | [260630-u7d-make-this-app-airbnb-t1-ready](./quick/260630-u7d-make-this-app-airbnb-t1-ready/) | -| 260630-u64 | Make this app Outlook T1-ready | 2026-07-01 | working-tree | [260630-u64-make-this-app-outlook-t1-ready](./quick/260630-u64-make-this-app-outlook-t1-ready/) | -| 260630-u5z | Make this app Retool T1-ready | 2026-07-01 | working-tree | [260630-u5z-make-this-app-retool-t1-ready](./quick/260630-u5z-make-this-app-retool-t1-ready/) | -| 260630-u70 | Make this app Excel T1-ready | 2026-07-01 | working-tree | [260630-u70-make-this-app-excel-t1-ready](./quick/260630-u70-make-this-app-excel-t1-ready/) | -| 260630-u62 | Make this app Costco T1-ready | 2026-07-01 | working-tree | [260630-u62-make-this-app-costco-t1-ready](./quick/260630-u62-make-this-app-costco-t1-ready/) | -| 260630-u6d | Make this app YNAB T1-ready | 2026-07-01 | working-tree | [260630-u6d-make-this-app-ynab-t1-ready](./quick/260630-u6d-make-this-app-ynab-t1-ready/) | -| 260630-u7s | Make this app Todoist T1-ready | 2026-07-01 | working-tree | [260630-u7s-make-this-app-todoist-t1-ready](./quick/260630-u7s-make-this-app-todoist-t1-ready/) | -| 260630-u7q | Make this app Expedia T1-ready | 2026-07-01 | working-tree | [260630-u7q-make-this-app-expedia-t1-ready](./quick/260630-u7q-make-this-app-expedia-t1-ready/) | -| 260630-u6s | Make this app Webflow T1-ready | 2026-07-01 | working-tree | [260630-u6s-make-this-app-webflow-t1-ready](./quick/260630-u6s-make-this-app-webflow-t1-ready/) | -| 260630-qjb | Make this app PowerPoint T1-ready | 2026-07-01 | working-tree | [260630-qjb-make-this-app-powerpoint-t1-ready](./quick/260630-qjb-make-this-app-powerpoint-t1-ready/) | -| 260630-qjb | Make this app Lucid T1-ready | 2026-07-01 | working-tree | [260630-qjb-make-this-app-lucid-t1-ready](./quick/260630-qjb-make-this-app-lucid-t1-ready/) | -| 260630-qj8 | Make this app Target T1-ready | 2026-07-01 | working-tree | [260630-qj8-make-this-app-target-t1-ready](./quick/260630-qj8-make-this-app-target-t1-ready/) | -| 260630-qjd | Make Discord app T1-ready | 2026-07-01 | working-tree | [260630-qjd-make-discord-app-t1-ready](./quick/260630-qjd-make-discord-app-t1-ready/) | -| 260630-qj0 | Make this app Snowflake T1-ready | 2026-07-01 | working-tree | [260630-qj0-make-this-app-snowflake-t1-ready](./quick/260630-qj0-make-this-app-snowflake-t1-ready/) | -| 260630-ql3 | Make this app ChatGPT T1-ready | 2026-07-01 | working-tree | [260630-ql3-make-this-app-chatgpt-t1-ready](./quick/260630-ql3-make-this-app-chatgpt-t1-ready/) | -| 260630-qj4 | Make this app Hack2Hire T1-ready | 2026-07-01 | working-tree | [260630-qj4-make-this-app-hack2hire-t1-ready](./quick/260630-qj4-make-this-app-hack2hire-t1-ready/) | -| 260630-qiu | Make this app CockroachDB T1-ready | 2026-07-01 | working-tree | [260630-qiu-make-this-app-cockroachdb-t1-ready](./quick/260630-qiu-make-this-app-cockroachdb-t1-ready/) | -| 260630-qjh | Make this app MSWord T1-ready | 2026-06-30 | working-tree | [260630-qjh-make-this-app-msword-t1-ready](./quick/260630-qjh-make-this-app-msword-t1-ready/) | -| 260630-nh1 | Make this app Chipotle T1-ready | 2026-06-30 | working-tree | [260630-nh1-make-this-app-chipotle-t1-ready](./quick/260630-nh1-make-this-app-chipotle-t1-ready/) | -| 260630-nhs | Make this app amplitude T1-ready | 2026-06-30 | working-tree | [260630-nhs-make-this-app-amplitude-t1-ready](./quick/260630-nhs-make-this-app-amplitude-t1-ready/) | -| 260630-nh1 | Make this app dominos T1-ready | 2026-06-30 | working-tree | [260630-nh1-make-this-app-dominos-t1-ready](./quick/260630-nh1-make-this-app-dominos-t1-ready/) | -| 260630-njd | Make this app WhatsApp T1-ready | 2026-06-30 | working-tree | [260630-njd-make-this-app-whatsapp-t1-ready](./quick/260630-njd-make-this-app-whatsapp-t1-ready/) | -| 260630-nk0 | Make Medium T1-ready | 2026-06-30 | working-tree | [260630-nk0-make-medium-t1-ready](./quick/260630-nk0-make-medium-t1-ready/) | -| 260630-njd | Make this app Starbucks T1-ready | 2026-06-30 | working-tree | [260630-njd-make-this-app-starbucks-t1-ready](./quick/260630-njd-make-this-app-starbucks-t1-ready/) | -| 260630-nh3 | Make this app Bitbucket T1-ready | 2026-06-30 | working-tree | [260630-nh3-make-this-app-bitbucket-t1-ready](./quick/260630-nh3-make-this-app-bitbucket-t1-ready/) | -| 260630-mh2 | Make this app instagram T1-ready | 2026-06-30 | working-tree | [260630-mh2-make-this-app-instagram-t1-ready](./quick/260630-mh2-make-this-app-instagram-t1-ready/) | -| 260630-mj0 | Make Pinterest T1-ready | 2026-06-30 | working-tree | [260630-mj0-make-this-app-pinterest-t1-ready](./quick/260630-mj0-make-this-app-pinterest-t1-ready/) | -| 260630-mga | Make Instagram T1-ready | 2026-06-30 | working-tree | [260630-mga-make-instagram-t1-ready](./quick/260630-mga-make-instagram-t1-ready/) | -| 260630-mjs | Make MongoDB Atlas T1-ready | 2026-06-30 | working-tree | [260630-mjs-make-this-app-mongodb-t1-ready](./quick/260630-mjs-make-this-app-mongodb-t1-ready/) | -| 260630-mgp | Make Stack Overflow T1-ready | 2026-06-30 | working-tree | [260630-mgp-make-stack-overflow-t1-ready](./quick/260630-mgp-make-stack-overflow-t1-ready/) | -| 260630-moa | Make this app Netlify T1-ready | 2026-06-30 | working-tree | [260630-moa-make-this-app-netlify-t1-ready](./quick/260630-moa-make-this-app-netlify-t1-ready/) | -| 260630-mjd | Make Priceline T1-ready | 2026-06-30 | working-tree | [260630-mjd-make-this-app-priceline-t1-ready](./quick/260630-mjd-make-this-app-priceline-t1-ready/) | -| 260630-mgf | Make this app Reddit T1-ready | 2026-06-30 | working-tree | [260630-mgf-make-this-app-reddit-t1-ready](./quick/260630-mgf-make-this-app-reddit-t1-ready/) | -| 260630-mgf | Make Tumblr T1-ready | 2026-06-30 | working-tree | [260630-mgf-make-this-app-tumblr-t1-ready](./quick/260630-mgf-make-this-app-tumblr-t1-ready/) | -| 260630-m4c | Promote bsky public AppView reads to T1-ready | 2026-06-30 | working-tree | [260630-m4c-promote-bsky-public-appview-reads-to-t1-](./quick/260630-m4c-promote-bsky-public-appview-reads-to-t1-/) | -| 260630-m1q | Make this app Stripe T1-ready | 2026-06-30 | working-tree | [260630-m1q-make-this-app-stripe-t1-ready](./quick/260630-m1q-make-this-app-stripe-t1-ready/) | -| 260630-m0u | Make Meticulous same-origin GraphQL reads T1-ready | 2026-06-30 | working-tree | [260630-m0u-make-this-app-meticulous-t1-ready](./quick/260630-m0u-make-this-app-meticulous-t1-ready/) | -| 260630-m35 | Make this app twilio T1-ready | 2026-06-30 | working-tree | [260630-m35-make-this-app-twilio-t1-ready](./quick/260630-m35-make-this-app-twilio-t1-ready/) | -| 260630-m1a | Make Cloudflare T1-ready with same-origin dashboard read handlers | 2026-06-30 | working-tree | [260630-m1a-make-cloudflare-app-t1-ready-by-adding-s](./quick/260630-m1a-make-cloudflare-app-t1-ready-by-adding-s/) | -| 260630-m2u | Promote X public same-origin profile and tweet reads to T1-ready | 2026-06-30 | working-tree | [260630-m2u-promote-x-public-same-origin-profile-and](./quick/260630-m2u-promote-x-public-same-origin-profile-and/) | -| 260630-m16 | Make Terraform Cloud T1-ready | 2026-06-30 | working-tree | [260630-m16-make-terraform-cloud-t1-ready](./quick/260630-m16-make-terraform-cloud-t1-ready/) | -| 260630-lhy | Promote Zillow public same-origin search reads to T1-ready | 2026-06-30 | working-tree | [260630-lhy-promote-another-safe-same-origin-public-](./quick/260630-lhy-promote-another-safe-same-origin-public-/) | -| 260630-l0o | Promote TripAdvisor public same-origin reads to T1-ready | 2026-06-30 | working-tree | [260630-l0o-promote-another-safe-same-origin-public-](./quick/260630-l0o-promote-another-safe-same-origin-public-/) | -| 260630-kqt | Promote Yelp public same-origin reads to T1-ready | 2026-06-30 | working-tree | [260630-kqt-promote-one-more-safe-same-origin-public](./quick/260630-kqt-promote-one-more-safe-same-origin-public/) | -| 260630-kg0 | Promote npm public same-origin Spiferack reads to T1-ready | 2026-06-30 | working-tree | [260630-kg0-promote-npm-public-same-origin-read-hand](./quick/260630-kg0-promote-npm-public-same-origin-read-hand/) | -| 260630-hct | Anonymous aggregate state-level region telemetry (IP-derived at ingest, k≥5 floor, self-hosted DB-IP, graceful "unknown" until dataset present) | 2026-06-30 | e2a1b67a..8edf3525 | [260630-hct-anon-region-telemetry](./quick/260630-hct-anon-region-telemetry/) | -| 260630-k18 | Promote Hacker News public same-origin HTML reads to T1-ready | 2026-06-30 | working-tree | [260630-k18-promote-hacker-news-public-same-origin-h](./quick/260630-k18-promote-hacker-news-public-same-origin-h/) | -| 260630-hgl | Promote Wikipedia public same-origin reads to T1-ready | 2026-06-30 | working-tree | [260630-hgl-promote-wikipedia-public-same-origin-rea](./quick/260630-hgl-promote-wikipedia-public-same-origin-rea/) | -| 260630-ha5 | Promote LeetCode query-only same-origin reads to T1-ready | 2026-06-30 | working-tree | [260630-ha5-promote-leetcode-query-only-same-origin-](./quick/260630-ha5-promote-leetcode-query-only-same-origin-/) | -| 260630-gry | Promote Shortcut no-param same-origin reads to app-specific T1a handler proof | 2026-06-30 | working-tree | [260630-gry-promote-next-safe-same-origin-read-recip](./quick/260630-gry-promote-next-safe-same-origin-read-recip/) | -| 260629-ksj | Support i18n internationalization for latest updated showcase content | 2026-06-29 | working-tree | [260629-ksj-support-i18n-internationalization-for-la](./quick/260629-ksj-support-i18n-internationalization-for-la/) | +| # | Description | Date | Commit | Status | Directory | +|---|-------------|------|--------|--------|-----------| +| 260715-hs1 | Replace Google Sheets OAuth with a zero-extra-auth signed-in tab session | 2026-07-15 | 9ab3d40d | Needs Review | [260715-hs1-replace-google-sheets-oauth-with-a-signe](./quick/260715-hs1-replace-google-sheets-oauth-with-a-signe/) | +| 260715-8wh | Implement production-safe Google Sheets API MVP with Chrome OAuth, typed read/write capabilities, and spreadsheet-data recording redaction | 2026-07-15 | a83d21b8 | | [260715-8wh-implement-production-safe-google-sheets-](./quick/260715-8wh-implement-production-safe-google-sheets-/) | +| 260707-7id | Record MCP agent sessions into logs, history, replay, and memory like autopilot runs | 2026-07-07 | 721e2826 | | [260707-7id-record-mcp-agent-sessions-into-logs-hist](./quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/) | +| 260701-e6c | Implement Supabase to be T1 ready | 2026-07-01 | 2619d949 | | [260701-e6c-implement-this-supabase-to-be-t1-ready-u](./quick/260701-e6c-implement-this-supabase-to-be-t1-ready-u/) | +| 260701-e6d | Implement Microsoft Teams to be T1 ready | 2026-07-01 | working-tree | | [260701-e6d-implement-this-microsoft-teams-to-be-t1-](./quick/260701-e6d-implement-this-microsoft-teams-to-be-t1-/) | +| 260701-e69 | Implement Steam T1 readiness as blocked-policy terminal coverage | 2026-07-01 | blocked-working-tree | | [260701-e69-implement-steam-to-be-t1-ready-using-gsd](./quick/260701-e69-implement-steam-to-be-t1-ready-using-gsd/) | +| 260701-iz0 | Implement this Steam to be T1 ready | 2026-07-01 | 8d64b761 | | [260701-iz0-implement-this-steam-to-be-t1-ready](./quick/260701-iz0-implement-this-steam-to-be-t1-ready/) | +| 260701-e5j | Implement ClickUp to be T1 ready | 2026-07-01 | 999f07b0 | | [260701-e5j-implement-this-clickup-to-be-t1-ready-wo](./quick/260701-e5j-implement-this-clickup-to-be-t1-ready-wo/) | +| 260701-e5y | Implement Glama to be T1 ready | 2026-07-01 | working-tree | | [260701-e5y-implement-glama-to-be-t1-ready](./quick/260701-e5y-implement-glama-to-be-t1-ready/) | +| 260701-e60 | Implement LinkedIn to be T1 ready | 2026-07-01 | working-tree | | [260701-e60-implement-this-linkedin-to-be-t1-ready](./quick/260701-e60-implement-this-linkedin-to-be-t1-ready/) | +| 260701-e5v | Implement this Google Maps to be T1 ready | 2026-07-01 | working-tree | | [260701-e5v-implement-this-google-maps-to-be-t1-read](./quick/260701-e5v-implement-this-google-maps-to-be-t1-read/) | +| 260701-e5i | Implement Confluence to be T1 ready | 2026-07-01 | b8cd262a | | [260701-e5i-implement-this-confluence-to-be-t1-ready](./quick/260701-e5i-implement-this-confluence-to-be-t1-ready/) | +| 260701-e5j | Implement Fiverr to be T1 ready | 2026-07-01 | 41d5befd | | [260701-e5j-implement-this-fiverr-to-be-t1-ready](./quick/260701-e5j-implement-this-fiverr-to-be-t1-ready/) | +| 260701-e5y | Implement Microsoft Word to be T1 ready | 2026-07-01 | working-tree | | [260701-e5y-implement-this-microsoft-word-to-be-t1-r](./quick/260701-e5y-implement-this-microsoft-word-to-be-t1-r/) | +| 260701-e5m | Implement Fidelity to be T1 ready | 2026-07-01 | 72423b87 | | [260701-e5m-implement-fidelity-to-be-t1-ready](./quick/260701-e5m-implement-fidelity-to-be-t1-ready/) | +| 260701-e76 | Implement Uber rideshare to be T1 ready | 2026-07-01 | working-tree | | [260701-e76-implement-uber-rideshare-to-be-t1-ready](./quick/260701-e76-implement-uber-rideshare-to-be-t1-ready/) | +| 260701-e5v | Implement this Lyft to be T1 ready. | 2026-07-01 | working-tree | | [260701-e5v-implement-this-lyft-to-be-t1-ready](./quick/260701-e5v-implement-this-lyft-to-be-t1-ready/) | +| 260701-e6l | Implement Robinhood to be T1 ready | 2026-07-01 | working-tree | | [260701-e6l-implement-robinhood-to-be-t1-ready](./quick/260701-e6l-implement-robinhood-to-be-t1-ready/) | +| 260701-e74 | Fourth-agent T1 readiness integration pass for Uber, YouTube, and YouTube Music | 2026-07-01 | working-tree | | [260701-e74-fourth-agent-t1-readiness-integration-pa](./quick/260701-e74-fourth-agent-t1-readiness-integration-pa/) | +| 260701-e61 | Implement Netflix T1 readiness as blocked-policy terminal coverage | 2026-07-01 | working-tree | | [260701-e61-implement-this-netflix-to-be-t1-ready-ow](./quick/260701-e61-implement-this-netflix-to-be-t1-ready-ow/) | +| 260701-2m5 | Make Linear T1 ready | 2026-07-01 | working-tree | | [260701-2m5-make-linear-t1-ready](./quick/260701-2m5-make-linear-t1-ready/) | +| 260701-2m4 | Implement Sentry to be T1 ready | 2026-07-01 | working-tree | | [260701-2m4-implement-sentry-to-be-t1-ready](./quick/260701-2m4-implement-sentry-to-be-t1-ready/) | +| 260701-2mh | Make Uber Eats implementation T1 ready | 2026-07-01 | d4ab8789 | | [260701-2mh-make-uber-eats-implementation-t1-ready](./quick/260701-2mh-make-uber-eats-implementation-t1-ready/) | +| 260701-2sl | Implement PostHog to be T1 ready | 2026-07-01 | working-tree | | [260701-2sl-implement-posthog-to-be-t1-ready](./quick/260701-2sl-implement-posthog-to-be-t1-ready/) | +| 260701-2o4 | Implement Tinder to be T1 ready | 2026-07-01 | working-tree | | [260701-2o4-implement-tinder-to-be-t1-ready](./quick/260701-2o4-implement-tinder-to-be-t1-ready/) | +| 260701-2nw | Implement TikTok T1 readiness | 2026-07-01 | working-tree | | [260701-2nw-implement-this-tiktok-to-be-t1-ready](./quick/260701-2nw-implement-this-tiktok-to-be-t1-ready/) | +| 260701-2ln | Implement Eventbrite to be T1 ready | 2026-07-01 | working-tree | | [260701-2ln-implement-eventbrite-to-be-t1-ready](./quick/260701-2ln-implement-eventbrite-to-be-t1-ready/) | +| 260701-2lr | Implement Datadog to be T1 ready | 2026-07-01 | working-tree | | [260701-2lr-implement-datadog-to-be-t1-ready](./quick/260701-2lr-implement-datadog-to-be-t1-ready/) | +| 260701-2m8 | Make Kayak T1 ready | 2026-07-01 | working-tree | | [260701-2m8-make-kayak-t1-ready](./quick/260701-2m8-make-kayak-t1-ready/) | +| 260701-2mc | Implement Zendesk to be T1 ready | 2026-07-01 | working-tree | | [260701-2mc-implement-zendesk-to-be-t1-ready](./quick/260701-2mc-implement-zendesk-to-be-t1-ready/) | +| 260701-2ki | Implement AWS T1 readiness | 2026-07-01 | working-tree | | [260701-2ki-implement-aws-to-be-t1-ready](./quick/260701-2ki-implement-aws-to-be-t1-ready/) | +| 260701-2m5 | Implement Threads T1 readiness | 2026-07-01 | working-tree | | [260701-2m5-implement-this-threads-to-be-t1-ready](./quick/260701-2m5-implement-this-threads-to-be-t1-ready/) | +| 260701-2km | Implement Airtable to be T1 ready | 2026-07-01 | working-tree | | [260701-2km-implement-airtable-to-be-t1-ready-using-](./quick/260701-2km-implement-airtable-to-be-t1-ready-using-/) | +| 260701-2lo | Make OneNote T1-ready | 2026-07-01 | working-tree | | [260701-2lo-make-onenote-t1-ready](./quick/260701-2lo-make-onenote-t1-ready/) | +| 260701-2m6 | Implement Telegram to be T1 ready | 2026-07-01 | working-tree | | [260701-2m6-implement-telegram-to-be-t1-ready](./quick/260701-2m6-implement-telegram-to-be-t1-ready/) | +| 260701-2lx | Make Google Calendar T1 ready | 2026-07-01 | working-tree | | [260701-2lx-make-google-calendar-t1-ready](./quick/260701-2lx-make-google-calendar-t1-ready/) | +| 260701-2lw | Make this app Etsy T1-ready | 2026-07-01 | working-tree | | [260701-2lw-make-this-app-etsy-t1-ready](./quick/260701-2lw-make-this-app-etsy-t1-ready/) | +| 260701-2q3 | Implement OpenTable T1 readiness | 2026-07-01 | working-tree | | [260701-2q3-implement-opentable-to-be-t1-ready](./quick/260701-2q3-implement-opentable-to-be-t1-ready/) | +| 260701-2ll | Implement eBay to be T1 ready | 2026-07-01 | working-tree | | [260701-2ll-implement-ebay-to-be-t1-ready](./quick/260701-2ll-implement-ebay-to-be-t1-ready/) | +| 260701-2md | Make Mastodon T1-ready | 2026-07-01 | working-tree | | [260701-2md-make-mastodon-t1-ready](./quick/260701-2md-make-mastodon-t1-ready/) | +| 260701-2ku | Implement Claude T1 readiness | 2026-07-01 | working-tree | | [260701-2ku-implement-claude-to-be-t1-ready-using-gs](./quick/260701-2ku-implement-claude-to-be-t1-ready-using-gs/) | +| 260701-2lo | Implement Craigslist to be T1 ready | 2026-07-01 | working-tree | | [260701-2lo-implement-craigslist-to-be-t1-ready](./quick/260701-2lo-implement-craigslist-to-be-t1-ready/) | +| 260701-2lm | Make DoorDash T1-ready | 2026-07-01 | working-tree | | [260701-2lm-make-doordash-t1-ready](./quick/260701-2lm-make-doordash-t1-ready/) | +| 260701-2kj | Implement Amazon T1 readiness | 2026-07-01 | working-tree | | [260701-2kj-implement-amazon-to-be-t1-ready](./quick/260701-2kj-implement-amazon-to-be-t1-ready/) | +| 260701-2lr | Implement MiniMax T1 readiness | 2026-07-01 | working-tree | | [260701-2lr-implement-this-minimax-to-be-t1-ready](./quick/260701-2lr-implement-this-minimax-to-be-t1-ready/) | +| 260701-2mc | Implement Robinhood to be T1 ready | 2026-07-01 | 1dcb49a | | [260701-2mc-implement-robinhood-to-be-t1-ready](./quick/260701-2mc-implement-robinhood-to-be-t1-ready/) | +| 260701-2nu | Make this app Jira T1-ready | 2026-07-01 | working-tree | | [260701-2nu-make-this-app-jira-t1-ready](./quick/260701-2nu-make-this-app-jira-t1-ready/) | +| 260701-2km | Implement Carta to be T1 ready | 2026-07-01 | working-tree | | [260701-2km-implement-carta-to-be-t1-ready](./quick/260701-2km-implement-carta-to-be-t1-ready/) | +| 260701-2m2 | Implement Temporal to be T1 ready | 2026-07-01 | working-tree | | [260701-2m2-implement-this-temporal-to-be-t1-ready](./quick/260701-2m2-implement-this-temporal-to-be-t1-ready/) | +| 260701-2ml | Implement YouTube Music to be T1 ready | 2026-07-01 | 51666789 | | [260701-2ml-implement-youtube-music-to-be-t1-ready](./quick/260701-2ml-implement-youtube-music-to-be-t1-ready/) | +| 260701-e78 | Repair YouTube Music blocked-policy T1 readiness | 2026-07-01 | ce60e8cb | | [260701-e78-implement-youtube-music-to-be-t1-ready](./quick/260701-e78-implement-youtube-music-to-be-t1-ready/) | +| 260701-2no | Make YouTube T1-ready | 2026-07-01 | working-tree | | [260701-2no-make-youtube-t1-ready](./quick/260701-2no-make-youtube-t1-ready/) | +| 260701-j08 | Implement this YouTube to be T1 ready | 2026-07-01 | ce10b54d | | [260701-j08-implement-this-youtube-to-be-t1-ready](./quick/260701-j08-implement-this-youtube-to-be-t1-ready/) | +| 260701-2lz | Make this app OnlyFans T1 ready | 2026-07-01 | working-tree | | [260701-2lz-make-this-app-onlyfans-t1-ready](./quick/260701-2lz-make-this-app-onlyfans-t1-ready/) | +| 260701-2du | Fix CDP keyboard attach degradation that silently drops trusted keystrokes into cross-origin iframes (Stripe CVC) | 2026-07-01 | 293162d9 | | [260701-2du-fix-cdp-keyboard-attach-degradation-that](./quick/260701-2du-fix-cdp-keyboard-attach-degradation-that/) | +| 260701-2lu | Implement Netflix to be T1 ready | 2026-07-01 | working-tree | | [260701-2lu-implement-netflix-to-be-t1-ready](./quick/260701-2lu-implement-netflix-to-be-t1-ready/) | +| 260701-1nd | Make this app Calendly T1-ready | 2026-07-01 | working-tree | | [260701-1nd-make-this-app-calendly-t1-ready](./quick/260701-1nd-make-this-app-calendly-t1-ready/) | +| 260701-1kg | Make this app Walmart T1-ready | 2026-07-01 | working-tree | | [260701-1kg-make-this-app-walmart-t1-ready](./quick/260701-1kg-make-this-app-walmart-t1-ready/) | +| 260701-1kv | Make this app DockerHub T1-ready | 2026-07-01 | working-tree | | [260701-1kv-make-this-app-dockerhub-t1-ready](./quick/260701-1kv-make-this-app-dockerhub-t1-ready/) | +| 260701-1ka | Make this app Figma T1-ready | 2026-07-01 | working-tree | | [260701-1ka-make-this-app-figma-t1-ready](./quick/260701-1ka-make-this-app-figma-t1-ready/) | +| 260701-1kj | Make this app Instacart T1-ready | 2026-07-01 | working-tree | | [260701-1kj-make-this-app-instacart-t1-ready](./quick/260701-1kj-make-this-app-instacart-t1-ready/) | +| 260701-1tu | Make this app ClickHouse T1-ready | 2026-07-01 | working-tree | | [260701-1tu-make-this-app-clickhouse-t1-ready](./quick/260701-1tu-make-this-app-clickhouse-t1-ready/) | +| 260701-1lj | Make this app Slack T1-ready | 2026-07-01 | working-tree | | [260701-1lj-make-this-app-slack-t1-ready](./quick/260701-1lj-make-this-app-slack-t1-ready/) | +| 260630-vog | Make this app Panda Express T1-ready | 2026-07-01 | working-tree | | [260630-vog-make-this-app-pandaexpress-t1-ready](./quick/260630-vog-make-this-app-pandaexpress-t1-ready/) | +| 260630-vnj | Make this app Facebook T1-ready | 2026-07-01 | working-tree | | [260630-vnj-make-this-app-facebook-t1-ready](./quick/260630-vnj-make-this-app-facebook-t1-ready/) | +| 260630-vnw | Make this app New Relic T1-ready | 2026-07-01 | working-tree | | [260630-vnw-make-this-app-newrelic-t1-ready](./quick/260630-vnw-make-this-app-newrelic-t1-ready/) | +| 260630-vop | Make this app Redfin T1-ready | 2026-07-01 | working-tree | | [260630-vop-make-this-app-redfin-t1-ready](./quick/260630-vop-make-this-app-redfin-t1-ready/) | +| 260630-vq5 | Make this app Coinbase T1-ready | 2026-07-01 | working-tree | | [260630-vq5-make-this-app-coinbase-t1-ready](./quick/260630-vq5-make-this-app-coinbase-t1-ready/) | +| 260630-vns | Make this app Booking T1-ready | 2026-07-01 | working-tree | | [260630-vns-make-this-app-booking-t1-ready](./quick/260630-vns-make-this-app-booking-t1-ready/) | +| 260630-vo5 | Make this app CircleCI T1-ready | 2026-07-01 | working-tree | | [260630-vo5-make-this-app-circleci-t1-ready](./quick/260630-vo5-make-this-app-circleci-t1-ready/) | +| 260630-vpd | Make this app GitLab T1-ready | 2026-07-01 | working-tree | | [260630-vpd-make-this-app-gitlab-t1-ready](./quick/260630-vpd-make-this-app-gitlab-t1-ready/) | +| 260630-u7d | Make this app Airbnb T1-ready | 2026-07-01 | working-tree | | [260630-u7d-make-this-app-airbnb-t1-ready](./quick/260630-u7d-make-this-app-airbnb-t1-ready/) | +| 260630-u64 | Make this app Outlook T1-ready | 2026-07-01 | working-tree | | [260630-u64-make-this-app-outlook-t1-ready](./quick/260630-u64-make-this-app-outlook-t1-ready/) | +| 260630-u5z | Make this app Retool T1-ready | 2026-07-01 | working-tree | | [260630-u5z-make-this-app-retool-t1-ready](./quick/260630-u5z-make-this-app-retool-t1-ready/) | +| 260630-u70 | Make this app Excel T1-ready | 2026-07-01 | working-tree | | [260630-u70-make-this-app-excel-t1-ready](./quick/260630-u70-make-this-app-excel-t1-ready/) | +| 260630-u62 | Make this app Costco T1-ready | 2026-07-01 | working-tree | | [260630-u62-make-this-app-costco-t1-ready](./quick/260630-u62-make-this-app-costco-t1-ready/) | +| 260630-u6d | Make this app YNAB T1-ready | 2026-07-01 | working-tree | | [260630-u6d-make-this-app-ynab-t1-ready](./quick/260630-u6d-make-this-app-ynab-t1-ready/) | +| 260630-u7s | Make this app Todoist T1-ready | 2026-07-01 | working-tree | | [260630-u7s-make-this-app-todoist-t1-ready](./quick/260630-u7s-make-this-app-todoist-t1-ready/) | +| 260630-u7q | Make this app Expedia T1-ready | 2026-07-01 | working-tree | | [260630-u7q-make-this-app-expedia-t1-ready](./quick/260630-u7q-make-this-app-expedia-t1-ready/) | +| 260630-u6s | Make this app Webflow T1-ready | 2026-07-01 | working-tree | | [260630-u6s-make-this-app-webflow-t1-ready](./quick/260630-u6s-make-this-app-webflow-t1-ready/) | +| 260630-qjb | Make this app PowerPoint T1-ready | 2026-07-01 | working-tree | | [260630-qjb-make-this-app-powerpoint-t1-ready](./quick/260630-qjb-make-this-app-powerpoint-t1-ready/) | +| 260630-qjb | Make this app Lucid T1-ready | 2026-07-01 | working-tree | | [260630-qjb-make-this-app-lucid-t1-ready](./quick/260630-qjb-make-this-app-lucid-t1-ready/) | +| 260630-qj8 | Make this app Target T1-ready | 2026-07-01 | working-tree | | [260630-qj8-make-this-app-target-t1-ready](./quick/260630-qj8-make-this-app-target-t1-ready/) | +| 260630-qjd | Make Discord app T1-ready | 2026-07-01 | working-tree | | [260630-qjd-make-discord-app-t1-ready](./quick/260630-qjd-make-discord-app-t1-ready/) | +| 260630-qj0 | Make this app Snowflake T1-ready | 2026-07-01 | working-tree | | [260630-qj0-make-this-app-snowflake-t1-ready](./quick/260630-qj0-make-this-app-snowflake-t1-ready/) | +| 260630-ql3 | Make this app ChatGPT T1-ready | 2026-07-01 | working-tree | | [260630-ql3-make-this-app-chatgpt-t1-ready](./quick/260630-ql3-make-this-app-chatgpt-t1-ready/) | +| 260630-qj4 | Make this app Hack2Hire T1-ready | 2026-07-01 | working-tree | | [260630-qj4-make-this-app-hack2hire-t1-ready](./quick/260630-qj4-make-this-app-hack2hire-t1-ready/) | +| 260630-qiu | Make this app CockroachDB T1-ready | 2026-07-01 | working-tree | | [260630-qiu-make-this-app-cockroachdb-t1-ready](./quick/260630-qiu-make-this-app-cockroachdb-t1-ready/) | +| 260630-qjh | Make this app MSWord T1-ready | 2026-06-30 | working-tree | | [260630-qjh-make-this-app-msword-t1-ready](./quick/260630-qjh-make-this-app-msword-t1-ready/) | +| 260630-nh1 | Make this app Chipotle T1-ready | 2026-06-30 | working-tree | | [260630-nh1-make-this-app-chipotle-t1-ready](./quick/260630-nh1-make-this-app-chipotle-t1-ready/) | +| 260630-nhs | Make this app amplitude T1-ready | 2026-06-30 | working-tree | | [260630-nhs-make-this-app-amplitude-t1-ready](./quick/260630-nhs-make-this-app-amplitude-t1-ready/) | +| 260630-nh1 | Make this app dominos T1-ready | 2026-06-30 | working-tree | | [260630-nh1-make-this-app-dominos-t1-ready](./quick/260630-nh1-make-this-app-dominos-t1-ready/) | +| 260630-njd | Make this app WhatsApp T1-ready | 2026-06-30 | working-tree | | [260630-njd-make-this-app-whatsapp-t1-ready](./quick/260630-njd-make-this-app-whatsapp-t1-ready/) | +| 260630-nk0 | Make Medium T1-ready | 2026-06-30 | working-tree | | [260630-nk0-make-medium-t1-ready](./quick/260630-nk0-make-medium-t1-ready/) | +| 260630-njd | Make this app Starbucks T1-ready | 2026-06-30 | working-tree | | [260630-njd-make-this-app-starbucks-t1-ready](./quick/260630-njd-make-this-app-starbucks-t1-ready/) | +| 260630-nh3 | Make this app Bitbucket T1-ready | 2026-06-30 | working-tree | | [260630-nh3-make-this-app-bitbucket-t1-ready](./quick/260630-nh3-make-this-app-bitbucket-t1-ready/) | +| 260630-mh2 | Make this app instagram T1-ready | 2026-06-30 | working-tree | | [260630-mh2-make-this-app-instagram-t1-ready](./quick/260630-mh2-make-this-app-instagram-t1-ready/) | +| 260630-mj0 | Make Pinterest T1-ready | 2026-06-30 | working-tree | | [260630-mj0-make-this-app-pinterest-t1-ready](./quick/260630-mj0-make-this-app-pinterest-t1-ready/) | +| 260630-mga | Make Instagram T1-ready | 2026-06-30 | working-tree | | [260630-mga-make-instagram-t1-ready](./quick/260630-mga-make-instagram-t1-ready/) | +| 260630-mjs | Make MongoDB Atlas T1-ready | 2026-06-30 | working-tree | | [260630-mjs-make-this-app-mongodb-t1-ready](./quick/260630-mjs-make-this-app-mongodb-t1-ready/) | +| 260630-mgp | Make Stack Overflow T1-ready | 2026-06-30 | working-tree | | [260630-mgp-make-stack-overflow-t1-ready](./quick/260630-mgp-make-stack-overflow-t1-ready/) | +| 260630-moa | Make this app Netlify T1-ready | 2026-06-30 | working-tree | | [260630-moa-make-this-app-netlify-t1-ready](./quick/260630-moa-make-this-app-netlify-t1-ready/) | +| 260630-mjd | Make Priceline T1-ready | 2026-06-30 | working-tree | | [260630-mjd-make-this-app-priceline-t1-ready](./quick/260630-mjd-make-this-app-priceline-t1-ready/) | +| 260630-mgf | Make this app Reddit T1-ready | 2026-06-30 | working-tree | | [260630-mgf-make-this-app-reddit-t1-ready](./quick/260630-mgf-make-this-app-reddit-t1-ready/) | +| 260630-mgf | Make Tumblr T1-ready | 2026-06-30 | working-tree | | [260630-mgf-make-this-app-tumblr-t1-ready](./quick/260630-mgf-make-this-app-tumblr-t1-ready/) | +| 260630-m4c | Promote bsky public AppView reads to T1-ready | 2026-06-30 | working-tree | | [260630-m4c-promote-bsky-public-appview-reads-to-t1-](./quick/260630-m4c-promote-bsky-public-appview-reads-to-t1-/) | +| 260630-m1q | Make this app Stripe T1-ready | 2026-06-30 | working-tree | | [260630-m1q-make-this-app-stripe-t1-ready](./quick/260630-m1q-make-this-app-stripe-t1-ready/) | +| 260630-m0u | Make Meticulous same-origin GraphQL reads T1-ready | 2026-06-30 | working-tree | | [260630-m0u-make-this-app-meticulous-t1-ready](./quick/260630-m0u-make-this-app-meticulous-t1-ready/) | +| 260630-m35 | Make this app twilio T1-ready | 2026-06-30 | working-tree | | [260630-m35-make-this-app-twilio-t1-ready](./quick/260630-m35-make-this-app-twilio-t1-ready/) | +| 260630-m1a | Make Cloudflare T1-ready with same-origin dashboard read handlers | 2026-06-30 | working-tree | | [260630-m1a-make-cloudflare-app-t1-ready-by-adding-s](./quick/260630-m1a-make-cloudflare-app-t1-ready-by-adding-s/) | +| 260630-m2u | Promote X public same-origin profile and tweet reads to T1-ready | 2026-06-30 | working-tree | | [260630-m2u-promote-x-public-same-origin-profile-and](./quick/260630-m2u-promote-x-public-same-origin-profile-and/) | +| 260630-m16 | Make Terraform Cloud T1-ready | 2026-06-30 | working-tree | | [260630-m16-make-terraform-cloud-t1-ready](./quick/260630-m16-make-terraform-cloud-t1-ready/) | +| 260630-lhy | Promote Zillow public same-origin search reads to T1-ready | 2026-06-30 | working-tree | | [260630-lhy-promote-another-safe-same-origin-public-](./quick/260630-lhy-promote-another-safe-same-origin-public-/) | +| 260630-l0o | Promote TripAdvisor public same-origin reads to T1-ready | 2026-06-30 | working-tree | | [260630-l0o-promote-another-safe-same-origin-public-](./quick/260630-l0o-promote-another-safe-same-origin-public-/) | +| 260630-kqt | Promote Yelp public same-origin reads to T1-ready | 2026-06-30 | working-tree | | [260630-kqt-promote-one-more-safe-same-origin-public](./quick/260630-kqt-promote-one-more-safe-same-origin-public/) | +| 260630-kg0 | Promote npm public same-origin Spiferack reads to T1-ready | 2026-06-30 | working-tree | | [260630-kg0-promote-npm-public-same-origin-read-hand](./quick/260630-kg0-promote-npm-public-same-origin-read-hand/) | +| 260630-hct | Anonymous aggregate state-level region telemetry (IP-derived at ingest, k≥5 floor, self-hosted DB-IP, graceful "unknown" until dataset present) | 2026-06-30 | e2a1b67a..8edf3525 | | [260630-hct-anon-region-telemetry](./quick/260630-hct-anon-region-telemetry/) | +| 260630-k18 | Promote Hacker News public same-origin HTML reads to T1-ready | 2026-06-30 | working-tree | | [260630-k18-promote-hacker-news-public-same-origin-h](./quick/260630-k18-promote-hacker-news-public-same-origin-h/) | +| 260630-hgl | Promote Wikipedia public same-origin reads to T1-ready | 2026-06-30 | working-tree | | [260630-hgl-promote-wikipedia-public-same-origin-rea](./quick/260630-hgl-promote-wikipedia-public-same-origin-rea/) | +| 260630-ha5 | Promote LeetCode query-only same-origin reads to T1-ready | 2026-06-30 | working-tree | | [260630-ha5-promote-leetcode-query-only-same-origin-](./quick/260630-ha5-promote-leetcode-query-only-same-origin-/) | +| 260630-gry | Promote Shortcut no-param same-origin reads to app-specific T1a handler proof | 2026-06-30 | working-tree | | [260630-gry-promote-next-safe-same-origin-read-recip](./quick/260630-gry-promote-next-safe-same-origin-read-recip/) | +| 260629-ksj | Support i18n internationalization for latest updated showcase content | 2026-06-29 | working-tree | | [260629-ksj-support-i18n-internationalization-for-la](./quick/260629-ksj-support-i18n-internationalization-for-la/) | - ~~39-03 cold-start circuit-breaker FLAG (the eval smoke index reached 93.3KB / 96KB after the retail/marketplace sub-batch)~~ **RESOLVED by 39-04** per the orchestrator decision: the smoke byte ceiling was widened 96KB->512KB (the 96KB ceiling was sized for a tiny corpus; 39-04 reached 108.6KB at a flat 570 bytes/descriptor -- legitimate linear corpus growth, NO params-leak/layout regression). The tight <10ms load-time assert (the real cold-start latency concern) is KEPT, and a NEW per-descriptor footprint flatness check (<700 bytes/descriptor -- the real params-leak regression signal) was ADDED. 512KB is well within the SCALE-01 ~1-2MB full-corpus target; the authoritative full-corpus SCALE-01 cold-start gate (size + load-time) remains Phase 43, kept separate. 39-05/06 have ample headroom under 512KB. diff --git a/.planning/quick/260715-hs1-replace-google-sheets-oauth-with-a-signe/260715-hs1-PLAN.md b/.planning/quick/260715-hs1-replace-google-sheets-oauth-with-a-signe/260715-hs1-PLAN.md new file mode 100644 index 000000000..920a9909f --- /dev/null +++ b/.planning/quick/260715-hs1-replace-google-sheets-oauth-with-a-signe/260715-hs1-PLAN.md @@ -0,0 +1,85 @@ +--- +quick: 260715-hs1 +plan: 01 +type: execute +wave: 1 +depends_on: [] +autonomous: false +must_haves: + truths: + - Google Sheets capabilities use only the already signed-in active Sheets tab and never initiate or manage authentication. + - The five gsheets capability slugs remain discoverable through the existing generic MCP capability tools; no MCP package change is required. + - Reads are executable through a fixed page-client/UI transport and all three mutations remain fail-closed until real disposable-sheet UAT evidence exists. + - Unknown mutation outcomes are never retried and surface RECOVERY_AMBIGUOUS. + artifacts: + - extension/utils/google-sheets-session.js + - extension/content/actions.js + - catalog/handlers/gsheets.js + - tests/google-sheets-session.test.js + - scripts/verify-origin-classification.mjs + key_links: + - capability-router supplies only five context-bound session methods to the Sheets handlers. + - the session adapter pins every operation to the caller-owned tab before page-client or UI execution. + - content UI actions redact value-bearing parameters and return normalized shape-only mutation results. +--- + +# Quick 260715-hs1 Plan + +## Objective + +Replace the Chrome Identity/OAuth Sheets integration with a zero-extra-auth transport that reuses an already signed-in, agent-owned Google Sheets tab. Preserve the five typed capabilities and current MCP surface, with bounded UI fallback and guarded mutations pending live proof. + +## Constraints + +- Preserve unrelated dirty worktree changes, do not switch branches, and stage only task-owned hunks after inspecting each diff. +- Remove only Sheets OAuth/Identity code. Retain unrelated install identity, telemetry, consent, serialization, and spreadsheet-record redaction. +- Never initialize auth, request consent, inspect or persist tokens/cookies/storage credentials, or expose arbitrary URL/method/header/request inputs. +- Never auto-navigate. An explicit spreadsheet ID must equal the ID in the caller-owned active Sheets tab. +- Live UAT evidence must be real and redacted. If a signed-in disposable Sheet is unavailable or any mutation test fails, leave all three write handlers guarded. + +## Tasks + +### 1. Replace Sheets OAuth with a fixed signed-in-tab session adapter + +**Files:** `extension/manifest.json`, `extension/background.js`, `extension/ui/control_panel.html`, `extension/ui/options.js`, `extension/utils/google-sheets-session.js`, `extension/utils/capability-router.js`, `catalog/handlers/gsheets.js`, `extension/catalog/handlers/gsheets.js` + +**Action:** + +- Remove Sheets `identity` permissions, manifest `oauth2`, the OAuth API module, connect/status/disconnect runtime messages, and connection UI while retaining unrelated install-identity code. +- Add `FsbGoogleSheetsSession` with exactly `getSpreadsheet`, `getValues`, `updateValues`, `appendValues`, and `clearValues`. Bind each call to router context `{tabId,url,origin}`, re-read the tab, require `https://docs.google.com/spreadsheets/d/`, and reject explicit-ID mismatches. +- In MAIN world, opportunistically call only an already-present `gapi.client.request` using internally built Sheets v4 operations. Do not load gapi, call auth APIs, or access credentials. Normalize unavailable/known-no-effect failures for safe UI fallback; map uncertain post-dispatch mutations to `RECOVERY_AMBIGUOUS` with no retry. +- Update reads to use the session facade and the new typed target/session errors. Preserve the three mutation runtime guards until UAT. + +**Verify:** `node --test tests/google-sheets-session.test.js tests/gsheets-handler.test.js tests/google-sheets-wiring.test.js` + +**Done:** The extension contains no Sheets OAuth surface, reads are target-pinned through the five-method session facade, writes remain guarded, and no MCP source or package contract changes. + +### 2. Add bounded Sheets UI operations and reviewed origin/privacy contracts + +**Files:** `extension/content/actions.js`, `extension/content/messaging.js`, `catalog/descriptors/gsheets__*.json`, `extension/catalog/recipe-index.generated.js`, `scripts/verify-origin-classification.mjs`, `tests/verify-origin-classification.test.js`, `tests/spreadsheet-record-redaction.test.js` + +**Action:** + +- Add a fixed `sheetsSession` content action with metadata/read/update/append/clear operations. Reuse exact-host checks, Name Box navigation, formula-bar reads, trusted debugger keys, and clipboard paste. Mark its parameters and legacy `fillsheet`/`readsheet` parameters sensitive in content logging. +- UI reads are capped at 50x26 and return `transport: "ui"` plus `renderSemantics: "formula-bar"`. UI writes accept only bounded rectangular, losslessly encodable scalar matrices; reject null/ragged/tab/newline payloads. RAW forces formula-like strings to text, USER_ENTERED permits formulas, and pastes are row-chunked. +- Append only after a provable contiguous first-column boundary; implement `OVERWRITE` and row insertion for `INSERT_ROWS`, otherwise fail closed. Clear only a bounded verifiable range. Any failure after a mutation starts returns `RECOVERY_AMBIGUOUS`. +- Refactor legacy `fillsheet`/`readsheet` to call the shared Sheets helpers. Update descriptor provenance and ID wording, regenerate the packaged catalog, and replace the Chrome Identity origin exception with a source-verified fixed page-gapi/UI session accommodation plus negative controls. + +**Verify:** `node --test tests/google-sheets-content-actions.test.js tests/spreadsheet-record-redaction.test.js tests/verify-origin-classification.test.js && node scripts/package-extension.mjs` + +**Done:** All five operations have bounded deterministic UI support where semantics are safe, legacy tools share those internals, content is not logged, and the classifier rejects any broader credential or cross-origin transport. + +### 3. Verify, document, and gate live activation + +**Files:** `docs/google-sheets-api.md`, `extension/README.md`, `tests/google-sheets-session.test.js`, `tests/google-sheets-wiring.test.js`, `.planning/quick/260715-hs1-replace-google-sheets-oauth-with-a-signe/260715-hs1-SUMMARY.md` + +**Action:** + +- Replace OAuth setup documentation with the signed-in-tab requirement, fixed five-operation contract, UI limitations, clipboard behavior, typed errors, privacy guarantees, and extension-only MCP UAT procedure. +- Add static tests proving the manifest and Sheets sources contain no Chrome Identity/OAuth/client-ID/bearer/token/cookie/storage credential behavior and no connection messages/UI. Add unit coverage for pinning, mismatch, page-client success/unavailable/401, mutation ambiguity, lossless encoding, append boundaries, clear verification, normalized responses, and legacy reuse. +- Run focused suites, recorder/privacy gates, origin classification, extension validation, and packaging. Inspect task commits and leave all unrelated dirty changes untouched. +- If an agent-owned signed-in disposable Sheet is available, temporarily enable mutations in the unpacked development build and invoke all five slugs through existing `search_capabilities`/`invoke_capability`; record redacted evidence and commit activation only after readback passes. Otherwise record `human_needed` and retain guards without fabricated evidence. + +**Verify:** `npm run validate:extension && node --test tests/google-sheets-session.test.js tests/google-sheets-content-actions.test.js tests/gsheets-handler.test.js tests/google-sheets-wiring.test.js tests/spreadsheet-record-redaction.test.js tests/verify-origin-classification.test.js && npm run package:extension` + +**Done:** Automated gates pass, documentation matches zero-extra-auth behavior, MCP remains unchanged, and write activation truthfully reflects live UAT evidence. diff --git a/.planning/quick/260715-hs1-replace-google-sheets-oauth-with-a-signe/260715-hs1-SUMMARY.md b/.planning/quick/260715-hs1-replace-google-sheets-oauth-with-a-signe/260715-hs1-SUMMARY.md new file mode 100644 index 000000000..1ef387e6a --- /dev/null +++ b/.planning/quick/260715-hs1-replace-google-sheets-oauth-with-a-signe/260715-hs1-SUMMARY.md @@ -0,0 +1,57 @@ +--- +quick_id: 260715-hs1 +slug: replace-google-sheets-oauth-with-a-signe +status: complete +verification: human_needed +completed: 2026-07-15 +commits: + - 3d096b99 + - 7895fced + - c1d9ed6c + - 4c1d3887 + - d56e4942 + - 9ab3d40d +--- + +# Quick Task 260715-hs1 Summary: Zero-extra-auth Google Sheets + +## Outcome + +FSB now exposes the existing five `gsheets.*` capabilities through the user's already signed-in, agent-owned Google Sheets tab without any extension-managed Google authorization. + +- Removed the Sheets-specific Chrome Identity permission, OAuth manifest block, OAuth client, connection messages, and connection UI. Unrelated install identity and telemetry remain intact. +- Added a five-method session facade that re-pins every call to the active `docs.google.com/spreadsheets/d/` tab, rejects explicit-ID mismatches, and never navigates to a caller-selected spreadsheet. +- The facade first uses an already-initialized page-owned `gapi.client.request` with fixed Sheets v4 paths. It never initializes gapi/auth, reads cookies or tokens, accepts arbitrary request fields, or exposes a generic authenticated proxy. +- Added bounded trusted-UI fallback for metadata, formula-bar reads, lossless rectangular paste updates, provable `OVERWRITE` append, and clear with readback. Sheet-qualified ranges also require proof that the active worksheet switched. +- `INSERT_ROWS` remains available through the fixed page client. UI fallback refuses it with `RECIPE_DOM_FALLBACK_PENDING` because a swallowed row-insertion shortcut cannot be proven safe before paste. +- UI operations are serialized per tab across typed and legacy `fillsheet`/`readsheet` callers. Typed mutations are also serialized at the session layer. Unknown mutation outcomes return `RECOVERY_AMBIGUOUS` and are never automatically replayed. +- Legacy Sheets tools now use the same range/value/UI helpers; their old direct Name Box and cell-by-cell paths were removed. +- The source-verified Sheets origin contract rejects arbitrary fetch/gapi requests, dynamic property proxies, browser network sinks, dynamic imports, service-worker/worklet loaders, and generated code in the UI action. +- Spreadsheet recording remains shape-only and retains the exact recovery code while dropping IDs, ranges, sheet names, values, formulas, bodies, and raw errors. +- The generic MCP `search_capabilities` / `invoke_capability` flow is unchanged. No MCP server or package update is required. + +## Commits + +- `3d096b99` — `feat(sheets): replace OAuth with signed-in session` +- `7895fced` — `feat(sheets): add bounded signed-in UI fallback` +- `c1d9ed6c` — `fix(sheets): enforce fail-closed UI recovery` +- `4c1d3887` — `fix(sheets): close UI session edge cases` +- `d56e4942` — `docs(sheets): document signed-in session UAT` +- `9ab3d40d` — `fix(sheets): block dynamic UI network loaders` + +## Verification + +- Focused Sheets, handler, wiring, privacy, and origin suites pass. +- `tests/verify-origin-classification.test.js` passes 221/221, including adversarial network-proxy controls. +- The origin CLI passes all 124 shipped heads with exactly one signed-in page-gapi/UI Sheets accommodation. +- The complete `npm test` suite exits 0, including the showcase build, recorder/privacy tests, and catalog gates. +- `npm run package:extension` exits 0 and writes `dist/fsb-extension-v0.9.91.zip` with 6 recipes, 2319 descriptors, and 124 packaged handlers. +- Extension validation and write-activation gates retain all three mutation handlers in their guarded state. + +## Live UAT still required + +The connected MCP bridge was reachable, but its owned tabs contained no signed-in Google Sheet. FSB did not open or navigate to a spreadsheet automatically, so live calls could not truthfully be exercised. + +To finish activation evidence, reload the unpacked extension, open a disposable signed-in Sheet as the agent-owned tab, reconnect the existing MCP bridge, and invoke all five slugs. Exercise RAW and USER_ENTERED writes, both append modes (with `INSERT_ROWS` through page client), clear/readback, target mismatch, and ambiguous recovery. Until that passes, `update_values`, `append_values`, and `clear_values` remain guarded and no activation evidence is recorded. + +All unrelated pre-existing worktree changes were preserved; task commits contain only Sheets-owned files or Sheets-owned hunks. diff --git a/.planning/quick/260715-hs1-replace-google-sheets-oauth-with-a-signe/260715-hs1-VERIFICATION.md b/.planning/quick/260715-hs1-replace-google-sheets-oauth-with-a-signe/260715-hs1-VERIFICATION.md new file mode 100644 index 000000000..7fb09e70b --- /dev/null +++ b/.planning/quick/260715-hs1-replace-google-sheets-oauth-with-a-signe/260715-hs1-VERIFICATION.md @@ -0,0 +1,37 @@ +--- +quick_id: 260715-hs1 +status: human_needed +verified: 2026-07-15 +score: 4/4 truths verified; 5/5 artifacts present; 3/3 key links wired +--- + +# Quick Task 260715-hs1 Verification + +## Result + +The implementation goal is achieved in committed code. No implementation gaps were found. Live disposable-Sheet UAT is the only remaining gate, so the three mutation slugs correctly remain guarded and no activation evidence was added. + +## Must-have truths + +- **Signed-in tab only — verified.** `extension/utils/google-sheets-session.js` re-reads the caller-owned tab with `chrome.tabs.get`, accepts only `https://docs.google.com/spreadsheets/d/`, rejects explicit-ID mismatches, and re-pins `globalThis.location` immediately before the fixed page request. The removed OAuth client, manifest `oauth2`, Chrome Identity permission, connection messages, and connection UI are covered by static wiring tests. No auth initialization, token/cookie access, credential storage, or caller-selected network primitive exists in the Sheets session path. +- **Five existing MCP capabilities — verified.** `catalog/handlers/gsheets.js` registers exactly `get_spreadsheet`, `get_values`, `update_values`, `append_values`, and `clear_values`; descriptors and the packaged catalog expose them through the existing dynamic capability router. `git diff 6e8d40d1..HEAD -- mcp` is empty, so no MCP server/package change was introduced. +- **Fixed reads and guarded writes — verified.** The two read handlers call the five-method session facade, which selects only the fixed page-client or `sheetsSession` UI path. The write activation ledger has no active `gsheets.*` entries and retains all three mutations as `guarded-fail-closed`; their runtime handlers return `RECIPE_DOM_FALLBACK_PENDING` pending live evidence. +- **Ambiguous mutation recovery — verified.** Page timeouts, network/unknown failures, 408/5xx outcomes, script-result uncertainty, and untyped UI outcomes return `RECOVERY_AMBIGUOUS`. UI fallback is allowed for a mutation only when `requestSent === false` or `knownNoEffect === true`, so uncertain writes are never replayed. + +## Artifacts and key links + +All five declared artifacts exist: the session adapter, content UI action, Sheets handler, session tests, and origin-classification verifier. + +The router constructs a narrow facade containing only the five session methods and binds `{origin, tabId, url}`. Every session call resolves the current tab before transport selection; the MAIN-world page request and the content action independently verify the spreadsheet ID. UI mutation results contain shape/count metadata rather than values, and the shared recorder redactor removes IDs, ranges, sheet names, values, formulas, bodies, and raw errors from typed and legacy Sheets records. + +## Automated evidence + +- Focused Sheets/handler/wiring/privacy/origin command: **45 tests passed, 0 failed**; the origin harness additionally reported **221/221 checks passed**. +- Origin CLI: **124 shipped heads passed** with the narrowly scoped page-gapi/UI Sheets accommodation and adversarial cross-origin/network-proxy negative controls. +- Full `npm test`: **exit 0**. +- `npm run validate:extension`: **exit 0**, including guarded-write activation checks. +- `npm run package:extension`: **exit 0**; packaged catalog contains 6 recipes, 2,319 descriptors, and 124 handlers. + +## Human verification required + +Reload the unpacked extension, open a disposable signed-in Google Sheet as the agent-owned tab, and reconnect the existing MCP bridge. Invoke all five slugs through `invoke_capability`; verify both read paths where available, RAW versus USER_ENTERED updates, OVERWRITE and INSERT_ROWS append semantics, clear/readback, target mismatch, and no duplicate mutation after an ambiguous outcome. Only after redacted live evidence and readback pass should `update_values`, `append_values`, and `clear_values` be activated. From e9d0081d3a002eafe778276f887c20fb137b15ff Mon Sep 17 00:00:00 2001 From: Lakshman Date: Mon, 20 Jul 2026 12:53:48 -0500 Subject: [PATCH 22/26] feat: finalize MCP session replay and v0.9.91 release --- .../44-T1-READINESS.json | 2 +- .../44-T1-READINESS.md | 2 +- CHANGELOG.md | 6 +- README.md | 6 +- extension/README.md | 2 +- extension/ai/agent-loop.js | 2 +- extension/ai/ai-providers.js | 2 +- extension/ai/tool-definitions.js | 9 +- extension/ai/universal-provider.js | 2 +- extension/background.js | 40 +- extension/config/config.js | 2 +- extension/content/actions.js | 19 +- .../lib/visualization/knowledge-graph.js | 155 +- extension/manifest.json | 4 +- extension/ui/control_panel.html | 39 +- extension/ui/options.css | 23 + extension/ui/options.js | 36 +- extension/ui/popup.js | 4 +- extension/ui/sidepanel.js | 2 +- extension/utils/action-verification.js | 2 +- extension/utils/automation-logger.js | 202 ++- extension/utils/google-sheets-session.js | 83 +- extension/utils/google-sheets-ui.js | 2 + extension/utils/mcp-session-recorder.js | 1366 +++++++++++++++-- .../utils/spreadsheet-record-redaction.js | 57 +- extension/ws/mcp-bridge-client.js | 100 +- extension/ws/mcp-tool-dispatcher.js | 195 ++- mcp/CHANGELOG.md | 22 + mcp/README.md | 24 +- mcp/ai/tool-definitions.cjs | 9 +- mcp/build/version.d.ts | 2 +- mcp/build/version.js | 2 +- mcp/package-lock.json | 4 +- mcp/package.json | 2 +- mcp/server.json | 4 +- mcp/src/queue.ts | 13 +- mcp/src/tools/read-only.ts | 33 +- mcp/src/types.ts | 1 + mcp/src/version.ts | 2 +- package-lock.json | 4 +- package.json | 8 +- showcase/about.html | 6 +- showcase/angular/package-lock.json | 4 +- showcase/angular/package.json | 2 +- showcase/angular/public/llms-full.txt | 6 +- showcase/angular/public/llms.txt | 4 +- showcase/angular/public/sitemap.xml | 18 +- showcase/angular/scripts/llms-full.source.md | 4 +- showcase/angular/scripts/llms.source.md | 2 +- showcase/angular/src/app/core/seo/version.ts | 2 +- .../pages/lattice/lattice-page.component.html | 2 +- .../phantom-stream-page.component.html | 2 +- showcase/server/package-lock.json | 4 +- showcase/server/package.json | 2 +- skills/fsb/SKILL.md | 2 +- skills/fsb/references/multi-agent-contract.md | 2 +- store-assets/chrome-web-store/listing-copy.md | 2 +- tests/automation-logger-mcp-retention.test.js | 443 ++++++ tests/capability-autopilot-parity.test.js | 2 +- tests/capability-mcp-surface.test.js | 2 +- .../control-panel-scroll-containment.test.js | 25 +- tests/google-sheets-content-actions.test.js | 45 +- tests/google-sheets-session.test.js | 56 + tests/knowledge-graph-dedup.test.js | 143 ++ tests/mcp-bridge-background-dispatch.test.js | 37 +- tests/mcp-restricted-tab.test.js | 59 + tests/mcp-session-recorder.test.js | 1110 ++++++++++++-- tests/mcp-session-settings-ui.test.js | 85 + tests/mcp-tool-routing-contract.test.js | 199 +++ tests/mcp-tool-smoke.test.js | 97 ++ tests/mcp-version-parity.test.js | 44 +- tests/onboarding-first-run.test.js | 4 +- tests/recipe-schema-lock.test.js | 4 +- tests/skill-fsb-spec.test.js | 2 +- tests/spreadsheet-record-redaction.test.js | 398 ++++- tests/tool-definitions-parity.test.js | 2 +- tests/trigger-tool-dispatcher.test.js | 42 + 77 files changed, 4910 insertions(+), 447 deletions(-) create mode 100644 tests/automation-logger-mcp-retention.test.js create mode 100644 tests/knowledge-graph-dedup.test.js create mode 100644 tests/mcp-session-settings-ui.test.js diff --git a/.planning/phases/44-t1-readiness-inventory-status-surface/44-T1-READINESS.json b/.planning/phases/44-t1-readiness-inventory-status-surface/44-T1-READINESS.json index b42a04906..42cfb6b24 100644 --- a/.planning/phases/44-t1-readiness-inventory-status-surface/44-T1-READINESS.json +++ b/.planning/phases/44-t1-readiness-inventory-status-surface/44-T1-READINESS.json @@ -1,5 +1,5 @@ { - "generatedAt": "2026-07-03T17:58:36.680Z", + "generatedAt": "2026-07-15T11:05:02.940Z", "descriptorCount": 2314, "rowCount": 2314, "rows": [ diff --git a/.planning/phases/44-t1-readiness-inventory-status-surface/44-T1-READINESS.md b/.planning/phases/44-t1-readiness-inventory-status-surface/44-T1-READINESS.md index 668d8556b..ad346bb7f 100644 --- a/.planning/phases/44-t1-readiness-inventory-status-surface/44-T1-READINESS.md +++ b/.planning/phases/44-t1-readiness-inventory-status-surface/44-T1-READINESS.md @@ -1,6 +1,6 @@ # Phase 44 T1 Readiness Matrix -**Generated:** 2026-07-03T17:58:36.680Z +**Generated:** 2026-07-15T11:05:02.940Z This report is generated from `extension/catalog/recipe-index.generated.js` plus the live `capability-catalog.js` resolver. It is the v1.1.0 truth surface: catalog/search support means a capability is searchable and routable, not that every app has direct API execution today. diff --git a/CHANGELOG.md b/CHANGELOG.md index b9b157f9a..edbd4c3f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -All notable changes to the FSB product (Chrome extension + showcase dashboard) are documented in this file. Entries are organized by FSB milestone. The extension version is `0.9.90`; milestone versions such as `v0.11.0` and `v0.12.0` are the meaningful release units. +All notable changes to the FSB product (Chrome extension + showcase dashboard) are documented in this file. Entries are organized by FSB milestone. The extension version is `0.9.91`; milestone versions such as `v0.11.0` and `v0.12.0` are the meaningful release units. The `fsb-mcp-server` npm package keeps its own semver changelog in [`mcp/CHANGELOG.md`](./mcp/CHANGELOG.md). @@ -8,6 +8,10 @@ The `fsb-mcp-server` npm package keeps its own semver changelog in [`mcp/CHANGEL Nothing yet. +## v0.9.91 — Version Metadata Alignment — 2026-07-14 + +The extension, showcase, skill, documentation, and store metadata align at `0.9.91`. The independently versioned `fsb-mcp-server` advances to `0.11.0` for the `mcp:task-status` terminal task-outcome contract; see [`mcp/CHANGELOG.md`](./mcp/CHANGELOG.md) for its package-specific release notes and compatibility requirements. + ## v0.9.90 — Native Capability Catalog, Trigger Watchers, Control Panel Redesign & Onboarding — 2026-07-06 FSB v0.9.90 is the first stable release since v0.9.72. It turns FSB from a browser-driving extension into a **native capability platform**: a verified first-party API catalog that agents can invoke directly, reactive DOM **trigger watchers**, real **file uploads**, a fully **redesigned control panel**, and a **first-run onboarding** flow. Under the hood it folds in three milestone units shipped on the automation branch: v0.10.0 (Autopilot via Lattice), v0.11.0 (Trigger Watchers), and v0.12.0 (PhantomStream package migration), plus the v1.1.0 T1 execution milestone. MCP server moves to v0.10.0. diff --git a/README.md b/README.md index 4526e48c5..a8ae79060 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# FSB v0.9.90 Full Self Browsing +# FSB v0.9.91 Full Self Browsing
@@ -9,7 +9,7 @@ ![FSB](https://img.shields.io/badge/FSB-Full_Self_Browsing-000000?style=for-the-badge) -![Version](https://img.shields.io/badge/version-0.9.90-0078D4?style=for-the-badge) +![Version](https://img.shields.io/badge/version-0.9.91-0078D4?style=for-the-badge) ![Manifest V3](https://img.shields.io/badge/Manifest_V3-Chrome-34A853?style=for-the-badge&logo=googlechrome&logoColor=white) ![License](https://img.shields.io/badge/license-BSL_1.1-F5C518?style=for-the-badge) @@ -32,7 +32,7 @@ FSB (Full Self Browsing) is an open source Chrome extension for AI powered browser automation. Describe a task in plain English; FSB reads the live DOM, builds a plan, executes browser actions, verifies results, and reports progress through the popup, side panel, or MCP. -> FSB v0.9.90 is functional and production ready for supervised automation. Browser automation can still behave unpredictably on complex or sensitive sites, so monitor actions and test on non critical pages first. +> FSB v0.9.91 is functional and production ready for supervised automation. Browser automation can still behave unpredictably on complex or sensitive sites, so monitor actions and test on non critical pages first. ### Why DOM First diff --git a/extension/README.md b/extension/README.md index aba29c105..fa55b8f6f 100644 --- a/extension/README.md +++ b/extension/README.md @@ -1,6 +1,6 @@ # FSB Chrome Extension -`extension/` is the unpacked Chrome extension package for FSB v0.9.90. Public users should install FSB from the Chrome Web Store so Chrome can apply release updates automatically. Load this directory only for local development or an urgent unreleased fix. +`extension/` is the unpacked Chrome extension package for FSB v0.9.91. Public users should install FSB from the Chrome Web Store so Chrome can apply release updates automatically. Load this directory only for local development or an urgent unreleased fix. ## Load Unpacked diff --git a/extension/ai/agent-loop.js b/extension/ai/agent-loop.js index e4ee650c7..83b8f3af8 100644 --- a/extension/ai/agent-loop.js +++ b/extension/ai/agent-loop.js @@ -1,5 +1,5 @@ /** - * Agent Loop Engine for FSB v0.9.90 + * Agent Loop Engine for FSB v0.9.91 * * Core tool_use protocol loop that replaces startAutomationLoop. * Each iteration is a separate setTimeout callback (not a blocking while-loop) diff --git a/extension/ai/ai-providers.js b/extension/ai/ai-providers.js index d252b698c..bf9899052 100644 --- a/extension/ai/ai-providers.js +++ b/extension/ai/ai-providers.js @@ -1,5 +1,5 @@ /** - * AI Provider implementations for FSB v0.9.90 + * AI Provider implementations for FSB v0.9.91 * This module now uses the universal provider for model-agnostic support */ diff --git a/extension/ai/tool-definitions.js b/extension/ai/tool-definitions.js index b08622e74..a25bae83f 100644 --- a/extension/ai/tool-definitions.js +++ b/extension/ai/tool-definitions.js @@ -1136,11 +1136,12 @@ const TOOL_REGISTRY = [ { name: 'complete_task', - description: 'Signal that the task is fully complete. ONLY call this when the user\'s requested task has been fully achieved -- all data collected, all entries made, all actions performed. Include a summary of what was accomplished. Provide session_token only when completing a client-owned visual session created by start_visual_session; otherwise omit it and keep the normal task-lifecycle semantics. Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64).', + description: 'Signal that the task is fully complete. ONLY call this when the user\'s requested task has been fully achieved -- all data collected, all entries made, all actions performed. Include a summary of what was accomplished, but never include passwords, tokens, API keys, or sensitive form values. Provide tab_id when more than one tab/session could be active so the local task memory is associated exactly. Provide session_token only when completing a client-owned visual session created by start_visual_session; otherwise omit it and keep the normal task-lifecycle semantics. Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64).', inputSchema: { type: 'object', properties: { summary: { type: 'string', description: 'Summary of what was accomplished (e.g. "Found 50 Tesla internships and added them to Google Sheet with title, department, location columns")' }, + tab_id: { type: 'integer', description: 'Optional target tab id used to associate this outcome with the correct local MCP replay session.' }, session_token: { type: 'string', description: 'Optional token returned by start_visual_session. Provide this only when finalizing a client-owned visual session.' @@ -1158,13 +1159,14 @@ const TOOL_REGISTRY = [ { name: 'partial_task', - description: 'Signal that the task is partially complete because useful work was completed but an external blocker prevents the final step. Use this instead of fail_task when the user can still benefit from the completed work, especially for auth/manual handoff blockers after research, drafting, or data entry is already done. Auth/manual blockers include login required, no saved credentials, user skipped login, credentials failed, and manual approval, MFA, or external verification. Preserve three things clearly: what you completed, the exact blocker, and the manual next step the user should take. If the runtime offers one saved-credential or operator-prompt attempt, let that single attempt happen first; call partial_task only after that attempt is unavailable, skipped, exhausted, or fails. Provide session_token only when finalizing a client-owned visual session created by start_visual_session. Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64).', + description: 'Signal that the task is partially complete because useful work was completed but an external blocker prevents the final step. Use this instead of fail_task when the user can still benefit from the completed work, especially for auth/manual handoff blockers after research, drafting, or data entry is already done. Auth/manual blockers include login required, no saved credentials, user skipped login, credentials failed, and manual approval, MFA, or external verification. Preserve three things clearly: what you completed, the exact blocker, and the manual next step the user should take, but never include passwords, tokens, API keys, or sensitive form values. If the runtime offers one saved-credential or operator-prompt attempt, let that single attempt happen first; call partial_task only after that attempt is unavailable, skipped, exhausted, or fails. Provide tab_id when more than one tab/session could be active. Provide session_token only when finalizing a client-owned visual session created by start_visual_session. Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64).', inputSchema: { type: 'object', properties: { summary: { type: 'string', description: 'Summary of the useful work that was completed before the blocker was hit' }, blocker: { type: 'string', description: 'What prevented the final step from being completed (e.g. "Messaging requires login", "Manual approval required")' }, next_step: { type: 'string', description: 'Manual next step the user can take to finish manually or resume later. Include this for auth or approval blockers.' }, + tab_id: { type: 'integer', description: 'Optional target tab id used to associate this outcome with the correct local MCP replay session.' }, reason: { type: 'string', description: 'Optional machine-readable blocker category. Keep it narrow and stable for blocked/manual-handoff outcomes.', @@ -1187,11 +1189,12 @@ const TOOL_REGISTRY = [ { name: 'fail_task', - description: 'Signal that the task cannot be completed. Include the reason why. Call this instead of just stopping when you encounter an unrecoverable problem. Provide session_token only when ending a client-owned visual session created by start_visual_session; otherwise omit it and keep the normal task-failure semantics. Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64).', + description: 'Signal that the task cannot be completed. Include the reason why, but never include passwords, tokens, API keys, or sensitive form values. Call this instead of just stopping when you encounter an unrecoverable problem. Provide tab_id when more than one tab/session could be active. Provide session_token only when ending a client-owned visual session created by start_visual_session; otherwise omit it and keep the normal task-failure semantics. Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64).', inputSchema: { type: 'object', properties: { reason: { type: 'string', description: 'Why the task cannot be completed (e.g. "Page requires login", "Data not found on page")' }, + tab_id: { type: 'integer', description: 'Optional target tab id used to associate this outcome with the correct local MCP replay session.' }, session_token: { type: 'string', description: 'Optional token returned by start_visual_session. Provide this only when failing a client-owned visual session.' diff --git a/extension/ai/universal-provider.js b/extension/ai/universal-provider.js index c8874d827..4260ff0ba 100644 --- a/extension/ai/universal-provider.js +++ b/extension/ai/universal-provider.js @@ -1,5 +1,5 @@ /** - * Universal AI Provider for FSB v0.9.90 + * Universal AI Provider for FSB v0.9.91 * A model-agnostic provider that works with any OpenAI-compatible API */ diff --git a/extension/background.js b/extension/background.js index 0ceb3522d..e64e23845 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,4 +1,4 @@ -// Background service worker for FSB v0.9.90 +// Background service worker for FSB v0.9.91 // Import configuration and AI integration modules // Phase 269 / v0.9.69: install-identity.js MUST load FIRST so that any @@ -73,17 +73,20 @@ try { importScripts('utils/mcp-pricing.js'); } catch (e) { console.error('[FSB] // (it calls fsbMcpPricing) and AFTER mcp-tool-dispatcher.js (dispatcher hooks // call globalThis.fsbMcpMetricsRecorder). try { importScripts('utils/mcp-metrics-recorder.js'); } catch (e) { console.error('[FSB] Failed to load mcp-metrics-recorder.js:', e.message); } -// Quick 260707-7id -- MCP session recorder. Loaded AFTER the dispatcher and -// the metrics recorder: the dispatcher's finally-block sibling hooks call -// globalThis.fsbMcpSessionRecorder lazily, and this placement keeps the -// global ready before any WS dispatch can fire. +// Session history owns the shared local-storage mutation chain. Load it before +// the MCP recorder so startup redaction can serialize with every history/log +// writer instead of racing a stale read-modify-write snapshot. +importScripts('utils/automation-logger.js'); +// Quick 260707-7id -- MCP session recorder. Loaded AFTER the dispatcher, +// metrics recorder, and automation logger: the dispatcher's finally-block +// sibling hooks resolve the recorder lazily, while recorder startup can use the +// logger's storage mutation chain before any WS dispatch fires. try { importScripts('utils/mcp-session-recorder.js'); } catch (e) { console.error('[FSB] Failed to load mcp-session-recorder.js:', e.message); } // Phase 272 / v0.9.69 -- TelemetryCollector. Loaded AFTER mcp-metrics-recorder // (collector reads the rows the recorder writes to fsbUsageData). The alarm // handler in chrome.alarms.onAlarm + the install_announce setTimeout in // chrome.runtime.onInstalled are wired below. try { importScripts('utils/telemetry-collector.js'); } catch (e) { console.error('[FSB] Failed to load telemetry-collector.js:', e.message); } -importScripts('utils/automation-logger.js'); importScripts('utils/analytics.js'); importScripts('utils/keyboard-emulator.js'); importScripts('utils/site-explorer.js'); @@ -900,6 +903,15 @@ function getMcpVisualSessionManager() { return mcpVisualSessionManager; } +function resolveMcpVisualSessionTabId(sessionToken) { + const token = String(sessionToken || '').trim(); + if (!token) return null; + const session = getMcpVisualSessionManager().getSession(token); + return session && Number.isFinite(session.tabId) && session.tabId > 0 + ? Number(session.tabId) + : null; +} + function getMcpVisualSessionFinalClearDelayMs() { const delay = Number(MCP_VISUAL_SESSION_FINAL_CLEAR_DELAY_MS); return Number.isFinite(delay) && delay > 0 ? delay : 3200; @@ -1704,6 +1716,7 @@ if (typeof globalThis !== 'undefined') { globalThis.handleStartMcpVisualSession = handleStartMcpVisualSession; globalThis.handleEndMcpVisualSession = handleEndMcpVisualSession; globalThis.handleMcpVisualSessionTaskStatus = handleMcpVisualSessionTaskStatus; + globalThis.resolveMcpVisualSessionTabId = resolveMcpVisualSessionTabId; } /** @@ -15840,6 +15853,21 @@ chrome.action.onClicked.addListener(async (tab) => { // --- chrome.alarms.onAlarm Listener (MCP reconnect + dom-stream watchdog; agent branch DEPRECATED) --- chrome.alarms.onAlarm.addListener(async (alarm) => { + // MCP replay recorder idle + retention alarms. The recorder owns the + // fsbMcpSession: namespace so open sessions survive service-worker + // eviction and daily MCP-only pruning has one durable wake-up path. + if (globalThis.fsbMcpSessionRecorder + && alarm + && typeof alarm.name === 'string' + && alarm.name.startsWith(globalThis.fsbMcpSessionRecorder.FSB_MCP_SESSION_ALARM_PREFIX)) { + try { + await globalThis.fsbMcpSessionRecorder.handleAlarm(alarm); + } catch (err) { + console.warn('[FSB MCP] session recorder alarm failed (non-blocking):', err && err.message); + } + return; + } + // Phase 256 Plan 03 -- visual-session sliding-window death-timer alarm. // Alarm names of the form 'mcpVisualDeath:' route to the lifecycle // helper which deletes the storage entry and sends the v0.9.36 clear diff --git a/extension/config/config.js b/extension/config/config.js index 941e39dbc..92e5662d6 100644 --- a/extension/config/config.js +++ b/extension/config/config.js @@ -1,5 +1,5 @@ /** - * Configuration management for FSB v0.9.90 + * Configuration management for FSB v0.9.91 * This file handles loading configuration from environment variables and Chrome storage */ diff --git a/extension/content/actions.js b/extension/content/actions.js index d01ad6615..e0589de49 100644 --- a/extension/content/actions.js +++ b/extension/content/actions.js @@ -1599,6 +1599,15 @@ function sheetsTrustedKey(result, reason) { return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', reason || 'trusted-keyboard-unavailable'); } +function sheetsPrimaryModifier() { + const platform = `${navigator.userAgentData?.platform || ''} ${navigator.platform || ''}`; + return /mac|iphone|ipad|ipod/i.test(platform) ? { metaKey: true } : { ctrlKey: true }; +} + +function sheetsPrimaryShortcut(key, extra = {}) { + return { key, ...sheetsPrimaryModifier(), ...extra }; +} + async function sheetsNavigate(reference, settleMs = 80) { const nameBox = sheetsNameBox(); if (!nameBox) return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'name-box-unavailable'); @@ -1607,7 +1616,7 @@ async function sheetsNavigate(reference, settleMs = 80) { nameBox.click(); await sheetsDelay(25); const selected = sheetsTrustedKey( - await tools.keyPress({ key: 'a', ctrlKey: true, useDebuggerAPI: true }), + await tools.keyPress(sheetsPrimaryShortcut('a', { useDebuggerAPI: true })), 'name-box-select-not-trusted' ); if (!selected.success) return selected; @@ -1658,7 +1667,9 @@ async function sheetsReadValues(range, majorDimension) { if (!sheetsUi) return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'sheets-ui-helper-unavailable'); const parsed = sheetsUi.parseA1Range(range); if (!parsed || parsed.columnOnly) return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-read-requires-bounded-a1-range'); - if (parsed.rows > sheetsUi.limits.maxReadRows || parsed.columns > sheetsUi.limits.maxReadColumns) { + if (parsed.rows > sheetsUi.limits.maxReadRows || + parsed.columns > sheetsUi.limits.maxReadColumns || + parsed.rows * parsed.columns > sheetsUi.limits.maxReadCells) { return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-read-range-limit-exceeded'); } try { @@ -1727,7 +1738,7 @@ async function sheetsPasteEncoded(parsed, encoded, startRow) { } mutationStarted = true; try { - const pasted = await tools.keyPress({ key: 'v', ctrlKey: true, useDebuggerAPI: true }); + const pasted = await tools.keyPress(sheetsPrimaryShortcut('v', { useDebuggerAPI: true })); if (!pasted || pasted.success !== true || pasted.method !== 'debuggerAPI') { return sheetsError('RECOVERY_AMBIGUOUS', 'ui-paste-outcome-unknown', mutationStarted); } @@ -1881,7 +1892,7 @@ async function sheetsRenameSpreadsheet(title) { titleInput.click(); await sheetsDelay(80); if (typeof titleInput.select === 'function') titleInput.select(); - else await tools.keyPress({ key: 'a', ctrlKey: true, useDebuggerAPI: true }); + else await tools.keyPress(sheetsPrimaryShortcut('a', { useDebuggerAPI: true })); await tools.typeWithKeys({ text: String(title), clearFirst: false, delay: 8 }); await tools.keyPress({ key: 'Enter', useDebuggerAPI: true }); await sheetsDelay(180); diff --git a/extension/lib/visualization/knowledge-graph.js b/extension/lib/visualization/knowledge-graph.js index b1a189b44..57cd9fdc3 100644 --- a/extension/lib/visualization/knowledge-graph.js +++ b/extension/lib/visualization/knowledge-graph.js @@ -66,12 +66,115 @@ const KnowledgeGraph = (function () { // --------------------------------------------------------------- // Data consolidation -- free-floating sites, color-coded by category // --------------------------------------------------------------- + function normalizeSiteIdentity(value) { + if (value === undefined || value === null) return ''; + var identity = String(value).trim().toLowerCase(); + identity = identity.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ''); + identity = identity.replace(/^\/\//, ''); + identity = identity.split(/[/?#]/)[0]; + identity = identity.replace(/^www\./, ''); + identity = identity.replace(/\.+$/, ''); + return identity; + } + + function patternIdentityStartsInDottedSuffix(source, startIndex) { + if (startIndex >= 2 && source.slice(startIndex - 2, startIndex) === '\\.') return true; + + var openGroups = []; + var inCharacterClass = false; + for (var si = 0; si < startIndex; si++) { + var character = source.charAt(si); + if (character === '\\') { + si++; + continue; + } + if (character === '[') { + inCharacterClass = true; + continue; + } + if (character === ']' && inCharacterClass) { + inCharacterClass = false; + continue; + } + if (inCharacterClass) continue; + if (character === '(') openGroups.push(si); + if (character === ')' && openGroups.length > 0) openGroups.pop(); + } + + for (var gi = 0; gi < openGroups.length; gi++) { + var groupStart = openGroups[gi]; + if (groupStart >= 2 && source.slice(groupStart - 2, groupStart) === '\\.') return true; + } + return false; + } + + function patternSiteIdentities(pattern) { + if (!pattern || typeof pattern.source !== 'string') return []; + var identities = []; + var matcher = /[a-z0-9-]+(?:\\\.[a-z0-9-]+)+/gi; + var match; + while ((match = matcher.exec(pattern.source)) !== null) { + // In /amazon\.(com|...|com\.au)/, com.au is only an alternation + // suffix attached to "amazon.". Treating it as a complete hostname + // would make every .com.au task look like a built-in Amazon site. + if (patternIdentityStartsInDottedSuffix(pattern.source, match.index)) continue; + var identity = normalizeSiteIdentity(match[0].replace(/\\\./g, '.')); + if (identity && identities.indexOf(identity) === -1) identities.push(identity); + } + return identities; + } + + function identityMatchesKnownSite(identity, knownIdentity) { + return identity === knownIdentity || identity.slice(-(knownIdentity.length + 1)) === '.' + knownIdentity; + } + + function guideMatchesIdentity(guide, identity) { + if (!guide || !identity || !Array.isArray(guide.patterns)) return false; + var candidates = [identity, 'https://' + identity + '/', 'http://www.' + identity + '/']; + for (var pi = 0; pi < guide.patterns.length; pi++) { + var pattern = guide.patterns[pi]; + if (!pattern || typeof pattern.test !== 'function') continue; + var patternIdentities = patternSiteIdentities(pattern); + for (var ii = 0; ii < patternIdentities.length; ii++) { + if (identityMatchesKnownSite(identity, patternIdentities[ii])) return true; + } + var originalLastIndex = pattern.lastIndex; + for (var ci = 0; ci < candidates.length; ci++) { + pattern.lastIndex = 0; + if (pattern.test(candidates[ci])) { + pattern.lastIndex = originalLastIndex; + return true; + } + } + pattern.lastIndex = originalLastIndex; + } + return false; + } + function buildKnowledgeGraphData(detailLevel) { if (typeof getSiteGuidesByCategory !== 'function') return { nodes: [], links: [] }; var grouped = getSiteGuidesByCategory(); var nodes = []; var links = []; + // Duplicate detection must not depend on which nodes the selected detail + // level happens to render. Build identities from every guide up front. + var guideRegistry = []; + var existingLabels = {}; + Object.keys(grouped).forEach(function (categoryName) { + (grouped[categoryName] || []).forEach(function (guide) { + guideRegistry.push(guide); + var identity = normalizeSiteIdentity(guide && guide.site); + if (identity) existingLabels[identity] = true; + var patterns = (guide && guide.patterns) || []; + for (var pi = 0; pi < patterns.length; pi++) { + var patternIdentities = patternSiteIdentities(patterns[pi]); + for (var ii = 0; ii < patternIdentities.length; ii++) { + existingLabels[patternIdentities[ii]] = true; + } + } + }); + }); // Root node nodes.push({ @@ -174,30 +277,22 @@ const KnowledgeGraph = (function () { // nodes (their own pseudo-category). One tier only, same as built-in sites // stopping at one tier below their category. if (_taskMemories.length > 0) { - // Collect existing site labels to avoid duplicates (category nodes reuse - // type 'site' too, so exclude those via isCat) - var existingLabels = {}; - for (var n = 0; n < nodes.length; n++) { - if (nodes[n].type === 'site' && !nodes[n].isCat) { - existingLabels[nodes[n].label.toLowerCase()] = true; - } - } - // Group task memories by domain var domainMap = {}; for (var mi = 0; mi < _taskMemories.length; mi++) { var tm = _taskMemories[mi]; var tmDomain = (tm.typeData && tm.typeData.session && tm.typeData.session.domain) || (tm.metadata && tm.metadata.domain) || ''; - if (!tmDomain) continue; - if (!domainMap[tmDomain]) { - domainMap[tmDomain] = { selectors: [] }; + var tmIdentity = normalizeSiteIdentity(tmDomain); + if (!tmIdentity) continue; + if (!domainMap[tmIdentity]) { + domainMap[tmIdentity] = { label: tmIdentity, selectors: [] }; } var tmLearned = (tm.typeData && tm.typeData.learned) || {}; if (tmLearned.selectors) { for (var s = 0; s < tmLearned.selectors.length; s++) { - if (domainMap[tmDomain].selectors.indexOf(tmLearned.selectors[s]) === -1) { - domainMap[tmDomain].selectors.push(tmLearned.selectors[s]); + if (domainMap[tmIdentity].selectors.indexOf(tmLearned.selectors[s]) === -1) { + domainMap[tmIdentity].selectors.push(tmLearned.selectors[s]); } } } @@ -209,8 +304,12 @@ const KnowledgeGraph = (function () { var taskStartIdx = nCat; // continue golden-angle indexing after categories var tTotal = nCat + taskDomains.length; for (var ti = 0; ti < taskDomains.length; ti++) { - var tdName = taskDomains[ti]; - if (existingLabels[tdName.toLowerCase()]) continue; + var tdIdentity = taskDomains[ti]; + var isKnownGuide = existingLabels[tdIdentity] === true; + for (var gi = 0; !isKnownGuide && gi < guideRegistry.length; gi++) { + isKnownGuide = guideMatchesIdentity(guideRegistry[gi], tdIdentity); + } + if (isKnownGuide) continue; var tsId = 'task-site:' + ti; var tsIdx = taskStartIdx + ti; @@ -222,10 +321,10 @@ const KnowledgeGraph = (function () { var tsy = R_CAT * Math.cos(tPhi); var tsz = R_CAT * Math.sin(tPhi) * Math.sin(tTheta); - var domData = domainMap[tdName]; + var domData = domainMap[tdIdentity]; nodes.push({ id: tsId, - label: tdName, + label: domData.label, fullLabel: 'Task-Discovered', depth: 1, type: 'task-site', @@ -610,7 +709,7 @@ const KnowledgeGraph = (function () { } function getFitZoom(state, detailLevel) { - var defaultZoom = getDefaultZoom(state.container, detailLevel); + var defaultZoom = getDefaultZoom(state && state.container, detailLevel); if (!state || detailLevel !== 'full' || !state.canvas || !state.nodes || state.nodes.length === 0) { return defaultZoom; } @@ -650,6 +749,13 @@ const KnowledgeGraph = (function () { return Math.max(minZoom, Math.min(defaultZoom, fitZoom)); } + function getAutomaticZoom(state) { + if (!state) return 1; + return state.detailLevel === 'full' + ? getFitZoom(state, state.detailLevel) + : getDefaultZoom(state.container, state.detailLevel); + } + function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } @@ -968,12 +1074,19 @@ const KnowledgeGraph = (function () { _state = state; container._knowledgeGraphState = state; + if (!state.userZoomed) state.zoom = getAutomaticZoom(state); // Handle resize -- re-sync canvas buffer to CSS layout state._syncCanvasSize = syncCanvasSize; state._resizeHandler = function () { syncCanvasSize(); - if (!state.userZoomed) state.zoom = getDefaultZoom(container, state.detailLevel); + if (!state.userZoomed) { + if (state.zoomAnimId) { + cancelAnimationFrame(state.zoomAnimId); + state.zoomAnimId = null; + } + state.zoom = getAutomaticZoom(state); + } }; window.addEventListener('resize', state._resizeHandler); @@ -1011,7 +1124,7 @@ const KnowledgeGraph = (function () { _state.links = data.links; if (level === 'full') { _state.userZoomed = false; - animateZoomTo(_state, getFitZoom(_state, level), 650); + animateZoomTo(_state, getAutomaticZoom(_state), 650); } else if (!_state.userZoomed) { animateZoomTo(_state, getDefaultZoom(_state.container, level), 420); } diff --git a/extension/manifest.json b/extension/manifest.json index 31079d974..bba49366a 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, - "name": "FSB v0.9.90", - "version": "0.9.90", + "name": "FSB v0.9.91", + "version": "0.9.91", "description": "FSB - Universal AI-powered browser automation assistant with multi-model support", "homepage_url": "https://full-selfbrowsing.com", "permissions": [ diff --git a/extension/ui/control_panel.html b/extension/ui/control_panel.html index 7bc676798..dedd3c239 100644 --- a/extension/ui/control_panel.html +++ b/extension/ui/control_panel.html @@ -574,6 +574,43 @@

Action Change Reports

+
+
+
+ +
+
+

MCP Session Replay

+

Local, exact-fidelity history for MCP agent actions

+
+
+
+
+ +
+
+
+ MCP replay retention (days) +
+ + + +
+
+
Default 30. Range 1 to 365 days. This automatically deletes expired MCP replay sessions only; Autopilot history is never pruned by this setting.
+
+
+
+
@@ -1797,7 +1834,7 @@

Community

- ▽0.9.90 + ▽0.9.91
diff --git a/extension/ui/options.css b/extension/ui/options.css index 7b0135f36..a5ef3c621 100644 --- a/extension/ui/options.css +++ b/extension/ui/options.css @@ -1978,6 +1978,25 @@ textarea.form-input { /* Print Styles */ @media print { + html, + body { + height: auto; + overflow: visible; + } + + .dashboard-container { + display: block; + height: auto; + min-height: 0; + overflow: visible; + } + + .dashboard-main { + display: block; + min-height: 0; + overflow: visible; + } + .dashboard-header, .dashboard-sidebar, .save-bar, @@ -1986,7 +2005,11 @@ textarea.form-input { } .dashboard-content { + display: block; + min-height: 0; + overflow: visible; padding: 0; + margin-left: 0 !important; } .form-card, diff --git a/extension/ui/options.js b/extension/ui/options.js index 9d3a1d2d9..3258e2a46 100644 --- a/extension/ui/options.js +++ b/extension/ui/options.js @@ -1,4 +1,4 @@ -// FSB v0.9.90 - Modern Dashboard Control Panel Script +// FSB v0.9.91 - Modern Dashboard Control Panel Script // Default settings const defaultSettings = { @@ -37,7 +37,11 @@ const defaultSettings = { // Phase 245 D-07: global toggle for action change_report emission. When // false, the dispatcher skips harvest instrumentation entirely (zero // overhead) and action tool responses revert to pre-Phase-245 shape. - fsbChangeReportsEnabled: true + fsbChangeReportsEnabled: true, + // Exact-fidelity MCP replay is opt-out. Saved MCP sessions age out + // independently of Autopilot history. + fsbMcpSessionRecordingEnabled: true, + fsbMcpSessionRetentionDays: 30 }; let fsbRecommendedAgentCap = defaultSettings.fsbAgentCap; @@ -61,6 +65,15 @@ function clampAgentCapValue(value, fallbackValue) { return raw; } +function clampMcpSessionRetentionDays(value) { + let days = (typeof value === 'number') ? value : parseInt(value, 10); + if (!Number.isFinite(days)) days = defaultSettings.fsbMcpSessionRetentionDays; + days = Math.floor(days); + if (days < 1) return 1; + if (days > 365) return 365; + return days; +} + async function resolveRecommendedAgentCap() { const helper = getAgentCapRecommendationModule(); if (helper && typeof helper.getRecommendedAgentCap === 'function') { @@ -242,6 +255,8 @@ function cacheElements() { elements.fsbTriggerCapCurrentActive = document.getElementById('fsbTriggerCapCurrentActive'); // Phase 245 D-07: Action Change Reports global toggle. elements.fsbChangeReportsEnabled = document.getElementById('fsbChangeReportsEnabled'); + elements.fsbMcpSessionRecordingEnabled = document.getElementById('fsbMcpSessionRecordingEnabled'); + elements.fsbMcpSessionRetentionDays = document.getElementById('fsbMcpSessionRetentionDays'); elements.prioritizeViewport = document.getElementById('prioritizeViewport'); elements.animatedActionHighlights = document.getElementById('animatedActionHighlights'); elements.showSidepanelProgress = document.getElementById('showSidepanelProgress'); @@ -348,7 +363,9 @@ function setupEventListeners() { elements.enableLogin, elements.captchaSolverEnabled, elements.captchaApiKey, - elements.sttProvider + elements.sttProvider, + elements.fsbMcpSessionRecordingEnabled, + elements.fsbMcpSessionRetentionDays ]; formInputs.forEach(input => { @@ -1503,6 +1520,15 @@ function loadSettings() { elements.fsbChangeReportsEnabled.checked = settings.fsbChangeReportsEnabled ?? true; } + if (elements.fsbMcpSessionRecordingEnabled) { + elements.fsbMcpSessionRecordingEnabled.checked = settings.fsbMcpSessionRecordingEnabled !== false; + } + if (elements.fsbMcpSessionRetentionDays) { + const retentionDays = clampMcpSessionRetentionDays(settings.fsbMcpSessionRetentionDays); + elements.fsbMcpSessionRetentionDays.value = String(retentionDays); + refreshStepper(elements.fsbMcpSessionRetentionDays); + } + // Credential Manager if (elements.enableLogin) { elements.enableLogin.checked = settings.enableLogin ?? false; @@ -1682,7 +1708,9 @@ function saveSettings() { })(), // Phase 245 D-07: persist Action Change Reports toggle. Default true so // builds where the user has never visited the toggle still emit reports. - fsbChangeReportsEnabled: elements.fsbChangeReportsEnabled?.checked ?? true + fsbChangeReportsEnabled: elements.fsbChangeReportsEnabled?.checked ?? true, + fsbMcpSessionRecordingEnabled: elements.fsbMcpSessionRecordingEnabled?.checked ?? true, + fsbMcpSessionRetentionDays: clampMcpSessionRetentionDays(elements.fsbMcpSessionRetentionDays?.value) }; chrome.storage.local.set(settings, () => { diff --git a/extension/ui/popup.js b/extension/ui/popup.js index cc53213b3..4fd60a6db 100644 --- a/extension/ui/popup.js +++ b/extension/ui/popup.js @@ -1,4 +1,4 @@ -// Modern Chat Interface Script for FSB v0.9.90 +// Modern Chat Interface Script for FSB v0.9.91 // Phase 243 plan 03 (UI-02): the popup's surface id (matches the legacy:popup // agent synthesized by ensureLegacyPopupAgent below). When the active tab is @@ -1185,4 +1185,4 @@ console.log(`FSB v${chrome.runtime.getManifest().version} chat interface loaded` // default: // return schedule.type; // } -// } \ No newline at end of file +// } diff --git a/extension/ui/sidepanel.js b/extension/ui/sidepanel.js index bf39c7972..81c7962f6 100644 --- a/extension/ui/sidepanel.js +++ b/extension/ui/sidepanel.js @@ -1,4 +1,4 @@ -// Side Panel Script for FSB v0.9.90 - Persistent UI +// Side Panel Script for FSB v0.9.91 - Persistent UI // Phase 243 plan 03 (UI-02): the sidepanel's surface id (matches the // legacy:sidepanel agent synthesized by ensureLegacySidepanelAgent below). diff --git a/extension/utils/action-verification.js b/extension/utils/action-verification.js index 9f416443d..be644d626 100644 --- a/extension/utils/action-verification.js +++ b/extension/utils/action-verification.js @@ -1,4 +1,4 @@ -// Action Verification Module for FSB v0.9.90 +// Action Verification Module for FSB v0.9.91 // Provides post-action verification to ensure actions have their intended effects // // Phase 245 (v0.9.60) added the change_report builder pipeline: diff --git a/extension/utils/automation-logger.js b/extension/utils/automation-logger.js index 796d94de1..bd60faa81 100644 --- a/extension/utils/automation-logger.js +++ b/extension/utils/automation-logger.js @@ -7,7 +7,7 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { globalThis.__FSB_AUTOMATION_LOGGER_LOADED__ = true; console.log('[FSB] automation-logger.js loading'); - // Automation Logger for FSB v0.9.90 + // Automation Logger for FSB v0.9.91 // Provides structured logging for debugging automation loops function filterPersistedSessionLogs(sessionLogs) { @@ -17,6 +17,23 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { }); } + const SESSION_HISTORY_CAP_PER_MODE = 50; + + function capPersistedSessionHistory(sessionIndex, sessionStorage) { + const counts = { autopilot: 0, mcp: 0 }; + return (sessionIndex || []).filter(entry => { + const storedSession = entry?.id ? sessionStorage?.[entry.id] : null; + const mode = storedSession ? storedSession.mode : entry?.mode; + const bucket = mode === 'mcp-agent' ? 'mcp' : 'autopilot'; + if (counts[bucket] < SESSION_HISTORY_CAP_PER_MODE) { + counts[bucket]++; + return true; + } + if (entry?.id) delete sessionStorage[entry.id]; + return false; + }); + } + function getPersistedCommandList(sessionData = {}, fallbackTask = '') { const commands = Array.isArray(sessionData.commands) ? sessionData.commands.filter(command => typeof command === 'string' && command.trim().length > 0) @@ -54,7 +71,7 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { const normalizedStatus = typeof status === 'string' ? status.trim().toLowerCase() : ''; if (!normalizedStatus) return 'success'; if (normalizedStatus === 'partial') return 'partial'; - if (normalizedStatus === 'stopped') return 'stopped'; + if (normalizedStatus === 'stopped' || normalizedStatus === 'expired') return 'stopped'; if (normalizedStatus === 'error' || normalizedStatus === 'failed' || normalizedStatus === 'stuck' || normalizedStatus === 'replay_failed') { return 'failure'; } @@ -140,6 +157,19 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { }; } + function applyPersistedOutcomeFields(target, status, normalized) { + if (!target || !normalized) return target; + target.status = status || target.status || 'completed'; + target.outcome = normalized.outcome; + target.outcomeDetails = normalized.outcomeDetails; + target.result = normalized.result; + target.completionMessage = normalized.completionMessage; + target.error = normalized.error; + target.blocker = normalized.blocker; + target.nextStep = normalized.nextStep; + return target; + } + function hydratePersistedSessionRecord(sessionId, sessionData = {}) { if (!sessionData || typeof sessionData !== 'object') return null; const normalized = normalizePersistedOutcomeFields(sessionData, sessionData); @@ -187,6 +217,21 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { this.storageMode = 'full'; this.actionRecords = []; this._persistTimer = null; + // Session history and MCP-retention both mutate the same three storage + // keys. Keep every read-modify-write cycle on one chain so simultaneous + // MCP closes cannot replace each other's saves. + this._sessionMutationLock = Promise.resolve(); + } + + _withSessionMutationLock(fn) { + const next = this._sessionMutationLock.then(fn, fn); + this._sessionMutationLock = next.catch(() => {}); + return next; + } + + withSessionMutationLock(fn) { + if (typeof fn !== 'function') return Promise.resolve(undefined); + return this._withSessionMutationLock(fn); } log(level, message, data = null) { @@ -655,7 +700,11 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { } } - async persistLogs() { + persistLogs() { + return this.withSessionMutationLock(() => this._persistLogsUnlocked()); + } + + async _persistLogsUnlocked() { // Guard against invalidated extension context (service worker killed mid-timer) if (!chrome.runtime?.id) return; try { @@ -700,7 +749,11 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { }; } - async saveSession(sessionId, sessionData = {}) { + saveSession(sessionId, sessionData = {}) { + return this.withSessionMutationLock(() => this._saveSessionUnlocked(sessionId, sessionData)); + } + + async _saveSessionUnlocked(sessionId, sessionData = {}) { // Guard against invalidated extension context if (!chrome.runtime?.id) return false; try { @@ -724,7 +777,6 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { existing.logs = filterPersistedSessionLogs(existing.logs.concat(newLogs)); } existing.endTime = Date.now(); - existing.status = sessionData.status || existing.status; existing.actionCount = sessionData.actionHistory?.length || existing.actionCount; existing.iterationCount = sessionData.iterationCount || existing.iterationCount; existing.conversationId = metadata.conversationId; @@ -740,13 +792,7 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { // Quick 260707-7id: session source discriminator + MCP client label existing.mode = sessionData.mode || existing.mode || 'autopilot'; existing.mcpClient = sessionData.mcpClient || existing.mcpClient || null; - existing.outcome = normalizedOutcome.outcome; - existing.outcomeDetails = normalizedOutcome.outcomeDetails; - existing.result = normalizedOutcome.result; - existing.completionMessage = normalizedOutcome.completionMessage; - existing.error = normalizedOutcome.error; - existing.blocker = normalizedOutcome.blocker; - existing.nextStep = normalizedOutcome.nextStep; + applyPersistedOutcomeFields(existing, sessionData.status || existing.status, normalizedOutcome); // Update task to show the latest command if (metadata.commands.length > 1) { existing.task = metadata.commands.map((cmd, i) => `[${i + 1}] ${cmd}`).join(' | '); @@ -837,15 +883,23 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { const existingIndex = sessionIndex.findIndex(s => s.id === sessionId); if (existingIndex !== -1) sessionIndex[existingIndex] = indexEntry; else sessionIndex.unshift(indexEntry); - if (sessionIndex.length > 50) { - const toRemove = sessionIndex.slice(50); - toRemove.forEach(entry => delete sessionStorage[entry.id]); - sessionIndex.length = 50; - } - await chrome.storage.local.set({ fsbSessionLogs: sessionStorage, fsbSessionIndex: sessionIndex }); + const retainedSessionIndex = capPersistedSessionHistory(sessionIndex, sessionStorage); + await chrome.storage.local.set({ + fsbSessionLogs: sessionStorage, + fsbSessionIndex: retainedSessionIndex + }); // Persist DOM snapshots to dedicated storage key - await this._persistDOMSnapshots(sessionId, sessionIndex); + await this._persistDOMSnapshots(sessionId, retainedSessionIndex); + + if (savedSession.mode === 'mcp-agent') { + let retentionDays = 30; + try { + const policy = await chrome.storage.local.get('fsbMcpSessionRetentionDays'); + retentionDays = policy?.fsbMcpSessionRetentionDays; + } catch (_policyError) { /* save succeeded; prune with the default */ } + await this._pruneMcpSessionsUnlocked(retentionDays); + } console.log(`[FSB Logger] Session ${sessionId} saved with ${savedSession.logs?.length || 0} total logs, ${snapshotCount} DOM snapshots`); return true; @@ -857,6 +911,104 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { } } + pruneMcpSessions(retentionDays = 30) { + return this.withSessionMutationLock(() => this._pruneMcpSessionsUnlocked(retentionDays)); + } + + updateSessionOutcome(sessionId, sessionData = {}) { + return this.withSessionMutationLock(() => this._updateSessionOutcomeUnlocked(sessionId, sessionData)); + } + + async _updateSessionOutcomeUnlocked(sessionId, sessionData = {}) { + if (!chrome.runtime?.id || typeof sessionId !== 'string' || !sessionId) return false; + try { + const stored = await chrome.storage.local.get(['fsbSessionLogs', 'fsbSessionIndex']); + const sessionStorage = stored.fsbSessionLogs || {}; + const sessionIndex = Array.isArray(stored.fsbSessionIndex) ? stored.fsbSessionIndex : []; + const session = sessionStorage[sessionId]; + if (!session || typeof session !== 'object') return false; + + const status = getPersistedTextValue(sessionData.status, session.status) || 'completed'; + const normalized = normalizePersistedOutcomeFields(sessionData, session); + applyPersistedOutcomeFields(session, status, normalized); + + const indexEntry = sessionIndex.find(entry => entry?.id === sessionId); + if (indexEntry) applyPersistedOutcomeFields(indexEntry, status, normalized); + + await chrome.storage.local.set({ + fsbSessionLogs: sessionStorage, + fsbSessionIndex: sessionIndex + }); + return true; + } catch (error) { + if (chrome.runtime?.id) { + console.error('[FSB Logger] Failed to update session outcome:', error); + } + return false; + } + } + + async _pruneMcpSessionsUnlocked(retentionDays = 30) { + if (!chrome.runtime?.id) return { removed: 0, ids: [] }; + try { + let days = typeof retentionDays === 'number' ? retentionDays : parseInt(retentionDays, 10); + if (!Number.isFinite(days)) days = 30; + days = Math.min(365, Math.max(1, Math.floor(days))); + const cutoff = Date.now() - (days * 24 * 60 * 60 * 1000); + const stored = await chrome.storage.local.get([ + 'fsbSessionLogs', + 'fsbSessionIndex', + 'fsbDOMSnapshots', + 'automationLogs' + ]); + const sessionStorage = stored.fsbSessionLogs || {}; + const sessionIndex = Array.isArray(stored.fsbSessionIndex) ? stored.fsbSessionIndex : []; + const allSnapshots = stored.fsbDOMSnapshots || {}; + const persistedAutomationLogs = Array.isArray(stored.automationLogs) ? stored.automationLogs : []; + const indexById = new Map(sessionIndex.map(entry => [entry?.id, entry])); + const candidateIds = new Set([...Object.keys(sessionStorage), ...indexById.keys()]); + const expiredIds = []; + + for (const id of candidateIds) { + if (!id) continue; + // The full log entry is authoritative when both stores exist. This + // guarantees a stale index badge can never delete Autopilot history. + const record = sessionStorage[id] || indexById.get(id); + if (!record || record.mode !== 'mcp-agent') continue; + const rawTimestamp = record.endTime ?? record.startTime; + const timestamp = typeof rawTimestamp === 'number' ? rawTimestamp : Date.parse(rawTimestamp); + if (Number.isFinite(timestamp) && timestamp <= cutoff) expiredIds.push(id); + } + + if (expiredIds.length === 0) return { removed: 0, ids: [] }; + const expiredSet = new Set(expiredIds); + for (const id of expiredIds) { + delete sessionStorage[id]; + delete allSnapshots[id]; + if (this._domSnapshots) delete this._domSnapshots[id]; + } + const retainedIndex = sessionIndex.filter(entry => !expiredSet.has(entry?.id)); + const retainedAutomationLogs = persistedAutomationLogs.filter( + log => !expiredSet.has(log?.data?.sessionId) + ); + // Keep the in-memory source of future debounced persists in sync so a + // timer queued after this prune cannot resurrect expired raw rows. + this.logs = this.logs.filter(log => !expiredSet.has(log?.data?.sessionId)); + await chrome.storage.local.set({ + fsbSessionLogs: sessionStorage, + fsbSessionIndex: retainedIndex, + fsbDOMSnapshots: allSnapshots, + automationLogs: retainedAutomationLogs + }); + return { removed: expiredIds.length, ids: expiredIds }; + } catch (error) { + if (chrome.runtime?.id) { + console.error('[FSB Logger] Failed to prune MCP sessions:', error); + } + return { removed: 0, ids: [] }; + } + } + async _persistDOMSnapshots(sessionId, sessionIndex) { // Guard against invalidated extension context if (!chrome.runtime?.id) return; @@ -927,7 +1079,11 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { } } - async deleteSession(sessionId) { + deleteSession(sessionId) { + return this.withSessionMutationLock(() => this._deleteSessionUnlocked(sessionId)); + } + + async _deleteSessionUnlocked(sessionId) { // Guard against invalidated extension context if (!chrome.runtime?.id) return false; try { @@ -981,7 +1137,11 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { return lines.join('\n'); } - async clearAllSessions() { + clearAllSessions() { + return this.withSessionMutationLock(() => this._clearAllSessionsUnlocked()); + } + + async _clearAllSessionsUnlocked() { // Guard against invalidated extension context if (!chrome.runtime?.id) return false; try { diff --git a/extension/utils/google-sheets-session.js b/extension/utils/google-sheets-session.js index e38618620..89cf565e0 100644 --- a/extension/utils/google-sheets-session.js +++ b/extension/utils/google-sheets-session.js @@ -4,6 +4,8 @@ var SHEETS_ORIGIN = 'https://docs.google.com'; var SHEETS_API_BASE = 'https://sheets.googleapis.com/v4'; var REQUEST_TIMEOUT_MS = 15000; + var MAX_GET_VALUES_CELLS = 100000; + var MAX_RESPONSE_BODY_BYTES = 5 * 1024 * 1024; var SPREADSHEET_ID_RE = /^[A-Za-z0-9_-]{10,200}$/; var SHEETS_URL_RE = /^\/spreadsheets\/d\/([A-Za-z0-9_-]{10,200})(?:\/|$)/; var MUTATIONS = { updateValues: true, appendValues: true, clearValues: true }; @@ -12,6 +14,9 @@ GOOGLE_SHEETS_ACTIVE_TAB_REQUIRED: 'Open the target spreadsheet in the agent-owned Google Sheets tab.', GOOGLE_SHEETS_TARGET_MISMATCH: 'The requested spreadsheet does not match the agent-owned Google Sheets tab.', GOOGLE_SHEETS_SESSION_UNAVAILABLE: 'The signed-in Google Sheets page session is unavailable.', + GOOGLE_SHEETS_INVALID_ARGUMENT: 'A Google Sheets request argument is invalid.', + GOOGLE_SHEETS_REQUEST_TOO_LARGE: 'The Google Sheets request is too large.', + GOOGLE_SHEETS_RESPONSE_TOO_LARGE: 'The Google Sheets API response was too large.', RECIPE_DOM_FALLBACK_PENDING: 'The requested Sheets operation is not safely available through the UI fallback.', RECOVERY_AMBIGUOUS: 'The Sheets mutation may have taken effect. It was not retried.' }; @@ -68,6 +73,62 @@ if (!isFinite(status)) { status = Number(failure && failure.result && failure.result.error && failure.result.error.code); } return isFinite(status) ? status : 0; } + function byteLength(value) { + var text = String(value || ''); + if (typeof TextEncoder !== 'undefined') { return new TextEncoder().encode(text).byteLength; } + return unescape(encodeURIComponent(text)).length; + } + function splitRangeAddress(value) { + var input = String(value || '').trim(); + var bang = -1; + var quoted = false; + for (var index = 0; index < input.length; index++) { + if (input[index] === "'") { + if (quoted && input[index + 1] === "'") { index++; continue; } + quoted = !quoted; + } else if (input[index] === '!' && !quoted) { + bang = index; + } + } + return quoted ? '' : (bang === -1 ? input : input.slice(bang + 1)); + } + function columnNumber(value) { + var column = String(value || '').toUpperCase(); + if (!/^[A-Z]{1,3}$/.test(column)) { return 0; } + var number = 0; + for (var index = 0; index < column.length; index++) { + number = number * 26 + column.charCodeAt(index) - 64; + } + return number <= 18278 ? number : 0; + } + function validateGetValuesRange(value) { + var range = String(value || '').trim(); + if (!range || range.length > 500) { + return { code: 'GOOGLE_SHEETS_INVALID_ARGUMENT', reason: 'invalid-get-values-range' }; + } + var address = splitRangeAddress(range).replace(/\$/g, ''); + var match = address.match(/^([A-Za-z]+)(\d+)(?::([A-Za-z]+)(\d+))?$/); + if (!match) { + return { + code: /^[A-Za-z]+(?::[A-Za-z]+)?$/.test(address) + ? 'GOOGLE_SHEETS_REQUEST_TOO_LARGE' + : 'GOOGLE_SHEETS_INVALID_ARGUMENT', + reason: 'get-values-range-must-be-bounded' + }; + } + var startColumn = columnNumber(match[1]); + var endColumn = columnNumber(match[3] || match[1]); + var startRow = Number(match[2]); + var endRow = Number(match[4] || match[2]); + if (!startColumn || !endColumn || !Number.isSafeInteger(startRow) || !Number.isSafeInteger(endRow) || + startRow < 1 || endRow > 10000000 || endColumn < startColumn || endRow < startRow) { + return { code: 'GOOGLE_SHEETS_INVALID_ARGUMENT', reason: 'invalid-get-values-range' }; + } + var cells = (endColumn - startColumn + 1) * (endRow - startRow + 1); + return cells > 100000 + ? { code: 'GOOGLE_SHEETS_REQUEST_TOO_LARGE', reason: 'get-values-range-limit-exceeded', cells: cells } + : null; + } function responseData(response) { if (response && response.result && typeof response.result === 'object') { return response.result; } if (response && typeof response.body === 'string' && response.body) { @@ -106,6 +167,8 @@ if (operation === 'getSpreadsheet') { params.fields = 'spreadsheetId,properties(title,locale,timeZone),sheets(properties(sheetId,title,index,sheetType,gridProperties))'; } else if (operation === 'getValues') { + var rangeError = validateGetValuesRange(args.range); + if (rangeError) { return error(rangeError.code, rangeError); } path += '/values/' + encode(args.range); if (args.majorDimension) { params.majorDimension = args.majorDimension; } if (args.valueRenderOption) { params.valueRenderOption = args.valueRenderOption; } @@ -208,10 +271,24 @@ requestSent: true }); } + var data; + var serialized; + if (response && typeof response.body === 'string' && byteLength(response.body) > 5 * 1024 * 1024) { + serialized = null; + } else { + data = responseData(response); + try { serialized = JSON.stringify(data); } catch (_serializationError) { serialized = null; } + } + if (serialized === null || byteLength(serialized) > 5 * 1024 * 1024) { + return error(mutating ? 'RECOVERY_AMBIGUOUS' : 'GOOGLE_SHEETS_RESPONSE_TOO_LARGE', { + reason: 'page-gapi-response-limit-exceeded', + requestSent: true + }); + } return { success: true, status: responseStatus, - data: responseData(response), + data: data, transport: 'page-client' }; })(); @@ -377,7 +454,9 @@ session.constants = Object.freeze({ origin: SHEETS_ORIGIN, apiBaseUrl: SHEETS_API_BASE, - requestTimeoutMs: REQUEST_TIMEOUT_MS + requestTimeoutMs: REQUEST_TIMEOUT_MS, + maxGetValuesCells: MAX_GET_VALUES_CELLS, + maxResponseBodyBytes: MAX_RESPONSE_BODY_BYTES }); Object.freeze(session); global.FsbGoogleSheetsSession = session; diff --git a/extension/utils/google-sheets-ui.js b/extension/utils/google-sheets-ui.js index 42fd3fbaf..de3201aaa 100644 --- a/extension/utils/google-sheets-ui.js +++ b/extension/utils/google-sheets-ui.js @@ -3,6 +3,7 @@ var MAX_READ_ROWS = 50; var MAX_READ_COLUMNS = 26; + var MAX_READ_CELLS = 100; var MAX_UI_CELLS = 10000; var MAX_CLEAR_CELLS = 100; var MAX_CHUNK_CELLS = 5000; @@ -294,6 +295,7 @@ limits: Object.freeze({ maxReadRows: MAX_READ_ROWS, maxReadColumns: MAX_READ_COLUMNS, + maxReadCells: MAX_READ_CELLS, maxUiCells: MAX_UI_CELLS, maxClearCells: MAX_CLEAR_CELLS, maxChunkCells: MAX_CHUNK_CELLS, diff --git a/extension/utils/mcp-session-recorder.js b/extension/utils/mcp-session-recorder.js index e9ffeddb2..0a621c654 100644 --- a/extension/utils/mcp-session-recorder.js +++ b/extension/utils/mcp-session-recorder.js @@ -11,28 +11,28 @@ * tap lives where all three routes converge. * - Dispatcher message-route hook: dispatchMcpMessageRoute's finally block * in extension/ws/mcp-tool-dispatcher.js calls recordDispatch() so - * read-only/message traffic JOINs open sessions by agentId. + * read-only/message traffic JOINs an exact agentId+tabId session when a + * tab identity is supplied, with agent-only recency fallback otherwise. * Each recorded call folds one resolved MCP dispatch into an in-memory * open-session record keyed agentId+tabId; the visualSession sidecar drives * the lifecycle (birth on first action tool, close on isFinal or 60s idle). * - * On close the assembled session flows through DIRECT service-worker - * globals -- NEVER chrome.runtime.sendMessage, which does not loop back - * inside the SW: - * - globalThis.automationLogger.saveSession(sessionId, session) -- the - * fsbSessionLogs/fsbSessionIndex history store (mode + mcpClient badge - * fields carried by this task's automation-logger.js change). - * - extractAndStoreMemories(sessionId, session) -- background.js memory - * handoff; verified to tolerate a missing AI instance. - * - createSession(overrides) -- ai/session-schema.js factory when present - * (SW runtime); a manual same-keys object under bare Node. + * On close the assembled session flows through the DIRECT service-worker + * automationLogger global -- NEVER chrome.runtime.sendMessage, which does + * not loop back inside the SW -- for history/replay. Long-term memory is + * created only when the MCP client later supplies a terminal summary through + * complete_task / partial_task / fail_task; recorder close never invokes an + * AI provider. createSession(overrides) remains the runtime schema factory, + * with a manual same-keys object under bare Node. * * Persisted actionHistory entries are {tool, params, result, timestamp} so * the existing replay engine (background.js loadReplayableSession / - * executeReplaySequence) consumes MCP sessions unmodified. params get a - * KEY-TARGETED sensitive-value redaction (password/secret/token/credential/ - * api-key/authorization -- threat T-q7id-01) while replay-critical values - * (url, selector, text) persist raw. + * executeReplaySequence) consumes MCP sessions unmodified. Params and + * results are cloned and recursively redacted under secret-bearing keys; + * text/value are additionally redacted when field metadata identifies a + * sensitive target. Ordinary replay-critical url/selector/text values remain + * exact. Users can disable future MCP recording and configure MCP-only + * retention in Advanced Settings; the default retention window is 30 days. * * Open sessions survive MV3 SW eviction via a chrome.storage.session * versioned envelope (key fsbMcpSessionBuffer, v1 -- envelope pattern @@ -66,12 +66,26 @@ var FSB_MCP_SESSION_BUFFER_KEY = 'fsbMcpSessionBuffer'; var FSB_MCP_SESSION_BUFFER_VERSION = 1; + var FSB_MCP_MEMORY_CANDIDATES_KEY = 'fsbMcpMemoryCandidates'; + var FSB_MCP_MEMORY_CANDIDATES_VERSION = 1; + var FSB_MCP_REDACTION_VERSION_KEY = 'fsbMcpRedactionVersion'; + var FSB_MCP_REDACTION_VERSION = 3; + var FSB_MCP_SESSION_ALARM_PREFIX = 'fsbMcpSession:'; + var FSB_MCP_SESSION_IDLE_ALARM_PREFIX = FSB_MCP_SESSION_ALARM_PREFIX + 'idle:'; + var FSB_MCP_SESSION_RETENTION_ALARM = FSB_MCP_SESSION_ALARM_PREFIX + 'retention'; + var FSB_MCP_RECORDING_ENABLED_KEY = 'fsbMcpSessionRecordingEnabled'; + var FSB_MCP_RETENTION_DAYS_KEY = 'fsbMcpSessionRetentionDays'; + var FSB_MCP_RETENTION_DEFAULT_DAYS = 30; + var FSB_MCP_RETENTION_MIN_DAYS = 1; + var FSB_MCP_RETENTION_MAX_DAYS = 365; + var FSB_MCP_RETENTION_ALARM_PERIOD_MINUTES = 24 * 60; + var MCP_MEMORY_CANDIDATE_TTL_MS = 5 * 60 * 1000; + var MCP_MEMORY_CANDIDATE_CAP = 50; // Mirrors MCP_VISUAL_LIFECYCLE_DEATH_MS // (extension/utils/mcp-visual-session-lifecycle.js:79) -- a sliding 60s - // idle window re-armed on every recorded dispatch. Deliberately NOT read - // from that module: the recorder keeps its OWN timer so the two - // lifecycles stay decoupled. + // idle window re-armed on every recorded dispatch. The recorder owns a + // separate chrome.alarms namespace so expiry wakes an evicted MV3 worker. var MCP_SESSION_IDLE_DEATH_MS = 60000; // In-memory actionHistory cap -- matches the saveSession persistence cap @@ -79,12 +93,55 @@ // long-lived agent session. var MCP_SESSION_ACTION_HISTORY_CAP = 100; - // Key-targeted redaction pattern (threat T-q7id-01). VALUES of matching - // keys are replaced before any persist; everything else persists raw for - // replay fidelity. Note ownershipToken never enters actionHistory because - // only payload.params is recorded, but the pattern also catches a - // params-level token if one ever appears. - var SENSITIVE_KEY_PATTERN = /pass(word)?|secret|token|credential|api[-_]?key|authorization/i; + // Values under these keys are never useful for replay and must not enter + // the buffer, history, raw logs, or long-term memory. + var SENSITIVE_KEY_PATTERN = /pass(word)?|secret|token|credential|api[-_ ]?key|authorization|cookie|session[-_ ]?id|private[-_ ]?key/i; + // Generic text/value params and content-action result echoes remain + // replayable unless their surrounding target metadata identifies a + // credential or payment-secret field. + var SENSITIVE_TARGET_PATTERN = /pass(word)?|secret|token|credential|api[-_ ]?key|authorization|auth[-_ ]?code|one[-_ ]?time|otp|cvv|cvc|(?:^|[^a-z0-9])cc[-_ ]?(?:number|csc|cvc|cvv)(?:$|[^a-z0-9])|card[-_ ]?(number|no)|security[-_ ]?code/i; + // PIN needs token semantics: a bare alternation would also match ordinary + // targets such as "shipping", "shopping", and "opinion". Camel-case and + // acronym boundaries are split before applying non-letter boundaries so + // paymentPin/PINCode remain sensitive alongside pin, pin_code, and pin-code. + var SENSITIVE_PIN_TOKEN_PATTERN = /(?:^|[^a-z])pin(?:$|[^a-z])/i; + var SENSITIVE_TARGET_METADATA_KEY_PATTERN = /selector|field|name|input|target|autocomplete|type|label|placeholder|element/i; + var SENSITIVE_TEXT_KEY_PATTERN = /^(text|value|typed|actualValue|expectedValue|finalTextContent|previousValue)$/i; + var REDACTED_VALUE = '[REDACTED]'; + var SENSITIVE_URL_PARAM_NAMES = Object.freeze({ + code: 1, + auth: 1, + key: 1, + signature: 1, + sig: 1, + sign: 1, + hash: 1, + hmac: 1, + jwt: 1, + policy: 1, + session: 1, + sid: 1, + awsaccesskeyid: 1, + 'key-pair-id': 1, + googleaccessid: 1 + }); + var SENSITIVE_URL_PARAM_PREFIXES = ['x-amz-', 'x-goog-']; + var AZURE_SAS_PARAM_NAMES = Object.freeze({ + sig: 1, se: 1, sp: 1, sv: 1, sr: 1, st: 1, skoid: 1, sktid: 1, + skt: 1, ske: 1, sks: 1, skv: 1, spr: 1, sip: 1, ss: 1, srt: 1 + }); + // Keep this deliberately narrow and aligned with network-capture-redactor.js: + // recognizable credential prefixes are safe to mask without treating ordinary + // long slugs or IDs as secrets. + var URL_TOKEN_SHAPES = [ + /^eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+/, + /^(sk|pk|rk)_(live|test)_[A-Za-z0-9]{8,}/, + /^gh[opsur]_[A-Za-z0-9]{20,}/, + /^xox[bcpars]-[A-Za-z0-9-]{8,}/, + /^(AKIA|ASIA)[A-Z0-9]{16}/, + /^ya29\.[A-Za-z0-9_-]+/, + /^u![A-Za-z0-9_-]+/ + ]; // Wire-verb -> legacy replay-name map. The replay engine's whitelist // (background.js loadReplayableSession replayableTools) speaks the content @@ -104,25 +161,37 @@ // key = agentId + '::' + tabKey; value = open-session record. var _openSessions = new Map(); - // key -> idle-timer handle (kept out of the session record so records - // serialize cleanly into the storage envelope). - var _idleTimers = new Map(); + // key = source session id; value = safe, short-lived correlation metadata. + var _memoryCandidates = new Map(); // Monotonic guard for the autopilot-format session id (session_): // same-ms births within THIS recorder get last+1. Cross-engine same-ms // collision accepted per locked design. var _lastGeneratedSessionTs = 0; + var _recordingEnabled = true; + var _retentionDays = FSB_MCP_RETENTION_DEFAULT_DAYS; + var _initializationPromise = Promise.resolve(); + var _recordQueue = Promise.resolve(); // ---- Test seams (mirroring mcp-metrics-recorder.js) --------------------- var _storageShim = null; + var _localStorageShim = null; + var _alarmShim = null; var _timeShim = null; function _setStorageShim(shim) { _storageShim = shim; } - // shim = { now, setTimeout, clearTimeout } (any subset). Pass null to - // restore real time. + function _setLocalStorageShim(shim) { + _localStorageShim = shim; + } + + function _setAlarmShim(shim) { + _alarmShim = shim; + } + + // shim = { now }. Pass null to restore real time. function _setTimeShim(shim) { _timeShim = shim || null; } @@ -132,16 +201,6 @@ return Date.now(); } - function _setIdleTimeout(fn, ms) { - if (_timeShim && typeof _timeShim.setTimeout === 'function') return _timeShim.setTimeout(fn, ms); - return setTimeout(fn, ms); - } - - function _clearIdleTimeout(handle) { - if (_timeShim && typeof _timeShim.clearTimeout === 'function') return _timeShim.clearTimeout(handle); - return clearTimeout(handle); - } - // ---- Lazy global accessors ---------------------------------------------- function _getChrome() { @@ -155,57 +214,380 @@ return null; } + function _resolveLocalStorage() { + if (_localStorageShim) return _localStorageShim; + var c = _getChrome(); + if (c && c.storage && c.storage.local) return c.storage.local; + return null; + } + + function _resolveAlarms() { + if (_alarmShim) return _alarmShim; + var c = _getChrome(); + if (c && c.alarms) return c.alarms; + return null; + } + function _getAutomationLogger() { return (typeof globalThis !== 'undefined' && globalThis.automationLogger) ? globalThis.automationLogger : null; } - // ---- Redaction ----------------------------------------------------------- + // ---- Replay-value cloning + redaction ------------------------------------ + + // Clone through the same JSON-compatible boundary chrome.storage uses. + function cloneReplayValue(value, fallback) { + var clone; + try { + clone = JSON.parse(JSON.stringify(value === undefined ? null : value)); + } catch (_e) { + return fallback; + } + if (clone === null && fallback !== null) return fallback; + return clone; + } + + function _keyLooksUrlLike(key) { + if (typeof key !== 'string') return false; + return /^(url|uri|href)$/i.test(key) || + /(?:Url|URL|Uri|URI|Href|HREF)$/.test(key) || + /(?:^|[_-])(?:url|uri|href)$/i.test(key); + } + + function _urlValueLooksTokenShaped(value) { + if (typeof value !== 'string' || value.length === 0) return false; + var candidate = value; + try { candidate = decodeURIComponent(value); } catch (_e) { /* use the raw value */ } + for (var i = 0; i < URL_TOKEN_SHAPES.length; i++) { + if (URL_TOKEN_SHAPES[i].test(candidate)) return true; + } + return false; + } + + function _isSensitiveUrlParamName(name) { + if (typeof name !== 'string' || name.length === 0) return false; + var lower = name.toLowerCase(); + if (SENSITIVE_KEY_PATTERN.test(lower) || SENSITIVE_URL_PARAM_NAMES[lower] === 1) return true; + for (var i = 0; i < SENSITIVE_URL_PARAM_PREFIXES.length; i++) { + if (lower.indexOf(SENSITIVE_URL_PARAM_PREFIXES[i]) === 0) return true; + } + return false; + } + + function _sensitiveUrlQueryKeys(searchParams) { + var entries = []; + searchParams.forEach(function (value, key) { + entries.push({ key: key, lowerKey: String(key).toLowerCase(), value: value }); + }); + var hasAzureSignature = entries.some(function (entry) { return entry.lowerKey === 'sig'; }); + var keys = []; + for (var i = 0; i < entries.length; i++) { + var entry = entries[i]; + if (_isSensitiveUrlParamName(entry.key) || + _urlValueLooksTokenShaped(entry.value) || + (hasAzureSignature && AZURE_SAS_PARAM_NAMES[entry.lowerKey] === 1)) { + keys.push(entry.key); + } + } + return keys; + } + + function _fragmentLooksSensitive(hash) { + if (typeof hash !== 'string' || hash.length < 2) return false; + var body = hash.charAt(0) === '#' ? hash.slice(1) : hash; + var decoded = body; + try { decoded = decodeURIComponent(body); } catch (_e) { /* use the raw fragment */ } + var candidates = decoded === body ? [body] : [body, decoded]; + for (var i = 0; i < candidates.length; i++) { + var candidate = candidates[i]; + var segments = candidate.split(/[\/?#&=]/); + for (var s = 0; s < segments.length; s++) { + if (_urlValueLooksTokenShaped(segments[s])) return true; + } + var queryCandidates = [candidate]; + var queryIndex = candidate.indexOf('?'); + if (queryIndex !== -1) queryCandidates.push(candidate.slice(queryIndex + 1)); + for (var q = 0; q < queryCandidates.length; q++) { + if (queryCandidates[q].indexOf('=') === -1) continue; + try { + if (_sensitiveUrlQueryKeys(new URLSearchParams(queryCandidates[q])).length > 0) return true; + } catch (_e2) { /* keep inspecting other fragment forms */ } + } + } + return false; + } - // Replace the VALUE under a sensitive key. Uses the shape-only - // globalThis.redactForLog helper when available (lazy guard exactly like - // audit-log.js), else the literal '[REDACTED]'. NEVER shape-redacts whole - // params -- that would destroy replay fidelity. - function _redactValue(value) { + function _sanitizeUrlForPersistence(url) { + if (typeof url !== 'string' || url.length === 0) return null; + var parsed; try { - if (typeof globalThis !== 'undefined' && typeof globalThis.redactForLog === 'function') { - return globalThis.redactForLog(value); + parsed = new URL(url); + } catch (_e) { + return null; + } + + var changed = false; + if (parsed.username || parsed.password) { + parsed.username = ''; + parsed.password = ''; + changed = true; + } + + var sensitiveQueryKeys = _sensitiveUrlQueryKeys(parsed.searchParams); + for (var i = 0; i < sensitiveQueryKeys.length; i++) { + parsed.searchParams.delete(sensitiveQueryKeys[i]); + changed = true; + } + + if (_fragmentLooksSensitive(parsed.hash)) { + parsed.hash = ''; + changed = true; + } + + var pathSegments = parsed.pathname.split('/'); + for (var p = 0; p < pathSegments.length; p++) { + if (_urlValueLooksTokenShaped(pathSegments[p])) { + pathSegments[p] = ':token'; + changed = true; + } + } + if (changed) parsed.pathname = pathSegments.join('/'); + + // Preserve benign URLs byte-for-byte. URL#toString is used only after a + // redaction, where normalization cannot reduce replay fidelity further than + // removal of the credential itself already does. + return changed ? parsed.toString() : url; + } + + function _targetMetadataLooksSensitive(value) { + if (SENSITIVE_TARGET_PATTERN.test(value)) return true; + var tokenized = value + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2'); + return SENSITIVE_PIN_TOKEN_PATTERN.test(tokenized); + } + + function _targetLooksSensitive(node, depth) { + if (!node || typeof node !== 'object' || depth > 6) return false; + if (Array.isArray(node)) { + for (var i = 0; i < node.length; i++) { + if (_targetLooksSensitive(node[i], depth + 1)) return true; + } + return false; + } + var keys = Object.keys(node); + for (var k = 0; k < keys.length; k++) { + var key = keys[k]; + var value = node[key]; + if (SENSITIVE_KEY_PATTERN.test(key)) return true; + if (SENSITIVE_TARGET_METADATA_KEY_PATTERN.test(key) && + typeof value === 'string' && _targetMetadataLooksSensitive(value)) { + return true; } - } catch (_e) { /* fall through to literal */ } - return '[REDACTED]'; + if (value && typeof value === 'object' && _targetLooksSensitive(value, depth + 1)) return true; + } + return false; } - function _redactSensitiveKeysInPlace(node) { + function _redactSensitiveValuesInPlace(node, sensitiveTarget) { if (!node || typeof node !== 'object') return; if (Array.isArray(node)) { for (var i = 0; i < node.length; i++) { - _redactSensitiveKeysInPlace(node[i]); + _redactSensitiveValuesInPlace(node[i], sensitiveTarget); } return; } var keys = Object.keys(node); for (var k = 0; k < keys.length; k++) { var key = keys[k]; - if (SENSITIVE_KEY_PATTERN.test(key)) { - node[key] = _redactValue(node[key]); + if (SENSITIVE_KEY_PATTERN.test(key) || (sensitiveTarget && SENSITIVE_TEXT_KEY_PATTERN.test(key))) { + node[key] = REDACTED_VALUE; + } else if (_keyLooksUrlLike(key) && typeof node[key] === 'string') { + node[key] = _sanitizeUrlForPersistence(node[key]) || REDACTED_VALUE; } else { - _redactSensitiveKeysInPlace(node[key]); + _redactSensitiveValuesInPlace(node[key], sensitiveTarget); } } } - // Deep-clone via JSON round-trip (try/catch fallback to {}), then walk the - // clone replacing values under sensitive keys. url/selector/text persist - // raw -- the replay engine consumes recorded params unmodified. - function redactParams(params) { - var clone; + function _sanitizeReplayValue(value, fallback, sensitiveTarget) { + var clone = cloneReplayValue(value, fallback); + if (clone && typeof clone === 'object') { + _redactSensitiveValuesInPlace(clone, sensitiveTarget === true); + } + return clone; + } + + function cloneParamsForReplay(params) { + // Internal callers may supply a response as arguments[1] so opaque element + // references can inherit credential metadata without changing this exported + // helper's one-argument API shape. + var result = arguments.length > 1 ? arguments[1] : null; + var sensitiveTarget = _targetLooksSensitive(params, 0) || _targetLooksSensitive(result, 0); + var clone = _sanitizeReplayValue(params, {}, sensitiveTarget); + return (clone && typeof clone === 'object' && !Array.isArray(clone)) ? clone : {}; + } + + function cloneResultForReplay(result, params) { + var sensitiveTarget = _targetLooksSensitive(params, 0) || _targetLooksSensitive(result, 0); + return _sanitizeReplayValue(result, {}, sensitiveTarget); + } + + function _sanitizeActionHistory(history) { + if (!Array.isArray(history)) return []; + return history.slice(-MCP_SESSION_ACTION_HISTORY_CAP).map(function (entry) { + var item = (entry && typeof entry === 'object') ? entry : {}; + return { + tool: typeof item.tool === 'string' ? item.tool : '', + params: cloneParamsForReplay(item.params, item.result), + result: cloneResultForReplay(item.result, item.params), + timestamp: typeof item.timestamp === 'number' ? item.timestamp : null + }; + }); + } + + function _sanitizeRequestPayload(payload, result) { + var clone = _sanitizeReplayValue(payload, {}, false); + if (!clone || typeof clone !== 'object' || Array.isArray(clone)) clone = {}; + var sourceParams = payload && typeof payload.params === 'object' ? payload.params : {}; + clone.params = cloneParamsForReplay(sourceParams, result); + return clone; + } + + function _sanitizeSummaryText(value, maxLength) { + if (typeof value !== 'string') return ''; + var text = value.trim().slice(0, maxLength || 2000); + // Preserve the summary while masking common inline secret assignments and + // bearer credentials. Tool descriptions separately instruct clients not + // to include sensitive form values in the first place. + text = text.replace(/\b(password|passwd|secret|access[_ -]?token|refresh[_ -]?token|api[_ -]?key|authorization|credential|private[_ -]?key)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi, function (_match, label) { + return label + ': ' + REDACTED_VALUE; + }); + return text.replace(/\bBearer\s+[A-Za-z0-9._~+\/-]+=*/gi, 'Bearer ' + REDACTED_VALUE); + } + + function _sanitizeSummaryTextList(values, maxLength) { + if (!Array.isArray(values)) return []; + return values.map(function (value) { + return _sanitizeSummaryText(value, maxLength); + }).filter(Boolean); + } + + function _sanitizePersistedMcpSessionText(session) { + if (!session || typeof session !== 'object') return session; + session.task = _sanitizeSummaryText(session.task, 2000); + if (Array.isArray(session.visualReasons)) { + session.visualReasons = _sanitizeSummaryTextList(session.visualReasons, 2000); + } + + var fieldLimits = { + result: 2000, + completionMessage: 5000, + error: 1000, + blocker: 1000, + nextStep: 1000 + }; + Object.keys(fieldLimits).forEach(function (field) { + if (typeof session[field] === 'string') { + session[field] = _sanitizeSummaryText(session[field], fieldLimits[field]); + } + }); + + var details = session.outcomeDetails; + if (details && typeof details === 'object') { + var detailLimits = { + reason: 1000, + summary: 2000, + blocker: 1000, + nextStep: 1000, + result: 5000, + error: 1000 + }; + Object.keys(detailLimits).forEach(function (field) { + if (typeof details[field] === 'string') { + details[field] = _sanitizeSummaryText(details[field], detailLimits[field]); + } + }); + } + return session; + } + + // ---- Recording policy + initialization queue ---------------------------- + + function clampRetentionDays(value) { + var days = (typeof value === 'number') ? value : parseInt(value, 10); + if (!isFinite(days)) days = FSB_MCP_RETENTION_DEFAULT_DAYS; + days = Math.floor(days); + if (days < FSB_MCP_RETENTION_MIN_DAYS) return FSB_MCP_RETENTION_MIN_DAYS; + if (days > FSB_MCP_RETENTION_MAX_DAYS) return FSB_MCP_RETENTION_MAX_DAYS; + return days; + } + + async function _loadRecordingPolicy() { + var storage = _resolveLocalStorage(); + if (!storage || typeof storage.get !== 'function') { + _recordingEnabled = true; + _retentionDays = FSB_MCP_RETENTION_DEFAULT_DAYS; + return; + } try { - clone = JSON.parse(JSON.stringify(params === undefined ? null : params)); + var stored = await storage.get([FSB_MCP_RECORDING_ENABLED_KEY, FSB_MCP_RETENTION_DAYS_KEY]); + _recordingEnabled = !stored || stored[FSB_MCP_RECORDING_ENABLED_KEY] !== false; + _retentionDays = clampRetentionDays(stored && stored[FSB_MCP_RETENTION_DAYS_KEY]); } catch (_e) { - return {}; + _recordingEnabled = true; + _retentionDays = FSB_MCP_RETENTION_DEFAULT_DAYS; } - if (!clone || typeof clone !== 'object') return {}; - _redactSensitiveKeysInPlace(clone); - return clone; + } + + function _snapshotEntry(entry) { + var sourcePayload = (entry.requestPayload && typeof entry.requestPayload === 'object') + ? entry.requestPayload + : {}; + var sourceParams = (sourcePayload.params && typeof sourcePayload.params === 'object') + ? sourcePayload.params + : {}; + return { + client: entry.client, + tool: entry.tool, + requestPayload: _sanitizeRequestPayload(sourcePayload, entry.response), + response: cloneResultForReplay(entry.response, sourceParams), + success: entry.success, + dispatcher_route: entry.dispatcher_route, + tabId: entry.tabId + }; + } + + function _enqueueRecorderMutation(fn) { + var run = function () { + return Promise.resolve(_initializationPromise).catch(function () { /* initialize fail-open */ }).then(fn); + }; + var next = _recordQueue.then(run, run); + _recordQueue = next.catch(function () { /* keep queue alive */ }); + return next; + } + + function _closeAllOpenSessions(reason) { + var keys = Array.from(_openSessions.keys()); + for (var i = 0; i < keys.length; i++) closeSession(keys[i], reason); + } + + async function _requestRetentionPrune() { + var logger = _getAutomationLogger(); + if (!logger || typeof logger.pruneMcpSessions !== 'function') return; + try { + await logger.pruneMcpSessions(_retentionDays); + } catch (_e) { /* best-effort */ } + } + + async function _applyRecordingPolicy(enabled, retentionDays, flushOnDisable) { + var wasEnabled = _recordingEnabled; + _recordingEnabled = enabled !== false; + _retentionDays = clampRetentionDays(retentionDays); + if (flushOnDisable && wasEnabled && !_recordingEnabled) { + _closeAllOpenSessions('recording_disabled'); + } + _scheduleRetentionAlarm(); + await _requestRetentionPrune(); } // ---- Open-session buffer persistence (eviction survival) ---------------- @@ -214,6 +596,7 @@ // mcp-metrics-recorder.js so two persists cannot interleave their // read-modify-write cycles. var _persistLock = Promise.resolve(); + var _alarmLock = Promise.resolve(); function _withPersistLock(fn) { var next = _persistLock.then(fn, fn); @@ -221,6 +604,12 @@ return next; } + function _withAlarmLock(fn) { + var next = _alarmLock.then(fn, fn); + _alarmLock = next.catch(function () { /* keep chain alive */ }); + return next; + } + async function _readBufferEnvelope() { var storage = _resolveSessionStorage(); if (!storage || typeof storage.get !== 'function') { @@ -246,7 +635,7 @@ async function _writeBufferEnvelope(records) { var storage = _resolveSessionStorage(); - if (!storage) return; + if (!storage) return true; try { var nextRecords = (records && typeof records === 'object') ? records : {}; if (Object.keys(nextRecords).length === 0) { @@ -255,16 +644,212 @@ if (typeof storage.remove === 'function') { await storage.remove(FSB_MCP_SESSION_BUFFER_KEY); } - return; + return true; } - if (typeof storage.set !== 'function') return; + if (typeof storage.set !== 'function') return false; var toWrite = {}; toWrite[FSB_MCP_SESSION_BUFFER_KEY] = { v: FSB_MCP_SESSION_BUFFER_VERSION, records: nextRecords }; await storage.set(toWrite); - } catch (_e) { /* best-effort -- persistence must never break recording */ } + return true; + } catch (_e) { + return false; + } + } + + async function _readMemoryCandidateEnvelope() { + var storage = _resolveSessionStorage(); + if (!storage || typeof storage.get !== 'function') { + return { v: FSB_MCP_MEMORY_CANDIDATES_VERSION, records: {} }; + } + try { + var stored = await storage.get([FSB_MCP_MEMORY_CANDIDATES_KEY]); + var payload = stored ? stored[FSB_MCP_MEMORY_CANDIDATES_KEY] : null; + if (!payload || payload.v !== FSB_MCP_MEMORY_CANDIDATES_VERSION || + !payload.records || typeof payload.records !== 'object') { + return { v: FSB_MCP_MEMORY_CANDIDATES_VERSION, records: {} }; + } + return payload; + } catch (_e) { + return { v: FSB_MCP_MEMORY_CANDIDATES_VERSION, records: {} }; + } + } + + async function _writeMemoryCandidateEnvelope(records) { + var storage = _resolveSessionStorage(); + if (!storage) return true; + try { + var nextRecords = (records && typeof records === 'object') ? records : {}; + if (Object.keys(nextRecords).length === 0) { + if (typeof storage.remove === 'function') await storage.remove(FSB_MCP_MEMORY_CANDIDATES_KEY); + return true; + } + if (typeof storage.set !== 'function') return false; + var toWrite = {}; + toWrite[FSB_MCP_MEMORY_CANDIDATES_KEY] = { + v: FSB_MCP_MEMORY_CANDIDATES_VERSION, + records: nextRecords + }; + await storage.set(toWrite); + return true; + } catch (_e) { + return false; + } + } + + function _serializeMemoryCandidate(candidate) { + return { + sessionId: candidate.sessionId, + agentId: candidate.agentId, + tabId: candidate.tabId, + task: candidate.task, + client: candidate.client, + startTime: candidate.startTime, + endTime: candidate.endTime, + closedAt: candidate.closedAt, + expiresAt: candidate.expiresAt, + lastUrl: _sanitizeUrlForMemory(candidate.lastUrl), + actionCount: candidate.actionCount, + toolNames: Array.isArray(candidate.toolNames) ? candidate.toolNames.slice(-MCP_SESSION_ACTION_HISTORY_CAP) : [] + }; + } + + function _pruneMemoryCandidates(now) { + var changed = false; + _memoryCandidates.forEach(function (candidate, sessionId) { + if (!candidate || candidate.expiresAt <= now) { + _memoryCandidates.delete(sessionId); + changed = true; + } + }); + if (_memoryCandidates.size > MCP_MEMORY_CANDIDATE_CAP) { + var retained = Array.from(_memoryCandidates.values()) + .sort(function (a, b) { return b.closedAt - a.closedAt; }) + .slice(0, MCP_MEMORY_CANDIDATE_CAP); + _memoryCandidates.clear(); + for (var i = 0; i < retained.length; i++) { + _memoryCandidates.set(retained[i].sessionId, retained[i]); + } + changed = true; + } + return changed; + } + + function _persistMemoryCandidates() { + return _withPersistLock(async function () { + _pruneMemoryCandidates(_now()); + var records = {}; + _memoryCandidates.forEach(function (candidate, sessionId) { + records[sessionId] = _serializeMemoryCandidate(candidate); + }); + await _writeMemoryCandidateEnvelope(records); + }); + } + + function _sanitizeAutomationLogEntry(log) { + var originalData = log && log.data && typeof log.data === 'object' ? log.data : {}; + // MCP session ids are generated internal correlation keys, not user + // credentials. Keep this exact field so automationLogger can associate + // raw rows with the session index during retention pruning; nested or + // unrelated session-id-shaped fields remain covered by the redactor. + var correlationSessionId = typeof originalData.sessionId === 'string' && originalData.sessionId + ? originalData.sessionId + : null; + var clone = _sanitizeReplayValue(log, {}, false); + if (!clone || typeof clone !== 'object') return {}; + var data = clone.data && typeof clone.data === 'object' ? clone.data : null; + if (data && correlationSessionId) data.sessionId = correlationSessionId; + if (data && typeof originalData.task === 'string') { + data.task = _sanitizeSummaryText(originalData.task, 2000); + } + if (data && data.action && typeof data.action === 'object') { + var originalAction = originalData.action && typeof originalData.action === 'object' ? originalData.action : {}; + data.action.params = cloneParamsForReplay(originalAction.params, originalData.result); + data.result = cloneResultForReplay(originalData.result, originalAction.params); + } + return clone; + } + + async function _scrubPersistedMcpData() { + var bufferEnvelope = await _readBufferEnvelope(); + var bufferRecords = bufferEnvelope.records || {}; + var mcpSessionIds = new Set(); + Object.keys(bufferRecords).forEach(function (key) { + var record = bufferRecords[key]; + if (record && typeof record === 'object') { + if (typeof record.sessionId === 'string' && record.sessionId) mcpSessionIds.add(record.sessionId); + record.task = _sanitizeSummaryText(record.task, 2000); + record.visualReasons = _sanitizeSummaryTextList(record.visualReasons, 2000); + record.lastUrl = _sanitizeUrlForPersistence(record.lastUrl); + record.actionHistory = _sanitizeActionHistory(record.actionHistory); + } + }); + var bufferScrubbed = await _writeBufferEnvelope(bufferRecords); + + var storage = _resolveLocalStorage(); + if (!storage || typeof storage.get !== 'function' || typeof storage.set !== 'function') return; + var scrubLocalStorage = async function () { + try { + var stored = await storage.get([ + FSB_MCP_REDACTION_VERSION_KEY, + 'fsbSessionLogs', + 'fsbSessionIndex', + 'automationLogs' + ]); + if (Number(stored && stored[FSB_MCP_REDACTION_VERSION_KEY]) >= FSB_MCP_REDACTION_VERSION) return; + + var sessionLogs = stored && stored.fsbSessionLogs && typeof stored.fsbSessionLogs === 'object' + ? stored.fsbSessionLogs + : {}; + Object.keys(sessionLogs).forEach(function (sessionId) { + var session = sessionLogs[sessionId]; + if (session && session.mode === 'mcp-agent') { + mcpSessionIds.add(sessionId); + _sanitizePersistedMcpSessionText(session); + session.lastUrl = _sanitizeUrlForPersistence(session.lastUrl); + session.actionHistory = _sanitizeActionHistory(session.actionHistory); + if (Array.isArray(session.logs)) { + session.logs = session.logs.map(_sanitizeAutomationLogEntry); + } + } + }); + + var sessionIndex = Array.isArray(stored && stored.fsbSessionIndex) + ? stored.fsbSessionIndex + : []; + sessionIndex.forEach(function (entry) { + if (entry && entry.mode === 'mcp-agent' && typeof entry.id === 'string' && entry.id) { + mcpSessionIds.add(entry.id); + _sanitizePersistedMcpSessionText(entry); + } + }); + + var automationLogs = Array.isArray(stored && stored.automationLogs) + ? stored.automationLogs.map(function (log) { + var sessionId = log && log.data && log.data.sessionId; + return mcpSessionIds.has(sessionId) ? _sanitizeAutomationLogEntry(log) : log; + }) + : []; + var next = { + fsbSessionLogs: sessionLogs, + fsbSessionIndex: sessionIndex, + automationLogs: automationLogs + }; + if (bufferScrubbed) next[FSB_MCP_REDACTION_VERSION_KEY] = FSB_MCP_REDACTION_VERSION; + await storage.set(next); + } catch (_e) { + // Leave the version marker unset so the next worker startup retries. + } + }; + + var logger = _getAutomationLogger(); + if (logger && typeof logger.withSessionMutationLock === 'function') { + await logger.withSessionMutationLock(scrubLocalStorage); + } else { + await scrubLocalStorage(); + } } function _serializeRecord(session) { @@ -272,13 +857,13 @@ sessionId: session.sessionId, agentId: session.agentId, tabId: session.tabId, - task: session.task, + task: _sanitizeSummaryText(session.task, 2000), client: session.client, startTime: session.startTime, lastActivityAt: session.lastActivityAt, deadlineAt: session.deadlineAt, - lastUrl: session.lastUrl, - visualReasons: session.visualReasons.slice(), + lastUrl: _sanitizeUrlForPersistence(session.lastUrl), + visualReasons: _sanitizeSummaryTextList(session.visualReasons, 2000), actionHistory: session.actionHistory.slice(), sawActionTool: session.sawActionTool === true }; @@ -323,16 +908,23 @@ // Tab identity for session keying. Precedence mirrors the boundary // conventions end to end: an explicitly resolved tabId from the bridge - // action tap wins; otherwise wire params carry snake_case tab_id (the MCP - // schema field, preserved by PARAM_TRANSFORMS -- same order as - // resolveAgentTabOrError in utils/agent-tab-resolver.js); camelCase tabId - // last for back-compat with dispatcher-injected routeParams. - function _resolveNumericTabId(entry, params) { + // action tap wins; otherwise nested action params precede the top-level tab + // fields used by direct read routes such as mcp:read-page / mcp:get-dom. + function _resolveNumericTabId(entry, params, payload) { var explicit = _numericTabId(entry.tabId); if (explicit !== null) return explicit; var snake = _numericTabId(params.tab_id); if (snake !== null) return snake; - return _numericTabId(params.tabId); + var camel = _numericTabId(params.tabId); + if (camel !== null) return camel; + var topSnake = _numericTabId(payload && payload.tab_id); + if (topSnake !== null) return topSnake; + return _numericTabId(payload && payload.tabId); + } + + function _findSessionForAgentTab(agentId, numericTabId) { + if (numericTabId === null) return null; + return _openSessions.get(agentId + '::' + _tabKeyPart(numericTabId)) || null; } // JOIN attribution fallback: the open session with matching agentId that @@ -347,43 +939,387 @@ return best; } - // ---- Idle timer (sliding 60s window) ------------------------------------ + // ---- Client-authored memory correlation --------------------------------- + + function _buildMemoryCandidate(session, endTime) { + return { + sessionId: session.sessionId, + agentId: session.agentId, + tabId: session.tabId, + task: _sanitizeSummaryText(session.task, 2000), + client: session.client, + startTime: session.startTime, + endTime: endTime, + closedAt: endTime, + expiresAt: endTime + MCP_MEMORY_CANDIDATE_TTL_MS, + lastUrl: _sanitizeUrlForMemory(session.lastUrl), + actionCount: session.actionHistory.length, + toolNames: session.actionHistory.map(function (entry) { return String(entry && entry.tool || ''); }).filter(Boolean) + }; + } + + function _registerMemoryCandidate(session, endTime) { + var candidate = _buildMemoryCandidate(session, endTime); + _memoryCandidates.set(candidate.sessionId, candidate); + _pruneMemoryCandidates(endTime); + _persistMemoryCandidates().catch(function () { /* best-effort */ }); + return candidate; + } + + async function _restoreMemoryCandidates() { + var envelope = await _readMemoryCandidateEnvelope(); + var records = envelope.records || {}; + var now = _now(); + _memoryCandidates.clear(); + Object.keys(records).forEach(function (sessionId) { + var record = records[sessionId]; + if (!record || typeof record !== 'object' || typeof record.agentId !== 'string' || + typeof record.sessionId !== 'string' || !Number.isFinite(record.expiresAt) || record.expiresAt <= now) { + return; + } + _memoryCandidates.set(record.sessionId, { + sessionId: record.sessionId, + agentId: record.agentId, + tabId: _numericTabId(record.tabId), + task: typeof record.task === 'string' ? record.task : 'MCP agent session', + client: typeof record.client === 'string' ? record.client : 'unknown', + startTime: Number.isFinite(record.startTime) ? record.startTime : now, + endTime: Number.isFinite(record.endTime) ? record.endTime : now, + closedAt: Number.isFinite(record.closedAt) ? record.closedAt : now, + expiresAt: record.expiresAt, + lastUrl: _sanitizeUrlForMemory(record.lastUrl), + actionCount: Number.isFinite(record.actionCount) ? Math.max(0, Math.floor(record.actionCount)) : 0, + toolNames: Array.isArray(record.toolNames) + ? record.toolNames.filter(function (name) { return typeof name === 'string'; }).slice(-MCP_SESSION_ACTION_HISTORY_CAP) + : [] + }); + }); + _pruneMemoryCandidates(now); + await _persistMemoryCandidates(); + } + + function _findOutcomeTarget(agentId, tabId) { + var candidatesPruned = _pruneMemoryCandidates(_now()); + if (candidatesPruned) _persistMemoryCandidates().catch(function () { /* best-effort */ }); + + if (tabId !== null) { + var exactOpen = _findSessionForAgentTab(agentId, tabId); + if (exactOpen) return { kind: 'open', value: exactOpen }; + var exactClosed = Array.from(_memoryCandidates.values()) + .filter(function (candidate) { return candidate.agentId === agentId && candidate.tabId === tabId; }) + .sort(function (a, b) { return b.closedAt - a.closedAt; }); + return exactClosed.length > 0 ? { kind: 'closed', value: exactClosed[0] } : null; + } + + var all = []; + _openSessions.forEach(function (session) { + if (session.agentId === agentId) all.push({ kind: 'open', value: session }); + }); + _memoryCandidates.forEach(function (candidate) { + if (candidate.agentId === agentId) all.push({ kind: 'closed', value: candidate }); + }); + return all.length === 1 ? all[0] : null; + } + + function _snapshotTaskOutcome(input) { + var params = input && input.params && typeof input.params === 'object' ? input.params : {}; + var payload = input && input.payload && typeof input.payload === 'object' ? input.payload : {}; + return { + tool: input && input.tool, + agentId: typeof payload.agentId === 'string' ? payload.agentId : '', + tabId: _numericTabId(params.tab_id) !== null ? _numericTabId(params.tab_id) : _numericTabId(params.tabId), + summary: _sanitizeSummaryText(params.summary, 2000), + blocker: _sanitizeSummaryText(params.blocker, 1000), + nextStep: _sanitizeSummaryText(params.next_step || params.nextStep, 1000), + reason: _sanitizeSummaryText(params.reason, 1000) + }; + } + + function _normalizeTaskOutcome(snapshot) { + if (!snapshot || !snapshot.agentId) return null; + if (snapshot.tool === 'complete_task' && snapshot.summary) { + return { tool: snapshot.tool, outcome: 'success', status: 'completed', text: snapshot.summary, summary: snapshot.summary }; + } + if (snapshot.tool === 'partial_task' && snapshot.summary && snapshot.blocker) { + var partialText = snapshot.summary + '\nBlocker: ' + snapshot.blocker; + if (snapshot.nextStep) partialText += '\nNext step: ' + snapshot.nextStep; + return { + tool: snapshot.tool, + outcome: 'partial', + status: 'partial', + text: partialText, + summary: snapshot.summary, + blocker: snapshot.blocker, + nextStep: snapshot.nextStep, + reason: snapshot.reason || 'blocked' + }; + } + if (snapshot.tool === 'fail_task' && snapshot.reason) { + return { + tool: snapshot.tool, + outcome: 'failure', + status: 'failed', + text: snapshot.reason, + reason: snapshot.reason, + error: snapshot.reason + }; + } + return null; + } + + function _lifecycleSessionFields(outcome) { + if (!outcome || typeof outcome !== 'object') return {}; + return { + status: outcome.status, + outcome: outcome.outcome, + outcomeDetails: { + outcome: outcome.outcome, + reason: outcome.reason || (outcome.outcome === 'failure' ? 'error' : 'completed'), + summary: outcome.summary || null, + blocker: outcome.blocker || null, + nextStep: outcome.nextStep || null, + result: outcome.outcome === 'failure' ? null : outcome.text, + error: outcome.error || null + }, + result: outcome.summary || null, + completionMessage: outcome.outcome === 'failure' ? null : outcome.text, + error: outcome.error || null, + blocker: outcome.blocker || null, + nextStep: outcome.nextStep || null + }; + } - function _disarmIdleTimer(key) { - var handle = _idleTimers.get(key); - if (handle !== undefined) { - try { _clearIdleTimeout(handle); } catch (_e) { /* ignore */ } - _idleTimers.delete(key); + function _closeReasonSessionFields(reason) { + if (reason === 'expired') { + return _lifecycleSessionFields({ + status: 'expired', + outcome: 'stopped', + reason: 'expired', + text: null + }); + } + if (reason === 'recording_disabled') { + return _lifecycleSessionFields({ + status: 'stopped', + outcome: 'stopped', + reason: 'recording_disabled', + text: null + }); } + return { status: 'completed' }; } - function _armIdleTimer(session) { - var key = session.key; - _disarmIdleTimer(key); - var delay = session.deadlineAt - _now(); - if (delay < 0) delay = 0; + async function _updateClosedSessionOutcome(sessionId, outcome) { + var logger = _getAutomationLogger(); + if (!logger || typeof logger.updateSessionOutcome !== 'function') return false; try { - var handle = _setIdleTimeout(function () { + return (await logger.updateSessionOutcome(sessionId, _lifecycleSessionFields(outcome))) === true; + } catch (_e) { + return false; + } + } + + function _resolveTaskMemoryFactory() { + if (typeof globalThis !== 'undefined' && typeof globalThis.createTaskMemory === 'function') { + return globalThis.createTaskMemory; + } + if (typeof createTaskMemory === 'function') return createTaskMemory; + return null; + } + + function _resolveMemoryStorage() { + if (typeof globalThis !== 'undefined' && globalThis.memoryStorage) return globalThis.memoryStorage; + if (typeof memoryStorage !== 'undefined') return memoryStorage; + return null; + } + + function _domainFromUrl(url) { + if (typeof url !== 'string' || !url) return null; + try { return new URL(url).hostname || null; } catch (_e) { return null; } + } + + function _sanitizeUrlForMemory(url) { + if (typeof url !== 'string' || !url) return null; + try { + var parsed = new URL(url); + parsed.username = ''; + parsed.password = ''; + parsed.search = ''; + parsed.hash = ''; + return parsed.toString(); + } catch (_e) { + return null; + } + } + + async function _storeClientTaskMemory(candidate, outcome) { + var factory = _resolveTaskMemoryFactory(); + var storage = _resolveMemoryStorage(); + if (!factory || !storage || typeof storage.add !== 'function') return false; + try { + if (typeof storage.getAll === 'function') { + var existing = await storage.getAll(); + if (Array.isArray(existing) && existing.some(function (memory) { + return memory && memory.sourceSessionId === candidate.sessionId; + })) { + return true; + } + } + var domain = _domainFromUrl(candidate.lastUrl); + var memory = factory(outcome.text, { + sourceSessionId: candidate.sessionId, + domain: domain, + taskType: 'mcp-agent', + tags: ['mcp-agent', 'mcp-client-summary', outcome.outcome], + confidence: 1, + source: 'mcp-client', + mcpClient: candidate.client + }, { + session: { + task: _sanitizeSummaryText(candidate.task, 2000), + outcome: outcome.outcome, + domain: domain, + duration: Math.max(0, candidate.endTime - candidate.startTime), + iterationCount: candidate.actionCount, + finalUrl: candidate.lastUrl, + timeline: candidate.toolNames.map(function (toolName) { + return { action: toolName, target: '', url: null, result: '', timestamp: null }; + }), + failures: outcome.outcome === 'failure' ? [outcome.reason] : [] + }, + learned: { selectors: [], siteStructure: [], patterns: [] }, + procedures: [] + }); + return (await storage.add(memory)) !== false; + } catch (_e) { + return false; + } + } + + async function _recordTaskOutcomeNow(snapshot) { + var outcome = _normalizeTaskOutcome(snapshot); + if (!outcome) return { stored: false, reason: 'invalid_outcome' }; + var target = _findOutcomeTarget(snapshot.agentId, snapshot.tabId); + if (!target) return { stored: false, reason: 'no_unambiguous_session' }; + + var candidate = target.value; + var wasClosed = target.kind === 'closed'; + if (target.kind === 'open') { + candidate = closeSession(target.value.key, 'task_status', outcome); + } + if (!candidate) return { stored: false, reason: 'no_persistable_session' }; + + // Consume before storage so the first terminal outcome wins even if the + // local memory write fails. Session history remains available either way. + _memoryCandidates.delete(candidate.sessionId); + try { await _persistMemoryCandidates(); } catch (_e) { /* keep lifecycle writes independent */ } + if (wasClosed) { + await _updateClosedSessionOutcome(candidate.sessionId, outcome); + } + var stored = await _storeClientTaskMemory(candidate, outcome); + return { stored: stored, sessionId: candidate.sessionId }; + } + + function recordTaskOutcome(input) { + try { + if (!input || typeof input !== 'object') return; + var snapshot = _snapshotTaskOutcome(input); + _enqueueRecorderMutation(function () { return _recordTaskOutcomeNow(snapshot); }) + .catch(function () { /* lifecycle responses never depend on memory */ }); + } catch (_e) { /* fire-and-forget */ } + } + + // ---- MV3-survivable alarms ---------------------------------------------- + + function _idleAlarmName(sessionId) { + return FSB_MCP_SESSION_IDLE_ALARM_PREFIX + sessionId; + } + + function _findSessionById(sessionId) { + var found = null; + _openSessions.forEach(function (session) { + if (!found && session.sessionId === sessionId) found = session; + }); + return found; + } + + function _armIdleAlarm(session) { + var alarms = _resolveAlarms(); + if (!alarms || typeof alarms.create !== 'function' || !session) return; + var name = _idleAlarmName(session.sessionId); + var when = session.deadlineAt; + _withAlarmLock(async function () { + await alarms.create(name, { when: when }); + }).catch(function () { /* lazy sweep remains the fallback */ }); + } + + function _disarmIdleAlarm(session) { + var alarms = _resolveAlarms(); + if (!alarms || typeof alarms.clear !== 'function' || !session) return; + var name = _idleAlarmName(session.sessionId); + _withAlarmLock(async function () { + await alarms.clear(name); + }).catch(function () { /* best-effort */ }); + } + + function _scheduleRetentionAlarm() { + var alarms = _resolveAlarms(); + if (!alarms || typeof alarms.create !== 'function') return; + _withAlarmLock(async function () { + // Do not postpone the daily sweep every time MV3 spins up a fresh + // worker. chrome.alarms persists independently of worker lifetime. + if (typeof alarms.get === 'function') { try { - _idleTimers.delete(key); - var live = _openSessions.get(key); - if (!live) return; - if (live.deadlineAt <= _now()) { - closeSession(key, 'expired'); - } else { - // Stale timer (deadline slid forward without a re-arm) -- re-arm - // for the remaining window instead of closing early. - _armIdleTimer(live); - } - } catch (_e) { /* never throw out of a timer */ } - }, delay); - _idleTimers.set(key, handle); - } catch (_e) { /* timers unavailable -- lazy sweep still closes expired */ } + var existing = await new Promise(function (resolve) { + try { + var maybePromise = alarms.get(FSB_MCP_SESSION_RETENTION_ALARM, function (alarm) { + resolve(alarm || null); + }); + if (maybePromise && typeof maybePromise.then === 'function') { + maybePromise.then(function (alarm) { resolve(alarm || null); }, function () { resolve(null); }); + } + } catch (_getErr) { + resolve(null); + } + }); + if (existing) return; + } catch (_e) { /* fall through and create it */ } + } + await alarms.create(FSB_MCP_SESSION_RETENTION_ALARM, { + delayInMinutes: FSB_MCP_RETENTION_ALARM_PERIOD_MINUTES, + periodInMinutes: FSB_MCP_RETENTION_ALARM_PERIOD_MINUTES + }); + }).catch(function () { /* startup/save pruning still enforces retention */ }); + } + + async function _handleAlarmNow(alarm) { + if (!alarm || typeof alarm.name !== 'string' || !alarm.name.startsWith(FSB_MCP_SESSION_ALARM_PREFIX)) { + return { handled: false }; + } + if (alarm.name === FSB_MCP_SESSION_RETENTION_ALARM) { + await _requestRetentionPrune(); + return { handled: true, action: 'retention_pruned' }; + } + if (!alarm.name.startsWith(FSB_MCP_SESSION_IDLE_ALARM_PREFIX)) { + return { handled: true, action: 'ignored_unknown' }; + } + var sessionId = alarm.name.slice(FSB_MCP_SESSION_IDLE_ALARM_PREFIX.length); + var session = _findSessionById(sessionId); + if (!session) return { handled: true, action: 'missing' }; + if (session.deadlineAt <= _now()) { + closeSession(session.key, 'expired'); + return { handled: true, action: 'closed', sessionId: sessionId }; + } + _armIdleAlarm(session); + return { handled: true, action: 'rearmed', sessionId: sessionId }; + } + + function handleAlarm(alarm) { + return _enqueueRecorderMutation(function () { return _handleAlarmNow(alarm); }); } // Lazy sweep: close any open session whose deadline has passed. Runs at - // the top of every recordDispatch so expiry works even if timers were - // lost (SW eviction between dispatches). + // the top of every recordDispatch as a defensive backup to chrome.alarms. function _sweepExpired(now) { var expiredKeys = []; _openSessions.forEach(function (session, key) { @@ -399,38 +1335,49 @@ /** * Close an open session: remove it from the map, then (if it saw at least * one action tool and holds at least one actionHistory entry) build the - * schema session object and hand it to the history + memory pipeline via - * DIRECT globals. Never throws. + * schema session object and hand it to history via a DIRECT global. A safe + * short-lived candidate is retained for a later MCP-authored task summary. + * Never throws. * * @param {string} key - agentId::tabKey map key. - * @param {string} _reason - 'final' | 'expired' (diagnostic only). + * @param {string} reason - 'final' | 'expired' | 'recording_disabled' | 'task_status'. */ - function closeSession(key, _reason) { + function closeSession(key, reason, lifecycleOutcome) { try { var session = _openSessions.get(key); - if (!session) return; + if (!session) return null; _openSessions.delete(key); - _disarmIdleTimer(key); + _disarmIdleAlarm(session); _persistOpenSessions(); // >=1-action persistence gate (defence in depth -- the JOIN rule // already prevents sidecar-less births). - if (session.sawActionTool !== true || session.actionHistory.length < 1) return; + if (session.sawActionTool !== true || session.actionHistory.length < 1) return null; var endTime = _now(); + var normalizedLifecycle = lifecycleOutcome && typeof lifecycleOutcome === 'object' + ? lifecycleOutcome + : null; + var closeFields = normalizedLifecycle + ? _lifecycleSessionFields(normalizedLifecycle) + : _closeReasonSessionFields(reason); + var safeTask = _sanitizeSummaryText(session.task, 2000) || 'MCP agent session'; var overrides = { id: session.sessionId, - task: session.task, - status: 'completed', + task: safeTask, + status: closeFields.status, startTime: session.startTime, endTime: endTime, tabId: session.tabId, actionHistory: session.actionHistory, iterationCount: session.actionHistory.length, - lastUrl: session.lastUrl, + lastUrl: _sanitizeUrlForPersistence(session.lastUrl), mode: 'mcp-agent', mcpClient: session.client }; + Object.assign(overrides, closeFields); + + var memoryCandidate = _registerMemoryCandidate(session, endTime); // createSession is a SW global at runtime (ai/session-schema.js loads // as a classic script) but absent in bare Node and absent at this @@ -451,7 +1398,7 @@ typeof logger.logSessionStart === 'function') { var existingLogs = logger.getSessionLogs(session.sessionId); if (!existingLogs || existingLogs.length === 0) { - logger.logSessionStart(session.sessionId, session.task, session.tabId); + logger.logSessionStart(session.sessionId, safeTask, session.tabId); } } } catch (_e) { /* best-effort */ } @@ -468,17 +1415,7 @@ } catch (_e) { /* never let history persistence break close */ } } - // Memory handoff -- background.js extractAndStoreMemories tolerates a - // missing AI instance and calls memoryManager.add unconditionally. - // Absent under bare Node: skip silently. - try { - if (typeof extractAndStoreMemories === 'function') { - var memoryResult = extractAndStoreMemories(session.sessionId, sessionObject); - if (memoryResult && typeof memoryResult.catch === 'function') { - memoryResult.catch(function () { /* fire-and-forget */ }); - } - } - } catch (_e) { /* never let memory handoff break close */ } + return memoryCandidate; } catch (_outerErr) { try { if (typeof console !== 'undefined' && typeof console.debug === 'function') { @@ -486,6 +1423,7 @@ _outerErr && _outerErr.message ? _outerErr.message : _outerErr); } } catch (_e) { /* ignore */ } + return null; } } @@ -503,9 +1441,10 @@ * * @param {object} entry - The dispatch context from the dispatcher finally. */ - function recordDispatch(entry) { + function _recordDispatchNow(entry) { try { if (!entry || typeof entry !== 'object') return; + if (!_recordingEnabled) return; var payload = (entry.requestPayload && typeof entry.requestPayload === 'object') ? entry.requestPayload : {}; @@ -520,7 +1459,7 @@ _sweepExpired(now); var params = (payload.params && typeof payload.params === 'object') ? payload.params : {}; - var numericTabId = _resolveNumericTabId(entry, params); + var numericTabId = _resolveNumericTabId(entry, params, payload); var sidecar = (payload.visualSession && typeof payload.visualSession === 'object') ? payload.visualSession : null; @@ -536,7 +1475,7 @@ if (!session) { var sessionId = _generateSessionId(); var task = (typeof sidecar.visualReason === 'string' && sidecar.visualReason.length > 0) - ? sidecar.visualReason + ? _sanitizeSummaryText(sidecar.visualReason, 2000) : String(entry.tool); var client = (typeof sidecar.client === 'string' && sidecar.client.length > 0) ? sidecar.client @@ -565,11 +1504,13 @@ } session.sawActionTool = true; } else { - // Read-only tool route or message route -- JOIN the most recently - // active open session for this agentId. No open session -> ignore + // Read-only tool route or message route -- JOIN the exact agent/tab + // session when the route supplied a tab identity. Only tab-less routes + // fall back to the most recently active session for this agent. No open session -> ignore // (this structurally enforces the >=1-action persistence gate: // read-only calls never birth sessions). - session = _findMostRecentSessionForAgent(agentId); + session = _findSessionForAgentTab(agentId, numericTabId) + || (numericTabId === null ? _findMostRecentSessionForAgent(agentId) : null); if (!session) return; key = session.key; } @@ -578,31 +1519,33 @@ // storedTool applies the replay-name map; the guards below (navigate // lastUrl) keep matching on the raw wire verb. var storedTool = MCP_REPLAY_TOOL_NAME_MAP[entry.tool] || entry.tool; - var redactedParams = redactParams(params); + var replayParams = cloneParamsForReplay(params, entry.response); + var replayResult = cloneResultForReplay(entry.response, params); session.actionHistory.push({ tool: storedTool, - params: redactedParams, - result: entry.response, + params: replayParams, + result: replayResult, timestamp: now }); if (session.actionHistory.length > MCP_SESSION_ACTION_HISTORY_CAP) { session.actionHistory.splice(0, session.actionHistory.length - MCP_SESSION_ACTION_HISTORY_CAP); } - // lastUrl feeds extractAndStoreMemories' domain fallback. + // lastUrl supplies safe domain/final-URL metadata to client-authored + // task memories. if (entry.tool === 'navigate' && typeof params.url === 'string' && params.url.length > 0 && entry.success) { - session.lastUrl = params.url; + session.lastUrl = _sanitizeUrlForPersistence(params.url); } if (sidecar && typeof sidecar.visualReason === 'string' && sidecar.visualReason.length > 0 && - session.visualReasons.indexOf(sidecar.visualReason) === -1) { - session.visualReasons.push(sidecar.visualReason); + session.visualReasons.indexOf(_sanitizeSummaryText(sidecar.visualReason, 2000)) === -1) { + session.visualReasons.push(_sanitizeSummaryText(sidecar.visualReason, 2000)); } var logger = _getAutomationLogger(); if (logger && typeof logger.logAction === 'function') { try { - logger.logAction(session.sessionId, { tool: storedTool, params: redactedParams }, entry.response); + logger.logAction(session.sessionId, { tool: storedTool, params: replayParams }, replayResult); } catch (_e) { /* best-effort */ } } @@ -618,7 +1561,7 @@ // closeSession persists the buffer update itself. closeSession(key, 'final'); } else { - _armIdleTimer(session); + _armIdleAlarm(session); _persistOpenSessions(); } } catch (_outerErr) { @@ -632,6 +1575,15 @@ } } + function recordDispatch(entry) { + try { + if (!entry || typeof entry !== 'object') return; + var snapshot = _snapshotEntry(entry); + _enqueueRecorderMutation(function () { _recordDispatchNow(snapshot); }) + .catch(function () { /* fire-and-forget */ }); + } catch (_e) { /* fire-and-forget */ } + } + /** * Bridge-level action entry point (MCPBridgeClient._recordMcpSessionAction). * Thin adapter onto recordDispatch: same fire-and-forget contract, plus the @@ -685,21 +1637,21 @@ sessionId: record.sessionId, agentId: (typeof record.agentId === 'string') ? record.agentId : '', tabId: (typeof record.tabId === 'number' && isFinite(record.tabId)) ? record.tabId : null, - task: (typeof record.task === 'string' && record.task.length > 0) ? record.task : 'MCP agent session', + task: _sanitizeSummaryText(record.task, 2000) || 'MCP agent session', client: (typeof record.client === 'string' && record.client.length > 0) ? record.client : 'unknown', startTime: (typeof record.startTime === 'number') ? record.startTime : now, lastActivityAt: (typeof record.lastActivityAt === 'number') ? record.lastActivityAt : now, deadlineAt: (typeof record.deadlineAt === 'number') ? record.deadlineAt : 0, - lastUrl: (typeof record.lastUrl === 'string') ? record.lastUrl : null, - visualReasons: Array.isArray(record.visualReasons) ? record.visualReasons : [], - actionHistory: Array.isArray(record.actionHistory) ? record.actionHistory : [], + lastUrl: _sanitizeUrlForPersistence(record.lastUrl), + visualReasons: _sanitizeSummaryTextList(record.visualReasons, 2000), + actionHistory: _sanitizeActionHistory(record.actionHistory), sawActionTool: record.sawActionTool === true }; _openSessions.set(key, session); if (session.deadlineAt <= now) { closeSession(key, 'expired'); } else { - _armIdleTimer(session); + _armIdleAlarm(session); } } // Sync the envelope with whatever survived the restore pass. @@ -708,6 +1660,46 @@ })(); } + async function _initializeRecorder() { + await _loadRecordingPolicy(); + await _scrubPersistedMcpData(); + await _restoreMemoryCandidates(); + await _restoreFromBuffer(); + if (!_recordingEnabled) { + _closeAllOpenSessions('recording_disabled'); + } + _scheduleRetentionAlarm(); + await _requestRetentionPrune(); + } + + function _registerStorageListener() { + var c = _getChrome(); + if (!c || !c.storage || !c.storage.onChanged || + typeof c.storage.onChanged.addListener !== 'function') return; + c.storage.onChanged.addListener(function (changes, areaName) { + if (areaName !== 'local' || !changes) return; + if (!changes[FSB_MCP_RECORDING_ENABLED_KEY] && !changes[FSB_MCP_RETENTION_DAYS_KEY]) return; + + var hasEnabledChange = !!changes[FSB_MCP_RECORDING_ENABLED_KEY]; + var hasRetentionChange = !!changes[FSB_MCP_RETENTION_DAYS_KEY]; + var changedEnabled = hasEnabledChange + ? changes[FSB_MCP_RECORDING_ENABLED_KEY].newValue !== false + : null; + var changedRetentionDays = hasRetentionChange + ? changes[FSB_MCP_RETENTION_DAYS_KEY].newValue + : null; + + _enqueueRecorderMutation(function () { + // Resolve unchanged fields when this queued mutation actually runs; + // a second onChanged event may have been queued before the first one + // updates the in-memory policy. + var nextEnabled = hasEnabledChange ? changedEnabled : _recordingEnabled; + var nextRetentionDays = hasRetentionChange ? changedRetentionDays : _retentionDays; + return _applyRecordingPolicy(nextEnabled, nextRetentionDays, true); + }).catch(function () { /* best-effort */ }); + }); + } + // ---- Test seams ----------------------------------------------------------- function _peekOpenSessions() { @@ -718,13 +1710,45 @@ return out; } + function _peekMemoryCandidates() { + var out = {}; + _memoryCandidates.forEach(function (candidate, sessionId) { + out[sessionId] = _serializeMemoryCandidate(candidate); + }); + return out; + } + function _resetForTests() { - _openSessions.forEach(function (_session, key) { - _disarmIdleTimer(key); + _openSessions.forEach(function (session) { + _disarmIdleAlarm(session); }); _openSessions.clear(); - _idleTimers.clear(); + _memoryCandidates.clear(); _lastGeneratedSessionTs = 0; + _recordingEnabled = true; + _retentionDays = FSB_MCP_RETENTION_DEFAULT_DAYS; + _initializationPromise = Promise.resolve(); + _recordQueue = Promise.resolve(); + _persistLock = Promise.resolve(); + _alarmLock = Promise.resolve(); + } + + function _startInitializationForTests() { + _initializationPromise = _initializeRecorder().catch(function () { /* fail-open */ }); + return _initializationPromise; + } + + function _applyPolicyForTests(enabled, retentionDays) { + return _enqueueRecorderMutation(function () { + return _applyRecordingPolicy(enabled, retentionDays, true); + }); + } + + async function _drainForTests() { + await Promise.resolve(_initializationPromise).catch(function () { /* ignore */ }); + await Promise.resolve(_recordQueue).catch(function () { /* ignore */ }); + await Promise.resolve(_persistLock).catch(function () { /* ignore */ }); + await Promise.resolve(_alarmLock).catch(function () { /* ignore */ }); } // ---- Registration --------------------------------------------------------- @@ -732,14 +1756,39 @@ var _api = { recordDispatch: recordDispatch, recordAction: recordAction, - redactParams: redactParams, + recordTaskOutcome: recordTaskOutcome, + handleAlarm: handleAlarm, + cloneParamsForReplay: cloneParamsForReplay, + cloneResultForReplay: cloneResultForReplay, + // Compatibility alias retained for existing tests/callers. + redactParams: cloneParamsForReplay, FSB_MCP_SESSION_BUFFER_KEY: FSB_MCP_SESSION_BUFFER_KEY, + FSB_MCP_MEMORY_CANDIDATES_KEY: FSB_MCP_MEMORY_CANDIDATES_KEY, + FSB_MCP_REDACTION_VERSION_KEY: FSB_MCP_REDACTION_VERSION_KEY, + FSB_MCP_REDACTION_VERSION: FSB_MCP_REDACTION_VERSION, + FSB_MCP_SESSION_ALARM_PREFIX: FSB_MCP_SESSION_ALARM_PREFIX, + FSB_MCP_SESSION_RETENTION_ALARM: FSB_MCP_SESSION_RETENTION_ALARM, + FSB_MCP_RECORDING_ENABLED_KEY: FSB_MCP_RECORDING_ENABLED_KEY, + FSB_MCP_RETENTION_DAYS_KEY: FSB_MCP_RETENTION_DAYS_KEY, + FSB_MCP_RETENTION_DEFAULT_DAYS: FSB_MCP_RETENTION_DEFAULT_DAYS, MCP_SESSION_IDLE_DEATH_MS: MCP_SESSION_IDLE_DEATH_MS, + MCP_MEMORY_CANDIDATE_TTL_MS: MCP_MEMORY_CANDIDATE_TTL_MS, _setStorageShim: _setStorageShim, + _setLocalStorageShim: _setLocalStorageShim, + _setAlarmShim: _setAlarmShim, _setTimeShim: _setTimeShim, _peekOpenSessions: _peekOpenSessions, + _peekMemoryCandidates: _peekMemoryCandidates, _resetForTests: _resetForTests, - _restoreFromBuffer: _restoreFromBuffer + _restoreFromBuffer: _restoreFromBuffer, + _restoreMemoryCandidates: _restoreMemoryCandidates, + _scrubPersistedMcpData: _scrubPersistedMcpData, + _startInitializationForTests: _startInitializationForTests, + _applyPolicyForTests: _applyPolicyForTests, + _drainForTests: _drainForTests, + _getPolicyForTests: function () { + return { recordingEnabled: _recordingEnabled, retentionDays: _retentionDays }; + } }; // Service-worker classic-script surface (object-literal registration @@ -751,14 +1800,11 @@ module.exports = _api; } - // Fire-and-forget eviction restore at module load. Async storage read - // resolves AFTER the SW's synchronous startup script completes, so - // automationLogger / createSession / extractAndStoreMemories globals are - // all present by the time any expired session closes. + // Queue every dispatch behind one startup pass. Policy loads before the + // buffer restore, preventing a slow storage read from overwriting live + // actions accepted after service-worker startup. + _registerStorageListener(); try { - var _restorePromise = _restoreFromBuffer(); - if (_restorePromise && typeof _restorePromise.catch === 'function') { - _restorePromise.catch(function () { /* best-effort */ }); - } + _initializationPromise = _initializeRecorder().catch(function () { /* fail-open */ }); } catch (_e) { /* best-effort */ } })(); diff --git a/extension/utils/spreadsheet-record-redaction.js b/extension/utils/spreadsheet-record-redaction.js index 3bc613b64..24808e0d9 100644 --- a/extension/utils/spreadsheet-record-redaction.js +++ b/extension/utils/spreadsheet-record-redaction.js @@ -14,6 +14,14 @@ fillsheet: true, readsheet: true }); + var SHEETS_NAVIGATION_TOOLS = Object.freeze({ + navigate: true, + open_tab: true, + switch_tab: true, + close_tab: true + }); + var SHEETS_DOCUMENT_PATH = /^\/spreadsheets\/d\/[^/]+(?:\/|$)/; + var SAFE_OPERATION = /^[a-z0-9:_-]{1,80}$/i; var SAFE_ERROR_CODE = /^(?:GOOGLE_SHEETS|RECIPE)_[A-Z0-9_]{1,64}$/; var SAFE_EXACT_ERROR_CODES = Object.freeze({ RECOVERY_AMBIGUOUS: true }); @@ -21,18 +29,55 @@ return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; } + function isGoogleSheetsDocumentUrl(value) { + if (typeof value !== 'string' || value.length === 0) { return false; } + if (typeof URL !== 'function') { return true; } + try { + var parsed = new URL(value); + return (parsed.protocol === 'https:' || parsed.protocol === 'http:') && + parsed.hostname === 'docs.google.com' && SHEETS_DOCUMENT_PATH.test(parsed.pathname); + } catch (_e) { + return false; + } + } + + function hasGoogleSheetsDocumentUrl(entry, payload) { + var params = object(entry.params || payload.params); + var response = object(entry.response); + var changeReport = object(response.change_report); + var changeReportUrl = object(changeReport.url); + var candidates = [ + params.url, + response.url, + changeReportUrl.before, + changeReportUrl.after + ]; + for (var i = 0; i < candidates.length; i++) { + if (isGoogleSheetsDocumentUrl(candidates[i])) { return true; } + } + return false; + } + function classify(entry) { if (!entry || typeof entry !== 'object') { return null; } + var payload = object(entry.requestPayload || entry.payload); if (LEGACY_TOOLS[entry.tool]) { return { operation: entry.tool, actionShape: true }; } - var payload = object(entry.requestPayload || entry.payload); if (entry.tool === 'mcp:capabilities-invoke' && typeof payload.slug === 'string' && payload.slug.indexOf('gsheets.') === 0) { return { operation: KNOWN_CAPABILITIES[payload.slug] ? payload.slug : 'gsheets.unknown', actionShape: false }; } + if (entry.spreadsheetTarget === true || hasGoogleSheetsDocumentUrl(entry, payload)) { + return { + operation: typeof entry.tool === 'string' && SAFE_OPERATION.test(entry.tool) + ? entry.tool + : 'sheets.unknown', + actionShape: Object.prototype.hasOwnProperty.call(entry, 'payload') || SHEETS_NAVIGATION_TOOLS[entry.tool] === true + }; + } return null; } @@ -207,7 +252,15 @@ function recordSafely(recorder, method, entry) { if (!recorder || typeof recorder[method] !== 'function') { return false; } try { - recorder[method](sanitizeEntry(entry)); + var sanitized = sanitizeEntry(entry); + // Content-bearing recorder hooks must prove their resolved target origin. + // If the hook could not do so and the entry carries no direct Sheets + // evidence that this module can sanitize, omit the diagnostic record. + if (entry && entry.requireTargetOrigin === true && + entry.targetOriginResolved !== true && sanitized === entry) { + return false; + } + recorder[method](sanitized); return true; } catch (_e) { // Recording is diagnostic-only. A sanitization failure drops the entry. diff --git a/extension/ws/mcp-bridge-client.js b/extension/ws/mcp-bridge-client.js index 0beb0d57a..c44147a36 100644 --- a/extension/ws/mcp-bridge-client.js +++ b/extension/ws/mcp-bridge-client.js @@ -19,6 +19,34 @@ const MCP_DISPATCHER_SYNTHETIC_CHANGE_REPORT_TOOLS = new Set(['open_tab', 'close const TRIGGER_HEARTBEAT_INTERVAL_MS = 30000; const TRIGGER_BLOCKING_TIMEOUT_DEFAULT_MS = 120000; const TRIGGER_BLOCKING_SAFETY_CEILING_MS = 240000; + +function isMcpGoogleSheetsDocumentUrl(value) { + if (typeof value !== 'string' || value.length === 0) return false; + if (typeof URL !== 'function') return true; + try { + const parsed = new URL(value); + return (parsed.protocol === 'https:' || parsed.protocol === 'http:') && + parsed.hostname === 'docs.google.com' && + /^\/spreadsheets\/d\/[^/]+(?:\/|$)/.test(parsed.pathname); + } catch (_e) { + return false; + } +} + +function isMcpSpreadsheetRecord(payload, response) { + const tool = payload && payload.tool; + if (tool === 'fill_sheet' || tool === 'read_sheet' || tool === 'fillsheet' || tool === 'readsheet') { + return true; + } + const responseUrl = response && response.change_report && response.change_report.url; + return [ + payload && payload.params && payload.params.url, + response && response.url, + responseUrl && responseUrl.before, + responseUrl && responseUrl.after + ].some(isMcpGoogleSheetsDocumentUrl); +} + // Phase 241 D-07 / D-08 -- mirror of agent-registry.js RECONNECT_GRACE_MS. // On bridge _ws.onclose the bridge asks the registry to stage release for // every agent stamped with the current connection_id; that staged release @@ -423,6 +451,23 @@ class MCPBridgeClient { case 'mcp:end-visual-session': return this._handleEndVisualSession(payload); + case 'mcp:task-status': { + const taskParams = (payload.params && typeof payload.params === 'object') + ? { ...payload.params } + : {}; + // The dispatcher ownership gate consumes camelCase tabId while the + // public MCP schema remains snake_case tab_id. + if (Number.isFinite(taskParams.tab_id) && !Number.isFinite(taskParams.tabId)) { + taskParams.tabId = taskParams.tab_id; + } + return dispatchMcpToolRoute({ + tool: payload.tool, + params: taskParams, + client: this, + payload + }); + } + case 'mcp:execute-action': return this._handleExecuteAction(payload); @@ -705,13 +750,33 @@ class MCPBridgeClient { * Session-recorder tap at the bridge level so content- and cdp-routed * action tools are recorded too -- the dispatcher choke points only ever * see background-routed actions (executeFn's default branch sends straight - * to the content script). Fire-and-forget with the metrics-recorder - * discipline: NOT awaited, whole body guarded, never alters the action - * result. Placed ABOVE _handleExecuteAction so the 4500-char source gates + * to the content script). The target-origin lookup is best-effort and its + * failure only drops the diagnostic record; recording remains fire-and-forget, + * whole-body guarded, and never alters the action result. Placed ABOVE + * _handleExecuteAction so the 4500-char source gates * in tests/action-tool-agent-scoped.test.js and * tests/ownership-error-codes.test.js keep resolveAgentTabOrError in view. */ - _recordMcpSessionAction(payload, response, resolvedTabId) { + async _resolveMcpSessionRecordTarget(resolvedTabId, knownUrl) { + const unresolved = { targetOriginResolved: false, spreadsheetTarget: false }; + try { + if (!Number.isFinite(resolvedTabId)) return unresolved; + let targetUrl = typeof knownUrl === 'string' && knownUrl.length > 0 ? knownUrl : ''; + if (!targetUrl) { + const tab = await chrome.tabs.get(resolvedTabId); + targetUrl = (tab && (tab.url || tab.pendingUrl)) || ''; + } + if (!targetUrl) return unresolved; + return { + targetOriginResolved: true, + spreadsheetTarget: isMcpGoogleSheetsDocumentUrl(targetUrl) + }; + } catch (_e) { + return unresolved; + } + } + + _recordMcpSessionAction(payload, response, resolvedTabId, targetContext) { try { if (typeof globalThis === 'undefined' || !globalThis.fsbMcpSessionRecorder || @@ -727,15 +792,17 @@ class MCPBridgeClient { payload: payload, response: response, success: !(response && typeof response === 'object' && response.success === false), - tabId: Number.isFinite(resolvedTabId) ? resolvedTabId : null + tabId: Number.isFinite(resolvedTabId) ? resolvedTabId : null, + requireTargetOrigin: true, + targetOriginResolved: targetContext && targetContext.targetOriginResolved === true, + spreadsheetTarget: targetContext && targetContext.spreadsheetTarget === true }; const spreadsheetRedactor = globalThis.FsbSpreadsheetRecordRedaction; - const spreadsheetTool = payload && ( - payload.tool === 'fill_sheet' || payload.tool === 'read_sheet' || - payload.tool === 'fillsheet' || payload.tool === 'readsheet' - ); + const spreadsheetTool = sessionRecordEntry.spreadsheetTarget === true || + isMcpSpreadsheetRecord(payload, response); + const unresolvedRecordTarget = sessionRecordEntry.targetOriginResolved !== true; if (!spreadsheetRedactor || typeof spreadsheetRedactor.recordSafely !== 'function') { - if (!spreadsheetTool) { + if (!spreadsheetTool && !unresolvedRecordTarget) { globalThis.fsbMcpSessionRecorder.recordAction(sessionRecordEntry); } } else { @@ -804,7 +871,8 @@ class MCPBridgeClient { if (dispatched && dispatched.success === true) { const resolvedTabId = Number.isFinite(dispatched && dispatched.tabId) ? dispatched.tabId : null; if (resolvedTabId !== null) { - this._recordMcpSessionAction(payload, dispatched, resolvedTabId); + const recordTarget = await this._resolveMcpSessionRecordTarget(resolvedTabId, dispatched.url); + this._recordMcpSessionAction(payload, dispatched, resolvedTabId, recordTarget); await this._recordVisualSessionTickIfPresent(resolvedTabId, agentId, payload); // Phase 257 -- explicit completion. When the caller marks this // bootstrap call as the final action of the task, clear the visual @@ -830,7 +898,8 @@ class MCPBridgeClient { if (dispatched && dispatched.success === true) { const resolvedTabId = Number.isFinite(dispatched && dispatched.tabId) ? dispatched.tabId : null; if (resolvedTabId !== null) { - this._recordMcpSessionAction(payload, dispatched, resolvedTabId); + const recordTarget = await this._resolveMcpSessionRecordTarget(resolvedTabId, dispatched.url); + this._recordMcpSessionAction(payload, dispatched, resolvedTabId, recordTarget); await this._recordVisualSessionTickIfPresent(resolvedTabId, agentId, payload); await this._clearVisualSessionIfFinal(resolvedTabId, agentId, payload); } @@ -882,6 +951,11 @@ class MCPBridgeClient { }); }; + // Inspect the resolver-approved target as close as possible to execution. + // A failed lookup resolves to an unresolved recorder context and never + // prevents the browser action from running. + const recordTarget = await this._resolveMcpSessionRecordTarget(tabId); + let actionResult; if (typeof wrapWithChangeReport === 'function' && !usesDispatcherSyntheticChangeReport) { actionResult = await wrapWithChangeReport({ @@ -897,7 +971,7 @@ class MCPBridgeClient { // Session-recorder action tap: all three routes (content, cdp, // background) converge here with the resolver-approved tab identity. // Resolver-failure returns above record nothing (nothing executed). - this._recordMcpSessionAction(payload, actionResult, tabId); + this._recordMcpSessionAction(payload, actionResult, tabId, recordTarget); // Phase 257 -- explicit completion. When the caller marks this action as // the final action of the task, clear the visual session immediately diff --git a/extension/ws/mcp-tool-dispatcher.js b/extension/ws/mcp-tool-dispatcher.js index a18a6ca83..731fe6212 100644 --- a/extension/ws/mcp-tool-dispatcher.js +++ b/extension/ws/mcp-tool-dispatcher.js @@ -56,6 +56,11 @@ const MCP_CLAIMABLE_RECOVERY_TOOLS = new Set(['navigate', 'switch_tab']); // Persistence is fire-and-forget on the dispatcher hot path; the helper // _persistAgentClientLabel below catches every failure mode. const FSB_AGENT_CLIENT_LABELS_KEY = 'fsbAgentClientLabels'; +const MCP_TASK_OUTCOME_STATUS = Object.freeze({ + complete_task: 'completed', + partial_task: 'partial', + fail_task: 'failed' +}); const MCP_PHASE199_TOOL_ROUTES = { navigate: { routeFamily: 'browser', handler: handleNavigateRoute }, @@ -144,6 +149,13 @@ const MCP_PHASE199_MESSAGE_ROUTES = { 'agent:status': { routeFamily: 'agent', handler: handleAgentStatusRoute } }; +const MCP_VISUAL_SESSION_TASK_STATUS_TOOLS = new Set([ + 'report_progress', + 'complete_task', + 'partial_task', + 'fail_task' +]); + const MCP_TRIGGER_MESSAGE_TO_TOOL_NAME = { 'mcp:trigger': 'trigger', 'mcp:stop-trigger': 'stop_trigger', @@ -511,6 +523,62 @@ function _peekLastKnownMcpClientLabel(agentId) { return _agentClientLabelCache.size === 0 ? null : Object.fromEntries(_agentClientLabelCache); } +function canonicalizeVisualSessionTaskParams(tool, params) { + if (!MCP_VISUAL_SESSION_TASK_STATUS_TOOLS.has(tool)) { + return { params }; + } + + const sessionToken = boundedString(params?.session_token || params?.sessionToken, 200); + if (!sessionToken) return { params }; + + const resolver = typeof globalThis !== 'undefined' + ? globalThis.resolveMcpVisualSessionTabId + : null; + if (typeof resolver !== 'function') { + return { + error: createMcpRouteError(tool, 'visual-session', MCP_ROUTE_RECOVERY_HINT, { + errorCode: 'visual_session_unavailable', + error: 'Visual-session ownership resolver unavailable' + }) + }; + } + + let sessionTabId; + try { + sessionTabId = resolver(sessionToken); + } catch (error) { + return { + error: createMcpRouteError(tool, 'visual-session', MCP_ROUTE_RECOVERY_HINT, { + errorCode: 'visual_session_unavailable', + error: error?.message || 'Visual-session ownership resolver unavailable' + }) + }; + } + + // A missing token still follows the existing callback path so callers keep + // receiving the canonical visual_session_not_found response. + if (!Number.isFinite(sessionTabId) || sessionTabId <= 0) return { params }; + + const explicitTabIds = [params?.tabId, params?.tab_id].filter(Number.isFinite); + if (explicitTabIds.some((tabId) => tabId !== sessionTabId)) { + return { + error: createMcpInvalidParamsError( + tool, + 'The supplied tab_id does not match the visual session token', + { routeFamily: 'task-status' } + ) + }; + } + + return { + params: { + ...params, + tabId: sessionTabId, + tab_id: sessionTabId + } + }; +} + async function dispatchMcpToolRoute({ tool, params = {}, client = null, tab = null, payload = {} }) { const route = MCP_PHASE199_TOOL_ROUTES[tool]; if (!route) { @@ -521,6 +589,10 @@ async function dispatchMcpToolRoute({ tool, params = {}, client = null, tab = nu return createMcpRouteError(tool, route.routeFamily, MCP_ROUTE_RECOVERY_HINT); } + const canonicalized = canonicalizeVisualSessionTaskParams(tool, params); + if (canonicalized.error) return canonicalized.error; + params = canonicalized.params; + // Phase 240 D-06 / D-07: inline ownership gate. Sync; no await between gate // check and route.handler invocation. Same microtask discipline. const gateResult = checkOwnershipGate({ tool, params, payload }); @@ -576,6 +648,21 @@ async function dispatchMcpToolRoute({ tool, params = {}, client = null, tab = nu }); } } catch (_e) { /* defence in depth -- never let metrics break dispatch */ } + // Terminal task summaries are the only MCP-to-memory handoff. Record only + // a handler-confirmed lifecycle response (fail_task intentionally returns + // success:false with status:'failed'); invalid params and ownership + // rejections never reach this point with the matching status. + try { + if ( + MCP_TASK_OUTCOME_STATUS[tool] && + response && response.status === MCP_TASK_OUTCOME_STATUS[tool] && + typeof globalThis !== 'undefined' && + globalThis.fsbMcpSessionRecorder && + typeof globalThis.fsbMcpSessionRecorder.recordTaskOutcome === 'function' + ) { + globalThis.fsbMcpSessionRecorder.recordTaskOutcome({ tool, params, payload, response }); + } + } catch (_e) { /* never let task-memory recording break dispatch */ } // NOTE (260707-7id review fix): the session-recorder sibling hook that // lived here moved to MCPBridgeClient._recordMcpSessionAction -- this // route only ever carries background-routed actions (all originating in @@ -590,8 +677,8 @@ async function dispatchMcpMessageRoute({ type, payload = {}, client = null, mcpM return createMcpRouteError(type, 'message', MCP_ROUTE_RECOVERY_HINT); } - const restrictedReadResponse = await buildRestrictedResponseIfReadRoute({ type, client, payload }); - if (restrictedReadResponse) return restrictedReadResponse; + const readRouteInspection = await buildRestrictedResponseIfReadRoute({ type, client, payload }); + if (readRouteInspection.restrictedResponse) return readRouteInspection.restrictedResponse; // Phase 271 / v0.9.69 -- MCP analytics chokepoint (message surface). Same // try/finally pattern as dispatchMcpToolRoute: recorder fires from finally @@ -685,13 +772,27 @@ async function dispatchMcpMessageRoute({ type, payload = {}, client = null, mcpM requestPayload: payload, response, success, - dispatcher_route: 'message' + dispatcher_route: 'message', + tabId: readRouteInspection.recordContext + ? readRouteInspection.recordContext.tabId + : null, + requireTargetOrigin: readRouteInspection.applies === true, + targetOriginResolved: readRouteInspection.recordContext + ? readRouteInspection.recordContext.targetOriginResolved + : true, + spreadsheetTarget: readRouteInspection.recordContext + ? readRouteInspection.recordContext.spreadsheetTarget + : false }; var spreadsheetRedactor = globalThis.FsbSpreadsheetRecordRedaction; var spreadsheetInvoke = type === 'mcp:capabilities-invoke' && payload && typeof payload.slug === 'string' && payload.slug.indexOf('gsheets.') === 0; + var spreadsheetRecord = spreadsheetInvoke || sessionRecordEntry.spreadsheetTarget === true || + hasGoogleSheetsDocumentUrlForRecord(sessionRecordEntry); + var unresolvedRecordTarget = sessionRecordEntry.requireTargetOrigin === true && + sessionRecordEntry.targetOriginResolved !== true; if (!spreadsheetRedactor || typeof spreadsheetRedactor.recordSafely !== 'function') { - if (!spreadsheetInvoke) { + if (!spreadsheetRecord && !unresolvedRecordTarget) { globalThis.fsbMcpSessionRecorder.recordDispatch(sessionRecordEntry); } } else { @@ -720,7 +821,9 @@ function buildRestrictedMcpResponse({ currentUrl, pageType, tool, error }) { } async function buildRestrictedResponseIfReadRoute({ type, client, payload }) { - if (type !== 'mcp:read-page' && type !== 'mcp:get-dom') return null; + if (type !== 'mcp:read-page' && type !== 'mcp:get-dom') { + return { applies: false, recordContext: null, restrictedResponse: null }; + } const tool = type === 'mcp:read-page' ? 'read_page' : 'get_dom_snapshot'; if (typeof globalThis !== 'undefined' && typeof globalThis.resolveAgentTabOrError === 'function') { @@ -731,22 +834,26 @@ async function buildRestrictedResponseIfReadRoute({ type, client, payload }) { // behavior: check the OS-active tab. const activeTab = await getActiveTabFromClient(client).catch(() => null); const currentUrl = activeTab?.url || ''; - if (!isRestrictedMcpUrl(currentUrl)) return null; - - return buildRestrictedMcpResponse({ - currentUrl, - pageType: getPageTypeDescriptionForMcp(currentUrl), - tool, - error: 'Active tab is restricted' - }); + return { + applies: true, + recordContext: buildReadRouteRecordContext(activeTab), + restrictedResponse: isRestrictedMcpUrl(currentUrl) + ? buildRestrictedMcpResponse({ + currentUrl, + pageType: getPageTypeDescriptionForMcp(currentUrl), + tool, + error: 'Active tab is restricted' + }) + : null + }; } // Checks restriction on the CALLER'S ACTUAL TARGET tab (via the same // resolveAgentTabOrError the real _handleGetDOM/_handleReadPage handlers use) // instead of Chrome's OS-focused tab. Any resolution failure defers to the -// real handler (returns null) rather than guessing -- the real handler makes -// the identical resolveAgentTabOrError call and already returns its error -// shape verbatim, so duplicating that logic here would risk disagreeing with it. +// real handler rather than guessing; the unresolved recording context then +// makes the diagnostic hook fail closed. The real handler makes the identical +// resolveAgentTabOrError call and returns its error shape verbatim. async function buildRestrictedResponseForResolvedTab({ tool, client, payload }) { const agentId = (payload && payload.agentId) || null; const params = (payload && payload.params) || payload || {}; // matches _handleGetDOM/_handleReadPage @@ -755,28 +862,64 @@ async function buildRestrictedResponseForResolvedTab({ tool, client, payload }) try { resolved = await globalThis.resolveAgentTabOrError(agentId, params, client); } catch (_e) { - return null; + return { applies: true, recordContext: null, restrictedResponse: null }; } if (!resolved || resolved.success === false || !Number.isFinite(resolved.tabId)) { - return null; + return { applies: true, recordContext: null, restrictedResponse: null }; } let tab; try { tab = await getChromeTabsApi().get(resolved.tabId); } catch (_e) { - return null; + return { applies: true, recordContext: null, restrictedResponse: null }; } const currentUrl = (tab && tab.url) || ''; - if (!isRestrictedMcpUrl(currentUrl)) return null; + return { + applies: true, + recordContext: buildReadRouteRecordContext(tab), + restrictedResponse: isRestrictedMcpUrl(currentUrl) + ? buildRestrictedMcpResponse({ + currentUrl, + pageType: getPageTypeDescriptionForMcp(currentUrl), + tool, + error: 'Active tab is restricted' + }) + : null + }; +} - return buildRestrictedMcpResponse({ - currentUrl, - pageType: getPageTypeDescriptionForMcp(currentUrl), - tool, - error: 'Active tab is restricted' - }); +function buildReadRouteRecordContext(tab) { + const currentUrl = (tab && (tab.url || tab.pendingUrl)) || ''; + return { + tabId: tab && Number.isFinite(tab.id) ? tab.id : null, + targetOriginResolved: currentUrl.length > 0, + spreadsheetTarget: isGoogleSheetsDocumentUrlForRecord(currentUrl) + }; +} + +function isGoogleSheetsDocumentUrlForRecord(value) { + if (typeof value !== 'string' || value.length === 0) return false; + try { + const parsed = new URL(value); + return (parsed.protocol === 'https:' || parsed.protocol === 'http:') && + parsed.hostname === 'docs.google.com' && + /^\/spreadsheets\/d\/[^/]+(?:\/|$)/.test(parsed.pathname); + } catch (_e) { + return false; + } +} + +function hasGoogleSheetsDocumentUrlForRecord(entry) { + const payload = (entry && entry.requestPayload) || {}; + const params = payload && payload.params && typeof payload.params === 'object' ? payload.params : {}; + const response = entry && entry.response && typeof entry.response === 'object' ? entry.response : {}; + const changeUrl = response.change_report && response.change_report.url && typeof response.change_report.url === 'object' + ? response.change_report.url + : {}; + return [params.url, response.url, changeUrl.before, changeUrl.after] + .some(isGoogleSheetsDocumentUrlForRecord); } async function maybeBuildRestrictedResponse({ error, tool, client }) { diff --git a/mcp/CHANGELOG.md b/mcp/CHANGELOG.md index c4e64f143..40982b4c8 100644 --- a/mcp/CHANGELOG.md +++ b/mcp/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to `fsb-mcp-server` are documented in this file. Each entry corresponds to a published npm release; FSB extension milestones map to MCP package versions in the entry header. + + +## 0.11.0 (2026-07-15) + +Milestone: FSB v0.9.91 terminal task-outcome handoff. Minor release that makes the existing `complete_task`, `partial_task`, and `fail_task` schemas callable through a dedicated bridge contract and records their confirmed outcomes in local MCP task memory. + +### Added + +- **Terminal task-outcome bridge contract.** Added the `mcp:task-status` wire route for `complete_task`, `partial_task`, and `fail_task`. The MCP server preserves the caller's agent identity, ownership token, optional `tab_id`, and client-authored summary while the extension applies its normal ownership gate before recording an outcome. +- **Callable lifecycle tools.** The three lifecycle schemas that already existed in the shared registry are now registered with bridge message builders. A canonical `fail_task` response remains `success:false` to describe the task outcome but is returned to the MCP client as a successful tool acknowledgement rather than being remapped as a transport failure. +- **Ordered memory handoff.** Terminal lifecycle calls serialize behind pending browser mutations so a completion, partial, or failure summary cannot overtake the action it describes. Handler-confirmed terminal responses are the only MCP-to-memory handoff; validation and ownership failures are not recorded. + +### Compatibility + +- `fsb-mcp-server` 0.11.0 requires FSB extension 0.9.91 or newer for the new `mcp:task-status` route. Upgrade the extension and restart the MCP host together; an older extension does not recognize terminal lifecycle messages from this package. +- Existing manual actions, trigger watchers, observability tools, installer configuration, and npm package identity remain compatible. No dependency versions changed. + +### Anti-scope (NOT in 0.11.0) + +- No automatic task summaries are synthesized by the MCP server. Only explicit, client-authored `complete_task`, `partial_task`, or `fail_task` calls create the terminal memory handoff. +- No publish or tag action is performed by this release prep. Final `npm publish fsb-mcp-server@0.11.0` remains user-gated through the `mcp-v0.11.0` release tag. + ## 0.10.0 (2026-06-17) diff --git a/mcp/README.md b/mcp/README.md index b518fa8df..833c34a71 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -38,6 +38,16 @@ Use this package when you want your AI client to drive the browser directly whil > **PhantomStream (FSB v0.12.0):** the dashboard live-preview and remote-control relay are now powered by the published `@full-self-browsing/phantom-stream` package on the extension and showcase side. This is an internal capture/renderer/transport change — the MCP tool schemas, routes, and bridge contracts in this server are unchanged. +### What's New In v0.11.0 + +This is the terminal task-outcome handoff release. Full details live in `CHANGELOG.md`. + +- `complete_task`, `partial_task`, and `fail_task` are now callable MCP tools rather than shared-registry schemas without a server bridge route. +- The new `mcp:task-status` bridge message preserves agent ownership context and optional `tab_id` while handing a client-authored terminal summary to the extension's local MCP task memory. +- Terminal outcome calls serialize behind pending browser mutations, so the summary cannot overtake the final action it describes. +- A confirmed `fail_task` response remains `success:false` as task data but is returned as a normal MCP tool acknowledgement rather than a transport error. +- **Compatibility:** `fsb-mcp-server` 0.11.0 requires FSB extension 0.9.91 or newer for `mcp:task-status`. Upgrade the extension and restart the MCP host together. + ### What's New In v0.10.0 This is the trigger watchers release. Full details live in `CHANGELOG.md`. @@ -576,7 +586,8 @@ npm --prefix mcp run serve Release checks should verify: -- `mcp/src/version.ts` and `mcp/package.json` agree. +- `mcp/package.json`, `mcp/package-lock.json`, `mcp/server.json`, and `mcp/src/version.ts` agree. +- `mcp/CHANGELOG.md` and the current release instructions describe that same package version. - `mcp/README.md` tool counts match the registered runtime surface. - `mcp/src/tools/schema-bridge.ts` still loads the generated `ai/tool-definitions.cjs`. - Root tests still cover route contracts and setup guidance. @@ -590,7 +601,9 @@ The build command copies `extension/ai/tool-definitions.js` into `mcp/ai/tool-de ### Versioning -The MCP package has its own version (`0.10.0`) because it is published independently from the extension release (`0.9.90`). When extension bridge contracts change, update both the MCP version metadata and the compatibility notes in this README. When only website or extension UI text changes, the MCP version usually does not need to move. +The MCP package has its own version (`0.11.0`) because it is published independently from the extension release (`0.9.91`). When extension bridge contracts change, update all MCP version metadata plus the changelog and compatibility notes in this README. When only website or extension UI text changes, the MCP version usually does not need to move. + +Compatibility for this release: MCP 0.11.0 requires extension 0.9.91 or newer for the `mcp:task-status` route used by `complete_task`, `partial_task`, and `fail_task`. An older extension does not recognize those terminal lifecycle messages; update the extension before using these tools. Other 0.10.0 tool routes remain additive and unchanged. Contract-sensitive changes should be covered by tests before publishing: @@ -602,14 +615,15 @@ Contract-sensitive changes should be covered by tests before publishing: - vault redaction boundaries - multi-agent contract: `agent_id` capture, ownership gate, configurable cap, ownership tokens, `back` tool - trigger contract: `trigger`, `stop_trigger`, `get_trigger_status`, `list_triggers`, blocking/detached reporting, and local browser-open limits +- terminal task-outcome contract: `mcp:task-status`, agent/ownership context, serialized lifecycle ordering, and `fail_task` acknowledgement semantics -### Releasing 0.10.0 +### Releasing 0.11.0 -This 0.10.0 build is release-prep ready. The actual `npm publish` is a USER action via the existing tag-driven release workflow; autonomous mode does not run it. +This 0.11.0 build is release-prep ready. The actual `npm publish` is a USER action via the existing tag-driven release workflow; autonomous mode does not run it. To publish: -- Preferred: run the user's tag-driven release workflow (`git tag v0.10.0 && git push origin v0.10.0`); the workflow handles `npm publish` from a clean working tree. +- Preferred: run the MCP-only tag workflow (`git tag mcp-v0.11.0 && git push origin mcp-v0.11.0`); the workflow handles `npm publish` from a clean working tree. Repository milestone tags such as `v0.11.0` do not publish the MCP package. - Manual fallback: from the release branch, `cd mcp && npm publish` after confirming `npm whoami`, `npm --prefix mcp run build` exit 0, and the MCP smoke/parity/schema gates exit 0. Do not run `npm publish` from autonomous mode. diff --git a/mcp/ai/tool-definitions.cjs b/mcp/ai/tool-definitions.cjs index b08622e74..a25bae83f 100644 --- a/mcp/ai/tool-definitions.cjs +++ b/mcp/ai/tool-definitions.cjs @@ -1136,11 +1136,12 @@ const TOOL_REGISTRY = [ { name: 'complete_task', - description: 'Signal that the task is fully complete. ONLY call this when the user\'s requested task has been fully achieved -- all data collected, all entries made, all actions performed. Include a summary of what was accomplished. Provide session_token only when completing a client-owned visual session created by start_visual_session; otherwise omit it and keep the normal task-lifecycle semantics. Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64).', + description: 'Signal that the task is fully complete. ONLY call this when the user\'s requested task has been fully achieved -- all data collected, all entries made, all actions performed. Include a summary of what was accomplished, but never include passwords, tokens, API keys, or sensitive form values. Provide tab_id when more than one tab/session could be active so the local task memory is associated exactly. Provide session_token only when completing a client-owned visual session created by start_visual_session; otherwise omit it and keep the normal task-lifecycle semantics. Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64).', inputSchema: { type: 'object', properties: { summary: { type: 'string', description: 'Summary of what was accomplished (e.g. "Found 50 Tesla internships and added them to Google Sheet with title, department, location columns")' }, + tab_id: { type: 'integer', description: 'Optional target tab id used to associate this outcome with the correct local MCP replay session.' }, session_token: { type: 'string', description: 'Optional token returned by start_visual_session. Provide this only when finalizing a client-owned visual session.' @@ -1158,13 +1159,14 @@ const TOOL_REGISTRY = [ { name: 'partial_task', - description: 'Signal that the task is partially complete because useful work was completed but an external blocker prevents the final step. Use this instead of fail_task when the user can still benefit from the completed work, especially for auth/manual handoff blockers after research, drafting, or data entry is already done. Auth/manual blockers include login required, no saved credentials, user skipped login, credentials failed, and manual approval, MFA, or external verification. Preserve three things clearly: what you completed, the exact blocker, and the manual next step the user should take. If the runtime offers one saved-credential or operator-prompt attempt, let that single attempt happen first; call partial_task only after that attempt is unavailable, skipped, exhausted, or fails. Provide session_token only when finalizing a client-owned visual session created by start_visual_session. Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64).', + description: 'Signal that the task is partially complete because useful work was completed but an external blocker prevents the final step. Use this instead of fail_task when the user can still benefit from the completed work, especially for auth/manual handoff blockers after research, drafting, or data entry is already done. Auth/manual blockers include login required, no saved credentials, user skipped login, credentials failed, and manual approval, MFA, or external verification. Preserve three things clearly: what you completed, the exact blocker, and the manual next step the user should take, but never include passwords, tokens, API keys, or sensitive form values. If the runtime offers one saved-credential or operator-prompt attempt, let that single attempt happen first; call partial_task only after that attempt is unavailable, skipped, exhausted, or fails. Provide tab_id when more than one tab/session could be active. Provide session_token only when finalizing a client-owned visual session created by start_visual_session. Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64).', inputSchema: { type: 'object', properties: { summary: { type: 'string', description: 'Summary of the useful work that was completed before the blocker was hit' }, blocker: { type: 'string', description: 'What prevented the final step from being completed (e.g. "Messaging requires login", "Manual approval required")' }, next_step: { type: 'string', description: 'Manual next step the user can take to finish manually or resume later. Include this for auth or approval blockers.' }, + tab_id: { type: 'integer', description: 'Optional target tab id used to associate this outcome with the correct local MCP replay session.' }, reason: { type: 'string', description: 'Optional machine-readable blocker category. Keep it narrow and stable for blocked/manual-handoff outcomes.', @@ -1187,11 +1189,12 @@ const TOOL_REGISTRY = [ { name: 'fail_task', - description: 'Signal that the task cannot be completed. Include the reason why. Call this instead of just stopping when you encounter an unrecoverable problem. Provide session_token only when ending a client-owned visual session created by start_visual_session; otherwise omit it and keep the normal task-failure semantics. Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64).', + description: 'Signal that the task cannot be completed. Include the reason why, but never include passwords, tokens, API keys, or sensitive form values. Call this instead of just stopping when you encounter an unrecoverable problem. Provide tab_id when more than one tab/session could be active. Provide session_token only when ending a client-owned visual session created by start_visual_session; otherwise omit it and keep the normal task-failure semantics. Multi-agent: agent-scoped tabs; cross-agent reject with TAB_NOT_OWNED; cap configurable (default 8, 1-64).', inputSchema: { type: 'object', properties: { reason: { type: 'string', description: 'Why the task cannot be completed (e.g. "Page requires login", "Data not found on page")' }, + tab_id: { type: 'integer', description: 'Optional target tab id used to associate this outcome with the correct local MCP replay session.' }, session_token: { type: 'string', description: 'Optional token returned by start_visual_session. Provide this only when failing a client-owned visual session.' diff --git a/mcp/build/version.d.ts b/mcp/build/version.d.ts index b559bdaa4..44cd9ca6b 100644 --- a/mcp/build/version.d.ts +++ b/mcp/build/version.d.ts @@ -1,5 +1,5 @@ export declare const FSB_SERVER_NAME = "fsb"; -export declare const FSB_MCP_VERSION = "0.10.0"; +export declare const FSB_MCP_VERSION = "0.11.0"; export declare const FSB_EXTENSION_BRIDGE_PORT = 7225; export declare const FSB_EXTENSION_BRIDGE_URL = "ws://localhost:7225"; export declare const DEFAULT_HTTP_HOST = "127.0.0.1"; diff --git a/mcp/build/version.js b/mcp/build/version.js index 69cb25527..d93452174 100644 --- a/mcp/build/version.js +++ b/mcp/build/version.js @@ -1,5 +1,5 @@ export const FSB_SERVER_NAME = 'fsb'; -export const FSB_MCP_VERSION = '0.10.0'; +export const FSB_MCP_VERSION = '0.11.0'; export const FSB_EXTENSION_BRIDGE_PORT = 7225; export const FSB_EXTENSION_BRIDGE_URL = `ws://localhost:${FSB_EXTENSION_BRIDGE_PORT}`; export const DEFAULT_HTTP_HOST = '127.0.0.1'; diff --git a/mcp/package-lock.json b/mcp/package-lock.json index 8fc2f9bfe..643990a72 100644 --- a/mcp/package-lock.json +++ b/mcp/package-lock.json @@ -1,12 +1,12 @@ { "name": "fsb-mcp-server", - "version": "0.10.0", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fsb-mcp-server", - "version": "0.10.0", + "version": "0.11.0", "license": "BUSL-1.1", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/mcp/package.json b/mcp/package.json index 4424ee9f4..815baf9a6 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "fsb-mcp-server", - "version": "0.10.0", + "version": "0.11.0", "description": "FSB Browser Automation MCP Server", "license": "BUSL-1.1", "author": "Lakshman Turlapati", diff --git a/mcp/server.json b/mcp/server.json index c4bb1530a..b2a625311 100644 --- a/mcp/server.json +++ b/mcp/server.json @@ -3,12 +3,12 @@ "name": "io.github.fullselfbrowsing/fsb-mcp-server", "title": "FSB MCP Server", "description": "Control the FSB browser extension from any MCP client using a local stdio or Streamable HTTP companion runtime.", - "version": "0.10.0", + "version": "0.11.0", "packages": [ { "registryType": "npm", "identifier": "fsb-mcp-server", - "version": "0.10.0", + "version": "0.11.0", "transport": { "type": "stdio" } diff --git a/mcp/src/queue.ts b/mcp/src/queue.ts index 841928d7d..b31495d33 100644 --- a/mcp/src/queue.ts +++ b/mcp/src/queue.ts @@ -11,6 +11,14 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const toolDefs = require(path.resolve(__dirname, '../ai/tool-definitions.cjs')); const registryReadOnly: string[] = toolDefs.getReadOnlyTools().map((t: { name: string }) => t.name); +// These tools are registry-read-only for schema and registration purposes, but +// their terminal recorder state must be ordered after any pending mutation. +const SERIALIZED_LIFECYCLE_TOOLS = new Set([ + 'complete_task', + 'partial_task', + 'fail_task', +]); + // --------------------------------------------------------------------------- // TaskQueue // --------------------------------------------------------------------------- @@ -26,9 +34,10 @@ export class TaskQueue { private running = false; // Derived from tool-definitions.js registry + non-registry read-only tools - // (observability, agents, etc. registered by agents.ts and observability.ts) + // (observability, agents, etc. registered by agents.ts and observability.ts). + // Terminal lifecycle tools are deliberately excluded so they serialize. private readonly readOnlyTools = new Set([ - ...registryReadOnly, + ...registryReadOnly.filter(toolName => !SERIALIZED_LIFECYCLE_TOOLS.has(toolName)), // Non-registry read-only tools 'get_task_status', 'get_site_guides', diff --git a/mcp/src/tools/read-only.ts b/mcp/src/tools/read-only.ts index 14cfe805e..5958c8267 100644 --- a/mcp/src/tools/read-only.ts +++ b/mcp/src/tools/read-only.ts @@ -61,6 +61,18 @@ const MESSAGE_TYPE_MAP: Record< ...(p.domain ? { domain: p.domain, url: p.domain } : {}), }, }), + complete_task: (p) => ({ + type: 'mcp:task-status', + payload: { tool: 'complete_task', params: { ...p } }, + }), + partial_task: (p) => ({ + type: 'mcp:task-status', + payload: { tool: 'partial_task', params: { ...p } }, + }), + fail_task: (p) => ({ + type: 'mcp:task-status', + payload: { tool: 'fail_task', params: { ...p } }, + }), }; // --------------------------------------------------------------------------- @@ -80,9 +92,10 @@ const TIMEOUT_OVERRIDES: Record = { /** * Register read-only information tools from the shared TOOL_REGISTRY. - * These bypass the TaskQueue mutation serialization (their names are in - * the readOnlyTools set) so they can execute even while a mutation tool - * is running. + * Most bypass TaskQueue mutation serialization so they can execute while a + * mutation is running. The terminal lifecycle tools remain registered here, + * but TaskQueue deliberately serializes them so their outcomes cannot overtake + * the final mutation. * * Each tool's JSON Schema inputSchema is converted to Zod on the fly. * Bridge message types are resolved via MESSAGE_TYPE_MAP. @@ -127,6 +140,20 @@ export function registerReadOnlyTools( built.payload as Record, { timeout, targetTabId }, ); + // fail_task reports the task's outcome with success:false even when + // the dispatcher successfully recorded it. Treat only that canonical + // lifecycle envelope as an acknowledgement; genuine tool failures + // must still flow through mapFSBError. + if ( + tool.name === 'fail_task' && + result.success === false && + result.tool === 'fail_task' && + result.status === 'failed' + ) { + return { + content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }], + }; + } return mapFSBError(result); }); }, diff --git a/mcp/src/types.ts b/mcp/src/types.ts index 4a95ba053..66ddbab45 100644 --- a/mcp/src/types.ts +++ b/mcp/src/types.ts @@ -16,6 +16,7 @@ export type MCPMessageType = | 'mcp:list-triggers' // List persisted trigger snapshots | 'mcp:start-visual-session' // MCP-owned visible lifecycle start | 'mcp:end-visual-session' // MCP-owned visible lifecycle end + | 'mcp:task-status' // Client-authored complete/partial/fail summary | 'mcp:execute-action' // Manual: execute a single browser action | 'mcp:go-back' // Phase 242 D-01: ownership-gated single-step browser-history back | 'mcp:get-dom' // Read DOM snapshot diff --git a/mcp/src/version.ts b/mcp/src/version.ts index 27049bff6..43c5c93b9 100644 --- a/mcp/src/version.ts +++ b/mcp/src/version.ts @@ -1,5 +1,5 @@ export const FSB_SERVER_NAME = 'fsb'; -export const FSB_MCP_VERSION = '0.10.0'; +export const FSB_MCP_VERSION = '0.11.0'; export const FSB_EXTENSION_BRIDGE_PORT = 7225; export const FSB_EXTENSION_BRIDGE_URL = `ws://localhost:${FSB_EXTENSION_BRIDGE_PORT}`; export const DEFAULT_HTTP_HOST = '127.0.0.1'; diff --git a/package-lock.json b/package-lock.json index d52ab2c5c..adadeb374 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fsb-full-self-browsing", - "version": "0.9.90", + "version": "0.9.91", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fsb-full-self-browsing", - "version": "0.9.90", + "version": "0.9.91", "license": "BUSL-1.1", "os": [ "darwin", diff --git a/package.json b/package.json index 9fd74952a..c313e7196 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fsb-full-self-browsing", - "version": "0.9.90", + "version": "0.9.91", "description": "FSB - Full Self-Browsing: An intelligent, open-source Chrome extension for AI-powered browser automation using natural language instructions", "main": "background.js", "homepage": "https://github.com/fullselfbrowsing/FSB", @@ -14,14 +14,14 @@ "scripts": { "build": "node esbuild.config.js", "build:cfworker": "node_modules/.bin/esbuild node_modules/@cfworker/json-schema/dist/esm/index.js --bundle --format=iife --global-name=CfworkerJsonSchema --platform=browser --target=chrome120 --legal-comments=none --outfile=extension/lib/cfworker-json-schema.min.js", - "test": "node tests/test-overlay-state.js && node tests/cost-tracker-ordering.test.js && node tests/runtime-contracts.test.js && node tests/ai-integration-analytics.test.js && node tests/dashboard-runtime-state.test.js && node tests/task-router.test.js && npm --prefix mcp run build && node tests/mcp-bridge-client-lifecycle.test.js && node tests/mcp-bridge-topology.test.js && node tests/mcp-bridge-background-dispatch.test.js && node tests/mcp-tool-routing-contract.test.js && node tests/mcp-visual-session-contract.test.js && node tests/mcp-restricted-tab.test.js && node tests/mcp-recovery-messaging.test.js && node tests/mcp-in-flight-session-lookup.test.js && node tests/mcp-version-parity.test.js && node tests/mcp-diagnostics-status.test.js && node tests/mcp-install-platforms.test.js && node tests/mcp-setup-guidance.test.js && node tests/turn-result.test.js && node tests/action-history.test.js && node tests/universal-provider-lmstudio.test.js && node tests/config-lmstudio.test.js && node tests/cost-tracker.test.js && node tests/install-identity.test.js && node tests/mcp-pricing.test.js && node tests/mcp-pricing-data-parity.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-dispatcher-client-label.test.js && node tests/mcp-session-recorder.test.js && node tests/mcp-metrics-no-pii-leak.test.js && node tests/telemetry-collector.test.js && node tests/telemetry-collector-strips-internal-fields.test.js && node tests/telemetry-payload-allowlist.test.js && node tests/extension-record-dispatch-serializes.test.js && node tests/server-trust-proxy.test.js && node tests/server-telemetry-salt-rotation.test.js && node tests/server-telemetry-rate-limit.test.js && node tests/server-telemetry-body-cap.test.js && node tests/server-telemetry-batch-cap.test.js && node tests/server-telemetry-allowlist.test.js && node tests/server-telemetry-event-id-dedup.test.js && node tests/server-telemetry-timestamp-tolerance.test.js && node tests/server-telemetry-daily-budget.test.js && node tests/server-telemetry-sec-gpc.test.js && node tests/server-telemetry-optout-forget.test.js && node tests/server-telemetry-housekeeper.test.js && node tests/server-public-stats-headline.test.js && node tests/server-public-stats-series.test.js && node tests/server-public-stats-cache.test.js && node tests/server-public-stats-no-auth.test.js && node tests/server-github-cache.test.js && node tests/server-github-poller.test.js && node tests/fsb-telemetry-service.test.js && node tests/showcase-build-smoke.test.js && node tests/stats-chart-overhaul.test.js && node scripts/verify-store-listing.mjs && node tests/verify-store-listing.test.js && node tests/cumulative-commits-aggregator.test.js && node tests/dashboard-stream-readiness-ping.test.js && node tests/dashboard-stream-pending-intent.test.js && node tests/dashboard-stream-recovery-parity.test.js && node tests/server-ws-backpressure.test.js && node tests/server-ws-phantomstream-relay-compat.test.js && node tests/showcase-privacy-page.test.js && node tests/server-no-ip-leak.test.js && node tests/server-ip-geo.test.js && node tests/server-ip-geo-merge.test.js && node tests/server-ip-geo-scale.test.js && node tests/server-region-aggregation.test.js && node tests/transcript-store.test.js && node tests/hook-pipeline.test.js && node tests/session-schema.test.js && node tests/state-emitter.test.js && node tests/secure-config-credential-vault.test.js && node tests/qr-pairing.test.js && node tests/ws-client-decompress.test.js && node tests/dashboard-task-direct-start.test.js && node tests/phantom-stream-protocol-envelope.test.js && node tests/phantom-stream-differential-parity.test.js && node tests/phantom-stream-remote-control-parity.test.js && node tests/phantom-stream-public-package.test.js && node tests/phantom-stream-exports.test.js && node tests/phantom-stream-content-bundle.test.js && node tests/phantom-stream-capture-adapter.test.js && node tests/phantom-stream-sidechannels.test.js && node tests/phantom-stream-security-masking.test.js && node tests/phantom-stream-dashboard-viewer-bundle.test.js && node tests/phantom-stream-static-viewer.test.js && node tests/phantom-stream-dashboard-parity.test.js && node tests/phantom-stream-dashboard-sidechannels.test.js && node tests/phantom-stream-media-sync.test.js && node tests/phantom-stream-media-wiring.test.js && node tests/dom-stream-perf.test.js && node tests/extension-content-script-files-completeness.test.js && node tests/dom-analysis-viewport-priority.test.js && node tests/redact-for-log.test.js && node tests/diagnostics-ring-buffer.test.js && node tests/agent-sunset-back-end.test.js && node tests/agent-sunset-control-panel.test.js && node tests/agent-sunset-showcase.test.js && node tests/sync-tab-runtime.test.js && node tests/remote-control-rebrand.test.js && node tests/server-accept-language.test.js && node tests/metrics-wireup.test.js && node tests/dashboard-metrics-render.test.js && node tests/stuck-action-repetition.test.js && node tests/goal-progress-tracker.test.js && node tests/model-discovery.test.js && node tests/model-discovery-ui.test.js && node tests/model-combobox-ui.test.js && node tests/settings-card-select-clipping.test.js && node tests/control-panel-scroll-containment.test.js && node tests/model-discovery-runtime.test.js && node tests/overlay-stability-cadence.test.js && node tests/overlay-content-audit.test.js && node tests/meta-cognitive-tracker.test.js && node tests/agent-cap-recommendation.test.js && node tests/agent-cap-storage.test.js && node tests/agent-cap-ui.test.js && node tests/agent-registry.test.js && node tests/selected-tab-state.test.js && node tests/change-report-builder.test.js && node tests/change-report-size-cap.test.js && node tests/change-report-cross-origin.test.js && node tests/change-report-dispatcher.test.js && node tests/change-report-read-tools-excluded.test.js && node tests/change-report-toggle.test.js && node tests/change-report-settings-ui.test.js && node tests/agent-tab-resolver.test.js && node tests/read-tool-tab-resolution.test.js && node tests/open-tab-background-default.test.js && node tests/visual-session-agent-scoped.test.js && node tests/action-tool-agent-scoped.test.js && node tests/recovery-tools-bootstrap.test.js && node tests/legacy-agent-synthesis.test.js && node tests/visual-session-reentry.test.js && node tests/ownership-error-codes.test.js && node tests/multi-agent-regression.test.js && node tests/tool-definitions-parity.test.js && node tests/mcp-numeric-param-coercion.test.js && node tests/visual-session-schema-lock.test.js && node tests/mcp-visual-tick-lifecycle.test.js && node tests/agent-id-threading.test.js && node tests/skill-fsb-spec.test.js && node tests/agent-loop-empty-contents.test.js && node --test tests/google-sheets-session.test.js tests/google-sheets-content-actions.test.js tests/gsheets-handler.test.js tests/google-sheets-wiring.test.js tests/spreadsheet-record-redaction.test.js && node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js && node tests/mcp-philosophy-parity-smoke.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js && node tests/sidepanel-mcpvisualsession-listener.test.js && node tests/sidepanel-tab-scoping-fix-redo-smoke.test.js && node tests/sidepanel-progress-tick-setter-routing.test.js && node tests/sidepanel-background-tab-completion.test.js && node tests/sidepanel-multi-document-fanout.test.js && node tests/trigger-store.test.js && node tests/trigger-lifecycle.test.js && node tests/value-extractor.test.js && node tests/trigger-manager.test.js && node tests/trigger-cap.test.js && node tests/trigger-observe.test.js && node tests/trigger-observe-pulse.test.js && node tests/trigger-refresh-poll.test.js && node tests/trigger-tool-dispatcher.test.js && node tests/trigger-blocking-reporting.test.js && node tests/capability-recipe-schema.test.js && node tests/capability-interpreter.test.js && node tests/recipe-path-guard.test.js && node tests/capability-fetch.test.js && node tests/capability-search-eval.test.js && node tests/capability-mcp-surface.test.js && node tests/capability-router.test.js && node tests/capability-autopilot-parity.test.js && node tests/capability-head-handlers.test.js && node tests/consent-policy-store.test.js && node tests/consent-gate.test.js && node tests/consent-mutation-gate.test.js && node tests/consent-chokepoint.test.js && node tests/audit-log.test.js && node tests/audit-log-no-secret.test.js && node tests/recipe-signature.test.js && node tests/recipe-signature-interpreter-hook.test.js && node tests/service-denylist.test.js && node tests/classification-gate.test.js && node tests/provenance-scaffold.test.js && node tests/upload-file-route.test.js && node tests/upload-path-denylist.test.js && node tests/consent-audit-settings-ui.test.js && node tests/agent-loop-iterator-guard.test.js && node tests/network-capture.test.js && node tests/network-capture-consent.test.js && node tests/network-capture-redaction.test.js && node tests/recipe-synthesizer.test.js && node tests/learned-recipe-store.test.js && node tests/learned-search-add.test.js && node tests/learned-t2-outranking.test.js && node tests/learned-promote-after-replay.test.js && node tests/discovery-seeds-load.test.js && node tests/seed-resolve-t2.test.js && node tests/recipe-learn-pending-affordance.test.js && node tests/learned-local-provenance-exempt.test.js && node tests/provider-parity.test.js && node tests/recipe-schema-lock.test.js && node tests/capability-rot-detector.test.js && node tests/relearn-coalescing.test.js && node tests/rot-recurrence-classify.test.js && node tests/app-degraded-surfacing.test.js && node tests/relearn-router-wiring.test.js && node tests/import-extraction.test.js && node tests/import-forbidden-prescan.test.js && node tests/import-classify-gate-call.test.js && node tests/catalog-crosscheck.test.js && node tests/no-dead-entry.test.js && node tests/catalog-inline-shape.test.js && node tests/head-handler-cap.test.js && node tests/head-handler-upgrade.test.js && node tests/confluence-t1-handler.test.js && node tests/verify-origin-classification.test.js && node tests/guarded-write-failclosed.test.js && node tests/breadth-search-return.test.js && node tests/breadth-batch-gate.test.js && node tests/backing-status-annotation.test.js && node tests/t1-port-contract.test.js && node tests/t1-port-contract-gate.test.js && node tests/dom-only-default.test.js && node tests/sensitive-write-import-gate.test.js && node tests/coverage-report.test.js && node tests/payment-op-guard.test.js && node tests/vendor-augment-preserved.test.js && node tests/no-duplicate-stem.test.js && node tests/full-corpus-screen.test.js && node tests/full-corpus-scale.test.js && node tests/keyboard-attach-robustness.test.js && node --import tsx tests/no-orphan-descriptor.test.js", + "test": "node tests/test-overlay-state.js && node tests/cost-tracker-ordering.test.js && node tests/runtime-contracts.test.js && node tests/ai-integration-analytics.test.js && node tests/dashboard-runtime-state.test.js && node tests/task-router.test.js && npm --prefix mcp run build && node tests/mcp-bridge-client-lifecycle.test.js && node tests/mcp-bridge-topology.test.js && node tests/mcp-bridge-background-dispatch.test.js && node tests/mcp-tool-routing-contract.test.js && node tests/mcp-visual-session-contract.test.js && node tests/mcp-restricted-tab.test.js && node tests/mcp-recovery-messaging.test.js && node tests/mcp-in-flight-session-lookup.test.js && node tests/mcp-version-parity.test.js && node tests/mcp-diagnostics-status.test.js && node tests/mcp-install-platforms.test.js && node tests/mcp-setup-guidance.test.js && node tests/turn-result.test.js && node tests/action-history.test.js && node tests/universal-provider-lmstudio.test.js && node tests/config-lmstudio.test.js && node tests/cost-tracker.test.js && node tests/install-identity.test.js && node tests/mcp-pricing.test.js && node tests/mcp-pricing-data-parity.test.js && node tests/mcp-metrics-recorder.test.js && node tests/mcp-dispatcher-client-label.test.js && node tests/mcp-session-recorder.test.js && node tests/automation-logger-mcp-retention.test.js && node tests/mcp-session-settings-ui.test.js && node tests/knowledge-graph-dedup.test.js && node tests/mcp-metrics-no-pii-leak.test.js && node tests/telemetry-collector.test.js && node tests/telemetry-collector-strips-internal-fields.test.js && node tests/telemetry-payload-allowlist.test.js && node tests/extension-record-dispatch-serializes.test.js && node tests/server-trust-proxy.test.js && node tests/server-telemetry-salt-rotation.test.js && node tests/server-telemetry-rate-limit.test.js && node tests/server-telemetry-body-cap.test.js && node tests/server-telemetry-batch-cap.test.js && node tests/server-telemetry-allowlist.test.js && node tests/server-telemetry-event-id-dedup.test.js && node tests/server-telemetry-timestamp-tolerance.test.js && node tests/server-telemetry-daily-budget.test.js && node tests/server-telemetry-sec-gpc.test.js && node tests/server-telemetry-optout-forget.test.js && node tests/server-telemetry-housekeeper.test.js && node tests/server-public-stats-headline.test.js && node tests/server-public-stats-series.test.js && node tests/server-public-stats-cache.test.js && node tests/server-public-stats-no-auth.test.js && node tests/server-github-cache.test.js && node tests/server-github-poller.test.js && node tests/fsb-telemetry-service.test.js && node tests/showcase-build-smoke.test.js && node tests/stats-chart-overhaul.test.js && node scripts/verify-store-listing.mjs && node tests/verify-store-listing.test.js && node tests/cumulative-commits-aggregator.test.js && node tests/dashboard-stream-readiness-ping.test.js && node tests/dashboard-stream-pending-intent.test.js && node tests/dashboard-stream-recovery-parity.test.js && node tests/server-ws-backpressure.test.js && node tests/server-ws-phantomstream-relay-compat.test.js && node tests/showcase-privacy-page.test.js && node tests/server-no-ip-leak.test.js && node tests/server-ip-geo.test.js && node tests/server-ip-geo-merge.test.js && node tests/server-ip-geo-scale.test.js && node tests/server-region-aggregation.test.js && node tests/transcript-store.test.js && node tests/hook-pipeline.test.js && node tests/session-schema.test.js && node tests/state-emitter.test.js && node tests/secure-config-credential-vault.test.js && node tests/qr-pairing.test.js && node tests/ws-client-decompress.test.js && node tests/dashboard-task-direct-start.test.js && node tests/phantom-stream-protocol-envelope.test.js && node tests/phantom-stream-differential-parity.test.js && node tests/phantom-stream-remote-control-parity.test.js && node tests/phantom-stream-public-package.test.js && node tests/phantom-stream-exports.test.js && node tests/phantom-stream-content-bundle.test.js && node tests/phantom-stream-capture-adapter.test.js && node tests/phantom-stream-sidechannels.test.js && node tests/phantom-stream-security-masking.test.js && node tests/phantom-stream-dashboard-viewer-bundle.test.js && node tests/phantom-stream-static-viewer.test.js && node tests/phantom-stream-dashboard-parity.test.js && node tests/phantom-stream-dashboard-sidechannels.test.js && node tests/phantom-stream-media-sync.test.js && node tests/phantom-stream-media-wiring.test.js && node tests/dom-stream-perf.test.js && node tests/extension-content-script-files-completeness.test.js && node tests/dom-analysis-viewport-priority.test.js && node tests/redact-for-log.test.js && node tests/diagnostics-ring-buffer.test.js && node tests/agent-sunset-back-end.test.js && node tests/agent-sunset-control-panel.test.js && node tests/agent-sunset-showcase.test.js && node tests/sync-tab-runtime.test.js && node tests/remote-control-rebrand.test.js && node tests/server-accept-language.test.js && node tests/metrics-wireup.test.js && node tests/dashboard-metrics-render.test.js && node tests/stuck-action-repetition.test.js && node tests/goal-progress-tracker.test.js && node tests/model-discovery.test.js && node tests/model-discovery-ui.test.js && node tests/model-combobox-ui.test.js && node tests/settings-card-select-clipping.test.js && node tests/control-panel-scroll-containment.test.js && node tests/model-discovery-runtime.test.js && node tests/overlay-stability-cadence.test.js && node tests/overlay-content-audit.test.js && node tests/meta-cognitive-tracker.test.js && node tests/agent-cap-recommendation.test.js && node tests/agent-cap-storage.test.js && node tests/agent-cap-ui.test.js && node tests/agent-registry.test.js && node tests/selected-tab-state.test.js && node tests/change-report-builder.test.js && node tests/change-report-size-cap.test.js && node tests/change-report-cross-origin.test.js && node tests/change-report-dispatcher.test.js && node tests/change-report-read-tools-excluded.test.js && node tests/change-report-toggle.test.js && node tests/change-report-settings-ui.test.js && node tests/agent-tab-resolver.test.js && node tests/read-tool-tab-resolution.test.js && node tests/open-tab-background-default.test.js && node tests/visual-session-agent-scoped.test.js && node tests/action-tool-agent-scoped.test.js && node tests/recovery-tools-bootstrap.test.js && node tests/legacy-agent-synthesis.test.js && node tests/visual-session-reentry.test.js && node tests/ownership-error-codes.test.js && node tests/multi-agent-regression.test.js && node tests/tool-definitions-parity.test.js && node tests/mcp-numeric-param-coercion.test.js && node tests/visual-session-schema-lock.test.js && node tests/mcp-visual-tick-lifecycle.test.js && node tests/agent-id-threading.test.js && node tests/skill-fsb-spec.test.js && node tests/agent-loop-empty-contents.test.js && node --test tests/google-sheets-session.test.js tests/google-sheets-content-actions.test.js tests/gsheets-handler.test.js tests/google-sheets-wiring.test.js tests/spreadsheet-record-redaction.test.js && node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js && node tests/mcp-philosophy-parity-smoke.test.js && node tests/sidepanel-tab-aware-smoke.test.js && node tests/sidepanel-message-log-smoke.test.js && node tests/sidepanel-mcpvisualsession-listener.test.js && node tests/sidepanel-tab-scoping-fix-redo-smoke.test.js && node tests/sidepanel-progress-tick-setter-routing.test.js && node tests/sidepanel-background-tab-completion.test.js && node tests/sidepanel-multi-document-fanout.test.js && node tests/trigger-store.test.js && node tests/trigger-lifecycle.test.js && node tests/value-extractor.test.js && node tests/trigger-manager.test.js && node tests/trigger-cap.test.js && node tests/trigger-observe.test.js && node tests/trigger-observe-pulse.test.js && node tests/trigger-refresh-poll.test.js && node tests/trigger-tool-dispatcher.test.js && node tests/trigger-blocking-reporting.test.js && node tests/capability-recipe-schema.test.js && node tests/capability-interpreter.test.js && node tests/recipe-path-guard.test.js && node tests/capability-fetch.test.js && node tests/capability-search-eval.test.js && node tests/capability-mcp-surface.test.js && node tests/capability-router.test.js && node tests/capability-autopilot-parity.test.js && node tests/capability-head-handlers.test.js && node tests/consent-policy-store.test.js && node tests/consent-gate.test.js && node tests/consent-mutation-gate.test.js && node tests/consent-chokepoint.test.js && node tests/audit-log.test.js && node tests/audit-log-no-secret.test.js && node tests/recipe-signature.test.js && node tests/recipe-signature-interpreter-hook.test.js && node tests/service-denylist.test.js && node tests/classification-gate.test.js && node tests/provenance-scaffold.test.js && node tests/upload-file-route.test.js && node tests/upload-path-denylist.test.js && node tests/consent-audit-settings-ui.test.js && node tests/agent-loop-iterator-guard.test.js && node tests/network-capture.test.js && node tests/network-capture-consent.test.js && node tests/network-capture-redaction.test.js && node tests/recipe-synthesizer.test.js && node tests/learned-recipe-store.test.js && node tests/learned-search-add.test.js && node tests/learned-t2-outranking.test.js && node tests/learned-promote-after-replay.test.js && node tests/discovery-seeds-load.test.js && node tests/seed-resolve-t2.test.js && node tests/recipe-learn-pending-affordance.test.js && node tests/learned-local-provenance-exempt.test.js && node tests/provider-parity.test.js && node tests/recipe-schema-lock.test.js && node tests/capability-rot-detector.test.js && node tests/relearn-coalescing.test.js && node tests/rot-recurrence-classify.test.js && node tests/app-degraded-surfacing.test.js && node tests/relearn-router-wiring.test.js && node tests/import-extraction.test.js && node tests/import-forbidden-prescan.test.js && node tests/import-classify-gate-call.test.js && node tests/catalog-crosscheck.test.js && node tests/no-dead-entry.test.js && node tests/catalog-inline-shape.test.js && node tests/head-handler-cap.test.js && node tests/head-handler-upgrade.test.js && node tests/confluence-t1-handler.test.js && node tests/verify-origin-classification.test.js && node tests/guarded-write-failclosed.test.js && node tests/breadth-search-return.test.js && node tests/breadth-batch-gate.test.js && node tests/backing-status-annotation.test.js && node tests/t1-port-contract.test.js && node tests/t1-port-contract-gate.test.js && node tests/dom-only-default.test.js && node tests/sensitive-write-import-gate.test.js && node tests/coverage-report.test.js && node tests/payment-op-guard.test.js && node tests/vendor-augment-preserved.test.js && node tests/no-duplicate-stem.test.js && node tests/full-corpus-screen.test.js && node tests/full-corpus-scale.test.js && node tests/keyboard-attach-robustness.test.js && node --import tsx tests/no-orphan-descriptor.test.js", "test:lattice": "node tests/lattice-public-package.test.js && node tests/lattice-smoke.test.js && node tests/lattice-tripwire-smoke.test.js && node tests/lattice-checkpoint-smoke.test.js && node tests/lattice-providers-smoke.test.js && node tests/lattice-survivability-smoke.test.js && node tests/lattice-provider-bridge-smoke.test.js && node tests/lattice-host-step-transition-smoke.test.js && node tests/lattice-step-emitter-smoke.test.js", "test:mcp-smoke:lifecycle": "npm --prefix mcp run build && node tests/mcp-lifecycle-smoke.test.js", "test:mcp-smoke:tools": "npm --prefix mcp run build && node tests/mcp-tool-smoke.test.js", "test:mcp-smoke": "npm --prefix mcp run build && node tests/mcp-lifecycle-smoke.test.js && node tests/mcp-tool-smoke.test.js", "lint": "echo \"Linting not configured yet\"", "clean": "rm -f config/dev-settings.json", - "package": "zip -r fsb-v0.9.90.zip . -x '*.git*' 'node_modules/*' 'config/dev-*'", + "package": "zip -r fsb-v0.9.91.zip . -x '*.git*' 'node_modules/*' 'config/dev-*'", "package:extension": "node scripts/package-extension.mjs", "package:skill": "node scripts/package-skill.mjs", "showcase:install": "npm --prefix showcase/angular install", @@ -126,7 +126,7 @@ { "description": "Version", "href": "https://github.com/fullselfbrowsing/FSB/releases", - "url": "https://img.shields.io/badge/version-0.9.90-blue.svg" + "url": "https://img.shields.io/badge/version-0.9.91-blue.svg" }, { "description": "License", diff --git a/showcase/about.html b/showcase/about.html index 8cf943565..79b92af29 100644 --- a/showcase/about.html +++ b/showcase/about.html @@ -330,7 +330,7 @@

The Best AI Tools of Marc

@@ -837,7 +837,7 @@

About this item

@@ -1101,7 +1101,7 @@

Project

diff --git a/showcase/angular/package-lock.json b/showcase/angular/package-lock.json index b5850fa3b..c877e80a0 100644 --- a/showcase/angular/package-lock.json +++ b/showcase/angular/package-lock.json @@ -1,12 +1,12 @@ { "name": "showcase-angular", - "version": "0.9.90", + "version": "0.9.91", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "showcase-angular", - "version": "0.9.90", + "version": "0.9.91", "dependencies": { "@angular/animations": "^20.3.19", "@angular/common": "^20.3.19", diff --git a/showcase/angular/package.json b/showcase/angular/package.json index 95a6578f3..272075499 100644 --- a/showcase/angular/package.json +++ b/showcase/angular/package.json @@ -1,6 +1,6 @@ { "name": "showcase-angular", - "version": "0.9.90", + "version": "0.9.91", "private": true, "scripts": { "ng": "ng", diff --git a/showcase/angular/public/llms-full.txt b/showcase/angular/public/llms-full.txt index 022c90851..7ad6988a7 100644 --- a/showcase/angular/public/llms-full.txt +++ b/showcase/angular/public/llms-full.txt @@ -1,9 +1,9 @@ - + # FSB (Full Self-Browsing) ## 1. Project Description -FSB (Full Self-Browsing) is an open-source Chrome extension that automates the browser through natural language. You describe a task in plain English; FSB plans the clicks, types, and navigation to complete it. The extension runs in your browser with your own API keys, encrypted local storage, and opt-out anonymous usage telemetry for aggregate public stats only. Multi-model AI (xAI Grok, OpenAI, Anthropic Claude, Google Gemini, OpenRouter, LM Studio, custom OpenAI-compatible endpoints), 56 canonical browser tools, a 66-tool MCP surface, 142+ site-specific guides, reactive trigger watchers, real file uploads, and a 128-app native capability catalog for verified first-party API execution. Current release: FSB v0.9.90 (Chrome extension) with fsb-mcp-server 0.10.0 (npm MCP package). BSL 1.1 licensed. +FSB (Full Self-Browsing) is an open-source Chrome extension that automates the browser through natural language. You describe a task in plain English; FSB plans the clicks, types, and navigation to complete it. The extension runs in your browser with your own API keys, encrypted local storage, and opt-out anonymous usage telemetry for aggregate public stats only. Multi-model AI (xAI Grok, OpenAI, Anthropic Claude, Google Gemini, OpenRouter, LM Studio, custom OpenAI-compatible endpoints), 56 canonical browser tools, a 66-tool MCP surface, 142+ site-specific guides, reactive trigger watchers, real file uploads, and a 128-app native capability catalog for verified first-party API execution. Current release: FSB v0.9.91 (Chrome extension) with fsb-mcp-server 0.11.0 (npm MCP package). BSL 1.1 licensed. FSB also works especially well as an MCP browser layer for AI agents. Claude Code, Codex, Cursor, VS Code, Windsurf, OpenClaw, Hermes, and other MCP clients can use FSB to operate a real Chrome browser, inspect page state, verify outcomes, and feed observations back into their own coding or autonomy loop. @@ -130,7 +130,7 @@ FSB exposes its tooling through a Model Context Protocol (MCP) server. External The MCP surface is the best way to use FSB with coding agents and external autonomy layers. FSB handles precise single-attempt browser execution, visual feedback, observability, and verification. Long-running scheduling and personality/identity decisions belong in the calling agent layer, such as OpenClaw or Claude Routines. ### OpenClaw skill -FSB ships a dedicated OpenClaw + Hermes skill at `skills/fsb/` in the repo root. The skill is the canonical OpenClaw onboarding path: it runs the doctor flow against `fsb-mcp-server`, prints the OpenClaw stdio config block for the user to paste into OpenClaw's MCP config, and offers consent-gated install for any other MCP hosts detected on the same machine. The bare `--openclaw` flag in the FSB MCP installer stays manual / unsupported because OpenClaw's MCP config schema is still unstable across builds; the skill prints, the user pastes, no auto-write. Frontmatter ships with `name: fsb`, `version: 0.9.90`, `requires.bins: [node, npx]`, and `requires.env: []` so vault credentials never leave the FSB Chrome extension. The full marketing rundown (skill features, MCP power story, 3-step install, grounded use cases, autopilot rules) lives at `https://full-selfbrowsing.com/agents`. Hermes users get the same skill: `node skills/fsb/scripts/print-hermes-yaml.mjs` prints the canonical `~/.hermes/config.yaml` `mcp_servers.fsb` block, and FSB v0.9.69 (PR #49) added `Hermes` to the v0.9.36 shared MCP client allowlist so action calls passing `client: "Hermes"` work without schema changes. +FSB ships a dedicated OpenClaw + Hermes skill at `skills/fsb/` in the repo root. The skill is the canonical OpenClaw onboarding path: it runs the doctor flow against `fsb-mcp-server`, prints the OpenClaw stdio config block for the user to paste into OpenClaw's MCP config, and offers consent-gated install for any other MCP hosts detected on the same machine. The bare `--openclaw` flag in the FSB MCP installer stays manual / unsupported because OpenClaw's MCP config schema is still unstable across builds; the skill prints, the user pastes, no auto-write. Frontmatter ships with `name: fsb`, `version: 0.9.91`, `requires.bins: [node, npx]`, and `requires.env: []` so vault credentials never leave the FSB Chrome extension. The full marketing rundown (skill features, MCP power story, 3-step install, grounded use cases, autopilot rules) lives at `https://full-selfbrowsing.com/agents`. Hermes users get the same skill: `node skills/fsb/scripts/print-hermes-yaml.mjs` prints the canonical `~/.hermes/config.yaml` `mcp_servers.fsb` block, and FSB v0.9.69 (PR #49) added `Hermes` to the v0.9.36 shared MCP client allowlist so action calls passing `client: "Hermes"` work without schema changes. ### ClawHub install path The /agents page exposes a one-click install on ClawHub at `https://clawhub.ai/lakshmanturlapati/full-selfbrowsing`. ClawHub is the recommended distribution surface for OpenClaw users who want the FSB skill registered without manually pasting stdio config. The skill source remains browsable at `https://github.com/fullselfbrowsing/FSB/tree/main/skills/fsb` for users who prefer reading before installing. diff --git a/showcase/angular/public/llms.txt b/showcase/angular/public/llms.txt index a55835aa7..d3406b16f 100644 --- a/showcase/angular/public/llms.txt +++ b/showcase/angular/public/llms.txt @@ -1,7 +1,7 @@ - + # FSB (Full Self-Browsing) -FSB (Full Self-Browsing) is an open-source Chrome extension that automates the browser through natural language. You describe a task; FSB plans the clicks, types, and navigation to complete it. Multi-model AI (xAI, OpenAI, Anthropic, Gemini, OpenRouter, LM Studio, local), 56 browser tools, a 66-tool MCP surface, 142+ site guides, local-first execution, and BYO API keys. Current release: FSB v0.9.90 (extension) with fsb-mcp-server 0.10.0 (npm). +FSB (Full Self-Browsing) is an open-source Chrome extension that automates the browser through natural language. You describe a task; FSB plans the clicks, types, and navigation to complete it. Multi-model AI (xAI, OpenAI, Anthropic, Gemini, OpenRouter, LM Studio, local), 56 browser tools, a 66-tool MCP surface, 142+ site guides, local-first execution, and BYO API keys. Current release: FSB v0.9.91 (extension) with fsb-mcp-server 0.11.0 (npm). FSB also works as an MCP browser layer for AI agents. Claude Code, Codex, Cursor, VS Code, Windsurf, OpenClaw, Hermes, and other MCP clients can use FSB to operate a real Chrome browser, inspect page state, verify outcomes, and keep the calling agent in the loop. The MCP surface includes trigger watchers (`trigger`, `stop_trigger`, `get_trigger_status`, `list_triggers`) for reactive DOM monitoring, real local file uploads (`upload_file`) and synthetic dropzone support (`drop_file`), and a 128-app native capability catalog where `search_capabilities` finds verified first-party API actions and `invoke_capability` runs them against the user's logged-in sessions with readiness labels such as `t1-ready`, `t1-guarded-fail-closed`, `learn-pending`, and `discovery-pending`, plus audit records. diff --git a/showcase/angular/public/sitemap.xml b/showcase/angular/public/sitemap.xml index 19cb00093..b934e49e7 100644 --- a/showcase/angular/public/sitemap.xml +++ b/showcase/angular/public/sitemap.xml @@ -2,55 +2,55 @@ https://full-selfbrowsing.com - 2026-07-07 + 2026-07-20 weekly 1.0 https://full-selfbrowsing.com/about - 2026-07-07 + 2026-07-20 weekly 0.9 https://full-selfbrowsing.com/agents - 2026-07-07 + 2026-07-20 weekly 0.9 https://full-selfbrowsing.com/support - 2026-07-07 + 2026-07-20 monthly 0.7 https://full-selfbrowsing.com/privacy - 2026-07-07 + 2026-07-20 yearly 0.5 https://full-selfbrowsing.com/lattice - 2026-07-07 + 2026-07-20 weekly 0.8 https://full-selfbrowsing.com/phantom-stream - 2026-07-07 + 2026-07-20 weekly 0.8 https://full-selfbrowsing.com/prometheus - 2026-07-07 + 2026-07-20 weekly 0.8 https://full-selfbrowsing.com/sitemaps - 2026-07-07 + 2026-07-20 monthly 0.6 diff --git a/showcase/angular/scripts/llms-full.source.md b/showcase/angular/scripts/llms-full.source.md index d5a480eb9..b306a79d2 100644 --- a/showcase/angular/scripts/llms-full.source.md +++ b/showcase/angular/scripts/llms-full.source.md @@ -2,7 +2,7 @@ ## 1. Project Description -FSB (Full Self-Browsing) is an open-source Chrome extension that automates the browser through natural language. You describe a task in plain English; FSB plans the clicks, types, and navigation to complete it. The extension runs in your browser with your own API keys, encrypted local storage, and opt-out anonymous usage telemetry for aggregate public stats only. Multi-model AI (xAI Grok, OpenAI, Anthropic Claude, Google Gemini, OpenRouter, LM Studio, custom OpenAI-compatible endpoints), 56 canonical browser tools, a 66-tool MCP surface, 142+ site-specific guides, reactive trigger watchers, real file uploads, and a 128-app native capability catalog for verified first-party API execution. Current release: FSB v0.9.90 (Chrome extension) with fsb-mcp-server 0.10.0 (npm MCP package). BSL 1.1 licensed. +FSB (Full Self-Browsing) is an open-source Chrome extension that automates the browser through natural language. You describe a task in plain English; FSB plans the clicks, types, and navigation to complete it. The extension runs in your browser with your own API keys, encrypted local storage, and opt-out anonymous usage telemetry for aggregate public stats only. Multi-model AI (xAI Grok, OpenAI, Anthropic Claude, Google Gemini, OpenRouter, LM Studio, custom OpenAI-compatible endpoints), 56 canonical browser tools, a 66-tool MCP surface, 142+ site-specific guides, reactive trigger watchers, real file uploads, and a 128-app native capability catalog for verified first-party API execution. Current release: FSB v0.9.91 (Chrome extension) with fsb-mcp-server 0.11.0 (npm MCP package). BSL 1.1 licensed. FSB also works especially well as an MCP browser layer for AI agents. Claude Code, Codex, Cursor, VS Code, Windsurf, OpenClaw, Hermes, and other MCP clients can use FSB to operate a real Chrome browser, inspect page state, verify outcomes, and feed observations back into their own coding or autonomy loop. @@ -129,7 +129,7 @@ FSB exposes its tooling through a Model Context Protocol (MCP) server. External The MCP surface is the best way to use FSB with coding agents and external autonomy layers. FSB handles precise single-attempt browser execution, visual feedback, observability, and verification. Long-running scheduling and personality/identity decisions belong in the calling agent layer, such as OpenClaw or Claude Routines. ### OpenClaw skill -FSB ships a dedicated OpenClaw + Hermes skill at `skills/fsb/` in the repo root. The skill is the canonical OpenClaw onboarding path: it runs the doctor flow against `fsb-mcp-server`, prints the OpenClaw stdio config block for the user to paste into OpenClaw's MCP config, and offers consent-gated install for any other MCP hosts detected on the same machine. The bare `--openclaw` flag in the FSB MCP installer stays manual / unsupported because OpenClaw's MCP config schema is still unstable across builds; the skill prints, the user pastes, no auto-write. Frontmatter ships with `name: fsb`, `version: 0.9.90`, `requires.bins: [node, npx]`, and `requires.env: []` so vault credentials never leave the FSB Chrome extension. The full marketing rundown (skill features, MCP power story, 3-step install, grounded use cases, autopilot rules) lives at `https://full-selfbrowsing.com/agents`. Hermes users get the same skill: `node skills/fsb/scripts/print-hermes-yaml.mjs` prints the canonical `~/.hermes/config.yaml` `mcp_servers.fsb` block, and FSB v0.9.69 (PR #49) added `Hermes` to the v0.9.36 shared MCP client allowlist so action calls passing `client: "Hermes"` work without schema changes. +FSB ships a dedicated OpenClaw + Hermes skill at `skills/fsb/` in the repo root. The skill is the canonical OpenClaw onboarding path: it runs the doctor flow against `fsb-mcp-server`, prints the OpenClaw stdio config block for the user to paste into OpenClaw's MCP config, and offers consent-gated install for any other MCP hosts detected on the same machine. The bare `--openclaw` flag in the FSB MCP installer stays manual / unsupported because OpenClaw's MCP config schema is still unstable across builds; the skill prints, the user pastes, no auto-write. Frontmatter ships with `name: fsb`, `version: 0.9.91`, `requires.bins: [node, npx]`, and `requires.env: []` so vault credentials never leave the FSB Chrome extension. The full marketing rundown (skill features, MCP power story, 3-step install, grounded use cases, autopilot rules) lives at `https://full-selfbrowsing.com/agents`. Hermes users get the same skill: `node skills/fsb/scripts/print-hermes-yaml.mjs` prints the canonical `~/.hermes/config.yaml` `mcp_servers.fsb` block, and FSB v0.9.69 (PR #49) added `Hermes` to the v0.9.36 shared MCP client allowlist so action calls passing `client: "Hermes"` work without schema changes. ### ClawHub install path The /agents page exposes a one-click install on ClawHub at `https://clawhub.ai/lakshmanturlapati/full-selfbrowsing`. ClawHub is the recommended distribution surface for OpenClaw users who want the FSB skill registered without manually pasting stdio config. The skill source remains browsable at `https://github.com/fullselfbrowsing/FSB/tree/main/skills/fsb` for users who prefer reading before installing. diff --git a/showcase/angular/scripts/llms.source.md b/showcase/angular/scripts/llms.source.md index 0d3e9f144..837c41d85 100644 --- a/showcase/angular/scripts/llms.source.md +++ b/showcase/angular/scripts/llms.source.md @@ -1,6 +1,6 @@ # FSB (Full Self-Browsing) -FSB (Full Self-Browsing) is an open-source Chrome extension that automates the browser through natural language. You describe a task; FSB plans the clicks, types, and navigation to complete it. Multi-model AI (xAI, OpenAI, Anthropic, Gemini, OpenRouter, LM Studio, local), 56 browser tools, a 66-tool MCP surface, 142+ site guides, local-first execution, and BYO API keys. Current release: FSB v0.9.90 (extension) with fsb-mcp-server 0.10.0 (npm). +FSB (Full Self-Browsing) is an open-source Chrome extension that automates the browser through natural language. You describe a task; FSB plans the clicks, types, and navigation to complete it. Multi-model AI (xAI, OpenAI, Anthropic, Gemini, OpenRouter, LM Studio, local), 56 browser tools, a 66-tool MCP surface, 142+ site guides, local-first execution, and BYO API keys. Current release: FSB v0.9.91 (extension) with fsb-mcp-server 0.11.0 (npm). FSB also works as an MCP browser layer for AI agents. Claude Code, Codex, Cursor, VS Code, Windsurf, OpenClaw, Hermes, and other MCP clients can use FSB to operate a real Chrome browser, inspect page state, verify outcomes, and keep the calling agent in the loop. The MCP surface includes trigger watchers (`trigger`, `stop_trigger`, `get_trigger_status`, `list_triggers`) for reactive DOM monitoring, real local file uploads (`upload_file`) and synthetic dropzone support (`drop_file`), and a 128-app native capability catalog where `search_capabilities` finds verified first-party API actions and `invoke_capability` runs them against the user's logged-in sessions with readiness labels such as `t1-ready`, `t1-guarded-fail-closed`, `learn-pending`, and `discovery-pending`, plus audit records. diff --git a/showcase/angular/src/app/core/seo/version.ts b/showcase/angular/src/app/core/seo/version.ts index d7d9d1a9e..ffb51ab72 100644 --- a/showcase/angular/src/app/core/seo/version.ts +++ b/showcase/angular/src/app/core/seo/version.ts @@ -1,2 +1,2 @@ // Generated by scripts/build-crawler-files.mjs from manifest.json -- do not edit by hand. -export const APP_VERSION = '0.9.90'; +export const APP_VERSION = '0.9.91'; diff --git a/showcase/angular/src/app/pages/lattice/lattice-page.component.html b/showcase/angular/src/app/pages/lattice/lattice-page.component.html index 4eaaad0f3..d3e5045d6 100644 --- a/showcase/angular/src/app/pages/lattice/lattice-page.component.html +++ b/showcase/angular/src/app/pages/lattice/lattice-page.component.html @@ -366,7 +366,7 @@

Community

diff --git a/showcase/angular/src/app/pages/phantom-stream/phantom-stream-page.component.html b/showcase/angular/src/app/pages/phantom-stream/phantom-stream-page.component.html index 6fae18aa4..432f0bc11 100644 --- a/showcase/angular/src/app/pages/phantom-stream/phantom-stream-page.component.html +++ b/showcase/angular/src/app/pages/phantom-stream/phantom-stream-page.component.html @@ -371,7 +371,7 @@

Community

diff --git a/showcase/server/package-lock.json b/showcase/server/package-lock.json index 8a861f1b6..c9edf4cbc 100644 --- a/showcase/server/package-lock.json +++ b/showcase/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "fsb-server", - "version": "0.9.90", + "version": "0.9.91", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fsb-server", - "version": "0.9.90", + "version": "0.9.91", "dependencies": { "better-sqlite3": "^11.0.0", "cors": "^2.8.5", diff --git a/showcase/server/package.json b/showcase/server/package.json index c1c0c397f..7b70520d5 100644 --- a/showcase/server/package.json +++ b/showcase/server/package.json @@ -1,6 +1,6 @@ { "name": "fsb-server", - "version": "0.9.90", + "version": "0.9.91", "description": "FSB Background Agents backend server with result storage and dashboard", "main": "server.js", "scripts": { diff --git a/skills/fsb/SKILL.md b/skills/fsb/SKILL.md index 6a511e10d..d8d8c6640 100644 --- a/skills/fsb/SKILL.md +++ b/skills/fsb/SKILL.md @@ -1,7 +1,7 @@ --- name: fsb description: FSB drives the user's Chrome via the FSB extension and an MCP bridge for live web tasks. -version: 0.9.90 +version: 0.9.91 user-invocable: true requires: bins: [node, npx] diff --git a/skills/fsb/references/multi-agent-contract.md b/skills/fsb/references/multi-agent-contract.md index 61b167717..0e066b2fa 100644 --- a/skills/fsb/references/multi-agent-contract.md +++ b/skills/fsb/references/multi-agent-contract.md @@ -1,4 +1,4 @@ -# FSB multi-agent contract (current as of v0.9.90) +# FSB multi-agent contract (current as of v0.9.91) This file documents the rules that make FSB's per-agent tab ownership work. Anyone calling FSB tools through MCP MUST follow these rules; breaking them produces typed errors that recover cleanly only when the caller knows the contract. The contract is small (six error names + the `agent_id` rule + the `back` tool), but every rule matters: bypassing one corrupts the ownership graph and the only recovery is closing the affected tab. diff --git a/store-assets/chrome-web-store/listing-copy.md b/store-assets/chrome-web-store/listing-copy.md index d3566161b..f3a750624 100644 --- a/store-assets/chrome-web-store/listing-copy.md +++ b/store-assets/chrome-web-store/listing-copy.md @@ -2,7 +2,7 @@ ## Title -FSB v0.9.90 +FSB v0.9.91 ## Summary diff --git a/tests/automation-logger-mcp-retention.test.js b/tests/automation-logger-mcp-retention.test.js new file mode 100644 index 000000000..9268a5ee0 --- /dev/null +++ b/tests/automation-logger-mcp-retention.test.js @@ -0,0 +1,443 @@ +/** + * Concurrency + MCP-only retention coverage for AutomationLogger. + * Run: node tests/automation-logger-mcp-retention.test.js + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const LOGGER_PATH = path.resolve(__dirname, '..', 'extension', 'utils', 'automation-logger.js'); +const LOGGER_SOURCE = fs.readFileSync(LOGGER_PATH, 'utf8'); + +let passed = 0; +let failed = 0; + +function check(condition, message) { + if (condition) { + passed++; + console.log(' PASS:', message); + } else { + failed++; + console.error(' FAIL:', message); + } +} + +function clone(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); +} + +function makeStorage(initial, delayMs) { + const store = clone(initial || {}) || {}; + const delay = () => new Promise((resolve) => setTimeout(resolve, delayMs || 0)); + return { + store, + async get(keys) { + const requested = Array.isArray(keys) ? keys : [keys]; + // Snapshot at invocation time. Without the logger's mutation lock, two + // concurrent saves would both read the same stale state and one wins. + const snapshot = {}; + for (const key of requested) { + if (Object.prototype.hasOwnProperty.call(store, key)) snapshot[key] = clone(store[key]); + } + await delay(); + return snapshot; + }, + async set(values) { + const snapshot = clone(values); + await delay(); + Object.assign(store, snapshot); + }, + async remove(keys) { + await delay(); + for (const key of (Array.isArray(keys) ? keys : [keys])) delete store[key]; + } + }; +} + +function loadLogger(storage) { + const sandbox = { + chrome: { runtime: { id: 'test-extension' }, storage: { local: storage } }, + console: { log() {}, warn() {}, error() {} }, + setTimeout() { return 1; }, + clearTimeout() {}, + Blob: class Blob {}, + URL: { createObjectURL() { return 'blob:test'; } } + }; + sandbox.globalThis = sandbox; + vm.createContext(sandbox); + vm.runInContext(LOGGER_SOURCE, sandbox, { filename: LOGGER_PATH }); + return sandbox.automationLogger; +} + +function action(tool) { + return { tool, params: { selector: '#' + tool }, result: { success: true }, timestamp: Date.now() }; +} + +function rawLog(sessionId, marker) { + return { + timestamp: new Date().toISOString(), + level: 'info', + message: marker, + data: { sessionId, action: { tool: 'type', params: { text: marker } }, result: { success: true } } + }; +} + +(async function main() { + console.log('--- AutomationLogger concurrent save serialization ---'); + { + const storage = makeStorage({ fsbMcpSessionRetentionDays: 30 }, 8); + const logger = loadLogger(storage); + logger.logSessionStart('session-a', 'First MCP session', 1); + logger.logSessionStart('session-b', 'Second MCP session', 2); + + const [savedA, savedB] = await Promise.all([ + logger.saveSession('session-a', { + task: 'First MCP session', mode: 'mcp-agent', startTime: Date.now(), + status: 'completed', actionHistory: [action('click')] + }), + logger.saveSession('session-b', { + task: 'Second MCP session', mode: 'mcp-agent', startTime: Date.now(), + status: 'completed', actionHistory: [action('type')] + }) + ]); + + check(savedA === true && savedB === true, 'both simultaneous save calls complete successfully'); + check(!!storage.store.fsbSessionLogs['session-a'], 'first session remains in fsbSessionLogs'); + check(!!storage.store.fsbSessionLogs['session-b'], 'second session remains in fsbSessionLogs'); + check(storage.store.fsbSessionIndex.length === 2, 'both simultaneous saves remain in fsbSessionIndex'); + check(new Set(storage.store.fsbSessionIndex.map((entry) => entry.id)).size === 2, + 'serialized index contains two distinct session ids'); + } + + console.log('\n--- AutomationLogger expired status normalization ---'); + { + const storage = makeStorage({ fsbSessionLogs: {}, fsbSessionIndex: [] }, 0); + const logger = loadLogger(storage); + logger.logSessionStart('expired-session', 'Expired MCP session', 3); + const saved = await logger.saveSession('expired-session', { + task: 'Expired MCP session', mode: 'mcp-agent', startTime: Date.now(), + status: 'expired', actionHistory: [action('click')] + }); + const full = storage.store.fsbSessionLogs['expired-session']; + const indexed = storage.store.fsbSessionIndex.find((entry) => entry.id === 'expired-session'); + check(saved === true, 'expired session saves successfully'); + check(full.status === 'expired' && full.outcome === 'stopped', + 'expired full history normalizes to a stopped outcome'); + check(indexed.status === 'expired' && indexed.outcome === 'stopped', + 'expired history index normalizes to the same stopped outcome'); + } + + console.log('\n--- AutomationLogger independent per-mode history caps ---'); + { + const now = Date.now(); + const sessions = {}; + const index = []; + const autopilotIds = []; + const mcpIds = []; + + for (let i = 0; i < 50; i++) { + const autopilotId = `autopilot-${i}`; + const mcpId = `mcp-${i}`; + const autopilot = { + id: autopilotId, + task: autopilotId, + startTime: now - i - 100, + endTime: now - i, + status: 'completed' + }; + if (i !== 0) autopilot.mode = 'autopilot'; + const mcp = { + id: mcpId, + task: mcpId, + mode: 'mcp-agent', + startTime: now - i - 100, + endTime: now - i, + status: 'completed' + }; + sessions[autopilotId] = clone(autopilot); + sessions[mcpId] = clone(mcp); + index.push(clone(autopilot), clone(mcp)); + autopilotIds.push(autopilotId); + mcpIds.push(mcpId); + } + + const storage = makeStorage({ + fsbMcpSessionRetentionDays: 30, + fsbSessionLogs: sessions, + fsbSessionIndex: index + }, 0); + const logger = loadLogger(storage); + + logger.logSessionStart('mcp-new', 'Newest MCP session', 51); + const savedMcp = await logger.saveSession('mcp-new', { + task: 'Newest MCP session', mode: 'mcp-agent', startTime: now, + status: 'completed', actionHistory: [action('click')] + }); + + const afterMcpIndex = storage.store.fsbSessionIndex; + const afterMcpIds = afterMcpIndex.map((entry) => entry.id); + const expectedAfterMcpIds = ['mcp-new'].concat( + index.map((entry) => entry.id).filter((id) => id !== 'mcp-49') + ); + check(savedMcp === true, 'saving the 51st MCP session succeeds'); + check(afterMcpIndex.length === 100, 'combined history retains 50 MCP and 50 Autopilot rows'); + check(afterMcpIndex.filter((entry) => entry.mode === 'mcp-agent').length === 50, + 'MCP history is capped independently at 50 rows'); + check(autopilotIds.every((id) => afterMcpIds.includes(id)), + 'saving an MCP session preserves every existing Autopilot row'); + check(!afterMcpIds.includes('mcp-49') && !storage.store.fsbSessionLogs['mcp-49'], + 'the oldest MCP overflow row is removed from the index and full store'); + check(JSON.stringify(afterMcpIds) === JSON.stringify(expectedAfterMcpIds), + 'per-mode capping preserves the interleaved order of retained rows'); + check(afterMcpIds.includes('autopilot-0') && storage.store.fsbSessionLogs['autopilot-0'].mode === undefined, + 'a legacy row without mode remains in the Autopilot bucket'); + + logger.logSessionStart('autopilot-new', 'Newest Autopilot session', 52); + const savedAutopilot = await logger.saveSession('autopilot-new', { + task: 'Newest Autopilot session', mode: 'autopilot', startTime: now, + status: 'completed', actionHistory: [action('type')] + }); + + const afterAutopilotIndex = storage.store.fsbSessionIndex; + const afterAutopilotIds = afterAutopilotIndex.map((entry) => entry.id); + const expectedAfterAutopilotIds = ['autopilot-new'].concat( + afterMcpIds.filter((id) => id !== 'autopilot-49') + ); + check(savedAutopilot === true, 'saving the 51st Autopilot session succeeds'); + check(afterAutopilotIndex.length === 100, 'combined history remains bounded at 100 rows'); + check(afterAutopilotIndex.filter((entry) => entry.mode !== 'mcp-agent').length === 50, + 'Autopilot history is capped independently at 50 rows'); + check(['mcp-new'].concat(mcpIds.slice(0, 49)).every((id) => afterAutopilotIds.includes(id)), + 'saving an Autopilot session preserves every retained MCP row'); + check(!afterAutopilotIds.includes('autopilot-49') && !storage.store.fsbSessionLogs['autopilot-49'], + 'the oldest Autopilot overflow row is removed from the index and full store'); + check(JSON.stringify(afterAutopilotIds) === JSON.stringify(expectedAfterAutopilotIds), + 'the second per-mode cap also preserves retained-row ordering'); + } + + console.log('\n--- AutomationLogger MCP-only retention pruning ---'); + { + const DAY = 24 * 60 * 60 * 1000; + const now = Date.now(); + const sessions = { + 'mcp-31d': { id: 'mcp-31d', mode: 'mcp-agent', startTime: now - 32 * DAY, endTime: now - 31 * DAY }, + 'mcp-29d': { id: 'mcp-29d', mode: 'mcp-agent', startTime: now - 30 * DAY, endTime: now - 29 * DAY }, + 'mcp-8d': { id: 'mcp-8d', mode: 'mcp-agent', startTime: now - 9 * DAY, endTime: now - 8 * DAY }, + 'mcp-6d': { id: 'mcp-6d', mode: 'mcp-agent', startTime: now - 7 * DAY, endTime: now - 6 * DAY }, + 'mcp-no-time': { id: 'mcp-no-time', mode: 'mcp-agent' }, + 'autopilot-400d': { id: 'autopilot-400d', mode: 'autopilot', startTime: now - 401 * DAY, endTime: now - 400 * DAY }, + 'authoritative-autopilot': { + id: 'authoritative-autopilot', mode: 'autopilot', startTime: now - 401 * DAY, endTime: now - 400 * DAY + } + }; + const index = Object.values(sessions).map((entry) => ({ ...entry })); + index.find((entry) => entry.id === 'authoritative-autopilot').mode = 'mcp-agent'; + index.push({ id: 'orphan-mcp-old', mode: 'mcp-agent', startTime: now - 41 * DAY, endTime: now - 40 * DAY }); + const snapshots = {}; + for (const id of [...Object.keys(sessions), 'orphan-mcp-old']) snapshots[id] = [{ html: id }]; + const automationLogs = [ + rawLog('mcp-31d', 'expired-mcp'), + rawLog('orphan-mcp-old', 'expired-orphan'), + rawLog('mcp-29d', 'fresh-mcp'), + rawLog('autopilot-400d', 'old-autopilot') + ]; + + const storage = makeStorage({ + fsbMcpSessionRecordingEnabled: false, + fsbSessionLogs: sessions, + fsbSessionIndex: index, + fsbDOMSnapshots: snapshots, + automationLogs + }, 0); + const logger = loadLogger(storage); + logger.logs = clone(automationLogs); + logger._domSnapshots = { + 'mcp-31d': [{ html: 'memory-old' }], + 'mcp-6d': [{ html: 'memory-fresh' }] + }; + + const defaultResult = await logger.pruneMcpSessions(30); + check(defaultResult.ids.includes('mcp-31d'), '30-day policy removes an MCP session older than 30 days'); + check(defaultResult.ids.includes('orphan-mcp-old'), 'expired orphan MCP index entry is also removed'); + check(!storage.store.fsbSessionLogs['mcp-31d'], 'expired MCP log entry is deleted'); + check(!storage.store.fsbSessionIndex.some((entry) => entry.id === 'mcp-31d'), + 'expired MCP index entry is deleted'); + check(!storage.store.fsbDOMSnapshots['mcp-31d'], 'expired MCP DOM snapshots are deleted'); + check(!logger._domSnapshots['mcp-31d'], 'expired in-memory MCP DOM snapshots are deleted'); + check(!storage.store.automationLogs.some((log) => log.data?.sessionId === 'mcp-31d'), + 'expired MCP raw automation logs are deleted from storage'); + check(!storage.store.automationLogs.some((log) => log.data?.sessionId === 'orphan-mcp-old'), + 'expired orphan MCP raw logs are deleted from storage'); + check(!logger.logs.some((log) => log.data?.sessionId === 'mcp-31d'), + 'expired MCP raw logs are deleted from the in-memory persistence source'); + check(storage.store.automationLogs.some((log) => log.data?.sessionId === 'mcp-29d'), + 'fresh MCP raw logs survive the default cutoff'); + check(storage.store.automationLogs.some((log) => log.data?.sessionId === 'autopilot-400d'), + 'unrelated Autopilot raw logs survive MCP pruning'); + check(!!storage.store.fsbSessionLogs['mcp-29d'], 'fresh MCP session survives the 30-day cutoff'); + check(!!storage.store.fsbSessionLogs['autopilot-400d'], 'old Autopilot history is preserved'); + check(!!storage.store.fsbSessionLogs['authoritative-autopilot'], + 'full Autopilot record wins over a stale MCP index badge'); + check(!!storage.store.fsbSessionLogs['mcp-no-time'], 'MCP entry without a valid timestamp is preserved'); + + const customResult = await logger.pruneMcpSessions(7); + check(customResult.ids.includes('mcp-8d'), 'custom 7-day policy removes an 8-day-old MCP session'); + check(customResult.ids.includes('mcp-29d'), 'custom cutoff also removes older fresh-under-default MCP history'); + check(!!storage.store.fsbSessionLogs['mcp-6d'], '6-day-old MCP session survives a 7-day policy'); + check(!!storage.store.fsbSessionLogs['autopilot-400d'], 'Autopilot history remains after custom pruning'); + check(storage.store.fsbMcpSessionRecordingEnabled === false, + 'recording opt-out remains independent from retention storage'); + } + + console.log('\n--- AutomationLogger closed-session outcome update ---'); + { + const endTime = Date.now() - 5000; + const replayAction = action('click'); + const storedSession = { + id: 'closed-session', task: 'Closed task', mode: 'mcp-agent', + startTime: endTime - 1000, endTime, status: 'completed', outcome: 'success', + actionHistory: [replayAction], logs: [rawLog('closed-session', 'preserve-log')] + }; + const storage = makeStorage({ + fsbSessionLogs: { 'closed-session': storedSession }, + fsbSessionIndex: [{ + id: 'closed-session', task: 'Closed task', mode: 'mcp-agent', + startTime: storedSession.startTime, endTime, status: 'completed', outcome: 'success' + }] + }, 4); + const logger = loadLogger(storage); + const updated = await logger.updateSessionOutcome('closed-session', { + status: 'failed', + outcome: 'failure', + outcomeDetails: { + outcome: 'failure', reason: 'missing-data', summary: null, + blocker: null, nextStep: null, result: null, error: 'Requested data does not exist' + }, + error: 'Requested data does not exist' + }); + + const full = storage.store.fsbSessionLogs['closed-session']; + const indexed = storage.store.fsbSessionIndex[0]; + check(updated === true, 'closed-session outcome update reports success'); + check(full.status === 'failed' && full.outcome === 'failure', + 'full history row reflects the terminal failure'); + check(indexed.status === 'failed' && indexed.outcome === 'failure', + 'history index reflects the same terminal failure'); + check(full.error === 'Requested data does not exist' && indexed.error === full.error, + 'full history and index preserve the terminal error'); + check(full.endTime === endTime && indexed.endTime === endTime, + 'outcome-only update preserves the original session end time'); + check(full.actionHistory.length === 1 && full.actionHistory[0].tool === 'click', + 'outcome-only update preserves replay history'); + check(full.logs.length === 1 && full.logs[0].message === 'preserve-log', + 'outcome-only update preserves session logs'); + } + + console.log('\n--- AutomationLogger external scrub shares the save lock ---'); + { + const storage = makeStorage({ fsbSessionLogs: {}, fsbSessionIndex: [], automationLogs: [] }, 8); + const logger = loadLogger(storage); + logger.logSessionStart('new-session', 'New MCP session', 9); + const save = logger.saveSession('new-session', { + task: 'New MCP session', mode: 'mcp-agent', startTime: Date.now(), + status: 'completed', actionHistory: [action('click')] + }); + const scrub = logger.withSessionMutationLock(async () => { + const stored = await storage.get(['fsbSessionLogs', 'automationLogs']); + await storage.set({ + fsbSessionLogs: stored.fsbSessionLogs || {}, + automationLogs: stored.automationLogs || [], + fsbMcpSessionRedactionVersion: 1 + }); + }); + + await Promise.all([save, scrub]); + check(!!storage.store.fsbSessionLogs['new-session'], + 'startup scrub queued after a session save preserves the new history row'); + check(storage.store.fsbSessionIndex.some((entry) => entry.id === 'new-session'), + 'startup scrub does not leave a dangling index entry'); + check(storage.store.fsbMcpSessionRedactionVersion === 1, + 'startup scrub marker is written after the serialized snapshot'); + } + + console.log('\n--- AutomationLogger save and closed-outcome update serialize ---'); + { + const storage = makeStorage({ fsbSessionLogs: {}, fsbSessionIndex: [] }, 8); + const logger = loadLogger(storage); + const sessionId = 'save-then-outcome'; + logger.logSessionStart(sessionId, 'Finish after is_final', 12); + + const save = logger.saveSession(sessionId, { + task: 'Finish after is_final', mode: 'mcp-agent', startTime: Date.now(), + status: 'completed', actionHistory: [action('click')] + }); + const update = logger.updateSessionOutcome(sessionId, { + status: 'partial', outcome: 'partial', + outcomeDetails: { + outcome: 'partial', reason: 'manual-approval', summary: 'Prepared the change', + blocker: 'Manual approval required', nextStep: 'Approve the change', result: null, error: null + }, + completionMessage: 'Prepared the change', + blocker: 'Manual approval required', + nextStep: 'Approve the change' + }); + + const [, updated] = await Promise.all([save, update]); + const full = storage.store.fsbSessionLogs[sessionId]; + const indexed = storage.store.fsbSessionIndex.find((entry) => entry.id === sessionId); + check(updated === true, 'outcome update waits for the queued session save and succeeds'); + check(full.status === 'partial' && full.outcome === 'partial', + 'queued history save retains the later terminal outcome'); + check(indexed.status === 'partial' && indexed.outcome === 'partial', + 'queued index save retains the later terminal outcome'); + check(full.blocker === 'Manual approval required' && indexed.nextStep === 'Approve the change', + 'serialized full and index rows retain partial outcome details'); + check(full.actionHistory.length === 1 && full.actionHistory[0].tool === 'click', + 'serialized outcome patch leaves the saved replay history intact'); + } + + console.log('\n--- AutomationLogger pending persistence cannot resurrect pruned logs ---'); + { + const DAY = 24 * 60 * 60 * 1000; + const now = Date.now(); + const sessions = { + expired: { id: 'expired', mode: 'mcp-agent', startTime: now - 40 * DAY, endTime: now - 39 * DAY }, + fresh: { id: 'fresh', mode: 'mcp-agent', startTime: now - 2 * DAY, endTime: now - DAY }, + autopilot: { id: 'autopilot', mode: 'autopilot', startTime: now - 400 * DAY, endTime: now - 399 * DAY } + }; + const logs = [ + rawLog('expired', 'must-disappear'), + rawLog('fresh', 'must-remain'), + rawLog('autopilot', 'autopilot-remains') + ]; + const storage = makeStorage({ + fsbSessionLogs: sessions, + fsbSessionIndex: Object.values(sessions), + fsbDOMSnapshots: {}, + automationLogs: logs + }, 8); + const logger = loadLogger(storage); + logger.logs = clone(logs); + + // Queue a raw-log persist first, then pruning. Both must serialize on the + // session mutation lock, leaving prune as the final authoritative write. + await Promise.all([logger.persistLogs(), logger.pruneMcpSessions(30)]); + check(!storage.store.automationLogs.some((log) => log.data?.sessionId === 'expired'), + 'a pending persist cannot restore an expired MCP raw log'); + check(!logger.logs.some((log) => log.data?.sessionId === 'expired'), + 'the in-memory queue remains pruned after concurrent persistence'); + check(storage.store.automationLogs.some((log) => log.data?.sessionId === 'fresh'), + 'concurrent persistence preserves fresh MCP logs'); + check(storage.store.automationLogs.some((log) => log.data?.sessionId === 'autopilot'), + 'concurrent persistence preserves Autopilot logs'); + } + + console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); + process.exit(failed > 0 ? 1 : 0); +})().catch((error) => { + console.error(error && error.stack ? error.stack : error); + process.exit(2); +}); diff --git a/tests/capability-autopilot-parity.test.js b/tests/capability-autopilot-parity.test.js index f821ed062..463ba5e6f 100644 --- a/tests/capability-autopilot-parity.test.js +++ b/tests/capability-autopilot-parity.test.js @@ -40,7 +40,7 @@ const REPO_ROOT = path.resolve(__dirname, '..'); // tests/capability-mcp-surface.test.js:58-59 / tool-definitions-parity.test.js:52. // The two out-of-registry capability tools must NOT have moved this. const EXPECTED_NON_TRIGGER_REGISTRY_HASH = - '6354d78836bc8927f55af4562dec099f614ebbe034d018c163d7b8b2e5c6b60d'; + '0a525835adc6961463c5a954f3e80205f066e23bef6089283ef598c78f1d8623'; // The four trigger tools sit IN TOOL_REGISTRY but are excluded from the frozen // non-trigger baseline (mirrors capability-mcp-surface.test.js:63). diff --git a/tests/capability-mcp-surface.test.js b/tests/capability-mcp-surface.test.js index 69191eb5e..3a6350bc6 100644 --- a/tests/capability-mcp-surface.test.js +++ b/tests/capability-mcp-surface.test.js @@ -56,7 +56,7 @@ const REPO_ROOT = path.resolve(__dirname, '..'); // The INV-01 lock value -- reused verbatim from tests/tool-definitions-parity.test.js:52. // The two out-of-registry capability tools must NOT have moved this. const EXPECTED_NON_TRIGGER_REGISTRY_HASH = - '6354d78836bc8927f55af4562dec099f614ebbe034d018c163d7b8b2e5c6b60d'; + '0a525835adc6961463c5a954f3e80205f066e23bef6089283ef598c78f1d8623'; // The four trigger tools sit IN TOOL_REGISTRY but are excluded from the frozen // non-trigger baseline (mirrors tool-definitions-parity.test.js:35). diff --git a/tests/control-panel-scroll-containment.test.js b/tests/control-panel-scroll-containment.test.js index 79133fb03..4ebd997ad 100644 --- a/tests/control-panel-scroll-containment.test.js +++ b/tests/control-panel-scroll-containment.test.js @@ -8,9 +8,9 @@ const repoRoot = path.resolve(__dirname, '..'); const css = fs.readFileSync(path.join(repoRoot, 'extension', 'ui', 'options.css'), 'utf8'); const html = fs.readFileSync(path.join(repoRoot, 'extension', 'ui', 'control_panel.html'), 'utf8'); -function getCssRule(selector) { +function getCssRule(selector, source = css) { const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const match = css.match(new RegExp(escaped + '\\s*\\{([\\s\\S]*?)\\}', 'm')); + const match = source.match(new RegExp(escaped + '\\s*\\{([\\s\\S]*?)\\}', 'm')); return match ? match[1] : ''; } @@ -50,7 +50,26 @@ assert(/overflow-y\s*:\s*auto\s*;/.test(contentRule), assert(/overscroll-behavior\s*:\s*contain\s*;/.test(contentRule), 'dashboard content contains wheel/trackpad scroll chaining at its edges'); -assert(/]*id=["']branding["'][\s\S]*?▽0\.9\.90[\s\S]*?<\/section>/.test(html), +const printStart = css.indexOf('@media print'); +const printEnd = css.indexOf('/* ==========================================', printStart); +const printCss = css.slice(printStart, printEnd); +assert(printStart >= 0 && printEnd > printStart, 'print stylesheet block exists'); +assert(/html\s*,\s*body\s*\{[\s\S]*?height\s*:\s*auto\s*;[\s\S]*?overflow\s*:\s*visible\s*;/.test(printCss), + 'print restores document-root height and overflow'); + +const printContainerRule = getCssRule('.dashboard-container', printCss); +assert(/height\s*:\s*auto\s*;/.test(printContainerRule) && /overflow\s*:\s*visible\s*;/.test(printContainerRule), + 'print lets the dashboard container grow across pages'); + +const printMainRule = getCssRule('.dashboard-main', printCss); +assert(/display\s*:\s*block\s*;/.test(printMainRule) && /overflow\s*:\s*visible\s*;/.test(printMainRule), + 'print removes the clipped main flex scrollport'); + +const printContentRule = getCssRule('.dashboard-content', printCss); +assert(/display\s*:\s*block\s*;/.test(printContentRule) && /overflow\s*:\s*visible\s*;/.test(printContentRule), + 'print lets settings content flow beyond one viewport'); + +assert(/]*id=["']branding["'][\s\S]*?▽\d+\.\d+\.\d+[\s\S]*?<\/section>/.test(html), 'version footer remains present at the end of the control panel content'); console.log('PASS control panel scroll containment guard'); diff --git a/tests/google-sheets-content-actions.test.js b/tests/google-sheets-content-actions.test.js index 159658f89..b2eb93c89 100644 --- a/tests/google-sheets-content-actions.test.js +++ b/tests/google-sheets-content-actions.test.js @@ -22,6 +22,7 @@ function sheetsDomHarness(options = {}) { let pasteCalls = 0; let deleteCalls = 0; let insertCalls = 0; + const keyCalls = []; const nameBox = options.missingNameBox ? null : { value: selectedAddress, textContent: selectedAddress, @@ -35,6 +36,7 @@ function sheetsDomHarness(options = {}) { const trusted = { success: true, method: 'debuggerAPI' }; const tools = { async keyPress(params) { + keyCalls.push({ ...params }); if (typeof options.keyPress === 'function') { const override = await options.keyPress(params, { selectedAddress, cells }); if (override) return override; @@ -51,7 +53,7 @@ function sheetsDomHarness(options = {}) { } if (nameBox) nameBox.value = selectedAddress; } - if (params.key === 'v' && params.ctrlKey) pasteCalls++; + if (params.key === 'v' && (params.ctrlKey || params.metaKey)) pasteCalls++; if (params.key === '=' && (params.ctrlKey || params.metaKey) && params.altKey) insertCalls++; if (params.key === 'Delete') { deleteCalls++; @@ -89,6 +91,10 @@ function sheetsDomHarness(options = {}) { __tools: tools, document, navigator: { + platform: options.platform || 'Linux x86_64', + userAgentData: options.userAgentDataPlatform + ? { platform: options.userAgentDataPlatform } + : undefined, clipboard: { async writeText(text) { if (options.clipboardError) throw options.clipboardError; @@ -125,6 +131,7 @@ function sheetsDomHarness(options = {}) { return { api: context.__sheetsInternals, cells, + keyCalls, get pasteCalls() { return pasteCalls; }, get deleteCalls() { return deleteCalls; }, get insertCalls() { return insertCalls; } @@ -192,6 +199,22 @@ test('legacy CSV conversion and read transpose reuse the shared value helpers', assert.deepEqual(ui.transpose([['a', 'b'], ['c', 'd']]), [['a', 'c'], ['b', 'd']]); }); +test('DOM reads reject ranges whose cell-by-cell waits exceed the capability timeout budget', async () => { + assert.equal(ui.limits.maxReadCells, 100); + + const oversized = sheetsDomHarness(); + const rejected = await oversized.api.sheetsReadValues('A1:Z50', 'ROWS'); + assert.equal(rejected.code, 'RECIPE_DOM_FALLBACK_PENDING'); + assert.equal(rejected.reason, 'ui-read-range-limit-exceeded'); + assert.equal(oversized.keyCalls.length, 0); + + const bounded = sheetsDomHarness(); + const accepted = await bounded.api.sheetsReadValues('A1:J10', 'ROWS'); + assert.equal(accepted.success, true); + assert.equal(accepted.data.values.length, 10); + assert.equal(accepted.data.values[0].length, 10); +}); + test('append boundaries and clear readback fail closed when UI state is ambiguous', () => { const parsed = ui.parseA1Range('Data!A:C'); assert.deepEqual(ui.appendRowFromTable(parsed, [ @@ -273,6 +296,26 @@ test('DOM fallback requires trusted keys, confirmed addresses, and a real formul assert.equal(noFormula.api.sheetsSpreadsheetMetadata().code, 'GOOGLE_SHEETS_SESSION_UNAVAILABLE'); }); +test('DOM fallback uses Command on macOS and Control on other platforms', async () => { + const mac = sheetsDomHarness({ platform: 'MacIntel', userAgentDataPlatform: 'macOS' }); + assert.equal((await mac.api.sheetsUpdateValues('A1', [['mac']], 'RAW')).success, true); + const macSelect = mac.keyCalls.find(call => call.key === 'a'); + const macPaste = mac.keyCalls.find(call => call.key === 'v'); + assert.equal(macSelect.metaKey, true); + assert.equal(macSelect.ctrlKey, undefined); + assert.equal(macPaste.metaKey, true); + assert.equal(macPaste.ctrlKey, undefined); + + const windows = sheetsDomHarness({ platform: 'Win32', userAgentDataPlatform: 'Windows' }); + assert.equal((await windows.api.sheetsUpdateValues('A1', [['windows']], 'RAW')).success, true); + const windowsSelect = windows.keyCalls.find(call => call.key === 'a'); + const windowsPaste = windows.keyCalls.find(call => call.key === 'v'); + assert.equal(windowsSelect.ctrlKey, true); + assert.equal(windowsSelect.metaKey, undefined); + assert.equal(windowsPaste.ctrlKey, true); + assert.equal(windowsPaste.metaKey, undefined); +}); + test('DOM append scans a rectangular table and clear verifies actual readback', async () => { const append = sheetsDomHarness({ cells: { diff --git a/tests/google-sheets-session.test.js b/tests/google-sheets-session.test.js index 3cf367143..320a326d0 100644 --- a/tests/google-sheets-session.test.js +++ b/tests/google-sheets-session.test.js @@ -404,6 +404,62 @@ test('MAIN-world bridge builds only fixed Sheets requests and uses no supplied t } }); +test('MAIN-world bridge rejects unbounded and oversized getValues ranges before request dispatch', async () => { + const previous = globalThis.gapi; + const restoreLocation = installPageLocation(); + let calls = 0; + globalThis.gapi = { client: { request() { calls++; return Promise.resolve({ status: 200, result: {} }); } } }; + try { + const unbounded = await sessionModule.pageClientOperation({ + operation: 'getValues', spreadsheetId: ID, timeoutMs: 100, args: { range: 'A:ZZZ' } + }); + assert.equal(unbounded.code, 'GOOGLE_SHEETS_REQUEST_TOO_LARGE'); + assert.equal(unbounded.reason, 'get-values-range-must-be-bounded'); + + const oversized = await sessionModule.pageClientOperation({ + operation: 'getValues', spreadsheetId: ID, timeoutMs: 100, args: { range: 'A1:ZZ100000' } + }); + assert.equal(oversized.code, 'GOOGLE_SHEETS_REQUEST_TOO_LARGE'); + assert.equal(oversized.reason, 'get-values-range-limit-exceeded'); + + const malformed = await sessionModule.pageClientOperation({ + operation: 'getValues', spreadsheetId: ID, timeoutMs: 100, args: { range: 'not a range' } + }); + assert.equal(malformed.code, 'GOOGLE_SHEETS_INVALID_ARGUMENT'); + assert.equal(calls, 0); + } finally { + globalThis.gapi = previous; + restoreLocation(); + } +}); + +test('MAIN-world bridge refuses to clone responses larger than five MiB', async () => { + const previous = globalThis.gapi; + const restoreLocation = installPageLocation(); + globalThis.gapi = { + client: { + request() { + return Promise.resolve({ + status: 200, + result: { range: 'A1', values: [['x'.repeat(5 * 1024 * 1024)]] } + }); + } + } + }; + try { + const out = await sessionModule.pageClientOperation({ + operation: 'getValues', spreadsheetId: ID, timeoutMs: 100, args: { range: 'A1' } + }); + assert.equal(out.code, 'GOOGLE_SHEETS_RESPONSE_TOO_LARGE'); + assert.equal(out.reason, 'page-gapi-response-limit-exceeded'); + assert.equal(out.requestSent, true); + assert.equal(out.data, undefined); + } finally { + globalThis.gapi = previous; + restoreLocation(); + } +}); + test('MAIN-world bridge re-pins location immediately before the page request', async () => { const previous = globalThis.gapi; const restoreLocation = installPageLocation(OTHER_ID); diff --git a/tests/knowledge-graph-dedup.test.js b/tests/knowledge-graph-dedup.test.js new file mode 100644 index 000000000..483643506 --- /dev/null +++ b/tests/knowledge-graph-dedup.test.js @@ -0,0 +1,143 @@ +/** + * Knowledge Graph task-domain deduplication in simple and full detail modes. + * Run: node tests/knowledge-graph-dedup.test.js + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const GRAPH_PATH = path.resolve(__dirname, '..', 'extension', 'lib', 'visualization', 'knowledge-graph.js'); +const source = fs.readFileSync(GRAPH_PATH, 'utf8'); + +const groupedGuides = { + 'E-Commerce & Shopping': [ + { + site: 'Amazon', + patterns: [/amazon\.(com|co\.\w+|in|de|fr|jp|ca|com\.au|com\.br|com\.mx)/i], + selectors: { search: '#search' } + }, + { site: 'Booking.com', patterns: [/booking\.com/i], selectors: { destination: '#destination' } } + ], + 'Coding Platforms': [ + { site: 'GitHub', patterns: [/github\.com/i], selectors: { search: '#query' } } + ], + 'Productivity Tools': [ + { site: 'Google Sheets', patterns: [/docs\.google\.com\/spreadsheets/i], selectors: { grid: '#grid' } } + ] +}; + +const sandbox = { + self: {}, + getSiteGuidesByCategory() { return groupedGuides; }, + console +}; +sandbox.globalThis = sandbox; +vm.createContext(sandbox); +const instrumentedSource = source.replace( + ' return {\n render: render,', + ' self.__buildKnowledgeGraphDataForTests = buildKnowledgeGraphData;\n' + + ' self.__normalizeSiteIdentityForTests = normalizeSiteIdentity;\n' + + ' self.__patternSiteIdentitiesForTests = patternSiteIdentities;\n' + + ' self.__getDefaultZoomForTests = getDefaultZoom;\n' + + ' self.__getAutomaticZoomForTests = getAutomaticZoom;\n' + + ' return {\n render: render,' +); +vm.runInContext(instrumentedSource, sandbox, { filename: GRAPH_PATH }); + +const graph = sandbox.self.KnowledgeGraph; +const buildData = sandbox.self.__buildKnowledgeGraphDataForTests; +const normalizeIdentity = sandbox.self.__normalizeSiteIdentityForTests; +const patternIdentities = sandbox.self.__patternSiteIdentitiesForTests; +const getDefaultZoom = sandbox.self.__getDefaultZoomForTests; +const getAutomaticZoom = sandbox.self.__getAutomaticZoomForTests; +let passed = 0; +let failed = 0; + +function check(condition, message) { + if (condition) { + passed++; + console.log(' PASS:', message); + } else { + failed++; + console.error(' FAIL:', message); + } +} + +function taskMemory(domain, selectors) { + return { + typeData: { + session: { domain }, + learned: { selectors: selectors || [] } + }, + metadata: { domain } + }; +} + +console.log('--- Knowledge Graph complete-registry duplicate identities ---'); + +check(!!graph && typeof buildData === 'function', 'test harness reaches the internal data builder'); +check(normalizeIdentity('HTTPS://WWW.Example.COM./path?q=1') === 'example.com', + 'identity normalization removes case, protocol, www, path, and trailing dot'); +const amazonPattern = groupedGuides['E-Commerce & Shopping'][0].patterns[0]; +check(!patternIdentities(amazonPattern).some((identity) => ['com.au', 'com.br', 'com.mx'].includes(identity)), + 'real Amazon alternations do not expose public-suffix fragments as site identities'); + +graph.setTaskMemories([ + taskMemory('https://WWW.AMAZON.COM./products', ['#buy']), + taskMemory('https://www.amazon.com.au/products', ['#buy-au']), + taskMemory('BOOKING.COM./hotels', ['#hotel']), + taskMemory('https://github.com/org/repo', ['#code']), + taskMemory('https://docs.google.com/spreadsheets/d/example/edit', ['#sheet']), + taskMemory('https://news.com.au/world', ['#headline']), + taskMemory('https://www.genuinely-new.dev./docs', ['#one']), + taskMemory('GENUINELY-NEW.DEV/path', ['#two']) +]); + +for (const detailLevel of ['simple', 'full']) { + const data = buildData(detailLevel); + const taskSites = data.nodes.filter((node) => node.type === 'task-site'); + const discovered = taskSites.find((node) => node.label === 'genuinely-new.dev'); + const australianNews = taskSites.find((node) => node.label === 'news.com.au'); + check(taskSites.length === 2, + `${detailLevel} mode suppresses known guide domains and keeps both unrelated discovered domains`); + check(!!discovered, + `${detailLevel} mode renders the normalized genuinely discovered domain`); + check(discovered && discovered.selectorCount === 2, + `${detailLevel} mode coalesces normalized aliases and merges learned selectors`); + check(australianNews && australianNews.selectorCount === 1, + `${detailLevel} mode does not mistake news.com.au for an Amazon identity`); + check(!taskSites.some((node) => /amazon|booking|github|docs\.google/i.test(node.label)), + `${detailLevel} mode never duplicates a known guide, including a path-specific guide`); +} + +const simple = buildData('simple'); +const full = buildData('full'); +check(!simple.nodes.some((node) => node.id.startsWith('site:')), + 'simple mode does not need rendered site nodes for duplicate detection'); +check(full.nodes.some((node) => node.label === 'Amazon' && node.type === 'site'), + 'full mode still renders the built-in guide nodes normally'); + +const mobileContainer = { + clientWidth: 500, + clientHeight: 300, + getBoundingClientRect() { return { width: 500, height: 300 }; } +}; +const expandedState = { + container: mobileContainer, + canvas: { width: 500, height: 300 }, + dpr: 1, + nodes: full.nodes, + detailLevel: 'full', + rotY: 0, + rotX: 0.15 +}; +check(getAutomaticZoom(expandedState) < getDefaultZoom(mobileContainer, 'full'), + 'automatic Expanded zoom fits outer nodes inside a mobile-sized canvas'); +check((source.match(/state\.zoom = getAutomaticZoom\(state\)/g) || []).length === 2, + 'initial render and resize both apply automatic fit-to-view zoom'); + +console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/mcp-bridge-background-dispatch.test.js b/tests/mcp-bridge-background-dispatch.test.js index e68d20601..ff9ec3e6e 100644 --- a/tests/mcp-bridge-background-dispatch.test.js +++ b/tests/mcp-bridge-background-dispatch.test.js @@ -231,6 +231,7 @@ function buildClientHarness(options = {}) { setInterval: timers.setInterval, clearInterval: timers.clearInterval, dispatchMcpMessageRoute: options.dispatchMcpMessageRoute || (async () => ({ success: true })), + dispatchMcpToolRoute: options.dispatchMcpToolRoute || (async () => ({ success: true })), globalThis: {} }; context.globalThis = context; @@ -383,6 +384,37 @@ async function runAgentActionDeprecationCase() { assertEqual(dispatchCalls.length, 0, 'no agent action reaches the dispatch path'); } +async function runTaskStatusRouteCase() { + console.log('\n--- A7: mcp:task-status preserves agent ownership context ---'); + + const routeCalls = []; + const harness = buildClientHarness({ + async dispatchMcpToolRoute(input) { + routeCalls.push(input); + return { success: true, tool: input.tool, status: 'completed' }; + } + }); + const client = harness.exports.mcpBridgeClient; + const payload = { + tool: 'complete_task', + params: { summary: 'Finished locally', tab_id: 22 }, + agentId: 'agent-task-status', + ownershipToken: 'owner-token-22', + connectionId: 'connection-task-status' + }; + + const result = await client._routeMessage('mcp:task-status', payload, 'msg-task-status'); + assertEqual(routeCalls.length, 1, 'task status dispatches through the direct tool route exactly once'); + assertEqual(routeCalls[0].tool, 'complete_task', 'task status preserves the public lifecycle tool name'); + assertEqual(routeCalls[0].params.tab_id, 22, 'public snake_case tab_id remains available to the recorder'); + assertEqual(routeCalls[0].params.tabId, 22, 'bridge adds camelCase tabId for the ownership gate'); + assertEqual(routeCalls[0].payload.agentId, 'agent-task-status', 'agent identity remains top-level for the ownership gate'); + assertEqual(routeCalls[0].payload.ownershipToken, 'owner-token-22', 'ownership token remains top-level for the ownership gate'); + assertEqual(routeCalls[0].client, client, 'task status dispatch carries the active bridge client'); + assertDeepEqual(result, { success: true, tool: 'complete_task', status: 'completed' }, + 'task status response passes through unchanged'); +} + // --------------------------------------------------------------------------- // Part B -- fsbDispatchInternalMessage wrapper (extracted from background.js) // --------------------------------------------------------------------------- @@ -515,7 +547,9 @@ function runSourceContractCase() { 'const fsbHandleRuntimeMessage = (request, sender, sendResponse) => {', 'chrome.runtime.onMessage.addListener(fsbHandleRuntimeMessage);', 'function fsbDispatchInternalMessage(request) {', - 'globalThis.fsbDispatchInternalMessage = fsbDispatchInternalMessage;' + 'globalThis.fsbDispatchInternalMessage = fsbDispatchInternalMessage;', + 'function resolveMcpVisualSessionTabId(sessionToken) {', + 'globalThis.resolveMcpVisualSessionTabId = resolveMcpVisualSessionTabId;' ]; for (const snippet of backgroundSnippets) { assert(backgroundSource.includes(snippet), `background.js includes ${snippet}`); @@ -534,6 +568,7 @@ async function run() { await runSendMessageFallbackCase(); await runListCredentialsSecretStripCase(); await runAgentActionDeprecationCase(); + await runTaskStatusRouteCase(); await runWrapperBehaviorCases(); runSourceContractCase(); diff --git a/tests/mcp-restricted-tab.test.js b/tests/mcp-restricted-tab.test.js index 90cade46e..0ddb91de9 100644 --- a/tests/mcp-restricted-tab.test.js +++ b/tests/mcp-restricted-tab.test.js @@ -28,6 +28,7 @@ function assertDeepEqual(actual, expected, msg) { const repoRoot = path.resolve(__dirname, '..'); const dispatcherRelativePath = 'extension/ws/mcp-tool-dispatcher.js'; +const spreadsheetRedaction = require(path.join(repoRoot, 'extension/utils/spreadsheet-record-redaction.js')); const expectedRecoveryTools = ['navigate', 'open_tab', 'switch_tab', 'list_tabs']; const restrictedUrls = [ 'chrome://newtab/', @@ -382,6 +383,63 @@ async function runResolverAwareRouteCases(dispatcher) { } } +async function runSpreadsheetRecorderCase(dispatcher) { + console.log('\n--- resolved Google Sheets reads use shape-only session records ---'); + + if (!dispatcher || typeof dispatcher.dispatchMcpMessageRoute !== 'function') return; + const sheetId = 'private-sheet-id-for-recorder-test'; + const sentinel = 'PRIVATE_SHEET_CELL_VALUE'; + const tabsById = { + [RESOLVER_TAB_A]: { + id: RESOLVER_TAB_A, + url: `https://docs.google.com/spreadsheets/d/${sheetId}/edit#gid=0`, + active: true, + windowId: 1 + } + }; + const { client } = installResolverAwareGlobals({ + tabsById, + activeTabId: RESOLVER_TAB_A, + resolverResult: { tabId: RESOLVER_TAB_A, ownershipToken: null, skipGate: false } + }); + client._handleReadPage = async () => ({ success: true, text: sentinel, charCount: sentinel.length }); + + const recorded = []; + global.fsbMcpSessionRecorder = { recordDispatch(entry) { recorded.push(entry); } }; + global.FsbSpreadsheetRecordRedaction = spreadsheetRedaction; + + const response = await dispatcher.dispatchMcpMessageRoute({ + type: 'mcp:read-page', + payload: { agentId: 'agent-sheets', tab_id: RESOLVER_TAB_A, full: true }, + client, + mcpMsgId: 'sheets-read-page' + }); + const serializedRecord = JSON.stringify(recorded[0] || {}); + assert(response?.text === sentinel, 'Sheets redaction does not alter the public read_page response'); + assert(recorded.length === 1, 'resolved Sheets read produces one diagnostic session record'); + assert(!serializedRecord.includes(sentinel) && !serializedRecord.includes(sheetId), + 'resolved Sheets session record contains no cell text or spreadsheet id'); + assert(recorded[0]?.response?.shape?.valueCount === 0, + 'resolved Sheets read persists only response shape metadata'); + assert(!serializedRecord.includes('spreadsheetTarget') && !serializedRecord.includes('targetOriginResolved'), + 'private target-classification flags are omitted from the persisted record'); + + recorded.length = 0; + global.FsbSpreadsheetRecordRedaction = null; + const fallbackResponse = await dispatcher.dispatchMcpMessageRoute({ + type: 'mcp:read-page', + payload: { agentId: 'agent-sheets', tab_id: RESOLVER_TAB_A, full: true }, + client, + mcpMsgId: 'sheets-read-page-no-redactor' + }); + assert(fallbackResponse?.text === sentinel, 'missing-redactor fallback still returns the tool result'); + assert(recorded.length === 0, 'missing-redactor fallback drops the resolved Sheets record'); + + delete global.fsbMcpSessionRecorder; + delete global.FsbSpreadsheetRecordRedaction; + clearResolverAwareGlobals(); +} + async function runErrorMapperCase() { console.log('\n--- mapped MCP error messaging ---'); @@ -407,6 +465,7 @@ async function run() { const dispatcher = loadDispatcher(); await runDispatcherRouteCases(dispatcher); await runResolverAwareRouteCases(dispatcher); + await runSpreadsheetRecorderCase(dispatcher); await runErrorMapperCase(); console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); diff --git a/tests/mcp-session-recorder.test.js b/tests/mcp-session-recorder.test.js index 0339c4dff..cdd33f91a 100644 --- a/tests/mcp-session-recorder.test.js +++ b/tests/mcp-session-recorder.test.js @@ -25,16 +25,15 @@ * session; unknown agentId creates nothing). * 4. isFinal close -> saveSession exactly once with mode 'mcp-agent', * task = first visualReason, final action included, mcpClient set; - * extractAndStoreMemories called with the same sessionId + session. + * no provider-backed memory extraction; a safe candidate is retained. * Snake_case is_final tolerated. - * 5. 60s idle expiry with sliding re-arm (fake clock/timers -- no real - * waiting; no premature close before the re-armed deadline). + * 5. 60s idle expiry with sliding chrome.alarms re-arm (fake clock/alarm + * store -- no real waiting; no premature close). * 6. run_task skipped entirely (automation engine already records it). * 7. >=1-action persistence gate: pure read-only bursts never birth * sessions, saveSession never fires. - * 8. Key-targeted redaction: url persists RAW (replay-critical), password - * and nested apiKey values replaced; lazy globalThis.redactForLog - * branch used when the helper is present. + * 8. Secret-key + sensitive-target redaction across params/results while + * ordinary replay text remains exact and caller objects stay unchanged. * 9. Recorder never throws (throwing storage + throwing logger shims); * each hook site wrapped in its own try/catch -- dispatcher guard * string x1 (message route), bridge guard string x1 (action tap). @@ -66,6 +65,11 @@ * 16. Failure semantics: failed actions still append (result.success === * false -- the replay filter excludes them downstream); sidecar-less * recordAction calls JOIN, never birth. + * 17-20. Recording opt-out/mid-session flush, startup restore ordering, + * durable alarm routing/rearming, and persisted startup policy. + * 21-26. Client-authored local memories, ambiguity rules, SW-restored + * candidates/expiry, retryable persisted-data redaction migration, and + * independent non-fatal history/memory writes. * * Run: node tests/mcp-session-recorder.test.js * @@ -98,6 +102,11 @@ function passAssertEqual(actual, expected, msg) { msg + ' (expected: ' + JSON.stringify(expected) + ', got: ' + JSON.stringify(actual) + ')'); } +function passAssertDeepEqual(actual, expected, msg) { + passAssert(JSON.stringify(actual) === JSON.stringify(expected), + msg + ' (expected: ' + JSON.stringify(expected) + ', got: ' + JSON.stringify(actual) + ')'); +} + async function drainMicrotasks() { for (let i = 0; i < 10; i++) await Promise.resolve(); await new Promise(function (r) { setImmediate(r); }); @@ -109,10 +118,21 @@ async function drainMicrotasks() { function makeLoggerStub(opts) { opts = opts || {}; - const calls = { logSessionStart: [], logAction: [], saveSession: [] }; + const calls = { + logSessionStart: [], logAction: [], saveSession: [], pruneMcpSessions: [], + withSessionMutationLock: [], updateSessionOutcome: [] + }; const logs = []; + let mutationLock = Promise.resolve(); return { calls, + withSessionMutationLock(fn) { + if (opts.throwing) throw new Error('logger boom'); + calls.withSessionMutationLock.push(true); + const next = mutationLock.then(fn, fn); + mutationLock = next.catch(() => {}); + return next; + }, logSessionStart(sessionId, task, tabId) { if (opts.throwing) throw new Error('logger boom'); calls.logSessionStart.push({ sessionId, task, tabId }); @@ -131,6 +151,16 @@ function makeLoggerStub(opts) { if (opts.throwing) throw new Error('logger boom'); calls.saveSession.push({ sessionId, sessionData }); return Promise.resolve(true); + }, + updateSessionOutcome(sessionId, sessionData) { + if (opts.throwing) throw new Error('logger boom'); + calls.updateSessionOutcome.push({ sessionId, sessionData }); + return Promise.resolve(true); + }, + pruneMcpSessions(days) { + if (opts.throwing) throw new Error('logger boom'); + calls.pruneMcpSessions.push(days); + return Promise.resolve({ removed: 0, ids: [] }); } }; } @@ -145,6 +175,33 @@ function makeMemoriesStub() { return extractAndStoreMemoriesStub; } +function makeTaskMemoryStub() { + const memories = []; + const calls = []; + return { + memories, + calls, + createTaskMemory(text, metadata, typeData) { + return { + id: 'mem_' + (calls.length + 1), + type: 'task', + text, + metadata: { ...metadata }, + sourceSessionId: metadata.sourceSessionId || null, + typeData: JSON.parse(JSON.stringify(typeData || {})) + }; + }, + storage: { + async getAll() { return memories.slice(); }, + async add(memory) { + calls.push(memory); + memories.push(memory); + return true; + } + } + }; +} + function makeStorageShim() { const store = {}; return { @@ -181,52 +238,69 @@ function makeThrowingStorageShim() { function makeTimeShim(startMs) { let nowMs = startMs; - let nextId = 1; - const timers = new Map(); const shim = { - now: function () { return nowMs; }, - setTimeout: function (fn, ms) { - const id = nextId++; - timers.set(id, { fireAt: nowMs + ms, fn }); - return id; - }, - clearTimeout: function (id) { timers.delete(id); } + now: function () { return nowMs; } }; return { shim, - timers, advance(ms) { nowMs += ms; - let fired = true; - while (fired) { - fired = false; - const due = [...timers.entries()] - .filter(([, t]) => t.fireAt <= nowMs) - .sort((a, b) => a[1].fireAt - b[1].fireAt); - if (due.length > 0) { - const [id, t] = due[0]; - timers.delete(id); - t.fn(); - fired = true; - } - } }, now: function () { return nowMs; } }; } +function makeAlarmShim(time) { + const alarms = new Map(); + const calls = { create: [], clear: [] }; + return { + alarms, + calls, + create(name, info) { + calls.create.push({ name, info: { ...info } }); + alarms.set(name, { name, ...info }); + return Promise.resolve(); + }, + get(name, callback) { + const alarm = alarms.get(name) || null; + if (typeof callback === 'function') callback(alarm); + return Promise.resolve(alarm); + }, + clear(name) { + calls.clear.push(name); + const existed = alarms.delete(name); + return Promise.resolve(existed); + }, + async fireDue() { + const due = [...alarms.values()] + .filter((alarm) => typeof alarm.when === 'number' && alarm.when <= time.now()) + .sort((a, b) => a.when - b.when); + for (const alarm of due) await recorder.handleAlarm({ name: alarm.name }); + await recorder._drainForTests(); + } + }; +} + // Fresh section state: reset recorder, install fresh stubs, return handles. -function freshSection(startMs) { +async function freshSection(startMs) { + await recorder._drainForTests(); recorder._resetForTests(); const storage = makeStorageShim(); recorder._setStorageShim(storage); + const localStorage = makeStorageShim(); + recorder._setLocalStorageShim(localStorage); const time = makeTimeShim(startMs || 1750000000000); recorder._setTimeShim(time.shim); + const alarms = makeAlarmShim(time); + recorder._setAlarmShim(alarms); const logger = makeLoggerStub(); globalThis.automationLogger = logger; const memories = makeMemoriesStub(); globalThis.extractAndStoreMemories = memories; - return { storage, time, logger, memories }; + const taskMemories = makeTaskMemoryStub(); + globalThis.createTaskMemory = taskMemories.createTaskMemory; + globalThis.memoryStorage = taskMemories.storage; + return { storage, localStorage, time, alarms, logger, memories, taskMemories }; } // Bridge-tap entry builder -- the exact shape @@ -256,14 +330,19 @@ function bridgeAction(o) { // Dispatcher message-route entry builder (exact shape the // dispatchMcpMessageRoute finally block emits). function readDispatch(o) { - return { + const requestPayload = { tool: o.tool, params: o.params || {}, agentId: o.agentId }; + if (o.tab_id !== undefined) requestPayload.tab_id = o.tab_id; + if (o.payloadTabId !== undefined) requestPayload.tabId = o.payloadTabId; + const entry = { client: o.client || 'unknown', tool: o.tool, - requestPayload: { tool: o.tool, params: o.params || {}, agentId: o.agentId }, + requestPayload, response: o.response === undefined ? { success: true } : o.response, success: o.success === undefined ? true : o.success, dispatcher_route: o.route || 'message' }; + if (o.entryTabId !== undefined) entry.tabId = o.entryTabId; + return entry; } (async function main() { @@ -271,11 +350,12 @@ function readDispatch(o) { // -- Test 1: birth on first sidecar action -------------------------------- console.log('--- Test 1: birth on first sidecar action (bridge tap) ---'); { - const { storage, logger } = freshSection(); + const { storage, logger } = await freshSection(); recorder.recordAction(bridgeAction({ agentId: 'agent-1', tool: 'click', params: { tab_id: 42, selector: '#buy' }, tabId: 42, visualReason: 'Book a flight to Berlin', client: 'Claude' })); + await recorder._drainForTests(); const open = recorder._peekOpenSessions(); const keys = Object.keys(open); passAssertEqual(keys.length, 1, 'exactly one open session after birth'); @@ -300,7 +380,7 @@ function readDispatch(o) { // -- Test 2: actionHistory accumulation ------------------------------------ console.log('\n--- Test 2: actionHistory accumulation in replay shape (wire verbs) ---'); { - const { logger } = freshSection(); + const { logger } = await freshSection(); recorder.recordAction(bridgeAction({ agentId: 'agent-1', tool: 'click', params: { tab_id: 42, selector: '#compose' }, tabId: 42, visualReason: 'Compose an email', client: 'Claude' @@ -313,6 +393,7 @@ function readDispatch(o) { agentId: 'agent-1', tool: 'pressEnter', params: { tab_id: 42 }, tabId: 42, visualReason: 'Compose an email', client: 'Claude' })); + await recorder._drainForTests(); const rec = recorder._peekOpenSessions()['agent-1::42']; passAssertEqual(rec.actionHistory.length, 3, 'three dispatches -> three actionHistory entries'); passAssertEqual(rec.actionHistory[0].tool, 'click', 'entry 1 tool in order (replay whitelist name)'); @@ -330,26 +411,28 @@ function readDispatch(o) { // -- Test 3: read-only JOIN by agentId -------------------------------------- console.log('\n--- Test 3: read-only JOIN by agentId ---'); { - const { logger } = freshSection(); + const { logger } = await freshSection(); recorder.recordAction(bridgeAction({ agentId: 'agent-1', tool: 'navigate', params: { tab_id: 42, url: 'https://example.com/inbox' }, tabId: 42, visualReason: 'Open the inbox', client: 'Claude' })); recorder.recordDispatch(readDispatch({ agentId: 'agent-1', tool: 'mcp:read-page' })); + await recorder._drainForTests(); const rec = recorder._peekOpenSessions()['agent-1::42']; passAssertEqual(rec.actionHistory.length, 2, 'sidecar-less dispatch with same agentId JOINS the open session'); passAssertEqual(rec.actionHistory[1].tool, 'mcp:read-page', 'joined entry appended in order'); passAssertEqual(rec.lastUrl, 'https://example.com/inbox', 'successful navigate sets lastUrl (memory domain fallback)'); recorder.recordDispatch(readDispatch({ agentId: 'agent-UNKNOWN', tool: 'mcp:get-tabs' })); + await recorder._drainForTests(); passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 1, 'sidecar-less dispatch with unknown agentId creates NO session'); passAssertEqual(logger.calls.saveSession.length, 0, 'no close happened during joins'); } - // -- Test 4: isFinal close -> history + memory pipeline --------------------- - console.log('\n--- Test 4: isFinal close fires saveSession + extractAndStoreMemories ---'); + // -- Test 4: isFinal close -> history + safe memory candidate --------------- + console.log('\n--- Test 4: isFinal close saves history without provider extraction ---'); { - const { logger, memories } = freshSection(); + const { logger, memories, taskMemories } = await freshSection(); recorder.recordAction(bridgeAction({ agentId: 'agent-1', tool: 'navigate', params: { tab_id: 7, url: 'https://example.com/report' }, tabId: 7, visualReason: 'Compose the weekly report', client: 'Claude' @@ -362,6 +445,7 @@ function readDispatch(o) { agentId: 'agent-1', tool: 'click', params: { tab_id: 7, selector: '#send' }, tabId: 7, visualReason: 'Send the report', client: 'Claude', isFinal: true })); + await recorder._drainForTests(); passAssertEqual(logger.calls.saveSession.length, 1, 'saveSession invoked exactly once on isFinal'); const saved = logger.calls.saveSession[0]; passAssert(/^session_\d+$/.test(saved.sessionId), 'saveSession got the session id'); @@ -374,9 +458,13 @@ function readDispatch(o) { passAssertEqual(saved.sessionData.actionHistory[2].tool, 'click', 'last entry is the final action'); passAssertEqual(saved.sessionData.iterationCount, 3, 'iterationCount = actionHistory length'); passAssertEqual(saved.sessionData.lastUrl, 'https://example.com/report', 'lastUrl carried onto the session'); - passAssertEqual(memories.calls.length, 1, 'extractAndStoreMemories called once'); - passAssertEqual(memories.calls[0].sessionId, saved.sessionId, 'memory handoff got the same sessionId'); - passAssert(memories.calls[0].session === saved.sessionData, 'memory handoff got the SAME session object'); + passAssertEqual(memories.calls.length, 0, 'session close never calls provider-backed extractAndStoreMemories'); + passAssertEqual(taskMemories.calls.length, 0, 'session close creates no long-term memory without a lifecycle summary'); + const candidates = recorder._peekMemoryCandidates(); + passAssertEqual(Object.keys(candidates).length, 1, 'close retains one short-lived safe memory candidate'); + passAssertEqual(candidates[saved.sessionId].sessionId, saved.sessionId, 'candidate is keyed by the source session id'); + passAssertEqual(Object.prototype.hasOwnProperty.call(candidates[saved.sessionId], 'actionHistory'), false, + 'candidate contains no raw action history'); passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, 'session removed from the open map'); // Snake_case tolerance: is_final on the very first action closes a @@ -385,6 +473,7 @@ function readDispatch(o) { agentId: 'agent-2', tool: 'click', params: { tab_id: 9 }, tabId: 9, visualReason: 'One-shot action', client: 'Codex', is_final: true })); + await recorder._drainForTests(); passAssertEqual(logger.calls.saveSession.length, 2, 'snake_case is_final also closes (wire-spec tolerance)'); passAssertEqual(logger.calls.saveSession[1].sessionData.actionHistory.length, 1, 'one-shot session persisted with its single action'); @@ -395,36 +484,58 @@ function readDispatch(o) { // -- Test 5: 60s idle expiry with sliding re-arm ---------------------------- console.log('\n--- Test 5: 60s idle expiry (sliding window, fake clock) ---'); { - const { time, logger } = freshSection(1750000000000); + const { time, alarms, logger, taskMemories } = await freshSection(1750000000000); recorder.recordAction(bridgeAction({ agentId: 'agent-3', tool: 'click', params: { tab_id: 5 }, tabId: 5, visualReason: 'Sort the inbox', client: 'Claude' })); + await recorder._drainForTests(); time.advance(30000); // t+30s: inside the window passAssertEqual(logger.calls.saveSession.length, 0, 'no close before the 60s deadline'); recorder.recordAction(bridgeAction({ agentId: 'agent-3', tool: 'click', params: { tab_id: 5 }, tabId: 5, visualReason: 'Sort the inbox', client: 'Claude' })); // re-arms deadline to t+90s + await recorder._drainForTests(); time.advance(45000); // t+75s: past the ORIGINAL deadline (t+60s) but inside the re-armed one passAssertEqual(logger.calls.saveSession.length, 0, 'fresh action re-armed the window -- no premature close at t+75s'); passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 1, 'session still open at t+75s'); time.advance(20000); // t+95s: past the re-armed deadline (t+90s) + await alarms.fireDue(); passAssertEqual(logger.calls.saveSession.length, 1, 'idle expiry closed and persisted the session'); passAssertEqual(logger.calls.saveSession[0].sessionData.actionHistory.length, 2, 'expired session kept both recorded actions'); + passAssertEqual(logger.calls.saveSession[0].sessionData.status, 'expired', + 'idle expiry persists an expired status'); + passAssertEqual(logger.calls.saveSession[0].sessionData.outcome, 'stopped', + 'idle expiry does not claim task success'); + passAssertEqual(logger.calls.saveSession[0].sessionData.outcomeDetails.reason, 'expired', + 'idle expiry preserves its non-terminal close reason'); passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, 'expired session removed from the map'); + + recorder.recordTaskOutcome({ + tool: 'complete_task', params: { summary: 'Late confirmed completion', tab_id: 5 }, + payload: { agentId: 'agent-3' }, response: { status: 'completed' } + }); + await recorder._drainForTests(); + passAssertEqual(logger.calls.updateSessionOutcome.length, 1, + 'a terminal summary can still correct an expired history row'); + passAssertEqual(logger.calls.updateSessionOutcome[0].sessionData.outcome, 'success', + 'late completion replaces the provisional stopped outcome'); + passAssertEqual(taskMemories.calls.length, 1, + 'late completion still creates the provider-free Task Memory'); } // -- Test 6: run_task skipped ------------------------------------------------ console.log('\n--- Test 6: run_task dispatches are never recorded ---'); { - const { logger } = freshSection(); + const { logger } = await freshSection(); recorder.recordAction(bridgeAction({ agentId: 'agent-4', tool: 'run_task', params: { tab_id: 3, task: 'do things' }, tabId: 3, visualReason: 'Autopilot handoff', client: 'Claude' })); + await recorder._drainForTests(); passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, 'run_task (even with a sidecar) creates no session'); passAssertEqual(logger.calls.logSessionStart.length, 0, 'run_task seeds no session logs'); @@ -434,59 +545,233 @@ function readDispatch(o) { // -- Test 7: >=1-action persistence gate ------------------------------------- console.log('\n--- Test 7: pure read-only bursts never create sessions ---'); { - const { logger } = freshSection(); + const { logger } = await freshSection(); recorder.recordDispatch(readDispatch({ agentId: 'agent-ro', tool: 'mcp:get-tabs' })); recorder.recordDispatch(readDispatch({ agentId: 'agent-ro', tool: 'mcp:read-page' })); recorder.recordDispatch(readDispatch({ agentId: 'agent-ro', tool: 'read_page', route: 'tool' })); + await recorder._drainForTests(); passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, 'read-only burst (agentId, never a sidecar) births no session'); passAssertEqual(logger.calls.saveSession.length, 0, 'saveSession never called for the burst'); } - // -- Test 8: key-targeted redaction ------------------------------------------- - console.log('\n--- Test 8: sensitive-key redaction, replay values raw ---'); + // -- Test 8: targeted redaction + ordinary replay fidelity ------------------- + console.log('\n--- Test 8: secrets redact everywhere while ordinary replay text remains exact ---'); { - const { logger } = freshSection(); - delete globalThis.redactForLog; // exercise the literal-fallback branch + const { storage, logger } = await freshSection(); const originalParams = { - url: 'https://example.com/x', + selector: 'input[type="password"]', password: 'hunter2', nested: { apiKey: 'k' }, + text: 'hunter2', tab_id: 11 }; + const originalResult = { success: true, apiToken: 'result-secret', value: 'hunter2' }; + recorder.recordAction(bridgeAction({ + agentId: 'agent-5', tool: 'type', params: originalParams, tabId: 11, + response: originalResult, visualReason: 'Log in', client: 'Claude' + })); + await recorder._drainForTests(); + const buffered = storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY] + .records['agent-5::11'].actionHistory[0]; + passAssertEqual(buffered.params.password, '[REDACTED]', 'buffer redacts a password key'); + passAssertEqual(buffered.params.nested.apiKey, '[REDACTED]', 'buffer recursively redacts a nested API key'); + passAssertEqual(buffered.params.text, '[REDACTED]', 'buffer redacts generic text for a password target'); + passAssertEqual(buffered.result.apiToken, '[REDACTED]', 'buffer redacts secret-bearing result keys'); + passAssertEqual(buffered.result.value, '[REDACTED]', 'buffer redacts result value for a password target'); + passAssertEqual(logger.calls.logAction[0].action.params.password, '[REDACTED]', + 'automation logger receives only sanitized params'); + passAssertEqual(logger.calls.logAction[0].result.apiToken, '[REDACTED]', + 'automation logger receives only sanitized results'); + recorder.recordAction(bridgeAction({ - agentId: 'agent-5', tool: 'navigate', params: originalParams, tabId: 11, - visualReason: 'Log in', client: 'Claude', isFinal: true + agentId: 'agent-5', tool: 'type', + params: { selector: '#search', text: 'typed exactly as entered', tab_id: 11 }, tabId: 11, + response: { success: true, text: 'ordinary result text' }, + visualReason: 'Search', client: 'Claude', isFinal: true })); - passAssertEqual(logger.calls.saveSession.length, 1, 'redaction session closed and saved'); + await recorder._drainForTests(); + passAssertEqual(logger.calls.saveSession.length, 1, 'sanitized session closed and saved'); const savedHistory = logger.calls.saveSession[0].sessionData.actionHistory; - passAssertEqual(savedHistory[0].params.url, 'https://example.com/x', - 'url persists EXACTLY raw (replay-critical)'); - passAssert(savedHistory[0].params.password !== 'hunter2', 'password value replaced'); - passAssertEqual(savedHistory[0].params.password, '[REDACTED]', - 'literal [REDACTED] used when redactForLog is absent'); - passAssert(savedHistory[0].params.nested.apiKey !== 'k', 'nested apiKey value replaced'); - passAssertEqual(savedHistory[0].params.nested.apiKey, '[REDACTED]', - 'nested sensitive key redacted recursively'); - const historyJson = JSON.stringify(savedHistory); - passAssert(historyJson.indexOf('hunter2') === -1, 'original password absent from stored actionHistory JSON'); - passAssert(historyJson.indexOf('"apiKey":"k"') === -1, 'original apiKey value absent from stored actionHistory JSON'); + passAssertEqual(savedHistory[0].params.password, '[REDACTED]', 'history keeps the password redacted'); + passAssertEqual(savedHistory[0].result.apiToken, '[REDACTED]', 'history keeps the result token redacted'); + passAssertEqual(savedHistory[1].params.text, 'typed exactly as entered', + 'ordinary replay-critical typed text remains exact'); + passAssertEqual(savedHistory[1].result.text, 'ordinary result text', + 'ordinary non-secret action results remain exact'); passAssertEqual(originalParams.password, 'hunter2', - 'caller params object NOT mutated (deep-clone before redaction)'); + 'caller params object is not mutated while cloning for replay'); + passAssertEqual(originalParams.text, 'hunter2', 'caller sensitive text remains unchanged'); + passAssertEqual(originalResult.apiToken, 'result-secret', 'caller result object remains unchanged'); + + const ordinaryPinSubstrings = [ + { metadata: { selector: '#shipping-address' }, key: 'text', value: '123 Main St' }, + { metadata: { label: 'Shopping preferences' }, key: 'value', value: 'Weekly delivery' }, + { metadata: { placeholder: 'Share your opinion' }, key: 'text', value: 'Keep this exact' } + ]; + ordinaryPinSubstrings.forEach(function (fixture) { + const params = Object.assign({}, fixture.metadata, { [fixture.key]: fixture.value }); + const clonedParams = recorder.cloneParamsForReplay(params); + const clonedResult = recorder.cloneResultForReplay({ value: fixture.value }, params); + passAssertEqual(clonedParams[fixture.key], fixture.value, + Object.values(fixture.metadata)[0] + ' remains replayable'); + passAssertEqual(clonedResult.value, fixture.value, + Object.values(fixture.metadata)[0] + ' does not redact ordinary results'); + }); + + const sensitivePinTargets = [ + { selector: '#pin' }, + { name: 'pin_code' }, + { field: 'payment-pin' }, + { field: 'paymentPin' }, + { label: 'PINCode' } + ]; + sensitivePinTargets.forEach(function (metadata) { + const params = Object.assign({}, metadata, { value: '1234' }); + passAssertEqual(recorder.cloneParamsForReplay(params).value, '[REDACTED]', + Object.values(metadata)[0] + ' redacts replay input'); + passAssertEqual(recorder.cloneResultForReplay({ value: '1234' }, params).value, '[REDACTED]', + Object.values(metadata)[0] + ' redacts replay results'); + }); + + const sensitiveCardTargets = [ + { autocomplete: 'cc-number' }, + { selector: 'input[autocomplete="cc-csc"]' }, + { autocomplete: 'cc-cvc' }, + { selector: 'input[autocomplete="cc-cvv"]' } + ]; + sensitiveCardTargets.forEach(function (metadata) { + const params = Object.assign({}, metadata, { text: '4111111111111111' }); + const result = { typed: '4111111111111111', actualValue: '4111111111111111' }; + passAssertEqual(recorder.cloneParamsForReplay(params).text, '[REDACTED]', + Object.values(metadata)[0] + ' redacts payment input'); + passAssertEqual(recorder.cloneResultForReplay(result, params).typed, '[REDACTED]', + Object.values(metadata)[0] + ' redacts typed payment results'); + passAssertEqual(recorder.cloneResultForReplay(result, params).actualValue, '[REDACTED]', + Object.values(metadata)[0] + ' redacts echoed payment values'); + }); + + ['cc-name', 'cc-exp'].forEach(function (autocomplete) { + const params = { autocomplete, text: 'replay exactly' }; + passAssertEqual(recorder.cloneParamsForReplay(params).text, 'replay exactly', + autocomplete + ' remains replayable'); + passAssertEqual(recorder.cloneResultForReplay({ value: 'replay exactly' }, params).value, 'replay exactly', + autocomplete + ' does not redact ordinary results'); + }); + + const opaquePasswordResult = recorder.cloneResultForReplay({ + success: false, + typed: 'opaque-password', + actualValue: 'opaque-password', + expectedValue: 'opaque-password', + finalTextContent: 'opaque-password', + elementInfo: { + type: 'password', + previousValue: 'previous-password' + } + }, { selector: 'e12', text: 'opaque-password' }); + passAssertEqual(opaquePasswordResult.typed, '[REDACTED]', + 'result metadata redacts typed text when an opaque selector targets a password field'); + passAssertEqual(opaquePasswordResult.actualValue, '[REDACTED]', + 'result metadata redacts the echoed password value'); + passAssertEqual(opaquePasswordResult.expectedValue, '[REDACTED]', + 'result metadata redacts the failed action expected-value echo'); + passAssertEqual(opaquePasswordResult.finalTextContent, '[REDACTED]', + 'result metadata redacts the final password text'); + passAssertEqual(opaquePasswordResult.elementInfo.previousValue, '[REDACTED]', + 'result metadata redacts the prior password value'); - // Lazy-guard branch: with globalThis.redactForLog present, its shape - // output is used instead of the literal. - globalThis.redactForLog = function (value) { - return { kind: 'shimmed', length: String(value).length }; + const benignUrl = 'https://example.com/search?q=munich%20trip#results/list'; + passAssertEqual(recorder.cloneParamsForReplay({ url: benignUrl }).url, benignUrl, + 'ordinary navigation query and SPA fragment remain byte-exact'); + + const secretUrlParams = { + url: 'https://alice:password@example.com/callback?access_token=query-secret&view=summary#access_token=fragment-secret' + }; + const sanitizedSecretUrl = 'https://example.com/callback?view=summary'; + passAssertEqual(recorder.cloneParamsForReplay(secretUrlParams).url, sanitizedSecretUrl, + 'URL userinfo plus token query and fragment are removed while benign query state remains'); + passAssertEqual(secretUrlParams.url, + 'https://alice:password@example.com/callback?access_token=query-secret&view=summary#access_token=fragment-secret', + 'URL sanitization does not mutate caller-owned params'); + passAssertEqual( + recorder.cloneParamsForReplay({ url: 'https://example.com/callback?code=oauth-secret&view=summary' }).url, + 'https://example.com/callback?view=summary', + 'OAuth authorization codes are removed from recorded navigation URLs'); + passAssertEqual( + recorder.cloneParamsForReplay({ + url: 'https://storage.example.com/file?X-Amz-Signature=signed-secret&X-Amz-Date=20260720T000000Z&view=summary' + }).url, + 'https://storage.example.com/file?view=summary', + 'signed-storage parameters are removed without dropping benign query state'); + passAssertEqual( + recorder.cloneParamsForReplay({ + url: 'https://account.blob.core.windows.net/file?sv=2026-01-01&se=2026-07-21&sp=r&sig=azure-secret&view=summary' + }).url, + 'https://account.blob.core.windows.net/file?view=summary', + 'Azure SAS signatures and companion parameters are removed together'); + passAssertEqual( + recorder.cloneParamsForReplay({ url: 'https://example.com/#/callback?code=fragment-code' }).url, + 'https://example.com/', + 'SPA callback fragments carrying OAuth codes are removed'); + passAssertEqual( + recorder.cloneParamsForReplay({ url: 'https://example.com/#ghp_abcdefghijklmnopqrstuvwxyz123456' }).url, + 'https://example.com/', + 'opaque token-shaped fragments are removed'); + passAssertEqual( + recorder.cloneParamsForReplay({ url: 'https://example.com/login/ghp_abcdefghijklmnopqrstuvwxyz123456/end' }).url, + 'https://example.com/login/:token/end', + 'recognizable credential path segments are masked'); + passAssertEqual(recorder.cloneParamsForReplay({ url: 'not a valid URL' }).url, '[REDACTED]', + 'malformed URL-like replay fields fail closed'); + + const urlSection = await freshSection(); + const failedPasswordResponse = { + success: false, + typed: 'opaque-password', + actualValue: 'opaque-password', + expectedValue: 'opaque-password', + elementInfo: { type: 'password', previousValue: 'prior-password' } }; recorder.recordAction(bridgeAction({ - agentId: 'agent-6', tool: 'type', params: { tab_id: 12, text: 'ok', password: 'hunter2' }, tabId: 12, - visualReason: 'Second login', client: 'Claude', isFinal: true + agentId: 'agent-url-redaction', tool: 'type', + params: { tab_id: 12, selector: 'e42', text: 'opaque-password' }, tabId: 12, + response: failedPasswordResponse, success: false, + visualReason: 'Sign in safely', client: 'Codex' })); - const shimmed = logger.calls.saveSession[1].sessionData.actionHistory[0].params.password; - passAssert(shimmed && shimmed.kind === 'shimmed' && shimmed.length === 7, - 'globalThis.redactForLog used via lazy guard when available'); - delete globalThis.redactForLog; + recorder.recordAction(bridgeAction({ + agentId: 'agent-url-redaction', tool: 'navigate', params: { + tab_id: 12, + url: secretUrlParams.url + }, tabId: 12, visualReason: 'Sign in safely', client: 'Codex' + })); + await recorder._drainForTests(); + const redactedRecord = urlSection.storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY] + .records['agent-url-redaction::12']; + passAssertEqual(redactedRecord.actionHistory[0].params.text, '[REDACTED]', + 'response password metadata redacts opaque-selector request text in the session buffer'); + passAssertEqual(redactedRecord.actionHistory[0].result.expectedValue, '[REDACTED]', + 'failed password expectedValue is redacted in the session buffer'); + passAssertEqual(urlSection.logger.calls.logAction[0].action.params.text, '[REDACTED]', + 'automation logger receives redacted opaque-selector password params'); + passAssertEqual(urlSection.logger.calls.logAction[0].result.expectedValue, '[REDACTED]', + 'automation logger receives a redacted expectedValue'); + passAssertEqual(redactedRecord.actionHistory[1].params.url, sanitizedSecretUrl, + 'session buffer receives only the sanitized navigation URL'); + passAssertEqual(urlSection.logger.calls.logAction[1].action.params.url, sanitizedSecretUrl, + 'automation logger receives only the sanitized navigation URL'); + passAssertEqual(redactedRecord.lastUrl, sanitizedSecretUrl, + 'open-session lastUrl stores only the sanitized navigation URL'); + + recorder.recordAction(bridgeAction({ + agentId: 'agent-url-redaction', tool: 'click', params: { tab_id: 12, selector: '#done' }, tabId: 12, + visualReason: 'Sign in safely', client: 'Codex', isFinal: true + })); + await recorder._drainForTests(); + passAssertEqual(urlSection.logger.calls.saveSession[0].sessionData.lastUrl, sanitizedSecretUrl, + 'closed session history receives only the sanitized lastUrl'); + passAssertEqual(urlSection.logger.calls.saveSession[0].sessionData.actionHistory[1].params.url, sanitizedSecretUrl, + 'closed session replay history receives only the sanitized navigation URL'); } // -- Test 9: recorder never throws -------------------------------------------- @@ -521,6 +806,7 @@ function readDispatch(o) { } catch (_e) { threw = true; } + await recorder._drainForTests(); passAssertEqual(threw, false, 'recordAction/recordDispatch never throw under throwing storage/logger shims'); passAssertEqual(returned, undefined, 'recordDispatch returns undefined (fire-and-forget contract)'); passAssertEqual(returnedAction, undefined, 'recordAction returns undefined (fire-and-forget contract)'); @@ -617,12 +903,15 @@ function readDispatch(o) { return line.includes("importScripts('utils/mcp-session-recorder.js')"); }); passAssertEqual(loadLines.length, 1, 'background.js loads utils/mcp-session-recorder.js on exactly one line'); + passAssert(bgSource.indexOf("importScripts('utils/automation-logger.js')") < + bgSource.indexOf("importScripts('utils/mcp-session-recorder.js')"), + 'background.js loads the shared logger mutation chain before recorder initialization'); } // -- Test 11: eviction restore (expired closes, live rehydrates) ---------------- console.log('\n--- Test 11: eviction restore from the fsbMcpSessionBuffer envelope ---'); { - const { storage, time, logger } = freshSection(1750000100000); + const { storage, time, alarms, logger } = await freshSection(1750000100000); const T = time.now(); storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY] = { v: 1, @@ -654,17 +943,27 @@ function readDispatch(o) { passAssertEqual(logger.calls.saveSession[0].sessionId, 'session_100', 'the EXPIRED session is the one persisted'); passAssertEqual(logger.calls.saveSession[0].sessionData.mode, 'mcp-agent', 'restored close keeps mcp-agent mode'); passAssertEqual(logger.calls.saveSession[0].sessionData.mcpClient, 'Codex', 'restored close keeps the client label'); + passAssertEqual(logger.calls.saveSession[0].sessionData.status, 'expired', + 'restored expired session keeps the expired status'); + passAssertEqual(logger.calls.saveSession[0].sessionData.outcome, 'stopped', + 'restored expired session is not reported as successful'); passAssertEqual(logger.calls.logSessionStart.length, 1, 'empty post-eviction log buffer re-seeded so the saveSession gate passes'); const open = recorder._peekOpenSessions(); passAssertEqual(Object.keys(open).length, 1, 'live session rehydrated into the map'); passAssert(open['agent-y::2'] && open['agent-y::2'].sessionId === 'session_200', 'live session record intact after restore'); - passAssert(time.timers.size >= 1, 'idle timer re-armed for the live session'); + passAssert([...alarms.alarms.keys()].some((name) => name.startsWith(recorder.FSB_MCP_SESSION_ALARM_PREFIX + 'idle:')), + 'storage-backed idle alarm re-armed for the live session'); time.advance(31000); // past the live session's remaining window + await alarms.fireDue(); passAssertEqual(logger.calls.saveSession.length, 2, 'live session closes when its restored deadline passes'); passAssertEqual(logger.calls.saveSession[1].sessionId, 'session_200', 'live session persisted on expiry'); + passAssertEqual(logger.calls.saveSession[1].sessionData.status, 'expired', + 'rehydrated live session later persists as expired'); + passAssertEqual(logger.calls.saveSession[1].sessionData.outcome, 'stopped', + 'rehydrated expiry maps to stopped'); await drainMicrotasks(); passAssertEqual(storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY], undefined, 'buffer storage key REMOVED once no open sessions remain'); @@ -673,7 +972,7 @@ function readDispatch(o) { // -- Test 12: malformed / wrong-version envelope treated as empty ---------------- console.log('\n--- Test 12: malformed envelope collapses to canonical empty ---'); { - const { storage, logger } = freshSection(); + const { storage, logger } = await freshSection(); storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY] = { v: 99, records: { @@ -701,37 +1000,40 @@ function readDispatch(o) { // -- Test 13: tab identity precedence (review finding #1) ------------------------ console.log('\n--- Test 13: tab identity -- snake_case tab_id, explicit precedence, no collapse ---'); { - freshSection(); + await freshSection(); // Wire snake_case tab_id keys the session even with NO explicit tabId // (e.g. a future recordDispatch caller without the resolved id). recorder.recordAction(bridgeAction({ agentId: 'agent-8', tool: 'click', params: { tab_id: 42, selector: '#a' }, visualReason: 'Wire snake_case tab', client: 'Claude' })); + await recorder._drainForTests(); let keys = Object.keys(recorder._peekOpenSessions()); passAssertEqual(keys.length, 1, 'snake_case-only dispatch opened one session'); passAssertEqual(keys[0], 'agent-8::42', 'params.tab_id keys the session -- no agentId::none collapse'); // Explicit resolved tabId wins over params. - freshSection(); + await freshSection(); recorder.recordAction(bridgeAction({ agentId: 'agent-8', tool: 'click', params: { tab_id: 99 }, tabId: 42, visualReason: 'Resolver beats raw params', client: 'Claude' })); + await recorder._drainForTests(); keys = Object.keys(recorder._peekOpenSessions()); passAssertEqual(keys[0], 'agent-8::42', 'explicit resolved tabId takes precedence over params.tab_id'); // camelCase back-compat (dispatcher-injected routeParams shape). - freshSection(); + await freshSection(); recorder.recordAction(bridgeAction({ agentId: 'agent-8', tool: 'click', params: { tabId: 13 }, visualReason: 'Legacy camelCase param', client: 'Claude' })); + await recorder._drainForTests(); keys = Object.keys(recorder._peekOpenSessions()); passAssertEqual(keys[0], 'agent-8::13', 'camelCase params.tabId still keys the session (back-compat)'); // The review's headline scenario: one agent, two tabs -> two sessions. - const { logger } = freshSection(); + const { logger } = await freshSection(); recorder.recordAction(bridgeAction({ agentId: 'agent-m', tool: 'click', params: { tab_id: 1 }, tabId: 1, visualReason: 'Tab one work', client: 'Claude' @@ -740,23 +1042,46 @@ function readDispatch(o) { agentId: 'agent-m', tool: 'click', params: { tab_id: 2 }, tabId: 2, visualReason: 'Tab two work', client: 'Claude' })); + await recorder._drainForTests(); const open = recorder._peekOpenSessions(); passAssertEqual(Object.keys(open).length, 2, 'same agent driving two tabs holds two DISTINCT open sessions (no merge)'); passAssert(open['agent-m::1'] && open['agent-m::2'], 'sessions keyed agent-m::1 and agent-m::2'); - passAssertEqual(open['agent-m::1'].actionHistory.length, 1, 'tab-1 session holds only its own action'); + + recorder.recordDispatch(readDispatch({ agentId: 'agent-m', tool: 'mcp:read-page', tab_id: 1 })); + recorder.recordDispatch(readDispatch({ agentId: 'agent-m', tool: 'mcp:get-dom', payloadTabId: 2 })); + recorder.recordDispatch(readDispatch({ + agentId: 'agent-m', tool: 'mcp:read-page', params: { tabId: 2 }, tab_id: 1 + })); + recorder.recordDispatch(readDispatch({ + agentId: 'agent-m', tool: 'mcp:get-dom', params: { tab_id: 2 }, entryTabId: 1 + })); + recorder.recordDispatch(readDispatch({ agentId: 'agent-m', tool: 'mcp:read-page', tab_id: 999 })); + await recorder._drainForTests(); + const attributed = recorder._peekOpenSessions(); + passAssertEqual(attributed['agent-m::1'].actionHistory.length, 3, + 'top-level payload tab_id and entry.tabId reads join tab 1 exactly'); + passAssertEqual(attributed['agent-m::2'].actionHistory.length, 3, + 'top-level payload tabId and nested params.tabId reads join tab 2 exactly'); + passAssertEqual(attributed['agent-m::2'].actionHistory[2].tool, 'mcp:read-page', + 'nested tab identity takes precedence over a conflicting top-level payload id'); + passAssertEqual(attributed['agent-m::1'].actionHistory[2].tool, 'mcp:get-dom', + 'entry.tabId takes precedence over a conflicting nested tab identity'); + passAssertEqual(attributed['agent-m::1'].actionHistory.length + attributed['agent-m::2'].actionHistory.length, 6, + 'unknown explicit tab identity is ignored instead of falling back to another tab'); passAssertEqual(logger.calls.saveSession.length, 0, 'no spurious close while both tabs are active'); } // -- Test 14: bootstrap birth (open_tab/switch_tab post-dispatch tabId) ---------- console.log('\n--- Test 14: bootstrap birth with post-dispatch resolved tabId ---'); { - const { logger } = freshSection(); + const { logger } = await freshSection(); recorder.recordAction(bridgeAction({ agentId: 'agent-b', tool: 'open_tab', params: {}, visualReason: 'Open a fresh tab for research', client: 'Claude', response: { success: true, tabId: 7 }, tabId: 7 })); + await recorder._drainForTests(); const open = recorder._peekOpenSessions(); const keys = Object.keys(open); passAssertEqual(keys.length, 1, 'bootstrap action opened one session'); @@ -770,7 +1095,7 @@ function readDispatch(o) { // -- Test 15: replay-name map (review finding #2) --------------------------------- console.log('\n--- Test 15: go_back/go_forward stored as replay whitelist names ---'); { - const { logger } = freshSection(); + const { logger } = await freshSection(); recorder.recordAction(bridgeAction({ agentId: 'agent-h', tool: 'go_back', params: { tab_id: 4 }, tabId: 4, visualReason: 'Back to the results list', client: 'Claude' @@ -787,6 +1112,7 @@ function readDispatch(o) { agentId: 'agent-h', tool: 'navigate', params: { tab_id: 4, url: 'https://example.com/done' }, tabId: 4, visualReason: 'Wrap up', client: 'Claude', isFinal: true })); + await recorder._drainForTests(); passAssertEqual(logger.calls.saveSession.length, 1, 'mapping session closed and saved'); const tools = logger.calls.saveSession[0].sessionData.actionHistory.map(function (a) { return a.tool; }); passAssertEqual(tools[0], 'goBack', "wire 'go_back' stored as replay name 'goBack'"); @@ -811,7 +1137,7 @@ function readDispatch(o) { // -- Test 16: failure semantics + sidecar-less action calls ----------------------- console.log('\n--- Test 16: failed actions append; sidecar-less recordAction joins, never births ---'); { - const { logger } = freshSection(); + const { logger } = await freshSection(); recorder.recordAction(bridgeAction({ agentId: 'agent-f', tool: 'click', params: { tab_id: 3 }, tabId: 3, visualReason: 'Try a flaky button', client: 'Claude' @@ -821,6 +1147,7 @@ function readDispatch(o) { visualReason: 'Try a flaky button', client: 'Claude', response: { success: false, error: 'element not found' }, success: false })); + await recorder._drainForTests(); let rec = recorder._peekOpenSessions()['agent-f::3']; passAssertEqual(rec.actionHistory.length, 2, 'failed action still appended to the history'); passAssertEqual(rec.actionHistory[1].result.success, false, @@ -830,6 +1157,7 @@ function readDispatch(o) { recorder.recordAction(bridgeAction({ agentId: 'agent-f', tool: 'get_text', params: { tab_id: 3 }, tabId: 3, noSidecar: true })); + await recorder._drainForTests(); rec = recorder._peekOpenSessions()['agent-f::3']; passAssertEqual(rec.actionHistory.length, 3, 'sidecar-less action-path call JOINs the open session'); @@ -837,11 +1165,619 @@ function readDispatch(o) { recorder.recordAction(bridgeAction({ agentId: 'agent-fresh', tool: 'get_text', params: {}, tabId: 5, noSidecar: true })); + await recorder._drainForTests(); passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 1, 'sidecar-less call for an unknown agent births nothing'); passAssertEqual(logger.calls.saveSession.length, 0, 'no close fired during Test 16'); } + // -- Test 17: recording policy opt-out + mid-session disable --------------------- + console.log('\n--- Test 17: recording opt-out flushes open work and rejects future events ---'); + { + const { logger } = await freshSection(); + recorder.recordAction(bridgeAction({ + agentId: 'agent-policy', tool: 'type', params: { tab_id: 6, text: 'keep this' }, tabId: 6, + visualReason: 'Policy transition', client: 'Codex' + })); + await recorder._drainForTests(); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 1, + 'session is open before recording is disabled'); + + await recorder._applyPolicyForTests(false, 45); + await recorder._drainForTests(); + passAssertEqual(logger.calls.saveSession.length, 1, + 'disabling recording flushes the currently open session'); + passAssertEqual(logger.calls.saveSession[0].sessionData.actionHistory[0].params.text, 'keep this', + 'mid-session disable preserves the already-recorded raw action'); + passAssertEqual(logger.calls.saveSession[0].sessionData.status, 'stopped', + 'mid-session disable persists a stopped status'); + passAssertEqual(logger.calls.saveSession[0].sessionData.outcome, 'stopped', + 'mid-session disable does not claim task success'); + passAssertEqual(logger.calls.saveSession[0].sessionData.outcomeDetails.reason, 'recording_disabled', + 'mid-session disable preserves its close reason'); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, + 'flush removes all open recorder state'); + passAssertEqual(recorder._getPolicyForTests().retentionDays, 45, + 'custom retention policy is applied'); + + recorder.recordAction(bridgeAction({ + agentId: 'agent-policy', tool: 'click', params: { tab_id: 6 }, tabId: 6, + visualReason: 'Ignored after opt-out', client: 'Codex', isFinal: true + })); + await recorder._drainForTests(); + passAssertEqual(logger.calls.saveSession.length, 1, + 'new MCP events are ignored while recording is disabled'); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, + 'disabled recording cannot birth a new session'); + passAssert(logger.calls.pruneMcpSessions.includes(45), + 'policy change immediately requests MCP-only retention pruning'); + } + + // -- Test 18: startup restore ordering ------------------------------------------- + console.log('\n--- Test 18: startup initialization queues live events behind restore ---'); + { + const { storage, time } = await freshSection(1750000200000); + const T = time.now(); + storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY] = { + v: 1, + records: { + 'agent-race::8': { + sessionId: 'session_400', agentId: 'agent-race', tabId: 8, + task: 'Restore then append', client: 'Codex', + startTime: T - 1000, lastActivityAt: T - 500, deadlineAt: T + 30000, + lastUrl: null, visualReasons: ['Restore then append'], + actionHistory: [{ tool: 'click', params: { tab_id: 8 }, result: { success: true }, timestamp: T - 500 }], + sawActionTool: true + } + } + }; + const baseGet = storage.get; + let releaseStorageGet; + const storageGate = new Promise((resolve) => { releaseStorageGet = resolve; }); + storage.get = function (keys) { + return storageGate.then(function () { return baseGet(keys); }); + }; + + const initializing = recorder._startInitializationForTests(); + recorder.recordAction(bridgeAction({ + agentId: 'agent-race', tool: 'type', params: { tab_id: 8, text: 'live event' }, tabId: 8, + visualReason: 'Restore then append', client: 'Codex' + })); + await drainMicrotasks(); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, + 'live event waits while startup storage restore is unresolved'); + + releaseStorageGet(); + await initializing; + await recorder._drainForTests(); + const restored = recorder._peekOpenSessions()['agent-race::8']; + passAssert(restored && restored.actionHistory.length === 2, + 'restored action and queued live action both survive startup'); + passAssertEqual(restored.actionHistory[0].tool, 'click', + 'buffered action remains first in event order'); + passAssertEqual(restored.actionHistory[1].params.text, 'live event', + 'live action appends after restore instead of being overwritten by stale state'); + const persisted = storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY]; + passAssertEqual(persisted.records['agent-race::8'].actionHistory.length, 2, + 'final storage envelope contains both ordered actions'); + } + + // -- Test 19: durable alarm namespace + rearming ------------------------------- + console.log('\n--- Test 19: chrome.alarms rearm, daily prune, and background routing ---'); + { + const { time, alarms, logger } = await freshSection(1750000300000); + recorder.recordAction(bridgeAction({ + agentId: 'agent-alarm', tool: 'click', params: { tab_id: 4 }, tabId: 4, + visualReason: 'Alarm test', client: 'Claude' + })); + await recorder._drainForTests(); + const idleCreates1 = alarms.calls.create.filter((call) => call.name.includes(':idle:')); + passAssertEqual(idleCreates1.length, 1, 'first recorded action creates one durable idle alarm'); + const firstWhen = idleCreates1[0].info.when; + + time.advance(5000); + recorder.recordDispatch(readDispatch({ agentId: 'agent-alarm', tool: 'mcp:read-page', tab_id: 4 })); + await recorder._drainForTests(); + const idleCreates2 = alarms.calls.create.filter((call) => call.name.includes(':idle:')); + passAssertEqual(idleCreates2.length, 2, 'recorded read re-arms the same idle alarm'); + passAssertEqual(idleCreates2[0].name, idleCreates2[1].name, + 'rearm uses a stable session-scoped alarm name'); + passAssertEqual(idleCreates2[1].info.when, firstWhen + 5000, + 'rearmed alarm moves to the new sliding deadline'); + + await recorder.handleAlarm({ name: recorder.FSB_MCP_SESSION_RETENTION_ALARM }); + await recorder._drainForTests(); + passAssert(logger.calls.pruneMcpSessions.includes(30), + 'daily retention alarm requests pruning with the default policy'); + + const recorderSource = fs.readFileSync(RECORDER_PATH, 'utf8'); + passAssert(!/\bsetTimeout\s*\(/.test(recorderSource), + 'recorder contains no service-worker setTimeout idle timer'); + const backgroundSource = fs.readFileSync(BACKGROUND_PATH, 'utf8'); + passAssert(backgroundSource.includes('fsbMcpSessionRecorder.handleAlarm(alarm)'), + 'existing background alarm listener routes the recorder namespace'); + } + + // -- Test 20: persisted startup opt-out ----------------------------------------- + console.log('\n--- Test 20: persisted startup opt-out loads before buffered sessions ---'); + { + const { storage, localStorage, time, alarms, logger } = await freshSection(1750000400000); + const T = time.now(); + localStorage.store[recorder.FSB_MCP_RECORDING_ENABLED_KEY] = false; + localStorage.store[recorder.FSB_MCP_RETENTION_DAYS_KEY] = 12; + storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY] = { + v: 1, + records: { + 'agent-disabled::3': { + sessionId: 'session_500', agentId: 'agent-disabled', tabId: 3, + task: 'Flush on disabled startup', client: 'Claude', + startTime: T - 1000, lastActivityAt: T - 500, deadlineAt: T + 30000, + lastUrl: null, visualReasons: ['Flush on disabled startup'], + actionHistory: [{ tool: 'click', params: { tab_id: 3 }, result: { success: true }, timestamp: T - 500 }], + sawActionTool: true + } + } + }; + + await recorder._startInitializationForTests(); + await recorder._drainForTests(); + const policy = recorder._getPolicyForTests(); + passAssertEqual(policy.recordingEnabled, false, 'startup loads the persisted recording opt-out'); + passAssertEqual(policy.retentionDays, 12, 'startup loads the persisted custom retention'); + passAssertEqual(logger.calls.saveSession.length, 1, + 'disabled startup flushes the buffered open recording exactly once'); + passAssertEqual(logger.calls.saveSession[0].sessionData.status, 'stopped', + 'disabled startup flush persists a stopped status'); + passAssertEqual(logger.calls.saveSession[0].sessionData.outcome, 'stopped', + 'disabled startup flush does not claim success'); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, + 'disabled startup leaves no open recording state'); + passAssert(logger.calls.pruneMcpSessions.includes(12), + 'startup pruning uses the persisted custom retention'); + const retentionCreatesBefore = alarms.calls.create.filter((call) => + call.name === recorder.FSB_MCP_SESSION_RETENTION_ALARM).length; + await recorder._applyPolicyForTests(false, 12); + await recorder._drainForTests(); + const retentionCreatesAfter = alarms.calls.create.filter((call) => + call.name === recorder.FSB_MCP_SESSION_RETENTION_ALARM).length; + passAssertEqual(retentionCreatesAfter, retentionCreatesBefore, + 'worker/policy reinitialization does not postpone an existing daily retention alarm'); + } + + // -- Test 21: completed summary -> one provider-free local Task Memory ---------- + console.log('\n--- Test 21: client-authored completion creates one local Task Memory ---'); + { + const { logger, memories, taskMemories } = await freshSection(1750000500000); + recorder.recordAction(bridgeAction({ + agentId: 'agent-memory', tool: 'type', + params: { tab_id: 7, selector: 'input[type="password"]', text: 'do-not-copy' }, tabId: 7, + visualReason: 'Submit the report', client: 'Codex', isFinal: true + })); + await recorder._drainForTests(); + const sourceSessionId = logger.calls.saveSession[0].sessionId; + + recorder.recordTaskOutcome({ + tool: 'complete_task', + params: { summary: 'Report submitted; api_key=summary-secret', tab_id: 7 }, + payload: { agentId: 'agent-memory' }, + response: { status: 'completed' } + }); + await recorder._drainForTests(); + + passAssertEqual(memories.calls.length, 0, 'completion path never invokes provider-backed extraction'); + passAssertEqual(taskMemories.calls.length, 1, 'completion stores exactly one local Task Memory'); + const memory = taskMemories.calls[0]; + passAssertEqual(memory.sourceSessionId, sourceSessionId, 'memory is idempotently keyed to the replay session'); + passAssertEqual(memory.metadata.source, 'mcp-client', 'memory source identifies the MCP client summary'); + passAssertEqual(memory.typeData.session.outcome, 'success', 'completed summary maps to success outcome'); + passAssert(memory.text.includes('[REDACTED]') && !memory.text.includes('summary-secret'), + 'obvious secret assignments are sanitized from the client summary'); + passAssert(!JSON.stringify(memory).includes('do-not-copy'), 'memory contains no raw action params or results'); + passAssertEqual(Object.keys(recorder._peekMemoryCandidates()).length, 0, + 'successful terminal outcome consumes the pending candidate'); + passAssertEqual(logger.calls.updateSessionOutcome.length, 1, + 'completion arriving after is_final patches the closed history row'); + passAssertEqual(logger.calls.updateSessionOutcome[0].sessionId, sourceSessionId, + 'closed completion patches the history row for the matched replay session'); + passAssertEqual(logger.calls.updateSessionOutcome[0].sessionData.outcome, 'success', + 'closed completion persists a success outcome'); + + recorder.recordTaskOutcome({ + tool: 'complete_task', + params: { summary: 'Duplicate completion', tab_id: 7 }, + payload: { agentId: 'agent-memory' } + }); + await recorder._drainForTests(); + passAssertEqual(taskMemories.calls.length, 1, 'duplicate terminal outcome cannot create a second memory'); + passAssertEqual(logger.calls.updateSessionOutcome.length, 1, + 'duplicate terminal outcome cannot patch history a second time'); + } + + // -- Test 22: partial/failure outcomes + open-session close --------------------- + console.log('\n--- Test 22: partial and failure summaries map to local outcomes ---'); + { + let section = await freshSection(1750000600000); + recorder.recordAction(bridgeAction({ + agentId: 'agent-partial', tool: 'click', params: { tab_id: 3, selector: '#continue' }, tabId: 3, + visualReason: 'Complete checkout', client: 'Claude' + })); + recorder.recordTaskOutcome({ + tool: 'partial_task', + params: { + summary: 'Cart prepared', blocker: 'Manual approval required', + next_step: 'Approve the purchase', tab_id: 3 + }, + payload: { agentId: 'agent-partial' } + }); + await recorder._drainForTests(); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 0, + 'partial lifecycle closes a matching still-open session'); + passAssertEqual(section.logger.calls.saveSession[0].sessionData.status, 'partial', + 'open-session history records the partial status'); + passAssertEqual(section.logger.calls.updateSessionOutcome.length, 0, + 'open-session lifecycle writes its outcome through the initial save only'); + passAssertEqual(section.taskMemories.calls[0].typeData.session.outcome, 'partial', + 'partial lifecycle creates a partial Task Memory'); + passAssert(section.taskMemories.calls[0].text.includes('Manual approval required') && + section.taskMemories.calls[0].text.includes('Approve the purchase'), + 'partial memory preserves blocker and next step'); + + section = await freshSection(1750000700000); + recorder.recordAction(bridgeAction({ + agentId: 'agent-failed', tool: 'click', params: { tab_id: 4 }, tabId: 4, + visualReason: 'Find unavailable data', client: 'Codex', isFinal: true + })); + await recorder._drainForTests(); + recorder.recordTaskOutcome({ + tool: 'fail_task', + params: { reason: 'The requested data does not exist', tab_id: 4 }, + payload: { agentId: 'agent-failed' } + }); + await recorder._drainForTests(); + passAssertEqual(section.taskMemories.calls.length, 1, 'failure creates one local Task Memory'); + passAssertEqual(section.taskMemories.calls[0].typeData.session.outcome, 'failure', + 'fail_task maps to failure outcome'); + passAssertEqual(section.logger.calls.updateSessionOutcome.length, 1, + 'failure arriving after is_final patches the closed history row'); + passAssertEqual(section.logger.calls.updateSessionOutcome[0].sessionData.status, 'failed', + 'closed failure persists failed history status'); + passAssertEqual(section.logger.calls.updateSessionOutcome[0].sessionData.outcome, 'failure', + 'closed failure persists failure history outcome'); + + section = await freshSection(1750000750000); + recorder.recordAction(bridgeAction({ + agentId: 'agent-closed-partial', tool: 'click', params: { tab_id: 8 }, tabId: 8, + visualReason: 'Prepare protected change', client: 'Claude', isFinal: true + })); + await recorder._drainForTests(); + recorder.recordTaskOutcome({ + tool: 'partial_task', + params: { + summary: 'Preparation finished', blocker: 'Manual approval required', + next_step: 'Approve the change', tab_id: 8 + }, + payload: { agentId: 'agent-closed-partial' } + }); + await recorder._drainForTests(); + passAssertEqual(section.logger.calls.updateSessionOutcome.length, 1, + 'partial outcome arriving after is_final patches the closed history row'); + passAssertEqual(section.logger.calls.updateSessionOutcome[0].sessionData.status, 'partial', + 'closed partial outcome persists partial history status'); + passAssertEqual(section.logger.calls.updateSessionOutcome[0].sessionData.blocker, + 'Manual approval required', 'closed partial history preserves its blocker'); + } + + // -- Test 23: ambiguity falls back to history only ------------------------------ + console.log('\n--- Test 23: multi-tab ambiguity requires explicit tab_id ---'); + { + const { taskMemories } = await freshSection(1750000800000); + recorder.recordAction(bridgeAction({ + agentId: 'agent-ambiguous', tool: 'click', params: { tab_id: 1 }, tabId: 1, + visualReason: 'Tab one', client: 'Claude' + })); + recorder.recordAction(bridgeAction({ + agentId: 'agent-ambiguous', tool: 'click', params: { tab_id: 2 }, tabId: 2, + visualReason: 'Tab two', client: 'Claude' + })); + await recorder._drainForTests(); + recorder.recordTaskOutcome({ + tool: 'complete_task', params: { summary: 'Ambiguous completion' }, + payload: { agentId: 'agent-ambiguous' } + }); + await recorder._drainForTests(); + passAssertEqual(taskMemories.calls.length, 0, 'tab-less ambiguous summary creates no memory'); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 2, + 'ambiguous summary does not close or misattribute either session'); + + recorder.recordTaskOutcome({ + tool: 'complete_task', params: { summary: 'Tab two complete', tab_id: 2 }, + payload: { agentId: 'agent-ambiguous' } + }); + await recorder._drainForTests(); + passAssertEqual(taskMemories.calls.length, 1, 'explicit tab_id resolves exactly one session'); + passAssertEqual(Object.keys(recorder._peekOpenSessions()).length, 1, + 'explicit outcome closes only its matching tab session'); + + recorder.recordTaskOutcome({ + tool: 'complete_task', params: { tab_id: 1 }, payload: { agentId: 'agent-ambiguous' } + }); + await recorder._drainForTests(); + passAssertEqual(taskMemories.calls.length, 1, 'missing required summary remains history-only'); + } + + // -- Test 24: candidate survives SW restart and expires after five minutes ------ + console.log('\n--- Test 24: persisted candidate restoration and expiry ---'); + { + let section = await freshSection(1750000900000); + recorder.recordAction(bridgeAction({ + agentId: 'agent-restore-memory', tool: 'click', params: { tab_id: 5 }, tabId: 5, + visualReason: 'Restore candidate', client: 'Codex', isFinal: true + })); + await recorder._drainForTests(); + passAssert(!!section.storage.store[recorder.FSB_MCP_MEMORY_CANDIDATES_KEY], + 'closed-session candidate is persisted in chrome.storage.session'); + + recorder._resetForTests(); + recorder._setStorageShim(section.storage); + recorder._setLocalStorageShim(section.localStorage); + recorder._setAlarmShim(section.alarms); + recorder._setTimeShim(section.time.shim); + await recorder._restoreMemoryCandidates(); + passAssertEqual(Object.keys(recorder._peekMemoryCandidates()).length, 1, + 'candidate rehydrates after simulated service-worker eviction'); + recorder.recordTaskOutcome({ + tool: 'complete_task', params: { summary: 'Restored completion', tab_id: 5 }, + payload: { agentId: 'agent-restore-memory' } + }); + await recorder._drainForTests(); + passAssertEqual(section.taskMemories.calls.length, 1, 'restored candidate can create local memory'); + + section = await freshSection(1750001000000); + recorder.recordAction(bridgeAction({ + agentId: 'agent-expire-memory', tool: 'click', params: { tab_id: 6 }, tabId: 6, + visualReason: 'Expire candidate', client: 'Claude', isFinal: true + })); + await recorder._drainForTests(); + section.time.advance(recorder.MCP_MEMORY_CANDIDATE_TTL_MS + 1); + recorder._resetForTests(); + recorder._setStorageShim(section.storage); + recorder._setLocalStorageShim(section.localStorage); + recorder._setAlarmShim(section.alarms); + recorder._setTimeShim(section.time.shim); + await recorder._restoreMemoryCandidates(); + passAssertEqual(Object.keys(recorder._peekMemoryCandidates()).length, 0, + 'candidate older than five minutes is discarded on restore'); + } + + // -- Test 25: versioned persisted-data scrub retries after interruption --------- + console.log('\n--- Test 25: persisted MCP data scrub is recursive and retryable ---'); + { + const { storage, localStorage, time } = await freshSection(1750001100000); + const rawSecretUrl = 'https://old-user:old-password@example.com/callback?code=old-oauth-code&view=history#access_token=old-fragment-token'; + const sanitizedSecretUrl = 'https://example.com/callback?view=history'; + const rawTask = 'Complete setup with password=hunter2'; + const sanitizedTask = 'Complete setup with password: [REDACTED]'; + const rawCompletion = 'Finished with Bearer abc.def.ghi'; + const sanitizedCompletion = 'Finished with Bearer [REDACTED]'; + const rawVisualReasons = ['Bearer abc.def.ghi', 'Review ordinary dashboard text']; + const sanitizedVisualReasons = ['Bearer [REDACTED]', 'Review ordinary dashboard text']; + const rawAction = { + tool: 'type', + params: { selector: 'e42', text: 'old-password', nested: { apiKey: 'old-key' } }, + result: { + success: false, + apiToken: 'old-result-token', + typed: 'old-password', + actualValue: 'old-password', + expectedValue: 'old-password', + elementInfo: { type: 'password', previousValue: 'prior-password' } + }, + timestamp: time.now() + }; + const rawNavigate = { + tool: 'navigate', params: { url: rawSecretUrl }, result: { success: true }, timestamp: time.now() + }; + storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY] = { + v: 1, + records: { + 'agent-old::1': { + sessionId: 'session-old', agentId: 'agent-old', tabId: 1, + task: rawTask, client: 'Claude', startTime: time.now(), + lastActivityAt: time.now(), deadlineAt: time.now() + 1000, + lastUrl: rawSecretUrl, visualReasons: rawVisualReasons, + actionHistory: [rawAction, rawNavigate], sawActionTool: true + } + } + }; + localStorage.store.fsbSessionLogs = { + 'session-old': { + id: 'session-old', mode: 'mcp-agent', task: rawTask, + result: 'Saved api_key=old-key', completionMessage: rawCompletion, + outcomeDetails: { summary: rawTask, result: rawCompletion }, + lastUrl: rawSecretUrl, actionHistory: [rawAction, rawNavigate], + logs: [{ data: { sessionId: 'session-old', task: rawTask } }] + } + }; + localStorage.store.fsbSessionIndex = [ + { + id: 'session-old', mode: 'mcp-agent', task: rawTask, + result: 'Saved api_key=old-key', completionMessage: rawCompletion, + outcomeDetails: { summary: rawTask, result: rawCompletion } + }, + { id: 'autopilot-old', mode: 'autopilot', task: 'Keep password=autopilot-value' } + ]; + localStorage.store.automationLogs = [ + { + data: { + sessionId: 'session-old', + task: rawTask, + authorization: 'Bearer old-token', + lastUrl: rawSecretUrl, + action: { tool: 'type', params: rawAction.params }, + result: rawAction.result + } + }, + { + data: { + sessionId: 'autopilot-old', + task: 'Keep password=autopilot-value', + authorization: 'preserve-unrelated-row' + } + } + ]; + localStorage.store[recorder.FSB_MCP_REDACTION_VERSION_KEY] = 2; + const baseSet = localStorage.set.bind(localStorage); + let interrupt = true; + localStorage.set = function (values) { + if (interrupt) return Promise.reject(new Error('interrupted migration')); + return baseSet(values); + }; + + await recorder._scrubPersistedMcpData(); + passAssertEqual(localStorage.store[recorder.FSB_MCP_REDACTION_VERSION_KEY], 2, + 'failed scrub leaves the version-2 migration marker unchanged'); + interrupt = false; + await recorder._scrubPersistedMcpData(); + passAssertEqual(localStorage.store[recorder.FSB_MCP_REDACTION_VERSION_KEY], recorder.FSB_MCP_REDACTION_VERSION, + 'successful retry writes the redaction version marker'); + const buffered = storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY].records['agent-old::1'].actionHistory[0]; + const bufferedSession = storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY].records['agent-old::1']; + const historic = localStorage.store.fsbSessionLogs['session-old'].actionHistory[0]; + const historicSession = localStorage.store.fsbSessionLogs['session-old']; + const rawLog = localStorage.store.automationLogs[0].data; + passAssertEqual(buffered.params.text, '[REDACTED]', 'startup scrub sanitizes the session buffer'); + passAssertEqual(buffered.result.expectedValue, '[REDACTED]', + 'startup scrub removes failed password expectedValue echoes'); + passAssertEqual(bufferedSession.actionHistory[1].params.url, sanitizedSecretUrl, + 'startup scrub sanitizes replay navigation URLs in the session buffer'); + passAssertEqual(bufferedSession.lastUrl, sanitizedSecretUrl, + 'startup scrub sanitizes buffered lastUrl metadata'); + passAssertEqual(bufferedSession.task, sanitizedTask, + 'startup scrub sanitizes the buffered task text'); + passAssertDeepEqual(bufferedSession.visualReasons, sanitizedVisualReasons, + 'startup scrub sanitizes every buffered visual reason while preserving ordinary text'); + passAssertEqual(historic.params.nested.apiKey, '[REDACTED]', 'startup scrub sanitizes persisted history recursively'); + passAssertEqual(historic.params.text, '[REDACTED]', + 'startup scrub uses result metadata to redact opaque-selector request text'); + passAssertEqual(historicSession.actionHistory[1].params.url, sanitizedSecretUrl, + 'startup scrub sanitizes persisted replay navigation URLs'); + passAssertEqual(historicSession.lastUrl, sanitizedSecretUrl, + 'startup scrub sanitizes persisted session lastUrl metadata'); + passAssertEqual(historicSession.task, sanitizedTask, + 'startup scrub sanitizes the closed MCP session task'); + passAssertEqual(historicSession.completionMessage, sanitizedCompletion, + 'startup scrub sanitizes closed MCP lifecycle text'); + passAssertEqual(historicSession.logs[0].data.task, sanitizedTask, + 'startup scrub sanitizes session-start task text embedded in session logs'); + passAssertEqual(localStorage.store.fsbSessionIndex[0].task, sanitizedTask, + 'startup scrub sanitizes the MCP session-index task copy'); + passAssertEqual(localStorage.store.fsbSessionIndex[0].completionMessage, sanitizedCompletion, + 'startup scrub sanitizes the MCP session-index lifecycle copy'); + passAssertEqual(rawLog.result.apiToken, '[REDACTED]', 'startup scrub sanitizes associated automation results'); + passAssertEqual(rawLog.result.expectedValue, '[REDACTED]', + 'startup scrub sanitizes expectedValue in associated automation results'); + passAssertEqual(rawLog.action.params.text, '[REDACTED]', 'startup scrub sanitizes sensitive automation text'); + passAssertEqual(rawLog.lastUrl, sanitizedSecretUrl, + 'startup scrub sanitizes URL-like fields in associated automation logs'); + passAssertEqual(rawLog.authorization, '[REDACTED]', 'startup scrub recursively sanitizes associated MCP log fields'); + passAssertEqual(rawLog.task, sanitizedTask, + 'startup scrub sanitizes task text in the shared automation log'); + passAssertEqual(rawLog.sessionId, 'session-old', + 'startup scrub preserves the generated MCP session id used by retention pruning'); + passAssertEqual(localStorage.store.automationLogs[1].data.authorization, 'preserve-unrelated-row', + 'startup scrub leaves unrelated Autopilot raw logs unchanged'); + passAssertEqual(localStorage.store.automationLogs[1].data.task, 'Keep password=autopilot-value', + 'startup scrub leaves unrelated Autopilot task text unchanged'); + passAssertEqual(localStorage.store.fsbSessionIndex[1].task, 'Keep password=autopilot-value', + 'startup scrub leaves unrelated Autopilot index text unchanged'); + passAssertEqual(globalThis.automationLogger.calls.withSessionMutationLock.length, 2, + 'every local startup scrub attempt runs through the shared logger mutation chain'); + + // A malformed record written after migration must still be sanitized at + // restore, serialization, and final history/logging boundaries. + const malformedRecord = storage.store[recorder.FSB_MCP_SESSION_BUFFER_KEY].records['agent-old::1']; + malformedRecord.task = rawTask; + malformedRecord.visualReasons = rawVisualReasons.slice(); + await recorder._restoreFromBuffer(); + await recorder._drainForTests(); + const restored = recorder._peekOpenSessions()['agent-old::1']; + passAssertEqual(restored.task, sanitizedTask, + 'buffer restore sanitizes task text before exposing the open session'); + passAssertDeepEqual(restored.visualReasons, sanitizedVisualReasons, + 'buffer restore sanitizes visual reasons before re-persisting'); + time.advance(2000); + await recorder.handleAlarm({ name: recorder.FSB_MCP_SESSION_ALARM_PREFIX + 'idle:session-old' }); + await recorder._drainForTests(); + const finalStartLog = globalThis.automationLogger.calls.logSessionStart.slice(-1)[0]; + const finalSavedSession = globalThis.automationLogger.calls.saveSession.slice(-1)[0]; + passAssertEqual(finalStartLog.task, sanitizedTask, + 'restored task text is sanitized before final session-start logging'); + passAssertEqual(finalSavedSession.sessionData.task, sanitizedTask, + 'restored task text is sanitized before final history persistence'); + } + + // -- Test 26: closed lifecycle history and memory failures stay independent ----- + console.log('\n--- Test 26: closed lifecycle side effects are independent and one-shot ---'); + { + let section = await freshSection(1750001200000); + recorder.recordAction(bridgeAction({ + agentId: 'agent-history-failure', tool: 'click', params: { tab_id: 9 }, tabId: 9, + visualReason: 'Finish despite history failure', client: 'Codex', isFinal: true + })); + await recorder._drainForTests(); + section.logger.updateSessionOutcome = function (sessionId, sessionData) { + section.logger.calls.updateSessionOutcome.push({ sessionId, sessionData }); + return Promise.reject(new Error('history unavailable')); + }; + recorder.recordTaskOutcome({ + tool: 'complete_task', params: { summary: 'Work still completed', tab_id: 9 }, + payload: { agentId: 'agent-history-failure' } + }); + await recorder._drainForTests(); + passAssertEqual(section.logger.calls.updateSessionOutcome.length, 1, + 'closed lifecycle attempts its history patch once when history fails'); + passAssertEqual(section.taskMemories.calls.length, 1, + 'history failure does not prevent the independent Task Memory write'); + recorder.recordTaskOutcome({ + tool: 'complete_task', params: { summary: 'Duplicate completion', tab_id: 9 }, + payload: { agentId: 'agent-history-failure' } + }); + await recorder._drainForTests(); + passAssertEqual(section.logger.calls.updateSessionOutcome.length, 1, + 'history failure cannot make the terminal candidate reusable'); + passAssertEqual(section.taskMemories.calls.length, 1, + 'history failure cannot create a second Task Memory'); + + section = await freshSection(1750001300000); + recorder.recordAction(bridgeAction({ + agentId: 'agent-memory-failure', tool: 'click', params: { tab_id: 10 }, tabId: 10, + visualReason: 'Finish despite memory failure', client: 'Claude', isFinal: true + })); + await recorder._drainForTests(); + section.taskMemories.storage.add = async function (memory) { + section.taskMemories.calls.push(memory); + throw new Error('memory unavailable'); + }; + recorder.recordTaskOutcome({ + tool: 'fail_task', params: { reason: 'Terminal client failure', tab_id: 10 }, + payload: { agentId: 'agent-memory-failure' } + }); + await recorder._drainForTests(); + passAssertEqual(section.logger.calls.updateSessionOutcome.length, 1, + 'Task Memory failure does not prevent the independent history patch'); + passAssertEqual(section.taskMemories.calls.length, 1, + 'failed Task Memory storage is attempted only once'); + recorder.recordTaskOutcome({ + tool: 'fail_task', params: { reason: 'Duplicate client failure', tab_id: 10 }, + payload: { agentId: 'agent-memory-failure' } + }); + await recorder._drainForTests(); + passAssertEqual(section.logger.calls.updateSessionOutcome.length, 1, + 'Task Memory failure cannot permit a second history outcome'); + passAssertEqual(section.taskMemories.calls.length, 1, + 'Task Memory failure cannot make the terminal candidate reusable'); + } + // -- Wrap up --------------------------------------------------------------------- recorder._resetForTests(); recorder._setTimeShim(null); diff --git a/tests/mcp-session-settings-ui.test.js b/tests/mcp-session-settings-ui.test.js new file mode 100644 index 000000000..4ba0b224a --- /dev/null +++ b/tests/mcp-session-settings-ui.test.js @@ -0,0 +1,85 @@ +/** + * Advanced Settings contract for local, secret-redacted MCP replay recording. + * Run: node tests/mcp-session-settings-ui.test.js + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const OPTIONS_PATH = path.resolve(__dirname, '..', 'extension', 'ui', 'options.js'); +const HTML_PATH = path.resolve(__dirname, '..', 'extension', 'ui', 'control_panel.html'); +const options = fs.readFileSync(OPTIONS_PATH, 'utf8'); +const html = fs.readFileSync(HTML_PATH, 'utf8'); + +let passed = 0; +let failed = 0; + +function check(condition, message) { + if (condition) { + passed++; + console.log(' PASS:', message); + } else { + failed++; + console.error(' FAIL:', message); + } +} + +console.log('--- MCP replay Advanced Settings contract ---'); + +check(/fsbMcpSessionRecordingEnabled\s*:\s*true/.test(options), + 'recording default is true'); +check(/fsbMcpSessionRetentionDays\s*:\s*30\b/.test(options), + 'retention default is 30 days'); +check(options.includes("getElementById('fsbMcpSessionRecordingEnabled')"), + 'cacheElements wires the recording toggle'); +check(options.includes("getElementById('fsbMcpSessionRetentionDays')"), + 'cacheElements wires the retention input'); +check(/fsbMcpSessionRecordingEnabled\.checked\s*=\s*settings\.fsbMcpSessionRecordingEnabled\s*!==\s*false/.test(options), + 'loadSettings defaults the toggle on when the key is absent'); +check(/clampMcpSessionRetentionDays\(settings\.fsbMcpSessionRetentionDays\)/.test(options), + 'loadSettings clamps persisted retention before painting'); +check(/fsbMcpSessionRecordingEnabled\s*:\s*elements\.fsbMcpSessionRecordingEnabled\?\.checked\s*\?\?\s*true/.test(options), + 'saveSettings persists the recording toggle'); +check(/fsbMcpSessionRetentionDays\s*:\s*clampMcpSessionRetentionDays\(elements\.fsbMcpSessionRetentionDays\?\.value\)/.test(options), + 'saveSettings clamps and persists retention days'); + +check(/id=["']fsbMcpSessionRecordingEnabled["'][^>]*checked/.test(html), + 'Advanced Settings includes a checked recording toggle'); +check(/id=["']fsbMcpSessionRetentionDays["']/.test(html), + 'Advanced Settings includes the retention input'); +check(/data-min=["']1["'][^>]*data-max=["']365["']/.test(html), + 'retention stepper declares the 1-365 range'); +check(/id=["']fsbMcpSessionRetentionDays["'][^>]*value=["']30["']/.test(html), + 'retention input displays the 30-day default'); +check(/typed text/i.test(html) && /action results/i.test(html) && /locally/i.test(html), + 'disclosure says typed text and action results are stored locally'); +check(/redacting secret-bearing fields/i.test(html) && /sensitive inputs/i.test(html), + 'disclosure clearly states the replay redaction boundary'); +check(/no separate AI-provider call is made/i.test(html) && /local task memory/i.test(html), + 'disclosure explains the client-authored, provider-free memory handoff'); +check(/Autopilot history is unaffected/i.test(html) && /Autopilot history is never pruned/i.test(html), + 'copy limits opt-out and retention behavior to MCP history'); +check(/up to the 50 most recent sessions/i.test(html) && + /no session is kept longer than the configured retention period/i.test(html), + 'copy discloses both the MCP history capacity and retention-age limits'); +check(!/existing MCP sessions remain until their retention period expires/i.test(html), + 'copy no longer promises that every session survives until age expiry'); + +const helperMatch = options.match(/function clampMcpSessionRetentionDays\(value\)\s*\{[\s\S]*?\n\}/); +check(!!helperMatch, 'retention clamp helper exists'); +if (helperMatch) { + const sandbox = { defaultSettings: { fsbMcpSessionRetentionDays: 30 }, result: null }; + vm.createContext(sandbox); + vm.runInContext(helperMatch[0] + '\nresult = clampMcpSessionRetentionDays;', sandbox); + const clamp = sandbox.result; + check(clamp(0) === 1, 'clamp enforces minimum 1 day'); + check(clamp(999) === 365, 'clamp enforces maximum 365 days'); + check(clamp(30.9) === 30, 'clamp floors to an integer'); + check(clamp('not-a-number') === 30, 'invalid retention falls back to 30 days'); +} + +console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); +process.exit(failed > 0 ? 1 : 0); diff --git a/tests/mcp-tool-routing-contract.test.js b/tests/mcp-tool-routing-contract.test.js index 5929b28c2..194501e5b 100644 --- a/tests/mcp-tool-routing-contract.test.js +++ b/tests/mcp-tool-routing-contract.test.js @@ -473,6 +473,203 @@ async function runTriggerOwnershipGateCase(dispatcher, groups) { } } +async function runTaskOutcomeRecorderCase(dispatcher, groups) { + if (!dispatcher || !groups.includes('browser')) return; + + console.log('\n--- lifecycle summaries are the only task-memory handoff ---'); + const previousRecorder = global.fsbMcpSessionRecorder; + const calls = []; + global.fsbMcpSessionRecorder = { + recordTaskOutcome(input) { calls.push(input); } + }; + + try { + const payload = { agentId: 'agent-lifecycle', ownershipToken: 'token-lifecycle' }; + const completed = await dispatcher.dispatchMcpToolRoute({ + tool: 'complete_task', + params: { summary: 'Completed safely', tabId: 9, tab_id: 9 }, + payload + }); + assert(completed?.status === 'completed', 'complete_task returns a handler-confirmed completed status'); + assert(calls.length === 1, 'valid complete_task records one client-authored outcome'); + assert(calls[0].payload.agentId === 'agent-lifecycle', 'outcome hook preserves agent identity'); + assert(calls[0].params.tab_id === 9, 'outcome hook preserves public tab_id'); + + const failed = await dispatcher.dispatchMcpToolRoute({ + tool: 'fail_task', params: { reason: 'Unrecoverable' }, payload + }); + assert(failed?.status === 'failed' && failed?.success === false, + 'fail_task valid terminal response remains success:false with failed status'); + assert(calls.length === 2, 'valid fail_task still records a terminal outcome'); + + await dispatcher.dispatchMcpToolRoute({ tool: 'complete_task', params: {}, payload }); + assert(calls.length === 2, 'invalid lifecycle params never reach the task-memory recorder'); + } finally { + if (previousRecorder === undefined) delete global.fsbMcpSessionRecorder; + else global.fsbMcpSessionRecorder = previousRecorder; + } +} + +async function runVisualSessionTokenOwnershipCase(dispatcher, groups) { + if (!dispatcher || !groups.includes('browser')) return; + + console.log('\n--- visual-session tokens resolve to their authoritative owned tab ---'); + const previousRegistry = global.fsbAgentRegistryInstance; + const previousResolver = global.resolveMcpVisualSessionTabId; + const previousHandler = global.handleMcpVisualSessionTaskStatus; + const previousRecorder = global.fsbMcpSessionRecorder; + const handlerCalls = []; + const outcomeCalls = []; + + global.fsbAgentRegistryInstance = { + hasAgent(agentId) { + return agentId === 'agent-owner' || agentId === 'agent-intruder'; + }, + isOwnedBy(tabId, agentId, ownershipToken) { + if (tabId === 9) return agentId === 'agent-owner' && ownershipToken === 'owner-token-9'; + if (tabId === 10) return agentId === 'agent-intruder' && ownershipToken === 'intruder-token-10'; + return false; + }, + getOwner(tabId) { + if (tabId === 9) return 'agent-owner'; + if (tabId === 10) return 'agent-intruder'; + return null; + }, + getTabMetadata() { + return { incognito: false, windowId: 1 }; + }, + getAgentWindowId() { + return 1; + } + }; + global.resolveMcpVisualSessionTabId = (sessionToken) => ( + sessionToken === 'session-token-9' ? 9 : null + ); + global.handleMcpVisualSessionTaskStatus = (request, _sender, sendResponse) => { + handlerCalls.push(request); + if (request.sessionToken !== 'session-token-9') { + sendResponse({ success: false, errorCode: 'visual_session_not_found' }); + return true; + } + if (request.tool === 'report_progress') { + sendResponse({ success: true, tool: request.tool, hadEffect: true, message: request.message }); + } else if (request.tool === 'fail_task') { + sendResponse({ success: false, tool: request.tool, status: 'failed', reason: request.reason }); + } else { + sendResponse({ + success: true, + tool: request.tool, + status: request.tool === 'complete_task' ? 'completed' : 'partial' + }); + } + return true; + }; + global.fsbMcpSessionRecorder = { + recordTaskOutcome(input) { + outcomeCalls.push(input); + } + }; + + const toolCases = [ + { tool: 'report_progress', params: { message: 'Still working' } }, + { tool: 'complete_task', params: { summary: 'Finished safely' } }, + { tool: 'partial_task', params: { summary: 'Made progress', blocker: 'Approval required' } }, + { tool: 'fail_task', params: { reason: 'Unrecoverable' } } + ]; + const ownerPayload = { agentId: 'agent-owner', ownershipToken: 'owner-token-9' }; + const intruderPayload = { agentId: 'agent-intruder', ownershipToken: 'intruder-token-10' }; + + try { + for (const testCase of toolCases) { + const response = await dispatcher.dispatchMcpToolRoute({ + tool: testCase.tool, + params: { ...testCase.params, session_token: 'session-token-9' }, + payload: ownerPayload + }); + assert(response?.tool === testCase.tool, + `${testCase.tool} accepts an owner token without explicit tab_id`); + } + assert(handlerCalls.length === toolCases.length, + 'all token-backed lifecycle tools reach the visual-session handler for the owner'); + assert(outcomeCalls.length === 3, + 'only terminal token-backed lifecycle tools reach task-memory recording'); + assert(outcomeCalls.every(call => call.params.tabId === 9 && call.params.tab_id === 9), + 'token-only terminal outcomes are attributed to the authoritative tab'); + + const matchingExplicit = await dispatcher.dispatchMcpToolRoute({ + tool: 'complete_task', + params: { summary: 'Explicit match', session_token: 'session-token-9', tab_id: 9 }, + payload: ownerPayload + }); + assert(matchingExplicit?.status === 'completed', + 'matching explicit tab_id remains compatible with the token-backed route'); + assert(outcomeCalls.at(-1)?.params.tabId === 9 && outcomeCalls.at(-1)?.params.tab_id === 9, + 'matching explicit tab_id remains canonical in task-memory recording'); + + const callsBeforeRejects = handlerCalls.length; + const outcomesBeforeRejects = outcomeCalls.length; + for (const testCase of toolCases) { + const response = await dispatcher.dispatchMcpToolRoute({ + tool: testCase.tool, + params: { ...testCase.params, session_token: 'session-token-9' }, + payload: intruderPayload + }); + assert(response?.errorCode === 'TAB_NOT_OWNED', + `${testCase.tool} rejects a foreign agent before visual-session mutation`); + } + assert(handlerCalls.length === callsBeforeRejects, + 'foreign-agent token calls never invoke the visual-session handler'); + assert(outcomeCalls.length === outcomesBeforeRejects, + 'foreign-agent token calls never reach task-memory recording'); + + const staleOwnership = await dispatcher.dispatchMcpToolRoute({ + tool: 'complete_task', + params: { summary: 'Stale owner token', session_token: 'session-token-9' }, + payload: { agentId: 'agent-owner', ownershipToken: 'stale-owner-token' } + }); + assert(staleOwnership?.errorCode === 'TAB_NOT_OWNED', + 'same-agent calls with a stale ownership token are rejected'); + + const spoofedTab = await dispatcher.dispatchMcpToolRoute({ + tool: 'complete_task', + params: { summary: 'Spoofed tab', session_token: 'session-token-9', tab_id: 10 }, + payload: intruderPayload + }); + assert(spoofedTab?.errorCode === 'mcp_route_invalid_params', + 'an owned tab_id cannot be paired with another tab\'s visual-session token'); + + const missingToken = await dispatcher.dispatchMcpToolRoute({ + tool: 'complete_task', + params: { summary: 'Missing token', session_token: 'missing-session-token' }, + payload: ownerPayload + }); + assert(missingToken?.errorCode === 'visual_session_not_found', + 'an unknown session token preserves the existing not-found response'); + + delete global.resolveMcpVisualSessionTabId; + const unavailableResolver = await dispatcher.dispatchMcpToolRoute({ + tool: 'complete_task', + params: { summary: 'No resolver', session_token: 'session-token-9' }, + payload: ownerPayload + }); + assert(unavailableResolver?.errorCode === 'visual_session_unavailable', + 'token-backed lifecycle calls fail closed when the resolver is unavailable'); + assert(handlerCalls.length === callsBeforeRejects + 1, + 'only the unknown-token compatibility path reaches the handler after ownership rejections'); + assert(outcomeCalls.length === outcomesBeforeRejects, + 'rejected and unknown token calls do not create task outcomes'); + } finally { + if (previousRegistry === undefined) delete global.fsbAgentRegistryInstance; + else global.fsbAgentRegistryInstance = previousRegistry; + if (previousResolver === undefined) delete global.resolveMcpVisualSessionTabId; + else global.resolveMcpVisualSessionTabId = previousResolver; + if (previousHandler === undefined) delete global.handleMcpVisualSessionTaskStatus; + else global.handleMcpVisualSessionTaskStatus = previousHandler; + if (previousRecorder === undefined) delete global.fsbMcpSessionRecorder; + else global.fsbMcpSessionRecorder = previousRecorder; + } +} + async function run() { const groups = selectedGroups(); console.log(`\n--- MCP route contract group: ${groups.join(', ')} ---`); @@ -482,6 +679,8 @@ async function run() { runDispatcherChecks(dispatcher, groups); await runObservabilityRedactionCase(dispatcher, groups); await runTriggerOwnershipGateCase(dispatcher, groups); + await runTaskOutcomeRecorderCase(dispatcher, groups); + await runVisualSessionTokenOwnershipCase(dispatcher, groups); console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/mcp-tool-smoke.test.js b/tests/mcp-tool-smoke.test.js index 2da22dbd6..345429962 100644 --- a/tests/mcp-tool-smoke.test.js +++ b/tests/mcp-tool-smoke.test.js @@ -37,6 +37,9 @@ const requiredSmokeTools = [ 'end_visual_session', 'run_task', 'stop_task', + 'complete_task', + 'partial_task', + 'fail_task', 'trigger', 'stop_trigger', 'get_trigger_status', @@ -113,6 +116,22 @@ async function run() { }), 'mcp:start-automation': { success: true, sessionId: 'smoke-session', status: 'started' }, 'mcp:stop-automation': { success: true, stopped: true }, + 'mcp:task-status': ({ payload }) => { + if (payload.tool === 'fail_task') { + return { + success: false, + tool: 'fail_task', + status: 'failed', + error: payload.params.reason, + reason: payload.params.reason, + }; + } + return { + success: true, + tool: payload.tool, + status: payload.tool === 'complete_task' ? 'completed' : 'partial', + }; + }, 'mcp:trigger': ({ payload }) => ({ success: true, trigger_id: 'trg_smoke', status: 'armed', owner: payload.agentId }), 'mcp:stop-trigger': ({ payload }) => ({ success: true, trigger_id: payload.trigger_id, stopped: true }), 'mcp:get-trigger-status': ({ payload }) => ({ success: true, trigger_id: payload.trigger_id, status: 'armed' }), @@ -257,6 +276,84 @@ async function run() { 'stop_task routes through mcp:stop-automation with agentId payload (Phase 238 includes agentId; Phase 240 strengthens with ownershipToken)', ); + const completeTaskCall = await invokeTool(harness, 'complete_task', { + summary: 'Smoke task completed', + tab_id: 7, + }); + assertDeepEqual( + completeTaskCall && completeTaskCall.message, + { + type: 'mcp:task-status', + payload: { + tool: 'complete_task', + params: { summary: 'Smoke task completed', tab_id: 7 }, + agentId: 'agent_test_smoke', + ownershipToken: 'token_test_smoke', + }, + }, + 'complete_task routes its client-authored summary with agent identity and tab_id', + ); + + const partialTaskCall = await invokeTool(harness, 'partial_task', { + summary: 'Useful work completed', + blocker: 'Manual approval required', + next_step: 'Approve the operation', + }); + assertDeepEqual( + partialTaskCall && partialTaskCall.message, + { + type: 'mcp:task-status', + payload: { + tool: 'partial_task', + params: { + summary: 'Useful work completed', + blocker: 'Manual approval required', + next_step: 'Approve the operation', + }, + agentId: 'agent_test_smoke', + ownershipToken: 'token_test_smoke', + }, + }, + 'partial_task routes summary, blocker, and next step through mcp:task-status', + ); + + const failTaskHandler = harness.getHandler('fail_task'); + assert(typeof failTaskHandler === 'function', 'fail_task registers a callable MCP handler'); + const failTaskCallStart = harness.bridgeCalls.length; + const failTaskResult = await failTaskHandler( + { reason: 'Unrecoverable test failure' }, + harness.createExtra(), + ); + const failTaskCall = harness.bridgeCalls[failTaskCallStart]; + assertDeepEqual( + failTaskCall && failTaskCall.message, + { + type: 'mcp:task-status', + payload: { + tool: 'fail_task', + params: { reason: 'Unrecoverable test failure' }, + agentId: 'agent_test_smoke', + ownershipToken: 'token_test_smoke', + }, + }, + 'fail_task routes the client-authored reason through mcp:task-status', + ); + assert( + failTaskResult && failTaskResult.isError !== true, + 'a recorded fail_task outcome is returned as a normal MCP acknowledgement', + ); + assertDeepEqual( + JSON.parse(failTaskResult.content[0].text), + { + success: false, + tool: 'fail_task', + status: 'failed', + error: 'Unrecoverable test failure', + reason: 'Unrecoverable test failure', + }, + 'fail_task acknowledgement preserves the recorded failure details', + ); + const getLogsCall = await invokeTool(harness, 'get_logs', { sessionId: 'smoke-session', count: 10 }); assertDeepEqual( getLogsCall && getLogsCall.message, diff --git a/tests/mcp-version-parity.test.js b/tests/mcp-version-parity.test.js index d2a0915aa..1dd5cc63b 100644 --- a/tests/mcp-version-parity.test.js +++ b/tests/mcp-version-parity.test.js @@ -22,7 +22,7 @@ function assertEqual(actual, expected, msg) { } const repoRoot = path.resolve(__dirname, '..'); -const canonicalVersion = '0.10.0'; +const expectedReleaseVersion = '0.11.0'; function readText(relativePath) { return fs.readFileSync(path.join(repoRoot, relativePath), 'utf8'); @@ -37,6 +37,11 @@ function extractRuntimeVersion(versionSource) { return match ? match[1] : null; } +function extractLatestChangelogVersion(changelogSource) { + const match = changelogSource.match(/^## (\d+\.\d+\.\d+)(?:\s|$)/m); + return match ? match[1] : null; +} + function collectExplicitVersions(text) { const matches = []; const patterns = [ @@ -64,17 +69,52 @@ function runCommand(command) { async function run() { const packageJson = readJson('mcp/package.json'); + const packageLock = readJson('mcp/package-lock.json'); const serverJson = readJson('mcp/server.json'); const versionSource = readText('mcp/src/version.ts'); const packageReadme = readText('mcp/README.md'); + const packageChangelog = readText('mcp/CHANGELOG.md'); + const publishWorkflow = readText('.github/workflows/npm-publish.yml'); const rootReadme = readText('README.md'); + const rootChangelog = readText('CHANGELOG.md'); + const llmsSource = readText('showcase/angular/scripts/llms.source.md'); + const llmsFullSource = readText('showcase/angular/scripts/llms-full.source.md'); + const llmsPublic = readText('showcase/angular/public/llms.txt'); + const llmsFullPublic = readText('showcase/angular/public/llms-full.txt'); + const canonicalVersion = packageJson.version; console.log('\n--- metadata parity ---'); - assertEqual(packageJson.version, canonicalVersion, 'mcp/package.json version stays on canonical version parity target'); + assertEqual(canonicalVersion, expectedReleaseVersion, 'mcp/package.json advances to the intended release version'); + assertEqual(packageLock.version, canonicalVersion, 'mcp/package-lock.json top-level version matches canonical package version'); + assertEqual(packageLock.packages[''].version, canonicalVersion, 'mcp/package-lock.json root package version matches canonical package version'); assertEqual(extractRuntimeVersion(versionSource), canonicalVersion, 'FSB_MCP_VERSION matches canonical package version'); assertEqual(serverJson.version, canonicalVersion, 'server.json top-level version matches canonical package version'); assertEqual(serverJson.packages[0].version, canonicalVersion, 'server.json package version matches canonical package version'); + console.log('\n--- release documentation parity ---'); + assertEqual(extractLatestChangelogVersion(packageChangelog), canonicalVersion, 'latest MCP changelog entry matches canonical package version'); + assert(packageChangelog.includes('`mcp:task-status`'), 'MCP changelog documents the new task-status wire contract'); + assert(packageChangelog.includes(`fsb-mcp-server@${canonicalVersion}`), 'MCP changelog names the current publish artifact'); + assert(packageReadme.includes(`### What's New In v${canonicalVersion}`), 'MCP README has a current release summary'); + assert(packageReadme.includes(`### Releasing ${canonicalVersion}`), 'MCP README release instructions match canonical package version'); + assert(packageReadme.includes(`git tag mcp-v${canonicalVersion} && git push origin mcp-v${canonicalVersion}`), 'MCP README uses the MCP-only release tag'); + assert(!packageReadme.includes(`git tag v${canonicalVersion}`), 'MCP README does not use a repository milestone tag for npm publishing'); + assert(packageReadme.includes('requires FSB extension 0.9.91 or newer'), 'MCP README documents extension compatibility for task-status'); + assert(publishWorkflow.includes("- 'mcp-v*'"), 'npm publish workflow is restricted to MCP release tags'); + const currentProductRelease = rootChangelog + .split(/^## /m) + .find(section => section.startsWith('v0.9.91 ')); + assert(currentProductRelease && currentProductRelease.includes(`fsb-mcp-server\` advances to \`${canonicalVersion}`), + 'current product changelog names the independently versioned MCP release'); + for (const [name, content] of [ + ['LLM summary source', llmsSource], + ['LLM full source', llmsFullSource], + ['generated LLM summary', llmsPublic], + ['generated LLM full text', llmsFullPublic], + ]) { + assert(content.includes(`fsb-mcp-server ${canonicalVersion}`), `${name} advertises the canonical MCP release`); + } + console.log('\n--- cli output parity ---'); const helpOutput = runCommand('node mcp/build/index.js help'); const installOutput = runCommand('node mcp/build/index.js install'); diff --git a/tests/onboarding-first-run.test.js b/tests/onboarding-first-run.test.js index c9a36d446..e08d5cb1e 100644 --- a/tests/onboarding-first-run.test.js +++ b/tests/onboarding-first-run.test.js @@ -23,7 +23,7 @@ async function loadInitConfigHarness() { console, chrome: { runtime: { - getManifest: () => ({ version: '0.9.90' }), + getManifest: () => ({ version: '0.9.91' }), getURL: (rel) => `chrome-extension://fsb/${rel}`, openOptionsPage: () => openedOptions.push(true), onInstalled: { @@ -97,7 +97,7 @@ async function loadInitConfigHarness() { console.log('--- onboarding provider storage mapping ---'); { const context = { - chrome: { runtime: { getManifest: () => ({ version: '0.9.90' }) } }, + chrome: { runtime: { getManifest: () => ({ version: '0.9.91' }) } }, document: { addEventListener: () => {} }, window: { addEventListener: () => {} }, console, diff --git a/tests/recipe-schema-lock.test.js b/tests/recipe-schema-lock.test.js index 7025a72eb..007ebc149 100644 --- a/tests/recipe-schema-lock.test.js +++ b/tests/recipe-schema-lock.test.js @@ -42,7 +42,7 @@ const TOOL_DEFS_PATH = path.join(REPO_ROOT, 'mcp', 'ai', 'tool-definitions.cjs') // tests/tool-definitions-parity.test.js:52 / capability-mcp-surface.test.js. The // recipe-rot work must NOT move this (no tool-definitions edit this phase). const EXPECTED_NON_TRIGGER_REGISTRY_HASH = - '6354d78836bc8927f55af4562dec099f614ebbe034d018c163d7b8b2e5c6b60d'; + '0a525835adc6961463c5a954f3e80205f066e23bef6089283ef598c78f1d8623'; // The four trigger tools sit IN TOOL_REGISTRY but are excluded from the frozen // non-trigger baseline (mirrors tool-definitions-parity.test.js:35/132). @@ -140,7 +140,7 @@ const nonTriggerTools = td.TOOL_REGISTRY.filter(function (tool) { }); const actualRegistryHash = registryHash(nonTriggerTools); check(actualRegistryHash === EXPECTED_NON_TRIGGER_REGISTRY_HASH, - 'INV-01: the frozen non-trigger tool registry hash 6354d78836bc8927f55af4562dec099f614ebbe034d018c163d7b8b2e5c6b60d is unmoved (no tool-definitions edit this phase)'); + 'INV-01: the frozen non-trigger tool registry hash is unmoved (no tool-definitions edit this phase)'); if (actualRegistryHash !== EXPECTED_NON_TRIGGER_REGISTRY_HASH) { console.error(' DIAG: expected ' + EXPECTED_NON_TRIGGER_REGISTRY_HASH); console.error(' DIAG: actual ' + actualRegistryHash); diff --git a/tests/skill-fsb-spec.test.js b/tests/skill-fsb-spec.test.js index 31ba61d1c..c57588d3d 100644 --- a/tests/skill-fsb-spec.test.js +++ b/tests/skill-fsb-spec.test.js @@ -60,7 +60,7 @@ const nameMatch = fm.match(/^name:\s*(\S.*?)\s*$/m); check(nameMatch && nameMatch[1] === 'fsb', 'frontmatter name === fsb'); const versionMatch = fm.match(/^version:\s*(\S+)\s*$/m); -check(versionMatch && versionMatch[1] === '0.9.90', 'frontmatter version === 0.9.90'); +check(versionMatch && versionMatch[1] === '0.9.91', 'frontmatter version === 0.9.91'); // requires.bins must include node and npx const binsMatch = fm.match(/bins:\s*\[([^\]]*)\]/); diff --git a/tests/spreadsheet-record-redaction.test.js b/tests/spreadsheet-record-redaction.test.js index a984375b7..ece93af6d 100644 --- a/tests/spreadsheet-record-redaction.test.js +++ b/tests/spreadsheet-record-redaction.test.js @@ -20,14 +20,16 @@ function recorder(method) { }; } -function bridgeHarness(spreadsheetRedactor = redaction) { +function bridgeHarness(spreadsheetRedactor = redaction, chromeOverride) { const entries = []; const context = { console, + URL, fsbMcpSessionRecorder: { recordAction(entry) { entries.push(entry); } }, FsbSpreadsheetRecordRedaction: spreadsheetRedactor, + chrome: chromeOverride, resolveMcpClientLabel() { return 'test-client'; }, globalThis: null }; @@ -269,7 +271,334 @@ test('real bridge fillsheet and readsheet payloads are shape-only before recordA assertNoContent(readRecorded); }); -test('bridge drops every spreadsheet alias when the shared redactor is unavailable', () => { +test('real bridge strips Google Sheets document URLs from navigate and open_tab records', () => { + const cases = [ + { + tool: 'navigate', + url: `https://docs.google.com/spreadsheets/d/${ID}/edit?title=${SENTINEL}#gid=0` + }, + { + tool: 'open_tab', + url: `http://docs.google.com/spreadsheets/d/${ID}/edit?range=${encodeURIComponent(RANGE)}#${SENTINEL}` + } + ]; + const harness = bridgeHarness(); + + for (const fixture of cases) { + harness.client._recordMcpSessionAction({ + tool: fixture.tool, + params: { url: fixture.url }, + agentId: 'agent:navigation', + visualSession: { visualReason: `Open ${SENTINEL}`, client: 'test-client', isFinal: true } + }, { + success: true, + url: fixture.url, + spreadsheetId: ID, + title: SENTINEL + }, 12); + } + + assert.equal(harness.entries.length, 2); + for (let index = 0; index < cases.length; index++) { + const recorded = harness.entries[index]; + assert.equal(recorded.tool, cases[index].tool); + assert.deepEqual(recorded.params, { + operation: cases[index].tool, + shape: { rowCount: 0, columnCount: 0, valueCount: 0 } + }); + assert.deepEqual(recorded.payload.params, recorded.params); + assert.deepEqual(recorded.payload.visualSession, { isFinal: true }); + assert.deepEqual(recorded.response, { + success: true, + shape: { rowCount: 0, columnCount: 0, valueCount: 0 } + }); + assertNoContent(recorded); + } +}); + +test('real bridge strips Google Sheets document URLs discovered in tab responses', () => { + const sheetsUrl = `https://docs.google.com/spreadsheets/d/${ID}/edit?title=${SENTINEL}#gid=0`; + const cases = [ + { + tool: 'switch_tab', + params: { tabId: 12 }, + response: { success: true, tabId: 12, url: sheetsUrl, title: SENTINEL } + }, + { + tool: 'close_tab', + params: { tabId: 12, allow_active: true }, + response: { + success: true, + tabId: 12, + closed: true, + change_report: { + url: { before: sheetsUrl, after: null, changed: true }, + title_changed: false + } + } + }, + { + tool: 'navigate', + params: { url: 'https://example.com/redirect-to-sheet' }, + response: { + success: true, + url: 'https://example.com/redirect-to-sheet', + title: SENTINEL, + change_report: { + url: { before: 'https://example.com/redirect-to-sheet', after: sheetsUrl, changed: true } + } + } + } + ]; + const harness = bridgeHarness(); + + for (const fixture of cases) { + harness.client._recordMcpSessionAction({ + tool: fixture.tool, + params: fixture.params, + agentId: 'agent:response-navigation', + visualSession: { visualReason: `Open ${SENTINEL}`, client: 'test-client', isFinal: true } + }, fixture.response, 12); + } + + assert.equal(harness.entries.length, cases.length); + for (let index = 0; index < cases.length; index++) { + const recorded = harness.entries[index]; + assert.equal(recorded.tool, cases[index].tool); + assert.deepEqual(recorded.params, { + operation: cases[index].tool, + shape: { rowCount: 0, columnCount: 0, valueCount: 0 } + }); + assert.deepEqual(recorded.payload.params, recorded.params); + assert.deepEqual(recorded.payload.visualSession, { isFinal: true }); + assert.deepEqual(recorded.response, { + success: true, + shape: { rowCount: 0, columnCount: 0, valueCount: 0 } + }); + assertNoContent(recorded); + } +}); + +test('non-Sheets navigation URLs and lookalike hosts remain unchanged', () => { + const cases = [ + { + tool: 'navigate', + url: `https://example.com/path/${ID}?value=${SENTINEL}#keep` + }, + { + tool: 'open_tab', + url: `https://docs.google.com.evil.test/spreadsheets/d/${ID}/edit?value=${SENTINEL}` + } + ]; + + for (const fixture of cases) { + const source = { + client: 'test-client', + tool: fixture.tool, + params: { url: fixture.url }, + payload: { tool: fixture.tool, params: { url: fixture.url }, agentId: 'agent:navigation' }, + response: { success: true, url: fixture.url }, + success: true, + tabId: 12 + }; + assert.strictEqual(redaction.sanitizeEntry(source), source); + } +}); + +test('non-Sheets tab response URLs and lookalike hosts remain unchanged', () => { + const cases = [ + { + client: 'test-client', + tool: 'switch_tab', + params: { tabId: 12 }, + payload: { tool: 'switch_tab', params: { tabId: 12 }, agentId: 'agent:navigation' }, + response: { success: true, url: `https://example.com/${ID}?value=${SENTINEL}`, title: SENTINEL }, + success: true, + tabId: 12 + }, + { + client: 'test-client', + tool: 'close_tab', + params: { tabId: 12 }, + payload: { tool: 'close_tab', params: { tabId: 12 }, agentId: 'agent:navigation' }, + response: { + success: true, + change_report: { + url: { + before: `https://docs.google.com.evil.test/spreadsheets/d/${ID}/edit?value=${SENTINEL}`, + after: null, + changed: true + } + } + }, + success: true, + tabId: 12 + } + ]; + + for (const source of cases) { + assert.strictEqual(redaction.sanitizeEntry(source), source); + } +}); + +test('generic actions and page reads on Google Sheets targets are shape-only', () => { + const sheetsUrl = `https://docs.google.com/spreadsheets/d/${ID}/edit?range=${encodeURIComponent(RANGE)}#gid=0`; + const cases = [ + { + method: 'recordAction', + source: { + client: 'test-client', + tool: 'click', + params: { selector: `[aria-label="${SENTINEL}"]` }, + payload: { + tool: 'click', + params: { selector: `[aria-label="${SENTINEL}"]` }, + agentId: 'agent:generic-action', + visualSession: { visualReason: SENTINEL, isFinal: false } + }, + response: { success: true, text: SENTINEL, value: `=${SENTINEL}!A1` }, + success: true, + tabId: 12, + requireTargetOrigin: true, + targetOriginResolved: true, + spreadsheetTarget: true + } + }, + { + method: 'recordAction', + source: { + client: 'test-client', + tool: 'get_text', + params: { selector: '#selected-cell' }, + payload: { tool: 'get_text', params: { selector: '#selected-cell' }, agentId: 'agent:generic-action' }, + response: { success: true, text: SENTINEL, value: SENTINEL }, + success: true, + tabId: 12, + requireTargetOrigin: true, + targetOriginResolved: true, + spreadsheetTarget: true + } + }, + { + method: 'recordDispatch', + source: { + client: 'test-client', + tool: 'mcp:read-page', + requestPayload: { agentId: 'agent:generic-read', tab_id: 12, params: { selector: SENTINEL } }, + response: { success: true, text: `${SENTINEL} ${RANGE}`, charCount: 99 }, + success: true, + dispatcher_route: 'message', + tabId: 12, + requireTargetOrigin: true, + targetOriginResolved: true, + spreadsheetTarget: true + } + }, + { + method: 'recordDispatch', + source: { + client: 'test-client', + tool: 'mcp:get-dom', + requestPayload: { agentId: 'agent:generic-read', tab_id: 12, params: { maxElements: 50 } }, + response: { success: true, structuredDOM: { elements: [{ text: SENTINEL, value: RANGE }] } }, + success: true, + dispatcher_route: 'message', + tabId: 12, + requireTargetOrigin: true, + targetOriginResolved: true, + spreadsheetTarget: true + } + }, + { + method: 'recordDispatch', + source: { + client: 'test-client', + tool: 'mcp:get-page-snapshot', + requestPayload: { agentId: 'agent:snapshot', tab_id: 12, params: {} }, + response: { success: true, url: sheetsUrl, snapshot: `${SENTINEL} ${RANGE}` }, + success: true, + dispatcher_route: 'message' + } + } + ]; + + for (const fixture of cases) { + const sink = recorder(fixture.method); + assert.equal(redaction.recordSafely(sink.target, fixture.method, fixture.source), true); + assert.equal(sink.entries.length, 1); + const recorded = sink.entries[0]; + assert.equal(recorded.tool, fixture.source.tool); + assert.equal((recorded.params || recorded.requestPayload.params).operation, fixture.source.tool); + assert.deepEqual(recorded.response, { + success: true, + shape: { rowCount: 0, columnCount: 0, valueCount: 0 } + }); + assert.equal(Object.prototype.hasOwnProperty.call(recorded, 'requireTargetOrigin'), false); + assert.equal(Object.prototype.hasOwnProperty.call(recorded, 'targetOriginResolved'), false); + assert.equal(Object.prototype.hasOwnProperty.call(recorded, 'spreadsheetTarget'), false); + assertNoContent(recorded); + } +}); + +test('real bridge reduces resolved tab URLs to private target booleans', async () => { + const sheetsUrl = `https://docs.google.com/spreadsheets/d/${ID}/edit#gid=0`; + const harness = bridgeHarness(redaction, { + tabs: { + async get(tabId) { + if (tabId === 1) return { id: tabId, url: sheetsUrl }; + if (tabId === 2) return { id: tabId, url: `https://docs.google.com.evil.test/spreadsheets/d/${ID}/edit` }; + throw new Error('tab unavailable'); + } + } + }); + + const sheetsTarget = await harness.client._resolveMcpSessionRecordTarget(1); + const lookalikeTarget = await harness.client._resolveMcpSessionRecordTarget(2); + const unresolvedTarget = await harness.client._resolveMcpSessionRecordTarget(3); + assert.deepEqual({ ...sheetsTarget }, { targetOriginResolved: true, spreadsheetTarget: true }); + assert.deepEqual({ ...lookalikeTarget }, { targetOriginResolved: true, spreadsheetTarget: false }); + assert.deepEqual({ ...unresolvedTarget }, { targetOriginResolved: false, spreadsheetTarget: false }); + assert.equal(JSON.stringify(sheetsTarget).includes(ID), false); + + harness.client._recordMcpSessionAction({ + tool: 'click', + params: { selector: '#selected-cell' }, + agentId: 'agent:resolved-bridge' + }, { success: true, text: SENTINEL }, 1, sheetsTarget); + assert.equal(harness.entries.length, 1); + assertNoContent(harness.entries[0]); +}); + +test('content-bearing records fail closed when target origin resolution is unavailable', () => { + const source = { + client: 'test-client', + tool: 'mcp:read-page', + requestPayload: { agentId: 'agent:unresolved', params: {} }, + response: { success: true, text: SENTINEL }, + success: true, + requireTargetOrigin: true, + targetOriginResolved: false, + spreadsheetTarget: false + }; + const sink = recorder('recordDispatch'); + assert.equal(redaction.recordSafely(sink.target, 'recordDispatch', source), false); + assert.equal(sink.entries.length, 0); + + const explicitSheets = { + ...source, + response: { + success: true, + url: `https://docs.google.com/spreadsheets/d/${ID}/edit`, + text: SENTINEL + } + }; + const sanitized = recorder('recordDispatch'); + assert.equal(redaction.recordSafely(sanitized.target, 'recordDispatch', explicitSheets), true); + assert.equal(sanitized.entries.length, 1); + assertNoContent(sanitized.entries[0]); +}); + +test('bridge drops spreadsheet aliases and Sheets navigation when the shared redactor is unavailable', () => { const harness = bridgeHarness(null); for (const tool of ['fill_sheet', 'read_sheet', 'fillsheet', 'readsheet']) { harness.client._recordMcpSessionAction({ @@ -278,15 +607,72 @@ test('bridge drops every spreadsheet alias when the shared redactor is unavailab agentId: 'agent:wire' }, { success: true, data: SENTINEL }, 8); } + for (const tool of ['navigate', 'open_tab']) { + harness.client._recordMcpSessionAction({ + tool, + params: { url: `https://docs.google.com/spreadsheets/d/${ID}/edit?value=${SENTINEL}#gid=0` }, + agentId: 'agent:wire' + }, { success: true, data: SENTINEL }, 8); + } + harness.client._recordMcpSessionAction({ + tool: 'switch_tab', + params: { tabId: 8 }, + agentId: 'agent:wire' + }, { + success: true, + url: `https://docs.google.com/spreadsheets/d/${ID}/edit?value=${SENTINEL}`, + title: SENTINEL + }, 8); + harness.client._recordMcpSessionAction({ + tool: 'close_tab', + params: { tabId: 8 }, + agentId: 'agent:wire' + }, { + success: true, + change_report: { + url: { + before: `https://docs.google.com/spreadsheets/d/${ID}/edit?value=${SENTINEL}`, + after: null, + changed: true + } + } + }, 8); + harness.client._recordMcpSessionAction({ + tool: 'navigate', + params: { url: 'https://example.com/redirect-to-sheet' }, + agentId: 'agent:wire' + }, { + success: true, + change_report: { + url: { + before: 'https://example.com/redirect-to-sheet', + after: `https://docs.google.com/spreadsheets/d/${ID}/edit?value=${SENTINEL}`, + changed: true + } + } + }, 8); assert.equal(harness.entries.length, 0); + const nonSheetsTarget = { targetOriginResolved: true, spreadsheetTarget: false }; harness.client._recordMcpSessionAction({ tool: 'click', params: { selector: '#safe' }, agentId: 'agent:wire' - }, { success: true }, 8); - assert.equal(harness.entries.length, 1); + }, { success: true }, 8, nonSheetsTarget); + harness.client._recordMcpSessionAction({ + tool: 'navigate', + params: { url: `https://example.com/${ID}?value=${SENTINEL}` }, + agentId: 'agent:wire' + }, { success: true }, 8, nonSheetsTarget); + harness.client._recordMcpSessionAction({ + tool: 'switch_tab', + params: { tabId: 8 }, + agentId: 'agent:wire' + }, { success: true, url: `https://example.com/${ID}?value=${SENTINEL}` }, 8, nonSheetsTarget); + assert.equal(harness.entries.length, 3); assert.equal(harness.entries[0].tool, 'click'); + assert.equal(harness.entries[1].params.url, `https://example.com/${ID}?value=${SENTINEL}`); + assert.equal(harness.entries[2].response.url, `https://example.com/${ID}?value=${SENTINEL}`); }); test('unknown gsheets slugs are recognized and fail closed without retaining the raw slug', () => { @@ -319,6 +705,6 @@ test('both recording hooks call the shared ingress sanitizer and fail closed for < background.indexOf("importScripts('ws/mcp-tool-dispatcher.js')")); assert.match(dispatcher, /spreadsheetInvoke[\s\S]*recordSafely\([\s\S]*'recordDispatch'/); assert.match(bridge, /spreadsheetTool[\s\S]*recordSafely\([\s\S]*'recordAction'/); - assert.match(dispatcher, /if \(!spreadsheetInvoke\)[\s\S]*recordDispatch\(sessionRecordEntry\)/); - assert.match(bridge, /if \(!spreadsheetTool\)[\s\S]*recordAction\(sessionRecordEntry\)/); + assert.match(dispatcher, /if \(!spreadsheetRecord && !unresolvedRecordTarget\)[\s\S]*recordDispatch\(sessionRecordEntry\)/); + assert.match(bridge, /if \(!spreadsheetTool && !unresolvedRecordTarget\)[\s\S]*recordAction\(sessionRecordEntry\)/); }); diff --git a/tests/tool-definitions-parity.test.js b/tests/tool-definitions-parity.test.js index ffab36281..e91260e50 100644 --- a/tests/tool-definitions-parity.test.js +++ b/tests/tool-definitions-parity.test.js @@ -49,7 +49,7 @@ const EXPECTED_NON_TRIGGER_TOOL_NAMES = [ 'get_site_guide', 'search_memory', 'report_progress', 'complete_task', 'partial_task', 'fail_task', 'upload_file' ]; -const EXPECTED_NON_TRIGGER_REGISTRY_HASH = '6354d78836bc8927f55af4562dec099f614ebbe034d018c163d7b8b2e5c6b60d'; +const EXPECTED_NON_TRIGGER_REGISTRY_HASH = '0a525835adc6961463c5a954f3e80205f066e23bef6089283ef598c78f1d8623'; function stable(value) { if (Array.isArray(value)) return value.map(stable); diff --git a/tests/trigger-tool-dispatcher.test.js b/tests/trigger-tool-dispatcher.test.js index d35687eb5..dd0d48141 100644 --- a/tests/trigger-tool-dispatcher.test.js +++ b/tests/trigger-tool-dispatcher.test.js @@ -192,6 +192,47 @@ async function caseMcpQueueCompanionsBypassPendingMutation() { } } +async function caseMcpQueueLifecycleWaitsForPendingMutation() { + const queueModule = await import(pathToFileURL(MCP_BUILD_QUEUE_PATH).href); + + for (const toolName of ['complete_task', 'partial_task', 'fail_task']) { + const queue = new queueModule.TaskQueue(); + const events = []; + let releaseMutation; + const mutationGate = new Promise((resolve) => { + releaseMutation = resolve; + }); + + const mutation = queue.enqueue('click', async () => { + events.push('mutation:start'); + await mutationGate; + events.push('mutation:end'); + return 'mutation:done'; + }); + const lifecycle = queue.enqueue(toolName, async () => { + events.push(`${toolName}:run`); + return `${toolName}:done`; + }); + + const beforeRelease = await Promise.race([ + lifecycle.then(() => `${toolName}:early`), + new Promise((resolve) => setTimeout(() => resolve(`${toolName}:waiting`), 25)), + ]); + assert.strictEqual(beforeRelease, `${toolName}:waiting`, `${toolName} waits behind a pending mutation`); + assert.deepStrictEqual(events, ['mutation:start'], `${toolName} has not run before the mutation finishes`); + + releaseMutation(); + const [mutationResult, lifecycleResult] = await Promise.all([mutation, lifecycle]); + assert.strictEqual(mutationResult, 'mutation:done', 'mutation resolves normally'); + assert.strictEqual(lifecycleResult, `${toolName}:done`, `${toolName} resolves after the mutation`); + assert.deepStrictEqual( + events, + ['mutation:start', 'mutation:end', `${toolName}:run`], + `${toolName} runs only after the mutation completes` + ); + } +} + async function caseMcpDispatcherTriggerRoutesDelegateToBackground() { const src = readSource(MCP_DISPATCHER_PATH); for (const messageType of ['mcp:trigger', 'mcp:stop-trigger', 'mcp:get-trigger-status', 'mcp:list-triggers']) { @@ -1156,6 +1197,7 @@ async function caseAutopilotRejectsForeignOwner() { await runCase('MCP runtime registers trigger tools before manual tools', caseMcpRuntimeRegistersTriggerTools); await runCase('MCP TaskQueue derives read-only names from registry', caseMcpQueueDerivesReadOnlyTools); await runCase('MCP TaskQueue trigger companions bypass pending mutation', caseMcpQueueCompanionsBypassPendingMutation); + await runCase('MCP TaskQueue lifecycle outcomes wait behind pending mutation', caseMcpQueueLifecycleWaitsForPendingMutation); await runCase('MCP dispatcher trigger routes delegate to background helper', caseMcpDispatcherTriggerRoutesDelegateToBackground); await runCase('tool executor trigger routes delegate to background helper', caseToolExecutorTriggerRoutesDelegateToBackground); await runCase('tool executor runtime strips untrusted autopilot trigger fields', caseToolExecutorRuntimeStripsUntrustedAutopilotFields); From 15ddf9fffe8ecc1a9ef36a3b77ecdd34dae5765a Mon Sep 17 00:00:00 2001 From: Lakshman Date: Mon, 20 Jul 2026 13:57:50 -0500 Subject: [PATCH 23/26] fix(sheets): redact nested tab titles from sessions --- .../utils/spreadsheet-record-redaction.js | 18 ++++++- tests/spreadsheet-record-redaction.test.js | 52 +++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/extension/utils/spreadsheet-record-redaction.js b/extension/utils/spreadsheet-record-redaction.js index 24808e0d9..2f6e4097d 100644 --- a/extension/utils/spreadsheet-record-redaction.js +++ b/extension/utils/spreadsheet-record-redaction.js @@ -20,6 +20,10 @@ switch_tab: true, close_tab: true }); + var LIST_TAB_TOOLS = Object.freeze({ + list_tabs: true, + 'mcp:get-tabs': true + }); var SHEETS_DOCUMENT_PATH = /^\/spreadsheets\/d\/[^/]+(?:\/|$)/; var SAFE_OPERATION = /^[a-z0-9:_-]{1,80}$/i; var SAFE_ERROR_CODE = /^(?:GOOGLE_SHEETS|RECIPE)_[A-Z0-9_]{1,64}$/; @@ -58,6 +62,16 @@ return false; } + function hasSensitiveGoogleDocsTabSummary(entry) { + if (!LIST_TAB_TOOLS[entry.tool]) { return false; } + var tabs = object(entry.response).tabs; + if (!Array.isArray(tabs)) { return false; } + for (var i = 0; i < tabs.length; i++) { + if (object(tabs[i]).domain === 'docs.google.com') { return true; } + } + return false; + } + function classify(entry) { if (!entry || typeof entry !== 'object') { return null; } var payload = object(entry.requestPayload || entry.payload); @@ -70,7 +84,9 @@ actionShape: false }; } - if (entry.spreadsheetTarget === true || hasGoogleSheetsDocumentUrl(entry, payload)) { + if (entry.spreadsheetTarget === true || + hasGoogleSheetsDocumentUrl(entry, payload) || + hasSensitiveGoogleDocsTabSummary(entry)) { return { operation: typeof entry.tool === 'string' && SAFE_OPERATION.test(entry.tool) ? entry.tool diff --git a/tests/spreadsheet-record-redaction.test.js b/tests/spreadsheet-record-redaction.test.js index ece93af6d..c01a2e781 100644 --- a/tests/spreadsheet-record-redaction.test.js +++ b/tests/spreadsheet-record-redaction.test.js @@ -114,6 +114,58 @@ test('recordDispatch ingress strips metadata and values from every gsheets capab } }); +test('list_tabs records shape-redact nested docs.google.com tab titles', () => { + const source = { + client: 'test-client', + tool: 'mcp:get-tabs', + requestPayload: { agentId: 'agent:tabs' }, + response: { + success: true, + tabs: [ + { id: 1, title: 'Safe example', domain: 'example.com' }, + { id: 2, title: `${SENTINEL} - Google Sheets`, domain: 'docs.google.com' }, + { id: 3, title: 'Lookalike remains irrelevant', domain: 'docs.google.com.evil.test' } + ], + activeTabId: 2, + totalTabs: 3 + }, + success: true, + dispatcher_route: 'message' + }; + const sink = recorder('recordDispatch'); + + assert.equal(redaction.recordSafely(sink.target, 'recordDispatch', source), true); + assert.equal(sink.entries.length, 1); + assert.notStrictEqual(sink.entries[0], source); + assert.deepEqual(sink.entries[0].requestPayload.params, { + operation: 'mcp:get-tabs', + shape: { rowCount: 0, columnCount: 0, valueCount: 0 } + }); + assert.deepEqual(sink.entries[0].response, { + success: true, + shape: { rowCount: 0, columnCount: 0, valueCount: 0 } + }); + assertNoContent(sink.entries[0]); +}); + +test('list_tabs records leave safe and lookalike tab domains unchanged', () => { + for (const domain of ['example.com', 'docs.google.com.evil.test']) { + const source = { + client: 'test-client', + tool: 'mcp:get-tabs', + requestPayload: { agentId: 'agent:safe-tabs' }, + response: { + success: true, + tabs: [{ id: 1, title: SENTINEL, domain }], + totalTabs: 1 + }, + success: true, + dispatcher_route: 'message' + }; + assert.strictEqual(redaction.sanitizeEntry(source), source); + } +}); + test('retains only numeric request/result shape facts', () => { const source = capabilityEntry('gsheets.append_values', { values: [[SENTINEL, 1], ['x'], [true, false, `=${SENTINEL}!A1`]] From 2f8359b2f9926cd2628b4fd33fcfcde0c0e9602d Mon Sep 17 00:00:00 2001 From: Lakshman Date: Mon, 20 Jul 2026 13:57:54 -0500 Subject: [PATCH 24/26] fix(mcp): resolve token-only lifecycle ownership --- extension/ws/mcp-tool-dispatcher.js | 46 ++++++++++++++++++++---- tests/mcp-tool-routing-contract.test.js | 47 ++++++++++++++++++++++--- 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/extension/ws/mcp-tool-dispatcher.js b/extension/ws/mcp-tool-dispatcher.js index 03dd9c62a..fc585ce18 100644 --- a/extension/ws/mcp-tool-dispatcher.js +++ b/extension/ws/mcp-tool-dispatcher.js @@ -523,13 +523,45 @@ function _peekLastKnownMcpClientLabel(agentId) { return _agentClientLabelCache.size === 0 ? null : Object.fromEntries(_agentClientLabelCache); } -function canonicalizeVisualSessionTaskParams(tool, params) { +function canonicalizeVisualSessionOwnershipToken(payload, sessionTabId, hasExplicitTabId) { + if (hasExplicitTabId || !payload || typeof payload !== 'object') return payload; + + const reg = (typeof globalThis !== 'undefined') ? globalThis.fsbAgentRegistryInstance : null; + const agentId = payload.agentId; + const ownershipToken = payload.ownershipToken; + if (!reg || typeof agentId !== 'string' || !agentId || + typeof ownershipToken !== 'string' || !ownershipToken || + typeof reg.getAgentTabs !== 'function' || + typeof reg.isOwnedBy !== 'function' || + typeof reg.getTabMetadata !== 'function') { + return payload; + } + + const ownedTabIds = reg.getAgentTabs(agentId); + const tokenProvesCurrentAgentOwnership = Array.isArray(ownedTabIds) && ownedTabIds.some((tabId) => ( + Number.isFinite(tabId) && reg.isOwnedBy(tabId, agentId, ownershipToken) + )); + if (!tokenProvesCurrentAgentOwnership || getRegistryOwner(reg, sessionTabId) !== agentId) { + return payload; + } + + const sessionMetadata = reg.getTabMetadata(sessionTabId); + const sessionOwnershipToken = sessionMetadata && sessionMetadata.ownershipToken; + if (typeof sessionOwnershipToken !== 'string' || !sessionOwnershipToken) return payload; + + return { + ...payload, + ownershipToken: sessionOwnershipToken + }; +} + +function canonicalizeVisualSessionTaskParams(tool, params, payload) { if (!MCP_VISUAL_SESSION_TASK_STATUS_TOOLS.has(tool)) { - return { params }; + return { params, payload }; } const sessionToken = boundedString(params?.session_token || params?.sessionToken, 200); - if (!sessionToken) return { params }; + if (!sessionToken) return { params, payload }; const resolver = typeof globalThis !== 'undefined' ? globalThis.resolveMcpVisualSessionTabId @@ -557,7 +589,7 @@ function canonicalizeVisualSessionTaskParams(tool, params) { // A missing token still follows the existing callback path so callers keep // receiving the canonical visual_session_not_found response. - if (!Number.isFinite(sessionTabId) || sessionTabId <= 0) return { params }; + if (!Number.isFinite(sessionTabId) || sessionTabId <= 0) return { params, payload }; const explicitTabIds = [params?.tabId, params?.tab_id].filter(Number.isFinite); if (explicitTabIds.some((tabId) => tabId !== sessionTabId)) { @@ -575,7 +607,8 @@ function canonicalizeVisualSessionTaskParams(tool, params) { ...params, tabId: sessionTabId, tab_id: sessionTabId - } + }, + payload: canonicalizeVisualSessionOwnershipToken(payload, sessionTabId, explicitTabIds.length > 0) }; } @@ -589,9 +622,10 @@ async function dispatchMcpToolRoute({ tool, params = {}, client = null, tab = nu return createMcpRouteError(tool, route.routeFamily, MCP_ROUTE_RECOVERY_HINT); } - const canonicalized = canonicalizeVisualSessionTaskParams(tool, params); + const canonicalized = canonicalizeVisualSessionTaskParams(tool, params, payload); if (canonicalized.error) return canonicalized.error; params = canonicalized.params; + payload = canonicalized.payload; // Phase 240 D-06 / D-07: inline ownership gate. Sync; no await between gate // check and route.handler invocation. Same microtask discipline. diff --git a/tests/mcp-tool-routing-contract.test.js b/tests/mcp-tool-routing-contract.test.js index 194501e5b..ab392f99c 100644 --- a/tests/mcp-tool-routing-contract.test.js +++ b/tests/mcp-tool-routing-contract.test.js @@ -527,16 +527,28 @@ async function runVisualSessionTokenOwnershipCase(dispatcher, groups) { }, isOwnedBy(tabId, agentId, ownershipToken) { if (tabId === 9) return agentId === 'agent-owner' && ownershipToken === 'owner-token-9'; - if (tabId === 10) return agentId === 'agent-intruder' && ownershipToken === 'intruder-token-10'; + if (tabId === 10) return agentId === 'agent-owner' && ownershipToken === 'owner-token-10'; + if (tabId === 11) return agentId === 'agent-intruder' && ownershipToken === 'intruder-token-11'; return false; }, getOwner(tabId) { if (tabId === 9) return 'agent-owner'; - if (tabId === 10) return 'agent-intruder'; + if (tabId === 10) return 'agent-owner'; + if (tabId === 11) return 'agent-intruder'; return null; }, - getTabMetadata() { - return { incognito: false, windowId: 1 }; + getAgentTabs(agentId) { + if (agentId === 'agent-owner') return [9, 10]; + if (agentId === 'agent-intruder') return [11]; + return null; + }, + getTabMetadata(tabId) { + const tokens = { + 9: 'owner-token-9', + 10: 'owner-token-10', + 11: 'intruder-token-11' + }; + return { incognito: false, windowId: 1, ownershipToken: tokens[tabId] }; }, getAgentWindowId() { return 1; @@ -577,7 +589,8 @@ async function runVisualSessionTokenOwnershipCase(dispatcher, groups) { { tool: 'fail_task', params: { reason: 'Unrecoverable' } } ]; const ownerPayload = { agentId: 'agent-owner', ownershipToken: 'owner-token-9' }; - const intruderPayload = { agentId: 'agent-intruder', ownershipToken: 'intruder-token-10' }; + const ownerOtherTabPayload = { agentId: 'agent-owner', ownershipToken: 'owner-token-10' }; + const intruderPayload = { agentId: 'agent-intruder', ownershipToken: 'intruder-token-11' }; try { for (const testCase of toolCases) { @@ -596,6 +609,22 @@ async function runVisualSessionTokenOwnershipCase(dispatcher, groups) { assert(outcomeCalls.every(call => call.params.tabId === 9 && call.params.tab_id === 9), 'token-only terminal outcomes are attributed to the authoritative tab'); + for (const testCase of toolCases) { + const response = await dispatcher.dispatchMcpToolRoute({ + tool: testCase.tool, + params: { ...testCase.params, session_token: 'session-token-9' }, + payload: ownerOtherTabPayload + }); + assert(response?.tool === testCase.tool, + `${testCase.tool} translates another current same-agent tab token to the session tab`); + } + assert(handlerCalls.length === toolCases.length * 2, + 'same-agent cross-tab tokens reach every token-backed lifecycle handler'); + assert(outcomeCalls.length === 6, + 'same-agent cross-tab tokens record all three terminal outcomes'); + assert(outcomeCalls.every(call => call.params.tabId === 9 && call.params.tab_id === 9), + 'translated token-only outcomes remain attributed to the authoritative tab'); + const matchingExplicit = await dispatcher.dispatchMcpToolRoute({ tool: 'complete_task', params: { summary: 'Explicit match', session_token: 'session-token-9', tab_id: 9 }, @@ -630,6 +659,14 @@ async function runVisualSessionTokenOwnershipCase(dispatcher, groups) { assert(staleOwnership?.errorCode === 'TAB_NOT_OWNED', 'same-agent calls with a stale ownership token are rejected'); + const explicitWrongOwnership = await dispatcher.dispatchMcpToolRoute({ + tool: 'complete_task', + params: { summary: 'Explicit wrong token', session_token: 'session-token-9', tab_id: 9 }, + payload: ownerOtherTabPayload + }); + assert(explicitWrongOwnership?.errorCode === 'TAB_NOT_OWNED', + 'explicit tab calls never translate an ownership token from another tab'); + const spoofedTab = await dispatcher.dispatchMcpToolRoute({ tool: 'complete_task', params: { summary: 'Spoofed tab', session_token: 'session-token-9', tab_id: 10 }, From b827ca83f4353dcffadb0a2625838b1dde10e24f Mon Sep 17 00:00:00 2001 From: Lakshman Date: Mon, 20 Jul 2026 13:58:25 -0500 Subject: [PATCH 25/26] docs(quick-260720-jb5): Patch Sheets session redaction and multi-tab visual-session token finalization --- .planning/STATE.md | 3 +- .../260720-jb5-PLAN.md | 49 +++++++++++++++++++ .../260720-jb5-SUMMARY.md | 32 ++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 .planning/quick/260720-jb5-patch-sheets-session-redaction-and-multi/260720-jb5-PLAN.md create mode 100644 .planning/quick/260720-jb5-patch-sheets-session-redaction-and-multi/260720-jb5-SUMMARY.md diff --git a/.planning/STATE.md b/.planning/STATE.md index 0802ca53c..6e3a95021 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -40,7 +40,7 @@ See: .planning/milestones/v1.1.0-ROADMAP.md, .planning/milestones/v1.1.0-REQUIRE Phase: none active Plan: none active Status: v1.2.0 milestone complete and archived; no active milestone -Last activity: 2026-07-15 - Completed quick task 260715-hs1: Replace Google Sheets OAuth with a zero-extra-auth signed-in tab session +Last activity: 2026-07-20 - Completed quick task 260720-jb5: Patch Sheets session redaction and multi-tab visual-session token finalization Progress: [██████████] 100% (5/5 phases in v1.2.0) @@ -94,6 +94,7 @@ None yet. Two open judgment calls flagged by research to resolve during Phase 52 | # | Description | Date | Commit | Status | Directory | |---|-------------|------|--------|--------|-----------| +| 260720-jb5 | Patch Sheets session redaction and multi-tab visual-session token finalization | 2026-07-20 | 2f8359b2 | Verified | [260720-jb5-patch-sheets-session-redaction-and-multi](./quick/260720-jb5-patch-sheets-session-redaction-and-multi/) | | 260715-hs1 | Replace Google Sheets OAuth with a zero-extra-auth signed-in tab session | 2026-07-15 | 9ab3d40d | Needs Review | [260715-hs1-replace-google-sheets-oauth-with-a-signe](./quick/260715-hs1-replace-google-sheets-oauth-with-a-signe/) | | 260715-8wh | Implement production-safe Google Sheets API MVP with Chrome OAuth, typed read/write capabilities, and spreadsheet-data recording redaction | 2026-07-15 | a83d21b8 | | [260715-8wh-implement-production-safe-google-sheets-](./quick/260715-8wh-implement-production-safe-google-sheets-/) | | 260707-7id | Record MCP agent sessions into logs, history, replay, and memory like autopilot runs | 2026-07-07 | 721e2826 | | [260707-7id-record-mcp-agent-sessions-into-logs-hist](./quick/260707-7id-record-mcp-agent-sessions-into-logs-hist/) | diff --git a/.planning/quick/260720-jb5-patch-sheets-session-redaction-and-multi/260720-jb5-PLAN.md b/.planning/quick/260720-jb5-patch-sheets-session-redaction-and-multi/260720-jb5-PLAN.md new file mode 100644 index 000000000..9667aa851 --- /dev/null +++ b/.planning/quick/260720-jb5-patch-sheets-session-redaction-and-multi/260720-jb5-PLAN.md @@ -0,0 +1,49 @@ +--- +quick: 260720-jb5 +plan: 01 +type: execute +wave: 1 +depends_on: [] +autonomous: true +must_haves: + truths: + - list_tabs session records never persist titles from docs.google.com tabs. + - Token-only visual-session lifecycle calls use the resolved session tab's ownership token when the caller proves current ownership of another same-agent tab. + - Stale, foreign-agent, and explicit mismatched ownership claims remain rejected. + artifacts: + - extension/utils/spreadsheet-record-redaction.js + - extension/ws/mcp-tool-dispatcher.js + - tests/spreadsheet-record-redaction.test.js + - tests/mcp-tool-routing-contract.test.js + key_links: + - Nested tab summaries feed the shared spreadsheet record classifier before recorder persistence. + - Visual-session parameter canonicalization repairs only implicit token-only payloads before the existing ownership gate. +--- + +# Quick 260720-jb5 Plan + +## Objective + +Patch the two accepted review findings without changing public MCP or persisted-record schemas. + +## Tasks + +### 1. Close nested list-tabs recording leakage + +**Files:** `extension/utils/spreadsheet-record-redaction.js`, `tests/spreadsheet-record-redaction.test.js` + +**Action:** Treat a nested `response.tabs[]` item whose exact domain is `docs.google.com` as sensitive evidence. Route the entire record through the existing shape-only sanitizer, while leaving safe and lookalike domains unchanged. + +**Verify:** `node --test tests/spreadsheet-record-redaction.test.js` + +**Done:** Private Google Docs-family tab titles cannot reach MCP session storage. + +### 2. Canonicalize token-only lifecycle ownership and verify the patch + +**Files:** `extension/ws/mcp-tool-dispatcher.js`, `tests/mcp-tool-routing-contract.test.js` + +**Action:** For lifecycle calls with a session token and no explicit tab, translate a current token from another tab owned by the same agent to the resolved session tab's current token. Preserve fail-closed behavior for stale, foreign, and explicit mismatched claims. + +**Verify:** `node tests/mcp-tool-routing-contract.test.js && npm test && git diff --check` + +**Done:** All four visual-session lifecycle tools reach the correct tab in multi-tab workflows without weakening ownership checks. diff --git a/.planning/quick/260720-jb5-patch-sheets-session-redaction-and-multi/260720-jb5-SUMMARY.md b/.planning/quick/260720-jb5-patch-sheets-session-redaction-and-multi/260720-jb5-SUMMARY.md new file mode 100644 index 000000000..23a782fa7 --- /dev/null +++ b/.planning/quick/260720-jb5-patch-sheets-session-redaction-and-multi/260720-jb5-SUMMARY.md @@ -0,0 +1,32 @@ +--- +quick_id: 260720-jb5 +slug: patch-sheets-session-redaction-and-multi +status: complete +verification: passed +completed: 2026-07-20 +commits: + - 15ddf9ff + - 2f8359b2 +--- + +# Quick Task 260720-jb5 Summary: Session Privacy and Lifecycle Ownership + +## Outcome + +- `list_tabs` records containing an exact `docs.google.com` tab are now routed through the existing shape-only spreadsheet sanitizer, preventing nested tab titles from reaching session storage. +- Safe domains and lookalike hosts continue to pass through unchanged. +- Token-only visual-session lifecycle calls now translate a current token from another same-agent tab to the authoritative session tab before the ownership gate. +- Unknown, stale, foreign-agent, explicit wrong-token, and session/tab mismatch cases remain fail-closed. +- The behavior applies to `report_progress`, `complete_task`, `partial_task`, and `fail_task` without changing public tool or record schemas. + +## Commits + +- `15ddf9ff` — `fix(sheets): redact nested tab titles from sessions` +- `2f8359b2` — `fix(mcp): resolve token-only lifecycle ownership` + +## Verification + +- `node --test tests/spreadsheet-record-redaction.test.js` — 18 passed, 0 failed. +- `node tests/mcp-tool-routing-contract.test.js` — 220 passed, 0 failed. +- `npm test` — complete repository suite passed. +- `git diff --check` — passed. From 597c5438dbb77c5ea9932180251cb9eca5c0a7a7 Mon Sep 17 00:00:00 2001 From: Lakshman Date: Tue, 21 Jul 2026 10:28:33 -0500 Subject: [PATCH 26/26] fix: patch reviewed P1 and P2 defects --- extension/background.js | 32 ++++ extension/content/actions.js | 46 ++++++ .../lib/visualization/knowledge-graph.js | 33 ++-- extension/ui/options.js | 21 +-- extension/ui/sidepanel.js | 15 +- extension/utils/automation-logger.js | 105 +++++++++--- tests/automation-logger-mcp-retention.test.js | 150 +++++++++++++++++- tests/google-sheets-content-actions.test.js | 86 ++++++++++ tests/knowledge-graph-dedup.test.js | 22 ++- tests/mcp-session-settings-ui.test.js | 38 +++++ 10 files changed, 489 insertions(+), 59 deletions(-) diff --git a/extension/background.js b/extension/background.js index fada1eda7..2b13c203a 100644 --- a/extension/background.js +++ b/extension/background.js @@ -7950,6 +7950,38 @@ const fsbHandleRuntimeMessage = (request, sender, sendResponse) => { sendResponse({ success: true }); break; + case 'deleteSessionHistory': { + const sessionId = typeof request.sessionId === 'string' ? request.sessionId.trim() : ''; + if (!sessionId) { + sendResponse({ success: false, error: 'Session ID is required' }); + break; + } + (async () => { + try { + const deleted = await automationLogger.deleteSession(sessionId); + sendResponse(deleted + ? { success: true } + : { success: false, error: 'Failed to delete session history' }); + } catch (error) { + sendResponse({ success: false, error: error?.message || 'Failed to delete session history' }); + } + })(); + return true; + } + + case 'clearSessionHistory': + (async () => { + try { + const cleared = await automationLogger.clearAllSessions(); + sendResponse(cleared + ? { success: true } + : { success: false, error: 'Failed to clear session history' }); + } catch (error) { + sendResponse({ success: false, error: error?.message || 'Failed to clear session history' }); + } + })(); + return true; + case 'getSessionReplayData': // Get structured replay data for session visualization (async () => { diff --git a/extension/content/actions.js b/extension/content/actions.js index e0589de49..b4230ef80 100644 --- a/extension/content/actions.js +++ b/extension/content/actions.js @@ -1643,6 +1643,42 @@ async function sheetsNavigate(reference, settleMs = 80) { } } +async function sheetsBoldHeaderRow(startCell, columnCount) { + if (!sheetsUi) return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'sheets-ui-helper-unavailable'); + const parsed = sheetsUi.parseA1Range(startCell); + const columns = Number(columnCount); + if (!parsed || parsed.columnOnly || !Number.isInteger(columns) || columns < 1) { + return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-header-format-range-invalid'); + } + + const startColumn = sheetsUi.numberToColumn(parsed.startColumn); + const endColumn = sheetsUi.numberToColumn(parsed.startColumn + columns - 1); + if (!startColumn || !endColumn) { + return sheetsError('RECIPE_DOM_FALLBACK_PENDING', 'ui-header-format-range-invalid'); + } + + const headerRange = `${parsed.sheetPrefix}${startColumn}${parsed.startRow}:${endColumn}${parsed.startRow}`; + const selected = await sheetsNavigate(headerRange, 70); + if (!selected.success) return selected; + + try { + const bolded = sheetsTrustedKey( + await tools.keyPress(sheetsPrimaryShortcut('b', { useDebuggerAPI: true })), + 'header-bold-not-trusted' + ); + if (!bolded.success) return bolded; + await sheetsDelay(60); + const escaped = sheetsTrustedKey( + await tools.keyPress({ key: 'Escape', useDebuggerAPI: true }), + 'header-deselect-not-trusted' + ); + if (!escaped.success) return escaped; + return { success: true, range: headerRange }; + } catch (_error) { + return sheetsError('GOOGLE_SHEETS_SESSION_UNAVAILABLE', 'ui-header-format-failed'); + } +} + function sheetsFormulaBarState() { const selectors = ['#t-formula-bar-input', '.cell-input', '[aria-label="Formula bar"]']; for (const selector of selectors) { @@ -5159,6 +5195,16 @@ const tools = { await sheetsRenameSpreadsheet(sheetName); const sharedResult = await sheetsUpdateValues(startCell, sharedRows, 'RAW'); if (!sharedResult.success) return { ...sharedResult, action: 'fillsheet' }; + if (sharedRows.length > 1) { + try { + const formatted = await sheetsBoldHeaderRow(startCell, sharedRows[0].length); + if (!formatted.success) { + console.warn('[fillsheet] Header formatting failed (non-fatal):', formatted.reason || formatted.error); + } + } catch (formatError) { + console.warn('[fillsheet] Header formatting failed (non-fatal):', formatError?.message || formatError); + } + } return { success: true, action: 'fillsheet', diff --git a/extension/lib/visualization/knowledge-graph.js b/extension/lib/visualization/knowledge-graph.js index 57cd9fdc3..c5e0846c4 100644 --- a/extension/lib/visualization/knowledge-graph.js +++ b/extension/lib/visualization/knowledge-graph.js @@ -128,9 +128,30 @@ const KnowledgeGraph = (function () { return identity === knownIdentity || identity.slice(-(knownIdentity.length + 1)) === '.' + knownIdentity; } + function patternMatchesHostnameSuffix(pattern, identity) { + if (!pattern || typeof pattern.source !== 'string' || !identity) return false; + var flags = typeof pattern.flags === 'string' ? pattern.flags.replace(/[gy]/g, '') : ''; + var anchoredPattern; + try { + anchoredPattern = new RegExp('^(?:' + pattern.source + ')$', flags); + } catch (_error) { + return false; + } + + var suffixes = [identity]; + for (var di = 0; di < identity.length; di++) { + if (identity.charAt(di) === '.' && di + 1 < identity.length) { + suffixes.push(identity.slice(di + 1)); + } + } + for (var si = 0; si < suffixes.length; si++) { + if (anchoredPattern.test(suffixes[si])) return true; + } + return false; + } + function guideMatchesIdentity(guide, identity) { if (!guide || !identity || !Array.isArray(guide.patterns)) return false; - var candidates = [identity, 'https://' + identity + '/', 'http://www.' + identity + '/']; for (var pi = 0; pi < guide.patterns.length; pi++) { var pattern = guide.patterns[pi]; if (!pattern || typeof pattern.test !== 'function') continue; @@ -138,15 +159,7 @@ const KnowledgeGraph = (function () { for (var ii = 0; ii < patternIdentities.length; ii++) { if (identityMatchesKnownSite(identity, patternIdentities[ii])) return true; } - var originalLastIndex = pattern.lastIndex; - for (var ci = 0; ci < candidates.length; ci++) { - pattern.lastIndex = 0; - if (pattern.test(candidates[ci])) { - pattern.lastIndex = originalLastIndex; - return true; - } - } - pattern.lastIndex = originalLastIndex; + if (patternMatchesHostnameSuffix(pattern, identity)) return true; } return false; } diff --git a/extension/ui/options.js b/extension/ui/options.js index 3258e2a46..99e352d23 100644 --- a/extension/ui/options.js +++ b/extension/ui/options.js @@ -3151,21 +3151,11 @@ async function deleteSession(sessionId) { } try { - const stored = await chrome.storage.local.get(['fsbSessionLogs', 'fsbSessionIndex']); - const sessionStorage = stored.fsbSessionLogs || {}; - const sessionIndex = stored.fsbSessionIndex || []; - - // Remove from storage - delete sessionStorage[sessionId]; - - // Remove from index - const updatedIndex = sessionIndex.filter(s => s.id !== sessionId); - - // Save changes - await chrome.storage.local.set({ - fsbSessionLogs: sessionStorage, - fsbSessionIndex: updatedIndex + const response = await chrome.runtime.sendMessage({ + action: 'deleteSessionHistory', + sessionId }); + if (!response?.success) throw new Error(response?.error || 'Failed to delete session history'); // Close detail panel if viewing this session if (currentViewingSession && currentViewingSession.id === sessionId) { @@ -3193,7 +3183,8 @@ async function clearAllSessions() { } try { - await chrome.storage.local.remove(['fsbSessionLogs', 'fsbSessionIndex']); + const response = await chrome.runtime.sendMessage({ action: 'clearSessionHistory' }); + if (!response?.success) throw new Error(response?.error || 'Failed to clear session history'); closeSessionDetail(); loadSessionList(); diff --git a/extension/ui/sidepanel.js b/extension/ui/sidepanel.js index 81c7962f6..83ddd2241 100644 --- a/extension/ui/sidepanel.js +++ b/extension/ui/sidepanel.js @@ -3522,15 +3522,11 @@ async function startReplay(sessionId) { async function deleteHistorySession(sessionId) { try { - const stored = await chrome.storage.local.get(['fsbSessionLogs', 'fsbSessionIndex']); - const sessionStorage = stored.fsbSessionLogs || {}; - const sessionIndex = stored.fsbSessionIndex || []; - delete sessionStorage[sessionId]; - const updatedIndex = sessionIndex.filter(function(s) { return s.id !== sessionId; }); - await chrome.storage.local.set({ - fsbSessionLogs: sessionStorage, - fsbSessionIndex: updatedIndex + const response = await chrome.runtime.sendMessage({ + action: 'deleteSessionHistory', + sessionId: sessionId }); + if (!response?.success) throw new Error(response?.error || 'Failed to delete session history'); loadHistoryList(); } catch (error) { console.error('Failed to delete session:', error); @@ -3594,7 +3590,8 @@ async function loadSessionView(sessionId) { async function clearAllHistorySessions() { if (!confirm('Delete all session history? This cannot be undone.')) return; try { - await chrome.storage.local.remove(['fsbSessionLogs', 'fsbSessionIndex']); + const response = await chrome.runtime.sendMessage({ action: 'clearSessionHistory' }); + if (!response?.success) throw new Error(response?.error || 'Failed to clear session history'); loadHistoryList(); } catch (error) { console.error('Failed to clear all sessions:', error); diff --git a/extension/utils/automation-logger.js b/extension/utils/automation-logger.js index bd60faa81..af92fe21f 100644 --- a/extension/utils/automation-logger.js +++ b/extension/utils/automation-logger.js @@ -21,7 +21,8 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { function capPersistedSessionHistory(sessionIndex, sessionStorage) { const counts = { autopilot: 0, mcp: 0 }; - return (sessionIndex || []).filter(entry => { + const removedIds = []; + const retainedIndex = (sessionIndex || []).filter(entry => { const storedSession = entry?.id ? sessionStorage?.[entry.id] : null; const mode = storedSession ? storedSession.mode : entry?.mode; const bucket = mode === 'mcp-agent' ? 'mcp' : 'autopilot'; @@ -29,9 +30,22 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { counts[bucket]++; return true; } - if (entry?.id) delete sessionStorage[entry.id]; + if (entry?.id) { + removedIds.push(entry.id); + delete sessionStorage[entry.id]; + } return false; }); + return { retainedIndex, removedIds }; + } + + function filterAutomationLogsBySession(logs, sessionIds, removeAllSessionLogs = false) { + const ids = sessionIds instanceof Set ? sessionIds : new Set(sessionIds || []); + return (Array.isArray(logs) ? logs : []).filter(log => { + const sessionId = log?.data?.sessionId; + if (typeof sessionId !== 'string' || !sessionId) return true; + return !(removeAllSessionLogs || ids.has(sessionId)); + }); } function getPersistedCommandList(sessionData = {}, fallbackTask = '') { @@ -234,6 +248,24 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { return this._withSessionMutationLock(fn); } + _removeInMemorySessionArtifacts(sessionIds, removeAllSessionArtifacts = false) { + const ids = sessionIds instanceof Set ? sessionIds : new Set(sessionIds || []); + const shouldRemove = sessionId => ( + typeof sessionId === 'string' && + sessionId.length > 0 && + (removeAllSessionArtifacts || ids.has(sessionId)) + ); + + this.logs = (this.logs || []).filter(log => !shouldRemove(log?.data?.sessionId)); + this.actionRecords = (this.actionRecords || []).filter(record => !shouldRemove(record?.sessionId)); + + if (removeAllSessionArtifacts) { + this._domSnapshots = {}; + } else if (this._domSnapshots) { + ids.forEach(sessionId => delete this._domSnapshots[sessionId]); + } + } + log(level, message, data = null) { const entry = { timestamp: new Date().toISOString(), @@ -761,9 +793,16 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { const persistedLogs = filterPersistedSessionLogs(sessionLogs); if (sessionLogs.length === 0 && persistedLogs.length === 0) return false; - const stored = await chrome.storage.local.get(['fsbSessionLogs', 'fsbSessionIndex']); + const stored = await chrome.storage.local.get([ + 'fsbSessionLogs', + 'fsbSessionIndex', + 'fsbDOMSnapshots', + 'automationLogs' + ]); const sessionStorage = stored.fsbSessionLogs || {}; const sessionIndex = stored.fsbSessionIndex || []; + const allSnapshots = stored.fsbDOMSnapshots || {}; + const persistedAutomationLogs = Array.isArray(stored.automationLogs) ? stored.automationLogs : []; if (sessionStorage[sessionId]) { // APPEND MODE: Update existing session entry @@ -883,14 +922,27 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { const existingIndex = sessionIndex.findIndex(s => s.id === sessionId); if (existingIndex !== -1) sessionIndex[existingIndex] = indexEntry; else sessionIndex.unshift(indexEntry); - const retainedSessionIndex = capPersistedSessionHistory(sessionIndex, sessionStorage); - await chrome.storage.local.set({ + const cappedHistory = capPersistedSessionHistory(sessionIndex, sessionStorage); + const retainedSessionIndex = cappedHistory.retainedIndex; + const evictedIds = new Set(cappedHistory.removedIds); + const nextStorage = { fsbSessionLogs: sessionStorage, fsbSessionIndex: retainedSessionIndex - }); + }; + + if (evictedIds.size > 0) { + evictedIds.forEach(id => delete allSnapshots[id]); + nextStorage.fsbDOMSnapshots = allSnapshots; + nextStorage.automationLogs = filterAutomationLogsBySession(persistedAutomationLogs, evictedIds); + } + + await chrome.storage.local.set(nextStorage); + if (evictedIds.size > 0) this._removeInMemorySessionArtifacts(evictedIds); // Persist DOM snapshots to dedicated storage key - await this._persistDOMSnapshots(sessionId, retainedSessionIndex); + if (!evictedIds.has(sessionId)) { + await this._persistDOMSnapshots(sessionId, retainedSessionIndex); + } if (savedSession.mode === 'mcp-agent') { let retentionDays = 30; @@ -985,21 +1037,18 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { for (const id of expiredIds) { delete sessionStorage[id]; delete allSnapshots[id]; - if (this._domSnapshots) delete this._domSnapshots[id]; } const retainedIndex = sessionIndex.filter(entry => !expiredSet.has(entry?.id)); - const retainedAutomationLogs = persistedAutomationLogs.filter( - log => !expiredSet.has(log?.data?.sessionId) - ); - // Keep the in-memory source of future debounced persists in sync so a - // timer queued after this prune cannot resurrect expired raw rows. - this.logs = this.logs.filter(log => !expiredSet.has(log?.data?.sessionId)); + const retainedAutomationLogs = filterAutomationLogsBySession(persistedAutomationLogs, expiredSet); await chrome.storage.local.set({ fsbSessionLogs: sessionStorage, fsbSessionIndex: retainedIndex, fsbDOMSnapshots: allSnapshots, automationLogs: retainedAutomationLogs }); + // Keep the in-memory source of future debounced persists in sync so a + // timer queued after this prune cannot resurrect expired raw rows. + this._removeInMemorySessionArtifacts(expiredSet); return { removed: expiredIds.length, ids: expiredIds }; } catch (error) { if (chrome.runtime?.id) { @@ -1085,20 +1134,29 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { async _deleteSessionUnlocked(sessionId) { // Guard against invalidated extension context - if (!chrome.runtime?.id) return false; + if (!chrome.runtime?.id || typeof sessionId !== 'string' || !sessionId) return false; try { - const stored = await chrome.storage.local.get(['fsbSessionLogs', 'fsbSessionIndex', 'fsbDOMSnapshots']); + const stored = await chrome.storage.local.get([ + 'fsbSessionLogs', + 'fsbSessionIndex', + 'fsbDOMSnapshots', + 'automationLogs' + ]); const sessionStorage = stored.fsbSessionLogs || {}; - const sessionIndex = stored.fsbSessionIndex || []; + const sessionIndex = Array.isArray(stored.fsbSessionIndex) ? stored.fsbSessionIndex : []; const allSnapshots = stored.fsbDOMSnapshots || {}; + const removedIds = new Set([sessionId]); delete sessionStorage[sessionId]; delete allSnapshots[sessionId]; const updatedIndex = sessionIndex.filter(s => s.id !== sessionId); + const retainedAutomationLogs = filterAutomationLogsBySession(stored.automationLogs, removedIds); await chrome.storage.local.set({ fsbSessionLogs: sessionStorage, fsbSessionIndex: updatedIndex, - fsbDOMSnapshots: allSnapshots + fsbDOMSnapshots: allSnapshots, + automationLogs: retainedAutomationLogs }); + this._removeInMemorySessionArtifacts(removedIds); return true; } catch (error) { return false; @@ -1145,8 +1203,15 @@ if (globalThis.__FSB_AUTOMATION_LOGGER_LOADED__) { // Guard against invalidated extension context if (!chrome.runtime?.id) return false; try { - await chrome.storage.local.remove(['fsbSessionLogs', 'fsbSessionIndex', 'fsbDOMSnapshots']); - this._domSnapshots = {}; + const stored = await chrome.storage.local.get('automationLogs'); + const retainedAutomationLogs = filterAutomationLogsBySession(stored.automationLogs, [], true); + await chrome.storage.local.set({ + fsbSessionLogs: {}, + fsbSessionIndex: [], + fsbDOMSnapshots: {}, + automationLogs: retainedAutomationLogs + }); + this._removeInMemorySessionArtifacts([], true); return true; } catch (error) { return false; diff --git a/tests/automation-logger-mcp-retention.test.js b/tests/automation-logger-mcp-retention.test.js index 9268a5ee0..4b4038416 100644 --- a/tests/automation-logger-mcp-retention.test.js +++ b/tests/automation-logger-mcp-retention.test.js @@ -85,6 +85,25 @@ function rawLog(sessionId, marker) { }; } +function unscopedLog(marker) { + return { + timestamp: new Date().toISOString(), + level: 'info', + message: marker, + data: { logType: 'serviceWorker' } + }; +} + +function actionRecord(sessionId, marker) { + return { + sessionId, + tool: 'type', + timestamp: Date.now(), + params: { text: marker }, + success: true + }; +} + (async function main() { console.log('--- AutomationLogger concurrent save serialization ---'); { @@ -164,12 +183,33 @@ function rawLog(sessionId, marker) { mcpIds.push(mcpId); } + const capLogs = [ + rawLog('mcp-49', 'evicted-mcp-secret'), + rawLog('autopilot-49', 'evicted-autopilot-secret'), + rawLog('mcp-0', 'retained-mcp-log') + ]; const storage = makeStorage({ fsbMcpSessionRetentionDays: 30, fsbSessionLogs: sessions, - fsbSessionIndex: index + fsbSessionIndex: index, + fsbDOMSnapshots: { + 'mcp-49': [{ html: 'evicted-mcp-snapshot' }], + 'autopilot-49': [{ html: 'evicted-autopilot-snapshot' }], + 'mcp-0': [{ html: 'retained-mcp-snapshot' }] + }, + automationLogs: capLogs }, 0); const logger = loadLogger(storage); + logger.logs = clone(capLogs); + logger._domSnapshots = { + 'mcp-49': [{ html: 'memory-mcp-snapshot' }], + 'autopilot-49': [{ html: 'memory-autopilot-snapshot' }] + }; + logger.actionRecords = [ + actionRecord('mcp-49', 'evicted-mcp-action'), + actionRecord('autopilot-49', 'evicted-autopilot-action'), + actionRecord('mcp-0', 'retained-mcp-action') + ]; logger.logSessionStart('mcp-new', 'Newest MCP session', 51); const savedMcp = await logger.saveSession('mcp-new', { @@ -190,6 +230,15 @@ function rawLog(sessionId, marker) { 'saving an MCP session preserves every existing Autopilot row'); check(!afterMcpIds.includes('mcp-49') && !storage.store.fsbSessionLogs['mcp-49'], 'the oldest MCP overflow row is removed from the index and full store'); + check(!storage.store.automationLogs.some((log) => log.data?.sessionId === 'mcp-49') && + !logger.logs.some((log) => log.data?.sessionId === 'mcp-49'), + 'MCP cap eviction removes persisted and in-memory raw logs'); + check(!storage.store.fsbDOMSnapshots['mcp-49'] && !logger._domSnapshots['mcp-49'], + 'MCP cap eviction removes persisted and in-memory DOM snapshots'); + check(!logger.actionRecords.some((record) => record.sessionId === 'mcp-49'), + 'MCP cap eviction removes in-memory action records'); + check(storage.store.automationLogs.some((log) => log.data?.sessionId === 'autopilot-49'), + 'MCP cap eviction preserves the pending Autopilot raw log'); check(JSON.stringify(afterMcpIds) === JSON.stringify(expectedAfterMcpIds), 'per-mode capping preserves the interleaved order of retained rows'); check(afterMcpIds.includes('autopilot-0') && storage.store.fsbSessionLogs['autopilot-0'].mode === undefined, @@ -214,6 +263,13 @@ function rawLog(sessionId, marker) { 'saving an Autopilot session preserves every retained MCP row'); check(!afterAutopilotIds.includes('autopilot-49') && !storage.store.fsbSessionLogs['autopilot-49'], 'the oldest Autopilot overflow row is removed from the index and full store'); + check(!storage.store.automationLogs.some((log) => log.data?.sessionId === 'autopilot-49') && + !logger.logs.some((log) => log.data?.sessionId === 'autopilot-49'), + 'Autopilot cap eviction removes persisted and in-memory raw logs'); + check(!storage.store.fsbDOMSnapshots['autopilot-49'] && !logger._domSnapshots['autopilot-49'], + 'Autopilot cap eviction removes persisted and in-memory DOM snapshots'); + check(!logger.actionRecords.some((record) => record.sessionId === 'autopilot-49'), + 'Autopilot cap eviction removes in-memory action records'); check(JSON.stringify(afterAutopilotIds) === JSON.stringify(expectedAfterAutopilotIds), 'the second per-mode cap also preserves retained-row ordering'); } @@ -336,6 +392,98 @@ function rawLog(sessionId, marker) { 'outcome-only update preserves session logs'); } + console.log('\n--- AutomationLogger complete individual session deletion ---'); + { + const targetId = 'delete-target'; + const keepId = 'delete-keep'; + const globalLog = unscopedLog('keep-global-diagnostic'); + const logs = [ + rawLog(targetId, 'delete-sensitive-text'), + rawLog(keepId, 'keep-other-session'), + globalLog + ]; + const storage = makeStorage({ + fsbSessionLogs: { + [targetId]: { id: targetId, mode: 'mcp-agent' }, + [keepId]: { id: keepId, mode: 'autopilot' } + }, + fsbSessionIndex: [{ id: targetId }, { id: keepId }], + fsbDOMSnapshots: { + [targetId]: [{ html: 'delete-persisted-snapshot' }], + [keepId]: [{ html: 'keep-persisted-snapshot' }] + }, + automationLogs: logs + }, 8); + const logger = loadLogger(storage); + logger.logs = clone(logs); + logger._domSnapshots = { + [targetId]: [{ html: 'delete-memory-snapshot' }], + [keepId]: [{ html: 'keep-memory-snapshot' }] + }; + logger.actionRecords = [ + actionRecord(targetId, 'delete-action'), + actionRecord(keepId, 'keep-action'), + actionRecord(null, 'keep-unscoped-action') + ]; + + // Queue persistence first to prove deletion remains the authoritative final write. + const [, deleted] = await Promise.all([logger.persistLogs(), logger.deleteSession(targetId)]); + check(deleted === true, 'individual deletion reports success'); + check(!storage.store.fsbSessionLogs[targetId] && + !storage.store.fsbSessionIndex.some((entry) => entry.id === targetId), + 'individual deletion removes the full and indexed history rows'); + check(!storage.store.fsbDOMSnapshots[targetId] && !logger._domSnapshots[targetId], + 'individual deletion removes persisted and in-memory snapshots'); + check(!storage.store.automationLogs.some((log) => log.data?.sessionId === targetId) && + !logger.logs.some((log) => log.data?.sessionId === targetId), + 'individual deletion removes persisted and in-memory raw logs after a queued persist'); + check(!logger.actionRecords.some((record) => record.sessionId === targetId), + 'individual deletion removes in-memory action records'); + check(!!storage.store.fsbSessionLogs[keepId] && + storage.store.automationLogs.some((log) => log.data?.sessionId === keepId), + 'individual deletion preserves unrelated session history and logs'); + check(storage.store.automationLogs.some((log) => log.message === globalLog.message), + 'individual deletion preserves unscoped diagnostics'); + } + + console.log('\n--- AutomationLogger clear-all removes orphan session artifacts ---'); + { + const logs = [ + rawLog('saved-mcp', 'saved-session-secret'), + rawLog('already-orphaned', 'orphaned-session-secret'), + unscopedLog('keep-global-diagnostic') + ]; + const storage = makeStorage({ + fsbSessionLogs: { 'saved-mcp': { id: 'saved-mcp', mode: 'mcp-agent' } }, + fsbSessionIndex: [{ id: 'saved-mcp', mode: 'mcp-agent' }], + fsbDOMSnapshots: { 'saved-mcp': [{ html: 'saved-snapshot' }] }, + automationLogs: logs + }, 0); + const logger = loadLogger(storage); + logger.logs = clone(logs); + logger._domSnapshots = { 'saved-mcp': [{ html: 'memory-snapshot' }] }; + logger.actionRecords = [ + actionRecord('saved-mcp', 'saved-action'), + actionRecord('already-orphaned', 'orphan-action'), + actionRecord(null, 'keep-unscoped-action') + ]; + + const cleared = await logger.clearAllSessions(); + check(cleared === true, 'clear-all reports success'); + check(Object.keys(storage.store.fsbSessionLogs).length === 0 && storage.store.fsbSessionIndex.length === 0, + 'clear-all empties full and indexed session history'); + check(Object.keys(storage.store.fsbDOMSnapshots).length === 0 && + Object.keys(logger._domSnapshots).length === 0, + 'clear-all empties persisted and in-memory snapshots'); + check(storage.store.automationLogs.length === 1 && + storage.store.automationLogs[0].message === 'keep-global-diagnostic', + 'clear-all removes saved and already-orphaned raw session logs but preserves global diagnostics'); + check(logger.logs.length === 1 && logger.logs[0].message === 'keep-global-diagnostic', + 'clear-all keeps the in-memory persistence source aligned with storage'); + check(logger.actionRecords.length === 1 && logger.actionRecords[0].sessionId === null, + 'clear-all removes session action records while preserving unscoped records'); + } + console.log('\n--- AutomationLogger external scrub shares the save lock ---'); { const storage = makeStorage({ fsbSessionLogs: {}, fsbSessionIndex: [], automationLogs: [] }, 8); diff --git a/tests/google-sheets-content-actions.test.js b/tests/google-sheets-content-actions.test.js index b2eb93c89..105ceb41d 100644 --- a/tests/google-sheets-content-actions.test.js +++ b/tests/google-sheets-content-actions.test.js @@ -23,6 +23,7 @@ function sheetsDomHarness(options = {}) { let deleteCalls = 0; let insertCalls = 0; const keyCalls = []; + const typeCalls = []; const nameBox = options.missingNameBox ? null : { value: selectedAddress, textContent: selectedAddress, @@ -62,6 +63,7 @@ function sheetsDomHarness(options = {}) { return trusted; }, async typeWithKeys(params) { + typeCalls.push({ ...params }); if (options.typeResult) return options.typeResult; pendingAddress = String(params.text); if (nameBox) nameBox.value = pendingAddress; @@ -115,6 +117,7 @@ function sheetsDomHarness(options = {}) { context.globalThis = context; const exports = [ 'sheetsNavigate', + 'sheetsBoldHeaderRow', 'sheetsWithUiLock', 'sheetsReadValues', 'sheetsUpdateValues', @@ -132,12 +135,54 @@ function sheetsDomHarness(options = {}) { api: context.__sheetsInternals, cells, keyCalls, + typeCalls, get pasteCalls() { return pasteCalls; }, get deleteCalls() { return deleteCalls; }, get insertCalls() { return insertCalls; } }; } +function fillsheetActionHarness(options = {}) { + const start = ACTIONS_SOURCE.indexOf(' fillsheet: async (params) => {'); + const end = ACTIONS_SOURCE.indexOf('\n\n // =========================================================================\n // GOOGLE SHEETS: readsheet', start); + assert.ok(start >= 0 && end > start, 'legacy fillsheet action source exists'); + + const calls = { format: 0, rename: 0, update: 0, warnings: [] }; + const context = { + FSB: { isCanvasBasedEditor() { return true; } }, + sheetsUi: ui, + sheetsError(code, reason) { return { success: false, code, error: code, reason }; }, + sheetsWithUiLock(operation) { return operation(); }, + async sheetsRenameSpreadsheet() { calls.rename++; }, + async sheetsUpdateValues(range, values) { + calls.update++; + if (options.updateResult) return options.updateResult; + return { + success: true, + updatedCells: values.length * values[0].length, + chunks: 1 + }; + }, + async sheetsBoldHeaderRow() { + calls.format++; + if (options.formatError) throw options.formatError; + return options.formatResult || { success: true }; + }, + console: { + warn(...args) { calls.warnings.push(args); } + }, + globalThis: null + }; + context.globalThis = context; + vm.createContext(context); + vm.runInContext( + `globalThis.__fillsheet = ({${ACTIONS_SOURCE.slice(start, end)}}).fillsheet;`, + context, + { filename: 'extension/content/actions.fillsheet.js' } + ); + return { fillsheet: context.__fillsheet, calls }; +} + test('parses bounded A1, quoted-sheet, single-cell, and column append ranges', () => { assert.deepEqual(ui.parseA1Range('A1:B2'), { sheetPrefix: '', startColumn: 1, endColumn: 2, startRow: 1, endRow: 2, @@ -316,6 +361,46 @@ test('DOM fallback uses Command on macOS and Control on other platforms', async assert.equal(windowsPaste.metaKey, undefined); }); +test('legacy fillsheet selects and bolds the exact bounded header range', async () => { + const windows = sheetsDomHarness({ platform: 'Win32', userAgentDataPlatform: 'Windows' }); + const windowsResult = await windows.api.sheetsBoldHeaderRow("'Data 2026'!B4", 3); + assert.equal(windowsResult.success, true); + assert.equal(windowsResult.range, "'Data 2026'!B4:D4"); + assert.equal(windows.typeCalls[windows.typeCalls.length - 1].text, "'Data 2026'!B4:D4"); + const windowsBold = windows.keyCalls.find(call => call.key === 'b'); + assert.equal(windowsBold.ctrlKey, true); + assert.equal(windowsBold.metaKey, undefined); + assert.equal(windowsBold.useDebuggerAPI, true); + assert.equal(windows.keyCalls[windows.keyCalls.length - 1].key, 'Escape'); + + const mac = sheetsDomHarness({ platform: 'MacIntel', userAgentDataPlatform: 'macOS' }); + assert.equal((await mac.api.sheetsBoldHeaderRow('A1', 2)).success, true); + const macBold = mac.keyCalls.find(call => call.key === 'b'); + assert.equal(macBold.metaKey, true); + assert.equal(macBold.ctrlKey, undefined); +}); + +test('legacy fillsheet skips single-row formatting and keeps formatting failures non-fatal', async () => { + const singleRow = fillsheetActionHarness(); + const singleResult = await singleRow.fillsheet({ startCell: 'A1', data: 'Name,Age' }); + assert.equal(singleResult.success, true); + assert.equal(singleRow.calls.update, 1); + assert.equal(singleRow.calls.format, 0); + + const failedFormat = fillsheetActionHarness({ + formatResult: { success: false, reason: 'header-bold-not-trusted' } + }); + const multiResult = await failedFormat.fillsheet({ + startCell: 'C3', + data: 'Name,Age\nAda,37', + sheetName: 'People' + }); + assert.equal(multiResult.success, true); + assert.equal(multiResult.cellsFilled, 4); + assert.equal(failedFormat.calls.format, 1); + assert.equal(failedFormat.calls.warnings.length, 1); +}); + test('DOM append scans a rectangular table and clear verifies actual readback', async () => { const append = sheetsDomHarness({ cells: { @@ -407,6 +492,7 @@ test('content action exposes only the fixed Sheets UI operations and protects va assert.match(actions, /RECOVERY_AMBIGUOUS/); assert.match(actions, /renderSemantics:\s*'formula-bar'/); assert.match(actions, /sheetsUi\.csvToValues\(data\)/); + assert.match(legacySheetsActions, /sharedRows\.length > 1[\s\S]*sheetsBoldHeaderRow\(startCell, sharedRows\[0\]\.length\)/); assert.match(actions, /sheetsReadValues\(range, 'ROWS'\)/); assert.doesNotMatch(legacySheetsActions, /document\.querySelector\('#t-name-box'\)|tools\.keyPress\(/); assert.match(messaging, /'fillsheet'[\s\S]*'readsheet'[\s\S]*'sheetsSession'/); diff --git a/tests/knowledge-graph-dedup.test.js b/tests/knowledge-graph-dedup.test.js index 483643506..0292bb114 100644 --- a/tests/knowledge-graph-dedup.test.js +++ b/tests/knowledge-graph-dedup.test.js @@ -88,10 +88,16 @@ check(!patternIdentities(amazonPattern).some((identity) => ['com.au', 'com.br', graph.setTaskMemories([ taskMemory('https://WWW.AMAZON.COM./products', ['#buy']), taskMemory('https://www.amazon.com.au/products', ['#buy-au']), + taskMemory('https://shop.amazon.com/products', ['#buy-subdomain']), taskMemory('BOOKING.COM./hotels', ['#hotel']), taskMemory('https://github.com/org/repo', ['#code']), + taskMemory('https://api.github.com/repos/example', ['#api']), taskMemory('https://docs.google.com/spreadsheets/d/example/edit', ['#sheet']), taskMemory('https://news.com.au/world', ['#headline']), + taskMemory('https://notamazon.com/products', ['#not-amazon']), + taskMemory('https://fakebooking.com/hotels', ['#not-booking']), + taskMemory('https://notgithub.com/org/repo', ['#not-github']), + taskMemory('https://github.com.evil/org/repo', ['#github-superdomain']), taskMemory('https://www.genuinely-new.dev./docs', ['#one']), taskMemory('GENUINELY-NEW.DEV/path', ['#two']) ]); @@ -101,16 +107,22 @@ for (const detailLevel of ['simple', 'full']) { const taskSites = data.nodes.filter((node) => node.type === 'task-site'); const discovered = taskSites.find((node) => node.label === 'genuinely-new.dev'); const australianNews = taskSites.find((node) => node.label === 'news.com.au'); - check(taskSites.length === 2, - `${detailLevel} mode suppresses known guide domains and keeps both unrelated discovered domains`); + const collisionDomains = ['notamazon.com', 'fakebooking.com', 'notgithub.com', 'github.com.evil']; + check(taskSites.length === 6, + `${detailLevel} mode suppresses known guide domains and keeps unrelated and collision-shaped domains`); check(!!discovered, `${detailLevel} mode renders the normalized genuinely discovered domain`); check(discovered && discovered.selectorCount === 2, `${detailLevel} mode coalesces normalized aliases and merges learned selectors`); check(australianNews && australianNews.selectorCount === 1, `${detailLevel} mode does not mistake news.com.au for an Amazon identity`); - check(!taskSites.some((node) => /amazon|booking|github|docs\.google/i.test(node.label)), - `${detailLevel} mode never duplicates a known guide, including a path-specific guide`); + check(collisionDomains.every((domain) => taskSites.some((node) => node.label === domain)), + `${detailLevel} mode keeps hostname-prefix and superdomain collisions visible`); + check(!taskSites.some((node) => [ + 'amazon.com', 'amazon.com.au', 'shop.amazon.com', 'booking.com', + 'github.com', 'api.github.com', 'docs.google.com' + ].includes(node.label)), + `${detailLevel} mode never duplicates exact guides, true subdomains, or path-specific guides`); } const simple = buildData('simple'); @@ -138,6 +150,8 @@ check(getAutomaticZoom(expandedState) < getDefaultZoom(mobileContainer, 'full'), 'automatic Expanded zoom fits outer nodes inside a mobile-sized canvas'); check((source.match(/state\.zoom = getAutomaticZoom\(state\)/g) || []).length === 2, 'initial render and resize both apply automatic fit-to-view zoom'); +check(source.includes("new RegExp('^(?:' + pattern.source + ')$', flags)"), + 'regex fallback is anchored to a complete DNS-label suffix'); console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/mcp-session-settings-ui.test.js b/tests/mcp-session-settings-ui.test.js index 4ba0b224a..d9951a6ef 100644 --- a/tests/mcp-session-settings-ui.test.js +++ b/tests/mcp-session-settings-ui.test.js @@ -11,8 +11,12 @@ const vm = require('vm'); const OPTIONS_PATH = path.resolve(__dirname, '..', 'extension', 'ui', 'options.js'); const HTML_PATH = path.resolve(__dirname, '..', 'extension', 'ui', 'control_panel.html'); +const SIDEPANEL_PATH = path.resolve(__dirname, '..', 'extension', 'ui', 'sidepanel.js'); +const BACKGROUND_PATH = path.resolve(__dirname, '..', 'extension', 'background.js'); const options = fs.readFileSync(OPTIONS_PATH, 'utf8'); const html = fs.readFileSync(HTML_PATH, 'utf8'); +const sidepanel = fs.readFileSync(SIDEPANEL_PATH, 'utf8'); +const background = fs.readFileSync(BACKGROUND_PATH, 'utf8'); let passed = 0; let failed = 0; @@ -81,5 +85,39 @@ if (helperMatch) { check(clamp('not-a-number') === 30, 'invalid retention falls back to 30 days'); } +console.log('\n--- Session history deletion delegation contract ---'); + +const optionsDeleteStart = options.indexOf('async function deleteSession(sessionId)'); +const optionsDeleteEnd = options.indexOf('/**\n * Clear all saved sessions', optionsDeleteStart); +const optionsDelete = options.slice(optionsDeleteStart, optionsDeleteEnd); +const optionsClearStart = options.indexOf('async function clearAllSessions()', optionsDeleteEnd); +const optionsClearEnd = options.indexOf('// Session functions are now accessed', optionsClearStart); +const optionsClear = options.slice(optionsClearStart, optionsClearEnd); +const sidepanelDeleteStart = sidepanel.indexOf('async function deleteHistorySession(sessionId)'); +const sidepanelDeleteEnd = sidepanel.indexOf('async function loadSessionView', sidepanelDeleteStart); +const sidepanelDelete = sidepanel.slice(sidepanelDeleteStart, sidepanelDeleteEnd); +const sidepanelClearStart = sidepanel.indexOf('async function clearAllHistorySessions()'); +const sidepanelClearEnd = sidepanel.indexOf('function formatSessionDate', sidepanelClearStart); +const sidepanelClear = sidepanel.slice(sidepanelClearStart, sidepanelClearEnd); + +check(optionsDelete.includes("action: 'deleteSessionHistory'") && + sidepanelDelete.includes("action: 'deleteSessionHistory'"), + 'both history UIs delegate individual deletion to the service worker'); +check(optionsClear.includes("action: 'clearSessionHistory'") && + sidepanelClear.includes("action: 'clearSessionHistory'"), + 'both history UIs delegate clear-all to the service worker'); +check(!/chrome\.storage\.local\.(?:set|remove)/.test(optionsDelete + optionsClear + sidepanelDelete + sidepanelClear), + 'history UIs no longer mutate session storage directly'); +check(optionsDelete.indexOf('if (!response?.success)') < optionsDelete.indexOf('loadSessionList()') && + sidepanelDelete.indexOf('if (!response?.success)') < sidepanelDelete.indexOf('loadHistoryList()'), + 'individual-delete views refresh only after a successful response'); +check(optionsClear.indexOf('if (!response?.success)') < optionsClear.indexOf('loadSessionList()') && + sidepanelClear.indexOf('if (!response?.success)') < sidepanelClear.indexOf('loadHistoryList()'), + 'clear-all views refresh only after a successful response'); +check(/case 'deleteSessionHistory':[\s\S]*automationLogger\.deleteSession\(sessionId\)/.test(background), + 'background deletion action uses the race-safe automation logger path'); +check(/case 'clearSessionHistory':[\s\S]*automationLogger\.clearAllSessions\(\)/.test(background), + 'background clear-all action uses the race-safe automation logger path'); + console.log(`\n=== Results: ${passed} passed, ${failed} failed ===`); process.exit(failed > 0 ? 1 : 0);