openvibers/openvibe.extensions Commits

Companion 0.9.1: the panel's agent view reads Codes, and falls back when Live cannot be framed (#15)

OP OpenVibers committed 6 days ago · a2ccd18d57 · 8 files, +183 −59

The side panel holds the Dock token, so its four agent controls are read-only:
they are links to openvibe.codes, where the person's own session does the same
four things. The OAuth grant now asks for `agent.view` alongside dock, activity
and chat.read.

A `frame-ancestors` refusal is invisible to the embedding page, so the Live embed
announces itself with one empty postMessage. If no hello arrives, or the browser
is Firefox (whose moz-extension id changes per install and cannot be on an
allow-list), the panel hides the frame, shows the still it already draws, and
adds a plain "Watch on openvibe.live" link. A rendered embed keeps the frame.

scripts/check-panel.js now runs the panel twice in headless Chrome: once with an
embed that says hello, and once with one that stays silent. It checks the four
controls are links to Codes, and that a refused frame is replaced by the still
and the link (never an empty frame). test/auth.test.js and test/panel.test.js
carry the new scope.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 70be100..1da62c3 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,19 @@ The OpenVibe Companion's releases. Each one is a `browser/manifest.json` version
release](https://github.com/OpenVibers/OpenVibe.Extensions/releases); older entries are in the git
history.
++## 0.9.1 — 2026-11-07
++
++- **The side panel's agent view can read.** OpenVibe.Codes' two agent-view read routes now accept
++ the Companion's Dock token (Network's private `<network>/dock` issuer and keys, the new
++ `agent.view` scope only); the panel's four controls stay read-only and open openvibe.codes.
++ OpenVibe.Live's embed lists an extension origin when the owner sets
++ `AGENT_EMBED_EXTENSION_ORIGINS`.
++- When Live's frame is refused (Firefox, whose extension id changes per install, or Chrome before the
++ owner sets the id), the panel shows the still and a plain **Watch on openvibe.live** link instead of
++ an empty frame. A rendered embed announces itself (`postMessage`, carrying nothing), so a refused
++ frame is told apart from a loaded one.
++- The OAuth grant asks for `agent.view` alongside `dock`, `activity` and `chat.read`.
++
## 0.9.0 — 2026-11-07
- **The side panel's Dock is the current released one.** `browser/shared/` moves from OpenVibe.Shared
ddiff --git a/browser/lib/config.js b/browser/lib/config.js
iindex 815d3f0..591d010 100644
---- a/browser/lib/config.js
++++ b/browser/lib/config.js
@@@ -40,7 +40,7 @@
// identity.getRedirectURL() is not one of these refuses to sign in rather than send an
// unregistered redirect.
OAUTH_CLIENT_ID: 'companion',
-- OAUTH_SCOPES: Object.freeze(['dock', 'activity', 'chat.read']),
++ OAUTH_SCOPES: Object.freeze(['dock', 'activity', 'chat.read', 'agent.view']),
OAUTH_REDIRECT_URIS: Object.freeze([
'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/',
'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',
ddiff --git a/browser/manifest.json b/browser/manifest.json
iindex 1bf7b06..11f4288 100644
---- a/browser/manifest.json
++++ b/browser/manifest.json
@@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "OpenVibe Companion",
"short_name": "OpenVibe",
-- "version": "0.9.0",
++ "version": "0.9.1",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqVJohdlaysL2HgSsj2Fsd8PxPvXJkZHjEvNyGSYNSEpZBoIeZd7Cm7xznGVikDBbe7WseaNLt+HheX4WhN94jU/FrS2016AqQG9NTHoymJR8XSrPPDyhc85EUpICOFwqfFsSOykzaRyHZVcrvoepYwntwFjWdt1ul4VozwkqKovZoKUgCn398aNvZ6AH/J0xb0xWMiOiL9CvUWN/jK4wv0ShnJevpmyBBHL273CkQvfMS3VUBvBjq3seMU8BAHkE5RI3c2y/MRdjwYGsCoMU6MPYwt46PQZ0S8SPnFFozJndkpoqc/ue9oXhd77b12GKlJ1BPhHgn8BSx4IAvjeL1wIDAQAB",
"description": "Coupon codes for the shop you are on, tried in its coupon box when you ask; the same restaurant on every delivery app; a review site's rating saved to OpenVibe.Reviews; and one click to keep a rental listing on your OpenVibe.Rent board or watch a page. The OpenVibe Dock sits in the side panel, on every site. Reads the tab only when you open it.",
"homepage_url": "https://github.com/OpenVibers/OpenVibe.Extensions",
ddiff --git a/browser/shared/dock-agent.js b/browser/shared/dock-agent.js
iindex 5dac3ad..3ffb3c2 100644
---- a/browser/shared/dock-agent.js
++++ b/browser/shared/dock-agent.js
@@@ -22,6 +22,30 @@
const POLL_MS = 20000;
const EMBED = /^https:\/\/openvibe\.live\/embed\/agent\/ags_[0-9A-HJKMNP-TV-Z]{26}\?vt=wvt_[A-Za-z0-9_-]+$/;
++ const CODES = 'https://openvibe.codes';
++ // How long the embed gets to announce itself before the panel decides the frame is refused (a CSP
++ // `frame-ancestors` refusal shows an error page, which the parent cannot read; a rendered embed says hello).
++ const FRAME_MS = 3000;
++ /** The token's payload, never verified: only the panel's own behaviour is chosen from it (Codes is the judge). */
++ function payloadOf(token) {
++ const parts = String(token || '').split('.');
++ if (parts.length !== 3) return null;
++ try {
++ const b = parts[1].replace(/-/g, '+').replace(/_/g, '/');
++ const c = JSON.parse(root.atob(b + '='.repeat((4 - b.length % 4) % 4)));
++ return c && typeof c === 'object' ? c : null;
++ } catch { return null; }
++ }
++ /** True for the extension's Dock token: Network's `/dock` issuer, client companion, the agent.view scope. */
++ function readOnlyToken(token) {
++ const c = payloadOf(token);
++ if (!c || c.client_id !== 'companion' || !/\/dock$/.test(String(c.iss || ''))) return false;
++ return String(c.scope || '').split(/\s+/).filter(Boolean).includes('agent.view');
++ }
++ /** The browser's own scheme, for the one case no allow-list can name: a Firefox extension's changing id. */
++ function extensionScheme() {
++ try { return String((root.location && root.location.protocol) || ''); } catch { return ''; }
++ }
const svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;
const G = {
eye: '<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12z"/><circle cx="12" cy="12" r="3"/><path d="M4 4l16 16"/>',
@@@ -54,6 +78,8 @@
.ovda-line .dot.live { background: var(--live-red, #e5484d); animation: ovda-pulse 1.6s ease-in-out infinite; }
.ovda-line .dot.off { background: var(--text-muted, #8b8b96); }
.ovda-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 11.5px; color: var(--text-muted, #8b8b96); }
++ .ovda-watch { align-self: flex-start; font-size: 12.5px; color: var(--accent, #3b82f6); text-decoration: underline; min-height: 30px; display: inline-flex; align-items: center; }
++ .ovda-watch[hidden] { display: none; }
.ovda-acts { display: flex; gap: 6px; margin-left: auto; }
.ovda-acts button, .ovda-acts a { display: inline-flex; align-items: center; gap: 5px; min-height: 30px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.12));
background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 12px; font-weight: 700; cursor: pointer; text-decoration: none; }
@@@ -78,22 +104,29 @@
styles(d);
const o = { ...opts };
const h = (tag, cls, text) => { const n = d.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
++ const codesBase = String(o.base || CODES).replace(/\/+$/, '');
let gone = false, timer = 0, busy = false, agent = null, frameUrl = null, lastFrame = '';
++ let readOnly = false, framedFor = '', frameRefused = '', frameTimer = 0;
container.innerHTML = '';
container.classList.add('ovda');
const stage = h('div', 'ovda-stage');
const line = h('div', 'ovda-line');
const meta = h('div', 'ovda-meta');
++ const watch = h('a', 'ovda-watch', 'Watch on openvibe.live');
++ watch.target = '_blank';
++ watch.rel = 'noopener';
++ watch.hidden = true;
const acts = h('div', 'ovda-acts');
const head = h('div');
-- head.append(line, meta, acts);
++ head.append(line, meta, watch, acts);
const body = h('div');
body.append(stage, head);
container.append(body);
async function api(path, init = {}) {
-- const base = String(o.base || 'https://openvibe.codes').replace(/\/+$/, '');
++ const base = String(o.base || CODES).replace(/\/+$/, '');
const t = typeof o.token === 'function' ? await o.token() : o.token;
++ readOnly = readOnlyToken(t);
const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
const r = await fetch(base + path, { ...init, headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : undefined });
const b = await r.json().catch(() => ({}));
@@@ -127,25 +160,45 @@
if (gone) return;
const has = !!agent;
container.classList.toggle('on', has);
-- if (!has) { stage.innerHTML = ''; line.innerHTML = ''; meta.textContent = ''; acts.innerHTML = ''; return; }
++ if (!has) { stage.innerHTML = ''; line.innerHTML = ''; meta.textContent = ''; acts.innerHTML = ''; watch.hidden = true; return; }
const live = agent.state === 'live';
const ended = agent.state === 'ended';
-- // The picture: the live embed when there is one, else the still.
++ // The picture: the live embed when there is one and it may be framed, else the still and a plain link.
++ // A refused frame looks loaded to its parent (the browser shows its own error page), so the embed
++ // announces itself (Live's agent-embed.js); no hello in time means frame-ancestors kept it out, and the
++ // still is the honest view. A Firefox extension's moz-extension id changes per install and no allow-list
++ // can name it, so it is never tried.
const embed = typeof agent.embed_url === 'string' && EMBED.test(agent.embed_url) ? agent.embed_url : null;
-- const shown = stage.querySelector('iframe');
-- if (embed && (!shown || shown.getAttribute('src') !== embed)) {
-- stage.innerHTML = '';
-- const f = h('iframe', 'ovda-frame');
-- f.src = embed;
-- f.title = 'Your agent\'s screen';
-- f.setAttribute('allow', 'autoplay; fullscreen; picture-in-picture');
-- f.setAttribute('referrerpolicy', 'no-referrer');
-- stage.append(f);
-- } else if (!embed) {
-- if (shown) stage.innerHTML = '';
-- if (agent.last_frame_url) still(agent.last_frame_url);
-- else if (!stage.querySelector('img')) stage.append(h('div', 'ovda-wait', ended ? 'Nothing to show' : 'Waiting for the desk'));
++ const blocked = !!embed && (frameRefused === agent.id || extensionScheme() === 'moz-extension:');
++ const shown = stage.querySelector('iframe.ovda-frame');
++ if (embed && !blocked) {
++ watch.hidden = true;
++ if (!shown || shown.getAttribute('src') !== embed) {
++ stage.innerHTML = '';
++ frameUrl = null;
++ const f = h('iframe', 'ovda-frame');
++ f.src = embed;
++ f.title = 'Your agent\'s screen';
++ f.setAttribute('allow', 'autoplay; fullscreen; picture-in-picture');
++ f.setAttribute('referrerpolicy', 'no-referrer');
++ stage.append(f);
++ armFrame(f);
++ } else {
++ shown.style.display = '';
++ }
++ } else {
++ if (shown) shown.style.display = 'none';
++ if (agent.last_frame_url) {
++ if (!stage.querySelector('img.ovda-still')) frameUrl = null;
++ still(agent.last_frame_url);
++ } else if (!stage.querySelector('img.ovda-still') && !stage.querySelector('.ovda-wait')) {
++ stage.append(h('div', 'ovda-wait', ended ? 'Nothing to show' : 'Waiting for the desk'));
++ }
++ // The plain way to the stream, when this browser cannot frame it. It is a real link, not a control.
++ const page = typeof agent.page_url === 'string' && /^https:\/\/openvibe\.live\//.test(agent.page_url) ? agent.page_url : null;
++ if (page) watch.href = page;
++ watch.hidden = !blocked || !page;
}
// The line: what it is doing, one row.
@@@ -168,38 +221,31 @@
meta.textContent = bits.join(' · ');
// S5: the four controls, for the owner (the Dock is their own view, so resume is always theirs).
++ // The side panel holds the Dock's read-only token, so there the buttons are links to Codes, where the
++ // person's own session does the same four things.
acts.innerHTML = '';
-- if (!ended) {
-- const pause = h('button', null);
-- pause.type = 'button';
-- pause.dataset.act = 'pause';
-- pause.innerHTML = `${svg(agent.paused ? G.play : G.pause)}${agent.paused ? 'Resume' : 'Pause'}`;
-- pause.title = agent.paused && agent.paused_by ? `Paused by @${agent.paused_by}` : 'Pause the agent at the next safe point';
-- acts.append(pause);
-- }
-- if (live && !(agent.curtain && agent.curtain.up)) {
-- const b = h('button', null);
-- b.type = 'button';
-- b.dataset.act = 'curtain';
-- b.innerHTML = `${svg(G.eye)}Curtain now`;
-- b.title = 'Hide the screen now; your desk lifts it when it is clear';
-- acts.append(b);
-- }
-- if (!ended) {
-- const mute = h('button', null);
-- mute.type = 'button';
-- mute.dataset.act = 'mute';
-- mute.innerHTML = `${svg(agent.voice_muted ? G.sound : G.mute)}${agent.voice_muted ? 'Unmute' : 'Mute'}`;
-- mute.title = agent.voice_muted ? 'Let the agent speak again' : 'Mute the agent\'s voice';
-- acts.append(mute);
-- }
-- if (!ended) {
-- const b = h('button', null);
++ const control = (act, label, title) => {
++ if (readOnly) {
++ const a = h('a');
++ a.href = codesBase;
++ a.target = '_blank';
++ a.rel = 'noopener';
++ a.dataset.act = act;
++ a.innerHTML = label;
++ a.title = `${title} (opens openvibe.codes)`;
++ return a;
++ }
++ const b = h('button');
b.type = 'button';
-- b.dataset.act = 'end';
-- b.innerHTML = `${svg(G.stop)}End`;
-- acts.append(b);
-- }
++ b.dataset.act = act;
++ b.innerHTML = label;
++ b.title = title;
++ return b;
++ };
++ if (!ended) acts.append(control('pause', `${svg(agent.paused ? G.play : G.pause)}${agent.paused ? 'Resume' : 'Pause'}`, agent.paused && agent.paused_by ? `Paused by @${agent.paused_by}` : 'Pause the agent at the next safe point'));
++ if (live && !(agent.curtain && agent.curtain.up)) acts.append(control('curtain', `${svg(G.eye)}Curtain now`, 'Hide the screen now; your desk lifts it when it is clear'));
++ if (!ended) acts.append(control('mute', `${svg(agent.voice_muted ? G.sound : G.mute)}${agent.voice_muted ? 'Unmute' : 'Mute'}`, agent.voice_muted ? 'Let the agent speak again' : 'Mute the agent\'s voice'));
++ if (!ended) acts.append(control('end', `${svg(G.stop)}End`, 'End the agent stream'));
if (agent.page_url && /^https:\/\/openvibe\.live\//.test(agent.page_url)) {
const a = h('a');
a.href = agent.page_url;
@@@ -212,6 +258,27 @@
}
}
++ /** The embed must say hello before FRAME_MS, or the still takes its place (see paint above). */
++ function armFrame(f) {
++ clearTimeout(frameTimer);
++ const url = f.getAttribute('src');
++ if (framedFor === url) return;
++ frameTimer = setTimeout(() => {
++ if (gone || !agent || framedFor === url) return;
++ frameRefused = agent.id;
++ paint();
++ }, FRAME_MS);
++ }
++ function onFrameHello(e) {
++ if (!e || !e.data || e.data.type !== 'ov-agent-embed' || !agent) return;
++ const f = stage.querySelector('iframe.ovda-frame');
++ if (!f || e.source !== f.contentWindow) return;
++ clearTimeout(frameTimer);
++ framedFor = f.getAttribute('src') || framedFor;
++ if (frameRefused) { frameRefused = ''; paint(); }
++ }
++ root.addEventListener('message', onFrameHello);
++
async function refresh() {
if (gone || busy) return;
if (typeof o.active === 'function' && !o.active()) { schedule(); return; }
@@@ -261,7 +328,7 @@
});
refresh();
-- return { refresh, destroy() { gone = true; clearTimeout(timer); if (lastFrame) URL.revokeObjectURL(lastFrame); container.innerHTML = ''; container.classList.remove('ovda'); } };
++ return { refresh, destroy() { gone = true; clearTimeout(timer); clearTimeout(frameTimer); root.removeEventListener('message', onFrameHello); if (lastFrame) URL.revokeObjectURL(lastFrame); container.innerHTML = ''; container.classList.remove('ovda'); } };
}
root.OpenVibeDockAgent = { mount };
ddiff --git a/package.json b/package.json
iindex a8c48bc..dbcbfae 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-extensions",
-- "version": "0.9.0",
++ "version": "0.9.1",
"private": true,
"description": "OpenVibe's first-party client surfaces (ADR-023). Today: the OpenVibe Companion, a Manifest V3 browser extension for Chromium and Firefox: coupon codes from OpenVibe.Coupons, and one click to keep a listing on the OpenVibe.Rent board or watch a page with OpenVibe.Watch.",
"scripts": {
ddiff --git a/scripts/check-panel.js b/scripts/check-panel.js
iindex 1393322..550e26a 100644
---- a/scripts/check-panel.js
++++ b/scripts/check-panel.js
@@@ -30,7 +30,12 @@ function extensionId(key) {
}
const EXT_ID = extensionId(MANIFEST.key);
--const TOKEN = 'test.' + Buffer.from(JSON.stringify({ sub: 'usr_test', username: 'ada' })).toString('base64url') + '.signature';
++/** The Companion's Dock token: private issuer, client companion, the read-only scopes (unverified, as the panel reads it). */
++const TOKEN = `${Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url')}.${Buffer.from(JSON.stringify({
++ iss: 'https://openvibe.network/dock', sub: 'usr_test', username: 'ada', client_id: 'companion',
++ scope: 'dock activity chat.read agent.view', fam: 'fam_0123456789abcdef01234567',
++})).toString('base64url')}.signature`;
++const FRAME = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0xff, 0xd9]);
const AGENT_ID = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPQ';
const AGENT = {
paused: true, paused_by: 'ada', paused_by_role: 'owner', mode: 'watch',
@@@ -40,7 +45,7 @@ const AGENT = {
started_at: new Date(Date.now() - 754000).toISOString(), ended_at: null,
page_url: `https://openvibe.live/@ada/agents/${AGENT_ID}`,
embed_url: `https://openvibe.live/embed/agent/${AGENT_ID}?vt=wvt_test_ticket`,
-- last_frame_url: null, voice_muted: true,
++ last_frame_url: 'https://openvibe.codes/api/v1/me/agent-view/frame', voice_muted: true,
};
const DOCK = {
state: {
@@@ -85,16 +90,21 @@ const pause = (ms) => new Promise((r) => setTimeout(r, ms));
const json = (v) => ({ status: 200, type: 'application/json', body: JSON.stringify(v) });
/** What the stubbed first parties answer. A route this file does not know gets an empty object, never a refusal. */
++let refuseFrame = false;
function answer(url, method) {
const u = new URL(url);
const p = u.pathname;
if (method === 'OPTIONS') return { status: 204, type: 'text/plain', body: '' };
if (u.origin === 'https://openvibe.live') {
-- return { status: 200, type: 'text/html', body: '<!doctype html><html><body><p id="stub-embed">embed</p></body></html>' };
++ // A rendered embed announces itself to its parent (Live's agent-embed.js). With refuseFrame the page is
++ // served without that hello: what a browser shows a parent when `frame-ancestors` keeps the embed out.
++ const hello = refuseFrame ? '' : '<script>parent.postMessage({type:"ov-agent-embed",v:1},"*")</script>';
++ return { status: 200, type: 'text/html', body: `<!doctype html><html><body><p id="stub-embed">embed</p>${hello}</body></html>` };
}
if (u.origin === 'https://openvibe.network' && p === '/api/v1/me/dock') return json(DOCK);
if (u.origin === 'https://openvibe.network' && p === '/api/v1/me/actions') return json({ items: [], counts: {} });
if (u.origin === 'https://openvibe.codes' && p === '/api/v1/me/agent-view') return json({ agent: AGENT });
++ if (u.origin === 'https://openvibe.codes' && p === '/api/v1/me/agent-view/frame') return { status: 200, type: 'image/jpeg', body: FRAME };
return json({});
}
@@@ -158,7 +168,7 @@ async function main() {
await page.send('Page.navigate', { url: `chrome-extension://${EXT_ID}/panel.html` });
for (let i = 0; i < 100 && (await evaluate(page, 'document.readyState')) !== 'complete'; i++) await pause(50);
-- await evaluate(page, `chrome.storage.session.set(${JSON.stringify({ dockAccess: { token: TOKEN, expires_at: Date.now() + 3600000, scope: 'dock activity chat.read', username: 'ada' } })}).then(() => true)`);
++ await evaluate(page, `chrome.storage.session.set(${JSON.stringify({ dockAccess: { token: TOKEN, expires_at: Date.now() + 3600000, scope: 'dock activity chat.read agent.view', username: 'ada' } })}).then(() => true)`);
await evaluate(page, `chrome.storage.local.set(${JSON.stringify({ dockAccount: { username: 'ada', sub: 'usr_test' } })}).then(() => true)`);
await page.send('Page.reload');
@@@ -175,6 +185,8 @@ async function main() {
line: text(q('.ovda-line')),
meta: text(q('.ovda-meta')),
acts: [...document.querySelectorAll('.ovda-acts [data-act]')].map((b) => b.dataset.act + ':' + b.textContent.trim()),
++ actLinks: [...document.querySelectorAll('.ovda-acts a[data-act]')].map((a) => a.getAttribute('href')),
++ watch: (() => { const w = q('a.ovda-watch'); return w ? { hidden: w.hidden, text: w.textContent.trim() } : null; })(),
frame: (q('iframe.ovda-frame') || {}).src || null,
tab: text(q('[role="tab"][aria-selected="true"]')),
styles: !!document.getElementById('ovdk-styles') && !!document.getElementById('ovda-styles') && !!document.getElementById('ovac-styles'),
@@@ -186,6 +198,29 @@ async function main() {
for (let i = 0; i < 20 && !(paused.some((u) => u.startsWith('https://openvibe.live/embed/agent/')) && paused.some((u) => u.startsWith('https://openvibe.codes/api/v1/me/agent-view'))); i++) await pause(100);
++ // A frame the browser refuses: the embed never says hello, so the panel shows the still and the plain link
++ // instead of an empty frame, and the four controls are links to Codes (the panel's token is read-only).
++ refuseFrame = true;
++ await page.send('Page.reload');
++ let fallback = null;
++ for (let i = 0; i < 100; i++) {
++ await pause(100);
++ fallback = await evaluate(page, `(() => {
++ const q = (s) => document.querySelector(s);
++ const f = q('iframe.ovda-frame');
++ const w = q('a.ovda-watch');
++ const s = q('img.ovda-still');
++ return {
++ agent: !!q('.ovda.on'),
++ frame: f ? f.src : null,
++ frameShown: f ? getComputedStyle(f).display !== 'none' : false,
++ watch: w ? { hidden: w.hidden, text: w.textContent.trim(), href: w.href } : null,
++ still: s ? s.src : null,
++ };
++ })()`);
++ if (fallback && fallback.agent && fallback.watch && !fallback.watch.hidden && fallback.still) break;
++ }
++
const failures = [];
if (!seen || !seen.dock) failures.push('the panel did not mount the Dock');
if (!seen || !seen.gateHidden) failures.push('the sign-in gate stayed up with a signed-in Dock');
@@@ -196,13 +231,22 @@ async function main() {
if (seen && !/2 watching/.test(seen.meta || '')) failures.push(`the meta does not say how many watch: "${seen.meta}"`);
const wantActs = ['pause:Resume', 'curtain:Curtain now', 'mute:Unmute', 'end:End'];
if (seen && JSON.stringify(seen.acts) !== JSON.stringify(wantActs)) failures.push(`the four controls are ${JSON.stringify(seen.acts)}`);
++ if (seen && seen.actLinks.length !== 4) failures.push(`the read-only controls are not links: ${JSON.stringify(seen.actLinks)}`);
++ if (seen && seen.actLinks.some((href) => href !== 'https://openvibe.codes')) failures.push(`a control does not open Codes: ${JSON.stringify(seen.actLinks)}`);
if (seen && seen.frame !== AGENT.embed_url) failures.push(`the stream frame is "${seen.frame}"`);
if (seen && !seen.styles) failures.push("the Dock's own styles are missing");
if (seen && seen.lineDisplay !== 'flex') failures.push(`the agent's styles did not apply (line display ${seen.lineDisplay})`);
if (security.length) failures.push(`CSP refused: ${security.join(' | ')}`);
if (!paused.some((u) => u.startsWith('https://openvibe.live/embed/agent/'))) failures.push('the panel never asked for the agent embed');
if (!paused.some((u) => u.startsWith('https://openvibe.codes/api/v1/me/agent-view'))) failures.push('the panel never asked Codes for the agent view');
-- return { failures, staged: paused.length, seen };
++ // The refused frame: the still, the plain link, and no empty frame.
++ if (!fallback || !fallback.agent) failures.push('the panel did not draw the agent view with a refused frame');
++ if (fallback && fallback.frameShown) failures.push('a refused frame stayed on screen');
++ if (fallback && (!fallback.watch || fallback.watch.hidden)) failures.push('a refused frame has no "Watch on openvibe.live" link');
++ else if (fallback && fallback.watch.text !== 'Watch on openvibe.live') failures.push(`the watch link says "${fallback.watch.text}"`);
++ else if (fallback && fallback.watch.href !== AGENT.page_url) failures.push(`the watch link opens "${fallback.watch.href}"`);
++ if (fallback && !fallback.still) failures.push('a refused frame shows no still');
++ return { failures, staged: paused.length, seen, fallback };
} finally {
if (page) page.close();
proc.kill();
ddiff --git a/test/auth.test.js b/test/auth.test.js
iindex d4bccb8..a232f00 100644
---- a/test/auth.test.js
++++ b/test/auth.test.js
@@@ -65,7 +65,7 @@ function fetcher(responses) {
assert.strictEqual(u.searchParams.get('client_id'), 'companion');
assert.strictEqual(u.searchParams.get('redirect_uri'), CHROME);
assert.strictEqual(u.searchParams.get('response_type'), 'code');
-- assert.strictEqual(u.searchParams.get('scope'), 'dock activity chat.read');
++ assert.strictEqual(u.searchParams.get('scope'), 'dock activity chat.read agent.view');
assert.strictEqual(u.searchParams.get('code_challenge_method'), 'S256');
assert.match(state, /^[A-Za-z0-9_-]{43}$/);
issuedChallenge = u.searchParams.get('code_challenge');
ddiff --git a/test/panel.test.js b/test/panel.test.js
iindex b495bb4..a69ee30 100644
---- a/test/panel.test.js
++++ b/test/panel.test.js
@@@ -54,7 +54,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
const auth = {
signIns: 0,
status: async () => ({ mode, account: mode === 'oauth' ? { username: 'ada' } : null }),
-- scopes: async () => (mode === 'oauth' ? ['dock', 'activity', 'chat.read'] : null),
++ scopes: async () => (mode === 'oauth' ? ['dock', 'activity', 'chat.read', 'agent.view'] : null),
account: async () => (mode === 'oauth' ? { username: 'ada' } : null),
accessToken: async () => { if (mode === 'none') throw Object.assign(new Error('auth required'), { code: 'auth.required' }); return token; },
signIn: async () => { auth.signIns++; mode = 'oauth'; },
@@@ -74,7 +74,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
assert.strictEqual(out.mounted, true, 'the Dock is mounted');
assert.strictEqual(dock.opened, 1, 'and opened');
assert.strictEqual(await dock.mounted.token(), TOKEN, "with the person's sign-in");
-- assert.deepStrictEqual(dock.mounted.scopes, ['dock', 'activity', 'chat.read'], 'read-only scopes');
++ assert.deepStrictEqual(dock.mounted.scopes, ['dock', 'activity', 'chat.read', 'agent.view'], 'read-only scopes');
assert.strictEqual(dock.mounted.me, 'ada', 'the account name');
assert.strictEqual(dock.mounted.apiBase, 'https://openvibe.network', "the Network, where the Dock's state is");
assert.strictEqual(dock.mounted.chatBase, 'https://openvibe.chat', 'and Chat, where the Chats tab reads');