openvibers/openvibe.codes Commits

Codes: run a coding task on your own machine from any browser (/run, openvibe-codes job, through OpenVibe Run); account export and deletion (#62)

OP OpenVibers committed 30 days ago · a05ab784f1 · 22 files, +1094 −6

* Codes: the home and the improve page say what works now (no next or planned rows)

The plan's rule for every product home is "no placeholders". The Codes home's "What works today" table had three rows that only promised: a hosted runner ("Next"), Actor using Codes ("Next"), and "Improve OpenVibe from here … Planned". The `/improve` page also said "the harness will take the same path for you".

**Change** (`server/http/pages.js`)
- The three rows become one true row: Improve OpenVibe (a repository, a change on a branch with its test, a pull request that CI checks and a maintainer reviews). It is live by hand, or with the harness making the change on your machine.
- `/improve` now says every change takes that path, made by hand or by the harness on your machine with the agent you choose, and you open the pull request. There is no future tense.

**Tests**
- `layout`, `governance` and `perf-budget` pass.

* Codes: run a coding task on your own machine from any browser (/run, openvibe-codes job, through OpenVibe Run); account export and deletion

Plan T16, the web product's first form: **start a coding task in any browser (a phone included) and run it on your own machine.** It uses the chain made earlier today: Contracts#118, Network#177, Bot#98 (`/machines`, worker offers, owner-only dispatch) and Run#42 (owner-matched placement, delegated stream and cancel).

**`openvibe-codes job`** (`harness/job.js`, artifact `[email protected]`)
- What the OpenVibe Node's job worker runs in its sandbox. Input is one JSON line on stdin: `{ repo, ref?, prompt, agent?, model?, permission?, push? }`.
- Input checks: https only, no credentials in the URL, branch names, and a prompt length cap.
- Steps:
  1. clone (depth 50);
  2. run the harness in the clone with hand-offs, printing one short JSON line per event (step, agent, text, tool, hand-off, note, done) for the live page;
  3. hand the result to fd 3, Run's result channel: the agents' summary, the changed files, the patch (≤ 180 KB), cost and time.
- When asked, and when the machine holds `GITHUB_TOKEN`, it commits on a **new branch** (never the branch it started from), pushes it and opens a pull request.
- Tokens are scrubbed from every message. It exits 0 whenever it handed over a result, because Run keeps results only from successful jobs; `ok` inside says whether the task worked.

**`/run`** (`server/http/run.js`, `server/domain/run-client.js`, migration 0003 `run_tasks`)
- A guest sees what it is and a sign-in.
- A signed-in person:
  - picks one of their projects (owner or admin; creates one when they have none);
  - describes the task (repository, branch, agent or "any", edit or run anything, push and pull request).
- Codes starts a Run job for them: its service token, `X-OV-Subject` and `X-OV-Project`, the task id as idempotency key, class `code`, `trust: ['user-owned']`, public egress, 30 min and 4 GiB. Run places it only on one of that person's own machines.
- The task page:
  - streams the job's lines live through Codes (`/run/tasks/:id/events` mints a Run stream ticket and proxies the SSE);
  - then shows the verdict, the summary, a pushed branch or pull request, the changed files and a coloured patch;
  - has a Cancel button.
- Another person's task is 404, form posts are same-site only, and "Run on your machine" is in the navigation, the home page's "what works today" table and the README.

**Account export and deletion (ADR-033)** (`server/account-data.js`, migration 0004 `account_data_events`)
- Codes now holds personal rows, so it answers `network.account.export_requested` and `network.account.deleted` at `POST /internal/events`: `run-tasks.json` in the export, erasure and one confirmation on deletion.
- It subscribes at boot (`CODES_EVENTS_SECRET`). nginx answers 404 for `/internal/`.
- The Network grants follow, in the recipe's order: the subscription first, the account grants last.

**Other changes**
- Run-only styles are in `css/run.css`, loaded only on `/run` pages (the layout's new `css` option keeps the home page under its CSS budget).
- The SSRF ratchet lists the two new request sites and where they go.

**Tests**
- New `job` (5): input checks; clone → agent → patch handed over once; failures end with a reason; tokens never printed; the command over stdin.
- New `run-pages` (5): guest; project first; checks and the Run job for the person (subject, project, args, key); owner-only live stream and result; cancel; a Run refusal shown.
- New `account-data` (3).
- Whole suite: 27/27 files pass.

Changes

ddiff --git a/README.md b/README.md
iindex b485849..e4708de 100644
---- a/README.md
++++ b/README.md
@@@ -18,6 +18,7 @@ One place to send a coding task to the agent that fits it — by what the task n
| The harness | `openvibe-codes` ([bin/openvibe-codes.js](bin/openvibe-codes.js), [harness/](harness/)) | Runs on a person's own machine with their CLIs and keys; nothing goes through OpenVibe. See **Use the harness** below. |
| Home | `/` | What the harness is, the agents in the catalog, how a task finds its agent, Improve OpenVibe, and an honest table of what works today and what comes next. |
| Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. |
++| Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). |
| The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. |
| Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). |
| Improve OpenVibe | `/improve` | Every OpenVibe repository, from the pinned contracts' service manifests (the services that run, then the shared libraries), and the path from a change to a reviewed pull request. |
@@@ -57,7 +58,7 @@ openvibe-codes resume <run id> "now add a test"
## What comes next
--1. **A hosted runner** — the same harness on OpenVibe.Run workers for people who would rather not run agents themselves: `POST /api/v1/jobs`, a stream, cancel, budgets per person and per project, metered through Billing, the run log in Codes' own database.
++1. **A hosted runner** — the same harness on OpenVibe.Run workers for people who would rather not run agents themselves, with budgets per person and per project, metered through Billing. (Tasks from the web already run on the person's own machine: see "Run on your machine" above.)
2. **OpenVibe.Actor uses Codes** — Actor is OpenVibe's general agent (owner, 2026-10-08); its coding steps run through this harness.
3. **Improve OpenVibe as a flow** — repository → change → tests → independent review → pull request, never straight to `main`.
4. **The OpenVibe agent pool on this package** — `~/openvibe/agents` (the pool that builds OpenVibe) replaces its own adapters with these.
ddiff --git a/bin/openvibe-codes.js b/bin/openvibe-codes.js
iindex 728f235..b1eb938 100755
---- a/bin/openvibe-codes.js
++++ b/bin/openvibe-codes.js
@@@ -8,6 +8,7 @@
*/
const path = require('path');
const { createHarness } = require('../harness');
++const { runJob, resultWriter } = require('../harness/job');
const { version: VERSION } = require('../package.json');
const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your terminal
@@@ -28,6 +29,8 @@ const USAGE = `openvibe-codes ${VERSION} — code with any agent, from your term
openvibe-codes runs the latest runs on this machine
openvibe-codes show <run id> [--json] one run's attempts and result (--json: every event)
openvibe-codes resume <run id> "follow-up" continue a run's last session with its agent
++ openvibe-codes job one task as an OpenVibe Run job (JSON input on stdin; the
++ OpenVibe Node's job worker runs it on your own machine)
Environment: DEEPSEEK_API_KEY · OPENVIBE_CODES_BASE_URL, OPENVIBE_CODES_MODEL, OPENVIBE_CODES_API_KEY
(your own OpenAI-compatible server) · OPENVIBE_CODES_HOME (where runs are kept).
@@@ -133,6 +136,12 @@ async function main() {
for await (const e of run) { last = e; if (o.json) out(JSON.stringify(e)); else printEvent(e); }
return last && last.type === 'result' && !last.is_error ? 0 : 1;
}
++ if (cmd === 'job') {
++ // The job ends 0 whenever it handed over a result, ok or not (the document says which): OpenVibe Run keeps a
++ // result only from a job that exited 0, and the person needs the summary and the patch either way.
++ await runJob({ input: await readStdin(), harness, emit: out, writeResult: resultWriter(out) });
++ return 0;
++ }
if (cmd === 'runs') {
const runs = harness.store.listRuns(Number(o.limit) || 20);
if (o.json) { out(JSON.stringify(runs, null, 2)); return 0; }
ddiff --git a/deploy/nginx/openvibe.codes.conf b/deploy/nginx/openvibe.codes.conf
iindex 45b474f..eb520ad 100644
---- a/deploy/nginx/openvibe.codes.conf
++++ b/deploy/nginx/openvibe.codes.conf
@@@ -100,6 +100,8 @@ server {
# ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:4900/metrics) ──
location ~* ^/metrics(/|$) { return 404; }
++ # OpenVibe.Events delivers to 127.0.0.1 directly; nothing from outside reaches /internal/.
++ location /internal/ { return 404; }
# ── Pages, docs, robots.txt, sitemap, static assets ─────
location / {
ddiff --git a/harness/job.js b/harness/job.js
nnew file mode 100644
iindex 0000000..d9f383b
---- /dev/null
++++ b/harness/job.js
@@@ -0,0 +1,217 @@
++'use strict';
++
++/**
++ * `openvibe-codes job`: one coding task as an OpenVibe Run job on the person's own machine (plan T16; the Node's job
++ * worker runs it in its sandbox as artifact `[email protected]`, JOB_PROTOCOL below). The job's input is one JSON
++ * line on stdin:
++ *
++ * { repo, ref?, prompt, agent?, model?, permission?: 'edit'|'full', push?: { branch?, pull_request? } }
++ *
++ * It clones `repo` (https only) into a scratch directory, runs the harness there with hand-offs, and prints one compact
++ * JSON line per event on stdout (what the page shows live). The result goes to fd 3 (the Node's result channel), or as
++ * a last `{ t: 'result_doc' }` line when fd 3 is not open: what the agents said, the changed files and the patch, and,
++ * when `push` is asked and the machine holds GITHUB_TOKEN, the new branch it pushed (never the base branch) and its
++ * pull request. Nothing here ever prints a token.
++ */
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { execFile } = require('child_process');
++
++const JOB_PROTOCOL = '1.0.0';
++const MAX_PROMPT = 16_000;
++const MAX_PATCH = 180_000;
++const MAX_TEXT = 2_000;
++const BRANCH = /^[A-Za-z0-9._/-]{1,100}$/;
++
++/** git with no prompt and no credential helper; rejects with the command's stderr (tokens scrubbed). */
++function git(args, { cwd, env = {}, timeout = 120_000 } = {}) {
++ return new Promise((resolve, reject) => {
++ execFile('git', args, {
++ cwd, timeout, maxBuffer: 32 * 1024 * 1024,
++ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo', ...env },
++ }, (err, stdout, stderr) => {
++ if (err) reject(new Error(scrub(String(stderr || err.message)).trim().split('\n').slice(-3).join(' ')));
++ else resolve(String(stdout));
++ });
++ });
++}
++
++/** Any credential an URL or a message might carry, replaced. */
++function scrub(s) {
++ return String(s).replace(/(https?:\/\/)[^@/\s]+@/g, '$1***@').replace(/gh[pousr]_[A-Za-z0-9]{20,}/g, '***');
++}
++
++/** The job's input checked: a public https repository (file:// only when OPENVIBE_CODES_ALLOW_FILE_REPO=1, for tests). */
++function parseArgs(raw, env = process.env) {
++ let a;
++ try { a = JSON.parse(String(raw || '').trim() || '{}'); } catch { throw new Error('the job input is not JSON'); }
++ if (!a || typeof a !== 'object' || Array.isArray(a)) throw new Error('the job input must be an object');
++ let repo;
++ try { repo = new URL(String(a.repo || '')); } catch { throw new Error('repo must be a repository URL'); }
++ const fileOk = env.OPENVIBE_CODES_ALLOW_FILE_REPO === '1' && repo.protocol === 'file:';
++ if (!fileOk && repo.protocol !== 'https:') throw new Error('repo must be an https URL');
++ if (repo.username || repo.password) throw new Error('repo must not carry credentials');
++ const prompt = typeof a.prompt === 'string' ? a.prompt.trim() : '';
++ if (!prompt || prompt.length > MAX_PROMPT) throw new Error(`prompt must be 1-${MAX_PROMPT} characters`);
++ const ref = a.ref == null || a.ref === '' ? null : String(a.ref);
++ if (ref && !BRANCH.test(ref)) throw new Error('ref must be a branch or tag name');
++ const permission = a.permission === 'full' ? 'full' : 'edit';
++ const push = a.push && typeof a.push === 'object' ? {
++ branch: a.push.branch == null ? null : String(a.push.branch),
++ pull_request: a.push.pull_request === true,
++ } : null;
++ if (push && push.branch && (!BRANCH.test(push.branch) || push.branch.startsWith('-'))) throw new Error('push.branch must be a branch name');
++ return {
++ repo: repo.toString(), ref, prompt, permission, push,
++ agent: typeof a.agent === 'string' && /^[a-z0-9-]{1,40}$/.test(a.agent) ? a.agent : null,
++ model: typeof a.model === 'string' && a.model.length <= 100 ? a.model : null,
++ };
++}
++
++/** One harness event as a short line the page can show (long text cut, tool inputs reduced to what they touch). */
++function compact(e) {
++ const cut = (s) => { const t = String(s || ''); return t.length > MAX_TEXT ? `${t.slice(0, MAX_TEXT)}…` : t; };
++ if (e.type === 'system' && e.subtype === 'init') return { t: 'agent', agent: e.agent, model: e.model || null };
++ if (e.type === 'system' && e.subtype === 'handoff') return { t: 'handoff', from: e.from, reason: cut(e.reason) };
++ if (e.type === 'system' && (e.subtype === 'error' || e.subtype === 'warning')) return { t: 'note', text: cut(e.text) };
++ if (e.type === 'assistant') {
++ const out = [];
++ for (const c of (e.message && e.message.content) || []) {
++ if (c.type === 'text' && c.text) out.push({ t: 'text', text: cut(c.text) });
++ if (c.type === 'tool_use') {
++ const i = c.input || {};
++ const what = i.command || i.file_path || i.path || i.pattern || i.query || i.description || '';
++ out.push({ t: 'tool', name: String(c.name || ''), what: cut(String(what).split('\n')[0]).slice(0, 200) });
++ }
++ }
++ return out;
++ }
++ if (e.type === 'result') {
++ return { t: 'done', ok: !e.is_error, text: cut(e.result), duration_ms: e.duration_ms || 0, cost_usd: e.total_cost_usd || 0 };
++ }
++ return null;
++}
++
++/** What changed: per-file counts and the patch (new files included), cut to fit the result channel. */
++async function changes(dir) {
++ await git(['add', '-A'], { cwd: dir });
++ const numstat = await git(['diff', '--cached', '--numstat'], { cwd: dir });
++ const files = numstat.split('\n').filter(Boolean).slice(0, 500).map((line) => {
++ const [added, removed, ...p] = line.split('\t');
++ return { path: p.join('\t'), added: added === '-' ? null : Number(added), removed: removed === '-' ? null : Number(removed) };
++ });
++ const patch = files.length ? await git(['diff', '--cached', '--no-color'], { cwd: dir }) : '';
++ return { files, patch: patch.length > MAX_PATCH ? patch.slice(0, MAX_PATCH) : patch, patch_truncated: patch.length > MAX_PATCH };
++}
++
++/** github.com/<owner>/<name> of an https URL, or null. */
++function githubRepo(url) {
++ const m = /^https:\/\/github\.com\/([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/.exec(url);
++ return m ? { owner: m[1], name: m[2] } : null;
++}
++
++/** Commit on a new branch and push it with GITHUB_TOKEN (never the base branch); a pull request when asked. */
++async function pushBranch(dir, job, { env, fetchImpl, base }) {
++ const token = env.GITHUB_TOKEN;
++ const gh = githubRepo(job.repo);
++ if (!token || !gh) return { pushed: false, reason: !gh ? 'push is for github.com repositories' : 'this machine has no GITHUB_TOKEN' };
++ const branch = job.push.branch || `openvibe-codes/${Date.now().toString(36)}`;
++ if (branch === base) return { pushed: false, reason: 'a task never pushes to the branch it started from' };
++ const who = {
++ GIT_AUTHOR_NAME: env.GIT_AUTHOR_NAME || 'OpenVibe.Codes', GIT_AUTHOR_EMAIL: env.GIT_AUTHOR_EMAIL || '[email protected]',
++ GIT_COMMITTER_NAME: env.GIT_AUTHOR_NAME || 'OpenVibe.Codes', GIT_COMMITTER_EMAIL: env.GIT_AUTHOR_EMAIL || '[email protected]',
++ };
++ await git(['checkout', '-b', branch], { cwd: dir });
++ await git(['commit', '-m', job.prompt.split('\n')[0].slice(0, 72) || 'OpenVibe.Codes task'], { cwd: dir, env: who });
++ const remote = `https://x-access-token:${token}@github.com/${gh.owner}/${gh.name}.git`;
++ await git(['push', remote, `HEAD:refs/heads/${branch}`], { cwd: dir, timeout: 180_000 });
++ const out = { pushed: true, branch, branch_url: `https://github.com/${gh.owner}/${gh.name}/tree/${encodeURIComponent(branch)}` };
++ if (job.push.pull_request) {
++ const r = await fetchImpl(`https://api.github.com/repos/${gh.owner}/${gh.name}/pulls`, {
++ method: 'POST',
++ headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'Content-Type': 'application/json', 'User-Agent': 'openvibe-codes' },
++ body: JSON.stringify({ title: job.prompt.split('\n')[0].slice(0, 120), head: branch, base, body: `Made with OpenVibe.Codes on the requester's own machine.\n\n${job.prompt.slice(0, 4000)}` }),
++ signal: AbortSignal.timeout(30_000),
++ }).catch(() => null);
++ const doc = r ? await r.json().catch(() => null) : null;
++ if (r && r.ok && doc && doc.html_url) out.pull_request = doc.html_url;
++ else out.pull_request_error = r ? `GitHub answered ${r.status}` : 'GitHub did not answer';
++ }
++ return out;
++}
++
++/**
++ * Run one job. `harness` is createHarness(); `emit(line)` prints a line; `writeResult(doc)` hands the result over.
++ * → the result document.
++ */
++async function runJob({ input, harness, emit, writeResult, env = process.env, tmp = os.tmpdir(), fetchImpl = globalThis.fetch }) {
++ const started = Date.now();
++ const say = (doc) => emit(JSON.stringify(doc));
++ let job;
++ try {
++ job = parseArgs(input, env);
++ } catch (e) {
++ const doc = { protocol: JOB_PROTOCOL, ok: false, error: e.message };
++ say({ t: 'note', text: e.message });
++ writeResult(doc);
++ return doc;
++ }
++ const dir = fs.mkdtempSync(path.join(tmp, 'codes-'));
++ const work = path.join(dir, 'repo');
++ const result = { protocol: JOB_PROTOCOL, ok: false, repo: scrub(job.repo), ref: job.ref, agents: [], summary: '' };
++ try {
++ say({ t: 'step', text: `Cloning ${scrub(job.repo)}${job.ref ? ` (${job.ref})` : ''}` });
++ await git(['clone', '--depth', '50', ...(job.ref ? ['--branch', job.ref] : []), '--', job.repo, work], { timeout: 300_000 });
++ const base = (await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: work })).trim();
++ result.base = base;
++ say({ t: 'step', text: `Working on ${base}` });
++ const run = harness.run({
++ prompt: job.prompt, task: 'edit', agent: job.agent, model: job.model, cwd: work,
++ permission: job.permission, handoff: !job.agent, maxAttempts: 3,
++ });
++ let last = null;
++ for await (const e of run) {
++ last = e;
++ const c = compact(e);
++ for (const line of Array.isArray(c) ? c : c ? [c] : []) say(line);
++ if (e.type === 'system' && e.subtype === 'init' && e.agent && !result.agents.includes(e.agent)) result.agents.push(e.agent);
++ }
++ result.ok = !!(last && last.type === 'result' && !last.is_error);
++ result.summary = last && typeof last.result === 'string' ? last.result.slice(0, 4000) : '';
++ result.cost_usd = last && last.total_cost_usd ? last.total_cost_usd : 0;
++ Object.assign(result, await changes(work));
++ say({ t: 'step', text: result.files.length ? `${result.files.length} file${result.files.length === 1 ? '' : 's'} changed` : 'No files changed' });
++ if (job.push && result.ok && result.files.length) {
++ say({ t: 'step', text: 'Pushing a new branch' });
++ try {
++ result.push = await pushBranch(work, job, { env, fetchImpl, base });
++ } catch (e) {
++ result.push = { pushed: false, reason: scrub(e.message) };
++ }
++ say({ t: 'step', text: result.push.pushed ? `Pushed ${result.push.branch}` : `Not pushed: ${result.push.reason}` });
++ }
++ } catch (e) {
++ result.error = scrub(e.message);
++ say({ t: 'note', text: result.error });
++ } finally {
++ result.duration_ms = Date.now() - started;
++ fs.rmSync(dir, { recursive: true, force: true });
++ }
++ writeResult(result);
++ return result;
++}
++
++/** The Node's result channel is fd 3; outside a job the result is the last stdout line. */
++function resultWriter(emit) {
++ return (doc) => {
++ const text = JSON.stringify(doc);
++ try {
++ fs.writeSync(3, text);
++ } catch {
++ emit(JSON.stringify({ t: 'result_doc', result: doc }));
++ }
++ };
++}
++
++module.exports = { runJob, parseArgs, compact, resultWriter, scrub, githubRepo, JOB_PROTOCOL };
ddiff --git a/migrations/0003_run_tasks.sql b/migrations/0003_run_tasks.sql
nnew file mode 100644
iindex 0000000..4690154
---- /dev/null
++++ b/migrations/0003_run_tasks.sql
@@@ -0,0 +1,22 @@
++-- phase: expand
++-- Plan T16: a coding task a person starts on openvibe.codes and runs on their own machine (an OpenVibe Run job Codes
++-- starts for them). The row is the person's record of it: what was asked, which Run job runs it, and its result
++-- (the agents' summary, the changed files and the patch) once it ends. Nothing here holds a token or a key.
++CREATE TABLE run_tasks (
++ id text COLLATE "C" PRIMARY KEY, -- tsk_<ULID>
++ subject text COLLATE "C" NOT NULL, -- usr_… who asked
++ project_id text COLLATE "C" NOT NULL, -- prj_… the job belongs to
++ job_id text COLLATE "C", -- Run's job_<ULID>
++ repo text NOT NULL,
++ ref text,
++ prompt text NOT NULL,
++ agent text,
++ push boolean NOT NULL DEFAULT false,
++ pull_request boolean NOT NULL DEFAULT false,
++ state text COLLATE "C" NOT NULL, -- Run's job state: queued … succeeded, failed, cancelled, expired
++ result jsonb,
++ error text,
++ created_at bigint NOT NULL,
++ updated_at bigint NOT NULL
++);
++CREATE INDEX run_tasks_subject ON run_tasks (subject, created_at DESC);
ddiff --git a/migrations/0004_account_data.sql b/migrations/0004_account_data.sql
nnew file mode 100644
iindex 0000000..eadb3cd
---- /dev/null
++++ b/migrations/0004_account_data.sql
@@@ -0,0 +1,13 @@
++-- phase: expand
++-- OpenVibe.Codes: the receipts of the ADR-033 account export and deletion deliveries this service applied
++-- (openvibe-sdk/account-data's ACCOUNT_DATA_SCHEMA), so a redelivered export or deletion changes nothing.
++-- The row holds an export_id or deletion_id, the subject and the counts sent to Network; it is not a secret.
++
++CREATE TABLE IF NOT EXISTS account_data_events (
++ id TEXT PRIMARY KEY,
++ kind TEXT NOT NULL,
++ subject TEXT NOT NULL,
++ outcome JSONB,
++ applied_at TIMESTAMPTZ NOT NULL DEFAULT now(),
++ sent_at TIMESTAMPTZ
++);
ddiff --git a/public/css/codes.css b/public/css/codes.css
iindex d55b547..2d2de84 100644
---- a/public/css/codes.css
++++ b/public/css/codes.css
@@@ -79,3 +79,4 @@ meter.headroom { width: 8rem; max-width: 100%; vertical-align: middle; }
.page .sc-hero, .page .sc-sec, .page .sc-cta { padding-left: 0; padding-right: 0; }
.page .sc-ic .ov-icon { --ovi-size: 28px !important; }
.codes-hero-code { margin: 0; padding: 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-primary, #0a0f1c); font-size: 13px; line-height: 1.6; overflow: auto; }
++
ddiff --git a/public/css/run.css b/public/css/run.css
nnew file mode 100644
iindex 0000000..5e6bbfa
---- /dev/null
++++ b/public/css/run.css
@@@ -0,0 +1,33 @@
++/* Run on your machine (/run, server/http/run.js): the task form, the live log and the result. */
++.run-card { border: 1px solid var(--border, #1f2d47); border-radius: 14px; padding: 16px; background: var(--bg-card, #131c2e); max-width: 820px; display: grid; gap: 12px; margin: 16px 0; }
++.run-field { display: grid; gap: 4px; font-weight: 600; }
++.run-field small, .run-check small { font-weight: 400; color: var(--text-muted, #7386a3); }
++.run-field input, .run-field textarea, .run-field select { font: inherit; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border-light, #2c3d5c); background: var(--bg-primary, #0a0f1c); color: inherit; min-width: 0; }
++.run-field textarea { resize: vertical; }
++.run-field-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
++.run-check { display: flex; gap: 8px; align-items: baseline; }
++.run-btn { display: inline-block; font: inherit; font-weight: 700; padding: 9px 16px; border-radius: 10px; border: 1px solid var(--border-light, #2c3d5c); background: transparent; color: inherit; cursor: pointer; text-decoration: none; }
++.run-btn-primary { background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); border-color: transparent; justify-self: start; }
++.run-steps { line-height: 1.7; }
++.run-notice { padding: 10px 14px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); }
++.run-notice-error { border-color: var(--danger, #e5484d); }
++.run-notice-ok { border-color: var(--success, #22c55e); }
++.run-pill { display: inline-block; font-size: .8rem; padding: 2px 10px; border-radius: 999px; border: 1px solid var(--border-light, #2c3d5c); white-space: nowrap; }
++.run-pill-running, .run-pill-placed { border-color: var(--accent, #3b82f6); }
++.run-pill-succeeded { border-color: var(--success, #22c55e); }
++.run-pill-failed, .run-pill-expired { border-color: var(--danger, #e5484d); }
++.run-tasks { list-style: none; padding: 0; display: grid; gap: 6px; max-width: 820px; }
++.run-tasks a { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; text-decoration: none; color: inherit; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--border, #1f2d47); }
++.run-prompt, .run-summary { white-space: pre-wrap; overflow-wrap: anywhere; border-left: 3px solid var(--border-light, #2c3d5c); padding: 4px 12px; margin: 12px 0; font: inherit; }
++.run-log { list-style: none; padding: 12px; margin: 0; border-radius: 12px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); font-size: 14px; display: grid; gap: 4px; max-height: 60vh; overflow: auto; }
++.run-log li { overflow-wrap: anywhere; white-space: pre-wrap; }
++.run-log .tool, .run-log .step { color: var(--text-muted, #7386a3); }
++.run-log .agent, .run-log .handoff { font-weight: 700; }
++.run-log .note { color: var(--warning, #f59e0b); }
++.run-files td { padding: 2px 10px 2px 0; }
++.run-files .add { color: var(--success, #22c55e); }
++.run-files .del { color: var(--danger, #e5484d); }
++.run-patch { overflow: auto; max-height: 70vh; padding: 12px; border-radius: 12px; background: var(--bg-primary, #0a0f1c); border: 1px solid var(--border, #1f2d47); font-size: 13px; }
++.run-patch .add { color: var(--success, #22c55e); }
++.run-patch .del { color: var(--danger, #e5484d); }
++.run-patch .hunk { color: var(--accent-light, #60a5fa); }
ddiff --git a/public/js/run.js b/public/js/run.js
nnew file mode 100644
iindex 0000000..8f8497e
---- /dev/null
++++ b/public/js/run.js
@@@ -0,0 +1,83 @@
++/*
++ * Run on your machine (server/http/run.js): the live lines of a running task and the colours of a finished patch.
++ * The lines come from the task's own event stream (/run/tasks/:id/events, Run's job stream through Codes): `output`
++ * events carry the job's stdout, one JSON object per line (harness/job.js), `state` the job's state, `end` its end.
++ * Everything is put on the page as text, never as HTML.
++ */
++(function () {
++ 'use strict';
++ var LABEL = {
++ queued: 'Waiting for your machine', placed: 'Sent to your machine', running: 'Running', succeeded: 'Finished',
++ failed: 'Failed', cancelled: 'Cancelled', expired: 'Expired (no machine took it in time)',
++ };
++
++ function line(log, cls, text) {
++ var li = document.createElement('li');
++ li.className = cls;
++ li.textContent = text;
++ log.appendChild(li);
++ log.scrollTop = log.scrollHeight;
++ }
++
++ function show(log, doc) {
++ switch (doc.t) {
++ case 'step': return line(log, 'step', '· ' + doc.text);
++ case 'agent': return line(log, 'agent', '▶ ' + doc.agent + (doc.model ? ' (' + doc.model + ')' : ''));
++ case 'handoff': return line(log, 'handoff', '⇢ handing over from ' + doc.from + ': ' + doc.reason);
++ case 'text': return line(log, 'text', doc.text);
++ case 'tool': return line(log, 'tool', ' → ' + doc.name + (doc.what ? ' ' + doc.what : ''));
++ case 'note': return line(log, 'note', '! ' + doc.text);
++ case 'done': return line(log, doc.ok ? 'step' : 'note', (doc.ok ? '✔ done' : '✗ ' + (doc.text || 'not done')) + ' (' + Math.round((doc.duration_ms || 0) / 1000) + ' s)');
++ default: return undefined;
++ }
++ }
++
++ function follow(section) {
++ var log = section.querySelector('[data-log]');
++ var pill = document.querySelector('[data-state]');
++ var buffer = '';
++ if (!log || !window.EventSource) return;
++ var source = new EventSource(section.getAttribute('data-events'));
++ source.addEventListener('output', function (e) {
++ var doc;
++ try { doc = JSON.parse(e.data); } catch (err) { return; }
++ buffer += doc.chunk || '';
++ var parts = buffer.split('\n');
++ buffer = parts.pop();
++ parts.forEach(function (raw) {
++ if (!raw.trim()) return;
++ try { show(log, JSON.parse(raw)); } catch (err) { line(log, 'tool', raw); }
++ });
++ });
++ source.addEventListener('state', function (e) {
++ try {
++ var s = JSON.parse(e.data).state;
++ if (pill && LABEL[s]) { pill.textContent = LABEL[s]; pill.className = 'run-pill run-pill-' + s; }
++ } catch (err) { /* ignore */ }
++ });
++ source.addEventListener('end', function () {
++ source.close();
++ setTimeout(function () { window.location.reload(); }, 800);
++ });
++ source.onerror = function () {
++ // The stream ended or the link dropped: the page itself shows where the task is.
++ if (source.readyState === 2) setTimeout(function () { window.location.reload(); }, 3000);
++ };
++ }
++
++ function colour(pre) {
++ var lines = pre.textContent.split('\n');
++ pre.textContent = '';
++ lines.forEach(function (text, i) {
++ var span = document.createElement('span');
++ if (/^\+(?!\+\+)/.test(text)) span.className = 'add';
++ else if (/^-(?!--)/.test(text)) span.className = 'del';
++ else if (/^@@/.test(text)) span.className = 'hunk';
++ span.textContent = text + (i < lines.length - 1 ? '\n' : '');
++ pre.appendChild(span);
++ });
++ }
++
++ document.querySelectorAll('[data-events]').forEach(follow);
++ document.querySelectorAll('[data-patch]').forEach(colour);
++})();
ddiff --git a/server/account-data.js b/server/account-data.js
nnew file mode 100644
iindex 0000000..d88fbb5
---- /dev/null
++++ b/server/account-data.js
@@@ -0,0 +1,21 @@
++'use strict';
++/**
++ * Account export and deletion → Codes (ADR-033; openvibe-sdk/account-data). What Codes holds about a person is the
++ * coding tasks they started on their own machine (run_tasks, plan T16): what they asked, the Run job and its result.
++ * The export carries them whole; a deletion erases them. The Run jobs themselves are OpenVibe.Run's records (usage
++ * billed to the project) and are Run's to answer for.
++ */
++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
++
++const TABLES = [
++ {
++ table: 'run_tasks', subject: 'subject', file: 'run-tasks.json', order: 'created_at',
++ columns: ['id', 'project_id', 'job_id', 'repo', 'ref', 'prompt', 'agent', 'push', 'pull_request', 'state', 'result', 'error', 'created_at', 'updated_at'],
++ },
++];
++
++function create({ db, log = console }) {
++ return createAccountData({ db, service: 'codes', tables: TABLES, log });
++}
++
++module.exports = { create, TABLES, TOPICS };
ddiff --git a/server/app.js b/server/app.js
iindex fb409aa..7cbbe94 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -27,6 +27,8 @@ const { createKeyStore } = require('./auth/keys');
const { createSso } = require('./auth/sso');
const { createHarnesses } = require('./domain/harnesses');
const { createPageRoutes } = require('./http/pages');
++const { createRunRoutes } = require('./http/run');
++const { createRunClient } = require('./domain/run-client');
const { createApi } = require('./http/api');
const { createMovedRoutes } = require('./http/moved');
const { createCodesReadiness } = require('./observability');
@@@ -97,6 +99,18 @@ async function createApp(opts = {}) {
}));
app.use(cookieParser());
++ // ── OpenVibe.Events → Codes (loopback only: nginx answers 404 for /internal/) ──
++ // network.account.export_requested and network.account.deleted (ADR-033) for the coding tasks people started here.
++ // The consumer reads the raw body itself (the v2 signature covers it) and refuses a request that came through a proxy.
++ {
++ const { createNetworkSender } = require('openvibe-sdk/account-data');
++ const accountData = require('./account-data').create({ db: store.db, log });
++ const accountSend = opts.accountSend || (config.oauth.clientSecret
++ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl || globalThis.fetch })
++ : null);
++ app.post('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log }));
++ }
++
// ── Machine endpoints ───────────────────────────────────
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-codes', version: VERSION }));
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports into /metrics.
@@@ -136,6 +150,9 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.CODES_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false })); // per address; tests raise it
app.use(createPageRoutes(ctx));
++ // Run on your machine (plan T16): coding tasks on the person's own machine, through OpenVibe Run.
++ ctx.run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });
++ app.use(createRunRoutes({ config, store, sso, harnesses, run: ctx.run, fetchImpl: fetchImpl || globalThis.fetch, log }));
app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/harnesses">the agents</a>, <a href="/improve">Improve OpenVibe</a>, or the developer platform at <a href="https://openvibe.services">openvibe.services</a>.</p>` }));
// eslint-disable-next-line no-unused-vars
ddiff --git a/server/config.js b/server/config.js
iindex f40f1db..db0e1ea 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -53,6 +53,13 @@ function load(env = process.env) {
sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
},
cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
++ // OpenVibe.Events (ADR-033): account export and deletion arrive at POST /internal/events, signed with one of these.
++ events: {
++ url: trim(env.EVENTS_URL || ''),
++ secrets: String(env.CODES_EVENTS_SECRET || '').split(',').map((s) => s.trim()).filter(Boolean),
++ },
++ // OpenVibe.Run (plan T16): a coding task on the person's own machine is a Run job Codes starts for them.
++ run: { url: trim(env.CODES_RUN_URL || 'http://127.0.0.1:4920') },
};
}
ddiff --git a/server/domain/run-client.js b/server/domain/run-client.js
nnew file mode 100644
iindex 0000000..c290c1d
---- /dev/null
++++ b/server/domain/run-client.js
@@@ -0,0 +1,71 @@
++'use strict';
++/**
++ * OpenVibe.Run for Codes (plan T16): a coding task on the person's own machine is a Run job Codes starts for them —
++ * project delegation (ADR-003 amendment 2026-10-12): Codes' own service token (audience openvibe.run, run.job.submit,
++ * read, stream and cancel) with the person in X-OV-Subject and their project in X-OV-Project. Run checks the person is
++ * an admin or owner of the project before every job, places it only on that person's own machine (contracts 0.161.0)
++ * and lets Codes read, cancel and stream only the jobs Codes started for that person.
++ *
++ * submit({ subject, project, args, key }) → the job (run.job-read-result@1)
++ * read(jobId, who) · cancel(jobId, who) → the job
++ * streamUrl(jobId, who) → the job's SSE URL on Run with a one-use ticket
++ *
++ * The artifact is the job runner's protocol (`[email protected]`, harness/job.js JOB_PROTOCOL), which the person's
++ * Node declares; a failure is a RunError carrying Run's status and code.
++ */
++const { createServiceTokenClient } = require('openvibe-sdk/auth');
++const { JOB_PROTOCOL } = require('../../harness/job');
++
++const SCOPE = 'run.job.submit run.job.read run.job.stream run.job.cancel';
++
++class RunError extends Error {
++ constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; }
++}
++
++function createRunClient({ config, fetchImpl = globalThis.fetch, now = () => Date.now() }) {
++ const { clientId, clientSecret } = config.oauth;
++ const tokens = clientSecret
++ ? createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId, clientSecret, audience: 'openvibe.run', scope: SCOPE, fetch: fetchImpl, now })
++ : null;
++ const base = config.run.url;
++ async function call(method, path, { subject, project, body, key } = {}) {
++ if (!tokens) throw new RunError(503, 'run.unconfigured', 'Codes has no Network client secret, so it cannot start a job');
++ let res;
++ try {
++ res = await fetchImpl(`${base}${path}`, {
++ method,
++ headers: {
++ ...(await tokens.authHeaders()), Accept: 'application/json', 'X-OV-Subject': subject, 'X-OV-Project': project,
++ ...(body ? { 'Content-Type': 'application/json' } : {}), ...(key ? { 'Idempotency-Key': key } : {}),
++ },
++ ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(15_000),
++ });
++ } catch (e) {
++ throw new RunError(503, 'run.unavailable', `OpenVibe.Run did not answer: ${e.message}`);
++ }
++ if (res.status === 401) tokens.invalidate && tokens.invalidate();
++ const doc = await res.json().catch(() => null);
++ if (!res.ok) throw new RunError(res.status, (doc && doc.code) || 'run.error', (doc && (doc.detail || doc.title)) || `Run answered ${res.status}`);
++ return doc;
++ }
++ /** A coding job for `subject` in `project`, only on that person's own machine. */
++ async function submit({ subject, project, args, key }) {
++ const body = {
++ class: 'code', artifact: { name: 'openvibe-codes', version: JOB_PROTOCOL }, args,
++ limits: { wall_ms: 30 * 60_000, cpu_ms: 30 * 60_000, mem_bytes: 4 * 1024 ** 3 },
++ ttl_ms: 45 * 60_000, egress: 'public',
++ requirements: { kind: 'run.code', mobility: 'job', latency_class: 'background', objective: 'balanced', trust: ['user-owned'] },
++ };
++ const out = await call('POST', '/api/v1/jobs', { subject, project, body, key });
++ return out.job;
++ }
++ const read = (jobId, who) => call('GET', `/api/v1/jobs/${encodeURIComponent(jobId)}`, who);
++ const cancel = (jobId, who) => call('POST', `/api/v1/jobs/${encodeURIComponent(jobId)}/cancel`, who);
++ async function streamUrl(jobId, who) {
++ const t = await call('POST', `/api/v1/jobs/${encodeURIComponent(jobId)}/stream/ticket`, who);
++ return `${base}/api/v1/jobs/${encodeURIComponent(jobId)}/stream?ticket=${encodeURIComponent(t.ticket)}`;
++ }
++ return { submit, read, cancel, streamUrl, enabled: !!tokens };
++}
++
++module.exports = { createRunClient, RunError, SCOPE };
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 59fe800..2ceb20f 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -99,8 +99,8 @@ Added test/date.test.js: three cases, all pass.
eyebrow: 'OpenVibe.Codes · the open coding-agent harness · alpha',
title: 'Code with', accent: 'any agent',
lede: 'One open harness for Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API and models you host yourself: send a coding task to the agent that fits it, hand it over when one gets stuck, and keep the whole run in one place. Then point it at OpenVibe itself.',
-- actions: [{ label: 'Install the harness', href: '/start', primary: true }, { label: 'Meet the agents', href: '/harnesses' }],
-- note: `Open source (AGPL-3.0). Version ${VERSION} runs on your machine with the agents and keys you already have; nothing goes through OpenVibe. A hosted runner comes next; the list below says exactly what works.`,
++ actions: [{ label: 'Install the harness', href: '/start', primary: true }, { label: 'Run on your machine from the web', href: '/run' }, { label: 'Meet the agents', href: '/harnesses' }],
++ note: `Open source (AGPL-3.0). Version ${VERSION} runs on your machine with the agents and keys you already have, from your terminal or started from this site in any browser; the list below says exactly what works.`,
aside: { html: `<pre class="codes-hero-code"><code>${showcase.esc(snippet)}</code></pre>` },
}))}
${raw(showcase.features({
@@@ -123,6 +123,7 @@ ${raw(showcase.steps({
${table(['Piece', 'State'], [
[html`<a href="/start">The harness on your machine</a> (<code>openvibe-codes</code>)`, `Live, ${VERSION}: runs a task on any of the seven agents with your CLIs and keys, streams one event format, keeps every run to show or resume.`],
['Hand-offs between agents', 'Live: a failed, rate-limited or silent agent hands the task to the next one with a note on what was done.'],
++ [html`<a href="/run">Run on your machine</a>: start a task in any browser, your phone included; your own computer runs it`, 'Live: add your machine on openvibe.bot/machines (one command), then watch the task live here and get the patch, a branch or a pull request. It runs only on your own machines, in the Node\'s job sandbox.'],
[html`<a href="/harnesses">The agent catalog</a>`, 'Live: every harness and model with its capabilities, prices and limits.'],
[html`<code>GET /api/v1/harnesses</code>, <code>POST /api/v1/harnesses/route</code>`, 'Live: public, no key needed; per-address and per-caller limits.'],
[html`<a href="/improve">Improve OpenVibe</a>: a repository, a change on a branch with its test, a pull request that CI checks and a maintainer reviews`, 'Live: by hand, or with the harness doing the change on your machine.'],
@@@ -206,7 +207,7 @@ ${table(['Level', 'What the agent may do'], [
<pre><code>${fromCode}</code></pre>
<p><code>harness.agents()</code>, <code>harness.route({ task })</code> and <code>harness.run({ prompt, cwd, agent, permission, handoff })</code> are what the command line uses. The router is the same one behind <a href="/api/v1/harnesses"><code>/api/v1/harnesses/route</code></a>.</p>
<h2 id="next">What comes next</h2>
--<p>A hosted runner on OpenVibe.Run workers for people who would rather not run agents themselves, with budgets; OpenVibe.Actor using Codes for its coding work; and <a href="/improve">Improve OpenVibe</a> as a guided run: pick a repository, describe the change, get a pull request.</p>`,
++<p>Tasks from the web already run on your own machine (<a href="/run">Run on your machine</a>). Next: a hosted runner on OpenVibe.Run workers for people who would rather not run agents themselves, with budgets; OpenVibe.Actor using Codes for its coding work; and <a href="/improve">Improve OpenVibe</a> as a guided run: pick a repository, describe the change, get a pull request.</p>`,
});
});
ddiff --git a/server/http/run.js b/server/http/run.js
nnew file mode 100644
iindex 0000000..472f3e9
---- /dev/null
++++ b/server/http/run.js
@@@ -0,0 +1,246 @@
++'use strict';
++/**
++ * Run on your machine (plan T16): a coding task started from any browser that runs on the person's own machine.
++ *
++ * GET /run the form, the person's projects and their recent tasks (a sign-in prompt for a guest)
++ * POST /run start one: a run_tasks row and an OpenVibe Run job for the person (server/domain/run-client.js)
++ * POST /run/project create a project for the runs when the person has none they may run jobs in
++ * GET /run/tasks/:id one task: live while it runs, then the agents' summary, the changed files and the patch
++ * GET /run/tasks/:id/events its live lines, proxied from Run's job stream (server-sent events)
++ * POST /run/tasks/:id/cancel stop it
++ *
++ * Every task is its owner's alone (another person's is 404). Forms post only from this site. The job runs where Run
++ * places it: on a machine the person added on openvibe.bot/machines, never anyone else's (contracts 0.161.0).
++ */
++const express = require('express');
++const { asyncRouter } = require('./router');
++const { send } = require('../render/layout');
++const { html, raw, esc } = require('../render/html');
++const { parseArgs } = require('../../harness/job');
++const { RunError } = require('../domain/run-client');
++
++const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);
++const STATE_LABEL = {
++ queued: 'Waiting for your machine', placed: 'Sent to your machine', running: 'Running', succeeded: 'Finished',
++ failed: 'Failed', cancelled: 'Cancelled', expired: 'Expired (no machine took it in time)',
++};
++const DELEGABLE = new Set(['owner', 'admin']);
++const MACHINES_URL = 'https://openvibe.bot/machines';
++
++function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globalThis.fetch, log = console }) {
++ const r = asyncRouter();
++ const db = store.db;
++ const form = express.urlencoded({ extended: false, limit: '32kb' });
++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, scripts: ['js/run.js'], css: ['css/run.css'], ...o });
++ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
++ const sameOrigin = (req) => {
++ const origin = req.get('origin');
++ if (!origin) return true;
++ try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
++ };
++
++ /** The person's projects they may run jobs in (owner or admin, not archived), from Network with their own token. */
++ async function projectsOf(req, res) {
++ const list = await sso.asViewer(req, res, async (token) => {
++ const out = await fetchImpl(`${config.networkInternalUrl}/api/v1/projects`, { headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, signal: AbortSignal.timeout(8000) });
++ if (out.status === 401) { const e = new Error('expired'); e.status = 401; throw e; }
++ if (!out.ok) throw new Error(`Network answered ${out.status} for the projects`);
++ return (await out.json()).projects || [];
++ });
++ return list.filter((p) => DELEGABLE.has(p.role) && !p.archived_at).map((p) => ({ id: p.id, name: p.name }));
++ }
++
++ const own = async (req, id) => {
++ if (!/^tsk_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id))) return null;
++ const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [id]);
++ return row && row.subject === req.viewer.subject ? row : null;
++ };
++ const who = (row) => ({ subject: row.subject, project: row.project_id });
++
++ /** A task not yet ended asks Run where its job is; an ended job's result is kept on the row. */
++ async function refresh(row) {
++ if (!row.job_id || TERMINAL.has(row.state)) return row;
++ let job;
++ try { job = await run.read(row.job_id, who(row)); } catch (e) { log.warn(`[Codes] task ${row.id}: ${e.message}`); return row; }
++ if (job.state === row.state && !TERMINAL.has(job.state)) return row;
++ const error = job.error ? `${job.error.code}${job.error.detail ? `: ${job.error.detail}` : ''}` : null;
++ const result = job.result && typeof job.result === 'object' ? job.result : null;
++ return db.one('UPDATE run_tasks SET state = $2, result = $3, error = $4, updated_at = $5 WHERE id = $1 RETURNING *',
++ [row.id, job.state, result ? JSON.stringify(result) : null, error, store.now()]);
++ }
++
++ function formHtml({ projects, values = {}, error = null }) {
++ const agents = harnesses.list();
++ const opt = (v, label, sel) => html`<option value="${v}"${sel ? raw(' selected') : ''}>${label}</option>`;
++ return html`<form class="run-card run-form" method="post" action="/run">
++${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>` : ''}
++<label class="run-field"><span>Repository</span><input name="repo" type="url" required placeholder="https://github.com/you/project" value="${values.repo || ''}" autocomplete="off" spellcheck="false"></label>
++<label class="run-field"><span>Branch <small>(optional: the default branch)</small></span><input name="ref" value="${values.ref || ''}" autocomplete="off" spellcheck="false" placeholder="main"></label>
++<label class="run-field"><span>What should the agent do?</span><textarea name="prompt" rows="5" required maxlength="16000" placeholder="Add a test for the date parser and fix what it finds">${values.prompt || ''}</textarea></label>
++<div class="run-field-row">
++<label class="run-field"><span>Agent</span><select name="agent">${opt('', 'Any: the best fit, with hand-offs', !values.agent)}${agents.map((a) => opt(a.id, a.name, values.agent === a.id))}</select></label>
++<label class="run-field"><span>It may</span><select name="permission">${opt('edit', 'Edit files', values.permission !== 'full')}${opt('full', 'Run anything (tests, installs)', values.permission === 'full')}</select></label>
++<label class="run-field"><span>Project</span><select name="project">${projects.map((p) => opt(p.id, p.name, values.project === p.id))}</select></label>
++</div>
++<label class="run-check"><input type="checkbox" name="push" value="1"${values.push ? raw(' checked') : ''}> Push the change to a new branch <small>(GitHub; needs GITHUB_TOKEN on your machine; never the branch it started from)</small></label>
++<label class="run-check"><input type="checkbox" name="pull_request" value="1"${values.pull_request ? raw(' checked') : ''}> and open a pull request</label>
++<button class="run-btn run-btn-primary" type="submit">Start on my machine</button>
++</form>`;
++ }
++
++ function tasksHtml(rows) {
++ if (!rows.length) return html`<p class="muted">No tasks yet.</p>`;
++ return html`<ul class="run-tasks">${rows.map((t) => html`<li><a href="/run/tasks/${t.id}"><span class="run-pill run-pill-${t.state}">${STATE_LABEL[t.state] || t.state}</span> <b>${t.repo.replace(/^https:\/\//, '')}</b> <span class="muted">${t.prompt.split('\n')[0].slice(0, 90)}</span></a></li>`)}</ul>`;
++ }
++
++ async function runPage(req, res, { values, error, status = 200 } = {}) {
++ if (!signedIn(req)) {
++ return page(req, res, {
++ title: 'Run on your machine', index: true,
++ body: html`<h1>Run a coding task on your own machine</h1>
++<p class="lead">Start a task from any browser, your phone included, and your own computer does the work: the agents and keys you already have, in a sandbox, with nothing to host. You watch it live here and get the changes back as a patch, a branch or a pull request.</p>
++<ol class="run-steps"><li>Add your computer on <a href="${MACHINES_URL}">openvibe.bot/machines</a>: one command, which installs OpenVibe Node with its job worker.</li><li>Sign in here and describe the task.</li><li>Your machine clones the repository, runs the agent and sends the result back.</li></ol>
++<p><a class="run-btn run-btn-primary" href="/auth/login?next=%2Frun">Sign in to start</a></p>`,
++ });
++ }
++ const [projects, recent] = await Promise.all([
++ projectsOf(req, res),
++ db.many('SELECT id, repo, prompt, state, created_at FROM run_tasks WHERE subject = $1 ORDER BY created_at DESC LIMIT 20', [req.viewer.subject]),
++ ]);
++ const body = html`<h1>Run on your machine</h1>
++<p class="lead">The task runs on a machine you added on <a href="${MACHINES_URL}">openvibe.bot/machines</a>, in its job sandbox, with the agents and keys installed there. Nobody else's machine ever gets it.</p>
++${projects.length ? formHtml({ projects, values, error }) : html`<form class="run-card" method="post" action="/run/project">
++${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>` : ''}
++<h2>One thing first: a project</h2><p class="muted">Runs belong to a project on OpenVibe (that is where their usage is counted). You have none you can run jobs in yet.</p>
++<label class="run-field"><span>Project name</span><input name="name" value="My code" maxlength="80" required></label>
++<button class="run-btn run-btn-primary" type="submit">Create the project</button></form>`}
++<h2>Your tasks</h2>
++${tasksHtml(recent)}`;
++ return page(req, res, { title: 'Run on your machine', body }, status);
++ }
++
++ r.get('/run', (req, res) => runPage(req, res));
++
++ r.post('/run/project', form, async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Frun');
++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
++ const name = String((req.body && req.body.name) || '').trim().slice(0, 80) || 'My code';
++ try {
++ await sso.asViewer(req, res, async (token) => {
++ const out = await fetchImpl(`${config.networkInternalUrl}/api/v1/projects`, {
++ method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json' },
++ body: JSON.stringify({ name }), signal: AbortSignal.timeout(8000),
++ });
++ if (out.status === 401) { const e = new Error('expired'); e.status = 401; throw e; }
++ if (!out.ok) throw new Error(`Network answered ${out.status}`);
++ });
++ } catch (e) {
++ return runPage(req, res, { error: `The project could not be created (${e.message}). Try again.`, status: 502 });
++ }
++ return res.redirect(303, '/run');
++ });
++
++ r.post('/run', form, async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Frun');
++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
++ const b = req.body || {};
++ const values = {
++ repo: String(b.repo || '').trim(), ref: String(b.ref || '').trim(), prompt: String(b.prompt || ''),
++ agent: String(b.agent || '').trim(), permission: b.permission === 'full' ? 'full' : 'edit', project: String(b.project || ''),
++ push: b.push === '1', pull_request: b.push === '1' && b.pull_request === '1',
++ };
++ let args;
++ try {
++ if (values.agent && !harnesses.list().some((a) => a.id === values.agent)) throw new Error('choose an agent from the list');
++ args = parseArgs(JSON.stringify({
++ repo: values.repo, ref: values.ref || null, prompt: values.prompt, agent: values.agent || null, permission: values.permission,
++ ...(values.push ? { push: { pull_request: values.pull_request } } : {}),
++ }));
++ } catch (e) {
++ return runPage(req, res, { values, error: `Check the form: ${e.message}.`, status: 422 });
++ }
++ const projects = await projectsOf(req, res);
++ if (!projects.some((p) => p.id === values.project)) return runPage(req, res, { values, error: 'Choose one of your projects.', status: 422 });
++ const id = store.newId('tsk');
++ const at = store.now();
++ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, created_at, updated_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $10)`,
++ [id, req.viewer.subject, values.project, args.repo, args.ref, args.prompt, args.agent, !!args.push, !!(args.push && args.push.pull_request), at]);
++ try {
++ const job = await run.submit({ subject: req.viewer.subject, project: values.project, args, key: id });
++ await db.exec('UPDATE run_tasks SET job_id = $2, state = $3, updated_at = $4 WHERE id = $1', [id, job.id, job.state, store.now()]);
++ } catch (e) {
++ const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;
++ await db.exec("UPDATE run_tasks SET state = 'failed', error = $2, updated_at = $3 WHERE id = $1", [id, why, store.now()]);
++ }
++ return res.redirect(303, `/run/tasks/${id}`);
++ });
++
++ function resultHtml(row) {
++ const d = row.result || {};
++ const files = Array.isArray(d.files) ? d.files : [];
++ const push = d.push || null;
++ const verdict = row.state !== 'succeeded'
++ ? html`<p class="run-notice run-notice-error">${row.state === 'expired' ? 'No machine of yours took the task in time. Is it online on openvibe.bot/machines, with the Codes job runner?' : row.error || STATE_LABEL[row.state]}</p>`
++ : d.ok ? html`<p class="run-notice run-notice-ok">The agents finished the task${d.agents && d.agents.length ? ` (${d.agents.join(' → ')})` : ''}.</p>`
++ : html`<p class="run-notice run-notice-error">The task ended without success${d.error ? `: ${d.error}` : ''}.</p>`;
++ return html`<section class="run-result">${verdict}
++${d.summary ? html`<h2>What the agent said</h2><pre class="run-summary">${d.summary}</pre>` : ''}
++${push ? (push.pushed ? html`<p>Pushed <a href="${push.branch_url}">${push.branch}</a>${push.pull_request ? html` · <a href="${push.pull_request}">pull request</a>` : ''}${push.pull_request_error ? html` · the pull request was not opened (${push.pull_request_error})` : ''}</p>` : html`<p class="muted">Not pushed: ${push.reason}</p>`) : ''}
++${files.length ? html`<h2>Changed files</h2><table class="run-files"><tbody>${files.map((f) => html`<tr><td><code>${f.path}</code></td><td class="add">+${f.added == null ? '·' : f.added}</td><td class="del">−${f.removed == null ? '·' : f.removed}</td></tr>`)}</tbody></table>` : (row.state === 'succeeded' ? html`<p class="muted">No files changed.</p>` : '')}
++${d.patch ? html`<h2>The patch${d.patch_truncated ? ' (cut)' : ''}</h2><pre class="run-patch" data-patch>${d.patch}</pre>` : ''}
++${typeof d.duration_ms === 'number' ? html`<p class="muted">${Math.round(d.duration_ms / 1000)} s on your machine${d.cost_usd ? ` · about $${Number(d.cost_usd).toFixed(4)} of model use on your own keys` : ''}</p>` : ''}
++</section>`;
++ }
++
++ r.get('/run/tasks/:id', async (req, res) => {
++ if (!signedIn(req)) return res.redirect(302, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
++ let row = await own(req, req.params.id);
++ if (!row) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>No such task of yours. <a href="/run">Your tasks</a></p>` }, 404);
++ row = await refresh(row);
++ const live = !TERMINAL.has(row.state);
++ const body = html`<p class="crumbs"><a href="/run">Run on your machine</a></p>
++<h1>${row.repo.replace(/^https:\/\//, '')}${row.ref ? html` <small>${row.ref}</small>` : ''}</h1>
++<p><span class="run-pill run-pill-${row.state}" data-state>${STATE_LABEL[row.state] || row.state}</span>${row.agent ? html` <span class="run-pill">${row.agent}</span>` : ''}</p>
++<pre class="run-prompt">${row.prompt}</pre>
++${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events" aria-live="polite"><h2>Live</h2><ol class="run-log" data-log></ol></section>
++<form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : resultHtml(row)}`;
++ return page(req, res, { title: 'Task', body });
++ });
++
++ r.get('/run/tasks/:id/events', async (req, res) => {
++ if (!signedIn(req)) return res.status(401).end();
++ const row = await own(req, req.params.id);
++ if (!row || !row.job_id) return res.status(404).end();
++ let url;
++ try { url = await run.streamUrl(row.job_id, who(row)); } catch (e) { return res.status(e.status === 404 ? 404 : 503).end(); }
++ const upstream = new AbortController();
++ req.on('close', () => upstream.abort());
++ let resp;
++ try {
++ resp = await fetchImpl(url, { headers: { Accept: 'text/event-stream' }, signal: upstream.signal });
++ } catch { return res.status(503).end(); }
++ if (!resp.ok || !resp.body) return res.status(resp.status === 404 ? 404 : 503).end();
++ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no', Connection: 'keep-alive' });
++ try {
++ for await (const chunk of resp.body) { if (!res.write(chunk)) await new Promise((ok) => res.once('drain', ok)); }
++ } catch { /* the browser left, or Run closed the stream */ }
++ res.end();
++ return undefined;
++ });
++
++ r.post('/run/tasks/:id/cancel', form, async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Frun');
++ if (!sameOrigin(req)) return res.status(403).type('text/plain').send('Cross-site form posts are refused.\n');
++ const row = await own(req, req.params.id);
++ if (!row) return res.status(404).type('text/plain').send('No such task of yours.\n');
++ if (row.job_id && !TERMINAL.has(row.state)) {
++ try { await run.cancel(row.job_id, who(row)); } catch (e) { log.warn(`[Codes] cancel ${row.id}: ${e.message}`); }
++ }
++ return res.redirect(303, `/run/tasks/${row.id}`);
++ });
++
++ return r;
++}
++
++module.exports = { createRunRoutes, STATE_LABEL, TERMINAL, esc };
ddiff --git a/server/index.js b/server/index.js
iindex 1ea863f..18c10c3 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -6,6 +6,7 @@
*/
const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
++const { startSubscriptions } = require('openvibe-sdk/account-data');
/**
* The process stop (openvibe-sdk/service, docs/service.md's 5 s family): the HTTP drain runs
@@@ -31,6 +32,12 @@ async function start() {
// Keep the JWKS cache fresh in the background (openvibe-sdk/auth): one client per URL, the last
// good keys through outages, exponential backoff, unknown-kid floods throttled, an unref'd timer.
ctx.keys.client.start();
++ // The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
++ // CODES_EVENTS_SECRET or the client secret.
++ startSubscriptions({
++ eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],
++ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ });
createLifecycle({ server, ctx });
return { server, ctx };
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 549ffca..0d0b355 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -23,6 +23,7 @@ const SITE_NAME = 'OpenVibe.Codes';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const NAV = [
{ label: 'Get started', href: '/start' },
++ { label: 'Run on your machine', href: '/run' },
{ label: 'Agents', href: '/harnesses' },
{ label: 'Improve OpenVibe', href: '/improve' },
{ label: 'Community', href: '/policy' },
@@@ -45,7 +46,8 @@ function setRelease(id) { if (id) RELEASE = String(id); }
/**
* o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],
-- * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names]
++ * crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names],
++ * css [this site's own extra stylesheets, rel to public/, for the pages that need them]
*/
function renderPage(o) {
const viewer = o.viewer || { kind: 'anonymous' };
@@@ -93,6 +95,7 @@ function renderPage(o) {
head: `<meta name="referrer" content="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
${appIcon.headTags({ site: 'codes' })}
<link rel="stylesheet" href="${asset('css/codes.css')}">
++${(o.css || []).map((rel) => `<link rel="stylesheet" href="${esc(asset(rel))}">`).join('\n')}
${(o.styles || []).map((name) => `<link rel="stylesheet" href="${esc(ovServe.url(name))}">`).join('\n')}
${render(scripts)}
<meta name="ov-boost" content="codes@${esc(RELEASE)}">
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..20544ce
---- /dev/null
++++ b/test/account-data.test.js
@@@ -0,0 +1,85 @@
++'use strict';
++/**
++ * ADR-033: Codes' part of an account export and of an account deletion, through the signed loopback route
++ * POST /internal/events with a stand-in Network. Ada and Bo each started a coding task on their own machine
++ * (run_tasks, plan T16). Ada's export carries her task whole; her deletion erases it, leaves Bo's, and confirms once;
++ * an unsigned delivery is refused.
++ */
++const assert = require('assert');
++const http = require('http');
++const { createNetworkSender } = require('openvibe-sdk/account-data');
++const { signDeliveryHeaders } = require('openvibe-sdk/events');
++const { ids } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/boot');
++
++const SECRET = `whsec_${'fixture'.repeat(6)}`;
++
++async function startNetworkStub() {
++ const calls = [];
++ const server = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_codes', token_type: 'Bearer', expires_in: 300 });
++ calls.push({ url: req.url, body: JSON.parse(Buffer.concat(chunks).toString() || 'null') });
++ return json(201, {});
++ });
++ });
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
++}
++
++(async () => {
++ const stub = await startNetworkStub();
++ const t = await boot({ env: { CODES_EVENTS_SECRET: SECRET }, accountSend: createNetworkSender({ networkInternalUrl: stub.url, clientId: 'codes', clientSecret: 'shh' }) });
++ const db = t.ctx.store.db;
++ const ada = ids.newId('user');
++ const bo = ids.newId('user');
++ const task = (subject, prompt) => db.exec(`INSERT INTO run_tasks (id, subject, project_id, job_id, repo, prompt, state, result, created_at, updated_at)
++ VALUES ($1, $2, $3, $4, 'https://github.com/a/b', $5, 'succeeded', '{"ok":true}'::jsonb, 1, 2)`, [ids.newId('task').replace(/^[a-z]+_/, 'tsk_'), subject, ids.newId('project'), `job_${ids.ulid()}`, prompt]);
++ await task(ada, 'Ada’s task');
++ await task(bo, 'Bo’s task');
++ const deliver = async (event, { secret = SECRET } = {}) => {
++ const body = JSON.stringify({ event, seq: 1 });
++ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...(secret ? signDeliveryHeaders(body, secret) : {}) } });
++ return { status: res.status };
++ };
++ const ev = (id, type, payload) => ({ event_id: id, event_type: type, source: 'network', version: 1, timestamp: new Date().toISOString(), payload });
++
++ try {
++ await check('an unsigned delivery is refused', async () => {
++ const r = await deliver(ev(`evt_${ids.ulid()}`, 'network.account.deleted', { deletion_id: `del_${ids.ulid()}`, subject: ada, aliases: [] }), { secret: null });
++ assert.strictEqual(r.status, 401);
++ assert.strictEqual(Number(await db.value('SELECT count(*) FROM run_tasks')), 2);
++ });
++
++ await check('the export carries the person’s tasks whole', async () => {
++ const exportId = `exp_${ids.ulid()}`;
++ const r = await deliver(ev(`evt_${ids.ulid()}`, 'network.account.export_requested', { export_id: exportId, subject: ada }));
++ assert.ok(r.status < 300, String(r.status));
++ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${exportId}/parts`);
++ assert.ok(part, JSON.stringify(stub.calls));
++ const file = part.body.files.find((f) => f.name === 'run-tasks.json');
++ assert.strictEqual(file.content.length, 1);
++ assert.strictEqual(file.content[0].prompt, 'Ada’s task');
++ assert.deepStrictEqual(file.content[0].result, { ok: true });
++ });
++
++ await check('a deletion erases them, keeps everyone else’s, and confirms once', async () => {
++ const deletionId = `del_${ids.ulid()}`;
++ const event = ev(`evt_${ids.ulid()}`, 'network.account.deleted', { deletion_id: deletionId, subject: ada, aliases: [] });
++ assert.ok((await deliver(event)).status < 300);
++ assert.ok((await deliver(event)).status < 300, 'a redelivery changes nothing');
++ const rows = await db.many('SELECT subject FROM run_tasks');
++ assert.deepStrictEqual(rows.map((r) => r.subject), [bo]);
++ const confirmations = stub.calls.filter((c) => c.url === `/internal/account-deletions/${deletionId}/confirmations`);
++ assert.strictEqual(confirmations.length, 1);
++ assert.strictEqual(confirmations[0].body.erased.run_tasks, 1);
++ });
++ } finally {
++ await t.close();
++ await stub.close();
++ }
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
iindex 21b9dcd..723b4d0 100644
---- a/test/helpers/boot.js
++++ b/test/helpers/boot.js
@@@ -37,7 +37,7 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
// One database per boot (PGlite, or CODES_TEST_STORE=pg: the containers), dropped when the boot closes.
const testdb = await require('./db').testDb();
-- const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true });
++ const built = await createApp({ config, store: createStore(testdb.db), log, limitsNow: opts.limitsNow, actorLimits: opts.actorLimits === true, ...(opts.run ? { run: opts.run } : {}), ...(opts.accountSend ? { accountSend: opts.accountSend } : {}) });
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
ddiff --git a/test/job.test.js b/test/job.test.js
nnew file mode 100644
iindex 0000000..93f5cf7
---- /dev/null
++++ b/test/job.test.js
@@@ -0,0 +1,127 @@
++'use strict';
++/**
++ * `openvibe-codes job` (harness/job.js): one coding task as an OpenVibe Run job on the person's own machine. Its input
++ * is checked (an https repository with no credentials, a prompt, a branch name); it clones, runs the harness there and
++ * prints one short JSON line per event; the result (the agents' summary, the changed files and the patch) goes to the
++ * result channel once; a push is only ever a new branch on github.com, and no token is ever printed. The scratch
++ * directory is gone afterwards.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { execFileSync, spawnSync } = require('child_process');
++const { runJob, parseArgs, compact, scrub, githubRepo, JOB_PROTOCOL } = require('../harness/job');
++const { check, done } = require('./helpers/boot');
++
++const sh = (cwd, ...args) => execFileSync('git', args, { cwd, env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' } });
++
++/** A small repository to clone, as a file:// URL. */
++function repository() {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-src-'));
++ sh(dir, 'init', '-q', '-b', 'main');
++ fs.writeFileSync(path.join(dir, 'README.md'), '# Demo\n');
++ sh(dir, 'add', '-A');
++ sh(dir, 'commit', '-q', '-m', 'first');
++ return { dir, url: `file://${dir}` };
++}
++
++/** A stand-in harness: the "agent" edits README.md in the clone and says so. */
++function fakeHarness({ fail = false } = {}) {
++ const calls = [];
++ return {
++ calls,
++ run(opts) {
++ calls.push(opts);
++ return (async function* run() {
++ yield { type: 'system', subtype: 'init', agent: 'claude-code', model: 'test-model' };
++ fs.appendFileSync(path.join(opts.cwd, 'README.md'), '\nAdded by the agent.\n');
++ yield { type: 'assistant', message: { content: [{ type: 'text', text: 'I updated the README.' }, { type: 'tool_use', name: 'Edit', input: { file_path: 'README.md' } }] } };
++ yield { type: 'result', is_error: fail, result: fail ? 'gave up' : 'Done: README updated.', duration_ms: 1200, total_cost_usd: 0.0021 };
++ })();
++ },
++ };
++}
++
++(async () => {
++ const env = { ...process.env, OPENVIBE_CODES_ALLOW_FILE_REPO: '1' };
++
++ await check('the input is checked: an https repository without credentials, a prompt, branch names', () => {
++ const ok = parseArgs(JSON.stringify({ repo: 'https://github.com/OpenVibers/OpenVibe.Codes', prompt: 'Fix the typo', ref: 'main', push: { branch: 'fix/typo' } }));
++ assert.deepStrictEqual([ok.repo, ok.ref, ok.permission, ok.push.branch], ['https://github.com/OpenVibers/OpenVibe.Codes', 'main', 'edit', 'fix/typo']);
++ for (const [input, why] of [
++ [{ repo: 'http://github.com/a/b', prompt: 'x' }, /https/],
++ [{ repo: 'https://user:[email protected]/a/b', prompt: 'x' }, /credentials/],
++ [{ repo: 'file:///tmp/x', prompt: 'x' }, /https/],
++ [{ repo: 'https://github.com/a/b', prompt: ' ' }, /prompt/],
++ [{ repo: 'https://github.com/a/b', prompt: 'x', ref: '--upload-pack=evil' }, /ref/],
++ [{ repo: 'https://github.com/a/b', prompt: 'x', push: { branch: '-x' } }, /push.branch/],
++ ]) assert.throws(() => parseArgs(JSON.stringify(input)), why);
++ assert.throws(() => parseArgs('not json'), /JSON/);
++ assert.strictEqual(parseArgs(JSON.stringify({ repo: 'file:///tmp/x', prompt: 'x' }), env).repo, 'file:///tmp/x');
++ });
++
++ await check('a task clones, runs the agent there, streams short lines and hands over the patch once', async () => {
++ const src = repository();
++ const lines = [];
++ const docs = [];
++ const harness = fakeHarness();
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-tmp-'));
++ const doc = await runJob({
++ input: JSON.stringify({ repo: src.url, prompt: 'Add a line to the README', push: { pull_request: true } }),
++ harness, emit: (l) => lines.push(JSON.parse(l)), writeResult: (d) => docs.push(d), env, tmp,
++ });
++ assert.strictEqual(docs.length, 1);
++ assert.strictEqual(doc, docs[0]);
++ assert.strictEqual(doc.protocol, JOB_PROTOCOL);
++ assert.strictEqual(doc.ok, true, JSON.stringify(doc));
++ assert.deepStrictEqual(doc.agents, ['claude-code']);
++ assert.strictEqual(doc.base, 'main');
++ assert.deepStrictEqual(doc.files, [{ path: 'README.md', added: 2, removed: 0 }]);
++ assert.ok(doc.patch.includes('+Added by the agent.') && doc.patch_truncated === false);
++ assert.strictEqual(doc.summary, 'Done: README updated.');
++ assert.strictEqual(doc.cost_usd, 0.0021);
++ // The agent worked inside the clone, with edit permission and hand-offs (no agent was named).
++ assert.strictEqual(harness.calls[0].permission, 'edit');
++ assert.strictEqual(harness.calls[0].handoff, true);
++ assert.ok(harness.calls[0].cwd.startsWith(tmp));
++ const kinds = lines.map((l) => l.t);
++ for (const k of ['step', 'agent', 'text', 'tool', 'done']) assert.ok(kinds.includes(k), `${k} in ${kinds}`);
++ // A push of a non-GitHub repository is declined, with the reason.
++ assert.strictEqual(doc.push.pushed, false);
++ assert.match(doc.push.reason, /github\.com/);
++ assert.deepStrictEqual(fs.readdirSync(tmp), [], 'the scratch directory is gone');
++ // The source repository is untouched.
++ assert.strictEqual(fs.readFileSync(path.join(src.dir, 'README.md'), 'utf8'), '# Demo\n');
++ });
++
++ await check('a failed agent or a bad clone ends with a reason, never a crash', async () => {
++ const src = repository();
++ const failed = await runJob({ input: JSON.stringify({ repo: src.url, prompt: 'x' }), harness: fakeHarness({ fail: true }), emit: () => {}, writeResult: () => {}, env });
++ assert.strictEqual(failed.ok, false);
++ assert.strictEqual(failed.summary, 'gave up');
++ const missing = await runJob({ input: JSON.stringify({ repo: `file://${path.join(os.tmpdir(), 'no-such-repo-here')}`, prompt: 'x' }), harness: fakeHarness(), emit: () => {}, writeResult: () => {}, env });
++ assert.strictEqual(missing.ok, false);
++ assert.ok(missing.error, JSON.stringify(missing));
++ const bad = await runJob({ input: '{"repo":"http://x"}', harness: fakeHarness(), emit: () => {}, writeResult: () => {} });
++ assert.deepStrictEqual([bad.ok, bad.protocol], [false, JOB_PROTOCOL]);
++ });
++
++ await check('tokens never reach a line or a result', () => {
++ assert.strictEqual(scrub('fatal: https://x-access-token:[email protected]/a/b.git'), 'fatal: https://***@github.com/a/b.git');
++ assert.strictEqual(scrub('token ghs_abcdefghijklmnopqrstuvwxyz0123 leaked'), 'token *** leaked');
++ assert.deepStrictEqual(githubRepo('https://github.com/OpenVibers/OpenVibe.Codes.git'), { owner: 'OpenVibers', name: 'OpenVibe.Codes' });
++ assert.strictEqual(githubRepo('https://gitlab.com/a/b'), null);
++ assert.deepStrictEqual(compact({ type: 'result', is_error: false, result: 'ok', duration_ms: 5 }), { t: 'done', ok: true, text: 'ok', duration_ms: 5, cost_usd: 0 });
++ });
++
++ await check('the command reads its input on stdin and prints its result when fd 3 is not open', () => {
++ const r = spawnSync(process.execPath, [path.join(__dirname, '..', 'bin', 'openvibe-codes.js'), 'job'], { input: '{"repo":"ftp://x","prompt":"y"}\n', encoding: 'utf8', env: { ...process.env, OPENVIBE_CODES_HOME: fs.mkdtempSync(path.join(os.tmpdir(), 'codes-home-')) } });
++ assert.strictEqual(r.status, 0, 'a handed-over result ends the job 0 (ok or not is in the document)');
++ const last = JSON.parse(r.stdout.trim().split('\n').pop());
++ assert.strictEqual(last.t, 'result_doc');
++ assert.match(last.result.error, /https/);
++ });
++
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/run-pages.test.js b/test/run-pages.test.js
nnew file mode 100644
iindex 0000000..4e9fb7e
---- /dev/null
++++ b/test/run-pages.test.js
@@@ -0,0 +1,120 @@
++'use strict';
++/**
++ * Run on your machine (server/http/run.js, plan T16): a guest is asked to sign in; a person with no project they may
++ * run jobs in creates one; a task is checked like the job runner checks it, recorded, and started as an OpenVibe Run job
++ * for that person in their project (the task id is the idempotency key); its page streams the job's lines through
++ * Codes and then shows the summary, the changed files and the patch; another person never sees it; cancelling reaches
++ * Run; a form posted from another site is refused.
++ */
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ // A stand-in OpenVibe.Run: what Codes asked, and the job each read answers.
++ const calls = [];
++ let state = 'queued';
++ let result = null;
++ let failSubmit = null;
++ const sse = http.createServer((req, res) => {
++ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
++ res.write('event: output\nid: 1\ndata: {"type":"output","seq":1,"chunk":"{\\"t\\":\\"step\\",\\"text\\":\\"Cloning\\"}\\n"}\n\n');
++ res.end('event: end\nid: 2\ndata: {"type":"end","seq":2,"state":"succeeded"}\n\n');
++ });
++ await new Promise((ok) => sse.listen(0, '127.0.0.1', ok));
++ const run = {
++ enabled: true,
++ async submit(o) { calls.push(['submit', o]); if (failSubmit) throw failSubmit; return { id: 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J1', state: 'queued' }; },
++ async read(jobId, who) { calls.push(['read', jobId, who]); return { id: jobId, state, result, error: state === 'failed' ? { code: 'run.job.worker_failed', detail: 'the machine went away' } : null }; },
++ async cancel(jobId, who) { calls.push(['cancel', jobId, who]); return { id: jobId, state: 'cancelled' }; },
++ async streamUrl(jobId, who) { calls.push(['stream', jobId, who]); return `http://127.0.0.1:${sse.address().port}/stream`; },
++ };
++ const t = await boot({ run });
++ const ada = t.network.addUser('ada');
++ const bo = t.network.addUser('bo');
++ const post = (p, as, form, headers = {}) => t.get(p, { as, form, headers, method: 'POST' });
++
++ await check('a guest is told what it is and asked to sign in', async () => {
++ const r = await t.get('/run');
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.text.includes('Run a coding task on your own machine') && r.text.includes('/auth/login?next=%2Frun') && r.text.includes('openvibe.bot/machines'));
++ });
++
++ let project;
++ await check('with no project to run in, the person creates one first', async () => {
++ const first = await t.get('/run', { as: ada });
++ assert.ok(first.text.includes('One thing first: a project') && !first.text.includes('name="repo"'));
++ assert.strictEqual((await post('/run/project', ada, { name: 'My code' }, { origin: 'https://evil.example' })).status, 403, 'cross-site');
++ const made = await post('/run/project', ada, { name: 'My code' });
++ assert.strictEqual(made.status, 303);
++ const page = await t.get('/run', { as: ada });
++ assert.ok(page.text.includes('name="repo"') && page.text.includes('My code'), page.text.slice(0, 400));
++ project = /<option value="(prj_[0-9A-Z]{26})"/.exec(page.text)[1];
++ });
++
++ let taskId;
++ await check('a task is checked like the runner checks it, then started as a Run job for that person', async () => {
++ const bad = await post('/run', ada, { repo: 'http://github.com/a/b', prompt: 'x', project });
++ assert.strictEqual(bad.status, 422);
++ assert.ok(bad.text.includes('https'));
++ assert.strictEqual(calls.length, 0, 'nothing was started');
++ assert.strictEqual((await post('/run', ada, { repo: 'https://github.com/a/b', prompt: 'x', project }, { origin: 'https://evil.example' })).status, 403);
++ const other = await post('/run', ada, { repo: 'https://github.com/a/b', prompt: 'x', project: 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P9' });
++ assert.strictEqual(other.status, 422, 'only one of the person’s own projects');
++ const ok = await post('/run', ada, { repo: 'https://github.com/OpenVibers/OpenVibe.Codes', ref: 'main', prompt: 'Fix the typo in README', agent: 'claude-code', project, push: '1', pull_request: '1' });
++ assert.strictEqual(ok.status, 303, ok.text);
++ taskId = /\/run\/tasks\/(tsk_[0-9A-Z]{26})$/.exec(ok.headers.get('location'))[1];
++ const [kind, sent] = calls.find((c) => c[0] === 'submit');
++ assert.strictEqual(kind, 'submit');
++ assert.deepStrictEqual([sent.subject, sent.project, sent.key], [ada.subject, project, taskId]);
++ assert.deepStrictEqual(sent.args, { repo: 'https://github.com/OpenVibers/OpenVibe.Codes', ref: 'main', prompt: 'Fix the typo in README', permission: 'edit', push: { branch: null, pull_request: true }, agent: 'claude-code', model: null });
++ const row = await t.ctx.store.db.one('SELECT subject, project_id, job_id, state FROM run_tasks WHERE id = $1', [taskId]);
++ assert.deepStrictEqual({ ...row }, { subject: ada.subject, project_id: project, job_id: 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J1', state: 'queued' });
++ });
++
++ await check('its page is its owner’s: live lines through Codes while it runs, the result after', async () => {
++ assert.strictEqual((await t.get(`/run/tasks/${taskId}`, { as: bo })).status, 404, 'another person');
++ const live = await t.get(`/run/tasks/${taskId}`, { as: ada });
++ assert.ok(live.text.includes(`data-events="/run/tasks/${taskId}/events"`) && live.text.includes('Waiting for your machine'));
++ const events = await t.get(`/run/tasks/${taskId}/events`, { as: ada });
++ assert.strictEqual(events.status, 200);
++ assert.match(events.headers.get('content-type'), /text\/event-stream/);
++ assert.ok(events.text.includes('Cloning') && events.text.includes('event: end'));
++ assert.strictEqual((await t.get(`/run/tasks/${taskId}/events`, { as: bo })).status, 404);
++ const stream = calls.find((c) => c[0] === 'stream');
++ assert.deepStrictEqual(stream[2], { subject: ada.subject, project });
++ state = 'succeeded';
++ result = { protocol: '1.0.0', ok: true, agents: ['claude-code'], summary: 'Fixed <the> typo', files: [{ path: 'README.md', added: 1, removed: 1 }], patch: '@@ -1 +1 @@\n-teh\n+the\n', patch_truncated: false, push: { pushed: true, branch: 'openvibe-codes/x', branch_url: 'https://github.com/OpenVibers/OpenVibe.Codes/tree/openvibe-codes%2Fx', pull_request: 'https://github.com/OpenVibers/OpenVibe.Codes/pull/9' }, duration_ms: 42000 };
++ const done_ = await t.get(`/run/tasks/${taskId}`, { as: ada });
++ assert.ok(done_.text.includes('The agents finished the task') && done_.text.includes('Fixed &lt;the&gt; typo') && done_.text.includes('README.md'));
++ assert.ok(done_.text.includes('pull/9') && done_.text.includes('data-patch') && !done_.text.includes('data-events'));
++ const row = await t.ctx.store.db.one('SELECT state, result FROM run_tasks WHERE id = $1', [taskId]);
++ assert.strictEqual(row.state, 'succeeded');
++ assert.strictEqual(row.result.summary, 'Fixed <the> typo');
++ const list = await t.get('/run', { as: ada });
++ assert.ok(list.text.includes(`/run/tasks/${taskId}`) && list.text.includes('Finished'));
++ });
++
++ await check('cancel reaches Run for a running task; a Run refusal at start leaves a failed task that says why', async () => {
++ state = 'running';
++ result = null;
++ const ok = await post('/run', ada, { repo: 'https://github.com/a/b', prompt: 'Long task', project });
++ const id = /tsk_[0-9A-Z]{26}/.exec(ok.headers.get('location'))[0];
++ await t.ctx.store.db.exec("UPDATE run_tasks SET job_id = 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J2' WHERE id = $1", [id]);
++ assert.strictEqual((await post(`/run/tasks/${id}/cancel`, bo, {})).status, 404);
++ const c = await post(`/run/tasks/${id}/cancel`, ada, {});
++ assert.strictEqual(c.status, 303);
++ assert.ok(calls.some((x) => x[0] === 'cancel' && x[1] === 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J2'));
++ const { RunError } = require('../server/domain/run-client');
++ failSubmit = new RunError(403, 'project.not_delegated', 'not an admin of that project');
++ const refused = await post('/run', ada, { repo: 'https://github.com/a/b', prompt: 'x', project });
++ const rid = /tsk_[0-9A-Z]{26}/.exec(refused.headers.get('location'))[0];
++ state = 'failed';
++ const page = await t.get(`/run/tasks/${rid}`, { as: ada });
++ assert.ok(page.text.includes('project.not_delegated'), page.text.slice(-800));
++ });
++
++ await t.close();
++ sse.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex bbbbef0..c2572d7 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -41,6 +41,8 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
const REVIEWED = {
'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS',
'server/auth/sso.js': 'Network OAuth token and revoke (configured)',
++ 'server/domain/run-client.js': 'OpenVibe.Run (CODES_RUN_URL, configured) and the Network token endpoint; job ids only, never a URL from a person',
++ 'server/http/run.js': 'Network projects API (configured) with the viewer\'s token, and the Run job stream URL run-client.js builds on its own base',
};
const root = path.join(__dirname, '..');
const found = [];