openvibers/openvibe.actor Commits
One Computer: the task's step, Take over / Give it back, and the still (#320)
Changes
@@@ -2,6 +2,21 @@What changed in OpenVibe.Actor, newest first. Each site also publishes its patch notes at /updates.++## Unreleased — one Computer++++- **One Computer** (plan 2026-11-07: "Surf, Computer and Actor are one experience"): when a task opens a desk, its++ own step carries `computer` (`session_id` — OpenVibe.Run's `ses_…`, or null on the person's own machine through++ Bot — `machine`, `where` and `cost_hint`) from the tool's answer, never the model's words. `GET /api/v1/tasks/:id`++ reads the same object off the task's own steps, with `driving` while the person holds the desk, and++ `agent.timeline-entry@1` stores it (migration 0035).++- **Take over / Give it back** are one API on the task: `POST /api/v1/tasks/:id/takeover` and `…/giveback`, built on++ the `hand_to_person` hand-off. While the person drives, `computer_input` and the acting `computer_browser` actions++ are refused with a clear reason; the task's timeline gets one `handoff` step and the Dock's timeline one step with++ the same Computer. Only the task's own person.++- **The still**: `GET /api/v1/tasks/:id/still` asks the session's own service (Run) for the newest frame as Actor's++ service token acting for the person, and answers the bytes with `Cache-Control: private, no-store`. A person's++ task only; another person, a signed-out visitor and an app token all get the same 404 as a missing task.++## 0.3.0 — 2026-11-07- **Space's builder as a task of the person's one agent** (plan 2026-10-22 §Z, stage Z1): six new tools — `space_create`, `space_files_list`, `space_files_read`, `space_files_write`, `space_preview` and `space_publish`. The agent works on the person's own spaces through OpenVibe.Space's builder API (`/api/v1/builder/spaces`), as the person: another person's space is the same 404 as one that does not exist, and every write goes through the same path, limits and audit as Space's own editor.@@@ -260,7 +260,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.321.0++- openvibe-contracts: v0.327.0- openvibe-sdk: v0.94.0- openvibe-shared: v4.135.0<!-- versions:end -->nnew file mode 100644@@@ -0,0 +1,6 @@++-- phase: expand++-- One Computer across Surf, Actor and the Dock (plan 2026-11-07): a step of the one timeline now names the Computer++-- it works on ({ session_id, machine, where, cost_hint }), the same object the task record and the task's own step++-- carry. It holds an id, a word for a machine and a cost phrase: no prompt, page or screen.++ALTER TABLE agent_timeline++ ADD COLUMN IF NOT EXISTS computer jsonb;@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.322.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0","pg": "^8.23.0"@@@ -1146,9 +1146,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.322.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.322.0",-- "integrity": "sha512-dY9e6x1i9+IZlGWjaFemycVsIPRfWsn+Tx0W0yKMszGe5sinMUFjcA34FuIsC8volbSut7ZSVvWHNWSQVBjTTw==",++ "version": "0.327.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",++ "integrity": "sha512-LssZZeTdg9OkPPpry6YSEVhbIv13MjLYOlNxpoZ/0bpIATy0zfJrzfLAaJyUY+UMq5Tqf1FLzx2a/3xcJ7UJDw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -28,7 +28,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.322.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0","pg": "^8.23.0"@@@ -26,6 +26,7 @@const { serviceAuth } = require('openvibe-contracts');const MACHINE_ID = /^dev_[0-9A-HJKMNP-TV-Z]{26}$/;++const SESSION_ID = /^ses_[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']);@@@ -90,7 +91,8 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensOif (!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}` } : {}) };++ // The session carries the task back (contracts 0.327.0): one id across Surf, the Computer and Actor.++ ...(taskId ? { idempotency_key: `actor:${taskId}`, task_id: taskId } : {}) };const made = await runCall('POST', '/sessions', subject, { body, signal });if (!made.ok) return runProblem(made, 'a browser session');let s = made.data;@@@ -113,6 +115,32 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensOreturn r.ok ? { running: false, stopped: true } : runProblem(r, 'ending the session');}++ /**++ * The session's newest frame (plan 2026-11-07): Run's still, as Actor's service token acting for the person++ * (run.session.read, X-OV-Subject). Run itself accepts only the session's person or a capable service, answers++ * the curtain picture while Curtain now is up, and captures at most once a second. → { bytes, type } or { error }.++ */++ async function still(subject, sessionId, { signal } = {}) {++ if (!sessionsOn) return { error: 'no session service is reachable from here' };++ if (!USER_SUBJECT.test(subject) || !SESSION_ID.test(String(sessionId || ''))) return { error: 'not a session of the person\'s' };++ const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(12_000)]) : AbortSignal.timeout(12_000);++ let res;++ try {++ res = await fetchImpl(`${runBase}/api/v1/sessions/${sessionId}/still`, {++ headers: { Accept: 'image/jpeg, image/png', 'X-OV-Subject': subject, ...(await runTokens.authHeaders()) }, signal: sig,++ });++ } catch {++ return { error: 'the session service did not answer' };++ }++ if (!res.ok) return { status: res.status, error: 'the session has no picture' };++ // Only a picture is ever passed on: a broken or hostile upstream cannot serve HTML from openvibe.actor.++ const type = String(res.headers.get('content-type') || '').split(';')[0].trim();++ if (!/^image\/(jpeg|png|webp)$/.test(type)) return { status: 502, code: 'still.bad_type', error: 'the session sent a picture that is not an image' };++ const bytes = Buffer.from(await res.arrayBuffer());++ if (!bytes.length || bytes.length > 4 * 1024 * 1024) return { status: 502, error: 'the session service sent no picture' };++ return { bytes, type };++ }++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}`, {@@@ -404,7 +432,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, sessionsOn, isSession: (m) => SESSION_MACHINE.test(String(m || '')) };++ 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 || '')) };}module.exports = { createComputers };nnew file mode 100644@@@ -0,0 +1,35 @@++'use strict';++++/**++ * Who is driving a task's Computer right now (plan 2026-11-07: Take over / Give it back).++ *++ * The person's take-over is an API call (POST /api/v1/tasks/:id/takeover), not a desk command, so the task's tools++ * cannot see it in Bot: this is the one place that says the person is driving. While a task is here, the agent's++ * input actions are refused with a reason; giveback clears it. In memory only, like looks.js: a restart loses the++ * marker, and Bot's own person-first turn still holds the desk off the agent either way.++ */++const KEEP_MS = 60 * 60 * 1000; // the longest a take-over holds without a giveback++const MAX = 2000;++++const driving = new Map(); // task id → { at }++++function take(taskId, now = Date.now()) {++ if (!taskId) return false;++ driving.delete(taskId);++ driving.set(taskId, { at: now });++ while (driving.size > MAX) driving.delete(driving.keys().next().value);++ return true;++}++++function give(taskId) {++ return driving.delete(taskId);++}++++function is(taskId, now = Date.now()) {++ const d = taskId ? driving.get(taskId) : null;++ if (!d) return false;++ if (now - d.at > KEEP_MS) { driving.delete(taskId); return false; }++ return true;++}++++module.exports = { take, give, is, KEEP_MS };@@@ -22,6 +22,9 @@ const subs = new Map(); // subject → Set<{ res }>: the live update streamconst iso = (v) => (v instanceof Date ? v.toISOString() : (v ? new Date(v).toISOString() : null));const text = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);++// The Computer of a step (plan 2026-11-07): a plain object with session_id, machine, where and cost_hint. Anything++// else — an array, a string, a nested payload — is not stored.++const computer = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);/** The person an event is for: only a user subject is the agent's. */function subjectOf(ev) {@@@ -44,6 +47,7 @@ function entryOf(row) {ended_at: iso(row.ended_at),status: row.status,placement: (row.placement_where || row.placement_why) ? { where: row.placement_where || null, why: row.placement_why || null } : null,++ computer: row.computer == null ? null : (typeof row.computer === 'object' ? row.computer : (() => { try { return JSON.parse(row.computer); } catch { return null; } })()),cost_usd: row.cost_usd === null || row.cost_usd === undefined ? null : Number(row.cost_usd),link: row.link || null,reason: row.reason || null,@@@ -60,21 +64,21 @@ async function upsert(s, subject, payload) {if (!subject || !MODES.has(step.mode) || !STATUSES.has(step.status) || !text(step.step_id, 80)) return null;const now = s.iso();const row = await s.db.one(`INSERT INTO agent_timeline (id, subject, step_id, product, mode, ref, title, status, placement_where, placement_why,-- cost_usd, link, reason, transition, approval_product, approval_id, approval_kind, audience, shared, started_at, ended_at, created_at, updated_at)-- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,'private',false,$18,$19,$20,$20)++ cost_usd, link, reason, transition, approval_product, approval_id, approval_kind, audience, shared, started_at, ended_at, created_at, updated_at, computer)++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,'private',false,$18,$19,$20,$20,$21)ON CONFLICT (subject, product, step_id) DO UPDATE SETmode = EXCLUDED.mode, ref = EXCLUDED.ref, title = EXCLUDED.title, status = EXCLUDED.status,placement_where = EXCLUDED.placement_where, placement_why = EXCLUDED.placement_why, cost_usd = EXCLUDED.cost_usd,link = EXCLUDED.link, reason = EXCLUDED.reason, transition = EXCLUDED.transition,approval_product = EXCLUDED.approval_product, approval_id = EXCLUDED.approval_id, approval_kind = EXCLUDED.approval_kind,-- ended_at = EXCLUDED.ended_at, updated_at = EXCLUDED.updated_at++ ended_at = EXCLUDED.ended_at, updated_at = EXCLUDED.updated_at, computer = EXCLUDED.computerRETURNING *`,[s.newId('tl'), subject, text(step.step_id, 80), text(step.product, 40) || 'agent', step.mode, text(step.ref, 120),text(step.title, 160) || 'A step of the agent', step.status,text(step.placement && step.placement.where, 160), text(step.placement && step.placement.why, 240),num(step.cost_usd), text(step.link, 2048), text(step.reason, 240), text(step.transition, 240),text(step.approval && step.approval.product, 40), text(step.approval && step.approval.id, 80), text(step.approval && step.approval.kind, 40),-- iso(step.started_at) || now, iso(step.ended_at), now]);++ iso(step.started_at) || now, iso(step.ended_at), now, computer(step.computer) ? JSON.stringify(computer(step.computer)) : null]);publish(subject, entryOf(row));return row;}@@@ -146,7 +146,10 @@ async function createApp(opts = {}) {// The one sharing system (design 2026-11-06): an agent's run is its owner's until the Share sheet says otherwise.const sharing = opts.sharing || createSharing({ db: s.db, config, fetchImpl, log });setSharing(sharing);-- const ctx = { config, s, keys, aiCatalogConfig, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits, sharing };++ // The person's computers (agents/computers.js): one adapter for the task's still proxy, made here so a test can++ // stand Bot/Run in (opts.bot.computers) exactly as the Console pages do.++ const computers = (opts.bot && opts.bot.computers) || require('./agents/computers').createComputers({ config, fetchImpl: providerFetch, log });++ const ctx = { config, s, keys, aiCatalogConfig, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits, sharing, computers };// Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;@@@ -40,6 +40,7 @@ const approvals = require('../agents/approvals');const items = require('../actions/items');const timeline = require('../agents/timeline');const modelPrefs = require('../runtime/model-prefs');++const driving = require('../agents/driving');const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;const USER_ID = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -426,9 +427,73 @@ function createApi(ctx) {// The desk it works on now, to watch live and take over (Desks plan).const desk = await store.taskDesk(s, row);if (desk) out.desk = desk;++ // The Computer it opened (plan 2026-11-07: one id across Surf, the Dock and Actor), read from its own steps.++ const computer = await store.taskComputer(s, row);++ if (computer) out.computer = { ...computer, driving: driving.is(row.id, s.now()) };res.json(out);});++ // Take over / Give it back (plan 2026-11-07), built on the hand_to_person machinery: the person drives the desk++ // they were handed, the agent's input is refused while they do, and the task's timeline gets one hand-off step.++ // Only the task's own people (load()), and only while it runs on a Computer.++ async function handoff(req, res, { take }) {++ const row = await load(req, res);++ if (!row) return;++ if (!store.OPEN.includes(row.state)) return problem(req, res, 409, 'actor.task.ended', 'This task has ended: its Computer is gone.');++ const computer = await store.taskComputer(s, row);++ const desk = await store.taskDesk(s, row);++ if (!computer && !desk) return problem(req, res, 409, 'actor.task.no_computer', 'This task has no Computer to hand over.');++ if (!String(row.requester).startsWith('user:')) return problem(req, res, 409, 'actor.task.no_person', 'A Computer is a person\'s: sign in as the task\'s own person to take it.');++ const at = s.iso();++ const now = take ? (driving.take(row.id, s.now()), true) : (driving.give(row.id), false);++ // The step in the task's own timeline (the same events /events streams), carrying the Computer.++ const event = await store.appendEvent(s, row.id, {++ at, kind: 'output', step: 'handoff',++ text: now ? 'You took over the desk. The agent waits until you give it back.' : 'You gave the desk back. The agent goes on.',++ ...(computer ? { computer } : {}),++ });++ stream.publish(row.id, event);++ // And one step in the one timeline behind the Dock (agent.timeline-entry@1), the same Computer.++ try {++ await timeline.upsert(s, row.requester, {++ step_id: `${row.id}:takeover`, mode: 'browse', product: 'actor', ref: row.id,++ title: now ? 'You took over the desk' : 'You gave the desk back',++ status: now ? 'running' : 'done', started_at: now ? at : undefined, ended_at: now ? null : at,++ placement: computer ? { where: computer.machine, why: now ? 'you are driving the desk' : 'the agent goes on' } : null,++ link: `${config.baseUrl}/tasks/${row.id}`, ...(computer ? { computer } : {}),++ });++ } catch (err) { /* the task's own timeline above is the source of truth */ }++ const out = await wireTask(row);++ if (computer) out.computer = { ...computer, driving: Boolean(now) };++ if (desk) out.desk = desk;++ res.json(out);++ }++ r.post('/tasks/:id/takeover', principal.requireCapability('actor.task.create'), async (req, res) => handoff(req, res, { take: true }));++ r.post('/tasks/:id/giveback', principal.requireCapability('actor.task.create'), async (req, res) => handoff(req, res, { take: false }));++++ // The Computer's newest frame, fetched from the session's own service (Run) as Actor, acting for the task's++ // person: the person's own token would have to cross an origin, and the picture is theirs. Owner/project only++ // (load()); a task with no Run session has no picture, and neither has anyone asking for another person's task.++ // A still is its task's person's, and nobody else's: a signed-out visitor and an app token get the same 404 as a++ // task that does not exist (never a 401 that would confirm one).++ const stillPerson = (req, res, next) => ((req.principal && req.principal.kind === 'user') ? next() : problem(req, res, 404, 'actor.task.not_found', 'No such task.'));++ r.get('/tasks/:id/still', stillPerson, principal.requireCapability('actor.task.read'), limits.reads('actor.task.read'), async (req, res) => {++ const row = await load(req, res);++ if (!row) return;++ const computer = await store.taskComputer(s, row);++ if (!computer || !computer.session_id) return problem(req, res, 404, 'actor.task.no_still', 'This task has no Computer picture.');++ if (!String(row.requester).startsWith('user:')) return problem(req, res, 404, 'actor.task.no_still', 'This task has no Computer picture.');++ const shot = await ctx.computers.still(row.requester.slice(5), computer.session_id);++ if (!shot || shot.error) return problem(req, res, shot && shot.status === 404 ? 404 : 502, (shot && shot.code) || 'actor.task.no_still', (shot && shot.error) || 'No picture.');++ // Only a picture: a broken or hostile upstream can never serve HTML from openvibe.actor.++ if (!['image/jpeg', 'image/png', 'image/webp'].includes(shot.type)) return problem(req, res, 502, 'still.bad_type', 'The session sent something that is not an image.');++ res.set('Cache-Control', 'private, no-store');++ res.set('X-Content-Type-Options', 'nosniff');++ res.set('Content-Type', shot.type);++ res.set('Content-Length', String(shot.bytes.length));++ return res.status(200).end(shot.bytes);++ });++// The task's trace (stage V2-10): the numbers and names of its model, tool, check and delegate calls, to its own// people. Another person gets the same 404 as a task that does not exist.r.get('/tasks/:id/trace', principal.requireCapability('actor.task.read'), limits.reads('actor.task.read'), async (req, res) => {@@@ -417,7 +417,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.const shown = displayName(p.name, ctx);const item = state.ledger.add({ tool: shown, input: p.args, text: out.text, data: out.data, sources: out.sources, artifacts: out.artifacts, refs: out.refs, secret: out.secret === true });state.messages.push({ role: 'tool', tool_call_id: p.call.id, content: JSON.stringify({ evidence: item.id, tool: shown, result: clip(out.text, resultChars), ...(out.secret === true ? { secret: true } : {}) }) });-- await ctx.emit({ step: 'tool_result', tool: shown, text: clip(out.text, 600), is_error: out.isError || undefined, ...(out.artifacts && out.artifacts.length ? { artifacts: out.artifacts } : {}) });++ await ctx.emit({ step: 'tool_result', tool: shown, text: clip(out.text, 600), is_error: out.isError || undefined, ...(out.artifacts && out.artifacts.length ? { artifacts: out.artifacts } : {}), ...(out.computer !== undefined ? { computer: out.computer } : {}) });if (out.isError) { errorResults += 1; lastTool = p.name; } else okResults += 1;if (state.brain) {state.brain.toolRuns = (state.brain.toolRuns || 0) + 1;@@@ -782,7 +782,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.const shown = displayName(name, ctx);const item = state.ledger.add({ tool: shown, input: args, text: out.text, data: out.data, sources: out.sources, artifacts: out.artifacts, refs: out.refs, secret: out.secret === true });state.messages.push({ role: 'tool', tool_call_id: id, content: JSON.stringify({ evidence: item.id, tool: shown, result: clip(out.text, resultChars), ...(out.secret === true ? { secret: true } : {}) }) });-- await ctx.emit({ step: 'tool_result', tool: shown, text: clip(out.text, 600), is_error: out.isError || undefined, ...(out.artifacts && out.artifacts.length ? { artifacts: out.artifacts } : {}) });++ await ctx.emit({ step: 'tool_result', tool: shown, text: clip(out.text, 600), is_error: out.isError || undefined, ...(out.artifacts && out.artifacts.length ? { artifacts: out.artifacts } : {}), ...(out.computer !== undefined ? { computer: out.computer } : {}) });await spanCall(ctx.trace, { kind: 'tool', name, startedAt, ms: now() - startedAt, ok: !(out && out.isError), errorCode: out && out.isError ? 'actor.tool.failed' : null, step: state.calls });note(name, !(out && out.isError));};@@@ -20,15 +20,23 @@ const PERSON_QUIET_MS = 20_000;const AGENT_PROFILE = 'agent';function deskRun(taskId = null) {-- return { name: `desk-${crypto.randomBytes(5).toString('hex')}`, taskId, named: null, kinds: new Map(), keeps: new Set(), used: new Set(), placed: null };++ return { name: `desk-${crypto.randomBytes(5).toString('hex')}`, taskId, named: null, kinds: new Map(), keeps: new Set(), used: new Set(), placed: null, names: new Map() };}++/** The person's own word for their machine (their name for it), remembered from list_my_computers. */++function rememberMachines(run, computers) {++ if (!run || !Array.isArray(computers)) return;++ for (const m of computers) if (m && typeof m.id === 'string') run.names.set(m.id, String(m.name || 'your computer').slice(0, 80));++}++const machineWord = (run, machine) => (run && run.names.get(machine)) || 'your computer';++/** The task's own desk for a machine that runs several; else the machine's one private desk. */async function deskFor(run, person, machine, { core, signal }) {if (!run) return 'private';if (run.named === null) {const mine = await core.pcs.mine(person, { signal });const computers = (mine && mine.computers) || [];++ rememberMachines(run, computers);run.named = new Set(computers.filter((m) => m.desks_at_once).map((m) => m.id));run.kinds = new Map(computers.filter((m) => Array.isArray(m.desk_kinds)).map((m) => [m.id, m.desk_kinds]));run.keeps = new Set(computers.filter((m) => m.keeps_sign_ins).map((m) => m.id));@@@ -47,8 +55,9 @@ function deskKind(run, machine, asked) {}/** Where a desk for machine "any" goes: the online machines that run several, desk hosts first, then the most free. */--async function placementOrder(person, { core, signal }) {++async function placementOrder(person, { core, signal, run = null }) {const mine = await core.pcs.mine(person, { signal });++ rememberMachines(run, (mine && mine.computers) || []);const free = (m) => (Number(m.desks_at_once) || 1) - (Number(m.desks_running) || 0);const ownScreen = (m) => ((m.can || []).includes('screen') ? 1 : 0);return ((mine && mine.computers) || [])@@@ -144,5 +153,5 @@ async function prepareDesk(tool, a, person, core, ctx) {module.exports = {personOf, deskRun, deskFor, deskKind, placementOrder, closeDesks, lookAtScreen, handToPerson, prepareDesk, SCREEN_SYSTEM,-- PERSON_WAIT_MS, PERSON_POLL_MS, PERSON_QUIET_MS, AGENT_PROFILE, DESK_TOOLS,++ PERSON_WAIT_MS, PERSON_POLL_MS, PERSON_QUIET_MS, AGENT_PROFILE, DESK_TOOLS, rememberMachines, machineWord,};@@@ -2,6 +2,7 @@const { serviceFor, json, personOf } = require('./_openvibe');const { prepareDesk } = require('./_person');++const driving = require('../../agents/driving');const machine = { type: 'string', description: 'the machine id (dev_…) from list_my_computers' };@@@ -15,6 +16,10 @@ module.exports = {timeoutMs: 60_000,available: (ctx) => Boolean(ctx.config && ctx.config.services && ctx.config.services.botUrl && personOf(ctx.requester)),async run(input, ctx) {++ // While the person drives, the agent may read the page but never act on it (Take over / Give it back).++ if (input && input.action && !['read', 'close'].includes(input.action) && driving.is(ctx.taskId)) {++ return { text: json({ error: 'The person is using the desk right now (they took it over): wait until they give it back, then read the page again before you act.' }), isError: true };++ }const core = serviceFor(ctx);const person = personOf(ctx.requester);if (!core.pcs.enabled || !person) return { text: json({ error: 'computers are only for a signed-in person\'s task' }), isError: true };@@@ -1,7 +1,28 @@'use strict';const { serviceFor, json, personOf } = require('./_openvibe');--const { prepareDesk, deskKind, placementOrder, AGENT_PROFILE } = require('./_person');++const { prepareDesk, deskKind, placementOrder, AGENT_PROFILE, machineWord } = require('./_person');++++/**++ * The Computer this task opened, as platform.task@1 carries it (plan 2026-11-07): the one id every surface reads.++ * A desk on a desk host through an OpenVibe.Run session names that session (ses_…); a desk on the person's own++ * machine has no Run session and says so with session_id null. where is whose it is: yours (their machine), home (a++ * desk host someone offers at home) or network (an OpenVibe desk host).++ */++function computerOf({ run, machine, ses = null }) {++ const session = ses && ses.session ? ses.session : null;++ if (session) {++ const trust = session.placement && session.placement.trust;++ const where = trust === 'user-owned' ? 'yours' : trust === 'community' ? 'home' : 'network';++ return {++ session_id: session.id,++ machine: where === 'yours' ? machineWord(run, session.placement && session.placement.machine) : where === 'home' ? 'a shared desk host' : 'an OpenVibe desk host',++ where,++ cost_hint: where === 'yours' ? 'free on your computer' : 'a desk host, metered by the minute',++ };++ }++ return { session_id: null, machine: machineWord(run, machine), where: 'yours', cost_hint: 'free on your own computer' };++}/*** A task's own desk (Desks plan D1). The model only ever says desk "private"; on a machine that runs several desks@@@ -41,13 +62,14 @@ module.exports = {if (a.machine === 'any') {if (desks && desks.placed) a = { ...a, machine: desks.placed };else if (a.action === 'start') {-- const order = await placementOrder(person, { core, signal: ctx.signal });++ const order = await placementOrder(person, { core, signal: ctx.signal, run: desks });let out = null;for (const id of order.slice(0, 4)) {out = await startDesk({ ...a, machine: id }, person, core, ctx);if (!(out && out.error && /limit|max_desks|already runs/i.test(out.error))) {if (out && !out.error && desks && !desks.placed) desks.placed = id;-- return { text: json(out && !out.error ? { machine: id, ...out } : out), data: out };++ const computer = out && !out.error ? computerOf({ run: desks, machine: id }) : undefined;++ return { text: json(out && !out.error ? { machine: id, ...out } : out), data: out, ...(computer ? { computer } : {}) };}}// None of the person's own machines has room (or none runs desks): a browser desk on a desk host,@@@ -56,7 +78,7 @@ module.exports = {const ses = await core.pcs.openSession(person, { taskId: desks && desks.taskId, width: a.width, height: a.height, signal: ctx.signal });if (!ses.error) {if (desks) desks.placed = ses.machine;-- return { text: 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.' }), data: ses };++ return { text: 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.' }), data: ses, computer: computerOf({ run: desks, machine: ses.machine, ses }) };}if (!order.length) return { text: json({ error: `none of the person's computers runs desks for you now, and ${ses.error}` }), isError: true };}@@@ -71,12 +93,14 @@ module.exports = {if (a.action === 'start') {const out = await startDesk(a, person, core, ctx);if (out && !out.error && desks && !desks.placed) desks.placed = a.machine;-- return { text: json(out && out.running && !(core.pcs.isSession && core.pcs.isSession(a.machine)) ? { watch: core.pcs.watchUrl(a.machine, a.desk), ...out } : out), data: out };++ const computer = out && !out.error ? computerOf({ run: desks, machine: a.machine }) : undefined;++ return { text: json(out && out.running && !(core.pcs.isSession && core.pcs.isSession(a.machine)) ? { watch: core.pcs.watchUrl(a.machine, a.desk), ...out } : out), data: out, ...(computer ? { computer } : {}) };}const out = await core.pcs.desk(person, a.machine, a, { signal: ctx.signal });if (desks && out && !out.error) {if (a.action === 'stop' && desks.placed === a.machine) desks.placed = null;}-- return { text: json(out && out.running && a.action !== 'stop' && !(core.pcs.isSession && core.pcs.isSession(a.machine)) ? { watch: core.pcs.watchUrl(a.machine, a.desk), ...out } : out), data: out };++ // A stop clears the Computer: the task no longer holds one.++ return { text: json(out && out.running && a.action !== 'stop' && !(core.pcs.isSession && core.pcs.isSession(a.machine)) ? { watch: core.pcs.watchUrl(a.machine, a.desk), ...out } : out), data: out, ...(a.action === 'stop' && out && !out.error ? { computer: null } : {}) };},};@@@ -2,6 +2,7 @@const { serviceFor, json, personOf } = require('./_openvibe');const { prepareDesk } = require('./_person');++const driving = require('../../agents/driving');const machine = { type: 'string', description: 'the machine id (dev_…) from list_my_computers' };@@@ -15,6 +16,8 @@ module.exports = {timeoutMs: 40_000,available: (ctx) => Boolean(ctx.config && ctx.config.services && ctx.config.services.botUrl && personOf(ctx.requester)),async run(input, ctx) {++ // The person's take-over beats everything: no input reaches the desk while they drive it.++ if (driving.is(ctx.taskId)) return { text: json({ error: 'The person is using the desk right now (they took it over): wait until they give it back, then look again before you act.' }), isError: true };const core = serviceFor(ctx);const person = personOf(ctx.requester);if (!core.pcs.enabled || !person) return { text: json({ error: 'computers are only for a signed-in person\'s task' }), isError: true };@@@ -152,6 +152,27 @@ async function taskDesk(s, row) {return deskFrom(rows.map((r) => parse(r.body)).reverse());}++/**++ * The Computer a task opened (plan 2026-11-07: one id), read from its own computer_desk results: the last start that++ * carried one, cleared by a stop. The event's `computer` is set by the loop from the tool's own answer, never by the++ * model. → { session_id, machine, where, cost_hint } or null.++ */++function computerFrom(events) {++ let computer = null;++ for (const e of events) {++ if (!e || e.step !== 'tool_result' || e.tool !== 'computer_desk' || e.is_error) continue;++ if (e.computer === null) computer = null;++ else if (e.computer && typeof e.computer === 'object' && !Array.isArray(e.computer)) computer = e.computer;++ }++ return computer;++}++++async function taskComputer(s, row) {++ if (!row || !OPEN.includes(row.state)) return null;++ const rows = await s.db.many('SELECT body FROM task_events WHERE task_id = $1 ORDER BY seq DESC LIMIT 200', [row.id]);++ return computerFrom(rows.map((r) => parse(r.body)).reverse());++}++async function eventsAfter(s, taskId, after = 0) {const rows = await s.db.many('SELECT body FROM task_events WHERE task_id = $1 AND seq > $2 ORDER BY seq', [taskId, after]);return rows.map((r) => parse(r.body));@@@ -266,4 +287,4 @@ function toWire(row, { baseUrl }) {return t;}--module.exports = { insertTask, getTask, byIdempotency, listTasks, updateTask, appendEvent, eventsAfter, lastEvent, needsYou, deskFrom, deskPlace, taskDesk, addSpend, spendToday, spendDays, lockSpendToday, failInterrupted, openTasks, prune, toWire, subjectRef, dayOf, parseJson: parse, OPEN, RETENTION_DAYS, followContext, followUps, forks, recentSucceeded };++module.exports = { insertTask, getTask, byIdempotency, listTasks, updateTask, appendEvent, eventsAfter, lastEvent, needsYou, deskFrom, deskPlace, taskDesk, computerFrom, taskComputer, addSpend, spendToday, spendDays, lockSpendToday, failInterrupted, openTasks, prune, toWire, subjectRef, dayOf, parseJson: parse, OPEN, RETENTION_DAYS, followContext, followUps, forks, recentSucceeded };nnew file mode 100644@@@ -0,0 +1,121 @@++'use strict';++/**++ * One Computer across Surf, the Dock and Actor (plan 2026-11-07): the task's record and its step carry the same++ * computer { session_id, machine, where, cost_hint }; the still is the task person's alone (another person and a++ * signed-out visitor get 404); Take over marks the person as driving and the agent's input is refused with a reason;++ * Give it back hands the desk over again. The session's service is a stand-in here, exactly as Bot/Run are in the++ * other suites.++ * node test/computer-handoff.test.js++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const store = require('../server/tasks/store');++const driving = require('../server/agents/driving');++const timeline = require('../server/agents/timeline');++const computerInput = require('../server/runtime/tools/computer_input');++const computerBrowser = require('../server/runtime/tools/computer_browser');++++const SAME = { 'sec-fetch-site': 'same-origin', accept: 'application/json' };++const SESSION = 'ses_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const COMPUTER = { session_id: SESSION, machine: 'a shared desk host', where: 'network', cost_hint: 'a desk host, metered by the minute' };++++(async () => {++ const still = Buffer.from('/9j/4AAQ', 'base64');++ const asked = [];++ let stillType = 'image/jpeg';++ const computers = {++ enabled: true,++ async still(subject, sessionId) { asked.push([subject, sessionId]); return { bytes: still, type: stillType }; },++ };++ const t = await boot({ bot: { computers } });++ const kim = t.network.addUser('kim');++ const bo = t.network.addUser('bo');++ const s = t.ctx.s;++ const valid = (task) => { const v = contracts.validate('platform.task@1', task); assert.ok(v.valid, JSON.stringify(v.errors)); };++++ async function runningTask(text) {++ const id = (await t.get('/api/v1/tasks', { as: kim, json: { task: text }, headers: SAME })).json().id;++ await t.waitFor(id);++ await s.db.query("UPDATE tasks SET state = 'running' WHERE id = $1", [id]);++ return id;++ }++++ try {++ const id = await runningTask('Compare three hosting plans under $10 a month and tell me which one to pick.');++ await store.appendEvent(s, id, {++ at: new Date().toISOString(), kind: 'output', step: 'tool_result', tool: 'computer_desk', is_error: false,++ text: JSON.stringify({ watch: 'https://openvibe.bot/machines/dev_01J8Z4M2Q0R7T9YV3K6N8P1W2A/screen?desk=desk-trains', running: true, kind: 'browser' }),++ computer: COMPUTER,++ });++++ await check('the task record carries the one Computer, with the driving flag', async () => {++ const r = await t.get(`/api/v1/tasks/${id}`, { as: kim });++ assert.strictEqual(r.status, 200, r.text);++ // desk and needs_you are this API's own additions (they predate the contract); the rest is platform.task@1.++ const { desk: _desk, needs_you: _needs, ...task } = r.json();++ valid(task);++ assert.deepStrictEqual(r.json().computer, { ...COMPUTER, driving: false });++ assert.ok(r.json().desk && /openvibe\.bot/.test(r.json().desk.url), 'the watch link is still there');++ });++++ await check('the still is its person\'s alone: another person and a signed-out visitor get 404', async () => {++ const mine = await t.get(`/api/v1/tasks/${id}/still`, { as: kim });++ assert.strictEqual(mine.status, 200, mine.text);++ assert.match(mine.headers.get('content-type'), /^image\/jpeg/);++ assert.strictEqual(mine.headers.get('cache-control'), 'private, no-store');++ assert.strictEqual(mine.headers.get('x-content-type-options'), 'nosniff');++ assert.deepStrictEqual(asked, [[kim.subject, SESSION]], 'fetched from the session\'s service as the person');++ assert.strictEqual((await mine.buffer.equals(still)), true);++ assert.strictEqual((await t.get(`/api/v1/tasks/${id}/still`, { as: bo })).status, 404, 'another person: the same 404 as a missing task');++ assert.strictEqual((await t.get(`/api/v1/tasks/${id}/still`)).status, 404, 'signed out: 404, never 401');++ // A broken or hostile upstream cannot serve HTML from openvibe.actor.++ stillType = 'text/html';++ const bad = await t.get(`/api/v1/tasks/${id}/still`, { as: kim });++ assert.strictEqual(bad.status, 502, bad.text);++ assert.strictEqual(bad.json().code, 'still.bad_type');++ assert.ok(!bad.text.includes('<html'));++ stillType = 'image/jpeg';++ });++++ await check('take over: the person drives, the timeline gets a step, and the agent\'s input is refused', async () => {++ assert.strictEqual((await t.get(`/api/v1/tasks/${id}/takeover`, { as: bo, method: 'POST', headers: SAME })).status, 404, 'another person');++ const r = await t.get(`/api/v1/tasks/${id}/takeover`, { as: kim, method: 'POST', headers: SAME });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().computer.driving, true);++ assert.ok(driving.is(id));++ const events = (await s.db.many('SELECT body FROM task_events WHERE task_id = $1 ORDER BY seq', [id])).map((e) => (typeof e.body === 'string' ? JSON.parse(e.body) : e.body));++ const handoff = events.find((e) => e.step === 'handoff');++ assert.ok(handoff && /took over/i.test(handoff.text), 'the take-over is a step in the task');++ assert.deepStrictEqual(handoff.computer, COMPUTER, 'the step carries the same Computer');++ const entries = (await timeline.list(s, `user:${kim.subject}`)).entries;++ const entry = entries.find((e) => e.id && e.title === 'You took over the desk');++ assert.ok(entry && entry.status === 'running' && entry.computer.session_id === SESSION, 'the Dock\'s timeline has one step');++ // The agent's input actions are refused, with a reason a person can read.++ const ctx = { requester: `user:${kim.subject}`, taskId: id, config: t.config };++ const click = JSON.parse((await computerInput.run({ machine: 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2A', action: 'click', x: 10, y: 20 }, ctx)).text);++ assert.match(click.error, /took it over/);++ const clickBrowser = JSON.parse((await computerBrowser.run({ machine: 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2A', action: 'click', selector: 'a' }, ctx)).text);++ assert.match(clickBrowser.error, /took it over/);++ });++++ await check('give it back: the agent goes on and the same step ends', async () => {++ const r = await t.get(`/api/v1/tasks/${id}/giveback`, { as: kim, method: 'POST', headers: SAME });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().computer.driving, false);++ assert.ok(!driving.is(id));++ const entries = (await timeline.list(s, `user:${kim.subject}`)).entries;++ const entry = entries.find((e) => e.title === 'You gave the desk back');++ assert.ok(entry && entry.status === 'done' && entry.ended_at, 'the same Dock step is done');++ });++++ await check('a task with no Computer has nothing to take over and no still', async () => {++ const other = await runningTask('And what is a mutex?');++ assert.strictEqual((await t.get(`/api/v1/tasks/${other}/still`, { as: kim })).status, 404);++ const r = await t.get(`/api/v1/tasks/${other}/takeover`, { as: kim, method: 'POST', headers: SAME });++ assert.deepStrictEqual([r.status, r.json().code], [409, 'actor.task.no_computer']);++ });++ } finally {++ await done(t);++ }++})();@@@ -409,7 +409,7 @@ const as = { requester: `user:${ME}` };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.deepStrictEqual(runCalls[0].body, { kind: 'browser', ttl_s: 3600, idle_s: 900, idempotency_key: 'actor:tsk_1', task_id: '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');@@@ -42,8 +42,10 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';[agentMemoryId, agentId, owner, oldStyleText, 'agent', now]);// 0017 on top of the running schema.const second = await db.migrate({ dir: MIGRATIONS });-- // 0025 is the person's own model choice (plan L55 C3): it is a migration of its own, like every one before it.-- assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026', '0027', '0028', '0029', '0030', '0031', '0032', '0033']);++ // Every migration after 0016 applies on top, in order: the list is read from the directory, so a new++ // expand-only migration (like 0035, the timeline's Computer) does not need this test edited.++ const later = fs.readdirSync(MIGRATIONS).filter((x) => x.endsWith('.sql') && x >= '0017').map((x) => x.slice(0, 4)).sort();++ assert.deepStrictEqual(second.applied.map((m) => m.id), later);await check('an old-style INSERT still works after 0017, and a 900-character row is intact', async () => {const old = await db.maybe('SELECT id, text, origin, status, kind, uses FROM actor_agent_memory WHERE id = $1', [agentMemoryId]);
@@ -2,6 +2,21 @@
@@ -2,6 +2,21 @@
What changed in OpenVibe.Actor, newest first. Each site also publishes its patch notes at /updates.
What changed in OpenVibe.Actor, newest first. Each site also publishes its patch notes at /updates.
+## Unreleased — one Computer
+
+- **One Computer** (plan 2026-11-07: "Surf, Computer and Actor are one experience"): when a task opens a desk, its
+ own step carries `computer` (`session_id` — OpenVibe.Run's `ses_…`, or null on the person's own machine through
+ Bot — `machine`, `where` and `cost_hint`) from the tool's answer, never the model's words. `GET /api/v1/tasks/:id`
+ reads the same object off the task's own steps, with `driving` while the person holds the desk, and
+ `agent.timeline-entry@1` stores it (migration 0035).
+- **Take over / Give it back** are one API on the task: `POST /api/v1/tasks/:id/takeover` and `…/giveback`, built on
+ the `hand_to_person` hand-off. While the person drives, `computer_input` and the acting `computer_browser` actions
+ are refused with a clear reason; the task's timeline gets one `handoff` step and the Dock's timeline one step with
+ the same Computer. Only the task's own person.
+- **The still**: `GET /api/v1/tasks/:id/still` asks the session's own service (Run) for the newest frame as Actor's
+ service token acting for the person, and answers the bytes with `Cache-Control: private, no-store`. A person's
+ task only; another person, a signed-out visitor and an app token all get the same 404 as a missing task.
+
## 0.3.0 — 2026-11-07
## 0.3.0 — 2026-11-07
- **Space's builder as a task of the person's one agent** (plan 2026-10-22 §Z, stage Z1): six new tools — `space_create`, `space_files_list`, `space_files_read`, `space_files_write`, `space_preview` and `space_publish`. The agent works on the person's own spaces through OpenVibe.Space's builder API (`/api/v1/builder/spaces`), as the person: another person's space is the same 404 as one that does not exist, and every write goes through the same path, limits and audit as Space's own editor.
- **Space's builder as a task of the person's one agent** (plan 2026-10-22 §Z, stage Z1): six new tools — `space_create`, `space_files_list`, `space_files_read`, `space_files_write`, `space_preview` and `space_publish`. The agent works on the person's own spaces through OpenVibe.Space's builder API (`/api/v1/builder/spaces`), as the person: another person's space is the same 404 as one that does not exist, and every write goes through the same path, limits and audit as Space's own editor.
@@ -260,7 +260,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -260,7 +260,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.321.0
+- openvibe-contracts: v0.327.0
- openvibe-sdk: v0.94.0
- openvibe-sdk: v0.94.0
- openvibe-shared: v4.135.0
- openvibe-shared: v4.135.0
<!-- versions:end -->
<!-- versions:end -->
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+-- phase: expand
+-- One Computer across Surf, Actor and the Dock (plan 2026-11-07): a step of the one timeline now names the Computer
+-- it works on ({ session_id, machine, where, cost_hint }), the same object the task record and the task's own step
+-- carry. It holds an id, a word for a machine and a cost phrase: no prompt, page or screen.
+ALTER TABLE agent_timeline
+ ADD COLUMN IF NOT EXISTS computer jsonb;
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.322.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1146,9 +1146,9 @@
@@ -1146,9 +1146,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.322.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.322.0",
- "integrity": "sha512-dY9e6x1i9+IZlGWjaFemycVsIPRfWsn+Tx0W0yKMszGe5sinMUFjcA34FuIsC8volbSut7ZSVvWHNWSQVBjTTw==",
+ "version": "0.327.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",
+ "integrity": "sha512-LssZZeTdg9OkPPpry6YSEVhbIv13MjLYOlNxpoZ/0bpIATy0zfJrzfLAaJyUY+UMq5Tqf1FLzx2a/3xcJ7UJDw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.322.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.327.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.135.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -26,6 +26,7 @@
@@ -26,6 +26,7 @@
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}$/;
+const SESSION_ID = /^ses_[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>.
// 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_MACHINE = /^run:(ses_[0-9A-HJKMNP-TV-Z]{26})$/;
const SESSION_KINDS = new Set(['screen', 'input', 'browser']);
const SESSION_KINDS = new Set(['screen', 'input', 'browser']);
@@ -90,7 +91,8 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensO
@@ -90,7 +91,8 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensO
if (!USER_SUBJECT.test(subject)) return { error: 'a session is only for a signed-in person' };
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,
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 } : {}),
...(Number.isInteger(width) ? { width } : {}), ...(Number.isInteger(height) ? { height } : {}),
- ...(taskId ? { idempotency_key: `actor:${taskId}` } : {}) };
+ // The session carries the task back (contracts 0.327.0): one id across Surf, the Computer and Actor.
+ ...(taskId ? { idempotency_key: `actor:${taskId}`, task_id: taskId } : {}) };
const made = await runCall('POST', '/sessions', subject, { body, signal });
const made = await runCall('POST', '/sessions', subject, { body, signal });
if (!made.ok) return runProblem(made, 'a browser session');
if (!made.ok) return runProblem(made, 'a browser session');
let s = made.data;
let s = made.data;
@@ -113,6 +115,32 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensO
@@ -113,6 +115,32 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensO
return r.ok ? { running: false, stopped: true } : runProblem(r, 'ending the session');
return r.ok ? { running: false, stopped: true } : runProblem(r, 'ending the session');
}
}
+ /**
+ * The session's newest frame (plan 2026-11-07): Run's still, as Actor's service token acting for the person
+ * (run.session.read, X-OV-Subject). Run itself accepts only the session's person or a capable service, answers
+ * the curtain picture while Curtain now is up, and captures at most once a second. → { bytes, type } or { error }.
+ */
+ async function still(subject, sessionId, { signal } = {}) {
+ if (!sessionsOn) return { error: 'no session service is reachable from here' };
+ if (!USER_SUBJECT.test(subject) || !SESSION_ID.test(String(sessionId || ''))) return { error: 'not a session of the person\'s' };
+ const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(12_000)]) : AbortSignal.timeout(12_000);
+ let res;
+ try {
+ res = await fetchImpl(`${runBase}/api/v1/sessions/${sessionId}/still`, {
+ headers: { Accept: 'image/jpeg, image/png', 'X-OV-Subject': subject, ...(await runTokens.authHeaders()) }, signal: sig,
+ });
+ } catch {
+ return { error: 'the session service did not answer' };
+ }
+ if (!res.ok) return { status: res.status, error: 'the session has no picture' };
+ // Only a picture is ever passed on: a broken or hostile upstream cannot serve HTML from openvibe.actor.
+ const type = String(res.headers.get('content-type') || '').split(';')[0].trim();
+ if (!/^image\/(jpeg|png|webp)$/.test(type)) return { status: 502, code: 'still.bad_type', error: 'the session sent a picture that is not an image' };
+ const bytes = Buffer.from(await res.arrayBuffer());
+ if (!bytes.length || bytes.length > 4 * 1024 * 1024) return { status: 502, error: 'the session service sent no picture' };
+ return { bytes, type };
+ }
+
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}`, {
@@ -404,7 +432,7 @@ function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensO
@@ -404,7 +432,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, sessionsOn, isSession: (m) => SESSION_MACHINE.test(String(m || '')) };
+ 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 || '')) };
}
}
module.exports = { createComputers };
module.exports = { createComputers };
new file mode 100644
new file mode 100644
@@ -0,0 +1,35 @@
@@ -0,0 +1,35 @@
+'use strict';
+
+/**
+ * Who is driving a task's Computer right now (plan 2026-11-07: Take over / Give it back).
+ *
+ * The person's take-over is an API call (POST /api/v1/tasks/:id/takeover), not a desk command, so the task's tools
+ * cannot see it in Bot: this is the one place that says the person is driving. While a task is here, the agent's
+ * input actions are refused with a reason; giveback clears it. In memory only, like looks.js: a restart loses the
+ * marker, and Bot's own person-first turn still holds the desk off the agent either way.
+ */
+const KEEP_MS = 60 * 60 * 1000; // the longest a take-over holds without a giveback
+const MAX = 2000;
+
+const driving = new Map(); // task id → { at }
+
+function take(taskId, now = Date.now()) {
+ if (!taskId) return false;
+ driving.delete(taskId);
+ driving.set(taskId, { at: now });
+ while (driving.size > MAX) driving.delete(driving.keys().next().value);
+ return true;
+}
+
+function give(taskId) {
+ return driving.delete(taskId);
+}
+
+function is(taskId, now = Date.now()) {
+ const d = taskId ? driving.get(taskId) : null;
+ if (!d) return false;
+ if (now - d.at > KEEP_MS) { driving.delete(taskId); return false; }
+ return true;
+}
+
+module.exports = { take, give, is, KEEP_MS };
@@ -22,6 +22,9 @@ const subs = new Map(); // subject → Set<{ res }>: the live update stream
@@ -22,6 +22,9 @@ const subs = new Map(); // subject → Set<{ res }>: the live update stream
const iso = (v) => (v instanceof Date ? v.toISOString() : (v ? new Date(v).toISOString() : null));
const iso = (v) => (v instanceof Date ? v.toISOString() : (v ? new Date(v).toISOString() : null));
const text = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
const text = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
+// The Computer of a step (plan 2026-11-07): a plain object with session_id, machine, where and cost_hint. Anything
+// else — an array, a string, a nested payload — is not stored.
+const computer = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : null);
/** The person an event is for: only a user subject is the agent's. */
/** The person an event is for: only a user subject is the agent's. */
function subjectOf(ev) {
function subjectOf(ev) {
@@ -44,6 +47,7 @@ function entryOf(row) {
@@ -44,6 +47,7 @@ function entryOf(row) {
ended_at: iso(row.ended_at),
ended_at: iso(row.ended_at),
status: row.status,
status: row.status,
placement: (row.placement_where || row.placement_why) ? { where: row.placement_where || null, why: row.placement_why || null } : null,
placement: (row.placement_where || row.placement_why) ? { where: row.placement_where || null, why: row.placement_why || null } : null,
+ computer: row.computer == null ? null : (typeof row.computer === 'object' ? row.computer : (() => { try { return JSON.parse(row.computer); } catch { return null; } })()),
cost_usd: row.cost_usd === null || row.cost_usd === undefined ? null : Number(row.cost_usd),
cost_usd: row.cost_usd === null || row.cost_usd === undefined ? null : Number(row.cost_usd),
link: row.link || null,
link: row.link || null,
reason: row.reason || null,
reason: row.reason || null,
@@ -60,21 +64,21 @@ async function upsert(s, subject, payload) {
@@ -60,21 +64,21 @@ async function upsert(s, subject, payload) {
if (!subject || !MODES.has(step.mode) || !STATUSES.has(step.status) || !text(step.step_id, 80)) return null;
if (!subject || !MODES.has(step.mode) || !STATUSES.has(step.status) || !text(step.step_id, 80)) return null;
const now = s.iso();
const now = s.iso();
const row = await s.db.one(`INSERT INTO agent_timeline (id, subject, step_id, product, mode, ref, title, status, placement_where, placement_why,
const row = await s.db.one(`INSERT INTO agent_timeline (id, subject, step_id, product, mode, ref, title, status, placement_where, placement_why,
- cost_usd, link, reason, transition, approval_product, approval_id, approval_kind, audience, shared, started_at, ended_at, created_at, updated_at)
- VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,'private',false,$18,$19,$20,$20)
+ cost_usd, link, reason, transition, approval_product, approval_id, approval_kind, audience, shared, started_at, ended_at, created_at, updated_at, computer)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,'private',false,$18,$19,$20,$20,$21)
ON CONFLICT (subject, product, step_id) DO UPDATE SET
ON CONFLICT (subject, product, step_id) DO UPDATE SET
mode = EXCLUDED.mode, ref = EXCLUDED.ref, title = EXCLUDED.title, status = EXCLUDED.status,
mode = EXCLUDED.mode, ref = EXCLUDED.ref, title = EXCLUDED.title, status = EXCLUDED.status,
placement_where = EXCLUDED.placement_where, placement_why = EXCLUDED.placement_why, cost_usd = EXCLUDED.cost_usd,
placement_where = EXCLUDED.placement_where, placement_why = EXCLUDED.placement_why, cost_usd = EXCLUDED.cost_usd,
link = EXCLUDED.link, reason = EXCLUDED.reason, transition = EXCLUDED.transition,
link = EXCLUDED.link, reason = EXCLUDED.reason, transition = EXCLUDED.transition,
approval_product = EXCLUDED.approval_product, approval_id = EXCLUDED.approval_id, approval_kind = EXCLUDED.approval_kind,
approval_product = EXCLUDED.approval_product, approval_id = EXCLUDED.approval_id, approval_kind = EXCLUDED.approval_kind,
- ended_at = EXCLUDED.ended_at, updated_at = EXCLUDED.updated_at
+ ended_at = EXCLUDED.ended_at, updated_at = EXCLUDED.updated_at, computer = EXCLUDED.computer
RETURNING *`,
RETURNING *`,
[s.newId('tl'), subject, text(step.step_id, 80), text(step.product, 40) || 'agent', step.mode, text(step.ref, 120),
[s.newId('tl'), subject, text(step.step_id, 80), text(step.product, 40) || 'agent', step.mode, text(step.ref, 120),
text(step.title, 160) || 'A step of the agent', step.status,
text(step.title, 160) || 'A step of the agent', step.status,
text(step.placement && step.placement.where, 160), text(step.placement && step.placement.why, 240),
text(step.placement && step.placement.where, 160), text(step.placement && step.placement.why, 240),
num(step.cost_usd), text(step.link, 2048), text(step.reason, 240), text(step.transition, 240),
num(step.cost_usd), text(step.link, 2048), text(step.reason, 240), text(step.transition, 240),
text(step.approval && step.approval.product, 40), text(step.approval && step.approval.id, 80), text(step.approval && step.approval.kind, 40),
text(step.approval && step.approval.product, 40), text(step.approval && step.approval.id, 80), text(step.approval && step.approval.kind, 40),
- iso(step.started_at) || now, iso(step.ended_at), now]);
+ iso(step.started_at) || now, iso(step.ended_at), now, computer(step.computer) ? JSON.stringify(computer(step.computer)) : null]);
publish(subject, entryOf(row));
publish(subject, entryOf(row));
return row;
return row;
}
}
@@ -146,7 +146,10 @@ async function createApp(opts = {}) {
@@ -146,7 +146,10 @@ async function createApp(opts = {}) {
// The one sharing system (design 2026-11-06): an agent's run is its owner's until the Share sheet says otherwise.
// The one sharing system (design 2026-11-06): an agent's run is its owner's until the Share sheet says otherwise.
const sharing = opts.sharing || createSharing({ db: s.db, config, fetchImpl, log });
const sharing = opts.sharing || createSharing({ db: s.db, config, fetchImpl, log });
setSharing(sharing);
setSharing(sharing);
- const ctx = { config, s, keys, aiCatalogConfig, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits, sharing };
+ // The person's computers (agents/computers.js): one adapter for the task's still proxy, made here so a test can
+ // stand Bot/Run in (opts.bot.computers) exactly as the Console pages do.
+ const computers = (opts.bot && opts.bot.computers) || require('./agents/computers').createComputers({ config, fetchImpl: providerFetch, log });
+ const ctx = { config, s, keys, aiCatalogConfig, sso, stream, adapters, engine, webhooks, principal, projects, log, chatAsks, vibeDelivery, models, registry, loop, notifier, stats, checkpoints, connectors, waits, sharing, computers };
// Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.
// Account export and deletion (ADR-033, ./identity/account-data.js): the two tables that hold a person's rows.
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
// The sender posts to Network's internal export/deletion routes with this service's own client-credentials token;
@@ -40,6 +40,7 @@ const approvals = require('../agents/approvals');
@@ -40,6 +40,7 @@ const approvals = require('../agents/approvals');
const items = require('../actions/items');
const items = require('../actions/items');
const timeline = require('../agents/timeline');
const timeline = require('../agents/timeline');
const modelPrefs = require('../runtime/model-prefs');
const modelPrefs = require('../runtime/model-prefs');
+const driving = require('../agents/driving');
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_ID = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_ID = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -426,9 +427,73 @@ function createApi(ctx) {
@@ -426,9 +427,73 @@ function createApi(ctx) {
// The desk it works on now, to watch live and take over (Desks plan).
// The desk it works on now, to watch live and take over (Desks plan).
const desk = await store.taskDesk(s, row);
const desk = await store.taskDesk(s, row);
if (desk) out.desk = desk;
if (desk) out.desk = desk;
+ // The Computer it opened (plan 2026-11-07: one id across Surf, the Dock and Actor), read from its own steps.
+ const computer = await store.taskComputer(s, row);
+ if (computer) out.computer = { ...computer, driving: driving.is(row.id, s.now()) };
res.json(out);
res.json(out);
});
});
+ // Take over / Give it back (plan 2026-11-07), built on the hand_to_person machinery: the person drives the desk
+ // they were handed, the agent's input is refused while they do, and the task's timeline gets one hand-off step.
+ // Only the task's own people (load()), and only while it runs on a Computer.
+ async function handoff(req, res, { take }) {
+ const row = await load(req, res);
+ if (!row) return;
+ if (!store.OPEN.includes(row.state)) return problem(req, res, 409, 'actor.task.ended', 'This task has ended: its Computer is gone.');
+ const computer = await store.taskComputer(s, row);
+ const desk = await store.taskDesk(s, row);
+ if (!computer && !desk) return problem(req, res, 409, 'actor.task.no_computer', 'This task has no Computer to hand over.');
+ if (!String(row.requester).startsWith('user:')) return problem(req, res, 409, 'actor.task.no_person', 'A Computer is a person\'s: sign in as the task\'s own person to take it.');
+ const at = s.iso();
+ const now = take ? (driving.take(row.id, s.now()), true) : (driving.give(row.id), false);
+ // The step in the task's own timeline (the same events /events streams), carrying the Computer.
+ const event = await store.appendEvent(s, row.id, {
+ at, kind: 'output', step: 'handoff',
+ text: now ? 'You took over the desk. The agent waits until you give it back.' : 'You gave the desk back. The agent goes on.',
+ ...(computer ? { computer } : {}),
+ });
+ stream.publish(row.id, event);
+ // And one step in the one timeline behind the Dock (agent.timeline-entry@1), the same Computer.
+ try {
+ await timeline.upsert(s, row.requester, {
+ step_id: `${row.id}:takeover`, mode: 'browse', product: 'actor', ref: row.id,
+ title: now ? 'You took over the desk' : 'You gave the desk back',
+ status: now ? 'running' : 'done', started_at: now ? at : undefined, ended_at: now ? null : at,
+ placement: computer ? { where: computer.machine, why: now ? 'you are driving the desk' : 'the agent goes on' } : null,
+ link: `${config.baseUrl}/tasks/${row.id}`, ...(computer ? { computer } : {}),
+ });
+ } catch (err) { /* the task's own timeline above is the source of truth */ }
+ const out = await wireTask(row);
+ if (computer) out.computer = { ...computer, driving: Boolean(now) };
+ if (desk) out.desk = desk;
+ res.json(out);
+ }
+ r.post('/tasks/:id/takeover', principal.requireCapability('actor.task.create'), async (req, res) => handoff(req, res, { take: true }));
+ r.post('/tasks/:id/giveback', principal.requireCapability('actor.task.create'), async (req, res) => handoff(req, res, { take: false }));
+
+ // The Computer's newest frame, fetched from the session's own service (Run) as Actor, acting for the task's
+ // person: the person's own token would have to cross an origin, and the picture is theirs. Owner/project only
+ // (load()); a task with no Run session has no picture, and neither has anyone asking for another person's task.
+ // A still is its task's person's, and nobody else's: a signed-out visitor and an app token get the same 404 as a
+ // task that does not exist (never a 401 that would confirm one).
+ const stillPerson = (req, res, next) => ((req.principal && req.principal.kind === 'user') ? next() : problem(req, res, 404, 'actor.task.not_found', 'No such task.'));
+ r.get('/tasks/:id/still', stillPerson, principal.requireCapability('actor.task.read'), limits.reads('actor.task.read'), async (req, res) => {
+ const row = await load(req, res);
+ if (!row) return;
+ const computer = await store.taskComputer(s, row);
+ if (!computer || !computer.session_id) return problem(req, res, 404, 'actor.task.no_still', 'This task has no Computer picture.');
+ if (!String(row.requester).startsWith('user:')) return problem(req, res, 404, 'actor.task.no_still', 'This task has no Computer picture.');
+ const shot = await ctx.computers.still(row.requester.slice(5), computer.session_id);
+ if (!shot || shot.error) return problem(req, res, shot && shot.status === 404 ? 404 : 502, (shot && shot.code) || 'actor.task.no_still', (shot && shot.error) || 'No picture.');
+ // Only a picture: a broken or hostile upstream can never serve HTML from openvibe.actor.
+ if (!['image/jpeg', 'image/png', 'image/webp'].includes(shot.type)) return problem(req, res, 502, 'still.bad_type', 'The session sent something that is not an image.');
+ res.set('Cache-Control', 'private, no-store');
+ res.set('X-Content-Type-Options', 'nosniff');
+ res.set('Content-Type', shot.type);
+ res.set('Content-Length', String(shot.bytes.length));
+ return res.status(200).end(shot.bytes);
+ });
+
// The task's trace (stage V2-10): the numbers and names of its model, tool, check and delegate calls, to its own
// The task's trace (stage V2-10): the numbers and names of its model, tool, check and delegate calls, to its own
// people. Another person gets the same 404 as a task that does not exist.
// people. Another person gets the same 404 as a task that does not exist.
r.get('/tasks/:id/trace', principal.requireCapability('actor.task.read'), limits.reads('actor.task.read'), async (req, res) => {
r.get('/tasks/:id/trace', principal.requireCapability('actor.task.read'), limits.reads('actor.task.read'), async (req, res) => {
@@ -417,7 +417,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -417,7 +417,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
const shown = displayName(p.name, ctx);
const shown = displayName(p.name, ctx);
const item = state.ledger.add({ tool: shown, input: p.args, text: out.text, data: out.data, sources: out.sources, artifacts: out.artifacts, refs: out.refs, secret: out.secret === true });
const item = state.ledger.add({ tool: shown, input: p.args, text: out.text, data: out.data, sources: out.sources, artifacts: out.artifacts, refs: out.refs, secret: out.secret === true });
state.messages.push({ role: 'tool', tool_call_id: p.call.id, content: JSON.stringify({ evidence: item.id, tool: shown, result: clip(out.text, resultChars), ...(out.secret === true ? { secret: true } : {}) }) });
state.messages.push({ role: 'tool', tool_call_id: p.call.id, content: JSON.stringify({ evidence: item.id, tool: shown, result: clip(out.text, resultChars), ...(out.secret === true ? { secret: true } : {}) }) });
- await ctx.emit({ step: 'tool_result', tool: shown, text: clip(out.text, 600), is_error: out.isError || undefined, ...(out.artifacts && out.artifacts.length ? { artifacts: out.artifacts } : {}) });
+ await ctx.emit({ step: 'tool_result', tool: shown, text: clip(out.text, 600), is_error: out.isError || undefined, ...(out.artifacts && out.artifacts.length ? { artifacts: out.artifacts } : {}), ...(out.computer !== undefined ? { computer: out.computer } : {}) });
if (out.isError) { errorResults += 1; lastTool = p.name; } else okResults += 1;
if (out.isError) { errorResults += 1; lastTool = p.name; } else okResults += 1;
if (state.brain) {
if (state.brain) {
state.brain.toolRuns = (state.brain.toolRuns || 0) + 1;
state.brain.toolRuns = (state.brain.toolRuns || 0) + 1;
@@ -782,7 +782,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
@@ -782,7 +782,7 @@ function createLoop({ config, models, registry, log = console, now = () => Date.
const shown = displayName(name, ctx);
const shown = displayName(name, ctx);
const item = state.ledger.add({ tool: shown, input: args, text: out.text, data: out.data, sources: out.sources, artifacts: out.artifacts, refs: out.refs, secret: out.secret === true });
const item = state.ledger.add({ tool: shown, input: args, text: out.text, data: out.data, sources: out.sources, artifacts: out.artifacts, refs: out.refs, secret: out.secret === true });
state.messages.push({ role: 'tool', tool_call_id: id, content: JSON.stringify({ evidence: item.id, tool: shown, result: clip(out.text, resultChars), ...(out.secret === true ? { secret: true } : {}) }) });
state.messages.push({ role: 'tool', tool_call_id: id, content: JSON.stringify({ evidence: item.id, tool: shown, result: clip(out.text, resultChars), ...(out.secret === true ? { secret: true } : {}) }) });
- await ctx.emit({ step: 'tool_result', tool: shown, text: clip(out.text, 600), is_error: out.isError || undefined, ...(out.artifacts && out.artifacts.length ? { artifacts: out.artifacts } : {}) });
+ await ctx.emit({ step: 'tool_result', tool: shown, text: clip(out.text, 600), is_error: out.isError || undefined, ...(out.artifacts && out.artifacts.length ? { artifacts: out.artifacts } : {}), ...(out.computer !== undefined ? { computer: out.computer } : {}) });
await spanCall(ctx.trace, { kind: 'tool', name, startedAt, ms: now() - startedAt, ok: !(out && out.isError), errorCode: out && out.isError ? 'actor.tool.failed' : null, step: state.calls });
await spanCall(ctx.trace, { kind: 'tool', name, startedAt, ms: now() - startedAt, ok: !(out && out.isError), errorCode: out && out.isError ? 'actor.tool.failed' : null, step: state.calls });
note(name, !(out && out.isError));
note(name, !(out && out.isError));
};
};
@@ -20,15 +20,23 @@ const PERSON_QUIET_MS = 20_000;
@@ -20,15 +20,23 @@ const PERSON_QUIET_MS = 20_000;
const AGENT_PROFILE = 'agent';
const AGENT_PROFILE = 'agent';
function deskRun(taskId = null) {
function deskRun(taskId = null) {
- return { name: `desk-${crypto.randomBytes(5).toString('hex')}`, taskId, named: null, kinds: new Map(), keeps: new Set(), used: new Set(), placed: null };
+ return { name: `desk-${crypto.randomBytes(5).toString('hex')}`, taskId, named: null, kinds: new Map(), keeps: new Set(), used: new Set(), placed: null, names: new Map() };
}
}
+/** The person's own word for their machine (their name for it), remembered from list_my_computers. */
+function rememberMachines(run, computers) {
+ if (!run || !Array.isArray(computers)) return;
+ for (const m of computers) if (m && typeof m.id === 'string') run.names.set(m.id, String(m.name || 'your computer').slice(0, 80));
+}
+const machineWord = (run, machine) => (run && run.names.get(machine)) || 'your computer';
+
/** The task's own desk for a machine that runs several; else the machine's one private desk. */
/** The task's own desk for a machine that runs several; else the machine's one private desk. */
async function deskFor(run, person, machine, { core, signal }) {
async function deskFor(run, person, machine, { core, signal }) {
if (!run) return 'private';
if (!run) return 'private';
if (run.named === null) {
if (run.named === null) {
const mine = await core.pcs.mine(person, { signal });
const mine = await core.pcs.mine(person, { signal });
const computers = (mine && mine.computers) || [];
const computers = (mine && mine.computers) || [];
+ rememberMachines(run, computers);
run.named = new Set(computers.filter((m) => m.desks_at_once).map((m) => m.id));
run.named = new Set(computers.filter((m) => m.desks_at_once).map((m) => m.id));
run.kinds = new Map(computers.filter((m) => Array.isArray(m.desk_kinds)).map((m) => [m.id, m.desk_kinds]));
run.kinds = new Map(computers.filter((m) => Array.isArray(m.desk_kinds)).map((m) => [m.id, m.desk_kinds]));
run.keeps = new Set(computers.filter((m) => m.keeps_sign_ins).map((m) => m.id));
run.keeps = new Set(computers.filter((m) => m.keeps_sign_ins).map((m) => m.id));
@@ -47,8 +55,9 @@ function deskKind(run, machine, asked) {
@@ -47,8 +55,9 @@ function deskKind(run, machine, asked) {
}
}
/** Where a desk for machine "any" goes: the online machines that run several, desk hosts first, then the most free. */
/** Where a desk for machine "any" goes: the online machines that run several, desk hosts first, then the most free. */
-async function placementOrder(person, { core, signal }) {
+async function placementOrder(person, { core, signal, run = null }) {
const mine = await core.pcs.mine(person, { signal });
const mine = await core.pcs.mine(person, { signal });
+ rememberMachines(run, (mine && mine.computers) || []);
const free = (m) => (Number(m.desks_at_once) || 1) - (Number(m.desks_running) || 0);
const free = (m) => (Number(m.desks_at_once) || 1) - (Number(m.desks_running) || 0);
const ownScreen = (m) => ((m.can || []).includes('screen') ? 1 : 0);
const ownScreen = (m) => ((m.can || []).includes('screen') ? 1 : 0);
return ((mine && mine.computers) || [])
return ((mine && mine.computers) || [])
@@ -144,5 +153,5 @@ async function prepareDesk(tool, a, person, core, ctx) {
@@ -144,5 +153,5 @@ async function prepareDesk(tool, a, person, core, ctx) {
module.exports = {
module.exports = {
personOf, deskRun, deskFor, deskKind, placementOrder, closeDesks, lookAtScreen, handToPerson, prepareDesk, SCREEN_SYSTEM,
personOf, deskRun, deskFor, deskKind, placementOrder, closeDesks, lookAtScreen, handToPerson, prepareDesk, SCREEN_SYSTEM,
- PERSON_WAIT_MS, PERSON_POLL_MS, PERSON_QUIET_MS, AGENT_PROFILE, DESK_TOOLS,
+ PERSON_WAIT_MS, PERSON_POLL_MS, PERSON_QUIET_MS, AGENT_PROFILE, DESK_TOOLS, rememberMachines, machineWord,
};
};
@@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
const { serviceFor, json, personOf } = require('./_openvibe');
const { serviceFor, json, personOf } = require('./_openvibe');
const { prepareDesk } = require('./_person');
const { prepareDesk } = require('./_person');
+const driving = require('../../agents/driving');
const machine = { type: 'string', description: 'the machine id (dev_…) from list_my_computers' };
const machine = { type: 'string', description: 'the machine id (dev_…) from list_my_computers' };
@@ -15,6 +16,10 @@ module.exports = {
@@ -15,6 +16,10 @@ module.exports = {
timeoutMs: 60_000,
timeoutMs: 60_000,
available: (ctx) => Boolean(ctx.config && ctx.config.services && ctx.config.services.botUrl && personOf(ctx.requester)),
available: (ctx) => Boolean(ctx.config && ctx.config.services && ctx.config.services.botUrl && personOf(ctx.requester)),
async run(input, ctx) {
async run(input, ctx) {
+ // While the person drives, the agent may read the page but never act on it (Take over / Give it back).
+ if (input && input.action && !['read', 'close'].includes(input.action) && driving.is(ctx.taskId)) {
+ return { text: json({ error: 'The person is using the desk right now (they took it over): wait until they give it back, then read the page again before you act.' }), isError: true };
+ }
const core = serviceFor(ctx);
const core = serviceFor(ctx);
const person = personOf(ctx.requester);
const person = personOf(ctx.requester);
if (!core.pcs.enabled || !person) return { text: json({ error: 'computers are only for a signed-in person\'s task' }), isError: true };
if (!core.pcs.enabled || !person) return { text: json({ error: 'computers are only for a signed-in person\'s task' }), isError: true };
@@ -1,7 +1,28 @@
@@ -1,7 +1,28 @@
'use strict';
'use strict';
const { serviceFor, json, personOf } = require('./_openvibe');
const { serviceFor, json, personOf } = require('./_openvibe');
-const { prepareDesk, deskKind, placementOrder, AGENT_PROFILE } = require('./_person');
+const { prepareDesk, deskKind, placementOrder, AGENT_PROFILE, machineWord } = require('./_person');
+
+/**
+ * The Computer this task opened, as platform.task@1 carries it (plan 2026-11-07): the one id every surface reads.
+ * A desk on a desk host through an OpenVibe.Run session names that session (ses_…); a desk on the person's own
+ * machine has no Run session and says so with session_id null. where is whose it is: yours (their machine), home (a
+ * desk host someone offers at home) or network (an OpenVibe desk host).
+ */
+function computerOf({ run, machine, ses = null }) {
+ const session = ses && ses.session ? ses.session : null;
+ if (session) {
+ const trust = session.placement && session.placement.trust;
+ const where = trust === 'user-owned' ? 'yours' : trust === 'community' ? 'home' : 'network';
+ return {
+ session_id: session.id,
+ machine: where === 'yours' ? machineWord(run, session.placement && session.placement.machine) : where === 'home' ? 'a shared desk host' : 'an OpenVibe desk host',
+ where,
+ cost_hint: where === 'yours' ? 'free on your computer' : 'a desk host, metered by the minute',
+ };
+ }
+ return { session_id: null, machine: machineWord(run, machine), where: 'yours', cost_hint: 'free on your own computer' };
+}
/**
/**
* A task's own desk (Desks plan D1). The model only ever says desk "private"; on a machine that runs several desks
* A task's own desk (Desks plan D1). The model only ever says desk "private"; on a machine that runs several desks
@@ -41,13 +62,14 @@ module.exports = {
@@ -41,13 +62,14 @@ module.exports = {
if (a.machine === 'any') {
if (a.machine === 'any') {
if (desks && desks.placed) a = { ...a, machine: desks.placed };
if (desks && desks.placed) a = { ...a, machine: desks.placed };
else if (a.action === 'start') {
else if (a.action === 'start') {
- const order = await placementOrder(person, { core, signal: ctx.signal });
+ const order = await placementOrder(person, { core, signal: ctx.signal, run: desks });
let out = null;
let out = null;
for (const id of order.slice(0, 4)) {
for (const id of order.slice(0, 4)) {
out = await startDesk({ ...a, machine: id }, person, core, ctx);
out = await startDesk({ ...a, machine: id }, person, core, ctx);
if (!(out && out.error && /limit|max_desks|already runs/i.test(out.error))) {
if (!(out && out.error && /limit|max_desks|already runs/i.test(out.error))) {
if (out && !out.error && desks && !desks.placed) desks.placed = id;
if (out && !out.error && desks && !desks.placed) desks.placed = id;
- return { text: json(out && !out.error ? { machine: id, ...out } : out), data: out };
+ const computer = out && !out.error ? computerOf({ run: desks, machine: id }) : undefined;
+ return { text: json(out && !out.error ? { machine: id, ...out } : out), data: out, ...(computer ? { computer } : {}) };
}
}
}
}
// None of the person's own machines has room (or none runs desks): a browser desk on a desk host,
// None of the person's own machines has room (or none runs desks): a browser desk on a desk host,
@@ -56,7 +78,7 @@ module.exports = {
@@ -56,7 +78,7 @@ module.exports = {
const ses = await core.pcs.openSession(person, { taskId: desks && desks.taskId, width: a.width, height: a.height, signal: ctx.signal });
const ses = await core.pcs.openSession(person, { taskId: desks && desks.taskId, width: a.width, height: a.height, signal: ctx.signal });
if (!ses.error) {
if (!ses.error) {
if (desks) desks.placed = ses.machine;
if (desks) desks.placed = ses.machine;
- return { text: 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.' }), data: ses };
+ return { text: 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.' }), data: ses, computer: computerOf({ run: desks, machine: ses.machine, ses }) };
}
}
if (!order.length) return { text: json({ error: `none of the person's computers runs desks for you now, and ${ses.error}` }), isError: true };
if (!order.length) return { text: json({ error: `none of the person's computers runs desks for you now, and ${ses.error}` }), isError: true };
}
}
@@ -71,12 +93,14 @@ module.exports = {
@@ -71,12 +93,14 @@ module.exports = {
if (a.action === 'start') {
if (a.action === 'start') {
const out = await startDesk(a, person, core, ctx);
const out = await startDesk(a, person, core, ctx);
if (out && !out.error && desks && !desks.placed) desks.placed = a.machine;
if (out && !out.error && desks && !desks.placed) desks.placed = a.machine;
- return { text: json(out && out.running && !(core.pcs.isSession && core.pcs.isSession(a.machine)) ? { watch: core.pcs.watchUrl(a.machine, a.desk), ...out } : out), data: out };
+ const computer = out && !out.error ? computerOf({ run: desks, machine: a.machine }) : undefined;
+ return { text: json(out && out.running && !(core.pcs.isSession && core.pcs.isSession(a.machine)) ? { watch: core.pcs.watchUrl(a.machine, a.desk), ...out } : out), data: out, ...(computer ? { computer } : {}) };
}
}
const out = await core.pcs.desk(person, a.machine, a, { signal: ctx.signal });
const out = await core.pcs.desk(person, a.machine, a, { signal: ctx.signal });
if (desks && out && !out.error) {
if (desks && out && !out.error) {
if (a.action === 'stop' && desks.placed === a.machine) desks.placed = null;
if (a.action === 'stop' && desks.placed === a.machine) desks.placed = null;
}
}
- return { text: json(out && out.running && a.action !== 'stop' && !(core.pcs.isSession && core.pcs.isSession(a.machine)) ? { watch: core.pcs.watchUrl(a.machine, a.desk), ...out } : out), data: out };
+ // A stop clears the Computer: the task no longer holds one.
+ return { text: json(out && out.running && a.action !== 'stop' && !(core.pcs.isSession && core.pcs.isSession(a.machine)) ? { watch: core.pcs.watchUrl(a.machine, a.desk), ...out } : out), data: out, ...(a.action === 'stop' && out && !out.error ? { computer: null } : {}) };
},
},
};
};
@@ -2,6 +2,7 @@
@@ -2,6 +2,7 @@
const { serviceFor, json, personOf } = require('./_openvibe');
const { serviceFor, json, personOf } = require('./_openvibe');
const { prepareDesk } = require('./_person');
const { prepareDesk } = require('./_person');
+const driving = require('../../agents/driving');
const machine = { type: 'string', description: 'the machine id (dev_…) from list_my_computers' };
const machine = { type: 'string', description: 'the machine id (dev_…) from list_my_computers' };
@@ -15,6 +16,8 @@ module.exports = {
@@ -15,6 +16,8 @@ module.exports = {
timeoutMs: 40_000,
timeoutMs: 40_000,
available: (ctx) => Boolean(ctx.config && ctx.config.services && ctx.config.services.botUrl && personOf(ctx.requester)),
available: (ctx) => Boolean(ctx.config && ctx.config.services && ctx.config.services.botUrl && personOf(ctx.requester)),
async run(input, ctx) {
async run(input, ctx) {
+ // The person's take-over beats everything: no input reaches the desk while they drive it.
+ if (driving.is(ctx.taskId)) return { text: json({ error: 'The person is using the desk right now (they took it over): wait until they give it back, then look again before you act.' }), isError: true };
const core = serviceFor(ctx);
const core = serviceFor(ctx);
const person = personOf(ctx.requester);
const person = personOf(ctx.requester);
if (!core.pcs.enabled || !person) return { text: json({ error: 'computers are only for a signed-in person\'s task' }), isError: true };
if (!core.pcs.enabled || !person) return { text: json({ error: 'computers are only for a signed-in person\'s task' }), isError: true };
@@ -152,6 +152,27 @@ async function taskDesk(s, row) {
@@ -152,6 +152,27 @@ async function taskDesk(s, row) {
return deskFrom(rows.map((r) => parse(r.body)).reverse());
return deskFrom(rows.map((r) => parse(r.body)).reverse());
}
}
+/**
+ * The Computer a task opened (plan 2026-11-07: one id), read from its own computer_desk results: the last start that
+ * carried one, cleared by a stop. The event's `computer` is set by the loop from the tool's own answer, never by the
+ * model. → { session_id, machine, where, cost_hint } or null.
+ */
+function computerFrom(events) {
+ let computer = null;
+ for (const e of events) {
+ if (!e || e.step !== 'tool_result' || e.tool !== 'computer_desk' || e.is_error) continue;
+ if (e.computer === null) computer = null;
+ else if (e.computer && typeof e.computer === 'object' && !Array.isArray(e.computer)) computer = e.computer;
+ }
+ return computer;
+}
+
+async function taskComputer(s, row) {
+ if (!row || !OPEN.includes(row.state)) return null;
+ const rows = await s.db.many('SELECT body FROM task_events WHERE task_id = $1 ORDER BY seq DESC LIMIT 200', [row.id]);
+ return computerFrom(rows.map((r) => parse(r.body)).reverse());
+}
+
async function eventsAfter(s, taskId, after = 0) {
async function eventsAfter(s, taskId, after = 0) {
const rows = await s.db.many('SELECT body FROM task_events WHERE task_id = $1 AND seq > $2 ORDER BY seq', [taskId, after]);
const rows = await s.db.many('SELECT body FROM task_events WHERE task_id = $1 AND seq > $2 ORDER BY seq', [taskId, after]);
return rows.map((r) => parse(r.body));
return rows.map((r) => parse(r.body));
@@ -266,4 +287,4 @@ function toWire(row, { baseUrl }) {
@@ -266,4 +287,4 @@ function toWire(row, { baseUrl }) {
return t;
return t;
}
}
-module.exports = { insertTask, getTask, byIdempotency, listTasks, updateTask, appendEvent, eventsAfter, lastEvent, needsYou, deskFrom, deskPlace, taskDesk, addSpend, spendToday, spendDays, lockSpendToday, failInterrupted, openTasks, prune, toWire, subjectRef, dayOf, parseJson: parse, OPEN, RETENTION_DAYS, followContext, followUps, forks, recentSucceeded };
+module.exports = { insertTask, getTask, byIdempotency, listTasks, updateTask, appendEvent, eventsAfter, lastEvent, needsYou, deskFrom, deskPlace, taskDesk, computerFrom, taskComputer, addSpend, spendToday, spendDays, lockSpendToday, failInterrupted, openTasks, prune, toWire, subjectRef, dayOf, parseJson: parse, OPEN, RETENTION_DAYS, followContext, followUps, forks, recentSucceeded };
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
+'use strict';
+/**
+ * One Computer across Surf, the Dock and Actor (plan 2026-11-07): the task's record and its step carry the same
+ * computer { session_id, machine, where, cost_hint }; the still is the task person's alone (another person and a
+ * signed-out visitor get 404); Take over marks the person as driving and the agent's input is refused with a reason;
+ * Give it back hands the desk over again. The session's service is a stand-in here, exactly as Bot/Run are in the
+ * other suites.
+ * node test/computer-handoff.test.js
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const store = require('../server/tasks/store');
+const driving = require('../server/agents/driving');
+const timeline = require('../server/agents/timeline');
+const computerInput = require('../server/runtime/tools/computer_input');
+const computerBrowser = require('../server/runtime/tools/computer_browser');
+
+const SAME = { 'sec-fetch-site': 'same-origin', accept: 'application/json' };
+const SESSION = 'ses_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const COMPUTER = { session_id: SESSION, machine: 'a shared desk host', where: 'network', cost_hint: 'a desk host, metered by the minute' };
+
+(async () => {
+ const still = Buffer.from('/9j/4AAQ', 'base64');
+ const asked = [];
+ let stillType = 'image/jpeg';
+ const computers = {
+ enabled: true,
+ async still(subject, sessionId) { asked.push([subject, sessionId]); return { bytes: still, type: stillType }; },
+ };
+ const t = await boot({ bot: { computers } });
+ const kim = t.network.addUser('kim');
+ const bo = t.network.addUser('bo');
+ const s = t.ctx.s;
+ const valid = (task) => { const v = contracts.validate('platform.task@1', task); assert.ok(v.valid, JSON.stringify(v.errors)); };
+
+ async function runningTask(text) {
+ const id = (await t.get('/api/v1/tasks', { as: kim, json: { task: text }, headers: SAME })).json().id;
+ await t.waitFor(id);
+ await s.db.query("UPDATE tasks SET state = 'running' WHERE id = $1", [id]);
+ return id;
+ }
+
+ try {
+ const id = await runningTask('Compare three hosting plans under $10 a month and tell me which one to pick.');
+ await store.appendEvent(s, id, {
+ at: new Date().toISOString(), kind: 'output', step: 'tool_result', tool: 'computer_desk', is_error: false,
+ text: JSON.stringify({ watch: 'https://openvibe.bot/machines/dev_01J8Z4M2Q0R7T9YV3K6N8P1W2A/screen?desk=desk-trains', running: true, kind: 'browser' }),
+ computer: COMPUTER,
+ });
+
+ await check('the task record carries the one Computer, with the driving flag', async () => {
+ const r = await t.get(`/api/v1/tasks/${id}`, { as: kim });
+ assert.strictEqual(r.status, 200, r.text);
+ // desk and needs_you are this API's own additions (they predate the contract); the rest is platform.task@1.
+ const { desk: _desk, needs_you: _needs, ...task } = r.json();
+ valid(task);
+ assert.deepStrictEqual(r.json().computer, { ...COMPUTER, driving: false });
+ assert.ok(r.json().desk && /openvibe\.bot/.test(r.json().desk.url), 'the watch link is still there');
+ });
+
+ await check('the still is its person\'s alone: another person and a signed-out visitor get 404', async () => {
+ const mine = await t.get(`/api/v1/tasks/${id}/still`, { as: kim });
+ assert.strictEqual(mine.status, 200, mine.text);
+ assert.match(mine.headers.get('content-type'), /^image\/jpeg/);
+ assert.strictEqual(mine.headers.get('cache-control'), 'private, no-store');
+ assert.strictEqual(mine.headers.get('x-content-type-options'), 'nosniff');
+ assert.deepStrictEqual(asked, [[kim.subject, SESSION]], 'fetched from the session\'s service as the person');
+ assert.strictEqual((await mine.buffer.equals(still)), true);
+ assert.strictEqual((await t.get(`/api/v1/tasks/${id}/still`, { as: bo })).status, 404, 'another person: the same 404 as a missing task');
+ assert.strictEqual((await t.get(`/api/v1/tasks/${id}/still`)).status, 404, 'signed out: 404, never 401');
+ // A broken or hostile upstream cannot serve HTML from openvibe.actor.
+ stillType = 'text/html';
+ const bad = await t.get(`/api/v1/tasks/${id}/still`, { as: kim });
+ assert.strictEqual(bad.status, 502, bad.text);
+ assert.strictEqual(bad.json().code, 'still.bad_type');
+ assert.ok(!bad.text.includes('<html'));
+ stillType = 'image/jpeg';
+ });
+
+ await check('take over: the person drives, the timeline gets a step, and the agent\'s input is refused', async () => {
+ assert.strictEqual((await t.get(`/api/v1/tasks/${id}/takeover`, { as: bo, method: 'POST', headers: SAME })).status, 404, 'another person');
+ const r = await t.get(`/api/v1/tasks/${id}/takeover`, { as: kim, method: 'POST', headers: SAME });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().computer.driving, true);
+ assert.ok(driving.is(id));
+ const events = (await s.db.many('SELECT body FROM task_events WHERE task_id = $1 ORDER BY seq', [id])).map((e) => (typeof e.body === 'string' ? JSON.parse(e.body) : e.body));
+ const handoff = events.find((e) => e.step === 'handoff');
+ assert.ok(handoff && /took over/i.test(handoff.text), 'the take-over is a step in the task');
+ assert.deepStrictEqual(handoff.computer, COMPUTER, 'the step carries the same Computer');
+ const entries = (await timeline.list(s, `user:${kim.subject}`)).entries;
+ const entry = entries.find((e) => e.id && e.title === 'You took over the desk');
+ assert.ok(entry && entry.status === 'running' && entry.computer.session_id === SESSION, 'the Dock\'s timeline has one step');
+ // The agent's input actions are refused, with a reason a person can read.
+ const ctx = { requester: `user:${kim.subject}`, taskId: id, config: t.config };
+ const click = JSON.parse((await computerInput.run({ machine: 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2A', action: 'click', x: 10, y: 20 }, ctx)).text);
+ assert.match(click.error, /took it over/);
+ const clickBrowser = JSON.parse((await computerBrowser.run({ machine: 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2A', action: 'click', selector: 'a' }, ctx)).text);
+ assert.match(clickBrowser.error, /took it over/);
+ });
+
+ await check('give it back: the agent goes on and the same step ends', async () => {
+ const r = await t.get(`/api/v1/tasks/${id}/giveback`, { as: kim, method: 'POST', headers: SAME });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().computer.driving, false);
+ assert.ok(!driving.is(id));
+ const entries = (await timeline.list(s, `user:${kim.subject}`)).entries;
+ const entry = entries.find((e) => e.title === 'You gave the desk back');
+ assert.ok(entry && entry.status === 'done' && entry.ended_at, 'the same Dock step is done');
+ });
+
+ await check('a task with no Computer has nothing to take over and no still', async () => {
+ const other = await runningTask('And what is a mutex?');
+ assert.strictEqual((await t.get(`/api/v1/tasks/${other}/still`, { as: kim })).status, 404);
+ const r = await t.get(`/api/v1/tasks/${other}/takeover`, { as: kim, method: 'POST', headers: SAME });
+ assert.deepStrictEqual([r.status, r.json().code], [409, 'actor.task.no_computer']);
+ });
+ } finally {
+ await done(t);
+ }
+})();
@@ -409,7 +409,7 @@ const as = { requester: `user:${ME}` };
@@ -409,7 +409,7 @@ const as = { requester: `user:${ME}` };
const run = t.deskRun('tsk_1');
const run = t.deskRun('tsk_1');
const placed = JSON.parse(await t.call('computer_desk', { machine: 'any', action: 'start' }, { ...as, desks: run }));
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([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.deepStrictEqual(runCalls[0].body, { kind: 'browser', ttl_s: 3600, idle_s: 900, idempotency_key: 'actor:tsk_1', task_id: 'tsk_1' });
assert.strictEqual(runCalls[0].subject, ME, 'as the person');
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 }));
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');
assert.strictEqual(page.title, 'Example');
@@ -42,8 +42,10 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';
@@ -42,8 +42,10 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';
[agentMemoryId, agentId, owner, oldStyleText, 'agent', now]);
[agentMemoryId, agentId, owner, oldStyleText, 'agent', now]);
// 0017 on top of the running schema.
// 0017 on top of the running schema.
const second = await db.migrate({ dir: MIGRATIONS });
const second = await db.migrate({ dir: MIGRATIONS });
- // 0025 is the person's own model choice (plan L55 C3): it is a migration of its own, like every one before it.
- assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026', '0027', '0028', '0029', '0030', '0031', '0032', '0033']);
+ // Every migration after 0016 applies on top, in order: the list is read from the directory, so a new
+ // expand-only migration (like 0035, the timeline's Computer) does not need this test edited.
+ const later = fs.readdirSync(MIGRATIONS).filter((x) => x.endsWith('.sql') && x >= '0017').map((x) => x.slice(0, 4)).sort();
+ assert.deepStrictEqual(second.applied.map((m) => m.id), later);
await check('an old-style INSERT still works after 0017, and a 900-character row is intact', async () => {
await check('an old-style INSERT still works after 0017, and a 900-character row is intact', async () => {
const old = await db.maybe('SELECT id, text, origin, status, kind, uses FROM actor_agent_memory WHERE id = $1', [agentMemoryId]);
const old = await db.maybe('SELECT id, text, origin, status, kind, uses FROM actor_agent_memory WHERE id = $1', [agentMemoryId]);