openvibers/openvibe.extensions Commits
Companion 0.9.1: the panel's agent view reads Codes, and falls back when Live cannot be framed (#15)
Changes
@@@ -4,6 +4,19 @@ The OpenVibe Companion's releases. Each one is a `browser/manifest.json` versionrelease](https://github.com/OpenVibers/OpenVibe.Extensions/releases); older entries are in the githistory.++## 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@@@ -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/',@@@ -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",@@@ -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 };@@@ -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": {@@@ -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();@@@ -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');@@@ -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');
@@ -4,6 +4,19 @@ The OpenVibe Companion's releases. Each one is a `browser/manifest.json` version
@@ -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
release](https://github.com/OpenVibers/OpenVibe.Extensions/releases); older entries are in the git
history.
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
## 0.9.0 — 2026-11-07
- **The side panel's Dock is the current released one.** `browser/shared/` moves from OpenVibe.Shared
- **The side panel's Dock is the current released one.** `browser/shared/` moves from OpenVibe.Shared
@@ -40,7 +40,7 @@
@@ -40,7 +40,7 @@
// identity.getRedirectURL() is not one of these refuses to sign in rather than send an
// identity.getRedirectURL() is not one of these refuses to sign in rather than send an
// unregistered redirect.
// unregistered redirect.
OAUTH_CLIENT_ID: 'companion',
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([
OAUTH_REDIRECT_URIS: Object.freeze([
'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/',
'https://bjcjcfcecpkkmfndegkjekoipdmbgakd.chromiumapp.org/',
'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',
'https://bb6593a68b1dcfbb7acd9650f04a169f4c5b57a0.extensions.allizom.org/',
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
"manifest_version": 3,
"manifest_version": 3,
"name": "OpenVibe Companion",
"name": "OpenVibe Companion",
"short_name": "OpenVibe",
"short_name": "OpenVibe",
- "version": "0.9.0",
+ "version": "0.9.1",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqVJohdlaysL2HgSsj2Fsd8PxPvXJkZHjEvNyGSYNSEpZBoIeZd7Cm7xznGVikDBbe7WseaNLt+HheX4WhN94jU/FrS2016AqQG9NTHoymJR8XSrPPDyhc85EUpICOFwqfFsSOykzaRyHZVcrvoepYwntwFjWdt1ul4VozwkqKovZoKUgCn398aNvZ6AH/J0xb0xWMiOiL9CvUWN/jK4wv0ShnJevpmyBBHL273CkQvfMS3VUBvBjq3seMU8BAHkE5RI3c2y/MRdjwYGsCoMU6MPYwt46PQZ0S8SPnFFozJndkpoqc/ue9oXhd77b12GKlJ1BPhHgn8BSx4IAvjeL1wIDAQAB",
"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.",
"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",
"homepage_url": "https://github.com/OpenVibers/OpenVibe.Extensions",
@@ -22,6 +22,30 @@
@@ -22,6 +22,30 @@
const POLL_MS = 20000;
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 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 svg = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true">${p}</svg>`;
const G = {
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"/>',
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 @@
@@ -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.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-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-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 { 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));
.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; }
background: transparent; color: var(--text-secondary, #b6b6c0); font: inherit; font-size: 12px; font-weight: 700; cursor: pointer; text-decoration: none; }
@@ -78,22 +104,29 @@
@@ -78,22 +104,29 @@
styles(d);
styles(d);
const o = { ...opts };
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 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 gone = false, timer = 0, busy = false, agent = null, frameUrl = null, lastFrame = '';
+ let readOnly = false, framedFor = '', frameRefused = '', frameTimer = 0;
container.innerHTML = '';
container.innerHTML = '';
container.classList.add('ovda');
container.classList.add('ovda');
const stage = h('div', 'ovda-stage');
const stage = h('div', 'ovda-stage');
const line = h('div', 'ovda-line');
const line = h('div', 'ovda-line');
const meta = h('div', 'ovda-meta');
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 acts = h('div', 'ovda-acts');
const head = h('div');
const head = h('div');
- head.append(line, meta, acts);
+ head.append(line, meta, watch, acts);
const body = h('div');
const body = h('div');
body.append(stage, head);
body.append(stage, head);
container.append(body);
container.append(body);
async function api(path, init = {}) {
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;
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 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 r = await fetch(base + path, { ...init, headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : undefined });
const b = await r.json().catch(() => ({}));
const b = await r.json().catch(() => ({}));
@@ -127,25 +160,45 @@
@@ -127,25 +160,45 @@
if (gone) return;
if (gone) return;
const has = !!agent;
const has = !!agent;
container.classList.toggle('on', has);
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 live = agent.state === 'live';
const ended = agent.state === 'ended';
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 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.
// The line: what it is doing, one row.
@@ -168,38 +221,31 @@
@@ -168,38 +221,31 @@
meta.textContent = bits.join(' · ');
meta.textContent = bits.join(' · ');
// S5: the four controls, for the owner (the Dock is their own view, so resume is always theirs).
// 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 = '';
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.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)) {
if (agent.page_url && /^https:\/\/openvibe\.live\//.test(agent.page_url)) {
const a = h('a');
const a = h('a');
a.href = agent.page_url;
a.href = agent.page_url;
@@ -212,6 +258,27 @@
@@ -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() {
async function refresh() {
if (gone || busy) return;
if (gone || busy) return;
if (typeof o.active === 'function' && !o.active()) { schedule(); return; }
if (typeof o.active === 'function' && !o.active()) { schedule(); return; }
@@ -261,7 +328,7 @@
@@ -261,7 +328,7 @@
});
});
refresh();
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 };
root.OpenVibeDockAgent = { mount };
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-extensions",
"name": "openvibe-extensions",
- "version": "0.9.0",
+ "version": "0.9.1",
"private": true,
"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.",
"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": {
"scripts": {
@@ -30,7 +30,12 @@ function extensionId(key) {
@@ -30,7 +30,12 @@ function extensionId(key) {
}
}
const EXT_ID = extensionId(MANIFEST.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_ID = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPQ';
const AGENT = {
const AGENT = {
paused: true, paused_by: 'ada', paused_by_role: 'owner', mode: 'watch',
paused: true, paused_by: 'ada', paused_by_role: 'owner', mode: 'watch',
@@ -40,7 +45,7 @@ const AGENT = {
@@ -40,7 +45,7 @@ const AGENT = {
started_at: new Date(Date.now() - 754000).toISOString(), ended_at: null,
started_at: new Date(Date.now() - 754000).toISOString(), ended_at: null,
page_url: `https://openvibe.live/@ada/agents/${AGENT_ID}`,
page_url: `https://openvibe.live/@ada/agents/${AGENT_ID}`,
embed_url: `https://openvibe.live/embed/agent/${AGENT_ID}?vt=wvt_test_ticket`,
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 = {
const DOCK = {
state: {
state: {
@@ -85,16 +90,21 @@ const pause = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -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) });
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. */
/** 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) {
function answer(url, method) {
const u = new URL(url);
const u = new URL(url);
const p = u.pathname;
const p = u.pathname;
if (method === 'OPTIONS') return { status: 204, type: 'text/plain', body: '' };
if (method === 'OPTIONS') return { status: 204, type: 'text/plain', body: '' };
if (u.origin === 'https://openvibe.live') {
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/dock') return json(DOCK);
if (u.origin === 'https://openvibe.network' && p === '/api/v1/me/actions') return json({ items: [], counts: {} });
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') 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({});
return json({});
}
}
@@ -158,7 +168,7 @@ async function main() {
@@ -158,7 +168,7 @@ async function main() {
await page.send('Page.navigate', { url: `chrome-extension://${EXT_ID}/panel.html` });
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);
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 evaluate(page, `chrome.storage.local.set(${JSON.stringify({ dockAccount: { username: 'ada', sub: 'usr_test' } })}).then(() => true)`);
await page.send('Page.reload');
await page.send('Page.reload');
@@ -175,6 +185,8 @@ async function main() {
@@ -175,6 +185,8 @@ async function main() {
line: text(q('.ovda-line')),
line: text(q('.ovda-line')),
meta: text(q('.ovda-meta')),
meta: text(q('.ovda-meta')),
acts: [...document.querySelectorAll('.ovda-acts [data-act]')].map((b) => b.dataset.act + ':' + b.textContent.trim()),
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,
frame: (q('iframe.ovda-frame') || {}).src || null,
tab: text(q('[role="tab"][aria-selected="true"]')),
tab: text(q('[role="tab"][aria-selected="true"]')),
styles: !!document.getElementById('ovdk-styles') && !!document.getElementById('ovda-styles') && !!document.getElementById('ovac-styles'),
styles: !!document.getElementById('ovdk-styles') && !!document.getElementById('ovda-styles') && !!document.getElementById('ovac-styles'),
@@ -186,6 +198,29 @@ async function main() {
@@ -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);
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 = [];
const failures = [];
if (!seen || !seen.dock) failures.push('the panel did not mount the Dock');
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');
if (!seen || !seen.gateHidden) failures.push('the sign-in gate stayed up with a signed-in Dock');
@@ -196,13 +231,22 @@ async function main() {
@@ -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}"`);
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'];
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 && 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.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.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 (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 (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.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');
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 {
} finally {
if (page) page.close();
if (page) page.close();
proc.kill();
proc.kill();
@@ -65,7 +65,7 @@ function fetcher(responses) {
@@ -65,7 +65,7 @@ function fetcher(responses) {
assert.strictEqual(u.searchParams.get('client_id'), 'companion');
assert.strictEqual(u.searchParams.get('client_id'), 'companion');
assert.strictEqual(u.searchParams.get('redirect_uri'), CHROME);
assert.strictEqual(u.searchParams.get('redirect_uri'), CHROME);
assert.strictEqual(u.searchParams.get('response_type'), 'code');
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.strictEqual(u.searchParams.get('code_challenge_method'), 'S256');
assert.match(state, /^[A-Za-z0-9_-]{43}$/);
assert.match(state, /^[A-Za-z0-9_-]{43}$/);
issuedChallenge = u.searchParams.get('code_challenge');
issuedChallenge = u.searchParams.get('code_challenge');
@@ -54,7 +54,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
@@ -54,7 +54,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
const auth = {
const auth = {
signIns: 0,
signIns: 0,
status: async () => ({ mode, account: mode === 'oauth' ? { username: 'ada' } : null }),
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),
account: async () => (mode === 'oauth' ? { username: 'ada' } : null),
accessToken: async () => { if (mode === 'none') throw Object.assign(new Error('auth required'), { code: 'auth.required' }); return token; },
accessToken: async () => { if (mode === 'none') throw Object.assign(new Error('auth required'), { code: 'auth.required' }); return token; },
signIn: async () => { auth.signIns++; mode = 'oauth'; },
signIn: async () => { auth.signIns++; mode = 'oauth'; },
@@ -74,7 +74,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
@@ -74,7 +74,7 @@ function fakeAuth({ mode = 'oauth', token = TOKEN } = {}) {
assert.strictEqual(out.mounted, true, 'the Dock is mounted');
assert.strictEqual(out.mounted, true, 'the Dock is mounted');
assert.strictEqual(dock.opened, 1, 'and opened');
assert.strictEqual(dock.opened, 1, 'and opened');
assert.strictEqual(await dock.mounted.token(), TOKEN, "with the person's sign-in");
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.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.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');
assert.strictEqual(dock.mounted.chatBase, 'https://openvibe.chat', 'and Chat, where the Chats tab reads');