openvibers/openvibe.actor Commits
Add agent locker fill login tool (#364)
Changes
@@@ -114,6 +114,21 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensOconst 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: tokensOreturn { 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 };nnew file mode 100644@@@ -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 } };++ },++};@@@ -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);@@@ -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.@@@ -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,
@@ -114,6 +114,21 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensO
@@ -114,6 +114,21 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensO
const r = await runCall('POST', `/sessions/${m[1]}/end`, subject, { signal });
const r = await runCall('POST', `/sessions/${m[1]}/end`, subject, { signal });
return r.ok ? { running: false, stopped: true } : runProblem(r, 'ending the session');
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
* 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
@@ -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 { 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 };
module.exports = { createComputers };
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -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 } };
+ },
+};
@@ -424,6 +424,23 @@ const as = { requester: `user:${ME}` };
@@ -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/);
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 () => {
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);
@@ -108,7 +108,7 @@ const good = (name, scope = 'public') => `module.exports = {
@@ -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('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');
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' });
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');
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
// 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.
// person gate never opens; a server without Media has nothing to store into.
@@ -24,6 +24,7 @@ const EXPECTED = {
@@ -24,6 +24,7 @@ const EXPECTED = {
load_skill: false,
load_skill: false,
list_my_computers: false,
list_my_computers: false,
list_my_robots: false,
list_my_robots: false,
+ locker_fill_login: false,
market_quote: false,
market_quote: false,
place_info: false,
place_info: false,
read_page: false,
read_page: false,