openvibers/openvibe.actor Commits

Add agent locker fill login tool (#364)

OP OpenVibers committed 4 days ago · 2f2e6101e5 · 5 files, +58 −2

Changes

ddiff --git a/server/agents/computers.js b/server/agents/computers.js
iindex a200713..581b9c8 100644
---- a/server/agents/computers.js
++++ b/server/agents/computers.js
@@@ -114,6 +114,21 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensO
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 fillLogin(subject, machine, origin, { signal, timeoutMs = 125_000 } = {}) {
++ const m = SESSION_MACHINE.exec(String(machine || ''));
++ if (!m || !sessionsOn) return { ok: false, reason: 'no_private_browser_session' };
++ if (typeof origin !== 'string') return { ok: false, reason: 'origin_required' };
++ const created = await runCall('POST', `/sessions/${m[1]}/fill-requests`, subject, { body: { origin }, signal });
++ if (!created.ok || !created.data || !created.data.request_id || created.data.status !== 'pending') return { ok: false, reason: 'request_refused' };
++ const until = Date.now() + timeoutMs;
++ while (Date.now() < until && !(signal && signal.aborted)) {
++ await pause(1000, signal);
++ const r = await runCall('GET', `/sessions/${m[1]}/fill-requests/${created.data.request_id}`, subject, { signal, timeoutMs: 8000 });
++ if (!r.ok) return { ok: false, reason: 'request_unavailable' };
++ if (['ok', 'failed', 'expired'].includes(r.data.state)) return { ok: r.data.outcome === 'ok', reason: r.data.reason || null };
++ }
++ return { ok: false, reason: signal && signal.aborted ? 'cancelled' : 'approval_timeout' };
++ }
/**
* The session's newest frame (plan 2026-11-07): Run's still, as Actor's service token acting for the person
@@@ -432,7 +447,7 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensO
return { url: data.url, expires_at: data.expires_at || null, control: !!data.control, desk: data.desk || desk };
}
-- return { enabled, mine, list, read, run, write, screen, input, browser, desk, deskTurn, watchUrl, watchLink, openSession, endSession, still, sessionsOn, isSession: (m) => SESSION_MACHINE.test(String(m || '')) };
++ return { enabled, mine, list, read, run, write, screen, input, browser, desk, deskTurn, watchUrl, watchLink, openSession, endSession, fillLogin, still, sessionsOn, isSession: (m) => SESSION_MACHINE.test(String(m || '')) };
}
module.exports = { createComputers };
ddiff --git a/server/runtime/tools/locker_fill_login.js b/server/runtime/tools/locker_fill_login.js
nnew file mode 100644
iindex 0000000..7f02606
---- /dev/null
++++ b/server/runtime/tools/locker_fill_login.js
@@@ -0,0 +1,23 @@
++'use strict';
++
++const { serviceFor, json, personOf } = require('./_openvibe');
++
++module.exports = {
++ name: 'locker_fill_login',
++ description: 'Ask the person to approve one saved sign-in for the current private Run browser session. The person chooses the login and what to type in Companion. You receive only ok or a short reason, never credentials or codes.',
++ input: { type: 'object', properties: { origin: { type: 'string', description: 'the exact HTTPS origin shown in the browser address bar, including port; no path' } }, required: ['origin'] },
++ scope: 'person',
++ // Creating a request does not fill anything; Run pauses control and the person approves in Companion.
++ acts: false,
++ costUsd: 0,
++ timeoutMs: 130_000,
++ available: (ctx) => Boolean(ctx.config && ctx.config.services && ctx.config.services.runUrl && ctx.config.oauth && ctx.config.oauth.clientSecret && personOf(ctx.requester)),
++ async run(input, ctx) {
++ const person = personOf(ctx.requester);
++ const machine = ctx.state && ctx.state.desks && ctx.state.desks.placed;
++ const core = serviceFor(ctx);
++ if (!person || !core.pcs.enabled || !core.pcs.fillLogin) return { text: json({ ok: false, reason: 'sessions_unavailable' }), isError: true };
++ const result = await core.pcs.fillLogin(person, machine, input.origin, { signal: ctx.signal });
++ return { text: json({ ok: result.ok === true, reason: result.reason || null }), data: { ok: result.ok === true, reason: result.reason || null } };
++ },
++};
ddiff --git a/test/computers.test.js b/test/computers.test.js
iindex de3d9ed..56bd484 100644
---- a/test/computers.test.js
++++ b/test/computers.test.js
@@@ -424,6 +424,23 @@ const as = { requester: `user:${ME}` };
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('locker_fill_login requests approval in Run and returns only outcome metadata', async () => {
++ const seen = [];
++ const runConfig = { ...config, services: { ...config.services, runUrl: 'http://run.test' } };
++ const fetchImpl = async (url, init = {}) => {
++ const u = new URL(url); seen.push({ path: u.pathname, body: init.body && JSON.parse(init.body), auth: init.headers.Authorization });
++ if (u.pathname.endsWith('/fill-requests') && init.method === 'POST') return new Response(JSON.stringify({ request_id: 'fr_01JAB2C3D4E5F6G7H8J9K0MNPQ', status: 'pending' }), { status: 201 });
++ return new Response(JSON.stringify({ state: 'ok', outcome: 'ok', reason: null }), { status: 200 });
++ };
++ const pcs = createComputers({ config: runConfig, fetchImpl, tokens });
++ const result = await pcs.fillLogin(ME, 'run:ses_01JAB2C3D4E5F6G7H8J9K0MNPQ', 'https://example.test');
++ assert.deepStrictEqual(result, { ok: true, reason: null });
++ assert.deepStrictEqual(seen[0].body, { origin: 'https://example.test' });
++ assert.match(seen[1].path, /fr_01JAB2C3D4E5F6G7H8J9K0MNPQ$/);
++ assert.ok(seen.every((r) => r.auth === 'Bearer svc-token'));
++ assert.ok(!JSON.stringify(result).match(/username|password|secret|code/i));
++ });
++
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);
ddiff --git a/test/runtime-tools.test.js b/test/runtime-tools.test.js
iindex a8349e7..2179a56 100644
---- a/test/runtime-tools.test.js
++++ b/test/runtime-tools.test.js
@@@ -108,7 +108,7 @@ const good = (name, scope = 'public') => `module.exports = {
assert.ok(anon.includes('search_openvibe') && anon.includes('search_web') && anon.includes('find_tools') && anon.includes('run_tool') && anon.includes('read_page'));
assert.ok(!anon.includes('list_my_robots') && !anon.includes('codes_task'), 'no person tools without a signed-in person');
const person = names({ config: withBot, requester: 'user:usr_01JAB2C3D4E5F6G7H8J9K0MNPT' });
-- for (const n of ['list_my_robots', 'robot_steps', 'list_my_computers', 'computer_run', 'computer_desk', 'hand_to_person', 'codes_task', 'computer_screen']) assert.ok(person.includes(n), n);
++ for (const n of ['list_my_robots', 'robot_steps', 'list_my_computers', 'computer_run', 'computer_desk', 'hand_to_person', 'codes_task', 'computer_screen', 'locker_fill_login']) assert.ok(person.includes(n), n);
assert.deepStrictEqual(names({ config: withBot, requester: 'user:usr_01JAB2C3D4E5F6G7H8J9K0MNPT', mode: 'private' }), [], 'private mode has no network tools');
// Files and charts need the signed-in person and a Media store. A public-chat task has no requester, so the
// person gate never opens; a server without Media has nothing to store into.
ddiff --git a/test/tool-acts.test.js b/test/tool-acts.test.js
iindex a7134af..4dfd5d2 100644
---- a/test/tool-acts.test.js
++++ b/test/tool-acts.test.js
@@@ -24,6 +24,7 @@ const EXPECTED = {
load_skill: false,
list_my_computers: false,
list_my_robots: false,
++ locker_fill_login: false,
market_quote: false,
place_info: false,
read_page: false,