openvibers/openvibe.run Commits

Add blind fill request flow (#213)

OP OpenVibers committed 4 days ago · 1147cf0b0d · 7 files, +256 −7

* Add blind fill request flow to Run

* Apply blind fill review fixes

* Test blind fill page text withholding

* Test blind fill request limits

* Bind machine fill to sealed origin

* Apply expiry cooldown before lease check

* Validate HTTPS origin pattern in Run

Changes

ddiff --git a/migrations/0015_blind_fill.sql b/migrations/0015_blind_fill.sql
nnew file mode 100644
iindex 0000000..827d8b4
---- /dev/null
++++ b/migrations/0015_blind_fill.sql
@@@ -0,0 +1,17 @@
++-- phase: expand
++-- Blind fill stores request metadata and a credential-free result; envelopes are single-use in transit only.
++CREATE TABLE IF NOT EXISTS run_session_fill_requests (
++ id text PRIMARY KEY,
++ session_id text NOT NULL REFERENCES run_sessions(id) ON DELETE CASCADE,
++ machine_node_id text NOT NULL,
++ agent_subject text NOT NULL,
++ origin text NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending', 'running', 'ok', 'failed', 'expired')),
++ decision text CHECK (decision IS NULL OR decision IN ('approved', 'declined', 'expired')),
++ outcome text CHECK (outcome IS NULL OR outcome IN ('ok', 'failed')),
++ created_at bigint NOT NULL,
++ expires_at bigint NOT NULL,
++ finished_at bigint,
++ resumed_at bigint
++);
++CREATE INDEX IF NOT EXISTS run_fill_pending ON run_session_fill_requests (session_id, state, expires_at);
ddiff --git a/server/api/sessions.js b/server/api/sessions.js
iindex 5aca596..43f106d 100644
---- a/server/api/sessions.js
++++ b/server/api/sessions.js
@@@ -27,7 +27,7 @@ const { capabilities, http, ids } = require('openvibe-contracts');
const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SESSION_RE = /^ses_[0-9A-HJKMNP-TV-Z]{26}$/;
--function sessionsRouter({ sessions }) {
++function sessionsRouter({ sessions, verifyFillToken = async () => null }) {
const r = express.Router();
const problem = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
@@@ -102,6 +102,20 @@ function sessionsRouter({ sessions }) {
if (!w) return;
res.json(await sessions.act(row, req.body && typeof req.body === 'object' ? req.body : {}, w.actor));
}));
++ r.post('/sessions/:id/fill-requests', wrap(async (req, res) => {
++ const row = await mine(req, res, 'run.session.control', true);
++ if (!row) return;
++ const w = who(req, res, 'run.session.control');
++ if (!w) return;
++ res.status(201).json(await sessions.createFillRequest(row, req.body && req.body.origin, w.actor));
++ }));
++ r.get('/sessions/:id/fill-requests/:requestId', wrap(async (req, res) => {
++ const row = await mine(req, res, 'run.session.control', true);
++ if (!row) return;
++ const w = who(req, res, 'run.session.control');
++ if (!w) return;
++ res.json(await sessions.getFillRequest(req.params.requestId, row.id, w.owner));
++ }));
r.post('/sessions/:id/control/request', wrap(async (req, res) => {
const row = await mine(req, res, 'run.session.control', true);
if (!row) return;
@@@ -162,4 +176,35 @@ function sessionsRouter({ sessions }) {
return r;
}
--module.exports = { sessionsRouter };
++/** Dock auth is deliberately isolated to Companion approval endpoints and runs before general user auth. */
++function fillApprovalRouter({ sessions, verifyFillToken }) {
++ const r = express.Router();
++ const problem = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
++ 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);
++ });
++ async function whoFill(req, res) {
++ const token = String(req.get('Authorization') || '').replace(/^Bearer\s+/i, '');
++ const claims = await verifyFillToken(token);
++ if (!claims) { problem(res, req, 401, 'capability.denied', 'Companion sign-in with run.fill.approve is required'); return null; }
++ return { owner: `user:${claims.subject_id}` };
++ }
++ r.get('/fill-requests', wrap(async (req, res) => {
++ const w = await whoFill(req, res); if (!w) return;
++ res.json({ requests: await sessions.listFillRequests(w.owner) });
++ }));
++ r.post('/fill-requests/:id/grant', wrap(async (req, res) => {
++ const w = await whoFill(req, res); if (!w) return;
++ const body = req.body && typeof req.body === 'object' ? req.body : {};
++ if (body.request_id !== req.params.id) return problem(res, req, 422, 'run.fill.request_id', 'request_id must match the route');
++ res.json(await sessions.approveFill(req.params.id, body.envelope, w.owner));
++ }));
++ r.post('/fill-requests/:id/decline', wrap(async (req, res) => {
++ const w = await whoFill(req, res); if (!w) return;
++ res.json(await sessions.decideFill(req.params.id, 'declined', w.owner));
++ }));
++ return r;
++}
++
++module.exports = { sessionsRouter, fillApprovalRouter };
ddiff --git a/server/app.js b/server/app.js
iindex 176288d..c24ad4d 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -22,7 +22,7 @@ const { http } = require('openvibe-contracts');
const { isLoopbackDirect } = require('openvibe-shared/metrics');
const { loadConfig } = require('./config');
const { openDb } = require('./db');
--const { createKeyProvider, createUserAuth, createProjectMembership } = require('./network');
++const { createKeyProvider, createUserAuth, createFillAuth, createProjectMembership, createFillNotifier } = require('./network');
const { createRunOutbox } = require('./events/outbox');
const { createStreamTickets } = require('./jobs/ticket');
const { createJobStream } = require('./jobs/stream');
@@@ -30,7 +30,7 @@ const { createRunEvents } = require('./jobs/events');
const { createDispatcher } = require('./dispatch');
const { createApiAuth } = require('./api/auth');
const { v1Router } = require('./api/v1');
--const { sessionsRouter } = require('./api/sessions');
++const { sessionsRouter, fillApprovalRouter } = require('./api/sessions');
const { createDeskClient } = require('./sessions/desks');
const { createSessions } = require('./sessions/service');
const { inputError } = require('./util');
@@@ -50,6 +50,7 @@ function createApp(opts = {}) {
const now = opts.now || (() => Date.now());
const keys = opts.keys || createKeyProvider(config, { fetchImpl, log });
const userAuth = createUserAuth(config, keys);
++ const fillAuth = createFillAuth(config, keys);
const outbox = opts.outbox || createRunOutbox({ db, config, fetchImpl: opts.eventsFetch, now, log });
const events = opts.events || createRunEvents({ outbox, now, log, config });
const stream = opts.stream || createJobStream({ config, now, log });
@@@ -59,11 +60,12 @@ function createApp(opts = {}) {
// Run cannot mint the Bot token — and nothing runs until server/index.js calls its start().
const dispatch = opts.dispatch || createDispatcher({ config, db, store: require('./jobs/store'), events, stream, log, now, fetchImpl });
const membership = opts.membership || createProjectMembership(config, { fetchImpl, now });
++ const fillNotifier = opts.fillNotifier || createFillNotifier(config, { fetchImpl, now });
const apiAuth = createApiAuth({ config, keys, userAuth, membership, log });
// Run sessions (contracts 0.223.0): browser desks Bot leases per session; the tick starts in server/index.js.
const desks = opts.desks || createDeskClient({ config, fetchImpl, now });
const broker = opts.broker || require('./compute/broker').createBroker({ config, db, desks, now, fetchImpl, log });
-- const sessions = opts.sessions || createSessions({ config, db, desks, outbox, now, log, broker });
++ const sessions = opts.sessions || createSessions({ config, db, desks, outbox, now, log, broker, notifyFill: fillNotifier.notify });
broker.attachSessions(async (id) => { const row = await sessions.get(id); if (row) await sessions.finish(row, 'ttl', 'compute rental budget exhausted'); });
broker.attachJobs(async (machine) => {
const jobs = require('./jobs/store');
@@@ -128,6 +130,7 @@ function createApp(opts = {}) {
}),
express.json({ limit: JSON_LIMIT }),
(req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); },
++ fillApprovalRouter({ sessions, verifyFillToken: fillAuth.verify }),
apiAuth.middleware,
require('./compute/routes').computeRouter({ broker, config }),
sessionsRouter({ sessions }),
ddiff --git a/server/network.js b/server/network.js
iindex 26c66b7..2996df8 100644
---- a/server/network.js
++++ b/server/network.js
@@@ -11,9 +11,11 @@
* createProjectMembership), can act on jobs — every route says so.
*/
const crypto = require('crypto');
++const jwt = require('jsonwebtoken');
const { verifyUserToken, createServiceTokenClient } = require('openvibe-sdk/auth');
const MEMBER_READ = 'network.project.member.read';
++const FILL_NOTIFY = 'network.notifications.push';
const DELEGABLE_ROLES = new Set(['admin', 'owner']);
const MEMBER_TTL_MS = 60_000; // the amendment's ceiling: a removed member or an archived project stops new jobs within a minute
@@@ -66,6 +68,21 @@ function createUserAuth(config, keys) {
return { verify };
}
++/** Companion Dock tokens are accepted only by fill approval routes, never as general user auth. */
++function createFillAuth(config, keys) {
++ async function verify(token) {
++ if (!token || !keys.get()) return null;
++ try {
++ const claims = jwt.verify(token, keys.get(), { algorithms: ['RS256'], issuer: `${String(config.network.issuer).replace(/\/+$/, '')}/dock`, audience: 'openvibe.network' });
++ if (claims.typ !== undefined || claims.purpose !== undefined || claims.actor_type !== undefined || claims.client_id !== 'companion' || claims.role !== undefined || claims.staff_caps !== undefined
++ || !/^fam_[0-9a-f]{24}$/.test(String(claims.fam || '')) || !String(claims.scope || '').split(/\s+/).includes('run.fill.approve')
++ || !require('openvibe-contracts').ids.isSubjectId('user', String(claims.subject_id || ''))) return null;
++ return claims;
++ } catch { return null; }
++ }
++ return { verify };
++}
++
/**
* Project delegation (ADR-003 amendment 2026-10-12): may a first-party service start a job for this person in
* this project? Network states the membership (GET /internal/projects/:project/members/:subject, capability
@@@ -101,4 +118,21 @@ function createProjectMembership(config, { fetchImpl = globalThis.fetch, now = (
return { allows, enabled: Boolean(tokens) };
}
--module.exports = { createKeyProvider, createUserAuth, createProjectMembership, MEMBER_TTL_MS, DELEGABLE_ROLES };
++function createFillNotifier(config, { fetchImpl = globalThis.fetch, now = () => Date.now() } = {}) {
++ const { clientId, clientSecret } = config.oauth;
++ const tokens = clientSecret ? createServiceTokenClient({ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId, clientSecret, audience: 'openvibe.network', scope: FILL_NOTIFY, fetch: fetchImpl, now }) : null;
++ async function notify(request) {
++ if (!tokens) return;
++ const res = await fetchImpl(`${config.network.internalUrl}/internal/notifications/push`, {
++ method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...(await tokens.authHeaders()) },
++ body: JSON.stringify({ subject: request.subject, type: 'RUN_FILL_REQUEST', category: 'service', priority: 'high',
++ title: 'Approve a sign-in', message: `${request.agent} on ${request.computer} wants to fill sign-in fields on ${request.origin}. Review what will be typed in Companion.`,
++ service: 'run', url: `https://openvibe.network/companion/fill-requests?request=${encodeURIComponent(request.id)}`, expires_at: request.expiresAt }),
++ signal: AbortSignal.timeout(5000),
++ });
++ if (!res.ok) throw new Error(`Network notification answered ${res.status}`);
++ }
++ return { notify, enabled: Boolean(tokens) };
++}
++
++module.exports = { createKeyProvider, createUserAuth, createFillAuth, createProjectMembership, createFillNotifier, MEMBER_TTL_MS, DELEGABLE_ROLES };
ddiff --git a/server/sessions/service.js b/server/sessions/service.js
iindex 04d449e..00db5b4 100644
---- a/server/sessions/service.js
++++ b/server/sessions/service.js
@@@ -43,7 +43,7 @@ class SessionError extends Error {
constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail; }
}
--function createSessions({ config, db, desks, outbox, now = () => Date.now(), log = console, broker = null }) {
++function createSessions({ config, db, desks, outbox, now = () => Date.now(), log = console, broker = null, notifyFill = async () => {} }) {
const cfg = config.sessions || {};
const tickMs = cfg.tickMs || 2000;
const leaseCheckMs = cfg.leaseCheckMs || 30_000;
@@@ -53,6 +53,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
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 fills = new Map(); // session id → active blind fill id; credential data is never held here
const bare = (owner) => String(owner).replace(/^user:/, '');
@@@ -179,6 +180,8 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
return row.control_lease || lease;
}
async function requestControl(row, actor) {
++ const activeFill = fills.has(row.id) || await db.maybe("SELECT id FROM run_session_fill_requests WHERE session_id = $1 AND (state IN ('pending','running') OR (state IN ('ok','failed','expired') AND resumed_at IS NULL)) LIMIT 1", [row.id]);
++ if (activeFill) throw new SessionError(423, 'run.fill.in_progress', 'control is paused while a sign-in fill is active');
const current = await controlLease(row);
const target = actorHolder(actor, row);
if (sameHolder(current.holder, target)) return current;
@@@ -195,6 +198,8 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
return saved.control_lease;
}
async function grantControl(row, target, reason = 'granted') {
++ const activeFill = await db.maybe("SELECT id FROM run_session_fill_requests WHERE session_id = $1 AND (state IN ('pending','running') OR (state IN ('ok','failed','expired') AND resumed_at IS NULL)) LIMIT 1", [row.id]);
++ if (activeFill && reason !== 'fill_finished') throw new SessionError(423, 'run.fill.in_progress', 'control is paused while a sign-in fill is active');
const current = await controlLease(row);
if (!target || !['person', 'ai', 'nobody'].includes(target.kind)) throw new SessionError(422, 'run.session.control_target', 'target kind is person, ai or nobody');
const subject = target.kind === 'nobody' ? null : String(target.subject || '').trim().slice(0, 200);
@@@ -213,8 +218,11 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
const RESULTS = new Set(['ack', 'nack', 'refused', 'expired']);
async function act(row, cmd, actor = null) {
++ const activeFill = fills.has(row.id) || await db.maybe("SELECT id FROM run_session_fill_requests WHERE session_id = $1 AND (state IN ('pending','running') OR (state IN ('ok','failed','expired') AND resumed_at IS NULL)) LIMIT 1", [row.id]);
++ if (activeFill) throw new SessionError(423, 'run.fill.in_progress', 'control is paused while a sign-in fill is active');
const v = contracts.validate('run.session-control-request@1', cmd);
if (!v.valid) throw new SessionError(422, 'run.session.invalid', `invalid command: ${(v.errors && v.errors[0] && v.errors[0].message) || 'bad value'}`.slice(0, 300));
++ if (cmd.kind === 'browser' && ['active_origin', 'blind_fill'].includes(cmd.value && cmd.value.action)) throw new SessionError(403, 'run.fill.internal_action', 'this browser action is available only through the blind sign-in flow');
const id = cmd.id || `c-${now().toString(36)}`;
if (row.state !== 'running') throw new SessionError(409, 'run.session.not_running', row.state === 'ended' ? 'the session has ended' : 'the session\'s desk is not running yet');
const lease = await controlLease(row);
@@@ -239,6 +247,81 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
return out;
}
++ const EXACT_HTTPS_ORIGIN = /^https:\/\/[a-z0-9.-]+(:[0-9]{1,5})?$/;
++ const exactOrigin = (value) => {
++ try { if (typeof value !== 'string' || !EXACT_HTTPS_ORIGIN.test(value)) return null; const u = new URL(value); if (u.protocol !== 'https:' || u.origin !== value) return null; return u.origin; }
++ catch { return null; }
++ };
++ async function createFillRequest(row, requestedOrigin, actor) {
++ const origin = exactOrigin(requestedOrigin);
++ if (!origin) throw new SessionError(422, 'run.fill.origin', 'origin must be an exact HTTPS origin');
++ if (row.state !== 'running' || !row.machine || !row.desk) throw new SessionError(409, 'run.session.not_running', 'the session is not running');
++ if (!['user-owned', 'first-party'].includes(row.trust)) throw new SessionError(403, 'run.fill.machine_forbidden', 'blind fill is available only on your machine or OpenVibe machines');
++ const expiredFill = await db.maybe("UPDATE run_session_fill_requests SET state = 'expired', decision = 'expired', finished_at = $2 WHERE session_id = $1 AND state = 'pending' AND expires_at <= $2 RETURNING id, agent_subject", [row.id, now()]);
++ if (expiredFill) setTimeout(async () => { await resumeFill(expiredFill.id, row.id, expiredFill.agent_subject); }, 5000).unref?.();
++ const recent = await db.maybe("SELECT decision, COALESCE(finished_at, expires_at) AS ended_at FROM run_session_fill_requests WHERE session_id = $1 ORDER BY created_at DESC LIMIT 1", [row.id]);
++ if (recent && ['declined', 'expired'].includes(recent.decision) && now() - Number(recent.ended_at) < 60_000) throw new SessionError(429, 'run.fill.cooldown', 'wait before asking for another sign-in approval');
++ if (fills.has(row.id)) throw new SessionError(409, 'run.fill.pending', 'a sign-in approval is already pending');
++ const hourly = await db.many("SELECT id FROM run_session_fill_requests WHERE session_id = $1 AND created_at > $2 LIMIT 10", [row.id, now() - 60 * 60 * 1000]);
++ if (hourly.length >= 10) throw new SessionError(429, 'run.fill.limit', 'this session reached its sign-in approval limit');
++ const lease = await controlLease(row);
++ const holder = actorHolder(actor, row);
++ if (holder.kind !== 'ai' || !sameHolder(lease.holder, holder)) throw new SessionError(403, 'run.fill.control_required', 'the agent must hold this session control lease');
++ const current = await desks.act(row.machine, row.desk, bare(row.owner), { id: `fill-origin-${now()}`, kind: 'browser', value: { action: 'active_origin' } });
++ const activeOrigin = current && current.result === 'ack' && current.data && current.data.origin;
++ if (exactOrigin(activeOrigin) !== origin) throw new SessionError(409, 'run.fill.origin_mismatch', 'the active tab origin does not match');
++ const id = `fr_${require('openvibe-contracts').ids.ulid()}`;
++ const created = now(), expires = created + 120_000;
++ await db.maybe(`INSERT INTO run_session_fill_requests (id, session_id, machine_node_id, agent_subject, origin, state, created_at, expires_at) VALUES ($1,$2,$3,$4,$5,'pending',$6,$7) RETURNING id`, [id, row.id, row.machine, holder.subject, origin, created, expires]);
++ fills.set(row.id, id);
++ const paused = { ...lease, holder: { kind: 'nobody', subject: null }, since_ms: created, expires_ms: null, seq: lease.seq + 1 };
++ await saveLease(row, paused, 'awaiting_fill');
++ Promise.resolve(notifyFill({ subject: bare(row.owner), id, origin, agent: String(holder.subject).slice(0, 200), computer: String(row.machine).slice(0, 200), expiresAt: new Date(expires).toISOString() }))
++ .catch((e) => log.warn(`[Run] fill notification ${id}: ${e.code || e.message}`));
++ return { request_id: id, status: 'pending' };
++ }
++ async function approveFill(id, envelope, owner) {
++ const request = await db.maybe("SELECT * FROM run_session_fill_requests WHERE id = $1 AND state = 'pending' AND expires_at > $2", [id, now()]);
++ if (!request) throw new SessionError(404, 'run.fill.not_found', 'no pending approval');
++ const row = await store.get(db, request.session_id);
++ if (!row || row.owner !== owner) throw new SessionError(404, 'run.fill.not_found', 'no pending approval');
++ if (typeof envelope !== 'string' || envelope.length < 12 || envelope.length > 16_384 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(envelope)) throw new SessionError(413, 'run.fill.envelope', 'the base64 encrypted envelope must be at most 16 KB');
++ const claimed = await db.maybe("UPDATE run_session_fill_requests SET state = 'running', decision = 'approved' WHERE id = $1 AND state = 'pending' AND expires_at > $2 RETURNING id", [id, now()]);
++ if (!claimed) throw new SessionError(409, 'run.fill.used', 'the approval was already used');
++ const result = await desks.act(row.machine, row.desk, bare(row.owner), { id: `fill-${id}`, kind: 'browser', value: { action: 'blind_fill', request_id: id, envelope } }).catch(() => null);
++ const outcome = result && result.result === 'ack' && result.data && result.data.outcome === 'ok' ? 'ok' : 'failed';
++ await db.maybe("UPDATE run_session_fill_requests SET state = $1, outcome = $1, finished_at = $2 WHERE id = $3 AND state = 'running'", [outcome, now(), id]);
++ setTimeout(async () => {
++ await resumeFill(id, row.id, request.agent_subject);
++ }, 5000).unref?.();
++ return { request_id: id, outcome, reason: outcome === 'ok' ? null : 'decrypt_failed' };
++ }
++ async function decideFill(id, decision, owner) {
++ if (decision !== 'declined') throw new SessionError(422, 'run.fill.decision', 'unsupported decision');
++ const row = await db.maybe("UPDATE run_session_fill_requests f SET state = 'failed', decision = 'declined', outcome = 'failed', finished_at = $1 FROM run_sessions s WHERE f.id = $2 AND f.session_id = s.id AND s.owner = $3 AND f.state = 'pending' AND f.expires_at > $1 RETURNING f.session_id, f.agent_subject", [now(), id, owner]);
++ if (!row) throw new SessionError(404, 'run.fill.not_found', 'no pending approval');
++ setTimeout(async () => { await resumeFill(id, row.session_id, row.agent_subject); }, 5000).unref?.();
++ return { request_id: id, outcome: 'failed', reason: 'refused' };
++ }
++ async function resumeFill(id, sessionId, agentSubject) {
++ const claimed = await db.maybe("UPDATE run_session_fill_requests SET resumed_at = $1 WHERE id = $2 AND resumed_at IS NULL RETURNING id", [now(), id]);
++ if (!claimed) return;
++ fills.delete(sessionId);
++ const session = await store.get(db, sessionId);
++ if (session && session.state === 'running') await grantControl(session, { kind: 'ai', subject: agentSubject }, 'fill_finished').catch((e) => log.warn(`[Run] fill ${id}: could not resume controller (${e.code || e.message})`));
++ }
++ async function getFillRequest(id, sessionId, owner) {
++ const r = await db.maybe('SELECT f.id, f.origin, f.state, f.outcome, f.created_at, f.expires_at FROM run_session_fill_requests f JOIN run_sessions s ON s.id=f.session_id WHERE f.id=$1 AND f.session_id=$2 AND s.owner=$3', [id, sessionId, owner]);
++ if (!r) throw new SessionError(404, 'run.fill.not_found', 'no such fill request');
++ const reason = r.state === 'expired' ? 'expired' : r.outcome === 'failed' ? 'refused' : null;
++ return { request_id:r.id, origin:r.origin, state:r.state, outcome:r.outcome || null, created_at:new Date(Number(r.created_at)).toISOString(), expires_at:new Date(Number(r.expires_at)).toISOString(), reason };
++ }
++ async function listFillRequests(owner) {
++ const rows = await db.many("SELECT f.id, f.session_id, f.machine_node_id, f.origin, f.created_at, f.expires_at, f.agent_subject FROM run_session_fill_requests f JOIN run_sessions s ON s.id = f.session_id WHERE s.owner = $1 AND f.state = 'pending' AND f.expires_at > $2 ORDER BY f.created_at", [owner, now()]);
++ return rows.map((r) => ({ id: r.id, session_id: r.session_id, machine_node_id: r.machine_node_id, origin: r.origin,
++ created_at: new Date(Number(r.created_at)).toISOString(), expires_at: new Date(Number(r.expires_at)).toISOString(), agent: String(r.agent_subject).slice(0, 200), computer: String(r.machine_node_id).slice(0, 200) }));
++ }
++
async function logAction(row, id, cmd, actor, result, code) {
const kind = String(cmd.kind || 'unknown').slice(0, 40);
const action = kind === 'browser' && cmd.value && typeof cmd.value.action === 'string' ? cmd.value.action.slice(0, 48) : null;
@@@ -277,6 +360,8 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
*/
async function still(row) {
if (!row) throw new SessionError(404, 'run.session.not_found', 'no such session');
++ const protectedFill = fills.has(row.id) || await db.maybe("SELECT id FROM run_session_fill_requests WHERE session_id = $1 AND (state IN ('pending','running') OR (state IN ('ok','failed','expired') AND resumed_at IS NULL)) LIMIT 1", [row.id]);
++ if (protectedFill) throw new SessionError(423, 'run.fill.in_progress', 'capture withheld while a sign-in fill is active');
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);
@@@ -373,6 +458,12 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
// ── the tick ──────────────────────────────────────────────────────────────────────────────────────────────
async function tickOnce() {
++ const expiredFills = await db.many("SELECT id, session_id, agent_subject FROM run_session_fill_requests WHERE state = 'pending' AND expires_at <= $1", [now()]);
++ for (const fill of expiredFills) {
++ await db.maybe("UPDATE run_session_fill_requests SET state = 'expired', decision = 'expired', finished_at = $1 WHERE id = $2 AND state = 'pending'", [now(), fill.id]);
++ }
++ const dueResume = await db.many("SELECT id, session_id, agent_subject FROM run_session_fill_requests WHERE state IN ('ok','failed','expired') AND resumed_at IS NULL AND COALESCE(finished_at,expires_at) <= $1", [now() - 5000]);
++ for (const fill of dueResume) await resumeFill(fill.id, fill.session_id, fill.agent_subject);
for (const row of await store.open(db)) {
try {
if (row.state === 'placing') { await place(row); continue; }
@@@ -413,6 +504,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
return {
create, act, finish, still, setCurtain, requestControl, grantControl, takeControl, controlLease, timeline, isGuest, snapshot, tick, SessionError,
++ createFillRequest, approveFill, decideFill, listFillRequests, getFillRequest,
get: (id) => store.get(db, id),
list: (owner, opts) => store.listOf(db, owner, opts),
present: (row) => store.present(row, now()),
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex e6a0966..950c610 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -129,6 +129,9 @@ async function startNetwork() {
signUser({ subject = ids.newId('user'), username = 'ada', role = 'user' } = {}) {
return jwt.sign({ sub: String(Date.parse('2026-01-01T00:00:00Z')), subject_id: subject, username, display_name: username, role }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });
},
++ signDock({ subject = ids.newId('user'), scope = 'run.fill.approve' } = {}) {
++ return jwt.sign({ sub: String(Date.parse('2026-01-01T00:00:00Z')), subject_id: subject, client_id: 'companion', scope, fam: `fam_${crypto.randomBytes(12).toString('hex')}` }, privatePem, { algorithm: 'RS256', issuer: `${issuer}/dock`, audience: 'openvibe.network', expiresIn: '1h' });
++ },
};
}
ddiff --git a/test/sessions.test.js b/test/sessions.test.js
iindex 5b7acbb..404565e 100644
---- a/test/sessions.test.js
++++ b/test/sessions.test.js
@@@ -41,6 +41,8 @@ function fakeDesks() {
async act(machine, desk, subject, cmd) {
calls.push(['act', machine, desk, subject, cmd]);
if (!leases.has(`${machine} ${desk}`)) return null;
++ if (cmd.value && cmd.value.action === 'active_origin') return { machine_id: machine, desk, id: cmd.id, result: 'ack', data: { origin: 'https://example.org' } };
++ if (cmd.value && cmd.value.action === 'blind_fill') return { machine_id: machine, desk, id: cmd.id, result: 'ack', data: { outcome: 'ok' } };
return { machine_id: machine, desk, id: cmd.id, result: 'ack', data: cmd.kind === 'screen' ? { format: 'jpeg', width: 1280, height: 800, content_b64: '/9j/' } : { url: 'https://example.org/', title: 'Example' } };
},
async end(machine, desk) { calls.push(['end', machine, desk]); const had = leases.delete(`${machine} ${desk}`); return had ? { ended: true, stopped: true } : null; },
@@@ -55,6 +57,7 @@ function fakeDesks() {
const ada = ids.newId('user');
const bea = ids.newId('user');
const as = (subject) => ({ token: t.network.signUser({ subject }) });
++ const asCompanion = (subject) => ({ token: t.network.signDock({ subject }) });
const valid = (doc, id) => { const v = contracts.validate(id, doc); assert.ok(v.valid, `${id}: ${JSON.stringify(v.errors)}\n${JSON.stringify(doc)}`); };
const usageOf = async (id) => (await t.db.many('SELECT envelope FROM run_usage_outbox ORDER BY id')).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((u) => u.id.startsWith(`run:${id}:`));
@@@ -124,6 +127,53 @@ function fakeDesks() {
valid(grant.json.lease, 'common.control-lease@1');
const held = await t.call('POST', `/api/v1/sessions/${ses.id}/commands`, { ...agent, body: { kind: 'screen', value: {} } });
assert.strictEqual(held.status, 200, held.text);
++ assert.strictEqual((await t.call('POST', `/api/v1/sessions/${ses.id}/fill-requests`, { ...agent, body: { origin: 'https://example.org/path' } })).status, 422, 'the requested value must be an exact origin');
++ assert.strictEqual((await t.call('POST', `/api/v1/sessions/${ses.id}/fill-requests`, { ...agent, body: { origin: 'http://example.org' } })).status, 422, 'plain HTTP is never eligible for credential entry');
++ const dock = asCompanion(ada);
++ for (const [method, route, body] of [
++ ['POST', `/api/v1/sessions/${ses.id}/commands`, { kind: 'screen', value: {} }],
++ ['GET', `/api/v1/sessions/${ses.id}/control`],
++ ]) assert.ok([401, 403].includes((await t.call(method, route, { ...dock, body })).status), `Dock token cannot use ${route}`);
++ assert.ok([401, 403].includes((await t.call('GET', `/api/v1/sessions/${ses.id}/still`, dock)).status), 'Dock token receives no capture access');
++ const mismatch = await t.call('POST', `/api/v1/sessions/${ses.id}/fill-requests`, { ...agent, body: { origin: 'https://other.example' } });
++ assert.strictEqual(mismatch.status, 409, 'Run reads and checks the active tab origin itself');
++ const pending = await t.call('POST', `/api/v1/sessions/${ses.id}/fill-requests`, { ...agent, body: { origin: 'https://example.org' } });
++ assert.strictEqual(pending.status, 201, pending.text);
++ assert.deepStrictEqual(Object.keys(pending.json).sort(), ['request_id', 'status']);
++ assert.strictEqual(pending.json.status, 'pending');
++ const hiddenWhileFill = await t.call('GET', `/api/v1/sessions/${ses.id}/still`, as(ada));
++ assert.strictEqual(hiddenWhileFill.status, 423, 'screenshots are withheld while a fill is pending');
++ assert.ok([401, 403].includes((await t.call('GET', '/api/v1/fill-requests', as(ada))).status), 'an ordinary user token cannot approve fills');
++ const requests = await t.call('GET', '/api/v1/fill-requests', dock);
++ assert.strictEqual(requests.json && requests.json.requests && requests.json.requests[0] && requests.json.requests[0].origin, 'https://example.org', requests.text);
++ const oversized = await t.call('POST', `/api/v1/fill-requests/${pending.json.request_id}/grant`, { ...dock, body: { request_id: pending.json.request_id, envelope: 'A'.repeat(16_388) } });
++ assert.strictEqual(oversized.status, 413, 'oversized envelopes are rejected before claim');
++ assert.strictEqual(oversized.json.code, 'run.fill.envelope');
++ const grantFill = await t.call('POST', `/api/v1/fill-requests/${pending.json.request_id}/grant`, { ...dock, body: { request_id: pending.json.request_id, envelope: 'T1ZMSwAAAAAAAAA=' } });
++ assert.strictEqual(grantFill.json.outcome, 'ok', grantFill.text);
++ const machineFill = desks.calls.find((c) => c[4] && c[4].value && c[4].value.action === 'blind_fill')[4].value;
++ assert.deepStrictEqual(Object.keys(machineFill).sort(), ['action', 'envelope', 'request_id']);
++ assert.ok(!/username|password|secret|code/i.test(JSON.stringify([pending.json, requests.json, grantFill.json])), 'no response contains credential contents');
++ assert.strictEqual((await t.call('GET', `/api/v1/sessions/${ses.id}/still`, as(ada))).status, 423, 'captures remain withheld after completion until resume');
++ assert.strictEqual((await t.call('POST', `/api/v1/sessions/${ses.id}/commands`, { ...agent, body: { kind: 'browser', value: { action: 'read' } } })).status, 423, 'page text stays withheld until resume');
++ assert.strictEqual((await t.call('POST', `/api/v1/fill-requests/${pending.json.request_id}/grant`, { ...dock, body: { request_id: pending.json.request_id, envelope: 'T1ZMSwAAAAAAAAA=' } })).status, 404, 'a request cannot be reused');
++ await new Promise((resolve) => setTimeout(resolve, 5100));
++ const expiring = await t.call('POST', `/api/v1/sessions/${ses.id}/fill-requests`, { ...agent, body: { origin: 'https://example.org' } });
++ assert.strictEqual(expiring.status, 201, expiring.text);
++ await t.db.maybe('UPDATE run_session_fill_requests SET expires_at = $1 WHERE id = $2 RETURNING id', [Date.now() + t.clock.offset - 1, expiring.json.request_id]);
++ const expiredCooldown = await t.call('POST', `/api/v1/sessions/${ses.id}/fill-requests`, { ...agent, body: { origin: 'https://example.org' } });
++ assert.deepStrictEqual([expiredCooldown.status, expiredCooldown.json.code], [429, 'run.fill.cooldown']);
++ await new Promise((resolve) => setTimeout(resolve, 5100));
++ await t.call('POST', `/api/v1/sessions/${ses.id}/control/take`, as(ada));
++ t.clock.offset += 60_001;
++ await t.call('POST', `/api/v1/sessions/${ses.id}/control/grant`, { ...as(ada), body: { holder: { kind: 'ai', subject: 'svc:actor' } } });
++ const declinedRequest = await t.call('POST', `/api/v1/sessions/${ses.id}/fill-requests`, { ...agent, body: { origin: 'https://example.org' } });
++ assert.strictEqual(declinedRequest.status, 201, declinedRequest.text);
++ const declined = await t.call('POST', `/api/v1/fill-requests/${declinedRequest.json.request_id}/decline`, dock);
++ assert.strictEqual(declined.json.reason, 'refused');
++ await new Promise((resolve) => setTimeout(resolve, 5100));
++ const cooldown = await t.call('POST', `/api/v1/sessions/${ses.id}/fill-requests`, { ...agent, body: { origin: 'https://example.org' } });
++ assert.deepStrictEqual([cooldown.status, cooldown.json.code], [429, 'run.fill.cooldown']);
assert.strictEqual((await t.call('POST', `/api/v1/sessions/${ses.id}/commands`, { ...as(ada), body: { kind: 'screen', value: {} } })).status, 403, 'owner must take back before sending input');
lease = (await t.call('POST', `/api/v1/sessions/${ses.id}/control/take`, as(ada))).json.lease;
assert.deepStrictEqual(lease.holder, { kind: 'person', subject: ada });
@@@ -176,6 +226,11 @@ function fakeDesks() {
await sessions.tick();
const s = (await t.call('GET', `/api/v1/sessions/${r.json.id}`, as(bea))).json;
assert.deepStrictEqual([s.state, s.placement.machine, s.placement.trust], ['running', SHARED, 'community']);
++ await t.call('POST', `/api/v1/sessions/${s.id}/control/grant`, { ...as(bea), body: { holder: { kind: 'ai', subject: 'svc:actor' } } });
++ const agent = { token: t.network.signService({ sub: 'svc:actor', cap: ['run.session.control'] }), headers: { 'X-OV-Subject': bea } };
++ const communityFill = await t.call('POST', `/api/v1/sessions/${s.id}/fill-requests`, { ...agent, body: { origin: 'https://example.org' } });
++ assert.deepStrictEqual([communityFill.status, communityFill.json.code], [403, 'run.fill.machine_forbidden']);
++ await t.call('POST', `/api/v1/sessions/${s.id}/control/take`, as(bea));
desks.state.busy.delete(OPENVIBE);
await t.call('POST', `/api/v1/sessions/${s.id}/end`, as(bea));
const named = await t.call('POST', '/api/v1/sessions', { ...as(bea), body: { kind: 'browser', machine: 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2Z' } });