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)

OP OpenVibers committed 6 days ago · 9cb7889ee7 · 14 files, +833 −6

* Agent Streams S6: chat plays

* Agent Streams S6: the chat plays mode in the agent streams guide

* Agent Streams S6: the sandbox worker gate, the grants and the dispatch checks

* Agent Streams S6: the worker registry is exempted in the account data test

* Agent Streams S6: the overlay draws the queue's top three

* Agent Streams S6: only the sandbox worker may register a sandbox worker

The check-in route was the owner's to call: their session or the desk ticket
could register a worker, and an owner could declare their own unisolated
machine a sandbox and switch the mode on.

Now only OpenVibe's sandbox worker may check in: a Network service token whose
principal holds live.agent_sandbox.register, naming the person it works for.
Health comes only from the worker's own check-ins (a minute, and the isolation
it reports), and Live stores no endpoint and never calls the worker.

Changes

ddiff --git a/docs/agent-streams.md b/docs/agent-streams.md
iindex 0af740df..b7d18cd6 100644
---- a/docs/agent-streams.md
++++ b/docs/agent-streams.md
@@@ -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
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).
++
++## 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.
ddiff --git a/migrations/0033_agent_plays.sql b/migrations/0033_agent_plays.sql
nnew file mode 100644
iindex 00000000..f7cd8ff3
---- /dev/null
++++ b/migrations/0033_agent_plays.sql
@@@ -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);
ddiff --git a/public/css/features/agent-stream.css b/public/css/features/agent-stream.css
iindex 1c9da044..e6d927fa 100644
---- a/public/css/features/agent-stream.css
++++ b/public/css/features/agent-stream.css
@@@ -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'; }
ddiff --git a/public/fragments/agent-stream.html b/public/fragments/agent-stream.html
iindex 2763e39b..7ef89a4a 100644
---- a/public/fragments/agent-stream.html
++++ b/public/fragments/agent-stream.html
@@@ -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">
ddiff --git a/public/js/agent-embed.js b/public/js/agent-embed.js
iindex 7b760f2f..6c8b524a 100644
---- a/public/js/agent-embed.js
++++ b/public/js/agent-embed.js
@@@ -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);
ddiff --git a/public/js/agent-overlay.js b/public/js/agent-overlay.js
iindex 08a5f408..254736b6 100644
---- a/public/js/agent-overlay.js
++++ b/public/js/agent-overlay.js
@@@ -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;
ddiff --git a/public/js/agent-stream.js b/public/js/agent-stream.js
iindex ef573781..bafe79d8 100644
---- a/public/js/agent-stream.js
++++ b/public/js/agent-stream.js
@@@ -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;
})();
ddiff --git a/server/agent-streams/auth.js b/server/agent-streams/auth.js
iindex 6b98a264..b2529def 100644
---- a/server/agent-streams/auth.js
++++ b/server/agent-streams/auth.js
@@@ -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 };
ddiff --git a/server/agent-streams/plays.js b/server/agent-streams/plays.js
nnew file mode 100644
iindex 00000000..56eabbe9
---- /dev/null
++++ b/server/agent-streams/plays.js
@@@ -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 };
ddiff --git a/server/agent-streams/routes.js b/server/agent-streams/routes.js
iindex 1fe063aa..43ebe610 100644
---- a/server/agent-streams/routes.js
++++ b/server/agent-streams/routes.js
@@@ -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');
ddiff --git a/server/agent-streams/sandbox.js b/server/agent-streams/sandbox.js
nnew file mode 100644
iindex 00000000..eb7986cb
---- /dev/null
++++ b/server/agent-streams/sandbox.js
@@@ -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 };
ddiff --git a/server/agent-streams/service.js b/server/agent-streams/service.js
iindex a9d2c229..2ad3f257 100644
---- a/server/agent-streams/service.js
++++ b/server/agent-streams/service.js
@@@ -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,
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex ef74773c..769aeeb3 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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 });
ddiff --git a/test/agent-streams-s6.test.js b/test/agent-streams-s6.test.js
nnew file mode 100644
iindex 00000000..d2f1b225
---- /dev/null
++++ b/test/agent-streams-s6.test.js
@@@ -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); });