openvibers/openvibe.codes Commits
The pictures of a task: private in Media, signed for the owner (#188)
Changes
@@@ -94,7 +94,7 @@ More: [docs/cli.md](docs/cli.md).- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256) and its JWKS.- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).--- **openvibe-contracts v0.243.0**, **openvibe-sdk v0.74.0**, **openvibe-shared v4.48.0** (pinned tag tarballs).++- **openvibe-contracts v0.243.0**, **openvibe-sdk v0.74.0**, **openvibe-shared v4.50.0** (pinned tag tarballs).No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).@@@ -171,5 +171,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [<!-- versions:start -->- openvibe-contracts: v0.247.0- openvibe-sdk: v0.74.0--- openvibe-shared: v4.48.0++- openvibe-shared: v4.50.0<!-- versions:end -->@@@ -26,6 +26,11 @@ const MAX_PATCH = 180_000;const MAX_TEXT = 2_000;const MAX_OUTPUT = 4_000; // a step's output on a line (live)const MAX_LOG = 240_000; // the session log kept in the result (its outputs cut shorter when it would not fit)++// The pictures a run carries back: Run's JSON body is 768 KB, so each image is capped and the run as a whole is too.++// What does not fit is left out — the picture stays on the person's machine and the session shows its name.++const MAX_IMAGE_BYTES = 160 * 1024;++const MAX_RESULT_BYTES = 320 * 1024;++const IMAGE_MIME = /^image\/(png|jpeg|gif|webp)$/i;const BRANCH = /^[A-Za-z0-9._/-]{1,100}$/;/** git with no prompt and no credential helper; rejects with the command's stderr (tokens scrubbed). */@@@ -198,7 +203,47 @@ const resultText = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map(* A line from a helper agent carries `parent`, the id of the Task step that started it. 1.0 readers keep working: the* fields they read are where they were.*/--function compact(e) {++/**++ * The pictures a tool answered with: a Read of an image comes back as an image block, and the session shows it as a++ * rich embed. The bytes travel as a data URL, so the page needs no other request to draw them.++ */++function imageAttachments(content, budget) {++ const out = [];++ for (const c of Array.isArray(content) ? content : []) {++ if (!c || c.type !== 'image') continue;++ const src = c.source && typeof c.source === 'object' ? c.source : {};++ const mime = String(src.media_type || c.media_type || 'image/png');++ const data = String(src.data || '');++ if (!data || !IMAGE_MIME.test(mime)) continue;++ const bytes = Math.ceil(data.length * 3 / 4);++ if (bytes > MAX_IMAGE_BYTES) continue;++ if (!budget || budget.left < bytes) continue;++ if (budget) budget.left -= bytes;++ out.push({ url: `data:${mime};base64,${data}`, name: String(c.name || c.file_name || ''), mime, kind: 'image', role: 'read' });++ }++ return out;++}++++/**++ * A proof screenshot with its bytes, when they fit: the page shows the picture, not a name for it. The picture itself++ * stays in the clone; only these bytes travel.++ */++function shotWithBytes(shot, budget) {++ if (!shot || typeof shot !== 'object') return shot;++ const file = String(shot.path || '');++ if (!file) return shot;++ try {++ const bytes = fs.readFileSync(file);++ if (!bytes.length || bytes.length > MAX_IMAGE_BYTES) return shot;++ if (budget && budget.left < bytes.length) return shot;++ if (budget) budget.left -= bytes.length;++ return { ...shot, name: shot.name || path.basename(file), mime: shot.mime || 'image/jpeg', data: bytes.toString('base64') };++ } catch {++ return shot; // the picture stays on the machine; the session shows its name++ }++}++++function compact(e, ctx) {const cut = (s) => cutTo(s, MAX_TEXT);const parent = e.parent_tool_use_id ? { parent: String(e.parent_tool_use_id) } : {};if (e.type === 'system' && e.subtype === 'init') return { t: 'agent', agent: e.agent, model: e.model || null, session: e.session_id || null };@@@ -220,7 +265,12 @@ function compact(e) {}if (e.type === 'user' && e.message && Array.isArray(e.message.content)) {return e.message.content.filter((c) => c && c.type === 'tool_result')-- .map((c) => ({ t: 'tool_result', id: String(c.tool_use_id || ''), ok: !c.is_error, output: clean(cutTo(resultText(c.content), MAX_OUTPUT)), ...parent }));++ .map((c) => {++ const line = { t: 'tool_result', id: String(c.tool_use_id || ''), ok: !c.is_error, output: clean(cutTo(resultText(c.content), MAX_OUTPUT)), ...parent };++ const pictures = imageAttachments(c.content, ctx && ctx.pictures);++ if (pictures.length) line.attachments = pictures;++ return line;++ });}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, turns: e.num_turns || 0 };@@@ -238,6 +288,12 @@ function sessionLog(lines) {let list = lines.slice();let total = list.reduce((n, l) => n + size(l), 0);if (total <= MAX_LOG) return list;++ // The pictures go before the words: a session keeps what was said before it keeps what was shown.++ list = list.map((l) => (l.attachments && l.attachments.length)++ ? { ...l, attachments: l.attachments.map((x) => (x && typeof x.url === 'string' && x.url.startsWith('data:')) ? { ...x, url: undefined } : x) }++ : l);++ total = list.reduce((n, l) => n + size(l), 0);++ if (total <= MAX_LOG) return list;const ended = new Set(list.filter((l) => l.t === 'tool_result').map((l) => l.id));list = list.filter((l) => !(l.t === 'tool_progress' && ended.has(l.id)));for (const n of [1500, 600, 200]) {@@@ -442,9 +498,11 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm...(adapterOptions ? { adapterOptions } : {}),});let last = null;++ // What the pictures may take of the result's room.++ const pictures = { left: MAX_RESULT_BYTES };for await (const e of run) {last = e;-- const c = compact(e);++ const c = compact(e, { pictures });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);}@@@ -472,7 +530,7 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tmauthor: { agent: job.agent, model: job.model },emit: (item) => say({ t: 'proof', kind: item.kind, label: item.label, ok: item.ok, skipped: !!item.skipped, summary: item.summary, family: item.family || null }),});-- result.proof = { items: collected.items.map((i) => ({ kind: i.kind, label: i.label, ok: i.ok, skipped: !!i.skipped, summary: i.summary, family: i.family || null, detail: i.shots ? { shots: i.shots } : (i.kind === 'diff' ? { files: i.files, stats: i.stats } : null) })), outcome: collected.outcome };++ result.proof = { items: collected.items.map((i) => ({ kind: i.kind, label: i.label, ok: i.ok, skipped: !!i.skipped, summary: i.summary, family: i.family || null, detail: i.shots ? { shots: i.shots.map((s) => shotWithBytes(s, pictures)) } : (i.kind === 'diff' ? { files: i.files, stats: i.stats } : null) })), outcome: collected.outcome };} catch (e) {say({ t: 'note', level: 'warning', text: `The proof could not be collected: ${scrub(e.message)}` });result.proof = { items: [], outcome: null };@@@ -102,12 +102,14 @@ async function shootPage({ playwright, chromePath, url, width, outDir, route })await page.goto(url, { waitUntil: 'load', timeout: 30000 });await page.waitForTimeout(600);const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);-- const file = path.join(outDir, `proof-${(route || '/').replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '') || 'home'}-${width}.png`);-- await page.screenshot({ path: file, fullPage: true });++ const file = path.join(outDir, `proof-${(route || '/').replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '') || 'home'}-${width}.jpg`);++ // A JPEG, not a PNG: the picture travels back to Codes in the job result, and Run's JSON body is 768 KB.++ await page.screenshot({ path: file, fullPage: true, type: 'jpeg', quality: 70 });const overflow = Number(scrollWidth) > width;const ok = !overflow && !errors.length;return {ok, path: file, width, route: route || '/', errors, console_errors: consoleErrors, overflow, url,++ name: path.basename(file), mime: 'image/jpeg',final_url: typeof page.url === 'function' ? page.url() : url,summary: ok ? 'the page renders clean': [overflow ? 'the page is ' + scrollWidth + 'px wide, so it scrolls sideways on a ' + width + 'px screen' : null]@@@ -17,7 +17,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.247.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.50.0","pg": "^8.23.0"},"bin": {@@@ -1193,9 +1193,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.48.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",-- "integrity": "sha512-JzASdCIvFTnJz3Xeng1e/j9WGZAVPpa6AvF60tgNf4TGFXm+PXQXyFp2o2JyHDkqF3L8HlY6CDVcIRGTQegjJQ==",++ "version": "4.50.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.50.0",++ "integrity": "sha512-mWefCYJUMGS3Pd14BuU1uTiWyYrjxgf8hgGmPbahfLwDYQ/dBFdYVQ5PKXXxN5uY/NTFt9+FKGPLmeDiDvpkZQ==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -32,7 +32,7 @@"iovalkey": "^0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.247.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.50.0","pg": "^8.23.0"},"engines": {@@@ -76,7 +76,7 @@ async function createApp(opts = {}) {// The harness catalog (server/data/harness-offers.json), read once: a bad seed row fails boot.const harnesses = createHarnesses();// Sessions, the run.job.* consumer and the codes.task.* outbox share one store and one clock.-- const sessions = createSessionLines({ store, config, log });++ const sessions = createSessionLines({ store, config, log, media: require('./media').createMedia({ config, log }) });const outbox = createOutbox({ store, log });const run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });// A person's own connections (Codes C3): their provider keys live in OpenVibe.AI, and their harnesses run with a@@@ -111,6 +111,16 @@ function load(env = process.env) {retentionDays: Math.max(1, int(env.CODES_SESSION_RETENTION_DAYS, 90)),retentionIntervalMs: Math.max(60_000, int(env.CODES_SESSION_RETENTION_INTERVAL_MS, 86_400_000)),},++ // OpenVibe.Media: the private objects a task's pictures live in. A proof screenshot can show anything on the++ // person's screen, so it is never public: the bytes go to Media as a private object of the person, and the++ // session shows a short-lived signed URL that only that person can use. Without a key there are no pictures.++ media: {++ url: trim(env.CODES_MEDIA_URL || 'https://openvibe.media'),++ key: String(env.CODES_MEDIA_KEY || '').trim(),++ app: trim(env.CODES_MEDIA_APP || 'codes'),++ ttl: int(env.CODES_MEDIA_URL_TTL, 600),++ },++// 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') },@@@ -55,6 +55,7 @@ function createProof({ store, config = {}, log = console }) {const seen = new Set(known.map((r) => Number(r.n)));const attempt = attemptN == null ? 0 : Number(attemptN);const stored = [];++ const byId = new Map();for (const item of list) {const kind = KINDS.includes(item && item.kind) ? item.kind : 'note';let n = stored.length + 1;@@@ -62,8 +63,16 @@ function createProof({ store, config = {}, log = console }) {seen.add(n);const id = store.newId('prf');const detail = item && item.detail !== undefined ? item.detail : item;++ // The pictures of a proof item: their bytes go on the line, where Codes puts them in Media as private++ // objects of the person. The stored detail keeps what the picture is, never its bytes.++ const shots = detail && Array.isArray(detail.shots) ? detail.shots : [];++ const pictures = shots.filter((s) => s && s.data).map((s) => ({++ url: `data:${String(s.mime || 'image/jpeg')};base64,${String(s.data)}`,++ name: String(s.name || ''), mime: String(s.mime || 'image/jpeg'), kind: 'image', role: 'output',++ }));++ const kept = shots.length ? { ...detail, shots: shots.map((s) => { const c = { ...s }; delete c.data; return c; }) } : detail;let body = null;-- try { body = JSON.stringify(detail); } catch { body = null; }++ try { body = JSON.stringify(kept); } catch { body = null; }if (body && body.length > MAX_DETAIL) body = body.slice(0, MAX_DETAIL);const row = await db.maybe('INSERT INTO codes_task_proof (id, task_id, attempt_n, n, kind, label, ok, skipped, summary, family, detail, created_at)'@@@ -73,7 +82,7 @@ function createProof({ store, config = {}, log = console }) {item && item.ok === false ? false : true, item && item.skipped === true ? true : false,clip(item && item.summary, 1000), clip(item && item.family, 80), body, store.iso()],);-- if (row) stored.push(row);++ if (row) { stored.push(row); byId.set(row.id, pictures); }}// One line per item on the task's stream, after the lines that are already there.let seq = await nextSeq(taskId);@@@ -83,6 +92,7 @@ function createProof({ store, config = {}, log = console }) {body: {t: 'proof', kind: row.kind, label: row.label, ok: row.ok === true,skipped: row.skipped === true, summary: row.summary || null, family: row.family || null,++ ...(byId.get(row.id) && byId.get(row.id).length ? { attachments: byId.get(row.id) } : {}),},}));return { rows: stored, lines };@@@ -149,7 +149,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouconst db = store.db;const who = (row) => ({ subject: row.subject, project: row.project_id });// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).-- const sessionLines = sessions || require('./session-lines').createSessionLines({ store, config, log });++ const sessionLines = sessions || require('./session-lines').createSessionLines({ store, config, log, media: require('../media').createMedia({ config, log }) });const taskOutbox = outbox || require('../events/outbox').createOutbox({ store, log });// The proof of a finished task (stage C8): the tests, the pages, the diff and the second model's opinion.const taskProof = proof || require('./proof').createProof({ store, config, log });@@@ -94,12 +94,61 @@ function lineOf(raw) {return { kind, body };}--function createSessionLines({ store, config = {}, log = console }) {++function createSessionLines({ store, config = {}, log = console, media = null }) {const db = store.db;const retentionDays = Math.max(1, Number(config.sessions && config.sessions.retentionDays) || 90);++ const pictures = media && media.configured ? media : null;++++ /** The bytes of a data URL, or null when it is not one. */++ function bytesOf(url) {++ const m = /^data:([^;,]+)?;base64,(.*)$/i.exec(String(url || ''));++ if (!m) return null;++ try { return Buffer.from(m[2], 'base64'); } catch { return null; }++ }++++ /**++ * The pictures on a line: the bytes go to Media as private objects of the person, and the line keeps the object++ * id — never the bytes, and never a public URL. Without Media the pictures are left out and the line keeps its++ * words.++ */++ async function storeAttachments(body, subject) {++ if (!body || !Array.isArray(body.attachments) || !body.attachments.length) return body;++ const list = [];++ for (const a of body.attachments) {++ if (!a || typeof a !== 'object') continue;++ const url = String(a.url || '');++ const bytes = bytesOf(url);++ if (!bytes || !pictures) continue; // not our bytes: nothing to upload++ try {++ const put = await pictures.put({ bytes, mime: a.mime, name: a.name, kind: 'screenshot', subject });++ list.push({ name: String(a.name || ''), mime: String(a.mime || ''), kind: String(a.kind || 'image'), role: String(a.role || 'output'), media: put.id });++ } catch (e) {++ log.warn(`[Codes] a picture did not reach Media: ${String(e && e.message).slice(0, 160)}`);++ }++ }++ if (!list.length) { const copy = { ...body }; delete copy.attachments; return copy; }++ return { ...body, attachments: list };++ }++++ /** The lines a reader gets: every picture a short-lived signed URL, and only for the person the task belongs to. */++ async function signLines(list, subject) {++ if (!Array.isArray(list) || !list.length) return list;++ if (!pictures) return list;++ const out = [];++ for (const l of list) {++ if (!l || !Array.isArray(l.attachments) || !l.attachments.length) { out.push(l); continue; }++ out.push({ ...l, attachments: await pictures.signAttachments(l.attachments, subject) });++ }++ return out;++ }++ /** One line with its pictures signed. */++ async function signLine(line, subject) {++ const [out] = await signLines([line], subject);++ return out;++ }/** Insert lines in order; an existing (task_id, seq) wins, so a replay never duplicates. → rows inserted. */-- async function append(taskId, lines, attemptN = null) {++ async function append(taskId, lines, attemptN = null, subject = null) {if (!taskId || !Array.isArray(lines) || !lines.length) return 0;const clean = [];for (const line of lines) {@@@ -109,7 +158,7 @@ function createSessionLines({ store, config = {}, log = console }) {const kind = String(line.kind || 'text').slice(0, 64) || 'text';// The transcript normaliser: a run token (Codes C3) that an agent printed is redacted here, before the// line is kept, so it never reaches the transcript, the page or the log.-- clean.push({ seq, at, kind, body: clipBody(redact.scrubValue(line.body)) });++ clean.push({ seq, at, kind, body: clipBody(redact.scrubValue(await storeAttachments(line.body, subject))) });}let inserted = 0;for (let i = 0; i < clean.length; i += 200) {@@@ -138,7 +187,7 @@ function createSessionLines({ store, config = {}, log = console }) {* into the job's JSON lines; state and end frames become their own lines. The stream's seq is the base, so the* same event replayed after a reconnect maps to the same rows.*/-- function recorder(taskId, { attemptN = null } = {}) {++ function recorder(taskId, { attemptN = null, subject = null } = {}) {let frameBuffer = '';let outputBuffer = '';let fallbackSeq = 0;@@@ -199,7 +248,7 @@ function createSessionLines({ store, config = {}, log = console }) {frameBuffer = `${frameBuffer}${String(text == null ? '' : text)}`.replace(/\r\n/g, '\n');const out = [];drainFrames(out);-- if (out.length) await append(taskId, out, attemptN);++ if (out.length) await append(taskId, out, attemptN, subject);},async flush() {const out = [];@@@ -211,7 +260,7 @@ function createSessionLines({ store, config = {}, log = console }) {out.push({ seq: (lastBase || 0) + PARTIAL_LINE_INDEX, at: store.iso(), kind: line.kind, body: line.body });outputBuffer = '';}-- if (out.length) await append(taskId, out, attemptN);++ if (out.length) await append(taskId, out, attemptN, subject);},ended: () => ended,};@@@ -245,7 +294,7 @@ function createSessionLines({ store, config = {}, log = console }) {const at = atIso(row.finished_at) || (row.updated_at ? iso(row.updated_at) : store.iso());return await append(row.id, missing.map((body, i) => ({seq: start + i, at, kind: typeof body.t === 'string' ? body.t : 'text', body,-- })), attemptN);++ })), attemptN, row.subject);}async function complete(taskId) {@@@ -266,8 +315,8 @@ function createSessionLines({ store, config = {}, log = console }) {if (!row || !row.id) return [];const rows = await allRows(row.id);const session = rows.filter((r) => r.kind !== 'state' && r.kind !== 'end').map((r) => r.body);-- if (session.length) return session;-- return sessionLogOf(row) || [];++ if (session.length) return await signLines(session, row.subject);++ return await signLines(sessionLogOf(row) || [], row.subject);}/** One replay page (at most 500 lines after `after`), from the table or the old result.log. */@@@ -287,6 +336,8 @@ function createSessionLines({ store, config = {}, log = console }) {.filter((line) => line.seq > from);}const page = lines.slice(0, max);++ const signed = await signLines(page.map((l) => l.body), row.subject);++ page.forEach((l, i) => { l.body = signed[i]; });return {task_id: row.id,source,@@@ -67,7 +67,7 @@ function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globa};// A task reads Run only as the 10-minute fallback; its state now arrives from run.job.* events.const taskStore = tasks || createRunTasks({ store, run, config, log });-- const sessionStore = sessions || createSessionLines({ store, config, log });++ const sessionStore = sessions || createSessionLines({ store, config, log, media: require('../media').createMedia({ config, log }) });const { who, refresh } = taskStore;function formHtml({ projects, values = {}, error = null }) {@@@ -298,7 +298,7 @@ ${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events// The bytes go to the browser unchanged, and the same frames become codes_session_lines as they stream.const attempts = await taskStore.attempts.list(row.id);const attemptN = attempts.length ? Number(attempts[attempts.length - 1].n) : null;-- const recorder = sessionStore.recorder(row.id, { attemptN });++ const recorder = sessionStore.recorder(row.id, { attemptN, subject: row.subject });const decoder = new TextDecoder();try {for await (const chunk of resp.body) {nnew file mode 100644@@@ -0,0 +1,118 @@++'use strict';++/**++ * OpenVibe.Media: the private objects a task's images live in.++ *++ * A proof screenshot can show anything on the person's screen, so it never becomes a public file. The bytes go to++ * Media as a private object owned by the person, and the session shows a short-lived signed URL that only that++ * person (through Codes) can use. Without a key there are no attachments, and the session simply shows the text.++ *++ * createMedia({ config, fetchImpl })++ * configured() is Media set up?++ * put({ bytes, mime, name, kind, subject }) → { id } a private object++ * signed(id, subject) → { url } a short-lived download URL++ * uploadAttachments(list, subject) → the list with the bytes swapped for object ids++ * signAttachments(list, subject) → the list with the object ids swapped for URLs++ */++const DEFAULT_TTL = 900; // a signed URL lives fifteen minutes++const MAX_BYTES = 12 * 1024 * 1024;++++function createMedia({ config, fetchImpl = globalThis.fetch, log = null } = {}) {++ const media = (config && config.media) || {};++ const url = String(media.url || '').replace(/\/+$/, '');++ const key = String(media.key || '').trim();++ const app = String(media.app || 'codes').trim() || 'codes';++ const ttl = Math.min(3600, Math.max(60, Number(media.ttl) || DEFAULT_TTL));++ const base = () => `${url}/api/v2/${encodeURIComponent(app)}/objects`;++ const configured = () => !!(url && key);++++ const warn = (msg) => { if (log && typeof log.warn === 'function') log.warn(msg); };++++ async function call(method, apiUrl, subject, body, raw) {++ const headers = { authorization: `Bearer ${key}`, accept: 'application/json' };++ if (subject) headers['x-ov-subject'] = String(subject);++ if (body != null) headers['content-type'] = 'application/json';++ const ctrl = new AbortController();++ const timer = setTimeout(() => ctrl.abort(), 30000);++ let res;++ try {++ res = await fetchImpl(apiUrl, { method, headers, body: body == null ? undefined : JSON.stringify(body), signal: ctrl.signal });++ } finally {++ clearTimeout(timer);++ }++ const text = await res.text();++ let json = null;++ try { json = text ? JSON.parse(text) : null; } catch { json = null; }++ if (!res.ok) {++ const err = new Error(`Media answered HTTP ${res.status}`);++ err.status = res.status;++ err.body = json || text.slice(0, 200);++ throw err;++ }++ return raw ? json : (json || {});++ }++++ /** The bytes into a private object of the person: { id }. */++ async function put({ bytes, mime, name, kind = 'screenshot', subject } = {}) {++ if (!configured()) throw new Error('Media is not set up');++ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(String(bytes || ''), 'base64');++ if (!buf.length) throw new Error('no bytes');++ if (buf.length > MAX_BYTES) throw new Error('too big for Media');++ const init = await call('POST', base(), subject, {++ kind, visibility: 'private', mime_type: String(mime || 'image/png'),++ size_bytes: buf.length, filename: String(name || 'image'),++ });++ const up = init.upload || {};++ const res = await fetchImpl(up.url, { method: up.method || 'PUT', body: buf, headers: { 'content-type': String(mime || 'image/png') } });++ if (!res.ok) throw new Error(`Media refused the bytes: HTTP ${res.status}`);++ await call('POST', up.complete_url || `${base()}/${encodeURIComponent(init.id)}/complete`, subject, {});++ return { id: String(init.id || '') };++ }++++ /** A short-lived signed URL for a private object: { url, expires_at }. */++ async function signed(id, subject) {++ if (!configured()) return null;++ const r = await call('GET', `${base()}/${encodeURIComponent(id)}/download?ttl=${ttl}`, subject, null, true);++ return r && r.url ? r : null;++ }++++ /** A list of attachments whose bytes become private objects: the bytes are gone from the list. */++ async function uploadAttachments(list, subject) {++ const arr = Array.isArray(list) ? list : [];++ if (!arr.length || !configured()) return arr;++ const out = [];++ for (const a of arr) {++ if (!a || typeof a !== 'object') continue;++ const data = a.data ? String(a.data) : '';++ if (!data) { out.push(a); continue; }++ try {++ const put_ = await put({ bytes: data, mime: a.mime, name: a.name, kind: a.kind === 'image' ? 'screenshot' : 'file', subject });++ out.push({ url: '', media: put_.id, name: a.name || '', mime: a.mime || '', kind: a.kind || '', role: a.role || 'output' });++ } catch (e) {++ warn(`[Codes] a picture did not reach Media: ${String(e && e.message).slice(0, 160)}`);++ }++ }++ return out;++ }++++ /** A list of attachments whose objects become signed URLs: { url, … }. */++ async function signAttachments(list, subject) {++ const arr = Array.isArray(list) ? list : [];++ if (!arr.length || !configured()) return arr;++ const out = [];++ for (const a of arr) {++ if (!a || typeof a !== 'object') continue;++ if (!a.media || a.url) { out.push(a); continue; }++ try {++ const s = await signed(a.media, subject);++ if (s && s.url) out.push(Object.assign({}, a, { url: s.url }));++ } catch (e) {++ warn(`[Codes] a picture could not be signed: ${String(e && e.message).slice(0, 160)}`);++ }++ }++ return out;++ }++++ return { configured, put, signed, uploadAttachments, signAttachments, apiBase: base };++}++++module.exports = { createMedia, DEFAULT_TTL, MAX_BYTES };nnew file mode 100644@@@ -0,0 +1,133 @@++'use strict';++/**++ * The pictures of a task (Codes C10): the images a harness read and the screenshots the proof shot travel in the job++ * result, and Codes puts them in Media as private objects of the person — never a public URL. The session shows a++ * short-lived signed URL, and only the task's owner gets one: another person gets a 404.++ */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++++const { createSessionLines } = require('../server/domain/session-lines');++const { createProof } = require('../server/domain/proof');++const { createMedia } = require('../server/media');++const { compact } = require('../harness/job');++++const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';++const b64 = (bytes) => Buffer.alloc(bytes, 7).toString('base64');++++(async () => {++ const calls = [];++ const mediaServer = http.createServer((req, res) => {++ const u = req.url.split('?')[0];++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks);++ calls.push({ method: req.method, url: req.url, headers: req.headers, body: raw });++ if (req.method === 'POST' && u === '/api/v2/codes/objects') {++ const body = JSON.parse(raw.toString('utf8') || '{}');++ res.writeHead(201, { 'content-type': 'application/json' });++ return res.end(JSON.stringify({++ id: 'obj_1',++ upload: {++ method: 'PUT', url: `http://127.0.0.1:${mediaServer.address().port}/upload/obj_1?token=t`,++ token: 't', complete_url: `http://127.0.0.1:${mediaServer.address().port}/api/v2/codes/objects/obj_1/complete`,++ },++ }));++ }++ if (req.method === 'PUT' && u.startsWith('/upload/')) { res.writeHead(200); return res.end('ok'); }++ if (req.method === 'POST' && u.endsWith('/complete')) { res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{}'); }++ if (req.method === 'GET' && u.endsWith('/download')) {++ res.writeHead(200, { 'content-type': 'application/json' });++ return res.end(JSON.stringify({ url: 'https://openvibe.media/o/obj_1?exp=99&sig=abc', expires_at: new Date(Date.now() + 600000).toISOString(), public: false }));++ }++ res.writeHead(404); return res.end('no');++ });++ });++ await new Promise((ok) => mediaServer.listen(0, '127.0.0.1', ok));++ const mediaUrl = `http://127.0.0.1:${mediaServer.address().port}`;++++ const t = await boot({ env: { CODES_MEDIA_URL: mediaUrl, CODES_MEDIA_KEY: 'codes-test-key' } });++ const db = t.ctx.store.db;++ const ada = t.network.addUser('ada');++ const bo = t.network.addUser('bo');++ const project = 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1';++ const taskId = 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T55';++ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, job_id, repo, prompt, state, created_at, updated_at)++ VALUES ($1, $2, $3, 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J55', 'https://github.com/a/b', 'look at the picture', 'running', 1, 2)`,++ [taskId, ada.subject, project]);++++ const media = createMedia({ config: { media: { url: mediaUrl, key: 'codes-test-key', app: 'codes', ttl: 600 } }, log: console });++ const lines = createSessionLines({ store: t.ctx.store, config: t.ctx.config, log: console, media });++ const proof = createProof({ store: t.ctx.store, config: t.ctx.config, log: console });++++ await check('a picture on a line goes to Media as a private object of the person, and the line keeps no bytes', async () => {++ calls.length = 0;++ await lines.append(taskId, [{++ seq: 1, at: new Date().toISOString(), kind: 'tool_result',++ body: { t: 'tool_result', id: 'tool-1', ok: true, output: '', attachments: [{ url: `data:image/png;base64,${PNG}`, name: 'shot.png', mime: 'image/png', kind: 'image', role: 'read' }] },++ }], null, ada.subject);++++ const init = calls.find((c) => c.method === 'POST' && c.url === '/api/v2/codes/objects');++ assert.ok(init, 'the picture was offered to Media');++ const body = JSON.parse(init.body.toString('utf8'));++ assert.strictEqual(body.visibility, 'private', 'a screenshot is never public');++ assert.strictEqual(body.mime_type, 'image/png', 'Media is told what it is');++ assert.ok(init.headers['x-ov-subject'] === ada.subject, 'the object belongs to the person the task belongs to');++ assert.ok(calls.some((c) => c.method === 'PUT' && c.url.startsWith('/upload/')), 'the bytes were sent');++ assert.ok(calls.some((c) => c.method === 'POST' && c.url.endsWith('/complete')), 'and the object was completed');++++ const rows = await db.many('SELECT body FROM codes_session_lines WHERE task_id = $1', [taskId]);++ const stored = rows[0].body;++ assert.ok(stored.attachments && stored.attachments[0].media === 'obj_1', 'the line keeps the object id');++ assert.ok(!stored.attachments[0].url, 'and no URL of its own');++ assert.ok(!JSON.stringify(stored).includes('base64'), 'and never the bytes');++ });++++ await check('the reader gets a short-lived signed URL, and only the owner', async () => {++ const out = await lines.linesForRow({ id: taskId, subject: ada.subject });++ const att = out[0].attachments[0];++ assert.ok(att.url && att.url.startsWith('https://openvibe.media/o/'), 'the picture is served by Media');++ assert.ok(att.url.includes('sig='), 'through a signed URL');++ assert.ok(!att.media || att.media === 'obj_1', 'the object id is still there');++ });++++ await check('another person cannot read the task at all', async () => {++ const res = await t.get(`/run/tasks/${taskId}/session.json`, { as: bo });++ assert.strictEqual(res.status, 404, 'a task of yours is 404 for another person');++ const row = await db.maybe('SELECT id, subject FROM run_tasks WHERE id = $1', [taskId]);++ process.stderr.write(`[test] the row: ${JSON.stringify(row)}; ada: ${ada.subject}\n`);++ });++++ await check('a proof item with a screenshot puts the picture on its line and keeps no bytes in the record', async () => {++ const recorded = await proof.record(taskId, null, [{++ kind: 'screenshots', label: 'screenshots', ok: true, summary: '2 screenshots of 1 page at 390px, clean',++ detail: { shots: [{ path: '/tmp/proof-home-390.jpg', name: 'proof-home-390.jpg', mime: 'image/jpeg', width: 390, data: b64(1200) }] },++ }]);++ assert.strictEqual(recorded.lines.length, 1, 'the proof has a line');++ const att = recorded.lines[0].body.attachments;++ assert.ok(att && att.length === 1, 'the line carries the picture');++ assert.ok(att[0].url.startsWith('data:image/jpeg;base64,'), 'with its bytes, for Codes to put in Media');++ assert.strictEqual(att[0].role, 'output', 'and it is what the run produced');++ const row = recorded.rows[0];++ assert.ok(!JSON.stringify(row.detail).includes('"data"'), 'the stored record keeps no bytes');++ assert.ok(row.detail.shots[0].width === 390, 'but keeps what the picture is');++ });++++ await check('the harness reads a picture once, and only if it fits', async () => {++ const small = compact({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't1', content: [{ type: 'image', source: { media_type: 'image/png', data: PNG } }] }] } }, { pictures: { left: 320 * 1024 } });++ assert.ok(small[0].attachments && small[0].attachments.length === 1, 'the picture comes back on the line');++ assert.ok(small[0].attachments[0].url.startsWith('data:image/png;base64,'), 'as its bytes');++ assert.strictEqual(small[0].attachments[0].role, 'read', 'and it is what the agent read');++++ const big = compact({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't2', content: [{ type: 'image', source: { media_type: 'image/png', data: b64(200 * 1024) } }] }] } }, { pictures: { left: 320 * 1024 } });++ assert.ok(!big[0].attachments, 'a picture over the cap is left out');++++ const budget = { left: 100 };++ const two = compact({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't3', content: [{ type: 'image', source: { media_type: 'image/png', data: b64(900) } }] }] } }, { pictures: budget });++ assert.ok(!two[0].attachments || two[0].attachments.length === 0, 'and what does not fit the result is left out');++ });++++ done();++})().catch((e) => { console.error(e && e.stack || e); process.exit(1); });@@@ -50,6 +50,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,'server/http/tasks-api.js': 'OpenVibe.Bench (CODES_BENCH_URL, configured), Network projects API (configured) with the verified user token; benchmark ids and task ids are URL-encoded, no URL from a person','server/http/pages.js': 'Network projects API (configured) for Improve OpenVibe project creation; Forgejo repo read (CODES_GIT_INTERNAL_URL) for repo details; no URL from a person','server/domain/connections.js': 'OpenVibe.AI (CODES_AI_URL, configured) for a person\'s own keys: the credential routes and the two model endpoints, with the provider and the subject as path segments; no URL from a person',++ 'server/media.js': 'OpenVibe.Media\'s objects API (CODES_MEDIA_URL, configured) with the service key, and the presigned upload URL Media itself answers for that object; never a URL from a person or a model','server/domain/run-tokens.js': 'OpenVibe.Network\'s token endpoint (OV_NETWORK_INTERNAL_URL, configured) for a run token; no URL from a person','server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a nod_ shape, never a URL from a person','server/domain/conductor.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, for the project a conductor runs in; and the Run job stream URL run-client.js builds on its own base. No URL from a person',
@@ -94,7 +94,7 @@ More: [docs/cli.md](docs/cli.md).
@@ -94,7 +94,7 @@ More: [docs/cli.md](docs/cli.md).
- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256) and its JWKS.
- **OpenVibe.Network** — SSO (OAuth client `codes`, PKCE S256) and its JWKS.
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).
-- **openvibe-contracts v0.243.0**, **openvibe-sdk v0.74.0**, **openvibe-shared v4.48.0** (pinned tag tarballs).
+- **openvibe-contracts v0.243.0**, **openvibe-sdk v0.74.0**, **openvibe-shared v4.50.0** (pinned tag tarballs).
No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).
No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).
@@ -171,5 +171,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
@@ -171,5 +171,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.247.0
- openvibe-contracts: v0.247.0
- openvibe-sdk: v0.74.0
- openvibe-sdk: v0.74.0
-- openvibe-shared: v4.48.0
+- openvibe-shared: v4.50.0
<!-- versions:end -->
<!-- versions:end -->
@@ -26,6 +26,11 @@ const MAX_PATCH = 180_000;
@@ -26,6 +26,11 @@ const MAX_PATCH = 180_000;
const MAX_TEXT = 2_000;
const MAX_TEXT = 2_000;
const MAX_OUTPUT = 4_000; // a step's output on a line (live)
const MAX_OUTPUT = 4_000; // a step's output on a line (live)
const MAX_LOG = 240_000; // the session log kept in the result (its outputs cut shorter when it would not fit)
const MAX_LOG = 240_000; // the session log kept in the result (its outputs cut shorter when it would not fit)
+// The pictures a run carries back: Run's JSON body is 768 KB, so each image is capped and the run as a whole is too.
+// What does not fit is left out — the picture stays on the person's machine and the session shows its name.
+const MAX_IMAGE_BYTES = 160 * 1024;
+const MAX_RESULT_BYTES = 320 * 1024;
+const IMAGE_MIME = /^image\/(png|jpeg|gif|webp)$/i;
const BRANCH = /^[A-Za-z0-9._/-]{1,100}$/;
const BRANCH = /^[A-Za-z0-9._/-]{1,100}$/;
/** git with no prompt and no credential helper; rejects with the command's stderr (tokens scrubbed). */
/** git with no prompt and no credential helper; rejects with the command's stderr (tokens scrubbed). */
@@ -198,7 +203,47 @@ const resultText = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map(
@@ -198,7 +203,47 @@ const resultText = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map(
* A line from a helper agent carries `parent`, the id of the Task step that started it. 1.0 readers keep working: the
* A line from a helper agent carries `parent`, the id of the Task step that started it. 1.0 readers keep working: the
* fields they read are where they were.
* fields they read are where they were.
*/
*/
-function compact(e) {
+/**
+ * The pictures a tool answered with: a Read of an image comes back as an image block, and the session shows it as a
+ * rich embed. The bytes travel as a data URL, so the page needs no other request to draw them.
+ */
+function imageAttachments(content, budget) {
+ const out = [];
+ for (const c of Array.isArray(content) ? content : []) {
+ if (!c || c.type !== 'image') continue;
+ const src = c.source && typeof c.source === 'object' ? c.source : {};
+ const mime = String(src.media_type || c.media_type || 'image/png');
+ const data = String(src.data || '');
+ if (!data || !IMAGE_MIME.test(mime)) continue;
+ const bytes = Math.ceil(data.length * 3 / 4);
+ if (bytes > MAX_IMAGE_BYTES) continue;
+ if (!budget || budget.left < bytes) continue;
+ if (budget) budget.left -= bytes;
+ out.push({ url: `data:${mime};base64,${data}`, name: String(c.name || c.file_name || ''), mime, kind: 'image', role: 'read' });
+ }
+ return out;
+}
+
+/**
+ * A proof screenshot with its bytes, when they fit: the page shows the picture, not a name for it. The picture itself
+ * stays in the clone; only these bytes travel.
+ */
+function shotWithBytes(shot, budget) {
+ if (!shot || typeof shot !== 'object') return shot;
+ const file = String(shot.path || '');
+ if (!file) return shot;
+ try {
+ const bytes = fs.readFileSync(file);
+ if (!bytes.length || bytes.length > MAX_IMAGE_BYTES) return shot;
+ if (budget && budget.left < bytes.length) return shot;
+ if (budget) budget.left -= bytes.length;
+ return { ...shot, name: shot.name || path.basename(file), mime: shot.mime || 'image/jpeg', data: bytes.toString('base64') };
+ } catch {
+ return shot; // the picture stays on the machine; the session shows its name
+ }
+}
+
+function compact(e, ctx) {
const cut = (s) => cutTo(s, MAX_TEXT);
const cut = (s) => cutTo(s, MAX_TEXT);
const parent = e.parent_tool_use_id ? { parent: String(e.parent_tool_use_id) } : {};
const parent = e.parent_tool_use_id ? { parent: String(e.parent_tool_use_id) } : {};
if (e.type === 'system' && e.subtype === 'init') return { t: 'agent', agent: e.agent, model: e.model || null, session: e.session_id || null };
if (e.type === 'system' && e.subtype === 'init') return { t: 'agent', agent: e.agent, model: e.model || null, session: e.session_id || null };
@@ -220,7 +265,12 @@ function compact(e) {
@@ -220,7 +265,12 @@ function compact(e) {
}
}
if (e.type === 'user' && e.message && Array.isArray(e.message.content)) {
if (e.type === 'user' && e.message && Array.isArray(e.message.content)) {
return e.message.content.filter((c) => c && c.type === 'tool_result')
return e.message.content.filter((c) => c && c.type === 'tool_result')
- .map((c) => ({ t: 'tool_result', id: String(c.tool_use_id || ''), ok: !c.is_error, output: clean(cutTo(resultText(c.content), MAX_OUTPUT)), ...parent }));
+ .map((c) => {
+ const line = { t: 'tool_result', id: String(c.tool_use_id || ''), ok: !c.is_error, output: clean(cutTo(resultText(c.content), MAX_OUTPUT)), ...parent };
+ const pictures = imageAttachments(c.content, ctx && ctx.pictures);
+ if (pictures.length) line.attachments = pictures;
+ return line;
+ });
}
}
if (e.type === 'result') {
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, turns: e.num_turns || 0 };
return { t: 'done', ok: !e.is_error, text: cut(e.result), duration_ms: e.duration_ms || 0, cost_usd: e.total_cost_usd || 0, turns: e.num_turns || 0 };
@@ -238,6 +288,12 @@ function sessionLog(lines) {
@@ -238,6 +288,12 @@ function sessionLog(lines) {
let list = lines.slice();
let list = lines.slice();
let total = list.reduce((n, l) => n + size(l), 0);
let total = list.reduce((n, l) => n + size(l), 0);
if (total <= MAX_LOG) return list;
if (total <= MAX_LOG) return list;
+ // The pictures go before the words: a session keeps what was said before it keeps what was shown.
+ list = list.map((l) => (l.attachments && l.attachments.length)
+ ? { ...l, attachments: l.attachments.map((x) => (x && typeof x.url === 'string' && x.url.startsWith('data:')) ? { ...x, url: undefined } : x) }
+ : l);
+ total = list.reduce((n, l) => n + size(l), 0);
+ if (total <= MAX_LOG) return list;
const ended = new Set(list.filter((l) => l.t === 'tool_result').map((l) => l.id));
const ended = new Set(list.filter((l) => l.t === 'tool_result').map((l) => l.id));
list = list.filter((l) => !(l.t === 'tool_progress' && ended.has(l.id)));
list = list.filter((l) => !(l.t === 'tool_progress' && ended.has(l.id)));
for (const n of [1500, 600, 200]) {
for (const n of [1500, 600, 200]) {
@@ -442,9 +498,11 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
@@ -442,9 +498,11 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
...(adapterOptions ? { adapterOptions } : {}),
...(adapterOptions ? { adapterOptions } : {}),
});
});
let last = null;
let last = null;
+ // What the pictures may take of the result's room.
+ const pictures = { left: MAX_RESULT_BYTES };
for await (const e of run) {
for await (const e of run) {
last = e;
last = e;
- const c = compact(e);
+ const c = compact(e, { pictures });
for (const line of Array.isArray(c) ? c : c ? [c] : []) say(line);
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);
if (e.type === 'system' && e.subtype === 'init' && e.agent && !result.agents.includes(e.agent)) result.agents.push(e.agent);
}
}
@@ -472,7 +530,7 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
@@ -472,7 +530,7 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
author: { agent: job.agent, model: job.model },
author: { agent: job.agent, model: job.model },
emit: (item) => say({ t: 'proof', kind: item.kind, label: item.label, ok: item.ok, skipped: !!item.skipped, summary: item.summary, family: item.family || null }),
emit: (item) => say({ t: 'proof', kind: item.kind, label: item.label, ok: item.ok, skipped: !!item.skipped, summary: item.summary, family: item.family || null }),
});
});
- result.proof = { items: collected.items.map((i) => ({ kind: i.kind, label: i.label, ok: i.ok, skipped: !!i.skipped, summary: i.summary, family: i.family || null, detail: i.shots ? { shots: i.shots } : (i.kind === 'diff' ? { files: i.files, stats: i.stats } : null) })), outcome: collected.outcome };
+ result.proof = { items: collected.items.map((i) => ({ kind: i.kind, label: i.label, ok: i.ok, skipped: !!i.skipped, summary: i.summary, family: i.family || null, detail: i.shots ? { shots: i.shots.map((s) => shotWithBytes(s, pictures)) } : (i.kind === 'diff' ? { files: i.files, stats: i.stats } : null) })), outcome: collected.outcome };
} catch (e) {
} catch (e) {
say({ t: 'note', level: 'warning', text: `The proof could not be collected: ${scrub(e.message)}` });
say({ t: 'note', level: 'warning', text: `The proof could not be collected: ${scrub(e.message)}` });
result.proof = { items: [], outcome: null };
result.proof = { items: [], outcome: null };
@@ -102,12 +102,14 @@ async function shootPage({ playwright, chromePath, url, width, outDir, route })
@@ -102,12 +102,14 @@ async function shootPage({ playwright, chromePath, url, width, outDir, route })
await page.goto(url, { waitUntil: 'load', timeout: 30000 });
await page.goto(url, { waitUntil: 'load', timeout: 30000 });
await page.waitForTimeout(600);
await page.waitForTimeout(600);
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
- const file = path.join(outDir, `proof-${(route || '/').replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '') || 'home'}-${width}.png`);
- await page.screenshot({ path: file, fullPage: true });
+ const file = path.join(outDir, `proof-${(route || '/').replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '') || 'home'}-${width}.jpg`);
+ // A JPEG, not a PNG: the picture travels back to Codes in the job result, and Run's JSON body is 768 KB.
+ await page.screenshot({ path: file, fullPage: true, type: 'jpeg', quality: 70 });
const overflow = Number(scrollWidth) > width;
const overflow = Number(scrollWidth) > width;
const ok = !overflow && !errors.length;
const ok = !overflow && !errors.length;
return {
return {
ok, path: file, width, route: route || '/', errors, console_errors: consoleErrors, overflow, url,
ok, path: file, width, route: route || '/', errors, console_errors: consoleErrors, overflow, url,
+ name: path.basename(file), mime: 'image/jpeg',
final_url: typeof page.url === 'function' ? page.url() : url,
final_url: typeof page.url === 'function' ? page.url() : url,
summary: ok ? 'the page renders clean'
summary: ok ? 'the page renders clean'
: [overflow ? 'the page is ' + scrollWidth + 'px wide, so it scrolls sideways on a ' + width + 'px screen' : null]
: [overflow ? 'the page is ' + scrollWidth + 'px wide, so it scrolls sideways on a ' + width + 'px screen' : null]
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.247.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.247.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.50.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"bin": {
"bin": {
@@ -1193,9 +1193,9 @@
@@ -1193,9 +1193,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.48.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
- "integrity": "sha512-JzASdCIvFTnJz3Xeng1e/j9WGZAVPpa6AvF60tgNf4TGFXm+PXQXyFp2o2JyHDkqF3L8HlY6CDVcIRGTQegjJQ==",
+ "version": "4.50.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.50.0",
+ "integrity": "sha512-mWefCYJUMGS3Pd14BuU1uTiWyYrjxgf8hgGmPbahfLwDYQ/dBFdYVQ5PKXXxN5uY/NTFt9+FKGPLmeDiDvpkZQ==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -32,7 +32,7 @@
@@ -32,7 +32,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.247.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.247.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.74.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.48.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.50.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -76,7 +76,7 @@ async function createApp(opts = {}) {
@@ -76,7 +76,7 @@ async function createApp(opts = {}) {
// The harness catalog (server/data/harness-offers.json), read once: a bad seed row fails boot.
// The harness catalog (server/data/harness-offers.json), read once: a bad seed row fails boot.
const harnesses = createHarnesses();
const harnesses = createHarnesses();
// Sessions, the run.job.* consumer and the codes.task.* outbox share one store and one clock.
// Sessions, the run.job.* consumer and the codes.task.* outbox share one store and one clock.
- const sessions = createSessionLines({ store, config, log });
+ const sessions = createSessionLines({ store, config, log, media: require('./media').createMedia({ config, log }) });
const outbox = createOutbox({ store, log });
const outbox = createOutbox({ store, log });
const run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });
const run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });
// A person's own connections (Codes C3): their provider keys live in OpenVibe.AI, and their harnesses run with a
// A person's own connections (Codes C3): their provider keys live in OpenVibe.AI, and their harnesses run with a
@@ -111,6 +111,16 @@ function load(env = process.env) {
@@ -111,6 +111,16 @@ function load(env = process.env) {
retentionDays: Math.max(1, int(env.CODES_SESSION_RETENTION_DAYS, 90)),
retentionDays: Math.max(1, int(env.CODES_SESSION_RETENTION_DAYS, 90)),
retentionIntervalMs: Math.max(60_000, int(env.CODES_SESSION_RETENTION_INTERVAL_MS, 86_400_000)),
retentionIntervalMs: Math.max(60_000, int(env.CODES_SESSION_RETENTION_INTERVAL_MS, 86_400_000)),
},
},
+ // OpenVibe.Media: the private objects a task's pictures live in. A proof screenshot can show anything on the
+ // person's screen, so it is never public: the bytes go to Media as a private object of the person, and the
+ // session shows a short-lived signed URL that only that person can use. Without a key there are no pictures.
+ media: {
+ url: trim(env.CODES_MEDIA_URL || 'https://openvibe.media'),
+ key: String(env.CODES_MEDIA_KEY || '').trim(),
+ app: trim(env.CODES_MEDIA_APP || 'codes'),
+ ttl: int(env.CODES_MEDIA_URL_TTL, 600),
+ },
+
// OpenVibe.Run (plan T16): a coding task on the person's own machine is a Run job Codes starts for them.
// 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') },
run: { url: trim(env.CODES_RUN_URL || 'http://127.0.0.1:4920') },
@@ -55,6 +55,7 @@ function createProof({ store, config = {}, log = console }) {
@@ -55,6 +55,7 @@ function createProof({ store, config = {}, log = console }) {
const seen = new Set(known.map((r) => Number(r.n)));
const seen = new Set(known.map((r) => Number(r.n)));
const attempt = attemptN == null ? 0 : Number(attemptN);
const attempt = attemptN == null ? 0 : Number(attemptN);
const stored = [];
const stored = [];
+ const byId = new Map();
for (const item of list) {
for (const item of list) {
const kind = KINDS.includes(item && item.kind) ? item.kind : 'note';
const kind = KINDS.includes(item && item.kind) ? item.kind : 'note';
let n = stored.length + 1;
let n = stored.length + 1;
@@ -62,8 +63,16 @@ function createProof({ store, config = {}, log = console }) {
@@ -62,8 +63,16 @@ function createProof({ store, config = {}, log = console }) {
seen.add(n);
seen.add(n);
const id = store.newId('prf');
const id = store.newId('prf');
const detail = item && item.detail !== undefined ? item.detail : item;
const detail = item && item.detail !== undefined ? item.detail : item;
+ // The pictures of a proof item: their bytes go on the line, where Codes puts them in Media as private
+ // objects of the person. The stored detail keeps what the picture is, never its bytes.
+ const shots = detail && Array.isArray(detail.shots) ? detail.shots : [];
+ const pictures = shots.filter((s) => s && s.data).map((s) => ({
+ url: `data:${String(s.mime || 'image/jpeg')};base64,${String(s.data)}`,
+ name: String(s.name || ''), mime: String(s.mime || 'image/jpeg'), kind: 'image', role: 'output',
+ }));
+ const kept = shots.length ? { ...detail, shots: shots.map((s) => { const c = { ...s }; delete c.data; return c; }) } : detail;
let body = null;
let body = null;
- try { body = JSON.stringify(detail); } catch { body = null; }
+ try { body = JSON.stringify(kept); } catch { body = null; }
if (body && body.length > MAX_DETAIL) body = body.slice(0, MAX_DETAIL);
if (body && body.length > MAX_DETAIL) body = body.slice(0, MAX_DETAIL);
const row = await db.maybe(
const row = await db.maybe(
'INSERT INTO codes_task_proof (id, task_id, attempt_n, n, kind, label, ok, skipped, summary, family, detail, created_at)'
'INSERT INTO codes_task_proof (id, task_id, attempt_n, n, kind, label, ok, skipped, summary, family, detail, created_at)'
@@ -73,7 +82,7 @@ function createProof({ store, config = {}, log = console }) {
@@ -73,7 +82,7 @@ function createProof({ store, config = {}, log = console }) {
item && item.ok === false ? false : true, item && item.skipped === true ? true : false,
item && item.ok === false ? false : true, item && item.skipped === true ? true : false,
clip(item && item.summary, 1000), clip(item && item.family, 80), body, store.iso()],
clip(item && item.summary, 1000), clip(item && item.family, 80), body, store.iso()],
);
);
- if (row) stored.push(row);
+ if (row) { stored.push(row); byId.set(row.id, pictures); }
}
}
// One line per item on the task's stream, after the lines that are already there.
// One line per item on the task's stream, after the lines that are already there.
let seq = await nextSeq(taskId);
let seq = await nextSeq(taskId);
@@ -83,6 +92,7 @@ function createProof({ store, config = {}, log = console }) {
@@ -83,6 +92,7 @@ function createProof({ store, config = {}, log = console }) {
body: {
body: {
t: 'proof', kind: row.kind, label: row.label, ok: row.ok === true,
t: 'proof', kind: row.kind, label: row.label, ok: row.ok === true,
skipped: row.skipped === true, summary: row.summary || null, family: row.family || null,
skipped: row.skipped === true, summary: row.summary || null, family: row.family || null,
+ ...(byId.get(row.id) && byId.get(row.id).length ? { attachments: byId.get(row.id) } : {}),
},
},
}));
}));
return { rows: stored, lines };
return { rows: stored, lines };
@@ -149,7 +149,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -149,7 +149,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const db = store.db;
const db = store.db;
const who = (row) => ({ subject: row.subject, project: row.project_id });
const who = (row) => ({ subject: row.subject, project: row.project_id });
// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).
// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).
- const sessionLines = sessions || require('./session-lines').createSessionLines({ store, config, log });
+ const sessionLines = sessions || require('./session-lines').createSessionLines({ store, config, log, media: require('../media').createMedia({ config, log }) });
const taskOutbox = outbox || require('../events/outbox').createOutbox({ store, log });
const taskOutbox = outbox || require('../events/outbox').createOutbox({ store, log });
// The proof of a finished task (stage C8): the tests, the pages, the diff and the second model's opinion.
// The proof of a finished task (stage C8): the tests, the pages, the diff and the second model's opinion.
const taskProof = proof || require('./proof').createProof({ store, config, log });
const taskProof = proof || require('./proof').createProof({ store, config, log });
@@ -94,12 +94,61 @@ function lineOf(raw) {
@@ -94,12 +94,61 @@ function lineOf(raw) {
return { kind, body };
return { kind, body };
}
}
-function createSessionLines({ store, config = {}, log = console }) {
+function createSessionLines({ store, config = {}, log = console, media = null }) {
const db = store.db;
const db = store.db;
const retentionDays = Math.max(1, Number(config.sessions && config.sessions.retentionDays) || 90);
const retentionDays = Math.max(1, Number(config.sessions && config.sessions.retentionDays) || 90);
+ const pictures = media && media.configured ? media : null;
+
+ /** The bytes of a data URL, or null when it is not one. */
+ function bytesOf(url) {
+ const m = /^data:([^;,]+)?;base64,(.*)$/i.exec(String(url || ''));
+ if (!m) return null;
+ try { return Buffer.from(m[2], 'base64'); } catch { return null; }
+ }
+
+ /**
+ * The pictures on a line: the bytes go to Media as private objects of the person, and the line keeps the object
+ * id — never the bytes, and never a public URL. Without Media the pictures are left out and the line keeps its
+ * words.
+ */
+ async function storeAttachments(body, subject) {
+ if (!body || !Array.isArray(body.attachments) || !body.attachments.length) return body;
+ const list = [];
+ for (const a of body.attachments) {
+ if (!a || typeof a !== 'object') continue;
+ const url = String(a.url || '');
+ const bytes = bytesOf(url);
+ if (!bytes || !pictures) continue; // not our bytes: nothing to upload
+ try {
+ const put = await pictures.put({ bytes, mime: a.mime, name: a.name, kind: 'screenshot', subject });
+ list.push({ name: String(a.name || ''), mime: String(a.mime || ''), kind: String(a.kind || 'image'), role: String(a.role || 'output'), media: put.id });
+ } catch (e) {
+ log.warn(`[Codes] a picture did not reach Media: ${String(e && e.message).slice(0, 160)}`);
+ }
+ }
+ if (!list.length) { const copy = { ...body }; delete copy.attachments; return copy; }
+ return { ...body, attachments: list };
+ }
+
+ /** The lines a reader gets: every picture a short-lived signed URL, and only for the person the task belongs to. */
+ async function signLines(list, subject) {
+ if (!Array.isArray(list) || !list.length) return list;
+ if (!pictures) return list;
+ const out = [];
+ for (const l of list) {
+ if (!l || !Array.isArray(l.attachments) || !l.attachments.length) { out.push(l); continue; }
+ out.push({ ...l, attachments: await pictures.signAttachments(l.attachments, subject) });
+ }
+ return out;
+ }
+ /** One line with its pictures signed. */
+ async function signLine(line, subject) {
+ const [out] = await signLines([line], subject);
+ return out;
+ }
/** Insert lines in order; an existing (task_id, seq) wins, so a replay never duplicates. → rows inserted. */
/** Insert lines in order; an existing (task_id, seq) wins, so a replay never duplicates. → rows inserted. */
- async function append(taskId, lines, attemptN = null) {
+ async function append(taskId, lines, attemptN = null, subject = null) {
if (!taskId || !Array.isArray(lines) || !lines.length) return 0;
if (!taskId || !Array.isArray(lines) || !lines.length) return 0;
const clean = [];
const clean = [];
for (const line of lines) {
for (const line of lines) {
@@ -109,7 +158,7 @@ function createSessionLines({ store, config = {}, log = console }) {
@@ -109,7 +158,7 @@ function createSessionLines({ store, config = {}, log = console }) {
const kind = String(line.kind || 'text').slice(0, 64) || 'text';
const kind = String(line.kind || 'text').slice(0, 64) || 'text';
// The transcript normaliser: a run token (Codes C3) that an agent printed is redacted here, before the
// The transcript normaliser: a run token (Codes C3) that an agent printed is redacted here, before the
// line is kept, so it never reaches the transcript, the page or the log.
// line is kept, so it never reaches the transcript, the page or the log.
- clean.push({ seq, at, kind, body: clipBody(redact.scrubValue(line.body)) });
+ clean.push({ seq, at, kind, body: clipBody(redact.scrubValue(await storeAttachments(line.body, subject))) });
}
}
let inserted = 0;
let inserted = 0;
for (let i = 0; i < clean.length; i += 200) {
for (let i = 0; i < clean.length; i += 200) {
@@ -138,7 +187,7 @@ function createSessionLines({ store, config = {}, log = console }) {
@@ -138,7 +187,7 @@ function createSessionLines({ store, config = {}, log = console }) {
* into the job's JSON lines; state and end frames become their own lines. The stream's seq is the base, so the
* into the job's JSON lines; state and end frames become their own lines. The stream's seq is the base, so the
* same event replayed after a reconnect maps to the same rows.
* same event replayed after a reconnect maps to the same rows.
*/
*/
- function recorder(taskId, { attemptN = null } = {}) {
+ function recorder(taskId, { attemptN = null, subject = null } = {}) {
let frameBuffer = '';
let frameBuffer = '';
let outputBuffer = '';
let outputBuffer = '';
let fallbackSeq = 0;
let fallbackSeq = 0;
@@ -199,7 +248,7 @@ function createSessionLines({ store, config = {}, log = console }) {
@@ -199,7 +248,7 @@ function createSessionLines({ store, config = {}, log = console }) {
frameBuffer = `${frameBuffer}${String(text == null ? '' : text)}`.replace(/\r\n/g, '\n');
frameBuffer = `${frameBuffer}${String(text == null ? '' : text)}`.replace(/\r\n/g, '\n');
const out = [];
const out = [];
drainFrames(out);
drainFrames(out);
- if (out.length) await append(taskId, out, attemptN);
+ if (out.length) await append(taskId, out, attemptN, subject);
},
},
async flush() {
async flush() {
const out = [];
const out = [];
@@ -211,7 +260,7 @@ function createSessionLines({ store, config = {}, log = console }) {
@@ -211,7 +260,7 @@ function createSessionLines({ store, config = {}, log = console }) {
out.push({ seq: (lastBase || 0) + PARTIAL_LINE_INDEX, at: store.iso(), kind: line.kind, body: line.body });
out.push({ seq: (lastBase || 0) + PARTIAL_LINE_INDEX, at: store.iso(), kind: line.kind, body: line.body });
outputBuffer = '';
outputBuffer = '';
}
}
- if (out.length) await append(taskId, out, attemptN);
+ if (out.length) await append(taskId, out, attemptN, subject);
},
},
ended: () => ended,
ended: () => ended,
};
};
@@ -245,7 +294,7 @@ function createSessionLines({ store, config = {}, log = console }) {
@@ -245,7 +294,7 @@ function createSessionLines({ store, config = {}, log = console }) {
const at = atIso(row.finished_at) || (row.updated_at ? iso(row.updated_at) : store.iso());
const at = atIso(row.finished_at) || (row.updated_at ? iso(row.updated_at) : store.iso());
return await append(row.id, missing.map((body, i) => ({
return await append(row.id, missing.map((body, i) => ({
seq: start + i, at, kind: typeof body.t === 'string' ? body.t : 'text', body,
seq: start + i, at, kind: typeof body.t === 'string' ? body.t : 'text', body,
- })), attemptN);
+ })), attemptN, row.subject);
}
}
async function complete(taskId) {
async function complete(taskId) {
@@ -266,8 +315,8 @@ function createSessionLines({ store, config = {}, log = console }) {
@@ -266,8 +315,8 @@ function createSessionLines({ store, config = {}, log = console }) {
if (!row || !row.id) return [];
if (!row || !row.id) return [];
const rows = await allRows(row.id);
const rows = await allRows(row.id);
const session = rows.filter((r) => r.kind !== 'state' && r.kind !== 'end').map((r) => r.body);
const session = rows.filter((r) => r.kind !== 'state' && r.kind !== 'end').map((r) => r.body);
- if (session.length) return session;
- return sessionLogOf(row) || [];
+ if (session.length) return await signLines(session, row.subject);
+ return await signLines(sessionLogOf(row) || [], row.subject);
}
}
/** One replay page (at most 500 lines after `after`), from the table or the old result.log. */
/** One replay page (at most 500 lines after `after`), from the table or the old result.log. */
@@ -287,6 +336,8 @@ function createSessionLines({ store, config = {}, log = console }) {
@@ -287,6 +336,8 @@ function createSessionLines({ store, config = {}, log = console }) {
.filter((line) => line.seq > from);
.filter((line) => line.seq > from);
}
}
const page = lines.slice(0, max);
const page = lines.slice(0, max);
+ const signed = await signLines(page.map((l) => l.body), row.subject);
+ page.forEach((l, i) => { l.body = signed[i]; });
return {
return {
task_id: row.id,
task_id: row.id,
source,
source,
@@ -67,7 +67,7 @@ function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globa
@@ -67,7 +67,7 @@ function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globa
};
};
// A task reads Run only as the 10-minute fallback; its state now arrives from run.job.* events.
// A task reads Run only as the 10-minute fallback; its state now arrives from run.job.* events.
const taskStore = tasks || createRunTasks({ store, run, config, log });
const taskStore = tasks || createRunTasks({ store, run, config, log });
- const sessionStore = sessions || createSessionLines({ store, config, log });
+ const sessionStore = sessions || createSessionLines({ store, config, log, media: require('../media').createMedia({ config, log }) });
const { who, refresh } = taskStore;
const { who, refresh } = taskStore;
function formHtml({ projects, values = {}, error = null }) {
function formHtml({ projects, values = {}, error = null }) {
@@ -298,7 +298,7 @@ ${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events
@@ -298,7 +298,7 @@ ${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events
// The bytes go to the browser unchanged, and the same frames become codes_session_lines as they stream.
// The bytes go to the browser unchanged, and the same frames become codes_session_lines as they stream.
const attempts = await taskStore.attempts.list(row.id);
const attempts = await taskStore.attempts.list(row.id);
const attemptN = attempts.length ? Number(attempts[attempts.length - 1].n) : null;
const attemptN = attempts.length ? Number(attempts[attempts.length - 1].n) : null;
- const recorder = sessionStore.recorder(row.id, { attemptN });
+ const recorder = sessionStore.recorder(row.id, { attemptN, subject: row.subject });
const decoder = new TextDecoder();
const decoder = new TextDecoder();
try {
try {
for await (const chunk of resp.body) {
for await (const chunk of resp.body) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
+'use strict';
+/**
+ * OpenVibe.Media: the private objects a task's images live in.
+ *
+ * A proof screenshot can show anything on the person's screen, so it never becomes a public file. The bytes go to
+ * Media as a private object owned by the person, and the session shows a short-lived signed URL that only that
+ * person (through Codes) can use. Without a key there are no attachments, and the session simply shows the text.
+ *
+ * createMedia({ config, fetchImpl })
+ * configured() is Media set up?
+ * put({ bytes, mime, name, kind, subject }) → { id } a private object
+ * signed(id, subject) → { url } a short-lived download URL
+ * uploadAttachments(list, subject) → the list with the bytes swapped for object ids
+ * signAttachments(list, subject) → the list with the object ids swapped for URLs
+ */
+const DEFAULT_TTL = 900; // a signed URL lives fifteen minutes
+const MAX_BYTES = 12 * 1024 * 1024;
+
+function createMedia({ config, fetchImpl = globalThis.fetch, log = null } = {}) {
+ const media = (config && config.media) || {};
+ const url = String(media.url || '').replace(/\/+$/, '');
+ const key = String(media.key || '').trim();
+ const app = String(media.app || 'codes').trim() || 'codes';
+ const ttl = Math.min(3600, Math.max(60, Number(media.ttl) || DEFAULT_TTL));
+ const base = () => `${url}/api/v2/${encodeURIComponent(app)}/objects`;
+ const configured = () => !!(url && key);
+
+ const warn = (msg) => { if (log && typeof log.warn === 'function') log.warn(msg); };
+
+ async function call(method, apiUrl, subject, body, raw) {
+ const headers = { authorization: `Bearer ${key}`, accept: 'application/json' };
+ if (subject) headers['x-ov-subject'] = String(subject);
+ if (body != null) headers['content-type'] = 'application/json';
+ const ctrl = new AbortController();
+ const timer = setTimeout(() => ctrl.abort(), 30000);
+ let res;
+ try {
+ res = await fetchImpl(apiUrl, { method, headers, body: body == null ? undefined : JSON.stringify(body), signal: ctrl.signal });
+ } finally {
+ clearTimeout(timer);
+ }
+ const text = await res.text();
+ let json = null;
+ try { json = text ? JSON.parse(text) : null; } catch { json = null; }
+ if (!res.ok) {
+ const err = new Error(`Media answered HTTP ${res.status}`);
+ err.status = res.status;
+ err.body = json || text.slice(0, 200);
+ throw err;
+ }
+ return raw ? json : (json || {});
+ }
+
+ /** The bytes into a private object of the person: { id }. */
+ async function put({ bytes, mime, name, kind = 'screenshot', subject } = {}) {
+ if (!configured()) throw new Error('Media is not set up');
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(String(bytes || ''), 'base64');
+ if (!buf.length) throw new Error('no bytes');
+ if (buf.length > MAX_BYTES) throw new Error('too big for Media');
+ const init = await call('POST', base(), subject, {
+ kind, visibility: 'private', mime_type: String(mime || 'image/png'),
+ size_bytes: buf.length, filename: String(name || 'image'),
+ });
+ const up = init.upload || {};
+ const res = await fetchImpl(up.url, { method: up.method || 'PUT', body: buf, headers: { 'content-type': String(mime || 'image/png') } });
+ if (!res.ok) throw new Error(`Media refused the bytes: HTTP ${res.status}`);
+ await call('POST', up.complete_url || `${base()}/${encodeURIComponent(init.id)}/complete`, subject, {});
+ return { id: String(init.id || '') };
+ }
+
+ /** A short-lived signed URL for a private object: { url, expires_at }. */
+ async function signed(id, subject) {
+ if (!configured()) return null;
+ const r = await call('GET', `${base()}/${encodeURIComponent(id)}/download?ttl=${ttl}`, subject, null, true);
+ return r && r.url ? r : null;
+ }
+
+ /** A list of attachments whose bytes become private objects: the bytes are gone from the list. */
+ async function uploadAttachments(list, subject) {
+ const arr = Array.isArray(list) ? list : [];
+ if (!arr.length || !configured()) return arr;
+ const out = [];
+ for (const a of arr) {
+ if (!a || typeof a !== 'object') continue;
+ const data = a.data ? String(a.data) : '';
+ if (!data) { out.push(a); continue; }
+ try {
+ const put_ = await put({ bytes: data, mime: a.mime, name: a.name, kind: a.kind === 'image' ? 'screenshot' : 'file', subject });
+ out.push({ url: '', media: put_.id, name: a.name || '', mime: a.mime || '', kind: a.kind || '', role: a.role || 'output' });
+ } catch (e) {
+ warn(`[Codes] a picture did not reach Media: ${String(e && e.message).slice(0, 160)}`);
+ }
+ }
+ return out;
+ }
+
+ /** A list of attachments whose objects become signed URLs: { url, … }. */
+ async function signAttachments(list, subject) {
+ const arr = Array.isArray(list) ? list : [];
+ if (!arr.length || !configured()) return arr;
+ const out = [];
+ for (const a of arr) {
+ if (!a || typeof a !== 'object') continue;
+ if (!a.media || a.url) { out.push(a); continue; }
+ try {
+ const s = await signed(a.media, subject);
+ if (s && s.url) out.push(Object.assign({}, a, { url: s.url }));
+ } catch (e) {
+ warn(`[Codes] a picture could not be signed: ${String(e && e.message).slice(0, 160)}`);
+ }
+ }
+ return out;
+ }
+
+ return { configured, put, signed, uploadAttachments, signAttachments, apiBase: base };
+}
+
+module.exports = { createMedia, DEFAULT_TTL, MAX_BYTES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,133 @@
@@ -0,0 +1,133 @@
+'use strict';
+/**
+ * The pictures of a task (Codes C10): the images a harness read and the screenshots the proof shot travel in the job
+ * result, and Codes puts them in Media as private objects of the person — never a public URL. The session shows a
+ * short-lived signed URL, and only the task's owner gets one: another person gets a 404.
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+
+const { createSessionLines } = require('../server/domain/session-lines');
+const { createProof } = require('../server/domain/proof');
+const { createMedia } = require('../server/media');
+const { compact } = require('../harness/job');
+
+const PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==';
+const b64 = (bytes) => Buffer.alloc(bytes, 7).toString('base64');
+
+(async () => {
+ const calls = [];
+ const mediaServer = http.createServer((req, res) => {
+ const u = req.url.split('?')[0];
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks);
+ calls.push({ method: req.method, url: req.url, headers: req.headers, body: raw });
+ if (req.method === 'POST' && u === '/api/v2/codes/objects') {
+ const body = JSON.parse(raw.toString('utf8') || '{}');
+ res.writeHead(201, { 'content-type': 'application/json' });
+ return res.end(JSON.stringify({
+ id: 'obj_1',
+ upload: {
+ method: 'PUT', url: `http://127.0.0.1:${mediaServer.address().port}/upload/obj_1?token=t`,
+ token: 't', complete_url: `http://127.0.0.1:${mediaServer.address().port}/api/v2/codes/objects/obj_1/complete`,
+ },
+ }));
+ }
+ if (req.method === 'PUT' && u.startsWith('/upload/')) { res.writeHead(200); return res.end('ok'); }
+ if (req.method === 'POST' && u.endsWith('/complete')) { res.writeHead(200, { 'content-type': 'application/json' }); return res.end('{}'); }
+ if (req.method === 'GET' && u.endsWith('/download')) {
+ res.writeHead(200, { 'content-type': 'application/json' });
+ return res.end(JSON.stringify({ url: 'https://openvibe.media/o/obj_1?exp=99&sig=abc', expires_at: new Date(Date.now() + 600000).toISOString(), public: false }));
+ }
+ res.writeHead(404); return res.end('no');
+ });
+ });
+ await new Promise((ok) => mediaServer.listen(0, '127.0.0.1', ok));
+ const mediaUrl = `http://127.0.0.1:${mediaServer.address().port}`;
+
+ const t = await boot({ env: { CODES_MEDIA_URL: mediaUrl, CODES_MEDIA_KEY: 'codes-test-key' } });
+ const db = t.ctx.store.db;
+ const ada = t.network.addUser('ada');
+ const bo = t.network.addUser('bo');
+ const project = 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1';
+ const taskId = 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T55';
+ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, job_id, repo, prompt, state, created_at, updated_at)
+ VALUES ($1, $2, $3, 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J55', 'https://github.com/a/b', 'look at the picture', 'running', 1, 2)`,
+ [taskId, ada.subject, project]);
+
+ const media = createMedia({ config: { media: { url: mediaUrl, key: 'codes-test-key', app: 'codes', ttl: 600 } }, log: console });
+ const lines = createSessionLines({ store: t.ctx.store, config: t.ctx.config, log: console, media });
+ const proof = createProof({ store: t.ctx.store, config: t.ctx.config, log: console });
+
+ await check('a picture on a line goes to Media as a private object of the person, and the line keeps no bytes', async () => {
+ calls.length = 0;
+ await lines.append(taskId, [{
+ seq: 1, at: new Date().toISOString(), kind: 'tool_result',
+ body: { t: 'tool_result', id: 'tool-1', ok: true, output: '', attachments: [{ url: `data:image/png;base64,${PNG}`, name: 'shot.png', mime: 'image/png', kind: 'image', role: 'read' }] },
+ }], null, ada.subject);
+
+ const init = calls.find((c) => c.method === 'POST' && c.url === '/api/v2/codes/objects');
+ assert.ok(init, 'the picture was offered to Media');
+ const body = JSON.parse(init.body.toString('utf8'));
+ assert.strictEqual(body.visibility, 'private', 'a screenshot is never public');
+ assert.strictEqual(body.mime_type, 'image/png', 'Media is told what it is');
+ assert.ok(init.headers['x-ov-subject'] === ada.subject, 'the object belongs to the person the task belongs to');
+ assert.ok(calls.some((c) => c.method === 'PUT' && c.url.startsWith('/upload/')), 'the bytes were sent');
+ assert.ok(calls.some((c) => c.method === 'POST' && c.url.endsWith('/complete')), 'and the object was completed');
+
+ const rows = await db.many('SELECT body FROM codes_session_lines WHERE task_id = $1', [taskId]);
+ const stored = rows[0].body;
+ assert.ok(stored.attachments && stored.attachments[0].media === 'obj_1', 'the line keeps the object id');
+ assert.ok(!stored.attachments[0].url, 'and no URL of its own');
+ assert.ok(!JSON.stringify(stored).includes('base64'), 'and never the bytes');
+ });
+
+ await check('the reader gets a short-lived signed URL, and only the owner', async () => {
+ const out = await lines.linesForRow({ id: taskId, subject: ada.subject });
+ const att = out[0].attachments[0];
+ assert.ok(att.url && att.url.startsWith('https://openvibe.media/o/'), 'the picture is served by Media');
+ assert.ok(att.url.includes('sig='), 'through a signed URL');
+ assert.ok(!att.media || att.media === 'obj_1', 'the object id is still there');
+ });
+
+ await check('another person cannot read the task at all', async () => {
+ const res = await t.get(`/run/tasks/${taskId}/session.json`, { as: bo });
+ assert.strictEqual(res.status, 404, 'a task of yours is 404 for another person');
+ const row = await db.maybe('SELECT id, subject FROM run_tasks WHERE id = $1', [taskId]);
+ process.stderr.write(`[test] the row: ${JSON.stringify(row)}; ada: ${ada.subject}\n`);
+ });
+
+ await check('a proof item with a screenshot puts the picture on its line and keeps no bytes in the record', async () => {
+ const recorded = await proof.record(taskId, null, [{
+ kind: 'screenshots', label: 'screenshots', ok: true, summary: '2 screenshots of 1 page at 390px, clean',
+ detail: { shots: [{ path: '/tmp/proof-home-390.jpg', name: 'proof-home-390.jpg', mime: 'image/jpeg', width: 390, data: b64(1200) }] },
+ }]);
+ assert.strictEqual(recorded.lines.length, 1, 'the proof has a line');
+ const att = recorded.lines[0].body.attachments;
+ assert.ok(att && att.length === 1, 'the line carries the picture');
+ assert.ok(att[0].url.startsWith('data:image/jpeg;base64,'), 'with its bytes, for Codes to put in Media');
+ assert.strictEqual(att[0].role, 'output', 'and it is what the run produced');
+ const row = recorded.rows[0];
+ assert.ok(!JSON.stringify(row.detail).includes('"data"'), 'the stored record keeps no bytes');
+ assert.ok(row.detail.shots[0].width === 390, 'but keeps what the picture is');
+ });
+
+ await check('the harness reads a picture once, and only if it fits', async () => {
+ const small = compact({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't1', content: [{ type: 'image', source: { media_type: 'image/png', data: PNG } }] }] } }, { pictures: { left: 320 * 1024 } });
+ assert.ok(small[0].attachments && small[0].attachments.length === 1, 'the picture comes back on the line');
+ assert.ok(small[0].attachments[0].url.startsWith('data:image/png;base64,'), 'as its bytes');
+ assert.strictEqual(small[0].attachments[0].role, 'read', 'and it is what the agent read');
+
+ const big = compact({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't2', content: [{ type: 'image', source: { media_type: 'image/png', data: b64(200 * 1024) } }] }] } }, { pictures: { left: 320 * 1024 } });
+ assert.ok(!big[0].attachments, 'a picture over the cap is left out');
+
+ const budget = { left: 100 };
+ const two = compact({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't3', content: [{ type: 'image', source: { media_type: 'image/png', data: b64(900) } }] }] } }, { pictures: budget });
+ assert.ok(!two[0].attachments || two[0].attachments.length === 0, 'and what does not fit the result is left out');
+ });
+
+ done();
+})().catch((e) => { console.error(e && e.stack || e); process.exit(1); });
@@ -50,6 +50,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -50,6 +50,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/http/tasks-api.js': 'OpenVibe.Bench (CODES_BENCH_URL, configured), Network projects API (configured) with the verified user token; benchmark ids and task ids are URL-encoded, no URL from a person',
'server/http/tasks-api.js': 'OpenVibe.Bench (CODES_BENCH_URL, configured), Network projects API (configured) with the verified user token; benchmark ids and task ids are URL-encoded, no URL from a person',
'server/http/pages.js': 'Network projects API (configured) for Improve OpenVibe project creation; Forgejo repo read (CODES_GIT_INTERNAL_URL) for repo details; no URL from a person',
'server/http/pages.js': 'Network projects API (configured) for Improve OpenVibe project creation; Forgejo repo read (CODES_GIT_INTERNAL_URL) for repo details; no URL from a person',
'server/domain/connections.js': 'OpenVibe.AI (CODES_AI_URL, configured) for a person\'s own keys: the credential routes and the two model endpoints, with the provider and the subject as path segments; no URL from a person',
'server/domain/connections.js': 'OpenVibe.AI (CODES_AI_URL, configured) for a person\'s own keys: the credential routes and the two model endpoints, with the provider and the subject as path segments; no URL from a person',
+ 'server/media.js': 'OpenVibe.Media\'s objects API (CODES_MEDIA_URL, configured) with the service key, and the presigned upload URL Media itself answers for that object; never a URL from a person or a model',
'server/domain/run-tokens.js': 'OpenVibe.Network\'s token endpoint (OV_NETWORK_INTERNAL_URL, configured) for a run token; no URL from a person',
'server/domain/run-tokens.js': 'OpenVibe.Network\'s token endpoint (OV_NETWORK_INTERNAL_URL, configured) for a run token; no URL from a person',
'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a nod_ shape, never a URL from a person',
'server/domain/machines.js': 'OpenVibe.Network (OV_NETWORK_INTERNAL_URL, configured) /api/v1/me/nodes with the person\'s own session token: their machines and a revoke; the path id is checked against a nod_ shape, never a URL from a person',
'server/domain/conductor.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, for the project a conductor runs in; and the Run job stream URL run-client.js builds on its own base. No URL from a person',
'server/domain/conductor.js': 'OpenVibe.Network\'s projects API (OV_NETWORK_INTERNAL_URL, configured) with the person\'s own token, for the project a conductor runs in; and the Run job stream URL run-client.js builds on its own base. No URL from a person',