openvibers/openvibe.run Commits
Keep Actor browser sign-ins across computer sessions (#217)
Changes
nnew file mode 100644@@@ -0,0 +1,10 @@++-- phase: expand++CREATE TABLE run_actor_profiles (++ owner TEXT NOT NULL,++ actor_id TEXT NOT NULL,++ sealed TEXT,++ updated_at BIGINT NOT NULL,++ PRIMARY KEY (owner, actor_id)++);++ALTER TABLE run_sessions ADD COLUMN actor_id TEXT;++CREATE UNIQUE INDEX run_one_actor_browser ON run_sessions(owner, actor_id) WHERE actor_id IS NOT NULL AND state <> 'ended';@@@ -16,6 +16,7 @@const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');const TABLES = [++ { table: 'run_actor_profiles', subject: 'owner', value: usr => `user:${usr}`, file: 'saved-sites.json', columns: ['actor_id', 'updated_at'], erase: 'delete' },{table: 'run_sessions', subject: 'owner', value: (usr) => `user:${usr}`, file: 'sessions.json', order: 'created_at',columns: ['id', 'via', 'kind', 'state', 'width', 'height', 'ttl_s', 'idle_s', 'machine_asked', 'requirements', 'machine', 'created_at', 'started_at', 'last_used_at', 'ended_at', 'end_reason', 'end_detail', 'metered_minutes', 'updated_at'],@@@ -80,9 +80,17 @@ function sessionsRouter({ sessions, verifyFillToken = async () => null }) {r.post('/sessions', wrap(async (req, res) => {const w = who(req, res, 'run.session.create');if (!w) return;-- const { row, created } = await sessions.create(w.owner, w.via, req.body && typeof req.body === 'object' ? req.body : {}, { pool: req.query.pool, strategy: req.query.strategy, template: req.query.template });++ const { row, created } = await sessions.create(w.owner, w.via, req.body && typeof req.body === 'object' ? req.body : {}, { pool: req.query.pool, strategy: req.query.strategy, template: req.query.template, actor: req.query.actor });res.status(created ? 201 : 200).json(sessions.present(row));}));++ r.get('/actor-profiles/:actor/sites', wrap(async (req, res) => {++ const w = who(req, res, 'run.session.read');++ if (w) res.json(await sessions.profileSites(w.owner, req.params.actor));++ }));++ r.delete('/actor-profiles/:actor/sites/:site', wrap(async (req, res) => {++ const w = who(req, res, 'run.session.control');++ if (w) res.json(await sessions.signOut(w.owner, req.params.actor, req.params.site));++ }));r.get('/sessions', wrap(async (req, res) => {const w = who(req, res, 'run.session.read');if (!w) return;@@@ -102,6 +110,21 @@ function sessionsRouter({ sessions, verifyFillToken = async () => null }) {if (!w) return;res.json(await sessions.act(row, req.body && typeof req.body === 'object' ? req.body : {}, w.actor));}));++ // Actor proxies the person's scoped page controls; the model's commands use the normal AI holder.++ r.post('/sessions/:id/person-commands', wrap(async (req, res) => {++ const row = await mine(req, res, 'run.session.control');++ if (!row) return;++ const w = who(req, res, 'run.session.control');++ if (w.via && w.via !== 'service:actor') return problem(res, req, 403, 'capability.denied', 'Only Actor may pass on its person’s controls.');++ if (!req.body || req.body.kind !== 'input') return problem(res, req, 422, 'run.session.invalid', 'Use the mouse or keyboard.');++ res.json(await sessions.act(row, req.body, { kind: 'person', subject: w.owner.slice(5) }));++ }));++ r.post('/sessions/:id/profile/save', wrap(async (req, res) => {++ const row = await mine(req, res, 'run.session.control');++ if (!row) return;++ await sessions.saveProfile(row);++ res.json({ saved: true });++ }));r.post('/sessions/:id/fill-requests', wrap(async (req, res) => {const row = await mine(req, res, 'run.session.control', true);if (!row) return;nnew file mode 100644@@@ -0,0 +1,57 @@++'use strict';++const crypto = require('crypto');++const { createStreamTickets } = require('../jobs/ticket');++const error = (status, message) => Object.assign(new Error(message), { status, code: 'run.profile.unavailable' });++function createProfiles({ config, db, now = Date.now, log = console }) {++ const tickets = createStreamTickets({ config, log, now });++ tickets.start();++ // Use Run's existing durable private key; no extra secret and no plaintext profile on disk.++ const key = !tickets.status().ephemeral && tickets.get() ? crypto.hkdfSync('sha256', tickets.get().export({ type: 'pkcs8', format: 'der' }), Buffer.from('run-actor-profiles-v1'), Buffer.from('browser-storage'), 32) : null;++ const aad = (owner, actor) => Buffer.from(JSON.stringify([owner, actor]));++ function seal(owner, actor, value) {++ if (!key) throw error(503, 'Saved sign-ins need Run’s durable signing key.');++ const bytes = Buffer.from(JSON.stringify(value));++ if (bytes.length > 47000) throw error(422, 'Saved sign-ins exceed this computer’s limit.');++ const nonce = crypto.randomBytes(12), cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);++ cipher.setAAD(aad(owner, actor));++ return Buffer.concat([nonce, cipher.update(bytes), cipher.final(), cipher.getAuthTag()]).toString('base64');++ }++ function open(owner, actor, text) {++ if (!text) return { cookies: [], origins: [] };++ if (!key) throw error(503, 'Saved sign-ins need Run’s durable signing key.');++ const bytes = Buffer.from(text, 'base64'), decipher = crypto.createDecipheriv('aes-256-gcm', key, bytes.subarray(0, 12));++ decipher.setAAD(aad(owner, actor)); decipher.setAuthTag(bytes.subarray(-16));++ return JSON.parse(Buffer.concat([decipher.update(bytes.subarray(12, -16)), decipher.final()]).toString());++ }++ async function read(owner, actor) {++ const row = await db.maybe('SELECT sealed FROM run_actor_profiles WHERE owner=$1 AND actor_id=$2', [owner, actor]);++ return open(owner, actor, row && row.sealed);++ }++ async function write(owner, actor, value) {++ if (!value || !Array.isArray(value.cookies) || !Array.isArray(value.origins)) throw error(502, 'This computer could not save its sign-ins.');++ await db.exec(`INSERT INTO run_actor_profiles(owner,actor_id,sealed,updated_at) VALUES($1,$2,$3,$4)++ ON CONFLICT(owner,actor_id) DO UPDATE SET sealed=EXCLUDED.sealed,updated_at=EXCLUDED.updated_at`, [owner, actor, seal(owner, actor, value), now()]);++ }++ async function sites(owner, actor) {++ const p = await read(owner, actor);++ return [...new Set([...p.cookies.map(c => String(c.domain || '').replace(/^\./, '').toLowerCase()), ...p.origins.map(o => { try { return new URL(o.origin).hostname; } catch { return ''; } })].filter(Boolean))].sort();++ }++ async function forget(owner, actor, site) {++ const p = await read(owner, actor);++ // Signing out of a site signs out of its subdomains too (accounts.google.com when the person signs out of++ // google.com): a cookie or a storage origin on the site or under it goes.++ const under = (host) => { const h = String(host || '').replace(/^\./, '').toLowerCase(); return h === site || h.endsWith(`.${site}`); };++ p.cookies = p.cookies.filter(c => !under(c.domain));++ p.origins = p.origins.filter(o => { try { return !under(new URL(o.origin).hostname); } catch { return true; } });++ await write(owner, actor, p);++ }++ async function lock(owner, actor, fn) {++ return db.tx(async t => {++ await t.exec('INSERT INTO run_actor_profiles(owner,actor_id,updated_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING', [owner, actor, now()]);++ await t.one('SELECT actor_id FROM run_actor_profiles WHERE owner=$1 AND actor_id=$2 FOR UPDATE', [owner, actor]);++ return fn(t);++ });++ }++ return { lock, enabled: Boolean(key), read, write, sites, forget };++}++module.exports = { createProfiles };@@@ -44,6 +44,7 @@ class SessionError extends Error {}function createSessions({ config, db, desks, outbox, now = () => Date.now(), log = console, broker = null, notifyFill = async () => {} }) {++ const profiles = require('./profiles').createProfiles({ config, db, now, log });const cfg = config.sessions || {};const tickMs = cfg.tickMs || 2000;const leaseCheckMs = cfg.leaseCheckMs || 30_000;@@@ -95,13 +96,16 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), logconst strategy = compute.strategy || null;const template = compute.template || 'browser';if (!['browser', 'kept-browser', 'throwaway'].includes(template)) throw new SessionError(422, 'run.session.template', 'template must be browser, kept-browser or throwaway');-- if (template === 'kept-browser') throw new SessionError(501, 'run.session.template_unavailable', 'Kept browser profiles are not available on this machine.');++ const actorId = compute.actor || null;++ if (actorId && !/^act_[0-9A-HJKMNP-TV-Z]{26}$/.test(actorId)) throw new SessionError(422, 'run.profile.invalid', 'Choose a valid Actor.');++ if (template === 'kept-browser' && (!actorId || !profiles.enabled)) throw new SessionError(503, 'run.profile.unavailable', 'Saved sign-ins need an Actor and Run’s durable signing key.');++ if (actorId && template !== 'kept-browser') throw new SessionError(422, 'run.profile.invalid', 'An Actor uses a kept browser.');if (strategy && !require('openvibe-sdk/compute').STRATEGIES.includes(strategy)) throw new SessionError(422, 'run.compute.strategy', 'unknown compute strategy');if (strategy && !pool) throw new SessionError(422, 'run.compute.pool', 'a strategy requires a compute pool');if (pool) { if (!broker) throw new SessionError(503, 'run.compute.off'); await broker.poolConfig(pool); }const v = contracts.validate('run.session-create-request@1', body);if (!v.valid) throw new SessionError(422, 'run.session.invalid', `invalid session request: ${(v.errors && v.errors[0] && v.errors[0].message) || 'bad value'}`.slice(0, 300));-- const digest = store.digestOf(pool ? { ...body, compute_pool: pool, compute_strategy: strategy } : body);++ const digest = store.digestOf({ ...body, compute_pool: pool, compute_strategy: strategy, template, actor_id: actorId });if (body.idempotency_key) {const earlier = await db.maybe('SELECT * FROM run_sessions WHERE owner = $1 AND idempotency_key = $2 AND created_at > $3', [owner, body.idempotency_key, now() - 86_400_000]);if (earlier) {@@@ -115,19 +119,28 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), logconst row = {id, owner, via: via || null, kind: 'browser', state: 'placing',width: body.width || 1280, height: body.height || 800, ttl_s: body.ttl_s || 3600, idle_s: body.idle_s || 900,-- compute_pool: pool, compute_strategy: strategy, compute_decision: null, template, machine_asked: body.machine || null, requirements: body.requirements || null, idempotency_key: body.idempotency_key || null,++ compute_pool: pool, compute_strategy: strategy, compute_decision: null, template, actor_id: actorId, machine_asked: body.machine || null, requirements: body.requirements || null, idempotency_key: body.idempotency_key || null,request_digest: digest, reasons: [], task_id: body.task_id || null, curtain: false,control_lease: { thing: `run.session:${id}`, holder: { kind: 'person', subject: bare(owner) }, since_ms: at, expires_ms: at + 60_000, watchers: [], seq: 1 },created_at: at, updated_at: at,};-- const out = await store.insert(db, row);++ let out;++ try { out = await store.insert(db, row); }++ catch (e) { if (e.code === '23505' && actorId) throw new SessionError(409, 'run.profile.busy', 'This Actor already has an open computer.'); throw e; }++ if (!out.row && actorId) throw new SessionError(409, 'run.profile.busy', 'This Actor already has an open computer.');if (out.created) setImmediate(() => { tick().catch((e) => log.warn(`[Run] session tick: ${e.message}`)); });return out;}-- async function finish(row, reason, detail = null, { release = true } = {}) {++ async function finish(row, reason, detail = null, options = {}) {++ return row && row.actor_id ? profiles.lock(row.owner, row.actor_id, async () => finishLocked(await store.get(db, row.id), reason, detail, options)) : finishLocked(row, reason, detail, options);++ }++ async function finishLocked(row, reason, detail = null, { release = true } = {}) {if (!row || row.state === 'ended') return row;if (release && row.machine && row.desk && desks.enabled) {++ if (row.actor_id) {++ try { await saveProfile(row, true); } catch { detail = 'The last sign-in changes could not be saved; the earlier saved copy is kept.'; }++ }try { await desks.end(row.machine, row.desk, bare(row.owner)); } catch (e) { log.warn(`[Run] session ${row.id}: the desk did not end cleanly (${e.code || e.message})`); }}return db.tx(async (t) => {@@@ -167,6 +180,8 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), logcontracts.assertValid('events.event-envelope@1', prepared);await t.maybe(`INSERT INTO ${EVENTS_TABLE} (event_id, envelope, created_at) VALUES ($1, $2, $3) ON CONFLICT (event_id) DO NOTHING RETURNING id`,[`run:${row.id}:control-lease:${lease.seq}`, JSON.stringify(prepared), now()]);++ await t.maybe(`INSERT INTO run_session_actions (session_id,command_id,kind,action,actor_kind,actor_subject,result,created_at)++ VALUES ($1,$2,'control',$3,$4,$5,'ack',$6) RETURNING id`, [row.id,`control:${lease.seq}`,reason,lease.holder.kind,lease.holder.subject || '',now()]);return saved;});}@@@ -247,6 +262,35 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), logreturn out;}++ async function profileCommand(row, action, value = {}) {++ const result = await desks.act(row.machine, row.desk, bare(row.owner), { id: `profile-${require('crypto').randomUUID()}`, kind: 'browser', value: { action, ...value } });++ if (!result || result.result !== 'ack') throw new SessionError(502, 'run.profile.unavailable', 'This computer could not save its sign-ins.');++ return result.data;++ }++ async function saveProfile(row, locked = false) {++ if (!row.actor_id) return;++ if (!locked) return profiles.lock(row.owner, row.actor_id, () => saveProfile(row, true));++ const fresh = await store.get(db, row.id);++ if (!fresh || fresh.state === 'ended') return;++ await profiles.write(row.owner, row.actor_id, await profileCommand(fresh, 'profile_export'));++ }++ async function profileSites(owner, actorId) {++ const row = await db.maybe("SELECT * FROM run_sessions WHERE owner=$1 AND actor_id=$2 AND state='running'", [owner, actorId]);++ if (row) await saveProfile(row);++ return { sites: await profiles.sites(owner, actorId) };++ }++ async function signOut(owner, actorId, site) {++ if (!/^[a-z0-9.-]{1,253}$/.test(site || '')) throw new SessionError(422, 'run.profile.invalid', 'Choose a site.');++ // Lock the session row against lifecycle changes while clearing the live and saved copies.++ return profiles.lock(owner, actorId, async t => {++ const row = await t.maybe("SELECT * FROM run_sessions WHERE owner=$1 AND actor_id=$2 AND state<>'ended' FOR UPDATE", [owner, actorId]);++ if (row && row.state !== 'running') throw new SessionError(409, 'run.profile.busy', 'Wait for the computer to open.');++ if (row) { await profileCommand(row, 'profile_sign_out', { site }); await saveProfile(row, true); }++ await profiles.forget(owner, actorId, site);++ return { sites: await profiles.sites(owner, actorId) };++ });++ }++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; }@@@ -447,6 +491,11 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), logcontinue;}if (!lease) { await store.update(db, row.id, { state: 'placing', machine: null, desk: null, trust: null, offer_id: null, reasons: [], updated_at: now() }); continue; }++ if (row.actor_id) {++ const current = await store.get(db, row.id);++ try { await profileCommand(current, 'profile_import', { profile: await profiles.read(row.owner, row.actor_id) }); }++ catch (e) { await desks.end(h.machine_id, desk, bare(row.owner)); return finish(current, 'unplaceable', 'Saved sign-ins could not be opened on this computer.', { release: false }); }++ }return db.tx(async (t) => {const saved = await store.update(t, row.id, { state: 'running', started_at: now(), last_used_at: now(), lease_checked_at: now(), updated_at: now() });await emit(t, saved, 'run.session.started');@@@ -471,8 +520,10 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), logif (now() - row.updated_at < STARTING_STALE_MS) continue;const lease = row.machine && row.desk ? await desks.get(row.machine, row.desk, bare(row.owner)).catch(() => undefined) : null;if (lease === undefined) continue;-- if (lease) await db.tx(async (t) => { const s = await store.update(t, row.id, { state: 'running', started_at: row.started_at || now(), last_used_at: now(), lease_checked_at: now(), updated_at: now() }); await emit(t, s, 'run.session.started'); });-- else await store.update(db, row.id, { state: 'placing', machine: null, desk: null, trust: null, offer_id: null, reasons: [], updated_at: now() });++ if (lease) {++ if (row.actor_id) await profileCommand(row, 'profile_import', { profile: await profiles.read(row.owner, row.actor_id) });++ await db.tx(async (t) => { const s = await store.update(t, row.id, { state: 'running', started_at: row.started_at || now(), last_used_at: now(), lease_checked_at: now(), updated_at: now() }); await emit(t, s, 'run.session.started'); });++ } else await store.update(db, row.id, { state: 'placing', machine: null, desk: null, trust: null, offer_id: null, reasons: [], updated_at: now() });continue;}// running@@@ -482,7 +533,10 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), logif (now() - (row.lease_checked_at || 0) >= leaseCheckMs) {const lease = await desks.get(row.machine, row.desk, bare(row.owner)).catch(() => undefined);if (lease === null) { await finish(row, 'host_lost', 'its machine no longer holds the desk', { release: false }); continue; }-- if (lease) await store.update(db, row.id, { lease_checked_at: now() });++ if (lease) {++ if (row.actor_id) await saveProfile(row).catch(() => log.warn('[Run] saved sign-ins could not be refreshed'));++ await store.update(db, row.id, { lease_checked_at: now() });++ }}await db.tx(async (t) => {const fresh = await store.get(t, row.id);@@@ -503,7 +557,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,++ create, act, finish, profileSites, signOut, saveProfile, 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),@@@ -14,7 +14,7 @@ const { ids } = require('openvibe-contracts');const COLUMNS = ['id', 'owner', 'via', 'kind', 'state', 'width', 'height', 'ttl_s', 'idle_s', 'machine_asked', 'requirements', 'idempotency_key','compute_pool', 'compute_strategy', 'compute_decision', 'request_digest', 'machine', 'desk', 'trust', 'offer_id', 'reasons', 'created_at', 'started_at', 'last_used_at', 'ended_at', 'end_reason',-- 'end_detail', 'metered_minutes', 'lease_checked_at', 'task_id', 'curtain', 'control_lease', 'template', 'egress_label', 'updated_at'];++ 'end_detail', 'metered_minutes', 'lease_checked_at', 'task_id', 'curtain', 'control_lease', 'template', 'actor_id', 'egress_label', 'updated_at'];const JSON_COLUMNS = new Set(['requirements', 'reasons', 'compute_decision', 'control_lease']);const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());nnew file mode 100644@@@ -0,0 +1,50 @@++'use strict';++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/app');++(async () => {++ const machine = 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2A';++ const browsers = new Map();++ const desks = { enabled: true, hosts: async () => [{ machine_id: machine, trust: 'first-party', online: true, free: 4, max_browser_desks: 4 }],++ lease: async (_m, _s, b) => { browsers.set(b.desk, { cookies: [], origins: [] }); return { desk: b.desk }; },++ get: async (_m, d) => browsers.has(d) ? {} : null,++ end: async (_m, d) => browsers.delete(d),++ act: async (_m, d, _s, cmd) => {++ if (!browsers.has(d)) return null;++ const v = cmd.value;++ if (v.action === 'profile_import') browsers.set(d, structuredClone(v.profile));++ if (v.action === 'profile_sign_out') { const p = browsers.get(d); p.cookies = p.cookies.filter(c => { const h = c.domain.replace(/^\./, ''); return h !== v.site && !h.endsWith('.' + v.site); }); p.origins = p.origins.filter(o => new URL(o.origin).hostname !== v.site); }++ return { result: 'ack', data: v.action === 'profile_export' ? structuredClone(browsers.get(d)) : { done: true } };++ } };++ const t = await boot({ appOpts: { desks } });++ const owner = 'user:' + ids.newId('user'), actor = 'act_01J8Z4M2Q0R7T9YV3K6N8P1W2A';++ let sessions = t.app.locals.sessions;++ try {++ await check('encrypted Actor profile survives a session and service restart; sign-out is site-specific', async () => {++ const start = async () => { const r = await sessions.create(owner, 'service:actor', { kind: 'browser' }, { template: 'kept-browser', actor }); await sessions.tick(); return sessions.get(r.row.id); };++ const one = await start(); assert.equal(one.state, 'running');++ await sessions.grantControl(one,{kind:'ai',subject:'svc_actor'});++ await sessions.takeControl(await sessions.get(one.id));++ const history=await sessions.timeline(one);++ assert.equal(history.actions[0].action,'owner_take_back');++ assert.equal(history.actions[0].actor.kind,'person');++ await assert.rejects(() => sessions.create(owner, 'service:actor', { kind: 'browser' }, { template: 'kept-browser', actor }), e => e.status === 409);++ browsers.set(one.desk, { cookies: [{ name: 'login', value: 'synthetic-signin-a', domain: '.a.example', path: '/' }, { name: 'login', value: 'synthetic-signin-b', domain: 'b.example', path: '/' }, { name: 'sso', value: 'synthetic-signin-c', domain: 'accounts.a.example', path: '/' }], origins: [{ origin: 'https://a.example', entries: [['theme', 'dark']] }] });++ await sessions.finish(one, 'ended');++ const stored = await t.db.one('SELECT sealed FROM run_actor_profiles WHERE owner=$1', [owner]);++ assert.ok(!stored.sealed.includes('synthetic-signin') && !stored.sealed.includes('a.example'));++ sessions = require('../server/sessions/service').createSessions({ config: t.config, db: t.db, desks, outbox: t.outbox, log: { warn() {} } });++ const two = await start();++ assert.equal(browsers.get(two.desk).cookies.length, 3);++ assert.deepEqual((await sessions.profileSites(owner, actor)).sites.sort(), ['a.example', 'accounts.a.example', 'b.example']);++ assert.deepEqual(await sessions.profileSites('user:' + ids.newId('user'), actor), { sites: [] });++ await sessions.signOut(owner, actor, 'a.example');++ assert.deepEqual(browsers.get(two.desk).cookies.map(c => c.domain), ['b.example'], 'signing out of a.example also signs out of accounts.a.example');++ assert.deepEqual(browsers.get(two.desk).origins, []);++ await sessions.finish(two, 'ended');++ const three = await start();++ assert.deepEqual(browsers.get(three.desk).cookies.map(c => c.domain), ['b.example']);++ await sessions.finish(three, 'ended');++ });++ } finally { await t.close(); }++})().then(done).catch(e => { console.error(e); process.exit(1); });@@@ -83,8 +83,8 @@ function fakeDesks() {await check('browser and throwaway presets are accepted; kept profiles and snapshots say plainly when unavailable', async () => {const unavailable = await t.call('POST', '/api/v1/sessions?template=kept-browser', { ...as(bea), body: { kind: 'browser' } });-- assert.strictEqual(unavailable.status, 501);-- assert.match(unavailable.json.detail, /not available on this machine/);++ assert.strictEqual(unavailable.status, 503);++ assert.match(unavailable.json.detail, /Saved sign-ins need/);const throwaway = await t.call('POST', '/api/v1/sessions?template=throwaway', { ...as(bea), body: { kind: 'browser' } });assert.strictEqual(throwaway.status, 201, throwaway.text);await sessions.finish(await sessions.get(throwaway.json.id), 'ended', 'test complete');
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+-- phase: expand
+CREATE TABLE run_actor_profiles (
+ owner TEXT NOT NULL,
+ actor_id TEXT NOT NULL,
+ sealed TEXT,
+ updated_at BIGINT NOT NULL,
+ PRIMARY KEY (owner, actor_id)
+);
+ALTER TABLE run_sessions ADD COLUMN actor_id TEXT;
+CREATE UNIQUE INDEX run_one_actor_browser ON run_sessions(owner, actor_id) WHERE actor_id IS NOT NULL AND state <> 'ended';
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
const TABLES = [
const TABLES = [
+ { table: 'run_actor_profiles', subject: 'owner', value: usr => `user:${usr}`, file: 'saved-sites.json', columns: ['actor_id', 'updated_at'], erase: 'delete' },
{
{
table: 'run_sessions', subject: 'owner', value: (usr) => `user:${usr}`, file: 'sessions.json', order: 'created_at',
table: 'run_sessions', subject: 'owner', value: (usr) => `user:${usr}`, file: 'sessions.json', order: 'created_at',
columns: ['id', 'via', 'kind', 'state', 'width', 'height', 'ttl_s', 'idle_s', 'machine_asked', 'requirements', 'machine', 'created_at', 'started_at', 'last_used_at', 'ended_at', 'end_reason', 'end_detail', 'metered_minutes', 'updated_at'],
columns: ['id', 'via', 'kind', 'state', 'width', 'height', 'ttl_s', 'idle_s', 'machine_asked', 'requirements', 'machine', 'created_at', 'started_at', 'last_used_at', 'ended_at', 'end_reason', 'end_detail', 'metered_minutes', 'updated_at'],
@@ -80,9 +80,17 @@ function sessionsRouter({ sessions, verifyFillToken = async () => null }) {
@@ -80,9 +80,17 @@ function sessionsRouter({ sessions, verifyFillToken = async () => null }) {
r.post('/sessions', wrap(async (req, res) => {
r.post('/sessions', wrap(async (req, res) => {
const w = who(req, res, 'run.session.create');
const w = who(req, res, 'run.session.create');
if (!w) return;
if (!w) return;
- const { row, created } = await sessions.create(w.owner, w.via, req.body && typeof req.body === 'object' ? req.body : {}, { pool: req.query.pool, strategy: req.query.strategy, template: req.query.template });
+ const { row, created } = await sessions.create(w.owner, w.via, req.body && typeof req.body === 'object' ? req.body : {}, { pool: req.query.pool, strategy: req.query.strategy, template: req.query.template, actor: req.query.actor });
res.status(created ? 201 : 200).json(sessions.present(row));
res.status(created ? 201 : 200).json(sessions.present(row));
}));
}));
+ r.get('/actor-profiles/:actor/sites', wrap(async (req, res) => {
+ const w = who(req, res, 'run.session.read');
+ if (w) res.json(await sessions.profileSites(w.owner, req.params.actor));
+ }));
+ r.delete('/actor-profiles/:actor/sites/:site', wrap(async (req, res) => {
+ const w = who(req, res, 'run.session.control');
+ if (w) res.json(await sessions.signOut(w.owner, req.params.actor, req.params.site));
+ }));
r.get('/sessions', wrap(async (req, res) => {
r.get('/sessions', wrap(async (req, res) => {
const w = who(req, res, 'run.session.read');
const w = who(req, res, 'run.session.read');
if (!w) return;
if (!w) return;
@@ -102,6 +110,21 @@ function sessionsRouter({ sessions, verifyFillToken = async () => null }) {
@@ -102,6 +110,21 @@ function sessionsRouter({ sessions, verifyFillToken = async () => null }) {
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));
}));
}));
+ // Actor proxies the person's scoped page controls; the model's commands use the normal AI holder.
+ r.post('/sessions/:id/person-commands', wrap(async (req, res) => {
+ const row = await mine(req, res, 'run.session.control');
+ if (!row) return;
+ const w = who(req, res, 'run.session.control');
+ if (w.via && w.via !== 'service:actor') return problem(res, req, 403, 'capability.denied', 'Only Actor may pass on its person’s controls.');
+ if (!req.body || req.body.kind !== 'input') return problem(res, req, 422, 'run.session.invalid', 'Use the mouse or keyboard.');
+ res.json(await sessions.act(row, req.body, { kind: 'person', subject: w.owner.slice(5) }));
+ }));
+ r.post('/sessions/:id/profile/save', wrap(async (req, res) => {
+ const row = await mine(req, res, 'run.session.control');
+ if (!row) return;
+ await sessions.saveProfile(row);
+ res.json({ saved: true });
+ }));
r.post('/sessions/:id/fill-requests', wrap(async (req, res) => {
r.post('/sessions/:id/fill-requests', 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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -0,0 +1,57 @@
+'use strict';
+const crypto = require('crypto');
+const { createStreamTickets } = require('../jobs/ticket');
+const error = (status, message) => Object.assign(new Error(message), { status, code: 'run.profile.unavailable' });
+function createProfiles({ config, db, now = Date.now, log = console }) {
+ const tickets = createStreamTickets({ config, log, now });
+ tickets.start();
+ // Use Run's existing durable private key; no extra secret and no plaintext profile on disk.
+ const key = !tickets.status().ephemeral && tickets.get() ? crypto.hkdfSync('sha256', tickets.get().export({ type: 'pkcs8', format: 'der' }), Buffer.from('run-actor-profiles-v1'), Buffer.from('browser-storage'), 32) : null;
+ const aad = (owner, actor) => Buffer.from(JSON.stringify([owner, actor]));
+ function seal(owner, actor, value) {
+ if (!key) throw error(503, 'Saved sign-ins need Run’s durable signing key.');
+ const bytes = Buffer.from(JSON.stringify(value));
+ if (bytes.length > 47000) throw error(422, 'Saved sign-ins exceed this computer’s limit.');
+ const nonce = crypto.randomBytes(12), cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
+ cipher.setAAD(aad(owner, actor));
+ return Buffer.concat([nonce, cipher.update(bytes), cipher.final(), cipher.getAuthTag()]).toString('base64');
+ }
+ function open(owner, actor, text) {
+ if (!text) return { cookies: [], origins: [] };
+ if (!key) throw error(503, 'Saved sign-ins need Run’s durable signing key.');
+ const bytes = Buffer.from(text, 'base64'), decipher = crypto.createDecipheriv('aes-256-gcm', key, bytes.subarray(0, 12));
+ decipher.setAAD(aad(owner, actor)); decipher.setAuthTag(bytes.subarray(-16));
+ return JSON.parse(Buffer.concat([decipher.update(bytes.subarray(12, -16)), decipher.final()]).toString());
+ }
+ async function read(owner, actor) {
+ const row = await db.maybe('SELECT sealed FROM run_actor_profiles WHERE owner=$1 AND actor_id=$2', [owner, actor]);
+ return open(owner, actor, row && row.sealed);
+ }
+ async function write(owner, actor, value) {
+ if (!value || !Array.isArray(value.cookies) || !Array.isArray(value.origins)) throw error(502, 'This computer could not save its sign-ins.');
+ await db.exec(`INSERT INTO run_actor_profiles(owner,actor_id,sealed,updated_at) VALUES($1,$2,$3,$4)
+ ON CONFLICT(owner,actor_id) DO UPDATE SET sealed=EXCLUDED.sealed,updated_at=EXCLUDED.updated_at`, [owner, actor, seal(owner, actor, value), now()]);
+ }
+ async function sites(owner, actor) {
+ const p = await read(owner, actor);
+ return [...new Set([...p.cookies.map(c => String(c.domain || '').replace(/^\./, '').toLowerCase()), ...p.origins.map(o => { try { return new URL(o.origin).hostname; } catch { return ''; } })].filter(Boolean))].sort();
+ }
+ async function forget(owner, actor, site) {
+ const p = await read(owner, actor);
+ // Signing out of a site signs out of its subdomains too (accounts.google.com when the person signs out of
+ // google.com): a cookie or a storage origin on the site or under it goes.
+ const under = (host) => { const h = String(host || '').replace(/^\./, '').toLowerCase(); return h === site || h.endsWith(`.${site}`); };
+ p.cookies = p.cookies.filter(c => !under(c.domain));
+ p.origins = p.origins.filter(o => { try { return !under(new URL(o.origin).hostname); } catch { return true; } });
+ await write(owner, actor, p);
+ }
+ async function lock(owner, actor, fn) {
+ return db.tx(async t => {
+ await t.exec('INSERT INTO run_actor_profiles(owner,actor_id,updated_at) VALUES($1,$2,$3) ON CONFLICT DO NOTHING', [owner, actor, now()]);
+ await t.one('SELECT actor_id FROM run_actor_profiles WHERE owner=$1 AND actor_id=$2 FOR UPDATE', [owner, actor]);
+ return fn(t);
+ });
+ }
+ return { lock, enabled: Boolean(key), read, write, sites, forget };
+}
+module.exports = { createProfiles };
@@ -44,6 +44,7 @@ class SessionError extends Error {
@@ -44,6 +44,7 @@ class SessionError extends Error {
}
}
function createSessions({ config, db, desks, outbox, now = () => Date.now(), log = console, broker = null, notifyFill = async () => {} }) {
function createSessions({ config, db, desks, outbox, now = () => Date.now(), log = console, broker = null, notifyFill = async () => {} }) {
+ const profiles = require('./profiles').createProfiles({ config, db, now, log });
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;
@@ -95,13 +96,16 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -95,13 +96,16 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
const strategy = compute.strategy || null;
const strategy = compute.strategy || null;
const template = compute.template || 'browser';
const template = compute.template || 'browser';
if (!['browser', 'kept-browser', 'throwaway'].includes(template)) throw new SessionError(422, 'run.session.template', 'template must be browser, kept-browser or throwaway');
if (!['browser', 'kept-browser', 'throwaway'].includes(template)) throw new SessionError(422, 'run.session.template', 'template must be browser, kept-browser or throwaway');
- if (template === 'kept-browser') throw new SessionError(501, 'run.session.template_unavailable', 'Kept browser profiles are not available on this machine.');
+ const actorId = compute.actor || null;
+ if (actorId && !/^act_[0-9A-HJKMNP-TV-Z]{26}$/.test(actorId)) throw new SessionError(422, 'run.profile.invalid', 'Choose a valid Actor.');
+ if (template === 'kept-browser' && (!actorId || !profiles.enabled)) throw new SessionError(503, 'run.profile.unavailable', 'Saved sign-ins need an Actor and Run’s durable signing key.');
+ if (actorId && template !== 'kept-browser') throw new SessionError(422, 'run.profile.invalid', 'An Actor uses a kept browser.');
if (strategy && !require('openvibe-sdk/compute').STRATEGIES.includes(strategy)) throw new SessionError(422, 'run.compute.strategy', 'unknown compute strategy');
if (strategy && !require('openvibe-sdk/compute').STRATEGIES.includes(strategy)) throw new SessionError(422, 'run.compute.strategy', 'unknown compute strategy');
if (strategy && !pool) throw new SessionError(422, 'run.compute.pool', 'a strategy requires a compute pool');
if (strategy && !pool) throw new SessionError(422, 'run.compute.pool', 'a strategy requires a compute pool');
if (pool) { if (!broker) throw new SessionError(503, 'run.compute.off'); await broker.poolConfig(pool); }
if (pool) { if (!broker) throw new SessionError(503, 'run.compute.off'); await broker.poolConfig(pool); }
const v = contracts.validate('run.session-create-request@1', body);
const v = contracts.validate('run.session-create-request@1', body);
if (!v.valid) throw new SessionError(422, 'run.session.invalid', `invalid session request: ${(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 session request: ${(v.errors && v.errors[0] && v.errors[0].message) || 'bad value'}`.slice(0, 300));
- const digest = store.digestOf(pool ? { ...body, compute_pool: pool, compute_strategy: strategy } : body);
+ const digest = store.digestOf({ ...body, compute_pool: pool, compute_strategy: strategy, template, actor_id: actorId });
if (body.idempotency_key) {
if (body.idempotency_key) {
const earlier = await db.maybe('SELECT * FROM run_sessions WHERE owner = $1 AND idempotency_key = $2 AND created_at > $3', [owner, body.idempotency_key, now() - 86_400_000]);
const earlier = await db.maybe('SELECT * FROM run_sessions WHERE owner = $1 AND idempotency_key = $2 AND created_at > $3', [owner, body.idempotency_key, now() - 86_400_000]);
if (earlier) {
if (earlier) {
@@ -115,19 +119,28 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -115,19 +119,28 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
const row = {
const row = {
id, owner, via: via || null, kind: 'browser', state: 'placing',
id, owner, via: via || null, kind: 'browser', state: 'placing',
width: body.width || 1280, height: body.height || 800, ttl_s: body.ttl_s || 3600, idle_s: body.idle_s || 900,
width: body.width || 1280, height: body.height || 800, ttl_s: body.ttl_s || 3600, idle_s: body.idle_s || 900,
- compute_pool: pool, compute_strategy: strategy, compute_decision: null, template, machine_asked: body.machine || null, requirements: body.requirements || null, idempotency_key: body.idempotency_key || null,
+ compute_pool: pool, compute_strategy: strategy, compute_decision: null, template, actor_id: actorId, machine_asked: body.machine || null, requirements: body.requirements || null, idempotency_key: body.idempotency_key || null,
request_digest: digest, reasons: [], task_id: body.task_id || null, curtain: false,
request_digest: digest, reasons: [], task_id: body.task_id || null, curtain: false,
control_lease: { thing: `run.session:${id}`, holder: { kind: 'person', subject: bare(owner) }, since_ms: at, expires_ms: at + 60_000, watchers: [], seq: 1 },
control_lease: { thing: `run.session:${id}`, holder: { kind: 'person', subject: bare(owner) }, since_ms: at, expires_ms: at + 60_000, watchers: [], seq: 1 },
created_at: at, updated_at: at,
created_at: at, updated_at: at,
};
};
- const out = await store.insert(db, row);
+ let out;
+ try { out = await store.insert(db, row); }
+ catch (e) { if (e.code === '23505' && actorId) throw new SessionError(409, 'run.profile.busy', 'This Actor already has an open computer.'); throw e; }
+ if (!out.row && actorId) throw new SessionError(409, 'run.profile.busy', 'This Actor already has an open computer.');
if (out.created) setImmediate(() => { tick().catch((e) => log.warn(`[Run] session tick: ${e.message}`)); });
if (out.created) setImmediate(() => { tick().catch((e) => log.warn(`[Run] session tick: ${e.message}`)); });
return out;
return out;
}
}
- async function finish(row, reason, detail = null, { release = true } = {}) {
+ async function finish(row, reason, detail = null, options = {}) {
+ return row && row.actor_id ? profiles.lock(row.owner, row.actor_id, async () => finishLocked(await store.get(db, row.id), reason, detail, options)) : finishLocked(row, reason, detail, options);
+ }
+ async function finishLocked(row, reason, detail = null, { release = true } = {}) {
if (!row || row.state === 'ended') return row;
if (!row || row.state === 'ended') return row;
if (release && row.machine && row.desk && desks.enabled) {
if (release && row.machine && row.desk && desks.enabled) {
+ if (row.actor_id) {
+ try { await saveProfile(row, true); } catch { detail = 'The last sign-in changes could not be saved; the earlier saved copy is kept.'; }
+ }
try { await desks.end(row.machine, row.desk, bare(row.owner)); } catch (e) { log.warn(`[Run] session ${row.id}: the desk did not end cleanly (${e.code || e.message})`); }
try { await desks.end(row.machine, row.desk, bare(row.owner)); } catch (e) { log.warn(`[Run] session ${row.id}: the desk did not end cleanly (${e.code || e.message})`); }
}
}
return db.tx(async (t) => {
return db.tx(async (t) => {
@@ -167,6 +180,8 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -167,6 +180,8 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
contracts.assertValid('events.event-envelope@1', prepared);
contracts.assertValid('events.event-envelope@1', prepared);
await t.maybe(`INSERT INTO ${EVENTS_TABLE} (event_id, envelope, created_at) VALUES ($1, $2, $3) ON CONFLICT (event_id) DO NOTHING RETURNING id`,
await t.maybe(`INSERT INTO ${EVENTS_TABLE} (event_id, envelope, created_at) VALUES ($1, $2, $3) ON CONFLICT (event_id) DO NOTHING RETURNING id`,
[`run:${row.id}:control-lease:${lease.seq}`, JSON.stringify(prepared), now()]);
[`run:${row.id}:control-lease:${lease.seq}`, JSON.stringify(prepared), now()]);
+ await t.maybe(`INSERT INTO run_session_actions (session_id,command_id,kind,action,actor_kind,actor_subject,result,created_at)
+ VALUES ($1,$2,'control',$3,$4,$5,'ack',$6) RETURNING id`, [row.id,`control:${lease.seq}`,reason,lease.holder.kind,lease.holder.subject || '',now()]);
return saved;
return saved;
});
});
}
}
@@ -247,6 +262,35 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -247,6 +262,35 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
return out;
return out;
}
}
+ async function profileCommand(row, action, value = {}) {
+ const result = await desks.act(row.machine, row.desk, bare(row.owner), { id: `profile-${require('crypto').randomUUID()}`, kind: 'browser', value: { action, ...value } });
+ if (!result || result.result !== 'ack') throw new SessionError(502, 'run.profile.unavailable', 'This computer could not save its sign-ins.');
+ return result.data;
+ }
+ async function saveProfile(row, locked = false) {
+ if (!row.actor_id) return;
+ if (!locked) return profiles.lock(row.owner, row.actor_id, () => saveProfile(row, true));
+ const fresh = await store.get(db, row.id);
+ if (!fresh || fresh.state === 'ended') return;
+ await profiles.write(row.owner, row.actor_id, await profileCommand(fresh, 'profile_export'));
+ }
+ async function profileSites(owner, actorId) {
+ const row = await db.maybe("SELECT * FROM run_sessions WHERE owner=$1 AND actor_id=$2 AND state='running'", [owner, actorId]);
+ if (row) await saveProfile(row);
+ return { sites: await profiles.sites(owner, actorId) };
+ }
+ async function signOut(owner, actorId, site) {
+ if (!/^[a-z0-9.-]{1,253}$/.test(site || '')) throw new SessionError(422, 'run.profile.invalid', 'Choose a site.');
+ // Lock the session row against lifecycle changes while clearing the live and saved copies.
+ return profiles.lock(owner, actorId, async t => {
+ const row = await t.maybe("SELECT * FROM run_sessions WHERE owner=$1 AND actor_id=$2 AND state<>'ended' FOR UPDATE", [owner, actorId]);
+ if (row && row.state !== 'running') throw new SessionError(409, 'run.profile.busy', 'Wait for the computer to open.');
+ if (row) { await profileCommand(row, 'profile_sign_out', { site }); await saveProfile(row, true); }
+ await profiles.forget(owner, actorId, site);
+ return { sites: await profiles.sites(owner, actorId) };
+ });
+ }
+
const EXACT_HTTPS_ORIGIN = /^https:\/\/[a-z0-9.-]+(:[0-9]{1,5})?$/;
const EXACT_HTTPS_ORIGIN = /^https:\/\/[a-z0-9.-]+(:[0-9]{1,5})?$/;
const exactOrigin = (value) => {
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; }
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; }
@@ -447,6 +491,11 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -447,6 +491,11 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
continue;
continue;
}
}
if (!lease) { await store.update(db, row.id, { state: 'placing', machine: null, desk: null, trust: null, offer_id: null, reasons: [], updated_at: now() }); continue; }
if (!lease) { await store.update(db, row.id, { state: 'placing', machine: null, desk: null, trust: null, offer_id: null, reasons: [], updated_at: now() }); continue; }
+ if (row.actor_id) {
+ const current = await store.get(db, row.id);
+ try { await profileCommand(current, 'profile_import', { profile: await profiles.read(row.owner, row.actor_id) }); }
+ catch (e) { await desks.end(h.machine_id, desk, bare(row.owner)); return finish(current, 'unplaceable', 'Saved sign-ins could not be opened on this computer.', { release: false }); }
+ }
return db.tx(async (t) => {
return db.tx(async (t) => {
const saved = await store.update(t, row.id, { state: 'running', started_at: now(), last_used_at: now(), lease_checked_at: now(), updated_at: now() });
const saved = await store.update(t, row.id, { state: 'running', started_at: now(), last_used_at: now(), lease_checked_at: now(), updated_at: now() });
await emit(t, saved, 'run.session.started');
await emit(t, saved, 'run.session.started');
@@ -471,8 +520,10 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -471,8 +520,10 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
if (now() - row.updated_at < STARTING_STALE_MS) continue;
if (now() - row.updated_at < STARTING_STALE_MS) continue;
const lease = row.machine && row.desk ? await desks.get(row.machine, row.desk, bare(row.owner)).catch(() => undefined) : null;
const lease = row.machine && row.desk ? await desks.get(row.machine, row.desk, bare(row.owner)).catch(() => undefined) : null;
if (lease === undefined) continue;
if (lease === undefined) continue;
- if (lease) await db.tx(async (t) => { const s = await store.update(t, row.id, { state: 'running', started_at: row.started_at || now(), last_used_at: now(), lease_checked_at: now(), updated_at: now() }); await emit(t, s, 'run.session.started'); });
- else await store.update(db, row.id, { state: 'placing', machine: null, desk: null, trust: null, offer_id: null, reasons: [], updated_at: now() });
+ if (lease) {
+ if (row.actor_id) await profileCommand(row, 'profile_import', { profile: await profiles.read(row.owner, row.actor_id) });
+ await db.tx(async (t) => { const s = await store.update(t, row.id, { state: 'running', started_at: row.started_at || now(), last_used_at: now(), lease_checked_at: now(), updated_at: now() }); await emit(t, s, 'run.session.started'); });
+ } else await store.update(db, row.id, { state: 'placing', machine: null, desk: null, trust: null, offer_id: null, reasons: [], updated_at: now() });
continue;
continue;
}
}
// running
// running
@@ -482,7 +533,10 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -482,7 +533,10 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
if (now() - (row.lease_checked_at || 0) >= leaseCheckMs) {
if (now() - (row.lease_checked_at || 0) >= leaseCheckMs) {
const lease = await desks.get(row.machine, row.desk, bare(row.owner)).catch(() => undefined);
const lease = await desks.get(row.machine, row.desk, bare(row.owner)).catch(() => undefined);
if (lease === null) { await finish(row, 'host_lost', 'its machine no longer holds the desk', { release: false }); continue; }
if (lease === null) { await finish(row, 'host_lost', 'its machine no longer holds the desk', { release: false }); continue; }
- if (lease) await store.update(db, row.id, { lease_checked_at: now() });
+ if (lease) {
+ if (row.actor_id) await saveProfile(row).catch(() => log.warn('[Run] saved sign-ins could not be refreshed'));
+ await store.update(db, row.id, { lease_checked_at: now() });
+ }
}
}
await db.tx(async (t) => {
await db.tx(async (t) => {
const fresh = await store.get(t, row.id);
const fresh = await store.get(t, row.id);
@@ -503,7 +557,7 @@ function createSessions({ config, db, desks, outbox, now = () => Date.now(), log
@@ -503,7 +557,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, profileSites, signOut, saveProfile, still, setCurtain, requestControl, grantControl, takeControl, controlLease, timeline, isGuest, snapshot, tick, SessionError,
createFillRequest, approveFill, decideFill, listFillRequests, getFillRequest,
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),
@@ -14,7 +14,7 @@ const { ids } = require('openvibe-contracts');
@@ -14,7 +14,7 @@ const { ids } = require('openvibe-contracts');
const COLUMNS = ['id', 'owner', 'via', 'kind', 'state', 'width', 'height', 'ttl_s', 'idle_s', 'machine_asked', 'requirements', 'idempotency_key',
const COLUMNS = ['id', 'owner', 'via', 'kind', 'state', 'width', 'height', 'ttl_s', 'idle_s', 'machine_asked', 'requirements', 'idempotency_key',
'compute_pool', 'compute_strategy', 'compute_decision', 'request_digest', 'machine', 'desk', 'trust', 'offer_id', 'reasons', 'created_at', 'started_at', 'last_used_at', 'ended_at', 'end_reason',
'compute_pool', 'compute_strategy', 'compute_decision', 'request_digest', 'machine', 'desk', 'trust', 'offer_id', 'reasons', 'created_at', 'started_at', 'last_used_at', 'ended_at', 'end_reason',
- 'end_detail', 'metered_minutes', 'lease_checked_at', 'task_id', 'curtain', 'control_lease', 'template', 'egress_label', 'updated_at'];
+ 'end_detail', 'metered_minutes', 'lease_checked_at', 'task_id', 'curtain', 'control_lease', 'template', 'actor_id', 'egress_label', 'updated_at'];
const JSON_COLUMNS = new Set(['requirements', 'reasons', 'compute_decision', 'control_lease']);
const JSON_COLUMNS = new Set(['requirements', 'reasons', 'compute_decision', 'control_lease']);
const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());
const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
+'use strict';
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/app');
+(async () => {
+ const machine = 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2A';
+ const browsers = new Map();
+ const desks = { enabled: true, hosts: async () => [{ machine_id: machine, trust: 'first-party', online: true, free: 4, max_browser_desks: 4 }],
+ lease: async (_m, _s, b) => { browsers.set(b.desk, { cookies: [], origins: [] }); return { desk: b.desk }; },
+ get: async (_m, d) => browsers.has(d) ? {} : null,
+ end: async (_m, d) => browsers.delete(d),
+ act: async (_m, d, _s, cmd) => {
+ if (!browsers.has(d)) return null;
+ const v = cmd.value;
+ if (v.action === 'profile_import') browsers.set(d, structuredClone(v.profile));
+ if (v.action === 'profile_sign_out') { const p = browsers.get(d); p.cookies = p.cookies.filter(c => { const h = c.domain.replace(/^\./, ''); return h !== v.site && !h.endsWith('.' + v.site); }); p.origins = p.origins.filter(o => new URL(o.origin).hostname !== v.site); }
+ return { result: 'ack', data: v.action === 'profile_export' ? structuredClone(browsers.get(d)) : { done: true } };
+ } };
+ const t = await boot({ appOpts: { desks } });
+ const owner = 'user:' + ids.newId('user'), actor = 'act_01J8Z4M2Q0R7T9YV3K6N8P1W2A';
+ let sessions = t.app.locals.sessions;
+ try {
+ await check('encrypted Actor profile survives a session and service restart; sign-out is site-specific', async () => {
+ const start = async () => { const r = await sessions.create(owner, 'service:actor', { kind: 'browser' }, { template: 'kept-browser', actor }); await sessions.tick(); return sessions.get(r.row.id); };
+ const one = await start(); assert.equal(one.state, 'running');
+ await sessions.grantControl(one,{kind:'ai',subject:'svc_actor'});
+ await sessions.takeControl(await sessions.get(one.id));
+ const history=await sessions.timeline(one);
+ assert.equal(history.actions[0].action,'owner_take_back');
+ assert.equal(history.actions[0].actor.kind,'person');
+ await assert.rejects(() => sessions.create(owner, 'service:actor', { kind: 'browser' }, { template: 'kept-browser', actor }), e => e.status === 409);
+ browsers.set(one.desk, { cookies: [{ name: 'login', value: 'synthetic-signin-a', domain: '.a.example', path: '/' }, { name: 'login', value: 'synthetic-signin-b', domain: 'b.example', path: '/' }, { name: 'sso', value: 'synthetic-signin-c', domain: 'accounts.a.example', path: '/' }], origins: [{ origin: 'https://a.example', entries: [['theme', 'dark']] }] });
+ await sessions.finish(one, 'ended');
+ const stored = await t.db.one('SELECT sealed FROM run_actor_profiles WHERE owner=$1', [owner]);
+ assert.ok(!stored.sealed.includes('synthetic-signin') && !stored.sealed.includes('a.example'));
+ sessions = require('../server/sessions/service').createSessions({ config: t.config, db: t.db, desks, outbox: t.outbox, log: { warn() {} } });
+ const two = await start();
+ assert.equal(browsers.get(two.desk).cookies.length, 3);
+ assert.deepEqual((await sessions.profileSites(owner, actor)).sites.sort(), ['a.example', 'accounts.a.example', 'b.example']);
+ assert.deepEqual(await sessions.profileSites('user:' + ids.newId('user'), actor), { sites: [] });
+ await sessions.signOut(owner, actor, 'a.example');
+ assert.deepEqual(browsers.get(two.desk).cookies.map(c => c.domain), ['b.example'], 'signing out of a.example also signs out of accounts.a.example');
+ assert.deepEqual(browsers.get(two.desk).origins, []);
+ await sessions.finish(two, 'ended');
+ const three = await start();
+ assert.deepEqual(browsers.get(three.desk).cookies.map(c => c.domain), ['b.example']);
+ await sessions.finish(three, 'ended');
+ });
+ } finally { await t.close(); }
+})().then(done).catch(e => { console.error(e); process.exit(1); });
@@ -83,8 +83,8 @@ function fakeDesks() {
@@ -83,8 +83,8 @@ function fakeDesks() {
await check('browser and throwaway presets are accepted; kept profiles and snapshots say plainly when unavailable', async () => {
await check('browser and throwaway presets are accepted; kept profiles and snapshots say plainly when unavailable', async () => {
const unavailable = await t.call('POST', '/api/v1/sessions?template=kept-browser', { ...as(bea), body: { kind: 'browser' } });
const unavailable = await t.call('POST', '/api/v1/sessions?template=kept-browser', { ...as(bea), body: { kind: 'browser' } });
- assert.strictEqual(unavailable.status, 501);
- assert.match(unavailable.json.detail, /not available on this machine/);
+ assert.strictEqual(unavailable.status, 503);
+ assert.match(unavailable.json.detail, /Saved sign-ins need/);
const throwaway = await t.call('POST', '/api/v1/sessions?template=throwaway', { ...as(bea), body: { kind: 'browser' } });
const throwaway = await t.call('POST', '/api/v1/sessions?template=throwaway', { ...as(bea), body: { kind: 'browser' } });
assert.strictEqual(throwaway.status, 201, throwaway.text);
assert.strictEqual(throwaway.status, 201, throwaway.text);
await sessions.finish(await sessions.get(throwaway.json.id), 'ended', 'test complete');
await sessions.finish(await sessions.get(throwaway.json.id), 'ended', 'test complete');