openvibers/openvibe.shared Commits
4.181.0: the Dock's agent reads through Network's frame relay (#402)
Changes
@@@ -1,5 +1,16 @@# Changelog++## 4.181.0 — 2026-11-10++++**The Dock's agent view, timeline and Actor go through Network's frame relay.** `dock-agent.js`, `dock-timeline.js`++and `dock-actor.js` asked `openvibe.codes` and `openvibe.actor` directly from every site. Every site's `connect-src`++allows `openvibe.network` and refuses those, so 49 of 49 signed-in sites logged 1-4 console errors at 1280 on++2026-11-09 (CSP on most, CORS on the sites without one). The three modules now ask Network's same-origin++`/api/frame/...` relay by default: `GET /agent-view`, its still and the four controls; `GET /agent/timeline` and its++writes; `GET /actor` and its subpaths. An explicit `base`/`agentBase`/`actorBase` (a test, or the extension's own++panel) still reaches the product itself, and nothing is asked without the person's token. The markup and styles are++unchanged. `test/dock-relay.test.js` fails if a Dock script fetches another product's origin directly again.++## 4.180.0 — 2026-11-10- **A demo scope for motion: `data-fx-demo`** (`ov-fx.js`, `ov-mark.js`, `theme-loader.js`). Inside a container@@@ -5,7 +5,7 @@**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt**Install and test:** `npm ci && npm test`--**Status:** alpha, v4.180.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed++**Status:** alpha, v4.181.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployedconsumer installs a tagged release and none keeps a vendored copy([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` namesthe tag it pins.@@@ -4,13 +4,14 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "4.180.0",++ "version": "4.181.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)","contracts": "openvibe-contracts v0.384.0 (devDependency: the release manifest contract)","sdk": "openvibe-sdk v0.133.0","features": [++ "the Dock's agent view, timeline and Actor ask Network's /api/frame relay instead of openvibe.codes and openvibe.actor, so a signed-in page logs no cross-origin console errors; an explicit base still reaches the product (4.181.0)","an agent's live panel: ov-canvas.js draws actor.canvas@1 (headings, text, lists, checklists, tables, metrics, bar and line charts, pictures from openvibe.media, https links, buttons) the same on every site; built node by node, a button only asks, a tick tells, versions only grow (4.145.0)","send it on OpenVibe: the share sheet's first place for a signed-in person is OpenVibe Chat; the Dock opens on Send to… (a conversation, or someone new by their name) and the message waits in the box until the person sends it, OpenVibeDock.share({ text, title }) (4.144.0)","one timeline for many players: ov-sync.js, OVSync.attach(mediaEl, { room, socket }), follows Chat's common.sync-clock@1 state over the room socket, measures the server clock with five time samples and holds the element within ±80 ms, nudging 0.95–1.05 or seeking, and respecting the control lease (4.143.0)",@@@ -5,8 +5,10 @@// tab opens:// OpenVibeDockActor.mount(container, { token, base, active }) → { refresh(), destroy() }//--// `base` is OpenVibe.Actor (GET /api/v1/me/actor with the person's Network Dock token, scope actor.me.read; the--// answers, pause, resume and tell are POSTs with actor.me.write). It shows:++// It reads OpenVibe.Actor's GET /api/v1/me/actor with the person's Network token (scope actor.me.read); the answers,++// pause, resume and tell are POSTs with actor.me.write. On a site every call goes through Network's same-origin relay++// (/api/frame/actor…) because every site's connect-src allows openvibe.network and refuses openvibe.actor; `base`++// reaches Actor itself in a test, and `frameBase` names the relay. Nothing is asked without a token. It shows:// - the Actor's face, name and state (Working, Waiting for you, Resting, Paused), Pause/Resume, and its model;// - Now: the step it is on in plain words, what it is looking at, the last three steps and what comes next, and the// cost so far today;@@@ -76,12 +78,18 @@let gone = false, timer = 0, busy = false;container.replaceChildren();container.classList.add('ovdac');-- const base = String(o.base || 'https://openvibe.actor').replace(/\/+$/, '');++ // Network's frame relay on a site; an explicit base reaches Actor itself (a test).++ const direct = o.base ? String(o.base).replace(/\/+$/, '') : '';++ const frameBase = String(o.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');++ const endpoint = (path) => (direct ? direct + '/api/v1/me/actor' + path : frameBase + '/actor' + path);++ // "Open" is a navigation, never a fetch: Actor's own page, wherever the API is.++ const openBase = direct || 'https://openvibe.actor';async function api(path, init = {}) {const t = typeof o.token === 'function' ? await o.token() : o.token;-- const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };-- const r = await fetch(base + '/api/v1/me/actor' + path, { method: init.method || 'GET', headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : undefined });++ if (!t) throw Object.assign(new Error('Sign in to use your Actor'), { status: 401 });++ const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), authorization: `Bearer ${t}` };++ const r = await fetch(endpoint(path), { method: init.method || 'GET', headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : undefined });const b = await r.json().catch(() => ({}));if (!r.ok) throw Object.assign(new Error(b.detail || `HTTP ${r.status}`), { status: r.status });return b;@@@ -109,7 +117,7 @@who.append(st);const pause = h('button', 'ovdac-btn', a.status === 'paused' ? 'Resume' : 'Pause'); pause.type = 'button';pause.addEventListener('click', () => act(a.status === 'paused' ? '/resume' : '/pause', {}, pause));-- const open = h('a', 'ovdac-btn', 'Open'); open.href = base + '/you'; open.target = '_blank'; open.rel = 'noopener';++ const open = h('a', 'ovdac-btn', 'Open'); open.href = openBase + '/you'; open.target = '_blank'; open.rel = 'noopener';head.append(face, who, pause, open);container.append(head);// Now: the step, what it looks at, the last three, next, today's cost.@@@ -5,8 +5,11 @@// tab opens:// OpenVibeDockAgent.mount(container, { token, base, active }) → { refresh(), destroy() }//--// `base` is OpenVibe.Codes' API (GET /api/v1/me/agent-view, the same POST for pause, Curtain now, mute and End, and--// the frame). Agent Streams S5 adds the four controls: a moderator cannot resume the owner's pause on Live, and the++// The read is OpenVibe.Codes' GET /api/v1/me/agent-view (the same POST for pause, Curtain now, mute and End, and the++// frame). It asks Network's same-origin relay (/api/frame/agent-view…) because every site's connect-src allows++// openvibe.network and refuses openvibe.codes; `frameBase` overrides the relay, and `base` reaches Codes itself in a++// test or the extension (an explicit base wins). Nothing is asked when there is no token: a signed-out page can never++// read the view. Agent Streams S5 adds the four controls: a moderator cannot resume the owner's pause on Live, and the// Dock is the owner's own view, so it may always resume.// Codes mints the view ticket server-side with the person's own token — an iframe on another site has no Live cookie —// so the frame it answers is already Live's /embed/agent/<ags_id>?vt=wvt_…: the picture, the "AI agent" label, the@@@ -79,6 +82,10 @@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; };++ // Network's frame relay on a site; an explicit base reaches Codes itself (a test, or the extension's panel).++ const direct = o.base ? String(o.base).replace(/\/+$/, '') : '';++ const frameBase = String(o.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');++ const endpoint = (path) => (direct ? direct + path : frameBase + path.replace(/^\/api\/v1\/me\/agent-view/, '/agent-view'));let gone = false, timer = 0, busy = false, agent = null, frameUrl = null, lastFrame = '';container.innerHTML = '';container.classList.add('ovda');@@@ -93,10 +100,10 @@container.append(body);async function api(path, init = {}) {-- const base = String(o.base || 'https://openvibe.codes').replace(/\/+$/, '');const t = typeof o.token === 'function' ? await o.token() : o.token;-- 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 });++ if (!t) throw Object.assign(new Error('Sign in to see your agent'), { status: 401 });++ const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), authorization: `Bearer ${t}` };++ const r = await fetch(endpoint(path), { ...init, headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : undefined });const b = await r.json().catch(() => ({}));if (!r.ok) throw Object.assign(new Error(b.detail || `HTTP ${r.status}`), { status: r.status });return b;@@@ -106,8 +113,10 @@function still(url) {if (!url || frameUrl === url) return;frameUrl = url;-- Promise.resolve(typeof o.token === 'function' ? o.token() : o.token).then((t) => fetch(url, { headers: t ? { authorization: `Bearer ${t}` } : {}, credentials: 'omit' }))-- .then((r) => (r.ok ? r.blob() : null))++ Promise.resolve(typeof o.token === 'function' ? o.token() : o.token).then((t) => (t++ ? fetch(endpoint('/api/v1/me/agent-view/frame'), { headers: { authorization: `Bearer ${t}`, accept: 'image/jpeg, image/webp' }, credentials: 'omit' })++ : null))++ .then((r) => (r && r.ok ? r.blob() : null)).then((blob) => {if (!blob || gone || frameUrl !== url) return;const next = URL.createObjectURL(blob);@@@ -4,10 +4,12 @@// OpenVibeDockTimeline.mount(container, { token, base, active, focus }) → { refresh(), destroy() }//// `token` is the person's own Network token: a string, or the function the navbar hands over (which--// resolves it when it is needed).--// `base` is OpenVibe.Actor's API: GET /api/v1/agent/timeline (the person's own steps, newest first, with the cursor--// and the mode and needs-you filters), POST /api/v1/agent/approvals/:product/:id/decide (an approval is answered in--// the mode that owns it) and POST /api/v1/agent/kill-switch (stop what is open and pause the agent).++// resolves it when it is needed). Nothing is asked without it: a signed-out page can never read the timeline.++// OpenVibe.Actor's API is GET /api/v1/agent/timeline (the person's own steps, newest first, with the cursor and the++// mode and needs-you filters), POST /api/v1/agent/approvals/:product/:id/decide (an approval is answered in the mode++// that owns it) and POST /api/v1/agent/kill-switch (stop what is open and pause the agent). On a site every call goes++// through Network's same-origin relay (/api/frame/agent…, /api/frame/tasks…) because every site's connect-src allows++// openvibe.network and refuses openvibe.actor; `base` reaches Actor itself in a test, and `frameBase` names the relay.// `focus` is the id of the step to point at (the task id a site opened the Dock with): the entry that// carries it is marked and brought into view.//@@@ -107,7 +109,10 @@function mount(container, opts = {}) {const doc = container.ownerDocument || document;styles(doc);-- const base = String(opts.base || 'https://openvibe.actor').replace(/\/+$/, '');++ // Network's frame relay on a site; an explicit base reaches Actor itself (a test).++ const direct = opts.base ? String(opts.base).replace(/\/+$/, '') : '';++ const frameBase = String(opts.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');++ const endpoint = (path) => (direct ? direct + path : frameBase + path.replace(/^\/api\/v1/, ''));const rawToken = opts.token;const active = typeof opts.active === 'function' ? opts.active : () => true;const focus = typeof opts.focus === 'function' ? opts.focus : () => opts.focus || null;@@@ -134,7 +139,8 @@async function api(path, options = {}) {const auth = await bearer();-- const res = await fetch(base + path, Object.assign({ headers: { Accept: 'application/json', ...(auth ? { Authorization: auth } : {}) } }, options));++ if (!auth) throw new Error('Sign in to see your agent');++ const res = await fetch(endpoint(path), Object.assign({ headers: { Accept: 'application/json', Authorization: auth } }, options));const body = await res.json().catch(() => ({}));if (!res.ok) throw new Error((body && (body.detail || body.title)) || ('HTTP ' + res.status));return body;@@@ -70,9 +70,13 @@const kind = () => (phone() ? 'phone' : 'desktop');const place = () => ({ ...DEFAULT_STATE.place[kind()], ...((state().place || {})[kind()] || {}) });const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);-- // K-D3: the agent stream comes from OpenVibe.Codes' API (it mints the view ticket with the person's own token).-- const agentBase = () => String(o.agentBase || 'https://openvibe.codes').replace(/\/+$/, '');-- // The one timeline behind the Dock (design 2026-11-07): OpenVibe.Actor keeps it.++ // K-D3: the agent stream is OpenVibe.Codes' (it mints the view ticket with the person's own token) and the one++ // timeline behind the Dock is OpenVibe.Actor's (design 2026-11-07). On a site both go through Network's frame++ // relay: every site's connect-src allows openvibe.network and refuses openvibe.codes and openvibe.actor. A test++ // or the extension passes agentBase/actorBase and reaches the product itself; actorBase also names the Actor's++ // pages in the run list.++ const frameBase = () => String(o.frameBase || (String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '') + '/api/frame')).replace(/\/+$/, '');++ const directBase = (v) => (v ? String(v).replace(/\/+$/, '') : '');const actorBase = () => String(o.actorBase || 'https://openvibe.actor').replace(/\/+$/, '');async function call(path, init = {}) {@@@ -106,7 +110,7 @@}// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.-- const DOCK_REV = { 'dock-vibe.js': '3dc4a69f0678', 'dock-runs.js': '8df917372f19', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '125c20e01289', 'dock-timeline.js': '3cac6118d6e9', 'dock-actor.js': '402c006ac20f', 'dock-agent.js': '8026ded2b4e5', 'dock-timer.js': 'f5d9fee28824', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };++ const DOCK_REV = { 'dock-vibe.js': '3dc4a69f0678', 'dock-runs.js': '8df917372f19', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '125c20e01289', 'dock-timeline.js': 'a73188de9bd8', 'dock-actor.js': '470b0c14e9f1', 'dock-agent.js': '8a5423641d14', 'dock-timer.js': 'f5d9fee28824', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };function load(src, test) {return new Promise((ok) => {if (test()) return ok(true);@@@ -546,14 +550,14 @@// The Agent panel: Now, Needs you and the one timeline (design 2026-11-07). It is first in the tab, so on a// phone Now and Needs you are on screen before anything else.load('dock-timeline.js', () => !!(root.OpenVibeDockTimeline && root.OpenVibeDockTimeline.mount)).then((on) => {-- if (on && !mounts[id + 'Timeline'] && panel.isConnected) mounts[id + 'Timeline'] = root.OpenVibeDockTimeline.mount(panel.querySelector('.ovdk-timeline'), { token: o.token, base: actorBase(), active: () => isOpen() && tab === 'agent', focus: () => o.task || null });++ if (on && !mounts[id + 'Timeline'] && panel.isConnected) mounts[id + 'Timeline'] = root.OpenVibeDockTimeline.mount(panel.querySelector('.ovdk-timeline'), { token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), active: () => isOpen() && tab === 'agent', focus: () => o.task || null });});// Actor v3 (A3): your Actor first, on every site; it shows nothing for a person without one.load('dock-actor.js', () => !!(root.OpenVibeDockActor && root.OpenVibeDockActor.mount)).then((on) => {-- if (on && !mounts[id + 'Actor'] && panel.isConnected) mounts[id + 'Actor'] = root.OpenVibeDockActor.mount(panel.querySelector('.ovdk-actor'), { token: o.token, base: actorBase(), active: () => isOpen() && tab === 'agent' });++ if (on && !mounts[id + 'Actor'] && panel.isConnected) mounts[id + 'Actor'] = root.OpenVibeDockActor.mount(panel.querySelector('.ovdk-actor'), { token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), active: () => isOpen() && tab === 'agent' });});load('dock-agent.js', () => !!(root.OpenVibeDockAgent && root.OpenVibeDockAgent.mount)).then((on) => {-- if (on && !mounts[id + 'View'] && panel.isConnected) mounts[id + 'View'] = root.OpenVibeDockAgent.mount(panel.querySelector('.ovdk-agent'), { token: o.token, base: agentBase(), active: () => isOpen() && tab === 'agent' });++ if (on && !mounts[id + 'View'] && panel.isConnected) mounts[id + 'View'] = root.OpenVibeDockAgent.mount(panel.querySelector('.ovdk-agent'), { token: o.token, base: directBase(o.agentBase), frameBase: frameBase(), active: () => isOpen() && tab === 'agent' });});const m = root.OpenVibeDockRuns.mount(panel.querySelector('.ovdk-scroll'), {call, state, save, scopes: o.scopes, active: () => isOpen() && tab === 'agent', actorBase: actorBase(), loadMarks: () => load('ai-marks-client.js', () => !!root.OpenVibeAIMarks),@@@ -572,7 +572,7 @@const NAV_ICONS_REV = '1091daee9c7e'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)// END generated// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.-- const SIBLING_REV = { 'ov-mark.js': 'ced6e6ecf191', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': 'f0dc83b9c816', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '44a863d7a267', 'ov-share.css': '45b92b654148', 'search.js': 'bbae7542436f', 'dock.js': '7ff462f31951', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'e06f1388b696', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': '631efe800b13', 'nav-slots.js': 'a5af310d62e0', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e' };++ const SIBLING_REV = { 'ov-mark.js': 'ced6e6ecf191', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': 'f0dc83b9c816', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '44a863d7a267', 'ov-share.css': '45b92b654148', 'search.js': 'bbae7542436f', 'dock.js': '67101584eb54', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'e06f1388b696', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': '631efe800b13', 'nav-slots.js': 'a5af310d62e0', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e' };// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.180.0",++ "version": "4.181.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.180.0",++ "version": "4.181.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.180.0",++ "version": "4.181.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {nnew file mode 100644@@@ -0,0 +1,134 @@++'use strict';++// The Dock's three agent modules on a site: with no explicit product base they ask Network's same-origin frame relay,++// never openvibe.codes or openvibe.actor (2026-11-09: every site's connect-src allows openvibe.network and refuses++// them). This runs the modules in a real headless Chrome against a stand-in for Network's relay paths:++// • dock-agent.js: GET /api/frame/agent-view, POST /api/frame/agent-view/pause, the still at /api/frame/agent-view/frame;++// • dock-timeline.js: GET /api/frame/agent/timeline with its mode filter, POST /api/frame/agent/approvals/…/decide;++// • dock-actor.js: GET /api/frame/actor, POST /api/frame/actor/tell;++// • with no token, nothing is asked at all (a signed-out page can never read them).++// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const h = require('../browser-harness');++++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {++ console.log('dock-relay-chrome: skipped (no Chrome; set CHROME_BIN)');++ process.exit(0);++}++++const ROOT = path.join(__dirname, '..');++const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0xff, 0xd9]);++const AGENT = { id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNPQ', state: 'ended', title: 'Ship it', task: null, last_frame_url: 'https://openvibe.codes/api/v1/me/agent-view/frame' };++const ACTOR = { id: 'act_1', enabled: true, name: 'Juniper', status: 'resting', brain: 'Auto', caps: {}, now: { cost_today: 0 }, asks: [] };++const TIMELINE = { now: null, needs_you: [], entries: [], next_cursor: null, counts: {} };++const calls = [];++++const page = (signedIn) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Dock relay</title>++<link rel="icon" href="data:,"></head><body style="margin:0;background:#111;color:#eee;font:14px system-ui">++<div id="a"></div><div id="t"></div><div id="c"></div>++<script src="/dock-agent.js"></script><script src="/dock-timeline.js"></script><script src="/dock-actor.js"></script>++<script>++const token = ${signedIn ? "async () => 'tok-1'" : 'null'};++OpenVibeDockAgent.mount(document.getElementById('a'), { token, frameBase: '/api/frame', active: () => true });++OpenVibeDockTimeline.mount(document.getElementById('t'), { token, frameBase: '/api/frame', active: () => true });++OpenVibeDockActor.mount(document.getElementById('c'), { token, frameBase: '/api/frame', active: () => true });++</script></body></html>`;++++const server = http.createServer((req, res) => {++ const u = new URL(req.url, 'http://x');++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(page(u.searchParams.get('in') !== '0')); }++ for (const f of ['dock-agent.js', 'dock-timeline.js', 'dock-actor.js']) {++ if (u.pathname === '/' + f) { res.writeHead(200, { 'content-type': 'text/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, f))); }++ }++ let body = '';++ req.on('data', (c) => { body += c; });++ req.on('end', () => {++ calls.push({ method: req.method, url: req.url, auth: req.headers.authorization || '', body: body ? JSON.parse(body) : null });++ if (u.pathname === '/api/frame/agent-view') {++ res.writeHead(200, { 'content-type': 'application/json' });++ return res.end(JSON.stringify({ agent: AGENT }));++ }++ if (u.pathname === '/api/frame/agent-view/frame') {++ res.writeHead(200, { 'content-type': 'image/jpeg' });++ return res.end(JPEG);++ }++ if (/^\/api\/frame\/agent-view\/(pause|curtain|mute|end)$/.test(u.pathname)) {++ res.writeHead(200, { 'content-type': 'application/json' });++ return res.end(JSON.stringify({ agent: AGENT }));++ }++ if (u.pathname === '/api/frame/agent/timeline') {++ res.writeHead(200, { 'content-type': 'application/json' });++ return res.end(JSON.stringify(TIMELINE));++ }++ if (u.pathname === '/api/frame/agent/approvals/actor/apr_1/decide') {++ res.writeHead(200, { 'content-type': 'application/json' });++ return res.end(JSON.stringify({ product: 'actor', id: 'apr_1', state: 'approved' }));++ }++ if (u.pathname === '/api/frame/actor') {++ res.writeHead(200, { 'content-type': 'application/json' });++ return res.end(JSON.stringify(ACTOR));++ }++ if (u.pathname === '/api/frame/actor/tell') {++ res.writeHead(200, { 'content-type': 'application/json' });++ return res.end('{"ok":true}');++ }++ // Anything else, including a direct /api/v1/me/... call, is a miss this test remembers.++ res.writeHead(404, { 'content-type': 'application/json' });++ return res.end('{"error":"no route"}');++ });++});++++const wait = async (pg, expr, ms = 4000) => {++ const end = Date.now() + ms;++ for (;;) {++ if (await pg.evaluate(expr)) return true;++ if (Date.now() > end) return false;++ await new Promise((ok) => setTimeout(ok, 50));++ }++};++++(async () => {++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));++ const base = `http://127.0.0.1:${server.address().port}`;++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-dockrelay-'));++ const browser = await h.launch({ tmpDir });++ try {++ const context = await browser.newContext();++ const pg = await h.openPage(browser, context, { width: 390, height: 800 });++ await pg.goto(`${base}/`, { maxMs: 8000 });++ await wait(pg, `document.querySelector('.ovda-still') && document.querySelector('.ovdac-tell input')`);++ await pg.evaluate(`document.querySelector('.ovdac-tell input').value = 'hello'`);++ await pg.evaluate(`document.querySelector('.ovdac-tell button').click()`);++ await wait(pg, `document.querySelector('.ovdac-tell input').value === ''`, 2000);++ await new Promise((ok) => setTimeout(ok, 400));++++ const paths = calls.map((c) => `${c.method} ${c.url.split('?')[0]}`);++ assert.ok(paths.includes('GET /api/frame/agent-view'), 'the agent view is read through the relay');++ assert.ok(paths.includes('GET /api/frame/agent-view/frame'), 'the still comes through the relay too');++ assert.ok(paths.includes('GET /api/frame/agent/timeline'), 'the timeline is read through the relay');++ assert.ok(paths.includes('GET /api/frame/actor'), 'the Actor block is read through the relay');++ assert.ok(paths.includes('POST /api/frame/actor/tell'), 'telling the Actor goes through the relay');++ assert.ok(calls.every((c) => c.auth === 'Bearer tok-1'), 'every call carries the person\'s token');++ assert.ok(calls.every((c) => c.url.startsWith('/api/frame/')), 'no call leaves for another product: ' + paths.join(', '));++ const timeline = calls.find((c) => c.url.startsWith('/api/frame/agent/timeline'));++ assert.ok(timeline, 'the timeline was asked');++ assert.ok(await pg.evaluate(`document.querySelector('.ovda-still') && document.querySelector('.ovda-still').src.startsWith('blob:')`), 'the still shows as the person\'s own picture');++++ // Signed out: the same three modules ask the relay nothing.++ const before = calls.length;++ const out = await h.openPage(browser, context, { width: 390, height: 800 });++ await out.goto(`${base}/?in=0`, { maxMs: 8000 });++ await new Promise((ok) => setTimeout(ok, 1200));++ assert.strictEqual(calls.length, before, 'a signed-out page asks nothing at all');++ assert.ok(!(await out.evaluate(`!!document.querySelector('.ovdac.on')`)), 'and shows no Actor');++ } finally {++ await browser.close();++ server.close();++ }++ console.log('dock relay chrome: the Dock\'s agent modules ask Network\'s /api/frame relay, and nothing without a token');++ console.log('all passed');++ process.exit(0);++})().catch((e) => { console.error(e.stack || e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++// The Dock loads on every OpenVibe site, so its scripts may not fetch another product's origin directly: each site's++// connect-src allows openvibe.network and refuses openvibe.codes and openvibe.actor (2026-11-09: 49 sites logged 1-4++// console errors at 1280). The three reads and their controls go through Network's same-origin frame relay++// (/api/frame/agent-view…, /api/frame/agent…, /api/frame/tasks…, /api/frame/actor…); an explicit base (a test, the++// extension's own panel) still reaches the product. This check fails when a Dock script takes the direct road again,++// and when one of the three modules loses either the relay default or its no-token guard.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const ROOT = path.join(__dirname, '..');++const scripts = fs.readdirSync(ROOT).filter((f) => /^dock.*\.js$/.test(f)).sort();++assert.ok(scripts.includes('dock.js') && scripts.includes('dock-agent.js') && scripts.includes('dock-timeline.js') && scripts.includes('dock-actor.js'), 'the Dock and its three agent modules are here');++const src = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8');++++// Another product's origin: anything under openvibe.* other than openvibe.network (the Frame's own host).++const OTHER = /https:\/\/openvibe\.(?!network\b)[a-z0-9-]+/i;++++/** Every fetch()'s first argument, with its parentheses and quotes balanced (so a call inside a call is read whole). */++function fetchArgs(code) {++ const out = [];++ const re = /(?:^|[^.\w$])fetch\s*\(/g;++ let m;++ while ((m = re.exec(code))) {++ let i = re.lastIndex, depth = 1, quote = null, arg = '';++ for (; i < code.length && depth > 0; i += 1) {++ const c = code[i];++ if (quote) {++ if (c === '\\') { arg += c + (code[i + 1] || ''); i += 1; continue; }++ if (c === quote) quote = null;++ } else if (c === '"' || c === "'" || c === '`') quote = c;++ else if (c === '(') depth += 1;++ else if (c === ')') { depth -= 1; if (depth === 0) break; }++ arg += c;++ }++ out.push(arg.trim());++ }++ return out;++}++++// 1. No fetch names another product's origin, as a literal or through a variable set to one.++const offenders = [];++for (const file of scripts) {++ const code = src(file);++ const args = fetchArgs(code);++ for (const arg of args) if (OTHER.test(arg)) offenders.push(`${file}: fetch(${arg.slice(0, 100)})`);++ for (const m of code.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:String\(\s*)?['"`](https:\/\/openvibe\.(?!network\b)[a-z0-9-]+)/g)) {++ if (args.some((arg) => new RegExp(`(^|[^\\w$])${m[1]}([^\\w$]|$)`).test(arg))) offenders.push(`${file}: fetch uses ${m[1]}, which is ${m[2]}`);++ }++}++assert.deepStrictEqual(offenders, [], 'the Dock must not fetch another product\'s origin directly; it goes through Network\'s /api/frame relay:\n ' + offenders.join('\n '));++++// 2. Each agent module defaults to the relay and asks nothing without the person's token.++for (const [file, guard] of [['dock-agent.js', /if \(!t\) throw/], ['dock-timeline.js', /if \(!auth\) throw/], ['dock-actor.js', /if \(!t\) throw/]]) {++ const code = src(file);++ assert.ok(/frameBase[^;]*openvibe\.network\/api\/frame/.test(code), `${file} defaults to Network's frame relay`);++ assert.ok(/endpoint = \(path\)/.test(code), `${file} builds its calls from that one endpoint`);++ assert.ok(guard.test(code), `${file} asks nothing when the person is not signed in`);++}++++// 3. The relay's paths are the ones Network serves, and the Dock hands every module the same relay.++const agent = src('dock-agent.js');++assert.ok(agent.includes("path.replace(/^\\/api\\/v1\\/me\\/agent-view/, '/agent-view')"), 'dock-agent.js maps Codes\' view to the relay');++assert.ok(agent.includes("endpoint('/api/v1/me/agent-view/frame')"), 'the still comes through the relay too');++const timeline = src('dock-timeline.js');++assert.ok(timeline.includes("/api/v1/agent/timeline"), 'the timeline still asks for its own steps');++assert.ok(timeline.includes("/api/v1/agent/approvals/") && timeline.includes('/api/v1/agent/kill-switch'), 'its two writes still go to Actor through the relay');++const actor = src('dock-actor.js');++assert.ok(actor.includes("frameBase + '/actor' + path"), 'dock-actor.js maps /actor and its subpaths to the relay');++const dock = src('dock.js');++assert.ok(/const frameBase = \(\) => String\(o\.frameBase \|\|/.test(dock), 'the Dock derives the relay from its Network base');++assert.ok(/base: directBase\(o\.actorBase\), frameBase: frameBase\(\)/.test(dock), 'the timeline and the Actor block get both bases');++assert.ok(/base: directBase\(o\.agentBase\), frameBase: frameBase\(\)/.test(dock), 'so does the agent view');++assert.ok(!/base: agentBase\(\)|base: actorBase\(\)/.test(dock), 'no module is handed a product origin as its default');++++console.log('dock relay: the Dock\'s scripts fetch Network\'s /api/frame relay, never another product directly');++console.log('all passed');
@@ -1,5 +1,16 @@
@@ -1,5 +1,16 @@
# Changelog
# Changelog
+## 4.181.0 — 2026-11-10
+
+**The Dock's agent view, timeline and Actor go through Network's frame relay.** `dock-agent.js`, `dock-timeline.js`
+and `dock-actor.js` asked `openvibe.codes` and `openvibe.actor` directly from every site. Every site's `connect-src`
+allows `openvibe.network` and refuses those, so 49 of 49 signed-in sites logged 1-4 console errors at 1280 on
+2026-11-09 (CSP on most, CORS on the sites without one). The three modules now ask Network's same-origin
+`/api/frame/...` relay by default: `GET /agent-view`, its still and the four controls; `GET /agent/timeline` and its
+writes; `GET /actor` and its subpaths. An explicit `base`/`agentBase`/`actorBase` (a test, or the extension's own
+panel) still reaches the product itself, and nothing is asked without the person's token. The markup and styles are
+unchanged. `test/dock-relay.test.js` fails if a Dock script fetches another product's origin directly again.
+
## 4.180.0 — 2026-11-10
## 4.180.0 — 2026-11-10
- **A demo scope for motion: `data-fx-demo`** (`ov-fx.js`, `ov-mark.js`, `theme-loader.js`). Inside a container
- **A demo scope for motion: `data-fx-demo`** (`ov-fx.js`, `ov-mark.js`, `theme-loader.js`). Inside a container
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Docs:** https://openvibe.services · **For AI:** https://openvibe.network/llms.txt
**Install and test:** `npm ci && npm test`
**Install and test:** `npm ci && npm test`
-**Status:** alpha, v4.180.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
+**Status:** alpha, v4.181.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
the tag it pins.
@@ -4,13 +4,14 @@
@@ -4,13 +4,14 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "4.180.0",
+ "version": "4.181.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"contracts": "openvibe-contracts v0.384.0 (devDependency: the release manifest contract)",
"contracts": "openvibe-contracts v0.384.0 (devDependency: the release manifest contract)",
"sdk": "openvibe-sdk v0.133.0",
"sdk": "openvibe-sdk v0.133.0",
"features": [
"features": [
+ "the Dock's agent view, timeline and Actor ask Network's /api/frame relay instead of openvibe.codes and openvibe.actor, so a signed-in page logs no cross-origin console errors; an explicit base still reaches the product (4.181.0)",
"an agent's live panel: ov-canvas.js draws actor.canvas@1 (headings, text, lists, checklists, tables, metrics, bar and line charts, pictures from openvibe.media, https links, buttons) the same on every site; built node by node, a button only asks, a tick tells, versions only grow (4.145.0)",
"an agent's live panel: ov-canvas.js draws actor.canvas@1 (headings, text, lists, checklists, tables, metrics, bar and line charts, pictures from openvibe.media, https links, buttons) the same on every site; built node by node, a button only asks, a tick tells, versions only grow (4.145.0)",
"send it on OpenVibe: the share sheet's first place for a signed-in person is OpenVibe Chat; the Dock opens on Send to… (a conversation, or someone new by their name) and the message waits in the box until the person sends it, OpenVibeDock.share({ text, title }) (4.144.0)",
"send it on OpenVibe: the share sheet's first place for a signed-in person is OpenVibe Chat; the Dock opens on Send to… (a conversation, or someone new by their name) and the message waits in the box until the person sends it, OpenVibeDock.share({ text, title }) (4.144.0)",
"one timeline for many players: ov-sync.js, OVSync.attach(mediaEl, { room, socket }), follows Chat's common.sync-clock@1 state over the room socket, measures the server clock with five time samples and holds the element within ±80 ms, nudging 0.95–1.05 or seeking, and respecting the control lease (4.143.0)",
"one timeline for many players: ov-sync.js, OVSync.attach(mediaEl, { room, socket }), follows Chat's common.sync-clock@1 state over the room socket, measures the server clock with five time samples and holds the element within ±80 ms, nudging 0.95–1.05 or seeking, and respecting the control lease (4.143.0)",
@@ -5,8 +5,10 @@
@@ -5,8 +5,10 @@
// tab opens:
// tab opens:
// OpenVibeDockActor.mount(container, { token, base, active }) → { refresh(), destroy() }
// OpenVibeDockActor.mount(container, { token, base, active }) → { refresh(), destroy() }
//
//
-// `base` is OpenVibe.Actor (GET /api/v1/me/actor with the person's Network Dock token, scope actor.me.read; the
-// answers, pause, resume and tell are POSTs with actor.me.write). It shows:
+// It reads OpenVibe.Actor's GET /api/v1/me/actor with the person's Network token (scope actor.me.read); the answers,
+// pause, resume and tell are POSTs with actor.me.write. On a site every call goes through Network's same-origin relay
+// (/api/frame/actor…) because every site's connect-src allows openvibe.network and refuses openvibe.actor; `base`
+// reaches Actor itself in a test, and `frameBase` names the relay. Nothing is asked without a token. It shows:
// - the Actor's face, name and state (Working, Waiting for you, Resting, Paused), Pause/Resume, and its model;
// - the Actor's face, name and state (Working, Waiting for you, Resting, Paused), Pause/Resume, and its model;
// - Now: the step it is on in plain words, what it is looking at, the last three steps and what comes next, and the
// - Now: the step it is on in plain words, what it is looking at, the last three steps and what comes next, and the
// cost so far today;
// cost so far today;
@@ -76,12 +78,18 @@
@@ -76,12 +78,18 @@
let gone = false, timer = 0, busy = false;
let gone = false, timer = 0, busy = false;
container.replaceChildren();
container.replaceChildren();
container.classList.add('ovdac');
container.classList.add('ovdac');
- const base = String(o.base || 'https://openvibe.actor').replace(/\/+$/, '');
+ // Network's frame relay on a site; an explicit base reaches Actor itself (a test).
+ const direct = o.base ? String(o.base).replace(/\/+$/, '') : '';
+ const frameBase = String(o.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');
+ const endpoint = (path) => (direct ? direct + '/api/v1/me/actor' + path : frameBase + '/actor' + path);
+ // "Open" is a navigation, never a fetch: Actor's own page, wherever the API is.
+ const openBase = direct || 'https://openvibe.actor';
async function api(path, init = {}) {
async function api(path, init = {}) {
const t = typeof o.token === 'function' ? await o.token() : o.token;
const t = typeof o.token === 'function' ? await o.token() : o.token;
- const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), ...(t ? { authorization: `Bearer ${t}` } : {}) };
- const r = await fetch(base + '/api/v1/me/actor' + path, { method: init.method || 'GET', headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : undefined });
+ if (!t) throw Object.assign(new Error('Sign in to use your Actor'), { status: 401 });
+ const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), authorization: `Bearer ${t}` };
+ const r = await fetch(endpoint(path), { method: init.method || 'GET', headers, credentials: 'omit', body: init.json ? JSON.stringify(init.body || {}) : undefined });
const b = await r.json().catch(() => ({}));
const b = await r.json().catch(() => ({}));
if (!r.ok) throw Object.assign(new Error(b.detail || `HTTP ${r.status}`), { status: r.status });
if (!r.ok) throw Object.assign(new Error(b.detail || `HTTP ${r.status}`), { status: r.status });
return b;
return b;
@@ -109,7 +117,7 @@
@@ -109,7 +117,7 @@
who.append(st);
who.append(st);
const pause = h('button', 'ovdac-btn', a.status === 'paused' ? 'Resume' : 'Pause'); pause.type = 'button';
const pause = h('button', 'ovdac-btn', a.status === 'paused' ? 'Resume' : 'Pause'); pause.type = 'button';
pause.addEventListener('click', () => act(a.status === 'paused' ? '/resume' : '/pause', {}, pause));
pause.addEventListener('click', () => act(a.status === 'paused' ? '/resume' : '/pause', {}, pause));
- const open = h('a', 'ovdac-btn', 'Open'); open.href = base + '/you'; open.target = '_blank'; open.rel = 'noopener';
+ const open = h('a', 'ovdac-btn', 'Open'); open.href = openBase + '/you'; open.target = '_blank'; open.rel = 'noopener';
head.append(face, who, pause, open);
head.append(face, who, pause, open);
container.append(head);
container.append(head);
// Now: the step, what it looks at, the last three, next, today's cost.
// Now: the step, what it looks at, the last three, next, today's cost.
@@ -5,8 +5,11 @@
@@ -5,8 +5,11 @@
// tab opens:
// tab opens:
// OpenVibeDockAgent.mount(container, { token, base, active }) → { refresh(), destroy() }
// OpenVibeDockAgent.mount(container, { token, base, active }) → { refresh(), destroy() }
//
//
-// `base` is OpenVibe.Codes' API (GET /api/v1/me/agent-view, the same POST for pause, Curtain now, mute and End, and
-// the frame). Agent Streams S5 adds the four controls: a moderator cannot resume the owner's pause on Live, and the
+// The read is OpenVibe.Codes' GET /api/v1/me/agent-view (the same POST for pause, Curtain now, mute and End, and the
+// frame). It asks Network's same-origin relay (/api/frame/agent-view…) because every site's connect-src allows
+// openvibe.network and refuses openvibe.codes; `frameBase` overrides the relay, and `base` reaches Codes itself in a
+// test or the extension (an explicit base wins). Nothing is asked when there is no token: a signed-out page can never
+// read the view. Agent Streams S5 adds the four controls: a moderator cannot resume the owner's pause on Live, and the
// Dock is the owner's own view, so it may always resume.
// Dock is the owner's own view, so it may always resume.
// Codes mints the view ticket server-side with the person's own token — an iframe on another site has no Live cookie —
// Codes mints the view ticket server-side with the person's own token — an iframe on another site has no Live cookie —
// so the frame it answers is already Live's /embed/agent/<ags_id>?vt=wvt_…: the picture, the "AI agent" label, the
// so the frame it answers is already Live's /embed/agent/<ags_id>?vt=wvt_…: the picture, the "AI agent" label, the
@@ -79,6 +82,10 @@
@@ -79,6 +82,10 @@
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; };
+ // Network's frame relay on a site; an explicit base reaches Codes itself (a test, or the extension's panel).
+ const direct = o.base ? String(o.base).replace(/\/+$/, '') : '';
+ const frameBase = String(o.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');
+ const endpoint = (path) => (direct ? direct + path : frameBase + path.replace(/^\/api\/v1\/me\/agent-view/, '/agent-view'));
let gone = false, timer = 0, busy = false, agent = null, frameUrl = null, lastFrame = '';
let gone = false, timer = 0, busy = false, agent = null, frameUrl = null, lastFrame = '';
container.innerHTML = '';
container.innerHTML = '';
container.classList.add('ovda');
container.classList.add('ovda');
@@ -93,10 +100,10 @@
@@ -93,10 +100,10 @@
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 t = typeof o.token === 'function' ? await o.token() : o.token;
const t = typeof o.token === 'function' ? await o.token() : o.token;
- 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 });
+ if (!t) throw Object.assign(new Error('Sign in to see your agent'), { status: 401 });
+ const headers = { accept: 'application/json', ...(init.json ? { 'content-type': 'application/json' } : {}), authorization: `Bearer ${t}` };
+ const r = await fetch(endpoint(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(() => ({}));
if (!r.ok) throw Object.assign(new Error(b.detail || `HTTP ${r.status}`), { status: r.status });
if (!r.ok) throw Object.assign(new Error(b.detail || `HTTP ${r.status}`), { status: r.status });
return b;
return b;
@@ -106,8 +113,10 @@
@@ -106,8 +113,10 @@
function still(url) {
function still(url) {
if (!url || frameUrl === url) return;
if (!url || frameUrl === url) return;
frameUrl = url;
frameUrl = url;
- Promise.resolve(typeof o.token === 'function' ? o.token() : o.token).then((t) => fetch(url, { headers: t ? { authorization: `Bearer ${t}` } : {}, credentials: 'omit' }))
- .then((r) => (r.ok ? r.blob() : null))
+ Promise.resolve(typeof o.token === 'function' ? o.token() : o.token).then((t) => (t
+ ? fetch(endpoint('/api/v1/me/agent-view/frame'), { headers: { authorization: `Bearer ${t}`, accept: 'image/jpeg, image/webp' }, credentials: 'omit' })
+ : null))
+ .then((r) => (r && r.ok ? r.blob() : null))
.then((blob) => {
.then((blob) => {
if (!blob || gone || frameUrl !== url) return;
if (!blob || gone || frameUrl !== url) return;
const next = URL.createObjectURL(blob);
const next = URL.createObjectURL(blob);
@@ -4,10 +4,12 @@
@@ -4,10 +4,12 @@
// OpenVibeDockTimeline.mount(container, { token, base, active, focus }) → { refresh(), destroy() }
// OpenVibeDockTimeline.mount(container, { token, base, active, focus }) → { refresh(), destroy() }
//
//
// `token` is the person's own Network token: a string, or the function the navbar hands over (which
// `token` is the person's own Network token: a string, or the function the navbar hands over (which
-// resolves it when it is needed).
-// `base` is OpenVibe.Actor's API: GET /api/v1/agent/timeline (the person's own steps, newest first, with the cursor
-// and the mode and needs-you filters), POST /api/v1/agent/approvals/:product/:id/decide (an approval is answered in
-// the mode that owns it) and POST /api/v1/agent/kill-switch (stop what is open and pause the agent).
+// resolves it when it is needed). Nothing is asked without it: a signed-out page can never read the timeline.
+// OpenVibe.Actor's API is GET /api/v1/agent/timeline (the person's own steps, newest first, with the cursor and the
+// mode and needs-you filters), POST /api/v1/agent/approvals/:product/:id/decide (an approval is answered in the mode
+// that owns it) and POST /api/v1/agent/kill-switch (stop what is open and pause the agent). On a site every call goes
+// through Network's same-origin relay (/api/frame/agent…, /api/frame/tasks…) because every site's connect-src allows
+// openvibe.network and refuses openvibe.actor; `base` reaches Actor itself in a test, and `frameBase` names the relay.
// `focus` is the id of the step to point at (the task id a site opened the Dock with): the entry that
// `focus` is the id of the step to point at (the task id a site opened the Dock with): the entry that
// carries it is marked and brought into view.
// carries it is marked and brought into view.
//
//
@@ -107,7 +109,10 @@
@@ -107,7 +109,10 @@
function mount(container, opts = {}) {
function mount(container, opts = {}) {
const doc = container.ownerDocument || document;
const doc = container.ownerDocument || document;
styles(doc);
styles(doc);
- const base = String(opts.base || 'https://openvibe.actor').replace(/\/+$/, '');
+ // Network's frame relay on a site; an explicit base reaches Actor itself (a test).
+ const direct = opts.base ? String(opts.base).replace(/\/+$/, '') : '';
+ const frameBase = String(opts.frameBase || 'https://openvibe.network/api/frame').replace(/\/+$/, '');
+ const endpoint = (path) => (direct ? direct + path : frameBase + path.replace(/^\/api\/v1/, ''));
const rawToken = opts.token;
const rawToken = opts.token;
const active = typeof opts.active === 'function' ? opts.active : () => true;
const active = typeof opts.active === 'function' ? opts.active : () => true;
const focus = typeof opts.focus === 'function' ? opts.focus : () => opts.focus || null;
const focus = typeof opts.focus === 'function' ? opts.focus : () => opts.focus || null;
@@ -134,7 +139,8 @@
@@ -134,7 +139,8 @@
async function api(path, options = {}) {
async function api(path, options = {}) {
const auth = await bearer();
const auth = await bearer();
- const res = await fetch(base + path, Object.assign({ headers: { Accept: 'application/json', ...(auth ? { Authorization: auth } : {}) } }, options));
+ if (!auth) throw new Error('Sign in to see your agent');
+ const res = await fetch(endpoint(path), Object.assign({ headers: { Accept: 'application/json', Authorization: auth } }, options));
const body = await res.json().catch(() => ({}));
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((body && (body.detail || body.title)) || ('HTTP ' + res.status));
if (!res.ok) throw new Error((body && (body.detail || body.title)) || ('HTTP ' + res.status));
return body;
return body;
@@ -70,9 +70,13 @@
@@ -70,9 +70,13 @@
const kind = () => (phone() ? 'phone' : 'desktop');
const kind = () => (phone() ? 'phone' : 'desktop');
const place = () => ({ ...DEFAULT_STATE.place[kind()], ...((state().place || {})[kind()] || {}) });
const place = () => ({ ...DEFAULT_STATE.place[kind()], ...((state().place || {})[kind()] || {}) });
const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);
const sibling = (name) => (typeof o.sibling === 'function' ? o.sibling(name) : `https://openvibe.network/shared/${name}`);
- // K-D3: the agent stream comes from OpenVibe.Codes' API (it mints the view ticket with the person's own token).
- const agentBase = () => String(o.agentBase || 'https://openvibe.codes').replace(/\/+$/, '');
- // The one timeline behind the Dock (design 2026-11-07): OpenVibe.Actor keeps it.
+ // K-D3: the agent stream is OpenVibe.Codes' (it mints the view ticket with the person's own token) and the one
+ // timeline behind the Dock is OpenVibe.Actor's (design 2026-11-07). On a site both go through Network's frame
+ // relay: every site's connect-src allows openvibe.network and refuses openvibe.codes and openvibe.actor. A test
+ // or the extension passes agentBase/actorBase and reaches the product itself; actorBase also names the Actor's
+ // pages in the run list.
+ const frameBase = () => String(o.frameBase || (String(o.apiBase || 'https://openvibe.network').replace(/\/+$/, '') + '/api/frame')).replace(/\/+$/, '');
+ const directBase = (v) => (v ? String(v).replace(/\/+$/, '') : '');
const actorBase = () => String(o.actorBase || 'https://openvibe.actor').replace(/\/+$/, '');
const actorBase = () => String(o.actorBase || 'https://openvibe.actor').replace(/\/+$/, '');
async function call(path, init = {}) {
async function call(path, init = {}) {
@@ -106,7 +110,7 @@
@@ -106,7 +110,7 @@
}
}
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
// Each sibling's content hash (scripts/sibling-revs.js writes it), so a release gives each a new URL.
- const DOCK_REV = { 'dock-vibe.js': '3dc4a69f0678', 'dock-runs.js': '8df917372f19', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '125c20e01289', 'dock-timeline.js': '3cac6118d6e9', 'dock-actor.js': '402c006ac20f', 'dock-agent.js': '8026ded2b4e5', 'dock-timer.js': 'f5d9fee28824', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };
+ const DOCK_REV = { 'dock-vibe.js': '3dc4a69f0678', 'dock-runs.js': '8df917372f19', 'ai-marks-client.js': 'f4a880a9f206', 'action-center.js': '125c20e01289', 'dock-timeline.js': 'a73188de9bd8', 'dock-actor.js': '470b0c14e9f1', 'dock-agent.js': '8a5423641d14', 'dock-timer.js': 'f5d9fee28824', 'dock-share.js': 'e83b2e27b3ef', 'dock-card.js': '2643c20b0016' };
function load(src, test) {
function load(src, test) {
return new Promise((ok) => {
return new Promise((ok) => {
if (test()) return ok(true);
if (test()) return ok(true);
@@ -546,14 +550,14 @@
@@ -546,14 +550,14 @@
// The Agent panel: Now, Needs you and the one timeline (design 2026-11-07). It is first in the tab, so on a
// The Agent panel: Now, Needs you and the one timeline (design 2026-11-07). It is first in the tab, so on a
// phone Now and Needs you are on screen before anything else.
// phone Now and Needs you are on screen before anything else.
load('dock-timeline.js', () => !!(root.OpenVibeDockTimeline && root.OpenVibeDockTimeline.mount)).then((on) => {
load('dock-timeline.js', () => !!(root.OpenVibeDockTimeline && root.OpenVibeDockTimeline.mount)).then((on) => {
- if (on && !mounts[id + 'Timeline'] && panel.isConnected) mounts[id + 'Timeline'] = root.OpenVibeDockTimeline.mount(panel.querySelector('.ovdk-timeline'), { token: o.token, base: actorBase(), active: () => isOpen() && tab === 'agent', focus: () => o.task || null });
+ if (on && !mounts[id + 'Timeline'] && panel.isConnected) mounts[id + 'Timeline'] = root.OpenVibeDockTimeline.mount(panel.querySelector('.ovdk-timeline'), { token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), active: () => isOpen() && tab === 'agent', focus: () => o.task || null });
});
});
// Actor v3 (A3): your Actor first, on every site; it shows nothing for a person without one.
// Actor v3 (A3): your Actor first, on every site; it shows nothing for a person without one.
load('dock-actor.js', () => !!(root.OpenVibeDockActor && root.OpenVibeDockActor.mount)).then((on) => {
load('dock-actor.js', () => !!(root.OpenVibeDockActor && root.OpenVibeDockActor.mount)).then((on) => {
- if (on && !mounts[id + 'Actor'] && panel.isConnected) mounts[id + 'Actor'] = root.OpenVibeDockActor.mount(panel.querySelector('.ovdk-actor'), { token: o.token, base: actorBase(), active: () => isOpen() && tab === 'agent' });
+ if (on && !mounts[id + 'Actor'] && panel.isConnected) mounts[id + 'Actor'] = root.OpenVibeDockActor.mount(panel.querySelector('.ovdk-actor'), { token: o.token, base: directBase(o.actorBase), frameBase: frameBase(), active: () => isOpen() && tab === 'agent' });
});
});
load('dock-agent.js', () => !!(root.OpenVibeDockAgent && root.OpenVibeDockAgent.mount)).then((on) => {
load('dock-agent.js', () => !!(root.OpenVibeDockAgent && root.OpenVibeDockAgent.mount)).then((on) => {
- if (on && !mounts[id + 'View'] && panel.isConnected) mounts[id + 'View'] = root.OpenVibeDockAgent.mount(panel.querySelector('.ovdk-agent'), { token: o.token, base: agentBase(), active: () => isOpen() && tab === 'agent' });
+ if (on && !mounts[id + 'View'] && panel.isConnected) mounts[id + 'View'] = root.OpenVibeDockAgent.mount(panel.querySelector('.ovdk-agent'), { token: o.token, base: directBase(o.agentBase), frameBase: frameBase(), active: () => isOpen() && tab === 'agent' });
});
});
const m = root.OpenVibeDockRuns.mount(panel.querySelector('.ovdk-scroll'), {
const m = root.OpenVibeDockRuns.mount(panel.querySelector('.ovdk-scroll'), {
call, state, save, scopes: o.scopes, active: () => isOpen() && tab === 'agent', actorBase: actorBase(), loadMarks: () => load('ai-marks-client.js', () => !!root.OpenVibeAIMarks),
call, state, save, scopes: o.scopes, active: () => isOpen() && tab === 'agent', actorBase: actorBase(), loadMarks: () => load('ai-marks-client.js', () => !!root.OpenVibeAIMarks),
@@ -572,7 +572,7 @@
@@ -572,7 +572,7 @@
const NAV_ICONS_REV = '1091daee9c7e'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
const NAV_ICONS_REV = '1091daee9c7e'; // sha256(nav-icons.js), first 12 hex: its ?v= (cacheable forever)
// END generated
// END generated
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
// The files loaded from beside this one, by content (scripts/sibling-revs.js): a release changes their URLs.
- const SIBLING_REV = { 'ov-mark.js': 'ced6e6ecf191', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': 'f0dc83b9c816', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '44a863d7a267', 'ov-share.css': '45b92b654148', 'search.js': 'bbae7542436f', 'dock.js': '7ff462f31951', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'e06f1388b696', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': '631efe800b13', 'nav-slots.js': 'a5af310d62e0', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e' };
+ const SIBLING_REV = { 'ov-mark.js': 'ced6e6ecf191', 'ov-glyphs.js': '6f8c8dd2a10d', 'ov-brand.woff2': '3b4e6a8a5878', 'ov-look.js': '2d3a47d3535c', 'ov-fx.js': 'f0dc83b9c816', 'ov-labels.js': 'a30b178391f5', 'ov-forms.js': '20f4ef41d127', 'ov-forms.css': '98cf0f28bb68', 'ov-share.js': '44a863d7a267', 'ov-share.css': '45b92b654148', 'search.js': 'bbae7542436f', 'dock.js': '67101584eb54', 'presence.js': '1b675c37d888', 'panels.js': 'd90bfe987d81', 'surfaces.js': 'e72f5a304f2d', 'notification-ui.js': 'e06f1388b696', 'history.js': '1e6598f78ba4', 'site-switcher.js': '292bea111aa8', 'appbar.js': '1edf7532f228', 'release-watch.js': '25f02a3f0684', 'ov-icons.js': 'ba3981246bc0', 'sso-client.js': '52439265b980', 'overlay.js': 'd18308ab8a63', 'ov-home.js': '631efe800b13', 'nav-slots.js': 'a5af310d62e0', 'ov-wallet.js': '5c4d7fdb448d', 'ov-wallet.css': 'e46f653fa59e' };
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// The navbar's icons are filled paths (no Font Awesome CSS needed): the ones above ship here, the rest
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// (nav-icons.js) load after first paint when a link, chip or menu row names one, into an empty <svg> of their
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
// size. Anything unknown, or nav-icons.js failing, falls back to <i class="fa-solid">.
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.180.0",
+ "version": "4.181.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.180.0",
+ "version": "4.181.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.180.0",
+ "version": "4.181.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"bin": {
"bin": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,134 @@
@@ -0,0 +1,134 @@
+'use strict';
+// The Dock's three agent modules on a site: with no explicit product base they ask Network's same-origin frame relay,
+// never openvibe.codes or openvibe.actor (2026-11-09: every site's connect-src allows openvibe.network and refuses
+// them). This runs the modules in a real headless Chrome against a stand-in for Network's relay paths:
+// • dock-agent.js: GET /api/frame/agent-view, POST /api/frame/agent-view/pause, the still at /api/frame/agent-view/frame;
+// • dock-timeline.js: GET /api/frame/agent/timeline with its mode filter, POST /api/frame/agent/approvals/…/decide;
+// • dock-actor.js: GET /api/frame/actor, POST /api/frame/actor/tell;
+// • with no token, nothing is asked at all (a signed-out page can never read them).
+// Skipped (exit 0) without Chrome or with OV_SKIP_BROWSER=1.
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const h = require('../browser-harness');
+
+if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
+ console.log('dock-relay-chrome: skipped (no Chrome; set CHROME_BIN)');
+ process.exit(0);
+}
+
+const ROOT = path.join(__dirname, '..');
+const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0xff, 0xd9]);
+const AGENT = { id: 'ags_01JAB2C3D4E5F6G7H8J9K0MNPQ', state: 'ended', title: 'Ship it', task: null, last_frame_url: 'https://openvibe.codes/api/v1/me/agent-view/frame' };
+const ACTOR = { id: 'act_1', enabled: true, name: 'Juniper', status: 'resting', brain: 'Auto', caps: {}, now: { cost_today: 0 }, asks: [] };
+const TIMELINE = { now: null, needs_you: [], entries: [], next_cursor: null, counts: {} };
+const calls = [];
+
+const page = (signedIn) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Dock relay</title>
+<link rel="icon" href="data:,"></head><body style="margin:0;background:#111;color:#eee;font:14px system-ui">
+<div id="a"></div><div id="t"></div><div id="c"></div>
+<script src="/dock-agent.js"></script><script src="/dock-timeline.js"></script><script src="/dock-actor.js"></script>
+<script>
+const token = ${signedIn ? "async () => 'tok-1'" : 'null'};
+OpenVibeDockAgent.mount(document.getElementById('a'), { token, frameBase: '/api/frame', active: () => true });
+OpenVibeDockTimeline.mount(document.getElementById('t'), { token, frameBase: '/api/frame', active: () => true });
+OpenVibeDockActor.mount(document.getElementById('c'), { token, frameBase: '/api/frame', active: () => true });
+</script></body></html>`;
+
+const server = http.createServer((req, res) => {
+ const u = new URL(req.url, 'http://x');
+ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html' }); return res.end(page(u.searchParams.get('in') !== '0')); }
+ for (const f of ['dock-agent.js', 'dock-timeline.js', 'dock-actor.js']) {
+ if (u.pathname === '/' + f) { res.writeHead(200, { 'content-type': 'text/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, f))); }
+ }
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ calls.push({ method: req.method, url: req.url, auth: req.headers.authorization || '', body: body ? JSON.parse(body) : null });
+ if (u.pathname === '/api/frame/agent-view') {
+ res.writeHead(200, { 'content-type': 'application/json' });
+ return res.end(JSON.stringify({ agent: AGENT }));
+ }
+ if (u.pathname === '/api/frame/agent-view/frame') {
+ res.writeHead(200, { 'content-type': 'image/jpeg' });
+ return res.end(JPEG);
+ }
+ if (/^\/api\/frame\/agent-view\/(pause|curtain|mute|end)$/.test(u.pathname)) {
+ res.writeHead(200, { 'content-type': 'application/json' });
+ return res.end(JSON.stringify({ agent: AGENT }));
+ }
+ if (u.pathname === '/api/frame/agent/timeline') {
+ res.writeHead(200, { 'content-type': 'application/json' });
+ return res.end(JSON.stringify(TIMELINE));
+ }
+ if (u.pathname === '/api/frame/agent/approvals/actor/apr_1/decide') {
+ res.writeHead(200, { 'content-type': 'application/json' });
+ return res.end(JSON.stringify({ product: 'actor', id: 'apr_1', state: 'approved' }));
+ }
+ if (u.pathname === '/api/frame/actor') {
+ res.writeHead(200, { 'content-type': 'application/json' });
+ return res.end(JSON.stringify(ACTOR));
+ }
+ if (u.pathname === '/api/frame/actor/tell') {
+ res.writeHead(200, { 'content-type': 'application/json' });
+ return res.end('{"ok":true}');
+ }
+ // Anything else, including a direct /api/v1/me/... call, is a miss this test remembers.
+ res.writeHead(404, { 'content-type': 'application/json' });
+ return res.end('{"error":"no route"}');
+ });
+});
+
+const wait = async (pg, expr, ms = 4000) => {
+ const end = Date.now() + ms;
+ for (;;) {
+ if (await pg.evaluate(expr)) return true;
+ if (Date.now() > end) return false;
+ await new Promise((ok) => setTimeout(ok, 50));
+ }
+};
+
+(async () => {
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-dockrelay-'));
+ const browser = await h.launch({ tmpDir });
+ try {
+ const context = await browser.newContext();
+ const pg = await h.openPage(browser, context, { width: 390, height: 800 });
+ await pg.goto(`${base}/`, { maxMs: 8000 });
+ await wait(pg, `document.querySelector('.ovda-still') && document.querySelector('.ovdac-tell input')`);
+ await pg.evaluate(`document.querySelector('.ovdac-tell input').value = 'hello'`);
+ await pg.evaluate(`document.querySelector('.ovdac-tell button').click()`);
+ await wait(pg, `document.querySelector('.ovdac-tell input').value === ''`, 2000);
+ await new Promise((ok) => setTimeout(ok, 400));
+
+ const paths = calls.map((c) => `${c.method} ${c.url.split('?')[0]}`);
+ assert.ok(paths.includes('GET /api/frame/agent-view'), 'the agent view is read through the relay');
+ assert.ok(paths.includes('GET /api/frame/agent-view/frame'), 'the still comes through the relay too');
+ assert.ok(paths.includes('GET /api/frame/agent/timeline'), 'the timeline is read through the relay');
+ assert.ok(paths.includes('GET /api/frame/actor'), 'the Actor block is read through the relay');
+ assert.ok(paths.includes('POST /api/frame/actor/tell'), 'telling the Actor goes through the relay');
+ assert.ok(calls.every((c) => c.auth === 'Bearer tok-1'), 'every call carries the person\'s token');
+ assert.ok(calls.every((c) => c.url.startsWith('/api/frame/')), 'no call leaves for another product: ' + paths.join(', '));
+ const timeline = calls.find((c) => c.url.startsWith('/api/frame/agent/timeline'));
+ assert.ok(timeline, 'the timeline was asked');
+ assert.ok(await pg.evaluate(`document.querySelector('.ovda-still') && document.querySelector('.ovda-still').src.startsWith('blob:')`), 'the still shows as the person\'s own picture');
+
+ // Signed out: the same three modules ask the relay nothing.
+ const before = calls.length;
+ const out = await h.openPage(browser, context, { width: 390, height: 800 });
+ await out.goto(`${base}/?in=0`, { maxMs: 8000 });
+ await new Promise((ok) => setTimeout(ok, 1200));
+ assert.strictEqual(calls.length, before, 'a signed-out page asks nothing at all');
+ assert.ok(!(await out.evaluate(`!!document.querySelector('.ovdac.on')`)), 'and shows no Actor');
+ } finally {
+ await browser.close();
+ server.close();
+ }
+ console.log('dock relay chrome: the Dock\'s agent modules ask Network\'s /api/frame relay, and nothing without a token');
+ console.log('all passed');
+ process.exit(0);
+})().catch((e) => { console.error(e.stack || e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+// The Dock loads on every OpenVibe site, so its scripts may not fetch another product's origin directly: each site's
+// connect-src allows openvibe.network and refuses openvibe.codes and openvibe.actor (2026-11-09: 49 sites logged 1-4
+// console errors at 1280). The three reads and their controls go through Network's same-origin frame relay
+// (/api/frame/agent-view…, /api/frame/agent…, /api/frame/tasks…, /api/frame/actor…); an explicit base (a test, the
+// extension's own panel) still reaches the product. This check fails when a Dock script takes the direct road again,
+// and when one of the three modules loses either the relay default or its no-token guard.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.join(__dirname, '..');
+const scripts = fs.readdirSync(ROOT).filter((f) => /^dock.*\.js$/.test(f)).sort();
+assert.ok(scripts.includes('dock.js') && scripts.includes('dock-agent.js') && scripts.includes('dock-timeline.js') && scripts.includes('dock-actor.js'), 'the Dock and its three agent modules are here');
+const src = (f) => fs.readFileSync(path.join(ROOT, f), 'utf8');
+
+// Another product's origin: anything under openvibe.* other than openvibe.network (the Frame's own host).
+const OTHER = /https:\/\/openvibe\.(?!network\b)[a-z0-9-]+/i;
+
+/** Every fetch()'s first argument, with its parentheses and quotes balanced (so a call inside a call is read whole). */
+function fetchArgs(code) {
+ const out = [];
+ const re = /(?:^|[^.\w$])fetch\s*\(/g;
+ let m;
+ while ((m = re.exec(code))) {
+ let i = re.lastIndex, depth = 1, quote = null, arg = '';
+ for (; i < code.length && depth > 0; i += 1) {
+ const c = code[i];
+ if (quote) {
+ if (c === '\\') { arg += c + (code[i + 1] || ''); i += 1; continue; }
+ if (c === quote) quote = null;
+ } else if (c === '"' || c === "'" || c === '`') quote = c;
+ else if (c === '(') depth += 1;
+ else if (c === ')') { depth -= 1; if (depth === 0) break; }
+ arg += c;
+ }
+ out.push(arg.trim());
+ }
+ return out;
+}
+
+// 1. No fetch names another product's origin, as a literal or through a variable set to one.
+const offenders = [];
+for (const file of scripts) {
+ const code = src(file);
+ const args = fetchArgs(code);
+ for (const arg of args) if (OTHER.test(arg)) offenders.push(`${file}: fetch(${arg.slice(0, 100)})`);
+ for (const m of code.matchAll(/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:String\(\s*)?['"`](https:\/\/openvibe\.(?!network\b)[a-z0-9-]+)/g)) {
+ if (args.some((arg) => new RegExp(`(^|[^\\w$])${m[1]}([^\\w$]|$)`).test(arg))) offenders.push(`${file}: fetch uses ${m[1]}, which is ${m[2]}`);
+ }
+}
+assert.deepStrictEqual(offenders, [], 'the Dock must not fetch another product\'s origin directly; it goes through Network\'s /api/frame relay:\n ' + offenders.join('\n '));
+
+// 2. Each agent module defaults to the relay and asks nothing without the person's token.
+for (const [file, guard] of [['dock-agent.js', /if \(!t\) throw/], ['dock-timeline.js', /if \(!auth\) throw/], ['dock-actor.js', /if \(!t\) throw/]]) {
+ const code = src(file);
+ assert.ok(/frameBase[^;]*openvibe\.network\/api\/frame/.test(code), `${file} defaults to Network's frame relay`);
+ assert.ok(/endpoint = \(path\)/.test(code), `${file} builds its calls from that one endpoint`);
+ assert.ok(guard.test(code), `${file} asks nothing when the person is not signed in`);
+}
+
+// 3. The relay's paths are the ones Network serves, and the Dock hands every module the same relay.
+const agent = src('dock-agent.js');
+assert.ok(agent.includes("path.replace(/^\\/api\\/v1\\/me\\/agent-view/, '/agent-view')"), 'dock-agent.js maps Codes\' view to the relay');
+assert.ok(agent.includes("endpoint('/api/v1/me/agent-view/frame')"), 'the still comes through the relay too');
+const timeline = src('dock-timeline.js');
+assert.ok(timeline.includes("/api/v1/agent/timeline"), 'the timeline still asks for its own steps');
+assert.ok(timeline.includes("/api/v1/agent/approvals/") && timeline.includes('/api/v1/agent/kill-switch'), 'its two writes still go to Actor through the relay');
+const actor = src('dock-actor.js');
+assert.ok(actor.includes("frameBase + '/actor' + path"), 'dock-actor.js maps /actor and its subpaths to the relay');
+const dock = src('dock.js');
+assert.ok(/const frameBase = \(\) => String\(o\.frameBase \|\|/.test(dock), 'the Dock derives the relay from its Network base');
+assert.ok(/base: directBase\(o\.actorBase\), frameBase: frameBase\(\)/.test(dock), 'the timeline and the Actor block get both bases');
+assert.ok(/base: directBase\(o\.agentBase\), frameBase: frameBase\(\)/.test(dock), 'so does the agent view');
+assert.ok(!/base: agentBase\(\)|base: actorBase\(\)/.test(dock), 'no module is handed a product origin as its default');
+
+console.log('dock relay: the Dock\'s scripts fetch Network\'s /api/frame relay, never another product directly');
+console.log('all passed');