openvibers/openvibe.actor Commits

Desk on a desk host when none of the person's computers has one (Desks D6): machine "any" opens an OpenVibe.Run session; screen, input and browser go through it; it ends with the task (#154)

OP OpenVibers committed 16 days ago · 6a04e84a08 · 4 files, +135 −4

Changes

ddiff --git a/server/agents/computers.js b/server/agents/computers.js
iindex 8ae9875..b01f011 100644
---- a/server/agents/computers.js
++++ b/server/agents/computers.js
@@@ -26,6 +26,9 @@
const { serviceAuth } = require('openvibe-contracts');
const MACHINE_ID = /^dev_[0-9A-HJKMNP-TV-Z]{26}$/;
++// A desk on a desk host, through an OpenVibe.Run session (Desks plan D6): the task's "machine" is then run:<session id>.
++const SESSION_MACHINE = /^run:(ses_[0-9A-HJKMNP-TV-Z]{26})$/;
++const SESSION_KINDS = new Set(['screen', 'input', 'browser']);
const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
// A command waits on the machine for its owner's approval at level "ask", then runs: two minutes, inside a task's three.
const ACT_WINDOW_MS = 120_000;
@@@ -58,6 +61,58 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensO
audience: 'openvibe.bot', scope: 'bot.computer.control', fetchImpl,
});
++ // OpenVibe.Run's sessions (contracts 0.223.0): a browser desk on a desk host for the person, when none of their own
++ // machines has one. Run's own service token (audience openvibe.run), naming the person in X-OV-Subject.
++ const runBase = config.services.runUrl;
++ const sessionsOn = !!(runBase && (tokensOverride || config.oauth.clientSecret));
++ const runTokens = !sessionsOn ? null : (tokensOverride || serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.run', scope: 'run.session.create run.session.read run.session.control', fetchImpl,
++ }));
++ async function runCall(method, path, subject, { body, signal, timeoutMs = 15_000 } = {}) {
++ const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
++ const res = await fetchImpl(`${runBase}/api/v1${path}`, {
++ method,
++ headers: { Accept: 'application/json', 'X-OV-Subject': subject, ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await runTokens.authHeaders()) },
++ body: body ? JSON.stringify(body) : undefined, signal: sig,
++ });
++ const data = await res.json().catch(() => null);
++ return { status: res.status, ok: res.ok, data };
++ }
++ const runProblem = (r, what) => ({ error: (r.data && (r.data.detail || r.data.title)) || `Run answered ${r.status} for ${what}`, code: (r.data && r.data.code) || null });
++ const pause = (ms, signal) => new Promise((resolve) => { const t = setTimeout(resolve, ms); if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true }); });
++ /**
++ * A browser session for the person on a desk host (Desks plan D6), started and waited for until its desk runs (at
++ * most 45 s). → { machine: 'run:ses_…', session } or { error }
++ */
++ async function openSession(subject, { taskId = null, ttlS = 3600, width, height, signal } = {}) {
++ if (!sessionsOn) return { error: 'no desk host is reachable from here (OpenVibe.Run sessions are off)' };
++ if (!USER_SUBJECT.test(subject)) return { error: 'a session is only for a signed-in person' };
++ const body = { kind: 'browser', ttl_s: Math.min(14400, Math.max(60, Math.round(ttlS))), idle_s: 900,
++ ...(Number.isInteger(width) ? { width } : {}), ...(Number.isInteger(height) ? { height } : {}),
++ ...(taskId ? { idempotency_key: `actor:${taskId}` } : {}) };
++ const made = await runCall('POST', '/sessions', subject, { body, signal });
++ if (!made.ok) return runProblem(made, 'a browser session');
++ let s = made.data;
++ const until = Date.now() + 45_000;
++ while (s && s.state !== 'running' && s.state !== 'ended' && Date.now() < until && !(signal && signal.aborted)) {
++ await pause(1000, signal);
++ const r = await runCall('GET', `/sessions/${s.id}`, subject, { signal });
++ if (r.ok) s = r.data;
++ }
++ if (!s || s.state !== 'running') {
++ if (s && s.state !== 'ended') await runCall('POST', `/sessions/${s.id}/end`, subject, { signal }).catch(() => null);
++ return { error: s && s.end ? `no desk host could take a browser desk now (${s.end.reason})` : 'no desk host took a browser desk in time' };
++ }
++ return { machine: `run:${s.id}`, session: s };
++ }
++ async function endSession(subject, machine, { signal } = {}) {
++ const m = SESSION_MACHINE.exec(String(machine || ''));
++ if (!m || !sessionsOn) return { error: 'not a session desk' };
++ const r = await runCall('POST', `/sessions/${m[1]}/end`, subject, { signal });
++ return r.ok ? { running: false, stopped: true } : runProblem(r, 'ending the session');
++ }
++
async function call(method, path, subject, { body, signal, timeoutMs = 15_000 } = {}) {
const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
const res = await fetchImpl(`${base}/api/v1${path}`, {
@@@ -71,11 +126,24 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensO
const problem = (r, what) => ({ error: (r.data && (r.data.detail || r.data.title)) || `Bot answered ${r.status} for ${what}`, code: (r.data && r.data.code) || null });
const guard = (subject, machine) => {
if (!USER_SUBJECT.test(subject)) return { error: 'computers are only for a signed-in person' };
++ if (SESSION_MACHINE.test(String(machine || ''))) return null; // the task's desk on a desk host (command() checks the kind)
if (!MACHINE_ID.test(String(machine || ''))) return { error: 'machine must be a machine id from list_my_computers (dev_…)' };
return null;
};
/** One command through Bot; the machine's answer, or its refusal (a nack's reason), as an error. */
async function command(subject, machine, kind, value, { signal, windowMs }) {
++ const ses = SESSION_MACHINE.exec(String(machine || ''));
++ if (ses) {
++ // A desk on a desk host: only its page (screen, input, browser), through Run, which names the desk itself.
++ if (!SESSION_KINDS.has(kind)) return { error: 'your desk is on a desk host, which runs a browser only: no programs or files there' };
++ const { desk: _desk, ...rest } = value || {};
++ const r = await runCall('POST', `/sessions/${ses[1]}/commands`, subject, { body: { kind, value: rest }, signal, timeoutMs: windowMs + 10_000 });
++ if (!r.ok) return runProblem(r, `the ${kind} command`);
++ if (r.data.result === 'ack') return { data: r.data.data || {} };
++ if (r.data.state === 'ended') return { error: 'your desk on the desk host has ended (its time ran out or its host went away): start a new one with computer_desk start and machine "any"' };
++ if (r.data.result === 'expired') return { error: 'the desk host did not answer in time' };
++ return { error: r.data.reason || `the desk host refused it (${r.data.result})` };
++ }
const r = await call('POST', `/machines/${machine}/commands`, subject, { body: { kind, value, ms: windowMs }, signal, timeoutMs: windowMs + 10_000 });
if (!r.ok) return problem(r, `the ${kind} command`);
if (r.data.result === 'ack') return { data: r.data.data || {} };
@@@ -269,6 +337,11 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensO
const bad = guard(subject, machine);
if (bad) return bad;
const a = args && typeof args === 'object' ? args : {};
++ if (SESSION_MACHINE.test(String(machine))) {
++ if (a.action === 'stop') return endSession(subject, machine, { signal });
++ if (a.action === 'status') return { running: true, where: 'a desk host (OpenVibe.Run session)' };
++ return { error: 'your desk on the desk host runs already' };
++ }
if (!DESK_ACTIONS.has(a.action)) return { error: 'action is start, stop or status' };
const value = { action: a.action, ...(DESK_NAME.test(String(a.desk || '')) ? { desk: a.desk } : {}) };
if (a.action === 'start' && typeof a.profile === 'string' && /^[a-z0-9][a-z0-9-]{0,23}$/.test(a.profile)) value.profile = a.profile;
@@@ -299,7 +372,7 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensO
/** The person's page to watch a desk live and take it over (Bot's own screen page). */
const watchUrl = (machine, desk) => `${pages}/machines/${machine}/screen${desk && desk !== 'main' ? `?desk=${encodeURIComponent(desk)}` : ''}`;
-- return { enabled, mine, list, read, run, write, screen, input, browser, desk, deskTurn, watchUrl };
++ return { enabled, mine, list, read, run, write, screen, input, browser, desk, deskTurn, watchUrl, openSession, endSession, sessionsOn, isSession: (m) => SESSION_MACHINE.test(String(m || '')) };
}
module.exports = { createComputers };
ddiff --git a/server/agents/tools.js b/server/agents/tools.js
iindex dbf1714..9f9ae26 100644
---- a/server/agents/tools.js
++++ b/server/agents/tools.js
@@@ -220,6 +220,10 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
async function closeDesks(run, requester) {
const person = personOf(requester);
++ if (run && person && pcs.isSession && pcs.isSession(run.placed)) {
++ await pcs.endSession(person, run.placed).catch(() => null); // the task's desk on a desk host ends with it
++ run.placed = null;
++ }
if (!run || !person || !run.used.size) return;
await Promise.all([...run.used].map((machine) => pcs.desk(person, machine, { action: 'stop', desk: run.name }).catch(() => null)));
run.used.clear();
@@@ -279,12 +283,22 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
if (desks && desks.placed) a = { ...a, machine: desks.placed };
else if (name === 'computer_desk' && a.action === 'start') {
const order = await placementOrder(person, { signal });
-- if (!order.length) return json({ error: 'none of the person\'s computers runs desks for you now: it needs OpenVibe Node v0.19.0 or newer, online, with "Let my agent use this computer" on' });
let out = null;
for (const id of order.slice(0, 4)) {
out = JSON.parse(await call(name, { ...a, machine: id }, { signal, requester, meter, budgetLeft, desks, emit, waitForPerson }));
if (!(out && out.error && /limit|max_desks|already runs/i.test(out.error))) return json(out && !out.error ? { machine: id, ...out } : out);
}
++ // None of the person's own machines has room (or none runs desks): a browser desk on a desk host,
++ // through an OpenVibe.Run session (Desks plan D6), for browser work only.
++ if (pcs.sessionsOn && (a.kind == null || a.kind === 'browser')) {
++ const ses = await pcs.openSession(person, { taskId: desks && desks.taskId, width: a.width, height: a.height, signal });
++ if (!ses.error) {
++ if (desks) desks.placed = ses.machine;
++ return json({ machine: ses.machine, running: true, kind: 'browser', where: 'a desk host (none of your own computers had a desk free)', note: 'A browser alone, on a machine that is not the person\'s: computer_browser, computer_screen and computer_input work there with machine "any"; no programs or files. It ends with the task.' });
++ }
++ if (!order.length) return json({ error: `none of the person's computers runs desks for you now, and ${ses.error}` });
++ }
++ if (!order.length) return json({ error: 'none of the person\'s computers runs desks for you now: it needs OpenVibe Node v0.19.0 or newer, online, with "Let my agent use this computer" on' });
return json({ error: `every computer of the person's that runs desks is full right now (${out.error})` });
} else return json({ error: 'no desk of yours runs yet: start one with computer_desk start and machine "any"' });
}
@@@ -317,9 +331,13 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
if (a.action === 'stop' && desks.placed === a.machine) desks.placed = null;
}
// The person can watch the task's desk live and take it over (Desks plan D4): the link comes first.
-- return json(out && out.running && a.action !== 'stop' ? { watch: pcs.watchUrl(a.machine, a.desk), ...out } : out);
++ return json(out && out.running && a.action !== 'stop' && !(pcs.isSession && pcs.isSession(a.machine)) ? { watch: pcs.watchUrl(a.machine, a.desk), ...out } : out);
++ }
++ if (name === 'hand_to_person') {
++ // A desk on a desk host (an OpenVibe.Run session) is not the person's to take over yet.
++ if (pcs.isSession && pcs.isSession(a.machine === 'any' && desks ? desks.placed : a.machine)) return json({ done: false, note: 'Your desk is on a desk host, which the person cannot take over from here: tell them in your answer what is left for them to do, and where.' });
++ return json(await handToPerson(person, a, { signal, desks, emit, waitForPerson }));
}
-- if (name === 'hand_to_person') return json(await handToPerson(person, a, { signal, desks, emit, waitForPerson }));
return json(await pcs.write(person, a.machine, a.path, a.text, { mode: a.mode || 'create', signal }));
}
if (name === 'search_openvibe') return json(await searchOpenVibe(a.query, { signal }));
ddiff --git a/server/config.js b/server/config.js
iindex 1b6b49e..0725902 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -89,6 +89,9 @@ function load(env = process.env) {
// OpenVibe.Codes: the `codes` agent runs a person's coding task on their own machine. Empty turns it off.
codesUrl: trim(env.ACTOR_CODES_URL != null ? env.ACTOR_CODES_URL : (selfHosted ? '' : 'http://127.0.0.1:4900')),
codesPublicUrl: trim(env.ACTOR_CODES_PUBLIC_URL || 'https://openvibe.codes'),
++ // OpenVibe.Run: a browser session on a desk host when none of the person's own machines has a desk for the
++ // task (Desks plan D6). Empty turns it off.
++ runUrl: trim(env.ACTOR_RUN_URL != null ? env.ACTOR_RUN_URL : (selfHosted ? '' : 'http://127.0.0.1:4920')),
},
// What one person may spend without paying (plan T17: "free tier from the bounded allowance"), and the
ddiff --git a/test/computers.test.js b/test/computers.test.js
iindex bb75322..158b9d8 100644
---- a/test/computers.test.js
++++ b/test/computers.test.js
@@@ -387,6 +387,43 @@ const as = { requester: `user:${ME}` };
assert.strictEqual(bot.calls.length, before, 'nothing went to Bot');
});
++ await check('a desk on a desk host when none of the person\'s computers has one (Desks plan D6, OpenVibe.Run sessions)', async () => {
++ const SES = 'ses_01JD2C3D4E5F6G7H8J9K0MNPQR';
++ const runCalls = [];
++ const bot = fakeBot({ machines: [] }); // no computer open to the agent
++ const inner = bot.fetchImpl;
++ bot.fetchImpl = async (url, init = {}) => {
++ const u = new URL(url);
++ if (u.host !== 'run.test') return inner(url, init);
++ const body = init.body ? JSON.parse(init.body) : null;
++ runCalls.push({ method: init.method || 'GET', path: u.pathname, subject: (init.headers || {})['X-OV-Subject'], body });
++ const session = (state) => ({ id: SES, state, owner: { type: 'user', id: ME } });
++ const reply = (status, b) => new Response(JSON.stringify(b), { status, headers: { 'content-type': 'application/json' } });
++ if (u.pathname === '/api/v1/sessions' && init.method === 'POST') return reply(201, session('running'));
++ if (u.pathname === `/api/v1/sessions/${SES}/commands`) return reply(200, { session_id: SES, id: 'c', result: 'ack', state: 'running', data: { url: body.value.url || 'https://example.org/', title: 'Example', text: 'hello', truncated: false, links: [] } });
++ if (u.pathname === `/api/v1/sessions/${SES}/end`) return reply(200, session('ended'));
++ return reply(404, { code: 'run.session.not_found' });
++ };
++ const cfg = { ...config, services: { ...config.services, runUrl: 'http://run.test' } };
++ const t = createOpenVibeTools({ config: cfg, fetchImpl: bot.fetchImpl, robots: createRobots({ config: cfg, fetchImpl: bot.fetchImpl, tokens }), computers: createComputers({ config: cfg, fetchImpl: bot.fetchImpl, tokens }), vision: async () => ({ text: 'x', cost_usd: 0 }) });
++ const run = t.deskRun('tsk_1');
++ const placed = JSON.parse(await t.call('computer_desk', { machine: 'any', action: 'start' }, { ...as, desks: run }));
++ assert.deepStrictEqual([placed.machine, placed.running, placed.kind], [`run:${SES}`, true, 'browser'], JSON.stringify(placed));
++ assert.deepStrictEqual(runCalls[0].body, { kind: 'browser', ttl_s: 3600, idle_s: 900, idempotency_key: 'actor:tsk_1' });
++ assert.strictEqual(runCalls[0].subject, ME, 'as the person');
++ const page = JSON.parse(await t.call('computer_browser', { machine: 'any', action: 'open', url: 'https://example.org/a', desk: 'private' }, { ...as, desks: run }));
++ assert.strictEqual(page.title, 'Example');
++ const sent = runCalls.find((c) => c.path.endsWith('/commands'));
++ assert.deepStrictEqual(sent.body, { kind: 'browser', value: { action: 'open', url: 'https://example.org/a' } }, 'Run names the desk: Actor never does');
++ assert.match(JSON.parse(await t.call('computer_run', { machine: `run:${SES}`, argv: ['id'] }, { ...as, desks: run })).error, /browser only/);
++ assert.ok(!JSON.parse(await t.call('hand_to_person', { machine: 'any', what: 'Sign in.' }, { ...as, desks: run })).watch, 'no take-over link for a desk host');
++ await t.closeDesks(run, as.requester);
++ assert.ok(runCalls.some((c) => c.path === `/api/v1/sessions/${SES}/end`), 'the session ends with the task');
++ // Without Run (no runUrl), the old answer stands.
++ const off = createOpenVibeTools({ config, fetchImpl: bot.fetchImpl, robots: createRobots({ config, fetchImpl: bot.fetchImpl, tokens }), computers: createComputers({ config, fetchImpl: bot.fetchImpl, tokens }) });
++ assert.match(JSON.parse(await off.call('computer_desk', { machine: 'any', action: 'start' }, { ...as, desks: off.deskRun() })).error, /none of the person's computers runs desks/);
++ });
++
await check('off without a Bot URL or a client secret', async () => {
assert.strictEqual(createComputers({ config: { ...config, services: { ...config.services, botUrl: '' } } }).enabled, false);
assert.strictEqual(createComputers({ config: { ...config, oauth: { clientId: 'actor', clientSecret: '' } } }).enabled, false);