openvibers/openvibe.actor Commits

Space's builder as a task of the person's one agent: the space_* tools (#314)

OP OpenVibers committed 6 days ago · f7281ade4e · 16 files, +476 −15

* Space's builder as a task of the person's one agent: the space_* tools

Six tools for the agent that builds a space (plan 2026-10-22 section Z, stage Z1):
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), with Actor's own service token and the person in
X-OV-Subject: another person's space is the same 404 as one that does not
exist, and every write goes through Space's own editor path, with its limits
and its audit.

Publishing is never done on its own: space_publish asks, the person answers in
the Dock, and the tool says the space is not published yet until they do.

* main into the space tools branch

* The SSRF ratchet: OpenVibe.Space's own base is reviewed too

The space tools fetch the configured OpenVibe.Space base (ACTOR_SPACE_URL,
loopback) with Actor's own service token; the person is named in
X-OV-Subject, the space id must be spc_<ULID> and the file path is
URL-encoded. No URL from the model or from a task is ever fetched here.

* The pins stay where the code can carry them

openvibe-contracts v0.321.0 knows the space.build.* capabilities this branch
uses; v0.322.0 and later also promise GET /api/v1/agent-systems, which this
service does not serve yet (that is another lane's work).

openvibe-shared stays at v4.129.0: the Dock's Agent panel grew, and this
service's page is 0.4 KB over its raw-JS budget with 4.134.0. The page does
not need the new Dock API; Space's page does, and Space carries it.

* Merge remote-tracking branch 'origin/main' into feature/z1-space-tools

Changes

ddiff --git a/.env.example b/.env.example
iindex 3ee48c5..ec94231 100644
---- a/.env.example
++++ b/.env.example
@@@ -89,6 +89,9 @@ ACTOR_RUNTIME_OUTPUT_USD_PER_MTOK=0
# The OpenVibe services the runtime acts through.
# ACTOR_SEARCH_URL=https://search.openvibe.network
# ACTOR_TOOLS_URL=https://openvibe.tools
++# OpenVibe.Space: the person's own spaces, which their agent builds (plan 2026-10-22 §Z stage Z1). With this
++# address the space_* tools are offered to a person's task; without it they are off.
++# ACTOR_SPACE_URL=http://127.0.0.1:4940
# The files and charts a task makes (Actor v2, stage V2-4) live in Actor's own PostgreSQL, private to the person who
# asked, and are served from the task's own page (server/runtime/artifacts.js says why they are not on OpenVibe.Media).
# The largest single file a task may make, and the most one task may hold.
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 5850568..9d26fcf 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,11 @@
What changed in OpenVibe.Actor, newest first. Each site also publishes its patch notes at /updates.
++## 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.
++- **Publishing waits for the person:** `space_publish` asks, and the answer lives in the Dock ("Needs you"); the tool says the space is not published yet until the person says yes.
++
## 0.2.0 — 2026-11-07
- **The one timeline behind the Dock** (design 2026-11-07): every mode of the agent (Chat, Codes, Run, Watch, Live, Surf) publishes the step it took as agent.step.started|updated|ended, and Actor keeps the one timeline: GET /api/v1/agent/timeline (cursor, mode and needs-you filters, the per-mode counts) and the live stream GET /api/v1/agent/timeline/stream. An entry is a title, an outcome and a link: the table has no column for a prompt or a diff, so none can be stored.
ddiff --git a/STATUS.json b/STATUS.json
iindex 9ebb4ed..b7aa5ed 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,13 +7,13 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4950,
"domain": "openvibe.actor",
-- "contracts": "openvibe-contracts v0.316.0",
++ "contracts": "openvibe-contracts v0.321.0",
"sdk": "openvibe-sdk v0.94.0",
-- "shared": "openvibe-shared v4.133.0",
++ "shared": "openvibe-shared v4.129.0",
"packages": [
-- "openvibe-contracts v0.316.0",
++ "openvibe-contracts v0.321.0",
"openvibe-sdk v0.94.0",
-- "openvibe-shared v4.133.0"
++ "openvibe-shared v4.129.0"
],
"summary": "Public at https://openvibe.actor (sudo ovhost deploy actor; unit openvibe-actor on 127.0.0.1:4950). A task in plain words is classified, routed by openvibe-sdk/placement to the agent system that can do it (the OpenVibe runtime acting through OpenVibe.Search and OpenVibe.Tools, OpenAI's web agent, an open model on OpenVibe's server for private mode), run with its steps streamed live, checked by a model of another family, and delivered as platform.task@1 with its cost to a fraction of a cent and the explanation; a failed agent or check hands it to the next agent. Free tier per person with an operator ceiling.",
"works": [
@@@ -31,7 +31,8 @@
"Personal agents: named instructions, editable memory, hourly/daily/weekly schedules, manual and scheduled runs, and their own approval inbox before work on the person's machine",
"Reach from Chat (A4): Chat starts the person's own agent for a message (POST /api/v1/chat-apps/tasks, svc:chat acting for a linked person); a direct message runs with their memory, a group with the stranger's text as untrusted data and no personal tools, and an acting tool under act_with_approvals waits for the person's decision. Shipped with Chat #358 and Network #591 (Actor #296)",
"MCP servers and A2A agents as tools (V2-11): a person connects their own MCP server by URL or an OpenVibe-offered one on /connections, turns each tool on, and the run calls it like any other tool; ask_agent talks to a connected A2A agent. Every call goes through the egress guard, is recorded as evidence with the server and tool as its source, and follows the run's approval policy (Actor #206)",
-- "Task webhooks on state changes: up to 8 https endpoints per task, signed with the task's own secret (or a project secret by reference), retried with backoff, with a delivery log on the task page and public addresses only"
++ "Task webhooks on state changes: up to 8 https endpoints per task, signed with the task's own secret (or a project secret by reference), retried with backoff, with a delivery log on the task page and public addresses only",
++ "Space's builder as a task of the person's one agent (plan 2026-10-22 §Z stage Z1): six space_* tools (create, list, read, write, preview, publish) work on the person's own spaces through OpenVibe.Space's builder API; another person's space is the same 404 as one that does not exist, and publishing waits for the person's answer in the Dock"
],
"notYet": [
"Browser and desktop agents on OpenVibe's own servers (OpenVibe.Run workers), watchable and taken over",
ddiff --git a/package-lock.json b/package-lock.json
iindex 620b908..f7ed079 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,9 +15,9 @@
"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.316.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.321.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.133.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -1146,9 +1146,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.316.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.316.0",
-- "integrity": "sha512-c+PZDmrq56rFyVXpYUhOlTpdvZntVJMA1bGOX9Amr72uobs8qh8G2DZDn31lYn56EGMMjPulhDAtvMxcTaxlFg==",
++ "version": "0.321.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.321.0",
++ "integrity": "sha512-x5h0xc9ieH61UziQIUoH9dBR8DWw2UHitMgFnboNkzGogHSGy7FeHjeK13Nw5zxMn8FZa67oR/qqRgWeuHQKxA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1190,9 +1190,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.133.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.133.0",
-- "integrity": "sha512-ry+yvht0O+fHhMQr2LOaQKRYJo38d2cgsI3UtRmHge91qEQ/gL0wtcHawME1/G7V0NrlpdGzJMueKYd8UKrhjw==",
++ "version": "4.129.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
++ "integrity": "sha512-wkBHrcR5C2mc+tmnIE4UVQaLztVNZtmg3xq1DQpaK1lqx3QshiVpBBAWkTvsaBMD5wOHxrXwfZ69pK6zCoFofQ==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 8a26ca6..b85c8e6 100644
---- a/package.json
++++ b/package.json
@@@ -28,9 +28,9 @@
"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.316.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.321.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.133.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/server/config.js b/server/config.js
iindex bb66874..777220d 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -163,6 +163,9 @@ function load(env = process.env) {
// OpenVibe.Run: a browser session on a desk host when none of the person's own machines has a desk for the
// task (Desks plan D6). Empty turns it off.
runUrl: trim(env.ACTOR_RUN_URL != null ? env.ACTOR_RUN_URL : (selfHosted ? '' : 'http://127.0.0.1:4920')),
++ // OpenVibe.Space: the person's own spaces, which their agent builds (plan 2026-10-22 §Z stage Z1).
++ // Empty turns the space tools off.
++ spaceUrl: trim(env.ACTOR_SPACE_URL != null ? env.ACTOR_SPACE_URL : (selfHosted ? '' : 'http://127.0.0.1:4940')),
},
// Artifacts (Actor v2, stage V2-4): what one task may make. The bytes live in Actor's own PostgreSQL,
@@@ -240,6 +243,13 @@ function load(env = process.env) {
replyIntervalMs: Math.max(200, int(env.ACTOR_CHAT_REPLY_INTERVAL_MS, 2000)),
},
++ // OpenVibe.Space, where the person answers the approval to publish a space their agent built (the Dock's
++ // approvals proxy asks the mode that owns the approval; agents/timeline.js productUrl). ACTOR_SPACE_URL
++ // points at it; without it the space tools are off.
++ space: {
++ url: trim(env.ACTOR_SPACE_URL != null ? env.ACTOR_SPACE_URL : (selfHosted ? '' : 'http://127.0.0.1:4940')),
++ },
++
// OpenVibe.Events → this service (server/events-consumer.js). The secret signs a delivery (comma-separated
// for rotation, 32+ characters each); unset turns POST /internal/events off (503). The url is where the
// network.account.export_requested and network.account.deleted subscriptions are created at boot, off when
ddiff --git a/server/runtime/tools/_space.js b/server/runtime/tools/_space.js
nnew file mode 100644
iindex 0000000..77d9180
---- /dev/null
++++ b/server/runtime/tools/_space.js
@@@ -0,0 +1,93 @@
++'use strict';
++
++/**
++ * OpenVibe.Space, from the tool modules (not a module itself: the registry skips files starting with "_").
++ *
++ * The agent works on the person's own spaces through Space's builder API (plan 2026-10-22 §Z stage Z1): the
++ * space_create, space_files_list, space_files_read, space_files_write, space_preview and space_publish tools.
++ *
++ * Actor calls Space with its own service token (audience openvibe.space, the capabilities space.build.*) and
++ * names the person in X-OV-Subject: Space then reads and writes only that person's own spaces, and another
++ * person's space is the same 404 as one that does not exist. Without the client secret or without a space
++ * address the tools are off (available() says so and the model never sees them).
++ *
++ * spaceFor(ctx) → { enabled, call(method, path, person, body) }
++ * call → { ok, status, data } — the answer as JSON, or { ok: false, status, error } when Space refused.
++ */
++
++const { serviceAuth } = require('openvibe-contracts');
++
++const SCOPE = 'space.build.read space.build.create space.build.write space.build.publish';
++const SPACE_ID = /^spc_[0-9A-HJKMNP-TV-Z]{26}$/;
++const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const TIMEOUT_MS = 20_000;
++
++const CACHE = new WeakMap();
++
++/** The person a task is for ('user:usr_…' → 'usr_…'), or null when the task has no person. */
++function personOf(requester) {
++ if (typeof requester !== 'string' || !requester.startsWith('user:')) return null;
++ const id = requester.slice(5);
++ return USER_SUBJECT.test(id) ? id : null;
++}
++
++function spaceFor(ctx) {
++ const config = ctx.config;
++ if (!config) throw new Error('a tool needs ctx.config');
++ const fetchImpl = ctx.fetch || globalThis.fetch;
++ // One client per (config, fetch): the fetch may be the caller's own, and each one has its own token.
++ let byFetch = CACHE.get(config);
++ if (!byFetch) { byFetch = new WeakMap(); CACHE.set(config, byFetch); }
++ if (byFetch.has(fetchImpl)) return byFetch.get(fetchImpl);
++ const base = String((config.services && config.services.spaceUrl) || '').replace(/\/+$/, '');
++ const clientSecret = config.oauth && config.oauth.clientSecret;
++ const enabled = Boolean(base && clientSecret);
++ const tokens = enabled ? serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
++ clientId: (config.oauth && config.oauth.clientId) || 'actor',
++ clientSecret,
++ audience: 'openvibe.space',
++ scope: SCOPE,
++ fetchImpl,
++ }) : null;
++ const space = {
++ enabled,
++ base,
++ /** One call to Space for one person. Never throws for a refusal: the model reads the answer. */
++ async call(method, path, person, body) {
++ if (!enabled) return { ok: false, status: 503, error: 'OpenVibe.Space is not connected to this server' };
++ if (!person) return { ok: false, status: 401, error: 'this work runs for you alone, so it needs you signed in' };
++ const headers = { Accept: 'application/json', 'X-OV-Subject': person, ...(await tokens.authHeaders()) };
++ if (body !== undefined) headers['Content-Type'] = 'application/json';
++ let res;
++ try {
++ res = await fetchImpl(`${base}${path}`, {
++ method,
++ headers,
++ body: body === undefined ? undefined : JSON.stringify(body),
++ signal: AbortSignal.timeout(TIMEOUT_MS),
++ });
++ } catch (err) {
++ return { ok: false, status: 502, error: `OpenVibe.Space did not answer: ${(err && err.message) || err}` };
++ }
++ const text = await res.text().catch(() => '');
++ let data = null;
++ try { data = text ? JSON.parse(text) : null; } catch { data = null; }
++ if (!res.ok) {
++ const detail = String((data && (data.detail || data.title || data.error)) || text || `OpenVibe.Space answered ${res.status}`).slice(0, 300);
++ return { ok: false, status: res.status, error: detail, data };
++ }
++ return { ok: true, status: res.status, data };
++ },
++ };
++ byFetch.set(fetchImpl, space);
++ return space;
++}
++
++/** The space id a tool call names, or null when it names none (or one that cannot exist). */
++function spaceIdOf(input) {
++ const id = String((input && input.space_id) || '').trim();
++ return SPACE_ID.test(id) ? id : null;
++}
++
++module.exports = { spaceFor, personOf, spaceIdOf, SCOPE, SPACE_ID, USER_SUBJECT };
ddiff --git a/server/runtime/tools/space_create.js b/server/runtime/tools/space_create.js
nnew file mode 100644
iindex 0000000..8ba8ad6
---- /dev/null
++++ b/server/runtime/tools/space_create.js
@@@ -0,0 +1,36 @@
++'use strict';
++
++/**
++ * Make one of the person's own spaces on OpenVibe.Space (plan 2026-10-22 §Z stage Z1).
++ *
++ * The space is made from Space's blank template; the agent then writes its files with space_files_write.
++ * Only the person's own spaces are in reach: Space reads the person in X-OV-Subject and refuses any space
++ * that is not theirs.
++ *
++ * This makes a space, so it is an acting call: the loop checkpoints the intent before it runs.
++ */
++
++const { spaceFor, personOf } = require('./_space');
++
++module.exports = {
++ name: 'space_create',
++ description: 'Make a new space of your own on OpenVibe.Space. The space starts from Space\'s blank template; write its files with space_files_write and show it with space_preview. Answers the space (its id, slug and name).',
++ input: { type: 'object', properties: { name: { type: 'string', description: 'the space\'s name, in a few words' }, description: { type: 'string', description: 'what the space does, in one line' } } },
++ scope: 'person',
++ costUsd: 0,
++ timeoutMs: 20_000,
++ acts: true,
++ available: (ctx) => Boolean(ctx.config && ctx.config.services && ctx.config.services.spaceUrl && ctx.config.oauth && ctx.config.oauth.clientSecret && personOf(ctx.requester)),
++ async run(input, ctx) {
++ const space = spaceFor(ctx);
++ if (!space.enabled) return { text: JSON.stringify({ error: 'OpenVibe.Space is not connected to this server' }), isError: true };
++ const person = personOf(ctx.requester);
++ const body = {};
++ if (input && input.name) body.name = String(input.name).slice(0, 40);
++ if (input && input.description) body.description = String(input.description).slice(0, 400);
++ const out = await space.call('POST', '/api/v1/builder/spaces', person, body);
++ if (!out.ok) return { text: JSON.stringify({ error: out.error }), isError: true };
++ const s = out.data && out.data.space;
++ return { text: JSON.stringify({ space: s || null }), data: { space: s || null } };
++ },
++};
ddiff --git a/server/runtime/tools/space_files_list.js b/server/runtime/tools/space_files_list.js
nnew file mode 100644
iindex 0000000..db38514
---- /dev/null
++++ b/server/runtime/tools/space_files_list.js
@@@ -0,0 +1,36 @@
++'use strict';
++
++/**
++ * The person's own spaces on OpenVibe.Space, and the files each one is made of (plan 2026-10-22 §Z stage Z1).
++ *
++ * With a space id, it lists that space's files; without one, it lists the person's spaces. Only their own
++ * spaces are in reach: Space reads the person in X-OV-Subject and refuses any space that is not theirs.
++ */
++
++const { spaceFor, personOf, spaceIdOf } = require('./_space');
++
++module.exports = {
++ name: 'space_files_list',
++ description: 'List the files of one of your spaces on OpenVibe.Space, or (with no space id) the spaces you own. Answers the file paths, and for a list of spaces each space\'s id, name and where it lives.',
++ input: { type: 'object', properties: { space_id: { type: 'string', description: 'the space id (spc_…); leave it out to list your spaces' } } },
++ scope: 'person',
++ costUsd: 0,
++ timeoutMs: 20_000,
++ available: (ctx) => Boolean(ctx.config && ctx.config.services && ctx.config.services.spaceUrl && ctx.config.oauth && ctx.config.oauth.clientSecret && personOf(ctx.requester)),
++ async run(input, ctx) {
++ const space = spaceFor(ctx);
++ if (!space.enabled) return { text: JSON.stringify({ error: 'OpenVibe.Space is not connected to this server' }), isError: true };
++ const person = personOf(ctx.requester);
++ const id = spaceIdOf(input);
++ if (!id) {
++ const out = await space.call('GET', '/api/v1/builder/spaces', person);
++ if (!out.ok) return { text: JSON.stringify({ error: out.error }), isError: true };
++ const spaces = (out.data && out.data.spaces) || [];
++ return { text: JSON.stringify({ spaces }), data: { spaces } };
++ }
++ const out = await space.call('GET', `/api/v1/builder/spaces/${id}/preview`, person);
++ if (!out.ok) return { text: JSON.stringify({ error: out.error }), isError: true };
++ const files = (out.data && out.data.files) || [];
++ return { text: JSON.stringify({ space_id: id, files, url: (out.data && out.data.url) || null }), data: { files } };
++ },
++};
ddiff --git a/server/runtime/tools/space_files_read.js b/server/runtime/tools/space_files_read.js
nnew file mode 100644
iindex 0000000..29dbe6f
---- /dev/null
++++ b/server/runtime/tools/space_files_read.js
@@@ -0,0 +1,33 @@
++'use strict';
++
++/**
++ * Read one file of one of the person's own spaces on OpenVibe.Space (plan 2026-10-22 §Z stage Z1).
++ *
++ * The file comes back whole. Only the person's own spaces are in reach: Space reads the person in
++ * X-OV-Subject and refuses any space that is not theirs.
++ */
++
++const { spaceFor, personOf, spaceIdOf } = require('./_space');
++
++module.exports = {
++ name: 'space_files_read',
++ description: 'Read one file of one of your spaces on OpenVibe.Space (its whole content). Name the space (spc_…) and the file path (for example index.html).',
++ input: { type: 'object', properties: { space_id: { type: 'string', description: 'the space id (spc_…)' }, path: { type: 'string', description: 'the file\'s path inside the space' } }, required: ['space_id', 'path'] },
++ scope: 'person',
++ costUsd: 0,
++ timeoutMs: 20_000,
++ available: (ctx) => Boolean(ctx.config && ctx.config.services && ctx.config.services.spaceUrl && ctx.config.oauth && ctx.config.oauth.clientSecret && personOf(ctx.requester)),
++ async run(input, ctx) {
++ const space = spaceFor(ctx);
++ if (!space.enabled) return { text: JSON.stringify({ error: 'OpenVibe.Space is not connected to this server' }), isError: true };
++ const person = personOf(ctx.requester);
++ const id = spaceIdOf(input);
++ if (!id) return { text: JSON.stringify({ error: 'that is not a space id (spc_…)' }), isError: true };
++ const path = String((input && input.path) || '').trim().replace(/^\.?\//, '');
++ if (!path) return { text: JSON.stringify({ error: 'name the file to read' }), isError: true };
++ const out = await space.call('GET', `/api/v1/builder/spaces/${id}/files/${encodeURIComponent(path)}`, person);
++ if (!out.ok) return { text: JSON.stringify({ error: out.error }), isError: true };
++ const files = (out.data && out.data.files) || [];
++ return { text: JSON.stringify({ space_id: id, files }), data: { files } };
++ },
++};
ddiff --git a/server/runtime/tools/space_files_write.js b/server/runtime/tools/space_files_write.js
nnew file mode 100644
iindex 0000000..368f80b
---- /dev/null
++++ b/server/runtime/tools/space_files_write.js
@@@ -0,0 +1,53 @@
++'use strict';
++
++/**
++ * Write the files of one of the person's own spaces on OpenVibe.Space (plan 2026-10-22 §Z stage Z1).
++ *
++ * The files in the call replace the space's files as a whole, so the agent sends every file it wants the
++ * space to have. Space checks the same rules as its own editor (an index.html, safe paths, the file and byte
++ * limits) and writes the space's files as the person who owns it.
++ *
++ * This changes the space, so it is an acting call: the loop checkpoints the intent before it runs.
++ */
++
++const { spaceFor, personOf, spaceIdOf } = require('./_space');
++
++module.exports = {
++ name: 'space_files_write',
++ description: 'Write the files of one of your spaces on OpenVibe.Space. The files you send replace the space\'s files as a whole, so send every file the space should have (index.html first). Answers the files as they were written.',
++ input: {
++ type: 'object',
++ properties: {
++ space_id: { type: 'string', description: 'the space id (spc_…)' },
++ files: {
++ type: 'array',
++ description: 'the files the space is made of',
++ items: {
++ type: 'object',
++ properties: { path: { type: 'string', description: 'the file\'s path inside the space (for example index.html)' }, content: { type: 'string', description: 'the file\'s whole content' } },
++ required: ['path', 'content'],
++ },
++ },
++ },
++ required: ['space_id', 'files'],
++ },
++ scope: 'person',
++ costUsd: 0,
++ timeoutMs: 30_000,
++ acts: true,
++ available: (ctx) => Boolean(ctx.config && ctx.config.services && ctx.config.services.spaceUrl && ctx.config.oauth && ctx.config.oauth.clientSecret && personOf(ctx.requester)),
++ async run(input, ctx) {
++ const space = spaceFor(ctx);
++ if (!space.enabled) return { text: JSON.stringify({ error: 'OpenVibe.Space is not connected to this server' }), isError: true };
++ const person = personOf(ctx.requester);
++ const id = spaceIdOf(input);
++ if (!id) return { text: JSON.stringify({ error: 'that is not a space id (spc_…)' }), isError: true };
++ const list = Array.isArray(input && input.files) ? input.files : [];
++ if (!list.length) return { text: JSON.stringify({ error: 'send the files the space is made of' }), isError: true };
++ const files = list.slice(0, 20).map((f) => ({ path: String((f && f.path) || '').slice(0, 80), content: String((f && f.content) || '').slice(0, 200000) }));
++ const out = await space.call('PUT', `/api/v1/builder/spaces/${id}/files`, person, { files });
++ if (!out.ok) return { text: JSON.stringify({ error: out.error }), isError: true };
++ const written = (out.data && out.data.files) || [];
++ return { text: JSON.stringify({ space_id: id, files: written.map((f) => ({ path: f.path, bytes: String(f.content || '').length })) }), data: { files: written } };
++ },
++};
ddiff --git a/server/runtime/tools/space_preview.js b/server/runtime/tools/space_preview.js
nnew file mode 100644
iindex 0000000..72c803e
---- /dev/null
++++ b/server/runtime/tools/space_preview.js
@@@ -0,0 +1,31 @@
++'use strict';
++
++/**
++ * Where one of the person's own spaces can be seen right now (plan 2026-10-22 §Z stage Z1).
++ *
++ * While the space is a draft, its preview is the editor on openvibe.space, which shows the space as it is
++ * being built; once it is published, its own address. Only the person's own spaces are in reach.
++ */
++
++const { spaceFor, personOf, spaceIdOf } = require('./_space');
++
++module.exports = {
++ name: 'space_preview',
++ description: 'Show one of your spaces on OpenVibe.Space: where it can be seen now (the editor with its live preview while it is a draft, its own address once it is published), the files it is made of, and whether it is live.',
++ input: { type: 'object', properties: { space_id: { type: 'string', description: 'the space id (spc_…)' } }, required: ['space_id'] },
++ scope: 'person',
++ costUsd: 0,
++ timeoutMs: 20_000,
++ available: (ctx) => Boolean(ctx.config && ctx.config.services && ctx.config.services.spaceUrl && ctx.config.oauth && ctx.config.oauth.clientSecret && personOf(ctx.requester)),
++ async run(input, ctx) {
++ const space = spaceFor(ctx);
++ if (!space.enabled) return { text: JSON.stringify({ error: 'OpenVibe.Space is not connected to this server' }), isError: true };
++ const person = personOf(ctx.requester);
++ const id = spaceIdOf(input);
++ if (!id) return { text: JSON.stringify({ error: 'that is not a space id (spc_…)' }), isError: true };
++ const out = await space.call('GET', `/api/v1/builder/spaces/${id}/preview`, person);
++ if (!out.ok) return { text: JSON.stringify({ error: out.error }), isError: true };
++ const data = out.data || {};
++ return { text: JSON.stringify({ space_id: id, url: data.url || null, files: data.files || [], published: Boolean(data.published) }), data };
++ },
++};
ddiff --git a/server/runtime/tools/space_publish.js b/server/runtime/tools/space_publish.js
nnew file mode 100644
iindex 0000000..ab1009c
---- /dev/null
++++ b/server/runtime/tools/space_publish.js
@@@ -0,0 +1,44 @@
++'use strict';
++
++/**
++ * Ask to publish one of the person's own spaces on OpenVibe.Space (plan 2026-10-22 §Z stage Z1).
++ *
++ * Publishing puts the space on the open web at its own address, so the agent never does it on its own: it
++ * asks, and the person answers in the Dock (the "Needs you" card) or on the space's page. Until they answer,
++ * nothing goes live and the answer says so.
++ *
++ * This changes the world, so it is an acting call: the loop checkpoints the intent before it runs.
++ */
++
++const { spaceFor, personOf, spaceIdOf } = require('./_space');
++
++module.exports = {
++ name: 'space_publish',
++ description: 'Ask to publish one of your spaces on OpenVibe.Space. The person answers before anything goes live, so this answers "waiting for your approval" until they do. Tell them what the space is and where they can see it.',
++ input: { type: 'object', properties: { space_id: { type: 'string', description: 'the space id (spc_…)' } }, required: ['space_id'] },
++ scope: 'person',
++ costUsd: 0,
++ timeoutMs: 20_000,
++ acts: true,
++ available: (ctx) => Boolean(ctx.config && ctx.config.services && ctx.config.services.spaceUrl && ctx.config.oauth && ctx.config.oauth.clientSecret && personOf(ctx.requester)),
++ async run(input, ctx) {
++ const space = spaceFor(ctx);
++ if (!space.enabled) return { text: JSON.stringify({ error: 'OpenVibe.Space is not connected to this server' }), isError: true };
++ const person = personOf(ctx.requester);
++ const id = spaceIdOf(input);
++ if (!id) return { text: JSON.stringify({ error: 'that is not a space id (spc_…)' }), isError: true };
++ const out = await space.call('POST', `/api/v1/builder/spaces/${id}/publish`, person, {});
++ if (!out.ok) return { text: JSON.stringify({ error: out.error }), isError: true };
++ const data = out.data || {};
++ if (data.state === 'published') return { text: JSON.stringify({ space_id: id, state: 'published', url: data.url || null }), data };
++ return {
++ text: JSON.stringify({
++ space_id: id,
++ state: data.state || 'pending',
++ approval_id: data.approval_id || null,
++ message: 'Not published yet: the person has to approve it first. Say what the space is and where they can see it, and stop here.',
++ }),
++ data,
++ };
++ },
++};
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex f5fdd53..8cf7f3a 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -117,6 +117,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'server/waits/events.js': 'the configured OpenVibe.Events base (ACTOR_EVENTS_URL) with the service token (events.subscription.manage, events.event.read): the topic patterns and the cursors come from the wait rows, never from a task (test/waits.test.js)', 'server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',
'server/runtime/models.js': 'the configured provider base URLs (OpenAI, DeepSeek, the runtime, any OpenAI-compatible or local endpoint) with the operator\'s keys, only to list models at boot and to call chat completions; never a URL from a task (test/runtime-models.test.js)',
'server/runtime/tools/_openvibe.js': 'the configured OpenVibe.Search, OpenVibe.Tools and OpenVibe.Bot bases with Actor\'s own service token; a page URL is sent to Tools as input, never fetched here (test/runtime-tools.test.js)',
++ 'server/runtime/tools/_space.js': 'the configured OpenVibe.Space base (ACTOR_SPACE_URL, loopback) with Actor\'s own service token (space.build.read|create|write|publish): the person is named in X-OV-Subject, the space id must be spc_<ULID> and the file path is URL-encoded; no URL from the model or from a task is ever fetched here (test/space-tools.test.js)',
'server/runtime/copilot.js': 'OpenVibe.Chat (OV_CHAT_URL/config, configured) with Actor\'s own service token for the room digest, answer and poll, and OpenVibe.Live for the state, copilot-ready and step calls; the stream id must be ags_<ULID> and is URL-encoded; no URL from a person or a chat line (test/agent-copilot.test.js)',
'server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot',
'server/net/secret-signer.js': 'OpenVibe.Network: the configured networkInternalUrl (OV_NETWORK_INTERNAL_URL), POST /internal/secrets/use to sign a webhook delivery with a project secret (ADR-056); the body is the timestamp and the delivery, never a URL',
ddiff --git a/test/space-tools.test.js b/test/space-tools.test.js
nnew file mode 100644
iindex 0000000..65a498f
---- /dev/null
++++ b/test/space-tools.test.js
@@@ -0,0 +1,107 @@
++'use strict';
++/**
++ * Space's builder as a task of the person's one agent (plan 2026-10-22 §Z stage Z1): the space_* tools.
++ *
++ * The agent works on the person's own spaces through Space's builder API. Actor calls Space with its own service
++ * token and names the person in X-OV-Subject, so Space reads and writes only that person's spaces. Publishing
++ * never happens without the person's answer: the tool asks and waits.
++ */
++const assert = require('assert');
++const { createRegistry } = require('../server/runtime/tools');
++const { check, done } = require('./helpers/boot');
++
++const ME = 'usr_01M4W5JBFEG3QG9ER8W1A2K0RX';
++const OTHER = 'usr_01M4W5JBFEG3QG9ER8W1A2K0RY';
++const SPACE = 'spc_01M4XENCR31NXDJQYDCCW2J9R9';
++
++const config = {
++ networkInternalUrl: 'http://network.test',
++ oauth: { clientId: 'actor', clientSecret: 'actor-secret' },
++ services: { spaceUrl: 'http://space.test' },
++};
++
++/** A fake OpenVibe.Space: it answers the token request, and records every call it gets. */
++function fakeSpace(handler) {
++ const calls = [];
++ const reply = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
++ const fetchImpl = async (url, init = {}) => {
++ const u = new URL(url);
++ if (u.pathname === '/oauth/token') return reply(200, { access_token: 'svc-space-token', expires_in: 300 });
++ const headers = init.headers || {};
++ calls.push({ method: init.method || 'GET', path: u.pathname, subject: headers['X-OV-Subject'], auth: headers.Authorization, body: init.body ? JSON.parse(init.body) : null });
++ return handler({ method: init.method || 'GET', path: u.pathname, body: init.body ? JSON.parse(init.body) : null }, reply);
++ };
++ return { calls, fetchImpl };
++}
++
++const ctxFor = (fetchImpl, requester = `user:${ME}`) => ({ config, fetch: fetchImpl, requester, signal: undefined, log: console });
++
++(async () => {
++ await check('the space tools are offered to a signed-in person and to nobody else', async () => {
++ const registry = createRegistry({ strict: true, log: { warn() {}, log() {} } });
++ const names = registry.definitions({ requester: `user:${ME}`, config }).map((d) => d.function.name);
++ for (const tool of ['space_create', 'space_files_list', 'space_files_read', 'space_files_write', 'space_preview', 'space_publish']) {
++ assert.ok(names.includes(tool), `${tool} is offered to a signed-in person`);
++ }
++ const anonymous = registry.definitions({ requester: null, config }).map((d) => d.function.name);
++ assert.ok(!anonymous.includes('space_files_write'), 'a task with no person never sees the space tools');
++ assert.ok(!registry.get('space_files_write').available({ config: { services: {}, oauth: {} }, requester: `user:${ME}` }), 'without a Space address the tools are off');
++ });
++
++ await check('writing a space\'s files goes to Space as the person, with Actor\'s own token', async () => {
++ const space = fakeSpace(({ path, body }, reply) => (path === `/api/v1/builder/spaces/${SPACE}/files` ? reply(200, { files: body.files }) : reply(404, { detail: 'no such space' })));
++ const registry = createRegistry({ strict: true, log: { warn() {}, log() {} } });
++ const out = await registry.run('space_files_write', { space_id: SPACE, files: [{ path: 'index.html', content: '<h1>Hi</h1>' }] }, ctxFor(space.fetchImpl));
++ const call = space.calls[0];
++ assert.strictEqual(call.method, 'PUT');
++ assert.strictEqual(call.path, `/api/v1/builder/spaces/${SPACE}/files`);
++ assert.strictEqual(call.subject, ME, 'the person is named for Space');
++ assert.strictEqual(call.auth, 'Bearer svc-space-token', 'the call carries Actor\'s own service token');
++ assert.deepStrictEqual(call.body.files, [{ path: 'index.html', content: '<h1>Hi</h1>' }]);
++ assert.ok(!out.isError, out.text);
++ assert.ok(out.text.includes('index.html'), out.text);
++ });
++
++ await check('another person\'s space is refused, and nothing is written', async () => {
++ const space = fakeSpace(({ path }, reply) => reply(404, { detail: 'no such space' }));
++ const registry = createRegistry({ strict: true, log: { warn() {}, log() {} } });
++ const out = await registry.run('space_files_write', { space_id: SPACE, files: [{ path: 'index.html', content: 'x' }] }, ctxFor(space.fetchImpl));
++ assert.ok(out.isError, 'the refusal is an error the model reads');
++ assert.ok(out.text.includes('no such space'), out.text);
++ assert.strictEqual(space.calls[0].subject, ME, 'the call still names the person, never another one');
++ });
++
++ await check('a task with no person cannot touch a space at all', async () => {
++ const space = fakeSpace(() => { throw new Error('Space must not be called'); });
++ const registry = createRegistry({ strict: true, log: { warn() {}, log() {} } });
++ const out = await registry.run('space_files_write', { space_id: SPACE, files: [{ path: 'index.html', content: 'x' }] }, ctxFor(space.fetchImpl, null));
++ assert.ok(out.isError && /signed in|not available/.test(out.text), out.text);
++ assert.strictEqual(space.calls.length, 0, 'Space was never called');
++ });
++
++ await check('publishing asks first: the person answers before anything goes live', async () => {
++ const space = fakeSpace(({ path }, reply) => (path === `/api/v1/builder/spaces/${SPACE}/publish` ? reply(200, { state: 'pending', approval_id: 'apr_01M4XENCR31NXDJQYDCCW2J9R9', url: null }) : reply(404, { detail: 'no such space' })));
++ const registry = createRegistry({ strict: true, log: { warn() {}, log() {} } });
++ const out = await registry.run('space_publish', { space_id: SPACE }, ctxFor(space.fetchImpl));
++ assert.ok(!out.isError, out.text);
++ assert.ok(out.text.includes('approve'), out.text);
++ assert.ok(out.text.includes('Not published yet'), out.text);
++ assert.strictEqual(space.calls[0].method, 'POST');
++ assert.strictEqual(space.calls[0].path, `/api/v1/builder/spaces/${SPACE}/publish`);
++ });
++
++ await check('the preview says where the space can be seen, and the list says which spaces are theirs', async () => {
++ const space = fakeSpace(({ path }, reply) => {
++ if (path === `/api/v1/builder/spaces/${SPACE}/preview`) return reply(200, { url: 'https://openvibe.space/spaces/podcast/edit', files: ['index.html'], published: false });
++ if (path === '/api/v1/builder/spaces') return reply(200, { spaces: [{ id: SPACE, name: 'Podcast' }] });
++ return reply(404, { detail: 'no such space' });
++ });
++ const registry = createRegistry({ strict: true, log: { warn() {}, log() {} } });
++ const preview = await registry.run('space_preview', { space_id: SPACE }, ctxFor(space.fetchImpl));
++ assert.ok(preview.text.includes('openvibe.space/spaces/podcast/edit'), preview.text);
++ const list = await registry.run('space_files_list', {}, ctxFor(space.fetchImpl));
++ assert.ok(list.text.includes(SPACE), list.text);
++ });
++
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/tool-acts.test.js b/test/tool-acts.test.js
iindex 6681321..2039272 100644
---- a/test/tool-acts.test.js
++++ b/test/tool-acts.test.js
@@@ -46,6 +46,14 @@ const EXPECTED = {
remember: true,
robot_steps: true,
run_tool: true,
++ // Space: the agent builds the person's own spaces (plan 2026-10-22 §Z stage Z1). Making one, writing its files
++ // and asking to publish it change the world; listing and reading them do not.
++ space_create: true,
++ space_files_list: false,
++ space_files_read: false,
++ space_files_write: true,
++ space_preview: false,
++ space_publish: true,
};
(async () => {