openvibers/openvibe.surf Commits
Surf's card: the Computer's still, and Take over / Give it back through one API (#99)
Changes
@@@ -2,6 +2,20 @@What changed in OpenVibe.Surf, newest first. Each site also publishes its patch notes at /updates.++## Unreleased — one Computer++++- **The task card shows the Computer** (plan 2026-11-07: "Surf, Computer and Actor are one experience"): the card++ reads the task's `computer` (`session_id`, `machine`, `where`, `cost_hint`, `driving`) and puts the machine and the++ cost where it says where the work happens. While the task runs it shows the Computer's newest frame as an++ `<img class="ov-task-still">`, refreshed every 2 s only while the page is visible, stopping when the task ends or++ the tab is hidden. It is a same-origin proxy (`GET /api/v1/web/tasks/:id/still`) so the person's token stays in its++ cookie; Actor answers `404` for another person's task, and the picture is `Cache-Control: private, no-store`.++- **Take over / Give it back** are now one API on the task: the card's button posts to Actor (`/takeover`,++ `/giveback`) through this site (`POST /api/v1/web/tasks/:id/takeover` and `…/giveback`, same-origin only). Take over++ goes to the desk's own page; Give it back returns to the card, which then says so. No new layout: the existing++ Take over control and one `.ov-task-still` size rule.++- The Dock's step carries the same `computer` (server/web/agent-step.js).++## Unreleased — watch together- **Home Watch scene** (`server/views/index.js`, `server/http/pages.js`): the home no longer shows the written-in@@@ -259,7 +259,7 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by[OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.312.0++- openvibe-contracts: v0.327.0- openvibe-sdk: v0.94.0--- openvibe-shared: v4.135.0++- openvibe-shared: v4.136.0<!-- versions:end -->@@@ -8,13 +8,13 @@"runtime": "node 22, express 4, openvibe-shared/shell; no database, no Valkey, no Events; watch parties are in memory and end on restart","port": "5110","domain": "openvibe.surf",-- "contracts": "openvibe-contracts v0.312.0",++ "contracts": "openvibe-contracts v0.327.0","sdk": "openvibe-sdk v0.94.0",-- "shared": "openvibe-shared v4.135.0",++ "shared": "openvibe-shared v4.136.0","packages": [-- "openvibe-contracts v0.312.0",++ "openvibe-contracts v0.327.0","openvibe-sdk v0.94.0",-- "openvibe-shared v4.135.0"++ "openvibe-shared v4.136.0"],"summary": "openvibe.surf, the Surf room: Network sign-in as OAuth client `surf`; Ride (OpenVibe.Games' surf maps, cached 60 s, embedded); Watch (a lean-back TV over OpenVibe.Live's live streams, clips and AI moments, clips and moments playing in the page over HLS) plus watch together (in-memory SSE parties); Web (Surf with Vibe: one OpenVibe.Actor task made with the person's own Network token). No database.","works": [@@@ -14,9 +14,9 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0"++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.136.0"},"engines": {"node": ">=22.0.0"@@@ -1059,9 +1059,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.312.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",-- "integrity": "sha512-2GsD53zuIedL3eW2LBlYQ68ODTHzoZJHrAmVnZCpiL7ZWnAXGwPW9dpQagLI9Zdkh3LQuuU5ae1YJI0K+acXbA==",++ "version": "0.327.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",++ "integrity": "sha512-LssZZeTdg9OkPPpry6YSEVhbIv13MjLYOlNxpoZ/0bpIATy0zfJrzfLAaJyUY+UMq5Tqf1FLzx2a/3xcJ7UJDw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1103,9 +1103,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.135.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0",-- "integrity": "sha512-+v59lQjCjkb8poKNyKOKfTr8SoFMyr/4dKMoIpPgxqvn8MDFYpylvI80U3AvSxeJTqZpthhrBBdkgVlok/M3dg==",++ "version": "4.136.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.136.0",++ "integrity": "sha512-r/pfiONg/RJWozZFcxc9f+pQPhGcLKY0cHyteuk8i07l9qR/eNGdQI/JpLHYJ/U4N5nqh41W0D1t3CSq/i3tFA==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -20,9 +20,9 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0"++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.136.0"},"engines": {"node": ">=22.0.0"@@@ -220,6 +220,9 @@ html.ov-motion .sf-party-playing .sf-party-dot { animation: sf-pulse 1.6s ease-o/* Surf with Vibe: the task page (/web/t/:id): state, desk links, live steps as a timeline, sources, the report */.sf-desk-page { display: grid; gap: 14px; }++/* The Computer's live still (plan 2026-11-07): size and aspect only; Opus designs the rest. */++.ov-task-still { display: block; width: 100%; max-width: 320px; aspect-ratio: 16 / 9; object-fit: cover; }++.ov-task-still[hidden] { display: none; }.sf-desk-head h1 { margin-bottom: 6px; }.sf-desk-query { margin: 0; font-size: 1.05rem; line-height: 1.5; color: var(--text-primary, #e6edf7); overflow-wrap: anywhere; }.sf-desk-state { display: inline-flex; align-items: center; gap: 8px; margin: 12px 0 0; padding: 5px 12px 5px 10px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; background: var(--bg-secondary, #101828); font-size: .8rem; font-weight: 800; letter-spacing: .03em; text-transform: capitalize; }@@@ -366,12 +366,58 @@var WORDS = { queued: 'Getting ready', running: 'Working', verifying: 'Checking the answer', succeeded: 'Done', done: 'Done', failed: 'Did not finish', stopped: 'Stopped', cancelled: 'Stopped' };var count = root.querySelector('[data-sf-desk-count]');var latest = root.querySelector('[data-sf-desk-latest]');++ var still = root.querySelector('[data-sf-desk-still]');++ var stillTimer = null;++ var stillOpen = false;++ // The still (plan 2026-11-07): the Computer's newest frame, every 2 s, only while the page is visible and the++ // task runs. It is same-origin, so the person's cookie is the credential; hidden or ended, it never asks.++ function tickStill() {++ if (!still || !stillOpen || document.hidden) return;++ var url = still.getAttribute('data-src');++ if (!url) return;++ still.src = url + (url.indexOf('?') < 0 ? '?' : '&') + 't=' + Date.now();++ }++ function showStill(open) {++ stillOpen = !!open;++ if (!still) return;++ still.hidden = !stillOpen;++ if (stillOpen) {++ if (!stillTimer) stillTimer = setInterval(tickStill, 2000);++ if (!still.getAttribute('src')) tickStill();++ } else {++ if (stillTimer) clearInterval(stillTimer);++ stillTimer = null;++ still.removeAttribute('src');++ }++ }++ if (still) still.addEventListener('error', function () { still.hidden = true; });++ if (still) still.addEventListener('load', function () { if (stillOpen) still.hidden = false; });++ document.addEventListener('visibilitychange', function () {++ if (!still) return;++ if (document.hidden) {++ if (stillTimer) clearInterval(stillTimer);++ stillTimer = null;++ } else if (stillOpen && !stillTimer) {++ stillTimer = setInterval(tickStill, 2000);++ tickStill();++ }++ });function snapshot(t) {var st = t.state || 'running';++ var open = ['queued', 'running', 'verifying'].includes(st);++ var computer = t.computer && typeof t.computer === 'object' ? t.computer : null;++ var driving = !!(computer && computer.driving);root.setAttribute('data-state', st);root.querySelector('[data-sf-desk-state]').textContent = WORDS[st] || st;var desk = botDesk(t.desk && t.desk.url);-- root.querySelector('[data-sf-desk-note]').textContent = desk ? 'On a private desk: a real browser of its own' : ['queued', 'running', 'verifying'].includes(st) ? 'Searching and reading; no desk open' : 'Searched and read the web';++ root.querySelector('[data-sf-desk-note]').textContent = computer++ ? 'On ' + computer.machine + (computer.cost_hint ? ' · ' + computer.cost_hint : '')++ : desk ? 'On a private desk: a real browser of its own' : open ? 'Searching and reading; no desk open' : 'Searched and read the web';++ showStill(open && !!(computer && computer.session_id));++ var takeForm = root.querySelector('[data-sf-desk-takeover-form]');++ if (takeForm) takeForm.hidden = !(desk && open && !driving);++ var giveForm = root.querySelector('[data-sf-desk-giveback-form]');++ if (giveForm) giveForm.hidden = !driving;var links = root.querySelector('[data-sf-desk-links]');links.hidden = !desk;if (desk) {@@@ -401,6 +447,7 @@if (!['queued', 'running', 'verifying'].includes(t.state)) {if (timer) clearInterval(timer);if (stream) stream.close();++ showStill(false);root.setAttribute('data-sf-desk-open', '0'); root.querySelector('[data-sf-desk-steps-note]').textContent = 'The task has ended.';if (latest) latest.textContent = '';}@@@ -114,6 +114,46 @@ function createApi(ctx) {});r.get('/v1/web/tasks/:id/events', requireToken, (req, res) => web.events({ token: req.webToken, id: req.params.id, after: req.get('last-event-id'), res }));++ // The task's Computer still (plan 2026-11-07): the browser never talks to Actor or to the session's service++ // directly, and the person's token stays in the httpOnly cookie. Same-origin <img>, no-store, private.++ r.get('/v1/web/tasks/:id/still', requireToken, async (req, res) => {++ try {++ const shot = await web.still({ token: req.webToken, id: req.params.id });++ // Only a picture: a broken or hostile upstream can never serve HTML from openvibe.surf.++ if (!['image/jpeg', 'image/png', 'image/webp'].includes(shot.type)) return problem(req, res, 502, 'still.bad_type', 'The desk sent something that is not an image.');++ res.set('Cache-Control', 'private, no-store');++ res.set('X-Content-Type-Options', 'nosniff');++ res.set('Content-Type', shot.type);++ res.set('Content-Length', String(shot.bytes.length));++ return res.status(200).end(shot.bytes);++ } catch (err) {++ return problem(req, res, err.status || 502, err.code || 'web.still.unavailable', err.message);++ }++ });++++ // Take over / Give it back (plan 2026-11-07): one form, no JavaScript needed. Take over goes to the desk's own++ // page (Actor answered the watch link); Give it back returns to the task. Only this site's own forms.++ const deskUrl = (value) => {++ try {++ const u = new URL(String(value || ''));++ return u.origin === 'https://openvibe.bot' && /^\/machines\/dev_[0-9A-HJKMNP-TV-Z]{26}\/screen$/.test(u.pathname) ? u.href : null;++ } catch { return null; }++ };++ for (const action of ['takeover', 'giveback']) {++ r.post(`/v1/web/tasks/:id/${action}`, requireToken, requireOrigin, async (req, res) => {++ try {++ const task = await web.handoff({ token: req.webToken, id: req.params.id, action, subject: req.viewer && req.viewer.subject ? req.viewer.subject : null });++ if (action === 'takeover') {++ const desk = deskUrl(task.desk && task.desk.url);++ if (desk) return res.redirect(303, desk);++ }++ return res.redirect(303, `/web/t/${encodeURIComponent(req.params.id)}`);++ } catch (err) {++ return problem(req, res, err.status || 502, err.code || 'web.handoff.unavailable', err.message);++ }++ });++ }++return r;}@@@ -316,17 +316,26 @@ function webTask({ task, actorUrl } = {}) {const id = String(task.id);const actor = `${String(actorUrl).replace(/\/+$/, '')}/tasks/${encodeURIComponent(id)}`;const desk = deskUrl(task.desk && task.desk.url);++ // The one Computer (plan 2026-11-07): the same object the task record and the Dock's step carry.++ const computer = task.computer && typeof task.computer === 'object' ? task.computer : null;++ const driving = !!(computer && computer.driving);const sources = task.result && Array.isArray(task.result.sources) ? task.result.sources.map(safeWebUrl).filter(Boolean) : [];const report = task.result && typeof task.result.answer === 'string' ? task.result.answer : '';const state = String(task.state || 'queued');const open = ['queued', 'running', 'verifying'].includes(state);++ const where = computer++ ? `On ${computer.machine}${computer.cost_hint ? ` · ${computer.cost_hint}` : ''}`++ : desk ? 'On a private desk: a real browser of its own' : open ? 'Searching and reading; no desk open' : 'Searched and read the web';const body = `<div class="sf-wrap sf-page-pad sf-narrow sf-desk-page sf-task" data-sf-desk-task="${esc(id)}" data-sf-desk-open="${open ? '1' : '0'}" data-state="${esc(state)}"><header class="sf-task-head"><p class="sf-kicker">Surf with Vibe</p><h1 class="sf-task-q">${esc(task.task || 'Your web task')}</h1></header><section class="sf-task-card" aria-label="What your agent is doing"><p class="sf-task-now"><span class="sf-task-dot" aria-hidden="true"></span><b data-sf-desk-state role="status">${esc(TASK_STATE[state] || state)}</b><span class="sf-task-latest" data-sf-desk-latest>${open ? 'Your agent is on it.' : ''}</span></p>--<p class="sf-task-where">${kit.icon('globe')}<span data-sf-desk-note>${desk ? 'On a private desk: a real browser of its own' : open ? 'Searching and reading; no desk open' : 'Searched and read the web'}</span></p>++<p class="sf-task-where">${kit.icon('globe')}<span data-sf-desk-note>${esc(where)}</span></p>++<img class="ov-task-still" data-sf-desk-still data-src="/api/v1/web/tasks/${esc(id)}/still" alt="Your agent's screen now" width="320" height="180" hidden><p class="sf-task-needs" data-sf-desk-needs${task.needs_you ? '' : ' hidden'}>${esc(task.needs_you && task.needs_you.what || '')}</p>--<p class="sf-task-acts" data-sf-desk-links${desk ? '' : ' hidden'}><a class="ovk-btn ovk-btn--primary sf-desk-takeover" data-sf-desk-takeover href="${esc(desk || '#')}" target="_blank" rel="noopener noreferrer">Take over</a><a class="ovk-btn sf-desk-watch" data-sf-desk-watch href="${esc(desk || '#')}" target="_blank" rel="noopener noreferrer">Watch the desk</a></p>++<form class="sf-task-takeover" method="post" action="/api/v1/web/tasks/${esc(id)}/takeover" data-sf-desk-takeover-form${desk && open && !driving ? '' : ' hidden'}><button class="ovk-btn ovk-btn--primary sf-desk-takeover" type="submit" data-sf-desk-takeover>Take over</button></form>++<form class="sf-task-giveback" method="post" action="/api/v1/web/tasks/${esc(id)}/giveback" data-sf-desk-giveback-form${driving ? '' : ' hidden'}><button class="ovk-btn sf-desk-takeover" type="submit" data-sf-desk-giveback>Give it back</button></form>++<p class="sf-task-acts" data-sf-desk-links${desk ? '' : ' hidden'}><a class="ovk-btn sf-desk-watch" data-sf-desk-watch href="${esc(desk || '#')}" target="_blank" rel="noopener noreferrer">Watch the desk</a></p><p class="sf-task-more"><a href="${esc(actor)}" target="_blank" rel="noopener noreferrer">Open in Actor</a><span aria-hidden="true">·</span><a href="/web">Ask another</a></p></section><section class="sf-card sf-desk-report sf-task-answer" aria-labelledby="sf-desk-report-h"><h2 id="sf-desk-report-h">The answer</h2>@@@ -23,6 +23,8 @@ function agentStepOf(task, { status = null, baseUrl = 'https://openvibe.surf', sconst state = STATUS_OF[status] || status || STATUS_OF[task.state] || 'running';const base = String(baseUrl || 'https://openvibe.surf').replace(/\/+$/, '');const cost = costUsd == null && task.cost ? Number(task.cost.usd) : costUsd;++ // The one Computer (plan 2026-11-07): the Dock's step reads the same object the task record carries, never a copy.++ const computer = task.computer && typeof task.computer === 'object' ? task.computer : null;return {step_id: String(task.id),mode: 'browse',@@@ -32,7 +34,8 @@ function agentStepOf(task, { status = null, baseUrl = 'https://openvibe.surf', sstatus: state,started_at: new Date(startedAt == null ? Date.now() : startedAt).toISOString(),ended_at: ENDED.has(state) ? new Date(endedAt == null ? Date.now() : endedAt).toISOString() : null,-- placement: { where: 'On a private desk', why: 'it browses for you, never with your accounts, and stops at logins and anything that would spend money' },++ placement: { where: computer ? computer.machine : 'On a private desk', why: 'it browses for you, never with your accounts, and stops at logins and anything that would spend money' },++ ...(computer ? { computer } : {}),cost_usd: Number.isFinite(cost) && cost >= 0 ? cost : null,link: `${base}/web/t/${encodeURIComponent(String(task.id))}`,reason: state === 'failed' || state === 'stopped' ? (reason == null ? null : String(reason).slice(0, 240)) : null,@@@ -64,6 +64,55 @@ function createWeb({ config, fetchImpl = globalThis.fetch, log = console, stepsreturn data;}++ /**++ * Take over / Give it back (plan 2026-11-07): one API on the task, at Actor. Built on the same hand-off the++ * agent's hand_to_person tool uses; the answer is the task (with computer.driving true or false and the desk to++ * watch). A 4xx from Actor (another person's task is 404, a task with no Computer is 409) keeps its words.++ */++ async function handoff({ token, id, action, subject = null }) {++ if (!token) throw Object.assign(new Error('Sign in to drive the desk.'), { status: 401 });++ if (!TASK_ID.test(String(id || ''))) throw Object.assign(new Error('Task not found.'), { status: 404 });++ if (!['takeover', 'giveback'].includes(action)) throw Object.assign(new Error('Task not found.'), { status: 404 });++ let res;++ try {++ res = await fetchImpl(`${taskPath(id)}/${action}`, {++ method: 'POST', headers: { ...headers(token), 'Content-Type': 'application/json' },++ body: '{}', signal: AbortSignal.timeout(config.actorTimeoutMs || 15_000),++ });++ } catch {++ throw Object.assign(new Error('OpenVibe.Actor did not answer.'), { status: 502 });++ }++ const data = await res.json().catch(() => null);++ if (!res.ok || !data || data.id !== id) {++ const detail = String((data && (data.detail || data.title || data.error)) || 'OpenVibe.Actor did not answer.').slice(0, 300);++ throw Object.assign(new Error(detail), { status: res.status === 404 ? 404 : res.status >= 400 && res.status < 500 ? res.status : 502, code: data && data.code });++ }++ agentSteps.note(data, { subject }).catch(() => {});++ return data;++ }++++ /**++ * The task's Computer still, fetched from Actor as the person (the token stays in the cookie and this service is++ * the browser's only origin). Actor in turn asks the session's own service. → { status, type, bytes }.++ */++ async function still({ token, id }) {++ if (!token) throw Object.assign(new Error('Sign in to see the desk.'), { status: 401 });++ if (!TASK_ID.test(String(id || ''))) throw Object.assign(new Error('Task not found.'), { status: 404 });++ let res;++ try {++ res = await fetchImpl(`${taskPath(id)}/still`, { headers: { Accept: 'image/jpeg, image/png', ...headers(token) }, signal: AbortSignal.timeout(config.actorTimeoutMs || 15_000) });++ } catch {++ throw Object.assign(new Error('OpenVibe.Actor did not answer.'), { status: 502 });++ }++ if (!res.ok) throw Object.assign(new Error(res.status === 404 ? 'No picture.' : 'The desk is not reachable.'), { status: res.status === 404 || res.status === 401 ? res.status : 502 });++ // Only a picture: a broken or hostile upstream can never serve HTML from openvibe.surf.++ const type = String(res.headers.get('content-type') || '').split(';')[0].trim();++ if (!/^image\/(jpeg|png|webp)$/.test(type)) throw Object.assign(new Error('The desk sent something that is not an image.'), { status: 502, code: 'still.bad_type' });++ const bytes = Buffer.from(await res.arrayBuffer());++ if (!bytes.length) throw Object.assign(new Error('No picture.'), { status: 404 });++ return { status: 200, type, bytes };++ }++// Actor offers SSE. Proxy it server-side so the person's token stays in an httpOnly cookie and never enters JS.async function events({ token, id, after, res }) {if (!token) return res.status(401).end();@@@ -139,7 +188,7 @@ function createWeb({ config, fetchImpl = globalThis.fetch, log = console, stepsreturn { task_id: id, page: `/web/t/${encodeURIComponent(id)}` };}-- return { createTask, task, events, validQuery };++ return { createTask, task, events, handoff, still, validQuery };}module.exports = { createWeb, taskText, validQuery, newIdempotencyKey, PREFIX, MIN_QUERY, MAX_QUERY, TASK_ID };nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++/**++ * The Computer on Surf's task card (plan 2026-11-07: "Surf, Computer and Actor are one experience"): the card reads++ * the task's computer (machine, where, cost_hint, driving), shows its still from a same-origin proxy (the person's++ * token stays in the cookie), and Take over / Give it back are one API on the task at Actor.++ * node test/computer.test.js++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const ORIGIN = 'https://openvibe.surf';++const DESK = `https://openvibe.bot/machines/dev_${'A'.repeat(26)}/screen?desk=desk-surf`;++const COMPUTER = { session_id: 'ses_01JAB2C3D4E5F6G7H8J9K0MNPQR', machine: 'a shared desk host', where: 'network', cost_hint: 'a desk host, metered by the minute', driving: false };++++(async () => {++ const t = await boot();++ const kim = t.network.addUser('kim');++ const token = t.network.userToken(kim);++ const signedIn = `surf_at=${token}`;++ const id = t.upstreams.state.actor.id;++ t.upstreams.state.actor.task = { id, state: 'running', task: 'Compare three hosting plans', computer: { ...COMPUTER }, desk: { url: DESK, kind: 'browser' } };++++ try {++ await check('the card reads the one Computer and shows the still slot', async () => {++ const r = await t.get(`/web/t/${id}`, { cookie: signedIn });++ assert.strictEqual(r.status, 200, r.text);++ assert.match(r.text, /class="ov-task-still"/);++ assert.match(r.text, new RegExp(`data-src="/api/v1/web/tasks/${id}/still"`));++ assert.match(r.text, /a shared desk host/);++ assert.match(r.text, /metered by the minute/);++ assert.match(r.text, /data-sf-desk-takeover-form/);++ assert.match(r.text, new RegExp(`action="/api/v1/web/tasks/${id}/takeover"`));++ assert.match(r.text, /data-sf-desk-giveback-form hidden/);++ assert.ok(!r.text.includes(token), 'the token is never rendered');++ });++++ await check('the still is a same-origin proxy, no-store, with the person\'s Bearer at Actor', async () => {++ const r = await t.get(`/api/v1/web/tasks/${id}/still`, { cookie: signedIn });++ assert.strictEqual(r.status, 200, r.text);++ assert.match(r.headers.get('content-type'), /^image\/jpeg/);++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ assert.strictEqual(r.headers.get('x-content-type-options'), 'nosniff');++ assert.deepStrictEqual(r.buffer, Buffer.from('/9j/4AAQ', 'base64'));++ const sent = t.upstreams.lastFor(`/api/v1/tasks/${id}/still`);++ assert.strictEqual(sent.headers.authorization, `Bearer ${token}`);++ assert.strictEqual((await t.get(`/api/v1/web/tasks/${id}/still`)).status, 401, 'signed out');++ // A broken or hostile upstream cannot serve HTML from openvibe.surf.++ t.upstreams.state.actor.stillType = 'text/html';++ const bad = await t.get(`/api/v1/web/tasks/${id}/still`, { cookie: signedIn });++ assert.strictEqual(bad.status, 502, bad.text);++ assert.strictEqual(bad.json().code, 'still.bad_type');++ assert.ok(!bad.text.includes('<html'));++ t.upstreams.state.actor.stillType = 'image/jpeg';++ });++++ await check('another person\'s task is the same 404 as a missing one', async () => {++ t.upstreams.state.actor.stillStatus = 404;++ const r = await t.get(`/api/v1/web/tasks/${id}/still`, { cookie: signedIn });++ assert.strictEqual(r.status, 404);++ assert.match(r.text, /No picture/);++ });++++ await check('Take over calls the one API and goes to the desk; Give it back returns to the task', async () => {++ t.upstreams.state.actor.stillStatus = 200;++ assert.strictEqual((await t.get(`/api/v1/web/tasks/${id}/takeover`, { cookie: signedIn, method: 'POST', headers: { origin: 'https://evil.example' } })).status, 403, 'another site');++ const take = await t.get(`/api/v1/web/tasks/${id}/takeover`, { cookie: signedIn, method: 'POST', headers: { origin: ORIGIN } });++ assert.strictEqual(take.status, 303, take.text);++ assert.strictEqual(take.headers.get('location'), DESK, 'the person drives on the desk\'s own page');++ const sent = t.upstreams.lastFor(`/api/v1/tasks/${id}/takeover`);++ assert.strictEqual(sent.headers.authorization, `Bearer ${token}`);++ assert.deepStrictEqual(t.upstreams.state.actor.handoffs, ['takeover']);++ const page = await t.get(`/web/t/${id}`, { cookie: signedIn });++ assert.match(page.text, /data-sf-desk-giveback-form>/);++ assert.match(page.text, /data-sf-desk-takeover-form hidden/);++ const back = await t.get(`/api/v1/web/tasks/${id}/giveback`, { cookie: signedIn, method: 'POST', headers: { origin: ORIGIN } });++ assert.strictEqual(back.status, 303);++ assert.strictEqual(back.headers.get('location'), `/web/t/${id}`);++ assert.deepStrictEqual(t.upstreams.state.actor.handoffs, ['takeover', 'giveback']);++ assert.strictEqual((await t.get(`/api/v1/web/tasks/${id}/takeover`, { cookie: signedIn, method: 'POST' })).status, 403, 'no Origin is not this site');++ });++ } finally {++ await t.close();++ done();++ }++})().catch((err) => { console.error(err); process.exit(1); });@@@ -67,11 +67,13 @@ async function boot(opts = {}) {if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }if (o.form !== undefined) { body = new URLSearchParams(o.form).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }const res = await fetch(base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });-- const text = Buffer.from(await res.arrayBuffer()).toString('utf8');++ const buffer = Buffer.from(await res.arrayBuffer());++ const text = buffer.toString('utf8');return {status: res.status,headers: res.headers,text,++ buffer,json() { return JSON.parse(text); },};}@@@ -70,7 +70,7 @@ async function startUpstreams() {dvr: { 9002: 'https://openvibe.media/v/9002/dvr.m3u8' },fail: { streams: null, clips: null, moments: null },delay: { streams: 0, clips: 0, moments: 0 },-- actor: { status: 201, id: 'tsk_01J8SURFEXAMPLE00000000', problem: null, task: null, events: [] },++ actor: { status: 201, id: 'tsk_01J8SURFEXAMPLE00000000', problem: null, task: null, events: [], handoffs: [], still: null, stillType: 'image/jpeg', stillStatus: 200 },requests: [],counts: { streams: 0, clips: 0, moments: 0, maps: 0, map: 0, actor: 0 },};@@@ -145,6 +145,21 @@ async function startUpstreams() {for (const event of state.actor.events) res.write(`id: ${event.seq}\nevent: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`);return res.end();}++ // The Computer still (plan 2026-11-07) and Take over / Give it back, as Actor answers them.++ if (p === `/api/v1/tasks/${state.actor.id}/still` && req.method === 'GET') {++ if (state.actor.stillStatus && state.actor.stillStatus !== 200) return json(state.actor.stillStatus, { code: 'actor.task.no_still', detail: 'This task has no Computer picture.' });++ res.writeHead(200, { 'Content-Type': state.actor.stillType || 'image/jpeg' });++ return res.end(state.actor.still || Buffer.from('/9j/4AAQ', 'base64'));++ }++ const hand = p.match(new RegExp(`^/api/v1/tasks/${state.actor.id}/(takeover|giveback)$`));++ if (hand && req.method === 'POST') {++ const action = hand[1];++ state.actor.handoffs = (state.actor.handoffs || []).concat(action);++ const task = { ...(state.actor.task || { id: state.actor.id, state: 'running', task: 'Surf the web' }) };++ if (task.computer) task.computer = { ...task.computer, driving: action === 'takeover' };++ state.actor.task = task;++ return json(200, task);++ }return json(404, { error: 'not found' });});
@@ -2,6 +2,20 @@
@@ -2,6 +2,20 @@
What changed in OpenVibe.Surf, newest first. Each site also publishes its patch notes at /updates.
What changed in OpenVibe.Surf, newest first. Each site also publishes its patch notes at /updates.
+## Unreleased — one Computer
+
+- **The task card shows the Computer** (plan 2026-11-07: "Surf, Computer and Actor are one experience"): the card
+ reads the task's `computer` (`session_id`, `machine`, `where`, `cost_hint`, `driving`) and puts the machine and the
+ cost where it says where the work happens. While the task runs it shows the Computer's newest frame as an
+ `<img class="ov-task-still">`, refreshed every 2 s only while the page is visible, stopping when the task ends or
+ the tab is hidden. It is a same-origin proxy (`GET /api/v1/web/tasks/:id/still`) so the person's token stays in its
+ cookie; Actor answers `404` for another person's task, and the picture is `Cache-Control: private, no-store`.
+- **Take over / Give it back** are now one API on the task: the card's button posts to Actor (`/takeover`,
+ `/giveback`) through this site (`POST /api/v1/web/tasks/:id/takeover` and `…/giveback`, same-origin only). Take over
+ goes to the desk's own page; Give it back returns to the card, which then says so. No new layout: the existing
+ Take over control and one `.ov-task-still` size rule.
+- The Dock's step carries the same `computer` (server/web/agent-step.js).
+
## Unreleased — watch together
## Unreleased — watch together
- **Home Watch scene** (`server/views/index.js`, `server/http/pages.js`): the home no longer shows the written-in
- **Home Watch scene** (`server/views/index.js`, `server/http/pages.js`): the home no longer shows the written-in
@@ -259,7 +259,7 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by
@@ -259,7 +259,7 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by
[OpenVibers](https://github.com/OpenVibers).
[OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.312.0
+- openvibe-contracts: v0.327.0
- openvibe-sdk: v0.94.0
- openvibe-sdk: v0.94.0
-- openvibe-shared: v4.135.0
+- openvibe-shared: v4.136.0
<!-- versions:end -->
<!-- versions:end -->
@@ -8,13 +8,13 @@
@@ -8,13 +8,13 @@
"runtime": "node 22, express 4, openvibe-shared/shell; no database, no Valkey, no Events; watch parties are in memory and end on restart",
"runtime": "node 22, express 4, openvibe-shared/shell; no database, no Valkey, no Events; watch parties are in memory and end on restart",
"port": "5110",
"port": "5110",
"domain": "openvibe.surf",
"domain": "openvibe.surf",
- "contracts": "openvibe-contracts v0.312.0",
+ "contracts": "openvibe-contracts v0.327.0",
"sdk": "openvibe-sdk v0.94.0",
"sdk": "openvibe-sdk v0.94.0",
- "shared": "openvibe-shared v4.135.0",
+ "shared": "openvibe-shared v4.136.0",
"packages": [
"packages": [
- "openvibe-contracts v0.312.0",
+ "openvibe-contracts v0.327.0",
"openvibe-sdk v0.94.0",
"openvibe-sdk v0.94.0",
- "openvibe-shared v4.135.0"
+ "openvibe-shared v4.136.0"
],
],
"summary": "openvibe.surf, the Surf room: Network sign-in as OAuth client `surf`; Ride (OpenVibe.Games' surf maps, cached 60 s, embedded); Watch (a lean-back TV over OpenVibe.Live's live streams, clips and AI moments, clips and moments playing in the page over HLS) plus watch together (in-memory SSE parties); Web (Surf with Vibe: one OpenVibe.Actor task made with the person's own Network token). No database.",
"summary": "openvibe.surf, the Surf room: Network sign-in as OAuth client `surf`; Ride (OpenVibe.Games' surf maps, cached 60 s, embedded); Watch (a lean-back TV over OpenVibe.Live's live streams, clips and AI moments, clips and moments playing in the page over HLS) plus watch together (in-memory SSE parties); Web (Surf with Vibe: one OpenVibe.Actor task made with the person's own Network token). No database.",
"works": [
"works": [
@@ -14,9 +14,9 @@
@@ -14,9 +14,9 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0"
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.136.0"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
@@ -1059,9 +1059,9 @@
@@ -1059,9 +1059,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.312.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
- "integrity": "sha512-2GsD53zuIedL3eW2LBlYQ68ODTHzoZJHrAmVnZCpiL7ZWnAXGwPW9dpQagLI9Zdkh3LQuuU5ae1YJI0K+acXbA==",
+ "version": "0.327.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",
+ "integrity": "sha512-LssZZeTdg9OkPPpry6YSEVhbIv13MjLYOlNxpoZ/0bpIATy0zfJrzfLAaJyUY+UMq5Tqf1FLzx2a/3xcJ7UJDw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1103,9 +1103,9 @@
@@ -1103,9 +1103,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.135.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0",
- "integrity": "sha512-+v59lQjCjkb8poKNyKOKfTr8SoFMyr/4dKMoIpPgxqvn8MDFYpylvI80U3AvSxeJTqZpthhrBBdkgVlok/M3dg==",
+ "version": "4.136.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.136.0",
+ "integrity": "sha512-r/pfiONg/RJWozZFcxc9f+pQPhGcLKY0cHyteuk8i07l9qR/eNGdQI/JpLHYJ/U4N5nqh41W0D1t3CSq/i3tFA==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -20,9 +20,9 @@
@@ -20,9 +20,9 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0"
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.136.0"
},
},
"engines": {
"engines": {
"node": ">=22.0.0"
"node": ">=22.0.0"
@@ -220,6 +220,9 @@ html.ov-motion .sf-party-playing .sf-party-dot { animation: sf-pulse 1.6s ease-o
@@ -220,6 +220,9 @@ html.ov-motion .sf-party-playing .sf-party-dot { animation: sf-pulse 1.6s ease-o
/* Surf with Vibe: the task page (/web/t/:id): state, desk links, live steps as a timeline, sources, the report */
/* Surf with Vibe: the task page (/web/t/:id): state, desk links, live steps as a timeline, sources, the report */
.sf-desk-page { display: grid; gap: 14px; }
.sf-desk-page { display: grid; gap: 14px; }
+/* The Computer's live still (plan 2026-11-07): size and aspect only; Opus designs the rest. */
+.ov-task-still { display: block; width: 100%; max-width: 320px; aspect-ratio: 16 / 9; object-fit: cover; }
+.ov-task-still[hidden] { display: none; }
.sf-desk-head h1 { margin-bottom: 6px; }
.sf-desk-head h1 { margin-bottom: 6px; }
.sf-desk-query { margin: 0; font-size: 1.05rem; line-height: 1.5; color: var(--text-primary, #e6edf7); overflow-wrap: anywhere; }
.sf-desk-query { margin: 0; font-size: 1.05rem; line-height: 1.5; color: var(--text-primary, #e6edf7); overflow-wrap: anywhere; }
.sf-desk-state { display: inline-flex; align-items: center; gap: 8px; margin: 12px 0 0; padding: 5px 12px 5px 10px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; background: var(--bg-secondary, #101828); font-size: .8rem; font-weight: 800; letter-spacing: .03em; text-transform: capitalize; }
.sf-desk-state { display: inline-flex; align-items: center; gap: 8px; margin: 12px 0 0; padding: 5px 12px 5px 10px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; background: var(--bg-secondary, #101828); font-size: .8rem; font-weight: 800; letter-spacing: .03em; text-transform: capitalize; }
@@ -366,12 +366,58 @@
@@ -366,12 +366,58 @@
var WORDS = { queued: 'Getting ready', running: 'Working', verifying: 'Checking the answer', succeeded: 'Done', done: 'Done', failed: 'Did not finish', stopped: 'Stopped', cancelled: 'Stopped' };
var WORDS = { queued: 'Getting ready', running: 'Working', verifying: 'Checking the answer', succeeded: 'Done', done: 'Done', failed: 'Did not finish', stopped: 'Stopped', cancelled: 'Stopped' };
var count = root.querySelector('[data-sf-desk-count]');
var count = root.querySelector('[data-sf-desk-count]');
var latest = root.querySelector('[data-sf-desk-latest]');
var latest = root.querySelector('[data-sf-desk-latest]');
+ var still = root.querySelector('[data-sf-desk-still]');
+ var stillTimer = null;
+ var stillOpen = false;
+ // The still (plan 2026-11-07): the Computer's newest frame, every 2 s, only while the page is visible and the
+ // task runs. It is same-origin, so the person's cookie is the credential; hidden or ended, it never asks.
+ function tickStill() {
+ if (!still || !stillOpen || document.hidden) return;
+ var url = still.getAttribute('data-src');
+ if (!url) return;
+ still.src = url + (url.indexOf('?') < 0 ? '?' : '&') + 't=' + Date.now();
+ }
+ function showStill(open) {
+ stillOpen = !!open;
+ if (!still) return;
+ still.hidden = !stillOpen;
+ if (stillOpen) {
+ if (!stillTimer) stillTimer = setInterval(tickStill, 2000);
+ if (!still.getAttribute('src')) tickStill();
+ } else {
+ if (stillTimer) clearInterval(stillTimer);
+ stillTimer = null;
+ still.removeAttribute('src');
+ }
+ }
+ if (still) still.addEventListener('error', function () { still.hidden = true; });
+ if (still) still.addEventListener('load', function () { if (stillOpen) still.hidden = false; });
+ document.addEventListener('visibilitychange', function () {
+ if (!still) return;
+ if (document.hidden) {
+ if (stillTimer) clearInterval(stillTimer);
+ stillTimer = null;
+ } else if (stillOpen && !stillTimer) {
+ stillTimer = setInterval(tickStill, 2000);
+ tickStill();
+ }
+ });
function snapshot(t) {
function snapshot(t) {
var st = t.state || 'running';
var st = t.state || 'running';
+ var open = ['queued', 'running', 'verifying'].includes(st);
+ var computer = t.computer && typeof t.computer === 'object' ? t.computer : null;
+ var driving = !!(computer && computer.driving);
root.setAttribute('data-state', st);
root.setAttribute('data-state', st);
root.querySelector('[data-sf-desk-state]').textContent = WORDS[st] || st;
root.querySelector('[data-sf-desk-state]').textContent = WORDS[st] || st;
var desk = botDesk(t.desk && t.desk.url);
var desk = botDesk(t.desk && t.desk.url);
- root.querySelector('[data-sf-desk-note]').textContent = desk ? 'On a private desk: a real browser of its own' : ['queued', 'running', 'verifying'].includes(st) ? 'Searching and reading; no desk open' : 'Searched and read the web';
+ root.querySelector('[data-sf-desk-note]').textContent = computer
+ ? 'On ' + computer.machine + (computer.cost_hint ? ' · ' + computer.cost_hint : '')
+ : desk ? 'On a private desk: a real browser of its own' : open ? 'Searching and reading; no desk open' : 'Searched and read the web';
+ showStill(open && !!(computer && computer.session_id));
+ var takeForm = root.querySelector('[data-sf-desk-takeover-form]');
+ if (takeForm) takeForm.hidden = !(desk && open && !driving);
+ var giveForm = root.querySelector('[data-sf-desk-giveback-form]');
+ if (giveForm) giveForm.hidden = !driving;
var links = root.querySelector('[data-sf-desk-links]');
var links = root.querySelector('[data-sf-desk-links]');
links.hidden = !desk;
links.hidden = !desk;
if (desk) {
if (desk) {
@@ -401,6 +447,7 @@
@@ -401,6 +447,7 @@
if (!['queued', 'running', 'verifying'].includes(t.state)) {
if (!['queued', 'running', 'verifying'].includes(t.state)) {
if (timer) clearInterval(timer);
if (timer) clearInterval(timer);
if (stream) stream.close();
if (stream) stream.close();
+ showStill(false);
root.setAttribute('data-sf-desk-open', '0'); root.querySelector('[data-sf-desk-steps-note]').textContent = 'The task has ended.';
root.setAttribute('data-sf-desk-open', '0'); root.querySelector('[data-sf-desk-steps-note]').textContent = 'The task has ended.';
if (latest) latest.textContent = '';
if (latest) latest.textContent = '';
}
}
@@ -114,6 +114,46 @@ function createApi(ctx) {
@@ -114,6 +114,46 @@ function createApi(ctx) {
});
});
r.get('/v1/web/tasks/:id/events', requireToken, (req, res) => web.events({ token: req.webToken, id: req.params.id, after: req.get('last-event-id'), res }));
r.get('/v1/web/tasks/:id/events', requireToken, (req, res) => web.events({ token: req.webToken, id: req.params.id, after: req.get('last-event-id'), res }));
+ // The task's Computer still (plan 2026-11-07): the browser never talks to Actor or to the session's service
+ // directly, and the person's token stays in the httpOnly cookie. Same-origin <img>, no-store, private.
+ r.get('/v1/web/tasks/:id/still', requireToken, async (req, res) => {
+ try {
+ const shot = await web.still({ token: req.webToken, id: req.params.id });
+ // Only a picture: a broken or hostile upstream can never serve HTML from openvibe.surf.
+ if (!['image/jpeg', 'image/png', 'image/webp'].includes(shot.type)) return problem(req, res, 502, 'still.bad_type', 'The desk sent something that is not an image.');
+ res.set('Cache-Control', 'private, no-store');
+ res.set('X-Content-Type-Options', 'nosniff');
+ res.set('Content-Type', shot.type);
+ res.set('Content-Length', String(shot.bytes.length));
+ return res.status(200).end(shot.bytes);
+ } catch (err) {
+ return problem(req, res, err.status || 502, err.code || 'web.still.unavailable', err.message);
+ }
+ });
+
+ // Take over / Give it back (plan 2026-11-07): one form, no JavaScript needed. Take over goes to the desk's own
+ // page (Actor answered the watch link); Give it back returns to the task. Only this site's own forms.
+ const deskUrl = (value) => {
+ try {
+ const u = new URL(String(value || ''));
+ return u.origin === 'https://openvibe.bot' && /^\/machines\/dev_[0-9A-HJKMNP-TV-Z]{26}\/screen$/.test(u.pathname) ? u.href : null;
+ } catch { return null; }
+ };
+ for (const action of ['takeover', 'giveback']) {
+ r.post(`/v1/web/tasks/:id/${action}`, requireToken, requireOrigin, async (req, res) => {
+ try {
+ const task = await web.handoff({ token: req.webToken, id: req.params.id, action, subject: req.viewer && req.viewer.subject ? req.viewer.subject : null });
+ if (action === 'takeover') {
+ const desk = deskUrl(task.desk && task.desk.url);
+ if (desk) return res.redirect(303, desk);
+ }
+ return res.redirect(303, `/web/t/${encodeURIComponent(req.params.id)}`);
+ } catch (err) {
+ return problem(req, res, err.status || 502, err.code || 'web.handoff.unavailable', err.message);
+ }
+ });
+ }
+
return r;
return r;
}
}
@@ -316,17 +316,26 @@ function webTask({ task, actorUrl } = {}) {
@@ -316,17 +316,26 @@ function webTask({ task, actorUrl } = {}) {
const id = String(task.id);
const id = String(task.id);
const actor = `${String(actorUrl).replace(/\/+$/, '')}/tasks/${encodeURIComponent(id)}`;
const actor = `${String(actorUrl).replace(/\/+$/, '')}/tasks/${encodeURIComponent(id)}`;
const desk = deskUrl(task.desk && task.desk.url);
const desk = deskUrl(task.desk && task.desk.url);
+ // The one Computer (plan 2026-11-07): the same object the task record and the Dock's step carry.
+ const computer = task.computer && typeof task.computer === 'object' ? task.computer : null;
+ const driving = !!(computer && computer.driving);
const sources = task.result && Array.isArray(task.result.sources) ? task.result.sources.map(safeWebUrl).filter(Boolean) : [];
const sources = task.result && Array.isArray(task.result.sources) ? task.result.sources.map(safeWebUrl).filter(Boolean) : [];
const report = task.result && typeof task.result.answer === 'string' ? task.result.answer : '';
const report = task.result && typeof task.result.answer === 'string' ? task.result.answer : '';
const state = String(task.state || 'queued');
const state = String(task.state || 'queued');
const open = ['queued', 'running', 'verifying'].includes(state);
const open = ['queued', 'running', 'verifying'].includes(state);
+ const where = computer
+ ? `On ${computer.machine}${computer.cost_hint ? ` · ${computer.cost_hint}` : ''}`
+ : desk ? 'On a private desk: a real browser of its own' : open ? 'Searching and reading; no desk open' : 'Searched and read the web';
const body = `<div class="sf-wrap sf-page-pad sf-narrow sf-desk-page sf-task" data-sf-desk-task="${esc(id)}" data-sf-desk-open="${open ? '1' : '0'}" data-state="${esc(state)}">
const body = `<div class="sf-wrap sf-page-pad sf-narrow sf-desk-page sf-task" data-sf-desk-task="${esc(id)}" data-sf-desk-open="${open ? '1' : '0'}" data-state="${esc(state)}">
<header class="sf-task-head"><p class="sf-kicker">Surf with Vibe</p><h1 class="sf-task-q">${esc(task.task || 'Your web task')}</h1></header>
<header class="sf-task-head"><p class="sf-kicker">Surf with Vibe</p><h1 class="sf-task-q">${esc(task.task || 'Your web task')}</h1></header>
<section class="sf-task-card" aria-label="What your agent is doing">
<section class="sf-task-card" aria-label="What your agent is doing">
<p class="sf-task-now"><span class="sf-task-dot" aria-hidden="true"></span><b data-sf-desk-state role="status">${esc(TASK_STATE[state] || state)}</b><span class="sf-task-latest" data-sf-desk-latest>${open ? 'Your agent is on it.' : ''}</span></p>
<p class="sf-task-now"><span class="sf-task-dot" aria-hidden="true"></span><b data-sf-desk-state role="status">${esc(TASK_STATE[state] || state)}</b><span class="sf-task-latest" data-sf-desk-latest>${open ? 'Your agent is on it.' : ''}</span></p>
-<p class="sf-task-where">${kit.icon('globe')}<span data-sf-desk-note>${desk ? 'On a private desk: a real browser of its own' : open ? 'Searching and reading; no desk open' : 'Searched and read the web'}</span></p>
+<p class="sf-task-where">${kit.icon('globe')}<span data-sf-desk-note>${esc(where)}</span></p>
+<img class="ov-task-still" data-sf-desk-still data-src="/api/v1/web/tasks/${esc(id)}/still" alt="Your agent's screen now" width="320" height="180" hidden>
<p class="sf-task-needs" data-sf-desk-needs${task.needs_you ? '' : ' hidden'}>${esc(task.needs_you && task.needs_you.what || '')}</p>
<p class="sf-task-needs" data-sf-desk-needs${task.needs_you ? '' : ' hidden'}>${esc(task.needs_you && task.needs_you.what || '')}</p>
-<p class="sf-task-acts" data-sf-desk-links${desk ? '' : ' hidden'}><a class="ovk-btn ovk-btn--primary sf-desk-takeover" data-sf-desk-takeover href="${esc(desk || '#')}" target="_blank" rel="noopener noreferrer">Take over</a><a class="ovk-btn sf-desk-watch" data-sf-desk-watch href="${esc(desk || '#')}" target="_blank" rel="noopener noreferrer">Watch the desk</a></p>
+<form class="sf-task-takeover" method="post" action="/api/v1/web/tasks/${esc(id)}/takeover" data-sf-desk-takeover-form${desk && open && !driving ? '' : ' hidden'}><button class="ovk-btn ovk-btn--primary sf-desk-takeover" type="submit" data-sf-desk-takeover>Take over</button></form>
+<form class="sf-task-giveback" method="post" action="/api/v1/web/tasks/${esc(id)}/giveback" data-sf-desk-giveback-form${driving ? '' : ' hidden'}><button class="ovk-btn sf-desk-takeover" type="submit" data-sf-desk-giveback>Give it back</button></form>
+<p class="sf-task-acts" data-sf-desk-links${desk ? '' : ' hidden'}><a class="ovk-btn sf-desk-watch" data-sf-desk-watch href="${esc(desk || '#')}" target="_blank" rel="noopener noreferrer">Watch the desk</a></p>
<p class="sf-task-more"><a href="${esc(actor)}" target="_blank" rel="noopener noreferrer">Open in Actor</a><span aria-hidden="true">·</span><a href="/web">Ask another</a></p>
<p class="sf-task-more"><a href="${esc(actor)}" target="_blank" rel="noopener noreferrer">Open in Actor</a><span aria-hidden="true">·</span><a href="/web">Ask another</a></p>
</section>
</section>
<section class="sf-card sf-desk-report sf-task-answer" aria-labelledby="sf-desk-report-h"><h2 id="sf-desk-report-h">The answer</h2>
<section class="sf-card sf-desk-report sf-task-answer" aria-labelledby="sf-desk-report-h"><h2 id="sf-desk-report-h">The answer</h2>
@@ -23,6 +23,8 @@ function agentStepOf(task, { status = null, baseUrl = 'https://openvibe.surf', s
@@ -23,6 +23,8 @@ function agentStepOf(task, { status = null, baseUrl = 'https://openvibe.surf', s
const state = STATUS_OF[status] || status || STATUS_OF[task.state] || 'running';
const state = STATUS_OF[status] || status || STATUS_OF[task.state] || 'running';
const base = String(baseUrl || 'https://openvibe.surf').replace(/\/+$/, '');
const base = String(baseUrl || 'https://openvibe.surf').replace(/\/+$/, '');
const cost = costUsd == null && task.cost ? Number(task.cost.usd) : costUsd;
const cost = costUsd == null && task.cost ? Number(task.cost.usd) : costUsd;
+ // The one Computer (plan 2026-11-07): the Dock's step reads the same object the task record carries, never a copy.
+ const computer = task.computer && typeof task.computer === 'object' ? task.computer : null;
return {
return {
step_id: String(task.id),
step_id: String(task.id),
mode: 'browse',
mode: 'browse',
@@ -32,7 +34,8 @@ function agentStepOf(task, { status = null, baseUrl = 'https://openvibe.surf', s
@@ -32,7 +34,8 @@ function agentStepOf(task, { status = null, baseUrl = 'https://openvibe.surf', s
status: state,
status: state,
started_at: new Date(startedAt == null ? Date.now() : startedAt).toISOString(),
started_at: new Date(startedAt == null ? Date.now() : startedAt).toISOString(),
ended_at: ENDED.has(state) ? new Date(endedAt == null ? Date.now() : endedAt).toISOString() : null,
ended_at: ENDED.has(state) ? new Date(endedAt == null ? Date.now() : endedAt).toISOString() : null,
- placement: { where: 'On a private desk', why: 'it browses for you, never with your accounts, and stops at logins and anything that would spend money' },
+ placement: { where: computer ? computer.machine : 'On a private desk', why: 'it browses for you, never with your accounts, and stops at logins and anything that would spend money' },
+ ...(computer ? { computer } : {}),
cost_usd: Number.isFinite(cost) && cost >= 0 ? cost : null,
cost_usd: Number.isFinite(cost) && cost >= 0 ? cost : null,
link: `${base}/web/t/${encodeURIComponent(String(task.id))}`,
link: `${base}/web/t/${encodeURIComponent(String(task.id))}`,
reason: state === 'failed' || state === 'stopped' ? (reason == null ? null : String(reason).slice(0, 240)) : null,
reason: state === 'failed' || state === 'stopped' ? (reason == null ? null : String(reason).slice(0, 240)) : null,
@@ -64,6 +64,55 @@ function createWeb({ config, fetchImpl = globalThis.fetch, log = console, steps
@@ -64,6 +64,55 @@ function createWeb({ config, fetchImpl = globalThis.fetch, log = console, steps
return data;
return data;
}
}
+ /**
+ * Take over / Give it back (plan 2026-11-07): one API on the task, at Actor. Built on the same hand-off the
+ * agent's hand_to_person tool uses; the answer is the task (with computer.driving true or false and the desk to
+ * watch). A 4xx from Actor (another person's task is 404, a task with no Computer is 409) keeps its words.
+ */
+ async function handoff({ token, id, action, subject = null }) {
+ if (!token) throw Object.assign(new Error('Sign in to drive the desk.'), { status: 401 });
+ if (!TASK_ID.test(String(id || ''))) throw Object.assign(new Error('Task not found.'), { status: 404 });
+ if (!['takeover', 'giveback'].includes(action)) throw Object.assign(new Error('Task not found.'), { status: 404 });
+ let res;
+ try {
+ res = await fetchImpl(`${taskPath(id)}/${action}`, {
+ method: 'POST', headers: { ...headers(token), 'Content-Type': 'application/json' },
+ body: '{}', signal: AbortSignal.timeout(config.actorTimeoutMs || 15_000),
+ });
+ } catch {
+ throw Object.assign(new Error('OpenVibe.Actor did not answer.'), { status: 502 });
+ }
+ const data = await res.json().catch(() => null);
+ if (!res.ok || !data || data.id !== id) {
+ const detail = String((data && (data.detail || data.title || data.error)) || 'OpenVibe.Actor did not answer.').slice(0, 300);
+ throw Object.assign(new Error(detail), { status: res.status === 404 ? 404 : res.status >= 400 && res.status < 500 ? res.status : 502, code: data && data.code });
+ }
+ agentSteps.note(data, { subject }).catch(() => {});
+ return data;
+ }
+
+ /**
+ * The task's Computer still, fetched from Actor as the person (the token stays in the cookie and this service is
+ * the browser's only origin). Actor in turn asks the session's own service. → { status, type, bytes }.
+ */
+ async function still({ token, id }) {
+ if (!token) throw Object.assign(new Error('Sign in to see the desk.'), { status: 401 });
+ if (!TASK_ID.test(String(id || ''))) throw Object.assign(new Error('Task not found.'), { status: 404 });
+ let res;
+ try {
+ res = await fetchImpl(`${taskPath(id)}/still`, { headers: { Accept: 'image/jpeg, image/png', ...headers(token) }, signal: AbortSignal.timeout(config.actorTimeoutMs || 15_000) });
+ } catch {
+ throw Object.assign(new Error('OpenVibe.Actor did not answer.'), { status: 502 });
+ }
+ if (!res.ok) throw Object.assign(new Error(res.status === 404 ? 'No picture.' : 'The desk is not reachable.'), { status: res.status === 404 || res.status === 401 ? res.status : 502 });
+ // Only a picture: a broken or hostile upstream can never serve HTML from openvibe.surf.
+ const type = String(res.headers.get('content-type') || '').split(';')[0].trim();
+ if (!/^image\/(jpeg|png|webp)$/.test(type)) throw Object.assign(new Error('The desk sent something that is not an image.'), { status: 502, code: 'still.bad_type' });
+ const bytes = Buffer.from(await res.arrayBuffer());
+ if (!bytes.length) throw Object.assign(new Error('No picture.'), { status: 404 });
+ return { status: 200, type, bytes };
+ }
+
// Actor offers SSE. Proxy it server-side so the person's token stays in an httpOnly cookie and never enters JS.
// Actor offers SSE. Proxy it server-side so the person's token stays in an httpOnly cookie and never enters JS.
async function events({ token, id, after, res }) {
async function events({ token, id, after, res }) {
if (!token) return res.status(401).end();
if (!token) return res.status(401).end();
@@ -139,7 +188,7 @@ function createWeb({ config, fetchImpl = globalThis.fetch, log = console, steps
@@ -139,7 +188,7 @@ function createWeb({ config, fetchImpl = globalThis.fetch, log = console, steps
return { task_id: id, page: `/web/t/${encodeURIComponent(id)}` };
return { task_id: id, page: `/web/t/${encodeURIComponent(id)}` };
}
}
- return { createTask, task, events, validQuery };
+ return { createTask, task, events, handoff, still, validQuery };
}
}
module.exports = { createWeb, taskText, validQuery, newIdempotencyKey, PREFIX, MIN_QUERY, MAX_QUERY, TASK_ID };
module.exports = { createWeb, taskText, validQuery, newIdempotencyKey, PREFIX, MIN_QUERY, MAX_QUERY, TASK_ID };
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+/**
+ * The Computer on Surf's task card (plan 2026-11-07: "Surf, Computer and Actor are one experience"): the card reads
+ * the task's computer (machine, where, cost_hint, driving), shows its still from a same-origin proxy (the person's
+ * token stays in the cookie), and Take over / Give it back are one API on the task at Actor.
+ * node test/computer.test.js
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const ORIGIN = 'https://openvibe.surf';
+const DESK = `https://openvibe.bot/machines/dev_${'A'.repeat(26)}/screen?desk=desk-surf`;
+const COMPUTER = { session_id: 'ses_01JAB2C3D4E5F6G7H8J9K0MNPQR', machine: 'a shared desk host', where: 'network', cost_hint: 'a desk host, metered by the minute', driving: false };
+
+(async () => {
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const token = t.network.userToken(kim);
+ const signedIn = `surf_at=${token}`;
+ const id = t.upstreams.state.actor.id;
+ t.upstreams.state.actor.task = { id, state: 'running', task: 'Compare three hosting plans', computer: { ...COMPUTER }, desk: { url: DESK, kind: 'browser' } };
+
+ try {
+ await check('the card reads the one Computer and shows the still slot', async () => {
+ const r = await t.get(`/web/t/${id}`, { cookie: signedIn });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.match(r.text, /class="ov-task-still"/);
+ assert.match(r.text, new RegExp(`data-src="/api/v1/web/tasks/${id}/still"`));
+ assert.match(r.text, /a shared desk host/);
+ assert.match(r.text, /metered by the minute/);
+ assert.match(r.text, /data-sf-desk-takeover-form/);
+ assert.match(r.text, new RegExp(`action="/api/v1/web/tasks/${id}/takeover"`));
+ assert.match(r.text, /data-sf-desk-giveback-form hidden/);
+ assert.ok(!r.text.includes(token), 'the token is never rendered');
+ });
+
+ await check('the still is a same-origin proxy, no-store, with the person\'s Bearer at Actor', async () => {
+ const r = await t.get(`/api/v1/web/tasks/${id}/still`, { cookie: signedIn });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.match(r.headers.get('content-type'), /^image\/jpeg/);
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ assert.strictEqual(r.headers.get('x-content-type-options'), 'nosniff');
+ assert.deepStrictEqual(r.buffer, Buffer.from('/9j/4AAQ', 'base64'));
+ const sent = t.upstreams.lastFor(`/api/v1/tasks/${id}/still`);
+ assert.strictEqual(sent.headers.authorization, `Bearer ${token}`);
+ assert.strictEqual((await t.get(`/api/v1/web/tasks/${id}/still`)).status, 401, 'signed out');
+ // A broken or hostile upstream cannot serve HTML from openvibe.surf.
+ t.upstreams.state.actor.stillType = 'text/html';
+ const bad = await t.get(`/api/v1/web/tasks/${id}/still`, { cookie: signedIn });
+ assert.strictEqual(bad.status, 502, bad.text);
+ assert.strictEqual(bad.json().code, 'still.bad_type');
+ assert.ok(!bad.text.includes('<html'));
+ t.upstreams.state.actor.stillType = 'image/jpeg';
+ });
+
+ await check('another person\'s task is the same 404 as a missing one', async () => {
+ t.upstreams.state.actor.stillStatus = 404;
+ const r = await t.get(`/api/v1/web/tasks/${id}/still`, { cookie: signedIn });
+ assert.strictEqual(r.status, 404);
+ assert.match(r.text, /No picture/);
+ });
+
+ await check('Take over calls the one API and goes to the desk; Give it back returns to the task', async () => {
+ t.upstreams.state.actor.stillStatus = 200;
+ assert.strictEqual((await t.get(`/api/v1/web/tasks/${id}/takeover`, { cookie: signedIn, method: 'POST', headers: { origin: 'https://evil.example' } })).status, 403, 'another site');
+ const take = await t.get(`/api/v1/web/tasks/${id}/takeover`, { cookie: signedIn, method: 'POST', headers: { origin: ORIGIN } });
+ assert.strictEqual(take.status, 303, take.text);
+ assert.strictEqual(take.headers.get('location'), DESK, 'the person drives on the desk\'s own page');
+ const sent = t.upstreams.lastFor(`/api/v1/tasks/${id}/takeover`);
+ assert.strictEqual(sent.headers.authorization, `Bearer ${token}`);
+ assert.deepStrictEqual(t.upstreams.state.actor.handoffs, ['takeover']);
+ const page = await t.get(`/web/t/${id}`, { cookie: signedIn });
+ assert.match(page.text, /data-sf-desk-giveback-form>/);
+ assert.match(page.text, /data-sf-desk-takeover-form hidden/);
+ const back = await t.get(`/api/v1/web/tasks/${id}/giveback`, { cookie: signedIn, method: 'POST', headers: { origin: ORIGIN } });
+ assert.strictEqual(back.status, 303);
+ assert.strictEqual(back.headers.get('location'), `/web/t/${id}`);
+ assert.deepStrictEqual(t.upstreams.state.actor.handoffs, ['takeover', 'giveback']);
+ assert.strictEqual((await t.get(`/api/v1/web/tasks/${id}/takeover`, { cookie: signedIn, method: 'POST' })).status, 403, 'no Origin is not this site');
+ });
+ } finally {
+ await t.close();
+ done();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -67,11 +67,13 @@ async function boot(opts = {}) {
@@ -67,11 +67,13 @@ async function boot(opts = {}) {
if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
if (o.form !== undefined) { body = new URLSearchParams(o.form).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
if (o.form !== undefined) { body = new URLSearchParams(o.form).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
const res = await fetch(base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });
const res = await fetch(base + p, { method: o.method || (body ? 'POST' : 'GET'), headers, body, redirect: 'manual' });
- const text = Buffer.from(await res.arrayBuffer()).toString('utf8');
+ const buffer = Buffer.from(await res.arrayBuffer());
+ const text = buffer.toString('utf8');
return {
return {
status: res.status,
status: res.status,
headers: res.headers,
headers: res.headers,
text,
text,
+ buffer,
json() { return JSON.parse(text); },
json() { return JSON.parse(text); },
};
};
}
}
@@ -70,7 +70,7 @@ async function startUpstreams() {
@@ -70,7 +70,7 @@ async function startUpstreams() {
dvr: { 9002: 'https://openvibe.media/v/9002/dvr.m3u8' },
dvr: { 9002: 'https://openvibe.media/v/9002/dvr.m3u8' },
fail: { streams: null, clips: null, moments: null },
fail: { streams: null, clips: null, moments: null },
delay: { streams: 0, clips: 0, moments: 0 },
delay: { streams: 0, clips: 0, moments: 0 },
- actor: { status: 201, id: 'tsk_01J8SURFEXAMPLE00000000', problem: null, task: null, events: [] },
+ actor: { status: 201, id: 'tsk_01J8SURFEXAMPLE00000000', problem: null, task: null, events: [], handoffs: [], still: null, stillType: 'image/jpeg', stillStatus: 200 },
requests: [],
requests: [],
counts: { streams: 0, clips: 0, moments: 0, maps: 0, map: 0, actor: 0 },
counts: { streams: 0, clips: 0, moments: 0, maps: 0, map: 0, actor: 0 },
};
};
@@ -145,6 +145,21 @@ async function startUpstreams() {
@@ -145,6 +145,21 @@ async function startUpstreams() {
for (const event of state.actor.events) res.write(`id: ${event.seq}\nevent: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`);
for (const event of state.actor.events) res.write(`id: ${event.seq}\nevent: ${event.kind}\ndata: ${JSON.stringify(event)}\n\n`);
return res.end();
return res.end();
}
}
+ // The Computer still (plan 2026-11-07) and Take over / Give it back, as Actor answers them.
+ if (p === `/api/v1/tasks/${state.actor.id}/still` && req.method === 'GET') {
+ if (state.actor.stillStatus && state.actor.stillStatus !== 200) return json(state.actor.stillStatus, { code: 'actor.task.no_still', detail: 'This task has no Computer picture.' });
+ res.writeHead(200, { 'Content-Type': state.actor.stillType || 'image/jpeg' });
+ return res.end(state.actor.still || Buffer.from('/9j/4AAQ', 'base64'));
+ }
+ const hand = p.match(new RegExp(`^/api/v1/tasks/${state.actor.id}/(takeover|giveback)$`));
+ if (hand && req.method === 'POST') {
+ const action = hand[1];
+ state.actor.handoffs = (state.actor.handoffs || []).concat(action);
+ const task = { ...(state.actor.task || { id: state.actor.id, state: 'running', task: 'Surf the web' }) };
+ if (task.computer) task.computer = { ...task.computer, driving: action === 'takeover' };
+ state.actor.task = task;
+ return json(200, task);
+ }
return json(404, { error: 'not found' });
return json(404, { error: 'not found' });
});
});