openvibers/openvibe.sdk Commits
0.66.0: openvibe-sdk/bot machines and desks (a desk of your own on a machine: start, browser, screen, input, exec, turn, stop) (#90)
Changes
@@@ -3,6 +3,19 @@All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minorrelease may change an API and says so here.++## 0.66.0 (2026-10-27)++++- **`openvibe-sdk/bot`: machines and desks** (the Desks plan; the roadmap's SDK section). `bot.machines.list()`,++ `.computer(id)`, `.command(id, { kind, value })` (bot.command@1's computer kinds) and `.deskTurn(id, desk)`.++- **`bot.machines.desk(id, { name?, kind?, profile? })`** is a desk of your own on a machine: a browser alone or a whole++ screen, with its own files and sign-ins (OpenVibe.Node v0.19.0 to v0.26.0).++ - `start`, `stop` and `status`.++ - `browser`, `screen`, `input` and `exec` on that desk only, never on the person's own screen. `exec` takes++ `detach` for an app that keeps running until the desk stops.++ - `turn()` says whether a person is using the desk now.++ - Each method answers the machine's `data`. A command the machine does not do throws an `OpenVibeError` with code++ `bot.desk_nack`.++## 0.65.0 (2026-10-26)- **`openvibe-sdk/sso`: switch account and use another account** (the owner, 2026-10-26: sign-in "like the Google@@@ -2,14 +2,14 @@> Supported browser and server clients for the OpenVibe platform.--**Status:** alpha, v0.65.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.++**Status:** alpha, v0.66.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.```bash--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.66.0```It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.65.0",++ "version": "0.66.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30","deployed": false,"deployment": "none: a package released as git tags; services and apps pin a tag",@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.65.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.66.0, MIT). Do not edit.// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces// (auth, registry, modules, realtime, media, community, bot, search, themes, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -43,7 +43,7 @@ const { paginate, offsetPager } = __load(7);const semver = __load(6);/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.65.0';++const SDK_VERSION = '0.66.0';module.exports = {createClient,@@@ -1774,17 +1774,25 @@ function (module, exports) {* and the call is never retried (a code is one-time, so a lost answer cannot be safely repeated).* Every other POST carries an Idempotency-Key (the client generates one when retries are on).*++ * Machines (the OpenVibe Node's computer plugin): `machines.list`, `machines.computer`, `machines.command` (bot.command@1's++ * computer kinds) and `machines.deskTurn`; `machines.desk(id, { name?, kind?, profile? })` is a desk of your own on a++ * machine (the Desks plan: its own screen or browser, files and sign-ins), with start/stop/status and browser, screen,++ * input and exec on it. A desk command that the machine does not do throws an OpenVibeError with code `bot.desk_nack`++ * (the machine's own fault code is `fault`), so a desk reads as plain awaits.++ ** Not wrapped: the operator WebSocket (/control — Realtime frames bot.command@1), device* bootstrap (POST /devices/bind, a Network node token) and the internal Run -> Bot job dispatch* (bot.job.dispatch is service-to-service and is never delegated to people or apps). The signed* `/robots/:id/devices` listing is `robots.devices`; credential rotation and revocation are* `devices.rotate` / `devices.revoke`.*/--const { isOpenVibeError } = __load(3);++const { isOpenVibeError, OpenVibeError } = __load(3);const { isActingSubjectId } = __load(5);const { paginate } = __load(7);const enc = encodeURIComponent;++const DESK_NAME = /^desk-[a-z0-9][a-z0-9-]{0,23}$/;++const randomDesk = () => `desk-${Math.random().toString(36).slice(2, 12).padEnd(10, '0')}`;/** The header a service uses to say who it acts for: X-OV-Subject. Bot ignores the other X-OV-* ones. */function actingHeaders({ actingSubject } = {}) {@@@ -1862,8 +1870,64 @@ function createBotClient(client, defaults = {}) {},};++ // ── Machines (the OpenVibe Node's computer plugin) and desks of your own on them ──++ const machines = {++ /** { machines } — the person's machines with their computer capabilities (a service sees the ones opened to it). */++ list: (o = {}) => call({ path: '/api/v1/machines' }, o),++ /** { machine } — one machine's computer capabilities, or null. */++ computer: (id, o = {}) => orNull(call({ path: `/api/v1/machines/${enc(id)}/computer` }, o)),++ /**++ * { id?, kind, value, ms? } -> { machine_id, result, data?, reason? } (bot.command@1's computer kinds: exec, fs,++ * screen, input, browser, probe, desk). A refusal by Bot is an OpenVibeError (bot.computer_off, bot.person_driving …).++ */++ command: (id, input, o = {}) => call({ method: 'POST', path: `/api/v1/machines/${enc(id)}/commands`, json: input }, o),++ /** { desk, person_driving, last_person_at } — whether a person is using that desk now (the person always wins). */++ deskTurn: (id, desk, o = {}) => call({ path: `/api/v1/machines/${enc(id)}/desk-turn`, query: { desk } }, o),++ /**++ * A desk of your own on a machine (the Desks plan): a screen or a browser alone, its own files and sign-ins.++ * const desk = bot.machines.desk(machineId, { kind: 'browser', profile: 'agent' });++ * await desk.start();++ * const page = await desk.browser({ action: 'open', url: 'https://example.org/' });++ * await desk.stop();++ * Each method answers the machine's `data`; a nack throws (code bot.desk_nack, `fault` the machine's own).++ */++ desk(id, { name = randomDesk(), kind, profile, width, height } = {}, o = {}) {++ if (!DESK_NAME.test(name)) throw new TypeError('name is desk-<name>: lower-case letters, digits and dashes, at most 24 after desk-');++ const ack = (r) => {++ if (r && r.result === 'ack') return r.data === undefined ? {} : r.data;++ throw new OpenVibeError({ code: 'bot.desk_nack', status: 409, message: `the machine did not do it: ${(r && (r.reason || r.result)) || 'no answer'}`, detail: r && r.reason });++ };++ const on = (cmdKind, value, ms) => machines.command(id, { kind: cmdKind, value: { ...value, desk: name }, ...(ms ? { ms } : {}) }, o).then(ack);++ const start = {++ action: 'start', desk: name,++ ...(kind ? { kind } : {}), ...(profile ? { profile } : {}),++ ...(Number.isInteger(width) && Number.isInteger(height) ? { width, height } : {}),++ };++ return {++ name,++ machine: id,++ /** Start it (a no-op when it runs) -> { running, kind?, width?, height?, profile? }. */++ start: (ms) => machines.command(id, { kind: 'desk', value: start, ...(ms ? { ms } : {}) }, o).then(ack),++ /** Stop it and everything on it -> { running: false, stopped, profile_saved? }. */++ stop: () => machines.command(id, { kind: 'desk', value: { action: 'stop', desk: name } }, o).then(ack),++ status: () => machines.command(id, { kind: 'desk', value: { action: 'status', desk: name } }, o).then(ack),++ /** The agent's browser there: { action: open|read|click|fill|press|back|screenshot|close, … }. */++ browser: (value, ms) => on('browser', value, ms),++ /** A picture of the desk -> { format, width, height, content_b64 }. */++ screen: (value = {}, ms) => on('screen', value, ms),++ /** The desk's mouse and keyboard: { action: click|double_click|right_click|move|scroll|type|key, … }. */++ input: (value, ms) => on('input', value, ms),++ /** A program in the desk's session; { detach: true } leaves it running until the desk stops (a whole desk). */++ exec: (value, ms) => on('exec', value, ms),++ /** Whether a person is using it now. */++ turn: () => machines.deskTurn(id, name, o),++ };++ },++ };++return {robots,++ machines,devices: {/** Rotate a paired device's credential -> { device, credential?, …video } (the credential is shown once). */rotate: (id, o = {}) => call({ method: 'POST', path: `/api/v1/devices/${enc(id)}/rotate`, json: {} }, o),@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.65.0",++ "version": "0.66.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.65.0",++ "version": "0.66.0","license": "MIT","bin": {"openvibe-download": "bin/openvibe-download.js"@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.65.0",++ "version": "0.66.0","description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.","main": "index.js","types": "types/index.d.ts",@@@ -15,17 +15,25 @@* and the call is never retried (a code is one-time, so a lost answer cannot be safely repeated).* Every other POST carries an Idempotency-Key (the client generates one when retries are on).*++ * Machines (the OpenVibe Node's computer plugin): `machines.list`, `machines.computer`, `machines.command` (bot.command@1's++ * computer kinds) and `machines.deskTurn`; `machines.desk(id, { name?, kind?, profile? })` is a desk of your own on a++ * machine (the Desks plan: its own screen or browser, files and sign-ins), with start/stop/status and browser, screen,++ * input and exec on it. A desk command that the machine does not do throws an OpenVibeError with code `bot.desk_nack`++ * (the machine's own fault code is `fault`), so a desk reads as plain awaits.++ ** Not wrapped: the operator WebSocket (/control — Realtime frames bot.command@1), device* bootstrap (POST /devices/bind, a Network node token) and the internal Run -> Bot job dispatch* (bot.job.dispatch is service-to-service and is never delegated to people or apps). The signed* `/robots/:id/devices` listing is `robots.devices`; credential rotation and revocation are* `devices.rotate` / `devices.revoke`.*/--const { isOpenVibeError } = require('./core/errors');++const { isOpenVibeError, OpenVibeError } = require('./core/errors');const { isActingSubjectId } = require('./core/ids');const { paginate } = require('./core/paginate');const enc = encodeURIComponent;++const DESK_NAME = /^desk-[a-z0-9][a-z0-9-]{0,23}$/;++const randomDesk = () => `desk-${Math.random().toString(36).slice(2, 12).padEnd(10, '0')}`;/** The header a service uses to say who it acts for: X-OV-Subject. Bot ignores the other X-OV-* ones. */function actingHeaders({ actingSubject } = {}) {@@@ -103,8 +111,64 @@ function createBotClient(client, defaults = {}) {},};++ // ── Machines (the OpenVibe Node's computer plugin) and desks of your own on them ──++ const machines = {++ /** { machines } — the person's machines with their computer capabilities (a service sees the ones opened to it). */++ list: (o = {}) => call({ path: '/api/v1/machines' }, o),++ /** { machine } — one machine's computer capabilities, or null. */++ computer: (id, o = {}) => orNull(call({ path: `/api/v1/machines/${enc(id)}/computer` }, o)),++ /**++ * { id?, kind, value, ms? } -> { machine_id, result, data?, reason? } (bot.command@1's computer kinds: exec, fs,++ * screen, input, browser, probe, desk). A refusal by Bot is an OpenVibeError (bot.computer_off, bot.person_driving …).++ */++ command: (id, input, o = {}) => call({ method: 'POST', path: `/api/v1/machines/${enc(id)}/commands`, json: input }, o),++ /** { desk, person_driving, last_person_at } — whether a person is using that desk now (the person always wins). */++ deskTurn: (id, desk, o = {}) => call({ path: `/api/v1/machines/${enc(id)}/desk-turn`, query: { desk } }, o),++ /**++ * A desk of your own on a machine (the Desks plan): a screen or a browser alone, its own files and sign-ins.++ * const desk = bot.machines.desk(machineId, { kind: 'browser', profile: 'agent' });++ * await desk.start();++ * const page = await desk.browser({ action: 'open', url: 'https://example.org/' });++ * await desk.stop();++ * Each method answers the machine's `data`; a nack throws (code bot.desk_nack, `fault` the machine's own).++ */++ desk(id, { name = randomDesk(), kind, profile, width, height } = {}, o = {}) {++ if (!DESK_NAME.test(name)) throw new TypeError('name is desk-<name>: lower-case letters, digits and dashes, at most 24 after desk-');++ const ack = (r) => {++ if (r && r.result === 'ack') return r.data === undefined ? {} : r.data;++ throw new OpenVibeError({ code: 'bot.desk_nack', status: 409, message: `the machine did not do it: ${(r && (r.reason || r.result)) || 'no answer'}`, detail: r && r.reason });++ };++ const on = (cmdKind, value, ms) => machines.command(id, { kind: cmdKind, value: { ...value, desk: name }, ...(ms ? { ms } : {}) }, o).then(ack);++ const start = {++ action: 'start', desk: name,++ ...(kind ? { kind } : {}), ...(profile ? { profile } : {}),++ ...(Number.isInteger(width) && Number.isInteger(height) ? { width, height } : {}),++ };++ return {++ name,++ machine: id,++ /** Start it (a no-op when it runs) -> { running, kind?, width?, height?, profile? }. */++ start: (ms) => machines.command(id, { kind: 'desk', value: start, ...(ms ? { ms } : {}) }, o).then(ack),++ /** Stop it and everything on it -> { running: false, stopped, profile_saved? }. */++ stop: () => machines.command(id, { kind: 'desk', value: { action: 'stop', desk: name } }, o).then(ack),++ status: () => machines.command(id, { kind: 'desk', value: { action: 'status', desk: name } }, o).then(ack),++ /** The agent's browser there: { action: open|read|click|fill|press|back|screenshot|close, … }. */++ browser: (value, ms) => on('browser', value, ms),++ /** A picture of the desk -> { format, width, height, content_b64 }. */++ screen: (value = {}, ms) => on('screen', value, ms),++ /** The desk's mouse and keyboard: { action: click|double_click|right_click|move|scroll|type|key, … }. */++ input: (value, ms) => on('input', value, ms),++ /** A program in the desk's session; { detach: true } leaves it running until the desk stops (a whole desk). */++ exec: (value, ms) => on('exec', value, ms),++ /** Whether a person is using it now. */++ turn: () => machines.deskTurn(id, name, o),++ };++ },++ };++return {robots,++ machines,devices: {/** Rotate a paired device's credential -> { device, credential?, …video } (the credential is shown once). */rotate: (id, o = {}) => call({ method: 'POST', path: `/api/v1/devices/${enc(id)}/rotate`, json: {} }, o),@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');const semver = require('./semver');/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.65.0';++const SDK_VERSION = '0.66.0';module.exports = {createClient,@@@ -20,6 +20,8 @@ let contracts = null;try { contracts = require('openvibe-contracts'); } catch { /* the fixture case skips */ }const loadFixture = (dir, file) => JSON.parse(fs.readFileSync(path.join(FIX, dir, 'valid', file), 'utf8'));++const seenCommands = [];++/** A stub Bot: fixtures for the contract-shaped answers, plus the routes the SDK turns into calls. */function server() {return stubServer((req, res, body) => {@@@ -63,6 +65,17 @@ function server() {if (m === 'GET' && p === '/api/v1/profiles/adeept.adr036') return send(res, 200, { profile: loadFixture('bot.robot-profile', 'adeept-file.json') });if (m === 'GET' && p === '/api/v1/kits') return send(res, 200, { kits: [{ id: 'starter' }] });if (m === 'GET' && p === '/api/v1/kits/starter') return send(res, 200, { kit: { id: 'starter' } });++ if (m === 'GET' && p === '/api/v1/machines') return send(res, 200, { machines: [{ id: DEV, name: 'desk-host', online: true, computer: { desk: { named: true, kinds: ['browser'] } } }] });++ if (m === 'GET' && p === `/api/v1/machines/${DEV}/computer`) return send(res, 200, { machine: { id: DEV } });++ if (m === 'GET' && p === `/api/v1/machines/${DEV}/desk-turn`) return send(res, 200, { desk: u.searchParams.get('desk'), person_driving: false, last_person_at: null });++ if (m === 'POST' && p === `/api/v1/machines/${DEV}/commands`) {++ const input = JSON.parse(body.toString() || '{}');++ seenCommands.push(input);++ if (input.kind === 'browser' && input.value.action === 'click') return send(res, 200, { machine_id: DEV, result: 'nack', reason: 'nothing on the page matches #missing' });++ if (input.kind === 'desk') return send(res, 200, { machine_id: DEV, result: 'ack', data: { running: input.value.action === 'start', desk: input.value.desk, kind: input.value.kind || 'desktop' } });++ return send(res, 200, { machine_id: DEV, result: 'ack', data: { url: 'https://example.org/', title: 'Example' } });++ }++ if (p.startsWith('/api/v1/machines/')) return problem(res, 404, 'bot.device_not_found', 'no such machine of yours');if (p.startsWith('/api/v1/profiles/')) return problem(res, 404, 'bot.profile_not_found', 'no such profile');if (p.startsWith('/api/v1/kits/')) return problem(res, 404, 'bot.kit_not_found', 'no such kit');if (p.startsWith('/api/v1/robots/')) return problem(res, 404, 'bot.robot_not_found', 'no such robot');@@@ -73,6 +86,27 @@ function server() {const hdr = (r, k) => r.headers[k.toLowerCase()];run([++ ['machines and a desk of your own: start, the browser there, a nack thrown, the person\'s turn, stop', async () => {++ const srv = await server();++ try {++ const bot = createBotClient(createClient({ baseUrls: { bot: srv.url }, token: 'svc' }), { actingSubject: USR });++ assert.strictEqual((await bot.machines.list()).machines[0].id, DEV);++ assert.deepStrictEqual(await bot.machines.computer(DEV), { machine: { id: DEV } });++ assert.strictEqual(await bot.machines.computer('dev_01J8Z4N0A1B2C3D4E5F6G7H8J0'), null, 'an unknown machine is null');++ const desk = bot.machines.desk(DEV, { kind: 'browser', profile: 'agent' });++ assert.match(desk.name, /^desk-[a-z0-9]{10}$/);++ assert.deepStrictEqual(await desk.start(), { running: true, desk: desk.name, kind: 'browser' });++ assert.deepStrictEqual(seenCommands.at(-1), { kind: 'desk', value: { action: 'start', desk: desk.name, kind: 'browser', profile: 'agent' } });++ assert.deepStrictEqual(await desk.browser({ action: 'open', url: 'https://example.org/' }), { url: 'https://example.org/', title: 'Example' });++ assert.deepStrictEqual(seenCommands.at(-1).value, { action: 'open', url: 'https://example.org/', desk: desk.name }, 'on the desk, never the person\'s screen');++ await assert.rejects(() => desk.browser({ action: 'click', selector: '#missing' }), (e) => e.code === 'bot.desk_nack' && /nothing on the page/.test(e.message));++ assert.deepStrictEqual(await desk.turn(), { desk: desk.name, person_driving: false, last_person_at: null });++ await desk.exec({ argv: ['xterm'], detach: true });++ assert.deepStrictEqual(seenCommands.at(-1), { kind: 'exec', value: { argv: ['xterm'], detach: true, desk: desk.name } });++ assert.strictEqual((await desk.stop()).running, false);++ assert.throws(() => bot.machines.desk(DEV, { name: 'private' }), TypeError, 'only a named desk of your own');++ } finally { await srv.close(); }++ }],['a service acting for a person sends X-OV-Subject, its token and an Idempotency-Key', async () => {const srv = await server();const bot = createBotClient(createClient({ baseUrls: { bot: srv.url }, token: 'svc-token' }), { actingSubject: USR });@@@ -81,8 +81,31 @@ export interface PairInput {capabilities?: Record<string, unknown>;name?: string;}++export interface MachineCommandInput { id?: string; kind: 'exec' | 'fs' | 'screen' | 'input' | 'browser' | 'probe' | 'desk'; value: Record<string, unknown>; ms?: number; }++export interface MachineCommandResult { machine_id?: string; id?: string; result: 'ack' | 'nack' | 'expired' | 'refused' | string; data?: Record<string, unknown>; code?: string; reason?: string | null; [field: string]: unknown; }++export interface DeskOptions { name?: string; kind?: 'browser' | 'desktop'; profile?: string; width?: number; height?: number; }++export interface Desk {++ readonly name: string;++ readonly machine: string;++ start(ms?: number): Promise<Record<string, unknown>>;++ stop(): Promise<Record<string, unknown>>;++ status(): Promise<Record<string, unknown>>;++ browser(value: Record<string, unknown>, ms?: number): Promise<Record<string, unknown>>;++ screen(value?: Record<string, unknown>, ms?: number): Promise<Record<string, unknown>>;++ input(value: Record<string, unknown>, ms?: number): Promise<Record<string, unknown>>;++ exec(value: Record<string, unknown>, ms?: number): Promise<Record<string, unknown>>;++ turn(): Promise<{ desk: string; person_driving: boolean; last_person_at: string | null }>;++}++export interface MachinesApi {++ list(opts?: CallOptions): Promise<{ machines: Array<Record<string, unknown>> }>;++ computer(id: string, opts?: CallOptions): Promise<{ machine: Record<string, unknown> } | null>;++ command(id: string, input: MachineCommandInput, opts?: CallOptions): Promise<MachineCommandResult>;++ deskTurn(id: string, desk: string, opts?: CallOptions): Promise<{ desk: string; person_driving: boolean; last_person_at: string | null }>;++ desk(id: string, options?: DeskOptions, opts?: CallOptions): Desk;++}export interface BotClient {robots: RobotsApi;++ machines: MachinesApi;devices: DevicesApi;profiles: ProfilesApi;kits: KitsApi;
@@ -3,6 +3,19 @@
@@ -3,6 +3,19 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
release may change an API and says so here.
+## 0.66.0 (2026-10-27)
+
+- **`openvibe-sdk/bot`: machines and desks** (the Desks plan; the roadmap's SDK section). `bot.machines.list()`,
+ `.computer(id)`, `.command(id, { kind, value })` (bot.command@1's computer kinds) and `.deskTurn(id, desk)`.
+- **`bot.machines.desk(id, { name?, kind?, profile? })`** is a desk of your own on a machine: a browser alone or a whole
+ screen, with its own files and sign-ins (OpenVibe.Node v0.19.0 to v0.26.0).
+ - `start`, `stop` and `status`.
+ - `browser`, `screen`, `input` and `exec` on that desk only, never on the person's own screen. `exec` takes
+ `detach` for an app that keeps running until the desk stops.
+ - `turn()` says whether a person is using the desk now.
+ - Each method answers the machine's `data`. A command the machine does not do throws an `OpenVibeError` with code
+ `bot.desk_nack`.
+
## 0.65.0 (2026-10-26)
## 0.65.0 (2026-10-26)
- **`openvibe-sdk/sso`: switch account and use another account** (the owner, 2026-10-26: sign-in "like the Google
- **`openvibe-sdk/sso`: switch account and use another account** (the owner, 2026-10-26: sign-in "like the Google
@@ -2,14 +2,14 @@
@@ -2,14 +2,14 @@
> Supported browser and server clients for the OpenVibe platform.
> Supported browser and server clients for the OpenVibe platform.
-**Status:** alpha, v0.65.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
+**Status:** alpha, v0.66.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
```bash
```bash
-npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0
+npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.66.0
```
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.65.0",
+ "version": "0.66.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.65.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.66.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, themes, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, registry, modules, realtime, media, community, bot, search, themes, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -43,7 +43,7 @@ const { paginate, offsetPager } = __load(7);
@@ -43,7 +43,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.65.0';
+const SDK_VERSION = '0.66.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -1774,17 +1774,25 @@ function (module, exports) {
@@ -1774,17 +1774,25 @@ function (module, exports) {
* and the call is never retried (a code is one-time, so a lost answer cannot be safely repeated).
* and the call is never retried (a code is one-time, so a lost answer cannot be safely repeated).
* Every other POST carries an Idempotency-Key (the client generates one when retries are on).
* Every other POST carries an Idempotency-Key (the client generates one when retries are on).
*
*
+ * Machines (the OpenVibe Node's computer plugin): `machines.list`, `machines.computer`, `machines.command` (bot.command@1's
+ * computer kinds) and `machines.deskTurn`; `machines.desk(id, { name?, kind?, profile? })` is a desk of your own on a
+ * machine (the Desks plan: its own screen or browser, files and sign-ins), with start/stop/status and browser, screen,
+ * input and exec on it. A desk command that the machine does not do throws an OpenVibeError with code `bot.desk_nack`
+ * (the machine's own fault code is `fault`), so a desk reads as plain awaits.
+ *
* Not wrapped: the operator WebSocket (/control — Realtime frames bot.command@1), device
* Not wrapped: the operator WebSocket (/control — Realtime frames bot.command@1), device
* bootstrap (POST /devices/bind, a Network node token) and the internal Run -> Bot job dispatch
* bootstrap (POST /devices/bind, a Network node token) and the internal Run -> Bot job dispatch
* (bot.job.dispatch is service-to-service and is never delegated to people or apps). The signed
* (bot.job.dispatch is service-to-service and is never delegated to people or apps). The signed
* `/robots/:id/devices` listing is `robots.devices`; credential rotation and revocation are
* `/robots/:id/devices` listing is `robots.devices`; credential rotation and revocation are
* `devices.rotate` / `devices.revoke`.
* `devices.rotate` / `devices.revoke`.
*/
*/
-const { isOpenVibeError } = __load(3);
+const { isOpenVibeError, OpenVibeError } = __load(3);
const { isActingSubjectId } = __load(5);
const { isActingSubjectId } = __load(5);
const { paginate } = __load(7);
const { paginate } = __load(7);
const enc = encodeURIComponent;
const enc = encodeURIComponent;
+const DESK_NAME = /^desk-[a-z0-9][a-z0-9-]{0,23}$/;
+const randomDesk = () => `desk-${Math.random().toString(36).slice(2, 12).padEnd(10, '0')}`;
/** The header a service uses to say who it acts for: X-OV-Subject. Bot ignores the other X-OV-* ones. */
/** The header a service uses to say who it acts for: X-OV-Subject. Bot ignores the other X-OV-* ones. */
function actingHeaders({ actingSubject } = {}) {
function actingHeaders({ actingSubject } = {}) {
@@ -1862,8 +1870,64 @@ function createBotClient(client, defaults = {}) {
@@ -1862,8 +1870,64 @@ function createBotClient(client, defaults = {}) {
},
},
};
};
+ // ── Machines (the OpenVibe Node's computer plugin) and desks of your own on them ──
+ const machines = {
+ /** { machines } — the person's machines with their computer capabilities (a service sees the ones opened to it). */
+ list: (o = {}) => call({ path: '/api/v1/machines' }, o),
+ /** { machine } — one machine's computer capabilities, or null. */
+ computer: (id, o = {}) => orNull(call({ path: `/api/v1/machines/${enc(id)}/computer` }, o)),
+ /**
+ * { id?, kind, value, ms? } -> { machine_id, result, data?, reason? } (bot.command@1's computer kinds: exec, fs,
+ * screen, input, browser, probe, desk). A refusal by Bot is an OpenVibeError (bot.computer_off, bot.person_driving …).
+ */
+ command: (id, input, o = {}) => call({ method: 'POST', path: `/api/v1/machines/${enc(id)}/commands`, json: input }, o),
+ /** { desk, person_driving, last_person_at } — whether a person is using that desk now (the person always wins). */
+ deskTurn: (id, desk, o = {}) => call({ path: `/api/v1/machines/${enc(id)}/desk-turn`, query: { desk } }, o),
+ /**
+ * A desk of your own on a machine (the Desks plan): a screen or a browser alone, its own files and sign-ins.
+ * const desk = bot.machines.desk(machineId, { kind: 'browser', profile: 'agent' });
+ * await desk.start();
+ * const page = await desk.browser({ action: 'open', url: 'https://example.org/' });
+ * await desk.stop();
+ * Each method answers the machine's `data`; a nack throws (code bot.desk_nack, `fault` the machine's own).
+ */
+ desk(id, { name = randomDesk(), kind, profile, width, height } = {}, o = {}) {
+ if (!DESK_NAME.test(name)) throw new TypeError('name is desk-<name>: lower-case letters, digits and dashes, at most 24 after desk-');
+ const ack = (r) => {
+ if (r && r.result === 'ack') return r.data === undefined ? {} : r.data;
+ throw new OpenVibeError({ code: 'bot.desk_nack', status: 409, message: `the machine did not do it: ${(r && (r.reason || r.result)) || 'no answer'}`, detail: r && r.reason });
+ };
+ const on = (cmdKind, value, ms) => machines.command(id, { kind: cmdKind, value: { ...value, desk: name }, ...(ms ? { ms } : {}) }, o).then(ack);
+ const start = {
+ action: 'start', desk: name,
+ ...(kind ? { kind } : {}), ...(profile ? { profile } : {}),
+ ...(Number.isInteger(width) && Number.isInteger(height) ? { width, height } : {}),
+ };
+ return {
+ name,
+ machine: id,
+ /** Start it (a no-op when it runs) -> { running, kind?, width?, height?, profile? }. */
+ start: (ms) => machines.command(id, { kind: 'desk', value: start, ...(ms ? { ms } : {}) }, o).then(ack),
+ /** Stop it and everything on it -> { running: false, stopped, profile_saved? }. */
+ stop: () => machines.command(id, { kind: 'desk', value: { action: 'stop', desk: name } }, o).then(ack),
+ status: () => machines.command(id, { kind: 'desk', value: { action: 'status', desk: name } }, o).then(ack),
+ /** The agent's browser there: { action: open|read|click|fill|press|back|screenshot|close, … }. */
+ browser: (value, ms) => on('browser', value, ms),
+ /** A picture of the desk -> { format, width, height, content_b64 }. */
+ screen: (value = {}, ms) => on('screen', value, ms),
+ /** The desk's mouse and keyboard: { action: click|double_click|right_click|move|scroll|type|key, … }. */
+ input: (value, ms) => on('input', value, ms),
+ /** A program in the desk's session; { detach: true } leaves it running until the desk stops (a whole desk). */
+ exec: (value, ms) => on('exec', value, ms),
+ /** Whether a person is using it now. */
+ turn: () => machines.deskTurn(id, name, o),
+ };
+ },
+ };
+
return {
return {
robots,
robots,
+ machines,
devices: {
devices: {
/** Rotate a paired device's credential -> { device, credential?, …video } (the credential is shown once). */
/** Rotate a paired device's credential -> { device, credential?, …video } (the credential is shown once). */
rotate: (id, o = {}) => call({ method: 'POST', path: `/api/v1/devices/${enc(id)}/rotate`, json: {} }, o),
rotate: (id, o = {}) => call({ method: 'POST', path: `/api/v1/devices/${enc(id)}/rotate`, json: {} }, o),
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.65.0",
+ "version": "0.66.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.65.0",
+ "version": "0.66.0",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.65.0",
+ "version": "0.66.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -15,17 +15,25 @@
@@ -15,17 +15,25 @@
* and the call is never retried (a code is one-time, so a lost answer cannot be safely repeated).
* and the call is never retried (a code is one-time, so a lost answer cannot be safely repeated).
* Every other POST carries an Idempotency-Key (the client generates one when retries are on).
* Every other POST carries an Idempotency-Key (the client generates one when retries are on).
*
*
+ * Machines (the OpenVibe Node's computer plugin): `machines.list`, `machines.computer`, `machines.command` (bot.command@1's
+ * computer kinds) and `machines.deskTurn`; `machines.desk(id, { name?, kind?, profile? })` is a desk of your own on a
+ * machine (the Desks plan: its own screen or browser, files and sign-ins), with start/stop/status and browser, screen,
+ * input and exec on it. A desk command that the machine does not do throws an OpenVibeError with code `bot.desk_nack`
+ * (the machine's own fault code is `fault`), so a desk reads as plain awaits.
+ *
* Not wrapped: the operator WebSocket (/control — Realtime frames bot.command@1), device
* Not wrapped: the operator WebSocket (/control — Realtime frames bot.command@1), device
* bootstrap (POST /devices/bind, a Network node token) and the internal Run -> Bot job dispatch
* bootstrap (POST /devices/bind, a Network node token) and the internal Run -> Bot job dispatch
* (bot.job.dispatch is service-to-service and is never delegated to people or apps). The signed
* (bot.job.dispatch is service-to-service and is never delegated to people or apps). The signed
* `/robots/:id/devices` listing is `robots.devices`; credential rotation and revocation are
* `/robots/:id/devices` listing is `robots.devices`; credential rotation and revocation are
* `devices.rotate` / `devices.revoke`.
* `devices.rotate` / `devices.revoke`.
*/
*/
-const { isOpenVibeError } = require('./core/errors');
+const { isOpenVibeError, OpenVibeError } = require('./core/errors');
const { isActingSubjectId } = require('./core/ids');
const { isActingSubjectId } = require('./core/ids');
const { paginate } = require('./core/paginate');
const { paginate } = require('./core/paginate');
const enc = encodeURIComponent;
const enc = encodeURIComponent;
+const DESK_NAME = /^desk-[a-z0-9][a-z0-9-]{0,23}$/;
+const randomDesk = () => `desk-${Math.random().toString(36).slice(2, 12).padEnd(10, '0')}`;
/** The header a service uses to say who it acts for: X-OV-Subject. Bot ignores the other X-OV-* ones. */
/** The header a service uses to say who it acts for: X-OV-Subject. Bot ignores the other X-OV-* ones. */
function actingHeaders({ actingSubject } = {}) {
function actingHeaders({ actingSubject } = {}) {
@@ -103,8 +111,64 @@ function createBotClient(client, defaults = {}) {
@@ -103,8 +111,64 @@ function createBotClient(client, defaults = {}) {
},
},
};
};
+ // ── Machines (the OpenVibe Node's computer plugin) and desks of your own on them ──
+ const machines = {
+ /** { machines } — the person's machines with their computer capabilities (a service sees the ones opened to it). */
+ list: (o = {}) => call({ path: '/api/v1/machines' }, o),
+ /** { machine } — one machine's computer capabilities, or null. */
+ computer: (id, o = {}) => orNull(call({ path: `/api/v1/machines/${enc(id)}/computer` }, o)),
+ /**
+ * { id?, kind, value, ms? } -> { machine_id, result, data?, reason? } (bot.command@1's computer kinds: exec, fs,
+ * screen, input, browser, probe, desk). A refusal by Bot is an OpenVibeError (bot.computer_off, bot.person_driving …).
+ */
+ command: (id, input, o = {}) => call({ method: 'POST', path: `/api/v1/machines/${enc(id)}/commands`, json: input }, o),
+ /** { desk, person_driving, last_person_at } — whether a person is using that desk now (the person always wins). */
+ deskTurn: (id, desk, o = {}) => call({ path: `/api/v1/machines/${enc(id)}/desk-turn`, query: { desk } }, o),
+ /**
+ * A desk of your own on a machine (the Desks plan): a screen or a browser alone, its own files and sign-ins.
+ * const desk = bot.machines.desk(machineId, { kind: 'browser', profile: 'agent' });
+ * await desk.start();
+ * const page = await desk.browser({ action: 'open', url: 'https://example.org/' });
+ * await desk.stop();
+ * Each method answers the machine's `data`; a nack throws (code bot.desk_nack, `fault` the machine's own).
+ */
+ desk(id, { name = randomDesk(), kind, profile, width, height } = {}, o = {}) {
+ if (!DESK_NAME.test(name)) throw new TypeError('name is desk-<name>: lower-case letters, digits and dashes, at most 24 after desk-');
+ const ack = (r) => {
+ if (r && r.result === 'ack') return r.data === undefined ? {} : r.data;
+ throw new OpenVibeError({ code: 'bot.desk_nack', status: 409, message: `the machine did not do it: ${(r && (r.reason || r.result)) || 'no answer'}`, detail: r && r.reason });
+ };
+ const on = (cmdKind, value, ms) => machines.command(id, { kind: cmdKind, value: { ...value, desk: name }, ...(ms ? { ms } : {}) }, o).then(ack);
+ const start = {
+ action: 'start', desk: name,
+ ...(kind ? { kind } : {}), ...(profile ? { profile } : {}),
+ ...(Number.isInteger(width) && Number.isInteger(height) ? { width, height } : {}),
+ };
+ return {
+ name,
+ machine: id,
+ /** Start it (a no-op when it runs) -> { running, kind?, width?, height?, profile? }. */
+ start: (ms) => machines.command(id, { kind: 'desk', value: start, ...(ms ? { ms } : {}) }, o).then(ack),
+ /** Stop it and everything on it -> { running: false, stopped, profile_saved? }. */
+ stop: () => machines.command(id, { kind: 'desk', value: { action: 'stop', desk: name } }, o).then(ack),
+ status: () => machines.command(id, { kind: 'desk', value: { action: 'status', desk: name } }, o).then(ack),
+ /** The agent's browser there: { action: open|read|click|fill|press|back|screenshot|close, … }. */
+ browser: (value, ms) => on('browser', value, ms),
+ /** A picture of the desk -> { format, width, height, content_b64 }. */
+ screen: (value = {}, ms) => on('screen', value, ms),
+ /** The desk's mouse and keyboard: { action: click|double_click|right_click|move|scroll|type|key, … }. */
+ input: (value, ms) => on('input', value, ms),
+ /** A program in the desk's session; { detach: true } leaves it running until the desk stops (a whole desk). */
+ exec: (value, ms) => on('exec', value, ms),
+ /** Whether a person is using it now. */
+ turn: () => machines.deskTurn(id, name, o),
+ };
+ },
+ };
+
return {
return {
robots,
robots,
+ machines,
devices: {
devices: {
/** Rotate a paired device's credential -> { device, credential?, …video } (the credential is shown once). */
/** Rotate a paired device's credential -> { device, credential?, …video } (the credential is shown once). */
rotate: (id, o = {}) => call({ method: 'POST', path: `/api/v1/devices/${enc(id)}/rotate`, json: {} }, o),
rotate: (id, o = {}) => call({ method: 'POST', path: `/api/v1/devices/${enc(id)}/rotate`, json: {} }, o),
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.65.0';
+const SDK_VERSION = '0.66.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -20,6 +20,8 @@ let contracts = null;
@@ -20,6 +20,8 @@ let contracts = null;
try { contracts = require('openvibe-contracts'); } catch { /* the fixture case skips */ }
try { contracts = require('openvibe-contracts'); } catch { /* the fixture case skips */ }
const loadFixture = (dir, file) => JSON.parse(fs.readFileSync(path.join(FIX, dir, 'valid', file), 'utf8'));
const loadFixture = (dir, file) => JSON.parse(fs.readFileSync(path.join(FIX, dir, 'valid', file), 'utf8'));
+const seenCommands = [];
+
/** A stub Bot: fixtures for the contract-shaped answers, plus the routes the SDK turns into calls. */
/** A stub Bot: fixtures for the contract-shaped answers, plus the routes the SDK turns into calls. */
function server() {
function server() {
return stubServer((req, res, body) => {
return stubServer((req, res, body) => {
@@ -63,6 +65,17 @@ function server() {
@@ -63,6 +65,17 @@ function server() {
if (m === 'GET' && p === '/api/v1/profiles/adeept.adr036') return send(res, 200, { profile: loadFixture('bot.robot-profile', 'adeept-file.json') });
if (m === 'GET' && p === '/api/v1/profiles/adeept.adr036') return send(res, 200, { profile: loadFixture('bot.robot-profile', 'adeept-file.json') });
if (m === 'GET' && p === '/api/v1/kits') return send(res, 200, { kits: [{ id: 'starter' }] });
if (m === 'GET' && p === '/api/v1/kits') return send(res, 200, { kits: [{ id: 'starter' }] });
if (m === 'GET' && p === '/api/v1/kits/starter') return send(res, 200, { kit: { id: 'starter' } });
if (m === 'GET' && p === '/api/v1/kits/starter') return send(res, 200, { kit: { id: 'starter' } });
+ if (m === 'GET' && p === '/api/v1/machines') return send(res, 200, { machines: [{ id: DEV, name: 'desk-host', online: true, computer: { desk: { named: true, kinds: ['browser'] } } }] });
+ if (m === 'GET' && p === `/api/v1/machines/${DEV}/computer`) return send(res, 200, { machine: { id: DEV } });
+ if (m === 'GET' && p === `/api/v1/machines/${DEV}/desk-turn`) return send(res, 200, { desk: u.searchParams.get('desk'), person_driving: false, last_person_at: null });
+ if (m === 'POST' && p === `/api/v1/machines/${DEV}/commands`) {
+ const input = JSON.parse(body.toString() || '{}');
+ seenCommands.push(input);
+ if (input.kind === 'browser' && input.value.action === 'click') return send(res, 200, { machine_id: DEV, result: 'nack', reason: 'nothing on the page matches #missing' });
+ if (input.kind === 'desk') return send(res, 200, { machine_id: DEV, result: 'ack', data: { running: input.value.action === 'start', desk: input.value.desk, kind: input.value.kind || 'desktop' } });
+ return send(res, 200, { machine_id: DEV, result: 'ack', data: { url: 'https://example.org/', title: 'Example' } });
+ }
+ if (p.startsWith('/api/v1/machines/')) return problem(res, 404, 'bot.device_not_found', 'no such machine of yours');
if (p.startsWith('/api/v1/profiles/')) return problem(res, 404, 'bot.profile_not_found', 'no such profile');
if (p.startsWith('/api/v1/profiles/')) return problem(res, 404, 'bot.profile_not_found', 'no such profile');
if (p.startsWith('/api/v1/kits/')) return problem(res, 404, 'bot.kit_not_found', 'no such kit');
if (p.startsWith('/api/v1/kits/')) return problem(res, 404, 'bot.kit_not_found', 'no such kit');
if (p.startsWith('/api/v1/robots/')) return problem(res, 404, 'bot.robot_not_found', 'no such robot');
if (p.startsWith('/api/v1/robots/')) return problem(res, 404, 'bot.robot_not_found', 'no such robot');
@@ -73,6 +86,27 @@ function server() {
@@ -73,6 +86,27 @@ function server() {
const hdr = (r, k) => r.headers[k.toLowerCase()];
const hdr = (r, k) => r.headers[k.toLowerCase()];
run([
run([
+ ['machines and a desk of your own: start, the browser there, a nack thrown, the person\'s turn, stop', async () => {
+ const srv = await server();
+ try {
+ const bot = createBotClient(createClient({ baseUrls: { bot: srv.url }, token: 'svc' }), { actingSubject: USR });
+ assert.strictEqual((await bot.machines.list()).machines[0].id, DEV);
+ assert.deepStrictEqual(await bot.machines.computer(DEV), { machine: { id: DEV } });
+ assert.strictEqual(await bot.machines.computer('dev_01J8Z4N0A1B2C3D4E5F6G7H8J0'), null, 'an unknown machine is null');
+ const desk = bot.machines.desk(DEV, { kind: 'browser', profile: 'agent' });
+ assert.match(desk.name, /^desk-[a-z0-9]{10}$/);
+ assert.deepStrictEqual(await desk.start(), { running: true, desk: desk.name, kind: 'browser' });
+ assert.deepStrictEqual(seenCommands.at(-1), { kind: 'desk', value: { action: 'start', desk: desk.name, kind: 'browser', profile: 'agent' } });
+ assert.deepStrictEqual(await desk.browser({ action: 'open', url: 'https://example.org/' }), { url: 'https://example.org/', title: 'Example' });
+ assert.deepStrictEqual(seenCommands.at(-1).value, { action: 'open', url: 'https://example.org/', desk: desk.name }, 'on the desk, never the person\'s screen');
+ await assert.rejects(() => desk.browser({ action: 'click', selector: '#missing' }), (e) => e.code === 'bot.desk_nack' && /nothing on the page/.test(e.message));
+ assert.deepStrictEqual(await desk.turn(), { desk: desk.name, person_driving: false, last_person_at: null });
+ await desk.exec({ argv: ['xterm'], detach: true });
+ assert.deepStrictEqual(seenCommands.at(-1), { kind: 'exec', value: { argv: ['xterm'], detach: true, desk: desk.name } });
+ assert.strictEqual((await desk.stop()).running, false);
+ assert.throws(() => bot.machines.desk(DEV, { name: 'private' }), TypeError, 'only a named desk of your own');
+ } finally { await srv.close(); }
+ }],
['a service acting for a person sends X-OV-Subject, its token and an Idempotency-Key', async () => {
['a service acting for a person sends X-OV-Subject, its token and an Idempotency-Key', async () => {
const srv = await server();
const srv = await server();
const bot = createBotClient(createClient({ baseUrls: { bot: srv.url }, token: 'svc-token' }), { actingSubject: USR });
const bot = createBotClient(createClient({ baseUrls: { bot: srv.url }, token: 'svc-token' }), { actingSubject: USR });
@@ -81,8 +81,31 @@ export interface PairInput {
@@ -81,8 +81,31 @@ export interface PairInput {
capabilities?: Record<string, unknown>;
capabilities?: Record<string, unknown>;
name?: string;
name?: string;
}
}
+export interface MachineCommandInput { id?: string; kind: 'exec' | 'fs' | 'screen' | 'input' | 'browser' | 'probe' | 'desk'; value: Record<string, unknown>; ms?: number; }
+export interface MachineCommandResult { machine_id?: string; id?: string; result: 'ack' | 'nack' | 'expired' | 'refused' | string; data?: Record<string, unknown>; code?: string; reason?: string | null; [field: string]: unknown; }
+export interface DeskOptions { name?: string; kind?: 'browser' | 'desktop'; profile?: string; width?: number; height?: number; }
+export interface Desk {
+ readonly name: string;
+ readonly machine: string;
+ start(ms?: number): Promise<Record<string, unknown>>;
+ stop(): Promise<Record<string, unknown>>;
+ status(): Promise<Record<string, unknown>>;
+ browser(value: Record<string, unknown>, ms?: number): Promise<Record<string, unknown>>;
+ screen(value?: Record<string, unknown>, ms?: number): Promise<Record<string, unknown>>;
+ input(value: Record<string, unknown>, ms?: number): Promise<Record<string, unknown>>;
+ exec(value: Record<string, unknown>, ms?: number): Promise<Record<string, unknown>>;
+ turn(): Promise<{ desk: string; person_driving: boolean; last_person_at: string | null }>;
+}
+export interface MachinesApi {
+ list(opts?: CallOptions): Promise<{ machines: Array<Record<string, unknown>> }>;
+ computer(id: string, opts?: CallOptions): Promise<{ machine: Record<string, unknown> } | null>;
+ command(id: string, input: MachineCommandInput, opts?: CallOptions): Promise<MachineCommandResult>;
+ deskTurn(id: string, desk: string, opts?: CallOptions): Promise<{ desk: string; person_driving: boolean; last_person_at: string | null }>;
+ desk(id: string, options?: DeskOptions, opts?: CallOptions): Desk;
+}
export interface BotClient {
export interface BotClient {
robots: RobotsApi;
robots: RobotsApi;
+ machines: MachinesApi;
devices: DevicesApi;
devices: DevicesApi;
profiles: ProfilesApi;
profiles: ProfilesApi;
kits: KitsApi;
kits: KitsApi;