mirror of
https://github.com/razzant/ouroboros.git
synced 2026-08-22 09:13:21 +00:00
The owner's report (2026-08-08) was that the accounts UI is "кривоват и не прозрачен": the add-account button sat in an illogical place, accounts of one agent were not equivalent, and the limits text was neither compact nor understandable. The agent surfaces were also spread across three tabs, so no single screen answered "who does the work here". NEW TAB "Agents", between Models and Behavior. Named for what these subscriptions actually do — the owner's words: "эти агенты не только для кода используются, например для создания презентаций и любые tasks" (D-10). Top to bottom: one service banner, Accounts, Review lanes, Delegation. Moved in, each exactly once (no control is rendered twice — a move that leaves the old markup behind is how two drafts of one settings key appear): Providers → Agents Harness Accounts Models → Agents Reviewer Slots (now "Review lanes") Models → Agents Subagents (now "Delegation"), with mutative Off/Auto/On Advanced → Agents Active Subagents / Root, Subagent Depth Advanced → Agents the two subagent path roots, behind a collapsed Advanced Max Workers stays in Advanced (process capacity, not an agent setting); API keys stay in Providers; API model ids and effort lanes stay in Models. ACCOUNTS are now one card per family. The family header carries the name, an aggregate status that counts the accounts rotation can really use, and that card's OWN add button — the affordance used to hang off the native row, which is what made adding a Codex account a hunt. Rows inside are equivalent: the default CLI login uses the same two-line layout as any named account and is captioned "Managed by the <family> CLI" rather than laid out differently. Line 1 is the account and its status; line 2 is muted metadata in human words — "38% used · resets in 2h", "Limit reached · resets in 2h", "Usage unavailable", "Limits not checked" — never a raw ISO instant. REMOVAL goes through the engine's own contract (DELETE /v2/credential-profiles/:harness/:profileId behind a fourth thin proxy). A native CLI login has no removal button: this process cannot honestly sign a vendor CLI out, so it says who manages it instead of simulating one. A review row pinned to an account discovery no longer lists keeps its pin and gets one actionable sentence — it will refuse rather than reroute. ONE SERVICE BANNER replaces the scattering of "(not in discovery)". It stays per-facet: a refused quota read leaves the catalogue and accounts authoritative and says so, rather than declaring the service down. The all-delegated reviewer disclosure stopped advising against the ratified default. It used to end "keep at least one API reviewer row to avoid the fallback", which asks the owner to undo D-3 (with a subscription connected, everything that can run on one does, and a triad is never half API). It is now neutral routing information: commit and scope review run on subscriptions and wait for capacity rather than falling back to API spend, while plan review, task acceptance and skill review stay API-only on the shipped defaults. Three defects were caught by rendering the tab and looking at it, and each is pinned by a test: a spent window was DIMMED to 0.62 opacity (hiding the very sentence that reports the limit, and making live buttons read as disabled), a family with one signed-in and one cold account announced "2 accounts · rotating", and a row whose verification had failed offered "Connect". Verified visually at 1280px and 420px across zero / one / several accounts per family, service down, and a refused quota read. No version carrier is touched. Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
318 lines
14 KiB
Python
318 lines
14 KiB
Python
"""Static contract checks for the Widgets page renderer."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def _widgets_js() -> str:
|
|
return (REPO_ROOT / "web" / "modules" / "widgets.js").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
|
|
|
|
def _read(rel: str) -> str:
|
|
return (REPO_ROOT / rel).read_text(encoding="utf-8")
|
|
|
|
|
|
def test_widgets_support_declarative_schema_components():
|
|
"""Spot-check that widgets.js exposes the declarative schema entry point
|
|
and a representative set of components. Trimmed in v5.15.x — the full
|
|
type-marker enumeration (15+ entries) was brittle to schema evolution
|
|
and added little signal over a smoke check. Security/lifecycle pins
|
|
moved to the dedicated tests below (escape/sanitize, media source guard,
|
|
download host helper, etc.)."""
|
|
source = _widgets_js()
|
|
assert "render.kind === 'declarative'" in source
|
|
# Sentinel components — proof the declarative router is wired
|
|
assert "type === 'form'" in source
|
|
assert "type === 'action'" in source
|
|
assert "type === 'table'" in source
|
|
assert "type === 'markdown'" in source
|
|
# Lifecycle / cleanup discipline
|
|
assert "disposeMountedWidgets();" in source
|
|
assert "let widgetsMounted = false;" in source
|
|
assert "let renderGeneration = 0;" in source
|
|
page_shown_branch = source.split("window.addEventListener('ouro:page-shown'")[1]
|
|
assert "disposeMountedWidgets();" in page_shown_branch
|
|
|
|
|
|
def test_widgets_escape_and_sanitize_untrusted_content():
|
|
"""Widgets must reach the sanitised markdown helper through the v5.8.3-rc.5
|
|
SSOT (``web/modules/utils.js::renderMarkdownSafe``); the DOMPurify
|
|
allowlist itself moved to that module and is pinned by
|
|
``tests/test_web_utils_ssot.py::test_render_markdown_safe_strips_dangerous_tags_and_attrs``.
|
|
Widgets-side this test now only verifies the import and the
|
|
escapeHtml-around-untrusted-content discipline that remains local
|
|
(table cells, JSON dumps).
|
|
"""
|
|
source = _widgets_js()
|
|
assert "renderMarkdownSafe" in source
|
|
# Widgets must NOT redeclare the SSOT helper locally.
|
|
assert "function renderMarkdownSafe" not in source, (
|
|
"widgets.js must use renderMarkdownSafe from utils.js (SSOT), not a local copy"
|
|
)
|
|
assert "escapeHtml(JSON.stringify(value, null, 2))" in source
|
|
assert "renderTableCell(row, c)" in source
|
|
assert "function safeTableHref" in source
|
|
|
|
|
|
def test_widgets_media_sources_are_constrained_to_extension_routes_or_data_urls():
|
|
source = _widgets_js()
|
|
assert "function safeMediaSrc" in source
|
|
assert "effectiveTarget = ''" in source
|
|
assert "state[effectiveTarget || spec.target || 'result']" in source
|
|
assert "safeMediaSrc(tab, component, state, target)" in source
|
|
assert "const route = spec.route || spec.api_route || '';" in source
|
|
assert "extensionRoutePath(tab.skill, route, params)" in source
|
|
assert "data:(image\\/" in source
|
|
assert "parsed.pathname.startsWith(expectedPrefix)" in source
|
|
assert "parsed.origin === window.location.origin" in source
|
|
assert "javascript:" not in source
|
|
assert "`${treePath}.gallery.${idx}`, passiveTarget" in source
|
|
|
|
|
|
def test_widgets_downloads_use_host_handler_not_navigation():
|
|
source = _widgets_js()
|
|
helper = _read("web/modules/ui_helpers.js")
|
|
assert "data-widget-download-url" in source
|
|
assert "event.preventDefault();" in source
|
|
assert "downloadViaHostBridge(" in source
|
|
assert "download_file_to_downloads" in helper
|
|
assert "URL.createObjectURL" in helper
|
|
assert "window.location.href" not in source
|
|
assert "window.location.assign" not in source
|
|
assert '<a class="btn btn-default" href' not in source
|
|
|
|
|
|
def test_widgets_treat_head_as_no_body_request():
|
|
source = _widgets_js()
|
|
assert "const noBody = method === 'GET' || method === 'HEAD';" in source
|
|
assert "const init = noBody" in source
|
|
|
|
|
|
def test_widgets_keep_iframe_sandbox_locked_down():
|
|
"""The legacy ``kind: "iframe"`` widget surface mounts an extension
|
|
route inside a <iframe> with the *empty* sandbox attribute (no
|
|
permissions at all). v5.7.0 added ``kind: "module"``, which mounts
|
|
extension-supplied JS inside a separate <iframe srcdoc> with
|
|
``sandbox="allow-scripts"`` BUT no ``allow-same-origin`` token —
|
|
so the iframe is still an opaque origin (no SPA cookie / storage
|
|
access) and is further constrained by a strict CSP. We check both
|
|
invariants here:
|
|
|
|
1. The legacy iframe path still uses the empty sandbox.
|
|
2. The module iframe path adds ``allow-scripts`` but never adds
|
|
``allow-same-origin`` (the only token that would re-expose
|
|
parent storage).
|
|
"""
|
|
source = _widgets_js()
|
|
assert 'sandbox=""' in source
|
|
# ``allow-scripts`` is now legitimately present, but only inside the
|
|
# ``kind === 'module'`` branch. The dangerous combined sandbox token
|
|
# must never appear in an actual iframe attribute.
|
|
assert 'sandbox="allow-scripts"' in source
|
|
assert 'sandbox="allow-scripts allow-same-origin"' not in source
|
|
assert 'sandbox="allow-scripts allow-forms allow-same-origin"' not in source
|
|
assert "render.kind === 'module'" in source
|
|
# Verify the module iframe carries a CSP that does NOT grant network
|
|
# access directly. The parent injects a postMessage fetch bridge instead,
|
|
# restricted to /api/extensions/<skill>/... from the parent side.
|
|
assert "default-src 'none'" in source
|
|
assert "script-src 'unsafe-inline'" in source
|
|
assert "OuroborosWidget = { fetch: window.fetch }" in source
|
|
assert "module widget fetch outside extension route prefix" in source
|
|
|
|
|
|
def test_widgets_use_design_radius_tokens():
|
|
style = (REPO_ROOT / "web" / "style.css").read_text(encoding="utf-8")
|
|
block_start = style.index(".widget-field input,")
|
|
block_end = style.index("}", block_start)
|
|
block = style[block_start:block_end]
|
|
assert "border-radius: var(--radius-sm);" in block
|
|
assert "border-radius: 9px;" not in block
|
|
|
|
|
|
def test_widgets_refresh_button_shows_loading_state():
|
|
source = _widgets_js()
|
|
css = (REPO_ROOT / "web" / "style.css").read_text(encoding="utf-8")
|
|
|
|
assert "refreshBtn.classList.add('is-loading')" in source
|
|
assert "refreshBtn.classList.remove('is-loading')" in source
|
|
assert "refreshBtn.disabled = true" in source
|
|
assert "#widgets-refresh.is-loading::after" in css
|
|
|
|
|
|
def test_widgets_cards_do_not_stretch_to_row_height():
|
|
source = _widgets_js()
|
|
css = (REPO_ROOT / "web" / "style.css").read_text(encoding="utf-8")
|
|
masonry = (REPO_ROOT / "web" / "modules" / "masonry.js").read_text(encoding="utf-8")
|
|
assert "const span = Number(tab.span || tab.grid_span || 1);" in source
|
|
assert "widgets-card-span-2" in source
|
|
assert "applyMasonry(list)" in source
|
|
assert "function layout(container, config)" in masonry
|
|
assert "item.classList.contains(spanClass) ? 2 : 1" in masonry
|
|
assert "Math.min(desiredColumns, availableColumns)" in masonry
|
|
assert "itemResizeObserver" in masonry
|
|
assert "observeItems()" in masonry
|
|
widgets_block = css.split(".widgets-list {", 1)[1].split("}", 1)[0]
|
|
assert "display: grid" not in widgets_block
|
|
assert "position: relative;" in widgets_block
|
|
assert ".widgets-card-span-2" in css
|
|
|
|
|
|
def test_widget_form_label_is_accessible_heading_fallback():
|
|
source = _widgets_js()
|
|
assert "const heading = component.title || component.label || '';" in source
|
|
assert 'aria-label="${escapeHtml(heading)}"' in source
|
|
assert "heading ? `<h4>${escapeHtml(heading)}</h4>` : ''" in source
|
|
|
|
|
|
def test_widget_json_wraps_inside_its_host_card():
|
|
style = _read("web/style.css")
|
|
json_block = style.split(".widget-json pre {", 1)[1].split("}", 1)[0]
|
|
assert "max-width: 100%;" in json_block
|
|
assert "max-height: min(360px, 50vh);" in json_block
|
|
assert "overflow: auto;" in json_block
|
|
assert "white-space: pre-wrap;" in json_block
|
|
assert "overflow-wrap: anywhere;" in json_block
|
|
|
|
|
|
def test_widgets_card_order_is_owner_ui_preference():
|
|
source = _widgets_js()
|
|
css = (REPO_ROOT / "web" / "style.css").read_text(encoding="utf-8")
|
|
api_client = (REPO_ROOT / "web" / "modules" / "api_client.js").read_text(encoding="utf-8")
|
|
|
|
assert 'data-widget-reorder-handle' in source
|
|
assert "function sortTabsByWidgetOrder" in source
|
|
assert "originalIndex" in source
|
|
assert "return a.originalIndex - b.originalIndex;" in source
|
|
assert "Move widget: drag or use arrow keys" in source
|
|
assert "handle.addEventListener('keydown'" in source
|
|
assert "event.key === 'ArrowUp'" in source
|
|
assert "apiClient.uiPreferences()" in source
|
|
assert "apiClient.saveUiPreferences({ widget_order: normalized })" in source
|
|
assert "currentWidgetOrderFromDom(list)" in source
|
|
assert ".widgets-card-drag" in css
|
|
assert ".widgets-card.drag-over" in css
|
|
assert "uiPreferences: () => fetchJson('/api/ui/preferences'" in api_client
|
|
assert "saveUiPreferences: (payload) => jsonPost('/api/ui/preferences', payload)" in api_client
|
|
|
|
|
|
def test_widgets_inline_card_host_path_removed():
|
|
source = _widgets_js()
|
|
assert "render.kind === 'inline_card'" not in source
|
|
assert "skill-widget-weather" not in source
|
|
assert "const saved = widgetSessionState.get(persistenceKey) || {};" in source
|
|
|
|
|
|
def test_widgets_v5_7_0_new_components_render():
|
|
"""v5.7.0 host-owned declarative components: ``map`` (Leaflet-ready
|
|
fallback list), ``calendar`` (host SVG-style row list), ``kanban``
|
|
(HTML5 drag with on_move POST). All three must be present in the
|
|
declarative renderer so authors can reference them in widgets, and
|
|
none of them may bring skill-supplied JS into the SPA origin."""
|
|
source = _widgets_js()
|
|
assert "type === 'map'" in source
|
|
assert "type === 'calendar'" in source
|
|
assert "type === 'kanban'" in source
|
|
# Module / arbitrary <script> from the skill must NEVER be inserted
|
|
# into the host origin. ``data-widget-map-config`` carries the spec
|
|
# as JSON in a data attribute (host renders); no runtime eval of
|
|
# extension JS is acceptable in any of the new component renderers.
|
|
assert "data-widget-map-config" in source
|
|
assert "widget-kanban-card" in source
|
|
|
|
|
|
def test_widgets_render_subscription_children():
|
|
source = _widgets_js()
|
|
assert "type === 'subscription'" in source
|
|
assert "component.render" in source
|
|
assert "widget-subscription-render" in source
|
|
assert "inheritedTarget = ''" in source
|
|
assert "component.target || inheritedTarget || 'result'" in source
|
|
assert "renderComponent(tab, child, view, `${treePath}.render.${idx}`, target)" in source
|
|
assert "const passiveTarget = inheritedTarget ? target : '';" in source
|
|
assert "value_key" in source
|
|
assert "items_key" in source
|
|
assert "route_prefix" in source
|
|
assert "type === 'key_value'" in source
|
|
|
|
|
|
def test_widgets_schema_v1_composition_uses_stable_tree_keys():
|
|
source = _widgets_js()
|
|
assert "function componentIdentity" in source
|
|
assert "function indexComponentTree" in source
|
|
assert "type === 'group'" in source
|
|
assert "type === 'metric'" in source
|
|
assert "type === 'callout'" in source
|
|
assert "visibleKeys.forEach((key)" in source
|
|
assert "components[Number(" not in source
|
|
assert "data-widget-kanban-key" in source
|
|
|
|
|
|
def test_widgets_forms_charts_and_kanban_keep_host_owned_contracts():
|
|
source = _widgets_js()
|
|
helper = _read("web/modules/ui_helpers.js")
|
|
assert "renderSafeField(" in source
|
|
assert "collectSafeFieldValues(" in source
|
|
assert "includePasswords: false" in source
|
|
assert "pendingActions.has(key)" in source
|
|
assert "spanGaps: false" in source
|
|
assert "finiteChartValue" in source
|
|
assert "aria-label=" in source
|
|
assert "renderChartDataTable" in source
|
|
assert "data-widget-kanban-move" in source
|
|
assert "widget-kanban-empty" in source
|
|
assert "mount.querySelectorAll('[data-widget-kanban-key]')" in source
|
|
assert "{ card_id: cardId, column_id: columnId }" in source
|
|
assert "SAFE_FIELD_TYPES" in helper
|
|
assert "autocomplete=\"new-password\"" in helper
|
|
|
|
|
|
def test_widgets_responsive_design_system_styles_are_host_owned():
|
|
style = _read("web/style.css")
|
|
assert ".widget-group-grid" in style
|
|
assert ".widget-metric" in style
|
|
assert ".widget-callout" in style
|
|
assert ".widget-form-fields.widget-grid-cols-4" in style
|
|
assert "content: attr(data-label);" in style
|
|
assert ".widget-kanban-move" in style
|
|
assert ".widget-kanban-col.is-empty" in style
|
|
# The widget narrow block is found by its CONTENT, not by being the last
|
|
# `@media (max-width: 640px)` in the file. Position was never the fact under
|
|
# test, and any surface that later adds its own 640px block (the Agents tab's
|
|
# account rows did) would silently steal this assertion and fail it.
|
|
blocks = style.split("@media (max-width: 640px) {")[1:]
|
|
narrow = [b for b in blocks if ".widget-kanban-col.is-empty" in b]
|
|
assert narrow, "no @media (max-width: 640px) block carries the widget kanban rules"
|
|
assert any("min-height: 0;" in b for b in narrow)
|
|
assert any("padding-block: 8px;" in b for b in narrow)
|
|
assert ".widget-group-components > * { margin-top: 0; }" in style
|
|
assert "repeat(auto-fit, minmax(min(220px, 100%), 1fr))" in style
|
|
assert ".widget-group-grid > .widget-group-components > :is(" in style
|
|
|
|
|
|
def test_widget_public_tones_share_the_host_normalizer_and_canonical_css():
|
|
source = _widgets_js()
|
|
helper = _read("web/modules/ui_helpers.js")
|
|
style = _read("web/style.css")
|
|
assert "function widgetTone" not in source
|
|
assert "normalizeTone(component.tone)" in source
|
|
assert "normalizeTone(component.tone, 'info')" in source
|
|
assert "success: 'ok'" in helper
|
|
assert "warning: 'warn'" in helper
|
|
assert "neutral: 'muted'" in helper
|
|
assert '.widget-metric[data-tone="ok"], .widget-callout[data-tone="ok"]' in style
|
|
assert '.widget-metric[data-tone="warn"], .widget-callout[data-tone="warn"]' in style
|
|
|
|
|
|
def test_widget_metrics_share_the_standard_empty_value_and_numeric_formatter():
|
|
source = _widgets_js()
|
|
assert "const numericValue = text ? Number(text) : Number.NaN;" in source
|
|
assert "!Number.isNaN(numericValue) && !Number.isFinite(numericValue)" in source
|
|
assert "const structured = raw !== null && typeof raw === 'object';" in source
|
|
assert "nonFiniteText" in source
|
|
assert "typeof raw === 'number' || numericText ? formatNumber" in source
|