From 0f2fb53f615cf7de3e94a63392b427328883ee0b Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:28:25 +0530 Subject: [PATCH 01/46] Check Host and Origin on every Drive request and websocket. --- web/web-ui/serve.mjs | 46 +++++++++++++++++++++++++++++--------------- 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/web/web-ui/serve.mjs b/web/web-ui/serve.mjs index d048738..e0ca4d2 100644 --- a/web/web-ui/serve.mjs +++ b/web/web-ui/serve.mjs @@ -29,6 +29,21 @@ const DIR = path.dirname(fileURLToPath(import.meta.url)); const PORT = Number(process.env.PORT || 4174); const BIND = process.env.CASE_BIND || '127.0.0.1'; const TOKEN = (process.env.CASE_TOKEN || '').trim(); +const HOSTS = new Set(['127.0.0.1', 'localhost', '[::1]', 'ui', + ...(process.env.CASE_ALLOWED_HOSTS || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean), + ...((process.env.CASE_PUBLIC_HOST || '').trim() ? [process.env.CASE_PUBLIC_HOST.trim().toLowerCase()] : [])]); +export function hostOf(v) { + v = String(v || '').toLowerCase(); + return v.startsWith('[') ? v.slice(0, v.indexOf(']') + 1) : v.split(':')[0]; +} +// Host must be ours, and so must a present Origin: the two together stop DNS +// rebinding, cross-site form posts and cross-site websocket opens. +export function browserOk(req, hosts = HOSTS) { + if (!hosts.has(hostOf(req.headers.host))) return false; + const o = req.headers.origin; + if (!o) return true; + try { return hosts.has(hostOf(new URL(o).host)); } catch { return false; } +} export function parseCaseUrl(raw) { const u = new URL(String(raw || 'http://127.0.0.1:8787')); @@ -1518,6 +1533,21 @@ export function pageFile(p) { export const server = http.createServer(async (req, res) => { const url = new URL(req.url || '/', 'http://x'); const p = url.pathname; + if (!browserOk(req)) return json(res, 403, { error: 'unexpected Host or Origin' }); + if (TOKEN && req.method === 'GET' && url.searchParams.has('token') && tokenMatches(req)) { + res.writeHead(302, { + Location: p === '/' ? '/' : p, + 'Set-Cookie': `case_token=${encodeURIComponent(TOKEN)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`, + 'Cache-Control': 'no-store', + }); + return res.end(); + } + if (!tokenMatches(req)) { + if (p.startsWith('/api/') || p.startsWith('/live')) { + return json(res, 401, { error: 'unauthorized' }); + } + return send(res, 401, 'unauthorized — open with ?token=…'); + } if (p === '/api/health' && req.method === 'GET') { try { const h = await originHealth(); @@ -1532,20 +1562,6 @@ export const server = http.createServer(async (req, res) => { return json(res, 200, { ok: true, live: CASE.hostname, up: false, local: LOCAL, max_running: 0, running: 0 }); } } - if (TOKEN && url.searchParams.get('token') === TOKEN && req.method === 'GET') { - res.writeHead(302, { - Location: p === '/' ? '/' : p, - 'Set-Cookie': `case_token=${encodeURIComponent(TOKEN)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000`, - 'Cache-Control': 'no-store', - }); - return res.end(); - } - if (!tokenMatches(req)) { - if (p.startsWith('/api/') || p.startsWith('/live')) { - return json(res, 401, { error: 'unauthorized' }); - } - return send(res, 401, 'unauthorized — open with ?token=…'); - } try { if (req.method === 'GET' && p === '/api/computers') return computers(res); if (req.method === 'POST' && p === '/api/computers') return createComputer(res, req); @@ -1577,7 +1593,7 @@ export const server = http.createServer(async (req, res) => { send(res, 200, fs.readFileSync(abs), mimeFor(abs)); }); server.on('upgrade', (req, socket, head) => { - if (!tokenMatches(req)) { socket.destroy(); return; } + if (!browserOk(req) || !tokenMatches(req)) { socket.destroy(); return; } if ((req.url || '').startsWith('/live')) return vncWs(req, socket, head); socket.destroy(); }); From e3c33bdae54e5825f80b70559f292abaed63b8d3 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:28:55 +0530 Subject: [PATCH 02/46] Put desktops on their own network so only cased can reach them. --- compose.yaml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/compose.yaml b/compose.yaml index 084986e..09e7e47 100644 --- a/compose.yaml +++ b/compose.yaml @@ -8,12 +8,15 @@ # # API/MCP only (no Drive): docker compose up cased mcp --build # Optional share token: CASE_TOKEN=… in .env (required if you publish ports off localhost). +# Desktops sit on their own network (case-desks); only cased can reach them. name: case networks: case: name: case + desks: # desktops live here; only cased joins both + name: case-desks volumes: case-home: @@ -34,7 +37,7 @@ services: image: case-control:0.1 environment: CASE_HOME: /data - CASE_DOCKER_NETWORK: case + CASE_DOCKER_NETWORK: case-desks CASE_BIND: "0.0.0.0" CASE_IMAGE: ${CASE_IMAGE:-case-desk:0.1} CASE_TOKEN: ${CASE_TOKEN:-} @@ -52,6 +55,7 @@ services: CASE_NTFY_ANSWER_TOPIC: ${CASE_NTFY_ANSWER_TOPIC:-} CASE_NTFY_TOKEN: ${CASE_NTFY_TOKEN:-} CASE_PUBLIC_HOST: ${CASE_PUBLIC_HOST:-} + CASE_ALLOWED_HOSTS: ${CASE_ALLOWED_HOSTS:-} # Optional CAPTCHA auto-solve (see .env.example) CASE_DBC_AUTHTOKEN: ${CASE_DBC_AUTHTOKEN:-} CASE_DBC_USERNAME: ${CASE_DBC_USERNAME:-} @@ -61,7 +65,7 @@ services: - case-home:/data ports: - "127.0.0.1:8787:8787" - networks: [case] + networks: [case, desks] depends_on: desk-image: condition: service_completed_successfully @@ -104,7 +108,6 @@ services: image: case-ui:0.1 environment: CASE_URL: http://cased:8787/v1 - CASE_DOCKER_NETWORK: case CASE_BIND: "0.0.0.0" CASE_TOKEN: ${CASE_TOKEN:-} CASE_THREADS: /data/threads.json @@ -120,6 +123,8 @@ services: CASE_DRIVE_MODEL: ${CASE_DRIVE_MODEL:-} CASE_TELEGRAM_TOKEN: ${CASE_TELEGRAM_TOKEN:-} CASE_TELEGRAM_CHAT_ID: ${CASE_TELEGRAM_CHAT_ID:-} + CASE_PUBLIC_HOST: ${CASE_PUBLIC_HOST:-} + CASE_ALLOWED_HOSTS: ${CASE_ALLOWED_HOSTS:-} volumes: - ui-data:/data ports: From cfd82d4f608cc587b53d38aa30ebf56a601dfaf7 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:29:23 +0530 Subject: [PATCH 03/46] Proxy the live view through cased with an explicit upstream header list. --- web/web-ui/serve.mjs | 49 ++++++++++++++++++--------------------- web/web-ui/test_serve.mjs | 21 ++++++++++++----- 2 files changed, 38 insertions(+), 32 deletions(-) diff --git a/web/web-ui/serve.mjs b/web/web-ui/serve.mjs index e0ca4d2..2ecf3e1 100644 --- a/web/web-ui/serve.mjs +++ b/web/web-ui/serve.mjs @@ -5,8 +5,8 @@ * Serves Drive at / (index.html) and the computer deployer at /deploy. * * CASE_LOCAL (default on): 127.0.0.1 / compose `cased` — no SSH tunnel, /live - * proxies noVNC on the host port or docker network. CASE_LOCAL=0 is a no-op - * here (this process never tunnels); it only flips the health `local` flag. + * relays noVNC through cased. CASE_LOCAL=0 is a no-op here (this process never + * tunnels); it only flips the health `local` flag. * * OpenAI key arrives per-request in x-openai-key; Anthropic in x-anthropic-key; * never logged. @@ -103,15 +103,6 @@ export function tokenMatches(req, need = TOKEN) { return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(need)); } -const vncById = new Map(); -export function liveTarget(cid) { - if (!cid) return null; - if ((process.env.CASE_DOCKER_NETWORK || '').trim()) return { hostname: `case-${cid}`, port: 6080 }; - const port = vncById.get(cid); - if (port) return { hostname: '127.0.0.1', port }; - return null; -} - // ---------- small helpers (exported for tests) ---------- export function shq(s) { return "'" + String(s).replace(/'/g, "'\\''") + "'"; @@ -217,14 +208,11 @@ async function computers(res) { originHealth().catch(() => ({ json: null })), ]); if (r.status >= 400 || !r.json) return json(res, 502, { error: r.json?.error?.message || 'cased unreachable', up: false, local: LOCAL }); - const rows = (r.json.computers || []).map((c) => { - if (c.id && c.vnc_port) vncById.set(c.id, c.vnc_port); - return { - id: c.id, name: c.name, state: c.state === 'running' ? 'awake' : c.state, - credentials: c.credentials || [], pending_handoffs: c.pending_handoffs || 0, - cpus: c.resources?.cpus ?? null, ram_mb: c.resources?.ram_mb ?? null, - }; - }); + const rows = (r.json.computers || []).map((c) => ({ + id: c.id, name: c.name, state: c.state === 'running' ? 'awake' : c.state, + credentials: c.credentials || [], pending_handoffs: c.pending_handoffs || 0, + cpus: c.resources?.cpus ?? null, ram_mb: c.resources?.ram_mb ?? null, + })); const pick = rows.find((c) => c.state === 'awake') || rows[0]; if (pick) cachedCid = pick.id; const awake = ['awake', 'running', 'waking', 'creating']; @@ -1484,20 +1472,29 @@ export function startPhoneTelegram(env = process.env) { return true; } -// ---------- noVNC proxy (compose network or host-mapped vnc_port) ---------- +// ---------- live view (relayed by cased; only cased touches desktops) ---------- +const LIVE_PASS = ['accept', 'accept-language', 'user-agent', 'if-none-match', 'if-modified-since']; +const LIVE_WS_PASS = ['connection', 'upgrade', 'sec-websocket-key', 'sec-websocket-version', + 'sec-websocket-protocol', 'sec-websocket-extensions']; +// Allowlist, not {...req.headers}: the browser's Drive cookie/Authorization are +// Drive's credentials and must not ride upstream. +export function liveHeaders(req, ws = false, token = TOKEN) { + const out = { host: `${CASE.hostname}:${CASE.port}` }; + for (const k of ws ? [...LIVE_PASS, ...LIVE_WS_PASS] : LIVE_PASS) if (req.headers[k] != null) out[k] = req.headers[k]; + if (token) out.authorization = `Bearer ${token}`; + return out; +} function vncUpstream(req) { if (livePathHasDotDot(req.url)) return null; const destPath = liveDestPath(req.url); - if (destPath.includes('..')) return null; const cid = liveCid(new URL(req.url || '/', 'http://x').pathname) || cachedCid; - const t = liveTarget(cid); - if (!t) return null; - return { ...t, path: destPath, hostHeader: `${t.hostname}:${t.port}` }; + if (!cid) return null; + return { hostname: CASE.hostname, port: CASE.port, path: `/v1/computers/${encodeURIComponent(cid)}/live${destPath}` }; } function vncHttp(req, res) { const t = vncUpstream(req); if (!t) { res.writeHead(502).end('no computer / vnc'); return; } - const up = http.request({ hostname: t.hostname, port: t.port, path: t.path, method: req.method, headers: { ...req.headers, host: t.hostHeader } }, (upRes) => { + const up = http.request({ hostname: t.hostname, port: t.port, path: t.path, method: req.method, headers: liveHeaders(req) }, (upRes) => { res.writeHead(upRes.statusCode || 502, upRes.headers); upRes.pipe(res); }); @@ -1507,7 +1504,7 @@ function vncHttp(req, res) { function vncWs(req, socket, head) { const t = vncUpstream(req); if (!t) { socket.destroy(); return; } - const up = http.request({ hostname: t.hostname, port: t.port, path: t.path, method: 'GET', headers: { ...req.headers, host: t.hostHeader } }); + const up = http.request({ hostname: t.hostname, port: t.port, path: t.path, method: 'GET', headers: liveHeaders(req, true) }); up.on('upgrade', (upRes, upSocket, upHead) => { const lines = ['HTTP/1.1 101 Switching Protocols']; for (const [k, v] of Object.entries(upRes.headers)) lines.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`); diff --git a/web/web-ui/test_serve.mjs b/web/web-ui/test_serve.mjs index 549dcb1..199c6f9 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, stashAttach, resolveAttach, hydrateAttaches, attachKind, ATTACH_MAX, sseEvents } from './serve.mjs'; +import { shq, pathOk, parseFind, mimeFor, histTrim, histCloseOpenCalls, normHost, threadTurns, parseCaseUrl, liveCid, liveDestPath, livePathHasDotDot, tokenMatches, liveHeaders, hostOf, browserOk, extraPlan, isLocalMode, pageFile, clip, snapshotElide, stashShot, hydrateShots, migrateShots, stashAttach, resolveAttach, hydrateAttaches, attachKind, ATTACH_MAX, sseEvents } from './serve.mjs'; import { CASE_TOOLS, chatAuth, resolveChatModel, openaiToolsToAnthropic, newAnthropicStreamCtx, anthropicEventToNdjson, tracesFromAnthropicMessage, @@ -140,11 +140,20 @@ assert.ok(tokenMatches({ headers: { authorization: 'Bearer secret' }, url: '/' } assert.ok(tokenMatches({ headers: { cookie: 'case_token=secret' }, url: '/' }, 'secret')); assert.ok(tokenMatches({ headers: {}, url: '/?token=secret' }, 'secret')); -process.env.CASE_DOCKER_NETWORK = 'case'; -assert.deepEqual(liveTarget('c_abc12'), { hostname: 'case-c_abc12', port: 6080 }); -delete process.env.CASE_DOCKER_NETWORK; -assert.equal(liveTarget('c_abc12'), null); -assert.equal(liveTarget(''), null); +{ + const h = liveHeaders({ headers: { cookie: 'case_token=s', accept: '*/*' } }, false, 'tok'); + assert.equal(h.authorization, 'Bearer tok'); + assert.equal(h.cookie, undefined); + assert.equal(h.accept, '*/*'); +} + +assert.equal(hostOf('[::1]:4174'), '[::1]'); +assert.equal(hostOf('127.0.0.1:4174'), '127.0.0.1'); +assert.ok(browserOk({ headers: { host: '[::1]:4174' } })); +assert.ok(browserOk({ headers: { host: 'localhost:4174' } }), 'no Origin is a same-site navigation'); +assert.ok(browserOk({ headers: { host: 'localhost:4174', origin: 'http://127.0.0.1:4174' } })); +assert.ok(!browserOk({ headers: { host: 'localhost:4174', origin: 'https://evil.example' } })); +assert.ok(!browserOk({ headers: { host: 'evil.example' } }), 'DNS rebinding'); const loginPlan = extraPlan('computer_login', { credential: 'x.com', url: 'https://x.com' }, 'c_ab'); assert.equal(loginPlan.method, 'POST'); From 075d40ac08c099d29aba1d2ebe52b4707519acf3 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:30:02 +0530 Subject: [PATCH 04/46] Return structured cased errors to the model and call the skill secret check a lint. --- mcp/case_mcp.py | 18 ++++++++++++------ tests/test_mcp_http.py | 40 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/mcp/case_mcp.py b/mcp/case_mcp.py index 6676eef..5256ffa 100644 --- a/mcp/case_mcp.py +++ b/mcp/case_mcp.py @@ -54,7 +54,12 @@ def call(method, path, **kw): r = requests.request(method, BASE + path, timeout=kw.pop("timeout", 150), headers=_headers(), **kw) if r.status_code >= 400: - raise RuntimeError(r.text[:500]) + try: + e = r.json()["error"] + msg = f"{e['code']}: {e['message']}" + except (ValueError, KeyError, TypeError): + msg = f"cased returned {r.status_code}" + raise RuntimeError(msg) return r @@ -355,14 +360,15 @@ def computer_login(computer_id: str, credential: str, url: str, @mcp.tool() def computer_file_put(computer_id: str, path: str, content_b64: str) -> dict: - """Write a file on the computer (content is base64).""" + """Write a file on the computer (content is base64). Paths must be under /home/agent/.""" return call("PUT", f"/computers/{computer_id}/files", params={"path": path, "wake": "true"}, data=base64.b64decode(content_b64)).json() @mcp.tool() def computer_file_get(computer_id: str, path: str) -> dict: - """Read a file from the computer. Text files come back readable: + """Read a file from the computer. Paths must be under /home/agent/. + Text files come back readable: {encoding:"utf8", content, bytes}. Binary files come back as {encoding:"base64", content, bytes} — do not try to read base64 yourself; process binary files on the computer with computer_exec instead @@ -399,8 +405,8 @@ def skill_name_ok(name): def skill_content_risky(content): - """True when content carries something secret-shaped (key: value secrets, or a - 40+ char unbroken token). Vault names like 'credential: coupa' pass.""" + """True when content is obviously secret-shaped (key: value secrets, or a 40+ char + unbroken token) — a lint, not a guarantee. Vault names like 'credential: coupa' pass.""" m = _SKILL_RISKY_RE.search(content or "") return m.group(0)[:60] if m else None @@ -438,7 +444,7 @@ def case_skill(computer_id: str, action: str, name: str = "", content: str = "") - End with a "Done means" section: how to verify the task actually succeeded. - Logins: ONE step — computer_login(credential=) + auth_attempt_wait. NEVER write usernames, passwords, OTP codes, cookies or tokens into a skill; - save rejects secret-shaped content. + save refuses obviously secret-shaped content (a lint, not a guarantee). - On later runs where reality diverged: finish the task, then update the file and append a dated line to a `## Drift log` section — heal loudly. A new skill is a draft until a later run succeeds by following it.""" diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py index 351b98d..05dbb9b 100644 --- a/tests/test_mcp_http.py +++ b/tests/test_mcp_http.py @@ -1,10 +1,12 @@ # SPDX-License-Identifier: MIT -"""The remote door: case_mcp's HTTP mode must stay loopback-only and stateless, -and stdio must stay the default. No Docker, no network. +"""The remote door: case_mcp's HTTP mode defaults to loopback (compose overrides the +bind and publishes 127.0.0.1) and stays stateless, and stdio must stay the default. +No Docker, no network. Run: .venv/bin/python tests/test_mcp_http.py""" import importlib import os import sys +import types ROOT = os.path.join(os.path.dirname(__file__), "..") sys.path.insert(0, os.path.join(ROOT, "mcp")) @@ -22,7 +24,7 @@ def test_stdio_is_the_default(): assert _load().HTTP is False # unset env → every existing flow untouched -def test_http_mode_binds_loopback_only(): +def test_http_mode_defaults_to_loopback(): m = _load(CASE_MCP_HTTP="1", CASE_MCP_PORT="8899") assert m.HTTP is True assert m.mcp.settings.host == "127.0.0.1" # default; compose overrides CASE_MCP_BIND @@ -42,6 +44,38 @@ def test_http_app_serves_mcp_path(): assert "/mcp" in paths, paths +class _Resp: + def __init__(self, status_code, body): + self.status_code, self._body = status_code, body + + def json(self): + if isinstance(self._body, Exception): + raise self._body + return self._body + + +def _failed_call(body, status_code=500): + """call() against a >=400 response; returns (message, chained exception).""" + m = _load() + m.requests = types.SimpleNamespace(request=lambda *a, **kw: _Resp(status_code, body)) + try: + m.call("GET", "/computers") + except RuntimeError as e: + return str(e), e.__context__ + assert False, "call() must raise on a >=400 response" + + +def test_call_reports_the_cased_error(): + msg, _ = _failed_call({"error": {"code": "not_found", "message": "no such computer"}}) + assert msg == "not_found: no such computer", msg + + +def test_call_falls_back_to_the_status_unchained(): + msg, chained = _failed_call(ValueError("not json"), 502) + assert msg == "cased returned 502", msg + assert chained is None, chained # a chained decode error buries the status + + def test_no_credential_write_tool(): # security invariant: secrets enter via `case cred add` only, never a tool call m = _load() From ce54f0b7f3000c5d4779de270071d8277f0485b5 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:30:10 +0530 Subject: [PATCH 05/46] Serve files from the computer as downloads, never as pages. --- web/web-ui/index.html | 2 +- web/web-ui/serve.mjs | 9 ++++++++- web/web-ui/test_serve.mjs | 9 +++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/web/web-ui/index.html b/web/web-ui/index.html index 1d3e161..fd2fad8 100644 --- a/web/web-ui/index.html +++ b/web/web-ui/index.html @@ -1009,7 +1009,7 @@ if(f)openFile(f.dataset.file); }); const TEXT_RE=/\.(md|txt|json|js|mjs|ts|py|sh|ya?ml|toml|csv|log|html?|css|xml|svg|conf|cfg|ini|env|lock)$|^[^.]+$/i; -const IMG_RE=/\.(png|jpe?g|gif|webp|svg)$/i; +const IMG_RE=/\.(png|jpe?g|gif|webp)$/i; let fileGen=0; async function openFile(p){ const gen=++fileGen; diff --git a/web/web-ui/serve.mjs b/web/web-ui/serve.mjs index 2ecf3e1..484a3e6 100644 --- a/web/web-ui/serve.mjs +++ b/web/web-ui/serve.mjs @@ -285,7 +285,14 @@ async function fsFile(res, url) { return json(res, r.status, { error: msg }); } if (r.buf.length > FILE_CAP) return json(res, 413, { error: 'file over 8MB' }); - return send(res, 200, r.buf, mimeFor(p)); + const ext = path.extname(p).toLowerCase(); + const inline = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.pdf'].includes(ext); + res.writeHead(200, { + 'content-type': inline ? mimeFor(p) : 'application/octet-stream', + 'content-disposition': inline ? 'inline' : `attachment; filename="${path.basename(p).replace(/["\r\n]/g, '')}"`, + 'x-content-type-options': 'nosniff', 'cache-control': 'no-store', + }); + return res.end(r.buf); } catch (err) { return json(res, 502, { error: err.message || 'cased unreachable' }); } diff --git a/web/web-ui/test_serve.mjs b/web/web-ui/test_serve.mjs index 199c6f9..5034954 100644 --- a/web/web-ui/test_serve.mjs +++ b/web/web-ui/test_serve.mjs @@ -155,6 +155,15 @@ assert.ok(browserOk({ headers: { host: 'localhost:4174', origin: 'http://127.0.0 assert.ok(!browserOk({ headers: { host: 'localhost:4174', origin: 'https://evil.example' } })); assert.ok(!browserOk({ headers: { host: 'evil.example' } }), 'DNS rebinding'); +// A file off the computer is a download, never a page. +{ + const src = fs.readFileSync(fileURLToPath(new URL('./serve.mjs', import.meta.url)), 'utf8'); + const fn = src.slice(src.indexOf('async function fsFile('), src.indexOf('async function creds(')); + assert.match(fn, /'application\/octet-stream'/); + assert.match(fn, /'x-content-type-options': 'nosniff'/); + assert.ok(!/svg/.test(html.match(/const IMG_RE=[^\n]*/)[0]), 'svg previews as text, not as an image'); +} + const loginPlan = extraPlan('computer_login', { credential: 'x.com', url: 'https://x.com' }, 'c_ab'); assert.equal(loginPlan.method, 'POST'); assert.equal(loginPlan.rel, '/computers/c_ab/login?wake=true'); From 695ce6344ed05afa333cbeedbf6c4401cc527a8d Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:30:19 +0530 Subject: [PATCH 06/46] Require the desk token on the VNC websocket and exit if Xvfb never starts. --- image/start.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/image/start.sh b/image/start.sh index b51bdd6..accb9ab 100644 --- a/image/start.sh +++ b/image/start.sh @@ -47,11 +47,17 @@ Xvfb :0 -screen 0 "$RES" -nolisten tcp -fbdir /dev/shm & XVFB_PID=$! for _ in $(seq 1 100); do [ -e /tmp/.X11-unix/X0 ] && break; sleep 0.1; done +[ -e /tmp/.X11-unix/X0 ] || { echo "[start] Xvfb did not come up" >&2; exit 1; } -x11vnc -display :0 -forever -shared -nopw -quiet -bg +x11vnc -display :0 -localhost -forever -shared -nopw -quiet -bg +# On a shared network the only client is cased, which holds DESK_TOKEN. Host mode +# stays open: 6080 is loopback-only there and the /desk proxy door cannot add the header. +# -localhost on x11vnc matters too — without it a peer skips websockify and speaks RFB to 5900. +auth=() +[ -n "$CASE_DOCKER_NETWORK" ] && auth=(--auth-plugin BasicHTTPAuth --auth-source "agent:$DESK_TOKEN") # log to file, not compose stdout: its per-connection "Plain non-SSL (ws://)" # lines read like TLS errors to people skimming `docker compose logs` -/opt/deskd/bin/websockify --web /usr/share/novnc 6080 localhost:5900 >>/tmp/websockify.log 2>&1 & +/opt/deskd/bin/websockify "${auth[@]}" --web /usr/share/novnc 6080 localhost:5900 >>/tmp/websockify.log 2>&1 & # DESK_DEBUG=1 (docker run -e, or on cased to cover every desktop): mirror the # in-container logs to docker logs [ "$DESK_DEBUG" = "1" ] && tail -n +1 -F /tmp/chromium.log /tmp/websockify.log 2>/dev/null & From aca80998506291b0335433124599796facd4ce77 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:31:42 +0530 Subject: [PATCH 07/46] Send Anthropic the screenshot, not its base64. --- web/web-ui/case-tools.mjs | 11 +++++------ web/web-ui/serve.mjs | 28 ++++++++++++++++------------ web/web-ui/test_serve.mjs | 24 +++++++++++++++++++++++- 3 files changed, 44 insertions(+), 19 deletions(-) diff --git a/web/web-ui/case-tools.mjs b/web/web-ui/case-tools.mjs index 4656093..6f09a26 100644 --- a/web/web-ui/case-tools.mjs +++ b/web/web-ui/case-tools.mjs @@ -315,7 +315,7 @@ export function histToAnthropicMessages(items, { media = false } = {}) { pendingResults = []; }; for (const it of items || []) { - if (it.shot) continue; + if (it.shot && !media) continue; if (it.role === 'user' && it.content != null && !it.type) { flushAssistant(); flushResults(); @@ -512,11 +512,10 @@ export async function anthropicToolLoop({ ok: !!toolResult.ok, detail: clipJson(toolResult.error || toolResult.result || toolResult, 400), }); - results.push({ - type: 'tool_result', - tool_use_id: call.call_id || call.id, - content: clipJson(toolResult), - }); + const { image_b64, ...rest } = toolResult; + const content = [{ type: 'text', text: clipJson(rest) }]; + if (image_b64) content.push({ type: 'image', source: { type: 'base64', media_type: 'image/png', data: image_b64 } }); + results.push({ type: 'tool_result', tool_use_id: call.call_id || call.id, content }); } messages.push({ role: 'user', content: results }); } diff --git a/web/web-ui/serve.mjs b/web/web-ui/serve.mjs index 484a3e6..5543349 100644 --- a/web/web-ui/serve.mjs +++ b/web/web-ui/serve.mjs @@ -683,6 +683,19 @@ export function stashShot(b64, dir = shotsDir()) { return { role: 'user', shot: file, content: [{ type: 'input_text', text: '[screenshot]' }] }; } +// A click that changed nothing yields a byte-identical png. Re-sending it buys +// no information and then rides along in every later round of the turn. +export function pushShot(items, shots, b64, dir = shotsDir()) { + const h = crypto.createHash('sha1').update(b64).digest('hex'); + if (shots.has(h)) { + items.push({ role: 'user', content: [{ type: 'input_text', + text: 'screenshot identical to an earlier one this turn — the screen has not changed' }] }); + return; + } + shots.add(h); + items.push(stashShot(b64, dir)); +} + export function hydrateShots(items, dir = shotsDir()) { const root = path.resolve(dir) + path.sep; return (items || []).map((it) => { @@ -948,6 +961,7 @@ export async function runTurn({ try { if (auth.provider === 'anthropic') { const messages = histToAnthropicMessages(hydrateShots(hydrateAttaches(hist.items)), { media: true }); + const shots = new Set(); const { text: out, finished, spend, overBudget } = await anthropicToolLoop({ key: auth.key, model, @@ -983,6 +997,7 @@ export async function runTurn({ actFor(name, args || {}, id)); const { image_b64, ...persist } = result; hist.items.push({ type: 'function_call_output', call_id: call.call_id || call.id, output: clip(persist) }); + if (image_b64) pushShot(hist.items, shots, image_b64); return result; }, }); @@ -1119,18 +1134,7 @@ export async function runTurn({ if (image_b64) images.push(image_b64); } hist.items = histCloseOpenCalls(hist.items, { keepReasoning: true }); - for (const b64 of images) { - // A click that changed nothing yields a byte-identical png. Re-sending it buys - // no information and then rides along in every later round of the turn. - const h = crypto.createHash('sha1').update(b64).digest('hex'); - if (shots.has(h)) { - hist.items.push({ role: 'user', content: [{ type: 'input_text', - text: 'screenshot identical to an earlier one this turn — the screen has not changed' }] }); - continue; - } - shots.add(h); - hist.items.push(stashShot(b64)); - } + for (const b64 of images) pushShot(hist.items, shots, b64); } if (!finished && !stopped()) { // Out of steps or out of budget mid-task. Say so — silence here reads as diff --git a/web/web-ui/test_serve.mjs b/web/web-ui/test_serve.mjs index 5034954..83ef5d1 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, liveHeaders, hostOf, browserOk, extraPlan, isLocalMode, pageFile, clip, snapshotElide, stashShot, hydrateShots, migrateShots, stashAttach, resolveAttach, hydrateAttaches, attachKind, ATTACH_MAX, sseEvents } from './serve.mjs'; +import { shq, pathOk, parseFind, mimeFor, histTrim, histCloseOpenCalls, normHost, threadTurns, parseCaseUrl, liveCid, liveDestPath, livePathHasDotDot, tokenMatches, liveHeaders, hostOf, browserOk, extraPlan, isLocalMode, pageFile, clip, snapshotElide, stashShot, pushShot, hydrateShots, migrateShots, stashAttach, resolveAttach, hydrateAttaches, attachKind, ATTACH_MAX, sseEvents } from './serve.mjs'; import { CASE_TOOLS, chatAuth, resolveChatModel, openaiToolsToAnthropic, newAnthropicStreamCtx, anthropicEventToNdjson, tracesFromAnthropicMessage, @@ -323,6 +323,28 @@ assert.equal(pageFile('/deploy.html'), '/deploy.html'); assert.equal(msgs[0].content, 'after the shot'); } +// With media, a hydrated shot reaches the model as an image, not as a dropped item. +{ + const shot = { role: 'user', shot: '/tmp/x.png', content: [{ type: 'input_image', detail: 'high', image_url: 'data:image/png;base64,xx' }] }; + const msgs = histToAnthropicMessages([shot], { media: true }); + assert.equal(msgs.length, 1); + assert.deepEqual(msgs[0].content, [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'xx' } }]); +} + +// The Anthropic tool_result carries the png itself; a repeat says so instead. +{ + const src = fs.readFileSync(fileURLToPath(new URL('./case-tools.mjs', import.meta.url)), 'utf8'); + assert.match(src, /content\.push\(\{ type: 'image', source: \{ type: 'base64', media_type: 'image\/png', data: image_b64 \} \}\)/); + const items = []; + const shots = new Set(); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'case-dedupe-')); + pushShot(items, shots, 'AAAA', dir); + pushShot(items, shots, 'AAAA', dir); + assert.equal(items.length, 2); + assert.ok(items[0].shot, 'first shot is stashed'); + assert.match(items[1].content[0].text, /has not changed/); +} + // STOP keeps the turn — rewind only on provider errors, not disconnect. { const serveSrc = fs.readFileSync(fileURLToPath(new URL('./serve.mjs', import.meta.url)), 'utf8'); From cc3ff075438b79113b17d6af228e8dcc0e260833 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:32:26 +0530 Subject: [PATCH 08/46] Gate exec, actions and files during credential injection and scope files to /home/agent. --- image/deskd.py | 86 +++++++++++++++++++++++++++++++-------------- tests/test_deskd.py | 60 +++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 26 deletions(-) diff --git a/image/deskd.py b/image/deskd.py index 819424b..46ee9e2 100644 --- a/image/deskd.py +++ b/image/deskd.py @@ -45,6 +45,11 @@ def err(status, code, message): return JSONResponse({"error": {"code": code, "message": message}}, status_code=status) +def injecting(): + if state["injecting"]: + return err(423, "credential_injection", "blocked during credential injection") + + @app.middleware("http") async def auth(request: Request, call_next): got = request.headers.get("authorization") or "" @@ -115,8 +120,8 @@ def health(): @app.get("/screenshot") def screenshot(): - if state["injecting"]: - return err(423, "credential_injection", "screenshots blocked during credential injection") + if (r := injecting()): + return r return Response(grab(), media_type="image/png") @@ -152,9 +157,11 @@ def do_action(a): xdo("click", "--repeat", str(min(abs(dy), 50)), "--delay", "40", "5" if dy > 0 else "4") elif t == "type": text = str(a["text"]) + if len(text) > 10000: + raise ValueError("text over 10000 chars") # a killed xdotool leaves text half-typed and the caller retrying the # whole thing — scale the timeout so long texts can't hit it - xdo("type", "--delay", "15", "--", text, timeout=15 + len(text) // 10) + xdo("type", "--delay", "15", "--", text, timeout=min(15 + len(text) // 10, 300)) elif t == "key": xdo("key", "--", str(a["keys"])) elif t == "wait": @@ -165,6 +172,8 @@ def do_action(a): @app.post("/action") def action(a: dict = Body(...)): + if (r := injecting()): + return r try: do_action(a) except KeyError as e: @@ -174,16 +183,27 @@ def action(a: dict = Body(...)): out = {"ok": True} if a.get("screenshot"): time.sleep(min(int(a.get("delay_ms", 300)), 5000) / 1000) - if state["injecting"]: - return err(423, "credential_injection", "screenshots blocked during credential injection") + if (r := injecting()): + return r out["screenshot_png_b64"] = base64.b64encode(grab()).decode() return out # ---------- exec & files ---------- +HOME = "/home/agent" +FILE_MAX = 8 * 1024 * 1024 + + +def home_path(path): + p = os.path.realpath(path or "") + return p if p.startswith(HOME + "/") else None + + @app.post("/exec") def exec_(b: dict = Body(...)): + if (r := injecting()): + return r if "command" not in b: return err(400, "bad_request", "command required") timeout = min(int(b.get("timeout_s", 30)), 600) @@ -195,7 +215,7 @@ def exec_(b: dict = Body(...)): except subprocess.TimeoutExpired as e: code, out = 124, e.stdout or b"" errb = (e.stderr or b"") + b"\n[deskd] command timed out" - except NotADirectoryError: + except (FileNotFoundError, NotADirectoryError, PermissionError): return err(400, "bad_cwd", f"no such directory: {cwd}") truncated = len(out) > CAP or len(errb) > CAP return {"exit_code": code, "stdout": out[:CAP].decode(errors="replace"), @@ -204,20 +224,35 @@ def exec_(b: dict = Body(...)): @app.put("/file") async def file_put(request: Request, path: str): + if (r := injecting()): + return r + p = home_path(path) + if not p: + return err(400, "bad_path", f"path must be under {HOME}/") + if int(request.headers.get("content-length") or 0) > FILE_MAX: + return err(413, "too_large", f"file over {FILE_MAX} bytes") data = await request.body() - d = os.path.dirname(path) - if d: - os.makedirs(d, exist_ok=True) - with open(path, "wb") as f: - f.write(data) - return JSONResponse({"path": path, "bytes": len(data)}, status_code=201) + try: + os.makedirs(os.path.dirname(p), exist_ok=True) + with open(p, "wb") as f: + f.write(data) + except OSError as e: + return err(400, "bad_path", str(e)) + return JSONResponse({"path": p, "bytes": len(data)}, status_code=201) @app.get("/file") def file_get(path: str): - if not os.path.isfile(path): - return err(404, "not_found", path) - with open(path, "rb") as f: + if (r := injecting()): + return r + p = home_path(path) + if not p: + return err(400, "bad_path", f"path must be under {HOME}/") + if not os.path.isfile(p): + return err(404, "not_found", p) + if os.path.getsize(p) > FILE_MAX: + return err(413, "too_large", f"file over {FILE_MAX} bytes") + with open(p, "rb") as f: return Response(f.read(), media_type="application/octet-stream") @@ -325,8 +360,8 @@ def press_enter(tab): @app.post("/eval") def eval_(b: dict = Body(...)): - if state["injecting"]: - return err(423, "credential_injection", "eval blocked during credential injection") + if (r := injecting()): + return r if "expression" not in b: return err(400, "bad_request", "body needs 'expression'") timeout = min(int(b.get("timeout_s", 20)), 120) @@ -707,8 +742,8 @@ def login_resume(b: dict = Body(...)): @app.post("/auth/observe") def auth_observe(): - if state["injecting"]: - return err(423, "credential_injection", "observe blocked during credential injection") + if (r := injecting()): + return r try: tab = Tab() try: @@ -754,9 +789,8 @@ def auth_submit_challenge(b: dict = Body(...)): def auth_navigate_verification(b: dict = Body(...)): if "url" not in b: return err(400, "bad_request", "body needs 'url'") - if state["injecting"]: - return err(423, "credential_injection", - "navigate_verification blocked during credential injection") + if (r := injecting()): + return r url = b["url"] domains = b.get("domains") host = urlparse(url).hostname @@ -920,8 +954,8 @@ def capture_start(b: dict = Body(...)): @app.get("/capture") def capture_get(): - if state["injecting"]: - return err(423, "credential_injection", "capture blocked during credential injection") + if (r := injecting()): + return r cap = state["capture"] if not cap: return {"items": [], "running": False, "error": None} @@ -932,8 +966,8 @@ def capture_get(): @app.delete("/capture") def capture_delete(): - if state["injecting"]: - return err(423, "credential_injection", "capture blocked during credential injection") + if (r := injecting()): + return r cap = _stop_capture() return {"items": _drain(cap["buf"]) if cap else [], "running": False, "error": cap["error"] if cap else None} diff --git a/tests/test_deskd.py b/tests/test_deskd.py index 277f160..762673b 100644 --- a/tests/test_deskd.py +++ b/tests/test_deskd.py @@ -455,6 +455,66 @@ def test_capture_step_getResponseBody_error_is_visible(): assert buf[0]["error"] == "No data found for resource" and "body" not in buf[0] +# ---- the injection gate and /file scoping, over the real ASGI app ---- + +import tempfile # noqa: E402 + +from fastapi.testclient import TestClient # noqa: E402 + +H = {"Authorization": "Bearer test"} + + +def _client(): + return TestClient(deskd.app, raise_server_exceptions=False) + + +def test_no_bearer_is_401(): + assert _client().get("/health").status_code == 401 + assert _client().get("/health", headers=H).status_code == 200 + + +def test_injection_gates_screenshot_exec_and_file(): + deskd.state["injecting"] = True + try: + c = _client() + for r in (c.get("/screenshot", headers=H), + c.post("/exec", headers=H, json={"command": "id"}), + c.get("/file", headers=H, params={"path": "/home/agent/x"})): + assert r.status_code == 423 + assert r.json()["error"]["code"] == "credential_injection" + finally: + deskd.state["injecting"] = False + + +def test_file_get_rejects_paths_outside_home(): + c = _client() + assert c.get("/file", headers=H, params={"path": "/etc/passwd"}).status_code == 400 + esc = c.get("/file", headers=H, params={"path": "/home/agent/../../etc/passwd"}) + assert esc.status_code == 400 # realpath resolves .. before the check + assert esc.json()["error"]["code"] == "bad_path" + + +def test_file_put_get_roundtrip_under_home(): + with tempfile.TemporaryDirectory() as td: + home = os.path.realpath(td) + with mock.patch.object(deskd, "HOME", home): + c = _client() + p = f"{home}/sub/note.txt" + put = c.put("/file", headers=H, params={"path": p}, content=b"hello") + assert put.status_code == 201 and put.json()["bytes"] == 5 + got = c.get("/file", headers=H, params={"path": p}) + assert got.status_code == 200 and got.content == b"hello" + + +def test_file_put_rejects_oversized_content_length(): + with tempfile.TemporaryDirectory() as td: + home = os.path.realpath(td) + with mock.patch.object(deskd, "HOME", home): + r = _client().put("/file", headers={**H, "content-length": "99999999"}, + params={"path": f"{home}/big"}, content=b"x") + assert r.status_code == 413 + assert not os.path.exists(f"{home}/big") # rejected before any write + if __name__ == "__main__": for name, fn in sorted(globals().items()): if name.startswith("test_"): From 8a8344ff63a63ec6267994f3a9bf426d49d1f2d4 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:32:47 +0530 Subject: [PATCH 09/46] Deliver the assist link, flatten prompts for ntfy, and sign the approve and deny buttons. --- control-plane/handoffs.py | 18 ++++++++++- control-plane/notify.py | 22 +++++++------- tests/test_handoffs.py | 17 +++++++++++ tests/test_notify.py | 64 ++++++++++++++++++++++++++++++++++++--- 4 files changed, 105 insertions(+), 16 deletions(-) diff --git a/control-plane/handoffs.py b/control-plane/handoffs.py index 999023d..9391f51 100644 --- a/control-plane/handoffs.py +++ b/control-plane/handoffs.py @@ -17,13 +17,14 @@ writes go through store.transition_handoff (CAS + revision bump). Legacy `answered` is treated as completed on read for one release. """ +import hmac import os import assist import captcha from datetime import datetime, timezone -from config import HANDOFF_TTL, log +from config import API_BASE, HANDOFF_TTL, log from deskclient import auth_submit_challenge, desk_json, eval_js, screenshot_b64 from errors import ApiError from events import emit @@ -139,6 +140,9 @@ def create_handoff(computer_row, kind, prompt, screenshot=None, login_credential assist_url = f"https://{host}/assist/{raw_token}" if host else "" if not host: log.warning("CASE_PUBLIC_HOST unset — notification carries no assist link") + answer_url = "" + if kind == "approval": + answer_url = f"{API_BASE.removesuffix('/v1')}/answer/{hid}/{store.sign('answer:' + hid)}" notifier.notify({ "id": hid, "computer_id": computer_row["id"], @@ -147,6 +151,7 @@ def create_handoff(computer_row, kind, prompt, screenshot=None, login_credential "screenshot": screenshot, "domain": domain, "assist_url": assist_url, + "answer_url": answer_url, "expires_at": expires_at, }, computer_row["name"]) return handoff_json(row) @@ -446,6 +451,17 @@ def answer_handoff(hid, value): f"handoff continuation {cont!r} cannot be answered this way") +def answer_token_ok(hid, token): + return hmac.compare_digest(store.sign("answer:" + hid), token or "") + + +def answer_by_token(hid, token, value): + """Public ntfy-button door: the token is the only credential, so a bad one is a 404.""" + if not answer_token_ok(hid, token): + raise ApiError(404, "not_found", "no such handoff") + return answer_handoff(hid, value) + + def on_ntfy_answer(hid, value): if hid is None: pending = store.pending_handoff_ids() diff --git a/control-plane/notify.py b/control-plane/notify.py index 38bc8be..212a7f8 100644 --- a/control-plane/notify.py +++ b/control-plane/notify.py @@ -16,8 +16,6 @@ import requests -from config import API_BASE - log = logging.getLogger("cased.notify") OUTBOUND_TAG = "case-outbound" @@ -39,11 +37,10 @@ def _tags(ev): class Ntfy: - def __init__(self, url, topic, answer_topic, api_base): + def __init__(self, url, topic, answer_topic): self.url = url.rstrip("/") self.topic = topic self.answer_topic = answer_topic - self.api_base = api_base if not topic: log.warning("CASE_NTFY_TOPIC unset — handoff notifications disabled") if topic and answer_topic and topic == answer_topic: @@ -63,14 +60,17 @@ def _send(self, h, computer_name): tags = [OUTBOUND_TAG] if h.get("id"): tags.append(h["id"]) + prompt = " ".join((h.get("prompt") or "").split()) # header values can't hold newlines headers = { **_auth_headers(), "X-Title": ascii_(f"[Case] {h['kind']} — {computer_name}"), "X-Tags": ",".join(tags), - "X-Message": ascii_(h["prompt"])[:800], + "X-Message": ascii_(prompt)[:800], } - if h["kind"] == "approval": - a = f"{self.api_base}/handoffs/{h['id']}/answer" + if h.get("assist_url"): + headers["X-Click"] = h["assist_url"] + if h.get("answer_url"): + a = h["answer_url"] headers["X-Actions"] = ( f"http, Approve, {a}, method=POST, body={{\"value\":\"approve\"}}; " f"http, Deny, {a}, method=POST, body={{\"value\":\"deny\"}}") @@ -107,7 +107,7 @@ def _listen(self, on_answer): while True: try: r = requests.get(f"{self.url}/{self.answer_topic}/sse", stream=True, - headers=headers, timeout=(10, None)) + headers=headers, timeout=(10, 90)) for line in r.iter_lines(): if not line or not line.startswith(b"data: "): continue @@ -126,15 +126,15 @@ def _listen(self, on_answer): on_answer(None, msg) except Exception as e: log.warning("answer via ntfy rejected: %s", e) - except Exception: - pass + except Exception as e: + log.warning("ntfy listen: %s", e) time.sleep(5) def build_notifier(): return Ntfy(os.environ.get("CASE_NTFY_URL", "https://ntfy.sh"), os.environ.get("CASE_NTFY_TOPIC"), - os.environ.get("CASE_NTFY_ANSWER_TOPIC"), API_BASE) + os.environ.get("CASE_NTFY_ANSWER_TOPIC")) notifier = build_notifier() diff --git a/tests/test_handoffs.py b/tests/test_handoffs.py index bead0ca..480d569 100644 --- a/tests/test_handoffs.py +++ b/tests/test_handoffs.py @@ -25,6 +25,9 @@ # guarantee — stub it so these tests can never publish a real handoff to a real phone. handoffs.notifier = type("N", (), {"notify": lambda self, h, name: None})() +# Approval handoffs sign their ntfy answer URL with store.sign (WP-B). +mock.patch.object(store, "sign", lambda text: "sig-" + text, create=True).start() + def _cleanup(*ids): for hid in ids or IDS: @@ -49,6 +52,20 @@ def _persist(hid, kind, prompt, login_credential=None, domain=None, **kw): return store.get_handoff(hid) +def test_only_approvals_carry_a_signed_answer_url(): + _cleanup() + seen = [] + handoffs.notifier = type("N", (), {"notify": lambda self, h, name: seen.append(h)})() + try: + _mk(ROW, "approval", "ok?") + _mk(ROW, "question", "who?") + assert seen[0]["answer_url"].endswith(f"/answer/{seen[0]['id']}/sig-answer:{seen[0]['id']}") + assert seen[1]["answer_url"] == "" + finally: + handoffs.notifier = type("N", (), {"notify": lambda self, h, name: None})() + _cleanup() + + def test_rebuild_login_ctx_recovers_pending_login_handoff(): # a login handoff persisted before a (simulated) restart, with the in-memory map wiped _cleanup() diff --git a/tests/test_notify.py b/tests/test_notify.py index 415b62a..6ef40c0 100644 --- a/tests/test_notify.py +++ b/tests/test_notify.py @@ -36,7 +36,7 @@ def test_no_topic_means_warned_noop_not_a_crash(): def test_ntfy_notify_posts_to_the_topic(): - ntfy = notify.Ntfy("https://ntfy.sh", "topic-x", None, "http://127.0.0.1:8787/v1") + ntfy = notify.Ntfy("https://ntfy.sh", "topic-x", None) done = threading.Event() posted = {} @@ -57,7 +57,7 @@ def fake_post(url, **kw): def test_ntfy_notify_sends_bearer_token(): os.environ["CASE_NTFY_TOKEN"] = "secret-tok" - ntfy = notify.Ntfy("https://ntfy.sh", "topic-x", None, "http://127.0.0.1:8787/v1") + ntfy = notify.Ntfy("https://ntfy.sh", "topic-x", None) done = threading.Event() posted = {} @@ -78,14 +78,14 @@ def fake_post(url, **kw): def test_same_topic_does_not_start_answer_listen(): - ntfy = notify.Ntfy("https://ntfy.sh", "same", "same", "http://127.0.0.1:8787/v1") + ntfy = notify.Ntfy("https://ntfy.sh", "same", "same") with mock.patch.object(notify.threading, "Thread") as th: ntfy.listen(lambda *a: None) th.assert_not_called() def test_push_marks_outbound(): - ntfy = notify.Ntfy("https://ntfy.sh", "topic-x", None, "http://127.0.0.1:8787/v1") + ntfy = notify.Ntfy("https://ntfy.sh", "topic-x", None) done = threading.Event() posted = {} @@ -100,6 +100,62 @@ def fake_post(url, **kw): assert posted["headers"].get("X-Tags") == "case-outbound" +def _post_once(payload, name="box"): + ntfy = notify.Ntfy("https://ntfy.sh", "topic-x", None) + done = threading.Event() + posted = {} + + def fake_post(url, **kw): + posted.update(kw.get("headers") or {}) + done.set() + return mock.Mock(status_code=200) + + with mock.patch.object(notify.requests, "post", side_effect=fake_post): + ntfy.notify(payload, name) + assert done.wait(2), "ntfy thread did not run" + return posted + + +def test_multiline_prompt_is_flattened_into_the_header(): + h = _post_once({"id": "h_1", "kind": "question", "screenshot": None, + "prompt": "line one\nline two\r\n line three"}) + assert h["X-Message"] == "line one line two line three" + + +def test_assist_url_becomes_the_click_action(): + h = _post_once({"id": "h_1", "kind": "question", "prompt": "hi", "screenshot": None, + "assist_url": "https://acme.example/assist/tok"}) + assert h["X-Click"] == "https://acme.example/assist/tok" + assert "X-Actions" not in h + + +def test_approval_buttons_use_the_signed_answer_url(): + h = _post_once({"id": "h_1", "kind": "approval", "prompt": "ok?", "screenshot": None, + "answer_url": "http://127.0.0.1:8787/answer/h_1/sig"}) + assert h["X-Actions"].count("http://127.0.0.1:8787/answer/h_1/sig") == 2 + assert "approve" in h["X-Actions"] and "deny" in h["X-Actions"] + + +def test_no_answer_url_means_no_buttons(): + h = _post_once({"id": "h_1", "kind": "approval", "prompt": "ok?", "screenshot": None}) + assert "X-Actions" not in h + + +def test_answer_token_ok_only_for_the_matching_signature(): + import handoffs + from store import store + with mock.patch.object(store, "sign", lambda text: "sig:" + text, create=True): + assert handoffs.answer_token_ok("h_1", "sig:answer:h_1") + assert not handoffs.answer_token_ok("h_1", "sig:answer:h_2") + assert not handoffs.answer_token_ok("h_1", "") + assert not handoffs.answer_token_ok("h_1", None) + try: + handoffs.answer_by_token("h_1", "nope", "approve") + assert False, "bad token must not reach the handoff" + except handoffs.ApiError as e: + assert e.status == 404, e + + def test_create_handoff_mints_assist_and_passes_url_to_notifier(): """Integration: create_handoff → mint → notify payload carries assist_url.""" os.environ["CASE_PUBLIC_HOST"] = "acme.case.example" From 12d37df37de811db4a9f961287e59d52c8b76425 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Fri, 4 Sep 2026 04:33:11 +0530 Subject: [PATCH 10/46] Write threads.json atomically and trim the small duplications. --- web/web-ui/deploy.html | 3 --- web/web-ui/index.html | 6 +---- web/web-ui/serve.mjs | 57 ++++++++++++++++++++++----------------- web/web-ui/test_serve.mjs | 16 +++++++++-- 4 files changed, 48 insertions(+), 34 deletions(-) diff --git a/web/web-ui/deploy.html b/web/web-ui/deploy.html index f0748de..6e9fec0 100644 --- a/web/web-ui/deploy.html +++ b/web/web-ui/deploy.html @@ -7,9 +7,6 @@ Deploy · case - - -