openvibers/openvibe.actor Commits

Actor: coding tasks go to OpenVibe.Codes and run on the person's own machine (the codes agent) (#50)

OP OpenVibers committed 30 days ago · 454de4b6cb · 10 files, +261 −10

Plan T16/T17 ("codes can be used by actor"): Actor's `codes` agent stops refusing coding tasks. It hands them to OpenVibe.Codes, which runs them on the person's own machine. Depends on Codes#65 and Network#180, both deployed.

**`server/agents/codes.js`**
- Starts the task with Actor's own service token (`openvibe.codes`, `codes.task.*`) and `X-OV-Subject` naming the person. The Actor task id is the idempotency key, so a hand-off retry never starts a second job.
- Takes the repository from the task text: the first https owner/name URL.
- Reports each state change, waits for the end, and answers in Markdown: summary, changed files, the patch (first 3 KB), the push link when there is one, and the task's page.
- **When the machine is still working near Actor's 180 s task time,** the answer says so and links the live page; the Codes task keeps running.
- **Cancelling** the Actor task cancels the Codes task.
- **Refusals that say what to do:**
  - signed out;
  - no repository named;
  - no `/run` task yet (409 `codes.no_project` → "add the machine on openvibe.bot/machines, then start one task on openvibe.codes/run");
  - expired (no machine of theirs took it).
- Task ids are checked (`tsk_<ULID>`) before any goes into a path. Added to the SSRF ratchet.

**Elsewhere**
- **Catalog:** `codes` is available when `ACTOR_CODES_URL` (default 127.0.0.1:4900) and the client secret are set. Its rate card is 0, since the model use is on the person's machine and keys.
- **Check:** `codes` answers are read by DeepSeek or OpenAI. The checker's instructions accept a coding report, or a still-running answer with its link.
- **Engine:** passes `taskId` to adapters.
- **Router:** the no-agent text no longer says hosted runs are not open.
- **Home:** the "What works today" row for coding tasks is current.

**Tests**
- `codes` (6 checks): repository parsing; the start as the person keyed by the task; the answer; the three refusals; the live-page answer past the deadline; expired; cancel; catalog and checker.
- `security-ssrf` ratchet.
- Suite: 26/26.

Changes

ddiff --git a/server/agents/adapters.js b/server/agents/adapters.js
iindex fc7abec..86df040 100644
---- a/server/agents/adapters.js
++++ b/server/agents/adapters.js
@@@ -10,9 +10,11 @@
* signed-in person's task also gets their own robots (agents/robots.js)
* openai-agent OpenAI's Responses agent with its hosted web search
* open-model the open model on OpenVibe's own server, one call, no tools
++ * codes OpenVibe.Codes: the person's coding task on their own machine (agents/codes.js)
*/
const { chatClient, responsesAgent, ProviderError } = require('./providers');
const { createOpenVibeTools } = require('./tools');
++const { createCodes } = require('./codes');
const MAX_TURNS = 10;
@@@ -34,7 +36,7 @@ const summarize = (args) => {
return String(v).slice(0, 300);
};
--function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = console, tools: toolsOverride }) {
++function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = console, tools: toolsOverride, codes: codesOverride }) {
const p = config.providers;
const ovTools = toolsOverride || createOpenVibeTools({ config, fetchImpl, log });
@@@ -94,7 +96,8 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
return { text: r.text.trim(), sources: [], model: r.model };
}
-- const run = { 'openvibe-runtime': runtime, 'openai-agent': openai, 'open-model': openModel };
++ const codes = codesOverride || createCodes({ config, fetchImpl });
++ const run = { 'openvibe-runtime': runtime, 'openai-agent': openai, 'open-model': openModel, codes: codes.adapter };
/** A cheap model of another family to classify and to check results (null when none is configured). */
const checkers = {
ddiff --git a/server/agents/catalog.js b/server/agents/catalog.js
iindex 68ae2f4..38ffe6d 100644
---- a/server/agents/catalog.js
++++ b/server/agents/catalog.js
@@@ -76,8 +76,9 @@ const AGENTS = [
trust: 'first-party',
quality: 'high',
can: ['task:code'],
-- rate_card: { input_usd_per_mtok: 0.30, output_usd_per_mtok: 1.20, source: 'https://openvibe.codes/harnesses', verified_at: VERIFIED },
-- blurb: 'Coding work goes to OpenVibe.Codes, the coding-agent harness (Claude Code, Codex, OpenCode, DeepSeek and more). Hosted coding runs come with OpenVibe.Run; today, run openvibe-codes on your own machine.',
++ // The model use is on the person's own machine and keys: nothing for Actor to meter.
++ rate_card: { input_usd_per_mtok: 0, output_usd_per_mtok: 0, source: 'https://openvibe.codes/run', verified_at: VERIFIED },
++ blurb: 'Coding work goes to OpenVibe.Codes and runs on your own machine (add it on openvibe.bot/machines, then start one task on openvibe.codes/run). Name the repository; you get the summary, the changed files and the patch. The model use is on your machine\'s own keys, so Actor charges nothing for it.',
},
];
@@@ -87,7 +88,10 @@ function availability(agent, config) {
if (agent.providerKey === 'deepseek') return p.deepseek && p.deepseek.apiKey ? { available: true } : { available: false, reason: 'no DeepSeek key is configured on this server' };
if (agent.providerKey === 'openai') return p.openai && p.openai.apiKey ? { available: true } : { available: false, reason: 'no OpenAI key is configured on this server' };
if (agent.providerKey === 'local') return p.local && p.local.url && p.local.model ? { available: true } : { available: false, reason: 'no open model is running on this server' };
-- if (agent.providerKey === 'codes') return { available: false, reason: 'hosted coding runs need OpenVibe.Run sandboxes, which are not open yet; run openvibe-codes on your own machine (openvibe.codes/start)' };
++ if (agent.providerKey === 'codes') {
++ const s = config.services || {};
++ return s.codesUrl && config.oauth && config.oauth.clientSecret ? { available: true } : { available: false, reason: 'OpenVibe.Codes is not connected to this server' };
++ }
return { available: false, reason: 'no adapter' };
}
ddiff --git a/server/agents/check.js b/server/agents/check.js
iindex bd67da1..d0cf080 100644
---- a/server/agents/check.js
++++ b/server/agents/check.js
@@@ -11,14 +11,16 @@
* empty answer or one about something else fails.
*/
--const FAMILY = { 'openvibe-runtime': 'deepseek', 'openai-agent': 'openai', 'open-model': 'local' };
--const CHECKER_FOR = { deepseek: ['openai', 'deepseek'], openai: ['deepseek', 'openai'], local: ['local'] };
++// codes: the person's own machine wrote it (any of Codes' agents), so either family reads it.
++const FAMILY = { 'openvibe-runtime': 'deepseek', 'openai-agent': 'openai', 'open-model': 'local', codes: 'codes' };
++const CHECKER_FOR = { deepseek: ['openai', 'deepseek'], openai: ['deepseek', 'openai'], local: ['local'], codes: ['deepseek', 'openai'] };
const SYSTEM = `You check an AI agent's answer before a person sees it. Reply with JSON only: {"ok": true|false, "reason": "<one short sentence>"}.
ok is true when the answer addresses the task and nothing in it is clearly invented, contradictory or unsafe. It does not have to be perfect.
ok is false when the answer is empty, refuses without a good reason, answers a different question, contradicts itself, or states something that is plainly impossible.
The agent may have searched the web or run tools: do not fail an answer only because it states recent facts, versions, prices or dates you cannot verify yourself, especially when it cites a source for them.
The agent may have made the person's own robot act through OpenVibe.Bot: an answer that reports what the robot did, or why it stopped or was refused, addresses the task.
++The agent may have run a coding task on the person's own machine through OpenVibe.Codes: an answer that reports what changed (a summary, the files, a patch), or that the machine is still working on it with a link to follow it, addresses the task.
When the task is a greeting, small talk, a test message or too vague to act on, a short friendly reply, a summary of what the agent can do or a clarifying question is a good answer: ok is true.`;
/** Which checkers may read this agent's answer, best first: another family, then the same family. */
ddiff --git a/server/agents/codes.js b/server/agents/codes.js
nnew file mode 100644
iindex 0000000..6c12dbc
---- /dev/null
++++ b/server/agents/codes.js
@@@ -0,0 +1,134 @@
++'use strict';
++
++/**
++ * The `codes` agent (plan T16/T17): a person's coding task goes to OpenVibe.Codes, which runs it on that person's own
++ * machine (openvibe.codes/run: an OpenVibe.Run job placed only on a machine of theirs, added on openvibe.bot/machines).
++ * Actor calls Codes' task API (Contracts 0.162 codes.task.*) with its own service token (audience openvibe.codes) and
++ * X-OV-Subject naming the person; Codes shows Actor only the tasks Actor started for that person. The model use is on
++ * the machine's own keys, so Actor meters nothing for it.
++ *
++ * createCodes({ config, fetchImpl, tokens?, sleep?, pollMs?, now? }) → { enabled, adapter }
++ * adapter({ task, requester, signal, emit, meter, budgetLeft, taskId }) → { text, sources, model }
++ *
++ * The adapter starts the task (the Actor task id is its idempotency key, so a retry never starts a second one),
++ * reports each state change, and waits for the end. When the machine is still working as Actor's own task time runs
++ * out, the answer says so and links the task's live page; when the task is cancelled, the Codes task is cancelled too.
++ * Off (enabled false) without ACTOR_CODES_URL or without Actor's client secret.
++ */
++const { serviceAuth } = require('openvibe-contracts');
++const { ProviderError } = require('./providers');
++
++const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
++const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);
++const LABEL = {
++ queued: 'Waiting for your machine', placed: 'Sent to your machine', running: 'Your machine is working on it',
++ succeeded: 'Finished', failed: 'Failed', cancelled: 'Cancelled', expired: 'No machine of yours took it in time',
++};
++// A repository the task names: an https URL with an owner and a name (GitHub, GitLab, Codeberg, a Forgejo …).
++const REPO = /https:\/\/[a-z0-9.-]+\.[a-z]{2,}\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+/;
++
++const personOf = (requester) => (typeof requester === 'string' && requester.startsWith('user:') ? requester.slice(5) : null);
++
++/** The first repository URL in the task text, without a trailing .git or punctuation; null when there is none. */
++function repoIn(text) {
++ const m = REPO.exec(String(text || ''));
++ if (!m) return null;
++ return m[0].replace(/[.,;:!?)]+$/, '').replace(/\.git$/, '');
++}
++
++/** The ended task's answer for the person, in Markdown. */
++function answerOf(t) {
++ const r = t.result || {};
++ const lines = [];
++ const took = typeof r.duration_ms === 'number' ? `, ${Math.max(1, Math.round(r.duration_ms / 1000))} s` : '';
++ lines.push(`**Done on your machine**${r.agents && r.agents.length ? ` (${r.agents.join(' → ')}${took})` : took ? ` (${took.slice(2)})` : ''}.`);
++ if (r.summary) lines.push('', r.summary.trim());
++ const files = Array.isArray(r.files) ? r.files : [];
++ if (files.length) {
++ const shown = files.slice(0, 20).map((f) => `\`${f.path}\` (+${f.added == null ? '·' : f.added} −${f.removed == null ? '·' : f.removed})`);
++ lines.push('', `**Changed files:** ${shown.join(', ')}${files.length > 20 ? ` and ${files.length - 20} more` : ''}`);
++ } else {
++ lines.push('', 'No files changed.');
++ }
++ if (r.patch) {
++ const cut = r.patch.length > 3000;
++ lines.push('', '```diff', r.patch.slice(0, 3000).replace(/```/g, '``​`'), '```', ...(cut || r.patch_truncated ? ['(The whole patch is on the task page.)'] : []));
++ }
++ if (r.push && r.push.pushed) lines.push('', `Pushed to the branch [${r.push.branch}](${r.push.branch_url})${r.push.pull_request ? ` · [pull request](${r.push.pull_request})` : ''}.`);
++ lines.push('', `[The task on OpenVibe.Codes](${t.url})`);
++ return lines.join('\n');
++}
++
++function createCodes({ config, fetchImpl = globalThis.fetch, tokens: tokensOverride = null, sleep = (ms, signal) => new Promise((ok) => { const h = setTimeout(ok, ms); if (signal) signal.addEventListener('abort', () => { clearTimeout(h); ok(); }, { once: true }); }), pollMs = 3000, now = () => Date.now() }) {
++ const base = config.services.codesUrl;
++ const pages = config.services.codesPublicUrl || 'https://openvibe.codes';
++ const enabled = !!(base && (tokensOverride || config.oauth.clientSecret));
++ const tokens = !enabled ? null : tokensOverride || serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.codes', scope: 'codes.task.start codes.task.read codes.task.cancel', fetchImpl,
++ });
++
++ async function call(method, path, subject, { body, signal } = {}) {
++ const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(20_000)]) : AbortSignal.timeout(20_000);
++ const res = await fetchImpl(`${base}/api/v1${path}`, {
++ method,
++ headers: { Accept: 'application/json', 'X-OV-Subject': subject, ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) },
++ body: body ? JSON.stringify(body) : undefined, signal: sig,
++ });
++ const data = await res.json().catch(() => null);
++ return { status: res.status, ok: res.ok, data };
++ }
++ const why = (r) => (r.data && (r.data.detail || r.data.title)) || `OpenVibe.Codes answered ${r.status}`;
++
++ async function adapter({ task, requester, signal, emit, taskId = null }) {
++ if (!enabled) throw new ProviderError('OpenVibe.Codes is not connected to this server', { code: 'actor.agent.unavailable' });
++ const subject = personOf(requester);
++ if (!subject || !USER_SUBJECT.test(subject)) {
++ throw new ProviderError(`Coding tasks run on your own machine through OpenVibe.Codes, so they need you signed in on openvibe.actor. You can also start one yourself at ${pages}/run`, { code: 'actor.agent.unavailable' });
++ }
++ const repo = repoIn(task);
++ if (!repo) throw new ProviderError('Name the repository to work on, as its https address (for example https://github.com/you/project), and say what to change.', { code: 'actor.task.no_repo' });
++ await emit({ step: 'tool_call', tool: 'codes.task.start', input: repo });
++ const started = await call('POST', '/tasks', subject, { body: { repo, prompt: String(task).slice(0, 8000), ...(taskId ? { idempotency_key: String(taskId).slice(0, 128) } : {}) }, signal });
++ if (started.status === 409 && started.data && started.data.code === 'codes.no_project') {
++ throw new ProviderError(`Before Actor can send coding work to your machine: add the machine at https://openvibe.bot/machines, then start one task yourself at ${pages}/run (it picks the project your tasks run in).`, { code: 'actor.agent.unavailable' });
++ }
++ if (!started.ok || !started.data || !started.data.task) throw new ProviderError(why(started), { code: 'actor.agent.failed' });
++ if (!TASK_ID.test(String(started.data.task.id))) throw new ProviderError('OpenVibe.Codes answered with no task id', { code: 'actor.agent.failed' });
++ let t = started.data.task;
++ await emit({ step: 'tool_result', tool: 'codes.task.start', text: `${LABEL[t.state] || t.state}: ${t.url}` });
++ // Wait for the end, inside Actor's own task time: past it, the answer is the task's live page.
++ const deadline = now() + Math.max(5_000, (config.taskTimeoutMs || 180_000) - 25_000);
++ let shown = t.state;
++ try {
++ while (!TERMINAL.has(t.state) && now() < deadline) {
++ await sleep(pollMs, signal);
++ if (signal && signal.aborted) break;
++ const r = await call('GET', `/tasks/${t.id}`, subject, { signal });
++ if (r.ok && r.data) t = r.data;
++ if (t.state !== shown) {
++ shown = t.state;
++ await emit({ step: 'text', text: `${LABEL[t.state] || t.state}.` });
++ }
++ }
++ } finally {
++ if (signal && signal.aborted && !TERMINAL.has(t.state)) {
++ await call('POST', `/tasks/${t.id}/cancel`, subject).catch(() => null);
++ }
++ }
++ if (signal && signal.aborted) throw new ProviderError('cancelled', { code: 'actor.task.cancelled' });
++ const sources = [t.url];
++ if (!TERMINAL.has(t.state)) {
++ return { text: `${LABEL[t.state] || t.state}, and it is not done yet. Follow it live, and see the change when it ends: [the task on OpenVibe.Codes](${t.url})`, sources, model: 'openvibe-codes' };
++ }
++ if (t.state === 'expired') throw new ProviderError(`No machine of yours took the task in time. Is it online at https://openvibe.bot/machines, with the Codes job runner (install it with --worker)? The task: ${t.url}`, { code: 'actor.agent.failed' });
++ if (t.state !== 'succeeded') throw new ProviderError(`The task ${t.state === 'cancelled' ? 'was cancelled' : 'failed'}${t.error ? `: ${t.error}` : ''}. ${t.url}`, { code: 'actor.agent.failed' });
++ if (!t.result || !t.result.ok) throw new ProviderError(`The agents on your machine did not finish${t.result && t.result.error ? `: ${t.result.error}` : ''}. ${t.url}`, { code: 'actor.agent.failed' });
++ return { text: answerOf(t), sources, model: (t.result.agents || [])[0] || 'openvibe-codes' };
++ }
++
++ return { enabled, adapter };
++}
++
++module.exports = { createCodes, repoIn, answerOf, personOf };
ddiff --git a/server/agents/router.js b/server/agents/router.js
iindex 72e5b8f..35baf20 100644
---- a/server/agents/router.js
++++ b/server/agents/router.js
@@@ -78,7 +78,7 @@ function whyNone(result, { cls, mode, budgetUsd }) {
}
if (mode === 'private') return { code: 'actor.no_agent', detail: `Private mode keeps the task on OpenVibe's own hardware, and no agent there can take a "${cls}" task yet (the open model answers simple questions only).` };
if (cls === 'robot') return { code: 'actor.no_agent', detail: 'Robot tasks go to the OpenVibe runtime, which acts on your own robots on OpenVibe.Bot. It is not available right now; try again later.' };
-- if (cls === 'code') return { code: 'actor.no_agent', detail: 'Coding tasks go to OpenVibe.Codes, and hosted coding runs are not open yet. Run openvibe-codes on your own machine: https://openvibe.codes/start' };
++ if (cls === 'code') return { code: 'actor.no_agent', detail: 'Coding tasks go to OpenVibe.Codes, which runs them on your own machine, and it is not connected right now. You can start one yourself at https://openvibe.codes/run' };
const down = cands.filter((c) => !c.eligible).map((c) => `${c.id}: ${c.excluded_because}`).join('; ');
return { code: 'actor.no_agent', detail: `No agent can take this task right now (${down || 'none configured'}).`.slice(0, 2000) };
}
ddiff --git a/server/config.js b/server/config.js
iindex 7a2dcb1..9e9fa9d 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -68,6 +68,9 @@ function load(env = process.env) {
// OpenVibe.Bot: the person's own robots (list_my_robots, robot_steps). Empty turns the robot tools off.
botUrl: trim(env.ACTOR_BOT_URL != null ? env.ACTOR_BOT_URL : 'http://127.0.0.1:4630'),
botPublicUrl: trim(env.ACTOR_BOT_PUBLIC_URL || 'https://openvibe.bot'),
++ // OpenVibe.Codes: the `codes` agent runs a person's coding task on their own machine. Empty turns it off.
++ codesUrl: trim(env.ACTOR_CODES_URL != null ? env.ACTOR_CODES_URL : 'http://127.0.0.1:4900'),
++ codesPublicUrl: trim(env.ACTOR_CODES_PUBLIC_URL || 'https://openvibe.codes'),
},
// What one person may spend without paying (plan T17: "free tier from the bounded allowance"), and the
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 6bedd17..4ce8578 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -142,7 +142,7 @@ ${table(['Piece', 'State'], [
[html`Tasks a watch hands over when its condition fires (<a href="https://openvibe.watch">OpenVibe.Watch</a>)`, 'Live: the task is yours, with your allowance; Watch sees only the tasks it started.'],
[html`Your own agents (<a href="/me/agents">Your agents</a>): instructions, memory you can read and edit, a schedule (hourly, daily or weekly), a budget per run and a daily cap`, 'Live.'],
[html`Your own robots on <a href="https://openvibe.bot">OpenVibe.Bot</a>: see them and drive the ones you opened to your agent, through Bot's limits and e-stop`, 'Live: turn on "Let my OpenVibe.Actor agent drive this robot" on the robot\'s panel.'],
-- ['Coding tasks', html`They go to OpenVibe.Codes: <a href="https://openvibe.codes/start">run openvibe-codes</a> on your machine.`],
++ [html`Coding tasks on your own machine, through <a href="https://openvibe.codes/run">OpenVibe.Codes</a>: name the repository and what to change, and get the summary, the files and the patch`, html`Live: add the machine on <a href="https://openvibe.bot/machines">openvibe.bot/machines</a> and start one task on openvibe.codes/run first. The model use is on your machine's own keys.`],
])}</section>
${raw(showcase.cta({ title: 'Build on it', text: 'Every task, its live stream, its webhooks and the router are an API. Apps get actor.task.* grants on OpenVibe.Services.', actions: [{ label: 'Read the API', href: '/docs', primary: true }, { label: 'See the agents', href: '/agents' }] }))}`,
}, status);
ddiff --git a/server/tasks/engine.js b/server/tasks/engine.js
iindex bcb42a1..4f23cd0 100644
---- a/server/tasks/engine.js
++++ b/server/tasks/engine.js
@@@ -113,7 +113,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
await emit(id, { kind: 'output', step: attempt > 1 ? 'handoff' : 'plan', agent: agentId, text: `${agent.name} takes it${placement.reasons && placement.reasons[0] ? `: ${placement.reasons[0]}` : ''}.` });
let out;
try {
-- out = await adapters.run[agentId]({ task: row.task, requester: row.requester, cls, mode: row.mode, signal, meter, budgetLeft, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });
++ out = await adapters.run[agentId]({ task: row.task, requester: row.requester, taskId: id, cls, mode: row.mode, signal, meter, budgetLeft, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });
} catch (err) {
if (signal.aborted) throw err;
const why = (err && err.message) || String(err);
ddiff --git a/test/codes.test.js b/test/codes.test.js
nnew file mode 100644
iindex 0000000..43de824
---- /dev/null
++++ b/test/codes.test.js
@@@ -0,0 +1,104 @@
++'use strict';
++// The `codes` agent (plan T16/T17, agents/codes.js): a signed-in person's coding task goes to OpenVibe.Codes as that
++// person (X-OV-Subject, Actor's own service token), with the Actor task id as its idempotency key; Actor follows it
++// to the end and answers with the summary, the files, the patch and the task's page. A person who never started a
++// task on openvibe.codes/run is told how; past Actor's own time the answer links the live page; a cancelled Actor task
++// cancels the Codes task; an expired one says the machine never took it. The catalog lists the agent as available
++// once Codes is connected, at no cost to Actor, and its answers are checked like any other.
++const assert = require('assert');
++const { createCodes, repoIn } = require('../server/agents/codes');
++const catalog = require('../server/agents/catalog');
++const { checkersFor } = require('../server/agents/check');
++const { check, done } = require('./helpers/boot');
++
++const ME = 'usr_01M4W5JBFEG3QG9ER8W1A2K0RX';
++const TASK = 'tsk_01M4XENCR31NXDJQYDCCW2J9R9';
++const config = {
++ networkInternalUrl: 'http://network.test', oauth: { clientId: 'actor', clientSecret: 'actor-secret' }, taskTimeoutMs: 180_000,
++ services: { codesUrl: 'http://codes.test', codesPublicUrl: 'https://openvibe.codes' },
++};
++const tokens = { authHeaders: async () => ({ Authorization: 'Bearer svc-token' }) };
++
++/** A fake OpenVibe.Codes: `states` is what each read answers, in turn (the last one repeats). */
++function fakeCodes({ start = 201, startBody = null, states = ['running', 'succeeded'] } = {}) {
++ const calls = [];
++ let reads = 0;
++ const page = `https://openvibe.codes/run/tasks/${TASK}`;
++ const task = (state) => ({
++ id: TASK, state, repo: 'https://github.com/octocat/Hello-World', url: page, created_at: '2026-10-14T15:00:00.000Z', updated_at: '2026-10-14T15:00:00.000Z',
++ ...(state === 'succeeded' ? { result: { ok: true, agents: ['deepseek'], summary: 'Appended the line.', files: [{ path: 'README', added: 1, removed: 0 }], patch: 'diff --git a/README b/README\n+Edited.\n', duration_ms: 4200 } } : {}),
++ });
++ 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);
++ 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 });
++ if (u.pathname === '/api/v1/tasks' && init.method === 'POST') return reply(start, startBody || { task: task('queued'), created: true });
++ if (u.pathname === `/api/v1/tasks/${TASK}` && (init.method || 'GET') === 'GET') return reply(200, task(states[Math.min(reads++, states.length - 1)]));
++ if (u.pathname === `/api/v1/tasks/${TASK}/cancel`) return reply(200, task('cancelled'));
++ return reply(404, { code: 'not_found' });
++ };
++ return { calls, fetchImpl };
++}
++const run = (codes, extra = {}) => {
++ const events = [];
++ return codes.adapter({ task: 'In https://github.com/octocat/Hello-World, add a line to the README.', requester: `user:${ME}`, taskId: 'act_task_01', emit: async (e) => events.push(e), ...extra })
++ .then((out) => ({ out, events }));
++};
++
++(async () => {
++ await check('the repository comes from the task text', async () => {
++ assert.strictEqual(repoIn('fix https://github.com/a/b.git, please'), 'https://github.com/a/b');
++ assert.strictEqual(repoIn('see https://codeberg.org/me/tool).'), 'https://codeberg.org/me/tool');
++ assert.strictEqual(repoIn('fix my code'), null);
++ assert.strictEqual(repoIn('http://github.com/a/b'), null);
++ });
++
++ await check('a coding task starts on Codes as the person, keyed by the Actor task, and answers with the change', async () => {
++ const codes = fakeCodes();
++ const { out, events } = await run(createCodes({ config, fetchImpl: codes.fetchImpl, tokens, sleep: async () => {}, pollMs: 0 }));
++ const start = codes.calls[0];
++ assert.deepStrictEqual([start.method, start.path, start.subject, start.auth], ['POST', '/api/v1/tasks', ME, 'Bearer svc-token']);
++ assert.strictEqual(start.body.repo, 'https://github.com/octocat/Hello-World');
++ assert.strictEqual(start.body.idempotency_key, 'act_task_01');
++ assert.ok(out.text.includes('Done on your machine') && out.text.includes('Appended the line.') && out.text.includes('`README` (+1 −0)') && out.text.includes('```diff'), out.text);
++ assert.deepStrictEqual(out.sources, [`https://openvibe.codes/run/tasks/${TASK}`]);
++ assert.ok(events.some((e) => e.text && e.text.startsWith('Your machine is working on it')));
++ });
++
++ await check('signed out, no repository, or no /run task yet: a refusal that says what to do', async () => {
++ const codes = createCodes({ config, fetchImpl: fakeCodes().fetchImpl, tokens, sleep: async () => {}, pollMs: 0 });
++ await assert.rejects(codes.adapter({ task: 'https://github.com/a/b fix', requester: 'ip:1.2.3.4', emit: async () => {} }), /signed in/);
++ await assert.rejects(codes.adapter({ task: 'fix my code', requester: `user:${ME}`, emit: async () => {} }), (e) => e.code === 'actor.task.no_repo');
++ const none = fakeCodes({ start: 409, startBody: { code: 'codes.no_project', detail: 'no /run task yet' } });
++ await assert.rejects(run(createCodes({ config, fetchImpl: none.fetchImpl, tokens, sleep: async () => {}, pollMs: 0 })), /openvibe\.bot\/machines.*openvibe\.codes\/run/);
++ });
++
++ await check('past Actor\'s own time the answer links the live page; an expired task says the machine never took it', async () => {
++ let clock = 0;
++ const slow = fakeCodes({ states: ['running'] });
++ const { out } = await run(createCodes({ config, fetchImpl: slow.fetchImpl, tokens, sleep: async () => { clock += 10_000; }, pollMs: 0, now: () => clock }));
++ assert.ok(out.text.includes('not done yet') && out.text.includes(`/run/tasks/${TASK}`), out.text);
++ assert.ok(!slow.calls.some((c) => c.path.endsWith('/cancel')), 'a slow task is left running');
++ const expired = fakeCodes({ states: ['expired'] });
++ await assert.rejects(run(createCodes({ config, fetchImpl: expired.fetchImpl, tokens, sleep: async () => {}, pollMs: 0 })), /No machine of yours took the task/);
++ });
++
++ await check('cancelling the Actor task cancels the Codes task', async () => {
++ const codes = fakeCodes({ states: ['running'] });
++ const ac = new AbortController();
++ const sleep = async () => { ac.abort(); };
++ await assert.rejects(run(createCodes({ config, fetchImpl: codes.fetchImpl, tokens, sleep, pollMs: 0 }), { signal: ac.signal }), (e) => e.code === 'actor.task.cancelled');
++ assert.ok(codes.calls.some((c) => c.method === 'POST' && c.path === `/api/v1/tasks/${TASK}/cancel` && c.subject === ME));
++ });
++
++ await check('the catalog lists the agent once Codes is connected, at no cost to Actor; either family checks its answers', async () => {
++ const agent = catalog.byId('codes');
++ assert.deepStrictEqual(catalog.availability(agent, config), { available: true });
++ assert.strictEqual(catalog.availability(agent, { ...config, services: { codesUrl: '' } }).available, false);
++ assert.strictEqual(catalog.typicalCost('codes'), 0);
++ const families = checkersFor('codes', { deepseek: async () => ({}), openai: async () => ({}) }, { mode: 'balanced' }).map((c) => c.family);
++ assert.deepStrictEqual(families, ['deepseek', 'openai']);
++ });
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 9d22770..5f52fe0 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -106,6 +106,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS from the configured Network',
'server/auth/sso.js': 'Network OAuth token and revoke (configured networkInternalUrl)',
'server/agents/providers.js': 'the configured provider base URLs (DeepSeek, OpenAI, the open model) with the operator\'s keys',
++ 'server/agents/codes.js': 'the configured OpenVibe.Codes base (ACTOR_CODES_URL) with Actor\'s own service token; the subject must be usr_<ULID> and every task id tsk_<ULID> before it goes in a path; the repository named in the task is data for the person\'s own machine, never fetched here (test/codes.test.js)',
'server/agents/robots.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token; the robot id must be rob_<ULID> and the subject usr_<ULID> before anything is sent (test/robots.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/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot',