openvibers/openvibe.run Commits

Run sessions: task_id back, and a live still behind the curtain (#196)

OP OpenVibers committed 6 days ago · a58e6b90ad · 10 files, +190 −9

* Run sessions: the task id back, and a still at most once a second

A session now carries task_id back (run.session@1), so the Computer points
at the task that asked for it. GET /api/v1/sessions/:id/still answers the
desk's newest frame as a small JPEG (at most 640 px, cached for a second, at
most one capture a second) from the desk's own capture on the session's
lease; no second capture path. Curtain now: POST /api/v1/sessions/:id/curtain,
and while it is up the still is the curtain picture, never the desk.

Only the session's person, or a first-party service holding run.session.read
and naming them in X-OV-Subject, reads the still; anyone else (a signed-out
visitor included) gets the same 404 as a missing session. Cache-Control:
private, no-store. Migration 0008 adds task_id and curtain.

* Pin openvibe-contracts to the one-Computer PR head

v0.327.0 adds task_id and curtain to run.session@1 and computer to
platform.task@1 / actor.task-event@2; the pin moves to the Contracts PR's head
commit until its release tag exists (the pin round after that).

* CHANGELOG: the session still and task_id

* Pin openvibe-contracts to the final one-Computer PR head

* Pin openvibe-contracts to the final Computer contracts head

* Pin openvibe-contracts v0.327.0 (the release tag)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex aa98768..168a058 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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's
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
ddiff --git a/README.md b/README.md
iindex 4e07150..2f6a06e 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/migrations/0008_session_task_and_curtain.sql b/migrations/0008_session_task_and_curtain.sql
nnew file mode 100644
iindex 0000000..939133e
---- /dev/null
++++ b/migrations/0008_session_task_and_curtain.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 662f922..4ba3511 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 0f307f4..ed6e491 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/api/sessions.js b/server/api/sessions.js
iindex 19faf4a..05a36a1 100644
---- a/server/api/sessions.js
++++ b/server/api/sessions.js
@@@ -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;
}
ddiff --git a/server/sessions/curtain.js b/server/sessions/curtain.js
nnew file mode 100644
iindex 0000000..febea42
---- /dev/null
++++ b/server/sessions/curtain.js
@@@ -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 };
ddiff --git a/server/sessions/service.js b/server/sessions/service.js
iindex b0a82ca..15d1922 100644
---- a/server/sessions/service.js
++++ b/server/sessions/service.js
@@@ -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(), log
let 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 captured
const bare = (owner) => String(owner).replace(/^user:/, '');
@@@ -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',
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(), log
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 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(), log
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 ─────────────────────────────────────────────────────────────────────────────────────────────
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()),
ddiff --git a/server/sessions/store.js b/server/sessions/store.js
iindex ffce8fe..6f6c31b 100644
---- a/server/sessions/store.js
++++ b/server/sessions/store.js
@@@ -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),
};
}
ddiff --git a/test/sessions.test.js b/test/sessions.test.js
iindex 013e81b..588c631 100644
---- a/test/sessions.test.js
++++ b/test/sessions.test.js
@@@ -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) => {