openvibers/openvibe.surf Commits

Surf's card: the Computer's still, and Take over / Give it back through one API (#99)

OP OpenVibers committed 6 days ago · 7089a24aa6 · 14 files, +290 −23

* Surf's task card shows the Computer, its still, and Take over / Give it back

The card reads the task's computer (machine, cost_hint, driving) and puts the
still in the card where the "where" line is: an <img class="ov-task-still">
refreshed every 2 s only while the page is visible, stopped when the task ends
or the tab hides. Same-origin proxy GET /api/v1/web/tasks/:id/still keeps the
person's token in the cookie; Actor's 404 passes through and the picture is
private, no-store.

Take over and Give it back post to Actor's one API on the task through this
site (same-origin only): Take over goes to the desk's own page, Give it back
returns to the card. The Dock's step now carries the same computer.

* Pin openvibe-contracts to the final Computer contracts head

* Security review: the still proxy passes only an image

Surf's still accepts image/jpeg, image/png or image/webp from Actor and
nothing else: any other content type is 502 still.bad_type with no body passed
through, so a broken or hostile upstream can never serve HTML from
openvibe.surf. Test: an upstream answering text/html gets 502.

* Merge origin/main; pin openvibe-contracts v0.327.0 and openvibe-shared v4.136.0

Both release tags exist and main's package.json says those versions. Surf's
main had moved to shared v4.135.0 while the branch was out: the merge keeps
the newer pin and moves it to the released 4.136.0 (the Dock's Take over).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 818573e..12c89ce 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex 1cb2e8c..0aee6df 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 213caa9..e4aeb51 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 6118be0..e190738 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 1631b8b..4f690b7 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/public/css/surf.css b/public/css/surf.css
iindex 0c74a9f..0cecfdb 100644
---- a/public/css/surf.css
++++ b/public/css/surf.css
@@@ -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; }
ddiff --git a/public/js/surf.js b/public/js/surf.js
iindex 6bd2fd6..e9c5353 100644
---- a/public/js/surf.js
++++ b/public/js/surf.js
@@@ -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 = '';
}
ddiff --git a/server/http/api.js b/server/http/api.js
iindex fdeb3e3..b129989 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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;
}
ddiff --git a/server/views/index.js b/server/views/index.js
iindex cd5925f..6757f09 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -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>
ddiff --git a/server/web/agent-step.js b/server/web/agent-step.js
iindex 504bc64..da95dcf 100644
---- a/server/web/agent-step.js
++++ b/server/web/agent-step.js
@@@ -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 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', s
status: 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,
ddiff --git a/server/web/tasks.js b/server/web/tasks.js
iindex 82592fb..994e95f 100644
---- a/server/web/tasks.js
++++ b/server/web/tasks.js
@@@ -64,6 +64,55 @@ function createWeb({ config, fetchImpl = globalThis.fetch, log = console, steps
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.
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, steps
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 };
ddiff --git a/test/computer.test.js b/test/computer.test.js
nnew file mode 100644
iindex 0000000..4f8c259
---- /dev/null
++++ b/test/computer.test.js
@@@ -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); });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex ffdb725..3507bd5 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -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); },
};
}
ddiff --git a/test/helpers/upstreams.js b/test/helpers/upstreams.js
iindex ed54728..cc55436 100644
---- a/test/helpers/upstreams.js
++++ b/test/helpers/upstreams.js
@@@ -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' });
});