Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ CASE_MAX_RAM_MB=
# Drive talks to cased on loopback / the compose network. No SSH.
CASE_LOCAL=1

# Drive home: threads.json, drive/shots (and later drive/inbox). Compose sets
# CASE_HOME=/data so they share the ui-data volume. Unset = ~/.case.
# CASE_HOME=

# Per-turn input-token ceiling for Drive chat. Default 2000000.
# CASE_TURN_TOKENS=2000000

# Desktop resolution for new/woken computers (WxH or WxHxDEPTH).
# DESK_RESOLUTION=1280x800x24

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ 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`.

### More knobs

Phone notifications for 2FA/approvals (ntfy), CAPTCHA auto-solve, scheduled
Expand Down
3 changes: 3 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ 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/<date>.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.

## Self-host trust model

Expand Down
1 change: 1 addition & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ services:
CASE_BIND: "0.0.0.0"
CASE_TOKEN: ${CASE_TOKEN:-}
CASE_THREADS: /data/threads.json
CASE_HOME: /data
CASE_LOCAL: "1"
PORT: "4174"
volumes:
Expand Down
160 changes: 137 additions & 23 deletions web/web-ui/case-tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,17 @@ export function caseToolPlan(name, args, cid) {
}
if (name === 'computer_exec') {
const t = Math.min(Math.max(Number(a.timeout_s) || 30, 1), 600);
return { method: 'POST', path: `/computers/${id}/exec?wake=true`, json: { command: a.command, timeout_s: t }, timeoutMs: (t + 30) * 1000, act: 'exec' };
// Spool the full output to a file inside the computer and show the model only the
// head of it. A noisy command otherwise lands whole in history and is re-sent on
// every later round of the turn; the file keeps the rest reachable with cat/grep/tail.
// The exit code has to be echoed explicitly — the redirect swallows it otherwise.
const log = `/tmp/case-out-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}.log`;
// Subshell, not a brace group: `exit 1` in the agent's command would otherwise
// kill this shell before the exit line is echoed, and `{ cmd\n; }` is a syntax error.
const command = `( ${a.command}\n) > ${log} 2>&1; __rc=$?; echo "exit=$__rc";`
+ ` wc -l < ${log} | tr -d ' ' | sed 's/^/lines=/'; head -c 1500 ${log};`
+ ` find /tmp -name 'case-out-*.log' -mmin +120 -delete 2>/dev/null || true`;
return { method: 'POST', path: `/computers/${id}/exec?wake=true`, json: { command, timeout_s: t }, timeoutMs: (t + 30) * 1000, act: 'exec', logPath: log };
}
return { error: `unknown tool ${name}` };
}
Expand Down Expand Up @@ -99,7 +109,11 @@ export async function runCaseTool(name, args, cid) {
if (r.status >= 400) {
return { ok: false, status: r.status, error: r.json || r.raw, act: plan.act };
}
return { ok: true, act: plan.act, result: r.json ?? { ok: true } };
const result = r.json ?? { ok: true };
if (plan.logPath && result && typeof result === 'object') {
result.full_output = `${plan.logPath} — only the first 1500 bytes are above; cat/grep/tail this file for the rest`;
}
return { ok: true, act: plan.act, result };
} catch (err) {
return { ok: false, error: err.message || 'cased unreachable', act: plan.act };
}
Expand Down Expand Up @@ -253,7 +267,28 @@ export function tracesFromAnthropicMessage(message) {
return { thinks, calls, texts };
}

export function histToAnthropicMessages(items) {
export function userContentText(content) {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return content == null ? '' : String(content);
return content.map((c) => {
if (typeof c === 'string') return c;
if (c?.type === 'input_text' || c?.type === 'text') return c.text || '';
return '';
}).filter(Boolean).join('\n');
}

function openaiPartToAnthropic(c) {
if (typeof c === 'string') return c ? { type: 'text', text: c } : null;
if (c?.type === 'input_text' || c?.type === 'text') return c.text ? { type: 'text', text: c.text } : null;
if (c?.type === 'input_image') {
const m = /^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i.exec(c.image_url || '');
if (!m) return null;
return { type: 'image', source: { type: 'base64', media_type: m[1], data: m[2] } };
}
return null;
}

export function histToAnthropicMessages(items, { media = false } = {}) {
const messages = [];
let pendingAssistant = [];
let pendingResults = [];
Expand All @@ -268,10 +303,22 @@ export function histToAnthropicMessages(items) {
pendingResults = [];
};
for (const it of items || []) {
if (it.role === 'user' && typeof it.content === 'string' && !it.type) {
if (it.shot) continue;
if (it.role === 'user' && it.content != null && !it.type) {
flushAssistant();
flushResults();
messages.push({ role: 'user', content: String(it.content) });
if (media && Array.isArray(it.content)) {
const parts = it.content.map(openaiPartToAnthropic).filter(Boolean);
if (parts.length) {
messages.push({
role: 'user',
content: parts.length === 1 && parts[0].type === 'text' ? parts[0].text : parts,
});
continue;
}
}
const text = userContentText(it.content);
if (text) messages.push({ role: 'user', content: text });
} else if (it.type === 'function_call') {
flushResults();
let input = {};
Expand Down Expand Up @@ -313,8 +360,59 @@ function clipJson(v, n = 8000) {
return s.length > n ? s.slice(0, n) + '…' : s;
}

/** Retry a provider round on rate limits (429/529), honoring the server's
* suggested wait ("try again in Xs" / retry-after), capped at 60s. History is
* only mutated after a round completes, so replaying a failed round is safe. */
function abortError(signal) {
if (signal?.reason instanceof Error) return signal.reason;
const err = new Error(signal?.reason ? String(signal.reason) : 'stopped by user');
err.name = 'AbortError';
return err;
}

function abortableDelay(ms, signal) {
if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
if (signal.aborted) return Promise.reject(abortError(signal));
return new Promise((resolve, reject) => {
const done = () => {
signal.removeEventListener('abort', stop);
resolve();
};
const stop = () => {
clearTimeout(timer);
signal.removeEventListener('abort', stop);
reject(abortError(signal));
};
const timer = setTimeout(done, ms);
signal.addEventListener('abort', stop, { once: true });
if (signal.aborted) stop();
});
}

export async function withRateRetry(fn, emit, tries = 5, signal) {
for (let a = 0; ; a++) {
if (signal?.aborted) throw abortError(signal);
try { return await fn(); }
catch (err) {
if (signal?.aborted) throw err;
const status = err?.status ?? err?.response?.status;
const limited = status === 429 || status === 529
|| /rate limit|overloaded/i.test(err?.message || '');
if (!limited || a >= tries - 1) throw err;
const m = /try again in ([\d.]+)s/i.exec(err?.message || '');
const hdr = Number(err?.headers?.['retry-after']
?? err?.response?.headers?.get?.('retry-after'));
let wait = m ? Number(m[1]) : Number.isFinite(hdr) && hdr > 0 ? hdr : 2 ** a;
wait = Math.min(Math.max(wait + 0.5, 1), 60);
emit?.({ type: 'think', text: `rate limited — retrying in ${Math.ceil(wait)}s` });
await abortableDelay(wait * 1000, signal);
}
}
}

export async function anthropicToolLoop({
key, model, effort, system, messages, tools, emit, rounds, runTool, actFor, stopped,
beforeRound, tokenBudget, signal,
}) {
const client = new Anthropic({ apiKey: key });
const antTools = openaiToolsToAnthropic(tools);
Expand All @@ -330,39 +428,55 @@ export async function anthropicToolLoop({
};
let text = '';
let finished = false;
const spend = { in: 0, cached: 0, out: 0 };
const overBudget = () => Number(tokenBudget) > 0 && spend.in > tokenBudget;
const round = async (p) => {
const ctx = newAnthropicStreamCtx();
let thinkDelta = false;
let textDelta = false;
const stream = client.messages.stream(p);
for await (const ev of stream) {
const nd = anthropicEventToNdjson(ev, ctx);
if (!nd) continue;
if (nd.type === 'think_delta') thinkDelta = true;
if (nd.type === 'text_delta') textDelta = true;
if (nd.type === 'tool') nd.act = actFor(nd.name, {}) || nd.name;
if (nd.type === 'tool_args') nd.act = actFor(nd.name || 'tool', nd.args || {}) || nd.name;
emit(nd);
}
const message = await stream.finalMessage();
const traces = tracesFromAnthropicMessage(message);
if (!thinkDelta) {
for (const t of traces.thinks) emit({ type: 'think', text: t });
const abort = () => stream.abort();
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) abort();
try {
for await (const ev of stream) {
const nd = anthropicEventToNdjson(ev, ctx);
if (!nd) continue;
if (nd.type === 'think_delta') thinkDelta = true;
if (nd.type === 'text_delta') textDelta = true;
if (nd.type === 'tool') nd.act = actFor(nd.name, {}) || nd.name;
if (nd.type === 'tool_args') nd.act = actFor(nd.name || 'tool', nd.args || {}) || nd.name;
emit(nd);
}
const message = await stream.finalMessage();
const traces = tracesFromAnthropicMessage(message);
if (!thinkDelta) {
for (const t of traces.thinks) emit({ type: 'think', text: t });
}
return { message, traces, textDelta };
} finally {
signal?.removeEventListener('abort', abort);
}
return { message, traces, textDelta };
};
for (let i = 0; i < rounds && !finished; i++) {
for (let i = 0; i < rounds && !finished && !overBudget(); i++) {
if (stopped?.()) break;
beforeRound?.(messages);
let result;
try {
result = await round(params);
result = await withRateRetry(() => round(params), emit, 5, signal);
} catch (err) {
if (signal?.aborted) throw err;
if (!params.output_config) throw err;
const rest = { ...params };
delete rest.output_config;
result = await round(rest);
result = await withRateRetry(() => round(rest), emit, 5, signal);
}
const { message, traces, textDelta } = result;
const u = message.usage || {};
spend.in += (u.input_tokens || 0) + (u.cache_read_input_tokens || 0)
+ (u.cache_creation_input_tokens || 0);
spend.cached += u.cache_read_input_tokens || 0;
spend.out += u.output_tokens || 0;
text = traces.texts.join('\n').trim();
if (!traces.calls.length) {
finished = true;
Expand Down Expand Up @@ -393,5 +507,5 @@ export async function anthropicToolLoop({
}
messages.push({ role: 'user', content: results });
}
return { text, finished };
return { text, finished, spend, overBudget: overBudget() };
}
54 changes: 51 additions & 3 deletions web/web-ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,14 @@
}
.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}
.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}
.qchip .qtxt{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:38ch}
.qchip .qx{border:0;background:transparent;cursor:pointer;color:var(--faint);font-size:12px;padding:0 2px}
.qchip .qx:hover{color:var(--ink)}
.ask{min-width:0}
.ask textarea{min-width:0}
.ask .btn{flex-shrink:0}
Expand Down Expand Up @@ -537,6 +545,7 @@
</button>
<div class="cfg-pop" id="cfgPop" hidden></div>
</div>
<div class="qline" id="qline" hidden></div>
<div class="ask">
<button type="button" class="plus" id="plusBtn" aria-haspopup="menu" aria-expanded="false" aria-label="More actions">+</button>
<div class="plus-pop" id="plusPop" hidden>
Expand Down Expand Up @@ -1204,11 +1213,49 @@ <h3 id="keyTitle" hidden>OPENAI KEY</h3>
let chatCtl=null;
$('stopBtn').addEventListener('click',()=>{if(chatCtl)chatCtl.abort();});

$('go').addEventListener('submit',async e=>{
/* Prompts typed mid-turn queue up; the next fires when the turn ends. Without
this a second submit 409s server-side and its cleanup hides the STOP button
for the turn still running. */
const promptQ=[];
const paintQ=()=>{
const ql=$('qline');
ql.hidden=!promptQ.length;
ql.innerHTML='';
promptQ.forEach((p,i)=>{
const chip=document.createElement('span');
chip.className='qchip';
chip.innerHTML='<span class="qtxt"></span><button type="button" class="qx" aria-label="Remove queued prompt">×</button>';
chip.querySelector('.qtxt').textContent=p;
chip.querySelector('.qx').addEventListener('click',()=>{promptQ.splice(i,1);paintQ();});
ql.appendChild(chip);
});
};
$('go').addEventListener('submit',e=>{
e.preventDefault();
const t=q.value.trim();
if(!t)return;
if(!currentKey()){openKey();return;}
q.value='';q.style.height='auto';
if(chatCtl){steerPrompt(t);return;}
sendPrompt(t);
});
/* 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. */
async function steerPrompt(t){
const tid=activeTid&&activeTid!=='__pending'?activeTid:'';
if(!tid){promptQ.push(t);paintQ();return;}
try{
const r=await fetch('/api/chat/steer',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({thread_id:tid,input:t})});
if(!r.ok){promptQ.push(t);paintQ();return;}
const art=document.createElement('article');
art.className='turn you';
art.innerHTML='<div class="k">YOU</div><p></p>';
art.querySelector('p').textContent=t;
inner.appendChild(art);
stick(true);
}catch{promptQ.push(t);paintQ();}
}
async function sendPrompt(t){
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){
Expand All @@ -1228,7 +1275,6 @@ <h3 id="keyTitle" hidden>OPENAI KEY</h3>
art.innerHTML='<div class="k">YOU</div><p></p>';
art.querySelector('p').textContent=t;
inner.appendChild(art);
q.value='';q.style.height='auto';
const reply=document.createElement('article');
reply.className='turn';
reply.innerHTML='<div class="k sys">'+esc(String(comp&&comp.name||'BOX').toUpperCase())+'</div>'
Expand Down Expand Up @@ -1324,6 +1370,7 @@ <h3 id="keyTitle" hidden>OPENAI KEY</h3>
glow(false);
if(ev.ok&&(row.dataset.name==='computer_exec'||ev.name==='computer_exec'))pulseFiles();
}
else if(ev.type==='steer'){/* injected mid-turn; bubble already rendered locally */}
else if(ev.type==='text_delta'&&ev.text){thinkBlock.open=false;addText(ev.text);}
else if(ev.type==='text'||(ev.type==='done'&&ev.text)){thinkBlock.open=false;setText(ev.text);if(ev.type==='done')glowOff();}
else if(ev.type==='done'){glowOff();}
Expand All @@ -1343,8 +1390,9 @@ <h3 id="keyTitle" hidden>OPENAI KEY</h3>
caret.remove();
if(activeTid==='__pending')activeTid=''; // stream died before the thread was born
refresh(); // state may have changed (wake on first tool); threads re-sync too
if(promptQ.length){const n=promptQ.shift();paintQ();sendPrompt(n);}
}
});
}

/* ---------- teach a task ---------- */
const teach={on:false,goal:'',events:[],lastHref:'',suppress:false,timer:null,gapNoted:false};
Expand Down
Loading