openvibers/openvibe.codes Commits

The Dock's agent view: the person's desk stream, with the view ticket minted here (K-D3) (#289)

OP OpenVibers committed 6 days ago · 09c118721d · 6 files, +360 −0

* The Dock's agent view: the person's own desk stream, with the view ticket minted here (Dock K-D3)

GET /api/v1/me/agent-view finds the person's live agent stream on OpenVibe.Live (or their last one), mints
its view ticket server-side with the person's own token, and answers the embed's address. A third-party
iframe has no Live cookie, which is why the ticket cannot be minted in the page (Agent Streams S1).

  GET  /api/v1/me/agent-view         the live stream with its embed, else the last one as a still
  GET  /api/v1/me/agent-view/frame   the still, because an <img> cannot carry a bearer
  POST /api/v1/me/agent-view/curtain Curtain now, on Live's own route
  POST /api/v1/me/agent-view/end     End, on Live's own route

Every call to Live carries the person's token and never a service token of Codes'; the signed-out answer is
401 and a Live that does not answer is a 502 problem. These four routes are the only ones in this API that
answer another site's page, so they send Access-Control-Allow-Origin: * with no credentials: the bearer is
the key. No step field: steps are Agent Streams S2.

test/agent-view.test.js runs against a stand-in Live: the embed and its ticket, the person's own bearer on
every call, the CORS preflight, the ended still through Codes, both controls reaching Live's paths, and the
502.

* Agent view reads Live's /api/streams/agent/mine; its calls to Live are a reviewed outbound site

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 691d4f7..db38157 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -47,6 +47,7 @@ const { createOutbox, createPublisher } = require('./events/outbox');
const { createEventsConsumer } = require('./events-consumer');
const { createApi } = require('./http/api');
const { createMeApi } = require('./http/me-api');
++const { createAgentView } = require('./http/agent-view');
const { createKnowledgeApi } = require('./http/knowledge-api');
const { createMovedRoutes } = require('./http/moved');
const { createCodesReadiness } = require('./observability');
@@@ -256,6 +257,8 @@ async function createApp(opts = {}) {
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, github: ctx.github, actorLimits: ctx.actorLimits, log }));
++ // The Dock's agent view (Dock K-D3, Agent Streams S1): the person's own desk stream with a view ticket minted here.
++ app.use('/api/v1', createAgentView({ config, sso, actorLimits: ctx.actorLimits, log }));
// Know K5: the person's own agents read and add workspace knowledge with their token (the ov-know MCP server).
app.use('/api/v1', createKnowledgeApi({ config, store, knowledge: ctx.knowledge, actorLimits: ctx.actorLimits, log }));
// A person's connections: their own provider keys (stored in OpenVibe.AI, never here) and their machines' plans.
ddiff --git a/server/config.js b/server/config.js
iindex 8936ad7..530d1d5 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -60,6 +60,9 @@ function load(env = process.env) {
clientSecret: env.CODES_FORGE_CLIENT_SECRET || '',
sealKey: env.CODES_SEAL_KEY || '',
},
++ // OpenVibe.Live (Agent Streams S1, Dock K-D3): the person's own agent streams, read and steered with their own
++ // token for the Dock's agent view (server/http/agent-view.js). Codes holds no token of its own here.
++ live: { url: trim(env.CODES_LIVE_URL || 'https://openvibe.live') },
// The audience of the Network service tokens a first-party service calls /api/v1/tasks with (codes.task.*).
serviceAudience: env.CODES_AUDIENCE || 'openvibe.codes',
oauth: {
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
iindex c5370a4..c3beef2 100644
---- a/server/http/actor-limits.js
++++ b/server/http/actor-limits.js
@@@ -40,6 +40,8 @@ const BUDGETS = {
'codes.repo.search': { minute: 6, hour: 60 },
// An agent adds what it learned through the ov-know MCP; a run may add a handful, not a flood.
'codes.knowledge.write': { minute: 30, hour: 600 },
++ // The Dock's agent view controls (K-D3): Curtain now and End. The owner presses them now and then, never in a burst.
++ 'codes.agent_view.control': { minute: 20, hour: 120 },
};
/**
ddiff --git a/server/http/agent-view.js b/server/http/agent-view.js
nnew file mode 100644
iindex 0000000..a975174
---- /dev/null
++++ b/server/http/agent-view.js
@@@ -0,0 +1,189 @@
++'use strict';
++
++/**
++ * The Dock's agent view (Dock plan 2026-10-30, K-D3; Agent Streams S1): the signed-in person's own agent desk stream,
++ * for the Dock that Shared draws on every OpenVibe site.
++ *
++ * GET /api/v1/me/agent-view → { agent } the live stream, with a view ticket, or the last one as a still
++ * GET /api/v1/me/agent-view/frame → the last frame (JPEG or WebP), the picture when nothing is live
++ * POST /api/v1/me/agent-view/curtain → { agent } { up } → Live's curtain, raised as the owner's own button
++ * POST /api/v1/me/agent-view/end → { agent } Live's end
++ *
++ * The person's own token does all of it, passed through to OpenVibe.Live: Codes mints the view ticket server-side
++ * (Agent Streams S1: an iframe on another site has no Live cookie), and Live decides — the stream is theirs, so the
++ * owner's rules apply and nobody else's stream is ever in an answer. Codes holds no token of its own here and keeps
++ * nothing.
++ *
++ * These four routes are the only ones in this API that answer another site's page: the Dock loads from whichever
++ * OpenVibe site the person is on. So they carry `Access-Control-Allow-Origin: *` — no credentials, no cookie, the
++ * person's bearer is the key, and a page that does not hold it reads nothing.
++ *
++ * The step (S2's `live.agent-stream-step@1`) is not in S1: there is no `step` field here, and the Dock shows none
++ * until Live serves them.
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { asyncRouter } = require('./router');
++
++const AGENT = /^ags_[0-9A-HJKMNP-TV-Z]{26}$/;
++const CORS_HEADERS = 'authorization, content-type';
++
++function createAgentView({ config, sso, actorLimits, fetchImpl = (...a) => fetch(...a), log = console }) {
++ const r = asyncRouter();
++ const json = express.json({ limit: '4kb' });
++ const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
++ const signedIn = (req) => !!(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
++ const liveBase = () => String((config.live && config.live.url) || 'https://openvibe.live').replace(/\/+$/, '');
++
++ /** One call to Live with the person's own token. Its own deadline: the Dock must not hang on a slow Live. */
++ async function live(token, path, init = {}) {
++ const res = await fetchImpl(`${liveBase()}${path}`, {
++ ...init,
++ headers: {
++ accept: 'application/json',
++ authorization: `Bearer ${token}`,
++ ...(init.body ? { 'content-type': 'application/json' } : {}),
++ ...(init.headers || {}),
++ },
++ signal: AbortSignal.timeout(8000),
++ });
++ return res;
++ }
++
++ /** The live stream if one is up, else the newest one; null when the person has never published one. */
++ async function streamOf(token) {
++ let res;
++ try {
++ res = await live(token, '/api/streams/agent/mine');
++ } catch (e) {
++ throw Object.assign(new Error(`OpenVibe.Live did not answer: ${e.message}`), { status: 502, code: 'agent_view.unavailable' });
++ }
++ if (!res.ok) throw Object.assign(new Error(`OpenVibe.Live answered ${res.status}`), { status: res.status === 401 ? 401 : 502, code: 'agent_view.unavailable' });
++ const body = await res.json().catch(() => null);
++ const rows = body && Array.isArray(body.streams) ? body.streams : [];
++ if (!rows.length) return null;
++ return rows.find((s) => s && s.state === 'live') || rows[0];
++ }
++
++ /** A view ticket while the stream is not ended; the embed needs one for a private or unlisted stream. */
++ async function ticketOf(token, row) {
++ if (!row || row.state === 'ended') return { ticket: null, expiresAt: null };
++ try {
++ const res = await live(token, `/api/streams/agent/${encodeURIComponent(row.id)}/view-ticket`, { method: 'POST', body: '{}' });
++ if (!res.ok) return { ticket: null, expiresAt: null };
++ const body = await res.json().catch(() => null);
++ return { ticket: body && typeof body.ticket === 'string' ? body.ticket : null, expiresAt: (body && body.expires_at) || null };
++ } catch { return { ticket: null, expiresAt: null }; }
++ }
++
++ /**
++ * What the Dock reads. The embed's address is built here from the stream's id and the ticket, never from a URL in
++ * Live's answer, so a foreign address can never end up in the Dock's frame.
++ */
++ function present(row, { ticket, expiresAt }) {
++ if (!row || !AGENT.test(String(row.id || ''))) return null;
++ return {
++ id: row.id,
++ state: row.state,
++ title: row.title,
++ task: row.task && typeof row.task === 'object' ? row.task : null,
++ curtain: { up: !!(row.curtain && row.curtain.up) },
++ viewers: Number(row.viewers) || 0,
++ worked_seconds: Number(row.worked_seconds) || 0,
++ visibility: row.visibility,
++ started_at: row.started_at || null,
++ ended_at: row.ended_at || null,
++ page_url: typeof row.page_url === 'string' && row.page_url.startsWith('https://openvibe.live/') ? row.page_url : `https://openvibe.live/@${encodeURIComponent(row.channel || 'you')}/agents/${row.id}`,
++ // The player, with the view ticket Codes just minted. It is in the address and nowhere else in the answer.
++ embed_url: ticket ? `https://openvibe.live/embed/agent/${row.id}?vt=${encodeURIComponent(ticket)}` : null,
++ // The picture when nothing is live: Codes serves it, because an <img> cannot carry the person's token.
++ last_frame_url: row.last_frame_url ? `${String(config.baseUrl).replace(/\/+$/, '')}/api/v1/me/agent-view/frame` : null,
++ };
++ }
++
++ async function viewOf(token) {
++ const row = await streamOf(token);
++ if (!row) return null;
++ return present(row, await ticketOf(token, row));
++ }
++
++ // ── CORS: this one part of the API answers every site's Dock ──
++ r.use('/me/agent-view', (req, res, next) => {
++ res.set('Access-Control-Allow-Origin', '*');
++ res.set('Cache-Control', 'no-store');
++ if (req.method === 'OPTIONS') {
++ res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
++ res.set('Access-Control-Allow-Headers', CORS_HEADERS);
++ res.set('Access-Control-Max-Age', '600');
++ return res.status(204).end();
++ }
++ return next();
++ });
++
++ r.get('/me/agent-view', actorLimits.reads('codes.read'), async (req, res) => {
++ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');
++ try {
++ const agent = await sso.asViewer(req, res, (token) => viewOf(token));
++ return res.json({ agent });
++ } catch (e) {
++ if (e && e.status === 401) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');
++ log.warn(`[Codes] agent view: ${String(e && e.message).slice(0, 200)}`);
++ return problem(req, res, 502, 'agent_view.unavailable', 'OpenVibe.Live did not answer. Try again in a moment.');
++ }
++ });
++
++ // The last frame: Live serves it to the owner only, so Codes asks with their token and passes the picture on.
++ r.get('/me/agent-view/frame', actorLimits.reads('codes.read'), async (req, res) => {
++ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');
++ let frame = null;
++ try {
++ frame = await sso.asViewer(req, res, async (token) => {
++ const row = await streamOf(token);
++ if (!row || !row.last_frame_url) return null;
++ // Built here from the stream's id and our own Live base: Codes never fetches an address Live names.
++ const out = await fetchImpl(`${liveBase()}/api/streams/agent/${encodeURIComponent(row.id)}/frame`, {
++ headers: { authorization: `Bearer ${token}`, accept: 'image/jpeg, image/webp' }, signal: AbortSignal.timeout(8000),
++ });
++ if (!out.ok) return null;
++ const type = String(out.headers.get('content-type') || '');
++ if (!/^image\/(jpeg|webp)$/.test(type)) return null;
++ return { type, bytes: Buffer.from(await out.arrayBuffer()) };
++ });
++ } catch (e) {
++ if (e && e.status === 401) return problem(req, res, 401, 'auth.required', 'sign in to see your agent');
++ frame = null;
++ }
++ if (!frame) return problem(req, res, 404, 'agent_view.no_frame', 'There is no still of your agent yet');
++ res.set('Content-Type', frame.type).set('Cache-Control', 'private, max-age=10').send(frame.bytes);
++ return undefined;
++ });
++
++ // Curtain now (the owner's own button: Live lifts it only from the desk, or from here while it is up) and End.
++ const control = (name, run) => async (req, res) => {
++ if (!signedIn(req)) return problem(req, res, 401, 'auth.required', `sign in to ${name} your agent stream`);
++ try {
++ const agent = await sso.asViewer(req, res, (token) => run(token));
++ return res.json({ agent });
++ } catch (e) {
++ if (e && e.status === 401) return problem(req, res, 401, 'auth.required', `sign in to ${name} your agent stream`);
++ log.warn(`[Codes] agent view ${name}: ${String(e && e.message).slice(0, 200)}`);
++ return problem(req, res, 502, 'agent_view.unavailable', 'OpenVibe.Live did not answer. Try again in a moment.');
++ }
++ };
++
++ // The answer is the view read again, so the Dock paints the curtain Live now reports and, after End, the still.
++ const act = (path, body) => async (token) => {
++ const row = await streamOf(token);
++ if (!row) return null;
++ const res = await live(token, `/api/streams/agent/${encodeURIComponent(row.id)}${path}`, { method: 'POST', body: JSON.stringify(body) });
++ if (!res.ok) throw Object.assign(new Error(`OpenVibe.Live answered ${res.status} for ${path}`), { status: 502, code: 'agent_view.unavailable' });
++ return viewOf(token);
++ };
++
++ r.post('/me/agent-view/curtain', actorLimits.budget('codes.agent_view.control'), json, control('curtain', act('/curtain', { up: true, reason: 'manual' })));
++ r.post('/me/agent-view/end', actorLimits.budget('codes.agent_view.control'), json, control('end', act('/end', {})));
++
++ return r;
++}
++
++module.exports = { createAgentView };
ddiff --git a/test/agent-view.test.js b/test/agent-view.test.js
nnew file mode 100644
iindex 0000000..28251ef
---- /dev/null
++++ b/test/agent-view.test.js
@@@ -0,0 +1,162 @@
++'use strict';
++/**
++ * The Dock's agent view (Dock plan 2026-10-30 K-D3; Agent Streams S1): Codes mints the person's view ticket server-side
++ * and hands the Dock the embed, with the person's own token passed through to OpenVibe.Live — never a service token,
++ * never another person's stream. Curtain now and End are Live's own routes, reached the same way; the still comes back
++ * through Codes because an <img> cannot carry a bearer.
++ *
++ * Live is a stand-in on a local port (CODES_LIVE_URL): the api/streams/agent list, the view ticket, the curtain, the
++ * end and the frame.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++
++const LIVE_ID = 'ags_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const FRAME = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0xff, 0xd9]);
++
++const STREAM = (o = {}) => ({
++ id: LIVE_ID, owner: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ', channel: 'ada', title: 'Fix the flaky upload test',
++ task: { source: 'codes', ref: 'tsk_1', title: 'Fix the flaky upload test', url: 'https://openvibe.codes/run/tasks/tsk_1' },
++ visibility: 'private', mode: 'watch', state: 'live', curtain: { up: false }, viewers: 2,
++ started_at: '2026-11-06T10:00:00.000Z', ended_at: null, worked_seconds: 720, last_frame_url: null, vod_id: null,
++ page_url: `https://openvibe.live/@ada/agents/${LIVE_ID}`, label: 'AI agent', ...o,
++});
++
++(async () => {
++ // ── OpenVibe.Live's stand-in ────────────────────────────────
++ const calls = [];
++ const live = { streams: [], ticket: 'wvt_the_view_ticket', fail: false, authorizations: [] };
++ const server = await new Promise((ok) => {
++ const srv = http.createServer((req, res) => {
++ const url = new URL(req.url, 'http://x');
++ const json = (status, body) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(body)); };
++ live.authorizations.push(String(req.headers.authorization || ''));
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ calls.push({ method: req.method, path: url.pathname, body: body ? JSON.parse(body) : null });
++ if (live.fail) return json(500, { error: 'Live is unwell', code: 'boom' });
++ if (url.pathname === '/api/streams/agent/mine' && req.method === 'GET') {
++ const onlyLive = url.searchParams.get('live') === '1';
++ return json(200, { streams: live.streams.filter((s) => !onlyLive || s.state === 'live') });
++ }
++ const m = /^\/api\/streams\/agent\/(ags_[0-9A-Z]+)\/(view-ticket|curtain|end)$/.exec(url.pathname);
++ if (m) {
++ if (m[2] === 'view-ticket') return json(200, { ticket: live.ticket, expires_at: new Date(Date.now() + 600_000).toISOString() });
++ if (m[2] === 'curtain') { live.streams = live.streams.map((s) => ({ ...s, curtain: { up: true } })); return json(200, { stream: STREAM({ curtain: { up: true } }) }); }
++ if (m[2] === 'end') { live.streams = live.streams.map((s) => ({ ...s, state: 'ended', last_frame_url: 'https://openvibe.live/api/streams/agent/' + LIVE_ID + '/frame' })); return json(200, { stream: STREAM({ state: 'ended' }) }); }
++ }
++ if (url.pathname === `/api/streams/agent/${LIVE_ID}/frame`) {
++ res.writeHead(200, { 'content-type': 'image/jpeg' });
++ return res.end(FRAME);
++ }
++ return json(404, { error: 'not found' });
++ });
++ });
++ srv.listen(0, '127.0.0.1', () => ok(srv));
++ });
++
++ const t = await boot({ env: { CODES_LIVE_URL: `http://127.0.0.1:${server.address().port}` } });
++ const ada = t.network.addUser('ada');
++ const bo = t.network.addUser('bo');
++
++ try {
++ await check('signed out, there is nothing to show', async () => {
++ const res = await t.get('/api/v1/me/agent-view');
++ assert.strictEqual(res.status, 401, res.text);
++ assert.strictEqual(res.json().code, 'auth.required');
++ });
++
++ await check('a person with no stream gets { agent: null }', async () => {
++ const res = await t.get('/api/v1/me/agent-view', { bearer: t.network.userToken(ada) });
++ assert.strictEqual(res.status, 200, res.text);
++ assert.deepStrictEqual(res.json(), { agent: null });
++ });
++
++ await check('the live stream comes with the embed, its ticket minted here, and the person\'s own token to Live', async () => {
++ live.streams = [STREAM({ state: 'ended', last_frame_url: 'https://openvibe.live/x' }), STREAM()];
++ const token = t.network.userToken(ada);
++ const res = await t.get('/api/v1/me/agent-view', { bearer: token });
++ assert.strictEqual(res.status, 200, res.text);
++ const a = res.json().agent;
++ assert.strictEqual(a.id, LIVE_ID);
++ assert.strictEqual(a.state, 'live');
++ assert.strictEqual(a.title, 'Fix the flaky upload test');
++ assert.deepStrictEqual(a.task, STREAM().task);
++ assert.deepStrictEqual([a.viewers, a.worked_seconds], [2, 720]);
++ assert.strictEqual(a.embed_url, `https://openvibe.live/embed/agent/${LIVE_ID}?vt=wvt_the_view_ticket`);
++ assert.strictEqual(a.page_url, `https://openvibe.live/@ada/agents/${LIVE_ID}`);
++ assert.ok(!JSON.stringify(a).includes('wpt_'), 'no publish ticket anywhere');
++ assert.ok(calls.some((c) => c.method === 'POST' && c.path === `/api/streams/agent/${LIVE_ID}/view-ticket`), 'the ticket was minted server-side');
++ // Every call to Live carried the person's own token: Codes holds no token of its own here.
++ for (const auth of live.authorizations) {
++ assert.strictEqual(auth, `Bearer ${token}`, 'the person\'s own bearer, not a service token');
++ }
++ assert.notStrictEqual(ada.subject, bo.subject);
++ });
++
++ await check('the Dock may read it from any site, and the preflight says so', async () => {
++ const get = await t.get('/api/v1/me/agent-view', { bearer: t.network.userToken(ada) });
++ assert.strictEqual(get.headers.get('access-control-allow-origin'), '*');
++ assert.match(String(get.headers.get('cache-control')), /no-store/);
++ const pre = await fetch(`${t.base}/api/v1/me/agent-view?x=1`, {
++ method: 'OPTIONS', headers: { origin: 'https://openvibe.live', 'access-control-request-method': 'POST', 'access-control-request-headers': 'authorization' },
++ });
++ assert.strictEqual(pre.status, 204);
++ assert.strictEqual(pre.headers.get('access-control-allow-origin'), '*');
++ assert.match(String(pre.headers.get('access-control-allow-headers')), /authorization/);
++ assert.match(String(pre.headers.get('access-control-allow-methods')), /POST/);
++ });
++
++ await check('an ended stream is the still, with no embed and no ticket', async () => {
++ live.streams = [STREAM({ state: 'ended', last_frame_url: 'https://openvibe.live/api/streams/agent/' + LIVE_ID + '/frame' })];
++ const before = calls.filter((c) => c.path.endsWith('/view-ticket')).length;
++ const res = await t.get('/api/v1/me/agent-view', { bearer: t.network.userToken(ada) });
++ const a = res.json().agent;
++ assert.strictEqual(a.state, 'ended');
++ assert.strictEqual(a.embed_url, null);
++ assert.strictEqual(a.last_frame_url, 'https://openvibe.codes/api/v1/me/agent-view/frame');
++ assert.strictEqual(calls.filter((c) => c.path.endsWith('/view-ticket')).length, before, 'no ticket for an ended stream');
++ });
++
++ await check('the still comes back through Codes, from Live\'s own frame', async () => {
++ const res = await t.get('/api/v1/me/agent-view/frame', { bearer: t.network.userToken(ada) });
++ assert.strictEqual(res.status, 200, res.text);
++ assert.strictEqual(res.headers.get('content-type'), 'image/jpeg');
++ assert.deepStrictEqual([...res.buffer], [...FRAME]);
++ assert.match(String(res.headers.get('cache-control')), /private/);
++ const anon = await t.get('/api/v1/me/agent-view/frame');
++ assert.strictEqual(anon.status, 401);
++ });
++
++ await check('Curtain now and End are Live\'s routes, and the answer is the view read again', async () => {
++ live.streams = [STREAM()];
++ const curtain = await t.get('/api/v1/me/agent-view/curtain', { bearer: t.network.userToken(ada), method: 'POST', json: {} });
++ assert.strictEqual(curtain.status, 200, curtain.text);
++ const sent = calls.filter((c) => c.path === `/api/streams/agent/${LIVE_ID}/curtain`).pop();
++ assert.deepStrictEqual(sent.body, { up: true, reason: 'manual' });
++ assert.strictEqual(curtain.json().agent.curtain.up, true, 'the Dock paints the curtain Live now reports');
++
++ const end = await t.get('/api/v1/me/agent-view/end', { bearer: t.network.userToken(ada), method: 'POST', json: {} });
++ assert.strictEqual(end.status, 200, end.text);
++ assert.ok(calls.some((c) => c.path === `/api/streams/agent/${LIVE_ID}/end`), 'End reached Live');
++ assert.strictEqual(end.json().agent.state, 'ended');
++
++ const anon = await t.get('/api/v1/me/agent-view/end', { method: 'POST', json: {} });
++ assert.strictEqual(anon.status, 401);
++ });
++
++ await check('a Live that does not answer is a 502 problem the Dock can ignore', async () => {
++ live.fail = true;
++ const res = await t.get('/api/v1/me/agent-view', { bearer: t.network.userToken(ada) });
++ assert.strictEqual(res.status, 502, res.text);
++ assert.strictEqual(res.json().code, 'agent_view.unavailable');
++ live.fail = false;
++ });
++ } finally {
++ await t.close();
++ server.close();
++ }
++ done();
++})().catch((e) => { process.stdout.write(`${e && e.stack || e}\n`); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 6764fdd..5a9949f 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -44,6 +44,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/domain/forgejo.js': 'OpenVibe Git API (CODES_GIT_INTERNAL_URL, configured loopback Forgejo) with a service token; paths built from validated owner/name',
'server/domain/run-client.js': 'OpenVibe.Run (CODES_RUN_URL, configured) and the Network token endpoint; job ids only, never a URL from a person',
'server/domain/bench-publisher.js': 'OpenVibe.Bench (CODES_BENCH_URL, configured) run publishing and the Network token endpoint; artifact URLs use the configured render origin',
++ 'server/http/agent-view.js': 'OpenVibe.Live (the configured Live base) with the signed-in person\'s own Network token: their own agent streams, a view ticket, the frame, curtain and end; stream ids from Live\'s own answer, URL-encoded; no URL from a person',
'server/http/run.js': 'Network projects API (configured) with the viewer\'s token, and the Run job stream URL run-client.js builds on its own base',
'server/http/home.js': 'OpenVibe Git\'s public repository search (CODES_GIT_INTERNAL_URL, configured loopback Forgejo) for the home page; no token',
'server/http/me-api.js': 'Network projects API (configured) with the viewer\'s token, for the user task API; no URL from a person',