From f9454633f9e787b0cb4254ba0302ae48a14de451 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Sun, 13 Sep 2026 16:38:36 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(perch):=20send=5Fuser=5Ffile=20relay?= =?UTF-8?q?=20=E2=80=94=20inline=20sent-file=20cards=20in=20the=20hub=20ch?= =?UTF-8?q?at=20(PR-E,=20audit=20item=2012)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pi-lab's real tool lives behind web/index.ts's bot guard (no web server in a bot process), so a perch interactive child never HAD send_user_file at all — this is the pi-lab-side relay branch the handoff gated on (operator OK 2026-09-13). Companion commit: pi-lab extensions/send-user-file.ts registers the tool under askUserPath 'ui' only and announces over ctx.ui.notify as 'crow-file:' + {path,name,mime,size,caption} — the crow-ask: precedent. - bridge.mjs: the interactive append now rides ask_user AND send_user_file (same PI_BOT_INTERACTIVE gate; channel csvs byte-identical; per-session narrowing can still remove either). - perch-interactive.js: a crow-file: notify jail-copies the file into the session's outputsDir — the only place the fd-based workspace route serves — collision policy SUFFIX (report.png -> report-2.png, never overwrite), dotfile basenames stored non-dot so the card is never a dead link, and a source already in the jail serves in place. Symlink/dir/over-200MB/missing render name-only with servable:false. Malformed JSON is swallowed like the crow-state/crow-ask mirrors. - perch_session_files (new table, BOTH rails: init-db + migrations/0007): one row per announce so a chat reload re-renders the cards (operator decision: persist). Keyed (bot_id, thread_id) like the transcript endpoint. - routes: GET /interactive/:sid/files/history — send order; a servable row whose file vanished drops to servable:false (a dead link is worse than a dim name). - hub client/css + i18n EN/ES: inline card in the transcript (images inline via the workspace route, caption, size, Download), dim not-servable state, fileSeen dedupe across the live/subscribe seam (rides the shared on() identity guard — count stays 18 by construction, not by omission). - tests: 9 engine relay, 3 route history, 6 hub card (incl. the two-different-files key regression), 3 migration rail, envelope csv updated. Full suite 4854 pass / 0 fail. --- scripts/init-db.js | 31 ++ scripts/pi-bots/bridge.mjs | 7 +- servers/gateway/dashboard/perch-hub/client.js | 104 ++++- servers/gateway/dashboard/perch-hub/css.js | 13 + servers/gateway/dashboard/shared/i18n.js | 5 + servers/gateway/perch-interactive.js | 112 +++++- .../gateway/routes/perch-interactive-api.js | 46 +++ tests/perch-hub-client.test.js | 116 ++++++ tests/perch-interactive-routes.test.js | 46 +++ tests/perch-interactive-sendfile.test.js | 378 ++++++++++++++++++ tests/perch-session-files-migration.test.js | 76 ++++ tests/pibot-tools-envelope.test.js | 20 +- 12 files changed, 939 insertions(+), 15 deletions(-) create mode 100644 tests/perch-interactive-sendfile.test.js create mode 100644 tests/perch-session-files-migration.test.js diff --git a/scripts/init-db.js b/scripts/init-db.js index 5e828b05..b5452b3a 100644 --- a/scripts/init-db.js +++ b/scripts/init-db.js @@ -2684,6 +2684,37 @@ await addColumnIfMissing("bot_sessions", "cwd", "TEXT"); // SCHEMA_GENERATION bump. await addColumnIfMissing("bot_sessions", "archived_at", "TEXT"); +// perch_session_files (PR-E, audit item 12): durable history of files the +// agent SENT to a perch chat via send_user_file's crow-file: relay. One row +// per announce; the chat replays these into the transcript on reload (the +// pi-session transcript endpoint carries messages, not tool calls, so the +// card needs its own rail). Keyed on (bot_id, thread_id) exactly like the +// transcript endpoint — NOT on bot_sessions.id, which a re-INSERT can move. +// `stored` is the basename inside the session's outputsDir (what the fd-based +// workspace route serves), NULL when servable=0 (source refused/too large/ +// copy failed — the reload then renders the same name-only dim card as the +// live frame). A SEPARATE table, deliberately: no bot_sessions column, so +// the canonical-COLUMN control-CHECK rebuild block below is untouched. A +// new table rides BOTH rails (init-db here + scripts/migrations/0007) — +// CREATE IF NOT EXISTS makes them converge idempotently on every instance. +await initTable("perch_session_files table", ` + CREATE TABLE IF NOT EXISTS perch_session_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bot_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + name TEXT NOT NULL, + stored TEXT, + mime TEXT NOT NULL DEFAULT 'application/octet-stream', + size INTEGER NOT NULL DEFAULT 0, + caption TEXT NOT NULL DEFAULT '', + servable INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_perch_session_files_thread + ON perch_session_files (bot_id, thread_id); +`); + // bot_sessions.control CHECK widen, 'run'/'stop' -> 'run'/'stop'/'interrupted' // (Track 3 Task 7): stopAll() parks a session that was genuinely mid-turn // when the gateway shut down with control='interrupted' instead of 'run', so diff --git a/scripts/pi-bots/bridge.mjs b/scripts/pi-bots/bridge.mjs index e95c016d..ab9e51f4 100644 --- a/scripts/pi-bots/bridge.mjs +++ b/scripts/pi-bots/bridge.mjs @@ -202,8 +202,13 @@ export class PiRpc { // spawns — so every channel caller's csv stays byte-identical, and the // per-session narrowing below can still take it away (envelope model: // narrowing only ever removes). + // PR-E (audit item 12) rides the SAME gate: pi-lab's send-user-file + // extension registers send_user_file only under askUserPath "ui" + // (PERMISSION_POLICY + INTERACTIVE), so appending it exactly where + // ask_user is appended keeps every channel bot's csv byte-identical and + // lets per-session narrowing below still take it away. if (opts.extraEnv && opts.extraEnv.PI_BOT_INTERACTIVE === "1") { - tools = [tools, "ask_user"].filter(Boolean).join(","); + tools = [tools, "ask_user", "send_user_file"].filter(Boolean).join(","); } // C-6: per-session narrowing is applied to the FINAL csv — AFTER the // subagent append — so a session can narrow `subagent` away too. Narrowing diff --git a/servers/gateway/dashboard/perch-hub/client.js b/servers/gateway/dashboard/perch-hub/client.js index 25230e87..22fd9bac 100644 --- a/servers/gateway/dashboard/perch-hub/client.js +++ b/servers/gateway/dashboard/perch-hub/client.js @@ -380,6 +380,9 @@ export function perchHubJs(lang = "en") { var TOOL_DONE='${tJs("perch.toolDone", lang)}'; var TOOL_FAILED='${tJs("perch.toolFailed", lang)}'; var COMMANDS_HIBERNATING='${tJs("perch.commandsHibernating", lang)}'; + /* PR-E (item 12): the chat's inline sent-file cards. */ + var FILE_DOWNLOAD='${tJs("perch.fileDownload", lang)}'; + var FILE_NOT_SERVABLE='${tJs("perch.fileNotServable", lang)}'; /* Row identity. One bot with eight sessions renders eight rows that read "R4 Assistant / awake" and nothing else — measured verbatim in a browser @@ -1089,6 +1092,7 @@ export function perchHubJs(lang = "en") { clearEl(el('perch-transcript')); clearEl(el('perch-ask')); clearEl(el('perch-activity-list')); /* the previous session's log is not this one's */ toolChips={}; /* Wave 3: the chip index dies with the transcript */ + fileSeen={}; /* PR-E: same seam for the card dedupe */ resetControls(); /* the PREVIOUS session's picker must not bleed in */ var known=rowIndex[sid]; if(known){ showHeader(known.botId,known.botName); afterHeader(mySid,known.botId); return; } @@ -1305,6 +1309,14 @@ export function perchHubJs(lang = "en") { } setTurnInFlight(false); }); + on('file',function(d){ + if(!d||!d.name) return; + if(!histSettled){ histBuf.push(d); return; } /* round 3 R4 seam applies to cards too */ + var k=fileKey(d); + if(fileSeen[k]) return; /* batch replay already drew it */ + fileSeen[k]=true; + renderFileCard(d); + }); on('ask_user',function(d){ renderAsk(d); }); on('error',function(d){ appendActivity(d.text||'error'); }); on('plan_state',function(d){ @@ -1436,23 +1448,32 @@ export function perchHubJs(lang = "en") { if(!sid||!botId) return; clearEl(el('perch-transcript')); toolChips={}; /* the chips just died with the transcript; a stale index would write into detached DOM */ + fileSeen={}; histSettled=false; histBuf=[]; loadHistory(botId,sid); } function loadHistory(botId,sid){ var mySid=sid; - perchApi('GET','/bots/'+encodeURIComponent(botId)+'/sessions/'+encodeURIComponent(sid)+'/transcript') - .then(function(r){ - if(current.sid!==mySid) return; /* identity guard, as everywhere */ + /* PR-E: the transcript (messages) and the sent-file card history arrive in + ONE join — the file cards flush only after BOTH land, so a slow history + fetch can never interleave cards out of order with batch messages. */ + var histP=perchApi('GET','/interactive/'+encodeURIComponent(sid)+'/files/history') + .then(function(h){ return (h.ok&&h.j&&Array.isArray(h.j.items))?h.j.items:[]; }); + Promise.all([ + perchApi('GET','/bots/'+encodeURIComponent(botId)+'/sessions/'+encodeURIComponent(sid)+'/transcript'), + histP, + ]).then(function(pair){ + var r=pair[0]; + if(current.sid!==mySid) return; /* identity guard, as everywhere */ /* A FAILED FETCH IS NOT AN EMPTY TRANSCRIPT. The old \`(r.ok&&r.j&&r.j.events)||[]\` collapsed a 500, a dropped tunnel and a logged-out session into the same "No transcript yet." — a reassuring sentence about a conversation that is still there. Say which happened. */ - if(!r.ok||!r.j){ appendNote(TRANSCRIPT_FAILED); flushHistBuf([]); return; } + if(!r.ok||!r.j){ appendNote(TRANSCRIPT_FAILED); flushHistBuf([], pair[1]); return; } var events=r.j.events||[]; - if(!events.length){ appendNote(NO_TRANSCRIPT); flushHistBuf([]); return; } + if(!events.length){ appendNote(NO_TRANSCRIPT); flushHistBuf([], pair[1]); return; } var batchTexts=[]; events.filter(function(e){ return e&&e.type==='message'; }).forEach(function(e){ var m=e.message||{}; @@ -1464,7 +1485,7 @@ export function perchHubJs(lang = "en") { if(String(m.role||'?')!=='user') batchTexts.push(txt); appendMessage(String(m.role||'?')==='user'?'user':'bot', String(m.role||'?'), txt, e.html); }); - flushHistBuf(batchTexts); + flushHistBuf(batchTexts, pair[1]); }); } @@ -1483,14 +1504,39 @@ export function perchHubJs(lang = "en") { not in the batch, and delaying them delays the composer. */ var histSettled=false; var histBuf=[]; + /* PR-E: name+stored keys of file cards already rendered THIS transcript — + dies with the transcript everywhere toolChips does (same seam). */ + var fileSeen={}; function renderTextFrame(d){ appendMessage('bot','bot',d.text,d.html); if(d.turnId) renderedTurn=d.turnId; else turnRendered=true; } - function flushHistBuf(batchTexts){ + /* PR-E: a card's identity across the live/replay seam. name+stored, because + the same original name can be sent twice (the jail stores report.png and + report-2.png) and both are DISTINCT cards. String()ed explicitly — an + earlier version of this used a bare pipe (x|''), a BITWISE or that + collapsed every key to "0|0" and silently dropped the second card of any + session. */ + function fileKey(d){ return String((d&&d.name)||'')+'|'+String((d&&d.stored)||''); } + function flushHistBuf(batchTexts, sentFiles){ histSettled=true; + /* PR-E: persisted sent-file cards replay first (they are older context — + the live buffer below can only hold TODAY's frames), deduped by + name+stored against buffered frames so a file sent between subscribe and + this batch lands exactly once. */ + var files=sentFiles||[]; + files.forEach(function(it){ + if(!it||!it.name) return; + fileSeen[fileKey(it)]=true; + renderFileCard(it); + }); var buf=histBuf; histBuf=[]; buf.forEach(function(f){ + if(f.type==='file'){ + if(fileSeen[fileKey(f)]) return; + renderFileCard(f); + return; + } if(f.reply){ var already=f.turnId?(renderedTurn===f.turnId):turnRendered; if(!already&&f.text&&batchTexts.indexOf(f.text)<0) appendMessage('bot','bot',f.text,f.html); @@ -1631,7 +1677,7 @@ export function perchHubJs(lang = "en") { setAttn(false); /* nor its unanswered-question banner */ /* Wave 2/3: the new session inherits none of the old one's readings. */ planState=null; renderPlan(); - toolChips={}; commandsCache=null; hideCmdMenu(); + toolChips={}; fileSeen={}; commandsCache=null; hideCmdMenu(); resetFacts(); } @@ -2423,6 +2469,48 @@ export function perchHubJs(lang = "en") { if(d&&d.toolCallId!=null) delete toolChips[d.toolCallId]; } + /* ---- PR-E (item 12): inline sent-file cards ----------------------------- + pi-lab's shape: an image renders inline, anything else is a download card + with name/caption/size; a file that could not be jail-copied renders + name-only with a dim "not servable" note. Servable rows link the EXISTING + fd-based workspace route — this adds no new serving path, and that jail's + O_NOFOLLOW discipline is what makes each download safe, not this markup. + createElement/textContent + encodeURIComponent only: names and captions + are child-controlled bytes and never reach a markup sink. Rides live + frames AND the /files/history reload batch (deduped by fileSeen). */ + function renderFileCard(d){ + var tr=el('perch-transcript'); if(!tr) return; + var name=String(d.name||''); + var servable=!!d.servable && !!d.stored; + // stored is normally a basename; the in-jail case can carry a nested + // relative path, so encode SEGMENT-wise (a blanket encodeURIComponent + // would turn its '/' into %2F). + var segs=String(d.stored).split('/').map(encodeURIComponent).join('/'); + var url=servable?API+'/interactive/'+encodeURIComponent(current.sid)+'/workspace/'+segs:''; + var wrap=document.createElement('div'); wrap.className='entry filecard'+(servable?'':' dim'); + var isImg=servable && String(d.mime||'').indexOf('image/')===0; + if(isImg){ + var img=document.createElement('img'); img.className='file-img'; img.src=url; img.alt=name; + img.loading='lazy'; + wrap.appendChild(img); + } + wrap.appendChild(line('file-title',name)); + if(d.caption) wrap.appendChild(line('file-cap',String(d.caption))); + var meta=fmtSize(d.size); + if(servable){ + wrap.appendChild(line('file-meta',meta)); + var a=document.createElement('a'); a.className='file-dl'; a.href=url; + a.setAttribute('download',name); + a.textContent=FILE_DOWNLOAD; + wrap.appendChild(a); + }else{ + wrap.appendChild(line('file-meta',meta)); + wrap.appendChild(line('file-note',FILE_NOT_SERVABLE)); + } + tr.appendChild(wrap); + tr.scrollTop=tr.scrollHeight; + } + /* ---- Wave 3: the slash-command menu ------------------------------------ Fed by pi's OWN get_commands registry through the engine (never a hardcoded list — a bot's commands depend on its loaded extensions and diff --git a/servers/gateway/dashboard/perch-hub/css.js b/servers/gateway/dashboard/perch-hub/css.js index adcaa3a9..8e887de5 100644 --- a/servers/gateway/dashboard/perch-hub/css.js +++ b/servers/gateway/dashboard/perch-hub/css.js @@ -426,6 +426,19 @@ text-transform:uppercase;letter-spacing:.07em;color:var(--dim);margin:6px 0 3px} #perch-hub-root .tool-details pre{background:var(--sky);padding:7px 9px;border-radius:6px; overflow-x:auto;overflow-y:auto;max-height:240px;margin:0;white-space:pre-wrap;word-break:break-word; font:11px/1.5 "JetBrains Mono",ui-monospace,monospace} +/* PR-E (audit item 12): inline sent-file cards in the chat. Column box on the + .entry row (which is itself a flex row — flex-direction:column + width:100% + own the line). Images inline capped to the card width; a not-jail-served + file renders dim with a name and the honest note, never a dead link. */ +#perch-hub-root .filecard{flex-direction:column;gap:3px;align-self:stretch;width:100%;min-width:0; +background:var(--card);border:1px solid var(--line);border-radius:10px;padding:9px 11px;margin:2px 0} +#perch-hub-root .filecard.dim{opacity:.6} +#perch-hub-root .filecard .file-title{font-weight:600;font-size:13.5px;word-break:break-all;color:var(--ink)} +#perch-hub-root .filecard .file-cap{font-size:13px;color:var(--ink);white-space:pre-wrap;word-break:break-word} +#perch-hub-root .filecard .file-meta{white-space:normal} +#perch-hub-root .filecard .file-note{font-size:12px;color:var(--dim)} +#perch-hub-root .filecard .file-dl{display:inline-block;margin-top:4px;color:var(--teal);font-size:13px;text-decoration:none} +#perch-hub-root .filecard .file-img{max-width:100%;height:auto;border-radius:6px;margin-bottom:4px} /* The menu anchors to #perch-composer (position:sticky = its containing block) and grows UPWARD — bottom:100% — so it can never cover Send. */ #perch-cmdmenu{position:absolute;bottom:100%;left:0;right:0;margin-bottom:6px;background:var(--card); diff --git a/servers/gateway/dashboard/shared/i18n.js b/servers/gateway/dashboard/shared/i18n.js index 81260477..63dcabf0 100644 --- a/servers/gateway/dashboard/shared/i18n.js +++ b/servers/gateway/dashboard/shared/i18n.js @@ -2359,6 +2359,11 @@ export const translations = { en: "Could not list the files.", es: "No se pudo listar los archivos.", }, + "perch.fileDownload": { en: "Download", es: "Descargar" }, + "perch.fileNotServable": { + en: "This file lives outside the session's outputs folder — the dashboard cannot serve it. Ask the bot to save it there.", + es: "Este archivo vive fuera de la carpeta de resultados de la sesión — el panel no puede servirlo. Pídele al bot que lo guarde allí.", + }, "perch.filesCwdHeading": { en: "Working directory", es: "Directorio de trabajo" }, "perch.filesOutputsHeading": { en: "Outputs", es: "Resultados" }, "perch.filesViewerClose": { en: "Close", es: "Cerrar" }, diff --git a/servers/gateway/perch-interactive.js b/servers/gateway/perch-interactive.js index 7eec9561..898b89dc 100644 --- a/servers/gateway/perch-interactive.js +++ b/servers/gateway/perch-interactive.js @@ -139,9 +139,9 @@ * (spec §9). */ import { randomUUID } from "node:crypto"; -import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { isAbsolute, join } from "node:path"; +import { isAbsolute, join, sep } from "node:path"; import { createDbClient } from "../db.js"; import { jobLockFor } from "./routes/board-lock.js"; @@ -220,6 +220,14 @@ const CROW_STATE_PREFIX = "crow-state:"; * today's sequential cards working untouched (feature-detect by arrival, never * by version). */ const CROW_ASK_PREFIX = "crow-ask:"; +/** PR-E (audit item 12): pi-lab's send-user-file relay announces a sent file + * over the same notify channel: `"crow-file:" + JSON.stringify({path, name, + * mime, size, caption})`. The engine jail-copies the file into the session's + * outputsDir (the ONLY place the workspace route can serve from) and emits a + * `file` frame for the chat's inline card. */ +const CROW_FILE_PREFIX = "crow-file:"; +/** Same cap as pi-lab's own mobile.ts send_user_file (200MB). */ +const FILE_MAX_BYTES = 200 * 1024 * 1024; /** The "Other…" free-text sentinel and the row/done formatting ask-user.ts's * `runTuiFlow` matches on. These MUST stay byte-identical to @@ -395,6 +403,27 @@ function cardFrom(m) { return card; } +/** + * PR-E (audit item 12): pick the stored name for a jail-copied sent file. + * Collision policy (operator decision 2026-09-13): SUFFIX, never overwrite — + * `report.png` → `report-2.png` → `report-3.png`, first slot free of every + * existing name in the dir, sync loop. A dotfile basename is renamed out of + * dotness (the workspace route refuses any dotfile path SEGMENT, so a stored + * `.secret` would be a permanently dead card; `secret` is served instead). + * Pure — exported only for tests; the engine's call site does the copy. + */ +function resolveSentName(dir, basename) { + const clean = String(basename == null ? "" : basename).replace(/[\\/]/g, "").replace(/^\.+/, "").trim() || "file"; + const dot = clean.lastIndexOf("."); + const stem = dot > 0 ? clean.slice(0, dot) : clean; + const ext = dot > 0 ? clean.slice(dot) : ""; + let candidate = clean; + for (let n = 2; existsSync(join(dir, candidate)); n++) { + candidate = stem + "-" + n + ext; + } + return candidate; +} + /** PR-A: clear ALL combined-ask state. Called wherever `pendingUi` is cleared * (child exit, abandon, stop, stopAll, abort, cycle, a resolved answer) so a * stale `askDance` can never auto-answer a later, unrelated dialog and a @@ -1440,6 +1469,11 @@ export function createInteractiveEngine({ // the real builder (B2); the `|| world.sessionDir` guard keeps a test seam // that returns no cwd from poisoning s.cwd with undefined. s.cwd = world.cwd || world.sessionDir || null; + // PR-E: the world root, recorded for handleFileNotify's no-outputsDir + // fallback (a session that announced a file before any spawn completed — + // in practice unreachable, since a crow-file: notify rides a live child; + // belt for the test seams that return a bare world). + s.worldRoot = world.sessionDir || null; // Track 3 Task 4 (wake fidelity, review finding 8): if the engine tracks a // model different from what prepareSpawn just resolved fresh (a live // model_select or a control() switch made while this session was awake or @@ -1723,6 +1757,64 @@ export function createInteractiveEngine({ return true; } + /** + * PR-E (audit item 12): act on one `crow-file:` relay announce. Jail-copy + * the sent file into the session's outputsDir — the ONLY place the fd-based + * workspace route serves from — then emit a `file` frame (live SSE) and + * persist a `perch_session_files` history row so a chat reload re-renders + * the card (operator decision: persist, not live-only). Anything that + * cannot land in the jail (no outputsDir yet, a special file, the 200MB + * cap, a copy failure) still renders as a name-only card with + * servable:false. Never throws — the caller is a protocol branch that must + * not break the turn. + */ + async function handleFileNotify(s, meta) { + if (!meta || typeof meta !== "object") return; + const src = typeof meta.path === "string" ? meta.path : ""; + const name = typeof meta.name === "string" && meta.name ? meta.name : (src ? src.split("/").pop() : ""); + if (!src || !name) return; + const caption = String(meta.caption == null ? "" : meta.caption).replace(/\s+/g, " ").trim().slice(0, 300); + let servable = false; + let stored = null; + let size = Number(meta.size); + let mime = typeof meta.mime === "string" ? meta.mime : "application/octet-stream"; + try { + const st = lstatSync(src); + if (st.isFile() && st.size <= FILE_MAX_BYTES) { + size = st.size; + // A dir is only ever mkdir'd when it is ABSOLUTE: the worldRoot + // fallback exists for test seams, and joining onto "" would produce a + // relative path and scatter an `outputs/` tree into the gateway's cwd. + const dir = s.outputsDir || (s.worldRoot ? join(s.worldRoot, "outputs", s.sessionId) : null); + if (!isAbsolute(dir || "")) throw new Error("no absolute outputs dir"); + mkdirSync(dir, { recursive: true }); + const dirReal = realpathSync(dir); + const srcReal = realpathSync(src); + // Strictly-under, the workspace route's own formula: the `+ sep` is + // what turns a string-prefix match into a containment test. + if (srcReal.startsWith(dirReal + sep)) { + // Already inside its own jail — nothing to copy, serve in place. + stored = srcReal.slice(dirReal.length + 1); + servable = true; + } else { + stored = resolveSentName(dir, name); + copyFileSync(srcReal, join(dir, stored)); + servable = true; + } + } + } catch { /* refused/unreachable source -> name-only card */ } + const frame = { type: "file", name, stored, mime, size: Number.isFinite(size) ? size : 0, caption, servable }; + emit(s, frame); + const db = createDbClient(); + try { + await db.execute({ + sql: "INSERT INTO perch_session_files (bot_id, thread_id, name, stored, mime, size, caption, servable) VALUES (?,?,?,?,?,?,?,?)", + args: [s.botId, s.threadId, name, stored, mime, frame.size, caption, servable ? 1 : 0], + }); + } catch { /* the live card stands; a lost history row costs a reload only */ } + finally { try { db.close(); } catch { /* already closed */ } } + } + function onUiRequest(s, m) { // PR-A: an in-flight combined-answer dance consumes the child's // select/input dialogs automatically — the operator already answered every @@ -1818,6 +1910,22 @@ export function createInteractiveEngine({ } return; } + // PR-E (audit item 12): pi-lab's send-user-file relay announces a sent + // file over this same notify channel, `"crow-file:" + JSON.stringify({ + // path, name, mime, size, caption})`. Jail-copy it into the session's + // outputsDir and emit a `file` frame; anything that cannot land in the + // jail renders as a name-only card with servable:false. Malformed JSON + // is swallowed exactly like the crow-state/crow-ask mirrors. + if (text.startsWith(CROW_FILE_PREFIX)) { + let parsed; + try { + parsed = JSON.parse(text.slice(CROW_FILE_PREFIX.length)); + } catch { + return; + } + handleFileNotify(s, parsed).catch(() => { /* a failed card never breaks the turn */ }); + return; + } emit(s, { type: "log", text }); return; } diff --git a/servers/gateway/routes/perch-interactive-api.js b/servers/gateway/routes/perch-interactive-api.js index f019d4ae..1717b3f7 100644 --- a/servers/gateway/routes/perch-interactive-api.js +++ b/servers/gateway/routes/perch-interactive-api.js @@ -921,6 +921,52 @@ export default function perchInteractiveApiRouter(dashboardAuth, { engine = getI } }); + // ---- GET /interactive/:sid/files/history — sent-file cards (PR-E, item 12) ---- + // The reload rail for the chat's inline file cards: one row per crow-file: + // relay announce this thread has ever made, oldest first (the hub appends in + // order). servable=1 rows whose STORED file no longer exists in the session's + // outputsDir drop to servable:false — a dead link is worse than a dim name. + // Same session-resolution shape as files/list (a hibernating session's + // snapshot still carries outputsDir). Capped like the list: the transcript + // itself trims at its own ceiling; the card rail must not balloon. + router.get(P + "/interactive/:sid/files/history", async (req, res) => { + const sid = String(req.params.sid); + try { + const eng = resolveEngine(); + const snap = await eng.get(sid); + if (!snap) return jsonError(res, 404, "no_such_session"); + const db = createDbClient(); + try { + // thread_id IS the sessionId (perch-interactive.js identity note); the + // bot_id filter keeps a cross-bot thread-id collision from leaking a + // card the caller has no right to render. + const r = await db.execute({ + sql: "SELECT name, stored, mime, size, caption, servable, created_at FROM perch_session_files WHERE bot_id=? AND thread_id=? ORDER BY id ASC LIMIT 200", + args: [String(snap.botId || ""), sid], + }); + const items = r.rows.map((row) => { + const it = { + name: String(row.name || ""), + stored: row.stored == null ? null : String(row.stored), + mime: String(row.mime || "application/octet-stream"), + size: Number(row.size) || 0, + caption: String(row.caption || ""), + servable: !!Number(row.servable), + created_at: String(row.created_at || ""), + }; + if (it.servable && (!snap.outputsDir || !it.stored)) { it.servable = false; return it; } + if (it.servable) { + try { it.servable = lstatSync(join(snap.outputsDir, it.stored)).isFile(); } catch { it.servable = false; } + } + return it; + }); + res.json({ items }); + } finally { try { db.close(); } catch { /* already closed */ } } + } catch (err) { + mapEngineError(res, err); + } + }); + // ---- GET /interactive/:sid/cwd/list — read-only cwd browse (PR-B, item 18) ---- // Lists dirs + files inside the session's OWN cwd, realpath-jailed under it // (never the uploadsDir). The Files tab's browser: a picker/viewer, not a diff --git a/tests/perch-hub-client.test.js b/tests/perch-hub-client.test.js index b00cee48..ba61379f 100644 --- a/tests/perch-hub-client.test.js +++ b/tests/perch-hub-client.test.js @@ -3483,6 +3483,122 @@ test("W3: a tool call grows a chip that spins, expands, and settles to done/fail assert.equal(wrap2.children[0].children[1].textContent, "failed"); }); +// --------------------------------------------------------------------------- +// PR-E (audit item 12): inline sent-file cards. +// --------------------------------------------------------------------------- + +function fileCards(hub) { + return hub.els["perch-transcript"].children.filter((c) => String(c.className).includes("filecard")); +} + +test("E: a servable file frame grows an inline card — image, title, caption, size, download link on the EXISTING workspace route", async () => { + const hub = await mountHub({ fetchImpl: stdFetch() }); + await openChatSession(hub); + FakeEventSource.instances[0]._serverFrame("file", { + name: "mockup.png", stored: "mockup.png", mime: "image/png", size: 20480, + caption: "the landing hero", servable: true, + }); + await new Promise((r) => setTimeout(r, 0)); + + const card = fileCards(hub)[0]; + assert.ok(card, "the card lands in the transcript, where the operator is looking"); + assert.doesNotMatch(String(card.className), /dim/); + const img = card.children[0]; + assert.equal(img.tagName, "IMG"); + assert.equal(img.src, "/dashboard/perch-api/interactive/perchlive-aaaaaaaa/workspace/mockup.png", + "the fd-based jail route is the ONLY serving path this card knows"); + assert.equal(card.children[1].textContent, "mockup.png"); + assert.equal(card.children[2].textContent, "the landing hero"); + assert.equal(card.children[4].tagName, "A"); + assert.equal(card.children[4].className, "file-dl"); + assert.equal(card.children[4].href, + "/dashboard/perch-api/interactive/perchlive-aaaaaaaa/workspace/mockup.png"); +}); + +test("E: a non-image servable file renders no inline preview, only the download card", async () => { + const hub = await mountHub({ fetchImpl: stdFetch() }); + await openChatSession(hub); + FakeEventSource.instances[0]._serverFrame("file", { + name: "report.pdf", stored: "report.pdf", mime: "application/pdf", size: 1024, caption: "", servable: true, + }); + await new Promise((r) => setTimeout(r, 0)); + const card = fileCards(hub)[0]; + assert.equal(card.children[0].tagName, "DIV", "a PDF never becomes an "); + assert.equal(card.children[0].className, "file-title"); + assert.equal(card.children[0].textContent, "report.pdf"); + assert.equal(card.children.filter((c) => c.className === "file-cap").length, 0, + "an empty caption costs no row"); + assert.equal(card.children.at(-1).className, "file-dl"); +}); + +test("E: a NOT-servable file renders dim, name-only, with the honest note and no dead link", async () => { + const hub = await mountHub({ fetchImpl: stdFetch() }); + await openChatSession(hub); + FakeEventSource.instances[0]._serverFrame("file", { + name: "secrets.dump", stored: null, mime: "application/octet-stream", size: 7, caption: "", servable: false, + }); + await new Promise((r) => setTimeout(r, 0)); + const card = fileCards(hub)[0]; + assert.match(String(card.className), /dim/); + assert.equal(card.children[0].textContent, "secrets.dump"); + assert.equal(card.children.filter((c) => c.tagName === "A").length, 0, + "a path the jail cannot serve must never render as a link"); + assert.match(card.children.at(-1).textContent, /cannot serve/i); +}); + +test("E: persisted cards replay on open, oldest first, and a live frame the batch already drew never duplicates", async () => { + const hub = await mountHub({ + fetchImpl: stdFetch({ + "/files/history": () => makeResponse(200, { items: [ + { name: "one.png", stored: "one.png", mime: "image/png", size: 10, caption: "first", servable: true }, + { name: "two.txt", stored: null, mime: "text/plain", size: 3, caption: "", servable: false }, + ] }), + }), + }); + await openChatSession(hub); + await new Promise((r) => setTimeout(r, 10)); + const cards = fileCards(hub); + assert.equal(cards.length, 2, "both persisted cards replay"); + assert.equal(cards[0].children[1].textContent, "one.png"); + assert.match(String(cards[1].className), /dim/, "the persisted refusal replays as the same dim card"); + + // The same file arriving live afterwards (a resend keyed identically) is a + // duplicate of what the batch drew — fileSeen holds it. + FakeEventSource.instances[0]._serverFrame("file", { + name: "one.png", stored: "one.png", mime: "image/png", size: 10, caption: "first", servable: true, + }); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(fileCards(hub).length, 2, "the subscribe/fetch seam dedupes cards as it does text"); +}); + +test("E: two DIFFERENT live files render two cards (the key must not collapse them)", async () => { + const hub = await mountHub({ fetchImpl: stdFetch() }); + await openChatSession(hub); + const es = FakeEventSource.instances[0]; + es._serverFrame("file", { name: "shot.png", stored: "shot.png", mime: "image/png", size: 1, caption: "", servable: true }); + es._serverFrame("file", { name: "shot.png", stored: "shot-2.png", mime: "image/png", size: 1, caption: "", servable: true }); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(fileCards(hub).length, 2, + "same original name, different jail slots = two cards (a collapsed key drops one)"); + assert.match(fileCards(hub)[1].children[0].src, /\/workspace\/shot-2\.png$/); +}); + +test("E: a session switch drops the previous session's cards (and their dedupe keys)", async () => { + const hub = await mountHub({ fetchImpl: stdFetch() }); + await openChatSession(hub); + FakeEventSource.instances[0]._serverFrame("file", { + name: "a.png", stored: "a.png", mime: "image/png", size: 1, caption: "", servable: true, + }); + await new Promise((r) => setTimeout(r, 0)); + assert.equal(fileCards(hub).length, 1); + + hub.location.hash = ""; + await new Promise((r) => setTimeout(r, 0)); + hub.location.hash = "perchlive-aaaaaaaa"; + await new Promise((r) => setTimeout(r, 0)); + assert.equal(fileCards(hub).length, 0, "the card index dies with the transcript, as toolChips' does"); +}); + test("W3: a tool END with no seen start mints nothing — the rail already has the line", async () => { const hub = await mountHub({ fetchImpl: stdFetch() }); await openChatSession(hub); diff --git a/tests/perch-interactive-routes.test.js b/tests/perch-interactive-routes.test.js index e17a2693..a6798f42 100644 --- a/tests/perch-interactive-routes.test.js +++ b/tests/perch-interactive-routes.test.js @@ -1972,6 +1972,52 @@ test("files/list carries the download route's own 404/409 shapes", async () => { assert.equal(r.body.error, "not_found"); }); +// --------------------------------------------------------------------------- +// PR-E (audit item 12) — GET /interactive/:sid/files/history, the reload rail +// for the chat's inline sent-file cards. +// --------------------------------------------------------------------------- + +function seedSentRow({ bot = "botty", thread = "perchlive-11111111", name, stored, mime = "image/png", size = 3, caption = "", servable = 1 }) { + const c = raw(); + c.prepare("INSERT INTO perch_session_files (bot_id, thread_id, name, stored, mime, size, caption, servable) VALUES (?,?,?,?,?,?,?,?)") + .run(bot, thread, name, stored, mime, size, caption, servable); + c.close(); +} + +test("files/history replays cards in order, and a servable row whose file vanished drops to servable:false", async () => { + const outputsDir = mkdtempSync(join(tmpdir(), "perch-hist-")); + writeFileSync(join(outputsDir, "live.png"), "PNGBYTES"); + seedSentRow({ name: "live.png", stored: "live.png", caption: "still here" }); + seedSentRow({ name: "gone.png", stored: "gone.png" }); // servable=1, file absent + seedSentRow({ name: "never.png", stored: null, servable: 0 }); // refused at send time + engineImpl.get = async (sid) => ({ sessionId: sid, botId: "botty", uploadsDir: null, outputsDir }); + + const { status, body } = await getJson("/interactive/perchlive-11111111/files/history"); + assert.equal(status, 200); + assert.deepEqual(body.items.map((i) => i.name), ["live.png", "gone.png", "never.png"], "id ASC = send order"); + assert.equal(body.items[0].servable, true); + assert.equal(body.items[0].caption, "still here"); + assert.equal(body.items[1].servable, false, "a dead link is worse than a dim name"); + assert.equal(body.items[2].servable, false); +}); + +test("files/history is scoped to (bot_id, thread_id) — another bot's cards on a colliding thread id never leak", async () => { + seedSentRow({ bot: "other", thread: "perchlive-11111111", name: "priv.png", stored: null, servable: 0 }); + engineImpl.get = async (sid) => ({ sessionId: sid, botId: "botty", uploadsDir: null, outputsDir: null }); + const { status, body } = await getJson("/interactive/perchlive-11111111/files/history"); + assert.equal(status, 200); + // (No per-test DB wipe in this harness — the assertion is the LEAK, not emptiness.) + assert.equal(body.items.filter((i) => i.name === "priv.png").length, 0, + "the bot_id filter is load-bearing, not decorative"); +}); + +test("files/history carries the same 404 shape as files/list for an unknown session", async () => { + engineImpl.get = async () => null; + const { status, body } = await getJson("/interactive/perchlive-dead0000/files/history"); + assert.equal(status, 404); + assert.equal(body.error, "no_such_session"); +}); + // --------------------------------------------------------------------------- // Wave 3 — GET /interactive/:sid/commands, the slash menu's feed. // --------------------------------------------------------------------------- diff --git a/tests/perch-interactive-sendfile.test.js b/tests/perch-interactive-sendfile.test.js new file mode 100644 index 00000000..ebef5c97 --- /dev/null +++ b/tests/perch-interactive-sendfile.test.js @@ -0,0 +1,378 @@ +/** + * Perch PR-E (audit item 12) — engine-side half of the send_user_file relay. + * + * pi-lab's `extensions/send-user-file.ts` (separate repo) registers the tool + * for perch interactive children only and announces a sent file over pi's + * `extension_ui_request` notify channel as + * "crow-file:" + JSON.stringify({path, name, mime, size, caption}) + * This file proves the engine's contract: + * 1. The notify branch jail-copies the file into the session's outputsDir + * (the ONLY place the fd-based workspace route serves from), emits a + * `file` frame (never a `log` line), and persists a perch_session_files + * history row keyed on (bot_id, thread_id) for the chat's reload. + * 2. Collision policy: SUFFIX, never overwrite (report.png -> report-2.png). + * 3. Honest refusals: symlink source, directory, over-cap, and a missing + * file all render name-only with servable:false — never a dead link. + * 4. Malformed JSON after the prefix is swallowed exactly like the + * crow-state/crow-ask mirrors. + * + * Same harness shape as tests/perch-interactive-statebridge.test.js (real + * scratch crow.db, injected fake PiRpc/bridge seam, injected clock/timers). + */ +import { test, before, after, beforeEach } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import Database from "better-sqlite3"; + +const dir = mkdtempSync(join(tmpdir(), "perch-interactive-sendfile-")); +process.env.CROW_DATA_DIR = dir; +process.env.CROW_HOME = join(dir, "home"); +delete process.env.CROW_DB_PATH; +process.env.PI_MODELS_JSON = join(dir, "models.json"); + +const CROW_HOME = process.env.CROW_HOME; +const REPO = new URL("..", import.meta.url).pathname.replace(/\/$/, ""); + +let createInteractiveEngine, _resetInteractiveEngineForTest; + +function makeClock() { + let t = 1_700_000_000_000; + let seq = 0; + const timers = new Map(); + return { + now: () => t, + setTimer(fn, ms) { const id = ++seq; timers.set(id, { fn, at: t + Number(ms) }); return id; }, + clearTimer(id) { timers.delete(id); }, + advance(ms) { + const target = t + ms; + for (;;) { + let pick = null; + for (const [id, e] of timers) if (e.at <= target && (pick === null || e.at < pick.entry.at)) pick = { id, entry: e }; + if (!pick) break; + timers.delete(pick.id); + t = pick.entry.at; + pick.entry.fn(); + } + t = target; + }, + }; +} + +const tick = async (n = 8) => { for (let i = 0; i < n; i++) await new Promise((r) => setImmediate(r)); }; + +let pidSeq = 900000; + +function makeBridge(opts = {}) { + const state = { + worlds: [], instances: [], modelKey: opts.modelKey || "crow-local/qwen3.6-35b-a3b", + livePi: 0, maxPi: 4, projectId: 7, + }; + class FakePi { + constructor(o) { + this.opts = o; this.onEvent = o.onEvent; this.sent = []; this.closed = 0; + this.turns = []; this._exitCode = null; this.proc = { pid: ++pidSeq }; + this.piSessionId = "pisess-" + this.proc.pid; + let done; this.exited = new Promise((r) => { done = r; }); this._done = done; + state.instances.push(this); + } + async getState() { return { data: { sessionId: this.piSessionId } }; } + async getSessionStats() { return { data: { tokens: { input: 10, output: 5, cacheRead: 0 } } }; } + promptTurn(message, ms) { + const rec = { message, ms }; + rec.promise = new Promise((resolve, reject) => { rec.resolve = resolve; rec.reject = reject; }); + this.turns.push(rec); return rec.promise; + } + lastTurn() { return this.turns[this.turns.length - 1]; } + trimLog() {} + async abortSince() { return null; } + send(o) { if (this._exitCode != null) throw new Error("pi exited"); this.sent.push(o); } + async promptAckOnly(message) { + if (this._exitCode != null) throw new Error("pi exited"); + const id = "prompt_" + (this._ackSeq = (this._ackSeq || 0) + 1); + this.send({ type: "prompt", id, message }); + return { type: "response", command: "prompt", id, success: true }; + } + async close() { this.closed += 1; this.exit(0); } + exit(code = 0) { if (this._exitCode != null) return; this._exitCode = code; this._done(code); } + _exitError() { return new Error("pi exited (code " + this._exitCode + ") before responding"); } + emit(m) { this.onEvent(m); } + } + const seam = { + _state: state, + PiRpc: FakePi, + LIFECYCLE_DEFAULTS: { get maxPi() { return state.maxPi; } }, + countLivePi: () => state.livePi, + async buildBotWorld(args) { + state.worlds.push(args); + const sessionDir = join(dir, "bots", args.botId); + mkdirSync(sessionDir, { recursive: true }); + return { + def: { session_dir: sessionDir, permission_policy: { bash: "deny", write_paths: [] } }, + bot: { bot_id: args.botId }, + crowHome: CROW_HOME, projectId: state.projectId, + projectSpace: null, projectMembers: [], + sessionDir, tasksDbPath: join(dir, "tasks.db"), + remoteEnabled: false, peerGatewayUrls: {}, session: null, narrowedTools: null, + gatewayType: args.gatewayType, + }; + }, + async prepareSpawn(world) { + const resolved = { + provider: state.modelKey.split("/")[0], model: state.modelKey.split("/").slice(1).join("/"), + key: state.modelKey, escalated: false, source: "default", escalationRequestedButUnavailable: false, + }; + return { + sysFile: join(dir, "sys.md"), selfAuthoringDir: null, resolved, + piRpcOpts: { + def: world.def, sessionDir: world.sessionDir, resolved, selfAuthoringDir: null, + remoteEnabled: world.remoteEnabled, narrowedTools: world.narrowedTools, + appendSystemPromptFile: join(dir, "sys.md"), + }, + }; + }, + async warmModel() {}, + async meterTurn() { return { recorded: true }; }, + appendAudit() {}, + }; + return seam; +} + +function makeEngine(o = {}) { + const clock = makeClock(); + const bridge = o.bridge || makeBridge(o.bridgeOpts); + const engine = createInteractiveEngine({ + crowHome: CROW_HOME, + env: Object.assign({ CROW_HOME }, o.env), + bridge, now: clock.now, setTimer: clock.setTimer, clearTimer: clock.clearTimer, log: () => {}, + }); + return { engine, clock, bridge, state: bridge._state }; +} + +async function collect(engine, sessionId) { + const events = []; + const off = await engine.subscribe(sessionId, (e) => events.push(e)); + return { events, off, ofType: (t) => events.filter((e) => e.type === t) }; +} + +async function spawned(engine, botId = "botty") { + const r = await engine.spawn({ botId }); + await tick(); + return r; +} + +/** A fresh outside-jail source file with known bytes. */ +function srcFile(name, bytes = "sent-payload") { + const p = join(dir, "src", name); + mkdirSync(join(dir, "src"), { recursive: true }); + writeFileSync(p, bytes); + return p; +} + +function crowFileFrame(meta) { + return "crow-file:" + JSON.stringify(meta); +} + +function historyRows(botId, threadId) { + const db = new Database(join(dir, "crow.db")); + try { + return db.prepare("SELECT name, stored, mime, size, caption, servable FROM perch_session_files WHERE bot_id=? AND thread_id=? ORDER BY id ASC") + .all(botId, threadId); + } finally { db.close(); } +} + +before(async () => { + execFileSync(process.execPath, ["scripts/init-db.js"], { + env: { ...process.env, CROW_DATA_DIR: dir }, stdio: "pipe", cwd: REPO, + }); + mkdirSync(CROW_HOME, { recursive: true }); + writeFileSync(process.env.PI_MODELS_JSON, JSON.stringify({ + providers: { "crow-local": { models: [{ id: "qwen3.6-35b-a3b" }] } }, + })); + const mod = await import("../servers/gateway/perch-interactive.js"); + createInteractiveEngine = mod.createInteractiveEngine; + _resetInteractiveEngineForTest = mod._resetInteractiveEngineForTest; +}); + +beforeEach(() => { if (_resetInteractiveEngineForTest) _resetInteractiveEngineForTest(); }); +after(() => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } }); + +// --------------------------------------------------------------------------- +// 1. happy path: jail-copy + file frame + history row +// --------------------------------------------------------------------------- + +test("a crow-file: notify jail-copies the file into outputsDir, emits a file frame (never log), and persists a history row", async () => { + const { engine, state } = makeEngine(); + const s = await spawned(engine); + const pi = state.instances[0]; + const sink = await collect(engine, s.sessionId); + const src = srcFile("report.png", "PNGBYTES"); + + pi.emit({ type: "extension_ui_request", method: "notify", message: crowFileFrame({ + path: src, name: "report.png", mime: "image/png", size: 8, caption: "the mockup", + }) }); + await tick(); + + const frames = sink.ofType("file"); + assert.equal(frames.length, 1); + const f = frames[0]; + assert.equal(f.name, "report.png"); + assert.equal(f.stored, "report.png"); + assert.equal(f.mime, "image/png"); + assert.equal(f.caption, "the mockup"); + assert.equal(f.servable, true); + assert.equal(sink.ofType("log").length, 0, "a crow-file: frame must never become a log line"); + + // The copy really landed inside the jail the workspace route serves from. + const snap = await engine.get(s.sessionId); + assert.ok(snap.outputsDir, "spawn recorded an outputsDir"); + assert.equal(readFileSync(join(snap.outputsDir, "report.png"), "utf8"), "PNGBYTES"); + assert.equal(existsSync(src), true, "the source file is never moved or removed"); + + const rows = historyRows("botty", s.sessionId); + assert.equal(rows.length, 1); + assert.deepEqual({ ...rows[0], servable: !!rows[0].servable }, { + name: "report.png", stored: "report.png", mime: "image/png", size: 8, caption: "the mockup", servable: true, + }); +}); + +test("collision policy is SUFFIX, never overwrite: the second send lands as report-2.png and both copies survive", async () => { + const { engine, state } = makeEngine(); + const s = await spawned(engine); + const pi = state.instances[0]; + const sink = await collect(engine, s.sessionId); + const a = srcFile("chart.png", "FIRST"); + const b = srcFile("chart-again.png", "SECOND"); + + pi.emit({ type: "extension_ui_request", method: "notify", message: crowFileFrame({ path: a, name: "chart.png", mime: "image/png", size: 5, caption: "" }) }); + await tick(); + pi.emit({ type: "extension_ui_request", method: "notify", message: crowFileFrame({ path: b, name: "chart.png", mime: "image/png", size: 6, caption: "" }) }); + await tick(); + + const snap = await engine.get(s.sessionId); + const frames = sink.ofType("file"); + assert.deepEqual(frames.map((x) => x.stored), ["chart.png", "chart-2.png"]); + assert.equal(readFileSync(join(snap.outputsDir, "chart.png"), "utf8"), "FIRST", "the original copy was NOT overwritten"); + assert.equal(readFileSync(join(snap.outputsDir, "chart-2.png"), "utf8"), "SECOND"); + assert.equal(historyRows("botty", s.sessionId).length, 2); +}); + +test("a dotfile basename is stored under a non-dot name (the workspace route refuses dot segments)", async () => { + const { engine, state } = makeEngine(); + const s = await spawned(engine); + const pi = state.instances[0]; + const sink = await collect(engine, s.sessionId); + const src = srcFile(".hidden", "H"); + + pi.emit({ type: "extension_ui_request", method: "notify", message: crowFileFrame({ path: src, name: ".hidden", mime: "text/plain", size: 1, caption: "" }) }); + await tick(); + + const f = sink.ofType("file")[0]; + assert.equal(f.stored, "hidden", "stored is servable, not permanently dead"); + assert.equal(f.servable, true); +}); + +test("a source already inside the session's own jail is served in place — no second copy is written", async () => { + const { engine, state } = makeEngine(); + const s = await spawned(engine); + const pi = state.instances[0]; + const sink = await collect(engine, s.sessionId); + const snap = await engine.get(s.sessionId); + const src = join(snap.outputsDir, "inplace.txt"); + writeFileSync(src, "IP"); + + pi.emit({ type: "extension_ui_request", method: "notify", message: crowFileFrame({ path: src, name: "inplace.txt", mime: "text/plain", size: 2, caption: "" }) }); + await tick(); + + const f = sink.ofType("file")[0]; + assert.equal(f.servable, true); + assert.equal(f.stored, "inplace.txt"); + assert.equal(existsSync(join(snap.outputsDir, "inplace-2.txt")), false, "no duplicate copy"); +}); + +// --------------------------------------------------------------------------- +// 2. honest refusals — name-only cards, never a dead link +// --------------------------------------------------------------------------- + +test("a symlinked source is refused: servable:false, nothing copied", async () => { + const { engine, state } = makeEngine(); + const s = await spawned(engine); + const pi = state.instances[0]; + const sink = await collect(engine, s.sessionId); + const target = srcFile("secret-target", "S"); + const link = join(dir, "src", "link.png"); + symlinkSync(target, link); + + pi.emit({ type: "extension_ui_request", method: "notify", message: crowFileFrame({ path: link, name: "link.png", mime: "image/png", size: 1, caption: "" }) }); + await tick(); + + const f = sink.ofType("file")[0]; + assert.equal(f.servable, false); + assert.equal(f.stored, null); + const snap = await engine.get(s.sessionId); + assert.equal(existsSync(join(snap.outputsDir, "link.png")), false); + assert.equal(historyRows("botty", s.sessionId)[0].servable, 0, "the reload renders the same dim card"); +}); + +test("a missing file and a directory both render name-only with servable:false (never a throw, never a log)", async () => { + const { engine, state } = makeEngine(); + const s = await spawned(engine); + const pi = state.instances[0]; + const sink = await collect(engine, s.sessionId); + + pi.emit({ type: "extension_ui_request", method: "notify", message: crowFileFrame({ path: join(dir, "nope.bin"), name: "nope.bin", mime: "application/octet-stream", size: 0, caption: "" }) }); + pi.emit({ type: "extension_ui_request", method: "notify", message: crowFileFrame({ path: join(dir, "src"), name: "src", mime: "application/octet-stream", size: 0, caption: "" }) }); + await tick(); + + const frames = sink.ofType("file"); + assert.equal(frames.length, 2); + assert.deepEqual(frames.map((x) => x.servable), [false, false]); + assert.equal(sink.ofType("log").length, 0); +}); + +// --------------------------------------------------------------------------- +// 3. protocol hygiene +// --------------------------------------------------------------------------- + +test("malformed JSON after the crow-file: prefix is swallowed — no frame, no log line, no throw", async () => { + const { engine, state } = makeEngine(); + const s = await spawned(engine); + const pi = state.instances[0]; + const sink = await collect(engine, s.sessionId); + + assert.doesNotThrow(() => { + pi.emit({ type: "extension_ui_request", method: "notify", message: "crow-file:{not valid json" }); + }); + await tick(); + assert.equal(sink.ofType("file").length, 0); + assert.equal(sink.ofType("log").length, 0); +}); + +test("a crow-file: frame with no path and no name is ignored entirely", async () => { + const { engine, state } = makeEngine(); + const s = await spawned(engine); + const pi = state.instances[0]; + const sink = await collect(engine, s.sessionId); + + pi.emit({ type: "extension_ui_request", method: "notify", message: crowFileFrame({ caption: "orphan" }) }); + await tick(); + assert.equal(sink.ofType("file").length, 0); + assert.equal(historyRows("botty", s.sessionId).length, 0); +}); + +test("regression: crow-state and crow-ask prefixes still discriminate — a crow-file frame is not a log line and a plain note is", async () => { + const { engine, state } = makeEngine(); + const s = await spawned(engine); + const pi = state.instances[0]; + const sink = await collect(engine, s.sessionId); + const src = srcFile("tri.txt", "T"); + + pi.emit({ type: "extension_ui_request", method: "notify", message: "plain operator note" }); + pi.emit({ type: "extension_ui_request", method: "notify", message: crowFileFrame({ path: src, name: "tri.txt", mime: "text/plain", size: 1, caption: "" }) }); + await tick(); + + assert.deepEqual(sink.ofType("log").map((e) => e.text), ["plain operator note"]); + assert.equal(sink.ofType("file").length, 1); +}); diff --git a/tests/perch-session-files-migration.test.js b/tests/perch-session-files-migration.test.js new file mode 100644 index 00000000..71e93a4c --- /dev/null +++ b/tests/perch-session-files-migration.test.js @@ -0,0 +1,76 @@ +// 0007-perch-session-files — the rail that creates the sent-file card history +// table on a co-hosted instance that converges without re-running init-db +// (Perch PR-E, audit item 12). Same outage class as 0005/0006: the table +// carries no SCHEMA_GENERATION bump, so an instance whose boot guard skips +// init-db would meet "no such table: perch_session_files" on the first +// crow-file: relay of its life — inside the engine's notify branch, where a +// throw is swallowed and the card silently stops persisting. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import Database from "better-sqlite3"; +import { run, id } from "../scripts/migrations/0007-perch-session-files.mjs"; + +function scratchDb({ withTable = false, withIndex = false } = {}) { + const dir = mkdtempSync(join(tmpdir(), "psf-mig-")); + const dbPath = join(dir, "crow.db"); + const db = new Database(dbPath); + if (withTable) { + db.exec(`CREATE TABLE perch_session_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bot_id TEXT NOT NULL, thread_id TEXT NOT NULL, name TEXT NOT NULL, + stored TEXT, mime TEXT NOT NULL DEFAULT 'application/octet-stream', + size INTEGER NOT NULL DEFAULT 0, caption TEXT NOT NULL DEFAULT '', + servable INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')))`); + db.prepare("INSERT INTO perch_session_files (bot_id, thread_id, name, servable) VALUES ('b','t','x.png',1)").run(); + } + if (withIndex) { + db.exec("CREATE INDEX idx_perch_session_files_thread ON perch_session_files (bot_id, thread_id)"); + } + db.close(); + return { dir, dbPath }; +} + +function shape(dbPath) { + const d = new Database(dbPath); + const out = { + table: !!d.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='perch_session_files'").get(), + index: !!d.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_perch_session_files_thread'").get(), + cols: d.prepare("PRAGMA table_info(perch_session_files)").all().map((c) => c.name), + }; + d.close(); + return out; +} + +test("creates the table and its thread index on an instance that never ran init-db's new block", () => { + const { dir, dbPath } = scratchDb(); + try { + const r = run({ dbPath, log: () => {} }); + assert.equal(r.applied, true); + const s = shape(dbPath); + assert.ok(s.table && s.index); + assert.deepEqual(s.cols, ["id", "bot_id", "thread_id", "name", "stored", "mime", "size", "caption", "servable", "created_at"], + "byte-identical column shape to init-db.js's CREATE body"); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("a second run is a clean no-op — no duplicate table, no lost rows", () => { + const { dir, dbPath } = scratchDb({ withTable: true, withIndex: true }); + try { + const first = run({ dbPath, log: () => {} }); + const second = run({ dbPath, log: () => {} }); + assert.equal(first.applied, true); + assert.equal(second.applied, true); + const d = new Database(dbPath); + const rows = d.prepare("SELECT COUNT(*) AS n FROM perch_session_files").get().n; + d.close(); + assert.equal(rows, 1, "CREATE IF NOT EXISTS never touched existing history"); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test("the module id matches its filename, as the runner's registry expects", () => { + assert.equal(id, "0007-perch-session-files"); +}); diff --git a/tests/pibot-tools-envelope.test.js b/tests/pibot-tools-envelope.test.js index 7119c3fb..4b727efe 100644 --- a/tests/pibot-tools-envelope.test.js +++ b/tests/pibot-tools-envelope.test.js @@ -82,21 +82,33 @@ test("an interactive spawn appends ask_user; the channel spawn's csv is byte-ide assert.equal(toolsFlag(channel), "read,mcp__tasks__tasks_list", "no marker, no append — every channel caller stays exactly as before"); const interactive = await spawnArgs(def, undefined, { PI_BOT_INTERACTIVE: "1" }); - assert.equal(toolsFlag(interactive), "read,mcp__tasks__tasks_list,ask_user"); + assert.equal(toolsFlag(interactive), "read,mcp__tasks__tasks_list,ask_user,send_user_file", + "PR-E (audit item 12): send_user_file rides the SAME gate — pi-lab's send-user-file\n" + + 'extension registers it only under askUserPath "ui", exactly like ask_user'); +}); + +test("a session can narrow send_user_file away too — same envelope model", async () => { + const argv = await spawnArgs({ tools: { pi_builtin: ["read"], crow_mcp: [] } }, + JSON.stringify(["send_user_file"]), { PI_BOT_INTERACTIVE: "1" }); + assert.equal(toolsFlag(argv), "read,ask_user", + "the append happens BEFORE narrowing, so the pane can take the file tool away alone"); }); test("an empty envelope still gains ask_user on an interactive spawn", async () => { const argv = await spawnArgs({ tools: { pi_builtin: [], crow_mcp: [] } }, undefined, { PI_BOT_INTERACTIVE: "1" }); - assert.equal(toolsFlag(argv), "ask_user", + assert.equal(toolsFlag(argv), "ask_user,send_user_file", "the join must not leave a leading comma on an otherwise-empty csv"); }); test("a session can narrow ask_user away — it joins the csv BEFORE narrowing", async () => { + // PR-E widened the interactive append set, so pin the csv down to read by + // narrowing BOTH appends — the point of this test (an append is narrowable) + // is unchanged. const argv = await spawnArgs({ tools: { pi_builtin: ["read"], crow_mcp: [] } }, - JSON.stringify(["ask_user"]), { PI_BOT_INTERACTIVE: "1" }); + JSON.stringify(["ask_user", "send_user_file"]), { PI_BOT_INTERACTIVE: "1" }); assert.equal(toolsFlag(argv), "read", - "narrowing only ever removes, and it can remove this too — the envelope model holds"); + "narrowing only ever removes, and it can remove these too — the envelope model holds"); }); test("a def cannot fake the marker: spawn_env hygiene strips PI_BOT_* before the append decision", async () => { From ac15852d05d7697291bcbccd10bcff95ee568ae9 Mon Sep 17 00:00:00 2001 From: kh0pper Date: Sun, 13 Sep 2026 16:47:21 -0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(perch):=20ship=20migrations/0007-perch-?= =?UTF-8?q?session-files.mjs=20=E2=80=94=20the=20positional-path=20commit?= =?UTF-8?q?=20omitted=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's suite red on ERR_MODULE_NOT_FOUND: tests/perch-session-files-migration.test.js imports the rail the previous commit never included (the new file was staged but positional-path commits only carry listed paths). One shape, two rails: this is the second of them (init-db.js already landed). --- .../migrations/0007-perch-session-files.mjs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 scripts/migrations/0007-perch-session-files.mjs diff --git a/scripts/migrations/0007-perch-session-files.mjs b/scripts/migrations/0007-perch-session-files.mjs new file mode 100644 index 00000000..75c77127 --- /dev/null +++ b/scripts/migrations/0007-perch-session-files.mjs @@ -0,0 +1,48 @@ +// scripts/migrations/0007-perch-session-files.mjs +// +// perch_session_files (Perch PR-E, audit item 12): the durable card-history +// rail for files an agent sends to a perch chat via send_user_file's +// crow-file: relay. A NEW TABLE carries no SCHEMA_GENERATION bump by design +// (a bump re-runs every DROP/CREATE against live DBs — this rail has no +// reason to ride it), which is exactly the 2026-09-12 convergence gap 0005 +// and 0006 exist for: a co-hosted instance on a shared checkout converges +// onto new code on its OWN restart, its boot guard skips init-db (generation +// matches), and code that INSERTs into a table nobody created 500s. This rail +// creates the table independently, byte-identical to init-db.js's own CREATE +// body (one shape, two rails), so either rail running first wins and both +// are idempotent. +// +// Idempotent, never destructive: CREATE TABLE/INDEX IF NOT EXISTS only. +import Database from "better-sqlite3"; + +export const id = "0007-perch-session-files"; + +const DDL = [ + `CREATE TABLE IF NOT EXISTS perch_session_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + bot_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + name TEXT NOT NULL, + stored TEXT, + mime TEXT NOT NULL DEFAULT 'application/octet-stream', + size INTEGER NOT NULL DEFAULT 0, + caption TEXT NOT NULL DEFAULT '', + servable INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + `CREATE INDEX IF NOT EXISTS idx_perch_session_files_thread + ON perch_session_files (bot_id, thread_id)`, +]; + +export function run({ dbPath, log = () => {} }) { + const db = new Database(dbPath); + db.pragma("busy_timeout = 10000"); + try { + const had = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='perch_session_files'").get(); + for (const sql of DDL) db.prepare(sql).run(); + log(` perch_session_files: ${had ? "no-op" : "created"}`); + } finally { + db.close(); + } + return { applied: true, results: ["table"] }; +}