openvibers/openvibe.codes Commits

The pictures of a task: private in Media, signed for the owner (#188)

OP OpenVibers committed 14 days ago · b9dabf2f3b · 14 files, +411 −28

* WIP: the Media client for the private objects

* The pictures of a task: private in Media, signed for the owner

* The session log's size after dropping pictures is the sum of every line; media.js in the SSRF ratchet

sessionLog() recounted the log after dropping inline pictures with reduce((n, l) => size(l)), which kept only the last
line's size. So an oversized log looked small and was kept whole, past the result's room (job.test.js). It now sums
every line.

server/media.js makes its own requests (Media's objects API at CODES_MEDIA_URL, and the presigned upload URL Media
answers), so the outbound-request ratchet lists it with where it goes.

Changes

ddiff --git a/README.md b/README.md
iindex 04057e2..66cc1cd 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/harness/job.js b/harness/job.js
iindex 1c68888..4560780 100644
---- a/harness/job.js
++++ b/harness/job.js
@@@ -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, tm
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 }),
});
-- 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 };
ddiff --git a/harness/proof.js b/harness/proof.js
iindex 39a757e..7897d85 100644
---- a/harness/proof.js
++++ b/harness/proof.js
@@@ -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]
ddiff --git a/package-lock.json b/package-lock.json
iindex 9b32bec..a5b386f 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 935dcb2..b8450b6 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/server/app.js b/server/app.js
iindex f0c5a7f..ae67bee 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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
ddiff --git a/server/config.js b/server/config.js
iindex ee716b5..69c8447 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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') },
ddiff --git a/server/domain/proof.js b/server/domain/proof.js
iindex 906adf2..7ffe27d 100644
---- a/server/domain/proof.js
++++ b/server/domain/proof.js
@@@ -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 };
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex b220c6f..f5e04c0 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -149,7 +149,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const 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 });
ddiff --git a/server/domain/session-lines.js b/server/domain/session-lines.js
iindex 20af1c4..36c022d 100644
---- a/server/domain/session-lines.js
++++ b/server/domain/session-lines.js
@@@ -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,
ddiff --git a/server/http/run.js b/server/http/run.js
iindex e97e98f..e519ecf 100644
---- a/server/http/run.js
++++ b/server/http/run.js
@@@ -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) {
ddiff --git a/server/media.js b/server/media.js
nnew file mode 100644
iindex 0000000..19fe366
---- /dev/null
++++ b/server/media.js
@@@ -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 };
ddiff --git a/test/media-embeds.test.js b/test/media-embeds.test.js
nnew file mode 100644
iindex 0000000..e0aba5d
---- /dev/null
++++ b/test/media-embeds.test.js
@@@ -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); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 376808d..c83979c 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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',