openvibers/openvibe.actor Commits

One Computer: the task's step, Take over / Give it back, and the still (#320)

OP OpenVibers committed 6 days ago · 1a45f54e1f · 19 files, +369 −28

* One Computer: the task's record and step, Take over / Give it back, the still

When a task opens a desk, computer_desk's own answer carries
{ session_id, machine, where, cost_hint }; the loop puts it on the tool_result
and GET /api/v1/tasks/:id reads it back, with driving while the person holds
the desk. A Run session's id is the one id; a desk on the person's own machine
through Bot has no Run session and says null.

POST /tasks/:id/takeover and /giveback, built on the hand_to_person machinery:
while the person drives, computer_input and the acting browser actions are
refused with a reason, the task's timeline gets one handoff step and the
Dock's timeline one step with the same Computer.

GET /tasks/:id/still fetches the session's newest frame from its own service
(Run) as Actor acting for the person; owner-only (another person, a signed-out
visitor and an app token all get 404), private, no-store. Migration 0034 adds
the timeline's computer column.

* Pin openvibe-contracts to the final Computer contracts head

* The still route keeps its actor.task.read guard behind the 404-first person check

* A Run session names its task in the create request

* Security review: the still passes only an image, and the migration is 0035

Actor's still accepts image/jpeg, image/png or image/webp from the session's
service and nothing else: any other content type is 502 still.bad_type with no
body passed through, so a broken or hostile upstream can never serve HTML from
openvibe.actor. Test: an upstream answering text/html gets 502.

The timeline migration is numbered 0035: another open PR takes 0034.

* Pin openvibe-contracts v0.327.0 (the release tag)

* Merge origin/main into the one-Computer branch

* The N-1 migration test reads the migration list from the directory

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 9d26fcf..5baa784 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
ddiff --git a/README.md b/README.md
iindex 407bbc6..336e0ed 100644
---- a/README.md
++++ b/README.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).
<!-- versions:start -->
--- openvibe-contracts: v0.321.0
++- openvibe-contracts: v0.327.0
- openvibe-sdk: v0.94.0
- openvibe-shared: v4.135.0
<!-- versions:end -->
ddiff --git a/migrations/0035_agent_timeline_computer.sql b/migrations/0035_agent_timeline_computer.sql
nnew file mode 100644
iindex 0000000..4110590
---- /dev/null
++++ b/migrations/0035_agent_timeline_computer.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex f3263bb..69cbe81 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 1ea4dc6..6ed86c2 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/agents/computers.js b/server/agents/computers.js
iindex 85b9beb..37618b3 100644
---- a/server/agents/computers.js
++++ b/server/agents/computers.js
@@@ -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: tokensO
if (!USER_SUBJECT.test(subject)) return { error: 'a session is only for a signed-in person' };
const body = { kind: 'browser', ttl_s: Math.min(14400, Math.max(60, Math.round(ttlS))), idle_s: 900,
...(Number.isInteger(width) ? { width } : {}), ...(Number.isInteger(height) ? { height } : {}),
-- ...(taskId ? { idempotency_key: `actor:${taskId}` } : {}) };
++ // 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: tokensO
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 } = {}) {
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: tokensO
return { url: data.url, expires_at: data.expires_at || null, control: !!data.control, desk: data.desk || desk };
}
-- return { enabled, mine, list, read, run, write, screen, input, browser, desk, deskTurn, watchUrl, watchLink, openSession, endSession, 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 };
ddiff --git a/server/agents/driving.js b/server/agents/driving.js
nnew file mode 100644
iindex 0000000..e6c863a
---- /dev/null
++++ b/server/agents/driving.js
@@@ -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 };
ddiff --git a/server/agents/timeline.js b/server/agents/timeline.js
iindex 3dae7bf..991abd1 100644
---- a/server/agents/timeline.js
++++ b/server/agents/timeline.js
@@@ -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 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 SET
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,
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.computer
RETURNING *`,
[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;
}
ddiff --git a/server/app.js b/server/app.js
iindex 0d69600..f4729e2 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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;
ddiff --git a/server/http/api.js b/server/http/api.js
iindex a6f922b..548600b 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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) => {
ddiff --git a/server/runtime/loop.js b/server/runtime/loop.js
iindex 104d548..6dc62ec 100644
---- a/server/runtime/loop.js
++++ b/server/runtime/loop.js
@@@ -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));
};
ddiff --git a/server/runtime/tools/_person.js b/server/runtime/tools/_person.js
iindex 8034664..a2a2ecc 100644
---- a/server/runtime/tools/_person.js
++++ b/server/runtime/tools/_person.js
@@@ -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,
};
ddiff --git a/server/runtime/tools/computer_browser.js b/server/runtime/tools/computer_browser.js
iindex 8c8e17f..a34bc5e 100644
---- a/server/runtime/tools/computer_browser.js
++++ b/server/runtime/tools/computer_browser.js
@@@ -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 };
ddiff --git a/server/runtime/tools/computer_desk.js b/server/runtime/tools/computer_desk.js
iindex 109c3cf..f37880a 100644
---- a/server/runtime/tools/computer_desk.js
++++ b/server/runtime/tools/computer_desk.js
@@@ -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 } : {}) };
},
};
ddiff --git a/server/runtime/tools/computer_input.js b/server/runtime/tools/computer_input.js
iindex b5c6352..9008501 100644
---- a/server/runtime/tools/computer_input.js
++++ b/server/runtime/tools/computer_input.js
@@@ -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 };
ddiff --git a/server/tasks/store.js b/server/tasks/store.js
iindex ab30603..628753d 100644
---- a/server/tasks/store.js
++++ b/server/tasks/store.js
@@@ -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 };
ddiff --git a/test/computer-handoff.test.js b/test/computer-handoff.test.js
nnew file mode 100644
iindex 0000000..a4c13ae
---- /dev/null
++++ b/test/computer-handoff.test.js
@@@ -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);
++ }
++})();
ddiff --git a/test/computers.test.js b/test/computers.test.js
iindex 158b9d8..7b59b7c 100644
---- a/test/computers.test.js
++++ b/test/computers.test.js
@@@ -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');
ddiff --git a/test/memory-migration.test.js b/test/memory-migration.test.js
iindex c24fe1c..134bce3 100644
---- a/test/memory-migration.test.js
++++ b/test/memory-migration.test.js
@@@ -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]);