openvibers/openvibe.live Commits
Agent Streams S6: the chat plays mode on the owner page — and the gate that keeps it off until the sandbox exists (#646)
Changes
@@@ -195,3 +195,48 @@ A moderator may not resume a pause the owner set. The overlay says "Paused by @m`agent_stream_controls` with who pressed it, which control and when; End also stops the room's Co-pilot reading whilethe task keeps running off camera. Curtain is S1's curtain, mute is S4's voice mute, and all four act within theirdesign limits (2 s for pause and end, 1 s for curtain and mute).++++## Chat plays (S6)++++The mode `chatplays` works the room's queue: the viewers of the stream ask for something with `!do <request>`++in the room on OpenVibe.Chat, vote for it with a thumbs up or `!vote <n>`, and the top request (pinned first,++then the most votes, then the oldest) is the next task for the desk on OpenVibe.Codes.++++The owner page carries the controls: **Chat plays** with the time box (5 to 30 minutes, the default 10), the++cap (money) and **Go**. Go arms the session once, so a stream never starts acting on chat unattended.++**Wipe now** joins pause, curtain, mute and end: it stops the run and resets the sandbox, and every press is++logged with who pressed it.++++The mode needs a public or unlisted stream, a task's service that can work the queue, and a budget the owner++set. The desk reports the queue it works; the overlay draws the current request (with who asked for it) and++the next three with their votes.++++### The sandbox worker++++A chat plays request runs in a network-isolated worker: a container or namespace with no host network, no++credentials, no access to the owner's files, and every egress request through the proxy that applies the++guard. It is never the owner's own Codes worker, Node or desk. That worker does not exist yet, so Live++refuses the mode (409 `chatplays_needs_sandbox_worker`) and the owner page says "Needs the sandbox worker;++coming soon" in as many words.++++Only OpenVibe's sandbox worker may register one. It checks in with a Network service token whose principal++holds the capability `live.agent_sandbox.register`, and it names the person it works for in++`X-OV-Subject`; the person must be the stream's owner.++++```++POST /api/streams/agent/<ags_id>/plays/worker the worker checks in (service token, X-OV-Subject)++ body: { "isolation": { "host_network": false, "credentials": false, "profile": false,++ "egress_proxy": true, "workspace": "ephemeral" } }++GET /api/streams/agent/<ags_id>/plays/worker is there a worker, and is it healthy? (the owner)++```++++The owner's own session is refused (403 `sandbox_worker.not_the_worker`), a desk ticket is refused, and a token++without the capability is refused (403 `capability.denied`). An owner who could register a worker would++declare their own unisolated machine a sandbox, and the gate would mean nothing.++++Health comes only from the worker's own check-ins: a check-in within the last minute (60 s) that reports the++isolation Live requires. A worker that cannot say it is isolated is not registered at all (422++`sandbox_worker_not_isolated`). The owner can read that state and cannot change it. A worker that falls++silent makes the mode unavailable again.++++Live stores no endpoint and never calls the worker: the worker reports, it is not called.nnew file mode 100644@@@ -0,0 +1,36 @@++-- phase: expand++-- Agent Streams S6: the chat plays mode of an agent stream. The owner sets the time box (5 to 30 minutes) and++-- the cap (money), presses Go to arm the session, and the desk reports the queue the overlay shows (its top++-- three, with their votes) and the current request.++--++-- Expand only: the columns are optional and an older release ignores them. A stream that is not a chat plays++-- stream keeps the defaults and behaves exactly as before.++++ALTER TABLE agent_streams ADD COLUMN plays_time_box_minutes integer NOT NULL DEFAULT 10;++ALTER TABLE agent_streams ADD COLUMN plays_cap_cents integer NOT NULL DEFAULT 0;++ALTER TABLE agent_streams ADD COLUMN plays_armed_at timestamptz;++ALTER TABLE agent_streams ADD COLUMN plays_spent_cents integer NOT NULL DEFAULT 0;++ALTER TABLE agent_streams ADD COLUMN plays_wiped_at timestamptz;++ALTER TABLE agent_streams ADD COLUMN plays_queue jsonb;++++-- The controls of S5 gain the S6 one: Wipe now stops the run and resets the sandbox.++DO $$ BEGIN++ ALTER TABLE agent_stream_controls DROP CONSTRAINT IF EXISTS agent_stream_controls_control_check;++ ALTER TABLE agent_stream_controls ADD CONSTRAINT agent_stream_controls_control_check++ CHECK (control IN ('pause', 'curtain', 'mute', 'end', 'wipe'));++EXCEPTION WHEN duplicate_object THEN NULL; END $$;++++-- The sandbox workers (S6): the network-isolated workers that would run a chat plays request. Only++-- OpenVibe's sandbox worker may register one (a Network service token holding live.agent_sandbox.register),++-- and it reports how it is isolated: no host network, no credentials, no profile, and every egress request++-- through the proxy that applies the guard. Live never stores an endpoint and never calls the worker.++CREATE TABLE IF NOT EXISTS agent_sandbox_workers (++ id text COLLATE "C" PRIMARY KEY, -- sbw_<ULID>++ subject text COLLATE "C" NOT NULL, -- the person the worker works for (usr_…)++ service text COLLATE "C" NOT NULL, -- the worker's own service identity (svc:…)++ isolation jsonb, -- what the worker reports about its isolation++ status text COLLATE "C" NOT NULL DEFAULT 'ready' CHECK (status IN ('ready', 'stopped')),++ last_seen_at bigint NOT NULL,++ created_at bigint NOT NULL DEFAULT (extract(epoch from now()) * 1000)::bigint++);++CREATE UNIQUE INDEX IF NOT EXISTS agent_sandbox_workers_subject ON agent_sandbox_workers (subject);@@@ -83,6 +83,14 @@.agent-overlay-caption[hidden] { display: none; }/* S4: the voice label in the overlay's foot: which voice is speaking, and in which personality. */.agent-overlay-voice { color: #cfc7ff; font-weight: 600; }++.agent-overlay-queue { margin: 2px 0 0; font-size: .82rem; color: #e8e2ff; }++.agent-overlay-queue[hidden] { display: none; }++.agent-overlay-queue-list { margin: 6px 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; }++.agent-overlay-queue-list[hidden] { display: none; }++.agent-overlay-queue-row { display: flex; align-items: baseline; gap: 8px; font-size: .82rem; }++.agent-overlay-queue-place { color: #cfc7ff; font-weight: 600; }++.agent-overlay-queue-text { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.agent-overlay-queue-votes { color: var(--text-secondary, #96a7c2); white-space: nowrap; }.agent-overlay-voice[hidden] { display: none; }.agent-overlay-caption::before { content: '\201C'; }.agent-overlay-caption::after { content: '\201D'; }@@@ -64,6 +64,22 @@</select><p class="agent-note" id="agent-copilot-note" hidden>Co-pilot needs a public or unlisted stream and a task's service that can read a digest.</p></div>++ <div class="agent-field" id="agent-plays">++ <label class="agent-field-label" for="agent-plays-timebox">Chat plays</label>++ <p class="agent-note">The desk works the room's queue. Set the time box and the cap, then press Go: the first request waits for it, so a stream never starts acting on chat unattended.</p>++ <p class="agent-note" id="agent-plays-note">Needs the sandbox worker; coming soon. A request runs in a network-isolated worker, and there is none yet.</p>++ <div class="agent-field-row">++ <label class="agent-field-label" for="agent-plays-timebox">Minutes per request</label>++ <input id="agent-plays-timebox" type="number" min="5" max="30" step="1" value="10" class="agent-input" inputmode="numeric" disabled>++ <label class="agent-field-label" for="agent-plays-cap">Cap in dollars</label>++ <input id="agent-plays-cap" type="number" min="0" step="0.5" value="5" class="agent-input" inputmode="decimal" disabled>++ </div>++ <div class="agent-actions">++ <button type="button" class="btn btn-primary" id="agent-plays-go" disabled>Go — arm the desk</button>++ <button type="button" class="btn btn-danger" id="agent-plays-wipe">Wipe now</button>++ </div>++ <p class="agent-note" id="agent-plays-state"></p>++ </div><div class="agent-field"><label class="agent-field-label" for="agent-voice-personality">How it says it</label><select id="agent-voice-personality" class="agent-select">@@@ -39,12 +39,23 @@}, 8 * 60 * 1000);}++ let queueView = null;++/** The overlay (S2): the same band the page draws, from the same steps feed, never burned into the video. */function drawOverlay(stream) {const host = $('agent-embed-overlay');if (!host || !window.AgentOverlay) return;if (!overlay) overlay = window.AgentOverlay.create({ host, title: stream.title, initial: steps });-- overlay.update({ steps, task: stream.task, workedSeconds: stream.worked_seconds, state: stream.state, pausedBy: streamView && streamView.__pausedBy });++ overlay.update({ steps, task: stream.task, workedSeconds: stream.worked_seconds, state: stream.state, pausedBy: streamView && streamView.__pausedBy, queue: queueView });++ }++++ /** S6: the chat plays queue the desk reported: the current request and the next three. */++ async function pollQueue() {++ try {++ const out = await json(`/api/streams/agent/${encodeURIComponent(id)}/plays/queue`);++ queueView = out && out.queue ? out.queue : null;++ } catch { queueView = null; }++ drawOverlay(streamView || {});}function pollSteps() {@@@ -60,6 +71,7 @@if (steps.length > 200) steps = steps.slice(-200);if (streamView) drawOverlay(streamView);} catch { /* the next turn tries again */ }++ await pollQueue();pollSteps();}, 2500);}@@@ -102,6 +114,7 @@}}drawOverlay(stream);++ await pollQueue();if (!steps.length) {try { const out = await json(`/api/streams/agent/${encodeURIComponent(id)}/steps`); steps = out.steps || []; } catch { /* */ }drawOverlay(stream);@@@ -86,6 +86,10 @@const pausedLine = el('p', 'agent-overlay-paused');pausedLine.hidden = true;++ // S6: the chat plays queue — the request the desk is on, and the next three with their votes.++ const queueLine = el('p', 'agent-overlay-queue');++ queueLine.hidden = true;++const ticks = el('div', 'agent-overlay-ticks');ticks.setAttribute('role', 'list');ticks.setAttribute('aria-label', 'The last steps');@@@ -102,8 +106,12 @@sheet.hidden = true;const sheetList = el('ol', 'agent-overlay-sheet-list');sheet.appendChild(sheetList);++ // S6: the queue's next three, with their votes.++ const queueList = el('ol', 'agent-overlay-queue-list');++ queueList.hidden = true;++ sheet.appendChild(queueList);-- body.append(taskLine, stepLine, pausedLine, captionLine, ticks, foot);++ body.append(taskLine, stepLine, pausedLine, captionLine, queueLine, ticks, foot);root.append(foldBtn, body, sheet);host.appendChild(root);@@@ -162,6 +170,29 @@});}++ /** S6: the queue the desk reported — the current request, and the next three with their votes. */++ function renderQueue(queue) {++ const q = queue || {};++ const current = q.current || null;++ const top = Array.isArray(q.top) ? q.top : [];++ if (current) {++ const who = current.author ? ` — ${current.author}` : '';++ queueLine.textContent = `Working on: ${current.text}${who}`;++ queueLine.hidden = false;++ } else {++ queueLine.textContent = '';++ queueLine.hidden = true;++ }++ queueList.replaceChildren();++ top.forEach((r, i) => {++ const li = el('li', 'agent-overlay-queue-row');++ li.append(el('span', 'agent-overlay-queue-place', `#${i + 1}`), el('span', 'agent-overlay-queue-text', r.text),++ el('span', 'agent-overlay-queue-votes', `${r.votes} vote${r.votes === 1 ? '' : 's'}`));++ queueList.appendChild(li);++ });++ queueList.hidden = !top.length;++ }++function update(next) {const data = next || {};const steps = Array.isArray(data.steps) ? data.steps : lastSteps;@@@ -197,6 +228,8 @@}renderTicks(steps);renderSheet(steps);++ // S6: the queue, when the desk reported one.++ renderQueue(data.queue || null);const tests = lastTests(steps);testsLine.textContent = tests ? `${tests.passed} of ${tests.passed + tests.failed} passing` : '';testsLine.hidden = !tests;@@@ -733,6 +733,68 @@}}++ /** S6: the chat plays controls on the owner page: the time box, the cap, Go and Wipe now. */++ function bindPlays() {++ const go = $('agent-plays-go');++ const wipe = $('agent-plays-wipe');++ const note = $('agent-plays-note');++ const box = $('agent-plays-timebox');++ const cap = $('agent-plays-cap');++ if (!go && !wipe) return;++ const tell = (text) => { const line = $('agent-plays-state'); if (line) line.textContent = text; };++ // The mode waits for a sandbox worker: until one is registered and healthy the controls stay grey and++ // the note says so.++ const setReady = (ready) => {++ if (go) go.disabled = !ready;++ if (box) box.disabled = !ready;++ if (cap) cap.disabled = !ready;++ if (note) note.hidden = !!ready;++ };++ setReady(false);++ (async () => {++ try {++ const res = await fetch(`/api/streams/agent/${encodeURIComponent(publicId)}/plays/worker`, { credentials: 'same-origin' });++ if (!res.ok) return;++ const data = await res.json();++ setReady(!!(data && data.worker && data.worker.ready));++ } catch { /* the controls stay grey: the worker is not here */ }++ })();++ if (go) go.addEventListener('click', async () => {++ const box = $('agent-plays-timebox');++ const cap = $('agent-plays-cap');++ try {++ const res = await fetch(`/api/streams/agent/${encodeURIComponent(publicId)}/plays`, {++ method: 'PUT', credentials: 'same-origin',++ headers: { 'content-type': 'application/json' },++ body: JSON.stringify({++ time_box_minutes: Number(box && box.value) || 10,++ cap_cents: Math.round((Number(cap && cap.value) || 0) * 100),++ go: true,++ }),++ });++ if (!res.ok) throw new Error('The chat plays settings could not be saved');++ tell('Go: the desk may work the queue. The first request waits for it.');++ } catch (err) {++ tell((err && err.message) || 'That did not work');++ }++ });++ if (wipe) wipe.addEventListener('click', async () => {++ try {++ const res = await fetch(`/api/streams/agent/${encodeURIComponent(publicId)}/control`, {++ method: 'POST', credentials: 'same-origin',++ headers: { 'content-type': 'application/json' },++ body: JSON.stringify({ control: 'wipe' }),++ });++ if (!res.ok) throw new Error('The desk could not be wiped');++ tell('Wiped: the run stopped and the sandbox is empty.');++ } catch (err) {++ tell((err && err.message) || 'That did not work');++ }++ });++ }++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bindPlays);++ else bindPlays();++window.loadAgentStreamPage = loadAgentStreamPage;window.stopAgentStreamPage = stop;})();@@@ -24,6 +24,11 @@ const AUDIENCE = 'openvibe.live';// before the registry entry is pinned still works.const CAPABILITIES = ['live.agent_stream.publish', 'live.agent-stream.publish'];const CAPABILITY = CAPABILITIES[0];++// The sandbox worker (S6): the capability that lets OpenVibe's sandbox worker register itself. The++// registry's id carries no hyphen; the design's spelling is accepted too, so a token minted before the++// registry entry is pinned still works.++const SANDBOX_CAPABILITIES = ['live.agent_sandbox.register', 'live.agent-sandbox.register'];++const SANDBOX_CAPABILITY = SANDBOX_CAPABILITIES[0];const FIRST_PARTY_SUB = /^(svc|node):/;/** The local account linked to a Network subject, or null. */@@@ -104,6 +109,60 @@ async function publisher(req, res, next) {}}++/** The capability on a service token, with the pinned registry's check when it knows the id. */++function holdsSandboxCapability(claims) {++ const c = claims || {};++ if (!Array.isArray(c.cap)) return false;++ return SANDBOX_CAPABILITIES.some((capability) => {++ if (!c.cap.includes(capability)) return false;++ if (contracts.capabilities.get(capability)) return contracts.capabilities.check(c, capability).allowed;++ return contracts.capabilities.grants(c.cap, capability);++ });++}++++/**++ * The sandbox worker, and only the sandbox worker.++ *++ * A first-party service token that holds live.agent_sandbox.register, naming the person it works for (the++ * same shape Codes' token has for the owner). The owner's own session is refused, a desk ticket is refused++ * (the desk is not the sandbox), and a token without the capability is refused: an owner who could++ * register a worker would declare their own unisolated machine a sandbox, and the gate would mean nothing.++ */++async function sandboxRegistrar(req, res, next) {++ try {++ await optionalAuth(req, res, () => {});++ if (req.user) {++ return res.status(403).json({ error: 'Only the sandbox worker may register itself', code: 'sandbox_worker.not_the_worker' });++ }++ const header = String(req.headers.authorization || '');++ const bearer = header.startsWith('Bearer ') ? header.slice(7).trim() : '';++ if (!bearer) return res.status(403).json({ error: 'Only the sandbox worker may register itself', code: 'sandbox_worker.not_the_worker' });++ if (bearer.startsWith('wpt_')) {++ return res.status(403).json({ error: 'A desk ticket may not register a sandbox worker', code: 'sandbox_worker.not_the_worker' });++ }++ const r = await verifyServiceToken(bearer, { ...networkVerifyOptions(), issuer: getNetworkIssuer(), audience: AUDIENCE, contracts });++ if (!r.ok) return res.status(403).json({ error: 'Only the sandbox worker may register itself', code: 'sandbox_worker.not_the_worker' });++ if (!holdsSandboxCapability(r.claims)) {++ return res.status(403).json({ error: 'This token may not register a sandbox worker', code: 'capability.denied' });++ }++ const sub = String(r.claims.sub || '');++ if (!FIRST_PARTY_SUB.test(sub)) {++ return res.status(403).json({ error: 'Only the sandbox worker may register itself', code: 'sandbox_worker.not_the_worker' });++ }++ const named = req.get ? req.get('x-ov-subject') : req.headers['x-ov-subject'];++ if (named === undefined || named === '') {++ return res.status(403).json({ error: 'The worker must say whose sandbox it is', code: 'sandbox_worker.subject_missing' });++ }++ if (!SUBJECT_RE.test(String(named))) {++ return res.status(403).json({ error: 'The worker must name the person it works for', code: 'sandbox_worker.subject_invalid' });++ }++ req.sandboxWorker = { service: sub, subject: String(named) };++ return next();++ } catch (err) {++ next(err);++ }++}++/** The visitor, if any (never refuses). `req.viewer` is a Live user row or null. */async function viewer(req, res, next) {try {@@@ -126,4 +185,4 @@ async function userFromToken(token) {return decoded ? await resolveNetworkUser(decoded) : null;}--module.exports = { publisher, viewer, viewerSubject, userForSubject, userFromToken, holdsCapability, AUDIENCE, CAPABILITY, CAPABILITIES };++module.exports = { publisher, viewer, viewerSubject, userForSubject, userFromToken, holdsCapability, holdsSandboxCapability, sandboxRegistrar, AUDIENCE, CAPABILITY, CAPABILITIES, SANDBOX_CAPABILITY, SANDBOX_CAPABILITIES };nnew file mode 100644@@@ -0,0 +1,82 @@++'use strict';++/**++ * The chat plays mode of an agent stream (Agent Streams S6, design 2026-11-06, §3, §4 and §6).++ *++ * The owner page: Chat plays with the time box (5 to 30 minutes, the default 10), the cap (money) and Go.++ * Go arms the session once: the first request of a session waits for it, so a stream never starts acting on++ * chat unattended. Wipe now stops the run and resets the sandbox; it is one of the controls, and every press++ * is logged like the other four.++ *++ * The desk reports the queue it works: the overlay shows its top three with their votes and the current++ * request with who asked it. Live keeps the last report and draws it; Chat owns the queue itself.++ */++const TIME_BOX_MIN = 5;++const TIME_BOX_MAX = 30;++const TIME_BOX_DEFAULT = 10;++const CAP_MIN_CENTS = 0;++const CAP_MAX_CENTS = 100000; // $1,000.00++++/** The time box the owner set: 5 to 30 minutes, the default 10. Null when it is not a number. */++function cleanTimeBox(value) {++ const n = Number(value);++ if (!Number.isFinite(n) || n <= 0) return null;++ return Math.min(TIME_BOX_MAX, Math.max(TIME_BOX_MIN, Math.round(n)));++}++++/** The cap the owner set, in cents. Null when it is not a number. */++function cleanCap(value) {++ const n = Number(value);++ if (!Number.isFinite(n) || n < 0) return null;++ return Math.min(CAP_MAX_CENTS, Math.max(CAP_MIN_CENTS, Math.round(n)));++}++++/** The plays settings of a stream, as the owner page and the desk read them. */++function playsState(row) {++ const r = row || {};++ const queue = r.plays_queue && typeof r.plays_queue === 'object' ? r.plays_queue : {};++ const requests = Array.isArray(queue.requests) ? queue.requests : [];++ return {++ time_box_minutes: Number(r.plays_time_box_minutes) || TIME_BOX_DEFAULT,++ cap_cents: Number(r.plays_cap_cents) || 0,++ spent_cents: Number(r.plays_spent_cents) || 0,++ armed: !!r.plays_armed_at,++ armed_at: r.plays_armed_at ? new Date(r.plays_armed_at).toISOString() : null,++ wiped_at: r.plays_wiped_at ? new Date(r.plays_wiped_at).toISOString() : null,++ queue: { requests, current: queue.current || null, reported_at: queue.reported_at || null },++ };++}++++/** The queue's top three, with their votes: what the overlay shows. */++function queueTop(row, n = 3) {++ const state = playsState(row);++ const requests = state.queue.requests++ .filter((r) => r && typeof r === 'object')++ .map((r) => ({++ request_id: String(r.request_id || ''),++ text: String(r.text || '').slice(0, 200),++ votes: Number(r.votes) || 0,++ author: (r.author && r.author.handle) ? String(r.author.handle) : null,++ pinned: r.pinned === true,++ }));++ return {++ current: state.queue.current ? {++ request_id: String(state.queue.current.request_id || ''),++ text: String(state.queue.current.text || '').slice(0, 200),++ author: (state.queue.current.author && state.queue.current.author.handle) ? String(state.queue.current.author.handle) : null,++ } : null,++ top: requests.slice(0, Math.max(0, n)),++ };++}++++/** The queue the desk reports: at most the top twenty, so a report never grows without bound. */++function cleanQueue(body) {++ const b = body || {};++ const requests = Array.isArray(b.requests) ? b.requests.slice(0, 20) : [];++ return {++ requests,++ current: b.current || null,++ reported_at: new Date().toISOString(),++ };++}++++module.exports = { TIME_BOX_MIN, TIME_BOX_MAX, TIME_BOX_DEFAULT, CAP_MIN_CENTS, CAP_MAX_CENTS, cleanTimeBox, cleanCap, playsState, queueTop, cleanQueue };@@@ -22,6 +22,8 @@const express = require('express');const auth = require('./auth');const service = require('./service');++const plays = require('./plays');++const sandbox = require('./sandbox');const tickets = require('./tickets');const frames = require('./frames');const steps = require('./steps');@@@ -396,6 +398,123 @@ router.post('/:id/end', deskOrOwner, async (req, res) => {}});++// ── The chat plays mode (S6) ───────────────────────────────────────++++/**++ * PUT /:id/plays — the owner's controls on the owner page: the time box (5 to 30 minutes), the cap (money) and++ * Go. Go arms the session once: the first request of a session waits for it. The owner may change the time box++ * and the cap at any time; a new Go after a Wipe now arms the session again.++ */++router.put('/:id/plays', auth.publisher, async (req, res) => {++ try {++ const row = await load(req, res);++ if (!row) return;++ if (Number(row.user_id) !== Number(req.publisher.user.id)) return fail(res, 403, 'not_your_stream', 'This agent stream belongs to another account');++ const body = req.body || {};++ const set = {};++ if (body.time_box_minutes !== undefined) {++ const box = plays.cleanTimeBox(body.time_box_minutes);++ if (box === null) return fail(res, 400, 'invalid_time_box', 'The time box is 5 to 30 minutes');++ set.plays_time_box_minutes = box;++ }++ if (body.cap_cents !== undefined) {++ const cap = plays.cleanCap(body.cap_cents);++ if (cap === null) return fail(res, 400, 'invalid_cap', 'That cap is not a number');++ set.plays_cap_cents = cap;++ }++ if (body.go === true) {++ // Go arms the session: it waits for a sandbox worker, like the mode does.++ const worker = await sandbox.workerFor(row.owner_subject);++ if (!worker.ok) return fail(res, 409, 'chatplays_needs_sandbox_worker', worker.reason);++ set.plays_armed_at = new Date();++ }++ if (!Object.keys(set).length) return fail(res, 400, 'nothing_to_change', 'Nothing to change');++ const next = await service.update(row, { set });++ res.set('Cache-Control', 'no-store');++ res.json({ plays: plays.playsState(next) });++ } catch (err) {++ console.error('[AgentStream] the plays settings failed:', err.message);++ fail(res, 500, 'plays_settings_failed', 'The chat plays settings could not be saved');++ }++});++++/**++ * POST /:id/plays/queue — the desk reports the queue it works: the requests with their votes and the current++ * one. Live keeps the last report and the overlay draws its top three.++ */++router.post('/:id/plays/queue', deskOrOwner, async (req, res) => {++ try {++ const row = await load(req, res);++ if (!row) return;++ const next = await service.update(row, { set: { plays_queue: plays.cleanQueue(req.body || {}) } });++ res.set('Cache-Control', 'no-store');++ res.json({ plays: plays.playsState(next), top: plays.queueTop(next, 3) });++ } catch (err) {++ console.error('[AgentStream] the queue report failed:', err.message);++ fail(res, 500, 'plays_queue_failed', 'The queue could not be reported');++ }++});++++/**++ * POST /:id/plays/worker — the sandbox worker checks in for the stream's owner. The publish ticket (or the++ * owner's session) names the stream, so the worker is registered for that owner and for nobody else.++ * GET /:id/plays/worker — what the owner page shows: is there a worker, and is it healthy?++ */++router.post('/:id/plays/worker', auth.sandboxRegistrar, async (req, res) => {++ try {++ const row = await load(req, res);++ if (!row) return;++ // The worker says whose sandbox it is, and it must be this stream's owner: a worker registered for++ // somebody else does not make this stream safe to run in.++ if (String(row.owner_subject || '') !== String(req.sandboxWorker.subject)) {++ return fail(res, 403, 'sandbox_worker.not_yours', 'This worker works for another account');++ }++ const body = req.body || {};++ const out = await sandbox.checkIn({++ subject: row.owner_subject,++ service: req.sandboxWorker.service,++ isolation: body.isolation || null,++ });++ if (!out.ok) {++ return fail(res, 422, out.code, 'A worker that cannot say it is isolated is not registered');++ }++ res.set('Cache-Control', 'no-store');++ res.json({ worker: await sandbox.state(row.owner_subject) });++ } catch (err) {++ console.error('[AgentStream] the worker check-in failed:', err.message);++ fail(res, 500, 'plays_worker_failed', 'The worker could not check in');++ }++});++++router.get('/:id/plays/queue', auth.viewer, async (req, res) => {++ try {++ const row = await load(req, res);++ if (!row) return;++ if (!await watchable(req, row)) return fail(res, 404, 'agent_stream_not_found', 'No such agent stream');++ // The queue the desk reported: the current request with who asked for it, and the next three with++ // their votes. The overlay draws this; it is the room's own business, so it is what the viewers see.++ const top = plays.queueTop(row, 3);++ res.set('Cache-Control', 'no-store');++ res.json({ queue: top });++ } catch (err) {++ console.error('[AgentStream] the queue read failed:', err.message);++ fail(res, 500, 'plays_queue_failed', 'The queue could not be read');++ }++});++++router.get('/:id/plays/worker', auth.publisher, async (req, res) => {++ try {++ const row = await load(req, res);++ if (!row) return;++ if (Number(row.user_id) !== Number(req.publisher.user.id)) return fail(res, 403, 'not_your_stream', 'This agent stream belongs to another account');++ res.set('Cache-Control', 'no-store');++ res.json({ worker: await sandbox.state(row.owner_subject) });++ } catch (err) {++ console.error('[AgentStream] the worker state failed:', err.message);++ fail(res, 500, 'plays_worker_failed', 'The worker state could not be read');++ }++});++// ── The four controls (S5) ────────────────────────────────────/**@@@ -412,8 +531,8 @@ router.post('/:id/control', auth.viewer, async (req, res) => {const role = await roleOn(row, req.viewer);if (!role) return fail(res, 403, 'not_moderator', 'Only the owner or a channel moderator may press this');const body = req.body || {};-- const control = service.CONTROLS.includes(body.control) ? body.control : null;-- if (!control) return fail(res, 400, 'invalid_control', 'control is pause, curtain, mute or end');++ const control = (service.CONTROLS.includes(body.control) || body.control === 'wipe') ? body.control : null;++ if (!control) return fail(res, 400, 'invalid_control', 'control is pause, curtain, mute, end or wipe');const handle = String(req.viewer.username || '').toLowerCase().slice(0, 32);if (!/^[a-z0-9_]{2,32}$/.test(handle)) return fail(res, 409, 'no_handle', 'This account has no handle to log');const by = { handle, role };@@@ -434,6 +553,9 @@ router.post('/:id/control', auth.viewer, async (req, res) => {next = await service.update(row, { set: { voice_muted: !!muted } });} else if (control === 'end') {next = await service.end(row, { reason: `ended_by_${role}` });++ } else if (control === 'wipe') {++ // S6: Wipe now stops the run and resets the sandbox. The desk reads it in the state it polls.++ next = await service.update(row, { set: { plays_wiped_at: new Date() } });}await require('../db/database').run(`INSERT INTO agent_stream_controls (managed_stream_id, control, by_handle, by_role, paused, paused_by)@@@ -649,7 +771,16 @@ router.put('/:id/voice', auth.publisher, async (req, res) => {const body = req.body || {};const set = {};if (body.mode !== undefined) {-- if (!['watch', 'narrated', 'copilot'].includes(body.mode)) return fail(res, 400, 'invalid_mode', 'mode is watch, narrated or copilot');++ if (!['watch', 'narrated', 'copilot', 'chatplays'].includes(body.mode)) return fail(res, 400, 'invalid_mode', 'mode is watch, narrated, copilot or chatplays');++ if (body.mode === 'chatplays') {++ // Chat plays needs a sandbox worker: a request runs in a network-isolated worker, not in the++ // owner's own Codes worker, Node or desk. There is no such worker yet, so the mode is refused.++ const worker = await sandbox.workerFor(row.owner_subject);++ if (!worker.ok) return fail(res, 409, 'chatplays_needs_sandbox_worker', worker.reason);++ if (!['public', 'unlisted'].includes(row.visibility)) return fail(res, 422, 'chatplays_needs_public', 'Chat plays needs a public or unlisted stream');++ if (!Number(row.copilot_ready)) return fail(res, 422, 'chatplays_not_ready', "This task's service cannot work the queue yet");++ if (!Number(row.plays_cap_cents)) return fail(res, 422, 'chatplays_needs_budget', 'Chat plays needs a budget: set the cap first');++ }if (body.mode === 'copilot') {// Co-pilot needs a public or unlisted stream and a task's service that can read a digest.if (!['public', 'unlisted'].includes(row.visibility)) return fail(res, 422, 'copilot_needs_public', 'Co-pilot needs a public or unlisted stream');nnew file mode 100644@@@ -0,0 +1,114 @@++'use strict';++/**++ * The sandbox worker registry (Agent Streams S6, design 2026-11-06, §1).++ *++ * A chat plays request runs in a network-isolated worker: a container or namespace with no host network, no++ * credentials and no access to the owner's files, whose egress goes through a proxy that applies the guard.++ * That worker is not built yet. Until one is registered and healthy, Live refuses the chat plays mode: the++ * mode may not be switched on, Go may not be pressed, and the owner page says so in as many words.++ *++ * Who may register a worker: only OpenVibe's sandbox worker itself. It checks in with a Network service++ * token whose principal holds the capability live.agent_sandbox.register, and it names the person it works++ * for. The owner cannot register one, a desk ticket cannot, and nobody else can: an owner who could would++ * declare their own unisolated machine a sandbox, and the gate would mean nothing.++ *++ * Health comes only from the worker's own check-ins: a check-in within the last WORKER_STALE_MS (a minute)++ * that reports the isolation Live requires. The owner can read that state and cannot change it.++ *++ * Live never stores an endpoint and never calls the worker: the worker reports, it is not called.++ */++const crypto = require('crypto');++const db = require('../db/database');++++const WORKER_STALE_MS = 60 * 1000;++const REASON = 'Needs the sandbox worker; coming soon';++++// The isolation a worker must report: no host network, no credentials, no profile, and every egress++// request through the proxy that applies the guard.++const REQUIRED_ISOLATION = {++ host_network: false,++ credentials: false,++ profile: false,++ egress_proxy: true,++ workspace: 'ephemeral',++};++++const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';++function ulid(now = Date.now()) {++ let time = '';++ for (let t = now, i = 0; i < 10; i++, t = Math.floor(t / 32)) time = ALPHABET[t % 32] + time;++ const bytes = crypto.randomBytes(16);++ let rand = '';++ for (let i = 0; i < 16; i++) rand += ALPHABET[bytes[i] % 32];++ return time + rand;++}++++/** Does the report say the worker is isolated the way Live requires? */++function isolationOk(report) {++ if (!report || typeof report !== 'object') return false;++ for (const [key, want] of Object.entries(REQUIRED_ISOLATION)) {++ if (report[key] !== want) return false;++ }++ return true;++}++++/**++ * The worker of a person. It answers { ok: true, worker } when one is there and healthy, and++ * { ok: false, code, reason } when it is not: the mode is refused either way.++ */++async function workerFor(subject, { now = Date.now() } = {}) {++ if (!subject) return { ok: false, code: 'sandbox_worker_missing', reason: REASON };++ let row = null;++ try {++ row = await db.getDb().prepare(++ "SELECT * FROM agent_sandbox_workers WHERE subject = ? AND status = 'ready' LIMIT 1").get(String(subject));++ } catch (err) {++ // No table yet (an older release): there is no worker.++ return { ok: false, code: 'sandbox_worker_missing', reason: REASON };++ }++ if (!row) return { ok: false, code: 'sandbox_worker_missing', reason: REASON };++ const seen = Number(row.last_seen_at) || 0;++ if (!seen || now - seen > WORKER_STALE_MS) {++ return { ok: false, code: 'sandbox_worker_stale', reason: REASON, worker: row };++ }++ // The worker must still report the isolation Live requires.++ let isolation = null;++ try { isolation = typeof row.isolation === 'string' ? JSON.parse(row.isolation) : row.isolation; } catch { isolation = null; }++ if (!isolationOk(isolation)) {++ return { ok: false, code: 'sandbox_worker_not_isolated', reason: REASON, worker: row };++ }++ return { ok: true, worker: row };++}++++/** The worker checks in: it is there, now, and it says how it is isolated. */++async function checkIn({ subject, service = 'unknown', isolation = null, now = Date.now() }) {++ if (!subject) return { ok: false, code: 'sandbox_worker_no_subject' };++ if (!isolationOk(isolation)) {++ // A worker that cannot say it is isolated is not one: it is not registered at all.++ return { ok: false, code: 'sandbox_worker_not_isolated' };++ }++ const at = Number(now);++ const existing = await db.getDb().prepare('SELECT id FROM agent_sandbox_workers WHERE subject = ? LIMIT 1').get(String(subject));++ const id = (existing && existing.id) || ('sbw_' + ulid(at));++ await db.getDb().prepare(++ `INSERT INTO agent_sandbox_workers (id, subject, service, isolation, status, last_seen_at, created_at)++ VALUES (?, ?, ?, ?, 'ready', ?, ?)++ ON CONFLICT (subject) DO UPDATE SET status = 'ready', service = excluded.service,++ isolation = excluded.isolation, last_seen_at = excluded.last_seen_at`)++ .run(id, String(subject), String(service || 'unknown'), JSON.stringify(isolation), at, at);++ return { ok: true, id, subject: String(subject), last_seen_at: at };++}++++/** What the owner page shows: is there a worker, and is it healthy? */++async function state(subject, { now = Date.now() } = {}) {++ const found = await workerFor(subject, { now });++ return {++ ready: found.ok === true,++ code: found.ok ? null : found.code,++ reason: found.ok ? null : REASON,++ last_seen_at: found.ok ? Number(found.worker.last_seen_at) : (found.worker ? Number(found.worker.last_seen_at) : null),++ stale_after_ms: WORKER_STALE_MS,++ };++}++++module.exports = { WORKER_STALE_MS, REASON, REQUIRED_ISOLATION, isolationOk, workerFor, checkIn, state, ulid };@@@ -77,6 +77,7 @@ const SELECT = `a.started_at, a.ended_at, a.worked_seconds, a.last_frame_path, a.last_frame_at, a.vod_id, a.allow_sites,a.voice, a.personality, a.voice_muted, a.voice_own, a.voice_own_consent_at, a.voice_paused,a.copilot_ready, a.paused, a.paused_by, a.paused_by_role, a.paused_at,++ a.plays_time_box_minutes, a.plays_cap_cents, a.plays_spent_cents, a.plays_armed_at, a.plays_wiped_at, a.plays_queue,a.unlisted_secret_hash,(SELECT s.id FROM streams s WHERE s.managed_stream_id = ms.id AND s.is_live = 1 ORDER BY s.started_at DESC LIMIT 1) AS live_session_id,(SELECT st.title FROM agent_stream_steps st WHERE st.managed_stream_id = ms.id ORDER BY st.id DESC LIMIT 1) AS latest_step_title,@@@ -78,6 +78,7 @@ const ulid = () => ids.ulid();exempt.share_access_requests = 'sharing: access requests by subject, erased by subject (eraseSharing)';exempt.share_transfers = 'sharing: offers by subject, erased by subject (eraseSharing)';exempt.agent_view_tickets = 'agent streams: view tickets carry the viewer\'s subject; the rows go with the stream (managed_streams cascade)';++ exempt.agent_sandbox_workers = 'agent streams: the sandbox worker registry names the person it works for; the row goes when the worker stops (it is not an account row)';// Every other table with a person column is handled by the schema-driven export and erase below.const handled = [...cols.keys()].filter((t) => !chatTables.includes(t));const missing = await uncovered(d, { taskKeyed: handled, exempt });nnew file mode 100644@@@ -0,0 +1,226 @@++'use strict';++/**++ * Agent streams S6, Live's part (design 2026-11-06): the chat plays mode.++ *++ * - the mode chatplays is refused unless a sandbox worker is registered and healthy: a request runs in a++ * network-isolated worker, not in the owner's own Codes worker, Node or desk (409, and the owner page++ * says the worker is still coming);++ * - only OpenVibe's sandbox worker may register one: a Network service token holding++ * live.agent_sandbox.register, naming the person it works for. The owner's session, a desk ticket and a++ * token without the capability are refused (403);++ * - a worker that does not report the isolation Live requires (no host network, no credentials, no++ * profile, egress through the proxy) is not registered at all;++ * - health comes only from the worker's own check-ins: a check-in within the last minute. A worker that++ * falls silent is not there, and the mode is refused again;++ * - the time box, the cap and the queue's top three are the owner's own, and a play request is never++ * dispatched from here.++ */++const assert = require('assert');++const http = require('http');++const express = require('express');++++process.env.NODE_ENV = 'test';++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++console.error = (...a) => process.stderr.write(a.join(' ') + '\n');++++const SID = { ann: 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1' };++const ISOLATION = { host_network: false, credentials: false, profile: false, egress_proxy: true, workspace: 'ephemeral' };++let failures = 0;++async function check(name, fn) {++ try { await fn(); quiet(' ✓', name); }++ catch (e) { failures++; quiet(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }++}++++(async () => {++ const db = require('../server/db/database');++ await db.initDb();++ const raw = db.getDb();++ await raw.prepare(++ `INSERT INTO users (id, username, display_name, email, password_hash, role, is_owner) OVERRIDING SYSTEM VALUE++ VALUES (1, 'ann', 'ann', 'ann@x', 'x', 'streamer', 0) ON CONFLICT (id) DO NOTHING`).run();++ await raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (1, 'network', '101', ?) ON CONFLICT DO NOTHING").run(SID.ann);++++ // The Network is not here: a service token is verified by this stand-in. The worker's token holds the++ // sandbox capability; the other one does not.++ const sdkAuth = require('openvibe-sdk/auth');++ sdkAuth.verifyServiceToken = async (token) => {++ if (token === 'tok-worker') return { ok: true, claims: { sub: 'svc:sandbox', cap: ['live.agent_sandbox.register'] } };++ if (token === 'tok-other') return { ok: true, claims: { sub: 'svc:other', cap: ['live.agent_stream.publish'] } };++ if (token === 'tok-person') return { ok: true, claims: { sub: SID.ann, cap: ['live.agent_sandbox.register'] } };++ return { ok: false, code: 'token.invalid' };++ };++++ const config = require('../server/config');++ config.baseUrl = 'https://openvibe.live';++ const service = require('../server/agent-streams/service');++ const sandbox = require('../server/agent-streams/sandbox');++ const chat = require('../server/chat/chat-client');++ chat.available = () => true;++ const chatCalls = [];++ chat.agentRoom = async (body) => { chatCalls.push(body); return { room: 'agent:' + body.agent_stream_id }; };++ const moderation = require('../server/chat/moderation-client');++ moderation.getChannelModeration = async () => ({ settings: {}, moderator_ids: [] });++++ const ann = await db.getUserById(1);++ const stream = await service.create({++ user: ann,++ title: 'Chat plays',++ task: { source: 'codes', ref: 'tsk_1', title: 'Work the queue', url: 'https://openvibe.codes/tasks/1' },++ visibility: 'public', copilotReady: true,++ });++++ const app = express();++ app.use(express.json());++ app.use((req, res, next) => { req.user = { 1: ann }[req.get('x-test-user')] || null; next(); });++ app.use('/api/streams/agent', require('../server/agent-streams/routes'));++ const server = await new Promise((ok) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => ok(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++ const call = async (method, url, { as = 0, body, token, subject } = {}) => {++ const headers = { 'Content-Type': 'application/json', 'x-test-user': String(as) };++ if (token) headers.authorization = `Bearer ${token}`;++ if (subject) headers['x-ov-subject'] = subject;++ const res = await fetch(`${base}${url}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });++ const text = await res.text();++ let data = {}; try { data = text ? JSON.parse(text) : {}; } catch { data = { text }; }++ return { status: res.status, data };++ };++ // The owner's own session asks: the mode is the owner's to choose.++ const voice = (body) => call('PUT', `/api/streams/agent/${stream.public_id}/voice`, { as: 1, body });++ const checkIn = (opts = {}) => call('POST', `/api/streams/agent/${stream.public_id}/plays/worker`, {++ token: 'tok-worker', subject: SID.ann, body: { isolation: ISOLATION }, ...opts,++ });++++ quiet('agent streams S6: the chat plays mode');++++ await check('the mode is refused without a sandbox worker', async () => {++ const r = await voice({ mode: 'chatplays' });++ assert.strictEqual(r.status, 409, JSON.stringify(r.data));++ assert.strictEqual(r.data.code, 'chatplays_needs_sandbox_worker');++ assert.match(String(r.data.error || ''), /sandbox worker/i, 'the reason says what is missing');++ });++++ await check('Go is refused without a sandbox worker too', async () => {++ const r = await call('PUT', `/api/streams/agent/${stream.public_id}/plays`, { as: 1, body: { time_box_minutes: 10, cap_cents: 500, go: true } });++ assert.strictEqual(r.status, 409, JSON.stringify(r.data));++ assert.strictEqual(r.data.code, 'chatplays_needs_sandbox_worker');++ });++++ await check('the owner page is told the worker is still coming', async () => {++ const r = await call('GET', `/api/streams/agent/${stream.public_id}/plays/worker`, { as: 1 });++ assert.strictEqual(r.status, 200, JSON.stringify(r.data));++ assert.strictEqual(r.data.worker.ready, false);++ assert.match(String(r.data.worker.reason || ''), /coming soon/i);++ });++++ await check("the owner's own session may not register a worker", async () => {++ const r = await call('POST', `/api/streams/agent/${stream.public_id}/plays/worker`, { as: 1, body: { isolation: ISOLATION } });++ assert.strictEqual(r.status, 403, JSON.stringify(r.data));++ assert.strictEqual(r.data.code, 'sandbox_worker.not_the_worker');++ });++++ await check('a desk ticket may not register a worker', async () => {++ const r = await call('POST', `/api/streams/agent/${stream.public_id}/plays/worker`, { token: 'wpt_a_desk_ticket', subject: SID.ann, body: { isolation: ISOLATION } });++ assert.strictEqual(r.status, 403, JSON.stringify(r.data));++ assert.strictEqual(r.data.code, 'sandbox_worker.not_the_worker');++ });++++ await check('a token without the capability may not register a worker', async () => {++ const r = await call('POST', `/api/streams/agent/${stream.public_id}/plays/worker`, { token: 'tok-other', subject: SID.ann, body: { isolation: ISOLATION } });++ assert.strictEqual(r.status, 403, JSON.stringify(r.data));++ assert.strictEqual(r.data.code, 'capability.denied');++ });++++ await check('a person token may not register a worker', async () => {++ const r = await call('POST', `/api/streams/agent/${stream.public_id}/plays/worker`, { token: 'tok-person', subject: SID.ann, body: { isolation: ISOLATION } });++ assert.strictEqual(r.status, 403, JSON.stringify(r.data));++ assert.strictEqual(r.data.code, 'sandbox_worker.not_the_worker');++ });++++ await check('a worker that does not say it is isolated is not registered', async () => {++ const r = await checkIn({ body: { isolation: { host_network: true, credentials: false, profile: false, egress_proxy: false, workspace: 'shared' } } });++ assert.strictEqual(r.status, 422, JSON.stringify(r.data));++ assert.strictEqual(r.data.code, 'sandbox_worker_not_isolated');++ const state = await call('GET', `/api/streams/agent/${stream.public_id}/plays/worker`, { as: 1 });++ assert.strictEqual(state.data.worker.ready, false, 'it is not registered at all');++ });++++ await check('the worker registers and the mode becomes available', async () => {++ const in1 = await checkIn();++ assert.strictEqual(in1.status, 200, JSON.stringify(in1.data));++ assert.strictEqual(in1.data.worker.ready, true);++ const cap = await call('PUT', `/api/streams/agent/${stream.public_id}/plays`, { as: 1, body: { time_box_minutes: 10, cap_cents: 500 } });++ assert.strictEqual(cap.status, 200, JSON.stringify(cap.data));++ const mode = await voice({ mode: 'chatplays' });++ assert.strictEqual(mode.status, 200, JSON.stringify(mode.data));++ assert.strictEqual(mode.data.voice.mode, 'chatplays');++ });++++ await check('a worker that fell silent is not there', async () => {++ await raw.prepare('UPDATE agent_sandbox_workers SET last_seen_at = ? WHERE subject = ?').run(Date.now() - 61 * 1000, SID.ann);++ const r = await voice({ mode: 'chatplays' });++ assert.strictEqual(r.status, 409, JSON.stringify(r.data));++ assert.strictEqual(r.data.code, 'chatplays_needs_sandbox_worker');++ // And it comes back when the worker checks in again.++ const in1 = await checkIn();++ assert.strictEqual(in1.status, 200, JSON.stringify(in1.data));++ assert.strictEqual(in1.data.worker.ready, true);++ });++++ await check('the controls are the owner\'s own: the time box, the cap and Go', async () => {++ const plays = require('../server/agent-streams/plays');++ assert.strictEqual(plays.cleanTimeBox(1), 5);++ assert.strictEqual(plays.cleanTimeBox(90), 30);++ assert.strictEqual(plays.cleanCap(500), 500);++ assert.strictEqual(plays.cleanCap(-5), null);++ // The owner's session still may not arm the desk by itself: Go waits for the worker like the mode does.++ const go = await call('PUT', `/api/streams/agent/${stream.public_id}/plays`, { as: 1, body: { go: true } });++ assert.strictEqual(go.status, 200, JSON.stringify(go.data));++ });++++ await check('the overlay gets the queue\'s top three and the current request', async () => {++ const plays = require('../server/agent-streams/plays');++ const row = { plays_queue: { requests: [++ { request_id: 'req_1', text: 'build a snake game', votes: 12, author: { handle: 'mira' } },++ { request_id: 'req_2', text: 'draw a house', votes: 7, author: { handle: 'bob' } },++ { request_id: 'req_3', text: 'make a quiz', votes: 3, author: { handle: 'ann' } },++ { request_id: 'req_4', text: 'make a meme', votes: 1, author: { handle: 'jo' } },++ ], current: { request_id: 'req_1', text: 'build a snake game', author: { handle: 'mira' } } } };++ const top = plays.queueTop(row, 3);++ assert.strictEqual(top.top.length, 3);++ assert.strictEqual(top.top[0].request_id, 'req_1');++ assert.strictEqual(top.top[0].votes, 12);++ assert.strictEqual(top.current.author, 'mira', 'the current request says who asked for it');++ });++++ await check('the overlay reads the queue: the current request and the next three', async () => {++ await raw.prepare('UPDATE agent_streams SET plays_queue = ? WHERE managed_stream_id = ?').run(++ JSON.stringify({++ requests: [++ { request_id: 'req_1', text: 'build a snake game', votes: 12, author: { handle: 'mira' } },++ { request_id: 'req_2', text: 'draw a house', votes: 7, author: { handle: 'bob' } },++ { request_id: 'req_3', text: 'make a quiz', votes: 3, author: { handle: 'ann' } },++ { request_id: 'req_4', text: 'make a meme', votes: 1, author: { handle: 'jo' } },++ ],++ current: { request_id: 'req_1', text: 'build a snake game', author: { handle: 'mira' } },++ }), stream.managed_stream_id);++ const r = await call('GET', `/api/streams/agent/${stream.public_id}/plays/queue`);++ assert.strictEqual(r.status, 200, JSON.stringify(r.data));++ assert.strictEqual(r.data.queue.top.length, 3, 'the overlay shows the queue\'s top three');++ assert.strictEqual(r.data.queue.top[0].request_id, 'req_1');++ assert.strictEqual(r.data.queue.top[0].votes, 12);++ assert.strictEqual(r.data.queue.current.author, 'mira', 'the current request says who asked for it');++ });++++ await check('a play request is never dispatched from here', async () => {++ const before = chatCalls.length;++ const r = await call('POST', `/api/streams/agent/${stream.public_id}/plays/queue`, { body: { requests: [], current: null } });++ assert.ok(r.status === 200 || r.status === 401 || r.status === 403, 'the desk may report the queue: ' + r.status);++ assert.strictEqual(chatCalls.length, before, 'Live asks nobody to run anything');++ });++++ server.close();++ quiet(failures ? `agent streams S6: ${failures} failed` : 'agent streams S6: the chat plays mode waits for the sandbox worker');++ process.exit(failures ? 1 : 0);++})().catch((e) => { console.error(e); process.exit(1); });
@@ -195,3 +195,48 @@ A moderator may not resume a pause the owner set. The overlay says "Paused by @m
@@ -195,3 +195,48 @@ A moderator may not resume a pause the owner set. The overlay says "Paused by @m
`agent_stream_controls` with who pressed it, which control and when; End also stops the room's Co-pilot reading while
`agent_stream_controls` with who pressed it, which control and when; End also stops the room's Co-pilot reading while
the task keeps running off camera. Curtain is S1's curtain, mute is S4's voice mute, and all four act within their
the task keeps running off camera. Curtain is S1's curtain, mute is S4's voice mute, and all four act within their
design limits (2 s for pause and end, 1 s for curtain and mute).
design limits (2 s for pause and end, 1 s for curtain and mute).
+
+## Chat plays (S6)
+
+The mode `chatplays` works the room's queue: the viewers of the stream ask for something with `!do <request>`
+in the room on OpenVibe.Chat, vote for it with a thumbs up or `!vote <n>`, and the top request (pinned first,
+then the most votes, then the oldest) is the next task for the desk on OpenVibe.Codes.
+
+The owner page carries the controls: **Chat plays** with the time box (5 to 30 minutes, the default 10), the
+cap (money) and **Go**. Go arms the session once, so a stream never starts acting on chat unattended.
+**Wipe now** joins pause, curtain, mute and end: it stops the run and resets the sandbox, and every press is
+logged with who pressed it.
+
+The mode needs a public or unlisted stream, a task's service that can work the queue, and a budget the owner
+set. The desk reports the queue it works; the overlay draws the current request (with who asked for it) and
+the next three with their votes.
+
+### The sandbox worker
+
+A chat plays request runs in a network-isolated worker: a container or namespace with no host network, no
+credentials, no access to the owner's files, and every egress request through the proxy that applies the
+guard. It is never the owner's own Codes worker, Node or desk. That worker does not exist yet, so Live
+refuses the mode (409 `chatplays_needs_sandbox_worker`) and the owner page says "Needs the sandbox worker;
+coming soon" in as many words.
+
+Only OpenVibe's sandbox worker may register one. It checks in with a Network service token whose principal
+holds the capability `live.agent_sandbox.register`, and it names the person it works for in
+`X-OV-Subject`; the person must be the stream's owner.
+
+```
+POST /api/streams/agent/<ags_id>/plays/worker the worker checks in (service token, X-OV-Subject)
+ body: { "isolation": { "host_network": false, "credentials": false, "profile": false,
+ "egress_proxy": true, "workspace": "ephemeral" } }
+GET /api/streams/agent/<ags_id>/plays/worker is there a worker, and is it healthy? (the owner)
+```
+
+The owner's own session is refused (403 `sandbox_worker.not_the_worker`), a desk ticket is refused, and a token
+without the capability is refused (403 `capability.denied`). An owner who could register a worker would
+declare their own unisolated machine a sandbox, and the gate would mean nothing.
+
+Health comes only from the worker's own check-ins: a check-in within the last minute (60 s) that reports the
+isolation Live requires. A worker that cannot say it is isolated is not registered at all (422
+`sandbox_worker_not_isolated`). The owner can read that state and cannot change it. A worker that falls
+silent makes the mode unavailable again.
+
+Live stores no endpoint and never calls the worker: the worker reports, it is not called.
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+-- phase: expand
+-- Agent Streams S6: the chat plays mode of an agent stream. The owner sets the time box (5 to 30 minutes) and
+-- the cap (money), presses Go to arm the session, and the desk reports the queue the overlay shows (its top
+-- three, with their votes) and the current request.
+--
+-- Expand only: the columns are optional and an older release ignores them. A stream that is not a chat plays
+-- stream keeps the defaults and behaves exactly as before.
+
+ALTER TABLE agent_streams ADD COLUMN plays_time_box_minutes integer NOT NULL DEFAULT 10;
+ALTER TABLE agent_streams ADD COLUMN plays_cap_cents integer NOT NULL DEFAULT 0;
+ALTER TABLE agent_streams ADD COLUMN plays_armed_at timestamptz;
+ALTER TABLE agent_streams ADD COLUMN plays_spent_cents integer NOT NULL DEFAULT 0;
+ALTER TABLE agent_streams ADD COLUMN plays_wiped_at timestamptz;
+ALTER TABLE agent_streams ADD COLUMN plays_queue jsonb;
+
+-- The controls of S5 gain the S6 one: Wipe now stops the run and resets the sandbox.
+DO $$ BEGIN
+ ALTER TABLE agent_stream_controls DROP CONSTRAINT IF EXISTS agent_stream_controls_control_check;
+ ALTER TABLE agent_stream_controls ADD CONSTRAINT agent_stream_controls_control_check
+ CHECK (control IN ('pause', 'curtain', 'mute', 'end', 'wipe'));
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
+
+-- The sandbox workers (S6): the network-isolated workers that would run a chat plays request. Only
+-- OpenVibe's sandbox worker may register one (a Network service token holding live.agent_sandbox.register),
+-- and it reports how it is isolated: no host network, no credentials, no profile, and every egress request
+-- through the proxy that applies the guard. Live never stores an endpoint and never calls the worker.
+CREATE TABLE IF NOT EXISTS agent_sandbox_workers (
+ id text COLLATE "C" PRIMARY KEY, -- sbw_<ULID>
+ subject text COLLATE "C" NOT NULL, -- the person the worker works for (usr_…)
+ service text COLLATE "C" NOT NULL, -- the worker's own service identity (svc:…)
+ isolation jsonb, -- what the worker reports about its isolation
+ status text COLLATE "C" NOT NULL DEFAULT 'ready' CHECK (status IN ('ready', 'stopped')),
+ last_seen_at bigint NOT NULL,
+ created_at bigint NOT NULL DEFAULT (extract(epoch from now()) * 1000)::bigint
+);
+CREATE UNIQUE INDEX IF NOT EXISTS agent_sandbox_workers_subject ON agent_sandbox_workers (subject);
@@ -83,6 +83,14 @@
@@ -83,6 +83,14 @@
.agent-overlay-caption[hidden] { display: none; }
.agent-overlay-caption[hidden] { display: none; }
/* S4: the voice label in the overlay's foot: which voice is speaking, and in which personality. */
/* S4: the voice label in the overlay's foot: which voice is speaking, and in which personality. */
.agent-overlay-voice { color: #cfc7ff; font-weight: 600; }
.agent-overlay-voice { color: #cfc7ff; font-weight: 600; }
+.agent-overlay-queue { margin: 2px 0 0; font-size: .82rem; color: #e8e2ff; }
+.agent-overlay-queue[hidden] { display: none; }
+.agent-overlay-queue-list { margin: 6px 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; }
+.agent-overlay-queue-list[hidden] { display: none; }
+.agent-overlay-queue-row { display: flex; align-items: baseline; gap: 8px; font-size: .82rem; }
+.agent-overlay-queue-place { color: #cfc7ff; font-weight: 600; }
+.agent-overlay-queue-text { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.agent-overlay-queue-votes { color: var(--text-secondary, #96a7c2); white-space: nowrap; }
.agent-overlay-voice[hidden] { display: none; }
.agent-overlay-voice[hidden] { display: none; }
.agent-overlay-caption::before { content: '\201C'; }
.agent-overlay-caption::before { content: '\201C'; }
.agent-overlay-caption::after { content: '\201D'; }
.agent-overlay-caption::after { content: '\201D'; }
@@ -64,6 +64,22 @@
@@ -64,6 +64,22 @@
</select>
</select>
<p class="agent-note" id="agent-copilot-note" hidden>Co-pilot needs a public or unlisted stream and a task's service that can read a digest.</p>
<p class="agent-note" id="agent-copilot-note" hidden>Co-pilot needs a public or unlisted stream and a task's service that can read a digest.</p>
</div>
</div>
+ <div class="agent-field" id="agent-plays">
+ <label class="agent-field-label" for="agent-plays-timebox">Chat plays</label>
+ <p class="agent-note">The desk works the room's queue. Set the time box and the cap, then press Go: the first request waits for it, so a stream never starts acting on chat unattended.</p>
+ <p class="agent-note" id="agent-plays-note">Needs the sandbox worker; coming soon. A request runs in a network-isolated worker, and there is none yet.</p>
+ <div class="agent-field-row">
+ <label class="agent-field-label" for="agent-plays-timebox">Minutes per request</label>
+ <input id="agent-plays-timebox" type="number" min="5" max="30" step="1" value="10" class="agent-input" inputmode="numeric" disabled>
+ <label class="agent-field-label" for="agent-plays-cap">Cap in dollars</label>
+ <input id="agent-plays-cap" type="number" min="0" step="0.5" value="5" class="agent-input" inputmode="decimal" disabled>
+ </div>
+ <div class="agent-actions">
+ <button type="button" class="btn btn-primary" id="agent-plays-go" disabled>Go — arm the desk</button>
+ <button type="button" class="btn btn-danger" id="agent-plays-wipe">Wipe now</button>
+ </div>
+ <p class="agent-note" id="agent-plays-state"></p>
+ </div>
<div class="agent-field">
<div class="agent-field">
<label class="agent-field-label" for="agent-voice-personality">How it says it</label>
<label class="agent-field-label" for="agent-voice-personality">How it says it</label>
<select id="agent-voice-personality" class="agent-select">
<select id="agent-voice-personality" class="agent-select">
@@ -39,12 +39,23 @@
@@ -39,12 +39,23 @@
}, 8 * 60 * 1000);
}, 8 * 60 * 1000);
}
}
+ let queueView = null;
+
/** The overlay (S2): the same band the page draws, from the same steps feed, never burned into the video. */
/** The overlay (S2): the same band the page draws, from the same steps feed, never burned into the video. */
function drawOverlay(stream) {
function drawOverlay(stream) {
const host = $('agent-embed-overlay');
const host = $('agent-embed-overlay');
if (!host || !window.AgentOverlay) return;
if (!host || !window.AgentOverlay) return;
if (!overlay) overlay = window.AgentOverlay.create({ host, title: stream.title, initial: steps });
if (!overlay) overlay = window.AgentOverlay.create({ host, title: stream.title, initial: steps });
- overlay.update({ steps, task: stream.task, workedSeconds: stream.worked_seconds, state: stream.state, pausedBy: streamView && streamView.__pausedBy });
+ overlay.update({ steps, task: stream.task, workedSeconds: stream.worked_seconds, state: stream.state, pausedBy: streamView && streamView.__pausedBy, queue: queueView });
+ }
+
+ /** S6: the chat plays queue the desk reported: the current request and the next three. */
+ async function pollQueue() {
+ try {
+ const out = await json(`/api/streams/agent/${encodeURIComponent(id)}/plays/queue`);
+ queueView = out && out.queue ? out.queue : null;
+ } catch { queueView = null; }
+ drawOverlay(streamView || {});
}
}
function pollSteps() {
function pollSteps() {
@@ -60,6 +71,7 @@
@@ -60,6 +71,7 @@
if (steps.length > 200) steps = steps.slice(-200);
if (steps.length > 200) steps = steps.slice(-200);
if (streamView) drawOverlay(streamView);
if (streamView) drawOverlay(streamView);
} catch { /* the next turn tries again */ }
} catch { /* the next turn tries again */ }
+ await pollQueue();
pollSteps();
pollSteps();
}, 2500);
}, 2500);
}
}
@@ -102,6 +114,7 @@
@@ -102,6 +114,7 @@
}
}
}
}
drawOverlay(stream);
drawOverlay(stream);
+ await pollQueue();
if (!steps.length) {
if (!steps.length) {
try { const out = await json(`/api/streams/agent/${encodeURIComponent(id)}/steps`); steps = out.steps || []; } catch { /* */ }
try { const out = await json(`/api/streams/agent/${encodeURIComponent(id)}/steps`); steps = out.steps || []; } catch { /* */ }
drawOverlay(stream);
drawOverlay(stream);
@@ -86,6 +86,10 @@
@@ -86,6 +86,10 @@
const pausedLine = el('p', 'agent-overlay-paused');
const pausedLine = el('p', 'agent-overlay-paused');
pausedLine.hidden = true;
pausedLine.hidden = true;
+ // S6: the chat plays queue — the request the desk is on, and the next three with their votes.
+ const queueLine = el('p', 'agent-overlay-queue');
+ queueLine.hidden = true;
+
const ticks = el('div', 'agent-overlay-ticks');
const ticks = el('div', 'agent-overlay-ticks');
ticks.setAttribute('role', 'list');
ticks.setAttribute('role', 'list');
ticks.setAttribute('aria-label', 'The last steps');
ticks.setAttribute('aria-label', 'The last steps');
@@ -102,8 +106,12 @@
@@ -102,8 +106,12 @@
sheet.hidden = true;
sheet.hidden = true;
const sheetList = el('ol', 'agent-overlay-sheet-list');
const sheetList = el('ol', 'agent-overlay-sheet-list');
sheet.appendChild(sheetList);
sheet.appendChild(sheetList);
+ // S6: the queue's next three, with their votes.
+ const queueList = el('ol', 'agent-overlay-queue-list');
+ queueList.hidden = true;
+ sheet.appendChild(queueList);
- body.append(taskLine, stepLine, pausedLine, captionLine, ticks, foot);
+ body.append(taskLine, stepLine, pausedLine, captionLine, queueLine, ticks, foot);
root.append(foldBtn, body, sheet);
root.append(foldBtn, body, sheet);
host.appendChild(root);
host.appendChild(root);
@@ -162,6 +170,29 @@
@@ -162,6 +170,29 @@
});
});
}
}
+ /** S6: the queue the desk reported — the current request, and the next three with their votes. */
+ function renderQueue(queue) {
+ const q = queue || {};
+ const current = q.current || null;
+ const top = Array.isArray(q.top) ? q.top : [];
+ if (current) {
+ const who = current.author ? ` — ${current.author}` : '';
+ queueLine.textContent = `Working on: ${current.text}${who}`;
+ queueLine.hidden = false;
+ } else {
+ queueLine.textContent = '';
+ queueLine.hidden = true;
+ }
+ queueList.replaceChildren();
+ top.forEach((r, i) => {
+ const li = el('li', 'agent-overlay-queue-row');
+ li.append(el('span', 'agent-overlay-queue-place', `#${i + 1}`), el('span', 'agent-overlay-queue-text', r.text),
+ el('span', 'agent-overlay-queue-votes', `${r.votes} vote${r.votes === 1 ? '' : 's'}`));
+ queueList.appendChild(li);
+ });
+ queueList.hidden = !top.length;
+ }
+
function update(next) {
function update(next) {
const data = next || {};
const data = next || {};
const steps = Array.isArray(data.steps) ? data.steps : lastSteps;
const steps = Array.isArray(data.steps) ? data.steps : lastSteps;
@@ -197,6 +228,8 @@
@@ -197,6 +228,8 @@
}
}
renderTicks(steps);
renderTicks(steps);
renderSheet(steps);
renderSheet(steps);
+ // S6: the queue, when the desk reported one.
+ renderQueue(data.queue || null);
const tests = lastTests(steps);
const tests = lastTests(steps);
testsLine.textContent = tests ? `${tests.passed} of ${tests.passed + tests.failed} passing` : '';
testsLine.textContent = tests ? `${tests.passed} of ${tests.passed + tests.failed} passing` : '';
testsLine.hidden = !tests;
testsLine.hidden = !tests;
@@ -733,6 +733,68 @@
@@ -733,6 +733,68 @@
}
}
}
}
+ /** S6: the chat plays controls on the owner page: the time box, the cap, Go and Wipe now. */
+ function bindPlays() {
+ const go = $('agent-plays-go');
+ const wipe = $('agent-plays-wipe');
+ const note = $('agent-plays-note');
+ const box = $('agent-plays-timebox');
+ const cap = $('agent-plays-cap');
+ if (!go && !wipe) return;
+ const tell = (text) => { const line = $('agent-plays-state'); if (line) line.textContent = text; };
+ // The mode waits for a sandbox worker: until one is registered and healthy the controls stay grey and
+ // the note says so.
+ const setReady = (ready) => {
+ if (go) go.disabled = !ready;
+ if (box) box.disabled = !ready;
+ if (cap) cap.disabled = !ready;
+ if (note) note.hidden = !!ready;
+ };
+ setReady(false);
+ (async () => {
+ try {
+ const res = await fetch(`/api/streams/agent/${encodeURIComponent(publicId)}/plays/worker`, { credentials: 'same-origin' });
+ if (!res.ok) return;
+ const data = await res.json();
+ setReady(!!(data && data.worker && data.worker.ready));
+ } catch { /* the controls stay grey: the worker is not here */ }
+ })();
+ if (go) go.addEventListener('click', async () => {
+ const box = $('agent-plays-timebox');
+ const cap = $('agent-plays-cap');
+ try {
+ const res = await fetch(`/api/streams/agent/${encodeURIComponent(publicId)}/plays`, {
+ method: 'PUT', credentials: 'same-origin',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ time_box_minutes: Number(box && box.value) || 10,
+ cap_cents: Math.round((Number(cap && cap.value) || 0) * 100),
+ go: true,
+ }),
+ });
+ if (!res.ok) throw new Error('The chat plays settings could not be saved');
+ tell('Go: the desk may work the queue. The first request waits for it.');
+ } catch (err) {
+ tell((err && err.message) || 'That did not work');
+ }
+ });
+ if (wipe) wipe.addEventListener('click', async () => {
+ try {
+ const res = await fetch(`/api/streams/agent/${encodeURIComponent(publicId)}/control`, {
+ method: 'POST', credentials: 'same-origin',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ control: 'wipe' }),
+ });
+ if (!res.ok) throw new Error('The desk could not be wiped');
+ tell('Wiped: the run stopped and the sandbox is empty.');
+ } catch (err) {
+ tell((err && err.message) || 'That did not work');
+ }
+ });
+ }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bindPlays);
+ else bindPlays();
+
window.loadAgentStreamPage = loadAgentStreamPage;
window.loadAgentStreamPage = loadAgentStreamPage;
window.stopAgentStreamPage = stop;
window.stopAgentStreamPage = stop;
})();
})();
@@ -24,6 +24,11 @@ const AUDIENCE = 'openvibe.live';
@@ -24,6 +24,11 @@ const AUDIENCE = 'openvibe.live';
// before the registry entry is pinned still works.
// before the registry entry is pinned still works.
const CAPABILITIES = ['live.agent_stream.publish', 'live.agent-stream.publish'];
const CAPABILITIES = ['live.agent_stream.publish', 'live.agent-stream.publish'];
const CAPABILITY = CAPABILITIES[0];
const CAPABILITY = CAPABILITIES[0];
+// The sandbox worker (S6): the capability that lets OpenVibe's sandbox worker register itself. The
+// registry's id carries no hyphen; the design's spelling is accepted too, so a token minted before the
+// registry entry is pinned still works.
+const SANDBOX_CAPABILITIES = ['live.agent_sandbox.register', 'live.agent-sandbox.register'];
+const SANDBOX_CAPABILITY = SANDBOX_CAPABILITIES[0];
const FIRST_PARTY_SUB = /^(svc|node):/;
const FIRST_PARTY_SUB = /^(svc|node):/;
/** The local account linked to a Network subject, or null. */
/** The local account linked to a Network subject, or null. */
@@ -104,6 +109,60 @@ async function publisher(req, res, next) {
@@ -104,6 +109,60 @@ async function publisher(req, res, next) {
}
}
}
}
+/** The capability on a service token, with the pinned registry's check when it knows the id. */
+function holdsSandboxCapability(claims) {
+ const c = claims || {};
+ if (!Array.isArray(c.cap)) return false;
+ return SANDBOX_CAPABILITIES.some((capability) => {
+ if (!c.cap.includes(capability)) return false;
+ if (contracts.capabilities.get(capability)) return contracts.capabilities.check(c, capability).allowed;
+ return contracts.capabilities.grants(c.cap, capability);
+ });
+}
+
+/**
+ * The sandbox worker, and only the sandbox worker.
+ *
+ * A first-party service token that holds live.agent_sandbox.register, naming the person it works for (the
+ * same shape Codes' token has for the owner). The owner's own session is refused, a desk ticket is refused
+ * (the desk is not the sandbox), and a token without the capability is refused: an owner who could
+ * register a worker would declare their own unisolated machine a sandbox, and the gate would mean nothing.
+ */
+async function sandboxRegistrar(req, res, next) {
+ try {
+ await optionalAuth(req, res, () => {});
+ if (req.user) {
+ return res.status(403).json({ error: 'Only the sandbox worker may register itself', code: 'sandbox_worker.not_the_worker' });
+ }
+ const header = String(req.headers.authorization || '');
+ const bearer = header.startsWith('Bearer ') ? header.slice(7).trim() : '';
+ if (!bearer) return res.status(403).json({ error: 'Only the sandbox worker may register itself', code: 'sandbox_worker.not_the_worker' });
+ if (bearer.startsWith('wpt_')) {
+ return res.status(403).json({ error: 'A desk ticket may not register a sandbox worker', code: 'sandbox_worker.not_the_worker' });
+ }
+ const r = await verifyServiceToken(bearer, { ...networkVerifyOptions(), issuer: getNetworkIssuer(), audience: AUDIENCE, contracts });
+ if (!r.ok) return res.status(403).json({ error: 'Only the sandbox worker may register itself', code: 'sandbox_worker.not_the_worker' });
+ if (!holdsSandboxCapability(r.claims)) {
+ return res.status(403).json({ error: 'This token may not register a sandbox worker', code: 'capability.denied' });
+ }
+ const sub = String(r.claims.sub || '');
+ if (!FIRST_PARTY_SUB.test(sub)) {
+ return res.status(403).json({ error: 'Only the sandbox worker may register itself', code: 'sandbox_worker.not_the_worker' });
+ }
+ const named = req.get ? req.get('x-ov-subject') : req.headers['x-ov-subject'];
+ if (named === undefined || named === '') {
+ return res.status(403).json({ error: 'The worker must say whose sandbox it is', code: 'sandbox_worker.subject_missing' });
+ }
+ if (!SUBJECT_RE.test(String(named))) {
+ return res.status(403).json({ error: 'The worker must name the person it works for', code: 'sandbox_worker.subject_invalid' });
+ }
+ req.sandboxWorker = { service: sub, subject: String(named) };
+ return next();
+ } catch (err) {
+ next(err);
+ }
+}
+
/** The visitor, if any (never refuses). `req.viewer` is a Live user row or null. */
/** The visitor, if any (never refuses). `req.viewer` is a Live user row or null. */
async function viewer(req, res, next) {
async function viewer(req, res, next) {
try {
try {
@@ -126,4 +185,4 @@ async function userFromToken(token) {
@@ -126,4 +185,4 @@ async function userFromToken(token) {
return decoded ? await resolveNetworkUser(decoded) : null;
return decoded ? await resolveNetworkUser(decoded) : null;
}
}
-module.exports = { publisher, viewer, viewerSubject, userForSubject, userFromToken, holdsCapability, AUDIENCE, CAPABILITY, CAPABILITIES };
+module.exports = { publisher, viewer, viewerSubject, userForSubject, userFromToken, holdsCapability, holdsSandboxCapability, sandboxRegistrar, AUDIENCE, CAPABILITY, CAPABILITIES, SANDBOX_CAPABILITY, SANDBOX_CAPABILITIES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
+'use strict';
+/**
+ * The chat plays mode of an agent stream (Agent Streams S6, design 2026-11-06, §3, §4 and §6).
+ *
+ * The owner page: Chat plays with the time box (5 to 30 minutes, the default 10), the cap (money) and Go.
+ * Go arms the session once: the first request of a session waits for it, so a stream never starts acting on
+ * chat unattended. Wipe now stops the run and resets the sandbox; it is one of the controls, and every press
+ * is logged like the other four.
+ *
+ * The desk reports the queue it works: the overlay shows its top three with their votes and the current
+ * request with who asked it. Live keeps the last report and draws it; Chat owns the queue itself.
+ */
+const TIME_BOX_MIN = 5;
+const TIME_BOX_MAX = 30;
+const TIME_BOX_DEFAULT = 10;
+const CAP_MIN_CENTS = 0;
+const CAP_MAX_CENTS = 100000; // $1,000.00
+
+/** The time box the owner set: 5 to 30 minutes, the default 10. Null when it is not a number. */
+function cleanTimeBox(value) {
+ const n = Number(value);
+ if (!Number.isFinite(n) || n <= 0) return null;
+ return Math.min(TIME_BOX_MAX, Math.max(TIME_BOX_MIN, Math.round(n)));
+}
+
+/** The cap the owner set, in cents. Null when it is not a number. */
+function cleanCap(value) {
+ const n = Number(value);
+ if (!Number.isFinite(n) || n < 0) return null;
+ return Math.min(CAP_MAX_CENTS, Math.max(CAP_MIN_CENTS, Math.round(n)));
+}
+
+/** The plays settings of a stream, as the owner page and the desk read them. */
+function playsState(row) {
+ const r = row || {};
+ const queue = r.plays_queue && typeof r.plays_queue === 'object' ? r.plays_queue : {};
+ const requests = Array.isArray(queue.requests) ? queue.requests : [];
+ return {
+ time_box_minutes: Number(r.plays_time_box_minutes) || TIME_BOX_DEFAULT,
+ cap_cents: Number(r.plays_cap_cents) || 0,
+ spent_cents: Number(r.plays_spent_cents) || 0,
+ armed: !!r.plays_armed_at,
+ armed_at: r.plays_armed_at ? new Date(r.plays_armed_at).toISOString() : null,
+ wiped_at: r.plays_wiped_at ? new Date(r.plays_wiped_at).toISOString() : null,
+ queue: { requests, current: queue.current || null, reported_at: queue.reported_at || null },
+ };
+}
+
+/** The queue's top three, with their votes: what the overlay shows. */
+function queueTop(row, n = 3) {
+ const state = playsState(row);
+ const requests = state.queue.requests
+ .filter((r) => r && typeof r === 'object')
+ .map((r) => ({
+ request_id: String(r.request_id || ''),
+ text: String(r.text || '').slice(0, 200),
+ votes: Number(r.votes) || 0,
+ author: (r.author && r.author.handle) ? String(r.author.handle) : null,
+ pinned: r.pinned === true,
+ }));
+ return {
+ current: state.queue.current ? {
+ request_id: String(state.queue.current.request_id || ''),
+ text: String(state.queue.current.text || '').slice(0, 200),
+ author: (state.queue.current.author && state.queue.current.author.handle) ? String(state.queue.current.author.handle) : null,
+ } : null,
+ top: requests.slice(0, Math.max(0, n)),
+ };
+}
+
+/** The queue the desk reports: at most the top twenty, so a report never grows without bound. */
+function cleanQueue(body) {
+ const b = body || {};
+ const requests = Array.isArray(b.requests) ? b.requests.slice(0, 20) : [];
+ return {
+ requests,
+ current: b.current || null,
+ reported_at: new Date().toISOString(),
+ };
+}
+
+module.exports = { TIME_BOX_MIN, TIME_BOX_MAX, TIME_BOX_DEFAULT, CAP_MIN_CENTS, CAP_MAX_CENTS, cleanTimeBox, cleanCap, playsState, queueTop, cleanQueue };
@@ -22,6 +22,8 @@
@@ -22,6 +22,8 @@
const express = require('express');
const express = require('express');
const auth = require('./auth');
const auth = require('./auth');
const service = require('./service');
const service = require('./service');
+const plays = require('./plays');
+const sandbox = require('./sandbox');
const tickets = require('./tickets');
const tickets = require('./tickets');
const frames = require('./frames');
const frames = require('./frames');
const steps = require('./steps');
const steps = require('./steps');
@@ -396,6 +398,123 @@ router.post('/:id/end', deskOrOwner, async (req, res) => {
@@ -396,6 +398,123 @@ router.post('/:id/end', deskOrOwner, async (req, res) => {
}
}
});
});
+// ── The chat plays mode (S6) ───────────────────────────────────────
+
+/**
+ * PUT /:id/plays — the owner's controls on the owner page: the time box (5 to 30 minutes), the cap (money) and
+ * Go. Go arms the session once: the first request of a session waits for it. The owner may change the time box
+ * and the cap at any time; a new Go after a Wipe now arms the session again.
+ */
+router.put('/:id/plays', auth.publisher, async (req, res) => {
+ try {
+ const row = await load(req, res);
+ if (!row) return;
+ if (Number(row.user_id) !== Number(req.publisher.user.id)) return fail(res, 403, 'not_your_stream', 'This agent stream belongs to another account');
+ const body = req.body || {};
+ const set = {};
+ if (body.time_box_minutes !== undefined) {
+ const box = plays.cleanTimeBox(body.time_box_minutes);
+ if (box === null) return fail(res, 400, 'invalid_time_box', 'The time box is 5 to 30 minutes');
+ set.plays_time_box_minutes = box;
+ }
+ if (body.cap_cents !== undefined) {
+ const cap = plays.cleanCap(body.cap_cents);
+ if (cap === null) return fail(res, 400, 'invalid_cap', 'That cap is not a number');
+ set.plays_cap_cents = cap;
+ }
+ if (body.go === true) {
+ // Go arms the session: it waits for a sandbox worker, like the mode does.
+ const worker = await sandbox.workerFor(row.owner_subject);
+ if (!worker.ok) return fail(res, 409, 'chatplays_needs_sandbox_worker', worker.reason);
+ set.plays_armed_at = new Date();
+ }
+ if (!Object.keys(set).length) return fail(res, 400, 'nothing_to_change', 'Nothing to change');
+ const next = await service.update(row, { set });
+ res.set('Cache-Control', 'no-store');
+ res.json({ plays: plays.playsState(next) });
+ } catch (err) {
+ console.error('[AgentStream] the plays settings failed:', err.message);
+ fail(res, 500, 'plays_settings_failed', 'The chat plays settings could not be saved');
+ }
+});
+
+/**
+ * POST /:id/plays/queue — the desk reports the queue it works: the requests with their votes and the current
+ * one. Live keeps the last report and the overlay draws its top three.
+ */
+router.post('/:id/plays/queue', deskOrOwner, async (req, res) => {
+ try {
+ const row = await load(req, res);
+ if (!row) return;
+ const next = await service.update(row, { set: { plays_queue: plays.cleanQueue(req.body || {}) } });
+ res.set('Cache-Control', 'no-store');
+ res.json({ plays: plays.playsState(next), top: plays.queueTop(next, 3) });
+ } catch (err) {
+ console.error('[AgentStream] the queue report failed:', err.message);
+ fail(res, 500, 'plays_queue_failed', 'The queue could not be reported');
+ }
+});
+
+/**
+ * POST /:id/plays/worker — the sandbox worker checks in for the stream's owner. The publish ticket (or the
+ * owner's session) names the stream, so the worker is registered for that owner and for nobody else.
+ * GET /:id/plays/worker — what the owner page shows: is there a worker, and is it healthy?
+ */
+router.post('/:id/plays/worker', auth.sandboxRegistrar, async (req, res) => {
+ try {
+ const row = await load(req, res);
+ if (!row) return;
+ // The worker says whose sandbox it is, and it must be this stream's owner: a worker registered for
+ // somebody else does not make this stream safe to run in.
+ if (String(row.owner_subject || '') !== String(req.sandboxWorker.subject)) {
+ return fail(res, 403, 'sandbox_worker.not_yours', 'This worker works for another account');
+ }
+ const body = req.body || {};
+ const out = await sandbox.checkIn({
+ subject: row.owner_subject,
+ service: req.sandboxWorker.service,
+ isolation: body.isolation || null,
+ });
+ if (!out.ok) {
+ return fail(res, 422, out.code, 'A worker that cannot say it is isolated is not registered');
+ }
+ res.set('Cache-Control', 'no-store');
+ res.json({ worker: await sandbox.state(row.owner_subject) });
+ } catch (err) {
+ console.error('[AgentStream] the worker check-in failed:', err.message);
+ fail(res, 500, 'plays_worker_failed', 'The worker could not check in');
+ }
+});
+
+router.get('/:id/plays/queue', auth.viewer, async (req, res) => {
+ try {
+ const row = await load(req, res);
+ if (!row) return;
+ if (!await watchable(req, row)) return fail(res, 404, 'agent_stream_not_found', 'No such agent stream');
+ // The queue the desk reported: the current request with who asked for it, and the next three with
+ // their votes. The overlay draws this; it is the room's own business, so it is what the viewers see.
+ const top = plays.queueTop(row, 3);
+ res.set('Cache-Control', 'no-store');
+ res.json({ queue: top });
+ } catch (err) {
+ console.error('[AgentStream] the queue read failed:', err.message);
+ fail(res, 500, 'plays_queue_failed', 'The queue could not be read');
+ }
+});
+
+router.get('/:id/plays/worker', auth.publisher, async (req, res) => {
+ try {
+ const row = await load(req, res);
+ if (!row) return;
+ if (Number(row.user_id) !== Number(req.publisher.user.id)) return fail(res, 403, 'not_your_stream', 'This agent stream belongs to another account');
+ res.set('Cache-Control', 'no-store');
+ res.json({ worker: await sandbox.state(row.owner_subject) });
+ } catch (err) {
+ console.error('[AgentStream] the worker state failed:', err.message);
+ fail(res, 500, 'plays_worker_failed', 'The worker state could not be read');
+ }
+});
+
// ── The four controls (S5) ────────────────────────────────────
// ── The four controls (S5) ────────────────────────────────────
/**
/**
@@ -412,8 +531,8 @@ router.post('/:id/control', auth.viewer, async (req, res) => {
@@ -412,8 +531,8 @@ router.post('/:id/control', auth.viewer, async (req, res) => {
const role = await roleOn(row, req.viewer);
const role = await roleOn(row, req.viewer);
if (!role) return fail(res, 403, 'not_moderator', 'Only the owner or a channel moderator may press this');
if (!role) return fail(res, 403, 'not_moderator', 'Only the owner or a channel moderator may press this');
const body = req.body || {};
const body = req.body || {};
- const control = service.CONTROLS.includes(body.control) ? body.control : null;
- if (!control) return fail(res, 400, 'invalid_control', 'control is pause, curtain, mute or end');
+ const control = (service.CONTROLS.includes(body.control) || body.control === 'wipe') ? body.control : null;
+ if (!control) return fail(res, 400, 'invalid_control', 'control is pause, curtain, mute, end or wipe');
const handle = String(req.viewer.username || '').toLowerCase().slice(0, 32);
const handle = String(req.viewer.username || '').toLowerCase().slice(0, 32);
if (!/^[a-z0-9_]{2,32}$/.test(handle)) return fail(res, 409, 'no_handle', 'This account has no handle to log');
if (!/^[a-z0-9_]{2,32}$/.test(handle)) return fail(res, 409, 'no_handle', 'This account has no handle to log');
const by = { handle, role };
const by = { handle, role };
@@ -434,6 +553,9 @@ router.post('/:id/control', auth.viewer, async (req, res) => {
@@ -434,6 +553,9 @@ router.post('/:id/control', auth.viewer, async (req, res) => {
next = await service.update(row, { set: { voice_muted: !!muted } });
next = await service.update(row, { set: { voice_muted: !!muted } });
} else if (control === 'end') {
} else if (control === 'end') {
next = await service.end(row, { reason: `ended_by_${role}` });
next = await service.end(row, { reason: `ended_by_${role}` });
+ } else if (control === 'wipe') {
+ // S6: Wipe now stops the run and resets the sandbox. The desk reads it in the state it polls.
+ next = await service.update(row, { set: { plays_wiped_at: new Date() } });
}
}
await require('../db/database').run(
await require('../db/database').run(
`INSERT INTO agent_stream_controls (managed_stream_id, control, by_handle, by_role, paused, paused_by)
`INSERT INTO agent_stream_controls (managed_stream_id, control, by_handle, by_role, paused, paused_by)
@@ -649,7 +771,16 @@ router.put('/:id/voice', auth.publisher, async (req, res) => {
@@ -649,7 +771,16 @@ router.put('/:id/voice', auth.publisher, async (req, res) => {
const body = req.body || {};
const body = req.body || {};
const set = {};
const set = {};
if (body.mode !== undefined) {
if (body.mode !== undefined) {
- if (!['watch', 'narrated', 'copilot'].includes(body.mode)) return fail(res, 400, 'invalid_mode', 'mode is watch, narrated or copilot');
+ if (!['watch', 'narrated', 'copilot', 'chatplays'].includes(body.mode)) return fail(res, 400, 'invalid_mode', 'mode is watch, narrated, copilot or chatplays');
+ if (body.mode === 'chatplays') {
+ // Chat plays needs a sandbox worker: a request runs in a network-isolated worker, not in the
+ // owner's own Codes worker, Node or desk. There is no such worker yet, so the mode is refused.
+ const worker = await sandbox.workerFor(row.owner_subject);
+ if (!worker.ok) return fail(res, 409, 'chatplays_needs_sandbox_worker', worker.reason);
+ if (!['public', 'unlisted'].includes(row.visibility)) return fail(res, 422, 'chatplays_needs_public', 'Chat plays needs a public or unlisted stream');
+ if (!Number(row.copilot_ready)) return fail(res, 422, 'chatplays_not_ready', "This task's service cannot work the queue yet");
+ if (!Number(row.plays_cap_cents)) return fail(res, 422, 'chatplays_needs_budget', 'Chat plays needs a budget: set the cap first');
+ }
if (body.mode === 'copilot') {
if (body.mode === 'copilot') {
// Co-pilot needs a public or unlisted stream and a task's service that can read a digest.
// Co-pilot needs a public or unlisted stream and a task's service that can read a digest.
if (!['public', 'unlisted'].includes(row.visibility)) return fail(res, 422, 'copilot_needs_public', 'Co-pilot needs a public or unlisted stream');
if (!['public', 'unlisted'].includes(row.visibility)) return fail(res, 422, 'copilot_needs_public', 'Co-pilot needs a public or unlisted stream');
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -0,0 +1,114 @@
+'use strict';
+/**
+ * The sandbox worker registry (Agent Streams S6, design 2026-11-06, §1).
+ *
+ * A chat plays request runs in a network-isolated worker: a container or namespace with no host network, no
+ * credentials and no access to the owner's files, whose egress goes through a proxy that applies the guard.
+ * That worker is not built yet. Until one is registered and healthy, Live refuses the chat plays mode: the
+ * mode may not be switched on, Go may not be pressed, and the owner page says so in as many words.
+ *
+ * Who may register a worker: only OpenVibe's sandbox worker itself. It checks in with a Network service
+ * token whose principal holds the capability live.agent_sandbox.register, and it names the person it works
+ * for. The owner cannot register one, a desk ticket cannot, and nobody else can: an owner who could would
+ * declare their own unisolated machine a sandbox, and the gate would mean nothing.
+ *
+ * Health comes only from the worker's own check-ins: a check-in within the last WORKER_STALE_MS (a minute)
+ * that reports the isolation Live requires. The owner can read that state and cannot change it.
+ *
+ * Live never stores an endpoint and never calls the worker: the worker reports, it is not called.
+ */
+const crypto = require('crypto');
+const db = require('../db/database');
+
+const WORKER_STALE_MS = 60 * 1000;
+const REASON = 'Needs the sandbox worker; coming soon';
+
+// The isolation a worker must report: no host network, no credentials, no profile, and every egress
+// request through the proxy that applies the guard.
+const REQUIRED_ISOLATION = {
+ host_network: false,
+ credentials: false,
+ profile: false,
+ egress_proxy: true,
+ workspace: 'ephemeral',
+};
+
+const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+function ulid(now = Date.now()) {
+ let time = '';
+ for (let t = now, i = 0; i < 10; i++, t = Math.floor(t / 32)) time = ALPHABET[t % 32] + time;
+ const bytes = crypto.randomBytes(16);
+ let rand = '';
+ for (let i = 0; i < 16; i++) rand += ALPHABET[bytes[i] % 32];
+ return time + rand;
+}
+
+/** Does the report say the worker is isolated the way Live requires? */
+function isolationOk(report) {
+ if (!report || typeof report !== 'object') return false;
+ for (const [key, want] of Object.entries(REQUIRED_ISOLATION)) {
+ if (report[key] !== want) return false;
+ }
+ return true;
+}
+
+/**
+ * The worker of a person. It answers { ok: true, worker } when one is there and healthy, and
+ * { ok: false, code, reason } when it is not: the mode is refused either way.
+ */
+async function workerFor(subject, { now = Date.now() } = {}) {
+ if (!subject) return { ok: false, code: 'sandbox_worker_missing', reason: REASON };
+ let row = null;
+ try {
+ row = await db.getDb().prepare(
+ "SELECT * FROM agent_sandbox_workers WHERE subject = ? AND status = 'ready' LIMIT 1").get(String(subject));
+ } catch (err) {
+ // No table yet (an older release): there is no worker.
+ return { ok: false, code: 'sandbox_worker_missing', reason: REASON };
+ }
+ if (!row) return { ok: false, code: 'sandbox_worker_missing', reason: REASON };
+ const seen = Number(row.last_seen_at) || 0;
+ if (!seen || now - seen > WORKER_STALE_MS) {
+ return { ok: false, code: 'sandbox_worker_stale', reason: REASON, worker: row };
+ }
+ // The worker must still report the isolation Live requires.
+ let isolation = null;
+ try { isolation = typeof row.isolation === 'string' ? JSON.parse(row.isolation) : row.isolation; } catch { isolation = null; }
+ if (!isolationOk(isolation)) {
+ return { ok: false, code: 'sandbox_worker_not_isolated', reason: REASON, worker: row };
+ }
+ return { ok: true, worker: row };
+}
+
+/** The worker checks in: it is there, now, and it says how it is isolated. */
+async function checkIn({ subject, service = 'unknown', isolation = null, now = Date.now() }) {
+ if (!subject) return { ok: false, code: 'sandbox_worker_no_subject' };
+ if (!isolationOk(isolation)) {
+ // A worker that cannot say it is isolated is not one: it is not registered at all.
+ return { ok: false, code: 'sandbox_worker_not_isolated' };
+ }
+ const at = Number(now);
+ const existing = await db.getDb().prepare('SELECT id FROM agent_sandbox_workers WHERE subject = ? LIMIT 1').get(String(subject));
+ const id = (existing && existing.id) || ('sbw_' + ulid(at));
+ await db.getDb().prepare(
+ `INSERT INTO agent_sandbox_workers (id, subject, service, isolation, status, last_seen_at, created_at)
+ VALUES (?, ?, ?, ?, 'ready', ?, ?)
+ ON CONFLICT (subject) DO UPDATE SET status = 'ready', service = excluded.service,
+ isolation = excluded.isolation, last_seen_at = excluded.last_seen_at`)
+ .run(id, String(subject), String(service || 'unknown'), JSON.stringify(isolation), at, at);
+ return { ok: true, id, subject: String(subject), last_seen_at: at };
+}
+
+/** What the owner page shows: is there a worker, and is it healthy? */
+async function state(subject, { now = Date.now() } = {}) {
+ const found = await workerFor(subject, { now });
+ return {
+ ready: found.ok === true,
+ code: found.ok ? null : found.code,
+ reason: found.ok ? null : REASON,
+ last_seen_at: found.ok ? Number(found.worker.last_seen_at) : (found.worker ? Number(found.worker.last_seen_at) : null),
+ stale_after_ms: WORKER_STALE_MS,
+ };
+}
+
+module.exports = { WORKER_STALE_MS, REASON, REQUIRED_ISOLATION, isolationOk, workerFor, checkIn, state, ulid };
@@ -77,6 +77,7 @@ const SELECT = `
@@ -77,6 +77,7 @@ const SELECT = `
a.started_at, a.ended_at, a.worked_seconds, a.last_frame_path, a.last_frame_at, a.vod_id, a.allow_sites,
a.started_at, a.ended_at, a.worked_seconds, a.last_frame_path, a.last_frame_at, a.vod_id, a.allow_sites,
a.voice, a.personality, a.voice_muted, a.voice_own, a.voice_own_consent_at, a.voice_paused,
a.voice, a.personality, a.voice_muted, a.voice_own, a.voice_own_consent_at, a.voice_paused,
a.copilot_ready, a.paused, a.paused_by, a.paused_by_role, a.paused_at,
a.copilot_ready, a.paused, a.paused_by, a.paused_by_role, a.paused_at,
+ a.plays_time_box_minutes, a.plays_cap_cents, a.plays_spent_cents, a.plays_armed_at, a.plays_wiped_at, a.plays_queue,
a.unlisted_secret_hash,
a.unlisted_secret_hash,
(SELECT s.id FROM streams s WHERE s.managed_stream_id = ms.id AND s.is_live = 1 ORDER BY s.started_at DESC LIMIT 1) AS live_session_id,
(SELECT s.id FROM streams s WHERE s.managed_stream_id = ms.id AND s.is_live = 1 ORDER BY s.started_at DESC LIMIT 1) AS live_session_id,
(SELECT st.title FROM agent_stream_steps st WHERE st.managed_stream_id = ms.id ORDER BY st.id DESC LIMIT 1) AS latest_step_title,
(SELECT st.title FROM agent_stream_steps st WHERE st.managed_stream_id = ms.id ORDER BY st.id DESC LIMIT 1) AS latest_step_title,
@@ -78,6 +78,7 @@ const ulid = () => ids.ulid();
@@ -78,6 +78,7 @@ const ulid = () => ids.ulid();
exempt.share_access_requests = 'sharing: access requests by subject, erased by subject (eraseSharing)';
exempt.share_access_requests = 'sharing: access requests by subject, erased by subject (eraseSharing)';
exempt.share_transfers = 'sharing: offers by subject, erased by subject (eraseSharing)';
exempt.share_transfers = 'sharing: offers by subject, erased by subject (eraseSharing)';
exempt.agent_view_tickets = 'agent streams: view tickets carry the viewer\'s subject; the rows go with the stream (managed_streams cascade)';
exempt.agent_view_tickets = 'agent streams: view tickets carry the viewer\'s subject; the rows go with the stream (managed_streams cascade)';
+ exempt.agent_sandbox_workers = 'agent streams: the sandbox worker registry names the person it works for; the row goes when the worker stops (it is not an account row)';
// Every other table with a person column is handled by the schema-driven export and erase below.
// Every other table with a person column is handled by the schema-driven export and erase below.
const handled = [...cols.keys()].filter((t) => !chatTables.includes(t));
const handled = [...cols.keys()].filter((t) => !chatTables.includes(t));
const missing = await uncovered(d, { taskKeyed: handled, exempt });
const missing = await uncovered(d, { taskKeyed: handled, exempt });
new file mode 100644
new file mode 100644
@@ -0,0 +1,226 @@
@@ -0,0 +1,226 @@
+'use strict';
+/**
+ * Agent streams S6, Live's part (design 2026-11-06): the chat plays mode.
+ *
+ * - the mode chatplays is refused unless a sandbox worker is registered and healthy: a request runs in a
+ * network-isolated worker, not in the owner's own Codes worker, Node or desk (409, and the owner page
+ * says the worker is still coming);
+ * - only OpenVibe's sandbox worker may register one: a Network service token holding
+ * live.agent_sandbox.register, naming the person it works for. The owner's session, a desk ticket and a
+ * token without the capability are refused (403);
+ * - a worker that does not report the isolation Live requires (no host network, no credentials, no
+ * profile, egress through the proxy) is not registered at all;
+ * - health comes only from the worker's own check-ins: a check-in within the last minute. A worker that
+ * falls silent is not there, and the mode is refused again;
+ * - the time box, the cap and the queue's top three are the owner's own, and a play request is never
+ * dispatched from here.
+ */
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+
+process.env.NODE_ENV = 'test';
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+console.error = (...a) => process.stderr.write(a.join(' ') + '\n');
+
+const SID = { ann: 'usr_01JAB2C3D4E5F6G7H8J9K0MNA1' };
+const ISOLATION = { host_network: false, credentials: false, profile: false, egress_proxy: true, workspace: 'ephemeral' };
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); quiet(' ✓', name); }
+ catch (e) { failures++; quiet(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
+}
+
+(async () => {
+ const db = require('../server/db/database');
+ await db.initDb();
+ const raw = db.getDb();
+ await raw.prepare(
+ `INSERT INTO users (id, username, display_name, email, password_hash, role, is_owner) OVERRIDING SYSTEM VALUE
+ VALUES (1, 'ann', 'ann', 'ann@x', 'x', 'streamer', 0) ON CONFLICT (id) DO NOTHING`).run();
+ await raw.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (1, 'network', '101', ?) ON CONFLICT DO NOTHING").run(SID.ann);
+
+ // The Network is not here: a service token is verified by this stand-in. The worker's token holds the
+ // sandbox capability; the other one does not.
+ const sdkAuth = require('openvibe-sdk/auth');
+ sdkAuth.verifyServiceToken = async (token) => {
+ if (token === 'tok-worker') return { ok: true, claims: { sub: 'svc:sandbox', cap: ['live.agent_sandbox.register'] } };
+ if (token === 'tok-other') return { ok: true, claims: { sub: 'svc:other', cap: ['live.agent_stream.publish'] } };
+ if (token === 'tok-person') return { ok: true, claims: { sub: SID.ann, cap: ['live.agent_sandbox.register'] } };
+ return { ok: false, code: 'token.invalid' };
+ };
+
+ const config = require('../server/config');
+ config.baseUrl = 'https://openvibe.live';
+ const service = require('../server/agent-streams/service');
+ const sandbox = require('../server/agent-streams/sandbox');
+ const chat = require('../server/chat/chat-client');
+ chat.available = () => true;
+ const chatCalls = [];
+ chat.agentRoom = async (body) => { chatCalls.push(body); return { room: 'agent:' + body.agent_stream_id }; };
+ const moderation = require('../server/chat/moderation-client');
+ moderation.getChannelModeration = async () => ({ settings: {}, moderator_ids: [] });
+
+ const ann = await db.getUserById(1);
+ const stream = await service.create({
+ user: ann,
+ title: 'Chat plays',
+ task: { source: 'codes', ref: 'tsk_1', title: 'Work the queue', url: 'https://openvibe.codes/tasks/1' },
+ visibility: 'public', copilotReady: true,
+ });
+
+ const app = express();
+ app.use(express.json());
+ app.use((req, res, next) => { req.user = { 1: ann }[req.get('x-test-user')] || null; next(); });
+ app.use('/api/streams/agent', require('../server/agent-streams/routes'));
+ const server = await new Promise((ok) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => ok(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const call = async (method, url, { as = 0, body, token, subject } = {}) => {
+ const headers = { 'Content-Type': 'application/json', 'x-test-user': String(as) };
+ if (token) headers.authorization = `Bearer ${token}`;
+ if (subject) headers['x-ov-subject'] = subject;
+ const res = await fetch(`${base}${url}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
+ const text = await res.text();
+ let data = {}; try { data = text ? JSON.parse(text) : {}; } catch { data = { text }; }
+ return { status: res.status, data };
+ };
+ // The owner's own session asks: the mode is the owner's to choose.
+ const voice = (body) => call('PUT', `/api/streams/agent/${stream.public_id}/voice`, { as: 1, body });
+ const checkIn = (opts = {}) => call('POST', `/api/streams/agent/${stream.public_id}/plays/worker`, {
+ token: 'tok-worker', subject: SID.ann, body: { isolation: ISOLATION }, ...opts,
+ });
+
+ quiet('agent streams S6: the chat plays mode');
+
+ await check('the mode is refused without a sandbox worker', async () => {
+ const r = await voice({ mode: 'chatplays' });
+ assert.strictEqual(r.status, 409, JSON.stringify(r.data));
+ assert.strictEqual(r.data.code, 'chatplays_needs_sandbox_worker');
+ assert.match(String(r.data.error || ''), /sandbox worker/i, 'the reason says what is missing');
+ });
+
+ await check('Go is refused without a sandbox worker too', async () => {
+ const r = await call('PUT', `/api/streams/agent/${stream.public_id}/plays`, { as: 1, body: { time_box_minutes: 10, cap_cents: 500, go: true } });
+ assert.strictEqual(r.status, 409, JSON.stringify(r.data));
+ assert.strictEqual(r.data.code, 'chatplays_needs_sandbox_worker');
+ });
+
+ await check('the owner page is told the worker is still coming', async () => {
+ const r = await call('GET', `/api/streams/agent/${stream.public_id}/plays/worker`, { as: 1 });
+ assert.strictEqual(r.status, 200, JSON.stringify(r.data));
+ assert.strictEqual(r.data.worker.ready, false);
+ assert.match(String(r.data.worker.reason || ''), /coming soon/i);
+ });
+
+ await check("the owner's own session may not register a worker", async () => {
+ const r = await call('POST', `/api/streams/agent/${stream.public_id}/plays/worker`, { as: 1, body: { isolation: ISOLATION } });
+ assert.strictEqual(r.status, 403, JSON.stringify(r.data));
+ assert.strictEqual(r.data.code, 'sandbox_worker.not_the_worker');
+ });
+
+ await check('a desk ticket may not register a worker', async () => {
+ const r = await call('POST', `/api/streams/agent/${stream.public_id}/plays/worker`, { token: 'wpt_a_desk_ticket', subject: SID.ann, body: { isolation: ISOLATION } });
+ assert.strictEqual(r.status, 403, JSON.stringify(r.data));
+ assert.strictEqual(r.data.code, 'sandbox_worker.not_the_worker');
+ });
+
+ await check('a token without the capability may not register a worker', async () => {
+ const r = await call('POST', `/api/streams/agent/${stream.public_id}/plays/worker`, { token: 'tok-other', subject: SID.ann, body: { isolation: ISOLATION } });
+ assert.strictEqual(r.status, 403, JSON.stringify(r.data));
+ assert.strictEqual(r.data.code, 'capability.denied');
+ });
+
+ await check('a person token may not register a worker', async () => {
+ const r = await call('POST', `/api/streams/agent/${stream.public_id}/plays/worker`, { token: 'tok-person', subject: SID.ann, body: { isolation: ISOLATION } });
+ assert.strictEqual(r.status, 403, JSON.stringify(r.data));
+ assert.strictEqual(r.data.code, 'sandbox_worker.not_the_worker');
+ });
+
+ await check('a worker that does not say it is isolated is not registered', async () => {
+ const r = await checkIn({ body: { isolation: { host_network: true, credentials: false, profile: false, egress_proxy: false, workspace: 'shared' } } });
+ assert.strictEqual(r.status, 422, JSON.stringify(r.data));
+ assert.strictEqual(r.data.code, 'sandbox_worker_not_isolated');
+ const state = await call('GET', `/api/streams/agent/${stream.public_id}/plays/worker`, { as: 1 });
+ assert.strictEqual(state.data.worker.ready, false, 'it is not registered at all');
+ });
+
+ await check('the worker registers and the mode becomes available', async () => {
+ const in1 = await checkIn();
+ assert.strictEqual(in1.status, 200, JSON.stringify(in1.data));
+ assert.strictEqual(in1.data.worker.ready, true);
+ const cap = await call('PUT', `/api/streams/agent/${stream.public_id}/plays`, { as: 1, body: { time_box_minutes: 10, cap_cents: 500 } });
+ assert.strictEqual(cap.status, 200, JSON.stringify(cap.data));
+ const mode = await voice({ mode: 'chatplays' });
+ assert.strictEqual(mode.status, 200, JSON.stringify(mode.data));
+ assert.strictEqual(mode.data.voice.mode, 'chatplays');
+ });
+
+ await check('a worker that fell silent is not there', async () => {
+ await raw.prepare('UPDATE agent_sandbox_workers SET last_seen_at = ? WHERE subject = ?').run(Date.now() - 61 * 1000, SID.ann);
+ const r = await voice({ mode: 'chatplays' });
+ assert.strictEqual(r.status, 409, JSON.stringify(r.data));
+ assert.strictEqual(r.data.code, 'chatplays_needs_sandbox_worker');
+ // And it comes back when the worker checks in again.
+ const in1 = await checkIn();
+ assert.strictEqual(in1.status, 200, JSON.stringify(in1.data));
+ assert.strictEqual(in1.data.worker.ready, true);
+ });
+
+ await check('the controls are the owner\'s own: the time box, the cap and Go', async () => {
+ const plays = require('../server/agent-streams/plays');
+ assert.strictEqual(plays.cleanTimeBox(1), 5);
+ assert.strictEqual(plays.cleanTimeBox(90), 30);
+ assert.strictEqual(plays.cleanCap(500), 500);
+ assert.strictEqual(plays.cleanCap(-5), null);
+ // The owner's session still may not arm the desk by itself: Go waits for the worker like the mode does.
+ const go = await call('PUT', `/api/streams/agent/${stream.public_id}/plays`, { as: 1, body: { go: true } });
+ assert.strictEqual(go.status, 200, JSON.stringify(go.data));
+ });
+
+ await check('the overlay gets the queue\'s top three and the current request', async () => {
+ const plays = require('../server/agent-streams/plays');
+ const row = { plays_queue: { requests: [
+ { request_id: 'req_1', text: 'build a snake game', votes: 12, author: { handle: 'mira' } },
+ { request_id: 'req_2', text: 'draw a house', votes: 7, author: { handle: 'bob' } },
+ { request_id: 'req_3', text: 'make a quiz', votes: 3, author: { handle: 'ann' } },
+ { request_id: 'req_4', text: 'make a meme', votes: 1, author: { handle: 'jo' } },
+ ], current: { request_id: 'req_1', text: 'build a snake game', author: { handle: 'mira' } } } };
+ const top = plays.queueTop(row, 3);
+ assert.strictEqual(top.top.length, 3);
+ assert.strictEqual(top.top[0].request_id, 'req_1');
+ assert.strictEqual(top.top[0].votes, 12);
+ assert.strictEqual(top.current.author, 'mira', 'the current request says who asked for it');
+ });
+
+ await check('the overlay reads the queue: the current request and the next three', async () => {
+ await raw.prepare('UPDATE agent_streams SET plays_queue = ? WHERE managed_stream_id = ?').run(
+ JSON.stringify({
+ requests: [
+ { request_id: 'req_1', text: 'build a snake game', votes: 12, author: { handle: 'mira' } },
+ { request_id: 'req_2', text: 'draw a house', votes: 7, author: { handle: 'bob' } },
+ { request_id: 'req_3', text: 'make a quiz', votes: 3, author: { handle: 'ann' } },
+ { request_id: 'req_4', text: 'make a meme', votes: 1, author: { handle: 'jo' } },
+ ],
+ current: { request_id: 'req_1', text: 'build a snake game', author: { handle: 'mira' } },
+ }), stream.managed_stream_id);
+ const r = await call('GET', `/api/streams/agent/${stream.public_id}/plays/queue`);
+ assert.strictEqual(r.status, 200, JSON.stringify(r.data));
+ assert.strictEqual(r.data.queue.top.length, 3, 'the overlay shows the queue\'s top three');
+ assert.strictEqual(r.data.queue.top[0].request_id, 'req_1');
+ assert.strictEqual(r.data.queue.top[0].votes, 12);
+ assert.strictEqual(r.data.queue.current.author, 'mira', 'the current request says who asked for it');
+ });
+
+ await check('a play request is never dispatched from here', async () => {
+ const before = chatCalls.length;
+ const r = await call('POST', `/api/streams/agent/${stream.public_id}/plays/queue`, { body: { requests: [], current: null } });
+ assert.ok(r.status === 200 || r.status === 401 || r.status === 403, 'the desk may report the queue: ' + r.status);
+ assert.strictEqual(chatCalls.length, before, 'Live asks nobody to run anything');
+ });
+
+ server.close();
+ quiet(failures ? `agent streams S6: ${failures} failed` : 'agent streams S6: the chat plays mode waits for the sandbox worker');
+ process.exit(failures ? 1 : 0);
+})().catch((e) => { console.error(e); process.exit(1); });