openvibers/openvibe.run Commits
Add blind fill request flow (#213)
Changes
nnew file mode 100644@@@ -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);@@@ -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 };@@@ -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 }),@@@ -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 };@@@ -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(), logconst 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 hereconst bare = (owner) => String(owner).replace(/^user:/, '');@@@ -179,6 +180,8 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), logreturn 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(), logreturn 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(), logconst 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(), logreturn 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(), logreturn {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()),@@@ -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' });++ },};}@@@ -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' } });
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -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);
@@ -27,7 +27,7 @@ const { capabilities, http, ids } = require('openvibe-contracts');
@@ -27,7 +27,7 @@ const { capabilities, http, ids } = require('openvibe-contracts');
const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const SESSION_RE = /^ses_[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 r = express.Router();
const problem = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
const problem = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
@@ -102,6 +102,20 @@ function sessionsRouter({ sessions }) {
@@ -102,6 +102,20 @@ function sessionsRouter({ sessions }) {
if (!w) return;
if (!w) return;
res.json(await sessions.act(row, req.body && typeof req.body === 'object' ? req.body : {}, w.actor));
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) => {
r.post('/sessions/:id/control/request', wrap(async (req, res) => {
const row = await mine(req, res, 'run.session.control', true);
const row = await mine(req, res, 'run.session.control', true);
if (!row) return;
if (!row) return;
@@ -162,4 +176,35 @@ function sessionsRouter({ sessions }) {
@@ -162,4 +176,35 @@ function sessionsRouter({ sessions }) {
return r;
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 };
@@ -22,7 +22,7 @@ const { http } = require('openvibe-contracts');
@@ -22,7 +22,7 @@ const { http } = require('openvibe-contracts');
const { isLoopbackDirect } = require('openvibe-shared/metrics');
const { isLoopbackDirect } = require('openvibe-shared/metrics');
const { loadConfig } = require('./config');
const { loadConfig } = require('./config');
const { openDb } = require('./db');
const { openDb } = require('./db');
-const { createKeyProvider, createUserAuth, createProjectMembership } = require('./network');
+const { createKeyProvider, createUserAuth, createFillAuth, createProjectMembership, createFillNotifier } = require('./network');
const { createRunOutbox } = require('./events/outbox');
const { createRunOutbox } = require('./events/outbox');
const { createStreamTickets } = require('./jobs/ticket');
const { createStreamTickets } = require('./jobs/ticket');
const { createJobStream } = require('./jobs/stream');
const { createJobStream } = require('./jobs/stream');
@@ -30,7 +30,7 @@ const { createRunEvents } = require('./jobs/events');
@@ -30,7 +30,7 @@ const { createRunEvents } = require('./jobs/events');
const { createDispatcher } = require('./dispatch');
const { createDispatcher } = require('./dispatch');
const { createApiAuth } = require('./api/auth');
const { createApiAuth } = require('./api/auth');
const { v1Router } = require('./api/v1');
const { v1Router } = require('./api/v1');
-const { sessionsRouter } = require('./api/sessions');
+const { sessionsRouter, fillApprovalRouter } = require('./api/sessions');
const { createDeskClient } = require('./sessions/desks');
const { createDeskClient } = require('./sessions/desks');
const { createSessions } = require('./sessions/service');
const { createSessions } = require('./sessions/service');
const { inputError } = require('./util');
const { inputError } = require('./util');
@@ -50,6 +50,7 @@ function createApp(opts = {}) {
@@ -50,6 +50,7 @@ function createApp(opts = {}) {
const now = opts.now || (() => Date.now());
const now = opts.now || (() => Date.now());
const keys = opts.keys || createKeyProvider(config, { fetchImpl, log });
const keys = opts.keys || createKeyProvider(config, { fetchImpl, log });
const userAuth = createUserAuth(config, keys);
const userAuth = createUserAuth(config, keys);
+ const fillAuth = createFillAuth(config, keys);
const outbox = opts.outbox || createRunOutbox({ db, config, fetchImpl: opts.eventsFetch, now, log });
const outbox = opts.outbox || createRunOutbox({ db, config, fetchImpl: opts.eventsFetch, now, log });
const events = opts.events || createRunEvents({ outbox, now, log, config });
const events = opts.events || createRunEvents({ outbox, now, log, config });
const stream = opts.stream || createJobStream({ config, now, log });
const stream = opts.stream || createJobStream({ config, now, log });
@@ -59,11 +60,12 @@ function createApp(opts = {}) {
@@ -59,11 +60,12 @@ function createApp(opts = {}) {
// Run cannot mint the Bot token — and nothing runs until server/index.js calls its start().
// 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 dispatch = opts.dispatch || createDispatcher({ config, db, store: require('./jobs/store'), events, stream, log, now, fetchImpl });
const membership = opts.membership || createProjectMembership(config, { fetchImpl, now });
const membership = opts.membership || createProjectMembership(config, { fetchImpl, now });
+ const fillNotifier = opts.fillNotifier || createFillNotifier(config, { fetchImpl, now });
const apiAuth = createApiAuth({ config, keys, userAuth, membership, log });
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.
// 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 desks = opts.desks || createDeskClient({ config, fetchImpl, now });
const broker = opts.broker || require('./compute/broker').createBroker({ config, db, desks, now, fetchImpl, log });
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.attachSessions(async (id) => { const row = await sessions.get(id); if (row) await sessions.finish(row, 'ttl', 'compute rental budget exhausted'); });
broker.attachJobs(async (machine) => {
broker.attachJobs(async (machine) => {
const jobs = require('./jobs/store');
const jobs = require('./jobs/store');
@@ -128,6 +130,7 @@ function createApp(opts = {}) {
@@ -128,6 +130,7 @@ function createApp(opts = {}) {
}),
}),
express.json({ limit: JSON_LIMIT }),
express.json({ limit: JSON_LIMIT }),
(req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); },
(req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); },
+ fillApprovalRouter({ sessions, verifyFillToken: fillAuth.verify }),
apiAuth.middleware,
apiAuth.middleware,
require('./compute/routes').computeRouter({ broker, config }),
require('./compute/routes').computeRouter({ broker, config }),
sessionsRouter({ sessions }),
sessionsRouter({ sessions }),
@@ -11,9 +11,11 @@
@@ -11,9 +11,11 @@
* createProjectMembership), can act on jobs — every route says so.
* createProjectMembership), can act on jobs — every route says so.
*/
*/
const crypto = require('crypto');
const crypto = require('crypto');
+const jwt = require('jsonwebtoken');
const { verifyUserToken, createServiceTokenClient } = require('openvibe-sdk/auth');
const { verifyUserToken, createServiceTokenClient } = require('openvibe-sdk/auth');
const MEMBER_READ = 'network.project.member.read';
const MEMBER_READ = 'network.project.member.read';
+const FILL_NOTIFY = 'network.notifications.push';
const DELEGABLE_ROLES = new Set(['admin', 'owner']);
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
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) {
@@ -66,6 +68,21 @@ function createUserAuth(config, keys) {
return { verify };
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
* 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
* this project? Network states the membership (GET /internal/projects/:project/members/:subject, capability
@@ -101,4 +118,21 @@ function createProjectMembership(config, { fetchImpl = globalThis.fetch, now = (
@@ -101,4 +118,21 @@ function createProjectMembership(config, { fetchImpl = globalThis.fetch, now = (
return { allows, enabled: Boolean(tokens) };
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 };
@@ -43,7 +43,7 @@ class SessionError extends Error {
@@ -43,7 +43,7 @@ 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; }
}
}
-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 cfg = config.sessions || {};
const tickMs = cfg.tickMs || 2000;
const tickMs = cfg.tickMs || 2000;
const leaseCheckMs = cfg.leaseCheckMs || 30_000;
const leaseCheckMs = cfg.leaseCheckMs || 30_000;
@@ -53,6 +53,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -53,6 +53,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
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 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 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:/, '');
const bare = (owner) => String(owner).replace(/^user:/, '');
@@ -179,6 +180,8 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -179,6 +180,8 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
return row.control_lease || lease;
return row.control_lease || lease;
}
}
async function requestControl(row, actor) {
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 current = await controlLease(row);
const target = actorHolder(actor, row);
const target = actorHolder(actor, row);
if (sameHolder(current.holder, target)) return current;
if (sameHolder(current.holder, target)) return current;
@@ -195,6 +198,8 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -195,6 +198,8 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
return saved.control_lease;
return saved.control_lease;
}
}
async function grantControl(row, target, reason = 'granted') {
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);
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');
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);
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
@@ -213,8 +218,11 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
const RESULTS = new Set(['ack', 'nack', 'refused', 'expired']);
const RESULTS = new Set(['ack', 'nack', 'refused', 'expired']);
async function act(row, cmd, actor = null) {
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);
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 (!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)}`;
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');
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);
const lease = await controlLease(row);
@@ -239,6 +247,81 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -239,6 +247,81 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
return out;
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) {
async function logAction(row, id, cmd, actor, result, code) {
const kind = String(cmd.kind || 'unknown').slice(0, 40);
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;
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
@@ -277,6 +360,8 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
*/
*/
async function still(row) {
async function still(row) {
if (!row) throw new SessionError(404, 'run.session.not_found', 'no such session');
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.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 };
if (row.curtain) return { bytes: curtain.BYTES, type: curtain.TYPE, width: curtain.WIDTH, height: curtain.HEIGHT, curtain: true };
const fresh = stills.get(row.id);
const fresh = stills.get(row.id);
@@ -373,6 +458,12 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -373,6 +458,12 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
// ── the tick ──────────────────────────────────────────────────────────────────────────────────────────────
// ── the tick ──────────────────────────────────────────────────────────────────────────────────────────────
async function tickOnce() {
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)) {
for (const row of await store.open(db)) {
try {
try {
if (row.state === 'placing') { await place(row); continue; }
if (row.state === 'placing') { await place(row); continue; }
@@ -413,6 +504,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -413,6 +504,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
return {
return {
create, act, finish, still, setCurtain, requestControl, grantControl, takeControl, controlLease, timeline, isGuest, snapshot, tick, SessionError,
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),
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()),
@@ -129,6 +129,9 @@ async function startNetwork() {
@@ -129,6 +129,9 @@ async function startNetwork() {
signUser({ subject = ids.newId('user'), username = 'ada', role = 'user' } = {}) {
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' });
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' });
+ },
};
};
}
}
@@ -41,6 +41,8 @@ function fakeDesks() {
@@ -41,6 +41,8 @@ function fakeDesks() {
async act(machine, desk, subject, cmd) {
async act(machine, desk, subject, cmd) {
calls.push(['act', machine, desk, subject, cmd]);
calls.push(['act', machine, desk, subject, cmd]);
if (!leases.has(`${machine} ${desk}`)) return null;
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' } };
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; },
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() {
@@ -55,6 +57,7 @@ function fakeDesks() {
const ada = ids.newId('user');
const ada = ids.newId('user');
const bea = ids.newId('user');
const bea = ids.newId('user');
const as = (subject) => ({ token: t.network.signUser({ subject }) });
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 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}:`));
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() {
@@ -124,6 +127,53 @@ function fakeDesks() {
valid(grant.json.lease, 'common.control-lease@1');
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: {} } });
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(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');
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;
lease = (await t.call('POST', `/api/v1/sessions/${ses.id}/control/take`, as(ada))).json.lease;
assert.deepStrictEqual(lease.holder, { kind: 'person', subject: ada });
assert.deepStrictEqual(lease.holder, { kind: 'person', subject: ada });
@@ -176,6 +226,11 @@ function fakeDesks() {
@@ -176,6 +226,11 @@ function fakeDesks() {
await sessions.tick();
await sessions.tick();
const s = (await t.call('GET', `/api/v1/sessions/${r.json.id}`, as(bea))).json;
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']);
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);
desks.state.busy.delete(OPENVIBE);
await t.call('POST', `/api/v1/sessions/${s.id}/end`, as(bea));
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' } });
const named = await t.call('POST', '/api/v1/sessions', { ...as(bea), body: { kind: 'browser', machine: 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2Z' } });