openvibers/openvibe.surf Commits

Surf with Vibe on its own page: live steps, desk links, sources and the report at /web/t/:id (#2)

OP OpenVibers committed 16 days ago · 05ad11cb87 · 9 files, +280 −15

Changes

ddiff --git a/public/css/surf.css b/public/css/surf.css
iindex c35a37c..3ac1c47 100644
---- a/public/css/surf.css
++++ b/public/css/surf.css
@@@ -216,3 +216,36 @@ html.ov-motion .sf-party-playing .sf-party-dot { animation: sf-pulse 1.6s ease-o
.sf-party-chat-url { flex: 1 1 240px; min-height: 38px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: inherit; font: inherit; }
.sf-party-id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; color: var(--text-muted, #7386a3); }
@media (prefers-reduced-motion: reduce) { .sf-party-dot { animation: none !important; } }
++
++/* 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-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; }
++.sf-desk-state::before { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted, #7386a3); }
++.sf-desk-page[data-sf-desk-open="1"] .sf-desk-state::before { background: #22c55e; animation: sf-desk-pulse 1.6s ease-out infinite; }
++@keyframes sf-desk-pulse { from { box-shadow: 0 0 0 0 rgba(34, 197, 94, .55); } to { box-shadow: 0 0 0 8px rgba(34, 197, 94, 0); } }
++.sf-desk-page section h2 { margin: 0 0 10px; font-size: .76rem; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
++.sf-desk-view p { margin: 0; line-height: 1.5; }
++.sf-desk-view [data-sf-desk-links] { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; font-size: 0; }
++.sf-desk-view [data-sf-desk-links][hidden], .sf-desk-view [data-sf-desk-needs][hidden] { display: none; }
++.sf-desk-watch, .sf-desk-takeover { display: inline-flex; align-items: center; min-height: 40px; padding: 0 16px; border-radius: 999px; font-size: .9rem; font-weight: 800; text-decoration: none; }
++.sf-desk-watch { background: var(--accent-strong, var(--accent, #3b82f6)); color: var(--on-accent-strong, var(--on-accent, #fff)); }
++.sf-desk-takeover { border: 1px solid var(--border, #1f2d47); color: var(--text-primary, #e6edf7); }
++.sf-desk-watch:focus-visible, .sf-desk-takeover:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
++.sf-desk-view [data-sf-desk-needs] { margin-top: 12px; padding: 10px 12px; border: 1px solid color-mix(in srgb, #f59e0b 45%, transparent); border-radius: 10px; background: color-mix(in srgb, #f59e0b 14%, transparent); font-weight: 600; }
++.sf-desk-steps ol { list-style: none; margin: 0; padding: 0; max-height: 420px; overflow-y: auto; overscroll-behavior: contain; }
++.sf-desk-step { position: relative; padding: 0 0 12px 22px; font-size: .92rem; line-height: 1.45; color: var(--text-secondary, #96a7c2); overflow-wrap: anywhere; }
++.sf-desk-step::before { content: ''; position: absolute; left: 0; top: .42em; width: 9px; height: 9px; border-radius: 50%; background: var(--accent, #3b82f6); }
++.sf-desk-step::after { content: ''; position: absolute; left: 4px; top: calc(.42em + 13px); bottom: 2px; width: 1px; background: var(--border, #1f2d47); }
++.sf-desk-step:last-child { color: var(--text-primary, #e6edf7); padding-bottom: 4px; }
++.sf-desk-step:last-child::after { display: none; }
++.sf-desk-steps .sf-fine { margin: 8px 0 0; }
++.sf-desk-evidence ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
++.sf-desk-evidence li a { display: block; padding: 8px 10px; border-radius: 10px; background: color-mix(in srgb, var(--text-primary, #e6edf7) 5%, transparent); font-size: .86rem; text-decoration: none; overflow-wrap: anywhere; }
++.sf-desk-evidence li a:hover { background: color-mix(in srgb, var(--text-primary, #e6edf7) 9%, transparent); }
++.sf-desk-report-text { white-space: pre-wrap; line-height: 1.6; overflow-wrap: anywhere; }
++.sf-desk-report p { margin: 0; color: var(--text-secondary, #96a7c2); }
++.sf-desk-actor { margin: 4px 0 0; text-align: center; }
++@media (prefers-reduced-motion: reduce) { .sf-desk-page[data-sf-desk-open="1"] .sf-desk-state::before { animation: none; } }
++html[data-ov-motion=reduced] .sf-desk-page[data-sf-desk-open="1"] .sf-desk-state::before { animation: none; }
ddiff --git a/public/js/surf.js b/public/js/surf.js
iindex 083cfad..89d91de 100644
---- a/public/js/surf.js
++++ b/public/js/surf.js
@@@ -354,7 +354,83 @@
});
}
-- function run() { runMotif(); tv(); webForm(); }
++ function webTask() {
++ var root = document.querySelector('[data-sf-desk-task]');
++ if (!root || root.dataset.ready) return;
++ root.dataset.ready = '1';
++ var id = root.getAttribute('data-sf-desk-task');
++ var base = '/api/v1/web/tasks/' + encodeURIComponent(id);
++ var steps = root.querySelector('[data-sf-desk-steps]');
++ var seen = {};
++ var timer = null;
++ var stream = null;
++ function safeUrl(value) {
++ try { var u = new URL(value); return /^https?:$/.test(u.protocol) ? u.href : null; } catch (_) { return null; }
++ }
++ function botDesk(value) {
++ var u = safeUrl(value);
++ return u && new URL(u).origin === 'https://openvibe.bot' && /^\/machines\/dev_[0-9A-HJKMNP-TV-Z]{26}\/screen$/.test(new URL(u).pathname) ? u : null;
++ }
++ function snapshot(t) {
++ root.querySelector('[data-sf-desk-state]').textContent = t.state || 'Working';
++ var desk = botDesk(t.desk && t.desk.url);
++ root.querySelector('[data-sf-desk-note]').textContent = desk ? 'Your agent is working on its private desk. Open the live viewer to watch or take over.' : 'The agent has not opened a desk, or its desk has ended.';
++ var links = root.querySelector('[data-sf-desk-links]');
++ links.hidden = !desk;
++ if (desk) {
++ root.querySelector('[data-sf-desk-watch]').href = desk;
++ root.querySelector('[data-sf-desk-takeover]').href = desk;
++ }
++ var needs = root.querySelector('[data-sf-desk-needs]');
++ needs.hidden = !t.needs_you;
++ needs.textContent = t.needs_you && t.needs_you.what || '';
++ var list = root.querySelector('[data-sf-desk-sources]');
++ list.replaceChildren();
++ var sources = t.result && Array.isArray(t.result.sources) ? t.result.sources : [];
++ sources.forEach(function (value) {
++ var url = safeUrl(value);
++ if (!url) return;
++ var li = document.createElement('li'), a = document.createElement('a');
++ a.href = url; a.textContent = url; a.target = '_blank'; a.rel = 'nofollow noopener noreferrer';
++ li.appendChild(a); list.appendChild(li);
++ });
++ root.querySelector('[data-sf-desk-sources-empty]').hidden = !!list.children.length;
++ var answer = t.result && typeof t.result.answer === 'string' ? t.result.answer : '';
++ root.querySelector('[data-sf-desk-report]').textContent = answer;
++ root.querySelector('[data-sf-desk-report-empty]').hidden = !!answer;
++ if (!['queued', 'running', 'verifying'].includes(t.state)) {
++ if (timer) clearInterval(timer);
++ if (stream) stream.close();
++ root.setAttribute('data-sf-desk-open', '0'); root.querySelector('[data-sf-desk-steps-note]').textContent = 'The task has ended.';
++ }
++ }
++ function refresh() {
++ fetch(base, { credentials: 'same-origin', headers: { accept: 'application/json' } })
++ .then(function (r) { if (!r.ok) throw new Error('Task unavailable'); return r.json(); })
++ .then(snapshot)
++ .catch(function () { root.querySelector('[data-sf-desk-steps-note]').textContent = 'Connection lost. Trying again…'; });
++ }
++ function step(e) {
++ try {
++ var item = JSON.parse(e.data);
++ if (item.kind === 'end') { if (stream) stream.close(); refresh(); return; }
++ if (item.kind !== 'output' || seen[item.seq]) return;
++ seen[item.seq] = true;
++ var line = document.createElement('li');
++ line.className = 'sf-desk-step';
++ line.textContent = [item.step === 'tool_call' ? 'Used ' + (item.tool || 'a tool') : item.step === 'tool_result' ? 'Evidence' : 'Step', item.text || item.input || ''].filter(Boolean).join(': ').slice(0, 1000);
++ steps.appendChild(line);
++ root.querySelector('[data-sf-desk-steps-note]').textContent = 'Updating live as the agent works.';
++ } catch (_) { /* ignore a malformed event */ }
++ }
++ if (window.EventSource) {
++ stream = new EventSource(base + '/events');
++ ['output', 'state', 'end'].forEach(function (name) { stream.addEventListener(name, step); });
++ } else root.querySelector('[data-sf-desk-steps-note]').textContent = 'Open the full task in Actor to see its steps.';
++ if (root.getAttribute('data-sf-desk-open') === '1') timer = setInterval(refresh, 3000);
++ }
++
++ function run() { runMotif(); tv(); webForm(); webTask(); }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run); else run();
document.addEventListener('ov:boost:load', run);
})();
ddiff --git a/server/app.js b/server/app.js
iindex 623dfb4..aa2270f 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -3,7 +3,7 @@
/**
* OpenVibe.Surf — Express app factory; server/index.js listens, tests build their own instance with mock upstreams.
*
-- * /, /ride, /ride/:map, /watch, /web, /developers the pages (http/pages.js)
++ * /, /ride, /ride/:map, /watch, /web, /web/t/:taskId, /developers the pages (http/pages.js)
* /watch/party, /watch/p/:id, …/events watch together (http/party.js)
* /web/go the no-JavaScript task form
* /api/v1/watch/feed, /api/v1/ride/maps… Watch and Ride (http/api.js)
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 0b7130d..0227dd3 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -107,6 +107,12 @@ function createApi(ctx) {
}
});
++ r.get('/v1/web/tasks/:id', requireToken, async (req, res) => {
++ try { return res.json(await web.task({ token: req.webToken, id: req.params.id })); }
++ catch (err) { return problem(req, res, err.status || 502, 'web.task.unavailable', err.message); }
++ });
++ 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 }));
++
return r;
}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 72140b0..54543b4 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -7,7 +7,8 @@
* /ride/:map one map: name, tier, stages, records, the embed
* /watch the merged Live feed as a list with thumbnails and play links
* /web the Surf with Vibe form and what it does
-- * /web/go?q= the no-JavaScript form: create the task, 303 to its Actor page
++ * /web/go the no-JavaScript form: create the task, 303 to its Surf page
++ * /web/t/:taskId a person's live desk, steps, sources and report
* /developers the API, for people and agents
*
* Every page is server-rendered and complete without JavaScript. A page for a signed-in person is private and
@@@ -84,6 +85,18 @@ function createPageRoutes(ctx) {
r.get('/web', (req, res) => page(res, 200, views.web({ me: meOf(req.viewer), query: typeof req.query.q === 'string' ? req.query.q.slice(0, 500) : '' }), req.viewer));
++ r.get('/web/t/:taskId', async (req, res) => {
++ res.set('Cache-Control', 'private, no-store');
++ const token = req.viewer && req.viewer.kind === 'user' ? req.viewer.token : null;
++ if (!token) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
++ try {
++ const task = await web.task({ token, id: req.params.taskId });
++ return page(res, 200, views.webTask({ task, actorUrl: config.actorPublicUrl }), req.viewer);
++ } catch (err) {
++ return page(res, err.status === 404 ? 404 : 502, views.webTaskError({ notFound: err.status === 404 }), req.viewer);
++ }
++ });
++
// The no-JavaScript form: a POST from this site that creates one task and goes to it (303). The query is in the
// body, never in a URL (so never in a log); another site's form is refused by its Origin.
r.get('/web/go', (req, res) => res.redirect(303, '/web'));
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 9d30f53..384cb6e 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -248,12 +248,46 @@ function web({ me = {}, query = '' } = {}) {
return layout({ title: 'Surf with Vibe', description: 'Ask an agent to surf the web for you: it searches, reads pages on a private desk when needed, keeps its sources and reports back with citations.', path: '/web', body, active: '/web' });
}
++const safeWebUrl = (value) => {
++ try {
++ const u = new URL(String(value));
++ return /^https?:$/.test(u.protocol) ? u.href : null;
++ } catch { return null; }
++};
++const deskUrl = (value) => {
++ const url = safeWebUrl(value);
++ return url && new URL(url).origin === 'https://openvibe.bot' && /^\/machines\/dev_[0-9A-HJKMNP-TV-Z]{26}\/screen$/.test(new URL(url).pathname) ? url : null;
++};
++
++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);
++ 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 body = `<div class="sf-wrap sf-page-pad sf-narrow sf-desk-page" data-sf-desk-task="${esc(id)}" data-sf-desk-open="${['queued', 'running', 'verifying'].includes(task.state) ? '1' : '0'}">
++<header class="sf-page-h sf-desk-head"><p class="sf-kicker">Web · Surf with Vibe</p><h1>Surf report</h1><p class="sf-desk-query">${esc(task.task || 'Your web task')}</p><p class="sf-desk-state" data-sf-desk-state role="status">${esc(task.state || 'queued')}</p></header>
++<section class="sf-card sf-desk-view" aria-labelledby="sf-desk-view-h"><h2 id="sf-desk-view-h">Agent desk</h2><p data-sf-desk-note>${desk ? 'Your agent is working on its private desk. Open the live viewer to watch or take over.' : 'The agent has not opened a desk, or its desk has ended.'}</p>
++<p data-sf-desk-links${desk ? '' : ' hidden'}><a class="sf-desk-watch" data-sf-desk-watch href="${esc(desk || '#')}" target="_blank" rel="noopener noreferrer">Watch live</a> · <a class="sf-desk-takeover" data-sf-desk-takeover href="${esc(desk || '#')}" target="_blank" rel="noopener noreferrer">Take over</a></p><p data-sf-desk-needs${task.needs_you ? '' : ' hidden'}>${esc(task.needs_you && task.needs_you.what || '')}</p></section>
++<section class="sf-card sf-desk-steps" aria-labelledby="sf-desk-steps-h"><h2 id="sf-desk-steps-h">Live steps</h2><ol data-sf-desk-steps></ol><p class="sf-fine" data-sf-desk-steps-note>Connecting to the agent’s live steps…</p></section>
++<section class="sf-card sf-desk-evidence" aria-labelledby="sf-desk-evidence-h"><h2 id="sf-desk-evidence-h">Sources cited</h2><ul data-sf-desk-sources>${sources.map((url) => `<li><a href="${esc(url)}" target="_blank" rel="nofollow noopener noreferrer">${esc(url)}</a></li>`).join('')}</ul><p data-sf-desk-sources-empty${sources.length ? ' hidden' : ''}>Sources will appear when the report is ready.</p></section>
++<section class="sf-card sf-desk-report" aria-labelledby="sf-desk-report-h"><h2 id="sf-desk-report-h">Final report</h2><div class="sf-desk-report-text" data-sf-desk-report>${esc(report)}</div><p data-sf-desk-report-empty${report ? ' hidden' : ''}>The agent is still working.</p></section>
++<p class="sf-fine sf-desk-actor"><a href="${esc(actor)}" target="_blank" rel="noopener noreferrer">Open full task in Actor</a> · <a href="/web">Start another surf</a></p></div>`;
++ return layout({ title: 'Surf report', path: `/web/t/${encodeURIComponent(id)}`, robots: 'noindex, nofollow', body, active: '/web' });
++}
++
++function webTaskError({ notFound = false } = {}) {
++ return layout({ title: 'Surf task', path: '/web', robots: 'noindex, nofollow', body: `<div class="sf-wrap sf-page-pad sf-narrow"><h1>${notFound ? 'Task not found' : 'The task is unavailable'}</h1><p>${notFound ? 'This task is unavailable to your account.' : 'Actor did not answer. Try this page again in a moment.'}</p><p><a href="/web">Back to Surf with Vibe</a></p></div>`, active: '/web' });
++}
++
function developers() {
const rows = [
['GET /api/v1/watch/feed?topic=all|live|clips|moments&amp;cursor=', 'The merged OpenVibe.Live feed: who is live (by viewers), then clips and AI moments by freshness, never two from one channel in a row. Each item says why it is on. Cached 30 s; a source that did not answer is named in <code>sources</code>.'],
['GET /api/v1/ride/maps', 'The surf maps from OpenVibe.Games: id, name, tier, stages, author, runs, the record. Cached 60 s.'],
['GET /api/v1/ride/maps/:id', 'One map, its fastest runs and its <code>embed_url</code>.'],
-- ['POST /api/v1/web/tasks', 'Signed in, from this site: <code>{ query }</code> (3–500 characters) starts an OpenVibe.Actor task as you and answers <code>{ task_id, page }</code>.'],
++ ['POST /api/v1/web/tasks', 'Signed in, from this site: <code>{ query }</code> (3–500 characters) starts an OpenVibe.Actor task as you and answers <code>{ task_id, page }</code>; the page is in Surf.'],
++ ['GET /api/v1/web/tasks/:id', 'Your Actor task state, desk link, sources and final report, fetched with your token.'],
++ ['GET /api/v1/web/tasks/:id/events', 'Your live Actor steps as server-sent events.'],
['POST /watch/party · GET /watch/p/:id/events', 'Watch together: signed in and same-site, opens an in-memory party at <code>/watch/p/&lt;id&gt;</code> (303, or 201 JSON). The SSE stream carries the host\'s <code>{ channel_key, position_s, playing, at }</code> commands to every member; a restart ends the party.'],
];
const body = `<div class="sf-wrap sf-page-pad">
@@@ -273,7 +307,7 @@ function notFound({ party = false } = {}) {
}
module.exports = {
-- home, ride, map, watch, party, web, developers, notFound, runTime,
++ home, ride, map, watch, party, web, webTask, webTaskError, developers, notFound, runTime,
layout,
setRelease: layoutLib.setRelease,
configure: layoutLib.configure,
ddiff --git a/server/web/tasks.js b/server/web/tasks.js
iindex c267410..347d2ae 100644
---- a/server/web/tasks.js
++++ b/server/web/tasks.js
@@@ -21,6 +21,7 @@ const crypto = require('crypto');
const MIN_QUERY = 3;
const MAX_QUERY = 500;
const IDEMPOTENCY = /^[A-Za-z0-9._:-]{8,128}$/;
++const TASK_ID = /^tsk_[A-Za-z0-9]{8,64}$/;
const PREFIX = 'Surf the web for me with your tools. Use search_web to find pages, read_page to read them, and a desk when a page needs one. Keep every source you use and finish with a short report that cites each source.';
function validQuery(value) {
@@@ -39,8 +40,53 @@ function newIdempotencyKey() {
}
function createWeb({ config, fetchImpl = globalThis.fetch, log = console }) {
++ const taskPath = (id) => `${config.actorInternalUrl}/api/v1/tasks/${encodeURIComponent(id)}`;
++ const headers = (token) => ({ Accept: 'application/json', Authorization: `Bearer ${token}` });
++
++ async function task({ token, id }) {
++ if (!token) throw Object.assign(new Error('Sign in to see your task.'), { 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), { headers: 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 || res.status === 403 ? 'Task not found.' : 'OpenVibe.Actor did not answer.'), { status: res.status === 401 ? 401 : res.status === 403 || res.status === 404 ? 404 : 502 });
++ let data;
++ try { data = await res.json(); } catch { /* invalid Actor response */ }
++ if (!data || data.id !== id) throw Object.assign(new Error('OpenVibe.Actor sent an invalid task.'), { status: 502 });
++ return data;
++ }
++
++ // 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();
++ if (!TASK_ID.test(String(id || ''))) return res.status(404).end();
++ const controller = new AbortController();
++ res.on('close', () => controller.abort());
++ let upstream;
++ try {
++ upstream = await fetchImpl(`${taskPath(id)}/events`, {
++ headers: { ...headers(token), Accept: 'text/event-stream', ...(after && /^\d{1,12}$/.test(after) ? { 'Last-Event-ID': after } : {}) }, signal: controller.signal,
++ });
++ if (!upstream.ok || !upstream.body) return res.status(upstream.status === 401 ? 401 : upstream.status === 403 || upstream.status === 404 ? 404 : 502).end();
++ res.status(200).set({ 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'private, no-store', 'X-Accel-Buffering': 'no' });
++ for await (const chunk of upstream.body) {
++ if (res.destroyed) break;
++ res.write(chunk);
++ }
++ res.end();
++ } catch (err) {
++ if (!controller.signal.aborted) {
++ log.warn('[OpenVibe.Surf] Actor event stream ended:', (err && err.message) || err);
++ if (!res.headersSent) res.status(502);
++ res.end();
++ }
++ }
++ }
/**
-- * Create one task for the person whose token this is. Resolves { task_id, page } or throws
++ * Create one task for the person whose token this is. Resolves { task_id, page } (Surf's task page) or throws
* { status, code, detail, actor } — a 4xx from Actor keeps its status and detail.
*/
async function createTask({ token, query, idempotencyKey = null } = {}) {
@@@ -84,10 +130,10 @@ function createWeb({ config, fetchImpl = globalThis.fetch, log = console }) {
log.warn('[OpenVibe.Surf] Actor answered without a task id');
throw Object.assign(new Error('OpenVibe.Actor answered without a task id.'), { status: 502, code: 'actor.invalid_response' });
}
-- return { task_id: id, page: `${String(config.actorPublicUrl).replace(/\/+$/, '')}/tasks/${encodeURIComponent(id)}` };
++ return { task_id: id, page: `/web/t/${encodeURIComponent(id)}` };
}
-- return { createTask, validQuery };
++ return { createTask, task, events, validQuery };
}
--module.exports = { createWeb, taskText, validQuery, newIdempotencyKey, PREFIX, MIN_QUERY, MAX_QUERY };
++module.exports = { createWeb, taskText, validQuery, newIdempotencyKey, PREFIX, MIN_QUERY, MAX_QUERY, TASK_ID };
ddiff --git a/test/helpers/upstreams.js b/test/helpers/upstreams.js
iindex dfb6b15..ed54728 100644
---- a/test/helpers/upstreams.js
++++ b/test/helpers/upstreams.js
@@@ -70,12 +70,12 @@ 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 },
++ actor: { status: 201, id: 'tsk_01J8SURFEXAMPLE00000000', problem: null, task: null, events: [] },
requests: [],
counts: { streams: 0, clips: 0, moments: 0, maps: 0, map: 0, actor: 0 },
};
-- const srv = await listen(async (req, raw, json) => {
++ const srv = await listen(async (req, raw, json, res) => {
const url = new URL(req.url, 'http://x');
const p = url.pathname;
const entry = { method: req.method, path: p, query: Object.fromEntries(url.searchParams), headers: { ...req.headers }, body: raw };
@@@ -137,6 +137,14 @@ async function startUpstreams() {
}
return json(state.actor.status, { id: state.actor.id, state: 'queued', task: 'the task' });
}
++ if (p === `/api/v1/tasks/${state.actor.id}` && req.method === 'GET') {
++ return json(200, state.actor.task || { id: state.actor.id, state: 'running', task: 'Surf the web', result: null });
++ }
++ if (p === `/api/v1/tasks/${state.actor.id}/events` && req.method === 'GET') {
++ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
++ 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 json(404, { error: 'not found' });
});
ddiff --git a/test/web.test.js b/test/web.test.js
iindex adc5d34..0d46216 100644
---- a/test/web.test.js
++++ b/test/web.test.js
@@@ -1,7 +1,7 @@
'use strict';
/**
* Web: signed out 401, cross-site 403, the Actor call carries the person's Bearer and an Idempotency-Key and the
-- * prefixed browsing brief, Actor's refusal passes through in words, and the plain form (POST /web/go, this site only) 303s to the Actor task page.
++ * prefixed browsing brief, Actor's refusal passes through in words, and the plain form (POST /web/go, this site only) 303s to the Surf task page.
*/
const assert = require('assert');
const { boot, check, done } = require('./helpers/boot');
@@@ -45,7 +45,7 @@ const ORIGIN = 'https://openvibe.surf';
assert.strictEqual(r.status, 201, r.text);
const b = r.json();
assert.strictEqual(b.task_id, 'tsk_01J8SURFEXAMPLE00000000');
-- assert.strictEqual(b.page, 'https://openvibe.actor/tasks/tsk_01J8SURFEXAMPLE00000000');
++ assert.strictEqual(b.page, '/web/t/tsk_01J8SURFEXAMPLE00000000');
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
assert.strictEqual(t.upstreams.state.counts.actor, 1);
@@@ -100,11 +100,11 @@ const ORIGIN = 'https://openvibe.surf';
});
const own = { origin: 'https://openvibe.surf', 'sec-fetch-site': 'same-origin' };
-- await check('POST /web/go (the plain form) creates the task and 303s to the Actor task page; the query is never in a URL', async () => {
++ await check('POST /web/go (the plain form) creates the task and 303s to the Surf task page; the query is never in a URL', async () => {
t.upstreams.resetCounts();
const r = await t.get('/web/go', { cookie: signedIn, form: { q: 'find the best surf spots in Portugal' }, headers: own });
assert.strictEqual(r.status, 303, r.text);
-- assert.strictEqual(r.headers.get('location'), 'https://openvibe.actor/tasks/tsk_01J8SURFEXAMPLE00000000');
++ assert.strictEqual(r.headers.get('location'), '/web/t/tsk_01J8SURFEXAMPLE00000000');
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
const sent = t.upstreams.lastFor('/api/v1/tasks');
assert.strictEqual(sent.headers.authorization, `Bearer ${token}`);
@@@ -134,6 +134,55 @@ const ORIGIN = 'https://openvibe.surf';
assert.match(r.text, /free tier runs 5 tasks a day/);
});
++ await check('the Surf task page shows the private desk link, cited sources and report', async () => {
++ const desk = `https://openvibe.bot/machines/dev_${'A'.repeat(26)}/screen?desk=desk-surf`;
++ t.upstreams.state.actor.task = {
++ id: t.upstreams.state.actor.id, state: 'succeeded', task: '<find a flight>',
++ desk: { url: desk, kind: 'browser' }, needs_you: { what: 'Sign in on the desk.' },
++ result: { answer: 'Found a flight < $500.', sources: ['https://example.com/flight', 'javascript:alert(1)'] },
++ };
++ const r = await t.get(`/web/t/${t.upstreams.state.actor.id}`, { cookie: signedIn });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
++ assert.match(r.text, /&lt;find a flight&gt;/);
++ assert.match(r.text, /Found a flight &lt; \$500/);
++ assert.match(r.text, /https:\/\/example.com\/flight/);
++ assert.doesNotMatch(r.text, /javascript:alert/);
++ assert.match(r.text, /Take over/);
++ assert.match(r.text, /openvibe\.bot\/machines/);
++ assert.match(r.text, /openvibe\.actor\/tasks/);
++ assert.ok(!r.text.includes(token));
++ const sent = t.upstreams.lastFor(`/api/v1/tasks/${t.upstreams.state.actor.id}`);
++ assert.strictEqual(sent.headers.authorization, `Bearer ${token}`);
++ });
++
++ await check('task JSON and live events use the person’s token without exposing it to the browser', async () => {
++ const id = t.upstreams.state.actor.id;
++ t.upstreams.state.actor.events = [{ seq: 1, kind: 'output', step: 'tool_result', text: 'Read page evidence' }, { seq: 2, kind: 'end', state: 'succeeded' }];
++ const info = await t.get(`/api/v1/web/tasks/${id}`, { cookie: signedIn });
++ assert.strictEqual(info.status, 200, info.text);
++ assert.strictEqual(info.json().id, id);
++ assert.strictEqual(info.headers.get('cache-control'), 'private, no-store');
++ const live = await t.get(`/api/v1/web/tasks/${id}/events`, { cookie: signedIn });
++ assert.strictEqual(live.status, 200, live.text);
++ assert.match(live.headers.get('content-type'), /text\/event-stream/);
++ assert.match(live.text, /Read page evidence/);
++ assert.ok(!live.text.includes(token));
++ assert.strictEqual(t.upstreams.lastFor(`/api/v1/tasks/${id}/events`).headers.authorization, `Bearer ${token}`);
++ assert.strictEqual((await t.get(`/api/v1/web/tasks/${id}`)).status, 401);
++ assert.strictEqual((await t.get(`/api/v1/web/tasks/${id}/events`)).status, 401);
++ });
++
++ await check('an unrelated task and an unsafe desk URL are never presented as a live desk', async () => {
++ const id = t.upstreams.state.actor.id;
++ t.upstreams.state.actor.task = { id, state: 'running', task: 'Surf', desk: { url: 'https://evil.example/machines/dev_x/screen' } };
++ const r = await t.get(`/web/t/${id}`, { cookie: signedIn });
++ assert.strictEqual(r.status, 200);
++ assert.doesNotMatch(r.text, /evil\.example/);
++ const other = await t.get('/web/t/tsk_other00000', { cookie: signedIn });
++ assert.strictEqual(other.status, 404);
++ });
++
await t.close();
done();
})().catch((err) => { console.error(err); process.exit(1); });