diff --git a/README.md b/README.md index 4f6f5c6..21fa8b6 100644 --- a/README.md +++ b/README.md @@ -96,9 +96,13 @@ bin/case up CASE_LOCAL=1 CASE_URL=http://127.0.0.1:8787 node web/web-ui/serve.mjs ``` -Drive stores thread screenshots under `~/.case/drive/shots` (Compose: the -`ui-data` volume via `CASE_HOME=/data`). `CASE_TURN_TOKENS` (default 2M) caps -one turn's cumulative input tokens. Mid-turn messages go to `/api/chat/steer`. +Drive stores thread screenshots under `~/.case/drive/shots` and chat +attachments under `~/.case/drive/inbox` (Compose: the `ui-data` volume via +`CASE_HOME=/data`). Those files persist after a thread is deleted — remove +the directory or volume if you need them gone. `CASE_TURN_TOKENS` (default 2M) +caps one turn's cumulative input tokens. Mid-turn messages go to +`/api/chat/steer`. Attach files from the plus menu; they stay on the Drive +host and are never copied onto the computer. ### More knobs diff --git a/SECURITY.md b/SECURITY.md index fd8c911..1021094 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -17,9 +17,14 @@ Case holds logins. These are promises, with code you can read. `127.0.0.1:4174`. Set `CASE_TOKEN` before exposing those ports. - **Audit log** (`~/.case/audit/.jsonl`): one line per API call; request bodies that can carry secrets are redacted; response bodies are never logged. -- **Drive screenshots persist on disk** under `~/.case/drive/shots` (Compose: - `ui-data` via `CASE_HOME=/data`). They are content-addressed and kept until - you delete the files or the volume. Deleting a thread does not erase them. +- **Drive screenshots and chat attachments persist on disk** under + `~/.case/drive/shots` and `~/.case/drive/inbox` (Compose: `ui-data` via + `CASE_HOME=/data`). They are content-addressed and kept until you delete the + files or the volume. Deleting a thread does not erase them. Treat + `~/.case/drive` / `ui-data` as sensitive chat material. Attachments never + copy onto the computer; the model reads them from Drive. Max 4 files per + turn, 5MB each; allowed types are PNG/JPEG/GIF/WebP, PDF, and text (including + JSON, JS, XML). ## Self-host trust model diff --git a/web/web-ui/case-tools.mjs b/web/web-ui/case-tools.mjs index 03684d9..51a747c 100644 --- a/web/web-ui/case-tools.mjs +++ b/web/web-ui/case-tools.mjs @@ -285,6 +285,11 @@ function openaiPartToAnthropic(c) { if (!m) return null; return { type: 'image', source: { type: 'base64', media_type: m[1], data: m[2] } }; } + if (c?.type === 'input_file') { + const m = /^data:application\/pdf;base64,(.+)$/i.exec(c.file_data || ''); + if (!m) return c.filename ? { type: 'text', text: '[' + c.filename + ']' } : null; + return { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: m[1] } }; + } return null; } @@ -425,6 +430,7 @@ export async function anthropicToolLoop({ tools: antTools, thinking: anthropicThinkingFor(messages), output_config: { effort: antEffort }, + cache_control: { type: 'ephemeral' }, }; let text = ''; let finished = false; diff --git a/web/web-ui/index.html b/web/web-ui/index.html index ceaee89..1d3e161 100644 --- a/web/web-ui/index.html +++ b/web/web-ui/index.html @@ -300,8 +300,8 @@ } .ask textarea::placeholder{color:var(--faint)} /* narrow panel: squeeze the textarea, never the SEND button */ -.qline{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 8px} -.qline[hidden]{display:none} +.qline,.aline{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 8px} +.qline[hidden],.aline[hidden]{display:none} .qchip{display:inline-flex;align-items:center;gap:6px;max-width:100%; font-family:var(--mono);font-size:10.5px;letter-spacing:.05em;color:var(--faint); border:1px dashed var(--ink);padding:4px 6px 4px 8px} @@ -546,9 +546,13 @@ + +
@@ -1217,27 +1221,53 @@ this a second submit 409s server-side and its cleanup hides the STOP button for the turn still running. */ const promptQ=[]; +const asPrompt=p=>typeof p==='string'?{text:p,files:[]}:{text:p&&p.text||'',files:p&&p.files||[]}; const paintQ=()=>{ const ql=$('qline'); ql.hidden=!promptQ.length; ql.innerHTML=''; promptQ.forEach((p,i)=>{ + const item=asPrompt(p); const chip=document.createElement('span'); chip.className='qchip'; chip.innerHTML=''; - chip.querySelector('.qtxt').textContent=p; + chip.querySelector('.qtxt').textContent=item.text||((item.files[0]&&item.files[0].name)||'queued'); chip.querySelector('.qx').addEventListener('click',()=>{promptQ.splice(i,1);paintQ();}); ql.appendChild(chip); }); }; +const pending=[]; +const ATTACH_EXTS='png,jpg,jpeg,gif,webp,txt,md,csv,json,html,htm,js,mjs,css,py,sh,yml,yaml,toml,log,xml,pdf'.split(','); +const paintAline=()=>{ + const al=$('aline'); + al.hidden=!pending.length; + al.innerHTML=''; + pending.forEach((f,i)=>{ + const chip=document.createElement('span'); + chip.className='qchip'; + chip.innerHTML=''; + chip.querySelector('.qtxt').textContent=f.name; + chip.querySelector('.qx').addEventListener('click',()=>{pending.splice(i,1);paintAline();}); + al.appendChild(chip); + }); +}; +const youLine=(t,files)=>{ + const names=(files||[]).map(f=>f.name||f).filter(Boolean); + return [t,...names].filter(Boolean).join('\n'); +}; $('go').addEventListener('submit',e=>{ e.preventDefault(); const t=q.value.trim(); - if(!t)return; + const files=pending.slice(); + if(!t&&!files.length)return; if(!currentKey()){openKey();return;} q.value='';q.style.height='auto'; - if(chatCtl){steerPrompt(t);return;} - sendPrompt(t); + pending.length=0;paintAline(); + if(chatCtl){ + if(files.length){promptQ.push({text:t,files});paintQ();return;} + steerPrompt(t);return; + } + sendPrompt(t,files); }); /* Mid-turn message: try to inject into the running turn; fall back to the local queue if the turn just ended (409) or the server is unreachable. */ @@ -1255,13 +1285,16 @@ stick(true); }catch{promptQ.push(t);paintQ();} } -async function sendPrompt(t){ +async function sendPrompt(t,files){ + if(t&&typeof t==='object'&&!Array.isArray(t)){files=t.files||[];t=t.text||'';} + files=files||[]; const es=$('emptyState');if(es)es.remove(); /* the task exists the moment you hit enter, born on the computer you are sat at */ if(!activeTid){ const agentId=comp&&comp.id||''; activeTid='__pending'; - threads.unshift({id:'__pending',title:t.replace(/\s+/g,' ').slice(0,72),agent:agentId,updated:Date.now(),pending:true}); + const title=(t||((files[0]&&files[0].name)||'file')).replace(/\s+/g,' ').slice(0,72); + threads.unshift({id:'__pending',title,agent:agentId,updated:Date.now(),pending:true}); paint(); const born=$('navlist').querySelector('.thread[data-t="__pending"]'); if(born)born.classList.add('born'); @@ -1273,7 +1306,7 @@ const art=document.createElement('article'); art.className='turn you'; art.innerHTML='
YOU

'; - art.querySelector('p').textContent=t; + art.querySelector('p').textContent=youLine(t,files); inner.appendChild(art); const reply=document.createElement('article'); reply.className='turn'; @@ -1317,6 +1350,17 @@ chatCtl=new AbortController(); $('stopBtn').hidden=false; try{ + const uploaded=[]; + for(const f of files){ + const ar=await fetch('/api/attach',{ + method:'POST',signal:chatCtl.signal, + headers:{'x-filename':f.name,'content-type':f.type||'application/octet-stream'}, + body:f + }); + const aj=await ar.json().catch(()=>({})); + if(!ar.ok||!aj.id)throw new Error(aj.error||('attach failed: '+f.name)); + uploaded.push({id:aj.id,name:aj.name||f.name}); + } const r=await fetch('/api/chat',{ method:'POST', signal:chatCtl.signal, @@ -1324,7 +1368,7 @@ 'content-type':'application/json', ...(provider==='anthropic'?{'x-anthropic-key':anthropicKey}:{'x-openai-key':openaiKey}) }, - body:JSON.stringify({model,effort,input:t,computer_id:comp&&comp.id||'',thread_id:activeTid==='__pending'?'':activeTid}) + body:JSON.stringify({model,effort,input:t,computer_id:comp&&comp.id||'',thread_id:activeTid==='__pending'?'':activeTid,files:uploaded}) }); const ctype=r.headers.get('content-type')||''; if(!ctype.includes('ndjson')){ @@ -1405,6 +1449,23 @@ document.addEventListener('click',e=>{ if(!e.target.closest('.ask')){$('plusPop').hidden=true;$('plusBtn').setAttribute('aria-expanded','false');} }); +$('attachStart').addEventListener('click',()=>{ + $('plusPop').hidden=true; + $('plusBtn').setAttribute('aria-expanded','false'); + $('attachPick').click(); +}); +$('attachPick').addEventListener('change',()=>{ + const pick=$('attachPick'); + for(const f of pick.files||[]){ + if(pending.length>=4)break; + const ext=(f.name.split('.').pop()||'').toLowerCase(); + if(!ATTACH_EXTS.includes(ext)){q.placeholder=f.name+' is not an allowed type';setTimeout(()=>paint([comp].filter(Boolean)),2500);continue;} + if(f.size>5*1024*1024){q.placeholder=f.name+' is over 5 MB';setTimeout(()=>paint([comp].filter(Boolean)),2500);continue;} + pending.push(f); + } + pick.value=''; + paintAline(); +}); $('teachStart').addEventListener('click',()=>{ $('plusPop').hidden=true; if(teach.on)return; diff --git a/web/web-ui/serve.mjs b/web/web-ui/serve.mjs index 3fe0913..ce62320 100644 --- a/web/web-ui/serve.mjs +++ b/web/web-ui/serve.mjs @@ -603,13 +603,16 @@ export function threadTurns(items) { for (const it of items || []) { if (it.shot) continue; // screenshot attachments are model-only if (it.role === 'user') { + const names = (it.attaches || []).map((a) => a.name).filter(Boolean); + let text = ''; if (Array.isArray(it.content)) { - const text = it.content.map((c) => (typeof c === 'string' ? c : c.text || c.input_text || '')).filter(Boolean).join('\n'); - if (!text) continue; // screenshot attachments are model-only - turns.push({ who: 'you', text }); - } else { - turns.push({ who: 'you', text: String(it.content) }); + text = it.content.map((c) => (typeof c === 'string' ? c : c.text || c.input_text || '')).filter(Boolean).join('\n'); + } else if (it.content != null) { + text = String(it.content); } + const shown = [text, ...names].filter(Boolean).join('\n'); + if (!shown) continue; // screenshot attachments are model-only + turns.push({ who: 'you', text: shown }); } else if (it.type === 'function_call') turns.push({ who: 'tool', name: it.name, args: String(it.arguments || '').slice(0, 400) }); else if (it.type === 'message' && it.role === 'assistant') turns.push({ who: 'agent', text: (it.content || []).map((c) => c.text || '').join('') }); @@ -690,6 +693,134 @@ export function hydrateShots(items, dir = shotsDir()) { }); } +export const ATTACH_MAX = 5 * 1024 * 1024; +export const ATTACH_MAX_N = 4; + +export function inboxDir(home = HOME) { + return path.join(home, 'drive', 'inbox'); +} + +export function attachKind(mime, name = '') { + const m = String(mime || mimeFor(name) || '').split(';')[0].trim().toLowerCase(); + if (m === 'image/png' || m === 'image/jpeg' || m === 'image/gif' || m === 'image/webp') return 'image'; + if (m === 'application/pdf') return 'pdf'; + if (m.startsWith('text/') || m === 'application/json' || m === 'application/javascript' + || m === 'text/javascript' || m === 'application/xml') return 'text'; + return ''; +} + +export function safeAttachName(name) { + const base = path.basename(String(name || 'file')).replace(/[^\w.\-]+/g, '_').slice(0, 80); + return base || 'file'; +} + +export function stashAttach(buf, name, mime, dir = inboxDir()) { + const kind = attachKind(mime, name); + if (!kind) { + const err = new Error('file type not allowed'); + err.status = 400; + throw err; + } + if (!buf || !buf.length) { + const err = new Error('empty file'); + err.status = 400; + throw err; + } + if (buf.length > ATTACH_MAX) { + const err = new Error('file too large'); + err.status = 413; + throw err; + } + const base = safeAttachName(name); + const h = crypto.createHash('sha1').update(buf).digest('hex'); + const id = h + '-' + base; + const file = path.join(dir, id); + try { fs.mkdirSync(dir, { recursive: true }); } catch { /* exists */ } + try { + if (!fs.existsSync(file)) fs.writeFileSync(file, buf); + } catch { + const err = new Error('could not store file'); + err.status = 500; + throw err; + } + if (!fs.existsSync(file)) { + const err = new Error('could not store file'); + err.status = 500; + throw err; + } + const stored = (mimeFor(base).split(';')[0] || mime || '').trim(); + return { id, name: base, mime: stored, path: file }; +} + +export function resolveAttach(id, dir = inboxDir()) { + const raw = String(id || ''); + const base = path.basename(raw); + if (!base || base !== raw || base === '.' || base === '..') return null; + const root = path.resolve(dir) + path.sep; + const abs = path.resolve(dir, base); + if (!abs.startsWith(root) || !fs.existsSync(abs)) return null; + const name = base.replace(/^[0-9a-f]{40}-/, '') || base; + return { id: base, path: abs, name, mime: mimeFor(name).split(';')[0] }; +} + +function hydrateOneAttach(a, dir) { + const name = a?.name || 'file'; + const note = { type: 'input_text', text: '[' + name + ']' }; + const abs = path.resolve(String(a?.path || '')); + const root = path.resolve(dir) + path.sep; + if (!abs.startsWith(root) || !fs.existsSync(abs)) return [note]; + const mime = String(a.mime || mimeFor(name)).split(';')[0].trim(); + const kind = attachKind(mime, name); + try { + const buf = fs.readFileSync(abs); + if (kind === 'image') { + return [{ + type: 'input_image', detail: 'high', + image_url: 'data:' + mime + ';base64,' + buf.toString('base64'), + }]; + } + if (kind === 'pdf') { + return [{ + type: 'input_file', + filename: name, + file_data: 'data:application/pdf;base64,' + buf.toString('base64'), + }]; + } + if (kind === 'text') { + return [{ type: 'input_text', text: name + '\n' + clip(buf.toString('utf8'), 32000) }]; + } + } catch { /* note */ } + return [note]; +} + +export function hydrateAttaches(items, dir = inboxDir()) { + return (items || []).map((it) => { + if (!it?.attaches?.length) return it; + const parts = []; + if (typeof it.content === 'string' && it.content) { + parts.push({ type: 'input_text', text: it.content }); + } else if (Array.isArray(it.content)) { + for (const c of it.content) parts.push(c); + } + for (const a of it.attaches) parts.push(...hydrateOneAttach(a, dir)); + return { role: 'user', content: parts }; + }); +} + +async function attach(req, res) { + const name = safeAttachName(req.headers['x-filename']); + const mime = mimeFor(name); + if (!attachKind(mime, name)) return json(res, 400, { error: 'file type not allowed' }); + const buf = await readBody(req, res, ATTACH_MAX); + if (!buf) return; + try { + const rec = stashAttach(buf, name, mime); + return json(res, 200, { id: rec.id, name: rec.name, mime: rec.mime }); + } catch (err) { + return json(res, err.status || 400, { error: err.message || 'attach failed' }); + } +} + export function migrateShots(items, dir = shotsDir()) { let changed = false; const next = (items || []).map((it) => { @@ -759,9 +890,16 @@ async function chat(req, res) { const model = resolveChatModel(body.model, auth.provider); const effort = ['none', 'low', 'medium', 'high', 'xhigh', 'max'].includes(body.effort) ? body.effort : 'medium'; const inputText = String(body.input || '').slice(0, 32000); - if (!inputText) return send(res, 400, 'empty input'); + const fileIds = Array.isArray(body.files) ? body.files.slice(0, ATTACH_MAX_N) : []; + const attaches = []; + for (const ref of fileIds) { + const rec = resolveAttach(typeof ref === 'string' ? ref : ref?.id); + if (!rec) return send(res, 400, 'attachment not found'); + attaches.push({ path: rec.path, name: rec.name, mime: rec.mime }); + } + if (!inputText && !attaches.length) return send(res, 400, 'empty input'); const picked = String(body.computer_id || ''); - const thread = THREADS.get(String(body.thread_id || '')) || newThread(inputText, picked); + const thread = THREADS.get(String(body.thread_id || '')) || newThread(inputText || attaches[0].name, picked); if (CHAT_BUSY.has(thread.id)) return json(res, 409, { error: 'this thread is still running a turn' }); CHAT_BUSY.add(thread.id); try { @@ -814,10 +952,12 @@ async function chat(req, res) { // A steer accepted in the turn's last round missed every drain; deliver it // ahead of the new prompt so nothing the user typed is lost. pushSteerItems(hist.items, takeSteers(thread.id)); - hist.items.push({ role: 'user', content: inputText }); + const userItem = { role: 'user', content: inputText }; + if (attaches.length) userItem.attaches = attaches; + hist.items.push(userItem); try { if (auth.provider === 'anthropic') { - const messages = histToAnthropicMessages(hydrateShots(hist.items), { media: true }); + const messages = histToAnthropicMessages(hydrateShots(hydrateAttaches(hist.items)), { media: true }); const { text: out, finished, spend, overBudget } = await anthropicToolLoop({ key: auth.key, model, @@ -888,7 +1028,7 @@ async function chat(req, res) { if (gone.signal.aborted) rc.abort(); try { const params = { - model, input: [dev, ...hydrateShots(hist.items)], tools: ALL_TOOLS, + model, input: [dev, ...hydrateShots(hydrateAttaches(hist.items))], tools: ALL_TOOLS, reasoning: { effort, summary }, stream: true, store: false, // The loop is append-only: every round re-sends [dev, ...items], which is // exactly the shape the prefix cache wants. A stable key is required for @@ -1018,7 +1158,12 @@ async function chat(req, res) { histTrim(hist); thread.updated = Date.now(); saveThreads(); - emit({ type: 'done', text, computer_id: id, thread_id: thread.id }); + const eff = Math.round((spend.in - spend.cached) + 0.1 * spend.cached); + console.log(`drive turn ${thread.id}: in=${spend.in} cached=${spend.cached}` + + ` (${spend.in ? Math.round((100 * spend.cached) / spend.in) : 0}%)` + + ` eff=${eff} out=${spend.out} rounds=${i}`); + spend.eff = eff; + emit({ type: 'done', text, computer_id: id, thread_id: thread.id, spend, rounds: i }); } catch (err) { // Keep the turn even on provider errors: tools already ran, that work is // real. histCloseOpenCalls synthesizes outputs for any dangling @@ -1131,6 +1276,7 @@ export const server = http.createServer(async (req, res) => { if (req.method === 'GET' && p === '/api/file') return fsFile(res, url); if (req.method === 'POST' && p === '/api/chat') return chat(req, res); if (req.method === 'POST' && p === '/api/chat/steer') return steer(req, res); + if (req.method === 'POST' && p === '/api/attach') return attach(req, res); if (req.method === 'POST' && p === '/api/teach-tick') { const id = await cid(); if (!id) return json(res, 409, { error: 'no computer' }); diff --git a/web/web-ui/test_serve.mjs b/web/web-ui/test_serve.mjs index e75b3fb..470d1da 100644 --- a/web/web-ui/test_serve.mjs +++ b/web/web-ui/test_serve.mjs @@ -7,7 +7,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { shq, pathOk, parseFind, mimeFor, histTrim, histCloseOpenCalls, normHost, threadTurns, parseCaseUrl, liveCid, liveDestPath, livePathHasDotDot, tokenMatches, liveTarget, extraPlan, isLocalMode, pageFile, clip, snapshotElide, stashShot, hydrateShots, migrateShots } from './serve.mjs'; +import { shq, pathOk, parseFind, mimeFor, histTrim, histCloseOpenCalls, normHost, threadTurns, parseCaseUrl, liveCid, liveDestPath, livePathHasDotDot, tokenMatches, liveTarget, extraPlan, isLocalMode, pageFile, clip, snapshotElide, stashShot, hydrateShots, migrateShots, stashAttach, resolveAttach, hydrateAttaches, attachKind, ATTACH_MAX } from './serve.mjs'; import { CASE_TOOLS, chatAuth, resolveChatModel, openaiToolsToAnthropic, newAnthropicStreamCtx, anthropicEventToNdjson, tracesFromAnthropicMessage, @@ -18,6 +18,8 @@ const html = fs.readFileSync(fileURLToPath(new URL('./index.html', import.meta.u assert.match(html, /x-anthropic-key/); assert.match(html, /ANTHROPIC KEY/); assert.match(html, /claude-sonnet-4-6/); +assert.match(html, /id="attachStart"/); +assert.match(html, /id="attachPick"/); // threadTurns: reopening a thread shows text + tool calls; outputs and reasoning stay server-side const view = threadTurns([ @@ -30,6 +32,12 @@ const view = threadTurns([ assert.deepEqual(view.map((t) => t.who), ['you', 'tool', 'agent']); assert.equal(view[1].name, 'computer_snapshot'); assert.ok(!JSON.stringify(view).includes('SECRET')); // outputs never reach the reopen view +assert.deepEqual(threadTurns([{ role: 'user', shot: '/tmp/x.png', content: [{ type: 'input_text', text: '[screenshot]' }] }]), [], + 'screenshots stay model-only on reopen'); +assert.deepEqual(threadTurns([{ role: 'user', content: 'look', attaches: [{ name: 'invoice.pdf' }] }]), + [{ who: 'you', text: 'look\ninvoice.pdf' }]); +assert.deepEqual(threadTurns([{ role: 'user', content: '', attaches: [{ name: 'notes.md' }] }]), + [{ who: 'you', text: 'notes.md' }]); // normHost: bare lowercase host or nothing — creds domains must never carry paths/creds assert.equal(normHost('https://Mail.Google.com/mail/u/0'), 'mail.google.com'); @@ -328,6 +336,12 @@ assert.equal(pageFile('/deploy.html'), '/deploy.html'); assert.match(chatFn, /if \(!stopped\(\)\) emit\(\{ type: 'error'/); assert.match(html, /\/api\/chat\/steer/); assert.match(html, /steerPrompt/); + assert.match(chatFn, /eff=\$\{eff\}/, 'turn log reports billed tokens, not nominal'); + assert.match(serveSrc, /p === '\/api\/attach'/, 'user files land on disk, not in the chat body'); + assert.match(fs.readFileSync(fileURLToPath(new URL('./case-tools.mjs', import.meta.url)), 'utf8'), + /cache_control: \{ type: 'ephemeral' \}/, 'Anthropic path requests prompt cache'); + assert.match(chatFn, /hydrateShots\(hydrateAttaches\(hist\.items\)\)/); + assert.match(chatFn, /attachment not found/, 'a missing file is an error, not a silent drop'); assert.ok(!/truncation:\s*['"]auto['"]/.test(chatFn), 'no truncation:auto'); assert.ok(!/compactHistory|SUMMARIZE_PROMPT|CASE_COMPACT_AT/.test(serveSrc), 'no compaction'); } @@ -383,6 +397,60 @@ assert.equal(pageFile('/deploy.html'), '/deploy.html'); fs.rmSync(dir, { recursive: true, force: true }); } +{ + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'case-inbox-')); + const pngB64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + const png = Buffer.from(pngB64, 'base64'); + assert.equal(attachKind('image/png'), 'image'); + assert.equal(attachKind('application/octet-stream', 'x.exe'), ''); + assert.throws(() => stashAttach(Buffer.from('MZ'), 'bad.exe', 'application/octet-stream', dir), /not allowed/); + assert.throws(() => stashAttach(Buffer.alloc(ATTACH_MAX + 1), 'big.txt', 'text/plain', dir), /too large/); + const img = stashAttach(png, 'dot.png', 'image/png', dir); + assert.ok(fs.existsSync(img.path)); + assert.ok(JSON.stringify(img).length < 400, 'the record is a pointer'); + const notes = stashAttach(Buffer.from('hello notes', 'utf8'), 'notes.md', 'text/plain', dir); + const dotted = stashAttach(Buffer.from('final report', 'utf8'), 'report..final.md', 'text/plain', dir); + assert.ok(dotted.name.includes('..'), 'dots in the name survive'); + assert.ok(resolveAttach(dotted.id, dir), 'an id with .. in the filename still resolves'); + assert.equal(resolveAttach('../etc/passwd', dir), null); + assert.equal(resolveAttach('..', dir), null); + const blocker = path.join(os.tmpdir(), 'case-inbox-not-a-dir'); + fs.writeFileSync(blocker, 'x'); + assert.throws(() => stashAttach(Buffer.from('hi'), 'notes.md', 'text/plain', blocker), /could not store/); + fs.rmSync(blocker); + const hyd = hydrateAttaches([ + { role: 'user', content: 'see these', attaches: [ + { path: img.path, name: img.name, mime: img.mime }, + { path: notes.path, name: notes.name, mime: notes.mime }, + ] }, + ], dir); + assert.equal(hyd[0].content[0].type, 'input_text'); + assert.equal(hyd[0].content[0].text, 'see these'); + assert.equal(hyd[0].content[1].type, 'input_image'); + assert.ok(hyd[0].content[1].image_url.startsWith('data:image/png;base64,')); + assert.equal(hyd[0].content[2].type, 'input_text'); + assert.match(hyd[0].content[2].text, /notes\.md/); + assert.match(hyd[0].content[2].text, /hello notes/); + const missing = hydrateAttaches([{ + role: 'user', content: '', + attaches: [{ path: path.join(dir, 'nope.md'), name: 'gone.md', mime: 'text/plain' }], + }], dir); + assert.equal(missing[0].content[0].text, '[gone.md]'); + const outside = hydrateAttaches([{ + role: 'user', content: '', + attaches: [{ path: '/etc/passwd', name: 'passwd', mime: 'text/plain' }], + }], dir); + assert.equal(outside[0].content[0].text, '[passwd]', 'paths outside the inbox are refused'); + const msgs = histToAnthropicMessages(hyd, { media: true }); + assert.equal(msgs[0].role, 'user'); + assert.ok(Array.isArray(msgs[0].content)); + assert.equal(msgs[0].content.find((p) => p.type === 'image').source.media_type, 'image/png'); + const silent = histToAnthropicMessages(hyd); + assert.equal(typeof silent[0].content, 'string'); + assert.ok(!JSON.stringify(silent).includes('image')); + fs.rmSync(dir, { recursive: true, force: true }); +} + { let n = 0; const out = await withRateRetry(async () => {