From 0dfb16bd4ce67a9b6edf382f8cb2f499a4376d8c Mon Sep 17 00:00:00 2001 From: Leo Lara Date: Wed, 15 Jul 2026 14:15:31 -0400 Subject: [PATCH 1/2] feat(ui): add strict foreground action registry Part of #5549 --- docs/foreground-actions.md | 30 ++++++ static/app.js | 22 ++++- static/js/MODULE_SUMMARY.md | 1 + static/js/calendar.js | 2 +- static/js/chatStream.js | 3 + static/js/foregroundActions.js | 124 +++++++++++++++++++++++ static/sw.js | 3 +- tests/test_foreground_actions_js.py | 147 ++++++++++++++++++++++++++++ 8 files changed, 329 insertions(+), 3 deletions(-) create mode 100644 docs/foreground-actions.md create mode 100644 static/js/foregroundActions.js create mode 100644 tests/test_foreground_actions_js.py diff --git a/docs/foreground-actions.md b/docs/foreground-actions.md new file mode 100644 index 0000000000..46158fd15f --- /dev/null +++ b/docs/foreground-actions.md @@ -0,0 +1,30 @@ +# Foreground action contract + +Odysseus browser modules may register one of three version 1 foreground actions: + +| Action ID | Core handler | +| --- | --- | +| `open_view:calendar` | Open Calendar | +| `close_view:document` | Close the visible or minimized document | +| `minimize_view:document` | Minimize the visible document | + +Streaming consumers pass this exact `ui_control` data object to +`handleUIControl()`: + +```json +{ + "ui_event": "foreground_action", + "version": 1, + "action": "open_view:calendar", + "payload": {} +} +``` + +The registry rejects unknown versions, actions, fields, non-empty payloads, +messages larger than 1 KiB, duplicate registrations, and invocation before a +handler is registered. Registration returns an idempotent unregister function. + +Action IDs include the only allowed target and version 1 payloads are empty. +This contract cannot carry a selector, URL, HTML, script, or generic DOM +command. Server-side authorization remains required before emitting an event; +the browser registry is an additional fail-closed boundary, not an auth layer. diff --git a/static/app.js b/static/app.js index cd691830ec..5f9da030a1 100644 --- a/static/app.js +++ b/static/app.js @@ -28,10 +28,11 @@ import notesModule from './js/notes.js'; import adminModule from './js/admin.js'; import settingsModule from './js/settings.js'; // Eagerly bind unified minimize/restore behavior across all tool modals. -import './js/modalManager.js'; +import * as Modals from './js/modalManager.js'; // Desktop window tiling — drag a modal near an edge/corner to snap. import './js/tileManager.js'; import themeModule from './js/theme.js'; +import { FOREGROUND_ACTIONS, registerForegroundAction } from './js/foregroundActions.js'; // IMPORTANT: import cookbook.js with NO ?v= query — the same plain specifier // every other importer (cookbook-hwfit.js / cookbook-diagnosis.js) uses. A query // mismatch makes the browser load cookbook.js twice as separate modules (two @@ -53,6 +54,25 @@ window.uiModule = uiModule; window.adminModule = adminModule; window.cookbookModule = cookbookModule; +registerForegroundAction(FOREGROUND_ACTIONS.OPEN_CALENDAR, () => { + calendarModule.openCalendar(); + return true; +}); +registerForegroundAction(FOREGROUND_ACTIONS.CLOSE_DOCUMENT, () => { + if (Modals.isMinimized('doc-panel')) { + Modals.close('doc-panel'); + return true; + } + if (!documentModule.isPanelOpen()) return false; + documentModule.closePanel(); + return true; +}); +registerForegroundAction(FOREGROUND_ACTIONS.MINIMIZE_DOCUMENT, () => { + if (!documentModule.isPanelOpen()) return false; + documentModule.closePanel('down'); + return true; +}); + function _isMobileChatInput() { return window.innerWidth <= 768; } diff --git a/static/js/MODULE_SUMMARY.md b/static/js/MODULE_SUMMARY.md index df5b0cb33c..794d8c1924 100644 --- a/static/js/MODULE_SUMMARY.md +++ b/static/js/MODULE_SUMMARY.md @@ -162,6 +162,7 @@ The largest and most central subsystem. Chat submission → backend SSE → prog | **`a11y.js`** | Accessibility helpers. | | **`platform.js`** | Platform detection (macOS/Windows/Linux) and keyboard-modifier helpers. | | **`escMenuStack.js`** | Stack manager for dismissible popups. | +| **`foregroundActions.js`** | Versioned, bounded allowlist for browser-owned Calendar/document foreground actions. | | **`dragSort.js`** | Drag-to-sort shared behavior. | | **`tourHints.js`** / **`tourAutoplay.js`** | Onboarding tour helpers. | | **`color/hex.js`**, **`colorPicker.js`**, **`langIcons.js`**, **`util/ordinal.js`** | Small utility modules for color, language icons, and formatting. | diff --git a/static/js/calendar.js b/static/js/calendar.js index bc0cc89ad3..ae662c5926 100644 --- a/static/js/calendar.js +++ b/static/js/calendar.js @@ -3478,13 +3478,13 @@ function _wheelNav(e) { } function openCalendar() { - if (_open) return; // If currently minimized — restore in place, preserve all state if (Modals.isMinimized('calendar-modal')) { Modals.restore('calendar-modal'); _open = true; return; } + if (_open) return; _open = true; if (_todayCount() > 0) { _markBadgeSeen(); _updateBadge(); } _collapseSidebar(); diff --git a/static/js/chatStream.js b/static/js/chatStream.js index 7b117d7464..e4e4cbfd64 100644 --- a/static/js/chatStream.js +++ b/static/js/chatStream.js @@ -8,6 +8,7 @@ import themeModule from './theme.js'; import markdownModule from './markdown.js'; import sessionModule from './sessions.js'; import documentModule from './document.js'; +import { dispatchForegroundAction } from './foregroundActions.js'; /** * Handle a ui_control SSE event — AI-driven UI manipulation. @@ -18,6 +19,8 @@ export function handleUIControl(uiData) { var esc = uiModule.esc; try { + if (dispatchForegroundAction(uiData)) return; + if (uiEvent === 'toggle' || uiData.ui_event === 'toggle') { var toggleMap = { web: 'web-toggle', bash: 'bash-toggle', rag: 'rag-toggle', diff --git a/static/js/foregroundActions.js b/static/js/foregroundActions.js new file mode 100644 index 0000000000..a8cea57b05 --- /dev/null +++ b/static/js/foregroundActions.js @@ -0,0 +1,124 @@ +// Strict browser-owned foreground action registry. +// +// Transport envelope: +// { ui_event: 'foreground_action', version: 1, action: '', payload: {} } +// +// Action IDs include their only valid target. V1 payloads are therefore empty +// objects: callers cannot smuggle selectors, URLs, markup, or executable text +// through this seam. + +export const FOREGROUND_ACTION_VERSION = 1; +export const MAX_FOREGROUND_ACTION_BYTES = 1024; +export const FOREGROUND_ACTIONS = Object.freeze({ + OPEN_CALENDAR: 'open_view:calendar', + CLOSE_DOCUMENT: 'close_view:document', + MINIMIZE_DOCUMENT: 'minimize_view:document', +}); + +const _allowedActions = new Set(Object.values(FOREGROUND_ACTIONS)); +const _handlers = new Map(); +const _envelopeKeys = new Set(['action', 'payload', 'version']); +const _messageKeys = new Set(['action', 'payload', 'ui_event', 'version']); + +function _fail(code, message) { + const error = new Error(message); + error.name = 'ForegroundActionError'; + error.code = code; + throw error; +} + +function _isPlainObject(value) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const proto = Object.getPrototypeOf(value); + return proto === Object.prototype || proto === null; +} + +function _requireExactKeys(value, allowed, code) { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) _fail(code, 'Foreground action fields do not match the contract'); + } + for (const key of allowed) { + if (!Object.prototype.hasOwnProperty.call(value, key)) { + _fail(code, 'Foreground action fields do not match the contract'); + } + } +} + +function _serializedBytes(value) { + let encoded; + try { + encoded = JSON.stringify(value); + } catch (_) { + _fail('invalid_envelope', 'Foreground action must be JSON serializable'); + } + if (typeof encoded !== 'string') { + _fail('invalid_envelope', 'Foreground action must be JSON serializable'); + } + return new TextEncoder().encode(encoded).byteLength; +} + +function _validateEnvelope(envelope) { + if (!_isPlainObject(envelope)) { + _fail('invalid_envelope', 'Foreground action envelope must be an object'); + } + _requireExactKeys(envelope, _envelopeKeys, 'invalid_envelope'); + if (_serializedBytes(envelope) > MAX_FOREGROUND_ACTION_BYTES) { + _fail('payload_too_large', 'Foreground action envelope is too large'); + } + if (envelope.version !== FOREGROUND_ACTION_VERSION) { + _fail('unsupported_version', 'Unsupported foreground action version'); + } + if (typeof envelope.action !== 'string' || !_allowedActions.has(envelope.action)) { + _fail('unknown_action', 'Unknown foreground action'); + } + if (!_isPlainObject(envelope.payload) || Object.keys(envelope.payload).length !== 0) { + _fail('invalid_payload', 'Foreground action payload must be an empty object'); + } +} + +export function registerForegroundAction(action, handler) { + if (typeof action !== 'string' || !_allowedActions.has(action)) { + _fail('unknown_action', 'Cannot register an unknown foreground action'); + } + if (typeof handler !== 'function') { + _fail('invalid_handler', 'Foreground action handler must be a function'); + } + if (_handlers.has(action)) { + _fail('duplicate_action', `Foreground action already registered: ${action}`); + } + + _handlers.set(action, handler); + let active = true; + return () => { + if (!active) return false; + active = false; + if (_handlers.get(action) !== handler) return false; + return _handlers.delete(action); + }; +} + +export function invokeForegroundAction(envelope) { + _validateEnvelope(envelope); + const handler = _handlers.get(envelope.action); + if (!handler) { + _fail('unregistered_action', `Foreground action is not registered: ${envelope.action}`); + } + return handler(Object.freeze({})); +} + +// Returns false for every legacy ui_control event so existing dispatch can +// continue. A message that declares itself as a foreground action is either +// invoked successfully or rejected; it never falls through to another path. +export function dispatchForegroundAction(message) { + if (!_isPlainObject(message) || message.ui_event !== 'foreground_action') return false; + _requireExactKeys(message, _messageKeys, 'invalid_envelope'); + if (_serializedBytes(message) > MAX_FOREGROUND_ACTION_BYTES) { + _fail('payload_too_large', 'Foreground action message is too large'); + } + invokeForegroundAction({ + version: message.version, + action: message.action, + payload: message.payload, + }); + return true; +} diff --git a/static/sw.js b/static/sw.js index 05b3d4e9b4..9c55f479fd 100644 --- a/static/sw.js +++ b/static/sw.js @@ -7,7 +7,7 @@ // - Other static assets (images/fonts/libs): cache-first with bg refresh. // - API / non-GET: never cached. // Bump CACHE_NAME whenever the precache list or SW logic changes. -const CACHE_NAME = 'odysseus-v344'; +const CACHE_NAME = 'odysseus-v345'; // Core shell precached on install so repeat opens are instant without any // network wait. Keep this list in sync with the