openvibers/openvibe.run Commits
Run sessions: task_id back, and a live still behind the curtain (#196)
Changes
@@@ -6,6 +6,13 @@ Notable changes to OpenVibe.Run. The service is unreleased; everything below is### Added++- One Computer (plan 2026-11-07, contracts 0.327.0): `run.session@1` carries `task_id` (the OpenVibe.Actor task the++ session is the Computer of, so the session points back at the task) and `curtain` (the privacy screen).++ `GET /api/v1/sessions/{id}/still` answers the desk's newest frame as a small JPEG (at most 640 px wide, cached for a++ second so it captures at most once a second) from the desk's own capture on the session's lease; it is the session++ person's alone, a signed-out visitor gets 404 like a missing session, `Cache-Control` is `private, no-store`, and++ while `POST /api/v1/sessions/{id}/curtain` holds it up the answer is the curtain picture, never the screen.++ Migration 0008 adds `task_id` and `curtain`.- Placement A1 (openvibe-sdk 0.76.0): `server/dispatch/placement.js` calls `place()` — Bench priors (an hour'scache), the person's learned counts and one plain line — and the same answer is stored on the job. `GET/api/v1/jobs/:id?explain=1` answers the line beside the contract body. `placement_outcomes` (migration 0007) learns@@@ -252,7 +252,7 @@ until Run has a product surface; the vhost below is installed then.| `STATUS.json` | What this repository is, what it does today and where it runs |<!-- versions:start -->--- openvibe-contracts: v0.312.0++- openvibe-contracts: v0.327.0- openvibe-sdk: v0.94.0- openvibe-shared: v4.135.0<!-- versions:end -->nnew file mode 100644@@@ -0,0 +1,12 @@++-- phase: expand++-- One Computer across Surf, Actor and the Dock (plan 2026-11-07): a session now names the Actor task that asked for it,++-- and it can be behind Curtain now, in which case its still never shows the screen.++--++-- task_id is the task the session is the Computer of (contracts 0.327.0, run.session@1 task_id). Null when a person++-- opened the session themselves. Not personal data: it is the id of a task the same person owns.++-- curtain is the privacy screen: while it is true the still endpoint answers the curtain picture, never the screen.++ALTER TABLE run_sessions++ ADD COLUMN IF NOT EXISTS task_id text,++ ADD COLUMN IF NOT EXISTS curtain boolean NOT NULL DEFAULT false;++++CREATE INDEX IF NOT EXISTS run_sessions_task ON run_sessions (task_id) WHERE task_id IS NOT NULL;@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0","pg": "^8.23.0"@@@ -1147,9 +1147,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.312.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",-- "integrity": "sha512-2GsD53zuIedL3eW2LBlYQ68ODTHzoZJHrAmVnZCpiL7ZWnAXGwPW9dpQagLI9Zdkh3LQuuU5ae1YJI0K+acXbA==",++ "version": "0.327.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",++ "integrity": "sha512-LssZZeTdg9OkPPpry6YSEVhbIv13MjLYOlNxpoZ/0bpIATy0zfJrzfLAaJyUY+UMq5Tqf1FLzx2a/3xcJ7UJDw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -26,7 +26,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0","pg": "^8.23.0"@@@ -9,6 +9,13 @@* GET /api/v1/sessions/:id run.session.read → run.session@1* POST /api/v1/sessions/:id/commands run.session.control run.session-control-request@1 → run.session-control-result@1* POST /api/v1/sessions/:id/end run.session.control → run.session@1, ended++ * GET /api/v1/sessions/:id/still run.session.read the newest frame (JPEG, at most 640 px, at most 1/s);++ * the curtain picture while "Curtain now" is up++ * POST /api/v1/sessions/:id/curtain run.session.control { up } → run.session@1++ *++ * The still is the one exception to this API's "401 for a signed-out caller": anyone who is not the session's person,++ * or a first-party service holding run.session.read and naming them, gets the same 404 as a session that does not++ * exist. A picture of someone's desk must never confirm that it is there.** A session is its person's: their own Network token, or a first-party service (svc:) holding the capability and* naming the person in X-OV-Subject (recorded as via). Anyone else's session is 404, never confirmed to exist. An app,@@@ -45,6 +52,26 @@ function sessionsRouter({ sessions }) {if (!row || row.owner !== w.owner) { problem(res, req, 404, 'run.session.not_found', 'no such session of yours'); return null; }return row;}++ /**++ * The still's own guard: the session's person, or a first-party service holding run.session.read and naming them.++ * Anyone else — including a signed-out visitor — gets 404, never 401/403: a still must not confirm a session exists.++ */++ async function mineStill(req, res) {++ const p = req.principal || { kind: 'anonymous' };++ const missing = () => { problem(res, req, 404, 'run.session.not_found', 'no such session'); return null; };++ let owner = null;++ if (p.kind === 'user') owner = `user:${p.subject}`;++ else if (p.kind === 'service') {++ const sub = String(p.sub || '');++ const subject = String(req.get('X-OV-Subject') || '').trim();++ if (sub.startsWith('svc:') && capabilities.check(p.claims, 'run.session.read').allowed && USER_SUBJECT.test(subject) && ids.isSubjectId('user', subject)) owner = `user:${subject}`;++ }++ if (!owner) return missing();++ const id = String(req.params.id || '');++ const row = SESSION_RE.test(id) ? await sessions.get(id) : null;++ if (!row || row.owner !== owner) return missing();++ return row;++ }const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch((err) => {if (err && err.status && err.code) return problem(res, req, err.status, err.code, err.detail || err.message);return next(err);@@@ -79,6 +106,27 @@ function sessionsRouter({ sessions }) {const ended = await sessions.finish(row, 'ended', 'ended by its person');res.json(sessions.present(ended || row));}));++ // The newest frame of the session's desk (plan 2026-11-07: the live still). The bytes come from the desk's own++ // capture, through the session's lease, at most once a second and at most 640 px wide.++ r.get('/sessions/:id/still', wrap(async (req, res) => {++ const row = await mineStill(req, res);++ if (!row) return;++ const shot = await sessions.still(row);++ res.set('Cache-Control', 'private, no-store');++ res.set('X-Content-Type-Options', 'nosniff');++ res.set('Content-Type', shot.type);++ res.set('Content-Length', String(shot.bytes.length));++ res.status(200).end(shot.bytes);++ }));++ // Curtain now: the privacy screen, so the next still is the curtain picture and never the desk.++ r.post('/sessions/:id/curtain', wrap(async (req, res) => {++ const row = await mine(req, res, 'run.session.control');++ if (!row) return;++ const up = req.body && typeof req.body === 'object' && typeof req.body.up === 'boolean' ? req.body.up : null;++ if (up === null) return problem(res, req, 422, 'run.session.invalid', 'up is true (raise the curtain) or false (lift it)');++ const saved = await sessions.setCurtain(row, up);++ res.json(sessions.present(saved));++ }));return r;}nnew file mode 100644@@@ -0,0 +1,10 @@++'use strict';++/**++ * The curtain still: a 640 × 360 dark JPEG (about 1.7 KB), answered by the still endpoint while a session is behind++ * "Curtain now". It is a picture of nothing on purpose: the desk's screen, its page and any text on it never reach++ * the caller. Generated once (ImageMagick: convert -size 640x360 xc:'#0a0c11' -quality 60), embedded so serving it++ * costs no file, no capture and no desk call.++ */++const BYTES = Buffer.from('/9j/4AAQSkZJRgABAQAAAAAAAAD/2wBDAA0JCgsKCA0LCgsODg0PEyAVExISEyccHhcgLikxMC4pLSwzOko+MzZGNywtQFdBRkxOUlNSMj5aYVpQYEpRUk//2wBDAQ4ODhMREyYVFSZPNS01T09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0//wAARCAFoAoADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFgEBAQEAAAAAAAAAAAAAAAAAAAEC/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AnADSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/2Q==', 'base64');++++module.exports = { BYTES, TYPE: 'image/jpeg', WIDTH: 640, HEIGHT: 360 };@@@ -24,6 +24,7 @@*/const contracts = require('openvibe-contracts');const store = require('./store');++const curtain = require('./curtain');const usage = require('../jobs/usage');const { TABLE: EVENTS_TABLE, ACTOR } = require('../events/outbox');@@@ -32,6 +33,11 @@ const DEFAULT_TRUST = ['first-party', 'community'];const STARTING_STALE_MS = 60_000;const OPERATION = 'browser.desk';const SOURCE = 'openvibe-run.session';++// The still (plan 2026-11-07): one frame a second at most, never wider than 640 px. The desk's own capture is++// reused — the same screen command Actor's computer tools and the agent stream's frames come from — and the answer++// is cached for the second, so a page refreshing every two seconds asks the desk at most once per second.++const STILL_MIN_MS = 1000;++const STILL_WIDTH = 640;class SessionError extends Error {constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail; }@@@ -45,6 +51,8 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), loglet timer = null;let running = null;const hostCache = new Map(); // subject → { at, hosts }++ const stills = new Map(); // session id → { at, bytes, type, width, height } (one second)++ const pending = new Map(); // session id → Promise of the frame being capturedconst bare = (owner) => String(owner).replace(/^user:/, '');@@@ -97,7 +105,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), logid: store.newSessionId(), owner, via: via || null, kind: 'browser', state: 'placing',width: body.width || 1280, height: body.height || 800, ttl_s: body.ttl_s || 3600, idle_s: body.idle_s || 900,machine_asked: body.machine || null, requirements: body.requirements || null, idempotency_key: body.idempotency_key || null,-- request_digest: digest, reasons: [], created_at: at, updated_at: at,++ request_digest: digest, reasons: [], task_id: body.task_id || null, curtain: false, created_at: at, updated_at: at,};const out = await store.insert(db, row);if (out.created) setImmediate(() => { tick().catch((e) => log.warn(`[Run] session tick: ${e.message}`)); });@@@ -115,6 +123,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), logconst ended = { ...fresh, state: 'ended', ended_at: now(), end_reason: reason, end_detail: detail ? String(detail).slice(0, 500) : null };const metered = await meter(t, ended, { final: true });const saved = await store.update(t, row.id, { state: 'ended', ended_at: ended.ended_at, end_reason: reason, end_detail: ended.end_detail, metered_minutes: metered, updated_at: now() });++ stills.delete(row.id);await emit(t, saved, 'run.session.ended');return saved;});@@@ -139,6 +148,50 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), logreturn out;}++ /**++ * The session's newest frame, as a small JPEG (plan 2026-11-07: the live still Surf's card shows and the Dock++ * links to). It reuses the desk's own capture — the `screen` command on the session's lease, the same one Actor's++ * computer tools read — at most STILL_WIDTH wide, and at most once a second: a caller asking again inside the++ * second gets the frame already captured.++ *++ * Curtain now: while the session's curtain is up the answer is the curtain picture, never a call to the desk.++ * A session that is not running has no frame (404): the caller never learns whether a machine is there.++ * → { bytes, type, width, height, curtain } or throws SessionError.++ */++ async function still(row) {++ if (!row) throw new SessionError(404, 'run.session.not_found', 'no such session');++ if (row.state !== 'running' || !row.machine || !row.desk) throw new SessionError(404, 'run.session.no_still', 'this session has no picture');++ if (row.curtain) return { bytes: curtain.BYTES, type: curtain.TYPE, width: curtain.WIDTH, height: curtain.HEIGHT, curtain: true };++ const fresh = stills.get(row.id);++ if (fresh && now() - fresh.at < STILL_MIN_MS) return fresh;++ if (pending.has(row.id)) return pending.get(row.id);++ const shot = (async () => {++ const r = await desks.act(row.machine, row.desk, bare(row.owner), { id: `still-${Math.floor(now() / 1000)}`, kind: 'screen', value: { max_width: STILL_WIDTH } });++ if (r === null) {++ await finish(row, 'host_lost', 'its machine no longer holds the desk', { release: false });++ throw new SessionError(404, 'run.session.no_still', 'this session has no picture');++ }++ const d = r && r.result === 'ack' && r.data ? r.data : null;++ if (!d || !d.content_b64 || !/^(jpeg|png)$/.test(String(d.format || ''))) throw new SessionError(404, 'run.session.no_still', 'the desk has not sent a frame yet');++ const bytes = Buffer.from(String(d.content_b64), 'base64');++ if (!bytes.length) throw new SessionError(404, 'run.session.no_still', 'the desk sent no picture');++ const out = { at: now(), bytes, type: d.format === 'png' ? 'image/png' : 'image/jpeg', width: Number(d.width) || null, height: Number(d.height) || null, curtain: false };++ stills.set(row.id, out);++ if (stills.size > 500) stills.delete(stills.keys().next().value);++ return out;++ })().finally(() => pending.delete(row.id));++ pending.set(row.id, shot);++ return shot;++ }++++ /** Curtain now: raise or lift the session's privacy screen. The next still is the curtain picture, never the desk. */++ async function setCurtain(row, up) {++ if (!row || row.state === 'ended') throw new SessionError(409, 'run.session.not_running', 'the session has ended');++ const saved = await store.update(db, row.id, { curtain: Boolean(up), updated_at: now() });++ stills.delete(row.id);++ return saved;++ }++// ── placement ─────────────────────────────────────────────────────────────────────────────────────────────async function hostsFor(subject) {const c = hostCache.get(subject);@@@ -235,7 +288,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log}return {-- create, act, finish, tick, SessionError,++ create, act, finish, still, setCurtain, tick, SessionError,get: (id) => store.get(db, id),list: (owner, opts) => store.listOf(db, owner, opts),present: (row) => store.present(row, now()),@@@ -14,7 +14,7 @@ const { ids } = require('openvibe-contracts');const COLUMNS = ['id', 'owner', 'via', 'kind', 'state', 'width', 'height', 'ttl_s', 'idle_s', 'machine_asked', 'requirements', 'idempotency_key','request_digest', 'machine', 'desk', 'trust', 'offer_id', 'reasons', 'created_at', 'started_at', 'last_used_at', 'ended_at', 'end_reason',-- 'end_detail', 'metered_minutes', 'lease_checked_at', 'updated_at'];++ 'end_detail', 'metered_minutes', 'lease_checked_at', 'task_id', 'curtain', 'updated_at'];const JSON_COLUMNS = new Set(['requirements', 'reasons']);const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());@@@ -60,6 +60,10 @@ function present(row, now = Date.now()) {end: row.state === 'ended' ? { reason: row.end_reason || 'ended', detail: row.end_detail ? String(row.end_detail).slice(0, 500) : null } : null,usage: { desk_seconds: deskSeconds(row, now) },idempotency_key: row.idempotency_key || null,++ // One Computer (contracts 0.327.0): the Actor task that asked for this session, and whether its privacy++ // screen is up. Both are additive: a session a person opened themselves has no task.++ task_id: row.task_id || null,++ curtain: Boolean(row.curtain),};}@@@ -178,6 +178,53 @@ function fakeDesks() {assert.deepStrictEqual([late.status, late.json.code], [409, 'run.session.not_running']);});++ await check('the still is its person\'s alone, at most once a second, and Curtain now hides the desk', async () => {++ const TASK = 'tsk_01JAB2C3D4E5F6G7H8J9K0MNPT';++ const r = await t.call('POST', '/api/v1/sessions', { ...as(bea), body: { kind: 'browser', ttl_s: 600, task_id: TASK } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json.task_id, TASK, 'the session carries the task back');++ valid(r.json, 'run.session@1');++ await sessions.tick();++ const s = (await t.call('GET', `/api/v1/sessions/${r.json.id}`, as(bea))).json;++ assert.deepStrictEqual([s.state, s.task_id, s.curtain], ['running', TASK, false]);++ const acts = () => desks.calls.filter((c) => c[0] === 'act' && c[1] === s.placement.machine && c[4].kind === 'screen').length;++ const before = acts();++++ const one = await t.call('GET', `/api/v1/sessions/${s.id}/still`, as(bea));++ assert.strictEqual(one.status, 200, one.text);++ assert.match(one.headers.get('content-type'), /^image\/jpeg/);++ assert.strictEqual(one.headers.get('cache-control'), 'private, no-store');++ assert.strictEqual(Number(one.headers.get('content-length')), 3, 'the desk\'s own frame, as it sent it');++ assert.strictEqual(acts(), before + 1, 'one capture');++ await t.call('GET', `/api/v1/sessions/${s.id}/still`, as(bea));++ assert.strictEqual(acts(), before + 1, 'a second still in the same second reuses the frame');++ t.clock.offset += 1_000;++ await t.call('GET', `/api/v1/sessions/${s.id}/still`, as(bea));++ assert.strictEqual(acts(), before + 2, 'a new second captures again');++++ assert.strictEqual((await t.call('GET', `/api/v1/sessions/${s.id}/still`, as(ada))).status, 404, 'another person: the same 404 as a missing session');++ assert.strictEqual((await t.call('GET', `/api/v1/sessions/${s.id}/still`, { token: null })).status, 404, 'a signed-out visitor: 404, never 401');++ const noRead = { token: t.network.signService({ sub: 'svc:actor', cap: ['run.session.create'] }), headers: { 'X-OV-Subject': bea } };++ assert.strictEqual((await t.call('GET', `/api/v1/sessions/${s.id}/still`, noRead)).status, 404, 'a service without run.session.read');++ const reader = { token: t.network.signService({ sub: 'svc:actor', cap: ['run.session.read'] }), headers: { 'X-OV-Subject': bea } };++ assert.strictEqual((await t.call('GET', `/api/v1/sessions/${s.id}/still`, reader)).status, 200, 'a service acting for the person with run.session.read');++ assert.strictEqual((await t.call('POST', `/api/v1/sessions/${s.id}/curtain`, { ...as(ada), body: { up: true } })).status, 404, 'only its person raises the curtain');++++ const up = await t.call('POST', `/api/v1/sessions/${s.id}/curtain`, { ...as(bea), body: { up: true } });++ assert.strictEqual(up.status, 200, up.text);++ assert.strictEqual(up.json.curtain, true);++ valid(up.json, 'run.session@1');++ const captured = acts();++ const hidden = await t.call('GET', `/api/v1/sessions/${s.id}/still`, as(bea));++ assert.strictEqual(hidden.status, 200);++ assert.strictEqual(Number(hidden.headers.get('content-length')), 1666, 'the curtain picture, not the desk');++ assert.strictEqual(acts(), captured, 'Curtain now never asks the desk');++ assert.strictEqual((await t.call('POST', `/api/v1/sessions/${s.id}/curtain`, { ...as(bea), body: {} })).status, 422);++ await t.call('POST', `/api/v1/sessions/${s.id}/end`, as(bea));++ // An ended session has no picture.++ assert.strictEqual((await t.call('GET', `/api/v1/sessions/${s.id}/still`, as(bea))).status, 404);++ });++await check('the real desk client: Run\'s token for Bot with bot.desk.session, the person in X-OV-Subject', async () => {const seen = [];const bot = http.createServer((req, res) => {
@@ -6,6 +6,13 @@ Notable changes to OpenVibe.Run. The service is unreleased; everything below is
@@ -6,6 +6,13 @@ Notable changes to OpenVibe.Run. The service is unreleased; everything below is
### Added
### Added
+- One Computer (plan 2026-11-07, contracts 0.327.0): `run.session@1` carries `task_id` (the OpenVibe.Actor task the
+ session is the Computer of, so the session points back at the task) and `curtain` (the privacy screen).
+ `GET /api/v1/sessions/{id}/still` answers the desk's newest frame as a small JPEG (at most 640 px wide, cached for a
+ second so it captures at most once a second) from the desk's own capture on the session's lease; it is the session
+ person's alone, a signed-out visitor gets 404 like a missing session, `Cache-Control` is `private, no-store`, and
+ while `POST /api/v1/sessions/{id}/curtain` holds it up the answer is the curtain picture, never the screen.
+ Migration 0008 adds `task_id` and `curtain`.
- Placement A1 (openvibe-sdk 0.76.0): `server/dispatch/placement.js` calls `place()` — Bench priors (an hour's
- Placement A1 (openvibe-sdk 0.76.0): `server/dispatch/placement.js` calls `place()` — Bench priors (an hour's
cache), the person's learned counts and one plain line — and the same answer is stored on the job. `GET
cache), the person's learned counts and one plain line — and the same answer is stored on the job. `GET
/api/v1/jobs/:id?explain=1` answers the line beside the contract body. `placement_outcomes` (migration 0007) learns
/api/v1/jobs/:id?explain=1` answers the line beside the contract body. `placement_outcomes` (migration 0007) learns
@@ -252,7 +252,7 @@ until Run has a product surface; the vhost below is installed then.
@@ -252,7 +252,7 @@ until Run has a product surface; the vhost below is installed then.
| `STATUS.json` | What this repository is, what it does today and where it runs |
| `STATUS.json` | What this repository is, what it does today and where it runs |
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.312.0
+- openvibe-contracts: v0.327.0
- openvibe-sdk: v0.94.0
- openvibe-sdk: v0.94.0
- openvibe-shared: v4.135.0
- openvibe-shared: v4.135.0
<!-- versions:end -->
<!-- versions:end -->
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+-- phase: expand
+-- One Computer across Surf, Actor and the Dock (plan 2026-11-07): a session now names the Actor task that asked for it,
+-- and it can be behind Curtain now, in which case its still never shows the screen.
+--
+-- task_id is the task the session is the Computer of (contracts 0.327.0, run.session@1 task_id). Null when a person
+-- opened the session themselves. Not personal data: it is the id of a task the same person owns.
+-- curtain is the privacy screen: while it is true the still endpoint answers the curtain picture, never the screen.
+ALTER TABLE run_sessions
+ ADD COLUMN IF NOT EXISTS task_id text,
+ ADD COLUMN IF NOT EXISTS curtain boolean NOT NULL DEFAULT false;
+
+CREATE INDEX IF NOT EXISTS run_sessions_task ON run_sessions (task_id) WHERE task_id IS NOT NULL;
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1147,9 +1147,9 @@
@@ -1147,9 +1147,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.312.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
- "integrity": "sha512-2GsD53zuIedL3eW2LBlYQ68ODTHzoZJHrAmVnZCpiL7ZWnAXGwPW9dpQagLI9Zdkh3LQuuU5ae1YJI0K+acXbA==",
+ "version": "0.327.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",
+ "integrity": "sha512-LssZZeTdg9OkPPpry6YSEVhbIv13MjLYOlNxpoZ/0bpIATy0zfJrzfLAaJyUY+UMq5Tqf1FLzx2a/3xcJ7UJDw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -9,6 +9,13 @@
@@ -9,6 +9,13 @@
* GET /api/v1/sessions/:id run.session.read → run.session@1
* GET /api/v1/sessions/:id run.session.read → run.session@1
* POST /api/v1/sessions/:id/commands run.session.control run.session-control-request@1 → run.session-control-result@1
* POST /api/v1/sessions/:id/commands run.session.control run.session-control-request@1 → run.session-control-result@1
* POST /api/v1/sessions/:id/end run.session.control → run.session@1, ended
* POST /api/v1/sessions/:id/end run.session.control → run.session@1, ended
+ * GET /api/v1/sessions/:id/still run.session.read the newest frame (JPEG, at most 640 px, at most 1/s);
+ * the curtain picture while "Curtain now" is up
+ * POST /api/v1/sessions/:id/curtain run.session.control { up } → run.session@1
+ *
+ * The still is the one exception to this API's "401 for a signed-out caller": anyone who is not the session's person,
+ * or a first-party service holding run.session.read and naming them, gets the same 404 as a session that does not
+ * exist. A picture of someone's desk must never confirm that it is there.
*
*
* A session is its person's: their own Network token, or a first-party service (svc:) holding the capability and
* A session is its person's: their own Network token, or a first-party service (svc:) holding the capability and
* naming the person in X-OV-Subject (recorded as via). Anyone else's session is 404, never confirmed to exist. An app,
* naming the person in X-OV-Subject (recorded as via). Anyone else's session is 404, never confirmed to exist. An app,
@@ -45,6 +52,26 @@ function sessionsRouter({ sessions }) {
@@ -45,6 +52,26 @@ function sessionsRouter({ sessions }) {
if (!row || row.owner !== w.owner) { problem(res, req, 404, 'run.session.not_found', 'no such session of yours'); return null; }
if (!row || row.owner !== w.owner) { problem(res, req, 404, 'run.session.not_found', 'no such session of yours'); return null; }
return row;
return row;
}
}
+ /**
+ * The still's own guard: the session's person, or a first-party service holding run.session.read and naming them.
+ * Anyone else — including a signed-out visitor — gets 404, never 401/403: a still must not confirm a session exists.
+ */
+ async function mineStill(req, res) {
+ const p = req.principal || { kind: 'anonymous' };
+ const missing = () => { problem(res, req, 404, 'run.session.not_found', 'no such session'); return null; };
+ let owner = null;
+ if (p.kind === 'user') owner = `user:${p.subject}`;
+ else if (p.kind === 'service') {
+ const sub = String(p.sub || '');
+ const subject = String(req.get('X-OV-Subject') || '').trim();
+ if (sub.startsWith('svc:') && capabilities.check(p.claims, 'run.session.read').allowed && USER_SUBJECT.test(subject) && ids.isSubjectId('user', subject)) owner = `user:${subject}`;
+ }
+ if (!owner) return missing();
+ const id = String(req.params.id || '');
+ const row = SESSION_RE.test(id) ? await sessions.get(id) : null;
+ if (!row || row.owner !== owner) return missing();
+ return row;
+ }
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch((err) => {
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch((err) => {
if (err && err.status && err.code) return problem(res, req, err.status, err.code, err.detail || err.message);
if (err && err.status && err.code) return problem(res, req, err.status, err.code, err.detail || err.message);
return next(err);
return next(err);
@@ -79,6 +106,27 @@ function sessionsRouter({ sessions }) {
@@ -79,6 +106,27 @@ function sessionsRouter({ sessions }) {
const ended = await sessions.finish(row, 'ended', 'ended by its person');
const ended = await sessions.finish(row, 'ended', 'ended by its person');
res.json(sessions.present(ended || row));
res.json(sessions.present(ended || row));
}));
}));
+ // The newest frame of the session's desk (plan 2026-11-07: the live still). The bytes come from the desk's own
+ // capture, through the session's lease, at most once a second and at most 640 px wide.
+ r.get('/sessions/:id/still', wrap(async (req, res) => {
+ const row = await mineStill(req, res);
+ if (!row) return;
+ const shot = await sessions.still(row);
+ res.set('Cache-Control', 'private, no-store');
+ res.set('X-Content-Type-Options', 'nosniff');
+ res.set('Content-Type', shot.type);
+ res.set('Content-Length', String(shot.bytes.length));
+ res.status(200).end(shot.bytes);
+ }));
+ // Curtain now: the privacy screen, so the next still is the curtain picture and never the desk.
+ r.post('/sessions/:id/curtain', wrap(async (req, res) => {
+ const row = await mine(req, res, 'run.session.control');
+ if (!row) return;
+ const up = req.body && typeof req.body === 'object' && typeof req.body.up === 'boolean' ? req.body.up : null;
+ if (up === null) return problem(res, req, 422, 'run.session.invalid', 'up is true (raise the curtain) or false (lift it)');
+ const saved = await sessions.setCurtain(row, up);
+ res.json(sessions.present(saved));
+ }));
return r;
return r;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+'use strict';
+/**
+ * The curtain still: a 640 × 360 dark JPEG (about 1.7 KB), answered by the still endpoint while a session is behind
+ * "Curtain now". It is a picture of nothing on purpose: the desk's screen, its page and any text on it never reach
+ * the caller. Generated once (ImageMagick: convert -size 640x360 xc:'#0a0c11' -quality 60), embedded so serving it
+ * costs no file, no capture and no desk call.
+ */
+const BYTES = Buffer.from('/9j/4AAQSkZJRgABAQAAAAAAAAD/2wBDAA0JCgsKCA0LCgsODg0PEyAVExISEyccHhcgLikxMC4pLSwzOko+MzZGNywtQFdBRkxOUlNSMj5aYVpQYEpRUk//2wBDAQ4ODhMREyYVFSZPNS01T09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0//wAARCAFoAoADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFgEBAQEAAAAAAAAAAAAAAAAAAAEC/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AnADSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/2Q==', 'base64');
+
+module.exports = { BYTES, TYPE: 'image/jpeg', WIDTH: 640, HEIGHT: 360 };
@@ -24,6 +24,7 @@
@@ -24,6 +24,7 @@
*/
*/
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const store = require('./store');
const store = require('./store');
+const curtain = require('./curtain');
const usage = require('../jobs/usage');
const usage = require('../jobs/usage');
const { TABLE: EVENTS_TABLE, ACTOR } = require('../events/outbox');
const { TABLE: EVENTS_TABLE, ACTOR } = require('../events/outbox');
@@ -32,6 +33,11 @@ const DEFAULT_TRUST = ['first-party', 'community'];
@@ -32,6 +33,11 @@ const DEFAULT_TRUST = ['first-party', 'community'];
const STARTING_STALE_MS = 60_000;
const STARTING_STALE_MS = 60_000;
const OPERATION = 'browser.desk';
const OPERATION = 'browser.desk';
const SOURCE = 'openvibe-run.session';
const SOURCE = 'openvibe-run.session';
+// The still (plan 2026-11-07): one frame a second at most, never wider than 640 px. The desk's own capture is
+// reused — the same screen command Actor's computer tools and the agent stream's frames come from — and the answer
+// is cached for the second, so a page refreshing every two seconds asks the desk at most once per second.
+const STILL_MIN_MS = 1000;
+const STILL_WIDTH = 640;
class SessionError extends Error {
class SessionError extends Error {
constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail; }
constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail; }
@@ -45,6 +51,8 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -45,6 +51,8 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
let timer = null;
let timer = null;
let running = null;
let running = null;
const hostCache = new Map(); // subject → { at, hosts }
const hostCache = new Map(); // subject → { at, hosts }
+ const stills = new Map(); // session id → { at, bytes, type, width, height } (one second)
+ const pending = new Map(); // session id → Promise of the frame being captured
const bare = (owner) => String(owner).replace(/^user:/, '');
const bare = (owner) => String(owner).replace(/^user:/, '');
@@ -97,7 +105,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -97,7 +105,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
id: store.newSessionId(), owner, via: via || null, kind: 'browser', state: 'placing',
id: store.newSessionId(), owner, via: via || null, kind: 'browser', state: 'placing',
width: body.width || 1280, height: body.height || 800, ttl_s: body.ttl_s || 3600, idle_s: body.idle_s || 900,
width: body.width || 1280, height: body.height || 800, ttl_s: body.ttl_s || 3600, idle_s: body.idle_s || 900,
machine_asked: body.machine || null, requirements: body.requirements || null, idempotency_key: body.idempotency_key || null,
machine_asked: body.machine || null, requirements: body.requirements || null, idempotency_key: body.idempotency_key || null,
- request_digest: digest, reasons: [], created_at: at, updated_at: at,
+ request_digest: digest, reasons: [], task_id: body.task_id || null, curtain: false, created_at: at, updated_at: at,
};
};
const out = await store.insert(db, row);
const out = await store.insert(db, row);
if (out.created) setImmediate(() => { tick().catch((e) => log.warn(`[Run] session tick: ${e.message}`)); });
if (out.created) setImmediate(() => { tick().catch((e) => log.warn(`[Run] session tick: ${e.message}`)); });
@@ -115,6 +123,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -115,6 +123,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
const ended = { ...fresh, state: 'ended', ended_at: now(), end_reason: reason, end_detail: detail ? String(detail).slice(0, 500) : null };
const ended = { ...fresh, state: 'ended', ended_at: now(), end_reason: reason, end_detail: detail ? String(detail).slice(0, 500) : null };
const metered = await meter(t, ended, { final: true });
const metered = await meter(t, ended, { final: true });
const saved = await store.update(t, row.id, { state: 'ended', ended_at: ended.ended_at, end_reason: reason, end_detail: ended.end_detail, metered_minutes: metered, updated_at: now() });
const saved = await store.update(t, row.id, { state: 'ended', ended_at: ended.ended_at, end_reason: reason, end_detail: ended.end_detail, metered_minutes: metered, updated_at: now() });
+ stills.delete(row.id);
await emit(t, saved, 'run.session.ended');
await emit(t, saved, 'run.session.ended');
return saved;
return saved;
});
});
@@ -139,6 +148,50 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -139,6 +148,50 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
return out;
return out;
}
}
+ /**
+ * The session's newest frame, as a small JPEG (plan 2026-11-07: the live still Surf's card shows and the Dock
+ * links to). It reuses the desk's own capture — the `screen` command on the session's lease, the same one Actor's
+ * computer tools read — at most STILL_WIDTH wide, and at most once a second: a caller asking again inside the
+ * second gets the frame already captured.
+ *
+ * Curtain now: while the session's curtain is up the answer is the curtain picture, never a call to the desk.
+ * A session that is not running has no frame (404): the caller never learns whether a machine is there.
+ * → { bytes, type, width, height, curtain } or throws SessionError.
+ */
+ async function still(row) {
+ if (!row) throw new SessionError(404, 'run.session.not_found', 'no such session');
+ if (row.state !== 'running' || !row.machine || !row.desk) throw new SessionError(404, 'run.session.no_still', 'this session has no picture');
+ if (row.curtain) return { bytes: curtain.BYTES, type: curtain.TYPE, width: curtain.WIDTH, height: curtain.HEIGHT, curtain: true };
+ const fresh = stills.get(row.id);
+ if (fresh && now() - fresh.at < STILL_MIN_MS) return fresh;
+ if (pending.has(row.id)) return pending.get(row.id);
+ const shot = (async () => {
+ const r = await desks.act(row.machine, row.desk, bare(row.owner), { id: `still-${Math.floor(now() / 1000)}`, kind: 'screen', value: { max_width: STILL_WIDTH } });
+ if (r === null) {
+ await finish(row, 'host_lost', 'its machine no longer holds the desk', { release: false });
+ throw new SessionError(404, 'run.session.no_still', 'this session has no picture');
+ }
+ const d = r && r.result === 'ack' && r.data ? r.data : null;
+ if (!d || !d.content_b64 || !/^(jpeg|png)$/.test(String(d.format || ''))) throw new SessionError(404, 'run.session.no_still', 'the desk has not sent a frame yet');
+ const bytes = Buffer.from(String(d.content_b64), 'base64');
+ if (!bytes.length) throw new SessionError(404, 'run.session.no_still', 'the desk sent no picture');
+ const out = { at: now(), bytes, type: d.format === 'png' ? 'image/png' : 'image/jpeg', width: Number(d.width) || null, height: Number(d.height) || null, curtain: false };
+ stills.set(row.id, out);
+ if (stills.size > 500) stills.delete(stills.keys().next().value);
+ return out;
+ })().finally(() => pending.delete(row.id));
+ pending.set(row.id, shot);
+ return shot;
+ }
+
+ /** Curtain now: raise or lift the session's privacy screen. The next still is the curtain picture, never the desk. */
+ async function setCurtain(row, up) {
+ if (!row || row.state === 'ended') throw new SessionError(409, 'run.session.not_running', 'the session has ended');
+ const saved = await store.update(db, row.id, { curtain: Boolean(up), updated_at: now() });
+ stills.delete(row.id);
+ return saved;
+ }
+
// ── placement ─────────────────────────────────────────────────────────────────────────────────────────────
// ── placement ─────────────────────────────────────────────────────────────────────────────────────────────
async function hostsFor(subject) {
async function hostsFor(subject) {
const c = hostCache.get(subject);
const c = hostCache.get(subject);
@@ -235,7 +288,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -235,7 +288,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
}
}
return {
return {
- create, act, finish, tick, SessionError,
+ create, act, finish, still, setCurtain, tick, SessionError,
get: (id) => store.get(db, id),
get: (id) => store.get(db, id),
list: (owner, opts) => store.listOf(db, owner, opts),
list: (owner, opts) => store.listOf(db, owner, opts),
present: (row) => store.present(row, now()),
present: (row) => store.present(row, now()),
@@ -14,7 +14,7 @@ const { ids } = require('openvibe-contracts');
@@ -14,7 +14,7 @@ const { ids } = require('openvibe-contracts');
const COLUMNS = ['id', 'owner', 'via', 'kind', 'state', 'width', 'height', 'ttl_s', 'idle_s', 'machine_asked', 'requirements', 'idempotency_key',
const COLUMNS = ['id', 'owner', 'via', 'kind', 'state', 'width', 'height', 'ttl_s', 'idle_s', 'machine_asked', 'requirements', 'idempotency_key',
'request_digest', 'machine', 'desk', 'trust', 'offer_id', 'reasons', 'created_at', 'started_at', 'last_used_at', 'ended_at', 'end_reason',
'request_digest', 'machine', 'desk', 'trust', 'offer_id', 'reasons', 'created_at', 'started_at', 'last_used_at', 'ended_at', 'end_reason',
- 'end_detail', 'metered_minutes', 'lease_checked_at', 'updated_at'];
+ 'end_detail', 'metered_minutes', 'lease_checked_at', 'task_id', 'curtain', 'updated_at'];
const JSON_COLUMNS = new Set(['requirements', 'reasons']);
const JSON_COLUMNS = new Set(['requirements', 'reasons']);
const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());
const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());
@@ -60,6 +60,10 @@ function present(row, now = Date.now()) {
@@ -60,6 +60,10 @@ function present(row, now = Date.now()) {
end: row.state === 'ended' ? { reason: row.end_reason || 'ended', detail: row.end_detail ? String(row.end_detail).slice(0, 500) : null } : null,
end: row.state === 'ended' ? { reason: row.end_reason || 'ended', detail: row.end_detail ? String(row.end_detail).slice(0, 500) : null } : null,
usage: { desk_seconds: deskSeconds(row, now) },
usage: { desk_seconds: deskSeconds(row, now) },
idempotency_key: row.idempotency_key || null,
idempotency_key: row.idempotency_key || null,
+ // One Computer (contracts 0.327.0): the Actor task that asked for this session, and whether its privacy
+ // screen is up. Both are additive: a session a person opened themselves has no task.
+ task_id: row.task_id || null,
+ curtain: Boolean(row.curtain),
};
};
}
}
@@ -178,6 +178,53 @@ function fakeDesks() {
@@ -178,6 +178,53 @@ function fakeDesks() {
assert.deepStrictEqual([late.status, late.json.code], [409, 'run.session.not_running']);
assert.deepStrictEqual([late.status, late.json.code], [409, 'run.session.not_running']);
});
});
+ await check('the still is its person\'s alone, at most once a second, and Curtain now hides the desk', async () => {
+ const TASK = 'tsk_01JAB2C3D4E5F6G7H8J9K0MNPT';
+ const r = await t.call('POST', '/api/v1/sessions', { ...as(bea), body: { kind: 'browser', ttl_s: 600, task_id: TASK } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json.task_id, TASK, 'the session carries the task back');
+ valid(r.json, 'run.session@1');
+ await sessions.tick();
+ const s = (await t.call('GET', `/api/v1/sessions/${r.json.id}`, as(bea))).json;
+ assert.deepStrictEqual([s.state, s.task_id, s.curtain], ['running', TASK, false]);
+ const acts = () => desks.calls.filter((c) => c[0] === 'act' && c[1] === s.placement.machine && c[4].kind === 'screen').length;
+ const before = acts();
+
+ const one = await t.call('GET', `/api/v1/sessions/${s.id}/still`, as(bea));
+ assert.strictEqual(one.status, 200, one.text);
+ assert.match(one.headers.get('content-type'), /^image\/jpeg/);
+ assert.strictEqual(one.headers.get('cache-control'), 'private, no-store');
+ assert.strictEqual(Number(one.headers.get('content-length')), 3, 'the desk\'s own frame, as it sent it');
+ assert.strictEqual(acts(), before + 1, 'one capture');
+ await t.call('GET', `/api/v1/sessions/${s.id}/still`, as(bea));
+ assert.strictEqual(acts(), before + 1, 'a second still in the same second reuses the frame');
+ t.clock.offset += 1_000;
+ await t.call('GET', `/api/v1/sessions/${s.id}/still`, as(bea));
+ assert.strictEqual(acts(), before + 2, 'a new second captures again');
+
+ assert.strictEqual((await t.call('GET', `/api/v1/sessions/${s.id}/still`, as(ada))).status, 404, 'another person: the same 404 as a missing session');
+ assert.strictEqual((await t.call('GET', `/api/v1/sessions/${s.id}/still`, { token: null })).status, 404, 'a signed-out visitor: 404, never 401');
+ const noRead = { token: t.network.signService({ sub: 'svc:actor', cap: ['run.session.create'] }), headers: { 'X-OV-Subject': bea } };
+ assert.strictEqual((await t.call('GET', `/api/v1/sessions/${s.id}/still`, noRead)).status, 404, 'a service without run.session.read');
+ const reader = { token: t.network.signService({ sub: 'svc:actor', cap: ['run.session.read'] }), headers: { 'X-OV-Subject': bea } };
+ assert.strictEqual((await t.call('GET', `/api/v1/sessions/${s.id}/still`, reader)).status, 200, 'a service acting for the person with run.session.read');
+ assert.strictEqual((await t.call('POST', `/api/v1/sessions/${s.id}/curtain`, { ...as(ada), body: { up: true } })).status, 404, 'only its person raises the curtain');
+
+ const up = await t.call('POST', `/api/v1/sessions/${s.id}/curtain`, { ...as(bea), body: { up: true } });
+ assert.strictEqual(up.status, 200, up.text);
+ assert.strictEqual(up.json.curtain, true);
+ valid(up.json, 'run.session@1');
+ const captured = acts();
+ const hidden = await t.call('GET', `/api/v1/sessions/${s.id}/still`, as(bea));
+ assert.strictEqual(hidden.status, 200);
+ assert.strictEqual(Number(hidden.headers.get('content-length')), 1666, 'the curtain picture, not the desk');
+ assert.strictEqual(acts(), captured, 'Curtain now never asks the desk');
+ assert.strictEqual((await t.call('POST', `/api/v1/sessions/${s.id}/curtain`, { ...as(bea), body: {} })).status, 422);
+ await t.call('POST', `/api/v1/sessions/${s.id}/end`, as(bea));
+ // An ended session has no picture.
+ assert.strictEqual((await t.call('GET', `/api/v1/sessions/${s.id}/still`, as(bea))).status, 404);
+ });
+
await check('the real desk client: Run\'s token for Bot with bot.desk.session, the person in X-OV-Subject', async () => {
await check('the real desk client: Run\'s token for Bot with bot.desk.session, the person in X-OV-Subject', async () => {
const seen = [];
const seen = [];
const bot = http.createServer((req, res) => {
const bot = http.createServer((req, res) => {