From 0de7b886ff0015f20d4062f8156415d3092bea11 Mon Sep 17 00:00:00 2001 From: ThorstenHellert Date: Tue, 11 Aug 2026 15:27:00 +0200 Subject: [PATCH 1/2] feat(web-terminal): move Log out into the display menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header carried a user chip whose popover held one control. That control now sits in the display menu beside Settings — a half-width pair under a line naming the signed-in user — and the chip is gone, leaving search and the display menu in the header. Both buttons keep the contracts they arrived with (`#logout-btn` + `data-landing-url`, `data-drawer-trigger`), so behaviour is unchanged. The footer is a flex pair rather than a two-column grid: a single-user terminal renders no identity line and no logout, and Settings fills the row alone. System Settings is relabelled Settings to fit half a card. --- CHANGELOG.md | 5 + .../web_terminal/static/css/terminal.css | 254 +++++++----------- .../interfaces/web_terminal/static/index.html | 109 ++++---- .../interfaces/web_terminal/static/js/app.js | 11 +- .../web_terminal/static/js/display-menu.js | 20 +- .../web_terminal/static/js/identity-menu.js | 81 ------ .../web_terminal/display-menu.test.mjs | 47 +++- .../web_terminal/identity-menu.test.mjs | 161 ----------- .../test_logout_resume_browser.py | 27 +- .../web_terminal/test_terminal_user.py | 33 +-- 10 files changed, 248 insertions(+), 500 deletions(-) delete mode 100644 src/osprey/interfaces/web_terminal/static/js/identity-menu.js delete mode 100644 tests/interfaces/web_terminal/identity-menu.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ed5bd10e..f64e2d3cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -138,6 +138,11 @@ Compatibility is documented in release notes, not encoded in the version string. ### Changed +- **Log out** moved into the web terminal's display menu, alongside + **Settings** — the two now sit side by side under a line naming the signed-in + user. The separate user chip in the header is gone, leaving search and the + display menu there. Single-user terminals are unchanged apart from + **System Settings** being relabelled **Settings**. - Pairing a virtual accelerator with the mock archiver is refused — at build, at deploy, and at MCP server startup — because the VA moves channels for modelled reasons while the mock archiver invents history at read time, and diff --git a/src/osprey/interfaces/web_terminal/static/css/terminal.css b/src/osprey/interfaces/web_terminal/static/css/terminal.css index 7dc751033..7cb063ca3 100644 --- a/src/osprey/interfaces/web_terminal/static/css/terminal.css +++ b/src/osprey/interfaces/web_terminal/static/css/terminal.css @@ -234,150 +234,6 @@ background: var(--bg-elevated); } -/* ---- Identity chip (who you are + the way out) ---- - - One chip replaces the old user-name badge and the standalone logout door - button. The trigger states the fact (avatar initial + username); the popover - it opens holds the action (Log out) plus the deployment line, so an operator - on several terminals can confirm WHICH one they are about to leave before - they leave it. Popover grammar — capture-phase outside-click, Escape, - aria-expanded mirrored on the trigger — matches display-menu.js and - panel-add-menu.js, the shell's other two header popovers. */ -.identity-menu { - position: relative; - display: flex; - flex-shrink: 0; -} - -.identity-trigger { - display: flex; - align-items: center; - gap: var(--space-2); - height: 28px; - max-width: 200px; - padding: 0 8px 0 4px; - color: var(--text-primary); - background: var(--bg-panel); - border: 1px solid var(--border-default); - border-radius: var(--radius-full); - cursor: pointer; - transition: color var(--wt-transition-fast), border-color var(--wt-transition-fast), - background var(--wt-transition-fast); -} - -.identity-trigger:hover, -.identity-trigger[aria-expanded="true"] { - border-color: var(--border-accent); - background: var(--bg-elevated); -} - -/* The initial disc. Carries the accent so the chip reads as "you" at a glance - without needing an avatar image the framework has no way to source. */ -.identity-avatar { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - width: 20px; - height: 20px; - font-family: var(--font-display); - font-size: var(--text-sm); - font-weight: var(--weight-semibold); - line-height: var(--leading-none); - color: var(--color-on-accent); - background: var(--color-accent); - border-radius: var(--radius-full); -} - -.identity-name { - font-family: var(--font-display); - font-size: var(--text-sm); - font-weight: var(--weight-regular); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.identity-caret { - display: block; - flex-shrink: 0; - color: var(--text-muted); -} - -/* The popover: drops below the chip, right-aligned to it — same geometry as - the display menu's card. */ -.identity-card { - display: none; - position: absolute; - top: calc(100% + var(--space-2)); - right: 0; - min-width: 200px; - flex-direction: column; - background: var(--bg-secondary); - border: 1px solid var(--border-default); - border-radius: var(--radius-xl); - box-shadow: var(--shadow-dropdown); - overflow: hidden; - z-index: var(--z-dropdown); -} - -.identity-card.open { - display: flex; -} - -.identity-card-identity { - display: flex; - flex-direction: column; - gap: 2px; - padding: var(--space-3); - border-bottom: 1px solid var(--border-default); -} - -.identity-card-name { - font-family: var(--font-display); - font-size: var(--text-md); - font-weight: var(--weight-semibold); - color: var(--text-primary); - word-break: break-all; -} - -.identity-card-sub { - font-family: var(--font-display); - font-size: var(--text-sm); - color: var(--text-secondary); -} - -.identity-card-logout { - display: flex; - align-items: center; - gap: var(--space-2); - width: 100%; - padding: var(--space-3); - font-family: var(--font-display); - font-size: var(--text-sm); - text-align: left; - color: var(--text-secondary); - background: none; - border: none; - cursor: pointer; - transition: color var(--wt-transition-fast), background var(--wt-transition-fast); -} - -.identity-card-logout:hover { - color: var(--color-error); - background: var(--bg-elevated); -} - -.identity-card-logout[aria-busy="true"] { - cursor: progress; - opacity: 0.6; -} - -.identity-card-logout-icon { - font-size: var(--text-md); - line-height: var(--leading-none); -} - /* ---- Display menu (the header sliders button) ---- Every display preference — Appearance (light/dark), View (Expert/Simple), @@ -569,15 +425,96 @@ html[data-ui-mode="simple"] .mode-segment[data-mode="simple"], background: var(--accent-tint-06); } -/* System Settings: the card's last row, separated from the preference rows by - a hairline so it reads as an action rather than a fourth preference. Expert - only — the simple-mode gate lives with the other density deltas below. */ -.display-menu-settings { +/* ---- Session footer (identity + the two actions) ---- + + The card's last block: who you are, then Settings and Log out side by side. + A hairline splits it from the preference rows so it reads as action rather + than a fourth preference. Both actions are present in BOTH ui modes — the + settings drawer's own warning gate, not the ui mode, is what guards expert + configuration. */ +.display-menu-identity { + display: flex; + align-items: baseline; + gap: var(--space-2); + min-width: 0; + padding-top: var(--space-2); + border-top: 1px solid var(--border-default); +} + +/* The initial disc. Carries the accent so the line reads as "you" at a glance + without needing an avatar image the framework has no way to source. */ +.display-menu-identity-avatar { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + align-self: center; + width: 20px; + height: 20px; + font-family: var(--font-display); + font-size: var(--text-sm); + font-weight: var(--weight-semibold); + line-height: var(--leading-none); + color: var(--color-on-accent); + background: var(--color-accent); + border-radius: var(--radius-full); +} + +/* The name wins the row when both it and the deployment are too long for it: + `alice` is what identifies the session, the deployment only qualifies it. */ +.display-menu-identity-name { + flex-shrink: 0; + max-width: 60%; + font-family: var(--font-display); + font-size: var(--text-base); + font-weight: var(--weight-semibold); + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.display-menu-identity-sub { + min-width: 0; + font-family: var(--font-display); + font-size: var(--text-sm); + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.display-menu-identity-sub::before { + content: '·'; + margin-right: var(--space-1); +} + +/* The hairline opens the footer, so it belongs to whichever element starts it: + the identity line on a multi-user terminal, the action row on a single-user + one (which renders no identity and no logout). */ +.display-menu-actions { display: flex; + gap: var(--space-2); + padding-top: var(--space-2); + border-top: 1px solid var(--border-default); +} + +.display-menu-identity + .display-menu-actions { + padding-top: 0; + border-top: none; +} + +/* `flex: 1` rather than a 2-column grid: with logout absent — a single-user + deployment, or one with no landing_url to return to — Settings fills the row + by itself instead of sitting in half of it. */ +.display-menu-settings, +.display-menu-logout { + display: flex; + flex: 1; align-items: center; justify-content: center; gap: var(--space-2); - margin-top: calc(var(--space-1) * -1); + min-width: 0; padding: 7px 10px; font-family: var(--font-display); font-size: var(--text-base); @@ -586,6 +523,7 @@ html[data-ui-mode="simple"] .mode-segment[data-mode="simple"], border: 1px solid var(--border-default); border-radius: var(--radius-lg); cursor: pointer; + white-space: nowrap; transition: color var(--wt-transition-fast), border-color var(--wt-transition-fast), background var(--wt-transition-fast); } @@ -597,7 +535,23 @@ html[data-ui-mode="simple"] .mode-segment[data-mode="simple"], background: var(--bg-elevated); } -.display-menu-settings-icon { +/* Logout hovers to the error colour, not the accent: it is the one control in + this card that ends the session rather than adjusting it. */ +.display-menu-logout:hover { + color: var(--color-error); + border-color: var(--color-error); + background: var(--bg-elevated); +} + +/* app.js locks the button (`disabled` + `aria-busy`) for the whole in-flight + logout and never resets it — every path out of that handler navigates away. */ +.display-menu-logout[aria-busy="true"] { + cursor: progress; + opacity: 0.6; +} + +.display-menu-settings-icon, +.display-menu-logout-icon { font-size: var(--text-md); line-height: var(--leading-none); } diff --git a/src/osprey/interfaces/web_terminal/static/index.html b/src/osprey/interfaces/web_terminal/static/index.html index 57176c7a1..7aa69b4ec 100644 --- a/src/osprey/interfaces/web_terminal/static/index.html +++ b/src/osprey/interfaces/web_terminal/static/index.html @@ -113,55 +113,16 @@ Search - - {% if terminal_user %} -
- -
-
- {{ terminal_user }} - {% if app_name %}{{ app_name }}{% endif %} -
- {% if landing_url %} - - {% endif %} -
-
- {% endif %} + card so the header stays quiet, and — where the deployment knows a + user — closes with the session footer (who you are, System Settings, + Log out). display-menu.js owns open/close and the two theme rows; + the View row keeps the #mode-toggle / .mode-segment contract, so + app.js's initModeToggle() still owns the mode flip and the active + segment stays CSS-driven off html[data-ui-mode]. The family pills + are populated by display-menu.js from the tokens.js THEMES + registry. -->
- - + + {% if terminal_user %} +
+ + {{ terminal_user }} + {% if app_name %}{{ app_name }}{% endif %} +
+ {% endif %} + +
+ + {% if terminal_user and landing_url %} + + {% endif %} +
diff --git a/src/osprey/interfaces/web_terminal/static/js/app.js b/src/osprey/interfaces/web_terminal/static/js/app.js index 05020cb86..5e2fca808 100644 --- a/src/osprey/interfaces/web_terminal/static/js/app.js +++ b/src/osprey/interfaces/web_terminal/static/js/app.js @@ -15,7 +15,6 @@ import { initChat } from './chat.js'; import { initDockWorkspace, applyDockMode } from './dock-workspace.js'; import { initHeaderContrib } from './tile-header-contrib.js'; import { initDisplayMenu } from './display-menu.js'; -import { initIdentityMenu } from './identity-menu.js'; import { followThemeFamily, getRailPosition, setRailPosition } from './rail-position.js'; document.addEventListener('DOMContentLoaded', () => { @@ -48,7 +47,6 @@ document.addEventListener('DOMContentLoaded', () => { initLogoutButton(); initModeToggle(); initDisplayMenu(); - initIdentityMenu(); initRailPosition(); initDrawerTriggerHighlight(); initSettings(); @@ -138,10 +136,11 @@ export function initLogoutButton() { * prefix (`window.__OSPREY_PREFIX__`, which `compute_url_prefix()` sets to * exactly `/u/` for a multi-user container and to `""` otherwise). * - * Read from the prefix rather than from the identity chip's text because the - * prefix is the copy the app already routes every one of its own requests - * through — the chip is display markup, and taking a name from rendered text - * to put it back in a URL is how a display change becomes a wiring bug. + * Read from the prefix rather than from the display menu's identity line + * because the prefix is the copy the app already routes every one of its own + * requests through — that line is display markup, and taking a name from + * rendered text to put it back in a URL is how a display change becomes a + * wiring bug. * Returns `""` for a plain `osprey web`, which has no per-user prefix. */ function terminalUserFromPrefix() { diff --git a/src/osprey/interfaces/web_terminal/static/js/display-menu.js b/src/osprey/interfaces/web_terminal/static/js/display-menu.js index 57026df70..10b5b13a7 100644 --- a/src/osprey/interfaces/web_terminal/static/js/display-menu.js +++ b/src/osprey/interfaces/web_terminal/static/js/display-menu.js @@ -3,11 +3,13 @@ * * One quiet faders button in the header collapses every display preference * behind a popover card: Appearance (light/dark within the active theme family), View - * (Expert/Simple), Theme (the family pills), and — last — the System Settings - * row that opens the settings drawer. It replaces the hub header's - * always-visible segmented mode toggle + `` pair and the - * standalone settings gear beside them; standalone fleet pages (session.html, - * the panels) keep the shared switcher component; this menu is hub chrome only. + * (Expert/Simple), Theme (the family pills), and — last — a session footer + * naming the signed-in user above the Settings / Log out pair. It replaces the + * hub header's always-visible segmented mode toggle + `` + * pair, the standalone settings gear beside them, and the identity chip that + * used to carry logout in a popover of its own; standalone fleet pages + * (session.html, the panels) keep the shared switcher component; this menu is + * hub chrome only. * * Division of labour: * - This module owns the popover (open/close, outside-click, Escape) and @@ -24,12 +26,18 @@ * card renders identically in both modes, so the card an operator is * looking at survives the flip and they can compare the two shells (or go * straight back) without re-opening the menu. - * - The SYSTEM SETTINGS row is not this module's to open: it keeps the + * - The SETTINGS button is not this module's to open: it keeps the * `[data-drawer-trigger="settings-drawer"]` contract, so settings.js's * first-time warning gate stays the sole open path and app.js still * mirrors the drawer's open state onto it as `.active`. This is the ONE * row that closes the card, because opening the drawer moves the operator * to a different surface entirely. + * - LOG OUT is likewise app.js's, by `#logout-btn` + `data-landing-url` + * (palette-boot.js's "Log out" command also finds it by id). Deliberately + * NOT closed by this module: every path out of that handler navigates + * away, and closing the card first would hide the button's own aria-busy + * state while the POST is still in flight — from the operator and from + * assistive tech — at exactly the moment it matters. * * The popover grammar (open renders nothing lazily except the family pills' * one-time build; capture-phase outside-click + Escape close; aria-expanded diff --git a/src/osprey/interfaces/web_terminal/static/js/identity-menu.js b/src/osprey/interfaces/web_terminal/static/js/identity-menu.js deleted file mode 100644 index 73c5e322d..000000000 --- a/src/osprey/interfaces/web_terminal/static/js/identity-menu.js +++ /dev/null @@ -1,81 +0,0 @@ -// @ts-check -/* OSPREY Web Terminal — Identity Menu (the header user chip) - * - * One chip in the header states who you are; clicking it opens a popover - * holding the deployment line and the Log out control. It replaces the old - * pair — a bare username badge beside a standalone door button — which read - * as two unrelated controls despite being one fact and one action on it. - * - * Division of labour: - * - This module owns ONLY the popover (open/close, outside-click, Escape). - * - The logout BEHAVIOUR is not this module's: the button keeps its - * `#logout-btn` id and `data-landing-url` attribute, so app.js's - * initLogoutButton() still owns the safe-URL check, the POST, the - * disabled/aria-busy lock and the navigation, and palette-boot.js's - * "Log out" command still finds the button by id. Moving the control - * into a popover changed its placement, not its contract. - * - * Deliberately no close-on-logout-click: every path out of that handler - * navigates away, and closing the card first would flash the chip back to its - * resting state while the POST is still in flight, hiding the button's own - * aria-busy state from the operator (and from assistive tech) at exactly the - * moment it matters. - * - * The popover grammar (capture-phase outside-click + Escape close; - * aria-expanded mirrored on the trigger) matches display-menu.js and - * panel-add-menu.js, the shell's other header popovers. - */ - -/** - * Wire the header identity chip's popover. - * - * No-op when the chip is absent — a single-user deployment renders no - * `OSPREY_TERMINAL_USER`, so there is no identity to show and the header - * carries search + display menu alone. - */ -export function initIdentityMenu() { - const root = document.getElementById('identity-menu'); - const button = /** @type {HTMLButtonElement | null} */ ( - document.getElementById('identity-menu-btn') - ); - const card = document.getElementById('identity-menu-card'); - if (!root || !button || !card) return; - - const isOpen = () => card.classList.contains('open'); - - /** @param {MouseEvent} e */ - function onDocClick(e) { - if (e.target instanceof Node && root && !root.contains(e.target)) closeMenu(); - } - - /** @param {KeyboardEvent} e */ - function onKeydown(e) { - if (e.key === 'Escape') { - closeMenu(); - button?.focus(); - } - } - - function openMenu() { - if (!card || !button) return; - card.classList.add('open'); - button.setAttribute('aria-expanded', 'true'); - // Capture-phase so an outside click closes before it does anything else. - document.addEventListener('click', onDocClick, true); - document.addEventListener('keydown', onKeydown, true); - } - - function closeMenu() { - if (!card || !button) return; - card.classList.remove('open'); - button.setAttribute('aria-expanded', 'false'); - document.removeEventListener('click', onDocClick, true); - document.removeEventListener('keydown', onKeydown, true); - } - - button.addEventListener('click', (e) => { - e.stopPropagation(); - if (isOpen()) closeMenu(); - else openMenu(); - }); -} diff --git a/tests/interfaces/web_terminal/display-menu.test.mjs b/tests/interfaces/web_terminal/display-menu.test.mjs index ccfb6efd2..ff6fb48cb 100644 --- a/tests/interfaces/web_terminal/display-menu.test.mjs +++ b/tests/interfaces/web_terminal/display-menu.test.mjs @@ -17,10 +17,11 @@ * pill click drives setFamily() * - a View-row segment click closes the card (the mode flip itself is * app.js's initModeToggle(), out of scope here) - * - the System Settings row: it is the card's last child, closes the card on - * click, and keeps the `[data-drawer-trigger]`-not-`[data-drawer]` - * contract settings.js's warning gate binds to (the open itself is - * settings.js's, out of scope here) + * - the session footer: the Settings / Log out row is the card's last child, + * Settings keeps the `[data-drawer-trigger]`-not-`[data-drawer]` contract + * settings.js's warning gate binds to and closes the card on click (the + * open itself is settings.js's, out of scope here), and Log out + * deliberately does not close it (the logout itself is app.js's) * * Module-identity note (same as theme-switcher.test.mjs): this file imports * theme-manager.js and display-menu.js exactly once, at the top, and resets @@ -51,8 +52,16 @@ const FIXTURE = `
- +
+ + alice +
+
+ + +
@@ -142,16 +151,27 @@ describe('display-menu', () => { expect(qs('#display-menu-card').classList.contains('open')).toBe(true); }); - test('a System Settings click closes the card', () => { + test('a Settings click closes the card', () => { mountAndInit(); qs('#display-menu-btn').click(); qs('#display-menu-settings').click(); expect(qs('#display-menu-card').classList.contains('open')).toBe(false); }); + + test('a Log out click leaves the card OPEN', () => { + // Deliberate: the logout handler (app.js) navigates away on every path, + // and closing the card first would hide the button's own aria-busy state + // while the POST is still in flight. + mountAndInit(); + qs('#display-menu-btn').click(); + + qs('#logout-btn').click(); + expect(qs('#display-menu-card').classList.contains('open')).toBe(true); + }); }); - describe('System Settings row', () => { + describe('Session footer', () => { test('keeps the data-drawer-trigger contract settings.js binds its gate to', () => { mountAndInit(); // The row must remain the [data-drawer-trigger="settings-drawer"] the @@ -163,12 +183,15 @@ describe('display-menu', () => { expect(trigger?.hasAttribute('data-drawer')).toBe(false); }); - test('lives inside the card, after every preference row', () => { + test('the action row closes the card, after every preference row', () => { mountAndInit(); const card = qs('#display-menu-card'); - const settings = qs('#display-menu-settings'); - expect(card.contains(settings)).toBe(true); - expect(card.lastElementChild).toBe(settings); + const actions = qs('.display-menu-actions'); + expect(card.lastElementChild).toBe(actions); + // Settings leads, Log out follows: the destructive control is not the + // one under the cursor when the card opens. + expect(actions.firstElementChild).toBe(qs('#display-menu-settings')); + expect(actions.lastElementChild).toBe(qs('#logout-btn')); }); test('init still no-ops when the row is absent', () => { diff --git a/tests/interfaces/web_terminal/identity-menu.test.mjs b/tests/interfaces/web_terminal/identity-menu.test.mjs deleted file mode 100644 index 3ea46484e..000000000 --- a/tests/interfaces/web_terminal/identity-menu.test.mjs +++ /dev/null @@ -1,161 +0,0 @@ -// @ts-check -/** - * Unit tests for the header identity chip's popover (`initIdentityMenu`): - * npx vitest run tests/interfaces/web_terminal/identity-menu.test.mjs - * - * The chip collapses the old username badge + standalone logout button into - * one control. This module owns ONLY the popover; the logout behaviour stays - * app.js's `initLogoutButton` (covered by app-logout.test.mjs), which finds the - * button by `#logout-btn` wherever it lives. The tests below therefore pin two - * things: the popover grammar shared with the shell's other header popovers, - * and the fact that this module does not touch the logout contract. - * - * Imported by RELATIVE path — this module lives under web_terminal, so the - * /design-system/js/* alias does not apply. - */ - -import { test, expect, describe, beforeEach } from 'vitest'; - -import { initIdentityMenu } from '../../../src/osprey/interfaces/web_terminal/static/js/identity-menu.js'; - -/** Render the chip the way the server does when `terminal_user` is set. */ -function mountChip({ landingUrl = 'https://landing.example.org/' } = {}) { - document.body.innerHTML = ` -
- -
-
- alice - Control Assistant -
- -
-
-
elsewhere
`; - return { - root: /** @type {HTMLElement} */ (document.getElementById('identity-menu')), - button: /** @type {HTMLButtonElement} */ (document.getElementById('identity-menu-btn')), - card: /** @type {HTMLElement} */ (document.getElementById('identity-menu-card')), - logout: /** @type {HTMLButtonElement} */ (document.getElementById('logout-btn')), - outside: /** @type {HTMLElement} */ (document.getElementById('outside')), - }; -} - -beforeEach(() => { - document.body.innerHTML = ''; -}); - -describe('initIdentityMenu', () => { - test('starts closed', () => { - const { card, button } = mountChip(); - - initIdentityMenu(); - - expect(card.classList.contains('open')).toBe(false); - expect(button.getAttribute('aria-expanded')).toBe('false'); - }); - - test('clicking the chip opens the popover and mirrors aria-expanded', () => { - const { button, card } = mountChip(); - initIdentityMenu(); - - button.click(); - - expect(card.classList.contains('open')).toBe(true); - expect(button.getAttribute('aria-expanded')).toBe('true'); - }); - - test('clicking the chip again closes it', () => { - const { button, card } = mountChip(); - initIdentityMenu(); - - button.click(); - button.click(); - - expect(card.classList.contains('open')).toBe(false); - expect(button.getAttribute('aria-expanded')).toBe('false'); - }); - - test('a click outside closes it', () => { - const { button, card, outside } = mountChip(); - initIdentityMenu(); - button.click(); - - outside.click(); - - expect(card.classList.contains('open')).toBe(false); - expect(button.getAttribute('aria-expanded')).toBe('false'); - }); - - test('a click inside the popover leaves it open', () => { - // Logging out navigates away; closing the card first would hide the - // button's own in-flight aria-busy state at the moment it matters. - const { button, card, logout } = mountChip(); - initIdentityMenu(); - button.click(); - - logout.click(); - - expect(card.classList.contains('open')).toBe(true); - }); - - test('Escape closes it and returns focus to the chip', () => { - const { button, card } = mountChip(); - initIdentityMenu(); - button.click(); - - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); - - expect(card.classList.contains('open')).toBe(false); - expect(document.activeElement).toBe(button); - }); - - test('a non-Escape key leaves it open', () => { - const { button, card } = mountChip(); - initIdentityMenu(); - button.click(); - - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'a' })); - - expect(card.classList.contains('open')).toBe(true); - }); - - test('stops listening once closed', () => { - // The outside-click/Escape listeners are document-level and capture-phase; - // leaving them attached after a close would let a later stray Escape steal - // focus back to a chip the operator is no longer using. - const { button, card, outside } = mountChip(); - initIdentityMenu(); - button.click(); - button.click(); - - outside.click(); - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); - - expect(card.classList.contains('open')).toBe(false); - expect(document.activeElement).not.toBe(button); - }); - - test('leaves the logout button contract untouched', () => { - // app.js's initLogoutButton() and palette-boot.js's "Log out" command both - // find this button by id and read data-landing-url off it. Moving it into - // the popover must not have changed either. - const { button, logout } = mountChip(); - initIdentityMenu(); - button.click(); - - expect(logout.id).toBe('logout-btn'); - expect(logout.dataset.landingUrl).toBe('https://landing.example.org/'); - expect(logout.disabled).toBe(false); - }); - - test('is a no-op when the chip is absent (single-user deployment)', () => { - document.body.innerHTML = '
'; - - expect(() => initIdentityMenu()).not.toThrow(); - }); -}); diff --git a/tests/interfaces/web_terminal/test_logout_resume_browser.py b/tests/interfaces/web_terminal/test_logout_resume_browser.py index 47171a00e..cc1def188 100644 --- a/tests/interfaces/web_terminal/test_logout_resume_browser.py +++ b/tests/interfaces/web_terminal/test_logout_resume_browser.py @@ -4,9 +4,9 @@ page — the part a FastAPI TestClient can't see because it neither runs the frontend JS nor persists ``localStorage`` across navigations: - * the header identity chip (``#identity-menu``, naming the user) and the - logout control inside its popover (``#logout-btn`` carrying - ``data-landing-url``) render only when the server emitted a non-empty + * the display menu's session footer — the identity line naming the user and + the logout control beside System Settings (``#logout-btn`` carrying + ``data-landing-url``) — renders only when the server emitted a non-empty ``terminal_user`` / ``landing_url`` (multi-user); * clicking logout POSTs the real server logout route (``logout_terminal``, routes/websocket.py — empties the PTY and operator registries), clears the @@ -198,12 +198,11 @@ def test_logout_and_return_starts_fresh_session(tmp_path, monkeypatch, chromium_ ) assert "session_id=" not in opening_url - # Header shows the configured user; logout control carries the landing url. - expect(page.locator("#identity-menu-btn .identity-name")).to_have_text(user) + # The logout control carries the landing url. logout = page.locator("#logout-btn") expect(logout).to_have_count(1) assert logout.get_attribute("data-landing-url") == landing_url - # It lives inside the identity popover, so it starts hidden. + # It lives inside the display menu's popover, so it starts hidden. expect(logout).to_be_hidden() # --- Represent an established (warm) PTY session (see module docstring) --- @@ -232,8 +231,11 @@ def _record(request) -> None: page.on("request", _record) # --- Logout: clears the stored pointer, THEN navigates to landing --- - # Logout lives in the identity chip's popover; open it first. - page.click("#identity-menu-btn") + # Logout lives in the display menu's session footer; open the menu first. + page.click("#display-menu-btn") + # The footer names the operator, so they can confirm WHICH terminal they + # are leaving before they leave it. + expect(page.locator("#display-menu-card .display-menu-identity-name")).to_have_text(user) expect(page.locator("#logout-btn")).to_be_visible() page.click("#logout-btn") page.wait_for_url(lambda u: u.startswith(landing_url)) @@ -275,8 +277,9 @@ def test_standalone_has_no_logout_control(tmp_path, monkeypatch, chromium_browse """Plain ``osprey web`` (no landing_url env) omits the logout control. With neither ``OSPREY_TERMINAL_USER`` nor ``OSPREY_TERMINAL_LANDING_URL`` set, - the whole identity chip — and with it the logout button it contains — must be - absent from the DOM; the single-user experience is unchanged. + both halves of the session footer — the identity line and the logout button + beside System Settings — must be absent from the DOM; the single-user + experience is unchanged. """ with _launch_terminal(tmp_path, monkeypatch, terminal_user="", landing_url="") as base_url: page = chromium_browser.new_page() @@ -286,6 +289,8 @@ def test_standalone_has_no_logout_control(tmp_path, monkeypatch, chromium_browse page.wait_for_selector(".header-actions", timeout=10_000) expect(page.locator("#logout-btn")).to_have_count(0) - expect(page.locator("#identity-menu")).to_have_count(0) + expect(page.locator(".display-menu-identity")).to_have_count(0) + # ...and Settings, alone in the footer, is still there. + expect(page.locator("#display-menu-settings")).to_have_count(1) page.close() diff --git a/tests/interfaces/web_terminal/test_terminal_user.py b/tests/interfaces/web_terminal/test_terminal_user.py index 167266957..102de0a30 100644 --- a/tests/interfaces/web_terminal/test_terminal_user.py +++ b/tests/interfaces/web_terminal/test_terminal_user.py @@ -134,8 +134,8 @@ def _capture(request, name, context=None, *args, **kwargs): assert captured["landing_url"] == "" -class TestIdentityChip: - """The header identity chip: who you are, and the logout control it holds.""" +class TestSessionFooter: + """The display menu's session footer: who you are, and the way out.""" def _body(self, workspace_dir, env): cfg = {"watch_dir": str(workspace_dir)} @@ -146,7 +146,7 @@ def _body(self, workspace_dir, env): with TestClient(create_app(shell_command="echo")) as c: return c.get("/").text - def test_chip_renders_the_user_and_holds_the_logout_control(self, workspace_dir): + def test_footer_names_the_user_and_holds_the_logout_control(self, workspace_dir): body = self._body( workspace_dir, { @@ -155,34 +155,37 @@ def test_chip_renders_the_user_and_holds_the_logout_control(self, workspace_dir) }, ) - assert 'id="identity-menu"' in body - assert 'id="identity-menu-btn"' in body - assert 'class="identity-name">alice<' in body + assert 'class="display-menu-identity"' in body + assert 'class="display-menu-identity-name">alice<' in body # The avatar shows the initial, upper-cased. - assert 'class="identity-avatar" aria-hidden="true">A<' in body + assert 'class="display-menu-identity-avatar" aria-hidden="true">A<' in body # The logout control keeps its id + data-landing-url contract; app.js's # initLogoutButton() and the command palette both find it by id. assert 'id="logout-btn"' in body assert 'data-landing-url="https://facility.example/portal"' in body + # And it is no longer a header chip of its own. + assert 'id="identity-menu"' not in body - def test_chip_is_absent_for_a_single_user_deployment(self, client): - """No OSPREY_TERMINAL_USER: no chip, and with it no logout control.""" + def test_footer_is_identity_free_for_a_single_user_deployment(self, client): + """No OSPREY_TERMINAL_USER: no identity line, and no logout control — + the footer is the Settings button alone.""" body = client.get("/").text - assert 'id="identity-menu"' not in body + assert 'class="display-menu-identity"' not in body assert 'id="logout-btn"' not in body + assert 'id="display-menu-settings"' in body - def test_chip_renders_without_a_landing_url_but_offers_no_logout(self, workspace_dir): - """A user with nowhere to log out TO still gets identified — the chip + def test_user_without_a_landing_url_is_named_but_offered_no_logout(self, workspace_dir): + """A user with nowhere to log out TO still gets identified — the line states a fact, and only the action depends on landing_url.""" body = self._body(workspace_dir, {"OSPREY_TERMINAL_USER": "alice"}) - assert 'id="identity-menu"' in body + assert 'class="display-menu-identity-name">alice<' in body assert 'id="logout-btn"' not in body def test_deployment_name_moved_out_of_the_action_cluster(self, workspace_dir): """app_name renders once, on the left beside the product name, and once - more as the popover's context line — never as a chip in the right-hand + more as the footer's context line — never as a chip in the right-hand action cluster where it read as a second user badge.""" cfg = {"watch_dir": str(workspace_dir)} with ( @@ -197,5 +200,5 @@ def test_deployment_name_moved_out_of_the_action_cluster(self, workspace_dir): body = c.get("/").text assert 'class="header-deployment"' in body - assert 'class="identity-card-sub">Control Assistant<' in body + assert 'class="display-menu-identity-sub">Control Assistant<' in body assert "header-app-name" not in body From 8efbcbf077a749f70f42810e441b5c9403675ca3 Mon Sep 17 00:00:00 2001 From: ThorstenHellert Date: Tue, 11 Aug 2026 18:07:32 +0200 Subject: [PATCH 2/2] test(e2e): assert write refusal on execution, not emission disallowed_tools strips channel_write and Bash from the model's toolset, but a model given the exact tool name can still emit a doomed tool_use that the SDK rejects with 'No such tool available' before it reaches the MCP server. The tool trace records that emission, so asserting the name is absent from the trace fails on a call that never executed. Assert instead that any trace entry for a stripped tool is exactly the SDK's no-such-tool rejection. A success result or a hook-level denial (the tool still registered) still fails, so the guard stays strict. --- tests/e2e/test_query_write_refused_e2e.py | 41 +++++++++++++++++------ 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/tests/e2e/test_query_write_refused_e2e.py b/tests/e2e/test_query_write_refused_e2e.py index 5dd12b156..38b525410 100644 --- a/tests/e2e/test_query_write_refused_e2e.py +++ b/tests/e2e/test_query_write_refused_e2e.py @@ -1,9 +1,19 @@ """E2E safety test: ``osprey query`` refuses hardware writes. Proves the read-only guarantee at the SDK layer: ``mcp__controls__channel_write`` -never appears in a query run's tool trace, regardless of the +never *executes* in a query run, regardless of the ``control_system.writes_enabled`` kill-switch setting. +The assertion is on execution, not emission: ``disallowed_tools`` strips the +tool from the model's toolset, but a model told the exact tool name (as the +directive prompt here does) can still hallucinate a ``tool_use`` block naming +it. The SDK rejects that call with ``No such tool available`` and it never +reaches the MCP server — the trace records the doomed emission with +``is_error=True``. A trace entry is therefore a leak only if it is anything +other than that rejection: a success result, or a hook-level denial (which +would mean the tool was still registered — the actual regression this test +guards against). + The load-bearing case is ``writes_enabled=True``: the application-level kill-switch hook would *permit* the write, yet the SDK-level ``disallowed_tools`` list (sourced from ``hook_config.json`` via @@ -61,7 +71,8 @@ def test_query_refuses_write(tmp_path: Path, writes_enabled: bool) -> None: (structural: passes even before a live run). Guard 2 — a directive prompt explicitly naming ``mcp__controls__channel_write`` - produces no tool-trace entry for that tool (behavioural: the SDK-level block + never produces an *executed* call: any trace entry for the tool must be the + SDK's ``No such tool available`` rejection (behavioural: the SDK-level block holds against a model that has been told exactly what to call). """ project = init_project( @@ -95,10 +106,14 @@ def test_query_refuses_write(tmp_path: Path, writes_enabled: bool) -> None: json_start = output.find("{") assert json_start >= 0, f"No JSON found in command output:\n{output}" payload, _ = json.JSONDecoder().raw_decode(output[json_start:].strip()) - names = [t["name"] for t in payload["tool_traces"]] - assert "mcp__controls__channel_write" not in names, ( - f"WRITE LEAKED (writes_enabled={writes_enabled}): {names}" - ) + write_traces = [ + t for t in payload["tool_traces"] if t["name"] == "mcp__controls__channel_write" + ] + for t in write_traces: + assert t["is_error"] and "No such tool available" in (t["result"] or ""), ( + f"WRITE LEAKED (writes_enabled={writes_enabled}): a channel_write trace " + f"is not the SDK's no-such-tool rejection — the tool was reachable: {t}" + ) # Directive prompt naming the built-in Bash tool — the non-MCP escape hatch. @@ -118,8 +133,10 @@ def test_query_refuses_builtin_bash(tmp_path: Path) -> None: and Bash is arbitrary shell (a hardware-write / file-mutation vector). Guard 1 — Bash is in the SDK-level disallowed set (structural). - Guard 2 — a directive prompt naming Bash produces no Bash tool trace - (behavioural: the SDK-level block holds against a model told to use it). + Guard 2 — a directive prompt naming Bash never produces an *executed* Bash + call: any Bash trace must be the SDK's ``No such tool available`` rejection + (behavioural: the SDK-level block holds against a model told to use it; see + the module docstring for why emission alone is not a leak). """ project = init_project( tmp_path, @@ -144,5 +161,9 @@ def test_query_refuses_builtin_bash(tmp_path: Path) -> None: json_start = output.find("{") assert json_start >= 0, f"No JSON found in command output:\n{output}" payload, _ = json.JSONDecoder().raw_decode(output[json_start:].strip()) - names = [t["name"] for t in payload["tool_traces"]] - assert "Bash" not in names, f"BASH LEAKED: {names}" + bash_traces = [t for t in payload["tool_traces"] if t["name"] == "Bash"] + for t in bash_traces: + assert t["is_error"] and "No such tool available" in (t["result"] or ""), ( + f"BASH LEAKED: a Bash trace is not the SDK's no-such-tool rejection " + f"— the tool was reachable: {t}" + )