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)
Changes
@@@ -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: tokensOaudience: '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: tokensOconst 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: tokensOconst 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 };@@@ -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 }));@@@ -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@@@ -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);
@@ -26,6 +26,9 @@
@@ -26,6 +26,9 @@
const { serviceAuth } = require('openvibe-contracts');
const { serviceAuth } = require('openvibe-contracts');
const MACHINE_ID = /^dev_[0-9A-HJKMNP-TV-Z]{26}$/;
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}$/;
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.
// 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;
const ACT_WINDOW_MS = 120_000;
@@ -58,6 +61,58 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensO
@@ -58,6 +61,58 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensO
audience: 'openvibe.bot', scope: 'bot.computer.control', fetchImpl,
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 } = {}) {
async function call(method, path, subject, { body, signal, timeoutMs = 15_000 } = {}) {
const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
const res = await fetchImpl(`${base}/api/v1${path}`, {
const res = await fetchImpl(`${base}/api/v1${path}`, {
@@ -71,11 +126,24 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensO
@@ -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 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) => {
const guard = (subject, machine) => {
if (!USER_SUBJECT.test(subject)) return { error: 'computers are only for a signed-in person' };
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_…)' };
if (!MACHINE_ID.test(String(machine || ''))) return { error: 'machine must be a machine id from list_my_computers (dev_…)' };
return null;
return null;
};
};
/** One command through Bot; the machine's answer, or its refusal (a nack's reason), as an error. */
/** 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 }) {
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 });
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.ok) return problem(r, `the ${kind} command`);
if (r.data.result === 'ack') return { data: r.data.data || {} };
if (r.data.result === 'ack') return { data: r.data.data || {} };
@@ -269,6 +337,11 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensO
@@ -269,6 +337,11 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensO
const bad = guard(subject, machine);
const bad = guard(subject, machine);
if (bad) return bad;
if (bad) return bad;
const a = args && typeof args === 'object' ? args : {};
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' };
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 } : {}) };
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;
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
@@ -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). */
/** 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)}` : ''}`;
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 };
module.exports = { createComputers };
@@ -220,6 +220,10 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
@@ -220,6 +220,10 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
async function closeDesks(run, requester) {
async function closeDesks(run, requester) {
const person = personOf(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;
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)));
await Promise.all([...run.used].map((machine) => pcs.desk(person, machine, { action: 'stop', desk: run.name }).catch(() => null)));
run.used.clear();
run.used.clear();
@@ -279,12 +283,22 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
@@ -279,12 +283,22 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
if (desks && desks.placed) a = { ...a, machine: desks.placed };
if (desks && desks.placed) a = { ...a, machine: desks.placed };
else if (name === 'computer_desk' && a.action === 'start') {
else if (name === 'computer_desk' && a.action === 'start') {
const order = await placementOrder(person, { signal });
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;
let out = null;
for (const id of order.slice(0, 4)) {
for (const id of order.slice(0, 4)) {
out = JSON.parse(await call(name, { ...a, machine: id }, { signal, requester, meter, budgetLeft, desks, emit, waitForPerson }));
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);
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})` });
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"' });
} 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 = () =>
@@ -317,9 +331,13 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
if (a.action === 'stop' && desks.placed === a.machine) desks.placed = null;
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.
// 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 }));
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 }));
if (name === 'search_openvibe') return json(await searchOpenVibe(a.query, { signal }));
@@ -89,6 +89,9 @@ function load(env = process.env) {
@@ -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.
// 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')),
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'),
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
// What one person may spend without paying (plan T17: "free tier from the bounded allowance"), and the
@@ -387,6 +387,43 @@ const as = { requester: `user:${ME}` };
@@ -387,6 +387,43 @@ const as = { requester: `user:${ME}` };
assert.strictEqual(bot.calls.length, before, 'nothing went to Bot');
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 () => {
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, services: { ...config.services, botUrl: '' } } }).enabled, false);
assert.strictEqual(createComputers({ config: { ...config, oauth: { clientId: 'actor', clientSecret: '' } } }).enabled, false);
assert.strictEqual(createComputers({ config: { ...config, oauth: { clientId: 'actor', clientSecret: '' } } }).enabled, false);