From f9914367f6b877720e9cb1251d64fcde4561bc14 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:51:03 +0000 Subject: [PATCH 001/126] Cache installs, bound job time, and check shell scripts and compose in CI. pip and npm now reuse their caches, each job has a timeout so a hung test cannot hold a runner for six hours, and a newer push to the same ref cancels the older run. start.sh and bin/case go through shellcheck and compose.yaml through `docker compose config`, neither of which ran anywhere before. The SPDX file list is built before the loop, so a git failure fails the job instead of checking nothing. --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7835313..2b1b257 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,18 +11,27 @@ on: permissions: contents: read +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + jobs: test: runs-on: ubuntu-latest + timeout-minutes: 20 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" + cache: pip + cache-dependency-path: requirements*.txt - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "22" + cache: npm + cache-dependency-path: web/package-lock.json - name: Install deps run: | @@ -47,6 +56,12 @@ jobs: npm --prefix web ci --omit=dev # serve.mjs imports openai at module load npm --prefix web test + - name: Shell scripts + run: shellcheck -e SC1090 image/start.sh bin/case # SC1090: ~/.case/env is the user's + + - name: Compose file + run: docker compose config -q + # Case is dual-licensed and sold commercially, which is only possible if the # project can sublicense every contribution. Deliberately no third-party CLA # bot: the one everyone used was archived in March 2026, and a licensing gate @@ -55,6 +70,7 @@ jobs: cla: if: github.event_name == 'pull_request' runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Contributor has signed the CLA @@ -88,16 +104,20 @@ jobs: spdx: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - name: Every source file declares its license run: | + # A plain pathspec's * crosses directories, so web/*.mjs covers web/ at any depth. + # The list is built first so a git failure fails the step instead of an empty loop. + files=$(git ls-files \ + 'control-plane/*.py' 'image/*.py' 'image/*.sh' \ + 'mcp/*.py' 'tests/*.py' 'web/*.mjs' 'bin/case') missing=0 - while read -r f; do + for f in $files; do grep -q 'SPDX-License-Identifier' "$f" || { echo "no SPDX header: $f"; missing=1; } - done < <(git ls-files \ - 'control-plane/*.py' 'image/*.py' 'image/*.sh' \ - 'mcp/*.py' 'tests/*.py' 'web/**/*.mjs' 'bin/case') + done [ "$missing" = 0 ] || { echo "New files take the SPDX id of their directory. See LICENSE.md." exit 1 From 80d4932808360573d80b4d7e335508cb6a2b2c1a Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:51:22 +0000 Subject: [PATCH 002/126] Keep pre-release tags off latest and pass image metadata through the environment. A v1.2.0-rc1 tag used to retag latest, since only the semver patterns skip pre-releases. The metadata JSON was pasted into a single-quoted shell string, so a quote in the repository description broke the step. --- .github/workflows/publish-image.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 850024f..a95dd1a 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -91,15 +91,17 @@ jobs: tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest,enable=${{ github.ref_type == 'tag' }} + type=raw,value=latest,enable=${{ github.ref_type == 'tag' && !contains(github.ref_name, '-') }} type=raw,value=edge,enable=${{ github.ref_type != 'tag' }} - name: One manifest for both arches + env: + META_JSON: ${{ steps.meta.outputs.json }} run: | cd /tmp/digests # shellcheck disable=SC2046 docker buildx imagetools create \ - $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< '${{ steps.meta.outputs.json }}') \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$META_JSON") \ $(printf '${{ env.IMAGE }}@sha256:%s ' *) - name: Show what landed From 85eada4c74b57d9efcba91c354cb94e35798eaf0 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:52:14 +0000 Subject: [PATCH 003/126] Build the images on pull requests that can break them. The desktop image was only ever built by the tag-triggered publish job, so a stale base digest, noVNC checksum or pin surfaced at release time. A path-filtered job now builds the desk, control and ui images without pushing them. --- .github/workflows/images.yml | 62 ++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/images.yml diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml new file mode 100644 index 0000000..e9979f0 --- /dev/null +++ b/.github/workflows/images.yml @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: MIT +# Build the three images on changes that can break them, so a bad base digest, +# noVNC checksum or pin shows up on the pull request instead of at release. +# Nothing is pushed; publish-image.yml does that from tags. +name: images + +on: + push: + branches: [main] + paths: + - image/** + - Dockerfile.* + - .dockerignore + - compose.yaml + - requirements.txt + - web/package*.json + - .github/workflows/images.yml + pull_request: + paths: + - image/** + - Dockerfile.* + - .dockerignore + - compose.yaml + - requirements.txt + - web/package*.json + - .github/workflows/images.yml + +permissions: + contents: read + +concurrency: + group: images-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - name: desk + context: ./image + file: ./image/Dockerfile + - name: control + context: . + file: ./Dockerfile.cased + - name: ui + context: . + file: ./Dockerfile.ui + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 + - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: ${{ matrix.context }} + file: ${{ matrix.file }} + platforms: linux/amd64 + cache-from: type=gha,scope=check-${{ matrix.name }} + cache-to: type=gha,mode=max,scope=check-${{ matrix.name }} + push: false From b55ddcba1b2159d4f37d046be2d2aa553fdf9b34 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:52:14 +0000 Subject: [PATCH 004/126] Audit the pinned Python and npm dependencies weekly and when a pin changes. The advisory bumps so far were found by hand. pip-audit checks the requirements files and npm audit checks Drive's lock file. --- .github/workflows/audit.yml | 40 +++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/audit.yml diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..cc287a6 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: MIT +# Known-vulnerability check for the pinned dependencies. Weekly, because an +# advisory lands on a pin that has not changed; and on pull requests that change +# a pin, so a bump is checked before it merges. +name: audit + +on: + schedule: + - cron: "17 6 * * 1" + workflow_dispatch: + pull_request: + paths: + - requirements*.txt + - web/package*.json + - .github/workflows/audit.yml + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "22" + + - name: Python + run: | + python -m venv .venv + .venv/bin/pip install pip-audit==2.10.1 + .venv/bin/pip-audit -r requirements-dev.txt + + - name: Drive + run: npm --prefix web audit --omit=dev From b34ff92caf4e9f0003e0f1ff6c2e28770b2f1511 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:52:45 +0000 Subject: [PATCH 005/126] Check Host and Origin on the MCP HTTP door. The SDK only turns its rebinding check on when bound to loopback, and compose binds 0.0.0.0, so any web page could rebind a name to 127.0.0.1:8788 and call computer_exec. The server now allows the same names cased and Drive do, plus the mcp service name, and compose hands it CASE_PUBLIC_HOST and CASE_ALLOWED_HOSTS. A foreign Host gets 421, a foreign Origin 403. --- SECURITY.md | 12 +++++++----- compose.yaml | 2 ++ mcp/case_mcp.py | 24 +++++++++++++++++++++++- tests/test_mcp_http.py | 35 ++++++++++++++++++++++++++++++++++- 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index c10006d..ec6a068 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -29,11 +29,13 @@ Case holds logins. These are promises, with code you can read. must be under `/home/agent/`, at most 5MB, and the snapshot ref must be `input[type=file]`. Password/OTP-like inputs are refused. Bytes travel through deskd `GET /file`, never command stdout. -- **cased and Drive check `Host`, and `Origin` when one is present.** Anything - else gets a 403. Allowed by default: `127.0.0.1`, `localhost`, `[::1]`, the - compose service name, plus `CASE_PUBLIC_HOST` and anything in - `CASE_ALLOWED_HOSTS`. This is what stops a DNS-rebinding page or a cross-site - WebSocket open from driving a loopback install. Drive checks every request. +- **cased, Drive and MCP HTTP check `Host`, and `Origin` when one is present.** + Anything else gets a 403 (MCP answers a bad `Host` with 421). Allowed by + default: `127.0.0.1`, `localhost`, `[::1]`, the compose service name, plus + `CASE_PUBLIC_HOST` and anything in `CASE_ALLOWED_HOSTS`. This is what stops a + DNS-rebinding page or a cross-site WebSocket open from driving a loopback + install, including a page calling `computer_exec` through `/mcp` on + `127.0.0.1:8788`. Drive checks every request. cased checks the untokened ones — the token-in-URL doors always, everything when `CASE_TOKEN` is unset; with `CASE_TOKEN` set the rest of the API is bearer-only. diff --git a/compose.yaml b/compose.yaml index 683a98b..32951e4 100644 --- a/compose.yaml +++ b/compose.yaml @@ -87,6 +87,8 @@ services: CASE_TOKEN: ${CASE_TOKEN:-} CASE_MCP_BIND: "0.0.0.0" CASE_MCP_PORT: "8788" + CASE_PUBLIC_HOST: ${CASE_PUBLIC_HOST:-} + CASE_ALLOWED_HOSTS: ${CASE_ALLOWED_HOSTS:-} # CASE_MCP_SCHEDULES=1 unlocks the schedule_* tools (see .env.example) CASE_MCP_SCHEDULES: ${CASE_MCP_SCHEDULES:-} command: ["python", "/app/mcp/case_mcp.py"] diff --git a/mcp/case_mcp.py b/mcp/case_mcp.py index f646712..54e58f6 100644 --- a/mcp/case_mcp.py +++ b/mcp/case_mcp.py @@ -18,6 +18,7 @@ import requests from mcp.server.fastmcp import FastMCP, Image +from mcp.server.transport_security import TransportSecuritySettings BASE = os.environ.get("CASE_URL", "http://127.0.0.1:8787/v1") HTTP = os.environ.get("CASE_MCP_HTTP") == "1" @@ -25,9 +26,30 @@ # In HTTP mode this is one id for the whole box (stateless: no per-client session) — # a box serves one person, so the audit log stays as useful as it is over stdio. SESSION = "mcp_" + secrets.token_hex(4) # one per MCP process; keys cased's audit log + + +def allowed_hosts(): + """Names a browser may address us as; anything else is a rebinding page.""" + hosts = ["127.0.0.1", "localhost", "[::1]", "mcp"] + hosts += [h.strip().lower() + for h in (os.environ.get("CASE_ALLOWED_HOSTS") or "").split(",") if h.strip()] + pub = (os.environ.get("CASE_PUBLIC_HOST") or "").strip().lower() + if pub: + hosts.append(pub) + return hosts + + +# The SDK only checks Host/Origin by itself when bound to loopback, and compose binds +# 0.0.0.0 — so name the same hosts cased and Drive accept, on any port or scheme. +_HOSTS = allowed_hosts() +SECURITY = TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=[h + p for h in _HOSTS for p in ("", ":*")], + allowed_origins=[f"{s}://{h}{p}" for s in ("http", "https") + for h in _HOSTS for p in ("", ":*")]) # stateless_http: no server-side session state, so a proxy or service restart never # strands a client mid-session. Harmless over stdio. -mcp = FastMCP("case", stateless_http=True, +mcp = FastMCP("case", stateless_http=True, transport_security=SECURITY, host=BIND, port=int(os.environ.get("CASE_MCP_PORT", "8788"))) if HTTP: diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py index 05dbb9b..d434af6 100644 --- a/tests/test_mcp_http.py +++ b/tests/test_mcp_http.py @@ -13,7 +13,8 @@ def _load(**env): - for k in ("CASE_MCP_HTTP", "CASE_MCP_PORT", "CASE_MCP_BIND"): + for k in ("CASE_MCP_HTTP", "CASE_MCP_PORT", "CASE_MCP_BIND", + "CASE_ALLOWED_HOSTS", "CASE_PUBLIC_HOST"): os.environ.pop(k, None) os.environ.update(env) mod = importlib.import_module("case_mcp") @@ -76,6 +77,38 @@ def test_call_falls_back_to_the_status_unchained(): assert chained is None, chained # a chained decode error buries the status +def _mcp_statuses(requests_, **env): + """POST a tools/call to a fresh HTTP app per (Host, Origin); returns the statuses.""" + from starlette.testclient import TestClient + m = _load(CASE_MCP_HTTP="1", CASE_MCP_BIND="0.0.0.0", **env) + m.requests = types.SimpleNamespace(request=lambda *a, **kw: _Resp(200, {"computers": []})) + body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "computer_list", "arguments": {}}} + out = [] + with TestClient(m.mcp.streamable_http_app()) as c: + for host, origin in requests_: + h = {"Host": host, "Accept": "application/json, text/event-stream"} + if origin: + h["Origin"] = origin + out.append(c.post("/mcp", headers=h, json=body).status_code) + return out + + +def test_http_refuses_a_rebound_host(): + # compose binds 0.0.0.0, where the SDK checks nothing by itself: a rebinding page + # reaches 127.0.0.1:8788 under its own name and must not get computer_exec + codes = _mcp_statuses([ + ("rebind.evil.example:8788", None), + ("127.0.0.1:8788", "http://rebind.evil.example:8788"), + ("127.0.0.1:8788", None), + ("localhost:8788", "http://localhost:8788"), + ("mcp:8788", None), + ("case.example.com", "https://case.example.com"), + ("box.example.com:8788", None), + ], CASE_ALLOWED_HOSTS="case.example.com", CASE_PUBLIC_HOST="box.example.com") + assert codes == [421, 403, 200, 200, 200, 200, 200], codes + + def test_no_credential_write_tool(): # security invariant: secrets enter via `case cred add` only, never a tool call m = _load() From f6b9e771ba9f28455dd3c42d22885a9246f4faa1 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:53:21 +0000 Subject: [PATCH 006/126] Let MCP optional parameters accept an explicit null. They were declared `x: int = None`, so the schema said integer and a client that sends "x": null for an unset argument got a validation error. They are `int | None = None` now, and null means unset. --- mcp/case_mcp.py | 28 ++++++++++++++-------------- tests/test_mcp_http.py | 16 ++++++++++++++++ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/mcp/case_mcp.py b/mcp/case_mcp.py index 54e58f6..649bfc1 100644 --- a/mcp/case_mcp.py +++ b/mcp/case_mcp.py @@ -116,11 +116,11 @@ def computer_screenshot(computer_id: str, marks: bool = False) -> Image: @mcp.tool() -def computer_action(computer_id: str, type: str, x: int = None, y: int = None, - button: str = None, text: str = None, keys: str = None, - dy: int = None, ms: int = None, - from_x: int = None, from_y: int = None, - to_x: int = None, to_y: int = None, +def computer_action(computer_id: str, type: str, x: int | None = None, y: int | None = None, + button: str | None = None, text: str | None = None, keys: str | None = None, + dy: int | None = None, ms: int | None = None, + from_x: int | None = None, from_y: int | None = None, + to_x: int | None = None, to_y: int | None = None, screenshot: bool = False) -> object: """Perform a UI action: click|double_click|move|drag|scroll|type|key|wait. Coordinates are pixels, origin top-left of the display (1280x800 by default; @@ -208,8 +208,8 @@ def computer_snapshot(computer_id: str) -> dict: @mcp.tool() -def computer_click_element(computer_id: str, ref: int, name: str = None, - text: str = None, screenshot: bool = False) -> dict: +def computer_click_element(computer_id: str, ref: int, name: str | None = None, + text: str | None = None, screenshot: bool = False) -> dict: """Click element [ref] from the last computer_snapshot. Pass name (the quoted text from the snapshot line) so a changed page is caught: on mismatch this REFUSES to click and returns {ok:false, stale:true, snapshot} unless exactly @@ -234,7 +234,7 @@ def computer_click_element(computer_id: str, ref: int, name: str = None, @mcp.tool() -def computer_hover(computer_id: str, ref: int, name: str = None) -> dict: +def computer_hover(computer_id: str, ref: int, name: str | None = None) -> dict: """Hover the OS pointer over snapshot [ref] without clicking — opens menus that only appear on hover. Pass name so a changed page is refused.""" body = {"ref": ref} @@ -245,7 +245,7 @@ def computer_hover(computer_id: str, ref: int, name: str = None) -> dict: @mcp.tool() -def computer_upload(computer_id: str, ref: int, path: str, name: str = None) -> dict: +def computer_upload(computer_id: str, ref: int, path: str, name: str | None = None) -> dict: """Assign a file already on the computer (path under /home/agent/, ≤5MB) to snapshot [ref], which must be input[type=file]. Never send file bytes through this tool — write the file with computer_file_put or computer_exec first.""" @@ -276,7 +276,7 @@ def computer_fill(computer_id: str, fields: list, submit: bool = False) -> dict: @mcp.tool() -def computer_wait_for(computer_id: str, selector: str = None, text: str = None, +def computer_wait_for(computer_id: str, selector: str | None = None, text: str | None = None, gone: bool = False, network_idle: bool = False, timeout_s: int = 30) -> dict: """Block in this one call until the page is ready — instead of polling with @@ -302,8 +302,8 @@ def computer_wait_for(computer_id: str, selector: str = None, text: str = None, @mcp.tool() -def computer_tabs(computer_id: str, action: str = "list", target_id: str = None, - url: str = None) -> dict: +def computer_tabs(computer_id: str, action: str = "list", target_id: str | None = None, + url: str | None = None) -> dict: """Browser tab management: action=list|activate|new|close. list returns [{id, title, url, active}] — the ACTIVE tab is the one computer_eval, computer_snapshot and computer_capture talk to. If a click opened a new tab @@ -345,7 +345,7 @@ def computer_capture_read(computer_id: str, stop: bool = False) -> dict: @mcp.tool() def computer_login(computer_id: str, credential: str, url: str, - idempotency_key: str = None, proof_spec: dict = None) -> dict: + idempotency_key: str | None = None, proof_spec: dict | None = None) -> dict: """Log into a site using a vaulted credential (added by the human via CLI/fill). Returns AuthAttemptResult / LoginResult with attempt_id always present: {"status":"success","attempt_id",…}, {"status":"failed"|"unverified","reason", @@ -513,7 +513,7 @@ def computer_sleep(computer_id: str) -> dict: @mcp.tool() -def auth_attempt_wait(attempt_id: str, since_revision: int = None, +def auth_attempt_wait(attempt_id: str, since_revision: int | None = None, max_wait_s: int = 240) -> dict: """Block in this tool call until the auth attempt advances or the budget ends. This is also the inspection tool: for a non-blocking snapshot of where a login diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py index d434af6..a3d3f19 100644 --- a/tests/test_mcp_http.py +++ b/tests/test_mcp_http.py @@ -109,6 +109,22 @@ def test_http_refuses_a_rebound_host(): assert codes == [421, 403, 200, 200, 200, 200, 200], codes +def test_optional_params_accept_explicit_null(): + # clients send "x": null for an unset optional; `x: int = None` rejected that + import asyncio + m = _load() + for name, tool in m.mcp._tool_manager._tools.items(): + for arg, prop in tool.parameters["properties"].items(): + if "default" in prop and prop["default"] is None: + assert {"type": "null"} in prop.get("anyOf", []), (name, arg, prop) + sent = [] + m.requests = types.SimpleNamespace( + request=lambda *a, **kw: sent.append(kw["json"]) or _Resp(200, {"ok": True})) + asyncio.run(m.mcp.call_tool("computer_action", { + "computer_id": "c", "type": "key", "keys": "Return", "x": None, "button": None})) + assert sent == [{"type": "key", "screenshot": False, "keys": "Return"}], sent + + def test_no_credential_write_tool(): # security invariant: secrets enter via `case cred add` only, never a tool call m = _load() From e6eb2bfe27c80bd61ad544796d8e207f261fb69e Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:54:49 +0000 Subject: [PATCH 007/126] Run MCP tools in a worker thread so one slow call does not stall the rest. FastMCP calls sync tools on the event loop, so a 280s computer_login froze every other client and /health until it returned. Tools are now registered through a small decorator that hands the blocking call to anyio's thread pool and leaves the module's functions as they were. --- mcp/case_mcp.py | 72 ++++++++++++++++++++++++------------------ tests/test_mcp_http.py | 40 ++++++++++++++++++++--- 2 files changed, 77 insertions(+), 35 deletions(-) diff --git a/mcp/case_mcp.py b/mcp/case_mcp.py index 649bfc1..c5c0f49 100644 --- a/mcp/case_mcp.py +++ b/mcp/case_mcp.py @@ -11,11 +11,13 @@ Publishing it further is the operator's reverse proxy's job (TLS + bearer). """ import base64 +import functools import os import re import secrets import time +import anyio import requests from mcp.server.fastmcp import FastMCP, Image from mcp.server.transport_security import TransportSecuritySettings @@ -63,6 +65,16 @@ async def _health(request: Request) -> PlainTextResponse: return PlainTextResponse("ok") +def tool(fn): + """mcp.tool(), run in a worker thread: FastMCP calls sync tools on the event loop, + where one 280s computer_login stalls every client and /health. Returns fn as is.""" + @functools.wraps(fn) + async def run(**kw): + return await anyio.to_thread.run_sync(functools.partial(fn, **kw)) + mcp.tool()(run) + return fn + + def _headers(**extra): h = {"X-Case-Session": SESSION} tok = (os.environ.get("CASE_TOKEN") or "").strip() @@ -85,7 +97,7 @@ def call(method, path, **kw): return r -@mcp.tool() +@tool def computer_create(name: str = "") -> dict: """Create a persistent computer (Linux desktop + Chromium). Blocks until running. Returns the computer incl. its `id` — pass that id to every other tool. @@ -95,7 +107,7 @@ def computer_create(name: str = "") -> dict: return call("POST", "/computers", json={"name": name} if name else {}).json() -@mcp.tool() +@tool def computer_list() -> dict: """List all computers with state, resources and VNC URL. `vnc_url` is bound to the *host's* loopback and its port changes on every wake — @@ -104,7 +116,7 @@ def computer_list() -> dict: return call("GET", "/computers").json() -@mcp.tool() +@tool def computer_screenshot(computer_id: str, marks: bool = False) -> Image: """Screenshot of the computer's display (1280x800 by default; see computer_list display for the actual size). Wakes the computer if asleep. @@ -115,7 +127,7 @@ def computer_screenshot(computer_id: str, marks: bool = False) -> Image: return Image(data=r.content, format="png") -@mcp.tool() +@tool def computer_action(computer_id: str, type: str, x: int | None = None, y: int | None = None, button: str | None = None, text: str | None = None, keys: str | None = None, dy: int | None = None, ms: int | None = None, @@ -145,7 +157,7 @@ def computer_action(computer_id: str, type: str, x: int | None = None, y: int | return out -@mcp.tool() +@tool def computer_exec(computer_id: str, command: str, timeout_s: int = 30) -> dict: """Run a shell command on the computer (bash, as user 'agent').""" return call("POST", f"/computers/{computer_id}/exec", params={"wake": "true"}, @@ -153,7 +165,7 @@ def computer_exec(computer_id: str, command: str, timeout_s: int = 30) -> dict: timeout=timeout_s + 30).json() -@mcp.tool() +@tool def computer_eval(computer_id: str, expression: str, timeout_s: int = 20) -> dict: """Evaluate a JavaScript expression in the computer's active browser tab (CDP Runtime.evaluate, JSON result, promises awaited). Prefer this over @@ -171,7 +183,7 @@ def computer_eval(computer_id: str, expression: str, timeout_s: int = 20) -> dic timeout=timeout_s + 20).json() -@mcp.tool() +@tool def computer_navigate(computer_id: str, url: str, timeout_s: int = 30) -> dict: """Point the computer's browser at url and block until the page has loaded. One call — do not follow it with readyState polling. Returns @@ -187,7 +199,7 @@ def computer_navigate(computer_id: str, url: str, timeout_s: int = 30) -> dict: timeout=timeout_s + 20).json() -@mcp.tool() +@tool def computer_snapshot(computer_id: str) -> dict: """Numbered list of the visible interactive elements on the computer's active browser tab — the cheap way to see what's clickable. PREFER THIS OVER @@ -207,7 +219,7 @@ def computer_snapshot(computer_id: str) -> dict: return call("GET", f"/computers/{computer_id}/page", params={"wake": "true"}).json() -@mcp.tool() +@tool def computer_click_element(computer_id: str, ref: int, name: str | None = None, text: str | None = None, screenshot: bool = False) -> dict: """Click element [ref] from the last computer_snapshot. Pass name (the quoted @@ -233,7 +245,7 @@ def computer_click_element(computer_id: str, ref: int, name: str | None = None, params={"wake": "true"}, json=body, timeout=60).json() -@mcp.tool() +@tool def computer_hover(computer_id: str, ref: int, name: str | None = None) -> dict: """Hover the OS pointer over snapshot [ref] without clicking — opens menus that only appear on hover. Pass name so a changed page is refused.""" @@ -244,7 +256,7 @@ def computer_hover(computer_id: str, ref: int, name: str | None = None) -> dict: params={"wake": "true"}, json=body, timeout=40).json() -@mcp.tool() +@tool def computer_upload(computer_id: str, ref: int, path: str, name: str | None = None) -> dict: """Assign a file already on the computer (path under /home/agent/, ≤5MB) to snapshot [ref], which must be input[type=file]. Never send file bytes through @@ -256,7 +268,7 @@ def computer_upload(computer_id: str, ref: int, path: str, name: str | None = No params={"wake": "true"}, json=body, timeout=90).json() -@mcp.tool() +@tool def computer_fill(computer_id: str, fields: list, submit: bool = False) -> dict: """Fill a whole form in ONE call: fields=[{"ref": 13, "value": "jane@x.com"}, …] with refs from computer_snapshot. Uses native value setters + input/change @@ -275,7 +287,7 @@ def computer_fill(computer_id: str, fields: list, submit: bool = False) -> dict: params={"wake": "true"}, json=body, timeout=60).json() -@mcp.tool() +@tool def computer_wait_for(computer_id: str, selector: str | None = None, text: str | None = None, gone: bool = False, network_idle: bool = False, timeout_s: int = 30) -> dict: @@ -301,7 +313,7 @@ def computer_wait_for(computer_id: str, selector: str | None = None, text: str | params={"wake": "true"}, json=body, timeout=timeout_s + 30).json() -@mcp.tool() +@tool def computer_tabs(computer_id: str, action: str = "list", target_id: str | None = None, url: str | None = None) -> dict: """Browser tab management: action=list|activate|new|close. list returns @@ -319,7 +331,7 @@ def computer_tabs(computer_id: str, action: str = "list", target_id: str | None params={"wake": "true"}, json=body, timeout=40).json() -@mcp.tool() +@tool def computer_capture_start(computer_id: str, url_pattern: str) -> dict: """Start capturing network response bodies in the computer's active browser tab whose URL matches url_pattern (a regex). A browser-level wiretap: survives page/SPA @@ -331,7 +343,7 @@ def computer_capture_start(computer_id: str, url_pattern: str) -> dict: json={"pattern": url_pattern}).json() -@mcp.tool() +@tool def computer_capture_read(computer_id: str, stop: bool = False) -> dict: """Drain captured network responses from the active browser tab. Returns {items, running, error}; each item is {ts,url,status,body,truncated} on success @@ -343,7 +355,7 @@ def computer_capture_read(computer_id: str, stop: bool = False) -> dict: return call(method, f"/computers/{computer_id}/capture", params={"wake": "true"}).json() -@mcp.tool() +@tool def computer_login(computer_id: str, credential: str, url: str, idempotency_key: str | None = None, proof_spec: dict | None = None) -> dict: """Log into a site using a vaulted credential (added by the human via CLI/fill). @@ -380,14 +392,14 @@ def computer_login(computer_id: str, credential: str, url: str, json=body, timeout=280).json() -@mcp.tool() +@tool def computer_file_put(computer_id: str, path: str, content_b64: str) -> dict: """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() +@tool def computer_file_get(computer_id: str, path: str) -> dict: """Read a file from the computer. Paths must be under /home/agent/. Text files come back readable: @@ -433,7 +445,7 @@ def skill_content_risky(content): return m.group(0)[:60] if m else None -@mcp.tool() +@tool def case_skill(computer_id: str, action: str, name: str = "", content: str = "") -> dict: """Procedural memory: save a browser/desktop task you just completed as a skill, and reuse skills on later runs. action=list|read|save. @@ -504,7 +516,7 @@ def case_skill(computer_id: str, action: str, name: str = "", content: str = "") raise RuntimeError("action must be list|read|save") -@mcp.tool() +@tool def computer_sleep(computer_id: str) -> dict: """Hibernate the computer. Disk state (sessions, cookies, files) survives, and it wakes in seconds. Sleeping frees the host's RAM so other computers can run. @@ -512,7 +524,7 @@ def computer_sleep(computer_id: str) -> dict: return call("POST", f"/computers/{computer_id}/sleep").json() -@mcp.tool() +@tool def auth_attempt_wait(attempt_id: str, since_revision: int | None = None, max_wait_s: int = 240) -> dict: """Block in this tool call until the auth attempt advances or the budget ends. @@ -580,7 +592,7 @@ def auth_attempt_wait(attempt_id: str, since_revision: int | None = None, continue -@mcp.tool() +@tool def handoff_request(computer_id: str, prompt: str, kind: str = "approval") -> dict: """Ask the human for help. kind is required semantically — one of: 'approval'|'question' → code/text Assist form; @@ -594,7 +606,7 @@ def handoff_request(computer_id: str, prompt: str, kind: str = "approval") -> di json={"kind": kind, "prompt": prompt}).json() -@mcp.tool() +@tool def handoff_list() -> dict: """List pending handoffs across all computers (id, computer_id, kind, prompt, status, continuation, domain). Status values: pending | validating | completed | @@ -606,7 +618,7 @@ def handoff_list() -> dict: return call("GET", "/handoffs", params={"status": "pending"}).json() -@mcp.tool() +@tool def handoff_get(handoff_id: str) -> dict: """Fetch one handoff/challenge by id (includes screenshot). For login journeys prefer auth_attempt_wait(attempt_id) — this is for a specific challenge only. @@ -621,7 +633,7 @@ def handoff_get(handoff_id: str) -> dict: # scheduler itself still runs (REST/CLI unchanged); set CASE_MCP_SCHEDULES=1 # to expose these tools again. if os.environ.get("CASE_MCP_SCHEDULES") == "1": - @mcp.tool() + @tool def schedule_create(computer_id: str, prompt: str, kind: str = "daily", spec: str = "09:00", name: str = "", jitter_s: int = 300, tz: str = "") -> dict: @@ -636,26 +648,26 @@ def schedule_create(computer_id: str, prompt: str, kind: str = "daily", return call("POST", f"/computers/{computer_id}/schedules", json=body).json() - @mcp.tool() + @tool def schedule_list(computer_id: str) -> list: """List schedules for a computer.""" return call("GET", f"/computers/{computer_id}/schedules").json() - @mcp.tool() + @tool def schedule_delete(schedule_id: str) -> str: """Delete a schedule by id.""" call("DELETE", f"/schedules/{schedule_id}") return "deleted" - @mcp.tool() + @tool def schedule_run(schedule_id: str) -> dict: """Fire a schedule immediately (async). Returns {status, schedule}.""" return call("POST", f"/schedules/{schedule_id}/run").json() - @mcp.tool() + @tool def schedule_runs(schedule_id: str) -> list: """List past runs for a schedule (status, summary, timestamps).""" return call("GET", f"/schedules/{schedule_id}/runs").json() diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py index a3d3f19..fcdf7bb 100644 --- a/tests/test_mcp_http.py +++ b/tests/test_mcp_http.py @@ -77,20 +77,23 @@ def test_call_falls_back_to_the_status_unchained(): assert chained is None, chained # a chained decode error buries the status +LIST_CALL = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "computer_list", "arguments": {}}} +ACCEPT = {"Accept": "application/json, text/event-stream"} + + def _mcp_statuses(requests_, **env): - """POST a tools/call to a fresh HTTP app per (Host, Origin); returns the statuses.""" + """POST a tools/call per (Host, Origin) to one HTTP app; returns the statuses.""" from starlette.testclient import TestClient m = _load(CASE_MCP_HTTP="1", CASE_MCP_BIND="0.0.0.0", **env) m.requests = types.SimpleNamespace(request=lambda *a, **kw: _Resp(200, {"computers": []})) - body = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", - "params": {"name": "computer_list", "arguments": {}}} out = [] with TestClient(m.mcp.streamable_http_app()) as c: for host, origin in requests_: - h = {"Host": host, "Accept": "application/json, text/event-stream"} + h = {"Host": host, **ACCEPT} if origin: h["Origin"] = origin - out.append(c.post("/mcp", headers=h, json=body).status_code) + out.append(c.post("/mcp", headers=h, json=LIST_CALL).status_code) return out @@ -109,6 +112,33 @@ def test_http_refuses_a_rebound_host(): assert codes == [421, 403, 200, 200, 200, 200, 200], codes +def test_slow_tool_leaves_health_answering(): + # FastMCP runs sync tools on the event loop: a 280s computer_login froze /health + # and every other client until it returned + import threading + import time + from starlette.testclient import TestClient + m = _load(CASE_MCP_HTTP="1") + started, release = threading.Event(), threading.Event() + + def slow(*a, **kw): + started.set() + release.wait(10) + return _Resp(200, {"computers": []}) + m.requests = types.SimpleNamespace(request=slow) + with TestClient(m.mcp.streamable_http_app(), base_url="http://127.0.0.1:8788") as c: + call = threading.Thread(target=c.post, args=("/mcp",), + kwargs={"headers": ACCEPT, "json": LIST_CALL}) + call.start() + assert started.wait(5) + t0 = time.time() + assert c.get("/health").text == "ok" + took = time.time() - t0 + release.set() + call.join() + assert took < 2, took + + def test_optional_params_accept_explicit_null(): # clients send "x": null for an unset optional; `x: int = None` rejected that import asyncio From 9c2894d21f86c6155b208ed890aee4fd5b15e496 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:55:28 +0000 Subject: [PATCH 008/126] Drop an unused headers parameter and a dead branch in the MCP server. _headers took extra headers no caller passed, and the end of the auth_attempt_wait loop had two branches that both continued. The loop keeps its one early return for a human wait that is out of budget. --- mcp/case_mcp.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/mcp/case_mcp.py b/mcp/case_mcp.py index c5c0f49..395b0ab 100644 --- a/mcp/case_mcp.py +++ b/mcp/case_mcp.py @@ -75,12 +75,11 @@ async def run(**kw): return fn -def _headers(**extra): +def _headers(): h = {"X-Case-Session": SESSION} tok = (os.environ.get("CASE_TOKEN") or "").strip() if tok: h["Authorization"] = "Bearer " + tok - h.update(extra) return h @@ -579,17 +578,12 @@ def auth_attempt_wait(attempt_id: str, since_revision: int | None = None, if last.get("wait_status") == "timeout": continue # Intermediate advance (new challenge / proving) — keep waiting inside budget. - if attempt["status"] == "awaiting_human" and last.get("changed"): - # Still human-needed; continue waiting for the next Assist action unless - # budget is nearly gone — then surface handoff_pending so the agent can - # re-enter wait without asking the user. - if deadline - time.time() < 5: - return {"wait_status": "timeout", "changed": True, "attempt": attempt, - "attempt_id": attempt["id"], "revision": attempt["revision"], - **lr} - continue - # Non-terminal progress (advancing/proving) — keep polling. - continue + # Still human-needed with the budget nearly gone: surface handoff_pending so + # the agent can re-enter wait without asking the user. + if (attempt["status"] == "awaiting_human" and last.get("changed") + and deadline - time.time() < 5): + return {"wait_status": "timeout", "changed": True, "attempt": attempt, + "attempt_id": attempt["id"], "revision": attempt["revision"], **lr} @tool From c1121c6424a98e8d7b9acac954df10066c022e1a Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:55:40 +0000 Subject: [PATCH 009/126] Let the no-credential-write test allow a read-only credential tool. It failed on any tool name containing "cred", so a credential_list would have tripped it. It now looks for a write verb next to "cred", which is the invariant SECURITY.md states. --- tests/test_mcp_http.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py index fcdf7bb..44d55ac 100644 --- a/tests/test_mcp_http.py +++ b/tests/test_mcp_http.py @@ -159,7 +159,9 @@ def test_no_credential_write_tool(): # security invariant: secrets enter via `case cred add` only, never a tool call m = _load() names = list(m.mcp._tool_manager._tools) - assert not [n for n in names if "cred" in n], names + writes = ("add", "create", "set", "put", "update", "save", "store", "write", + "delete", "remove") + assert not [n for n in names if "cred" in n and any(w in n for w in writes)], names if __name__ == "__main__": From a4f710b1a11162251d48679cd1e93232b13a4820 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:59:25 +0000 Subject: [PATCH 010/126] Count overlapping injections and refuse routes that raced one. injecting was a bool, so with /login, /login/resume and /auth/submit_challenge overlapping the first to finish reopened the gate while another was still typing. It is now held while any injecting route runs, and a second /login answers 409 instead of resetting the first one's state. The gate was also only checked when /eval, /action and /exec started, so an awaited eval begun before a login could read the password field and return it. Each injection now bumps a generation, and those routes answer 423 on the way out if one started while they ran. --- image/deskd.py | 54 ++++++++++++++++++++++++++++++++++++++------ tests/test_deskd.py | 55 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 7 deletions(-) diff --git a/image/deskd.py b/image/deskd.py index 4c30879..5387cef 100644 --- a/image/deskd.py +++ b/image/deskd.py @@ -34,6 +34,8 @@ state = { "injecting": False, # screenshots 423 while true + "injections": 0, # injecting routes in flight; injecting is injections > 0 + "inject_gen": 0, # bumped as each injection starts, see injected_since "login": None, # pending challenge ctx for /login/resume "in_login": False, # suppress blocker watchdog during login flows "blocker": None, # {"kind","prompt","fingerprint"} or None @@ -45,11 +47,39 @@ def err(status, code, message): return JSONResponse({"error": {"code": code, "message": message}}, status_code=status) +_inject_lock = threading.Lock() + + +def inject_begin(alone=False): + """Close the gate for one injecting route; alone refuses while another runs.""" + with _inject_lock: + if alone and state["injections"]: + return False + state["injections"] += 1 + state["inject_gen"] += 1 + state["injecting"] = True + return True + + +def inject_end(): + with _inject_lock: + state["injections"] -= 1 + state["injecting"] = state["injections"] > 0 + + def injecting(): if state["injecting"]: return err(423, "credential_injection", "blocked during credential injection") +def injected_since(gen): + # A route that passed the gate before an injection started can still read the page + # during it (an awaited /eval polling the password field), so it gets the 423 too. + if state["inject_gen"] != gen: + return err(423, "credential_injection", "blocked during credential injection") + return injecting() + + @app.middleware("http") async def auth(request: Request, call_next): got = request.headers.get("authorization") or "" @@ -172,6 +202,7 @@ def do_action(a): @app.post("/action") def action(a: dict = Body(...)): + gen = state["inject_gen"] if (r := injecting()): return r try: @@ -183,9 +214,11 @@ def action(a: dict = Body(...)): out = {"ok": True} if a.get("screenshot"): time.sleep(min(int(a.get("delay_ms", 300)), 5000) / 1000) - if (r := injecting()): + if (r := injected_since(gen)): return r out["screenshot_png_b64"] = base64.b64encode(grab()).decode() + if (r := injected_since(gen)): + return r return out @@ -202,6 +235,7 @@ def home_path(path): @app.post("/exec") def exec_(b: dict = Body(...)): + gen = state["inject_gen"] if (r := injecting()): return r if "command" not in b: @@ -217,6 +251,8 @@ def exec_(b: dict = Body(...)): errb = (e.stderr or b"") + b"\n[deskd] command timed out" except (FileNotFoundError, NotADirectoryError, PermissionError): return err(400, "bad_cwd", f"no such directory: {cwd}") + if (r := injected_since(gen)): + return r truncated = len(out) > CAP or len(errb) > CAP return {"exit_code": code, "stdout": out[:CAP].decode(errors="replace"), "stderr": errb[:CAP].decode(errors="replace"), "truncated": truncated} @@ -366,6 +402,7 @@ def press_enter(tab): @app.post("/eval") def eval_(b: dict = Body(...)): + gen = state["inject_gen"] if (r := injecting()): return r if "expression" not in b: @@ -381,6 +418,8 @@ def eval_(b: dict = Body(...)): tab.close() except Exception as e: return err(502, "eval_error", f"{type(e).__name__}: {e}") + if (gated := injected_since(gen)): + return gated exc = r.get("exceptionDetails") if exc: desc = exc.get("exception", {}).get("description") or exc.get("text", "js exception") @@ -701,9 +740,10 @@ def apply_challenge_action(tab, kind, value=None): @app.post("/login") def login(b: dict = Body(...)): cred, url = b["credential"], b["url"] + if not inject_begin(alone=True): + return err(409, "injection_running", "another credential injection is running") state["login"] = None state["in_login"] = True - state["injecting"] = True try: tab = Tab() try: @@ -725,7 +765,7 @@ def login(b: dict = Body(...)): except Exception as e: return {"status": "failed", "reason": f"login error: {type(e).__name__}: {e}"} finally: - state["injecting"] = False + inject_end() if not state["login"]: state["in_login"] = False @@ -737,7 +777,7 @@ def login_resume(b: dict = Body(...)): if not ctx: return err(409, "no_pending_login", "no login is waiting on a handoff") state["login"] = None - state["injecting"] = True + inject_begin() try: tab = Tab() try: @@ -770,7 +810,7 @@ def login_resume(b: dict = Body(...)): except Exception as e: return {"status": "failed", "reason": f"resume error: {type(e).__name__}: {e}"} finally: - state["injecting"] = False + inject_end() state["in_login"] = False @@ -803,7 +843,7 @@ def auth_submit_challenge(b: dict = Body(...)): not isinstance(b.get("domains"), list) or not b["domains"]): return err(400, "bad_request", "body needs 'domains' for otp/code") state["in_login"] = True - state["injecting"] = True + inject_begin() try: tab = Tab() try: @@ -823,7 +863,7 @@ def auth_submit_challenge(b: dict = Body(...)): except Exception as e: return {"ok": False, "reason": f"submit_challenge error: {type(e).__name__}: {e}"} finally: - state["injecting"] = False + inject_end() state["in_login"] = False diff --git a/tests/test_deskd.py b/tests/test_deskd.py index 703e442..a58c58e 100644 --- a/tests/test_deskd.py +++ b/tests/test_deskd.py @@ -8,6 +8,7 @@ import base64 import os import sys +import threading import types import unittest.mock as mock @@ -666,6 +667,60 @@ def test_login_clears_password_field_while_still_gated(): assert deskd.state["injecting"] is False +def test_overlapping_injections_keep_the_gate_closed(): + # The first injecting route to finish used to set injecting False while another + # was still typing, and a second /login stomped the first one's state. + started, release = threading.Event(), threading.Event() + + def apply(tab, kind, value=None): + if not started.is_set(): + started.set() + release.wait(5) + + deskd.state["login"] = {"kind": "approval", "cred_name": "x", "domains": ["site.com"], "at": 0} + with mock.patch.object(deskd, "Tab", RecordingTab), \ + mock.patch.object(deskd, "apply_challenge_action", apply): + resume = threading.Thread(target=deskd.login_resume, args=({"value": "approve"},)) + resume.start() + try: + assert started.wait(5) + assert deskd.auth_submit_challenge({"kind": "approval"}) == {"ok": True} + r = _client().post("/exec", headers=H, json={"command": "echo leaked"}) + assert r.status_code == 423, r.text + again = deskd.login({"credential": {"name": "x", "domains": ["site.com"]}, + "url": "https://site.com/login"}) + assert again.status_code == 409 + finally: + release.set() + resume.join() + assert deskd.state["injecting"] is False and deskd.state["injections"] == 0 + + +class SlowEvalTab(RecordingTab): + """Runtime.evaluate whose promise is still pending while `during` runs.""" + + def __init__(self, during): + super().__init__() + self.ws = types.SimpleNamespace(settimeout=lambda t: None) + self.during = during + + def cmd(self, method, **params): + self.during() + return {"result": {"value": "hunter2"}} + + +def test_eval_started_before_an_injection_is_refused(): + def inject(): + deskd.inject_begin() + deskd.inject_end() + with mock.patch.object(deskd, "Tab", lambda: SlowEvalTab(inject)): + r = _client().post("/eval", headers=H, json={"expression": "pw.value"}) + assert r.status_code == 423 and "hunter2" not in r.text, r.text + with mock.patch.object(deskd, "Tab", lambda: SlowEvalTab(lambda: None)): + r = _client().post("/eval", headers=H, json={"expression": "1"}) + assert r.json() == {"ok": True, "value": "hunter2", "truncated": False}, r.text + + def test_login_rejects_http_redirect_before_form_fill(): tab = RecordingTab() tab.js = lambda expr: "http://site.com/login" if expr == "location.href" else None From e64824b58ccaa54179aa38907ba664d90d2d807a Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:59:35 +0000 Subject: [PATCH 011/126] Give deskd's /login longer than its own worst case before cased gives up. deskd's navigate, field wait, two settles, post-submit wait and TOTP settle add up to about 112s, and cased stopped listening at 95s. A slow login was then failed as a desk error while deskd kept typing and held the gate. cased now waits 125s. --- control-plane/cased.py | 2 +- mcp/case_mcp.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/control-plane/cased.py b/control-plane/cased.py index f21c60e..718b8f9 100644 --- a/control-plane/cased.py +++ b/control-plane/cased.py @@ -967,7 +967,7 @@ def login(cid: str, body: dict = Body(...), wake: bool = False): try: result = desk_json(row, "POST", "/login", - json={"credential": material, "url": body["url"]}, timeout=95) + json={"credential": material, "url": body["url"]}, timeout=125) except ApiError: # domain_mismatch / desk errors must not leave the attempt stuck in # created, that blocks every later login with 409 auth_in_progress. diff --git a/mcp/case_mcp.py b/mcp/case_mcp.py index 395b0ab..d43da5c 100644 --- a/mcp/case_mcp.py +++ b/mcp/case_mcp.py @@ -380,7 +380,7 @@ def computer_login(computer_id: str, credential: str, url: str, Optional CAPTCHA auto-solve (CASE_DBC_*) is capability-gated; unsupported or terminal solver responses fail fast into the same handoff_pending path. - Timeout 280s: deskd login ≤95s plus optional DBC solve (≤60s) + settle/verify + Timeout 280s: deskd login ≤125s plus optional DBC solve (≤60s) + settle/verify + resume; under a typical 300s proxy read timeout.""" body = {"credential": credential, "url": url} if idempotency_key is not None: From 545eba3359c4ea2366bc7c31fab0129e46cb4c11 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:01:57 +0000 Subject: [PATCH 012/126] Return from /exec when bash exits and kill the whole command on timeout. A background job inherits the output pipes, so `sleep 30 & echo started` waited for their EOF until the timeout and reported 124, and a timeout killed only bash and orphaned its children. The command now runs in its own session; /exec stops reading half a second after bash exits and kills the process group on timeout. computer_exec tells agents to start background jobs with nohup and redirected output. --- image/deskd.py | 41 +++++++++++++++++++++++++++++++++++------ mcp/case_mcp.py | 5 ++++- tests/test_deskd.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/image/deskd.py b/image/deskd.py index 5387cef..4bfa437 100644 --- a/image/deskd.py +++ b/image/deskd.py @@ -11,6 +11,7 @@ import json import os import re +import signal import struct import subprocess import threading @@ -233,6 +234,14 @@ def home_path(path): return p if p.startswith(HOME + "/") else None +def _slurp(f, buf): + # drains until EOF even past CAP, so a writer never blocks on a full pipe + for chunk in iter(lambda: f.read1(65536), b""): + if len(buf) <= CAP: + buf.extend(chunk) + f.close() + + @app.post("/exec") def exec_(b: dict = Body(...)): gen = state["inject_gen"] @@ -243,14 +252,34 @@ def exec_(b: dict = Body(...)): timeout = min(int(b.get("timeout_s", 30)), 600) cwd = b.get("cwd", "/home/agent") try: - p = subprocess.run(["bash", "-c", b["command"]], cwd=cwd, env=denv(), - capture_output=True, timeout=timeout) - code, out, errb = p.returncode, p.stdout, p.stderr - except subprocess.TimeoutExpired as e: - code, out = 124, e.stdout or b"" - errb = (e.stderr or b"") + b"\n[deskd] command timed out" + # own session, so a timeout kills the whole command and not just bash + p = subprocess.Popen(["bash", "-c", b["command"]], cwd=cwd, env=denv(), + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + start_new_session=True) except (FileNotFoundError, NotADirectoryError, PermissionError): return err(400, "bad_cwd", f"no such directory: {cwd}") + out, errb = bytearray(), bytearray() + readers = [threading.Thread(target=_slurp, args=io, daemon=True) + for io in ((p.stdout, out), (p.stderr, errb))] + for t in readers: + t.start() + try: + code = p.wait(timeout) + except subprocess.TimeoutExpired: + try: + os.killpg(p.pid, signal.SIGKILL) + except ProcessLookupError: + pass + p.wait() + code = None + # `cmd &` leaves the job holding the pipes after bash exits: stop listening + # shortly after, or the call would last as long as the job + grace = time.time() + 0.5 + for t in readers: + t.join(max(0, grace - time.time())) + out, errb = bytes(out), bytes(errb) + if code is None: + code, errb = 124, errb + b"\n[deskd] command timed out" if (r := injected_since(gen)): return r truncated = len(out) > CAP or len(errb) > CAP diff --git a/mcp/case_mcp.py b/mcp/case_mcp.py index d43da5c..324dc99 100644 --- a/mcp/case_mcp.py +++ b/mcp/case_mcp.py @@ -158,7 +158,10 @@ def computer_action(computer_id: str, type: str, x: int | None = None, y: int | @tool def computer_exec(computer_id: str, command: str, timeout_s: int = 30) -> dict: - """Run a shell command on the computer (bash, as user 'agent').""" + """Run a shell command on the computer (bash, as user 'agent'). Returns when bash + exits; a timeout kills the command and everything it started. Start background + jobs as `nohup cmd >/dev/null 2>&1 &` — output a job writes after bash exits is + lost.""" return call("POST", f"/computers/{computer_id}/exec", params={"wake": "true"}, json={"command": command, "timeout_s": timeout_s}, timeout=timeout_s + 30).json() diff --git a/tests/test_deskd.py b/tests/test_deskd.py index a58c58e..9bedd30 100644 --- a/tests/test_deskd.py +++ b/tests/test_deskd.py @@ -7,8 +7,10 @@ """ import base64 import os +import subprocess import sys import threading +import time import types import unittest.mock as mock @@ -574,6 +576,40 @@ def test_injection_gates_screenshot_exec_and_file(): deskd.state["injecting"] = False +def _exec(command, timeout_s): + t0 = time.time() + r = _client().post("/exec", headers=H, json={ + "command": command, "timeout_s": timeout_s, "cwd": tempfile.gettempdir()}) + return r.json(), time.time() - t0 + + +def _running(pattern): + return subprocess.run(["pgrep", "-f", pattern], capture_output=True).returncode == 0 + + +def test_exec_returns_when_bash_exits_despite_a_background_job(): + # the job inherits the pipes; waiting for their EOF ran the call into its timeout + out, took = _exec("sleep 30.17 & echo started", 10) + try: + assert out["exit_code"] == 0 and out["stdout"] == "started\n", out + assert took < 5, took + finally: + subprocess.run(["pkill", "-f", "sleep 30.17"]) + + +def test_exec_timeout_kills_the_whole_command(): + out, took = _exec("sleep 30.23; echo done", 1) + assert out["exit_code"] == 124 and "timed out" in out["stderr"], out + assert took < 5, took + time.sleep(0.2) + assert not _running("sleep 30.23") # only bash died, the child lingered + + +def test_exec_passes_a_command_own_124_through(): + out, _ = _exec("exit 124", 5) + assert out["exit_code"] == 124 and "timed out" not in out["stderr"], out + + def test_file_get_rejects_paths_outside_home(): c = _client() assert c.get("/file", headers=H, params={"path": "/etc/passwd"}).status_code == 400 From 67aaf9de7e294cca9e6540d03bbbcaaf79f82ffc Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:03:14 +0000 Subject: [PATCH 013/126] Answer bad deskd input with 400 and report failed navigations as such. A non-numeric timeout_s or Content-Length, or a /login or /login/resume body without its credential or value, raised inside the handler and came back as a bare 500. Those are 400 bad_request now. navigate() ignored Page.navigate's errorText, so a DNS failure landed on chrome-error:// and login reported a foreign page origin; it now fails with the navigation error. --- image/deskd.py | 25 ++++++++++++++++++++----- tests/test_deskd.py | 24 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/image/deskd.py b/image/deskd.py index 4bfa437..dd9d3d5 100644 --- a/image/deskd.py +++ b/image/deskd.py @@ -249,7 +249,10 @@ def exec_(b: dict = Body(...)): return r if "command" not in b: return err(400, "bad_request", "command required") - timeout = min(int(b.get("timeout_s", 30)), 600) + try: + timeout = min(int(b.get("timeout_s", 30)), 600) + except (TypeError, ValueError): + return err(400, "bad_request", "timeout_s must be an integer") cwd = b.get("cwd", "/home/agent") try: # own session, so a timeout kills the whole command and not just bash @@ -294,8 +297,11 @@ async def file_put(request: Request, path: str): 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") + try: + if int(request.headers.get("content-length") or 0) > FILE_MAX: + return err(413, "too_large", f"file over {FILE_MAX} bytes") + except ValueError: + return err(400, "bad_request", "bad content-length") data = bytearray() async for chunk in request.stream(): if len(data) + len(chunk) > FILE_MAX: @@ -363,7 +369,9 @@ def close(self): def navigate(tab, url, timeout=25): - tab.cmd("Page.navigate", url=url) + # a DNS or TLS failure lands on chrome-error://, which would read as a foreign origin + if (e := tab.cmd("Page.navigate", url=url).get("errorText")): + raise RuntimeError(f"navigation failed: {e}") t0 = time.time() while time.time() - t0 < timeout: if tab.js("document.readyState") == "complete": @@ -436,7 +444,10 @@ def eval_(b: dict = Body(...)): return r if "expression" not in b: return err(400, "bad_request", "body needs 'expression'") - timeout = min(int(b.get("timeout_s", 20)), 120) + try: + timeout = min(int(b.get("timeout_s", 20)), 120) + except (TypeError, ValueError): + return err(400, "bad_request", "timeout_s must be an integer") try: tab = Tab() try: @@ -768,6 +779,8 @@ def apply_challenge_action(tab, kind, value=None): @app.post("/login") def login(b: dict = Body(...)): + if "credential" not in b or "url" not in b: + return err(400, "bad_request", "body needs 'credential' and 'url'") cred, url = b["credential"], b["url"] if not inject_begin(alone=True): return err(409, "injection_running", "another credential injection is running") @@ -801,6 +814,8 @@ def login(b: dict = Body(...)): @app.post("/login/resume") def login_resume(b: dict = Body(...)): + if "value" not in b: + return err(400, "bad_request", "body needs 'value'") value = str(b["value"]) ctx = state["login"] if not ctx: diff --git a/tests/test_deskd.py b/tests/test_deskd.py index 9bedd30..0e2dfe3 100644 --- a/tests/test_deskd.py +++ b/tests/test_deskd.py @@ -610,6 +610,30 @@ def test_exec_passes_a_command_own_124_through(): assert out["exit_code"] == 124 and "timed out" not in out["stderr"], out +def test_bad_input_is_400_not_500(): + c = _client() + for r in (c.post("/exec", headers=H, json={"command": "id", "timeout_s": "abc"}), + c.post("/eval", headers=H, json={"expression": "1", "timeout_s": None}), + c.put("/file", headers={**H, "content-length": "x"}, + params={"path": "/home/agent/x"}, content=b""), + c.post("/login", headers=H, json={"url": "https://site.com/login"}), + c.post("/login/resume", headers=H, json={})): + assert r.status_code == 400, (r.request.url, r.status_code, r.text) + assert r.json()["error"]["code"] == "bad_request" + + +def test_navigate_surfaces_the_navigation_error(): + # chromium parks a failed load on chrome-error://, which login then reported as + # a foreign origin instead of the DNS failure it was + tab = RecordingTab() + tab.cmd = lambda method, **p: {"frameId": "f", "errorText": "net::ERR_NAME_NOT_RESOLVED"} + with mock.patch.object(deskd, "Tab", lambda: tab): + out = deskd.login({"credential": {"name": "x", "domains": ["site.com"]}, + "url": "https://site.com/login"}) + assert out == {"status": "failed", "reason": "login error: RuntimeError: " + "navigation failed: net::ERR_NAME_NOT_RESOLVED"}, out + + def test_file_get_rejects_paths_outside_home(): c = _client() assert c.get("/file", headers=H, params={"path": "/etc/passwd"}).status_code == 400 From cd1ee55cc77f90c532650e250ea81d4588d9ca7d Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:03:59 +0000 Subject: [PATCH 014/126] Read the watchdog's page text and URL in one evaluate. The blocker watchdog runs every 2s on every awake desk and made two CDP round trips for it. One array expression returns both, with the text cut at the same 3000 chars. --- image/deskd.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/image/deskd.py b/image/deskd.py index dd9d3d5..5027df0 100644 --- a/image/deskd.py +++ b/image/deskd.py @@ -497,6 +497,7 @@ def eval_(b: dict = Body(...)): FOCUS_CODE = (f"(()=>{{{VIS}const c=[...document.querySelectorAll('{CODE_SEL}')].find(vis);" "if(!c)return false; c.focus(); if(c.select)c.select(); return true;})()") PAGE_TEXT = "(document.body ? document.body.innerText.slice(0, 5000) : '')" +WATCH_JS = "[(document.body ? document.body.innerText.slice(0, 3000) : ''), location.href]" CLEAR_PASS = "[...document.querySelectorAll('input[type=\"password\"]')].forEach(p=>{p.value=''})" # Generic auth observation — no website names. The JS only collects raw material @@ -1164,8 +1165,7 @@ def watchdog(): try: tab = Tab() try: - text = (tab.js(PAGE_TEXT) or "")[:3000] - href = tab.js("location.href") or "" + text, href = tab.js(WATCH_JS) or ("", "") finally: tab.close() # text only, and only once the page has painted. Matching the href raised a From cc3babf9e6b4e6119514fe2081f3671935fe130b Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:04:03 +0000 Subject: [PATCH 015/126] Share page-target discovery between Tab and the capture worker. Both listed the CDP targets, picked the first page and opened a websocket to it with the same options. page_ws() does that once. --- image/deskd.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/image/deskd.py b/image/deskd.py index 5027df0..9c90032 100644 --- a/image/deskd.py +++ b/image/deskd.py @@ -335,16 +335,21 @@ def file_get(path: str): # ---------- CDP ---------- +def page_ws(): + """A websocket to the most-recently-active Chromium page.""" + pages = [t for t in requests.get(f"{CDP}/json/list", timeout=5).json() if t["type"] == "page"] + if not pages: + raise RuntimeError("no chromium page target") + # suppress_origin: chromium 136+ rejects CDP websockets with an Origin header + return websocket.create_connection(pages[0]["webSocketDebuggerUrl"], timeout=30, + suppress_origin=True) + + class Tab: """One websocket to the most-recently-active Chromium page.""" def __init__(self): - pages = [t for t in requests.get(f"{CDP}/json/list", timeout=5).json() if t["type"] == "page"] - if not pages: - raise RuntimeError("no chromium page target") - # suppress_origin: chromium 136+ rejects CDP websockets with an Origin header - self.ws = websocket.create_connection(pages[0]["webSocketDebuggerUrl"], timeout=30, - suppress_origin=True) + self.ws = page_ws() self._id = 0 def cmd(self, method, **params): @@ -1013,12 +1018,7 @@ def capture_worker(cap): pending = {} # our command id -> {url,status}, awaiting body reply next_id = [10000] # keep clear of any low ids; events have no id anyway try: - pages = [t for t in requests.get(f"{CDP}/json/list", timeout=5).json() - if t["type"] == "page"] - if not pages: - raise RuntimeError("no chromium page target") - ws = websocket.create_connection(pages[0]["webSocketDebuggerUrl"], timeout=30, - suppress_origin=True) + ws = page_ws() ws.settimeout(2) ws.send(json.dumps({"id": 1, "method": "Network.enable"})) while not cap["stop"].is_set(): From 950f9c65e605c02e2be91c2b5457ef4cf2028a87 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:04:37 +0000 Subject: [PATCH 016/126] Export ~/.case/env in bin/case and survive an empty auth array on bash 3.2. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The env file was sourced without export, so nohup cased and the cred add python never saw CASE_TOKEN and friends, and `up` sourced it a second time. It is sourced once under set -a now. "${auth[@]}" with CASE_TOKEN unset died as an unbound variable under set -u on macOS's bash 3.2; the ${auth[@]+…} form expands to nothing there. --- bin/case | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bin/case b/bin/case index 6e7e5cf..5a8f4ff 100755 --- a/bin/case +++ b/bin/case @@ -2,18 +2,19 @@ # SPDX-License-Identifier: MIT # case — tiny CLI, thin curl wrappers over cased. set -euo pipefail -[ -f ~/.case/env ] && . ~/.case/env # ntfy, CASE_TOKEN, … +# ntfy, CASE_TOKEN, … — exported (set -a) so nohup cased and cred add's python see it +if [ -f ~/.case/env ]; then set -a; . ~/.case/env; set +a; fi BASE="${CASE_URL:-http://127.0.0.1:8787/v1}" pp() { python3 -m json.tool 2>/dev/null || cat; } auth=() [ -n "${CASE_TOKEN:-}" ] && auth=(-H "Authorization: Bearer ${CASE_TOKEN}") -c() { curl -sf "${auth[@]}" "$@"; } +# ${a[@]+…}: bash 3.2 (macOS) calls an empty array unbound under set -u +c() { curl -sf ${auth[@]+"${auth[@]}"} "$@"; } case "${1:-help}" in up) # boot the substrate: colima VM (Darwin) or native docker (Linux) + cased. # Idempotent. CASE_CPU/CASE_MEM override 4/4. If cased is systemd-managed # let systemd own it — don't spawn a rival process. - [ -f ~/.case/env ] && . ~/.case/env # ntfy topics etc. if [ "$(uname)" = Darwin ]; then colima status >/dev/null 2>&1 || colima start --cpu "${CASE_CPU:-4}" --memory "${CASE_MEM:-4}" else From 810d69f1ec9f6ea1bc78e636a5635b6dd4f10160 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:04:49 +0000 Subject: [PATCH 017/126] Ignore node_modules and Python caches at any depth in the build context. .dockerignore patterns match from the context root, so web/node_modules and the __pycache__ directories under control-plane and mcp were sent to the daemon, and COPY web/ laid the host's node_modules over the one npm ci had just built. --- .dockerignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.dockerignore b/.dockerignore index 083edf1..82204b1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,9 @@ .git .venv __pycache__ +**/__pycache__ *.pyc +**/*.pyc .pytest_cache .worktrees .claude @@ -11,6 +13,7 @@ __pycache__ *.key *.log node_modules +**/node_modules web/web-ui/threads.json .env *.env.local From a4183cafe99af0b8061efd443d0c7f921f28068d Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:05:36 +0000 Subject: [PATCH 018/126] Start Xvfb at depth 24 whatever depth DESK_RESOLUTION names. start.sh passed any WxHxD through, but deskd's framebuffer grab only reads 32bpp, so a desk started at 1280x800x16 came up with every screenshot failing. Another depth is now replaced with 24 and logged. --- image/start.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/image/start.sh b/image/start.sh index accb9ab..0bbb236 100644 --- a/image/start.sh +++ b/image/start.sh @@ -5,8 +5,12 @@ RES="${DESK_RESOLUTION:-1280x800x24}" # Xvfb needs WxHxD and dies on bare WxH; depth 24 is the only one deskd's -# XWD->PNG grab supports (32bpp), so it is also the only default we append. -case "$RES" in *x*x*) ;; *) RES="${RES}x24" ;; esac +# XWD->PNG grab supports (32bpp), so it is appended, and replaces any other. +case "$RES" in + *x*x24) ;; + *x*x*) echo "[start] depth ${RES##*x} unsupported, using 24" >&2; RES="${RES%x*}x24" ;; + *) RES="${RES}x24" ;; +esac # libXcursor honors this in every X client — the one switch that themes the # cursor everywhere (chromium reads it via GTK settings too, seeded below). From 7891f757d18527f4f16b11401f7e7bf89457a154 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:06:08 +0000 Subject: [PATCH 019/126] Trap TERM from the first line of start.sh and fit shutdown into 10s. The trap was installed only after everything had started, and PID 1 ignores a signal it has no handler for, so a sleep during startup did nothing until docker's SIGKILL. The quiesce call (6s) and the wait for chromium (8s) also outlasted docker stop's 10s, so the pkill fallback never ran. The trap now comes first, each step is a no-op for what has not started, and the budget is 3s + 4s + 2s. --- image/start.sh | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/image/start.sh b/image/start.sh index 0bbb236..3b59d6f 100644 --- a/image/start.sh +++ b/image/start.sh @@ -3,6 +3,23 @@ # PID 1 of a case-desk container. Brings up display, window manager, VNC, Chromium, deskd. # Traps SIGTERM (docker stop = sleep) so Chromium exits cleanly and flushes its profile. +# Installed before anything starts: PID 1 ignores a TERM it has no handler for. +# Every step is a no-op for what has not started yet. The whole thing fits in +# the 10s docker stop gives it (3 + 4 + 2), or the pkill fallback never runs. +term() { + kill "$CHROME_PID" 2>/dev/null # stop the respawn loop first + # Browser.close = graceful shutdown that persists fresh cookies; SIGTERM alone + # is chromium's fast path and loses anything newer than the last commit batch + curl -s -m 3 -X POST -H "Authorization: Bearer $DESK_TOKEN" \ + http://localhost:8000/quiesce >/dev/null 2>&1 + for _ in $(seq 1 20); do pgrep -f 'chrome-profile' >/dev/null || break; sleep 0.2; done + pkill -TERM -f 'chrome-profile' 2>/dev/null # fallback if CDP was unreachable + for _ in $(seq 1 10); do pgrep -f 'chrome-profile' >/dev/null || break; sleep 0.2; done + kill -TERM "$DESKD_PID" "$XVFB_PID" 2>/dev/null + exit 0 +} +trap term TERM INT + RES="${DESK_RESOLUTION:-1280x800x24}" # Xvfb needs WxHxD and dies on bare WxH; depth 24 is the only one deskd's # XWD->PNG grab supports (32bpp), so it is appended, and replaces any other. @@ -113,18 +130,4 @@ CHROME_PID=$! /opt/deskd/bin/python /opt/deskd/deskd.py & DESKD_PID=$! -term() { - kill "$CHROME_PID" 2>/dev/null # stop the respawn loop first - # Browser.close = graceful shutdown that persists fresh cookies; SIGTERM alone - # is chromium's fast path and loses anything newer than the last commit batch - curl -s -m 6 -X POST -H "Authorization: Bearer $DESK_TOKEN" \ - http://localhost:8000/quiesce >/dev/null 2>&1 - for _ in $(seq 1 40); do pgrep -f 'chrome-profile' >/dev/null || break; sleep 0.2; done - pkill -TERM -f 'chrome-profile' 2>/dev/null # fallback if CDP was unreachable - for _ in $(seq 1 10); do pgrep -f 'chrome-profile' >/dev/null || break; sleep 0.2; done - kill -TERM "$DESKD_PID" "$XVFB_PID" 2>/dev/null - exit 0 -} -trap term TERM INT - wait "$DESKD_PID" From 3916223b7c05034cbeca4e01fa1d0aa3d6c23922 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:07:19 +0000 Subject: [PATCH 020/126] Pin the rest of deskd's transitive dependencies. annotated-doc, annotated-types, pydantic-core, typing-extensions and typing-inspection still floated, although the comment says the transitives are pinned. They are pinned to what pip resolves for the current direct pins, versions only as before. --- image/Dockerfile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/image/Dockerfile b/image/Dockerfile index 948cb6b..00692c9 100644 --- a/image/Dockerfile +++ b/image/Dockerfile @@ -47,8 +47,10 @@ RUN python3 -m venv /opt/deskd \ && /opt/deskd/bin/pip install --no-cache-dir --upgrade pip==26.2 setuptools==83.0.0 \ && /opt/deskd/bin/pip install --no-cache-dir \ fastapi==0.141.1 uvicorn==0.52.4 websocket-client==1.9.2 requests==2.34.2 \ - anyio==4.15.0 certifi==2026.7.22 charset-normalizer==3.5.1 click==8.5.0 \ - h11==0.16.0 idna==3.19 pydantic==2.13.5 starlette==1.6.0 urllib3==2.7.0 \ + annotated-doc==0.0.5 annotated-types==0.8.0 anyio==4.15.0 certifi==2026.7.22 \ + charset-normalizer==3.5.1 click==8.5.0 h11==0.16.0 idna==3.19 pydantic==2.13.5 \ + pydantic-core==2.46.5 starlette==1.6.0 typing-extensions==4.16.0 \ + typing-inspection==0.4.4 urllib3==2.7.0 \ && /opt/deskd/bin/pip install --no-cache-dir --no-deps websockify==0.13.0 # Passwordless sudo: the container IS the agent's sandbox — self-serve apt From 647df1254f360f80d9fa97cd216758eadaab0114 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:07:23 +0000 Subject: [PATCH 021/126] Write pip.conf before the desktop COPYs and describe the LOOK re-seed. The pip.conf layer sat after COPY deskd.py, so every deskd edit re-ran it; it now comes before the files that change. The comment above the look assets said configs are seeded only when missing, but start.sh force-seeds once after each LOOK bump. --- image/Dockerfile | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/image/Dockerfile b/image/Dockerfile index 00692c9..c67f0d1 100644 --- a/image/Dockerfile +++ b/image/Dockerfile @@ -64,9 +64,13 @@ RUN useradd -m -u 1000 -s /bin/bash agent \ RUN apt-get update && apt-get install -y --no-install-recommends thunar xfdesktop4 \ && rm -rf /var/lib/apt/lists/* +# Unlock PEP-668 so pip works. Non-root pip defaults to --user (~/.local), +# which lives on the volume — pip installs survive container recreation. +RUN printf '[global]\nbreak-system-packages = true\n' > /etc/pip.conf + # Desktop look: wallpaper, single-bar panel config, Greybird theme, and a custom # cursor theme ("case", left_ptr in 24/32/48px). Per-user configs are seeded from -# /usr/share/case by start.sh only when missing, so user changes survive. +# /usr/share/case by start.sh: only when missing, except once after each LOOK bump. COPY assets/wallpaper.png /usr/share/backgrounds/case.png COPY assets/cursor-left_ptr /usr/share/icons/case/cursors/left_ptr RUN printf '[Icon Theme]\nName=case\n' > /usr/share/icons/case/index.theme @@ -75,9 +79,6 @@ COPY assets/gtk-settings.ini assets/xfce4-panel.xml assets/xfwm4.xml assets/xfce COPY deskd.py /opt/deskd/deskd.py COPY start.sh /usr/local/bin/start.sh RUN chmod +x /usr/local/bin/start.sh -# Unlock PEP-668 so pip works. Non-root pip defaults to --user (~/.local), -# which lives on the volume — pip installs survive container recreation. -RUN printf '[global]\nbreak-system-packages = true\n' > /etc/pip.conf USER agent WORKDIR /home/agent From 8e0a8b368bc16cbef19731bbd353296bfc9c7c90 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:07:39 +0000 Subject: [PATCH 022/126] Ship tzdata with the control plane. The scheduler resolves named zones through zoneinfo, which reads the system database first and the tzdata package second. A slim base image may have neither, and then every tz-bearing schedule fell back as if its zone were unknown. --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index 218b5f8..90916e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,5 @@ requests==2.34.2 mcp==1.28.1 Pillow==12.3.0 websockets==15.0.1 +# zoneinfo data for named schedule timezones when the base image has none +tzdata==2026.4 From 99843c6eeae99125bed94f954f38ed1276ac9fe2 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:08:31 +0000 Subject: [PATCH 023/126] Let the case cursor theme inherit Adwaita. The theme ships only left_ptr and its index.theme named no parent, so text, hand and resize cursors had no themed image to come from. Adwaita is installed with GTK 3, which chromium and xfwm4 pull in. --- image/Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/image/Dockerfile b/image/Dockerfile index c67f0d1..d0480de 100644 --- a/image/Dockerfile +++ b/image/Dockerfile @@ -73,7 +73,8 @@ RUN printf '[global]\nbreak-system-packages = true\n' > /etc/pip.conf # /usr/share/case by start.sh: only when missing, except once after each LOOK bump. COPY assets/wallpaper.png /usr/share/backgrounds/case.png COPY assets/cursor-left_ptr /usr/share/icons/case/cursors/left_ptr -RUN printf '[Icon Theme]\nName=case\n' > /usr/share/icons/case/index.theme +# Inherits: every cursor but left_ptr comes from Adwaita, which GTK 3 depends on +RUN printf '[Icon Theme]\nName=case\nInherits=Adwaita\n' > /usr/share/icons/case/index.theme COPY assets/gtk-settings.ini assets/xfce4-panel.xml assets/xfwm4.xml assets/xfce4-desktop.xml /usr/share/case/ COPY deskd.py /opt/deskd/deskd.py From acffd71f5418f40cc8ba82e4c03e95f09c04f3e0 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:08:51 +0000 Subject: [PATCH 024/126] Say in the README that a proxy in front of MCP needs its hostname allowed. MCP HTTP now checks Host against the cased and Drive list, so a proxy that forwards another name gets 421 until that name is in CASE_ALLOWED_HOSTS. --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ede43ec..330697c 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,9 @@ Set `CASE_TOKEN` to that value in `.env`. Add your proxy hostname to `CASE_TOKEN` protects Drive and the REST API. Open Drive through the HTTPS proxy with `?token=` on the first visit. MCP on port 8788 has no built-in client authentication: keep it local or configure authentication at its proxy. -Setting `CASE_TOKEN` alone does not protect the MCP endpoint. +Setting `CASE_TOKEN` alone does not protect the MCP endpoint. MCP checks `Host` +against the same list as cased and Drive, so a proxy in front of it needs its +hostname in `CASE_ALLOWED_HOSTS` too. For ntfy Assist links and approval buttons, set `CASE_PUBLIC_HOST` to the public hostname of your cased proxy, without a scheme. That hostname is allowed without From 18687213049f2c2e5c9ccdf6a35cdbef6119dadf Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:54:36 +0000 Subject: [PATCH 025/126] Keep the Drive tests off the developer's threads.json. Four test files imported serve.mjs with no CASE_THREADS, so they loaded web/web-ui/threads.json and, when it had threads, rewrote it. Each now points CASE_THREADS at a fresh temp file before the import. --- web/web-ui/test_http.mjs | 4 ++++ web/web-ui/test_ntfy.mjs | 7 ++++++- web/web-ui/test_serve.mjs | 5 ++++- web/web-ui/test_telegram.mjs | 7 ++++++- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/web/web-ui/test_http.mjs b/web/web-ui/test_http.mjs index c9eefd2..6d274dc 100644 --- a/web/web-ui/test_http.mjs +++ b/web/web-ui/test_http.mjs @@ -2,11 +2,15 @@ // SPDX-License-Identifier: MIT // Real-HTTP checks for the auth and Host/Origin gates. Run: node web/web-ui/test_http.mjs import assert from 'node:assert/strict'; +import fs from 'node:fs'; import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; process.env.CASE_TOKEN = 'tok'; delete process.env.CASE_DRIVE_API_KEY; delete process.env.CASE_DRIVE_PROVIDER; +process.env.CASE_THREADS = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'case-threads-')), 'threads.json'); const serve = await import('./serve.mjs'); const { server } = serve; await new Promise((r) => server.listen(0, '127.0.0.1', r)); diff --git a/web/web-ui/test_ntfy.mjs b/web/web-ui/test_ntfy.mjs index 926e8e3..6ab1bfc 100644 --- a/web/web-ui/test_ntfy.mjs +++ b/web/web-ui/test_ntfy.mjs @@ -1,12 +1,17 @@ #!/usr/bin/env node // SPDX-License-Identifier: MIT import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { envDriveAuth } from './case-tools.mjs'; import { OUTBOUND_TAG, authHeaders, clipNtfy, inboundText, isOutbound, listen, ntfyConfig, parseSseData, publish, tagsOf, } from './ntfy.mjs'; -import { startPhoneNtfy } from './serve.mjs'; + +process.env.CASE_THREADS = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'case-threads-')), 'threads.json'); +const { startPhoneNtfy } = await import('./serve.mjs'); assert.deepEqual(ntfyConfig({}), { url: 'https://ntfy.sh', topic: '', token: '', chat: false, diff --git a/web/web-ui/test_serve.mjs b/web/web-ui/test_serve.mjs index 5e4ba3d..664ec63 100644 --- a/web/web-ui/test_serve.mjs +++ b/web/web-ui/test_serve.mjs @@ -7,13 +7,16 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { shq, pathOk, parseErr, parseFind, mimeFor, histTrim, histApplyCompaction, 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, histToAnthropicMessages, anthropicThinkingFor, caseToolPlan, } from './case-tools.mjs'; +// serve.mjs loads threads.json at import and rewrites it; never the developer's own. +process.env.CASE_THREADS = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'case-threads-')), 'threads.json'); +const { shq, pathOk, parseErr, parseFind, mimeFor, histTrim, histApplyCompaction, 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 } = await import('./serve.mjs'); + const html = fs.readFileSync(fileURLToPath(new URL('./index.html', import.meta.url)), 'utf8'); assert.match(html, /x-anthropic-key/); assert.match(html, /ANTHROPIC KEY/); diff --git a/web/web-ui/test_telegram.mjs b/web/web-ui/test_telegram.mjs index 9ebe175..9146394 100644 --- a/web/web-ui/test_telegram.mjs +++ b/web/web-ui/test_telegram.mjs @@ -1,10 +1,15 @@ #!/usr/bin/env node // SPDX-License-Identifier: MIT import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { MAX_TEXT, STALE_S, chunk, handoffMessage, parseUpdate, poll, routeTelegram, telegramConfig, tgApi, } from './telegram.mjs'; -import { startPhoneTelegram } from './serve.mjs'; + +process.env.CASE_THREADS = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'case-threads-')), 'threads.json'); +const { startPhoneTelegram } = await import('./serve.mjs'); assert.deepEqual(telegramConfig({}), { token: '', chatId: 0 }); assert.deepEqual(telegramConfig({ CASE_TELEGRAM_TOKEN: ' 1:tok ', CASE_TELEGRAM_CHAT_ID: '42' }), From 6a2e1963a0ab2f3fcdd256f47708d2d7846aa8ba Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:54:51 +0000 Subject: [PATCH 026/126] Run every Drive test file from disk instead of a hand-kept list. The test script chained the eight files by name, so a new file was skipped until someone remembered to add it, and the web README listed only three. The script now loops over web-ui/test_*.mjs, still one node process per file, and the README points at npm --prefix web test. --- web/package.json | 2 +- web/web-ui/README.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/web/package.json b/web/package.json index 3a13020..7e044df 100644 --- a/web/package.json +++ b/web/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "start": "node web-ui/serve.mjs", - "test": "node web-ui/test_serve.mjs && node web-ui/test_rate_retry.mjs && node web-ui/test_http.mjs && node web-ui/test_phone.mjs && node web-ui/test_ntfy.mjs && node web-ui/test_telegram.mjs && node web-ui/test_nav.mjs && node web-ui/test_deploy.mjs" + "test": "for f in web-ui/test_*.mjs; do node \"$f\" || exit 1; done" }, "dependencies": { "@anthropic-ai/sdk": "^0.117.1", diff --git a/web/web-ui/README.md b/web/web-ui/README.md index 7d327f4..4cef162 100644 --- a/web/web-ui/README.md +++ b/web/web-ui/README.md @@ -25,4 +25,5 @@ network and never dials a desktop itself. Run: `node web/web-ui/serve.mjs` → http://127.0.0.1:4174/ and /deploy (or `docker compose up` from the repo root) -Tests: `node web/web-ui/test_serve.mjs`, `test_nav.mjs`, `test_deploy.mjs` +Tests: `npm --prefix web test` runs every `web-ui/test_*.mjs`, each in its own +node process. From 75f6b4e6c7203f9e6bd272913b473214957ce195 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:55:46 +0000 Subject: [PATCH 027/126] Keep one aborted request or a down cased from killing Drive. The router returned route promises from inside its try without awaiting them, and readBody let the stream's abort reject, so STOP during an attachment upload became an unhandled rejection that ended the process and every running turn with it. schedulesRoute also looked up the computer outside its try, so cased being down did the same. Routes are now awaited, readBody treats a hung-up client as no body, the lookup sits inside the try, and a process-level handler logs anything that still slips through instead of exiting. --- web/web-ui/serve.mjs | 45 +++++++++++++++++++++++----------------- web/web-ui/test_http.mjs | 25 ++++++++++++++++++++++ 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/web/web-ui/serve.mjs b/web/web-ui/serve.mjs index 203610f..91aa151 100644 --- a/web/web-ui/serve.mjs +++ b/web/web-ui/serve.mjs @@ -196,10 +196,14 @@ const BODY_CAP = 1 << 20; // 1 MB, chat input is capped to 32k anyway async function readBody(req, res, cap = BODY_CAP) { const chunks = []; let n = 0; - for await (const c of req) { - n += c.length; - if (n > cap) { json(res, 413, { error: 'body too large' }); req.destroy(); return null; } - chunks.push(c); + try { + for await (const c of req) { + n += c.length; + if (n > cap) { json(res, 413, { error: 'body too large' }); req.destroy(); return null; } + chunks.push(c); + } + } catch { + return null; // client hung up mid-body (STOP during an upload): nobody to answer } return Buffer.concat(chunks); } @@ -1297,10 +1301,10 @@ export async function runTurn({ export const driveLoop = { turn: runTurn }; export async function schedulesRoute(req, res, url) { - const id = await cid(); - if (!id) return json(res, 409, { error: 'no computer' }); const sid = String(url.searchParams.get('id') || '').trim(); try { + const id = await cid(); + if (!id) return json(res, 409, { error: 'no computer' }); if (req.method === 'GET' && url.pathname === '/api/schedules') { const r = await api('GET', `/computers/${encodeURIComponent(id)}/schedules`); return json(res, r.status >= 400 ? r.status : 200, r.json || []); @@ -1784,28 +1788,29 @@ export const server = http.createServer(async (req, res) => { } } try { - if (req.method === 'GET' && p === '/api/computers') return computers(res); - if (req.method === 'POST' && p === '/api/computers') return createComputer(res, req); - if (req.method === 'DELETE' && p === '/api/computers') return deleteComputer(res, req); - if (req.method === 'GET' && p === '/api/fs') return fsList(res, url); - if (p === '/api/creds') return creds(req, res, url); + if (req.method === 'GET' && p === '/api/computers') return await computers(res); + if (req.method === 'POST' && p === '/api/computers') return await createComputer(res, req); + if (req.method === 'DELETE' && p === '/api/computers') return await deleteComputer(res, req); + if (req.method === 'GET' && p === '/api/fs') return await fsList(res, url); + if (p === '/api/creds') return await creds(req, res, url); if (p === '/api/threads') return threadsRoute(req, res, url); - if (req.method === 'GET' && p === '/api/file') return fsFile(res, url); - if (req.method === 'POST' && p === '/api/brain') return brainRoute(req, res); - if (p === '/api/schedules' || p === '/api/schedules/run') return schedulesRoute(req, 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 === 'GET' && p === '/api/file') return await fsFile(res, url); + if (req.method === 'POST' && p === '/api/brain') return await brainRoute(req, res); + if (p === '/api/schedules' || p === '/api/schedules/run') return await schedulesRoute(req, res, url); + if (req.method === 'POST' && p === '/api/chat') return await chat(req, res); + if (req.method === 'POST' && p === '/api/chat/steer') return await steer(req, res); + if (req.method === 'POST' && p === '/api/attach') return await attach(req, res); if (req.method === 'POST' && p === '/api/teach-tick') { const id = await cid(); if (!id) return json(res, 409, { error: 'no computer' }); const r = await api('POST', `/computers/${encodeURIComponent(id)}/teach-tick?wake=true`, { timeoutMs: 15000 }); return json(res, r.status >= 400 ? r.status : 200, r.json || {}); } - if (req.method === 'POST' && p === '/api/wake') return power(res, req, 'wake'); - if (req.method === 'POST' && p === '/api/sleep') return power(res, req, 'sleep'); + if (req.method === 'POST' && p === '/api/wake') return await power(res, req, 'wake'); + if (req.method === 'POST' && p === '/api/sleep') return await power(res, req, 'sleep'); if (p.startsWith('/live')) return vncHttp(req, res); } catch (err) { + if (res.headersSent) return res.destroy(); return json(res, 500, { error: err.message || 'internal' }); } const file = pageFile(p); @@ -1824,6 +1829,8 @@ server.on('upgrade', (req, socket, head) => { const isMain = fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || ''); if (isMain) { server.on('clientError', (_e, s) => { try { s.destroy(); } catch { /* gone */ } }); + // One bad request must never take Drive (and every running turn) down with it. + process.on('unhandledRejection', (err) => console.error('drive unhandled:', err)); server.listen(PORT, BIND, () => { process.stdout.write(`drive http://${BIND}:${PORT}/ deploy http://${BIND}:${PORT}/deploy (cased ${CASE.hostname}:${CASE.port}${LOCAL ? ', local' : ''})\n`); startPhoneNtfy(); diff --git a/web/web-ui/test_http.mjs b/web/web-ui/test_http.mjs index 6d274dc..3250ad1 100644 --- a/web/web-ui/test_http.mjs +++ b/web/web-ui/test_http.mjs @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import http from 'node:http'; +import net from 'node:net'; import os from 'node:os'; import path from 'node:path'; @@ -11,6 +12,11 @@ process.env.CASE_TOKEN = 'tok'; delete process.env.CASE_DRIVE_API_KEY; delete process.env.CASE_DRIVE_PROVIDER; process.env.CASE_THREADS = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'case-threads-')), 'threads.json'); +// cased is down: a port that was just free and is closed again. +const dead = http.createServer(); +await new Promise((r) => dead.listen(0, '127.0.0.1', r)); +process.env.CASE_URL = `http://127.0.0.1:${dead.address().port}`; +await new Promise((r) => dead.close(r)); const serve = await import('./serve.mjs'); const { server } = serve; await new Promise((r) => server.listen(0, '127.0.0.1', r)); @@ -69,5 +75,24 @@ try { delete process.env.CASE_DRIVE_PROVIDER; } +// One bad request is that request's problem: a client hanging up mid-body (STOP +// during an attachment upload) or cased being down must not kill the process. +{ + const auth = { authorization: 'Bearer tok' }; + for (const [route, extra] of [['/api/chat', ''], ['/api/attach', 'x-filename: notes.txt\r\n']]) { + await new Promise((resolve) => { + const c = net.connect(server.address().port, '127.0.0.1', () => { + c.write(`POST ${route} HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer tok\r\n${extra}` + + 'Content-Type: application/json\r\nContent-Length: 100000\r\n\r\n{"input":"hi"'); + setTimeout(() => { c.destroy(); resolve(); }, 100); + }); + }); + } + await new Promise((r) => setTimeout(r, 200)); + assert.equal((await get('/api/threads', auth)).status, 200, 'still serving after aborted bodies'); + assert.equal((await get('/api/schedules', auth)).status, 502); + assert.equal((await post('/api/teach-tick', {}, auth)).status, 500); +} + server.close(); console.log('test_http: ok'); From 16d49eb4aedc2e5f26700842603224be72f52547 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:57:13 +0000 Subject: [PATCH 028/126] Act on the picked computer in Files, Credentials, Schedules and teach. These routes used cachedCid, the first awake computer cased listed, because the page never said which computer it was sat at. With two computers, the Files and Credentials tabs showed and changed the other desk's disk and vault. The page now sends computer_id on every such request and the server uses it; a request with no pick gets a 409 instead of being rerouted. Panes opened before the first refresh knew the pick reload once it does. --- web/web-ui/index.html | 30 ++++++++++++++++++------------ web/web-ui/serve.mjs | 25 ++++++++++++++----------- web/web-ui/test_http.mjs | 38 ++++++++++++++++++++++++++++++++++++-- web/web-ui/test_serve.mjs | 3 +++ 4 files changed, 71 insertions(+), 25 deletions(-) diff --git a/web/web-ui/index.html b/web/web-ui/index.html index 00c8e46..5333ed7 100644 --- a/web/web-ui/index.html +++ b/web/web-ui/index.html @@ -770,6 +770,7 @@

SCHEDULES

computer than the one you picked would mean different logins, so a missing pick says so rather than quietly seating you somewhere else. */ const PICK_KEY='case.drive.computer'; +const compQ=()=>'computer_id='+encodeURIComponent(comp&&comp.id||''); const readPick=()=>{try{return localStorage.getItem(PICK_KEY)||'';}catch{return '';}}; const writePick=id=>{try{localStorage.setItem(PICK_KEY,id);}catch{/* private mode */}}; let pickLost=false; // picked a computer, it is not on the box any more @@ -790,8 +791,13 @@

SCHEDULES

const want=(comp&&comp.id)||readPick(); const found=want?comps.find(c=>c.id===want):null; pickLost=!!(want&&!found&&comps.length); + const had=comp; comp=found||(want?null:(comps.find(c=>c.state==='awake')||comps[0]||null)); if(comp&&comp.id!==readPick())writePick(comp.id); + if(comp&&!had){ // a pane opened before the pick was known asked for no computer + if(mode==='files'&&!entries)loadDir(cwd); + if(mode==='creds')loadCreds(); + } try{ const rt=await fetch('/api/threads'); const jt=await rt.json(); @@ -1029,7 +1035,7 @@

SCHEDULES

const list=$('flist'); list.classList.add('loading'); try{ - const r=await fetch('/api/fs?path='+encodeURIComponent(p)); + const r=await fetch('/api/fs?path='+encodeURIComponent(p)+'&'+compQ()); const j=await r.json(); if(gen!==dirGen)return; if(!r.ok){renderList([{err:j.error||('http '+r.status)}],p);return;} @@ -1079,14 +1085,14 @@

SCHEDULES

prev.classList.add('has'); $('fhName').textContent=name; $('fhMeta').textContent=''; - $('fhOpen').href='/api/file?path='+encodeURIComponent(p); + $('fhOpen').href='/api/file?path='+encodeURIComponent(p)+'&'+compQ(); const bodyEl=$('fbody'); bodyEl.innerHTML='
loading…
'; const ent=(entries||[]).find(e=>(cwd==='/'?'':cwd)+'/'+e.name===p); if(ent)$('fhMeta').textContent=fmtSize(ent.size); if(IMG_RE.test(name)){ if(gen!==fileGen)return; - bodyEl.innerHTML=''+esc(name)+''; + bodyEl.innerHTML=''+esc(name)+''; return; } if(!TEXT_RE.test(name)||(ent&&ent.size>1048576)){ @@ -1094,7 +1100,7 @@

SCHEDULES

return; } try{ - const r=await fetch('/api/file?path='+encodeURIComponent(p)); + const r=await fetch('/api/file?path='+encodeURIComponent(p)+'&'+compQ()); if(gen!==fileGen)return; if(!r.ok){ const j=await r.json().catch(()=>({})); @@ -1133,7 +1139,7 @@

SCHEDULES

} async function loadCreds(){ try{ - const r=await fetch('/api/creds'); + const r=await fetch('/api/creds?'+compQ()); const j=await r.json(); credRows=r.ok?(j.credentials||[]):[]; if(!r.ok)$('credErr').textContent=j.error||('http '+r.status); @@ -1148,7 +1154,7 @@

SCHEDULES

if(!confirm('Delete "'+name+'" from the vault?'))return; btn.disabled=true; try{ - const r=await fetch('/api/creds?name='+encodeURIComponent(name),{method:'DELETE'}); + const r=await fetch('/api/creds?name='+encodeURIComponent(name)+'&'+compQ(),{method:'DELETE'}); if(!r.ok){const j=await r.json().catch(()=>({}));$('credErr').textContent=j.error||'delete failed';} }catch{$('credErr').textContent='cased down';} loadCreds(); @@ -1166,7 +1172,7 @@

SCHEDULES

const save=$('credSave'); save.disabled=true;save.textContent='SAVING…'; try{ - const r=await fetch('/api/creds',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload)}); + const r=await fetch('/api/creds?'+compQ(),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload)}); const j=await r.json().catch(()=>({})); if(!r.ok){err.textContent=j.error||('http '+r.status);} else{$('credForm').reset();loadCreds();} @@ -1283,7 +1289,7 @@

SCHEDULES

async function loadSchedules(){ $('schedErr').textContent=''; try{ - const r=await fetch('/api/schedules'); + const r=await fetch('/api/schedules?'+compQ()); const j=await r.json().catch(()=>[]); if(!r.ok){$('schedList').innerHTML='
'+(apiErr(j)||'could not load')+'
';return;} paintSchedList(Array.isArray(j)?j:[]); @@ -1310,7 +1316,7 @@

SCHEDULES

const id=row.dataset.id; if(e.target.classList.contains('sched-run')){ try{ - const r=await fetch('/api/schedules/run',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id})}); + const r=await fetch('/api/schedules/run?'+compQ(),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id})}); const j=await r.json().catch(()=>({})); $('schedErr').textContent=r.ok?'started':(apiErr(j)||'run failed'); }catch{$('schedErr').textContent='cased unreachable';} @@ -1318,7 +1324,7 @@

SCHEDULES

} if(e.target.classList.contains('sched-del')){ try{ - const r=await fetch('/api/schedules?id='+encodeURIComponent(id),{method:'DELETE'}); + const r=await fetch('/api/schedules?id='+encodeURIComponent(id)+'&'+compQ(),{method:'DELETE'}); if(!r.ok){const j=await r.json().catch(()=>({}));$('schedErr').textContent=apiErr(j)||'delete failed';return;} loadSchedules(); }catch{$('schedErr').textContent='cased unreachable';} @@ -1356,7 +1362,7 @@

SCHEDULES

if(name)body.name=name; $('schedErr').textContent=''; try{ - const r=await fetch('/api/schedules',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)}); + const r=await fetch('/api/schedules?'+compQ(),{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)}); const j=await r.json().catch(()=>({})); if(!r.ok){$('schedErr').textContent=apiErr(j)||'save failed';return;} $('schedPrompt').value=''; @@ -1655,7 +1661,7 @@

SCHEDULES

async function teachTick(){ if(!teach.on)return; try{ - const r=await fetch('/api/teach-tick',{method:'POST'}); + const r=await fetch('/api/teach-tick?'+compQ(),{method:'POST'}); const j=await r.json(); if(!j.ok)return; if(j.gap){ diff --git a/web/web-ui/serve.mjs b/web/web-ui/serve.mjs index 91aa151..ed61fe2 100644 --- a/web/web-ui/serve.mjs +++ b/web/web-ui/serve.mjs @@ -179,6 +179,10 @@ function originHealth() { } let cachedCid = ''; +// The computer the page is sat at. Files, vault, schedules and teach act on +// exactly that one: rerouting a missing pick to the first computer on the box +// would show and change someone else's desk (web-ui/README.md, "One seat"). +const pickedCid = (url) => String(url.searchParams.get('computer_id') || '').trim(); async function cid() { if (cachedCid) return cachedCid; const r = await api('GET', '/computers', { timeoutMs: 6000 }); @@ -261,8 +265,8 @@ async function fsList(res, url) { const p = url.searchParams.get('path') || '/home/agent'; if (!pathOk(p)) return json(res, 400, { error: 'bad path' }); try { - const id = await cid(); - if (!id) return json(res, 409, { error: 'no computer on the box' }); + const id = pickedCid(url); + if (!id) return json(res, 409, { error: 'no computer picked' }); const cmd = `find ${shq(p)} -mindepth 1 -maxdepth 1 -printf '%y\\t%s\\t%T@\\t%f\\n' 2>&1 || true`; const r = await api('POST', `/computers/${encodeURIComponent(id)}/exec?wake=true`, { body: { command: cmd, timeout_s: 15 }, timeoutMs: 30000 }); @@ -282,8 +286,8 @@ async function fsFile(res, url) { const p = url.searchParams.get('path') || ''; if (!pathOk(p)) return json(res, 400, { error: 'bad path' }); try { - const id = await cid(); - if (!id) return json(res, 409, { error: 'no computer on the box' }); + const id = pickedCid(url); + if (!id) return json(res, 409, { error: 'no computer picked' }); const r = await api('GET', `/computers/${encodeURIComponent(id)}/files?path=${encodeURIComponent(p)}&wake=true`, { timeoutMs: 60000, raw: true }); @@ -312,9 +316,8 @@ export function normHost(s) { return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(h) ? h : ''; } async function creds(req, res, url) { - let id = ''; - try { id = await cid(); } catch { /* fall through */ } - if (!id) return json(res, 502, { error: 'no computer — create one first' }); + const id = pickedCid(url); + if (!id) return json(res, 409, { error: 'no computer picked' }); const base = `/computers/${encodeURIComponent(id)}/credentials`; try { if (req.method === 'GET') { @@ -1303,8 +1306,8 @@ export const driveLoop = { turn: runTurn }; export async function schedulesRoute(req, res, url) { const sid = String(url.searchParams.get('id') || '').trim(); try { - const id = await cid(); - if (!id) return json(res, 409, { error: 'no computer' }); + const id = pickedCid(url); + if (!id) return json(res, 409, { error: 'no computer picked' }); if (req.method === 'GET' && url.pathname === '/api/schedules') { const r = await api('GET', `/computers/${encodeURIComponent(id)}/schedules`); return json(res, r.status >= 400 ? r.status : 200, r.json || []); @@ -1801,8 +1804,8 @@ export const server = http.createServer(async (req, res) => { if (req.method === 'POST' && p === '/api/chat/steer') return await steer(req, res); if (req.method === 'POST' && p === '/api/attach') return await attach(req, res); if (req.method === 'POST' && p === '/api/teach-tick') { - const id = await cid(); - if (!id) return json(res, 409, { error: 'no computer' }); + const id = pickedCid(url); + if (!id) return json(res, 409, { error: 'no computer picked' }); const r = await api('POST', `/computers/${encodeURIComponent(id)}/teach-tick?wake=true`, { timeoutMs: 15000 }); return json(res, r.status >= 400 ? r.status : 200, r.json || {}); } diff --git a/web/web-ui/test_http.mjs b/web/web-ui/test_http.mjs index 3250ad1..b6452b1 100644 --- a/web/web-ui/test_http.mjs +++ b/web/web-ui/test_http.mjs @@ -90,8 +90,42 @@ try { } await new Promise((r) => setTimeout(r, 200)); assert.equal((await get('/api/threads', auth)).status, 200, 'still serving after aborted bodies'); - assert.equal((await get('/api/schedules', auth)).status, 502); - assert.equal((await post('/api/teach-tick', {}, auth)).status, 500); + assert.equal((await get('/api/schedules?computer_id=c_1', auth)).status, 502); + assert.equal((await post('/api/teach-tick?computer_id=c_1', {}, auth)).status, 500); +} + +// Files, vault, schedules and teach act on the computer the page is sat at, not +// on whichever one cased lists first; with no pick they refuse rather than guess. +{ + const auth = { authorization: 'Bearer tok' }; + const hits = []; + const fake = http.createServer((req, res) => { + hits.push(`${req.method} ${req.url}`); + res.setHeader('content-type', 'application/json'); + if (req.url === '/v1/computers') return res.end(JSON.stringify({ computers: [{ id: 'c_A' }, { id: 'c_B' }] })); + res.end(req.url.includes('/exec') ? '{"stdout":""}' : '{}'); + }); + await new Promise((r) => fake.listen(0, '127.0.0.1', r)); + const was = process.env.CASE_URL; + process.env.CASE_URL = `http://127.0.0.1:${fake.address().port}/v1`; + try { + for (const p of ['/api/fs?path=/home/agent', '/api/file?path=/home/agent/a.txt', '/api/creds', '/api/schedules']) { + assert.equal((await get(`${p}${p.includes('?') ? '&' : '?'}computer_id=c_B`, auth)).status, 200, p); + } + assert.equal((await post('/api/creds?computer_id=c_B', { domains: 'x.com', username: 'u', secret: 's' }, auth)).status, 200); + assert.equal((await post('/api/teach-tick?computer_id=c_B', {}, auth)).status, 200); + assert.equal(hits.length, 6); + assert.ok(hits.every((h) => h.includes('/computers/c_B/')), hits.join('\n')); + hits.length = 0; + for (const p of ['/api/fs?path=/home/agent', '/api/file?path=/home/agent/a.txt', '/api/creds', '/api/schedules']) { + assert.equal((await get(p, auth)).status, 409, p); + } + assert.equal((await post('/api/teach-tick', {}, auth)).status, 409); + assert.deepEqual(hits, [], 'no pick is refused, never rerouted'); + } finally { + process.env.CASE_URL = was; + fake.close(); + } } server.close(); diff --git a/web/web-ui/test_serve.mjs b/web/web-ui/test_serve.mjs index 664ec63..3986717 100644 --- a/web/web-ui/test_serve.mjs +++ b/web/web-ui/test_serve.mjs @@ -29,6 +29,9 @@ assert.match(html, /id="schedBtn"/); assert.match(html, /id="schedBtnM"/); assert.match(html, /Intl\.supportedValuesOf/); assert.match(html, /brain_key/); +for (const m of html.matchAll(/'\/api\/(fs|file|creds|schedules|teach-tick)[^;]*/g)) { + assert.match(m[0], /compQ\(\)/, `${m[1]} names the picked computer: ${m[0]}`); +} assert.match(fs.readFileSync(fileURLToPath(new URL('./serve.mjs', import.meta.url)), 'utf8'), /schedulesRoute/); // threadTurns: reopening a thread shows text + tool calls; outputs and reasoning stay server-side From 48aabf8d7fd5ca463b608ac3092307dbb468be5b Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:57:37 +0000 Subject: [PATCH 029/126] Hand ntfy messages off without waiting for the turn they start. listen awaited onMessage, and a message that starts a task only settles when the turn ends. Every later message queued behind it, so a steer never reached the running turn and an OTP or approval for a handoff that turn was waiting on sat unread until the wait timed out. Messages are now dispatched the way the Telegram poller does it, with failures still logged. --- web/web-ui/ntfy.mjs | 6 +++++- web/web-ui/test_ntfy.mjs | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/web/web-ui/ntfy.mjs b/web/web-ui/ntfy.mjs index e5b5768..8f61568 100644 --- a/web/web-ui/ntfy.mjs +++ b/web/web-ui/ntfy.mjs @@ -72,6 +72,8 @@ export async function publish(cfg, { title, message, tags = [] }, fetchImpl = fe if (!r.ok) throw new Error(`ntfy publish ${r.status}`); } +/** Subscribe loop. onMessage is not awaited, so a running turn never holds up + * the next message: a steer, or the OTP / approval that very turn waits on. */ export async function listen(cfg, onMessage, { fetchImpl = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), @@ -102,7 +104,9 @@ export async function listen(cfg, onMessage, { seen.add(ev.id); if (seen.size > 200) seen.delete(seen.values().next().value); } - await onMessage(text, ev); + Promise.resolve().then(() => onMessage(text, ev)).catch((err) => { + console.warn('ntfy message:', err?.message || err); + }); } } } catch (err) { diff --git a/web/web-ui/test_ntfy.mjs b/web/web-ui/test_ntfy.mjs index 6ab1bfc..2d26c34 100644 --- a/web/web-ui/test_ntfy.mjs +++ b/web/web-ui/test_ntfy.mjs @@ -152,6 +152,45 @@ function sseBody(text) { assert.deepEqual(got, ['check gmail']); } +// A message that starts a long turn must not hold up the next one: that is a +// steer, or the OTP the running turn is itself waiting on. +{ + const got = []; + const ac = new AbortController(); + let n = 0; + const fetchImpl = async () => { + n += 1; + if (n > 1) { + ac.abort(); + throw new Error('stop'); + } + return { ok: true, body: sseBody( + 'data: {"id":"a","event":"message","message":"do a long task"}\n\n' + + 'data: {"id":"b","event":"message","message":"123456"}\n\n' + + 'data: {"id":"c","event":"message","message":"boom"}\n\n' + + 'data: {"id":"d","event":"message","message":"after"}\n\n') }; + }; + const warn = console.warn; + const warned = []; + console.warn = (...a) => warned.push(a.join(' ')); + try { + await listen( + { url: 'https://ntfy.sh', topic: 'top' }, + (text) => { + got.push(text); + if (text === 'boom') throw new Error('handler failed'); + return text.startsWith('do') ? new Promise(() => {}) : undefined; + }, + { fetchImpl, signal: ac.signal, now: () => 1700000000, sleep: async () => {} }, + ); + await new Promise((r) => setTimeout(r, 10)); + } finally { + console.warn = warn; + } + assert.deepEqual(got, ['do a long task', '123456', 'boom', 'after']); + assert.ok(warned.some((w) => /ntfy message: handler failed/.test(w)), 'a failed handler is logged'); +} + assert.equal(startPhoneNtfy({}), false); assert.equal(startPhoneNtfy({ CASE_NTFY_CHAT: '1' }), false); assert.equal(startPhoneNtfy({ CASE_NTFY_CHAT: '1', CASE_NTFY_TOPIC: 't' }), false); From 409d128b24a59212681bedc8615313fcd007908f Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:58:00 +0000 Subject: [PATCH 030/126] Honor retry-after from the SDKs' Headers object. rateWaitS indexed err.headers like a plain object, but both the OpenAI and Anthropic SDKs hand back a fetch Headers there, so the server's retry-after was never read and every 429 fell back to the exponential guess. It now reads the header through get() when there is one, and still accepts a plain object. --- web/web-ui/case-tools.mjs | 5 +++-- web/web-ui/test_rate_retry.mjs | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/web/web-ui/case-tools.mjs b/web/web-ui/case-tools.mjs index c5a8a15..3b45bdb 100644 --- a/web/web-ui/case-tools.mjs +++ b/web/web-ui/case-tools.mjs @@ -386,8 +386,9 @@ export function isRateLimited(err) { * or retry-after) if it gave one, else exponential. Padded, clamped to 1..60s. */ export function rateWaitS(err, a) { const m = /try again in ([\d.]+)s/i.exec(err?.message || ''); - const hdr = Number(err?.headers?.['retry-after'] - ?? err?.response?.headers?.get?.('retry-after')); + // Both SDKs' APIError.headers is a fetch Headers; plain objects still pass. + const header = (h) => (typeof h?.get === 'function' ? h.get('retry-after') : h?.['retry-after']) ?? undefined; + const hdr = Number(header(err?.headers) ?? header(err?.response?.headers)); const wait = m ? Number(m[1]) : Number.isFinite(hdr) && hdr > 0 ? hdr : 2 ** a; return Math.min(Math.max(wait + 0.5, 1), 60); } diff --git a/web/web-ui/test_rate_retry.mjs b/web/web-ui/test_rate_retry.mjs index 2bb4730..f0035b4 100644 --- a/web/web-ui/test_rate_retry.mjs +++ b/web/web-ui/test_rate_retry.mjs @@ -3,6 +3,8 @@ // Pure-unit checks for the rate-limit retry. Run: node web/web-ui/test_rate_retry.mjs import assert from 'node:assert/strict'; import fs from 'node:fs'; +import Anthropic from '@anthropic-ai/sdk'; +import OpenAI from 'openai'; import { isRateLimited, rateWaitS, withRateRetry } from './case-tools.mjs'; // --- what counts as "too fast" ------------------------------------------- @@ -20,6 +22,13 @@ assert.ok(!isRateLimited(undefined)); assert.equal(rateWaitS({ message: 'try again in 12s' }, 0), 12.5); assert.equal(rateWaitS({ headers: { 'retry-after': '9' } }, 0), 9.5); assert.equal(rateWaitS({ response: { headers: new Map([['retry-after', '4']]) } }, 0), 4.5); +// what the SDKs actually throw: APIError.headers is a fetch Headers object +{ + const h = new Headers({ 'retry-after': '30' }); + assert.equal(rateWaitS(Anthropic.APIError.generate(429, { error: { type: 'rate_limit_error', message: 'slow' } }, 'slow', h), 0), 30.5); + assert.equal(rateWaitS(OpenAI.APIError.generate(429, { message: 'slow' }, 'slow', h), 0), 30.5); + assert.equal(rateWaitS({ status: 429, headers: new Headers() }, 1), 2.5, 'no header: exponential'); +} // no hint: exponential in the attempt number assert.equal(rateWaitS({ status: 429 }, 0), 1.5); assert.equal(rateWaitS({ status: 429 }, 3), 8.5); From 670d280cd052b767e75f0339cd11b90bedbe1c95 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:59:55 +0000 Subject: [PATCH 031/126] Make withRateRetry the only retry policy for provider requests. Both SDK clients kept their default maxRetries of 2 underneath withRateRetry's 5 attempts, so one rate-limited round could send up to 15 requests. The clients now run with maxRetries: 0. So that transient 5xx, timeout and connection faults the SDKs used to absorb are not lost, withRateRetry retries those as well, and the two param fallbacks no longer treat them as a rejected parameter. --- web/web-ui/case-tools.mjs | 24 +++++++++++++++++------- web/web-ui/serve.mjs | 12 ++++++------ web/web-ui/test_rate_retry.mjs | 31 ++++++++++++++++++++++++++++--- web/web-ui/test_serve.mjs | 2 +- 4 files changed, 52 insertions(+), 17 deletions(-) diff --git a/web/web-ui/case-tools.mjs b/web/web-ui/case-tools.mjs index 3b45bdb..c98ec6c 100644 --- a/web/web-ui/case-tools.mjs +++ b/web/web-ui/case-tools.mjs @@ -382,6 +382,15 @@ export function isRateLimited(err) { || /rate limit|overloaded/i.test(err?.message || ''); } +/** Worth replaying the round unchanged: too fast, or a transient provider or + * network fault (what the SDKs retry by default). Both clients run with + * maxRetries: 0, so withRateRetry is the only retry policy. */ +export function isRetryable(err) { + const status = err?.status ?? err?.response?.status; + if (isRateLimited(err) || status === 408 || status === 409 || status >= 500) return true; + return status == null && /connection error|timed out/i.test(err?.message || ''); +} + /** Seconds to wait before attempt `a`: the server's own hint ("try again in Xs" * or retry-after) if it gave one, else exponential. Padded, clamped to 1..60s. */ export function rateWaitS(err, a) { @@ -393,8 +402,8 @@ export function rateWaitS(err, a) { return Math.min(Math.max(wait + 0.5, 1), 60); } -/** Retry a provider round on rate limits (429/529), honoring the server's - * suggested wait. History is only mutated after a round completes, so replaying +/** Retry a provider round on rate limits (429/529) and transient faults, + * honoring the server's suggested wait. History is only mutated after a round completes, so replaying * a failed round is safe. `signal` cancels the backoff sleep on STOP. */ function abortError(signal) { if (signal?.reason instanceof Error) return signal.reason; @@ -428,10 +437,11 @@ export async function withRateRetry(fn, emit, tries = 5, signal) { try { return await fn(); } catch (err) { if (signal?.aborted) throw err; - if (!isRateLimited(err) || a >= tries - 1) throw err; + if (!isRetryable(err) || a >= tries - 1) throw err; const wait = rateWaitS(err, a); + const why = isRateLimited(err) ? 'rate limited' : 'provider error'; // `rate: true` so a non-UI consumer can pick the wait out of the think stream. - emit?.({ type: 'think', rate: true, text: `rate limited — retrying in ${Math.ceil(wait)}s` }); + emit?.({ type: 'think', rate: true, text: `${why} — retrying in ${Math.ceil(wait)}s` }); await abortableDelay(wait * 1000, signal); } } @@ -441,7 +451,7 @@ 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 client = new Anthropic({ apiKey: key, maxRetries: 0 }); const antTools = openaiToolsToAnthropic(tools); const antEffort = effort === 'none' ? 'low' : (effort || 'medium'); const params = { @@ -495,8 +505,8 @@ export async function anthropicToolLoop({ } catch (err) { if (signal?.aborted) throw err; // This fallback is for models that reject output_config — not for a rate - // limit whose retries already ran dry, which would only buy 5 more waits. - if (!params.output_config || isRateLimited(err)) throw err; + // limit or fault whose retries already ran dry, which would only buy 5 more waits. + if (!params.output_config || isRetryable(err)) throw err; const rest = { ...params }; delete rest.output_config; result = await withRateRetry(() => round(rest), emit, 5, signal); diff --git a/web/web-ui/serve.mjs b/web/web-ui/serve.mjs index ed61fe2..32769bd 100644 --- a/web/web-ui/serve.mjs +++ b/web/web-ui/serve.mjs @@ -20,7 +20,7 @@ import path from 'node:path'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; import OpenAI from 'openai'; -import { CASE_TOOLS, caseCall, caseToolPlan, runCaseTool, streamEventToNdjson, tracesFromOutput, chatAuth, envDriveAuth, resolveChatModel, histToAnthropicMessages, anthropicToolLoop, withRateRetry, isRateLimited } from './case-tools.mjs'; +import { CASE_TOOLS, caseCall, caseToolPlan, runCaseTool, streamEventToNdjson, tracesFromOutput, chatAuth, envDriveAuth, resolveChatModel, histToAnthropicMessages, anthropicToolLoop, withRateRetry, isRetryable } from './case-tools.mjs'; import * as ntfy from './ntfy.mjs'; import { PHONE_THREAD_ID, routePhone } from './phone.mjs'; import * as telegram from './telegram.mjs'; @@ -1104,7 +1104,7 @@ export async function runTurn({ emit({ type: 'done', text, computer_id: id, thread_id: thread.id }); return { text, computerId: id, threadId: thread.id, finished }; } - const client = new OpenAI({ apiKey: auth.key }); + const client = new OpenAI({ apiKey: auth.key, maxRetries: 0 }); let text = ''; const spend = { in: 0, cached: 0, out: 0 }; const effSoFar = () => Math.round((spend.in - spend.cached) + 0.1 * spend.cached); @@ -1137,11 +1137,11 @@ export async function runTurn({ try { stream = await client.responses.create(params, { signal: rc.signal }); break; } catch (err) { // Only param fallbacks retry here. An abort must not — and neither may a - // rate limit: `summary`/`compact` outlive the round, so treating a 429 as - // "unsupported" retries with no backoff AND thins reasoning / drops the - // window guard for every later round. Let withRateRetry have it. + // rate limit or a 5xx: `summary`/`compact` outlive the round, so treating a + // 429 as "unsupported" retries with no backoff AND thins reasoning / drops + // the window guard for every later round. Let withRateRetry have it. if (gone.signal.aborted) throw err; - if (isRateLimited(err)) throw err; + if (isRetryable(err)) throw err; if (compact && /context_management|compaction/i.test(err?.message || '')) { console.log(`drive turn ${thread.id}: compaction rejected — ${err?.message || 'no message'}`); compact = false; diff --git a/web/web-ui/test_rate_retry.mjs b/web/web-ui/test_rate_retry.mjs index f0035b4..ddca4f6 100644 --- a/web/web-ui/test_rate_retry.mjs +++ b/web/web-ui/test_rate_retry.mjs @@ -5,7 +5,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import Anthropic from '@anthropic-ai/sdk'; import OpenAI from 'openai'; -import { isRateLimited, rateWaitS, withRateRetry } from './case-tools.mjs'; +import { isRateLimited, isRetryable, rateWaitS, withRateRetry } from './case-tools.mjs'; // --- what counts as "too fast" ------------------------------------------- assert.ok(isRateLimited({ status: 429 })); @@ -16,6 +16,15 @@ assert.ok(isRateLimited({ message: 'Overloaded' })); assert.ok(!isRateLimited({ status: 400, message: "unsupported value: 'detailed'" })); assert.ok(!isRateLimited({ status: 401 })); assert.ok(!isRateLimited(undefined)); +// The SDKs run with maxRetries: 0, so their transient faults are ours to retry too. +assert.ok(isRetryable({ status: 429 })); +assert.ok(isRetryable({ status: 503 })); +assert.ok(isRetryable({ status: 408 })); +assert.ok(isRetryable(new OpenAI.APIConnectionError({ message: undefined }))); +assert.ok(isRetryable(new Anthropic.APIConnectionTimeoutError())); +assert.ok(!isRetryable(new Anthropic.APIUserAbortError())); +assert.ok(!isRetryable({ status: 400, message: 'Request timed out' }), 'a 400 is never transient'); +assert.ok(!isRetryable({ status: 401 })); // --- how long to wait ---------------------------------------------------- // the server's own hint wins over the exponential guess @@ -60,6 +69,19 @@ assert.equal(seen.length, 2, 'one notice per wait'); assert.ok(seen.every((e) => e.type === 'think' && e.rate === true), 'notices carry rate:true so headless consumers can log them'); assert.match(seen[0].text, /rate limited — retrying in 1s/); +// a transient 5xx is retried the same way, and the notice does not call it a rate limit +{ + const notes = []; + let n = 0; + const r = await withRateRetry(async () => { + n += 1; + if (n === 1) throw Object.assign(new Error('502 bad gateway — try again in 0.01s'), { status: 502 }); + return 'ok'; + }, (ev) => notes.push(ev)); + assert.equal(r, 'ok'); + assert.match(notes[0].text, /^provider error — retrying in 1s/); +} + // retries do run dry — the caller has to see the error, not hang forever calls = 0; await assert.rejects(() => withRateRetry(async () => { @@ -91,9 +113,12 @@ assert.equal(calls, 3, 'tries is a hard cap'); // fire on a rate limit: no backoff, and serve.mjs's `summary` is sticky for the turn. const serve = fs.readFileSync(new URL('./serve.mjs', import.meta.url), 'utf8'); assert.match(serve, /if \(gone\.signal\.aborted\) throw err;/); -assert.match(serve, /if \(isRateLimited\(err\)\) throw err;/); +assert.match(serve, /if \(isRetryable\(err\)\) throw err;/); assert.match(serve, /else if \(summary !== 'auto'\)/); const tools = fs.readFileSync(new URL('./case-tools.mjs', import.meta.url), 'utf8'); -assert.match(tools, /!params\.output_config \|\| isRateLimited\(err\)/); +assert.match(tools, /!params\.output_config \|\| isRetryable\(err\)/); +// withRateRetry is the whole retry policy: SDK retries under it multiply to 15 requests. +assert.match(tools, /new Anthropic\(\{ apiKey: key, maxRetries: 0 \}\)/); +assert.match(serve, /new OpenAI\(\{ apiKey: auth\.key, maxRetries: 0 \}\)/); console.log('rate retry ok'); diff --git a/web/web-ui/test_serve.mjs b/web/web-ui/test_serve.mjs index 3986717..6d645b9 100644 --- a/web/web-ui/test_serve.mjs +++ b/web/web-ui/test_serve.mjs @@ -469,7 +469,7 @@ assert.equal(pageFile('/deploy.html'), '/deploy.html'); assert.match(loopFn, /responses\.create\(params, \{ signal: rc\.signal \}\)/); assert.ok(!/responses\.create\(params\)/.test(loopFn), 'every round is abortable'); assert.match(loopFn, /if \(gone\.signal\.aborted\) throw err;/, 'an abort never retries as a param fallback'); - assert.match(loopFn, /if \(isRateLimited\(err\)\) throw err;/); + assert.match(loopFn, /if \(isRetryable\(err\)\) throw err;/); assert.match(loopFn, /else if \(summary !== 'auto'\)/); assert.ok(loopFn.lastIndexOf('histApplyCompaction') > loopFn.indexOf("histCloseOpenCalls(hist.items, { keepReasoning: true })"), 'the tool round applies compaction only after closing its open calls'); From c455c2091acbeccd2df695b5b5414bd766ce7b2f Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:00:22 +0000 Subject: [PATCH 032/126] Clip live Anthropic tool results the way history clips them. The live tool_result went through clipJson, a tail-drop that loses the closing fields that say a result was cut, while the same result persisted to history went through clip, which keeps head and tail. The next turn therefore saw a different observation than the current one. clip now lives in case-tools.mjs and both paths use it. --- web/web-ui/case-tools.mjs | 13 +++++++++---- web/web-ui/serve.mjs | 12 +----------- web/web-ui/test_serve.mjs | 7 +++++-- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/web/web-ui/case-tools.mjs b/web/web-ui/case-tools.mjs index c98ec6c..940320b 100644 --- a/web/web-ui/case-tools.mjs +++ b/web/web-ui/case-tools.mjs @@ -367,9 +367,14 @@ export function anthropicThinkingFor(messages) { return hasThinking ? { type: 'adaptive' } : { type: 'disabled' }; } -function clipJson(v, n = 8000) { +export function clip(v, n = 8000) { const s = typeof v === 'string' ? v : JSON.stringify(v); - return s.length > n ? s.slice(0, n) + '…' : s; + if (s.length <= n) return s; + // Head+tail, not a tail-drop: a 150-element snapshot overruns n, and a blind cut + // throws away the very fields that say so (count, truncated) along with the + // closing brace, so the model gets mid-JSON garbage with no signal it was cut. + const half = Math.floor((n - 40) / 2); + return `${s.slice(0, half)}\n…${s.length - 2 * half} chars elided…\n${s.slice(-half)}`; } /** Is this the provider saying "too fast" rather than "bad request"? Callers with @@ -537,10 +542,10 @@ export async function anthropicToolLoop({ call_id: call.call_id, act: toolResult.act || act, ok: !!toolResult.ok, - detail: clipJson(toolResult.error || toolResult.result || toolResult, 400), + detail: clip(toolResult.error || toolResult.result || toolResult, 400), }); const { image_b64, ...rest } = toolResult; - const content = [{ type: 'text', text: clipJson(rest) }]; + const content = [{ type: 'text', text: clip(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 }); } diff --git a/web/web-ui/serve.mjs b/web/web-ui/serve.mjs index 32769bd..8d7b07c 100644 --- a/web/web-ui/serve.mjs +++ b/web/web-ui/serve.mjs @@ -20,7 +20,7 @@ import path from 'node:path'; import crypto from 'node:crypto'; import { fileURLToPath } from 'node:url'; import OpenAI from 'openai'; -import { CASE_TOOLS, caseCall, caseToolPlan, runCaseTool, streamEventToNdjson, tracesFromOutput, chatAuth, envDriveAuth, resolveChatModel, histToAnthropicMessages, anthropicToolLoop, withRateRetry, isRetryable } from './case-tools.mjs'; +import { CASE_TOOLS, caseCall, caseToolPlan, runCaseTool, streamEventToNdjson, tracesFromOutput, chatAuth, envDriveAuth, resolveChatModel, histToAnthropicMessages, anthropicToolLoop, withRateRetry, isRetryable, clip } from './case-tools.mjs'; import * as ntfy from './ntfy.mjs'; import { PHONE_THREAD_ID, routePhone } from './phone.mjs'; import * as telegram from './telegram.mjs'; @@ -951,16 +951,6 @@ export function migrateShots(items, dir = shotsDir()) { return changed ? next : items; } -export function clip(v, n = 8000) { - const s = typeof v === 'string' ? v : JSON.stringify(v); - if (s.length <= n) return s; - // Head+tail, not a tail-drop: a 150-element snapshot overruns n, and a blind cut - // throws away the very fields that say so (count, truncated) along with the - // closing brace, so the model gets mid-JSON garbage with no signal it was cut. - const half = Math.floor((n - 40) / 2); - return `${s.slice(0, half)}\n…${s.length - 2 * half} chars elided…\n${s.slice(-half)}`; -} - /** A re-snapshot after a click that changed nothing repeats the whole element list. * Only the item being appended is replaced — never an earlier one, which would * invalidate the cached prefix for the rest of the turn. */ diff --git a/web/web-ui/test_serve.mjs b/web/web-ui/test_serve.mjs index 6d645b9..58db492 100644 --- a/web/web-ui/test_serve.mjs +++ b/web/web-ui/test_serve.mjs @@ -10,12 +10,12 @@ import { fileURLToPath } from 'node:url'; import { CASE_TOOLS, chatAuth, resolveChatModel, openaiToolsToAnthropic, newAnthropicStreamCtx, anthropicEventToNdjson, tracesFromAnthropicMessage, - histToAnthropicMessages, anthropicThinkingFor, caseToolPlan, + histToAnthropicMessages, anthropicThinkingFor, caseToolPlan, clip, } from './case-tools.mjs'; // serve.mjs loads threads.json at import and rewrites it; never the developer's own. process.env.CASE_THREADS = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'case-threads-')), 'threads.json'); -const { shq, pathOk, parseErr, parseFind, mimeFor, histTrim, histApplyCompaction, 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 } = await import('./serve.mjs'); +const { shq, pathOk, parseErr, parseFind, mimeFor, histTrim, histApplyCompaction, histCloseOpenCalls, normHost, threadTurns, parseCaseUrl, liveCid, liveDestPath, livePathHasDotDot, tokenMatches, liveHeaders, hostOf, browserOk, extraPlan, isLocalMode, pageFile, snapshotElide, stashShot, pushShot, hydrateShots, migrateShots, stashAttach, resolveAttach, hydrateAttaches, attachKind, ATTACH_MAX, sseEvents } = await import('./serve.mjs'); const html = fs.readFileSync(fileURLToPath(new URL('./index.html', import.meta.url)), 'utf8'); assert.match(html, /x-anthropic-key/); @@ -440,6 +440,9 @@ assert.equal(pageFile('/deploy.html'), '/deploy.html'); { 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 \} \}\)/); + // live tool_result and persisted history are cut by the same head+tail clip + assert.match(src, /\{ type: 'text', text: clip\(rest\) \}/); + assert.ok(!/clipJson/.test(src)); const items = []; const shots = new Set(); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'case-dedupe-')); From 9fe25f5656a9f1c9933e4eb5134be3ae5eba7055 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:02:03 +0000 Subject: [PATCH 033/126] Bound Anthropic turns by billed tokens and warn them at 80% too. The Anthropic loop budgeted raw input, cache reads and writes counted in full, so a well-cached turn hit the 2M budget at a fraction of what it cost; OpenAI had the same bug until it switched to billed tokens. It also never got BUDGET_WARN or the unchanged-snapshot elision. Its budget now counts input + 1.25x cache writes + 0.1x cache reads, and both loops share one budgetWarn helper and snapshotElide. --- web/web-ui/case-tools.mjs | 17 +++++--- web/web-ui/serve.mjs | 27 ++++++++----- web/web-ui/test_serve.mjs | 84 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 112 insertions(+), 16 deletions(-) diff --git a/web/web-ui/case-tools.mjs b/web/web-ui/case-tools.mjs index 940320b..524cd8f 100644 --- a/web/web-ui/case-tools.mjs +++ b/web/web-ui/case-tools.mjs @@ -471,8 +471,11 @@ 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; + // eff is billed input, the number the budget bounds (serve.mjs counts OpenAI's + // the same way): input_tokens excludes the cache, whose reads bill at 0.1x and + // writes at 1.25x. Raw input counts every cached re-send in full. + const spend = { in: 0, cached: 0, out: 0, eff: 0 }; + const overBudget = () => Number(tokenBudget) > 0 && spend.eff > tokenBudget; const round = async (p) => { const ctx = newAnthropicStreamCtx(); let thinkDelta = false; @@ -503,7 +506,7 @@ export async function anthropicToolLoop({ }; for (let i = 0; i < rounds && !finished && !overBudget(); i++) { if (stopped?.()) break; - beforeRound?.(messages); + beforeRound?.(messages, { round: i, eff: spend.eff }); let result; try { result = await withRateRetry(() => round(params), emit, 5, signal); @@ -518,9 +521,11 @@ export async function anthropicToolLoop({ } 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; + const read = u.cache_read_input_tokens || 0; + const wrote = u.cache_creation_input_tokens || 0; + spend.in += (u.input_tokens || 0) + read + wrote; + spend.cached += read; + spend.eff += (u.input_tokens || 0) + 1.25 * wrote + 0.1 * read; spend.out += u.output_tokens || 0; text = traces.texts.join('\n').trim(); if (!traces.calls.length) { diff --git a/web/web-ui/serve.mjs b/web/web-ui/serve.mjs index 8d7b07c..2592c68 100644 --- a/web/web-ui/serve.mjs +++ b/web/web-ui/serve.mjs @@ -561,6 +561,15 @@ const ROUNDS = 200; const TURN_TOKEN_BUDGET = Number(process.env.CASE_TURN_TOKENS || 2_000_000); const BUDGET_WARN = 'Turn budget nearly spent — a few tool steps remain. Append your progress and the exact next step to a file under /home/agent/reports now, then stop and say where you stopped.'; const isBudgetWarn = (it) => Array.isArray(it?.content) && it.content.some((c) => c?.text === BUDGET_WARN); +/** Past 80% of the billed budget or of the rounds, queue BUDGET_WARN into history + * and say so; returns which bound tripped, '' if neither. Both provider loops. */ +function budgetWarn(hist, emit, eff, round) { + const why = eff > 0.8 * TURN_TOKEN_BUDGET ? 'budget' : round >= 0.8 * ROUNDS ? 'rounds' : ''; + if (!why) return ''; + pushSteerItems(hist.items, [BUDGET_WARN]); + emit({ type: 'think', text: `[80% of the turn ${why} — told the model to wrap up]` }); + return why; +} // Window guard. OpenAI compacts server-side once the rendered context passes this // (opaque `compaction` item we carry forward); `truncation:'auto'` is the floor if a // model lacks compaction. 200k fits every window in the list (272k–1M). 0 = off. @@ -1041,6 +1050,8 @@ export async function runTurn({ if (auth.provider === 'anthropic') { const messages = histToAnthropicMessages(hydrateShots(hydrateAttaches(hist.items)), { media: true }); const shots = new Set(); + const snaps = { last: '' }; + let warned = ''; const { text: out, finished, spend, overBudget } = await anthropicToolLoop({ key: auth.key, model, @@ -1053,7 +1064,8 @@ export async function runTurn({ tokenBudget: TURN_TOKEN_BUDGET, signal: gone.signal, stopped, - beforeRound: (msgs) => { + beforeRound: (msgs, { round, eff }) => { + if (!warned && (warned = budgetWarn(hist, emit, eff, round))) appendSteerToAnthropic(msgs, BUDGET_WARN); const nudges = takeSteers(thread.id); for (const n of nudges) { pushSteerItems(hist.items, [n]); @@ -1075,16 +1087,17 @@ export async function runTurn({ () => (eplan ? runExtra(eplan) : runCaseTool(name, args, id, null)), 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) }); + const rest = snapshotElide(name, persist, snaps); + hist.items.push({ type: 'function_call_output', call_id: call.call_id || call.id, output: clip(rest) }); if (image_b64) pushShot(hist.items, shots, image_b64); - return result; + return image_b64 ? { ...rest, image_b64 } : rest; }, }); let text = out; if (!finished && !stopped()) { text = (text ? text + '\n\n' : '') + (overBudget - ? `**Out of budget.** Stopped after ${spend.in.toLocaleString('en-US')} input tokens with the task unfinished. Say **continue** and I pick up from here.` + ? `**Out of budget.** Stopped after ${Math.round(spend.eff).toLocaleString('en-US')} billed input tokens with the task unfinished. Say **continue** and I pick up from here.` : `**Out of steps.** Stopped after ${ROUNDS} tool calls with the task unfinished. Say **continue** and I pick up from here.`); emit({ type: 'text', text }); } else if (text) { @@ -1190,11 +1203,7 @@ export async function runTurn({ let compactions = 0; let i = 0; for (; i < ROUNDS && !finished && !stopped() && !overBudget(); i++) { - if (!warned && (effSoFar() > 0.8 * TURN_TOKEN_BUDGET || i >= 0.8 * ROUNDS)) { - warned = effSoFar() > 0.8 * TURN_TOKEN_BUDGET ? 'budget' : 'rounds'; - pushSteerItems(hist.items, [BUDGET_WARN]); - emit({ type: 'think', text: `[80% of the turn ${warned} — told the model to wrap up]` }); - } + if (!warned) warned = budgetWarn(hist, emit, effSoFar(), i); const nudges = takeSteers(thread.id); if (nudges.length) { for (const n of nudges) { diff --git a/web/web-ui/test_serve.mjs b/web/web-ui/test_serve.mjs index 58db492..3041518 100644 --- a/web/web-ui/test_serve.mjs +++ b/web/web-ui/test_serve.mjs @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; +import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -15,7 +16,8 @@ import { // serve.mjs loads threads.json at import and rewrites it; never the developer's own. process.env.CASE_THREADS = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'case-threads-')), 'threads.json'); -const { shq, pathOk, parseErr, parseFind, mimeFor, histTrim, histApplyCompaction, histCloseOpenCalls, normHost, threadTurns, parseCaseUrl, liveCid, liveDestPath, livePathHasDotDot, tokenMatches, liveHeaders, hostOf, browserOk, extraPlan, isLocalMode, pageFile, snapshotElide, stashShot, pushShot, hydrateShots, migrateShots, stashAttach, resolveAttach, hydrateAttaches, attachKind, ATTACH_MAX, sseEvents } = await import('./serve.mjs'); +process.env.CASE_TURN_TOKENS = '10000'; // small enough for the scripted turns below to cross 80% +const { runTurn, shq, pathOk, parseErr, parseFind, mimeFor, histTrim, histApplyCompaction, histCloseOpenCalls, normHost, threadTurns, parseCaseUrl, liveCid, liveDestPath, livePathHasDotDot, tokenMatches, liveHeaders, hostOf, browserOk, extraPlan, isLocalMode, pageFile, snapshotElide, stashShot, pushShot, hydrateShots, migrateShots, stashAttach, resolveAttach, hydrateAttaches, attachKind, ATTACH_MAX, sseEvents } = await import('./serve.mjs'); const html = fs.readFileSync(fileURLToPath(new URL('./index.html', import.meta.url)), 'utf8'); assert.match(html, /x-anthropic-key/); @@ -645,4 +647,84 @@ assert.equal(pageFile('/deploy.html'), '/deploy.html'); assert.equal(rest, 'event: handoff_cre'); } +// ---- scripted turns: a fake cased and a fake provider behind global fetch ---- +async function fakeCased(routes) { + const hits = []; + const srv = http.createServer((req, res) => { + hits.push(`${req.method} ${req.url}`); + const hit = Object.entries(routes).find(([k]) => req.url.startsWith(k)); + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(hit ? hit[1] : {})); + }); + await new Promise((r) => srv.listen(0, '127.0.0.1', r)); + const was = process.env.CASE_URL; + process.env.CASE_URL = `http://127.0.0.1:${srv.address().port}/v1`; + return { hits, close: () => { process.env.CASE_URL = was; srv.close(); } }; +} +/** One Messages SSE body per round: blocks are {text} or {tool, input}. */ +function anthropicSse({ blocks, usage = {} }) { + const ev = (type, data) => `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`; + let out = ev('message_start', { message: { id: 'msg', type: 'message', role: 'assistant', model: 'm', content: [], + stop_reason: null, usage: { input_tokens: 0, output_tokens: 1, ...usage } } }); + blocks.forEach((b, index) => { + if (b.tool) { + out += ev('content_block_start', { index, content_block: { type: 'tool_use', id: b.id, name: b.tool, input: {} } }); + out += ev('content_block_delta', { index, delta: { type: 'input_json_delta', partial_json: JSON.stringify(b.input || {}) } }); + } else { + out += ev('content_block_start', { index, content_block: { type: 'text', text: '' } }); + out += ev('content_block_delta', { index, delta: { type: 'text_delta', text: b.text } }); + } + out += ev('content_block_stop', { index }); + }); + const stop = blocks.some((b) => b.tool) ? 'tool_use' : 'end_turn'; + return out + ev('message_delta', { delta: { stop_reason: stop, stop_sequence: null }, usage: { output_tokens: 5 } }) + + ev('message_stop', {}); +} +async function withAnthropic(rounds, fn) { + const sent = []; + const orig = globalThis.fetch; + globalThis.fetch = async (_url, init) => { + sent.push(JSON.parse(init.body)); + return new Response(anthropicSse(rounds.shift()), { status: 200, headers: { 'content-type': 'text/event-stream' } }); + }; + try { return await fn(sent); } finally { globalThis.fetch = orig; } +} + +// Anthropic turns are bounded by billed input, not raw: 50k cache reads bill as +// 5k. Past 80% of the budget the model gets BUDGET_WARN, and a re-snapshot of an +// unchanged page is elided — the same as the OpenAI loop. +{ + const cased = await fakeCased({ + '/v1/computers/c_1/page': { ok: true, url: 'https://x/', elements: ['[1] button "Go"'] }, + '/v1/computers/c_1': { name: 'desk', credentials: [] }, + }); + const thread = { id: 't_budget', title: 't', agent: '', items: [], created: 0, updated: 0 }; + const events = []; + try { + const result = await withAnthropic([ + { usage: { input_tokens: 100, cache_read_input_tokens: 50000 }, blocks: [{ tool: 'computer_snapshot', id: 'tu_1' }] }, + { usage: { input_tokens: 100, cache_read_input_tokens: 30000 }, blocks: [{ tool: 'computer_snapshot', id: 'tu_2' }] }, + { usage: { input_tokens: 100 }, blocks: [{ text: 'done' }] }, + ], async (sent) => { + const r = await runTurn({ + thread, inputText: 'look', auth: { provider: 'anthropic', key: 'sk-ant' }, computerId: 'c_1', + model: 'claude-sonnet-4-6', emit: (e) => events.push(e), + }); + assert.equal(sent.length, 3, 'raw input is over budget after round 1; billed (5.1k) is not'); + const last = JSON.stringify(sent[2].messages.at(-1)); + assert.ok(last.includes('Turn budget nearly spent'), 'round 3 carries the wrap-up warning'); + assert.ok(last.includes('same elements as the previous snapshot'), 'the repeat snapshot is elided live'); + return r; + }); + assert.equal(result.finished, true); + assert.equal(result.text, 'done'); + assert.ok(events.some((e) => e.type === 'think' && /80% of the turn budget/.test(e.text))); + const outs = thread.items.filter((it) => it.type === 'function_call_output'); + assert.match(outs[1].output, /"unchanged":true/, 'history matches what the model saw'); + assert.ok(!JSON.stringify(thread.items).includes('Turn budget nearly spent'), 'the warning is turn-scoped'); + } finally { + cased.close(); + } +} + console.log('web-ui serve: all checks pass'); From e088a3d50d9d5e38089fd43f0038228b4e63039e Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:02:37 +0000 Subject: [PATCH 034/126] Show the model the png that handoff_get and click return. handoff_get and computer_click_element with screenshot:true carry the screen as screenshot_png_b64 inside their JSON. runExtra's generic path passed that JSON through whole, so Drive clipped it to base64 noise and the model never saw the image. The png is now popped into image_b64 and takes the same path computer_screenshot does. --- web/web-ui/serve.mjs | 4 ++++ web/web-ui/test_serve.mjs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/web/web-ui/serve.mjs b/web/web-ui/serve.mjs index 2592c68..f299405 100644 --- a/web/web-ui/serve.mjs +++ b/web/web-ui/serve.mjs @@ -541,6 +541,10 @@ async function runExtra(plan) { } const r = await api(plan.method, plan.rel, { body: plan.body, timeoutMs: plan.timeoutMs || 60000 }); if (r.status >= 400) return { ok: false, status: r.status, error: r.json?.error || r.raw, act: plan.act }; + // handoff_get and click(screenshot:true) carry a png inside the JSON. Left there it + // is clipped to base64 noise; popped, it takes the screenshot's image path. + const { screenshot_png_b64: png, ...rest } = r.json && typeof r.json === 'object' ? r.json : {}; + if (png) return { ok: rest.ok !== false, act: plan.act, result: rest, image_b64: png }; return { ok: r.json?.ok !== false, act: plan.act, result: r.json }; } catch (err) { return { ok: false, error: err.message || 'cased unreachable', act: plan.act }; diff --git a/web/web-ui/test_serve.mjs b/web/web-ui/test_serve.mjs index 3041518..e06d4ea 100644 --- a/web/web-ui/test_serve.mjs +++ b/web/web-ui/test_serve.mjs @@ -727,4 +727,35 @@ async function withAnthropic(rounds, fn) { } } +// A png inside a tool's JSON (handoff_get, click with screenshot:true) reaches the +// model as an image, not as clipped base64 text. +{ + const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; + const cased = await fakeCased({ + '/v1/handoffs/h_1': { id: 'h_1', status: 'pending', screenshot_png_b64: png }, + '/v1/computers/c_1': { name: 'desk', credentials: [] }, + }); + const thread = { id: 't_hshot', title: 't', agent: '', items: [], created: 0, updated: 0 }; + try { + await withAnthropic([ + { blocks: [{ tool: 'handoff_get', id: 'tu_1', input: { handoff_id: 'h_1' } }] }, + { blocks: [{ text: 'seen' }] }, + ], async (sent) => { + await runTurn({ + thread, inputText: 'check', auth: { provider: 'anthropic', key: 'sk-ant' }, computerId: 'c_1', + model: 'claude-sonnet-4-6', emit: () => {}, + }); + const res = sent[1].messages.at(-1).content[0]; + assert.equal(res.type, 'tool_result'); + assert.ok(!res.content[0].text.includes(png), 'no base64 in the text'); + assert.match(res.content[0].text, /"status":"pending"/); + assert.deepEqual(res.content[1], { type: 'image', source: { type: 'base64', media_type: 'image/png', data: png } }); + }); + assert.ok(thread.items.some((it) => it.shot), 'history keeps the png as a screenshot item'); + assert.ok(!JSON.stringify(thread.items).includes(png)); + } finally { + cased.close(); + } +} + console.log('web-ui serve: all checks pass'); From ba39bbb61bdba73d9ec19f5ad63cb6ba7ba4692d Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:03:45 +0000 Subject: [PATCH 035/126] Drop a failed round's partial output before it is replayed. withRateRetry replays a round from the start, but a round that fails mid-stream (an overload after text began, say) has already emitted its text, thinking and tool rows, so the replay rendered them twice and left the first attempt's tool rows spinning. withRateRetry now emits round when a round starts and round_reset before each replay, the Anthropic output_config fallback resets the same way, and the page rolls its reply back to the mark on round_reset. --- web/web-ui/case-tools.mjs | 10 ++++++++-- web/web-ui/index.html | 6 ++++++ web/web-ui/test_rate_retry.mjs | 13 ++++++++----- web/web-ui/test_serve.mjs | 4 ++++ 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/web/web-ui/case-tools.mjs b/web/web-ui/case-tools.mjs index 524cd8f..c7a21b7 100644 --- a/web/web-ui/case-tools.mjs +++ b/web/web-ui/case-tools.mjs @@ -408,8 +408,9 @@ export function rateWaitS(err, a) { } /** Retry a provider round on rate limits (429/529) and transient faults, - * honoring the server's suggested wait. History is only mutated after a round completes, so replaying - * a failed round is safe. `signal` cancels the backoff sleep on STOP. */ + * honoring the server's suggested wait. History is only mutated after a round + * completes, so replaying a failed round is safe. `signal` cancels the backoff + * sleep on STOP. */ function abortError(signal) { if (signal?.reason instanceof Error) return signal.reason; const err = new Error(signal?.reason ? String(signal.reason) : 'stopped by user'); @@ -437,6 +438,9 @@ function abortableDelay(ms, signal) { } export async function withRateRetry(fn, emit, tries = 5, signal) { + // What was streamed is not replay-safe: a round that fails mid-stream has already + // emitted text and tool rows. `round` marks its start, `round_reset` rolls back to it. + emit?.({ type: 'round' }); for (let a = 0; ; a++) { if (signal?.aborted) throw abortError(signal); try { return await fn(); } @@ -445,6 +449,7 @@ export async function withRateRetry(fn, emit, tries = 5, signal) { if (!isRetryable(err) || a >= tries - 1) throw err; const wait = rateWaitS(err, a); const why = isRateLimited(err) ? 'rate limited' : 'provider error'; + emit?.({ type: 'round_reset' }); // `rate: true` so a non-UI consumer can pick the wait out of the think stream. emit?.({ type: 'think', rate: true, text: `${why} — retrying in ${Math.ceil(wait)}s` }); await abortableDelay(wait * 1000, signal); @@ -517,6 +522,7 @@ export async function anthropicToolLoop({ if (!params.output_config || isRetryable(err)) throw err; const rest = { ...params }; delete rest.output_config; + emit({ type: 'round_reset' }); result = await withRateRetry(() => round(rest), emit, 5, signal); } const { message, traces, textDelta } = result; diff --git a/web/web-ui/index.html b/web/web-ui/index.html index 5333ed7..d8b1e6a 100644 --- a/web/web-ui/index.html +++ b/web/web-ui/index.html @@ -1498,6 +1498,7 @@

SCHEDULES

const toolsEl=reply.querySelector('.tool-block'); const pEl=reply.querySelector('.pmsg'); let raw=''; + let mark=null; // render state when the provider round began; round_reset returns to it const caret=caretEl(); const setText=t=>{raw=t;pEl.innerHTML=md(raw);pEl.appendChild(caret);}; // machine types const addText=t=>setText(raw+t); @@ -1578,6 +1579,11 @@

SCHEDULES

if(activeTid==='__pending')activeTid=ev.thread_id; } else if(ev.type==='claim'&&ev.thread_id){claimThread(ev.thread_id,ev.agent);} + else if(ev.type==='round'){mark={raw,think:thinkEl.textContent,tools:toolsEl.children.length};} + else if(ev.type==='round_reset'&&mark){ + setText(mark.raw);thinkEl.textContent=mark.think; + while(toolsEl.children.length>mark.tools)toolsEl.lastChild.remove(); + } else if(ev.type==='think_delta'&&ev.text){thinkBlock.classList.add('has');thinkEl.textContent+=ev.text;} else if(ev.type==='think'&&ev.text){thinkBlock.classList.add('has');thinkEl.textContent+=(thinkEl.textContent?'\n\n':'')+ev.text;} else if(ev.type==='tool'||ev.type==='tool_args'){fillTool(toolRow(ev),ev);} diff --git a/web/web-ui/test_rate_retry.mjs b/web/web-ui/test_rate_retry.mjs index ddca4f6..0260c65 100644 --- a/web/web-ui/test_rate_retry.mjs +++ b/web/web-ui/test_rate_retry.mjs @@ -65,9 +65,12 @@ const out = await withRateRetry(async () => { }, (ev) => seen.push(ev)); assert.equal(out, 'round done'); assert.equal(calls, 3); -assert.equal(seen.length, 2, 'one notice per wait'); -assert.ok(seen.every((e) => e.type === 'think' && e.rate === true), 'notices carry rate:true so headless consumers can log them'); -assert.match(seen[0].text, /rate limited — retrying in 1s/); +// a replay re-streams the round: the UI is told where it began and to drop the +// failed attempt's partial output before each retry +assert.deepEqual(seen.map((e) => e.type), ['round', 'round_reset', 'think', 'round_reset', 'think']); +const notes = seen.filter((e) => e.type === 'think'); +assert.ok(notes.every((e) => e.rate === true), 'notices carry rate:true so headless consumers can log them'); +assert.match(notes[0].text, /rate limited — retrying in 1s/); // a transient 5xx is retried the same way, and the notice does not call it a rate limit { @@ -79,7 +82,7 @@ assert.match(seen[0].text, /rate limited — retrying in 1s/); return 'ok'; }, (ev) => notes.push(ev)); assert.equal(r, 'ok'); - assert.match(notes[0].text, /^provider error — retrying in 1s/); + assert.match(notes.find((e) => e.type === 'think').text, /^provider error — retrying in 1s/); } // retries do run dry — the caller has to see the error, not hang forever @@ -101,7 +104,7 @@ assert.equal(calls, 3, 'tries is a hard cap'); const err = new Error('rate limited'); err.status = 429; throw err; - }, () => ctl.abort(), 5, ctl.signal), + }, (e) => { if (e.type === 'think') ctl.abort(); }, 5, ctl.signal), (err) => err?.name === 'AbortError', ); assert.equal(n, 1, 'disconnect stops retries before another provider request'); diff --git a/web/web-ui/test_serve.mjs b/web/web-ui/test_serve.mjs index e06d4ea..a18f10c 100644 --- a/web/web-ui/test_serve.mjs +++ b/web/web-ui/test_serve.mjs @@ -495,6 +495,10 @@ assert.equal(pageFile('/deploy.html'), '/deploy.html'); assert.match(loopFn, /if \(!stopped\(\)\) emit\(\{ type: 'error'/); assert.match(html, /\/api\/chat\/steer/); assert.match(html, /steerPrompt/); + assert.match(html, /ev\.type==='round'\)\{mark=/, 'the UI marks where each provider round began'); + assert.match(html, /ev\.type==='round_reset'&&mark/, 'and drops a failed round\'s partial output on replay'); + assert.match(caseToolsSrc, /emit\(\{ type: 'round_reset' \}\);\n result = await withRateRetry\(\(\) => round\(rest\)/, + 'the output_config fallback replays the round too'); assert.match(loopFn, /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'), From c8a01ee82b1df5033b173e6a03dcbfb71c10ae1a Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:04:56 +0000 Subject: [PATCH 036/126] Answer a refused /live upgrade and forward the bytes behind it. vncWs had no 'response' handler, so when cased answered the websocket upgrade with anything but 101 (desk asleep, gone, unauthorized) the browser's socket stayed open forever waiting for a switch. It also ended the upstream request before writing the upgrade's head, so any bytes the browser sent right behind its request broke the connection. The refusal's status is now passed back and the socket closed, head is written before end, and a browser socket error tears down the upstream request instead of going unhandled. --- web/web-ui/serve.mjs | 9 ++++++++- web/web-ui/test_http.mjs | 40 +++++++++++++++++++++++++++++++++++----- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/web/web-ui/serve.mjs b/web/web-ui/serve.mjs index f299405..222717e 100644 --- a/web/web-ui/serve.mjs +++ b/web/web-ui/serve.mjs @@ -1738,6 +1738,13 @@ 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: liveHeaders(req, true) }); + socket.on('error', () => up.destroy()); + // cased refused the upgrade (asleep, gone, unauthorized): pass the status on, or + // the browser's socket waits for a 101 that never comes. + up.on('response', (upRes) => { + upRes.resume(); + socket.end(`HTTP/1.1 ${upRes.statusCode} ${upRes.statusMessage || ''}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); + }); 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}`); @@ -1748,8 +1755,8 @@ function vncWs(req, socket, head) { for (const s of [socket, upSocket]) s.on('error', () => { socket.destroy(); upSocket.destroy(); }); }); up.on('error', () => socket.destroy()); - up.end(); if (head?.length) up.write(head); + up.end(); } // Only the two pages are servable. Everything else in this directory — diff --git a/web/web-ui/test_http.mjs b/web/web-ui/test_http.mjs index b6452b1..df530cb 100644 --- a/web/web-ui/test_http.mjs +++ b/web/web-ui/test_http.mjs @@ -12,11 +12,17 @@ process.env.CASE_TOKEN = 'tok'; delete process.env.CASE_DRIVE_API_KEY; delete process.env.CASE_DRIVE_PROVIDER; process.env.CASE_THREADS = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'case-threads-')), 'threads.json'); -// cased is down: a port that was just free and is closed again. -const dead = http.createServer(); -await new Promise((r) => dead.listen(0, '127.0.0.1', r)); -process.env.CASE_URL = `http://127.0.0.1:${dead.address().port}`; -await new Promise((r) => dead.close(r)); +// cased is down for REST (every connection reset); its /live relay answers +// websocket upgrades: c_asleep is refused, anything else is switched and echoed. +const cased = http.createServer((req) => req.socket.destroy()); +cased.on('upgrade', (req, socket, head) => { + if (req.url.includes('/c_asleep/')) return socket.end('HTTP/1.1 409 Conflict\r\nContent-Length: 0\r\n\r\n'); + socket.write('HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n'); + if (head.length) socket.write(head); + socket.pipe(socket); +}); +await new Promise((r) => cased.listen(0, '127.0.0.1', r)); +process.env.CASE_URL = `http://127.0.0.1:${cased.address().port}`; const serve = await import('./serve.mjs'); const { server } = serve; await new Promise((r) => server.listen(0, '127.0.0.1', r)); @@ -128,5 +134,29 @@ try { } } +// /live websockets: a refused upgrade is answered, not left hanging, and bytes the +// browser sent right behind its upgrade request reach the desk. +{ + const open = (cid, extra = '') => new Promise((resolve) => { + const c = net.connect(server.address().port, '127.0.0.1', () => { + c.write(`GET /live/${cid}/websockify HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer tok\r\n` + + 'Connection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\n' + + `Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n${extra}`); + }); + let got = ''; + c.on('data', (d) => { + got += d; + if (extra && got.endsWith(extra)) { c.destroy(); resolve(got); } + }); + c.on('close', () => resolve(got)); + c.setTimeout(2000, () => { c.destroy(); resolve(`timeout: ${got}`); }); + }); + assert.match(await open('c_asleep'), /^HTTP\/1\.1 409 /); + const up = await open('c_awake', 'first-frame'); + assert.match(up, /^HTTP\/1\.1 101 /); + assert.ok(up.endsWith('first-frame'), up); +} + +cased.close(); server.close(); console.log('test_http: ok'); From a9e1010dd99fd489dec917e436fb86f213c58661 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:05:05 +0000 Subject: [PATCH 037/126] Escape the schedules error before putting it in innerHTML. loadSchedules wrote the server's error message into the list with innerHTML unescaped, so markup in a cased or proxy error rendered as HTML in the Drive page. It goes through esc() now, like every other server string the page renders. --- web/web-ui/index.html | 2 +- web/web-ui/test_serve.mjs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/web/web-ui/index.html b/web/web-ui/index.html index d8b1e6a..1466055 100644 --- a/web/web-ui/index.html +++ b/web/web-ui/index.html @@ -1291,7 +1291,7 @@

SCHEDULES

try{ const r=await fetch('/api/schedules?'+compQ()); const j=await r.json().catch(()=>[]); - if(!r.ok){$('schedList').innerHTML='
'+(apiErr(j)||'could not load')+'
';return;} + if(!r.ok){$('schedList').innerHTML='
'+esc(apiErr(j)||'could not load')+'
';return;} paintSchedList(Array.isArray(j)?j:[]); }catch{$('schedList').innerHTML='
cased unreachable
';} } diff --git a/web/web-ui/test_serve.mjs b/web/web-ui/test_serve.mjs index a18f10c..9ff44f3 100644 --- a/web/web-ui/test_serve.mjs +++ b/web/web-ui/test_serve.mjs @@ -31,6 +31,7 @@ assert.match(html, /id="schedBtn"/); assert.match(html, /id="schedBtnM"/); assert.match(html, /Intl\.supportedValuesOf/); assert.match(html, /brain_key/); +for (const m of html.matchAll(/innerHTML=[^\n]*apiErr\(j\)[^\n]*/g)) assert.match(m[0], /esc\(apiErr\(j\)/, 'error text is escaped before innerHTML'); for (const m of html.matchAll(/'\/api\/(fs|file|creds|schedules|teach-tick)[^;]*/g)) { assert.match(m[0], /compQ\(\)/, `${m[1]} names the picked computer: ${m[0]}`); } From c852a344994fbfe7dc19c26ff3e394e8255d72fe Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:06:02 +0000 Subject: [PATCH 038/126] Keep steers and queued prompts on the running thread, not the viewed one. steerPrompt sent to activeTid, the thread on screen, so after clicking another thread mid-turn a typed message 409ed against it, fell into the queue, and ran on that thread once the turn ended. openThread also had no stale-response guard, so a slow load could paint one thread's turns under another's selection. The page now tracks the running thread in runTid for steering and hands the queue back to it when the turn ends, and openThread drops a response that a newer click has superseded. --- web/web-ui/index.html | 22 +++++++++++++++++++--- web/web-ui/test_serve.mjs | 5 +++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/web/web-ui/index.html b/web/web-ui/index.html index 1466055..24525c8 100644 --- a/web/web-ui/index.html +++ b/web/web-ui/index.html @@ -777,6 +777,8 @@

SCHEDULES

let comps=[]; let threads=[]; let activeTid=''; +let runTid=''; // thread of the turn in flight; activeTid is only the one on screen +let threadGen=0; const EMPTY_HTML='
DRIVE

Tell it what to do. Watch if you want.

“open x.com and tell me what\'s on the home feed”
'; function showEmpty(){ $('loginner').innerHTML=EMPTY_HTML; @@ -859,6 +861,7 @@

SCHEDULES

paint(); } function newTask(){ + threadGen++; activeTid=''; showEmpty(); paint(); @@ -893,11 +896,13 @@

SCHEDULES

$('newAgent').addEventListener('click',()=>newTask()); async function openThread(tid){ if(tid===activeTid)return; + const gen=++threadGen; activeTid=tid; paint(); try{ const r=await fetch('/api/threads?id='+encodeURIComponent(tid)); const j=await r.json(); + if(gen!==threadGen)return; // clicked on to another thread meanwhile if(!r.ok){inner.innerHTML='
'+esc(j.error||'thread gone')+'
';return;} // Deliberately does NOT follow j.agent: moving you to another computer // behind a click means another set of logins. Deploy owns the seat. @@ -922,7 +927,7 @@

SCHEDULES

} log.scrollTop=log.scrollHeight; }catch{ - inner.innerHTML='
cannot reach the drive server
'; + if(gen===threadGen)inner.innerHTML='
cannot reach the drive server
'; } } @@ -1451,11 +1456,12 @@

SCHEDULES

/* 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:''; + const tid=runTid&&runTid!=='__pending'?runTid:''; 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;} + if(activeTid!==tid)return; // on screen is another thread; reopening this one shows it const art=document.createElement('article'); art.className='turn you'; art.innerHTML='
YOU

'; @@ -1481,6 +1487,7 @@

SCHEDULES

const th=threads.find(x=>x.id===activeTid); if(th){th.updated=Date.now();th.pending=true;paint();} } + runTid=activeTid; const art=document.createElement('article'); art.className='turn you'; art.innerHTML='
YOU

'; @@ -1577,6 +1584,7 @@

SCHEDULES

for(const el of $('navlist').querySelectorAll('[data-t="__pending"]')) el.dataset.t=ev.thread_id; } if(activeTid==='__pending')activeTid=ev.thread_id; + if(runTid==='__pending')runTid=ev.thread_id; } else if(ev.type==='claim'&&ev.thread_id){claimThread(ev.thread_id,ev.agent);} else if(ev.type==='round'){mark={raw,think:thinkEl.textContent,tools:toolsEl.children.length};} @@ -1617,8 +1625,16 @@

SCHEDULES

$('stopBtn').hidden=true; caret.remove(); if(activeTid==='__pending')activeTid=''; // stream died before the thread was born + const ran=runTid!=='__pending'?runTid:''; + runTid=''; refresh(); // state may have changed (wake on first tool); threads re-sync too - if(promptQ.length){const n=promptQ.shift();paintQ();sendPrompt(n);} + // Queued prompts were typed into the turn that just ended: they continue its + // thread, even if another one is on screen now. + if(promptQ.length){ + const n=promptQ.shift();paintQ(); + if(ran&&ran!==activeTid)openThread(ran).then(()=>{if(chatCtl){promptQ.unshift(n);paintQ();}else sendPrompt(n);}); + else sendPrompt(n); + } } } diff --git a/web/web-ui/test_serve.mjs b/web/web-ui/test_serve.mjs index 9ff44f3..f3b682b 100644 --- a/web/web-ui/test_serve.mjs +++ b/web/web-ui/test_serve.mjs @@ -496,6 +496,11 @@ assert.equal(pageFile('/deploy.html'), '/deploy.html'); assert.match(loopFn, /if \(!stopped\(\)\) emit\(\{ type: 'error'/); assert.match(html, /\/api\/chat\/steer/); assert.match(html, /steerPrompt/); + // switching threads mid-turn: a slow reopen never paints over a newer pick, and + // what is typed still steers the running turn, not the thread on screen + assert.match(html, /if\(gen!==threadGen\)return;/); + assert.match(html, /const tid=runTid&&runTid!=='__pending'\?runTid:'';/); + assert.ok(!/const tid=activeTid/.test(html)); assert.match(html, /ev\.type==='round'\)\{mark=/, 'the UI marks where each provider round began'); assert.match(html, /ev\.type==='round_reset'&&mark/, 'and drops a failed round\'s partial output on replay'); assert.match(caseToolsSrc, /emit\(\{ type: 'round_reset' \}\);\n result = await withRateRetry\(\(\) => round\(rest\)/, From d5c487cde6201db5fdfc687c1aba8b52976e7a01 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:06:23 +0000 Subject: [PATCH 039/126] Mark the Drive token cookie Secure when it is set over HTTPS. The case_token cookie never carried Secure, so behind the HTTPS proxy the README recommends it could still ride a plain-http request. It is now Secure when the proxy reports x-forwarded-proto https or the request's Origin is https, and stays as it was on plain-http loopback, where a Secure cookie would never be sent back. --- web/web-ui/serve.mjs | 8 +++++++- web/web-ui/test_http.mjs | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/web/web-ui/serve.mjs b/web/web-ui/serve.mjs index 222717e..b961bcf 100644 --- a/web/web-ui/serve.mjs +++ b/web/web-ui/serve.mjs @@ -84,6 +84,12 @@ export function livePathHasDotDot(rawUrl) { } return false; } +// Did the browser reach us over HTTPS? The proxy in front says so, or the page's +// origin does. Plain-http loopback must not get a Secure cookie: it never comes back. +export function viaHttps(req) { + const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase(); + return proto === 'https' || /^https:\/\//i.test(String(req.headers.origin || '')); +} export function tokenMatches(req, need = TOKEN) { if (!need) return true; const auth = req.headers.authorization || ''; @@ -1774,7 +1780,7 @@ export const server = http.createServer(async (req, res) => { 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`, + 'Set-Cookie': `case_token=${encodeURIComponent(TOKEN)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000${viaHttps(req) ? '; Secure' : ''}`, 'Cache-Control': 'no-store', }); return res.end(); diff --git a/web/web-ui/test_http.mjs b/web/web-ui/test_http.mjs index df530cb..3b260fd 100644 --- a/web/web-ui/test_http.mjs +++ b/web/web-ui/test_http.mjs @@ -37,6 +37,11 @@ assert.equal((await get('/api/threads')).status, 401); assert.equal((await get('/api/threads', { authorization: 'Bearer tok' })).status, 200); assert.equal((await fetch(base + '/api/threads', { method: 'POST', headers: { authorization: 'Bearer tok', origin: 'https://evil.example' } })).status, 403); assert.equal((await get('/?token=tok')).status, 302); +// the cookie is Secure only when the browser came in over HTTPS +assert.doesNotMatch((await get('/?token=tok')).headers.get('set-cookie'), /Secure/); +assert.match((await get('/?token=tok', { 'x-forwarded-proto': 'https' })).headers.get('set-cookie'), /; Secure$/); +assert.match((await get('/?token=tok', { 'x-forwarded-proto': 'https,http' })).headers.get('set-cookie'), /; Secure$/); +assert.doesNotMatch((await get('/?token=tok', { 'x-forwarded-proto': 'http' })).headers.get('set-cookie'), /Secure/); // fetch silently ignores a Host override; use http.get for the rebinding case. const status = await new Promise((r) => http.get({ host: '127.0.0.1', port: server.address().port, path: '/api/threads', headers: { authorization: 'Bearer tok', host: 'evil.example' } }, (res) => { res.resume(); r(res.statusCode); })); From 0be51b549db6a1cb09e7473225c5c6619baf7db0 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:07:18 +0000 Subject: [PATCH 040/126] Refuse an over-cap file before reading all of it. fsFile buffered the whole reply from cased and only then compared it with the 8MB cap, so opening a multi-gigabyte file held all of it in Drive's memory to answer 413. caseCall takes a maxBytes limit now: it gives up on a content-length over the limit before reading a byte, and stops a reply that grows past it, and fsFile passes FILE_CAP. --- web/web-ui/case-tools.mjs | 12 ++++++++++-- web/web-ui/serve.mjs | 4 ++-- web/web-ui/test_http.mjs | 27 +++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/web/web-ui/case-tools.mjs b/web/web-ui/case-tools.mjs index c7a21b7..165ceb2 100644 --- a/web/web-ui/case-tools.mjs +++ b/web/web-ui/case-tools.mjs @@ -65,7 +65,8 @@ export function caseToolPlan(name, args, cid) { // The one HTTP client for cased in JS: the chat tool loop and serve.mjs both // route through it. json/body are interchangeable JSON payload keys; rawBody // sends bytes as-is; raw:true resolves {status, buf} instead of parsed JSON. -export function caseCall(method, rel, { json, body, rawBody = null, raw = false, timeoutMs = 20000 } = {}) { +// maxBytes stops reading a bigger reply and resolves {status, tooBig: true}. +export function caseCall(method, rel, { json, body, rawBody = null, raw = false, timeoutMs = 20000, maxBytes = 0 } = {}) { const u = new URL(rel.startsWith('http') ? rel : caseRoot() + rel); const lib = u.protocol === 'https:' ? https : http; const data = json ?? body; @@ -84,7 +85,14 @@ export function caseCall(method, rel, { json, body, rawBody = null, raw = false, }, }, (res) => { const chunks = []; - res.on('data', (c) => chunks.push(c)); + let n = 0; + const tooBig = () => { res.destroy(); resolve({ status: res.statusCode || 0, tooBig: true }); }; + if (maxBytes && Number(res.headers['content-length']) > maxBytes) return tooBig(); + res.on('data', (c) => { + n += c.length; + if (maxBytes && n > maxBytes) return tooBig(); + chunks.push(c); + }); res.on('end', () => { const buf = Buffer.concat(chunks); if (raw) return resolve({ status: res.statusCode || 0, buf }); diff --git a/web/web-ui/serve.mjs b/web/web-ui/serve.mjs index b961bcf..3e39b97 100644 --- a/web/web-ui/serve.mjs +++ b/web/web-ui/serve.mjs @@ -296,12 +296,12 @@ async function fsFile(res, url) { if (!id) return json(res, 409, { error: 'no computer picked' }); const r = await api('GET', `/computers/${encodeURIComponent(id)}/files?path=${encodeURIComponent(p)}&wake=true`, - { timeoutMs: 60000, raw: true }); + { timeoutMs: 60000, raw: true, maxBytes: FILE_CAP }); + if (r.tooBig) return json(res, 413, { error: 'file over 8MB' }); if (r.status >= 400) { const msg = parseErr(r.buf, 'read failed'); return json(res, r.status, { error: msg }); } - if (r.buf.length > FILE_CAP) return json(res, 413, { error: 'file over 8MB' }); const ext = path.extname(p).toLowerCase(); const inline = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.pdf'].includes(ext); res.writeHead(200, { diff --git a/web/web-ui/test_http.mjs b/web/web-ui/test_http.mjs index 3b260fd..42d08cf 100644 --- a/web/web-ui/test_http.mjs +++ b/web/web-ui/test_http.mjs @@ -139,6 +139,33 @@ try { } } +// A file over the 8MB cap is refused as soon as that is known — from its +// content-length, or once the bytes read pass it — not after buffering it whole. +{ + const auth = { authorization: 'Bearer tok' }; + const fake = http.createServer((req, res) => { + if (req.url.includes('declared')) { + res.writeHead(200, { 'content-length': String(9 * 1024 * 1024) }); + return res.write('x'); // and never the rest + } + res.writeHead(200); // chunked: the size is only known by reading + res.write(Buffer.alloc(8 * 1024 * 1024 + 1)); + }); + await new Promise((r) => fake.listen(0, '127.0.0.1', r)); + const was = process.env.CASE_URL; + process.env.CASE_URL = `http://127.0.0.1:${fake.address().port}/v1`; + try { + for (const name of ['declared.bin', 'streamed.bin']) { + const r = await get(`/api/file?path=/home/agent/${name}&computer_id=c_1`, auth); + assert.equal(r.status, 413, name); + } + } finally { + process.env.CASE_URL = was; + fake.closeAllConnections(); + fake.close(); + } +} + // /live websockets: a refused upgrade is answered, not left hanging, and bytes the // browser sent right behind its upgrade request reach the desk. { From 54763d5f071e05797d51fbc1fc5721fce055b69a Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:08:41 +0000 Subject: [PATCH 041/126] Stop histTrim re-serializing the whole history on every pass. over() ran JSON.stringify(h.items) for each turn it considered, which made trimming quadratic: about 420ms on a 3.2MB thread, paid at the end of every turn. It now sizes each item once and keeps a running total through its splices; the total is the same number the stringify produced, so the trimmed history is identical. --- web/web-ui/serve.mjs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/web/web-ui/serve.mjs b/web/web-ui/serve.mjs index 3e39b97..b3ab688 100644 --- a/web/web-ui/serve.mjs +++ b/web/web-ui/serve.mjs @@ -727,7 +727,18 @@ function turnTail(items, budget) { * turn pays to re-send on every round. A short turn is nowhere near `max`, so a * follow-up like "click the blue one" still has its snapshot to work from. */ export function histTrim(h, max = HIST_MAX) { - const over = () => JSON.stringify(h.items).length > max; + // Per-item sizes plus brackets and commas add up to JSON.stringify(h.items).length + // without re-serializing the whole history on every pass. + const size = (it) => (JSON.stringify(it) ?? 'null').length; + const sizes = h.items.map(size); + let sum = sizes.reduce((a, n) => a + n, 0); + const over = () => sum + Math.max(h.items.length - 1, 0) + 2 > max; + const splice = (at, n, add = []) => { + const added = add.map(size); + for (const g of sizes.splice(at, n, ...added)) sum -= g; + for (const g of added) sum += g; + h.items.splice(at, n, ...add); + }; let starts = turnStarts(h.items); for (let k = 0; k < starts.length && over(); k++) { const [a, b] = [starts[k], starts[k + 1] ?? h.items.length]; @@ -747,11 +758,11 @@ export function histTrim(h, max = HIST_MAX) { const comp = body.filter((it) => it.type === 'compaction' && !keep.includes(it)); const kept = [{ ...turn[0], content: clip(turn[0].content, PROMPT_KEEP) }, ...comp, ...keep]; if (kept.length === turn.length) continue; - h.items.splice(a, b - a, ...kept); + splice(a, b - a, kept); starts = turnStarts(h.items); } while (starts.length > 1 && over()) { - h.items.splice(0, starts[1]); + splice(0, starts[1]); starts = turnStarts(h.items); } } From 677c0df6b66a68c94e51bd2fc37d20f1d2546e56 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:09:36 +0000 Subject: [PATCH 042/126] Read each screenshot and attachment once per turn, not once per round. The OpenAI loop rebuilds its stateless request every round, and each rebuild re-read and base64-encoded every screenshot and attachment in the thread from disk. hydrateShots and hydrateAttaches take a memo keyed by file path now; runTurn keeps one per turn, so the request is still rebuilt whole each round but each file is encoded once. --- web/web-ui/serve.mjs | 52 ++++++++++++++++++++++++--------------- web/web-ui/test_serve.mjs | 18 ++++++++++++++ 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/web/web-ui/serve.mjs b/web/web-ui/serve.mjs index b3ab688..a430ea3 100644 --- a/web/web-ui/serve.mjs +++ b/web/web-ui/serve.mjs @@ -815,28 +815,34 @@ export function pushShot(items, shots, b64, dir = shotsDir()) { items.push(stashShot(b64, dir)); } -export function hydrateShots(items, dir = shotsDir()) { +/** memo (shot path -> item) lets a turn read and encode each file once, not once + * per round; the request is still rebuilt whole every round (store:false). */ +export function hydrateShots(items, dir = shotsDir(), memo = new Map()) { const root = path.resolve(dir) + path.sep; return (items || []).map((it) => { if (!it?.shot) return it; - const abs = path.resolve(it.shot); - if (!abs.startsWith(root) || path.extname(abs) !== '.png') { - return { role: 'user', content: [{ type: 'input_text', text: '[screenshot]' }] }; - } - try { - const buf = fs.readFileSync(abs); - return { - role: 'user', - content: [{ - type: 'input_image', detail: 'high', - image_url: 'data:image/png;base64,' + buf.toString('base64'), - }], - }; - } catch { - return { role: 'user', content: [{ type: 'input_text', text: '[screenshot]' }] }; - } + if (!memo.has(it.shot)) memo.set(it.shot, hydrateShot(it.shot, root)); + return memo.get(it.shot); }); } +function hydrateShot(shot, root) { + const abs = path.resolve(shot); + if (!abs.startsWith(root) || path.extname(abs) !== '.png') { + return { role: 'user', content: [{ type: 'input_text', text: '[screenshot]' }] }; + } + try { + const buf = fs.readFileSync(abs); + return { + role: 'user', + content: [{ + type: 'input_image', detail: 'high', + image_url: 'data:image/png;base64,' + buf.toString('base64'), + }], + }; + } catch { + return { role: 'user', content: [{ type: 'input_text', text: '[screenshot]' }] }; + } +} export const ATTACH_MAX = 5 * 1024 * 1024; export const ATTACH_MAX_N = 4; @@ -938,7 +944,7 @@ function hydrateOneAttach(a, dir) { return [note]; } -export function hydrateAttaches(items, dir = inboxDir()) { +export function hydrateAttaches(items, dir = inboxDir(), memo = new Map()) { return (items || []).map((it) => { if (!it?.attaches?.length) return it; const parts = []; @@ -947,7 +953,11 @@ export function hydrateAttaches(items, dir = inboxDir()) { } 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)); + for (const a of it.attaches) { + const key = String(a?.path || ''); + if (!memo.has(key)) memo.set(key, hydrateOneAttach(a, dir)); + parts.push(...memo.get(key)); + } return { role: 'user', content: parts }; }); } @@ -1134,6 +1144,8 @@ export async function runTurn({ const effSoFar = () => Math.round((spend.in - spend.cached) + 0.1 * spend.cached); let summary = 'detailed'; let compact = COMPACT_AT > 0; + const shotMemo = new Map(); + const attachMemo = new Map(); const round = async () => { // The SDK leaves its abort listener on the signal after the round ends; over a // 200-round turn that is 200 dead listeners on one signal. A per-round @@ -1144,7 +1156,7 @@ export async function runTurn({ if (gone.signal.aborted) rc.abort(); try { const params = { - model, input: [dev, ...hydrateShots(hydrateAttaches(hist.items))], tools: ALL_TOOLS, + model, input: [dev, ...hydrateShots(hydrateAttaches(hist.items, inboxDir(), attachMemo), shotsDir(), shotMemo)], 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 diff --git a/web/web-ui/test_serve.mjs b/web/web-ui/test_serve.mjs index f3b682b..6084d26 100644 --- a/web/web-ui/test_serve.mjs +++ b/web/web-ui/test_serve.mjs @@ -561,6 +561,14 @@ assert.equal(pageFile('/deploy.html'), '/deploy.html'); assert.equal(missing[0].content[0].type, 'input_text'); const outside = hydrateShots([{ role: 'user', shot: '/etc/passwd', content: [{ type: 'input_text', text: '[screenshot]' }] }], dir); assert.equal(outside[0].content[0].type, 'input_text', 'paths outside the shots dir are refused'); + // A turn reads each file once: with its memo, a later round needs no disk. + const memo = new Map(); + const first = hydrateShots([item], dir, memo)[0]; + const copy = path.join(dir, 'keep.png'); + fs.renameSync(item.shot, copy); + assert.equal(hydrateShots([item], dir, memo)[0], first); + assert.equal(hydrateShots([item], dir)[0].content[0].type, 'input_text', 'without it the file is read again'); + fs.renameSync(copy, item.shot); const legacy = [{ role: 'user', content: [{ type: 'input_image', detail: 'high', image_url: 'data:image/png;base64,' + png }] }]; const moved = migrateShots(legacy, dir); assert.ok(moved[0].shot); @@ -603,6 +611,16 @@ assert.equal(pageFile('/deploy.html'), '/deploy.html'); 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 memo = new Map(); + const turn = [{ role: 'user', content: '', attaches: [{ path: img.path, name: img.name, mime: img.mime }] }]; + const once = hydrateAttaches(turn, dir, memo); + fs.renameSync(img.path, img.path + '.moved'); + const again = hydrateAttaches(turn, dir, memo); + fs.renameSync(img.path + '.moved', img.path); + assert.deepEqual(again, once, 'an attachment is read once per turn'); + assert.equal(again[0].content[0].type, 'input_image'); + } const missing = hydrateAttaches([{ role: 'user', content: '', attaches: [{ path: path.join(dir, 'nope.md'), name: 'gone.md', mime: 'text/plain' }], From 97eb0769428f2da6c773c7418163770afcd6c60e Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:10:06 +0000 Subject: [PATCH 043/126] Parse the streaming reply once a frame and repaint the sidebar on change. setText re-ran the markdown parser over the whole reply for every text_delta, so a long answer cost a full re-parse per token. It now records the text and draws at most once per animation frame, flushing the last frame before the caret is removed. refresh() rebuilt the thread list's innerHTML every 8 seconds even when nothing had changed; paint() now skips the write when the markup is the same. --- web/web-ui/index.html | 10 ++++++++-- web/web-ui/test_serve.mjs | 5 +++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/web/web-ui/index.html b/web/web-ui/index.html index 24525c8..087eb5a 100644 --- a/web/web-ui/index.html +++ b/web/web-ui/index.html @@ -838,10 +838,12 @@

SCHEDULES

if(!kids.length)return ''; return '
'+kids.map(threadRowHtml).join('')+'
'; } +let navDrawn=null; function paint(){ $('hostLabel').textContent=boxHost||'no box'; $('compName').textContent=(comp&&comp.name)||(pickLost?'computer gone':'no computer'); - $('navlist').innerHTML=navHtml(); + const nav=navHtml(); + if(nav!==navDrawn){navDrawn=nav;$('navlist').innerHTML=nav;} // the 8s refresh mostly changes nothing $('keyBtn').textContent=currentKey()?'KEY ON':'KEY'; $('keyBtn').classList.toggle('on',!!currentKey()); $('keyBtnM').textContent=$('keyBtn').textContent; @@ -1507,7 +1509,10 @@

SCHEDULES

let raw=''; let mark=null; // render state when the provider round began; round_reset returns to it const caret=caretEl(); - const setText=t=>{raw=t;pEl.innerHTML=md(raw);pEl.appendChild(caret);}; // machine types + let textFrame=0; + const drawText=()=>{textFrame=0;const was=nearBottom();pEl.innerHTML=md(raw);pEl.appendChild(caret);stick(was);}; + // machine types; the markdown is re-parsed once a frame, not once per streamed delta + const setText=t=>{raw=t;if(!textFrame)textFrame=requestAnimationFrame(drawText);}; const addText=t=>setText(raw+t); const pretty=v=>{if(v==null)return'';if(typeof v==='string')return v;try{return JSON.stringify(v,null,2);}catch{return String(v);}}; const toolRow=ev=>{ @@ -1623,6 +1628,7 @@

SCHEDULES

}finally{ chatCtl=null; $('stopBtn').hidden=true; + if(textFrame){cancelAnimationFrame(textFrame);drawText();} caret.remove(); if(activeTid==='__pending')activeTid=''; // stream died before the thread was born const ran=runTid!=='__pending'?runTid:''; diff --git a/web/web-ui/test_serve.mjs b/web/web-ui/test_serve.mjs index 6084d26..4aeeeac 100644 --- a/web/web-ui/test_serve.mjs +++ b/web/web-ui/test_serve.mjs @@ -496,6 +496,11 @@ assert.equal(pageFile('/deploy.html'), '/deploy.html'); assert.match(loopFn, /if \(!stopped\(\)\) emit\(\{ type: 'error'/); assert.match(html, /\/api\/chat\/steer/); assert.match(html, /steerPrompt/); + assert.match(html, /const setText=t=>\{raw=t;if\(!textFrame\)textFrame=requestAnimationFrame\(drawText\);\};/, + 'a text_delta does not re-parse the whole reply'); + assert.match(html, /if\(textFrame\)\{cancelAnimationFrame\(textFrame\);drawText\(\);\}\n caret\.remove\(\);/, + 'the last frame is drawn before the caret goes'); + assert.match(html, /if\(nav!==navDrawn\)/, 'refresh repaints the sidebar only when it changed'); // switching threads mid-turn: a slow reopen never paints over a newer pick, and // what is typed still steers the running turn, not the thread on screen assert.match(html, /if\(gen!==threadGen\)return;/); From f05c56e4b5a96aaf5590c19ddad2995f4717b0f4 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:10:43 +0000 Subject: [PATCH 044/126] Remove the CASE_LOCAL flag, which nothing read. Drive never tunnels, so CASE_LOCAL only set a `local` field in the health and computers replies, and no page, client or test read it. The flag, its helper and test, the compose and .env.example lines and the README mentions go; the documented start command works the same without it. --- .env.example | 3 --- README.md | 2 +- compose.yaml | 1 - web/web-ui/README.md | 3 +-- web/web-ui/serve.mjs | 26 +++++++++----------------- web/web-ui/test_serve.mjs | 7 +------ 6 files changed, 12 insertions(+), 30 deletions(-) diff --git a/.env.example b/.env.example index 2dcd3e7..b594b3d 100644 --- a/.env.example +++ b/.env.example @@ -14,9 +14,6 @@ CASE_MAX_RUNNING=4 # engine sees (on a Mac that is the VM, not the Mac). A create/wake past it 409s. 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= diff --git a/README.md b/README.md index 330697c..8cd4b19 100644 --- a/README.md +++ b/README.md @@ -328,7 +328,7 @@ Start cased, then Drive: ```bash bin/case up -CASE_LOCAL=1 CASE_URL=http://127.0.0.1:8787 node web/web-ui/serve.mjs +CASE_URL=http://127.0.0.1:8787 node web/web-ui/serve.mjs ``` Open the [computers page](http://127.0.0.1:4174/deploy). Drive runs in the diff --git a/compose.yaml b/compose.yaml index 32951e4..c607dce 100644 --- a/compose.yaml +++ b/compose.yaml @@ -116,7 +116,6 @@ services: CASE_TOKEN: ${CASE_TOKEN:-} CASE_THREADS: /data/threads.json CASE_HOME: /data - CASE_LOCAL: "1" PORT: "4174" CASE_NTFY_URL: ${CASE_NTFY_URL:-https://ntfy.sh} CASE_NTFY_TOPIC: ${CASE_NTFY_TOPIC:-} diff --git a/web/web-ui/README.md b/web/web-ui/README.md index 4cef162..c036c7d 100644 --- a/web/web-ui/README.md +++ b/web/web-ui/README.md @@ -13,8 +13,7 @@ choice (the DRIVE button, stored in `localStorage['case.drive.computer']`). Drive never switches computers on its own: landing on the wrong desk means landing in someone else's sessions, so a missing pick says so and stops. -**Transport:** `CASE_LOCAL=1` (default when `CASE_URL` is loopback or compose -`cased`). Talks to cased on `CASE_URL` — no SSH tunnel. Compose sets +**Transport:** talks to cased on `CASE_URL` — no SSH tunnel. Compose sets `http://cased:8787/v1`. `/live//…` proxies to cased `/v1/computers//live/…`, which relays noVNC: Drive is not on the desks network and never dials a desktop itself. diff --git a/web/web-ui/serve.mjs b/web/web-ui/serve.mjs index a430ea3..2a1592b 100644 --- a/web/web-ui/serve.mjs +++ b/web/web-ui/serve.mjs @@ -4,15 +4,14 @@ * Drive UI + deployer — talks to local cased (compose or CASE_URL). * 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 - * relays noVNC through cased. CASE_LOCAL=0 is a no-op here (this process never - * tunnels); it only flips the health `local` flag. + * No SSH tunnel: cased is on CASE_URL (loopback or compose `cased`), and /live + * relays noVNC through it. * * OpenAI key arrives per-request in x-openai-key; Anthropic in x-anthropic-key; * never logged. * Optional CASE_TOKEN: Bearer, case_token cookie, or ?token= on first hit. * - * Run: CASE_LOCAL=1 node web/web-ui/serve.mjs → http://127.0.0.1:4174/ + * Run: node web/web-ui/serve.mjs → http://127.0.0.1:4174/ */ import http from 'node:http'; import fs from 'node:fs'; @@ -53,13 +52,6 @@ export function parseCaseUrl(raw) { const CASE = parseCaseUrl(process.env.CASE_URL || 'http://127.0.0.1:8787'); process.env.CASE_URL = `${CASE.protocol}//${CASE.hostname}:${CASE.port}/v1`; -export function isLocalMode(env = process.env, host = CASE.hostname) { - const flag = String(env.CASE_LOCAL || '').trim().toLowerCase(); - if (flag === '0' || flag === 'false') return false; - if (flag === '1' || flag === 'true') return true; - return host === '127.0.0.1' || host === 'localhost' || host === 'cased'; -} -const LOCAL = isLocalMode(); const HOME = process.env.CASE_HOME || path.join(process.env.HOME || '/tmp', '.case'); export function liveCid(pathname) { @@ -225,7 +217,7 @@ async function computers(res) { api('GET', '/computers', { timeoutMs: 6000 }), 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 }); + if (r.status >= 400 || !r.json) return json(res, 502, { error: r.json?.error?.message || 'cased unreachable', up: false }); 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, @@ -237,12 +229,12 @@ async function computers(res) { const running = rows.filter((c) => awake.includes(c.state)).length; const maxRunning = Number(h.json?.max_running) || 0; return json(res, 200, { - computers: rows, live: CASE.hostname, up: true, local: LOCAL, + computers: rows, live: CASE.hostname, up: true, max_running: maxRunning, running: Number(h.json?.running) || running, max_ram_mb: Number(h.json?.max_ram_mb) || 0, ram_mb: Number(h.json?.ram_mb) || 0, }); } catch { - return json(res, 502, { error: 'cased unreachable', up: false, local: LOCAL }); + return json(res, 502, { error: 'cased unreachable', up: false }); } } @@ -1818,7 +1810,7 @@ export const server = http.createServer(async (req, res) => { try { const h = await originHealth(); return json(res, 200, { - ok: true, live: CASE.hostname, up: h.status === 200, local: LOCAL, + ok: true, live: CASE.hostname, up: h.status === 200, max_running: Number(h.json?.max_running) || 0, running: Number(h.json?.running) || 0, computers: Number(h.json?.computers) || 0, @@ -1826,7 +1818,7 @@ export const server = http.createServer(async (req, res) => { brain_key: !!envDriveAuth().key, }); } catch { - return json(res, 200, { ok: true, live: CASE.hostname, up: false, local: LOCAL, max_running: 0, running: 0, brain_key: !!envDriveAuth().key }); + return json(res, 200, { ok: true, live: CASE.hostname, up: false, max_running: 0, running: 0, brain_key: !!envDriveAuth().key }); } } try { @@ -1874,7 +1866,7 @@ if (isMain) { // One bad request must never take Drive (and every running turn) down with it. process.on('unhandledRejection', (err) => console.error('drive unhandled:', err)); server.listen(PORT, BIND, () => { - process.stdout.write(`drive http://${BIND}:${PORT}/ deploy http://${BIND}:${PORT}/deploy (cased ${CASE.hostname}:${CASE.port}${LOCAL ? ', local' : ''})\n`); + process.stdout.write(`drive http://${BIND}:${PORT}/ deploy http://${BIND}:${PORT}/deploy (cased ${CASE.hostname}:${CASE.port})\n`); startPhoneNtfy(); startPhoneTelegram(); }); diff --git a/web/web-ui/test_serve.mjs b/web/web-ui/test_serve.mjs index 4aeeeac..cb69556 100644 --- a/web/web-ui/test_serve.mjs +++ b/web/web-ui/test_serve.mjs @@ -17,7 +17,7 @@ import { // serve.mjs loads threads.json at import and rewrites it; never the developer's own. process.env.CASE_THREADS = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'case-threads-')), 'threads.json'); process.env.CASE_TURN_TOKENS = '10000'; // small enough for the scripted turns below to cross 80% -const { runTurn, shq, pathOk, parseErr, parseFind, mimeFor, histTrim, histApplyCompaction, histCloseOpenCalls, normHost, threadTurns, parseCaseUrl, liveCid, liveDestPath, livePathHasDotDot, tokenMatches, liveHeaders, hostOf, browserOk, extraPlan, isLocalMode, pageFile, snapshotElide, stashShot, pushShot, hydrateShots, migrateShots, stashAttach, resolveAttach, hydrateAttaches, attachKind, ATTACH_MAX, sseEvents } = await import('./serve.mjs'); +const { runTurn, shq, pathOk, parseErr, parseFind, mimeFor, histTrim, histApplyCompaction, histCloseOpenCalls, normHost, threadTurns, parseCaseUrl, liveCid, liveDestPath, livePathHasDotDot, tokenMatches, liveHeaders, hostOf, browserOk, extraPlan, pageFile, snapshotElide, stashShot, pushShot, hydrateShots, migrateShots, stashAttach, resolveAttach, hydrateAttaches, attachKind, ATTACH_MAX, sseEvents } = await import('./serve.mjs'); const html = fs.readFileSync(fileURLToPath(new URL('./index.html', import.meta.url)), 'utf8'); assert.match(html, /x-anthropic-key/); @@ -228,11 +228,6 @@ const kept = histCloseOpenCalls([{ type: 'reasoning', summary: [] }], { keepReas assert.equal(kept[0].type, 'reasoning'); assert.deepEqual(parseCaseUrl('http://cased:8787'), { hostname: 'cased', port: 8787, protocol: 'http:' }); -assert.equal(isLocalMode({ CASE_LOCAL: '1' }, 'example.com'), true); -assert.equal(isLocalMode({ CASE_LOCAL: '0' }, '127.0.0.1'), false); -assert.equal(isLocalMode({}, '127.0.0.1'), true); -assert.equal(isLocalMode({}, 'cased'), true); -assert.equal(isLocalMode({}, 'remote.example'), false); assert.equal(liveCid('/live/c_abc12/vnc.html'), 'c_abc12'); assert.equal(liveCid('/live/vnc.html'), ''); assert.equal(liveDestPath('/live/c_abc12/vnc.html?autoconnect=1'), '/vnc.html?autoconnect=1'); From bb44e29042174bdc14ac657da05ab33190f9af97 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:11:20 +0000 Subject: [PATCH 045/126] Let the model pass button to computer_action. caseToolPlan copies a button field into the action, and deskd and the MCP tool take left, middle or right, but the Drive schema did not list it and forbids extra properties, so a right-click was impossible from Drive. The schema has it now. --- web/web-ui/case-tools.mjs | 2 +- web/web-ui/test_serve.mjs | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/web/web-ui/case-tools.mjs b/web/web-ui/case-tools.mjs index 165ceb2..8d27af1 100644 --- a/web/web-ui/case-tools.mjs +++ b/web/web-ui/case-tools.mjs @@ -14,7 +14,7 @@ import Anthropic from '@anthropic-ai/sdk'; export const CASE_TOOLS = [ { type: 'function', name: 'computer_navigate', description: 'Point the computer browser at url and block until the page has loaded. Returns {ok, url, title, text, snapshot} — text is the first 2000 chars of the page; snapshot holds the numbered elements, so do NOT call computer_snapshot after this. Same-page #anchor jumps are not navigations.', parameters: { type: 'object', properties: { url: { type: 'string' }, timeout_s: { type: 'number' } }, required: ['url'], additionalProperties: false } }, { type: 'function', name: 'computer_eval', description: 'Evaluate JS in the active tab (CDP, promises awaited). Prefer this over screenshots for page content. Return plain values — DOM nodes are not serialisable. To read a page as prose, document.body.innerText. Do not drive location.assign from here; use computer_navigate.', parameters: { type: 'object', properties: { expression: { type: 'string' }, timeout_s: { type: 'number' } }, required: ['expression'], additionalProperties: false } }, - { type: 'function', name: 'computer_action', description: 'UI action on the desktop (1280x800 by default): click|double_click|move|drag|scroll|type|key|wait. Coordinates are pixels, origin top-left. keys uses xdotool syntax (ctrl+l, Return). For elements INSIDE a web page prefer computer_snapshot + computer_click_element; use this for the desktop itself, canvas, shortcuts, and scrolling.', parameters: { type: 'object', properties: { type: { type: 'string', enum: ['click', 'double_click', 'move', 'drag', 'scroll', 'type', 'key', 'wait'] }, x: { type: 'number' }, y: { type: 'number' }, text: { type: 'string' }, keys: { type: 'string' }, dy: { type: 'number' }, ms: { type: 'number' }, from_x: { type: 'number' }, from_y: { type: 'number' }, to_x: { type: 'number' }, to_y: { type: 'number' } }, required: ['type'], additionalProperties: false } }, + { type: 'function', name: 'computer_action', description: 'UI action on the desktop (1280x800 by default): click|double_click|move|drag|scroll|type|key|wait. Coordinates are pixels, origin top-left. keys uses xdotool syntax (ctrl+l, Return). For elements INSIDE a web page prefer computer_snapshot + computer_click_element; use this for the desktop itself, canvas, shortcuts, and scrolling.', parameters: { type: 'object', properties: { type: { type: 'string', enum: ['click', 'double_click', 'move', 'drag', 'scroll', 'type', 'key', 'wait'] }, x: { type: 'number' }, y: { type: 'number' }, button: { type: 'string', enum: ['left', 'middle', 'right'] }, text: { type: 'string' }, keys: { type: 'string' }, dy: { type: 'number' }, ms: { type: 'number' }, from_x: { type: 'number' }, from_y: { type: 'number' }, to_x: { type: 'number' }, to_y: { type: 'number' } }, required: ['type'], additionalProperties: false } }, { type: 'function', name: 'computer_exec', description: 'Run a shell command on the computer (bash, as user agent).', parameters: { type: 'object', properties: { command: { type: 'string' }, timeout_s: { type: 'number' } }, required: ['command'], additionalProperties: false } }, ]; diff --git a/web/web-ui/test_serve.mjs b/web/web-ui/test_serve.mjs index cb69556..2a66ac5 100644 --- a/web/web-ui/test_serve.mjs +++ b/web/web-ui/test_serve.mjs @@ -325,6 +325,10 @@ assert.equal(resolveChatModel('gpt-5.6-terra', 'anthropic'), 'claude-sonnet-4-6' { const tools = openaiToolsToAnthropic(CASE_TOOLS); assert.equal(tools[0].name, 'computer_navigate'); + // caseToolPlan forwards button, so the schema has to let the model send it + const action = CASE_TOOLS.find((t) => t.name === 'computer_action').parameters.properties; + assert.deepEqual(action.button.enum, ['left', 'middle', 'right']); + assert.equal(caseToolPlan('computer_action', { type: 'click', x: 1, y: 2, button: 'right' }, 'c_1').json.button, 'right'); assert.equal(tools[0].input_schema.required[0], 'url'); assert.equal(tools[0].type, undefined); } From ea636239508ac4ad275706d7edecb9859f189a46 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:11:28 +0000 Subject: [PATCH 046/126] Drop exports and ids nothing uses, and a comment naming a gone file. newThread, shotsDir, inboxDir, safeAttachName, ATTACH_MAX_N, phoneThread, brainRoute, caseRoot and userContentText were exported but only used in their own module; credsPane and schedSave were ids no script or style referred to. The chat tools comment pointed at a web/serve.mjs that no longer exists. --- web/web-ui/case-tools.mjs | 4 ++-- web/web-ui/index.html | 4 ++-- web/web-ui/serve.mjs | 16 ++++++++-------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/web/web-ui/case-tools.mjs b/web/web-ui/case-tools.mjs index 8d27af1..8607728 100644 --- a/web/web-ui/case-tools.mjs +++ b/web/web-ui/case-tools.mjs @@ -18,7 +18,7 @@ export const CASE_TOOLS = [ { type: 'function', name: 'computer_exec', description: 'Run a shell command on the computer (bash, as user agent).', parameters: { type: 'object', properties: { command: { type: 'string' }, timeout_s: { type: 'number' } }, required: ['command'], additionalProperties: false } }, ]; -export function caseRoot() { +function caseRoot() { const raw = String(process.env.CASE_URL || 'http://127.0.0.1:8787/v1').trim().replace(/\/$/, ''); return /\/v1$/.test(raw) ? raw : `${raw}/v1`; } @@ -282,7 +282,7 @@ export function tracesFromAnthropicMessage(message) { return { thinks, calls, texts }; } -export function userContentText(content) { +function userContentText(content) { if (typeof content === 'string') return content; if (!Array.isArray(content)) return content == null ? '' : String(content); return content.map((c) => { diff --git a/web/web-ui/index.html b/web/web-ui/index.html index 087eb5a..c98d278 100644 --- a/web/web-ui/index.html +++ b/web/web-ui/index.html @@ -624,7 +624,7 @@
-
+
LOADING

Add credential

@@ -690,7 +690,7 @@

SCHEDULES

diff --git a/web/web-ui/serve.mjs b/web/web-ui/serve.mjs index 2a1592b..90440e7 100644 --- a/web/web-ui/serve.mjs +++ b/web/web-ui/serve.mjs @@ -385,7 +385,7 @@ async function power(res, req, action) { } } -// Chat: same NDJSON contract as web/serve.mjs, hands always local REST. +// Chat tools beyond CASE_TOOLS; hands are always cased REST. // Tool names + semantics match mcp/case_mcp.py. const EXTRA_TOOLS = [ { type: 'function', name: 'computer_list', description: 'List all computers with state, resources and credential names. Reuse an existing computer — only computer_create for an identity that should stay separate.', parameters: { type: 'object', properties: {}, additionalProperties: false } }, @@ -630,7 +630,7 @@ function saveThreads() { }, 400); } if (THREADS.size) saveThreads(); -export function newThread(title, agent) { +function newThread(title, agent) { const t = { id: 't_' + Math.random().toString(36).slice(2, 10), title: String(title).replace(/\s+/g, ' ').trim().slice(0, 72), @@ -778,7 +778,7 @@ export function histCloseOpenCalls(items, { keepReasoning = false } = {}) { } return out; } -export function shotsDir(home = HOME) { +function shotsDir(home = HOME) { return path.join(home, 'drive', 'shots'); } @@ -837,9 +837,9 @@ function hydrateShot(shot, root) { } export const ATTACH_MAX = 5 * 1024 * 1024; -export const ATTACH_MAX_N = 4; +const ATTACH_MAX_N = 4; -export function inboxDir(home = HOME) { +function inboxDir(home = HOME) { return path.join(home, 'drive', 'inbox'); } @@ -852,7 +852,7 @@ export function attachKind(mime, name = '') { return ''; } -export function safeAttachName(name) { +function safeAttachName(name) { const base = path.basename(String(name || 'file')).replace(/[^\w.\-]+/g, '_').slice(0, 80); return base || 'file'; } @@ -1016,7 +1016,7 @@ async function steer(req, res) { STEER.set(tid, q); return json(res, 200, { queued: true }); } -export function phoneThread() { +function phoneThread() { let t = THREADS.get(PHONE_THREAD_ID); if (t) return t; t = { @@ -1368,7 +1368,7 @@ export async function schedulesRoute(req, res, url) { } } -export async function brainRoute(req, res) { +async function brainRoute(req, res) { const buf = await readBody(req, res); if (!buf) return; let body; From 6088278c7b28a714c902c03f9fb265857239a0b5 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:11:54 +0000 Subject: [PATCH 047/126] Show failed tools as failed when a thread is reopened. threadTurns kept only a tool's name and arguments, so the reopened view labelled every tool call "ok", including ones that failed or were interrupted. It now reads the ok flag at the head of each persisted output and the page renders fail the way the live stream does. --- web/web-ui/index.html | 2 +- web/web-ui/serve.mjs | 5 ++++- web/web-ui/test_serve.mjs | 13 +++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/web/web-ui/index.html b/web/web-ui/index.html index c98d278..ea7dbd5 100644 --- a/web/web-ui/index.html +++ b/web/web-ui/index.html @@ -917,7 +917,7 @@

SCHEDULES

art.querySelector('p').textContent=turn.text; }else if(turn.who==='tool'){ art.className='turn'; - art.innerHTML='
ok
'; + art.innerHTML='
'+(turn.ok===false?'fail':'ok')+'
'; art.querySelector('.tool-name').textContent=turn.name||'tool'; art.querySelector('.tool-args').textContent=turn.args||''; }else{ diff --git a/web/web-ui/serve.mjs b/web/web-ui/serve.mjs index 90440e7..48e5272 100644 --- a/web/web-ui/serve.mjs +++ b/web/web-ui/serve.mjs @@ -644,6 +644,9 @@ const threadSummary = (t) => ({ id: t.id, title: t.title, agent: t.agent, update // reasoning stay server-side. export function threadTurns(items) { const turns = []; + // A persisted output is the clipped tool result, whose head is always {"ok":…}. + const failed = new Set((items || []).filter((it) => it?.type === 'function_call_output' + && /^\{"ok":false[,}]/.test(String(it.output || ''))).map((it) => it.call_id)); for (const it of items || []) { if (it.shot) continue; // screenshot attachments are model-only if (it.role === 'user') { @@ -658,7 +661,7 @@ export function threadTurns(items) { 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 === 'function_call') turns.push({ who: 'tool', name: it.name, args: String(it.arguments || '').slice(0, 400), ok: !failed.has(it.call_id) }); else if (it.type === 'message' && it.role === 'assistant') turns.push({ who: 'agent', text: (it.content || []).map((c) => c.text || '').join('') }); } return turns; diff --git a/web/web-ui/test_serve.mjs b/web/web-ui/test_serve.mjs index 2a66ac5..8c1808a 100644 --- a/web/web-ui/test_serve.mjs +++ b/web/web-ui/test_serve.mjs @@ -48,6 +48,19 @@ 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.equal(view[1].ok, true); +// a reopened thread shows which tools failed, not "ok" for all of them +{ + const t = threadTurns([ + { type: 'function_call', call_id: 'a', name: 'computer_click_element', arguments: '{}' }, + { type: 'function_call_output', call_id: 'a', output: '{"ok":false,"status":409,"error":"stale ref","act":"click [3]"}' }, + { type: 'function_call', call_id: 'b', name: 'computer_login', arguments: '{}' }, + { type: 'function_call_output', call_id: 'b', output: '{"ok":false,"error":"interrupted"}' }, + { type: 'function_call', call_id: 'c', name: 'computer_snapshot', arguments: '{}' }, + { type: 'function_call_output', call_id: 'c', output: clip({ ok: true, act: 'snapshot', result: { elements: ['x'.repeat(9000)] } }) }, + ]); + assert.deepEqual(t.map((x) => x.ok), [false, false, true]); +} 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' }] }]), From 4ee8a5c543903c16be2651e6e5db834ec3da38a6 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:12:21 +0000 Subject: [PATCH 048/126] Say where Drive's threads.json really lives by default. .env.example and the README put threads.json in the Drive home, ~/.case, but serve.mjs defaults it to web/web-ui/threads.json beside itself and only CASE_THREADS moves it (Compose sets /data/threads.json). The docs now say so. The file stays where it is, so existing threads are not stranded. --- .env.example | 7 +++++-- README.md | 12 +++++++----- web/web-ui/README.md | 3 +++ 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index b594b3d..bdd4e26 100644 --- a/.env.example +++ b/.env.example @@ -14,10 +14,13 @@ CASE_MAX_RUNNING=4 # engine sees (on a Mac that is the VM, not the Mac). A create/wake past it 409s. CASE_MAX_RAM_MB= -# Drive home: threads.json, drive/shots (and later drive/inbox). Compose sets -# CASE_HOME=/data so they share the ui-data volume. Unset = ~/.case. +# Drive home: drive/shots and drive/inbox. Compose sets CASE_HOME=/data, the +# ui-data volume. Unset = ~/.case. # CASE_HOME= +# Drive threads. Compose sets /data/threads.json. Unset = web/web-ui/threads.json. +# CASE_THREADS= + # Per-turn input-token ceiling for Drive chat. Default 2000000. # CASE_TURN_TOKENS=2000000 diff --git a/README.md b/README.md index 8cd4b19..9e6a878 100644 --- a/README.md +++ b/README.md @@ -377,11 +377,13 @@ installation, or the `case-home` volume mounted at `/data` in Compose. Back up the database and key together, along with the desktop volumes you want to keep. Treat these backups as sensitive data. -Drive keeps `threads.json`, screenshots under `drive/shots`, and attachments -under `drive/inbox` in its home directory. This is `~/.case` by default; Compose -uses the `ui-data` volume mounted at `/data`. Deleting a thread does not remove -its screenshots or attachments. Files added through the plus menu stay on the -Drive host for the model to read; they are not copied onto the desktop computer. +Drive keeps screenshots under `drive/shots` and attachments under `drive/inbox` +in its home directory, `~/.case` by default. Its threads are in +`web/web-ui/threads.json` by default, or the file `CASE_THREADS` names. Compose +keeps all of them on the `ui-data` volume mounted at `/data`. Deleting a thread +does not remove its screenshots or attachments. Files added through the plus +menu stay on the Drive host for the model to read; they are not copied onto the +desktop computer. diff --git a/web/web-ui/README.md b/web/web-ui/README.md index c036c7d..7ba1e7a 100644 --- a/web/web-ui/README.md +++ b/web/web-ui/README.md @@ -18,6 +18,9 @@ landing in someone else's sessions, so a missing pick says so and stops. `/v1/computers//live/…`, which relays noVNC: Drive is not on the desks network and never dials a desktop itself. +Threads persist in `threads.json` beside `serve.mjs` unless `CASE_THREADS` +names another file (Compose: `/data/threads.json`). + **Files view** uses `computer_exec` `find` (`/api/fs`) and cased `GET /files` (`/api/file`). From 5aae0b057722ac3669ec60ab604404991ea93ed0 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:59:00 +0000 Subject: [PATCH 049/126] Keep a lost wake race from deleting the computer's volume. When the final running transition lost, do_wake called destroy_infra, which removes the home volume, even when the row had only gone asleep: a concurrent sleep, or the 20s reconcile forcing waking to asleep because the container was not up yet. Reconcile did the same to a create in progress and failed it with create_lost_race. Wake, sleep and destroy now run one at a time per computer, reconcile skips rows with an operation in flight and corrects the rest with a compare-and-set on the state it read, and a lost wake only tears down infra when the row is really gone. Stale rows after a restart are still repaired, since nothing is in flight then. --- control-plane/lifecycle.py | 87 ++++++++++++++++---- tests/test_lifecycle.py | 162 +++++++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+), 15 deletions(-) diff --git a/control-plane/lifecycle.py b/control-plane/lifecycle.py index 2b214f7..ba00de7 100644 --- a/control-plane/lifecycle.py +++ b/control-plane/lifecycle.py @@ -8,8 +8,11 @@ to match reality after a daemon restart, it corrects the machine rather than transitioning it, so it deliberately bypasses the transition guard. """ +import functools import secrets +import threading import time +from contextlib import contextmanager from config import DESK_H, DESK_W, IMAGE, MAX_RAM_MB, MAX_RUNNING, log from errors import ApiError @@ -29,6 +32,40 @@ "deleted": set(), } +# Wake, sleep and destroy of one computer run one at a time, and reconcile leaves a +# row alone while anything (create included) is in flight on it: mid-wake the +# container is not up yet, and "force the DB to Docker truth" would undo the wake. +_GUARD = threading.Lock() # guards the two maps below +_IN_FLIGHT = {} # cid -> operations in progress +_LOCKS = {} # cid -> RLock, dropped with its last operation + + +@contextmanager +def _in_flight(cid, serialize=True): + with _GUARD: + _IN_FLIGHT[cid] = _IN_FLIGHT.get(cid, 0) + 1 + lock = _LOCKS.setdefault(cid, threading.RLock()) + try: + if serialize: + with lock: + yield + else: + yield + finally: + with _GUARD: + _IN_FLIGHT[cid] -= 1 + if not _IN_FLIGHT[cid]: + del _IN_FLIGHT[cid] + del _LOCKS[cid] + + +def _serialized(fn): + @functools.wraps(fn) + def run(cid, *a, **kw): + with _in_flight(cid): + return fn(cid, *a, **kw) + return run + def get_computer(cid): row = store.get_computer(cid) @@ -53,19 +90,19 @@ def set_state(cid, to): if not can_transition(current, to): raise ApiError(409, "illegal_transition", f"{current} → {to} not allowed") if store.set_state(cid, to, expect=current) == 0: - # someone transitioned it between our read and write, don't emit a stale edge. - # The CAS makes the write safe; orchestration races (wake vs sleep) remain — - # add per-cid locks only if they actually bite. + # someone transitioned it between our read and write, don't emit a stale edge return False emit("state_changed", {"computer_id": cid, "from": current, "to": to}) return True -def _force_state(cid, current, to): - """Reconcile-only: align the DB to Docker truth, bypassing the transition guard.""" +def _force_state(cid, current, to, expect=None): + """Align the DB to Docker truth, bypassing the transition guard. `expect` makes + it a compare-and-set, so a stale read can't clobber a newer transition.""" if current == to: return - store.set_state(cid, to) + if store.set_state(cid, to, expect=expect) == 0: + return emit("state_changed", {"computer_id": cid, "from": current, "to": to}) @@ -129,6 +166,11 @@ def provision(name=None, cpus=1, ram_mb=2048): name = str(name or cid) token = secrets.token_hex(16) volume = f"case-{cid}" + with _in_flight(cid, serialize=False): + return _provision(cid, name, cpus, ram_mb, volume, token) + + +def _provision(cid, name, cpus, ram_mb, volume, token): store.insert_computer(cid, name, IMAGE, cpus, ram_mb, volume, token) try: dockerd.create_volume(volume) @@ -162,6 +204,7 @@ def _try_set(cid, to): return False +@_serialized def destroy(cid): row = get_computer(cid) dockerd.destroy_infra(cid, row["volume"]) @@ -172,6 +215,7 @@ def destroy(cid): _force_state(cid, row["state"], "deleted") +@_serialized def do_sleep(cid): row = get_computer(cid) if row["state"] == "asleep": @@ -208,6 +252,7 @@ def sleep_all(): return slept +@_serialized def do_wake(cid): row = get_computer(cid) if row["state"] == "running" and dockerd.container_up(cid): @@ -238,9 +283,14 @@ def do_wake(cid): _try_set(cid, "asleep") # tolerate a concurrent delete here too raise if not _try_set(cid, "running"): - # a DELETE landed while we were waking → we rebuilt a container the delete didn't - # know about. Tear it down instead of leaking a zombie on a 'deleted' row. - dockerd.destroy_infra(cid, row["volume"]) + now_row = store.get_computer(cid) + if not now_row or now_row["state"] == "deleted": + # a DELETE landed while we were waking → we rebuilt a container the delete + # didn't know about. Tear it down instead of leaking a zombie on a 'deleted' row. + dockerd.destroy_infra(cid, row["volume"]) + else: + # the row moved on (asleep) without us; the volume is still the user's + dockerd.stop_container(cid) raise ApiError(409, "wake_lost_race", "computer was deleted/changed during wake") @@ -257,14 +307,21 @@ def ensure_running(cid, wake): def reconcile(): - """After cased/daemon restart: force the DB to match what Docker actually has.""" - for row in store.all_non_deleted(): + """After cased/daemon restart: force the DB to match what Docker actually has. + + Each correction is a compare-and-set on the state read before Docker was asked, + so a wake/sleep that starts in between wins. Rows with an operation in flight + are skipped; after a restart there are none, so a stale waking/creating row is + still repaired.""" + with _GUARD: + rows = [r for r in store.all_non_deleted() if r["id"] not in _IN_FLIGHT] + for row in rows: cid, current = row["id"], row["state"] try: container = dockerd.get_container(cid) except dockerd.NotFound: log.warning("container for %s missing; will recreate from volume on wake", cid) - _force_state(cid, current, "asleep") + _force_state(cid, current, "asleep", expect=current) continue except Exception as e: log.warning("reconcile skipped (docker unavailable): %s", e) @@ -274,8 +331,8 @@ def reconcile(): desk_port, vnc_port = dockerd.container_ports(container) if (desk_port, vnc_port) != (row["desk_port"], row["vnc_port"]): store.set_ports(cid, desk_port, vnc_port) - _force_state(cid, current, "running") + _force_state(cid, current, "running", expect=current) except ApiError: - _force_state(cid, current, "asleep") + _force_state(cid, current, "asleep", expect=current) else: - _force_state(cid, current, "asleep") + _force_state(cid, current, "asleep", expect=current) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index c97aa60..9892b93 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -183,6 +183,168 @@ def test_wake_rebuilds_a_container_docker_no_longer_has(): store.delete_computer(cid) +class _Box: + """Just enough of a container for do_wake/reconcile.""" + def __init__(self, st): + self.st = st + + @property + def status(self): + return self.st["container"] + + +def _wake_harness(cid, during_wait): + """do_wake with Docker faked; `during_wait` runs while deskd is coming up.""" + from unittest import mock + import deskclient + import dockerd + import lifecycle + st = {"container": "exited", "destroyed": [], "stopped": []} + + def stop(c, timeout=10): + st["container"] = "exited" + st["stopped"].append(c) + + with mock.patch.object(dockerd, "start_container", + lambda c: st.update(container="running")), \ + mock.patch.object(dockerd, "get_container", lambda c: _Box(st)), \ + mock.patch.object(dockerd, "container_ports", lambda c, deadline=10: (1, 2)), \ + mock.patch.object(dockerd, "container_up", lambda c: st["container"] == "running"), \ + mock.patch.object(dockerd, "stop_container", stop), \ + mock.patch.object(dockerd, "destroy_infra", + lambda c, v: st["destroyed"].append(v)), \ + mock.patch.object(deskclient, "wait_desk", lambda *a: during_wait(cid, st)): + try: + lifecycle.do_wake(cid) + st["error"] = None + except ApiError as e: + st["error"] = e.code + return st + + +def _asleep_row(cid): + store.delete_computer(cid) + store.insert_computer(cid, "t", "img", 1, 512, "case-" + cid, "tok") + store.set_state(cid, "asleep") + + +def test_sleep_landing_mid_wake_keeps_the_volume(): + # The row went asleep under a wake: that is not a delete, so the user's home + # volume must survive; the container is just stopped to match the row. + import lifecycle + cid = "c_unittest_wake_sleep" + _asleep_row(cid) + try: + st = _wake_harness(cid, lambda c, st: lifecycle.do_sleep(c)) + assert st["error"] == "wake_lost_race", st + assert st["destroyed"] == [], st + assert st["container"] == "exited", st + assert store.get_computer(cid)["state"] == "asleep" + finally: + store.delete_computer(cid) + + +def test_sleep_from_another_thread_waits_for_the_wake(): + import threading + from unittest import mock + import time as _time + import lifecycle + cid = "c_unittest_wake_serial" + _asleep_row(cid) + other = {} + + def during(c, st): + other["t"] = threading.Thread(target=lifecycle.do_sleep, args=(c,)) + other["t"].start() + _time.sleep(0.2) + assert store.get_computer(c)["state"] == "waking" # the sleep is queued behind us + + try: + with mock.patch.object(lifecycle.dockerd, "stop_container"): # outlives the harness + st = _wake_harness(cid, during) + other["t"].join(5) + assert st["error"] is None, st + assert st["destroyed"] == [], st + assert store.get_computer(cid)["state"] == "asleep" + finally: + store.delete_computer(cid) + + +def test_delete_landing_mid_wake_still_tears_down(): + import lifecycle + cid = "c_unittest_wake_delete" + _asleep_row(cid) + try: + st = _wake_harness(cid, lambda c, st: lifecycle._force_state(c, "waking", "deleted")) + assert st["error"] == "wake_lost_race", st + assert st["destroyed"] == ["case-" + cid], st + finally: + store.delete_computer(cid) + + +def test_reconcile_leaves_a_wake_in_flight_alone(): + # The sweeper ticks every 20s; mid-wake the container is not up yet, and forcing + # waking → asleep used to make the wake destroy the computer's volume. + import lifecycle + + def sweep(cid, st): + st["container"] = "created" + lifecycle.reconcile() + assert store.get_computer(cid)["state"] == "waking" + st["container"] = "running" + + cid = "c_unittest_wake_recon" + _asleep_row(cid) + try: + st = _wake_harness(cid, sweep) + assert st["error"] is None, st + assert st["destroyed"] == [], st + assert store.get_computer(cid)["state"] == "running" + finally: + store.delete_computer(cid) + + +def test_reconcile_still_repairs_a_stale_waking_row(): + # cased restarted mid-wake: nothing is in flight, so the row is fixed as before. + from unittest import mock + import dockerd + import lifecycle + cid = "c_unittest_stale_waking" + _asleep_row(cid) + store.set_state(cid, "waking") + try: + with mock.patch.object(dockerd, "get_container", side_effect=dockerd.NotFound("x")): + lifecycle.reconcile() + assert store.get_computer(cid)["state"] == "asleep" + finally: + store.delete_computer(cid) + + +def test_reconcile_during_provision_does_not_lose_the_create(): + from unittest import mock + import deskclient + import dockerd + import lifecycle + + def volume(v): + # the sweeper ticks before the container exists + with mock.patch.object(dockerd, "get_container", side_effect=dockerd.NotFound("x")): + lifecycle.reconcile() + + with mock.patch.object(dockerd, "create_volume", volume), \ + mock.patch.object(dockerd, "create_container", lambda *a: None), \ + mock.patch.object(dockerd, "container_ports", lambda c, deadline=10: (1, 2)), \ + mock.patch.object(dockerd, "destroy_infra") as destroy, \ + mock.patch.object(deskclient, "wait_desk"), \ + mock.patch.object(lifecycle, "admit"): + out = lifecycle.provision("race") + try: + assert out["state"] == "running", out + destroy.assert_not_called() + finally: + store.delete_computer(out["id"]) + + def test_vault_directory_and_database_are_private(): # ~/.case holds the Fernet key and every encrypted secret. An install that # predates this (or a loose umask) leaves them world-readable. From b03d7ecb26e2906560e9cd9c6c13d5ed07ebe6c6 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:59:16 +0000 Subject: [PATCH 050/126] Stop the container when a wake fails after starting it. A wake whose deskd never got healthy put the row back to asleep but left the container running, so the next reconcile flipped the row to running and it held a MAX_RUNNING slot nobody would release. The failed wake now stops the container before marking the row asleep. --- control-plane/lifecycle.py | 6 ++++++ tests/test_lifecycle.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/control-plane/lifecycle.py b/control-plane/lifecycle.py index ba00de7..6672571 100644 --- a/control-plane/lifecycle.py +++ b/control-plane/lifecycle.py @@ -280,6 +280,12 @@ def do_wake(cid): log.info("wake %s: container %.2fs, deskd healthy %.2fs", cid, t_container, time.monotonic() - t0) except Exception: + # a desk that never got healthy is still a running container; don't leave it + # up under a row that says asleep + try: + dockerd.stop_container(cid) + except Exception as e: + log.warning("wake %s: could not stop the container after a failed wake (%s)", cid, e) _try_set(cid, "asleep") # tolerate a concurrent delete here too raise if not _try_set(cid, "running"): diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 9892b93..df39c10 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -270,6 +270,25 @@ def during(c, st): store.delete_computer(cid) +def test_failed_wake_stops_the_container_it_started(): + # deskd never got healthy: the row goes back to asleep, and so must the + # container, or the next reconcile flips the row to running behind everyone. + cid = "c_unittest_wake_fail" + _asleep_row(cid) + + def unhealthy(c, st): + raise ApiError(504, "daemon_timeout", "deskd not healthy after 30s") + + try: + st = _wake_harness(cid, unhealthy) + assert st["error"] == "daemon_timeout", st + assert st["stopped"] == [cid] and st["container"] == "exited", st + assert st["destroyed"] == [], st + assert store.get_computer(cid)["state"] == "asleep" + finally: + store.delete_computer(cid) + + def test_delete_landing_mid_wake_still_tears_down(): import lifecycle cid = "c_unittest_wake_delete" From c994b160ab7b28b5b9613db2b5648a4a7a15a56a Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:59:59 +0000 Subject: [PATCH 051/126] Make admission and the claim that counts against it one step. admit() read the running count and a separate write inserted the row or claimed it as waking, so with CASE_MAX_RUNNING=1 two creates in the same moment both got through. The check and the claim now happen under one lock. A wake also no longer takes over a row that is already waking: set_state treated that as done and the second wake built on it. --- control-plane/lifecycle.py | 23 +++++++++------ tests/test_lifecycle.py | 57 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/control-plane/lifecycle.py b/control-plane/lifecycle.py index 6672571..8269abc 100644 --- a/control-plane/lifecycle.py +++ b/control-plane/lifecycle.py @@ -38,6 +38,9 @@ _GUARD = threading.Lock() # guards the two maps below _IN_FLIGHT = {} # cid -> operations in progress _LOCKS = {} # cid -> RLock, dropped with its last operation +# admit() and the write that makes the new desktop count (insert as creating, claim +# as waking) happen as one step, or two concurrent creates both fit under the cap. +_ADMIT = threading.Lock() @contextmanager @@ -161,17 +164,18 @@ def admit(ram_mb): def provision(name=None, cpus=1, ram_mb=2048): cpus, ram_mb = float(cpus), int(ram_mb) - admit(ram_mb) cid = "c_" + secrets.token_hex(5) name = str(name or cid) token = secrets.token_hex(16) volume = f"case-{cid}" with _in_flight(cid, serialize=False): - return _provision(cid, name, cpus, ram_mb, volume, token) + with _ADMIT: + admit(ram_mb) + store.insert_computer(cid, name, IMAGE, cpus, ram_mb, volume, token) + return _provision(cid, cpus, ram_mb, volume, token) -def _provision(cid, name, cpus, ram_mb, volume, token): - store.insert_computer(cid, name, IMAGE, cpus, ram_mb, volume, token) +def _provision(cid, cpus, ram_mb, volume, token): try: dockerd.create_volume(volume) container = dockerd.create_container(cid, cpus, ram_mb, volume, token) @@ -260,10 +264,13 @@ def do_wake(cid): t0 = time.monotonic() # Asleep computers don't count against the budget; waking one must, same as create. # (create already checks; wake used to bypass the cap and OOM a small box.) - if row["state"] == "asleep": - admit(row["ram_mb"]) - if not set_state(cid, "waking"): - return # lost a race (concurrent sleep/delete), don't build infra on a stale row + with _ADMIT: + if row["state"] == "asleep": + admit(row["ram_mb"]) + if row["state"] == "waking": + raise ApiError(409, "waking", "computer is already waking; retry shortly") + if not set_state(cid, "waking"): + return # lost a race (concurrent sleep/delete), don't build infra on a stale row try: try: dockerd.start_container(cid) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index df39c10..13d150d 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -73,6 +73,63 @@ def test_wake_respects_max_running(): store.delete_computer(other) +def test_concurrent_creates_cannot_both_fit_under_the_cap(): + # admit() then insert was two steps: with MAX_RUNNING=1 two creates in the same + # moment both passed the check and both came up. + import threading + import time as _time + from unittest import mock + import deskclient + import dockerd + import lifecycle + for r in store.all_non_deleted(): + store.delete_computer(r["id"]) + real_admit, out = lifecycle.admit, [] + + def slow_admit(ram_mb): + real_admit(ram_mb) + _time.sleep(0.2) # widen the check-then-insert window + + def create(): + try: + out.append(lifecycle.provision("x")["state"]) + except ApiError as e: + out.append(e.code) + + with mock.patch.object(lifecycle, "MAX_RUNNING", 1), \ + mock.patch.object(lifecycle, "admit", slow_admit), \ + mock.patch.object(dockerd, "create_volume"), \ + mock.patch.object(dockerd, "create_container"), \ + mock.patch.object(dockerd, "container_ports", lambda c, deadline=10: (1, 2)), \ + mock.patch.object(deskclient, "wait_desk"): + ts = [threading.Thread(target=create) for _ in range(2)] + for t in ts: + t.start() + for t in ts: + t.join() + try: + assert sorted(out) == ["running", "too_many_running"], out + finally: + for r in store.all_non_deleted(): + store.delete_computer(r["id"]) + + +def test_a_second_wake_does_not_take_over_a_waking_row(): + from unittest import mock + import dockerd + import lifecycle + cid = "c_unittest_waking_claim" + _asleep_row(cid) + store.set_state(cid, "waking") + try: + with mock.patch.object(dockerd, "container_up", return_value=False), \ + mock.patch.object(dockerd, "start_container") as start: + _raises(lambda: lifecycle.do_wake(cid), "waking") + start.assert_not_called() + finally: + store.delete_computer(cid) + + def test_sleep_blocked_when_auth_attempt_active(): # POST …/sleep → 409 auth_in_progress while a non-terminal AuthAttempt exists. cid = "c_unittest_sleep_pin" From 2502fc73483770a03e202e98b53a64c41f3801fd Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:00:27 +0000 Subject: [PATCH 052/126] Pass the usage stats opt-out through compose. CASE_TELEMETRY=0 or DO_NOT_TRACK=1 in .env never reached cased, because the compose service lists its environment by hand, so the documented opt-out did nothing under compose. Both are now passed through and documented in .env.example. The acceptance run's cased child also sets CASE_TELEMETRY=0, since it is not a test_ script and would otherwise report stats. --- .env.example | 6 ++++++ compose.yaml | 3 +++ tests/test_acceptance.py | 1 + tests/test_acceptance_safety.py | 1 + 4 files changed, 11 insertions(+) diff --git a/.env.example b/.env.example index bdd4e26..19caf1a 100644 --- a/.env.example +++ b/.env.example @@ -76,3 +76,9 @@ CASE_MAX_RAM_MB= # CASE_DBC_AUTHTOKEN= # CASE_DBC_USERNAME= # CASE_DBC_PASSWORD= + +# Anonymous usage stats: counts of computers and schedules, and whether +# scheduled runs succeed. Never a name, prompt, domain or credential. Either +# line turns them off. +# CASE_TELEMETRY=0 +# DO_NOT_TRACK=1 diff --git a/compose.yaml b/compose.yaml index c607dce..7059b29 100644 --- a/compose.yaml +++ b/compose.yaml @@ -62,6 +62,9 @@ services: CASE_DBC_AUTHTOKEN: ${CASE_DBC_AUTHTOKEN:-} CASE_DBC_USERNAME: ${CASE_DBC_USERNAME:-} CASE_DBC_PASSWORD: ${CASE_DBC_PASSWORD:-} + # Usage stats opt-out (see .env.example) + CASE_TELEMETRY: ${CASE_TELEMETRY:-} + DO_NOT_TRACK: ${DO_NOT_TRACK:-} volumes: - /var/run/docker.sock:/var/run/docker.sock - case-home:/data diff --git a/tests/test_acceptance.py b/tests/test_acceptance.py index 03bd0b5..2854591 100644 --- a/tests/test_acceptance.py +++ b/tests/test_acceptance.py @@ -66,6 +66,7 @@ def child_env(parent, case_home, port, token, image): "CASE_PORT": str(port), "CASE_TOKEN": token, "CASE_IMAGE": image, + "CASE_TELEMETRY": "0", # the child is cased.py, not a test_ script }) return env diff --git a/tests/test_acceptance_safety.py b/tests/test_acceptance_safety.py index e35b292..8dfbe76 100644 --- a/tests/test_acceptance_safety.py +++ b/tests/test_acceptance_safety.py @@ -35,6 +35,7 @@ def test_child_env_removes_case_and_provider_settings(): assert env["CASE_TOKEN"] == "test-token" assert env["CASE_IMAGE"] == "test-image" assert env["CASE_BIND"] == "127.0.0.1" + assert env["CASE_TELEMETRY"] == "0" assert "CASE_DOCKER_NETWORK" not in env assert "DESK_DEBUG" not in env assert "OPENAI_API_KEY" not in env From 1227d2ab00f99b21d3662b183c0589b8995aff72 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:00:28 +0000 Subject: [PATCH 053/126] Leave deleted computers out of the usage stats count. destroy() keeps a deleted row behind, so the computers count grew with every computer ever made instead of the ones that exist. --- control-plane/store.py | 2 +- tests/test_telemetry.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/control-plane/store.py b/control-plane/store.py index b6cab73..d005856 100644 --- a/control-plane/store.py +++ b/control-plane/store.py @@ -618,7 +618,7 @@ def telemetry_counts(self, since): may become a name, prompt, domain or username.""" n = lambda sql, args=(): self.one(sql, args)["c"] return { - "computers": n("SELECT COUNT(*) c FROM computers"), + "computers": n("SELECT COUNT(*) c FROM computers WHERE state != 'deleted'"), "credentials": n("SELECT COUNT(*) c FROM credentials"), "schedules": n("SELECT COUNT(*) c FROM schedules"), "schedules_enabled": n("SELECT COUNT(*) c FROM schedules WHERE enabled=1"), diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 7d6a3c4..bccb1b2 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -105,6 +105,19 @@ def test_counts_are_counts(): assert all(type(v) is int for v in c.values()), c +def test_deleted_computers_are_not_counted(): + # destroy() keeps the row as a 'deleted' tombstone + for cid, state in (("c_tele_live", "asleep"), ("c_tele_gone", "deleted")): + store.delete_computer(cid) + store.insert_computer(cid, cid, "img", 1, 512, "vol", "tok") + store.set_state(cid, state) + try: + assert store.telemetry_counts("2000-01-01T00:00:00Z")["computers"] == 1 + finally: + store.delete_computer("c_tele_live") + store.delete_computer("c_tele_gone") + + if __name__ == "__main__": for name, fn in sorted(globals().items()): if name.startswith("test_"): From 1a1288077ec0ec4426d6347f06ddd54cd9b8deb3 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:01:06 +0000 Subject: [PATCH 054/126] Keep a schedule row that no longer computes from raising every sweep. compute_next ran before the run's try, so a row stored before the 60s interval floor, or a daily row whose tz no longer resolves, raised out of run_schedule before next_run_at moved. Every 20s sweep fired it again and no run was ever recorded. Legacy intervals now run at the 60s floor, and anything else that fails to compute records a failed run with the reason and is retried a day later. --- control-plane/scheduler.py | 16 ++++++++++++-- tests/test_scheduler.py | 45 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/control-plane/scheduler.py b/control-plane/scheduler.py index 506e7f9..4d2d99e 100644 --- a/control-plane/scheduler.py +++ b/control-plane/scheduler.py @@ -203,14 +203,26 @@ def run_schedule(sid): return # sqlite3.Row has no dict.get — index like every other column. tz = s["tz"] if "tz" in s.keys() else None - # Reschedule FIRST so a hung/crashed run never wedges the slot. - store.set_schedule_next(sid, compute_next(s["kind"], s["spec"], s["jitter_s"], tz)) + # Reschedule FIRST so a hung/crashed run never wedges the slot. A row that no + # longer computes (a zone tzdata no longer has, a hand edit) fails this run and + # comes back in a day, instead of raising out of every sweep unrecorded. + try: + spec = s["spec"] + if s["kind"] == "interval": + spec = max(60, int(spec)) # rows stored before the 60s floor + nxt, bad = compute_next(s["kind"], spec, s["jitter_s"], tz), None + except (ApiError, TypeError, ValueError) as e: + nxt, bad = (datetime.now(timezone.utc) + timedelta(days=1)).strftime( + "%Y-%m-%dT%H:%M:%SZ"), e + store.set_schedule_next(sid, nxt) cid, rid, started, t0 = s["computer_id"], new_id("run"), now(), time.monotonic() code, summary, status, artifact = -1, "", "fail", None # Only the run that woke an asleep box may put it back, never borrow a live session # (and never sleep under an active AuthAttempt; do_sleep also 409s as a belt). woke_for_run = False try: + if bad: + raise bad was_asleep = get_computer(cid)["state"] == "asleep" do_wake(cid) woke_for_run = was_asleep diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index fcd9d95..f0748a2 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -421,6 +421,51 @@ def _tight(cid): assert "ApiError" not in rec["summary"], rec +def _run_real_row(sid): + """run_schedule against the real store with the computer side faked out.""" + import unittest.mock as mock + import scheduler + with mock.patch.object(scheduler, "get_computer", lambda cid: {"id": cid, "state": "running"}), \ + mock.patch.object(scheduler, "do_wake") as wake, \ + mock.patch.object(scheduler, "do_sleep"), \ + mock.patch.object(scheduler, "run_brain", lambda cid, p, name="": (0, "done")), \ + mock.patch.object(scheduler, "capture_run_artifacts", lambda *a, **k: None), \ + mock.patch.object(scheduler.notifier, "push"), \ + mock.patch.object(scheduler, "emit"): + scheduler.run_schedule(sid) + return wake + + +def test_legacy_sub_minute_interval_runs_at_the_floor(): + # Rows stored before the 60s floor used to raise out of compute_next on every + # sweep, before the reschedule, so they re-fired forever and never recorded a run. + store.q("DELETE FROM schedules") + store.q("DELETE FROM runs") + store.insert_schedule("sch_legacy", "c_1", "legacy", "p", "interval", "30", 0, + "2020-01-01T00:00:00Z") + _run_real_row("sch_legacy") + nxt = _dt(store.get_schedule("sch_legacy")["next_run_at"]) + assert 55 <= (nxt - datetime.now(timezone.utc)).total_seconds() <= 65, nxt + assert [r["status"] for r in store.list_runs("sch_legacy")] == ["ok"] + + +def test_unresolvable_tz_fails_the_run_and_backs_off_a_day(): + store.q("DELETE FROM schedules") + store.q("DELETE FROM runs") + store.insert_schedule("sch_badtz", "c_1", "tz", "p", "daily", "07:00", 0, + "2020-01-01T00:00:00Z", tz="Mars/Olympus") + wake = _run_real_row("sch_badtz") + wake.assert_not_called() + s = store.get_schedule("sch_badtz") + assert s["enabled"] == 1 + left = (_dt(s["next_run_at"]) - datetime.now(timezone.utc)).total_seconds() + assert 86000 <= left <= 86400, left # not due again next sweep + runs = store.list_runs("sch_badtz") + assert [r["status"] for r in runs] == ["fail"], runs + assert "Mars/Olympus" in runs[0]["summary"], runs[0]["summary"] + assert s["last_status"] == "fail" + + def test_run_brain_url_finished(): import unittest.mock as mock import scheduler From b385a240266c25d398517cf256516d24857baf6c Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:01:50 +0000 Subject: [PATCH 055/126] Let the last schedule run on a computer be the one that sleeps it. Schedule A woke the box, schedule B started on it while A ran, and A's cleanup slept the box under B. Runs now count themselves as holders of their computer and only the last one out sleeps it, if any of them woke it. The count is held through the sleep so a run starting at that moment waits and then wakes the box itself. --- control-plane/scheduler.py | 21 +++++++++++++---- tests/test_scheduler.py | 48 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/control-plane/scheduler.py b/control-plane/scheduler.py index 4d2d99e..43db32e 100644 --- a/control-plane/scheduler.py +++ b/control-plane/scheduler.py @@ -34,6 +34,9 @@ SCHED_RUNNING = set() # in-memory guard, fine while one cased process runs _LOCK = threading.Lock() # guards the check-then-add on SCHED_RUNNING (sweeper vs run-now) +# computer_id -> [runs using it, whether one of them woke it]. Two schedules can share +# a box, and the one that woke it must not sleep it under the other: the last one out does. +_HOLDERS = {} def _wall_exists(t, hh, mm): @@ -220,6 +223,8 @@ def run_schedule(sid): # Only the run that woke an asleep box may put it back, never borrow a live session # (and never sleep under an active AuthAttempt; do_sleep also 409s as a belt). woke_for_run = False + with _LOCK: + _HOLDERS.setdefault(cid, [0, False])[0] += 1 try: if bad: raise bad @@ -245,11 +250,17 @@ def run_schedule(sid): summary = f"{type(e).__name__}: {e}" log.exception("schedule %s run failed", sid) finally: - try: - if woke_for_run and not store.active_attempt_exists(cid): - do_sleep(cid) - except Exception: - log.exception("sleep after schedule %s", sid) + with _LOCK: # held through the sleep: a run starting now waits, then wakes + held = _HOLDERS[cid] + held[0] -= 1 + held[1] = held[1] or woke_for_run + if not held[0]: + del _HOLDERS[cid] + try: + if held[1] and not store.active_attempt_exists(cid): + do_sleep(cid) + except Exception: + log.exception("sleep after schedule %s", sid) store.insert_run(rid, sid, cid, started, now(), code, summary, artifact, status) store.set_schedule_result(sid, started, status) shot = " 📸" if artifact else "" diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index f0748a2..62ab5b4 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -385,6 +385,54 @@ def active_attempt_exists(self, cid): assert rec.get("status") == "ok", rec +def test_a_run_does_not_sleep_the_box_under_another_schedule(): + # A woke the box, B started on it while A ran; A's cleanup used to sleep it + # under B. The last run out sleeps it instead. + import threading + import time + import unittest.mock as mock + import scheduler + store.q("DELETE FROM schedules") + store.q("DELETE FROM runs") + state, seen = {"c_two": "asleep"}, [] + for sid in ("sch_A", "sch_B"): + store.insert_schedule(sid, "c_two", sid, "p", "interval", "3600", 0, + "2020-01-01T00:00:00Z") + b_started, a_done = threading.Event(), threading.Event() + + def brain(cid, prompt, name=""): + if name == "sch_A": + b_started.wait(2) + else: + b_started.set() + a_done.wait(2) + seen.append(state[cid]) + return 0, "ok" + + def run_a(): + scheduler.run_schedule("sch_A") + a_done.set() + + with mock.patch.object(scheduler, "get_computer", lambda cid: {"id": cid, "state": state[cid]}), \ + mock.patch.object(scheduler, "do_wake", lambda cid: state.update({cid: "running"})), \ + mock.patch.object(scheduler, "do_sleep", lambda cid: state.update({cid: "asleep"})), \ + mock.patch.object(scheduler, "run_brain", brain), \ + mock.patch.object(scheduler, "capture_run_artifacts", lambda *a, **k: None), \ + mock.patch.object(scheduler.notifier, "push"), \ + mock.patch.object(scheduler, "emit"): + ta = threading.Thread(target=run_a) + ta.start() + while state["c_two"] != "running": # B borrows a box A already woke + time.sleep(0.01) + tb = threading.Thread(target=scheduler.run_schedule, args=("sch_B",)) + tb.start() + ta.join(5) + tb.join(5) + assert seen == ["running"], seen # B still had its box after A finished + assert state["c_two"] == "asleep" # and the last one out put it back + assert scheduler._HOLDERS == {} + + def test_ram_tight_box_is_a_skip_too(): import scheduler from errors import ApiError From 0b20c77a91fb2ea02e50451385ff9b8c52d2178e Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:02:24 +0000 Subject: [PATCH 056/126] Keep the session keeper off a box someone started using mid-probe. The keeper slept a computer it woke as long as no AuthAttempt was open, even if a schedule or a desk call had started on it meanwhile. And after its own wake the in-loop busy check read last_active_at from before the box slept, saw a "recent" session and skipped every probe. Recency is now checked before the wake only; inside the pass and before sleeping, the keeper asks whether a login, a schedule or a desk call started since it began. --- control-plane/session_keeper.py | 24 +++++++++++--- tests/test_session_keeper.py | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/control-plane/session_keeper.py b/control-plane/session_keeper.py index 0e03199..f4959da 100644 --- a/control-plane/session_keeper.py +++ b/control-plane/session_keeper.py @@ -3,8 +3,8 @@ Cadenced probe (default 6h, env `CASE_SESSION_KEEPER_INTERVAL_S`): for each credential with a `probe_url` and/or `proof_spec`, wake if needed, load the probe URL, observe/prove, -update `last_status`. Sleeps only when *this* probe woke the box and no AuthAttempt is -pinning it awake. +update `last_status`. Sleeps only when *this* probe woke the box and nobody started on +it meanwhile (an AuthAttempt, a schedule, a desk call). Skips busy computers: active AuthAttempt, in-flight schedule brain, or a recently touched live session (last_active_at within CASE_SESSION_KEEPER_BUSY_S, default 15m). @@ -25,7 +25,7 @@ from config import log from lifecycle import do_sleep, do_wake, get_computer from store import store -from util import row_get +from util import now, row_get # Default: every 6 hours per computer, not every 20s sweeper tick. INTERVAL_S = max(60, int(os.environ.get("CASE_SESSION_KEEPER_INTERVAL_S", "21600"))) @@ -138,6 +138,19 @@ def _busy(computer_id): return _recently_active(row) +def _taken_over(computer_id, since): + """Someone else started on the box after `since`: a login, a schedule, or a desk + call. last_active_at from before a keeper wake is the old session, not a new one.""" + if store.active_attempt_exists(computer_id) or _schedule_owns(computer_id): + return True + try: + row = get_computer(computer_id) + except Exception: + return True + ts = _parse_iso(row_get(row, "last_active_at")) + return bool(ts and ts >= since) + + def _probe_one_awake(computer_id, name): """Probe a credential on an already-running computer. Returns status or None.""" crow = store.get_credential(computer_id, name) @@ -209,11 +222,12 @@ def _tick(): except Exception: continue woke = row["state"] == "asleep" + since = _parse_iso(now()) try: if woke: do_wake(cid) for c in group: - if _busy(cid): + if _taken_over(cid, since): break try: _probe_one_awake(cid, c["name"]) @@ -223,7 +237,7 @@ def _tick(): log.exception("session_keeper: tick for %s failed", cid) finally: _last_probe_at[cid] = time.monotonic() - if woke and not store.active_attempt_exists(cid): + if woke and not _taken_over(cid, since): try: do_sleep(cid) except Exception: diff --git a/tests/test_session_keeper.py b/tests/test_session_keeper.py index a879858..8d02552 100644 --- a/tests/test_session_keeper.py +++ b/tests/test_session_keeper.py @@ -6,6 +6,7 @@ import sys import tempfile import unittest.mock as mock +from datetime import datetime, timezone sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) # assignment, NOT setdefault: these tests write credential probe rows. @@ -149,6 +150,61 @@ def test_tick_sleeps_only_if_it_woke(): assert slept == [], slept +def test_tick_does_not_sleep_a_box_someone_started_on_mid_probe(): + import scheduler + from util import now as _now + for takeover in ("schedule", "desk"): + _cleanup() + _reset_keeper_clock() + cid = _computer(state="asleep") + _cred(cid, proof_spec={"url_contains": "/x"}) + store.q("DELETE FROM schedules") + store.insert_schedule("sch_sk", cid, "n", "p", "interval", "3600", 0, + "2999-01-01T00:00:00Z") + slept = [] + + def probe(c, n): + if takeover == "schedule": + scheduler.SCHED_RUNNING.add("sch_sk") + else: + store.q("UPDATE computers SET last_active_at=? WHERE id=?", (_now(), c)) + return "ok" + + try: + with mock.patch.object(session_keeper, "do_wake", + side_effect=lambda c: store.set_state(c, "running")), \ + mock.patch.object(session_keeper, "do_sleep", + side_effect=lambda c: slept.append(c)), \ + mock.patch.object(session_keeper, "_probe_one_awake", side_effect=probe): + session_keeper.tick() + finally: + scheduler.SCHED_RUNNING.discard("sch_sk") + store.q("DELETE FROM schedules") + assert slept == [], (takeover, slept) + + +def test_tick_probes_after_its_own_wake_despite_old_activity(): + # last_active_at from before the box slept is not a live session: the keeper's + # own wake made the box "running" and the in-loop check used to skip every probe. + from datetime import timedelta + _cleanup() + _reset_keeper_clock() + cid = _computer(state="asleep") + _cred(cid, proof_spec={"url_contains": "/x"}) + five_min_ago = (datetime.now(timezone.utc) - timedelta(minutes=5)).strftime( + "%Y-%m-%dT%H:%M:%SZ") + store.q("UPDATE computers SET last_active_at=? WHERE id=?", (five_min_ago, cid)) + probed, slept = [], [] + with mock.patch.object(session_keeper, "do_wake", + side_effect=lambda c: store.set_state(c, "running")), \ + mock.patch.object(session_keeper, "do_sleep", side_effect=lambda c: slept.append(c)), \ + mock.patch.object(session_keeper, "_probe_one_awake", + side_effect=lambda c, n: probed.append(n) or "ok"): + session_keeper.tick() + assert probed == ["github"], probed + assert slept == [cid], slept + + def test_tick_batches_per_computer_one_wake(): _cleanup() _reset_keeper_clock() From d37629db398cc7c41c98caab5623438f907674d8 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:03:06 +0000 Subject: [PATCH 057/126] Notify on a failed session check instead of opening a stuck login. The keeper's recovery stub opened an AuthAttempt in created that nothing ever advanced. It pinned the computer awake, so the keeper's own sleep was refused, and 409'd the agent's login until it went stale about 15 minutes later. Its fixed idempotency key also meant it only ever happened once per credential. A failed check now records last_status=failed as before and sends a notification when the status flips to failed. --- control-plane/session_keeper.py | 31 +++++++++++++------------------ tests/test_session_keeper.py | 23 ++++++++++++++++++++++- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/control-plane/session_keeper.py b/control-plane/session_keeper.py index f4959da..abe3b90 100644 --- a/control-plane/session_keeper.py +++ b/control-plane/session_keeper.py @@ -10,7 +10,7 @@ touched live session (last_active_at within CASE_SESSION_KEEPER_BUSY_S, default 15m). Heuristic-only "looks fine" is never recorded as durable `ok`. -Unhealthy → record `failed`; optionally open an AuthAttempt for human recovery later. +Unhealthy → record `failed`, and notify the human when it flips to failed. """ import os import threading @@ -24,6 +24,7 @@ ) from config import log from lifecycle import do_sleep, do_wake, get_computer +from notify import notifier from store import store from util import now, row_get @@ -56,20 +57,12 @@ def _decide_status_for(computer, proof_spec, observation): return None -def _maybe_start_recovery(computer_id, name, probe_url, proof_spec): - """Optional stub: open an AuthAttempt so a human can recover later. Never raises.""" - if not probe_url: - log.info("session_keeper: %s/%s unhealthy; no probe_url to start recovery", - computer_id, name) - return - try: - import auth_attempts - auth_attempts.start_attempt( - computer_id, name, probe_url, proof_spec=proof_spec, - idempotency_key=f"keeper:{computer_id}:{name}") - log.info("session_keeper: started recovery attempt for %s/%s", computer_id, name) - except Exception as e: - log.info("session_keeper: recovery stub for %s/%s: %s", computer_id, name, e) +def _notify_unhealthy(computer_id, name): + """Tell the human. The keeper does not open an AuthAttempt for this: nothing would + advance it, and an open one pins the box awake and 409s the agent's own login.""" + log.info("session_keeper: %s/%s failed its session check", computer_id, name) + notifier.push(f"[{store.computer_name(computer_id)}] {name}: session check failed, " + "it may need a fresh login") def _observe(computer): @@ -161,6 +154,7 @@ def _probe_one_awake(computer_id, name): probe_url = row_get(crow, "probe_url") proof_spec = parse_proof_spec(row_get(crow, "proof_spec")) + was_failed = row_get(crow, "last_status") == "failed" # notified already computer = get_computer(computer_id) observation = None @@ -172,7 +166,8 @@ def _probe_one_awake(computer_id, name): log.exception("session_keeper: navigate probe_url failed for %s/%s", computer_id, name) store.record_credential_result(computer_id, name, "failed") - _maybe_start_recovery(computer_id, name, probe_url, proof_spec) + if not was_failed: + _notify_unhealthy(computer_id, name) return "failed" observation = _observe(computer) @@ -183,8 +178,8 @@ def _probe_one_awake(computer_id, name): status = _decide_status_for(computer, proof_spec, observation) if status: store.record_credential_result(computer_id, name, status) - if status == "failed": - _maybe_start_recovery(computer_id, name, probe_url, proof_spec) + if status == "failed" and not was_failed: + _notify_unhealthy(computer_id, name) return status diff --git a/tests/test_session_keeper.py b/tests/test_session_keeper.py index 8d02552..93e499d 100644 --- a/tests/test_session_keeper.py +++ b/tests/test_session_keeper.py @@ -117,7 +117,7 @@ def test_probe_records_failed_when_logged_out(): with mock.patch("deskclient.navigate", return_value={"ok": True}), \ mock.patch.object(session_keeper, "_observe", return_value=obs), \ - mock.patch.object(session_keeper, "_maybe_start_recovery") as recover: + mock.patch.object(session_keeper, "_notify_unhealthy") as recover: status = session_keeper._probe_one_awake(cid, "github") assert status == "failed" @@ -125,6 +125,27 @@ def test_probe_records_failed_when_logged_out(): recover.assert_called_once() +def test_failed_probe_notifies_once_and_opens_no_auth_attempt(): + # The old recovery "stub" opened an AuthAttempt in created that nothing ever + # advanced: it pinned the box awake and 409'd the agent's login, and its fixed + # idempotency key meant it only ever happened once. + _cleanup() + _reset_keeper_clock() + cid = _computer(state="running") + _cred(cid, probe_url="https://example.com/login", proof_spec=None, last_status="ok") + obs = {"href": "https://example.com/login", + "visible_fields": {"pass": True}, "challenge_signals": []} + pushed = [] + with mock.patch("deskclient.navigate", return_value={"ok": True}), \ + mock.patch.object(session_keeper, "_observe", return_value=obs), \ + mock.patch.object(session_keeper.notifier, "push", side_effect=pushed.append): + assert session_keeper._probe_one_awake(cid, "github") == "failed" + assert session_keeper._probe_one_awake(cid, "github") == "failed" + assert not store.active_attempt_exists(cid) + assert len(pushed) == 1 and "github" in pushed[0], pushed + assert store.get_credential(cid, "github")["last_status"] == "failed" + + def test_tick_sleeps_only_if_it_woke(): _cleanup() _reset_keeper_clock() From 757bf58eeb07af4034a8829d95d0616a9a2989ed Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:03:31 +0000 Subject: [PATCH 058/126] Treat an empty numeric env line as unset. bin/case sources ~/.case/env, and a line like CASE_MAX_RUNNING= exports an empty string, which int() rejects at import, so cased restart-looped with only a traceback. CASE_MAX_RUNNING, CASE_BRAIN_TIMEOUT and the session keeper's two knobs now use the `or default` form CASE_VNC_PORT already uses. --- control-plane/config.py | 4 ++-- control-plane/session_keeper.py | 4 ++-- tests/test_session_keeper.py | 24 ++++++++++++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/control-plane/config.py b/control-plane/config.py index 51d9797..2d985d3 100644 --- a/control-plane/config.py +++ b/control-plane/config.py @@ -11,7 +11,7 @@ log = logging.getLogger("cased") IMAGE = os.environ.get("CASE_IMAGE", "case-desk:0.1") -MAX_RUNNING = int(os.environ.get("CASE_MAX_RUNNING", "8")) +MAX_RUNNING = int(os.environ.get("CASE_MAX_RUNNING") or 8) def _host_ram_budget_mb(): @@ -73,7 +73,7 @@ def _desk_resolution(): MCP_CONFIG = os.environ.get( "CASE_MCP_CONFIG", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "case-mcp.json")) -BRAIN_TIMEOUT = int(os.environ.get("CASE_BRAIN_TIMEOUT", "1800")) +BRAIN_TIMEOUT = int(os.environ.get("CASE_BRAIN_TIMEOUT") or 1800) CASE_HOME = os.environ.get("CASE_HOME", os.path.expanduser("~/.case")) RUNS_DIR = os.path.join(CASE_HOME, "runs") # per-run screenshot artifacts (host side) diff --git a/control-plane/session_keeper.py b/control-plane/session_keeper.py index abe3b90..7030f6a 100644 --- a/control-plane/session_keeper.py +++ b/control-plane/session_keeper.py @@ -29,9 +29,9 @@ from util import now, row_get # Default: every 6 hours per computer, not every 20s sweeper tick. -INTERVAL_S = max(60, int(os.environ.get("CASE_SESSION_KEEPER_INTERVAL_S", "21600"))) +INTERVAL_S = max(60, int(os.environ.get("CASE_SESSION_KEEPER_INTERVAL_S") or 21600)) # Skip probing a running computer the agent/human touched this recently. -BUSY_S = max(0, int(os.environ.get("CASE_SESSION_KEEPER_BUSY_S", "900"))) +BUSY_S = max(0, int(os.environ.get("CASE_SESSION_KEEPER_BUSY_S") or 900)) # computer_id → monotonic timestamp of last completed probe pass for that box _last_probe_at = {} diff --git a/tests/test_session_keeper.py b/tests/test_session_keeper.py index 93e499d..539ea56 100644 --- a/tests/test_session_keeper.py +++ b/tests/test_session_keeper.py @@ -304,6 +304,30 @@ def test_tick_respects_cadence_and_skips_recent_live_session(): assert wakes == [], wakes +def test_empty_env_lines_fall_back_to_defaults(): + # bin/case sources ~/.case/env, where `CASE_MAX_RUNNING=` is an empty string, + # not an unset one: int("") crashed cased at import. + import importlib + import config + keys = ("CASE_MAX_RUNNING", "CASE_BRAIN_TIMEOUT", + "CASE_SESSION_KEEPER_INTERVAL_S", "CASE_SESSION_KEEPER_BUSY_S") + try: + for k in keys: + os.environ[k] = "" + importlib.reload(config) + importlib.reload(session_keeper) + assert (config.MAX_RUNNING, config.BRAIN_TIMEOUT) == (8, 1800) + assert (session_keeper.INTERVAL_S, session_keeper.BUSY_S) == (21600, 900) + os.environ["CASE_SESSION_KEEPER_BUSY_S"] = "0" # an explicit 0 still means off + importlib.reload(session_keeper) + assert session_keeper.BUSY_S == 0 + finally: + for k in keys: + os.environ.pop(k, None) + importlib.reload(config) + importlib.reload(session_keeper) + + def test_tick_is_not_reentrant(): session_keeper._TICK.acquire() try: From df74363f7de80ce7890a5847e53815229e736722 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:04:17 +0000 Subject: [PATCH 059/126] Answer a non-integer timeout_s or ref with a 400, not a 500. The desk routes ran int() on timeout_s and ref straight from the body, so "soon", null or a list surfaced as a 500 internal error, and a negative timeout reached requests as an invalid timeout. They now go through an _int helper next to _num that reports bad_request; blank or 0 still means the default timeout and the caps are unchanged. --- control-plane/cased.py | 39 +++++++++++++++++++++++++++++---------- tests/test_gates.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/control-plane/cased.py b/control-plane/cased.py index 718b8f9..d2b4feb 100644 --- a/control-plane/cased.py +++ b/control-plane/cased.py @@ -302,6 +302,24 @@ def _num(value, default, lo, hi, field): return n +def _int(value, default, field): + """A non-negative integer from a request body; blank is the default.""" + if value in (None, ""): + return default + try: + n = int(value) + except (TypeError, ValueError): + raise ApiError(400, "bad_request", f"{field} must be an integer") + if n < 0: + raise ApiError(400, "bad_request", f"{field} must not be negative") + return n + + +def _timeout(body, default, cap): + """timeout_s from a request body: blank or 0 is the default, capped at `cap`.""" + return min(_int(body.get("timeout_s"), 0, "timeout_s") or default, cap) + + @app.get("/v1/computers") def list_computers(): summaries = store.schedule_summaries() @@ -395,7 +413,7 @@ def exec_(cid: str, body: dict = Body(...), wake: bool = False): with awake(cid, wake) as row: if "command" not in body: raise ApiError(400, "bad_request", "body needs 'command'") - timeout = min(int(body.get("timeout_s") or 30), 600) + timeout = _timeout(body, 30, 600) return desk_json(row, "POST", "/exec", json=body, timeout=timeout + 15) @@ -404,7 +422,7 @@ def eval_(cid: str, body: dict = Body(...), wake: bool = False): with awake(cid, wake) as row: if "expression" not in body: raise ApiError(400, "bad_request", "body needs 'expression'") - timeout = min(int(body.get("timeout_s") or 20), 120) + timeout = _timeout(body, 20, 120) return desk_json(row, "POST", "/eval", json=body, timeout=timeout + 15) @@ -413,7 +431,7 @@ def navigate_(cid: str, body: dict = Body(...), wake: bool = False): with awake(cid, wake) as row: if "url" not in body: raise ApiError(400, "bad_request", "body needs 'url'") - timeout = max(1, min(int(body.get("timeout_s") or 30), 120)) # never navigate then + timeout = _timeout(body, 30, 120) # never navigate then out = navigate(row, body["url"], timeout) # report failure at t=0 if out.get("ok") and body.get("snapshot", True): fresh = browse.snapshot(row) # navigate already waited for readyState @@ -433,9 +451,9 @@ def page_(cid: str, wake: bool = False): @app.post("/v1/computers/{cid}/click") def click_(cid: str, body: dict = Body(...), wake: bool = False): with awake(cid, wake) as row: - if "ref" not in body: + if body.get("ref") in (None, ""): raise ApiError(400, "bad_request", "body needs 'ref' (from GET /page)") - return browse.click_element(row, int(body["ref"]), name=body.get("name"), + return browse.click_element(row, _int(body["ref"], None, "ref"), name=body.get("name"), text=body.get("text"), screenshot=bool(body.get("screenshot")), snapshot_after=bool(body.get("snapshot", True))) @@ -444,17 +462,18 @@ def click_(cid: str, body: dict = Body(...), wake: bool = False): @app.post("/v1/computers/{cid}/hover") def hover_(cid: str, body: dict = Body(...), wake: bool = False): with awake(cid, wake) as row: - if "ref" not in body: + if body.get("ref") in (None, ""): raise ApiError(400, "bad_request", "body needs 'ref' (from GET /page)") - return browse.hover(row, int(body["ref"]), name=body.get("name")) + return browse.hover(row, _int(body["ref"], None, "ref"), name=body.get("name")) @app.post("/v1/computers/{cid}/upload") def upload_(cid: str, body: dict = Body(...), wake: bool = False): with awake(cid, wake) as row: - if "ref" not in body or "path" not in body: + if body.get("ref") in (None, "") or "path" not in body: raise ApiError(400, "bad_request", "body needs 'ref' and 'path'") - return browse.upload(row, int(body["ref"]), body["path"], name=body.get("name")) + return browse.upload(row, _int(body["ref"], None, "ref"), body["path"], + name=body.get("name")) @app.post("/v1/computers/{cid}/fill") @@ -467,7 +486,7 @@ def fill_(cid: str, body: dict = Body(...), wake: bool = False): @app.post("/v1/computers/{cid}/wait") def wait_(cid: str, body: dict = Body(...), wake: bool = False): with awake(cid, wake) as row: - timeout = max(1, min(int(body.get("timeout_s") or 30), 120)) + timeout = _timeout(body, 30, 120) return browse.wait_for(row, selector=body.get("selector"), text=body.get("text"), gone=bool(body.get("gone")), network_idle=bool(body.get("network_idle")), diff --git a/tests/test_gates.py b/tests/test_gates.py index 65887f4..458e439 100644 --- a/tests/test_gates.py +++ b/tests/test_gates.py @@ -118,6 +118,37 @@ def test_login_url_must_be_https(): assert r.status_code != 400, (ok, r.text) +def test_non_integer_body_values_are_a_400_not_a_500(): + os.environ.pop("CASE_TOKEN", None) + row = {"id": "c_x", "desk_port": 1, "desk_token": "t"} + with mock.patch.object(cased.lifecycle, "ensure_running", return_value=row), \ + mock.patch.object(cased.store, "touch"), \ + mock.patch.object(cased, "desk_json", return_value={"ok": True}) as desk, \ + mock.patch.object(cased.browse, "click_element", return_value={"ok": True}): + c = _client() + for path, body in (("exec", {"command": "ls", "timeout_s": "soon"}), + ("exec", {"command": "ls", "timeout_s": -5}), + ("eval", {"expression": "1", "timeout_s": [1]}), + ("navigate", {"url": "https://x", "timeout_s": "1.5"}), + ("wait", {"timeout_s": "x"}), + ("click", {"ref": "abc"}), + ("click", {"ref": None}), + ("click", {"ref": -1}), + ("hover", {"ref": {}}), + ("upload", {"ref": "x", "path": "/home/agent/f"})): + r = c.post(f"/v1/computers/c_x/{path}", json=body) + assert r.status_code == 400, (path, body, r.status_code, r.text) + assert r.json()["error"]["code"] == "bad_request", r.text + # the forms that always worked still do: blank/0 is the default, the cap holds + assert c.post("/v1/computers/c_x/exec", json={"command": "ls", "timeout_s": 0} + ).status_code == 200 + assert desk.call_args.kwargs["timeout"] == 45 + assert c.post("/v1/computers/c_x/exec", json={"command": "ls", "timeout_s": "9999"} + ).status_code == 200 + assert desk.call_args.kwargs["timeout"] == 615 + assert c.post("/v1/computers/c_x/click", json={"ref": "7"}).status_code == 200 + + def test_live_relay_needs_the_bearer_on_both_halves(): # HTTP middleware never sees a websocket scope, so the socket has to check the # token itself or the desktop is one upgrade away from anyone. From 40ef1ea29f9808f77536b70863d7069de7ac516f Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:04:54 +0000 Subject: [PATCH 060/126] Serve a schedule's runs through run_json and 404 an unknown schedule. GET /v1/schedules/{sid}/runs returned the raw rows, host artifact_path included, which run_json deliberately keeps from clients, and an empty list for a schedule that does not exist. It now shapes rows with run_json like /v1/runs and answers 404 for an unknown schedule. scheduler.list_runs had no other caller and is gone. --- control-plane/cased.py | 5 ++++- control-plane/scheduler.py | 3 --- tests/test_runs.py | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/control-plane/cased.py b/control-plane/cased.py index d2b4feb..3d52649 100644 --- a/control-plane/cased.py +++ b/control-plane/cased.py @@ -1061,7 +1061,10 @@ def run_schedule_now(sid: str): @app.get("/v1/schedules/{sid}/runs") def list_runs(sid: str): - return scheduler.list_runs(sid) + if not store.get_schedule(sid): + raise ApiError(404, "not_found", f"no schedule {sid}") + names = store.computer_names() + return [run_json(r, names) for r in store.list_runs(sid)] # ---------- runs (scheduled-run activity) ---------- diff --git a/control-plane/scheduler.py b/control-plane/scheduler.py index 43db32e..beb39b3 100644 --- a/control-plane/scheduler.py +++ b/control-plane/scheduler.py @@ -317,6 +317,3 @@ def delete_schedule(sid): if store.delete_schedule(sid) == 0: raise ApiError(404, "not_found", f"no schedule {sid}") - -def list_runs(sid): - return [dict(r) for r in store.list_runs(sid)] diff --git a/tests/test_runs.py b/tests/test_runs.py index e67913b..95da811 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -85,6 +85,24 @@ def test_run_json_without_an_artifact(): assert cased.run_json(store.get_run("run_b"))["has_screenshot"] is False +def test_schedule_runs_route_hides_the_host_path_too(): + from fastapi.testclient import TestClient + from unittest import mock + store.q("DELETE FROM runs") + store.q("DELETE FROM schedules") + store.insert_schedule("sch_r", "c_1", "n", "p", "interval", "3600", 0, None) + store.insert_run("run_s", "sch_r", "c_1", "2026-07-27T09:00:00Z", + "2026-07-27T09:05:00Z", 0, "ok", "/home/case/.case/runs/run_s.png", "ok") + c = TestClient(cased.app, base_url="http://127.0.0.1", raise_server_exceptions=False) + with mock.patch.dict(os.environ, {"CASE_TOKEN": ""}): + r = c.get("/v1/schedules/sch_r/runs") + assert r.status_code == 200, r.text + assert [j["id"] for j in r.json()] == ["run_s"], r.text + assert "artifact_path" not in r.json()[0] and r.json()[0]["has_screenshot"] is True + assert c.get("/v1/schedules/sch_nope/runs").status_code == 404 + store.q("DELETE FROM schedules") + + def test_screenshot_serves_only_from_the_runs_dir(): from config import RUNS_DIR from errors import ApiError From 96e8290808231f8609977f1ea0316bdd765aec55 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:04:54 +0000 Subject: [PATCH 061/126] Resolve run owners from the name map even when it is empty. run_json fell back to one computer_name query per row whenever the map it was handed was falsy, so a box whose computers were all gone paid a query per run in the feed. The fallback now only applies when no map was passed. --- control-plane/cased.py | 2 +- tests/test_runs.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/control-plane/cased.py b/control-plane/cased.py index 3d52649..2f209df 100644 --- a/control-plane/cased.py +++ b/control-plane/cased.py @@ -1075,7 +1075,7 @@ def run_json(row, names=None): # its owners in one query rather than fifty. cid = row["computer_id"] return {"id": row["id"], "schedule_id": row["schedule_id"], "computer_id": cid, - "computer_name": names.get(cid, cid) if names else store.computer_name(cid), + "computer_name": names.get(cid, cid) if names is not None else store.computer_name(cid), "started_at": row["started_at"], "ended_at": row["ended_at"], "exit_code": row["exit_code"], "summary": row["summary"], "status": row["status"], "has_screenshot": bool(row["artifact_path"])} diff --git a/tests/test_runs.py b/tests/test_runs.py index 95da811..842e2ad 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -103,6 +103,16 @@ def test_schedule_runs_route_hides_the_host_path_too(): store.q("DELETE FROM schedules") +def test_run_json_resolves_names_from_an_empty_map_without_a_query(): + store.q("DELETE FROM runs") + store.insert_run("run_n", "sch_1", "c_gone", "2026-07-27T09:00:00Z", + "2026-07-27T09:05:00Z", 0, "", None, "ok") + from unittest import mock + with mock.patch.object(store, "computer_name") as one: + assert cased.run_json(store.get_run("run_n"), {})["computer_name"] == "c_gone" + one.assert_not_called() + + def test_screenshot_serves_only_from_the_runs_dir(): from config import RUNS_DIR from errors import ApiError From a18c18fae68729ca5688602147fdb7443eb522ad Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:05:33 +0000 Subject: [PATCH 062/126] Raise a blocker again once its handoff expires, and poll each desk alone. BLOCKER_SEEN kept a fingerprint for as long as the page showed it, so a challenge whose handoff expired after 15 minutes was never raised again, and entries for slept or deleted computers stayed forever. The poller now remembers the handoff it routed, re-routes when that handoff has expired, and drops entries for computers that are no longer running. One desk that raises no longer skips the rest of the pass. --- control-plane/cased.py | 46 +++++++++++++++++++++++------------ tests/test_blockers.py | 55 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 15 deletions(-) diff --git a/control-plane/cased.py b/control-plane/cased.py index 2f209df..408b57d 100644 --- a/control-plane/cased.py +++ b/control-plane/cased.py @@ -76,7 +76,7 @@ async def lifespan(_app): app = FastAPI(title="cased", lifespan=lifespan) -BLOCKER_SEEN = {} # computer_id -> fingerprint +BLOCKER_SEEN = {} # computer_id -> (fingerprint, handoff id) # ---------- error handling ---------- @@ -1139,22 +1139,38 @@ def blocker_poller(): while True: time.sleep(3) try: - for row in store.running_rows(): - try: - b = desk_json(row, "GET", "/blocker", timeout=5).get("blocker") - except ApiError: - continue - cid = row["id"] - if not b: - BLOCKER_SEEN.pop(cid, None) - continue - if BLOCKER_SEEN.get(cid) == b["fingerprint"]: - continue - if login_flow._route_blocker(row, b): - # Record only after routing succeeds, so transient errors retry. - BLOCKER_SEEN[cid] = b["fingerprint"] + rows = store.running_rows() except Exception: log.exception("blocker poller") + continue + for cid in set(BLOCKER_SEEN) - {r["id"] for r in rows}: # slept or deleted + BLOCKER_SEEN.pop(cid, None) + for row in rows: + try: + _poll_blocker(row) + except Exception: + log.exception("blocker poller: %s", row["id"]) + + +def _poll_blocker(row): + try: + b = desk_json(row, "GET", "/blocker", timeout=5).get("blocker") + except ApiError: + return + cid = row["id"] + if not b: + BLOCKER_SEEN.pop(cid, None) + return + seen = BLOCKER_SEEN.get(cid) + if seen and seen[0] == b["fingerprint"]: + # still the same challenge: raise it again only once its handoff timed out + h = store.get_handoff(seen[1]) + if h and h["status"] != "expired": + return + hid = login_flow._route_blocker(row, b) + if hid: + # Record only after routing succeeds, so transient errors retry. + BLOCKER_SEEN[cid] = (b["fingerprint"], hid) if __name__ == "__main__": diff --git a/tests/test_blockers.py b/tests/test_blockers.py index 7b1163d..2613925 100644 --- a/tests/test_blockers.py +++ b/tests/test_blockers.py @@ -122,6 +122,61 @@ def test_store_fingerprint_lookup_ignores_terminal_handoffs(): store.delete_handoff("h_fp") +class _Stop(Exception): + pass + + +def _one_pass(rows, desk): + """Run blocker_poller for exactly one sweep over `rows`.""" + with mock.patch.object(cased.time, "sleep", side_effect=[None, _Stop()]), \ + mock.patch.object(store, "running_rows", return_value=rows), \ + mock.patch.object(cased, "desk_json", side_effect=desk): + try: + cased.blocker_poller() + except _Stop: + pass + + +def test_expired_handoff_is_raised_again_while_the_page_still_blocks(): + store.delete_handoff("h_seen") + cased.BLOCKER_SEEN.clear() + try: + store.insert_handoff("h_seen", "c_1", "otp", "code", None, None, + challenge_fingerprint=BLOCKER["fingerprint"]) + cased.BLOCKER_SEEN["c_1"] = (BLOCKER["fingerprint"], "h_seen") + desk = lambda row, *a, **k: {"blocker": BLOCKER} + with mock.patch.object(login_flow, "_route_blocker", return_value="h_new") as route: + _one_pass([ROW], desk) + route.assert_not_called() # its handoff is still open + store.set_handoff_status("h_seen", "expired") + _one_pass([ROW], desk) + route.assert_called_once() + assert cased.BLOCKER_SEEN["c_1"] == (BLOCKER["fingerprint"], "h_new") + finally: + store.delete_handoff("h_seen") + cased.BLOCKER_SEEN.clear() + + +def test_poller_forgets_computers_that_left_running_and_survives_a_bad_desk(): + cased.BLOCKER_SEEN.clear() + cased.BLOCKER_SEEN["c_slept"] = ("fp", "h_x") + other = {"id": "c_2", "name": "bo", "state": "running"} + + def desk(row, *a, **k): + if row["id"] == "c_1": + raise RuntimeError("desk went sideways") + return {"blocker": BLOCKER} + + try: + with mock.patch.object(login_flow, "_route_blocker", return_value="h_2") as route: + _one_pass([ROW, other], desk) + assert route.call_args.args[0]["id"] == "c_2" # c_1's error did not skip c_2 + assert "c_slept" not in cased.BLOCKER_SEEN + assert cased.BLOCKER_SEEN["c_2"] == (BLOCKER["fingerprint"], "h_2") + finally: + cased.BLOCKER_SEEN.clear() + + if __name__ == "__main__": for name, fn in sorted(globals().copy().items()): if name.startswith("test_") and callable(fn): From 99cafc24538369ebdacdcd29709ce5cba0655e7d Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:05:44 +0000 Subject: [PATCH 063/126] Run each sweeper step on its own so one failure can't starve the rest. The whole 20s pass sat in one try, so an exception in reconcile (or any early step) skipped fire_due_schedules, the telemetry heartbeat and the session keeper for that tick, and every tick after while it kept failing. Each step now logs its own failure and the pass goes on. --- control-plane/cased.py | 44 +++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/control-plane/cased.py b/control-plane/cased.py index 408b57d..993ef6c 100644 --- a/control-plane/cased.py +++ b/control-plane/cased.py @@ -1110,29 +1110,37 @@ def _spawn(fn, arg): threading.Thread(target=fn, args=(arg,), daemon=True).start() +def _step(fn, *args): + """One sweeper step; its failure must not starve the steps after it.""" + try: + fn(*args) + except Exception: + log.exception("sweeper: %s", getattr(fn, "__name__", fn)) + + +def _prune_history(): + cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).strftime("%Y-%m-%dT%H:%M:%SZ") + unlink_run_artifacts(store.prune_old_runs(keep=1000)) + prune_old_audit_files() + store.prune_terminal_handoffs(cutoff) + + def sweeper(): tick = 0 while True: time.sleep(20) tick += 1 - try: - lifecycle.reconcile() # re-align DB with Docker if the daemon restarted - handoffs.expire_stale() - store.prune_expired_links() - store.prune_expired_assist_tokens() - if tick % 180 == 0: - cutoff = (datetime.now(timezone.utc) - timedelta(days=30)).strftime( - "%Y-%m-%dT%H:%M:%SZ") - unlink_run_artifacts(store.prune_old_runs(keep=1000)) - prune_old_audit_files() - store.prune_terminal_handoffs(cutoff) - scheduler.fire_due_schedules(_spawn) - telemetry.heartbeat_if_due() # at most one event per UTC day - # preflight persistent session health: it drives desks over the network, - # and a hung one must not stall reconcile or the schedule fire loop - threading.Thread(target=session_keeper.tick, daemon=True).start() - except Exception: - log.exception("sweeper") + _step(lifecycle.reconcile) # re-align DB with Docker if the daemon restarted + _step(handoffs.expire_stale) + _step(store.prune_expired_links) + _step(store.prune_expired_assist_tokens) + if tick % 180 == 0: + _step(_prune_history) + _step(scheduler.fire_due_schedules, _spawn) + _step(telemetry.heartbeat_if_due) # at most one event per UTC day + # preflight persistent session health: it drives desks over the network, + # and a hung one must not stall reconcile or the schedule fire loop + _step(threading.Thread(target=session_keeper.tick, daemon=True).start) def blocker_poller(): From 53b1c0089df56b581b4bba9e0200449a5740ab74 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:06:05 +0000 Subject: [PATCH 064/126] Look up the live relay's computer off the event loop. live_ws called the blocking ensure_running directly in the async handler, so a wait on the store lock stalled every other request and socket on the loop. It now runs in the threadpool like the other handlers. --- control-plane/cased.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/control-plane/cased.py b/control-plane/cased.py index 993ef6c..0739ea1 100644 --- a/control-plane/cased.py +++ b/control-plane/cased.py @@ -899,7 +899,8 @@ async def live_ws(ws: WebSocket, cid: str): await ws.close(code=1008) return try: - base, headers = live_upstream(lifecycle.ensure_running(cid, False)) + row = await asyncio.to_thread(lifecycle.ensure_running, cid, False) + base, headers = live_upstream(row) except ApiError: await ws.close(code=1011) return From be95029b1b327affa50f95adf8b01df1240ae82d Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:06:50 +0000 Subject: [PATCH 065/126] Count the live view and the desk door as activity on a computer. Only API desk routes touched last_active_at, so a human watching or driving the box through the /live relay or the /desk door looked idle, and the session keeper could navigate the tab out from under them. The relay now touches the computer when the socket opens and every minute while it stays open, and the desk forward-auth check touches it on every request it lets through. --- control-plane/cased.py | 14 ++++++++++++++ tests/test_gates.py | 9 +++++++++ 2 files changed, 23 insertions(+) diff --git a/control-plane/cased.py b/control-plane/cased.py index 0739ea1..9f7b877 100644 --- a/control-plane/cased.py +++ b/control-plane/cased.py @@ -855,6 +855,7 @@ def desk_check_ep(request: Request): if VNC_PORT and comp["vnc_port"] != VNC_PORT: return HTMLResponse(links.NOTREADY_HTML.replace("{why}", links.STALE_PORT), status_code=409) + store.touch(comp["id"]) # a human on the desk is using the box if not set_tok: return Response(status_code=200) # cookie already good, let the request through # First hit, token in the URL. forward-auth proxies only forward a NON-2xx auth @@ -871,6 +872,9 @@ def desk_check_ep(request: Request): # ---------- live view (noVNC, relayed) ---------- +LIVE_TOUCH_S = 60 # well inside the session keeper's 15-minute busy window + + def live_upstream(row): """(base_url, headers) for a computer's noVNC: same dial deskclient uses for deskd.""" auth = base64.b64encode(f"agent:{row['desk_token']}".encode()).decode() @@ -909,6 +913,14 @@ async def live_ws(ws: WebSocket, cid: str): async with ws_connect("ws" + base[4:] + "/websockify", additional_headers=headers, subprotocols=subs or None, max_size=None) as up: await ws.accept(subprotocol=up.subprotocol) + # a human watching the live view is using the box; the session keeper must + # not navigate over them + await asyncio.to_thread(store.touch, cid) + + async def keep_active(): + while True: + await asyncio.sleep(LIVE_TOUCH_S) + await asyncio.to_thread(store.touch, cid) async def to_desk(): try: @@ -921,11 +933,13 @@ async def to_desk(): await up.close() pump = asyncio.create_task(to_desk()) + active = asyncio.create_task(keep_active()) try: async for msg in up: await ws.send_bytes(msg if isinstance(msg, bytes) else msg.encode()) finally: pump.cancel() + active.cancel() try: await ws.close() except RuntimeError: diff --git a/tests/test_gates.py b/tests/test_gates.py index 458e439..0286690 100644 --- a/tests/test_gates.py +++ b/tests/test_gates.py @@ -224,6 +224,15 @@ async def connect(*args, **kwargs): headers=headers) as ws: assert ws.receive_bytes() == b"RFB 003.008\n" + # a human on the live view is activity: the session keeper reads last_active_at + with mock.patch.dict(os.environ, {"CASE_TOKEN": "", "CASE_DOCKER_NETWORK": ""}), \ + mock.patch.object(cased.lifecycle, "ensure_running", return_value=row), \ + mock.patch.object(cased, "ws_connect", side_effect=connect), \ + mock.patch.object(cased.store, "touch") as touch: + with _client().websocket_connect("ws://127.0.0.1/v1/computers/c_x/live/websockify") as ws: + ws.receive_bytes() + touch.assert_called_with("c_x") + if __name__ == "__main__": for name, fn in sorted(globals().items()): From 06ffc4c827ca63a620203da487cd8fc044b5221e Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:07:08 +0000 Subject: [PATCH 066/126] Say when destroy leaves a volume behind, and label new volumes. destroy_infra swallowed every error, so a daemon that was unreachable during a delete left the computer's container and home volume behind with nothing in the log. Already-gone is still silent; any other failure now logs a warning naming the container or volume. New volumes carry the managed-by=cased label the containers already have, so a leftover can be found with a filter. Nothing deletes volumes on its own. --- control-plane/dockerd.py | 14 ++++++++++---- tests/test_dockerd.py | 42 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/control-plane/dockerd.py b/control-plane/dockerd.py index 617887e..1043bc4 100644 --- a/control-plane/dockerd.py +++ b/control-plane/dockerd.py @@ -10,7 +10,7 @@ import docker -from config import IMAGE, VNC_PORT +from config import IMAGE, VNC_PORT, log from errors import ApiError NotFound = docker.errors.NotFound # lifecycle catches this without importing docker-py @@ -150,15 +150,21 @@ def stop_container(cid, timeout=10): def destroy_infra(cid, volume): + """Best effort, never raises. Already gone is fine; anything else leaves a + container or a volume behind, so say which.""" try: dc().containers.get(container_name(cid)).remove(force=True) - except Exception: + except docker.errors.NotFound: pass + except Exception as e: + log.warning("destroy %s: container not removed (%s)", cid, e) try: dc().volumes.get(volume).remove() - except Exception: + except docker.errors.NotFound: pass + except Exception as e: + log.warning("destroy %s: volume %s not removed (%s)", cid, volume, e) def create_volume(volume): - dc().volumes.create(name=volume) + dc().volumes.create(name=volume, labels={"managed-by": "cased"}) diff --git a/tests/test_dockerd.py b/tests/test_dockerd.py index 36528c6..eb20c62 100644 --- a/tests/test_dockerd.py +++ b/tests/test_dockerd.py @@ -149,6 +149,46 @@ def remove_container(self, name, force=False): dockerd._dc = old +def test_destroy_names_a_volume_it_could_not_remove(): + # NotFound is the normal "already gone"; a daemon that is down is not, and the + # volume it leaves behind holds a deleted computer's home. + import docker as dockerpy + from unittest import mock + + class Gone: + def get(self, name): + raise dockerpy.errors.NotFound("gone") + + class Down: + def get(self, name): + raise dockerpy.errors.DockerException("daemon unreachable") + + old = dockerd._dc + try: + for vols, warned in ((Gone(), False), (Down(), True)): + dockerd._dc = type("DC", (), {"containers": Gone(), "volumes": vols, + "ping": lambda self: True})() + with mock.patch.object(dockerd.log, "warning") as warn: + dockerd.destroy_infra("c_ab", "case-c_ab") + assert warn.called is warned, (vols, warn.call_args_list) + if warned: + assert "case-c_ab" in warn.call_args.args[0] % warn.call_args.args[1:] + finally: + dockerd._dc = old + + +def test_new_volumes_carry_the_cased_label(): + from unittest import mock + vols = mock.Mock() + old = dockerd._dc + dockerd._dc = type("DC", (), {"volumes": vols, "ping": lambda self: True})() + try: + dockerd.create_volume("case-c_ab") + finally: + dockerd._dc = old + vols.create.assert_called_once_with(name="case-c_ab", labels={"managed-by": "cased"}) + + if __name__ == "__main__": test_host_mode_dials_loopback_and_publishes_ports() test_container_limits_cover_swap_and_pids() @@ -157,4 +197,6 @@ def remove_container(self, name, force=False): test_deskclient_url_follows_the_network() test_vnc_url_hidden_when_computers_are_on_the_compose_network() test_create_clears_a_name_stuck_in_removal_and_retries_once() + test_destroy_names_a_volume_it_could_not_remove() + test_new_volumes_carry_the_cased_label() print("test_dockerd: ok") From ec13bc84460c06b847a24911b5309bc176ff7670 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:08:15 +0000 Subject: [PATCH 067/126] Drop the Docker ping before every call and reconcile from one listing. dc() pinged the daemon before handing out the cached client, one extra round trip on every Docker call, and reconcile did a get per computer every 20 seconds. The client is now rebuilt only when a call fails to connect (the daemon restarted or its socket moved), and reconcile reads every managed-by=cased container in one list call. A computer missing from the listing is handled like the old NotFound. --- control-plane/cased.py | 2 +- control-plane/dockerd.py | 42 ++++++++++++++++++++++-------- control-plane/lifecycle.py | 13 +++++----- tests/test_dockerd.py | 52 +++++++++++++++++++++++++++++++++----- tests/test_lifecycle.py | 6 +++-- 5 files changed, 89 insertions(+), 26 deletions(-) diff --git a/control-plane/cased.py b/control-plane/cased.py index 9f7b877..90c8501 100644 --- a/control-plane/cased.py +++ b/control-plane/cased.py @@ -361,7 +361,7 @@ def health(request: Request): return {"ok": True} n = len(store.list_computers()) try: - dockerd.dc().ping() + dockerd.ping() docker_ok = True except Exception: docker_ok = False diff --git a/control-plane/dockerd.py b/control-plane/dockerd.py index 1043bc4..e9733bb 100644 --- a/control-plane/dockerd.py +++ b/control-plane/dockerd.py @@ -9,6 +9,7 @@ import time import docker +import requests from config import IMAGE, VNC_PORT, log from errors import ApiError @@ -39,15 +40,26 @@ def _connect(): def dc(): global _dc - try: - if _dc is None: - _dc = _connect() - _dc.ping() - except Exception: - _dc = _connect() # daemon restarted (host reboot / colima restart) + if _dc is None: + _dc = _connect() return _dc +def _docker(fn): + """fn(client), once more on a fresh client when the daemon went away under the + cached one (host reboot / colima restart can move the socket).""" + global _dc + try: + return fn(dc()) + except requests.ConnectionError: + _dc = None + return fn(dc()) + + +def ping(): + _docker(lambda c: c.ping()) + + def docker_network(): """Compose network name, or empty when cased runs on the host (loopback ports).""" return (os.environ.get("CASE_DOCKER_NETWORK") or "").strip() @@ -99,7 +111,7 @@ def container_run_kwargs(cid, cpus, ram_mb, volume, token): def create_container(cid, cpus, ram_mb, volume, token): kw = container_run_kwargs(cid, cpus, ram_mb, volume, token) try: - return dc().containers.run(IMAGE, **kw) + return _docker(lambda c: c.containers.run(IMAGE, **kw)) except docker.errors.APIError as e: if e.status_code != 409: raise @@ -128,7 +140,15 @@ def container_ports(container, deadline=10): def get_container(cid): - return dc().containers.get(container_name(cid)) + return _docker(lambda c: c.containers.get(container_name(cid))) + + +def managed_containers(): + """{name: container} for every desktop cased made, from one list call. Sparse: + the listing carries the status, and container_ports reloads what it needs.""" + listed = _docker(lambda c: c.containers.list(all=True, sparse=True, + filters={"label": "managed-by=cased"})) + return {n.lstrip("/"): c for c in listed for n in c.attrs.get("Names") or ()} def container_up(cid): @@ -153,13 +173,13 @@ def destroy_infra(cid, volume): """Best effort, never raises. Already gone is fine; anything else leaves a container or a volume behind, so say which.""" try: - dc().containers.get(container_name(cid)).remove(force=True) + get_container(cid).remove(force=True) except docker.errors.NotFound: pass except Exception as e: log.warning("destroy %s: container not removed (%s)", cid, e) try: - dc().volumes.get(volume).remove() + _docker(lambda c: c.volumes.get(volume)).remove() except docker.errors.NotFound: pass except Exception as e: @@ -167,4 +187,4 @@ def destroy_infra(cid, volume): def create_volume(volume): - dc().volumes.create(name=volume, labels={"managed-by": "cased"}) + _docker(lambda c: c.volumes.create(name=volume, labels={"managed-by": "cased"})) diff --git a/control-plane/lifecycle.py b/control-plane/lifecycle.py index 8269abc..0bccfd1 100644 --- a/control-plane/lifecycle.py +++ b/control-plane/lifecycle.py @@ -328,17 +328,18 @@ def reconcile(): still repaired.""" with _GUARD: rows = [r for r in store.all_non_deleted() if r["id"] not in _IN_FLIGHT] + try: + containers = dockerd.managed_containers() + except Exception as e: + log.warning("reconcile skipped (docker unavailable): %s", e) + return for row in rows: cid, current = row["id"], row["state"] - try: - container = dockerd.get_container(cid) - except dockerd.NotFound: + container = containers.get(dockerd.container_name(cid)) + if container is None: log.warning("container for %s missing; will recreate from volume on wake", cid) _force_state(cid, current, "asleep", expect=current) continue - except Exception as e: - log.warning("reconcile skipped (docker unavailable): %s", e) - return if container.status == "running": try: desk_port, vnc_port = dockerd.container_ports(container) diff --git a/tests/test_dockerd.py b/tests/test_dockerd.py index eb20c62..8b0eaac 100644 --- a/tests/test_dockerd.py +++ b/tests/test_dockerd.py @@ -137,9 +137,7 @@ class Api: def remove_container(self, name, force=False): calls["removed"] = (name, force) - # dc() pings the cached client; without ping() it reconnects to real Docker. - fake = type("DC", (), {"containers": Containers(), "api": Api(), - "ping": lambda self: True})() + fake = type("DC", (), {"containers": Containers(), "api": Api()})() old = dockerd._dc dockerd._dc = fake try: @@ -166,8 +164,7 @@ def get(self, name): old = dockerd._dc try: for vols, warned in ((Gone(), False), (Down(), True)): - dockerd._dc = type("DC", (), {"containers": Gone(), "volumes": vols, - "ping": lambda self: True})() + dockerd._dc = type("DC", (), {"containers": Gone(), "volumes": vols})() with mock.patch.object(dockerd.log, "warning") as warn: dockerd.destroy_infra("c_ab", "case-c_ab") assert warn.called is warned, (vols, warn.call_args_list) @@ -181,7 +178,7 @@ def test_new_volumes_carry_the_cased_label(): from unittest import mock vols = mock.Mock() old = dockerd._dc - dockerd._dc = type("DC", (), {"volumes": vols, "ping": lambda self: True})() + dockerd._dc = type("DC", (), {"volumes": vols})() try: dockerd.create_volume("case-c_ab") finally: @@ -189,6 +186,47 @@ def test_new_volumes_carry_the_cased_label(): vols.create.assert_called_once_with(name="case-c_ab", labels={"managed-by": "cased"}) +def test_cached_client_reconnects_only_when_the_daemon_went_away(): + # No ping round trip before every call; a dead socket is what triggers a new client. + import requests + from unittest import mock + + class Dead: + def get(self, name): + raise requests.ConnectionError("socket gone") + + class Live: + def get(self, name): + return name + + old = dockerd._dc + dockerd._dc = type("DC", (), {"containers": Live()})() + try: + with mock.patch.object(dockerd, "_connect") as connect: + assert dockerd.get_container("c_ab") == "case-c_ab" + connect.assert_not_called() + dockerd._dc = type("DC", (), {"containers": Dead()})() + connect.return_value = type("DC", (), {"containers": Live()})() + assert dockerd.get_container("c_ab") == "case-c_ab" + connect.assert_called_once() + finally: + dockerd._dc = old + + +def test_managed_containers_is_one_listing_keyed_by_name(): + from unittest import mock + box = mock.Mock(attrs={"Names": ["/case-c_ab"], "State": "running"}) + listing = mock.Mock(return_value=[box]) + old = dockerd._dc + dockerd._dc = type("DC", (), {"containers": type("C", (), {"list": listing})()})() + try: + assert dockerd.managed_containers() == {"case-c_ab": box} + finally: + dockerd._dc = old + listing.assert_called_once_with(all=True, sparse=True, + filters={"label": "managed-by=cased"}) + + if __name__ == "__main__": test_host_mode_dials_loopback_and_publishes_ports() test_container_limits_cover_swap_and_pids() @@ -199,4 +237,6 @@ def test_new_volumes_carry_the_cased_label(): test_create_clears_a_name_stuck_in_removal_and_retries_once() test_destroy_names_a_volume_it_could_not_remove() test_new_volumes_carry_the_cased_label() + test_cached_client_reconnects_only_when_the_daemon_went_away() + test_managed_containers_is_one_listing_keyed_by_name() print("test_dockerd: ok") diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 13d150d..5a4c580 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -265,6 +265,8 @@ def stop(c, timeout=10): with mock.patch.object(dockerd, "start_container", lambda c: st.update(container="running")), \ mock.patch.object(dockerd, "get_container", lambda c: _Box(st)), \ + mock.patch.object(dockerd, "managed_containers", + lambda: {dockerd.container_name(cid): _Box(st)}), \ mock.patch.object(dockerd, "container_ports", lambda c, deadline=10: (1, 2)), \ mock.patch.object(dockerd, "container_up", lambda c: st["container"] == "running"), \ mock.patch.object(dockerd, "stop_container", stop), \ @@ -389,7 +391,7 @@ def test_reconcile_still_repairs_a_stale_waking_row(): _asleep_row(cid) store.set_state(cid, "waking") try: - with mock.patch.object(dockerd, "get_container", side_effect=dockerd.NotFound("x")): + with mock.patch.object(dockerd, "managed_containers", return_value={}): lifecycle.reconcile() assert store.get_computer(cid)["state"] == "asleep" finally: @@ -404,7 +406,7 @@ def test_reconcile_during_provision_does_not_lose_the_create(): def volume(v): # the sweeper ticks before the container exists - with mock.patch.object(dockerd, "get_container", side_effect=dockerd.NotFound("x")): + with mock.patch.object(dockerd, "managed_containers", return_value={}): lifecycle.reconcile() with mock.patch.object(dockerd, "create_volume", volume), \ From 0385a1cd6798990dd3c0e73fba4eab0e0325e8d0 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:09:28 +0000 Subject: [PATCH 068/126] Reuse deskd connections with one requests Session per thread. Every deskd call went through requests.request, which builds a Session and a fresh TCP connection each time; a navigate polls /eval every 0.4s. deskclient now keeps a keep-alive Session per thread. Its cookie policy accepts nothing, so like before no call carries state from an earlier one, and desks that share 127.0.0.1 cannot see each other's cookies. --- control-plane/deskclient.py | 19 +++++++++++++++++-- tests/test_dockerd.py | 30 ++++++++++++++++++++++-------- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/control-plane/deskclient.py b/control-plane/deskclient.py index 76152a4..a1ef549 100644 --- a/control-plane/deskclient.py +++ b/control-plane/deskclient.py @@ -8,7 +8,9 @@ run-reports want. """ import base64 +import http.cookiejar import json +import threading import time import requests @@ -18,12 +20,25 @@ from errors import ApiError +_local = threading.local() + + +def _session(): + """One keep-alive Session per thread (a Session is not thread-safe). No cookie + jar: every call used to start clean, and desks share 127.0.0.1.""" + s = getattr(_local, "session", None) + if s is None: + s = _local.session = requests.Session() + s.cookies.set_policy(http.cookiejar.DefaultCookiePolicy(allowed_domains=[])) + return s + + def desk(row, method, path, timeout=35, **kw): """Low-level: returns the raw Response (used when the caller needs the status).""" url = f"{desk_base(row['id'], row['desk_port'])}{path}" headers = {"Authorization": f"Bearer {row['desk_token']}"} try: - return requests.request(method, url, headers=headers, timeout=timeout, **kw) + return _session().request(method, url, headers=headers, timeout=timeout, **kw) except (requests.Timeout, requests.ConnectionError): raise ApiError(504, "daemon_timeout", "deskd did not respond") @@ -193,7 +208,7 @@ def wait_desk(cid, port, token, deadline_s): t0 = time.time() while time.time() - t0 < deadline_s: try: - r = requests.get(url, headers={"Authorization": f"Bearer {token}"}, timeout=3) + r = _session().get(url, headers={"Authorization": f"Bearer {token}"}, timeout=3) if r.ok and r.json().get("ok") and r.json().get("chrome"): return except Exception: diff --git a/tests/test_dockerd.py b/tests/test_dockerd.py index 8b0eaac..b90ce21 100644 --- a/tests/test_dockerd.py +++ b/tests/test_dockerd.py @@ -66,15 +66,16 @@ class R: def json(self): return {"ok": True} - orig = deskclient.requests.request - deskclient.requests.request = lambda method, url, headers=None, timeout=None, **kw: ( - seen.update(url=url, headers=headers) or R()) + session = type("S", (), {"request": lambda self, method, url, headers=None, timeout=None, + **kw: seen.update(url=url, headers=headers) or R()})() + orig = deskclient._session + deskclient._session = lambda: session try: deskclient.desk(row, "GET", "/health") assert seen["url"] == "http://127.0.0.1:32771/health" assert seen["headers"]["Authorization"] == "Bearer tok" finally: - deskclient.requests.request = orig + deskclient._session = orig def test_deskclient_url_follows_the_network(): @@ -87,14 +88,14 @@ class R: def json(self): return {} - def fake_request(method, url, headers=None, timeout=None, **kw): + def fake_request(self, method, url, headers=None, timeout=None, **kw): seen["url"] = url seen["headers"] = headers return R() import deskclient - orig = deskclient.requests.request - deskclient.requests.request = fake_request + orig = deskclient._session + deskclient._session = lambda: type("S", (), {"request": fake_request})() _net("case") try: deskclient.desk({"id": "c_ab", "desk_port": 8000, "desk_token": "secret"}, @@ -102,10 +103,22 @@ def fake_request(method, url, headers=None, timeout=None, **kw): assert seen["url"] == "http://case-c_ab:8000/health" assert seen["headers"]["Authorization"] == "Bearer secret" finally: - deskclient.requests.request = orig + deskclient._session = orig _net(None) +def test_deskclient_reuses_one_session_per_thread(): + import threading + import deskclient + here = deskclient._session() + assert deskclient._session() is here + other = [] + t = threading.Thread(target=lambda: other.append(deskclient._session())) + t.start() + t.join() + assert other[0] is not here + + def test_vnc_url_hidden_when_computers_are_on_the_compose_network(): from lifecycle import _vnc_url row = {"state": "running", "vnc_port": 6080} @@ -233,6 +246,7 @@ def test_managed_containers_is_one_listing_keyed_by_name(): test_compose_mode_uses_container_dns_and_no_host_ports() test_deskclient_accepts_sqlite_row() test_deskclient_url_follows_the_network() + test_deskclient_reuses_one_session_per_thread() test_vnc_url_hidden_when_computers_are_on_the_compose_network() test_create_clears_a_name_stuck_in_removal_and_retries_once() test_destroy_names_a_volume_it_could_not_remove() From 0a74ea59bbbba4f6d0b3bb7e13dbf02da23c5447 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:10:12 +0000 Subject: [PATCH 069/126] Index runs by schedule and count computers in SQL for /health. A schedule's run list scanned and sorted the whole runs table, which keeps up to 1000 rows; an index on (schedule_id, started_at) serves it directly. /health loaded every computer row only to take len() of it, and now asks sqlite for the count. --- control-plane/cased.py | 2 +- control-plane/store.py | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/control-plane/cased.py b/control-plane/cased.py index 90c8501..ae2cd25 100644 --- a/control-plane/cased.py +++ b/control-plane/cased.py @@ -359,7 +359,7 @@ def health(request: Request): # learns liveness only — the inventory is for whoever holds the bearer. if not bearer_ok(request.headers.get("authorization")): return {"ok": True} - n = len(store.list_computers()) + n = store.computer_count() try: dockerd.ping() docker_ok = True diff --git a/control-plane/store.py b/control-plane/store.py index d005856..b1a297b 100644 --- a/control-plane/store.py +++ b/control-plane/store.py @@ -74,6 +74,7 @@ started_at TEXT, ended_at TEXT, exit_code INTEGER, summary TEXT, artifact_path TEXT, status TEXT ); +CREATE INDEX IF NOT EXISTS idx_runs_schedule_started ON runs(schedule_id, started_at); CREATE TABLE IF NOT EXISTS links ( token TEXT PRIMARY KEY, computer_id TEXT, kind TEXT, created_at TEXT, expires_at TEXT, used_at TEXT @@ -205,6 +206,9 @@ def list_computers(self): def all_non_deleted(self): return self.all("SELECT * FROM computers WHERE state != 'deleted'") + def computer_count(self): + return self.one("SELECT COUNT(*) c FROM computers WHERE state != 'deleted'")["c"] + def running_rows(self): return self.all("SELECT * FROM computers WHERE state='running'") From 0bd72ae7de279f92957ed4bf435e6c091c93a23c Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:10:27 +0000 Subject: [PATCH 070/126] Stop desktops in parallel when cased shuts down. sleep_all stopped awake desktops one at a time, and each stop can take its full 10 seconds, so a box with many awake computers could reach the end of the stop grace period and be killed with some still running. Stops now run up to eight at a time; the logging and the returned list are unchanged. --- control-plane/lifecycle.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/control-plane/lifecycle.py b/control-plane/lifecycle.py index 0bccfd1..9023aaf 100644 --- a/control-plane/lifecycle.py +++ b/control-plane/lifecycle.py @@ -12,6 +12,7 @@ import secrets import threading import time +from concurrent.futures import ThreadPoolExecutor from contextlib import contextmanager from config import DESK_H, DESK_W, IMAGE, MAX_RAM_MB, MAX_RUNNING, log @@ -242,15 +243,19 @@ def sleep_all(): otherwise leave them running with a DB that still says "running". Data is safe either way (start.sh traps SIGTERM and quiesces Chromium); this just makes the graceful path the default one.""" - slept = [] - for row in store.list_computers(): - if row["state"] not in ("running", "waking"): - continue + def one(cid): try: - do_sleep(row["id"]) - slept.append(row["id"]) + do_sleep(cid) + return cid except Exception as e: # a login mid-flight, a dead container, a race - log.warning("shutdown: %s did not sleep cleanly (%s)", row["id"], e) + log.warning("shutdown: %s did not sleep cleanly (%s)", cid, e) + + awake = [r["id"] for r in store.list_computers() if r["state"] in ("running", "waking")] + slept = [] + if awake: + # each stop can take its full 10s; in series a full box can outrun the stop grace + with ThreadPoolExecutor(max_workers=min(len(awake), 8)) as pool: + slept = [cid for cid in pool.map(one, awake) if cid] if slept: log.info("shutdown: slept %d computer(s): %s", len(slept), ", ".join(slept)) return slept From c0a74507417cdd87b0bee758963bdec5b50529e7 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:10:49 +0000 Subject: [PATCH 071/126] Drop the index statements _migrate repeated from SCHEMA. SCHEMA already creates every index with IF NOT EXISTS on each start, and it runs before _migrate, so the second copy did nothing; an old case.db gets its indexes from SCHEMA since they only use columns those tables always had. One definition means they can't drift apart. --- control-plane/store.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/control-plane/store.py b/control-plane/store.py index b1a297b..d29b246 100644 --- a/control-plane/store.py +++ b/control-plane/store.py @@ -145,21 +145,6 @@ def _migrate(self): cols = [r["name"] for r in self.db.execute(f"PRAGMA table_info({table})")] if col not in cols: self.db.execute(f"ALTER TABLE {table} ADD COLUMN {col} {typ}") - # Indexes are also in SCHEMA; re-assert for DBs created before they existed. - self.db.execute( - "CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_attempts_idempotency " - "ON auth_attempts(computer_id, idempotency_key) " - "WHERE idempotency_key IS NOT NULL") - self.db.execute( - "CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_attempts_one_active " - "ON auth_attempts(computer_id) " - "WHERE status IN ('created','advancing','awaiting_human','proving')") - self.db.execute( - "CREATE INDEX IF NOT EXISTS idx_handoffs_computer_status " - "ON handoffs(computer_id, status)") - self.db.execute( - "CREATE INDEX IF NOT EXISTS idx_handoffs_status_created " - "ON handoffs(status, created_at)") # Pre-migration handoff rows: treat missing revision as 0. self.db.execute("UPDATE handoffs SET revision=0 WHERE revision IS NULL") self.db.commit() From 9dec92c1a7ba0e57475f921a105974e08a751bb3 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:11:01 +0000 Subject: [PATCH 072/126] Name audit files by the UTC day, like their lines and the prune. The audit file took its date from local time while each line's ts and prune_old_audit_files work in UTC, so on a box far from UTC a line could sit in the neighbouring day's file and the prune measured those names against a different clock. The file is now named from the line's own UTC timestamp. --- control-plane/cased.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/control-plane/cased.py b/control-plane/cased.py index ae2cd25..3c06347 100644 --- a/control-plane/cased.py +++ b/control-plane/cased.py @@ -200,7 +200,7 @@ async def audit_mw(request: Request, call_next): def _audit_append(line): os.makedirs(AUDIT_DIR, mode=0o700, exist_ok=True) - p = os.path.join(AUDIT_DIR, time.strftime("%Y-%m-%d") + ".jsonl") + p = os.path.join(AUDIT_DIR, line["ts"][:10] + ".jsonl") # UTC, like the prune with os.fdopen(os.open(p, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600), "a") as f: f.write(json.dumps(line) + "\n") From a2ef4fd6da9fb35fa7dab11faea576355a70964d Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:11:37 +0000 Subject: [PATCH 073/126] Bound SSE subscriber queues and let emit outlive the event loop. Each subscriber queue was unbounded, so a client that stopped reading kept every event in memory for as long as its socket stayed open. The queues now hold 256 events; a subscriber that fills one is dropped and its stream ends at the next heartbeat, so the client reconnects. emit from a worker thread after shutdown closed the loop raised RuntimeError into that thread; it is now a no-op. --- control-plane/events.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/control-plane/events.py b/control-plane/events.py index 8ee14ea..45aadae 100644 --- a/control-plane/events.py +++ b/control-plane/events.py @@ -11,6 +11,7 @@ LOOP = None # main asyncio loop, set at startup SUBS = [] # live subscriber queues +QUEUE_MAX = 256 # a subscriber this far behind has stalled; it is dropped, not buffered def set_loop(loop): @@ -21,12 +22,22 @@ def set_loop(loop): def emit(type_, data): if LOOP: for q in list(SUBS): - LOOP.call_soon_threadsafe(q.put_nowait, (type_, data)) + try: + LOOP.call_soon_threadsafe(_offer, q, (type_, data)) + except RuntimeError: # loop closed (shutdown): nobody is listening + return + + +def _offer(q, item): + try: + q.put_nowait(item) + except asyncio.QueueFull: + unsubscribe(q) # sse_gen hangs up on it at its next heartbeat def subscribe(): """Register a subscriber queue. Caller must unsubscribe() in finally.""" - q = asyncio.Queue() + q = asyncio.Queue(maxsize=QUEUE_MAX) SUBS.append(q) return q @@ -46,6 +57,8 @@ async def sse_gen(computer_id=None): try: type_, data = await asyncio.wait_for(q.get(), timeout=15) except asyncio.TimeoutError: + if q not in SUBS: + return # dropped as stalled; the client reconnects yield ": hb\n\n" continue if computer_id and data.get("computer_id") != computer_id: From e0d4a36280ff62fde9fe4d5a723bc6041805ae9a Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:12:06 +0000 Subject: [PATCH 074/126] Start the stock schedule brain where case-mcp.json's paths resolve. case-mcp.json runs `python3 mcp/case_mcp.py`: a path relative to the repo root and whichever python3 is first on PATH. The scheduler ran claude from cased's own cwd, and bin/case starts cased with the venv interpreter without putting the venv on PATH, so outside the repo root or without the deps installed system-wide the brain came up without its tools. The stock brain now runs from the config's directory with cased's interpreter first on PATH. A CASE_BRAIN_CMD template keeps its env and cwd as before, and compose's HTTP brain is untouched. --- control-plane/scheduler.py | 17 ++++++++++++----- tests/test_scheduler.py | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/control-plane/scheduler.py b/control-plane/scheduler.py index beb39b3..5420917 100644 --- a/control-plane/scheduler.py +++ b/control-plane/scheduler.py @@ -14,6 +14,7 @@ import shlex import shutil import subprocess +import sys import threading import time from datetime import datetime, timedelta, timezone @@ -152,13 +153,19 @@ def run_brain(cid, prompt, name=""): return 127, f"mcp config not found at {MCP_CONFIG} — set CASE_MCP_CONFIG" # BYOK: stock path forces the caller's logged-in/subscription auth by blanking the # key, UNLESS the operator explicitly set one (their key = their cost, still BYOK). - # Template path owns its env untouched. - env = os.environ - if not BRAIN_CMD and not os.environ.get("ANTHROPIC_API_KEY"): - env = {**os.environ, "ANTHROPIC_API_KEY": ""} + # Template path owns its env and cwd untouched. + env, cwd = os.environ, None + if not BRAIN_CMD: + # case-mcp.json says `python3 mcp/case_mcp.py`: run from the config's directory + # (the repo root) with this interpreter, which has the deps, first on PATH + env = {**os.environ, "PATH": os.pathsep.join( + [os.path.dirname(sys.executable), os.environ.get("PATH", "")])} + cwd = os.path.dirname(os.path.abspath(MCP_CONFIG)) + if not os.environ.get("ANTHROPIC_API_KEY"): + env["ANTHROPIC_API_KEY"] = "" try: p = subprocess.run(argv, capture_output=True, text=True, - timeout=BRAIN_TIMEOUT, env=env) + timeout=BRAIN_TIMEOUT, env=env, cwd=cwd) return p.returncode, (p.stdout or p.stderr or "").strip()[-800:] except subprocess.TimeoutExpired: return -1, f"brain run timed out ({BRAIN_TIMEOUT}s)" diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 62ab5b4..5456f59 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -246,6 +246,31 @@ def test_run_brain_malformed_template_is_clean_127(): scheduler.BRAIN_CMD = old +def test_stock_brain_resolves_case_mcp_json_from_anywhere(): + # case-mcp.json runs `python3 mcp/case_mcp.py`, relative, with whatever python3 is + # on PATH: from any other cwd, or with a system python lacking the deps, the + # brain came up without its tools. + import tempfile + import scheduler + fake = os.path.join(tempfile.mkdtemp(), "claude") + with open(fake, "w") as f: + f.write("#!/bin/sh\npwd\ncommand -v python3\n") + os.chmod(fake, 0o755) + old = (scheduler.BRAIN_BIN, scheduler.BRAIN_CMD, scheduler.BRAIN_URL) + here = os.getcwd() + try: + scheduler.BRAIN_BIN, scheduler.BRAIN_CMD, scheduler.BRAIN_URL = fake, "", "" + os.chdir("/") + code, out = scheduler.run_brain("c_1", "hi") + finally: + os.chdir(here) + scheduler.BRAIN_BIN, scheduler.BRAIN_CMD, scheduler.BRAIN_URL = old + cwd, py = out.splitlines() + assert code == 0, out + assert os.path.exists(os.path.join(cwd, "mcp", "case_mcp.py")), cwd + assert os.path.dirname(py) == os.path.dirname(sys.executable), py + + def test_busy_box_is_a_skip_not_a_raw_apierror(): # a box at CASE_MAX_RUNNING=1: a busy box must report a plain-English skip # (never "ApiError: …") and must never sleep someone else's live session. From ad5478cab2dcb155b6859de5f473ffd7249d0ac2 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:18:36 +0000 Subject: [PATCH 075/126] Pass the solver proxy, brain timeout and session keeper settings through compose, and document them. cased reads CASE_DBC_PROXY, CASE_DBC_PROXYTYPE, CASE_BRAIN_TIMEOUT and the two session keeper intervals, but compose never passed them, so setting them in .env did nothing. .env.example now lists them with DESK_DEBUG, and the README names the settings that only a host install reads, the ~/.case/env file bin/case sources, and the host default of eight awake computers. --- .env.example | 15 ++++++++++++++- README.md | 14 +++++++++++--- compose.yaml | 5 +++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 19caf1a..4f0156f 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,9 @@ CASE_MAX_RAM_MB= # Desktop resolution for new/woken computers (WxH or WxHxDEPTH). # DESK_RESOLUTION=1280x800x24 +# 1 = desktops mirror their Chromium and websockify logs to docker logs. +# DESK_DEBUG=1 + # Browser Drive still takes the key per request (x-openai-key / x-anthropic-key). # The box key below is used by phone chat and schedules. # CASE_DRIVE_PROVIDER=openai @@ -42,6 +45,13 @@ CASE_MAX_RAM_MB= # on PATH. # CASE_BRAIN_URL= # CASE_BRAIN_CMD= +# Seconds a scheduled run may take before it is stopped. Default 1800. +# CASE_BRAIN_TIMEOUT=1800 + +# Session keeper: how often saved logins with a probe_url are checked, and how +# recently a computer must have been used for the check to skip it. Seconds. +# CASE_SESSION_KEEPER_INTERVAL_S=21600 +# CASE_SESSION_KEEPER_BUSY_S=900 # Phone chat over Telegram: make a bot with @BotFather, paste its token, start # the ui, send /start to the bot and it replies with the chat id to put here. @@ -62,7 +72,7 @@ CASE_MAX_RAM_MB= # unset on a laptop; those links and buttons are omitted. # CASE_PUBLIC_HOST= -# Extra hostnames Drive and cased will answer to (comma-separated). Only needed +# Extra hostnames Drive, cased and MCP will answer to (comma-separated). Only needed # when something fronts them under another name; any other Host gets a 403. # CASE_PUBLIC_HOST is allowed without being repeated here. # CASE_ALLOWED_HOSTS= @@ -76,6 +86,9 @@ CASE_MAX_RAM_MB= # CASE_DBC_AUTHTOKEN= # CASE_DBC_USERNAME= # CASE_DBC_PASSWORD= +# Proxy the solver should use to load the page (host:port), and its type. +# CASE_DBC_PROXY= +# CASE_DBC_PROXYTYPE=HTTP # Anonymous usage stats: counts of computers and schedules, and whether # scheduled runs succeed. Never a name, prompt, domain or credential. Either diff --git a/README.md b/README.md index 9e6a878..0fd5912 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ Choose a computer's size under **+ New computer**, then **SIZE**. The default is | Setting | What it controls | Compose default | | --- | --- | --- | -| `CASE_MAX_RUNNING` | Maximum number of awake computers | 4 | +| `CASE_MAX_RUNNING` | Maximum number of awake computers | 4 (8 without Compose) | | `CASE_MAX_RAM_MB` | Total RAM that awake computers may reserve, in MB | 75% of the memory visible to cased | On macOS with Compose, that memory comes from the Docker VM. A 4 GB VM has room @@ -333,8 +333,16 @@ CASE_URL=http://127.0.0.1:8787 node web/web-ui/serve.mjs Open the [computers page](http://127.0.0.1:4174/deploy). Drive runs in the foreground in this terminal. Host processes use environment variables, not the -Compose `.env` file. If cased runs directly on macOS, set `CASE_MAX_RAM_MB` -explicitly; its automatic memory budget requires Linux's `/proc/meminfo`. +Compose `.env` file; `bin/case` also reads `~/.case/env` if it exists. If cased +runs directly on macOS, set `CASE_MAX_RAM_MB` explicitly; its automatic memory +budget requires Linux's `/proc/meminfo`. + +A few settings only apply here. `CASE_PORT` moves cased off 8787. +`CASE_VNC_PORT` pins every desktop's noVNC to one host port for a reverse +proxy. `CASE_BRAIN_BIN` +points the scheduler at a `claude` binary that is not on `PATH`, and +`CASE_MCP_CONFIG` replaces `case-mcp.json`. On macOS, `CASE_CPU` and `CASE_MEM` +size the Colima VM that `bin/case up` starts (default 4 CPUs, 4 GB). For a client that uses stdio MCP, [case-mcp.json](case-mcp.json) starts `mcp/case_mcp.py` with Python. It needs the installed Python dependencies and diff --git a/compose.yaml b/compose.yaml index 7059b29..6c5fe20 100644 --- a/compose.yaml +++ b/compose.yaml @@ -43,6 +43,9 @@ services: CASE_TOKEN: ${CASE_TOKEN:-} CASE_BRAIN_URL: ${CASE_BRAIN_URL:-http://ui:4174/api/brain} CASE_BRAIN_CMD: ${CASE_BRAIN_CMD:-} + CASE_BRAIN_TIMEOUT: ${CASE_BRAIN_TIMEOUT:-} + CASE_SESSION_KEEPER_INTERVAL_S: ${CASE_SESSION_KEEPER_INTERVAL_S:-} + CASE_SESSION_KEEPER_BUSY_S: ${CASE_SESSION_KEEPER_BUSY_S:-} CASE_MAX_RUNNING: ${CASE_MAX_RUNNING:-4} # Total RAM the awake desktops may hold. Unset = 75% of what the engine sees, # which on a Mac is the Docker VM, not the Mac. @@ -62,6 +65,8 @@ services: CASE_DBC_AUTHTOKEN: ${CASE_DBC_AUTHTOKEN:-} CASE_DBC_USERNAME: ${CASE_DBC_USERNAME:-} CASE_DBC_PASSWORD: ${CASE_DBC_PASSWORD:-} + CASE_DBC_PROXY: ${CASE_DBC_PROXY:-} + CASE_DBC_PROXYTYPE: ${CASE_DBC_PROXYTYPE:-} # Usage stats opt-out (see .env.example) CASE_TELEMETRY: ${CASE_TELEMETRY:-} DO_NOT_TRACK: ${DO_NOT_TRACK:-} From 233ee387011d68fa5ced3d4cc620ed823d88128e Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:18:58 +0000 Subject: [PATCH 076/126] Give the Drive pages an SPDX header and check them in CI. index.html and deploy.html carry most of Drive's browser code inline, but the SPDX job only looked at .mjs files. --- .github/workflows/ci.yml | 2 +- web/web-ui/deploy.html | 1 + web/web-ui/index.html | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b1b257..af1eb0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,7 +113,7 @@ jobs: # The list is built first so a git failure fails the step instead of an empty loop. files=$(git ls-files \ 'control-plane/*.py' 'image/*.py' 'image/*.sh' \ - 'mcp/*.py' 'tests/*.py' 'web/*.mjs' 'bin/case') + 'mcp/*.py' 'tests/*.py' 'web/*.mjs' 'web/*.html' 'bin/case') missing=0 for f in $files; do grep -q 'SPDX-License-Identifier' "$f" || { echo "no SPDX header: $f"; missing=1; } diff --git a/web/web-ui/deploy.html b/web/web-ui/deploy.html index 6e9fec0..4c1ac5f 100644 --- a/web/web-ui/deploy.html +++ b/web/web-ui/deploy.html @@ -1,4 +1,5 @@ + diff --git a/web/web-ui/index.html b/web/web-ui/index.html index ea7dbd5..09366ce 100644 --- a/web/web-ui/index.html +++ b/web/web-ui/index.html @@ -1,4 +1,5 @@ + From c3b64e78e150e3e524093527ccb6e560664403b2 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:19:56 +0000 Subject: [PATCH 077/126] Keep deskd's pins in one file and test deskd against them in CI. The Dockerfile listed deskd's pins inline, while CI tested deskd under Python 3.12 and the control plane's fastapi and starlette. The pins now live in image/requirements.txt, which the image installs and a CI job tests on Python 3.11, the image's version. The audit covers them too. --- .github/workflows/audit.yml | 3 ++- .github/workflows/ci.yml | 18 ++++++++++++++++++ image/Dockerfile | 11 +++-------- image/requirements.txt | 21 +++++++++++++++++++++ 4 files changed, 44 insertions(+), 9 deletions(-) create mode 100644 image/requirements.txt diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index cc287a6..ba548c2 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -11,6 +11,7 @@ on: pull_request: paths: - requirements*.txt + - image/requirements.txt - web/package*.json - .github/workflows/audit.yml @@ -34,7 +35,7 @@ jobs: run: | python -m venv .venv .venv/bin/pip install pip-audit==2.10.1 - .venv/bin/pip-audit -r requirements-dev.txt + .venv/bin/pip-audit -r requirements-dev.txt -r image/requirements.txt - name: Drive run: npm --prefix web audit --omit=dev diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af1eb0a..c44ca8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,24 @@ jobs: - name: Compose file run: docker compose config -q + # deskd runs on Debian bookworm's Python in the image, against its own pins, not + # the control plane's. Test it there too. + deskd: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.11" + cache: pip + cache-dependency-path: image/requirements.txt + - name: deskd tests on the image's pins + run: | + python -m venv .venv + .venv/bin/pip install -r image/requirements.txt httpx==0.28.1 + .venv/bin/python tests/test_deskd.py + # Case is dual-licensed and sold commercially, which is only possible if the # project can sublicense every contribution. Deliberately no third-party CLA # bot: the one everyone used was archived in March 2026, and a licensing gate diff --git a/image/Dockerfile b/image/Dockerfile index d0480de..b2f980d 100644 --- a/image/Dockerfile +++ b/image/Dockerfile @@ -41,16 +41,11 @@ RUN curl -fsSL -o /tmp/novnc.tgz "https://github.com/novnc/noVNC/archive/refs/ta && echo "${NOVNC_SHA256} /tmp/novnc.tgz" | sha256sum -c - \ && tar xz -f /tmp/novnc.tgz -C /usr/share && mv "/usr/share/noVNC-${NOVNC_VERSION}" /usr/share/novnc && rm /tmp/novnc.tgz -# Transitives pinned alongside the four direct deps: the image is the computer, -# and a rebuild months from now must produce the deskd that was tested. +# Pinned with its transitives in requirements.txt, which CI tests deskd against. +COPY requirements.txt /tmp/deskd-requirements.txt RUN python3 -m venv /opt/deskd \ && /opt/deskd/bin/pip install --no-cache-dir --upgrade pip==26.2 setuptools==83.0.0 \ - && /opt/deskd/bin/pip install --no-cache-dir \ - fastapi==0.141.1 uvicorn==0.52.4 websocket-client==1.9.2 requests==2.34.2 \ - annotated-doc==0.0.5 annotated-types==0.8.0 anyio==4.15.0 certifi==2026.7.22 \ - charset-normalizer==3.5.1 click==8.5.0 h11==0.16.0 idna==3.19 pydantic==2.13.5 \ - pydantic-core==2.46.5 starlette==1.6.0 typing-extensions==4.16.0 \ - typing-inspection==0.4.4 urllib3==2.7.0 \ + && /opt/deskd/bin/pip install --no-cache-dir -r /tmp/deskd-requirements.txt \ && /opt/deskd/bin/pip install --no-cache-dir --no-deps websockify==0.13.0 # Passwordless sudo: the container IS the agent's sandbox — self-serve apt diff --git a/image/requirements.txt b/image/requirements.txt new file mode 100644 index 0000000..5094678 --- /dev/null +++ b/image/requirements.txt @@ -0,0 +1,21 @@ +# deskd's venv, transitives included: the image is the computer, and a rebuild +# months from now must produce the deskd that was tested. websockify is installed +# on its own with --no-deps (see Dockerfile). +fastapi==0.141.1 +uvicorn==0.52.4 +websocket-client==1.9.2 +requests==2.34.2 +annotated-doc==0.0.5 +annotated-types==0.8.0 +anyio==4.15.0 +certifi==2026.7.22 +charset-normalizer==3.5.1 +click==8.5.0 +h11==0.16.0 +idna==3.19 +pydantic==2.13.5 +pydantic-core==2.46.5 +starlette==1.6.0 +typing-extensions==4.16.0 +typing-inspection==0.4.4 +urllib3==2.7.0 From 7c297c80cfb45c4e72ea8e3d8661639c6b1cf096 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:57:46 +0000 Subject: [PATCH 078/126] Accept only approve or deny as an approval answer. An approval handoff treated every value except "deny" as approval, so a typed "no", a stray code or an empty ntfy body approved it, and deskd did the same. Approvals now take "approve" or "deny" and anything else is a 400 that leaves the handoff pending. The Assist page shows Approve and Deny buttons for approvals instead of a code box, and the public /answer door returns the public handoff shape instead of the raw row with its screenshot and credential name. --- control-plane/assist.py | 9 +++++++++ control-plane/cased.py | 8 ++++++-- control-plane/handoffs.py | 10 +++++++++- image/deskd.py | 5 ++++- tests/test_assist.py | 21 ++++++++++++++++++++ tests/test_deskd.py | 14 +++++++++++++ tests/test_handoffs.py | 41 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 104 insertions(+), 4 deletions(-) diff --git a/control-plane/assist.py b/control-plane/assist.py index ef07600..b52229c 100644 --- a/control-plane/assist.py +++ b/control-plane/assist.py @@ -483,6 +483,15 @@ def render_page(view, token): f"

This page updates automatically.

" return _shell("Verifying", body, view, marg="16vh auto", align="center", poll=True) + if cont == "submit_value" and view["kind"] == "approval": + body = f"

Approve?

{prompt}

" + "".join( + f'
' + f'' + f'' + f"
" + for value, label in (("approve", "Approve"), ("deny", "Deny"))) + return _shell("Approve", body, view, poll=True) + if cont == "submit_value": body = ( f"

Enter the code

{prompt}

" diff --git a/control-plane/cased.py b/control-plane/cased.py index 3c06347..c7b8b07 100644 --- a/control-plane/cased.py +++ b/control-plane/cased.py @@ -797,7 +797,10 @@ async def assist_submit(token: str, request: Request): return HTMLResponse(assist.GONE_HTML, status_code=410) raise st = row["status"] - if st in ("completed", "answered"): + if view["kind"] == "approval" and st != "pending": + title = "Approved ✓" if value.lower() == "approve" else "Denied" + body = "Your answer was sent. You can close this page." + elif st in ("completed", "answered"): title, body = "Submitted ✓", "The code was accepted. You can close this page." elif st == "failed": title, body = "Failed", "This challenge could not be completed. Ask for a new link." @@ -1045,7 +1048,8 @@ def answer_handoff_ep(hid: str, body: dict = Body(...)): def answer_public(hid: str, token: str, body: dict = Body(...)): """ntfy's Approve/Deny buttons. The signed token in the URL is the whole auth — a phone has no bearer, and the notification is the only place it leaks to.""" - return handoffs.answer_by_token(hid, token, body.get("value")) + return handoffs.handoff_json(handoffs.answer_by_token(hid, token, body.get("value")), + with_screenshot=False) # ---------- schedules ---------- diff --git a/control-plane/handoffs.py b/control-plane/handoffs.py index 1be3c1f..58e230b 100644 --- a/control-plane/handoffs.py +++ b/control-plane/handoffs.py @@ -54,6 +54,10 @@ def _public_host(): VERIFY_DONE_VALUES = frozenset({"done", "approve", "i'm done", "im done", "i am done"}) +# The only answers an approval takes. ntfy and Telegram buttons and the Assist page +# send these; anything else (a typed "no", an empty body) is refused, not approved. +APPROVAL_VALUES = ("approve", "deny") + TERMINAL_STATUSES = frozenset({"completed", "answered", "failed", "expired"}) @@ -99,7 +103,7 @@ def _durable_answer(kind, continuation, value): # Approval markers first, "approve" is also a verify_page synonym, but an # approval handoff must persist approve/deny, not collapse to "done". if kind == "approval": - return low if low in ("approve", "deny") else None + return low if low in APPROVAL_VALUES else None if continuation == "verify_page" or low in VERIFY_DONE_VALUES: return "done" if low in ("approve", "deny"): @@ -357,6 +361,10 @@ def submit_handoff_value(hid, value): if _continuation_of(row) != "submit_value": raise ApiError(400, "bad_request", f"handoff continuation is {_continuation_of(row)!r}, not submit_value") + if row["kind"] == "approval": + value = str(value).strip().lower() if isinstance(value, str) else "" + if value not in APPROVAL_VALUES: + raise ApiError(400, "bad_request", "approval handoff expects 'approve' or 'deny'") _claim_validating(row, value) row = store.get_handoff(hid) aid = _attempt_id_of(row) diff --git a/image/deskd.py b/image/deskd.py index 9c90032..ac75f69 100644 --- a/image/deskd.py +++ b/image/deskd.py @@ -768,8 +768,11 @@ def apply_challenge_action(tab, kind, value=None): """Generic challenge action: otp/code fill+enter, or approval settle. Returns err str or None.""" k = (kind or "").lower() if k in ("approval", "approve"): - if value is not None and str(value).lower() == "deny": + v = "" if value is None else str(value).lower() + if v == "deny": return "denied by human" + if v != "approve": + return "approval expects 'approve' or 'deny'" time.sleep(8) # human approved out-of-band; let the site catch up settle(tab) return None diff --git a/tests/test_assist.py b/tests/test_assist.py index 1c10f9a..34d903b 100644 --- a/tests/test_assist.py +++ b/tests/test_assist.py @@ -293,6 +293,27 @@ def test_render_phases_submit_verify_wait_and_terminal_attempt(): assert payload_ok["allowed_actions"] == [] +def test_approval_renders_approve_and_deny_buttons(): + _cleanup() + _pending("h_otp", "approval", continuation="submit_value") + html = assist.render_page( + assist.build_view(store.get_handoff("h_otp"), store.get_handoff("h_otp"), None), "tok") + assert "Enter the code" not in html and "inputmode=numeric" not in html + assert "name=value value=approve" in html and "name=value value=deny" in html + assert html.count('action="/assist/tok/submit"') == 2 + + raw, _ = assist.mint_assist_token("h_otp") + session, _ = assist.exchange(raw) + from fastapi.testclient import TestClient + client = TestClient(cased.app, base_url="http://127.0.0.1", raise_server_exceptions=False) + response = client.post( + f"/assist/{raw}/submit", data={"value": "approve", "expected_revision": "0"}, + cookies={assist.COOKIE: session}, headers={"Origin": "http://127.0.0.1"}) + assert response.status_code == 200, response.text + assert "Approved" in response.text + assert store.get_handoff("h_otp")["answer"] == "approve" + + def test_attempt_scoped_session_follows_current_handoff(): _cleanup() _attempt("aa_1", status="awaiting_human", current_handoff_id="h_cap") diff --git a/tests/test_deskd.py b/tests/test_deskd.py index 0e2dfe3..5bd47e9 100644 --- a/tests/test_deskd.py +++ b/tests/test_deskd.py @@ -190,6 +190,20 @@ def test_question_challenge_is_never_injected_into_page(): press.assert_not_called() +def test_approval_action_takes_only_approve_or_deny(): + tab = FakeTab() + with mock.patch.object(deskd.time, "sleep") as sleep, \ + mock.patch.object(deskd, "settle") as settle: + for bad in (None, "", "no", "123456"): + assert deskd.apply_challenge_action(tab, "approval", bad) == \ + "approval expects 'approve' or 'deny'", bad + assert deskd.apply_challenge_action(tab, "approval", "deny") == "denied by human" + sleep.assert_not_called() + settle.assert_not_called() + assert deskd.apply_challenge_action(tab, "approval", "Approve") is None + settle.assert_called_once() + + # ---- challenge_signals_from_text: generic tags for durable-auth observations ---- def test_challenge_signals_captcha_otp_approval(): diff --git a/tests/test_handoffs.py b/tests/test_handoffs.py index 3936223..7f55ca7 100644 --- a/tests/test_handoffs.py +++ b/tests/test_handoffs.py @@ -362,6 +362,47 @@ def test_approval_approve_persists_approve_not_done(): _cleanup("h_plain") +def test_approval_takes_only_approve_or_deny(): + # Anything but "deny" used to approve: "no", a stray OTP, an empty ntfy body. + _cleanup("h_plain") + try: + for bad in ("no", "reject", None, {}, "123456", "done"): + _persist("h_plain", "approval", "Ship it?", continuation="submit_value") + try: + handoffs.answer_handoff("h_plain", bad) + assert False, f"expected 400 for {bad!r}" + except ApiError as e: + assert e.status == 400, (bad, e) + row = store.get_handoff("h_plain") + assert row["status"] == "pending" and row["answer"] is None, (bad, dict(row)) + _persist("h_plain", "approval", "Ship it?", continuation="submit_value") + row = handoffs.answer_handoff("h_plain", " Approve ") + assert row["status"] == "completed" and row["answer"] == "approve", dict(row) + finally: + _cleanup("h_plain") + + +def test_public_answer_door_returns_the_public_shape(): + import cased + from fastapi.testclient import TestClient + _cleanup("h_plain") + try: + store.insert_handoff("h_plain", "c_1", "approval", "Pay $500?", "iVBORw0KGgo=", + "bank", continuation="submit_value") + client = TestClient(cased.app, base_url="http://127.0.0.1", raise_server_exceptions=False) + url = f"/answer/h_plain/{store.sign('answer:h_plain')}" + assert client.post(url, json={}).status_code == 400 + assert store.get_handoff("h_plain")["status"] == "pending" + r = client.post(url, json={"value": "approve"}) + assert r.status_code == 200, r.text + body = r.json() + assert body["status"] == "completed" and body["answer"] == "approve", body + assert "screenshot" not in body and "screenshot_png_b64" not in body, body + assert "login_credential" not in body, body + finally: + _cleanup("h_plain") + + def test_wait_external_rejects_answer_handoff(): _cleanup("h_otp") try: From 5b8ba47e22887855b3f7ba6d9c6f672939270efe Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:58:25 +0000 Subject: [PATCH 079/126] Fail a handoff on the human's deny, not on a page saying denied. Any /login/resume reason containing "denied" (a site's "permission denied", say) terminally failed the handoff and its attempt. Only the deny answer does that now; other failures stay pending for a retry. The ntfy title also used an em dash that the ASCII header encoding always turned into "?", so it is a plain hyphen. --- control-plane/handoffs.py | 7 ++----- control-plane/notify.py | 2 +- tests/test_handoffs.py | 18 ++++++++++++++++++ tests/test_notify.py | 5 +++++ 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/control-plane/handoffs.py b/control-plane/handoffs.py index 58e230b..1952717 100644 --- a/control-plane/handoffs.py +++ b/control-plane/handoffs.py @@ -329,15 +329,13 @@ def _resume_and_finish(hid, ctx, value, *, value_present=True, hrow=None): advance_attempt, the attempt (not LOGIN_CTX) owns credential ok / prove. """ hrow = hrow or store.get_handoff(hid) - status, reason = "failed", None + status = "failed" try: row = get_computer(ctx["computer_id"]) out = desk_json(row, "POST", "/login/resume", json={"value": value}, timeout=90) status = out.get("status", "failed") - reason = out.get("reason") except Exception as e: log.warning("login resume failed: %s", e) - status, reason = "failed", str(e) if status == "success": return _finish_attempt_child( @@ -345,8 +343,7 @@ def _resume_and_finish(hid, ctx, value, *, value_present=True, hrow=None): answer=value if value_present else (hrow["answer"] if hrow else None), value_present=value_present, ctx=ctx) - denied = isinstance(reason, str) and "denied" in reason.lower() - if denied or (isinstance(value, str) and value.lower() == "deny"): + if isinstance(value, str) and value.lower() == "deny": return _fail_attempt_child(hid, hrow, value, ctx=ctx) # Soft fail: challenge still present / bad code / transient, human can retry. diff --git a/control-plane/notify.py b/control-plane/notify.py index 08dc41c..f607d85 100644 --- a/control-plane/notify.py +++ b/control-plane/notify.py @@ -63,7 +63,7 @@ def _send(self, h, computer_name): 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-Title": ascii_(f"[Case] {h['kind']} - {computer_name}"), "X-Tags": ",".join(tags), "X-Message": ascii_(prompt)[:800], } diff --git a/tests/test_handoffs.py b/tests/test_handoffs.py index 7f55ca7..2b8eed3 100644 --- a/tests/test_handoffs.py +++ b/tests/test_handoffs.py @@ -349,6 +349,24 @@ def test_approval_deny_is_terminal_failed(): _cleanup("h_otp") +def test_resume_reason_mentioning_denied_is_a_soft_fail(): + # A page saying "permission denied" is not the human saying deny. + _cleanup("h_otp") + try: + _persist("h_otp", "otp", "enter code", login_credential="chase", + continuation="submit_value") + with mock.patch.object(handoffs, "get_computer", return_value=ROW), \ + mock.patch.object(handoffs, "desk_json", + return_value={"status": "failed", "reason": "Permission denied"}), \ + mock.patch.object(store, "record_credential_result") as rec: + row = handoffs.submit_handoff_value("h_otp", "123456") + assert row["status"] == "pending", dict(row) + assert "h_otp" in handoffs.LOGIN_CTX + rec.assert_not_called() + finally: + _cleanup("h_otp") + + def test_approval_approve_persists_approve_not_done(): """'approve' is also a verify_page synonym — must not collapse approval answers.""" _cleanup("h_plain") diff --git a/tests/test_notify.py b/tests/test_notify.py index 70e008b..27af845 100644 --- a/tests/test_notify.py +++ b/tests/test_notify.py @@ -122,6 +122,11 @@ def test_multiline_prompt_is_flattened_into_the_header(): assert h["X-Message"] == "line one line two line three" +def test_title_is_plain_ascii_not_a_replaced_dash(): + h = _post_once({"id": "h_1", "kind": "otp", "prompt": "code?", "screenshot": None}, "ava") + assert h["X-Title"] == "[Case] otp - ava", h["X-Title"] + + 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"}) From dd248b6aef092de82e97d6fb7b2c19515d828d04 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:59:56 +0000 Subject: [PATCH 080/126] Check a submitted code against the page text only. The TOTP post-check in classify and the /login/resume post-check still matched RE_OTP and RE_FAIL against location.href, so a redirect to ?next=%2Fa%2Fdash or ?flash=invalid read as a rejected code, the same URL trap the rest of deskd already avoids. Both now share code_refused, which reads the page text once; resume no longer re-reads the text and the form fields just to learn that eval still works. --- image/deskd.py | 23 ++++++++++++++--------- tests/test_deskd.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/image/deskd.py b/image/deskd.py index ac75f69..326def6 100644 --- a/image/deskd.py +++ b/image/deskd.py @@ -702,8 +702,7 @@ def classify(tab, cred): fill(tab, FOCUS_CODE, totp(cred["totp_seed"])) press_enter(tab) settle(tab) - after = (tab.js(PAGE_TEXT) or "") + " " + (tab.js("location.href") or "") - if RE_FAIL.search(after) or RE_OTP.search(after): + if code_refused(tab): return {"status": "failed", "reason": "totp code rejected"} return {"status": "success", "totp_used": True} # SMS OTP / other code challenge -> human (or Twilio, decided by cased) @@ -718,6 +717,17 @@ def classify(tab, cred): return {"status": "success"} +def code_refused(tab): + """Why the page still refuses a submitted code, or None. Page text only, the + URL is the same `%2Fa` trap as in classify().""" + text = tab.js(PAGE_TEXT) or "" + if RE_FAIL.search(text): + return snippet(text, RE_FAIL) + if RE_OTP.search(text): + return "challenge still present" + return None + + def advanced_past_identifier(tab): """True only with positive evidence the identifier submit reached a challenge. @@ -846,13 +856,8 @@ def login_resume(b: dict = Body(...)): reason = apply_challenge_action(tab, "otp", value) if reason: return {"status": "failed", "reason": reason} - blob = (tab.js(PAGE_TEXT) or "") + " " + (tab.js("location.href") or "") - if RE_FAIL.search(blob): - return {"status": "failed", "reason": snippet(blob, RE_FAIL)} - page_text = tab.js(PAGE_TEXT) or "" - fields_ok = tab.js(HAS_FIELDS) is not None # eval reachable - if fields_ok and RE_OTP.search(page_text): # text only: href %2Fa trap, see classify() - return {"status": "failed", "reason": "challenge still present"} + if reason := code_refused(tab): + return {"status": "failed", "reason": reason} return {"status": "success"} finally: try: diff --git a/tests/test_deskd.py b/tests/test_deskd.py index 5bd47e9..3fddd35 100644 --- a/tests/test_deskd.py +++ b/tests/test_deskd.py @@ -832,6 +832,41 @@ def test_login_resume_rechecks_nonapproval_code_path_origin(): apply.assert_not_called() +def test_login_resume_post_check_reads_the_page_not_the_url(): + for href in ("https://site.com/home?next=%2Fa%2Fdash", "https://site.com/home?flash=invalid"): + tab = FakeTab(text="Welcome back", href=href) + tab.close = lambda: None + deskd.state["login"] = {"kind": "otp", "cred_name": "x", "domains": ["site.com"], "at": 0} + with mock.patch.object(deskd, "Tab", lambda: tab), \ + mock.patch.object(deskd, "apply_challenge_action", return_value=None): + assert deskd.login_resume({"value": "123456"}) == {"status": "success"}, href + tab = FakeTab(text="That code is invalid", href="https://site.com/otp") + tab.close = lambda: None + deskd.state["login"] = {"kind": "otp", "cred_name": "x", "domains": ["site.com"], "at": 0} + with mock.patch.object(deskd, "Tab", lambda: tab), \ + mock.patch.object(deskd, "apply_challenge_action", return_value=None): + out = deskd.login_resume({"value": "123456"}) + assert out["status"] == "failed" and "invalid" in out["reason"], out + + +def test_classify_totp_post_check_ignores_the_url(): + class TotpTab(FakeTab): + def __init__(self): + super().__init__(text="Two-factor authentication: enter the code") + + def js(self, expr): + if expr == "location.href" and self._text == "Welcome": + return "https://site.com/home?next=%2Fa%2Fdash&flash=invalid" + return super().js(expr) + + tab = TotpTab() + seed = base64.b32encode(b"12345678901234567890").decode() + with mock.patch.object(deskd, "fill", side_effect=lambda *a: setattr(tab, "_text", "Welcome")), \ + mock.patch.object(deskd, "press_enter"), mock.patch.object(deskd, "settle"): + r = deskd.classify(tab, {"name": "x", "totp_seed": seed, "domains": ["site.com"]}) + assert r == {"status": "success", "totp_used": True}, r + + class ChallengeTab: def __init__(self, href): self.href = href From 7a1bc7d337f8123bf048e04f47df36b1b6fc5fb4 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:00:49 +0000 Subject: [PATCH 081/126] Report a refused code from /auth/submit_challenge. The generic challenge submit typed the code, pressed Enter and returned ok without looking at the page, so a wrong code completed the handoff and Assist said the code was accepted. It now runs the same page-text check as /login/resume and returns ok:false with the reason, and cased keeps the handoff pending without typing the code a second time through /login/resume. The submit also cleared in_login while a /login was still held on a human, which let the blocker watchdog run mid-login; it now clears the hold only when the challenge is answered. --- control-plane/handoffs.py | 10 +++++----- image/deskd.py | 6 +++++- tests/test_auth_attempts.py | 22 ++++++++++++++++++++++ tests/test_deskd.py | 22 ++++++++++++++++++++++ 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/control-plane/handoffs.py b/control-plane/handoffs.py index 1952717..d3597f9 100644 --- a/control-plane/handoffs.py +++ b/control-plane/handoffs.py @@ -371,19 +371,19 @@ def submit_handoff_value(hid, value): # Durable attempt: prefer generic challenge submit (works after prior resume # cleared deskd state["login"]); fall back to /login/resume while held. computer = get_computer(row["computer_id"]) - submitted = False + out = None try: attempt = store.get_auth_attempt(aid) credential = store.get_credential(row["computer_id"], attempt["credential"]) domains = json.loads(credential["domains"]) if credential else [] out = auth_submit_challenge(computer, row["kind"], value=value, domains=domains) - submitted = bool(isinstance(out, dict) and out.get("ok")) except Exception as e: log.warning("auth_submit_challenge failed: %s", e) - submitted = False - if submitted: + if isinstance(out, dict) and out.get("ok"): return _finish_attempt_child(hid, row, answer=value, value_present=True, ctx=None) - if ctx: + # deskd already refused this code (the page kept its code wall, or the origin + # check failed); /login/resume would only type the same code again. + if ctx and not (row["kind"] == "otp" and isinstance(out, dict)): return _resume_and_finish(hid, ctx, value, value_present=True, hrow=row) # Soft fail, stay pending for retry; never keep the OTP in SQLite. store.transition_handoff(hid, "pending", answer=None) diff --git a/image/deskd.py b/image/deskd.py index 326def6..346201c 100644 --- a/image/deskd.py +++ b/image/deskd.py @@ -913,8 +913,11 @@ def auth_submit_challenge(b: dict = Body(...)): return err(400, "bad_request", "body needs 'value' for otp/code") if reason and reason.startswith("unknown challenge kind"): return err(400, "bad_request", reason) + if not reason and str(kind).lower() in ("otp", "code"): + reason = code_refused(tab) if reason: return {"ok": False, "reason": reason} + state["login"] = None # the challenge a /login was held on is answered return {"ok": True} finally: tab.close() @@ -922,7 +925,8 @@ def auth_submit_challenge(b: dict = Body(...)): return {"ok": False, "reason": f"submit_challenge error: {type(e).__name__}: {e}"} finally: inject_end() - state["in_login"] = False + if not state["login"]: + state["in_login"] = False @app.post("/auth/navigate_verification") diff --git a/tests/test_auth_attempts.py b/tests/test_auth_attempts.py index 5e5c8a6..a7cea96 100644 --- a/tests/test_auth_attempts.py +++ b/tests/test_auth_attempts.py @@ -372,6 +372,28 @@ def test_bad_code_stays_pending_same_challenge(): assert call.args[2] != "success", call +def test_refused_code_is_not_typed_again_through_resume(): + _cleanup() + a = auth_attempts.start_attempt("c_1", "cred", "https://example.com/login") + store.cas_auth_attempt_status(a["id"], "created", "awaiting_human", 0) + store.insert_handoff( + "h_bad", "c_1", "otp", "enter code", None, "cred", + continuation="submit_value", attempt_id=a["id"], sequence=1, revision=0) + store.set_attempt_handoff(a["id"], "h_bad") + handoffs.LOGIN_CTX["h_bad"] = {"computer_id": "c_1", "credential": "cred"} + + with mock.patch.object(handoffs, "get_computer", return_value=COMP), \ + mock.patch.object(handoffs, "auth_submit_challenge", + return_value={"ok": False, "reason": "Invalid code"}) as submit, \ + mock.patch.object(handoffs, "desk_json") as desk, \ + mock.patch("auth_attempts.advance_attempt") as adv: + row = handoffs.submit_handoff_value("h_bad", "000000") + assert row["status"] == "pending", dict(row) + submit.assert_called_once() + desk.assert_not_called() + adv.assert_not_called() + + def test_list_helpers_and_set_handoff(): _cleanup() a = auth_attempts.start_attempt("c_1", "github", "https://example.com/login") diff --git a/tests/test_deskd.py b/tests/test_deskd.py index 3fddd35..e77ea4b 100644 --- a/tests/test_deskd.py +++ b/tests/test_deskd.py @@ -910,6 +910,28 @@ def test_auth_submit_challenge_rejects_foreign_https_without_query_secret(): assert "topsecret" not in out["reason"] apply.assert_not_called() + +def test_auth_submit_challenge_refused_code_is_not_ok_and_keeps_the_login_held(): + held = {"kind": "otp", "cred_name": "x", "domains": ["site.com"], "at": 0} + deskd.state["login"], deskd.state["in_login"] = dict(held), True + tab = FakeTab(text="Invalid code. Enter the verification code", href="https://site.com/otp") + tab.close = lambda: None + with mock.patch.object(deskd, "Tab", lambda: tab), \ + mock.patch.object(deskd, "apply_challenge_action", return_value=None): + out = deskd.auth_submit_challenge({"kind": "otp", "value": "000000", + "domains": ["site.com"]}) + assert out["ok"] is False and "Invalid code" in out["reason"], out + assert deskd.state["login"] == held and deskd.state["in_login"] is True + + tab = FakeTab(text="Welcome back", href="https://site.com/home") + tab.close = lambda: None + with mock.patch.object(deskd, "Tab", lambda: tab), \ + mock.patch.object(deskd, "apply_challenge_action", return_value=None): + out = deskd.auth_submit_challenge({"kind": "otp", "value": "123456", + "domains": ["site.com"]}) + assert out == {"ok": True}, out + assert deskd.state["login"] is None and deskd.state["in_login"] is False + if __name__ == "__main__": for name, fn in sorted(globals().items()): if name.startswith("test_"): From e5f1d5a6ab2c9b208aa0bea177c88ed65cf36ffd Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:03:47 +0000 Subject: [PATCH 082/126] Define the attempt and handoff status sets once, in store. The active and terminal attempt statuses, the live and terminal handoff statuses and the SQL literals for them were each spelled out in two to four modules. They now live in store and every module imports them; the SQL strings are built from the same tuples. The unused auth_attempts.ACTIVE_STATUSES and the one-line _next_sequence wrapper are gone. --- control-plane/assist.py | 33 ++++++++++-------------- control-plane/auth_attempts.py | 33 ++++++++++-------------- control-plane/cased.py | 4 +-- control-plane/handoffs.py | 7 ++--- control-plane/login_flow.py | 4 +-- control-plane/store.py | 47 +++++++++++++++++++--------------- 6 files changed, 59 insertions(+), 69 deletions(-) diff --git a/control-plane/assist.py b/control-plane/assist.py index b52229c..e98153c 100644 --- a/control-plane/assist.py +++ b/control-plane/assist.py @@ -14,17 +14,12 @@ from urllib.parse import parse_qs, urlsplit from errors import ApiError -from store import store +from store import AUTH_ATTEMPT_ACTIVE, AUTH_ATTEMPT_TERMINAL, HANDOFF_LIVE, store from util import iso_in, now, row_get COOKIE = "case_assist" EXCHANGE_TTL_S = 900 # 15 minutes, the emailed link SESSION_TTL_S = 1800 # 30 minutes, or until attempt/handoff is gone -LIVE_STATUSES = frozenset({"pending", "validating"}) -_ATTEMPT_TERMINAL = frozenset({ - "authenticated", "unverified", "failed", "expired", "cancelled", -}) -_ATTEMPT_ACTIVE = frozenset({"created", "advancing", "awaiting_human", "proving"}) def _hash(raw): @@ -59,7 +54,7 @@ def exchange(raw_token): if row["expires_at"] <= now(): raise ApiError(410, "gone", "assist link expired") handoff = store.get_handoff(row["handoff_id"]) - if not handoff or handoff["status"] not in LIVE_STATUSES: + if not handoff or handoff["status"] not in HANDOFF_LIVE: # Attempt-scoped: allow exchange while the attempt still needs a human, # even if this mint's handoff row already moved on (rare race). aid = _attempt_id_of(handoff) if handoff else None @@ -99,7 +94,7 @@ def session_view(raw_session): aid = _attempt_id_of(bound) attempt = store.get_auth_attempt(aid) if aid else None - if bound["status"] in LIVE_STATUSES and not attempt: + if bound["status"] in HANDOFF_LIVE and not attempt: return bound, bound, None if attempt: @@ -107,16 +102,16 @@ def session_view(raw_session): cur_id = attempt["current_handoff_id"] if cur_id: current = store.get_handoff(cur_id) - if current and current["status"] not in LIVE_STATUSES: + if current and current["status"] not in HANDOFF_LIVE: # Stale pointer, ignore for action; keep for attempt terminal view. - if attempt["status"] in _ATTEMPT_ACTIVE: + if attempt["status"] in AUTH_ATTEMPT_ACTIVE: current = None - if current and current["status"] in LIVE_STATUSES: + if current and current["status"] in HANDOFF_LIVE: return bound, current, attempt # proving / terminal / between challenges, still viewable return bound, current, attempt - if bound["status"] in LIVE_STATUSES: + if bound["status"] in HANDOFF_LIVE: return bound, bound, attempt return None @@ -127,7 +122,7 @@ def valid_session(raw_session): if not view: return None _bound, current, _attempt = view - if current and current["status"] in LIVE_STATUSES: + if current and current["status"] in HANDOFF_LIVE: return current return None @@ -178,11 +173,11 @@ def continuation_of(handoff): def allowed_actions_for(handoff, attempt): """Typed Assist actions for the current phase, never implies secrets.""" - if attempt and attempt["status"] in _ATTEMPT_TERMINAL: + if attempt and attempt["status"] in AUTH_ATTEMPT_TERMINAL: return [] if attempt and attempt["status"] == "proving": return [] - if not handoff or handoff["status"] not in LIVE_STATUSES: + if not handoff or handoff["status"] not in HANDOFF_LIVE: return [] cont = continuation_of(handoff) if cont == "submit_value": @@ -209,7 +204,7 @@ def build_view(bound, current, attempt): "instructions": "Verifying your login…", "allowed_actions": [], } - if attempt and attempt["status"] in _ATTEMPT_TERMINAL: + if attempt and attempt["status"] in AUTH_ATTEMPT_TERMINAL: st = attempt["status"] instructions = { "authenticated": "You're signed in. You can close this page.", @@ -235,7 +230,7 @@ def build_view(bound, current, attempt): # Map legacy answered → completed for Assist surfaces. if status == "answered": status = "completed" - cont = continuation_of(handoff) if handoff and status in LIVE_STATUSES else None + cont = continuation_of(handoff) if handoff and status in HANDOFF_LIVE else None kind = handoff["kind"] if handoff else None instructions = (handoff["prompt"] or "") if handoff else "" revision = int(row_get(handoff, "revision", 0) or 0) if handoff else 0 @@ -329,7 +324,7 @@ def open_with_session(raw_session, url, expected_revision=None): raise ApiError(410, "gone", "assist session invalid or expired") bound, current, attempt = view handoff = current - if not handoff or handoff["status"] not in LIVE_STATUSES: + if not handoff or handoff["status"] not in HANDOFF_LIVE: raise ApiError(410, "gone", "no open challenge to navigate") built = build_view(bound, current, attempt) if "open_url" not in built["allowed_actions"]: @@ -466,7 +461,7 @@ def render_page(view, token): tok = html_mod.escape(token) rev = int(view["revision"] or 0) - if st in _ATTEMPT_TERMINAL or st in ("completed", "failed", "expired"): + if st in AUTH_ATTEMPT_TERMINAL or st in ("completed", "failed", "expired"): title = { "authenticated": "Signed in", "unverified": "Unverified", diff --git a/control-plane/auth_attempts.py b/control-plane/auth_attempts.py index faec6ef..579b145 100644 --- a/control-plane/auth_attempts.py +++ b/control-plane/auth_attempts.py @@ -18,12 +18,9 @@ import time from errors import ApiError -from store import store +from store import AUTH_ATTEMPT_TERMINAL, HANDOFF_LIVE, store from util import new_id, row_get -ACTIVE_STATUSES = frozenset(store.AUTH_ATTEMPT_ACTIVE) -TERMINAL_STATUSES = frozenset({"authenticated", "unverified", "failed", "expired", "cancelled"}) - # Long-poll ceiling stays under a typical reverse-proxy read timeout (300s) # and the MCP wait budget. WAIT_TIMEOUT_MAX_S = 270 @@ -159,7 +156,7 @@ def _cas_or_conflict(aid, from_status, to_status, revision_expect): def _cursor_changed(pub, after_revision, after_handoff_id): """True when the attempt has moved past the client's last-seen cursor.""" - if pub["status"] in TERMINAL_STATUSES: + if pub["status"] in AUTH_ATTEMPT_TERMINAL: return True if int(pub["revision"] or 0) > int(after_revision or 0): return True @@ -173,7 +170,7 @@ def _wait_payload(pub, *, changed, wait_status=None): st = "changed" if changed else "timeout" if wait_status: st = wait_status - elif pub["status"] in TERMINAL_STATUSES: + elif pub["status"] in AUTH_ATTEMPT_TERMINAL: st = "terminal" # login_result on every payload: the MCP client relays it verbatim instead of # keeping its own copy of the status vocabulary. @@ -190,14 +187,10 @@ def _totp(seed, at=None): return str((int.from_bytes(h[o:o + 4], "big") & 0x7FFFFFFF) % 10 ** 6).zfill(6) -def _next_sequence(attempt_id): - return store.next_handoff_sequence(attempt_id) - - def _ensure_advancing(row, expected_revision=None): """CAS into advancing from created|awaiting_human; return (row, revision).""" status = row["status"] - if status in TERMINAL_STATUSES: + if status in AUTH_ATTEMPT_TERMINAL: raise ApiError(409, "illegal_transition", f"attempt already terminal ({status})") rev = int(row["revision"] or 0) if expected_revision is None else int(expected_revision) @@ -262,7 +255,7 @@ def get_attempt(attempt_id): def cancel_attempt(attempt_id, expected_revision=None): row = _require(attempt_id) - if row["status"] in TERMINAL_STATUSES: + if row["status"] in AUTH_ATTEMPT_TERMINAL: if row["status"] == "cancelled": return attempt_public(row) raise ApiError(409, "illegal_transition", @@ -273,7 +266,7 @@ def cancel_attempt(attempt_id, expected_revision=None): # Terminalize the open child so handoff_list cannot leave a stale pending pin. if hid: h = store.get_handoff(hid) - if h and h["status"] in ("pending", "validating"): + if h and h["status"] in HANDOFF_LIVE: store.transition_handoff(hid, "failed", answer=None) try: import handoffs # cycle: handoffs → auth_attempts on answer paths @@ -315,7 +308,7 @@ def reobserve_if_solved(attempt_id): # The pending child is answered — the human did it on the desk itself. if hid and pub["status"] != "awaiting_human": h = store.get_handoff(hid) - if h and h["status"] in ("pending", "validating"): + if h and h["status"] in HANDOFF_LIVE: store.transition_handoff(hid, "completed", answer=None) return pub @@ -421,7 +414,7 @@ def raise_challenge(attempt_id, kind, prompt, screenshot=None, domain=None, # One pending/validating child at a time. if row["current_handoff_id"]: cur = store.get_handoff(row["current_handoff_id"]) - if cur and cur["status"] in ("pending", "validating"): + if cur and cur["status"] in HANDOFF_LIVE: # Ensure status is awaiting_human if we somehow still hold a live child. if row["status"] == "advancing": return _cas_or_conflict(attempt_id, "advancing", "awaiting_human", rev) @@ -438,7 +431,7 @@ def raise_challenge(attempt_id, kind, prompt, screenshot=None, domain=None, screenshot = screenshot_b64(computer) except Exception: screenshot = None - seq = _next_sequence(attempt_id) + seq = store.next_handoff_sequence(attempt_id) h = create_handoff( computer, kind, prompt, screenshot=screenshot, login_credential=row["credential"], domain=domain, @@ -459,7 +452,7 @@ def fail_attempt(attempt_id, reason=None, expected_revision=None): """ _ = reason row = _require(attempt_id) - if row["status"] in TERMINAL_STATUSES: + if row["status"] in AUTH_ATTEMPT_TERMINAL: return attempt_public(row) rev = int(row["revision"] or 0) if expected_revision is None else int(expected_revision) pub = _cas_or_conflict(attempt_id, row["status"], "failed", rev) @@ -563,7 +556,7 @@ def prove_attempt(attempt_id, expected_revision=None, observation=None): Only `authenticated` records credential success / login_completed(success). """ row = _require(attempt_id) - if row["status"] in TERMINAL_STATUSES: + if row["status"] in AUTH_ATTEMPT_TERMINAL: raise ApiError(409, "illegal_transition", f"attempt already terminal ({row['status']})") rev = int(row["revision"] or 0) if expected_revision is None else int(expected_revision) @@ -631,13 +624,13 @@ def advance_attempt(attempt_id, expected_revision=None, observation=None, _depth expected_revision=expected_revision) row = _require(attempt_id) - if row["status"] in TERMINAL_STATUSES: + if row["status"] in AUTH_ATTEMPT_TERMINAL: return attempt_public(row) row, rev = _ensure_advancing(row, expected_revision) # Clear finished child pointer while re-entering from awaiting_human. if row["current_handoff_id"]: cur = store.get_handoff(row["current_handoff_id"]) - if cur and cur["status"] not in ("pending", "validating"): + if cur and cur["status"] not in HANDOFF_LIVE: store.set_attempt_handoff(attempt_id, None) row = store.get_auth_attempt(attempt_id) diff --git a/control-plane/cased.py b/control-plane/cased.py index c7b8b07..33259fe 100644 --- a/control-plane/cased.py +++ b/control-plane/cased.py @@ -46,7 +46,7 @@ from errors import ApiError from events import sse_gen from notify import notifier -from store import store +from store import AUTH_ATTEMPT_TERMINAL, store from util import now, row_get @asynccontextmanager @@ -996,7 +996,7 @@ def login(cid: str, body: dict = Body(...), wake: bool = False): # Idempotent replay, never re-inject; agents poll GET /auth-attempts/{id}. if attempt["status"] == "awaiting_human": return auth_attempts.login_result(attempt) - if attempt["status"] in auth_attempts.TERMINAL_STATUSES: + if attempt["status"] in AUTH_ATTEMPT_TERMINAL: return auth_attempts.login_result(attempt) if attempt["status"] in ("advancing", "proving"): advanced = auth_attempts.advance_attempt(attempt["id"]) diff --git a/control-plane/handoffs.py b/control-plane/handoffs.py index d3597f9..7160969 100644 --- a/control-plane/handoffs.py +++ b/control-plane/handoffs.py @@ -31,7 +31,7 @@ from events import emit from lifecycle import get_computer from notify import notifier -from store import store +from store import HANDOFF_TERMINAL, store from util import new_id, row_get @@ -58,9 +58,6 @@ def _public_host(): # send these; anything else (a typed "no", an empty body) is refused, not approved. APPROVAL_VALUES = ("approve", "deny") -TERMINAL_STATUSES = frozenset({"completed", "answered", "failed", "expired"}) - - def continuation_for(kind, continuation=None): if continuation: return continuation @@ -241,7 +238,7 @@ def _require_open_handoff(hid): status = row["status"] if status == "expired": raise ApiError(409, "handoff_expired", "handoff expired after 15 minutes") - if status in TERMINAL_STATUSES: + if status in HANDOFF_TERMINAL: raise ApiError(409, "already_answered", "handoff already answered") if status == "validating": raise ApiError(409, "validating", "handoff is already being validated") diff --git a/control-plane/login_flow.py b/control-plane/login_flow.py index 888c8d1..2064726 100644 --- a/control-plane/login_flow.py +++ b/control-plane/login_flow.py @@ -14,7 +14,7 @@ import links from config import log from deskclient import desk_json, eval_js, eval_value, screenshot_b64 -from store import store +from store import HANDOFF_LIVE, store from util import row_get @@ -286,7 +286,7 @@ def _route_blocker(row, blocker): current_id = active["current_handoff_id"] if current_id: current = store.get_handoff(current_id) - if current and current["status"] in ("pending", "validating"): + if current and current["status"] in HANDOFF_LIVE: return current_id if row_get(active, "status") == "proving": return None diff --git a/control-plane/store.py b/control-plane/store.py index d29b246..c44645f 100644 --- a/control-plane/store.py +++ b/control-plane/store.py @@ -20,7 +20,20 @@ from config import CASE_HOME from util import now, row_get -SCHEMA = """ +# Status vocabularies. The one definition: callers import these, and the SQL +# below is built from them. +AUTH_ATTEMPT_ACTIVE = ("created", "advancing", "awaiting_human", "proving") +AUTH_ATTEMPT_TERMINAL = ("authenticated", "unverified", "failed", "expired", "cancelled") +HANDOFF_LIVE = ("pending", "validating") +# Terminal handoff statuses; transition_handoff never moves a row out of one. +HANDOFF_TERMINAL = ("completed", "answered", "failed", "expired") + + +def _sql_in(values): + return "(" + ",".join(f"'{v}'" for v in values) + ")" + + +SCHEMA = f""" CREATE TABLE IF NOT EXISTS computers ( id TEXT PRIMARY KEY, name TEXT, state TEXT, image TEXT, created_at TEXT, last_active_at TEXT, @@ -60,7 +73,7 @@ WHERE idempotency_key IS NOT NULL; CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_attempts_one_active ON auth_attempts(computer_id) - WHERE status IN ('created','advancing','awaiting_human','proving'); + WHERE status IN {_sql_in(AUTH_ATTEMPT_ACTIVE)}; CREATE INDEX IF NOT EXISTS idx_handoffs_computer_status ON handoffs(computer_id, status); CREATE INDEX IF NOT EXISTS idx_handoffs_status_created ON handoffs(status, created_at); CREATE TABLE IF NOT EXISTS schedules ( @@ -136,10 +149,6 @@ def __init__(self, home=None): ("schedules", "tz", "TEXT"), ] - # Active (non-terminal) auth-attempt statuses, kept here so the partial unique - # index and get_active_* share one definition with auth_attempts.ACTIVE_STATUSES. - AUTH_ATTEMPT_ACTIVE = ("created", "advancing", "awaiting_human", "proving") - def _migrate(self): for table, col, typ in self.MIGRATIONS: cols = [r["name"] for r in self.db.execute(f"PRAGMA table_info({table})")] @@ -335,7 +344,7 @@ def insert_handoff(self, hid, cid, kind, prompt, screenshot, login_credential, d def pending_login_handoffs(self): # pending + validating: a restart mid-verify must still recover LOGIN_CTX - return self.all("SELECT * FROM handoffs WHERE status IN ('pending','validating') " + return self.all(f"SELECT * FROM handoffs WHERE status IN {_sql_in(HANDOFF_LIVE)} " "AND login_credential IS NOT NULL") def get_handoff(self, hid): @@ -344,7 +353,7 @@ def get_handoff(self, hid): def get_open_handoff_by_fingerprint(self, cid, fingerprint): return self.one( "SELECT * FROM handoffs WHERE computer_id=? AND challenge_fingerprint=? " - "AND status IN ('pending','validating') ORDER BY created_at DESC LIMIT 1", + f"AND status IN {_sql_in(HANDOFF_LIVE)} ORDER BY created_at DESC LIMIT 1", (cid, fingerprint)) def list_handoffs(self, status=None): @@ -369,7 +378,7 @@ def pending_handoff_ids(self): def stale_pending_handoffs(self, cutoff): """Open handoffs older than `cutoff`, for the TTL sweeper. validating that never finished (restart mid-verify) also ages out.""" - return self.all("SELECT * FROM handoffs WHERE status IN ('pending','validating') " + return self.all(f"SELECT * FROM handoffs WHERE status IN {_sql_in(HANDOFF_LIVE)} " "AND created_at < ?", (cutoff,)) def delete_handoff(self, hid): @@ -377,9 +386,6 @@ def delete_handoff(self, hid): _ANSWER_UNCHANGED = object() - # Terminal handoff statuses; transition_handoff never moves a row out of one. - HANDOFF_TERMINAL = ("completed", "answered", "failed", "expired") - def set_handoff_status(self, hid, status, answer=_ANSWER_UNCHANGED): """Raw status write, no guard and no revision bump. Test fixtures only — production paths go through transition_handoff.""" @@ -396,7 +402,7 @@ def transition_handoff(self, hid, to, answer=_ANSWER_UNCHANGED): or None when the row is gone, already terminal, or another writer won — callers must do side effects (events, credential health) only on a row.""" row = self.get_handoff(hid) - if not row or row["status"] in self.HANDOFF_TERMINAL: + if not row or row["status"] in HANDOFF_TERMINAL: return None n = self.cas_handoff_status(hid, row["status"], to, int(row["revision"] or 0), answer=answer) @@ -436,16 +442,16 @@ def get_auth_attempt(self, aid): def get_active_auth_attempt(self, computer_id): """The newest non-terminal attempt on this computer, or None. At most one should exist — login 409s while one is active.""" - qs = ",".join("?" * len(self.AUTH_ATTEMPT_ACTIVE)) + qs = ",".join("?" * len(AUTH_ATTEMPT_ACTIVE)) return self.one( f"SELECT * FROM auth_attempts WHERE computer_id=? AND status IN ({qs}) " "ORDER BY created_at DESC LIMIT 1", - (computer_id, *self.AUTH_ATTEMPT_ACTIVE)) + (computer_id, *AUTH_ATTEMPT_ACTIVE)) def stale_active_auth_attempts(self, cutoff): - qs = ",".join("?" * len(self.AUTH_ATTEMPT_ACTIVE)) + qs = ",".join("?" * len(AUTH_ATTEMPT_ACTIVE)) return self.all(f"SELECT * FROM auth_attempts WHERE status IN ({qs}) AND updated_at < ?", - (*self.AUTH_ATTEMPT_ACTIVE, cutoff)) + (*AUTH_ATTEMPT_ACTIVE, cutoff)) def get_auth_attempt_by_idempotency(self, computer_id, idempotency_key): if not idempotency_key: @@ -618,10 +624,9 @@ def telemetry_counts(self, since): def prune_terminal_handoffs(self, cutoff): self.q("UPDATE handoffs SET screenshot=NULL WHERE screenshot IS NOT NULL " - "AND status IN ('completed','answered','failed','expired')") - return self.q("DELETE FROM handoffs WHERE status IN " - "('completed','answered','failed','expired') AND created_at < ?", - (cutoff,)).rowcount + f"AND status IN {_sql_in(HANDOFF_TERMINAL)}") + return self.q(f"DELETE FROM handoffs WHERE status IN {_sql_in(HANDOFF_TERMINAL)} " + "AND created_at < ?", (cutoff,)).rowcount def prune_old_runs(self, keep=1000): """Cap run history. Returns artifact_path values of deleted rows.""" From 264d5072a082f67624d9aeea34ad950295102679 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:04:46 +0000 Subject: [PATCH 083/126] Let the active-attempt queries use their indexes, and prune old attempts. get_active_auth_attempt and stale_active_auth_attempts bound the status list as ? parameters, which hides it from the planner, so both scanned the whole table instead of using idx_auth_attempts_one_active. They now inline the same literal list the partial index uses. A (status, updated_at) index serves the sweeper's stale scan, and the 30-day sweep now deletes terminal attempts alongside terminal handoffs instead of keeping every attempt forever. --- control-plane/cased.py | 1 + control-plane/store.py | 20 +++++++++++++------- tests/test_store.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/control-plane/cased.py b/control-plane/cased.py index 33259fe..4dc78d7 100644 --- a/control-plane/cased.py +++ b/control-plane/cased.py @@ -1142,6 +1142,7 @@ def _prune_history(): unlink_run_artifacts(store.prune_old_runs(keep=1000)) prune_old_audit_files() store.prune_terminal_handoffs(cutoff) + store.prune_terminal_auth_attempts(cutoff) def sweeper(): diff --git a/control-plane/store.py b/control-plane/store.py index c44645f..128e301 100644 --- a/control-plane/store.py +++ b/control-plane/store.py @@ -74,6 +74,7 @@ def _sql_in(values): CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_attempts_one_active ON auth_attempts(computer_id) WHERE status IN {_sql_in(AUTH_ATTEMPT_ACTIVE)}; +CREATE INDEX IF NOT EXISTS idx_auth_attempts_status_updated ON auth_attempts(status, updated_at); CREATE INDEX IF NOT EXISTS idx_handoffs_computer_status ON handoffs(computer_id, status); CREATE INDEX IF NOT EXISTS idx_handoffs_status_created ON handoffs(status, created_at); CREATE TABLE IF NOT EXISTS schedules ( @@ -439,19 +440,19 @@ def insert_auth_attempt(self, aid, computer_id, credential, target_url, def get_auth_attempt(self, aid): return self.one("SELECT * FROM auth_attempts WHERE id=?", (aid,)) + # Literal IN lists, not bound ?s: SQLite only picks the partial index + # idx_auth_attempts_one_active when the query repeats its WHERE term verbatim. def get_active_auth_attempt(self, computer_id): """The newest non-terminal attempt on this computer, or None. At most one should exist — login 409s while one is active.""" - qs = ",".join("?" * len(AUTH_ATTEMPT_ACTIVE)) return self.one( - f"SELECT * FROM auth_attempts WHERE computer_id=? AND status IN ({qs}) " - "ORDER BY created_at DESC LIMIT 1", - (computer_id, *AUTH_ATTEMPT_ACTIVE)) + f"SELECT * FROM auth_attempts WHERE computer_id=? " + f"AND status IN {_sql_in(AUTH_ATTEMPT_ACTIVE)} ORDER BY created_at DESC LIMIT 1", + (computer_id,)) def stale_active_auth_attempts(self, cutoff): - qs = ",".join("?" * len(AUTH_ATTEMPT_ACTIVE)) - return self.all(f"SELECT * FROM auth_attempts WHERE status IN ({qs}) AND updated_at < ?", - (*AUTH_ATTEMPT_ACTIVE, cutoff)) + return self.all(f"SELECT * FROM auth_attempts WHERE status IN " + f"{_sql_in(AUTH_ATTEMPT_ACTIVE)} AND updated_at < ?", (cutoff,)) def get_auth_attempt_by_idempotency(self, computer_id, idempotency_key): if not idempotency_key: @@ -628,6 +629,11 @@ def prune_terminal_handoffs(self, cutoff): return self.q(f"DELETE FROM handoffs WHERE status IN {_sql_in(HANDOFF_TERMINAL)} " "AND created_at < ?", (cutoff,)).rowcount + def prune_terminal_auth_attempts(self, cutoff): + return self.q(f"DELETE FROM auth_attempts WHERE status IN " + f"{_sql_in(AUTH_ATTEMPT_TERMINAL)} AND updated_at < ?", + (cutoff,)).rowcount + def prune_old_runs(self, keep=1000): """Cap run history. Returns artifact_path values of deleted rows.""" keep = max(1, int(keep)) diff --git a/tests/test_store.py b/tests/test_store.py index bfabcf4..5b93301 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -6,6 +6,7 @@ import sys import tempfile import threading +import unittest.mock as mock _HOME = tempfile.mkdtemp(prefix="case-store-test-") os.environ["CASE_HOME"] = _HOME @@ -35,9 +36,40 @@ def read(worker): list(pool.map(read, range(16))) +def _plan(query): + """The query plan of the one statement `query()` runs.""" + seen = [] + with mock.patch.object(store, "one", side_effect=lambda sql, args=(): seen.append((sql, args))), \ + mock.patch.object(store, "all", side_effect=lambda sql, args=(): seen.append((sql, args))): + query() + (sql, args), = seen + return " ".join(r["detail"] for r in store.db.execute("EXPLAIN QUERY PLAN " + sql, args)) + + +def test_active_attempt_queries_use_an_index(): + plan = _plan(lambda: store.get_active_auth_attempt("c_plan")) + assert "idx_auth_attempts_one_active" in plan, plan + plan = _plan(lambda: store.stale_active_auth_attempts("2000-01-01T00:00:00Z")) + assert "SEARCH" in plan, plan + + +def test_prune_terminal_auth_attempts_keeps_active_and_recent_rows(): + old = "2000-01-01T00:00:00Z" + for aid, status in (("a_old_done", "failed"), ("a_old_live", "awaiting_human"), + ("a_new_done", "authenticated")): + store.insert_auth_attempt(aid, "c_prune", "test", "https://example.com", status=status) + store.q("UPDATE auth_attempts SET updated_at=? WHERE id IN ('a_old_done','a_old_live')", + (old,)) + assert store.prune_terminal_auth_attempts("2001-01-01T00:00:00Z") == 1 + left = {r["id"] for r in store.all("SELECT id FROM auth_attempts WHERE computer_id='c_prune'")} + assert left == {"a_old_live", "a_new_done"}, left + + if __name__ == "__main__": try: test_concurrent_reads_keep_their_rows_intact() + test_active_attempt_queries_use_an_index() + test_prune_terminal_auth_attempts_keeps_active_and_recent_rows() print("test_store: ok") finally: store.db.close() From 7ca69e96a2a487a4f2e58d1cb6fa30d36855190d Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:05:56 +0000 Subject: [PATCH 084/126] Keep why an attempt failed. fail_attempt took a reason from every caller (handoff_expired, denied, desk_error, the deskd reason) and dropped it, so an agent reading a failed attempt only ever saw authentication_failed. The reason is now stored in a new fail_reason column, added through MIGRATIONS, and shows up as fail_reason on the attempt and on its login_result, next to the unchanged reason code. --- control-plane/auth_attempts.py | 18 +++++++++--------- control-plane/store.py | 13 ++++++++----- tests/test_auth_attempts.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 14 deletions(-) diff --git a/control-plane/auth_attempts.py b/control-plane/auth_attempts.py index 579b145..66791db 100644 --- a/control-plane/auth_attempts.py +++ b/control-plane/auth_attempts.py @@ -94,6 +94,7 @@ def attempt_public(row): "proof_level": _proof_level(proof_spec), "created_at": row["created_at"], "updated_at": row["updated_at"], + "fail_reason": row_get(row, "fail_reason"), } @@ -114,7 +115,8 @@ def login_result(attempt, reason=None): "reason": reason or "proof_missing_or_failed"} if st == "failed": return {**base, "status": "failed", - "reason": reason or "authentication_failed"} + "reason": reason or "authentication_failed", + "fail_reason": attempt.get("fail_reason")} if st == "cancelled": return {**base, "status": "failed", "reason": reason or "cancelled"} if st == "expired": @@ -144,8 +146,9 @@ def _publish_updated(pub): }) -def _cas_or_conflict(aid, from_status, to_status, revision_expect): - n = store.cas_auth_attempt_status(aid, from_status, to_status, revision_expect) +def _cas_or_conflict(aid, from_status, to_status, revision_expect, fail_reason=None): + n = store.cas_auth_attempt_status(aid, from_status, to_status, revision_expect, + fail_reason=fail_reason) if n != 1: raise ApiError(409, "revision_conflict", "auth attempt revision or status changed") @@ -446,16 +449,13 @@ def raise_challenge(attempt_id, kind, prompt, screenshot=None, domain=None, def fail_attempt(attempt_id, reason=None, expected_revision=None): - """Terminal failure; updates credential last_status=failed. - - `reason` is for callers building a LoginResult; not stored on the attempt row. - """ - _ = reason + """Terminal failure; updates credential last_status=failed and keeps `reason` + as the attempt's fail_reason.""" row = _require(attempt_id) if row["status"] in AUTH_ATTEMPT_TERMINAL: return attempt_public(row) rev = int(row["revision"] or 0) if expected_revision is None else int(expected_revision) - pub = _cas_or_conflict(attempt_id, row["status"], "failed", rev) + pub = _cas_or_conflict(attempt_id, row["status"], "failed", rev, fail_reason=reason) store.record_credential_result(row["computer_id"], row["credential"], "failed") from events import emit emit("login_completed", { diff --git a/control-plane/store.py b/control-plane/store.py index 128e301..33791fc 100644 --- a/control-plane/store.py +++ b/control-plane/store.py @@ -66,7 +66,8 @@ def _sql_in(values): idempotency_key TEXT, current_handoff_id TEXT, created_at TEXT NOT NULL, - updated_at TEXT NOT NULL + updated_at TEXT NOT NULL, + fail_reason TEXT ); CREATE UNIQUE INDEX IF NOT EXISTS idx_auth_attempts_idempotency ON auth_attempts(computer_id, idempotency_key) @@ -148,6 +149,7 @@ def __init__(self, home=None): ("credentials", "proof_spec", "TEXT"), ("credentials", "verification_hosts", "TEXT"), ("schedules", "tz", "TEXT"), + ("auth_attempts", "fail_reason", "TEXT"), ] def _migrate(self): @@ -461,12 +463,13 @@ def get_auth_attempt_by_idempotency(self, computer_id, idempotency_key): "SELECT * FROM auth_attempts WHERE computer_id=? AND idempotency_key=?", (computer_id, idempotency_key)) - def cas_auth_attempt_status(self, aid, from_status, to_status, revision_expect): + def cas_auth_attempt_status(self, aid, from_status, to_status, revision_expect, + fail_reason=None): """Compare-and-set status + bump revision. rowcount 1 = this caller won.""" return self.q( - "UPDATE auth_attempts SET status=?, revision=revision+1, updated_at=? " - "WHERE id=? AND status=? AND revision=?", - (to_status, now(), aid, from_status, revision_expect)).rowcount + "UPDATE auth_attempts SET status=?, revision=revision+1, updated_at=?, " + "fail_reason=COALESCE(?, fail_reason) WHERE id=? AND status=? AND revision=?", + (to_status, now(), fail_reason, aid, from_status, revision_expect)).rowcount def set_attempt_handoff(self, aid, handoff_id): return self.q( diff --git a/tests/test_auth_attempts.py b/tests/test_auth_attempts.py index a7cea96..9993d09 100644 --- a/tests/test_auth_attempts.py +++ b/tests/test_auth_attempts.py @@ -429,6 +429,40 @@ def test_login_result_compat_shape(): assert "revision" in lr +def test_fail_reason_is_kept_and_exposed(): + _cleanup() + a = auth_attempts.start_attempt("c_1", "github", "https://example.com/login") + assert a["fail_reason"] is None + auth_attempts.fail_attempt(a["id"], reason="handoff_expired") + got = auth_attempts.get_attempt(a["id"]) + assert got["fail_reason"] == "handoff_expired", got + assert got["login_result"]["reason"] == "authentication_failed", got + assert got["login_result"]["fail_reason"] == "handoff_expired", got + waited = auth_attempts._wait_payload(auth_attempts.attempt_public( + store.get_auth_attempt(a["id"])), changed=True) + assert waited["login_result"]["fail_reason"] == "handoff_expired", waited + + +def test_fail_reason_column_is_migrated_onto_an_old_db(): + import sqlite3 + from store import Store + home = tempfile.mkdtemp(prefix="case-auth-attempts-old-") + try: + db = sqlite3.connect(os.path.join(home, "case.db")) + db.execute("CREATE TABLE auth_attempts (id TEXT PRIMARY KEY, computer_id TEXT NOT NULL, " + "credential TEXT NOT NULL, status TEXT NOT NULL, revision INTEGER NOT NULL " + "DEFAULT 0, target_url TEXT, proof_spec TEXT, idempotency_key TEXT, " + "current_handoff_id TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)") + db.commit() + db.close() + old = Store(home) + cols = {r["name"] for r in old.db.execute("PRAGMA table_info(auth_attempts)")} + old.db.close() + assert "fail_reason" in cols, cols + finally: + shutil.rmtree(home, ignore_errors=True) + + def test_malformed_proof_spec_never_authenticates(): """Unknown keys / empty predicates must not count as configured success.""" for i, bad in enumerate( From e655bcde53a8c9ddfa35dc82aecbe24865fe4b0c Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:07:23 +0000 Subject: [PATCH 085/126] Close an attempt's open challenge whenever the attempt ends. Only cancel_attempt terminalized the live child handoff. When an attempt failed or proved with a challenge still pending, the handoff stayed pending, kept its LOGIN_CTX entry and could still be answered. Every terminal attempt transition now closes the open child: completed when the login finished (authenticated or unverified), failed otherwise. reobserve_if_solved relied on its own copy of this and now uses the shared path. --- control-plane/auth_attempts.py | 38 +++++++++++++++------------------- tests/test_auth_attempts.py | 17 +++++++++++++++ 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/control-plane/auth_attempts.py b/control-plane/auth_attempts.py index 66791db..dcea51e 100644 --- a/control-plane/auth_attempts.py +++ b/control-plane/auth_attempts.py @@ -153,10 +153,25 @@ def _cas_or_conflict(aid, from_status, to_status, revision_expect, fail_reason=N raise ApiError(409, "revision_conflict", "auth attempt revision or status changed") pub = attempt_public(store.get_auth_attempt(aid)) + if to_status in AUTH_ATTEMPT_TERMINAL: + # A finished login got past its challenge; any other end leaves it unanswered. + _close_child(pub["current_handoff_id"], + "completed" if to_status in ("authenticated", "unverified") else "failed") _publish_updated(pub) return pub +def _close_child(hid, status): + """Terminalize a still-open child so handoff_list cannot keep a stale pending pin.""" + if not hid: + return + h = store.get_handoff(hid) + if h and h["status"] in HANDOFF_LIVE: + store.transition_handoff(hid, status, answer=None) + import handoffs # cycle: handoffs → auth_attempts on answer paths + handoffs.LOGIN_CTX.pop(hid, None) + + def _cursor_changed(pub, after_revision, after_handoff_id): """True when the attempt has moved past the client's last-seen cursor.""" if pub["status"] in AUTH_ATTEMPT_TERMINAL: @@ -264,19 +279,7 @@ def cancel_attempt(attempt_id, expected_revision=None): raise ApiError(409, "illegal_transition", f"cannot cancel terminal attempt in status {row['status']}") rev = int(row["revision"] or 0) if expected_revision is None else int(expected_revision) - hid = row_get(row, "current_handoff_id") - pub = _cas_or_conflict(attempt_id, row["status"], "cancelled", rev) - # Terminalize the open child so handoff_list cannot leave a stale pending pin. - if hid: - h = store.get_handoff(hid) - if h and h["status"] in HANDOFF_LIVE: - store.transition_handoff(hid, "failed", answer=None) - try: - import handoffs # cycle: handoffs → auth_attempts on answer paths - handoffs.LOGIN_CTX.pop(hid, None) - except Exception: - pass - return pub + return _cas_or_conflict(attempt_id, row["status"], "cancelled", rev) def reobserve_if_solved(attempt_id): @@ -287,7 +290,6 @@ def reobserve_if_solved(attempt_id): row = store.get_auth_attempt(attempt_id) if not row or row["status"] != "awaiting_human": return None - hid = row_get(row, "current_handoff_id") from deskclient import observe_auth from lifecycle import get_computer @@ -305,15 +307,9 @@ def reobserve_if_solved(attempt_id): if observation is None or _classify_kind(observation): return None # challenge still up (or unreadable) — keep waiting try: - pub = advance_attempt(attempt_id, observation=observation) + return advance_attempt(attempt_id, observation=observation) except ApiError: return None # raced with an Assist submit; the waiter sees that change - # The pending child is answered — the human did it on the desk itself. - if hid and pub["status"] != "awaiting_human": - h = store.get_handoff(hid) - if h and h["status"] in HANDOFF_LIVE: - store.transition_handoff(hid, "completed", answer=None) - return pub async def wait_attempt(attempt_id, after_revision=0, after_handoff_id=None, diff --git a/tests/test_auth_attempts.py b/tests/test_auth_attempts.py index 9993d09..280f813 100644 --- a/tests/test_auth_attempts.py +++ b/tests/test_auth_attempts.py @@ -158,6 +158,23 @@ def test_cancel_attempt(): assert not store.active_attempt_exists("c_1") +def test_every_terminal_attempt_closes_its_live_child(): + for end, want in (("fail", "failed"), ("prove", "completed"), ("cancel", "failed")): + _cleanup() + a = auth_attempts.start_attempt("c_1", "github", "https://example.com/login") + store.cas_auth_attempt_status(a["id"], "created", "awaiting_human", 0) + store.insert_handoff("h_live", "c_1", "otp", "enter code", None, "github", + continuation="submit_value", attempt_id=a["id"], sequence=1) + store.set_attempt_handoff(a["id"], "h_live") + handoffs.LOGIN_CTX["h_live"] = {"computer_id": "c_1", "credential": "github"} + with mock.patch.object(store, "record_credential_result"): + {"fail": lambda: auth_attempts.fail_attempt(a["id"], reason="x"), + "prove": lambda: auth_attempts.prove_attempt(a["id"]), + "cancel": lambda: auth_attempts.cancel_attempt(a["id"])}[end]() + assert store.get_handoff("h_live")["status"] == want, end + assert "h_live" not in handoffs.LOGIN_CTX, end + + def test_claim_challenge_cas(): _cleanup() store.insert_handoff( From bc01705a4405b5858cd304a649876b241dcb793c Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:08:27 +0000 Subject: [PATCH 086/126] Stop raise_challenge from orphaning or churning challenge handoffs. raise_challenge created, pointed and notified a new handoff before its advancing to awaiting_human CAS. From proving, or when a cancel or fail landed in between, the CAS lost and a pending, notified handoff was left with nothing to answer it. Proving is now refused up front, and a lost CAS fails the new handoff. A repeat call while a live child is already awaiting the human used to bounce the attempt through advancing and back, bumping the revision twice and waking every waiter; it now returns the attempt unchanged. --- control-plane/auth_attempts.py | 25 +++++++++++------- tests/test_auth_attempts.py | 46 ++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/control-plane/auth_attempts.py b/control-plane/auth_attempts.py index dcea51e..6e39d66 100644 --- a/control-plane/auth_attempts.py +++ b/control-plane/auth_attempts.py @@ -409,15 +409,17 @@ def raise_challenge(attempt_id, kind, prompt, screenshot=None, domain=None, challenge_fingerprint=None, expected_revision=None): """Bind a child handoff and move the attempt to awaiting_human.""" row = _require(attempt_id) - row, rev = _ensure_advancing(row, expected_revision) + if row["status"] == "proving": + raise ApiError(409, "illegal_transition", "cannot raise a challenge while proving") # One pending/validating child at a time. - if row["current_handoff_id"]: - cur = store.get_handoff(row["current_handoff_id"]) - if cur and cur["status"] in HANDOFF_LIVE: - # Ensure status is awaiting_human if we somehow still hold a live child. - if row["status"] == "advancing": - return _cas_or_conflict(attempt_id, "advancing", "awaiting_human", rev) - return attempt_public(row) + cur = store.get_handoff(row["current_handoff_id"]) if row["current_handoff_id"] else None + live = cur is not None and cur["status"] in HANDOFF_LIVE + if live and row["status"] == "awaiting_human" and ( + expected_revision is None or int(expected_revision) == int(row["revision"] or 0)): + return attempt_public(row) + row, rev = _ensure_advancing(row, expected_revision) + if live: + return _cas_or_conflict(attempt_id, "advancing", "awaiting_human", rev) from deskclient import screenshot_b64 # cycle: handoffs → auth_attempts → deskclient from handoffs import create_handoff # cycle: handoffs → auth_attempts @@ -439,9 +441,14 @@ def raise_challenge(attempt_id, kind, prompt, screenshot=None, domain=None, store.set_attempt_handoff(attempt_id, h["id"]) # Pointer can change before the awaiting_human CAS; wake waiters early. _publish_updated(attempt_public(store.get_auth_attempt(attempt_id))) + try: + pub = _cas_or_conflict(attempt_id, "advancing", "awaiting_human", rev) + except ApiError: + _close_child(h["id"], "failed") # a cancel or fail won the race; no orphan + raise # Provisional vault health, definitive answer arrives on prove/fail/expire. store.record_credential_result(row["computer_id"], row["credential"], "challenge") - return _cas_or_conflict(attempt_id, "advancing", "awaiting_human", rev) + return pub def fail_attempt(attempt_id, reason=None, expected_revision=None): diff --git a/tests/test_auth_attempts.py b/tests/test_auth_attempts.py index 280f813..7906aa8 100644 --- a/tests/test_auth_attempts.py +++ b/tests/test_auth_attempts.py @@ -175,6 +175,52 @@ def test_every_terminal_attempt_closes_its_live_child(): assert "h_live" not in handoffs.LOGIN_CTX, end +def test_raise_challenge_while_proving_creates_no_handoff(): + _cleanup() + a = auth_attempts.start_attempt("c_1", "github", "https://example.com/login") + store.cas_auth_attempt_status(a["id"], "created", "proving", 0) + with mock.patch("handoffs.create_handoff") as create: + _raises(lambda: auth_attempts.raise_challenge(a["id"], "otp", "code?"), + "illegal_transition") + create.assert_not_called() + + +def test_raise_challenge_losing_to_a_cancel_leaves_no_pending_child(): + _cleanup() + a = auth_attempts.start_attempt("c_1", "github", "https://example.com/login") + real_create = handoffs.create_handoff + + def create_then_cancel(*args, **kw): + h = real_create(*args, **kw) + auth_attempts.cancel_attempt(a["id"]) # read the attempt before the pointer moved + return h + + with mock.patch("lifecycle.get_computer", return_value=COMP), \ + mock.patch("deskclient.screenshot_b64", return_value=None), \ + mock.patch("handoffs.create_handoff", side_effect=create_then_cancel): + _raises(lambda: auth_attempts.raise_challenge(a["id"], "otp", "code?"), + "revision_conflict") + rows = store.all("SELECT id, status FROM handoffs") + assert [r["status"] for r in rows] == ["failed"], [dict(r) for r in rows] + assert not handoffs.LOGIN_CTX, handoffs.LOGIN_CTX + + +def test_repeat_raise_challenge_with_a_live_child_changes_nothing(): + _cleanup() + a = auth_attempts.start_attempt("c_1", "github", "https://example.com/login") + store.cas_auth_attempt_status(a["id"], "created", "awaiting_human", 0) + store.insert_handoff("h_live", "c_1", "otp", "enter code", None, "github", + continuation="submit_value", attempt_id=a["id"], sequence=1) + store.set_attempt_handoff(a["id"], "h_live") + with mock.patch("events.emit") as emit, \ + mock.patch("handoffs.create_handoff") as create: + out = auth_attempts.raise_challenge(a["id"], "otp", "code?") + assert out["status"] == "awaiting_human" and out["revision"] == 1, out + assert out["current_handoff_id"] == "h_live" + create.assert_not_called() + emit.assert_not_called() + + def test_claim_challenge_cas(): _cleanup() store.insert_handoff( From 74cc929c3025eb3c8de78e1b7fcc8044acac54b4 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:08:58 +0000 Subject: [PATCH 087/126] Type an auto TOTP code at most once per 30s window per attempt. With a stale seed and a page that kept its code wall, advance_attempt typed the same wrong code five times in one window, then failed the attempt with advance_depth_exceeded and never asked the human, the fastest way to lock the account. The code is now typed once per window per attempt; if the code wall is still up, the attempt raises a human OTP challenge instead. --- control-plane/auth_attempts.py | 12 ++++++++++-- tests/test_auth_attempts.py | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/control-plane/auth_attempts.py b/control-plane/auth_attempts.py index 6e39d66..157396d 100644 --- a/control-plane/auth_attempts.py +++ b/control-plane/auth_attempts.py @@ -29,6 +29,11 @@ # Optional: fn(computer_row, computer_id, credential_name) -> {"status": "success"|"failed"}|None _CAPTCHA_AUTO = None +# attempt_id -> TOTP window its code was last typed in. A stale seed yields the +# same wrong code for the whole window, and typing it again only spends the +# site's lockout budget; once per window, then the human gets the challenge. +_TOTP_WINDOW = {} + # Signal priority matches deskd.classify order (generic tags only, no site names). _SIGNAL_PRIORITY = ("captcha", "otp", "approval", "email_verify", "passkey") @@ -154,6 +159,7 @@ def _cas_or_conflict(aid, from_status, to_status, revision_expect, fail_reason=N "auth attempt revision or status changed") pub = attempt_public(store.get_auth_attempt(aid)) if to_status in AUTH_ATTEMPT_TERMINAL: + _TOTP_WINDOW.pop(aid, None) # A finished login got past its challenge; any other end leaves it unanswered. _close_child(pub["current_handoff_id"], "completed" if to_status in ("authenticated", "unverified") else "failed") @@ -655,9 +661,11 @@ def advance_attempt(attempt_id, expected_revision=None, observation=None, _depth # Auto TOTP when vault has a seed. if kind == "otp": material = store.credential_material(row["computer_id"], row["credential"]) - if material and material.get("totp_seed"): + window = int(time.time() // 30) + if material and material.get("totp_seed") and _TOTP_WINDOW.get(attempt_id) != window: + _TOTP_WINDOW[attempt_id] = window try: - code = _totp(material["totp_seed"]) + code = _totp(material["totp_seed"], window * 30) out = auth_submit_challenge(computer, "otp", value=code, domains=material.get("domains") or []) if isinstance(out, dict) and out.get("ok"): diff --git a/tests/test_auth_attempts.py b/tests/test_auth_attempts.py index 7906aa8..807d6b8 100644 --- a/tests/test_auth_attempts.py +++ b/tests/test_auth_attempts.py @@ -392,6 +392,24 @@ def test_totp_submit_carries_credential_domains(): assert len(submit.call_args.kwargs["value"]) == 6 +def test_stale_totp_seed_is_typed_once_then_the_human_gets_the_challenge(): + _cleanup() + store.upsert_credential("c_1", "cred", "u", "secret", "JBSWY3DPEHPK3PXP", + None, ["example.com"]) + a = auth_attempts.start_attempt("c_1", "cred", "https://example.com/login") + otp = {"ok": True, "observation": _obs(challenge_signals=["otp"], + visible_fields={"code": True})} + with mock.patch("lifecycle.get_computer", return_value=COMP), \ + mock.patch("deskclient.observe_auth", return_value=otp), \ + mock.patch("deskclient.auth_submit_challenge", return_value={"ok": True}) as submit, \ + mock.patch("deskclient.screenshot_b64", return_value=None), \ + mock.patch("auth_attempts.time.time", return_value=1_000_000.0): + out = auth_attempts.advance_attempt(a["id"]) + assert submit.call_count == 1, submit.call_count + assert out["status"] == "awaiting_human", out + assert store.get_handoff(out["current_handoff_id"])["kind"] == "otp" + + def test_handoff_submit_carries_credential_domains(): _cleanup() store.upsert_credential("c_1", "cred", "u", "secret", None, None, ["example.com"]) From 8624dbeeecc1b6469c606a50ff0bc97026b0417e Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:10:26 +0000 Subject: [PATCH 088/126] Check the page gate before advancing a desk-solved page challenge. At a wait timeout, reobserve_if_solved advanced the attempt as soon as deskd's text observation showed no challenge, but that observation misses iframe gates such as LinkedIn's checkpoint captcha. For a verify_page child it now also requires the same captcha/gate page check the Assist "I'm done" button uses. advance_attempt also failed the attempt on a single 502 or 423 from /auth/observe, which is routine right after a human finishes a challenge; it retries once first. Both callers share one unwrap of the observe answer. --- control-plane/auth_attempts.py | 39 ++++++++++++++++++++++++---------- tests/test_auth_attempts.py | 24 +++++++++++++++++++++ tests/test_auth_wait.py | 28 ++++++++++++++++++++++-- 3 files changed, 78 insertions(+), 13 deletions(-) diff --git a/control-plane/auth_attempts.py b/control-plane/auth_attempts.py index 157396d..fb74cca 100644 --- a/control-plane/auth_attempts.py +++ b/control-plane/auth_attempts.py @@ -288,6 +288,15 @@ def cancel_attempt(attempt_id, expected_revision=None): return _cas_or_conflict(attempt_id, row["status"], "cancelled", rev) +def _observation(resp): + """The observation inside a deskd /auth/observe answer, or None.""" + if not isinstance(resp, dict): + return None + if resp.get("observation") is not None: + return resp["observation"] + return resp if "challenge_signals" in resp else None + + def reobserve_if_solved(attempt_id): """Humans can clear a challenge directly on the desk (the Drive UI has no Assist surface), which bumps nothing — the attempt would wait forever. @@ -304,14 +313,19 @@ def reobserve_if_solved(attempt_id): computer = get_computer(row["computer_id"]) if row_get(computer, "state") != "running": return None - resp = observe_auth(computer) + observation = _observation(observe_auth(computer)) except Exception: return None - observation = (resp or {}).get("observation") if isinstance(resp, dict) else None - if observation is None and isinstance(resp, dict) and "challenge_signals" in resp: - observation = resp if observation is None or _classify_kind(observation): return None # challenge still up (or unreadable) — keep waiting + child = store.get_handoff(row["current_handoff_id"]) if row["current_handoff_id"] else None + if child is not None and child["status"] in HANDOFF_LIVE: + import handoffs # cycle: handoffs → auth_attempts on answer paths + # deskd's text signals miss iframe gates (LinkedIn's checkpoint captcha); a + # page-verify child needs the same page check its "I'm done" gets. + if handoffs._continuation_of(child) == "verify_page" and \ + handoffs._page_still_challenged(computer): + return None try: return advance_attempt(attempt_id, observation=observation) except ApiError: @@ -648,13 +662,16 @@ def advance_attempt(attempt_id, expected_revision=None, observation=None, _depth computer = get_computer(row["computer_id"]) if observation is None: - try: - resp = observe_auth(computer) - observation = (resp or {}).get("observation") if isinstance(resp, dict) else None - if observation is None and isinstance(resp, dict) and "challenge_signals" in resp: - observation = resp - except Exception as e: - return fail_attempt(attempt_id, reason=f"observe_failed:{type(e).__name__}") + # One retry: right after a human finishes a challenge the page is still + # navigating (502) or deskd is mid-injection (423). + for retry in (True, False): + try: + observation = _observation(observe_auth(computer)) + break + except Exception as e: + if not (retry and isinstance(e, ApiError) and e.status in (502, 423)): + return fail_attempt(attempt_id, reason=f"observe_failed:{type(e).__name__}") + time.sleep(1.0) kind = _classify_kind(observation) diff --git a/tests/test_auth_attempts.py b/tests/test_auth_attempts.py index 807d6b8..35bb897 100644 --- a/tests/test_auth_attempts.py +++ b/tests/test_auth_attempts.py @@ -377,6 +377,30 @@ def test_challenge_completion_does_not_record_success_until_prove(): adv.assert_called_once_with(a["id"]) +def test_one_transient_observe_error_is_retried(): + _cleanup() + a = auth_attempts.start_attempt("c_1", "github", "https://example.com/login") + clean = {"ok": True, "observation": _obs(href="https://example.com/home")} + with mock.patch("lifecycle.get_computer", return_value=COMP), \ + mock.patch("deskclient.observe_auth", side_effect=[ + ApiError(502, "observe_error", "Target closed"), clean]), \ + mock.patch("auth_attempts.time.sleep"), \ + mock.patch.object(store, "record_credential_result"): + out = auth_attempts.advance_attempt(a["id"]) + assert out["status"] == "unverified", out + + _cleanup() + a = auth_attempts.start_attempt("c_1", "github", "https://example.com/login") + with mock.patch("lifecycle.get_computer", return_value=COMP), \ + mock.patch("deskclient.observe_auth", + side_effect=ApiError(502, "observe_error", "Target closed")) as observe, \ + mock.patch("auth_attempts.time.sleep"), \ + mock.patch.object(store, "record_credential_result"): + out = auth_attempts.advance_attempt(a["id"]) + assert out["status"] == "failed" and observe.call_count == 2, out + assert out["fail_reason"] == "observe_failed:ApiError", out + + def test_totp_submit_carries_credential_domains(): _cleanup() store.upsert_credential("c_1", "cred", "u", "secret", "JBSWY3DPEHPK3PXP", diff --git a/tests/test_auth_wait.py b/tests/test_auth_wait.py index 27960fb..4553fe6 100644 --- a/tests/test_auth_wait.py +++ b/tests/test_auth_wait.py @@ -277,11 +277,11 @@ async def cancel_soon(): assert out["attempt"]["status"] == "cancelled" -def _awaiting_with_child(hid): +def _awaiting_with_child(hid, kind="otp"): a = auth_attempts.start_attempt("c_1", "github", "https://example.com/login") auth_attempts._cas_or_conflict(a["id"], "created", "advancing", 0) store.insert_handoff( - hid, "c_1", "otp", "enter code", None, "github", + hid, "c_1", kind, "enter code", None, "github", domain="example.com", attempt_id=a["id"], sequence=1, revision=1) store.set_attempt_handoff(a["id"], hid) auth_attempts._cas_or_conflict(a["id"], "advancing", "awaiting_human", 1) @@ -334,6 +334,30 @@ async def go(): assert store.get_handoff("h_settings_page")["status"] == "completed" +def test_wait_timeout_page_verify_child_needs_the_gate_closed(): + # deskd's text signals miss an iframe captcha gate; the page check must agree too. + _cleanup() + aid, rev = _awaiting_with_child("h_gate", kind="captcha") + clean = {"ok": True, "observation": {"challenge_signals": [], "visible_fields": {}}} + + async def go(still_challenged): + events.set_loop(asyncio.get_running_loop()) + with mock.patch("lifecycle.get_computer", return_value=COMP), \ + mock.patch("deskclient.observe_auth", return_value=clean), \ + mock.patch.object(handoffs, "_page_still_challenged", + return_value=still_challenged): + return await auth_attempts.wait_attempt( + aid, after_revision=rev, timeout_s=1) + + out = _run(go(True)) + assert out["wait_status"] == "timeout", out + assert out["attempt"]["status"] == "awaiting_human", out + assert store.get_handoff("h_gate")["status"] == "pending" + out = _run(go(False)) + assert out["attempt"]["status"] == "unverified", out + assert store.get_handoff("h_gate")["status"] == "completed" + + def test_wait_timeout_challenge_still_up_keeps_waiting(): _cleanup() aid, rev = _awaiting_with_child("h_still_up") From 2d8090bbb77229998ff79ef59444f3d6059bd7d6 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:11:25 +0000 Subject: [PATCH 089/126] Raise a late "check your email" wall as a device challenge. The post-login probe counted "check your email" as an OTP wall and raised an otp (submit_value) handoff, while deskd tags the same page email_verify and advance_attempt raises it as device (verify_page). A page that only says "check your email" is now a device challenge; one that also asks for a code is still otp. --- control-plane/login_flow.py | 11 ++++++----- tests/test_blockers.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/control-plane/login_flow.py b/control-plane/login_flow.py index 2064726..170629c 100644 --- a/control-plane/login_flow.py +++ b/control-plane/login_flow.py @@ -80,23 +80,24 @@ def _post_login_challenge(row, cid, name, url, attempt_id=None): "const challengePath=path.split('/').some(" "p=>/^(codeentry|challenge|checkpoint)$/i.test(p));" "const otp=/two.?factor|\\b2fa\\b|one.?time|verification code|authentication code|" - "enter the code|\\b\\d\\s?-?\\s?digit code|check your email/i.test(text)" - "||challengePath;" - "return {href, text:text.slice(0,240), otp};" + "enter the code|\\b\\d\\s?-?\\s?digit code/i.test(text)||challengePath;" + "const email=/check your email/i.test(text);" + "return {href, text:text.slice(0,240), otp, email};" "})()" ) deadline = time.time() + 12.0 while time.time() < deadline: v = eval_value(row, probe, timeout_s=8) v = v if isinstance(v, dict) else {} - if v.get("otp"): + if v.get("otp") or v.get("email"): prompt = f"{links.normalize_domain(url)}: {str(v.get('text') or 'enter the code')[:160]}" try: shot = screenshot_b64(row) except Exception: shot = None + # An email-only wall is deskd's email_verify: a live-desk (device) challenge. pub = auth_attempts.raise_challenge( - attempt_id, "otp", prompt, screenshot=shot, + attempt_id, "otp" if v.get("otp") else "device", prompt, screenshot=shot, domain=links.normalize_domain(url)) return auth_attempts.login_result(pub) time.sleep(0.8) diff --git a/tests/test_blockers.py b/tests/test_blockers.py index 2613925..4191a77 100644 --- a/tests/test_blockers.py +++ b/tests/test_blockers.py @@ -35,6 +35,21 @@ def test_late_challenge_probe_matches_path_not_full_url(): assert result["status"] == "handoff_pending", result +def test_late_check_your_email_wall_is_a_device_challenge(): + # deskd tags this page email_verify, which advance_attempt raises as device. + for probe_out, kind in (({"otp": False, "email": True, "text": "Check your email"}, "device"), + ({"otp": True, "email": True, "text": "Enter the code"}, "otp")): + with mock.patch.object(login_flow, "eval_value", return_value=probe_out), \ + mock.patch.object(login_flow, "screenshot_b64", return_value=None), \ + mock.patch.object(cased.auth_attempts, "raise_challenge", + return_value={"id": "a_1", "revision": 2, + "status": "awaiting_human", + "current_handoff_id": "h_1"}) as raise_: + login_flow._post_login_challenge( + ROW, "c_1", "instagram.com", "https://instagram.com/", attempt_id="a_1") + assert raise_.call_args.args[1] == kind, (probe_out, raise_.call_args) + + def test_active_attempt_reuses_its_pending_handoff(): active = {"id": "a_1", "current_handoff_id": "h_live"} live = {"id": "h_live", "status": "pending"} From 9154cd3b6386c00f574dc0f80bceab1787f56e52 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:12:39 +0000 Subject: [PATCH 090/126] Judge an advance-path captcha solve by the gate when deskd holds no login. The captcha hook advance_attempt uses always resumes the deskd login, but by then deskd has usually let go of it and /login/resume answers 409 no_pending_login. The solve, already injected and verified, was refunded as bad and a human got a captcha handoff for a cleared page. A 409 no_pending_login now takes the resume=False path: success when the gate is closed. --- control-plane/login_flow.py | 17 +++++++++++++---- tests/test_captcha.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/control-plane/login_flow.py b/control-plane/login_flow.py index 170629c..6d6eb0d 100644 --- a/control-plane/login_flow.py +++ b/control-plane/login_flow.py @@ -13,6 +13,7 @@ import handoffs import links from config import log +from errors import ApiError from deskclient import desk_json, eval_js, eval_value, screenshot_b64 from store import HANDOFF_LIVE, store from util import row_get @@ -240,7 +241,18 @@ def _try_captcha_auto(row, cid, name, resume=True, record=True): if captcha_id: captcha.report(captcha_id) return None - if not resume: + resumed = None + if resume: + # Verified clean, only now approve the deskd login hold. + try: + resumed = desk_json(row, "POST", "/login/resume", + json={"value": "approve"}, timeout=25) + except ApiError as e: + # The advance path runs after deskd let go of the login: same as + # resume=False, not a bad solve. + if e.status != 409 or e.code != "no_pending_login": + raise + if resumed is None: # No deskd login hold to approve. Require the gate itself to be closed # before calling this a login, verify text alone can be clean on a page # that is still a checkpoint shell. @@ -255,9 +267,6 @@ def _try_captcha_auto(row, cid, name, resume=True, record=True): "status": "success"}) log.info("captcha_auto=ok path=gate") return {"status": "success", "captcha_auto": True} - # Verified clean, only now approve the deskd login hold. - resumed = desk_json(row, "POST", "/login/resume", - json={"value": "approve"}, timeout=25) if resumed.get("status") == "failed": log.info("captcha_auto=fail reason=resume_failed") if record: diff --git a/tests/test_captcha.py b/tests/test_captcha.py index f27992f..e1d0ffe 100644 --- a/tests/test_captcha.py +++ b/tests/test_captcha.py @@ -716,6 +716,39 @@ def eval_side_effect(row, expr, timeout_s=15): rec.assert_called_once_with("c_1", "linkedin.com", "failed") +def test_try_captcha_auto_no_pending_login_takes_the_gate_path(): + """The advance hook resumes, but deskd may no longer hold the login (409): a + verified solve is then judged by the gate, not refunded and handed to a human.""" + import login_flow + from errors import ApiError + + row = {"id": "c_1", "desk_port": 1, "desk_token": "t"} + + def eval_side_effect(row, expr, timeout_s=15): + if expr == captcha.DETECT_JS: + return _detect_ok() + if expr == "location.href": + return {"ok": True, "value": "https://x/checkpoint"} + if expr == captcha.VERIFY_JS: + return {"ok": True, "value": {"text": "Welcome home", "hasPassword": False}} + if expr == captcha.GATE_JS: + return {"ok": True, "value": {"gated": False}} + return {"ok": True, "value": {"ok": True, "method": "recaptcha_callback"}} + + with mock.patch("login_flow.captcha.enabled", return_value=True), \ + mock.patch("deskclient.eval_js", side_effect=eval_side_effect), \ + mock.patch("login_flow.eval_js", side_effect=eval_side_effect), \ + mock.patch("login_flow.captcha.solve_if_capable", + return_value={"id": "42", "token": "tok"}), \ + mock.patch("login_flow.desk_json", side_effect=ApiError( + 409, "no_pending_login", "no login is waiting on a handoff")), \ + mock.patch("login_flow.captcha.report") as report, \ + mock.patch("login_flow._settle_after_inject"): + out = login_flow._try_captcha_auto(row, "c_1", "x.com", resume=True, record=False) + assert out == {"status": "success", "captcha_auto": True}, out + report.assert_not_called() + + def _seed_login_computer(cid="c_1"): """Durable login needs a store computer (raise_challenge → get_computer).""" from store import store From 609587110b637b97a6d839b84da0087a7d8ccbad Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:13:23 +0000 Subject: [PATCH 091/126] Keep a click's result when its follow-up snapshot fails. On the stamped settle path, _settled_snapshot called snapshot() without the ApiError guard the unstamped path has, so a 502 while the page navigated raised out of click_element (and fill with submit) after the OS click had already fired, and the agent saw an error for a click that happened. All four snapshot calls now go through one guarded helper and the action returns without a snapshot instead. --- control-plane/browse.py | 22 ++++++++++++---------- tests/test_browse.py | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/control-plane/browse.py b/control-plane/browse.py index ebb012c..5ca0aa3 100644 --- a/control-plane/browse.py +++ b/control-plane/browse.py @@ -214,6 +214,14 @@ def _stamp(row): return False +def _snapshot_or_none(row): + # the action already happened; a snapshot lost to navigation churn is no reason to raise + try: + return snapshot(row) + except ApiError: + return None + + def _settled_snapshot(row, stamped, settle_s=1.0, budget_s=8.0): """The page as it stands once the action stops moving it.""" if not stamped: @@ -229,10 +237,7 @@ def _settled_snapshot(row, stamped, settle_s=1.0, budget_s=8.0): continue if r.get("value") == "complete": break - try: - return snapshot(row) - except ApiError: - return None + return _snapshot_or_none(row) deadline = time.time() + budget_s grace = time.time() + settle_s while time.time() < deadline: @@ -248,18 +253,15 @@ def _settled_snapshot(row, stamped, settle_s=1.0, budget_s=8.0): break navigated, ready = v[0], v[1] if navigated and ready == "complete": - return snapshot(row) + return _snapshot_or_none(row) if not navigated and time.time() >= grace: - fresh = snapshot(row) + fresh = _snapshot_or_none(row) try: eval_js(row, f"delete {_STAMP}", 3) except ApiError: pass return fresh - try: - return snapshot(row) - except ApiError: - return None + return _snapshot_or_none(row) def _attach_snapshot(row, res, want, stamped): diff --git a/tests/test_browse.py b/tests/test_browse.py index 67e572f..c37bc00 100644 --- a/tests/test_browse.py +++ b/tests/test_browse.py @@ -421,6 +421,20 @@ def test_click_that_changes_nothing_still_returns_the_current_page(): assert any(UNSTAMP in c for c in browse.eval_js.calls), browse.eval_js.calls +def test_click_survives_a_snapshot_lost_to_navigation(): + # the OS click already fired; a 502 while snapshotting must not turn it into an error + browse.eval_js = fake_eval_map({ + LOCATE: [{"ok": True, "value": {"ok": True, "name": "Go", "tag": "div", "x": 1, "y": 2}}], + STAMP: [{"ok": True, "value": 1}], + POLL: [{"ok": True, "value": [True, "complete"]}], + SNAP: [ApiError(502, "eval_error", "Target closed")], + }) + browse.desk_json = fake_desk({"ok": True}) + out = browse.click_element(ROW, 0, name="Go") + assert out["ok"] and out["clicked"] == "Go", out + assert "snapshot" not in out, out + + def test_click_snapshot_can_be_turned_off(): browse.eval_js = fake_eval({"ok": True, "value": {"ok": True, "name": "n", "tag": "a", "x": 1, "y": 2}}) From 7fb8f7c3511346a5ba73ce2d2454159d273936df Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:13:35 +0000 Subject: [PATCH 092/126] Refuse wait_for with network_idle plus a selector or text. The tool asks for exactly one condition, but wait_for accepted both and then compared the selector's true/false result for stability, so it reported ok after two polls for an element that never appeared. The combination is now a 400. --- control-plane/browse.py | 2 ++ tests/test_browse.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/control-plane/browse.py b/control-plane/browse.py index 5ca0aa3..3a1bc24 100644 --- a/control-plane/browse.py +++ b/control-plane/browse.py @@ -514,6 +514,8 @@ def wait_for(row, selector=None, text=None, gone=False, network_idle=False, time LLM turn per poll — the same trade navigate() already makes.""" if not (selector or text or network_idle): raise ApiError(400, "bad_request", "need selector, text or network_idle") + if network_idle and (selector or text): + raise ApiError(400, "bad_request", "network_idle cannot be combined with selector or text") if selector: cond = f"!!document.querySelector({json.dumps(selector)})" elif text: diff --git a/tests/test_browse.py b/tests/test_browse.py index c37bc00..a568b28 100644 --- a/tests/test_browse.py +++ b/tests/test_browse.py @@ -246,6 +246,18 @@ def test_wait_needs_a_condition(): assert False +def test_wait_network_idle_with_a_selector_is_refused(): + # the selector's boolean went stable too, so this reported ok for a missing element + browse.eval_js = fake_eval({"ok": True, "value": False}) + for kw in ({"selector": "#never"}, {"text": "never"}): + try: + browse.wait_for(ROW, network_idle=True, timeout_s=5, **kw) + except ApiError as e: + assert e.status == 400, e + continue + assert False, kw + + # ---------- tabs ---------- def test_tabs_list_filters_pages_and_marks_active(): From bf3518fd6e7851a851370e9b160a1fddf0cc53b1 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:14:27 +0000 Subject: [PATCH 093/126] Name the stored element the same way the walk names it. _locate's fast path re-derived the stored element's name from aria-label, placeholder and innerText only, while the walk also uses a submit or button input's value, then title and alt. For those elements the names never matched, so the stored handle was never used and every click re-walked the page. Both now call one __name helper in the walk. --- control-plane/browse.py | 12 ++++++++---- tests/test_browse.py | 12 ++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/control-plane/browse.py b/control-plane/browse.py index 3a1bc24..8d416a8 100644 --- a/control-plane/browse.py +++ b/control-plane/browse.py @@ -73,6 +73,12 @@ if(!hit)return false; return hit!==el&&!el.contains(hit)&&!hit.contains(el); }; +const __name=el=>{ + const tag=el.tagName.toLowerCase(); + return (el.getAttribute('aria-label')||el.placeholder|| + ((tag==='input'&&(el.type==='submit'||el.type==='button'))?el.value:'')|| + el.innerText||el.title||el.alt||'').trim().replace(/\\s+/g,' ').slice(0,80); +}; const __contained=r=>__boxes.some(b=>r.x>=b.x&&r.x+r.w<=b.x+b.w&&r.y>=b.y&&r.y+r.h<=b.y+b.h); const __topRect=el=>{ let r=el.getBoundingClientRect(),x=r.left,y=r.top; @@ -92,9 +98,7 @@ __seen.add(el); __boxes.push(tr); const tag=el.tagName.toLowerCase(); - const name=(el.getAttribute('aria-label')||el.placeholder|| - ((tag==='input'&&(el.type==='submit'||el.type==='button'))?el.value:'')|| - el.innerText||el.title||el.alt||'').trim().replace(/\\s+/g,' ').slice(0,80); + const name=__name(el); const bx=(window.outerWidth-window.innerWidth)/2; __els.push({el,tag,type:(el.type||el.getAttribute('role')||''),name, value:('value'in el&&!__secretish(el)&&tag!=='button')?String(el.value).slice(0,40):'', @@ -177,7 +181,7 @@ def _locate(row, ref, name, timeout_s=15): const stored=window.__caseEls&&window.__caseEls.els&&window.__caseEls.els[__i]; if(stored&&stored.isConnected){ const tag=stored.tagName.toLowerCase(); - const nm=(stored.getAttribute('aria-label')||stored.placeholder||stored.innerText||'').trim().replace(/\\s+/g,' ').slice(0,80); + const nm=__name(stored); if(__nameOk(nm))return __coords(stored,nm,tag,false,__i,__i); } if(__i>=0&&__i<__els.length){ diff --git a/tests/test_browse.py b/tests/test_browse.py index a568b28..e9cce94 100644 --- a/tests/test_browse.py +++ b/tests/test_browse.py @@ -526,6 +526,18 @@ def test_locate_script_prefers_stored_handle_then_heal(): assert "healed" in src +def test_locate_fast_path_names_elements_like_the_walk(): + # a submit input's value, an image's alt or a title never matched the stored + # handle's own shorter name, so those clicks always fell back to a re-walk + browse.eval_js = fake_eval({"ok": True, "value": { + "ok": True, "name": "x", "tag": "a", "x": 1, "y": 1}}) + browse.desk_json = fake_desk({"ok": True}) + browse.click_element(ROW, 0, name="x", snapshot_after=False) + src = browse.eval_js.calls[0] + assert "const nm=__name(stored)" in src and "const name=__name(el)" in src + assert src.count("el.alt") == 1, src + + def test_click_activates_a_new_tab(): listing0 = '[{"type":"page","id":"AA11","title":"One","url":"https://a"}]' listing1 = ('[{"type":"page","id":"BB22","title":"Two","url":"https://b"},' From 9ef6ac23a79b8932b507e981ad6f9c967718804f Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:14:38 +0000 Subject: [PATCH 094/126] Quote the proof selector with json.dumps. check_proof escaped backslashes and single quotes by hand, so a selector with a newline or other control character produced a JS syntax error and the proof silently failed. It now embeds the selector as a JSON string literal, the same way browse.wait_for does. --- control-plane/auth_attempts.py | 4 ++-- tests/test_auth_attempts.py | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/control-plane/auth_attempts.py b/control-plane/auth_attempts.py index fb74cca..59488db 100644 --- a/control-plane/auth_attempts.py +++ b/control-plane/auth_attempts.py @@ -552,9 +552,9 @@ def check_proof(computer, proof_spec, observation=None): return False if "selector" in predicates: - sel = predicates["selector"].replace("\\", "\\\\").replace("'", "\\'") + sel = json.dumps(predicates["selector"]) try: - out = eval_js(computer, f"!!document.querySelector('{sel}')", timeout_s=10) + out = eval_js(computer, f"!!document.querySelector({sel})", timeout_s=10) val = (out or {}).get("value") if isinstance(out, dict) else out if not val: return False diff --git a/tests/test_auth_attempts.py b/tests/test_auth_attempts.py index 35bb897..2122446 100644 --- a/tests/test_auth_attempts.py +++ b/tests/test_auth_attempts.py @@ -273,6 +273,14 @@ def test_prove_with_proof_spec_authenticated(): rec.assert_called_with("c_1", "github", "success") +def test_proof_selector_is_json_quoted_into_the_expression(): + sel = "a[title='x\ny']" + with mock.patch("deskclient.eval_value", return_value="https://example.com/home"), \ + mock.patch("deskclient.eval_js", return_value={"ok": True, "value": True}) as ev: + assert auth_attempts.check_proof(COMP, {"selector": sel}) is True + assert ev.call_args.args[1] == f"!!document.querySelector({json.dumps(sel)})", ev.call_args + + def test_captcha_then_otp_one_attempt(): """Two advances on one attempt: captcha handoff, then otp handoff, then prove.""" _cleanup() From 45f5838a4499d1e4cf2e936c4b857c3300691f34 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:15:06 +0000 Subject: [PATCH 095/126] Correct the comment on who can mint links. It said minting was loopback-only and that the agent's token could not answer handoffs or mint links. Nothing enforces either: the route sits behind CASE_TOKEN like the rest of the API and Drive mints over the compose network. The comment now says so. --- control-plane/cased.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/control-plane/cased.py b/control-plane/cased.py index 4dc78d7..07ccfc2 100644 --- a/control-plane/cased.py +++ b/control-plane/cased.py @@ -621,8 +621,9 @@ def delete_credential(cid: str, name: str): # ---------- human links (fill + desk) ---------- # Minted URLs are the only human auth on a box: no accounts, no sessions. -# Minting stays loopback-only (bin/case), because the agent's token must not -# be able to answer handoffs or mint its own links. +# Minting is an ordinary route behind CASE_TOKEN, not a loopback-only one (Drive +# mints over the compose network), so any caller holding that token, the +# agent's included, can mint links and answer handoffs. @app.post("/v1/computers/{cid}/links", status_code=201) def mint_link(cid: str, body: dict = Body(...)): From 2dfcda73fb71ef5d1b94b05e47a80b0501351041 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:16:03 +0000 Subject: [PATCH 096/126] Claim a challenge in one write. Claiming a handoff for validation took two compare-and-set writes, one from pending to validating and a second from validating to validating just to set the answer marker. claim_challenge now writes the marker in the same CAS. --- control-plane/auth_attempts.py | 6 +++--- control-plane/handoffs.py | 8 +++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/control-plane/auth_attempts.py b/control-plane/auth_attempts.py index 59488db..b7dfd3d 100644 --- a/control-plane/auth_attempts.py +++ b/control-plane/auth_attempts.py @@ -398,13 +398,13 @@ def _matches(type_, data): unsubscribe(q) -def claim_challenge(handoff_id, expected_revision): - """CAS handoff pending → validating (Assist / answer path scaffolding).""" +def claim_challenge(handoff_id, expected_revision, answer=None): + """CAS handoff pending → validating, writing `answer` (never an OTP) in the same step.""" row = store.get_handoff(handoff_id) if not row: raise ApiError(404, "not_found", f"handoff {handoff_id} not found") n = store.cas_handoff_status( - handoff_id, "pending", "validating", int(expected_revision)) + handoff_id, "pending", "validating", int(expected_revision), answer=answer) if n != 1: raise ApiError(409, "revision_conflict", "handoff revision or status changed") diff --git a/control-plane/handoffs.py b/control-plane/handoffs.py index 7160969..aa8dad9 100644 --- a/control-plane/handoffs.py +++ b/control-plane/handoffs.py @@ -264,13 +264,11 @@ def _complete(hid, *, answer=None, value_present=False): def _claim_validating(row, answer): """CAS pending → validating (claim_challenge) before desk work.""" - rev = int(row["revision"] or 0) import auth_attempts # cycle: auth_attempts → handoffs on raise_challenge - auth_attempts.claim_challenge(row["id"], rev) # Never park an OTP in validating.answer, soft-fail / restart paths read this row. - stored = _durable_answer(row["kind"], _continuation_of(row), answer) - store.transition_handoff(row["id"], "validating", answer=stored) - return store.get_handoff(row["id"]) + auth_attempts.claim_challenge( + row["id"], int(row["revision"] or 0), + answer=_durable_answer(row["kind"], _continuation_of(row), answer)) def _continue_attempt(attempt_id): From 5b3bcd01fe01774b281ec4c51e6072c0cec86262 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:16:34 +0000 Subject: [PATCH 097/126] Upload files to the page in 256 KiB chunks. computer_upload moved the file into the page 6000 base64 characters per /eval, about 1170 round trips for 5 MiB, and each append returned the whole growing buffer as its eval value. deskd's /eval has no body cap and CDP carries multi-megabyte messages, so chunks are now 256 KiB and each append evaluates to 0. The bytes that reach the file input are the same. --- control-plane/browse.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/control-plane/browse.py b/control-plane/browse.py index 8d416a8..7493a1a 100644 --- a/control-plane/browse.py +++ b/control-plane/browse.py @@ -393,7 +393,9 @@ def hover(row, ref, name=None): UPLOAD_MAX = 5 * 1024 * 1024 -_UPLOAD_CHUNK = 6000 +# Base64 chars per /eval. deskd's /eval has no body cap of its own and CDP takes +# multi-MB messages; 256 KiB keeps a 5 MiB file to ~27 round trips instead of ~1170. +_UPLOAD_CHUNK = 256 * 1024 def upload(row, ref, path, name=None): @@ -430,7 +432,8 @@ def upload(row, ref, path, name=None): data = base64.b64encode(raw).decode("ascii") eval_js(row, "window.__caseUp=''", 5) for i in range(0, len(data), _UPLOAD_CHUNK): - eval_js(row, "window.__caseUp+=%s" % json.dumps(data[i:i + _UPLOAD_CHUNK]), 10) + # ;0 so the eval does not echo the whole buffer back on every chunk + eval_js(row, "window.__caseUp+=%s;0" % json.dumps(data[i:i + _UPLOAD_CHUNK]), 10) done = eval_js(row, _iife(f""" const __i={use_ref}; const stored=window.__caseEls&&window.__caseEls.els&&window.__caseEls.els[__i]; From 6bfbadc0cf17569f1e52dfff340cd1211baf90df Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:18:27 +0000 Subject: [PATCH 098/126] Parse the Cookie header in one place. assist, links and cased each split the Cookie header with their own copy of the same comprehension. links.cookie is now the one parser. --- control-plane/assist.py | 8 ++------ control-plane/cased.py | 9 ++------- control-plane/links.py | 9 +++++++-- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/control-plane/assist.py b/control-plane/assist.py index e98153c..c760e3d 100644 --- a/control-plane/assist.py +++ b/control-plane/assist.py @@ -26,10 +26,6 @@ def _hash(raw): return hashlib.sha256(raw.encode()).hexdigest() -def _cookies(cookie_header): - return dict(p.strip().split("=", 1) for p in (cookie_header or "").split(";") if "=" in p) - - def _attempt_id_of(handoff): return row_get(handoff, "attempt_id") @@ -144,8 +140,8 @@ def resolve_view(raw_token, cookie_header=""): view_dict keys: bound, handoff, attempt, status, revision, kind, continuation, instructions, allowed_actions. """ - cookies = _cookies(cookie_header) - sess = cookies.get(COOKIE, "") + import links + sess = links.cookie(cookie_header, COOKIE) th = _hash(raw_token) row = store.get_assist_by_token_hash(th) set_sess = None diff --git a/control-plane/cased.py b/control-plane/cased.py index 07ccfc2..d09dc24 100644 --- a/control-plane/cased.py +++ b/control-plane/cased.py @@ -684,13 +684,8 @@ async def fill_submit(token: str, request: Request): # ---------- Assist door (public /assist/*, token is the auth; no MCP bearer) ---------- -def _cookie_value(cookie_header, name): - return dict(p.strip().split("=", 1) - for p in (cookie_header or "").split(";") if "=" in p).get(name, "") - - def _assist_cookie(request): - return _cookie_value(request.headers.get("cookie"), assist.COOKIE) + return links.cookie(request.headers.get("cookie"), assist.COOKIE) def _assist_set_cookie(set_sess): @@ -844,7 +839,7 @@ def desk_check_ep(request: Request): cookie = request.headers.get("cookie", "") link, set_tok = links.desk_check(uri, cookie) if not link: - handoff = assist.valid_session(_cookie_value(cookie, assist.COOKIE)) + handoff = assist.valid_session(links.cookie(cookie, assist.COOKIE)) if handoff: link, set_tok = {"computer_id": handoff["computer_id"], "kind": "assist", "expires_at": None, "token": None}, None diff --git a/control-plane/links.py b/control-plane/links.py index 1372e9a..a6316f8 100644 --- a/control-plane/links.py +++ b/control-plane/links.py @@ -135,6 +135,12 @@ def strip_token(uri, prefix="/desk/"): return path + ("?" + urlencode(q) if q else "") +def cookie(cookie_header, name): + """One cookie's value from a raw Cookie header, "" when absent.""" + return dict(p.strip().split("=", 1) + for p in (cookie_header or "").split(";") if "=" in p).get(name, "") + + def desk_check(forwarded_uri, cookie_header): """Forward-auth contract for /desk/*: returns (link_row|None, set_cookie_token|None). @@ -148,8 +154,7 @@ def desk_check(forwarded_uri, cookie_header): row = valid(q, "vnc") if q else None if row: return row, q - cookies = dict(p.strip().split("=", 1) for p in (cookie_header or "").split(";") if "=" in p) - tok = cookies.get("case_desk", "") + tok = cookie(cookie_header, "case_desk") if tok: v = valid(tok, "vnc") if v: From ff0b604353b8622f77f1025c750ac2fe003c3bad Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:18:38 +0000 Subject: [PATCH 099/126] Use handoffs' continuation and attempt-id helpers in assist. assist kept its own continuation_of and _attempt_id_of, copies of the helpers handoffs already has. assist now calls the handoffs ones. --- control-plane/assist.py | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/control-plane/assist.py b/control-plane/assist.py index c760e3d..d0c0cf6 100644 --- a/control-plane/assist.py +++ b/control-plane/assist.py @@ -26,10 +26,6 @@ def _hash(raw): return hashlib.sha256(raw.encode()).hexdigest() -def _attempt_id_of(handoff): - return row_get(handoff, "attempt_id") - - def mint_assist_token(handoff_id): """Mint an exchange token for a handoff. Returns (raw_token, expires_at). Only the SHA-256 hash is stored, plaintext rides the email link once.""" @@ -51,9 +47,10 @@ def exchange(raw_token): raise ApiError(410, "gone", "assist link expired") handoff = store.get_handoff(row["handoff_id"]) if not handoff or handoff["status"] not in HANDOFF_LIVE: + import handoffs # Attempt-scoped: allow exchange while the attempt still needs a human, # even if this mint's handoff row already moved on (rare race). - aid = _attempt_id_of(handoff) if handoff else None + aid = handoffs._attempt_id_of(handoff) attempt = store.get_auth_attempt(aid) if aid else None if not attempt: raise ApiError(410, "gone", "handoff is no longer open") @@ -84,10 +81,11 @@ def session_view(raw_session): row = _session_row(raw_session) if not row: return None + import handoffs bound = store.get_handoff(row["handoff_id"]) if not bound: return None - aid = _attempt_id_of(bound) + aid = handoffs._attempt_id_of(bound) attempt = store.get_auth_attempt(aid) if aid else None if bound["status"] in HANDOFF_LIVE and not attempt: @@ -161,21 +159,16 @@ def resolve_view(raw_token, cookie_header=""): return build_view(bound, current, attempt), set_sess -def continuation_of(handoff): - import handoffs - cont = row_get(handoff, "continuation") - return cont or handoffs.continuation_for(handoff["kind"]) - - def allowed_actions_for(handoff, attempt): """Typed Assist actions for the current phase, never implies secrets.""" + import handoffs if attempt and attempt["status"] in AUTH_ATTEMPT_TERMINAL: return [] if attempt and attempt["status"] == "proving": return [] if not handoff or handoff["status"] not in HANDOFF_LIVE: return [] - cont = continuation_of(handoff) + cont = handoffs._continuation_of(handoff) if cont == "submit_value": return ["submit_value"] if cont == "verify_page": @@ -187,6 +180,7 @@ def allowed_actions_for(handoff, attempt): def build_view(bound, current, attempt): """Public Assist view, no secrets, answers, or URLs.""" + import handoffs handoff = current if attempt and attempt["status"] == "proving": return { @@ -226,7 +220,7 @@ def build_view(bound, current, attempt): # Map legacy answered → completed for Assist surfaces. if status == "answered": status = "completed" - cont = continuation_of(handoff) if handoff and status in HANDOFF_LIVE else None + cont = handoffs._continuation_of(handoff) if handoff and status in HANDOFF_LIVE else None kind = handoff["kind"] if handoff else None instructions = (handoff["prompt"] or "") if handoff else "" revision = int(row_get(handoff, "revision", 0) or 0) if handoff else 0 @@ -268,7 +262,7 @@ def submit_with_session(raw_session, value, expected_revision=None): handoff = valid_session(raw_session) if not handoff: raise ApiError(410, "gone", "assist session invalid or expired") - if continuation_of(handoff) != "submit_value": + if handoffs._continuation_of(handoff) != "submit_value": raise ApiError(409, "bad_status", "current challenge does not accept a code") if expected_revision is not None and int(row_get(handoff, "revision", 0) or 0) != int( expected_revision): @@ -282,7 +276,7 @@ def done_with_session(raw_session, expected_revision=None): handoff = valid_session(raw_session) if not handoff: raise ApiError(410, "gone", "assist session invalid or expired") - if continuation_of(handoff) != "verify_page": + if handoffs._continuation_of(handoff) != "verify_page": raise ApiError(409, "bad_status", "current challenge is not a page verify") if expected_revision is not None and int(row_get(handoff, "revision", 0) or 0) != int( expected_revision): From 846e88bb849526f6b6c43368aa9e3d36b0c2a383 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:19:24 +0000 Subject: [PATCH 100/126] Read a VERIFY_JS answer in one place. handoffs._page_still_challenged and login_flow._try_captcha_auto each unwrapped the VERIFY_JS eval result into page text and a password flag before calling still_challenge. captcha.verify_still_challenged does that once for both. --- control-plane/captcha.py | 9 +++++++++ control-plane/handoffs.py | 9 +-------- control-plane/login_flow.py | 10 +--------- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/control-plane/captcha.py b/control-plane/captcha.py index 1cd3679..b6424c7 100644 --- a/control-plane/captcha.py +++ b/control-plane/captcha.py @@ -386,6 +386,15 @@ def still_challenge(page_text: str, has_password: bool = False) -> bool: return bool(RE_VERIFY.search(blob) or RE_FAIL_VERIFY.search(blob)) +def verify_still_challenged(verify) -> bool: + """still_challenge over a VERIFY_JS eval answer ({value: {text, hasPassword}}).""" + v = verify.get("value") if isinstance(verify, dict) else None + if isinstance(v, dict): + text = v.get("text") if isinstance(v.get("text"), str) else "" + return still_challenge(text, bool(v.get("hasPassword"))) + return still_challenge(v if isinstance(v, str) else "") + + class SolveResult(TypedDict): id: str token: str diff --git a/control-plane/handoffs.py b/control-plane/handoffs.py index aa8dad9..4e693a0 100644 --- a/control-plane/handoffs.py +++ b/control-plane/handoffs.py @@ -396,14 +396,7 @@ def _page_still_challenged(computer_row): except Exception as e: log.warning("handoff verify eval failed: %s", e) return True - v = (verify or {}).get("value") if isinstance(verify, dict) else None - page_blob, has_password = "", False - if isinstance(v, dict): - page_blob = v.get("text") if isinstance(v.get("text"), str) else "" - has_password = bool(v.get("hasPassword")) - elif isinstance(v, str): - page_blob = v - if captcha.still_challenge(page_blob, has_password): + if captcha.verify_still_challenged(verify): return True try: gate = eval_js(computer_row, captcha.GATE_JS, timeout_s=10) diff --git a/control-plane/login_flow.py b/control-plane/login_flow.py index 6d6eb0d..1635616 100644 --- a/control-plane/login_flow.py +++ b/control-plane/login_flow.py @@ -228,15 +228,7 @@ def _try_captcha_auto(row, cid, name, resume=True, record=True): # Settle then verify BEFORE resume, resume would clear state["login"]. _settle_after_inject(row, seconds=9.0) verify = eval_js(row, captcha.VERIFY_JS, timeout_s=15) - v = (verify or {}).get("value") if isinstance(verify, dict) else None - page_blob = "" - has_password = False - if isinstance(v, dict): - page_blob = v.get("text") if isinstance(v.get("text"), str) else "" - has_password = bool(v.get("hasPassword")) - elif isinstance(v, str): - page_blob = v - if captcha.still_challenge(page_blob, has_password): + if captcha.verify_still_challenged(verify): log.info("captcha_auto=fail reason=still_present") if captcha_id: captcha.report(captcha_id) From e6e22c24c8c6f3a1772be4968b46d7ce14df7d0b Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:20:47 +0000 Subject: [PATCH 101/126] Record a login outcome through one helper. Nine places wrote the credential's last status and then emitted login_completed by hand, and prove_attempt had two identical unverified branches. auth_attempts.record_login does the pair, and prove_attempt has one authenticated and one unverified exit. Two handoff tests now watch events.emit, where login_completed is sent from. --- control-plane/auth_attempts.py | 55 ++++++++++------------------------ control-plane/handoffs.py | 15 ++++------ control-plane/login_flow.py | 13 ++------ tests/test_handoffs.py | 4 +-- 4 files changed, 26 insertions(+), 61 deletions(-) diff --git a/control-plane/auth_attempts.py b/control-plane/auth_attempts.py index b7dfd3d..3f8d3f8 100644 --- a/control-plane/auth_attempts.py +++ b/control-plane/auth_attempts.py @@ -130,6 +130,16 @@ def login_result(attempt, reason=None): return {**base, "status": st} +def record_login(computer_id, credential, status, attempt_id=None): + """A definitive login outcome: vault health plus the login_completed event.""" + store.record_credential_result(computer_id, credential, status) + data = {"computer_id": computer_id, "credential": credential, "status": status} + if attempt_id: + data["attempt_id"] = attempt_id + from events import emit + emit("login_completed", data) + + def _require(attempt_id): row = store.get_auth_attempt(attempt_id) if not row: @@ -479,14 +489,7 @@ def fail_attempt(attempt_id, reason=None, expected_revision=None): return attempt_public(row) rev = int(row["revision"] or 0) if expected_revision is None else int(expected_revision) pub = _cas_or_conflict(attempt_id, row["status"], "failed", rev, fail_reason=reason) - store.record_credential_result(row["computer_id"], row["credential"], "failed") - from events import emit - emit("login_completed", { - "computer_id": row["computer_id"], - "credential": row["credential"], - "status": "failed", - "attempt_id": attempt_id, - }) + record_login(row["computer_id"], row["credential"], "failed", attempt_id=attempt_id) return pub @@ -597,42 +600,16 @@ def prove_attempt(attempt_id, expected_revision=None, observation=None): rev = int(row["revision"] or 0) proof_spec = parse_proof_spec(row["proof_spec"]) - from events import emit from lifecycle import get_computer - if not proof_spec: - # Spec: missing proof ends unverified, never authenticated. - pub = _cas_or_conflict(attempt_id, "proving", "unverified", rev) - store.record_credential_result(row["computer_id"], row["credential"], "unverified") - emit("login_completed", { - "computer_id": row["computer_id"], - "credential": row["credential"], - "status": "unverified", - "attempt_id": attempt_id, - }) - return pub - - computer = get_computer(row["computer_id"]) - ok = check_proof(computer, proof_spec, observation=observation) - if ok: + # Spec: missing proof ends unverified, never authenticated. + if proof_spec and check_proof(get_computer(row["computer_id"]), proof_spec, + observation=observation): pub = _cas_or_conflict(attempt_id, "proving", "authenticated", rev) - store.record_credential_result(row["computer_id"], row["credential"], "success") - emit("login_completed", { - "computer_id": row["computer_id"], - "credential": row["credential"], - "status": "success", - "attempt_id": attempt_id, - }) + record_login(row["computer_id"], row["credential"], "success", attempt_id=attempt_id) return pub - pub = _cas_or_conflict(attempt_id, "proving", "unverified", rev) - store.record_credential_result(row["computer_id"], row["credential"], "unverified") - emit("login_completed", { - "computer_id": row["computer_id"], - "credential": row["credential"], - "status": "unverified", - "attempt_id": attempt_id, - }) + record_login(row["computer_id"], row["credential"], "unverified", attempt_id=attempt_id) return pub diff --git a/control-plane/handoffs.py b/control-plane/handoffs.py index 4e693a0..62f3bc9 100644 --- a/control-plane/handoffs.py +++ b/control-plane/handoffs.py @@ -196,9 +196,7 @@ def expire_stale(): continue if ctx: # login handoffs only, a plain approval must never touch the vault # nobody answered: the login did not happen, and the vault says so - store.record_credential_result(ctx["computer_id"], ctx["credential"], "failed") - emit("login_completed", {"computer_id": ctx["computer_id"], - "credential": ctx["credential"], "status": "failed"}) + auth_attempts.record_login(ctx["computer_id"], ctx["credential"], "failed") # An attempt whose challenge was answered elsewhere (or never raised one) has no # handoff to expire, so it would sit `active` forever and 409 every later login. for a in store.stale_active_auth_attempts(cutoff): @@ -290,9 +288,8 @@ def _finish_attempt_child(hid, hrow, *, answer=None, value_present=False, ctx=No _continue_attempt(aid) elif ctx: # Legacy login handoff without an attempt, preserve prior success path. - store.record_credential_result(ctx["computer_id"], ctx["credential"], "success") - emit("login_completed", {"computer_id": ctx["computer_id"], - "credential": ctx["credential"], "status": "success"}) + import auth_attempts # cycle: auth_attempts → handoffs on raise_challenge + auth_attempts.record_login(ctx["computer_id"], ctx["credential"], "success") return done @@ -301,16 +298,14 @@ def _fail_attempt_child(hid, hrow, value, ctx=None): stored = _durable_answer(hrow["kind"], _continuation_of(hrow), value) store.transition_handoff(hid, "failed", answer=stored) aid = _attempt_id_of(hrow) + import auth_attempts # cycle: auth_attempts → handoffs on raise_challenge if aid: try: - import auth_attempts # cycle: auth_attempts → handoffs on raise_challenge auth_attempts.fail_attempt(aid, reason="denied") except Exception as e: log.warning("fail_attempt after deny %s: %s", aid, e) elif ctx: - store.record_credential_result(ctx["computer_id"], ctx["credential"], "failed") - emit("login_completed", {"computer_id": ctx["computer_id"], - "credential": ctx["credential"], "status": "failed"}) + auth_attempts.record_login(ctx["computer_id"], ctx["credential"], "failed") return store.get_handoff(hid) diff --git a/control-plane/login_flow.py b/control-plane/login_flow.py index 1635616..b91fcd7 100644 --- a/control-plane/login_flow.py +++ b/control-plane/login_flow.py @@ -9,7 +9,6 @@ import auth_attempts import captcha -import events import handoffs import links from config import log @@ -254,23 +253,17 @@ def _try_captcha_auto(row, cid, name, resume=True, record=True): captcha.report(captcha_id) return None if record: - store.record_credential_result(cid, name, "success") - events.emit("login_completed", {"computer_id": cid, "credential": name, - "status": "success"}) + auth_attempts.record_login(cid, name, "success") log.info("captcha_auto=ok path=gate") return {"status": "success", "captcha_auto": True} if resumed.get("status") == "failed": log.info("captcha_auto=fail reason=resume_failed") if record: - store.record_credential_result(cid, name, "failed") - events.emit("login_completed", {"computer_id": cid, "credential": name, - "status": "failed"}) + auth_attempts.record_login(cid, name, "failed") # Return failed to caller, do not create a dead handoff. return resumed if isinstance(resumed, dict) else {"status": "failed"} if record: - store.record_credential_result(cid, name, "success") - events.emit("login_completed", {"computer_id": cid, "credential": name, - "status": "success"}) + auth_attempts.record_login(cid, name, "success") log.info("captcha_auto=ok") return {"status": "success", "captcha_auto": True} except Exception as e: diff --git a/tests/test_handoffs.py b/tests/test_handoffs.py index 2b8eed3..6aac73d 100644 --- a/tests/test_handoffs.py +++ b/tests/test_handoffs.py @@ -195,7 +195,7 @@ def test_otp_submit_resume_success_completes_and_pops_ctx(): with mock.patch.object(handoffs, "get_computer", return_value=ROW), \ mock.patch.object(handoffs, "desk_json", return_value={"status": "success"}) as desk, \ - mock.patch.object(handoffs, "emit", side_effect=lambda *a, **k: events.append(a)), \ + mock.patch("events.emit", side_effect=lambda *a, **k: events.append(a)), \ mock.patch.object(store, "record_credential_result") as rec: row = handoffs.submit_handoff_value("h_otp", "123456") assert row["status"] == "completed", row @@ -466,7 +466,7 @@ def test_expire_stale_clears_login_ctx(): store.q("UPDATE handoffs SET created_at=? WHERE id=?", ("2000-01-01T00:00:00Z", "h_otp")) events = [] - with mock.patch.object(handoffs, "emit", side_effect=lambda *a, **k: events.append(a)), \ + with mock.patch("events.emit", side_effect=lambda *a, **k: events.append(a)), \ mock.patch.object(store, "record_credential_result") as rec: handoffs.expire_stale() assert store.get_handoff("h_otp")["status"] == "expired" From 45c60e5af4b5c7721982cd6c7defc4dfd9062b5d Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:21:39 +0000 Subject: [PATCH 102/126] Drop the unused captcha Solver scaffolding. solve_if_capable's solver= parameter was never passed, so the Solver protocol and the DbcSolver wrapper only forwarded to _dbc_solve, and _dbc_solve re-checked enabled(), the capability and the enterprise proxy that solve_if_capable had just checked. solve_if_capable now calls _dbc_solve directly. timeout_s stays: the tests drive the poll budget through it. --- control-plane/captcha.py | 49 +++++++--------------------------------- 1 file changed, 8 insertions(+), 41 deletions(-) diff --git a/control-plane/captcha.py b/control-plane/captcha.py index b6424c7..fced41e 100644 --- a/control-plane/captcha.py +++ b/control-plane/captcha.py @@ -3,7 +3,7 @@ Leaf-ish module: depends only on config + requests (never handoffs, no cycle). Local detection + verification stay authoritative (DETECT_JS, still_challenge, -gate_open). Auto-solve is quarantined behind a capability adapter; Death By +gate_open). Auto-solve is quarantined behind a capability check; Death By Captcha is the only vendor this release, and only for declared capabilities. Off unless CASE_DBC_* credentials are set. Only sitekey/publickey + pageurl @@ -23,7 +23,7 @@ import os import re import time -from typing import Protocol, TypedDict +from typing import TypedDict import requests from config import log @@ -400,15 +400,6 @@ class SolveResult(TypedDict): token: str -class Solver(Protocol): - """Optional auto-solver. Only declared capabilities may leave the box.""" - - capabilities: frozenset[str] - - def solve(self, capability: str, pageurl: str, key: str, *, - timeout_s: float = DBC_POLL_CAP_S) -> SolveResult | None: ... - - def capability_for(family: str, *, enterprise: bool = False) -> str | None: """Map detect-family (+ enterprise flag) → declared capability name, or None.""" if family == "recaptcha": @@ -429,19 +420,12 @@ def _dbc_solve(capability: str, pageurl: str, key: str, *, """Upload to DBC and poll until solved, refused, or the wall-clock budget is spent. Terminal answers (is_correct=false, service-overload) return immediately, no long - poll that delays the human handoff. Never logs token/password. + poll that delays the human handoff. Never logs token/password. solve_if_capable + has already checked enabled(), the capability and the enterprise proxy. """ - if capability not in DBC_CAPABILITIES or not pageurl or not key: - return None - if not enabled(): - return None - deadline = time.time() + max(1.0, float(timeout_s)) proxy = _proxy_fields() if capability == "recaptcha_enterprise": - if not proxy: - log.warning("captcha_auto=fail dbc_enterprise_needs_proxy") - return None # Try type 25 first; FAQ #18 allows type 4 for some Enterprise keys on # non-terminal upload failure. Overload itself is terminal (no fallback poll). payload = {"googlekey": key, "pageurl": pageurl, **proxy} @@ -521,25 +505,9 @@ def _dbc_solve(capability: str, pageurl: str, key: str, *, return None -class DbcSolver: - """Death By Captcha, the only Solver implementation this release.""" - - capabilities = DBC_CAPABILITIES - - def solve(self, capability: str, pageurl: str, key: str, *, - timeout_s: float = DBC_POLL_CAP_S) -> SolveResult | None: - if capability not in self.capabilities: - return None - return _dbc_solve(capability, pageurl, key, timeout_s=timeout_s) - - -_DEFAULT_SOLVER = DbcSolver() - - def solve_if_capable(family: str, pageurl: str, key: str, *, enterprise: bool = False, - timeout_s: float = DBC_POLL_CAP_S, - solver: Solver | None = None) -> SolveResult | None: - """Capability boundary: solve only when the adapter declares the family. + timeout_s: float = DBC_POLL_CAP_S) -> SolveResult | None: + """Capability boundary: solve only when DBC declares the family. Returns None immediately (no DBC HTTP, no long poll) when disabled, the family is unsupported, or enterprise lacks a configured proxy, caller creates email @@ -550,14 +518,13 @@ def solve_if_capable(family: str, pageurl: str, key: str, *, enterprise: bool = if not pageurl or not key: return None cap = capability_for(family, enterprise=enterprise) - impl = solver if solver is not None else _DEFAULT_SOLVER - if cap is None or cap not in impl.capabilities: + if cap is None or cap not in DBC_CAPABILITIES: log.info("captcha_auto=skip reason=unsupported_capability family=%s", family) return None if cap == "recaptcha_enterprise" and not _proxy_fields(): log.warning("captcha_auto=fail dbc_enterprise_needs_proxy") return None - return impl.solve(cap, pageurl, key, timeout_s=timeout_s) + return _dbc_solve(cap, pageurl, key, timeout_s=timeout_s) def report(captcha_id) -> None: From 7b40503ea818ad15886024572fec073530767f4b Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:22:22 +0000 Subject: [PATCH 103/126] Send the same headers on every Assist gone and done page. Some Assist 410 and result pages carried Cache-Control and Referrer-Policy: no-referrer, some only Cache-Control, some neither, and the re-rendered form page on a stale submit sent no-referrer where the normal page sends same-origin for the CSRF check. Gone and done pages now go through one helper, and the re-rendered page uses the same headers as the first render. --- control-plane/cased.py | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/control-plane/cased.py b/control-plane/cased.py index d09dc24..abb09e9 100644 --- a/control-plane/cased.py +++ b/control-plane/cased.py @@ -697,6 +697,16 @@ def _assist_set_cookie(set_sess): return headers +def _assist_static(html, status_code=200): + """A GONE or DONE page: never cached, and its URL (the token) never sent on.""" + return HTMLResponse(html, status_code=status_code, + headers={"Cache-Control": "no-store", "Referrer-Policy": "no-referrer"}) + + +def _assist_done(title, body): + return _assist_static(assist.DONE_HTML.replace("{title}", title).replace("{body}", body)) + + @app.get("/assist/static/assist.js") def assist_static_js(): """Same-origin poll script, served as a file so the page carries no inline JS.""" @@ -709,8 +719,7 @@ def assist_get(token: str, request: Request): try: view, set_sess = assist.resolve_view(token, request.headers.get("cookie", "")) except ApiError: - return HTMLResponse(assist.GONE_HTML, status_code=410, - headers={"Cache-Control": "no-store", "Referrer-Policy": "no-referrer"}) + return _assist_static(assist.GONE_HTML, status_code=410) body = assist.render_page(view, token) return HTMLResponse(body, headers=_assist_set_cookie(set_sess)) @@ -736,8 +745,7 @@ async def _assist_form(token, request): or the HTMLResponse to send back instead.""" if not assist.check_same_origin(request): raise ApiError(403, "csrf", "missing or mismatched Origin") - gone = HTMLResponse(assist.GONE_HTML, status_code=410, - headers={"Cache-Control": "no-store"}) + gone = _assist_static(assist.GONE_HTML, status_code=410) sess = _assist_cookie(request) if not sess: return gone @@ -762,13 +770,9 @@ async def assist_open(token: str, request: Request): (form.get("url") or "").strip(), expected_revision=expected) except ApiError as e: if e.status == 410: - return HTMLResponse(assist.GONE_HTML, status_code=410, - headers={"Cache-Control": "no-store"}) + return _assist_static(assist.GONE_HTML, status_code=410) raise - return HTMLResponse( - assist.DONE_HTML.replace("{title}", "Opened").replace( - "{body}", "The computer opened the link. Finish there, then return here."), - headers={"Cache-Control": "no-store", "Referrer-Policy": "no-referrer"}) + return _assist_done("Opened", "The computer opened the link. Finish there, then return here.") @app.post("/assist/{token}/submit") @@ -779,9 +783,7 @@ async def assist_submit(token: str, request: Request): return got sess, view, form, expected = got if "submit_value" not in view["allowed_actions"]: - return HTMLResponse( - assist.render_page(view, token), - headers={"Cache-Control": "no-store", "Referrer-Policy": "no-referrer"}) + return HTMLResponse(assist.render_page(view, token), headers=_assist_set_cookie(None)) value = (form.get("value") or "").strip() if not value: raise ApiError(400, "bad_request", "value is required") @@ -790,7 +792,7 @@ async def assist_submit(token: str, request: Request): expected_revision=expected) except ApiError as e: if e.status == 410: - return HTMLResponse(assist.GONE_HTML, status_code=410) + return _assist_static(assist.GONE_HTML, status_code=410) raise st = row["status"] if view["kind"] == "approval" and st != "pending": @@ -803,7 +805,7 @@ async def assist_submit(token: str, request: Request): else: title, body = "Not yet", ("That code did not clear the challenge. " "Reopen the link and try again.") - return HTMLResponse(assist.DONE_HTML.replace("{title}", title).replace("{body}", body)) + return _assist_done(title, body) @app.post("/assist/{token}/done") @@ -818,7 +820,7 @@ async def assist_done(token: str, request: Request): expected_revision=expected) except ApiError as e: if e.status == 410: - return HTMLResponse(assist.GONE_HTML, status_code=410) + return _assist_static(assist.GONE_HTML, status_code=410) raise st = row["status"] if st == "completed" or st == "answered": @@ -828,7 +830,7 @@ async def assist_done(token: str, request: Request): else: title, body = "Still open", ("The challenge still looks present. " "Finish it on the desktop, then click I'm done again.") - return HTMLResponse(assist.DONE_HTML.replace("{title}", title).replace("{body}", body)) + return _assist_done(title, body) @app.get("/v1/desk/check") From af626a562429aa088b22028da5bb9ba6ef5a9fd9 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:36:26 +0000 Subject: [PATCH 104/126] Reflow the host-install settings paragraph in the README. --- README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0fd5912..92a3cb4 100644 --- a/README.md +++ b/README.md @@ -338,11 +338,10 @@ runs directly on macOS, set `CASE_MAX_RAM_MB` explicitly; its automatic memory budget requires Linux's `/proc/meminfo`. A few settings only apply here. `CASE_PORT` moves cased off 8787. -`CASE_VNC_PORT` pins every desktop's noVNC to one host port for a reverse -proxy. `CASE_BRAIN_BIN` -points the scheduler at a `claude` binary that is not on `PATH`, and -`CASE_MCP_CONFIG` replaces `case-mcp.json`. On macOS, `CASE_CPU` and `CASE_MEM` -size the Colima VM that `bin/case up` starts (default 4 CPUs, 4 GB). +`CASE_VNC_PORT` pins every desktop's noVNC to one host port for a reverse proxy. +`CASE_BRAIN_BIN` points the scheduler at a `claude` binary that is not on `PATH`, +and `CASE_MCP_CONFIG` replaces `case-mcp.json`. On macOS, `CASE_CPU` and +`CASE_MEM` size the Colima VM that `bin/case up` starts (default 4 CPUs, 4 GB). For a client that uses stdio MCP, [case-mcp.json](case-mcp.json) starts `mcp/case_mcp.py` with Python. It needs the installed Python dependencies and From aff18290aaa57b00c20d608efa8514f579ee9085 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:45:21 +0000 Subject: [PATCH 105/126] Give each unit test process its own CASE_HOME. Thirteen test files pointed CASE_HOME at a fixed /tmp/case-*-test that was never removed, so two runs of the same file at once shared one SQLite vault and failed on each other's rows, and a later run started from an earlier run's leftovers. browse and navigate used setdefault and so took an inherited CASE_HOME. Every control-plane test now calls tests/_helpers.isolated_home(), which puts control-plane on sys.path and CASE_HOME in a fresh temporary directory that is removed at exit. The vault permissions test and the runs pruning test keep their scratch files under that directory too. --- tests/_helpers.py | 23 +++++++++++++++++++++++ tests/test_assist.py | 9 +++------ tests/test_auth_attempts.py | 9 +++------ tests/test_auth_wait.py | 9 +++------ tests/test_blockers.py | 7 +++---- tests/test_browse.py | 7 +++---- tests/test_captcha.py | 6 +++--- tests/test_dockerd.py | 6 +++--- tests/test_gates.py | 8 +++----- tests/test_handoffs.py | 8 +++----- tests/test_lifecycle.py | 10 ++++------ tests/test_links.py | 11 +++-------- tests/test_navigate.py | 6 ++---- tests/test_notify.py | 6 +++--- tests/test_runs.py | 10 ++++------ tests/test_scheduler.py | 8 +++----- tests/test_session_keeper.py | 9 +++------ tests/test_store.py | 9 +++------ tests/test_telemetry.py | 9 +++------ tests/test_token.py | 8 +++----- 20 files changed, 81 insertions(+), 97 deletions(-) create mode 100644 tests/_helpers.py diff --git a/tests/_helpers.py b/tests/_helpers.py new file mode 100644 index 0000000..9854163 --- /dev/null +++ b/tests/_helpers.py @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: MIT +"""Shared by the tests/test_*.py scripts, each of which runs as its own process.""" +import atexit +import os +import shutil +import sys +import tempfile + +ROOT = os.path.join(os.path.dirname(__file__), "..") + + +def isolated_home(): + """Put control-plane on sys.path and CASE_HOME in a fresh directory, removed at exit. + + Call it before anything imports config or store. Assignment, not setdefault: an + inherited CASE_HOME (a dev shell, ~/.case/env) would point the tests at a live + vault, and a fixed path is shared by parallel runs and keeps stale rows. + """ + sys.path.insert(0, os.path.join(ROOT, "control-plane")) + home = tempfile.mkdtemp(prefix="case-test-") + atexit.register(shutil.rmtree, home, ignore_errors=True) + os.environ["CASE_HOME"] = home + return home diff --git a/tests/test_assist.py b/tests/test_assist.py index 34d903b..75337d2 100644 --- a/tests/test_assist.py +++ b/tests/test_assist.py @@ -3,14 +3,11 @@ Run: .venv/bin/python tests/test_assist.py""" import hashlib import json -import os -import sys import unittest.mock as mock -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) -# assignment, NOT setdefault: these tests write assist_tokens + handoffs, and an -# inherited CASE_HOME would put them in a live box's DB. -os.environ["CASE_HOME"] = "/tmp/case-assist-test" +import _helpers + +_helpers.isolated_home() import assist # noqa: E402 import cased # noqa: E402 import handoffs # noqa: E402 diff --git a/tests/test_auth_attempts.py b/tests/test_auth_attempts.py index 2122446..8b5a5fb 100644 --- a/tests/test_auth_attempts.py +++ b/tests/test_auth_attempts.py @@ -4,15 +4,12 @@ import json import os import shutil -import sys import tempfile import unittest.mock as mock -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) -# assignment, NOT setdefault: these tests write auth_attempt rows, and an inherited -# CASE_HOME would put them in a live box's DB. -_HOME = tempfile.mkdtemp(prefix="case-auth-attempts-") -os.environ["CASE_HOME"] = _HOME +import _helpers + +_HOME = _helpers.isolated_home() import auth_attempts # noqa: E402 import handoffs # noqa: E402 diff --git a/tests/test_auth_wait.py b/tests/test_auth_wait.py index 4553fe6..4eac5ec 100644 --- a/tests/test_auth_wait.py +++ b/tests/test_auth_wait.py @@ -2,15 +2,12 @@ """Auth attempt long-poll wait (cursor + event wake). Run: .venv/bin/python tests/test_auth_wait.py""" import asyncio -import os import shutil -import sys -import tempfile import unittest.mock as mock -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) -_HOME = tempfile.mkdtemp(prefix="case-auth-wait-") -os.environ["CASE_HOME"] = _HOME +import _helpers + +_HOME = _helpers.isolated_home() import auth_attempts # noqa: E402 import events # noqa: E402 diff --git a/tests/test_blockers.py b/tests/test_blockers.py index 4191a77..58cc929 100644 --- a/tests/test_blockers.py +++ b/tests/test_blockers.py @@ -1,11 +1,10 @@ # SPDX-License-Identifier: MIT """Blocker routing: one live challenge owns one durable handoff.""" -import os -import sys import unittest.mock as mock -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) -os.environ["CASE_HOME"] = "/tmp/case-blockers-test" +import _helpers + +_helpers.isolated_home() import login_flow # noqa: E402 import cased # noqa: E402 diff --git a/tests/test_browse.py b/tests/test_browse.py index e9cce94..842575c 100644 --- a/tests/test_browse.py +++ b/tests/test_browse.py @@ -2,12 +2,11 @@ """browse.py — snapshot/click/fill/wait/tabs composition logic. Pure: eval_js and desk_json are faked, no Docker, no deskd. Run: .venv/bin/python tests/test_browse.py""" -import os -import sys from urllib.parse import quote -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) -os.environ.setdefault("CASE_HOME", "/tmp/case-browse-test") +import _helpers + +_helpers.isolated_home() import browse # noqa: E402 from errors import ApiError # noqa: E402 diff --git a/tests/test_captcha.py b/tests/test_captcha.py index e1d0ffe..410ef6c 100644 --- a/tests/test_captcha.py +++ b/tests/test_captcha.py @@ -8,11 +8,11 @@ """ import json import os -import sys import unittest.mock as mock -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) -os.environ["CASE_HOME"] = "/tmp/case-captcha-test" +import _helpers + +_helpers.isolated_home() # Clear DBC env for deterministic enabled() tests; individual tests set what they need. for _k in ("CASE_DBC_USERNAME", "CASE_DBC_PASSWORD", "CASE_DBC_AUTHTOKEN", diff --git a/tests/test_dockerd.py b/tests/test_dockerd.py index b90ce21..b7f4182 100644 --- a/tests/test_dockerd.py +++ b/tests/test_dockerd.py @@ -2,10 +2,10 @@ """dockerd network vs loopback reachability. Pure — no Docker daemon. Run: .venv/bin/python tests/test_dockerd.py""" import os -import sys -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) -os.environ["CASE_HOME"] = "/tmp/case-dockerd-test" +import _helpers + +_helpers.isolated_home() os.environ.pop("CASE_DOCKER_NETWORK", None) import dockerd # noqa: E402 diff --git a/tests/test_gates.py b/tests/test_gates.py index 0286690..439eacd 100644 --- a/tests/test_gates.py +++ b/tests/test_gates.py @@ -4,13 +4,11 @@ import glob import os import shutil -import sys from unittest import mock -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) -# assignment, NOT setdefault: this suite writes audit files and wipes the audit -# directory, and an inherited CASE_HOME would aim that at the real vault. -os.environ["CASE_HOME"] = "/tmp/case-gates-test" +import _helpers + +_helpers.isolated_home() from fastapi.testclient import TestClient # noqa: E402 import cased # noqa: E402 diff --git a/tests/test_handoffs.py b/tests/test_handoffs.py index 6aac73d..9cb6468 100644 --- a/tests/test_handoffs.py +++ b/tests/test_handoffs.py @@ -2,13 +2,11 @@ """Typed handoff state + verified continuation (restart recovery + Assist foundation). Run: .venv/bin/python tests/test_handoffs.py""" import os -import sys import unittest.mock as mock -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) -# assignment, NOT setdefault: the tests below write handoff rows, and an inherited -# CASE_HOME would put them in a live box's DB. Same reasoning as tests/test_links.py. -os.environ["CASE_HOME"] = "/tmp/case-handoffs-test" +import _helpers + +_helpers.isolated_home() import handoffs # noqa: E402 import links # noqa: E402 from errors import ApiError # noqa: E402 diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 5a4c580..b2f3433 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -2,12 +2,10 @@ """The state machine reified in lifecycle.TRANSITIONS. Pure — no Docker. Run: .venv/bin/python tests/test_lifecycle.py""" import os -import sys -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) -# assignment, NOT setdefault: these tests create and delete computer rows, and an -# inherited CASE_HOME (a dev shell, ~/.case/env) would point that at the real vault. -os.environ["CASE_HOME"] = "/tmp/case-lifecycle-test" +import _helpers + +HOME = _helpers.isolated_home() from errors import ApiError # noqa: E402 from lifecycle import can_transition, do_sleep, ensure_running # noqa: E402 from store import store # noqa: E402 @@ -428,7 +426,7 @@ def test_vault_directory_and_database_are_private(): # predates this (or a loose umask) leaves them world-readable. import shutil from store import Store - home = "/tmp/case-perms-test" + home = os.path.join(HOME, "perms") shutil.rmtree(home, ignore_errors=True) os.makedirs(home, mode=0o755) # the permissive dir an upgrade inherits try: diff --git a/tests/test_links.py b/tests/test_links.py index 5653014..2fe29b9 100644 --- a/tests/test_links.py +++ b/tests/test_links.py @@ -1,14 +1,9 @@ # SPDX-License-Identifier: MIT """Human link tokens: mint/expiry/burn + the /desk forward_auth check. Run: .venv/bin/python tests/test_links.py""" -import os -import sys - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) -# assignment, NOT setdefault: _cleanup() truncates the links table, and an inherited -# CASE_HOME (exported in a dev shell, or ~/.case/env) would point that at the real -# vault and kill every outstanding human link. -os.environ["CASE_HOME"] = "/tmp/case-links-test" +import _helpers + +_helpers.isolated_home() import links # noqa: E402 from store import store # noqa: E402 diff --git a/tests/test_navigate.py b/tests/test_navigate.py index 7cd903d..8c05874 100644 --- a/tests/test_navigate.py +++ b/tests/test_navigate.py @@ -2,11 +2,9 @@ """deskclient.navigate — the sentinel/poll loop that replaces an agent's manual readyState polling. Pure: eval_js is faked, no Docker, no deskd. Run: .venv/bin/python tests/test_navigate.py""" -import os -import sys +import _helpers -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) -os.environ.setdefault("CASE_HOME", "/tmp/case-navigate-test") +_helpers.isolated_home() import deskclient # noqa: E402 from errors import ApiError # noqa: E402 diff --git a/tests/test_notify.py b/tests/test_notify.py index 27af845..6ba343e 100644 --- a/tests/test_notify.py +++ b/tests/test_notify.py @@ -2,12 +2,12 @@ """ntfy notifier wiring. Run: .venv/bin/python tests/test_notify.py""" import importlib import os -import sys import threading import unittest.mock as mock -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) -os.environ["CASE_HOME"] = "/tmp/case-notify-test" +import _helpers + +_helpers.isolated_home() # Force a clean import under known env for module-level notifier wiring tests. os.environ.pop("CASE_NTFY_TOPIC", None) diff --git a/tests/test_runs.py b/tests/test_runs.py index 842e2ad..b75688b 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -2,12 +2,10 @@ """Read routes: runs, credential health, handoff list. Run: .venv/bin/python tests/test_runs.py""" import os -import sys -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) -# assignment, NOT setdefault — these tests DELETE FROM real tables, and an inherited -# CASE_HOME would point them at a live box's vault. Same reasoning as tests/test_links.py. -os.environ["CASE_HOME"] = "/tmp/case-runs-test" +import _helpers + +HOME = _helpers.isolated_home() import cased # noqa: E402 from store import store # noqa: E402 @@ -43,7 +41,7 @@ def test_unlink_run_artifacts_only_under_runs_dir(): from config import RUNS_DIR os.makedirs(RUNS_DIR, exist_ok=True) inside = os.path.join(RUNS_DIR, "prune_me.png") - outside = os.path.join("/tmp", "case-prune-outside.png") + outside = os.path.join(HOME, "case-prune-outside.png") open(inside, "wb").write(b"in") open(outside, "wb").write(b"out") try: diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 5456f59..57b1455 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -5,11 +5,9 @@ import sys from datetime import datetime, timezone -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) -# assignment, NOT setdefault: the run-status tests below truncate the runs table, and an -# inherited CASE_HOME (exported in a dev shell, or ~/.case/env) would point that at a -# live box's real history. Same reasoning as tests/test_links.py. -os.environ["CASE_HOME"] = "/tmp/case-sched-test" +import _helpers + +_helpers.isolated_home() from scheduler import compute_next # noqa: E402 from store import store # noqa: E402 diff --git a/tests/test_session_keeper.py b/tests/test_session_keeper.py index 539ea56..3ac2eb3 100644 --- a/tests/test_session_keeper.py +++ b/tests/test_session_keeper.py @@ -3,15 +3,12 @@ Run: .venv/bin/python tests/test_session_keeper.py""" import json import os -import sys -import tempfile import unittest.mock as mock from datetime import datetime, timezone -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) -# assignment, NOT setdefault: these tests write credential probe rows. -_HOME = tempfile.mkdtemp(prefix="case-session-keeper-") -os.environ["CASE_HOME"] = _HOME +import _helpers + +_helpers.isolated_home() import session_keeper # noqa: E402 from store import store # noqa: E402 diff --git a/tests/test_store.py b/tests/test_store.py index 5b93301..3957a70 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -1,16 +1,13 @@ # SPDX-License-Identifier: MIT """Concurrent reads from the shared vault connection. No Docker required.""" from concurrent.futures import ThreadPoolExecutor -import os import shutil -import sys -import tempfile import threading import unittest.mock as mock -_HOME = tempfile.mkdtemp(prefix="case-store-test-") -os.environ["CASE_HOME"] = _HOME -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) +import _helpers + +_HOME = _helpers.isolated_home() from store import store # noqa: E402 diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index bccb1b2..1ae7bac 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -4,14 +4,11 @@ import importlib import json import os -import shutil -import sys import unittest.mock as mock -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) -HOME = "/tmp/case-telemetry-test" -shutil.rmtree(HOME, ignore_errors=True) -os.environ["CASE_HOME"] = HOME +import _helpers + +_helpers.isolated_home() os.environ.pop("CASE_TELEMETRY", None) os.environ.pop("DO_NOT_TRACK", None) diff --git a/tests/test_token.py b/tests/test_token.py index 3e472a9..e0e7234 100644 --- a/tests/test_token.py +++ b/tests/test_token.py @@ -2,12 +2,10 @@ """Optional CASE_TOKEN gate. Pure — no Docker. Run: .venv/bin/python tests/test_token.py""" import os -import sys -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane")) -# assignment, NOT setdefault: an inherited CASE_HOME (a dev shell, ~/.case/env) -# would open the real vault just to read a token setting. -os.environ["CASE_HOME"] = "/tmp/case-token-test" +import _helpers + +_helpers.isolated_home() import cased # noqa: E402 From fa722065db8f2c2d1a2a4c7bb396c41424211124 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:47:20 +0000 Subject: [PATCH 106/126] Run every unit test file through one shared runner. Twenty files carried their own copy of the loop that calls each test_* function, and test_token, test_dockerd, test_store and test_acceptance_safety called their tests by name instead, so a test added there without also being added to that list never ran. _helpers.run_tests now runs every test_* function in a file, keeps going after a failure, prints each failure and exits non-zero if there was one. The same 508 tests run as before. --- tests/_helpers.py | 24 ++++++++++++++++++++++++ tests/test_acceptance_safety.py | 10 ++-------- tests/test_assist.py | 6 +----- tests/test_auth_attempts.py | 15 ++------------- tests/test_auth_mcp.py | 7 +++---- tests/test_auth_wait.py | 16 ++-------------- tests/test_blockers.py | 6 +----- tests/test_browse.py | 6 +----- tests/test_captcha.py | 6 +----- tests/test_deskd.py | 8 +++----- tests/test_dockerd.py | 14 +------------- tests/test_gates.py | 6 +----- tests/test_handoffs.py | 6 +----- tests/test_lifecycle.py | 6 +----- tests/test_links.py | 6 +----- tests/test_mcp_http.py | 7 +++---- tests/test_navigate.py | 6 +----- tests/test_notify.py | 6 +----- tests/test_runs.py | 6 +----- tests/test_scheduler.py | 6 +----- tests/test_session_keeper.py | 6 +----- tests/test_skills.py | 8 +++----- tests/test_store.py | 12 ++---------- tests/test_telemetry.py | 6 +----- tests/test_token.py | 6 +----- 25 files changed, 60 insertions(+), 151 deletions(-) diff --git a/tests/_helpers.py b/tests/_helpers.py index 9854163..4d5b0c0 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -5,6 +5,7 @@ import shutil import sys import tempfile +import traceback ROOT = os.path.join(os.path.dirname(__file__), "..") @@ -21,3 +22,26 @@ def isolated_home(): atexit.register(shutil.rmtree, home, ignore_errors=True) os.environ["CASE_HOME"] = home return home + + +def run_tests(namespace): + """Run every test_* function in `namespace` (a script's globals()) in name order. + + A failure is printed and the rest still run; any failure exits non-zero. + """ + failed = [] + for name, fn in sorted(namespace.items()): + if not (name.startswith("test_") and callable(fn)): + continue + try: + fn() + except Exception: + traceback.print_exc() + print("FAIL", name) + failed.append(name) + else: + print("ok", name) + if failed: + print(f"FAILED {len(failed)}: {' '.join(failed)}") + sys.exit(1) + print("PASS") diff --git a/tests/test_acceptance_safety.py b/tests/test_acceptance_safety.py index 8dfbe76..2bd69ef 100644 --- a/tests/test_acceptance_safety.py +++ b/tests/test_acceptance_safety.py @@ -8,6 +8,7 @@ from unittest import mock sys.path.insert(0, os.path.dirname(__file__)) +import _helpers # noqa: E402 import test_acceptance as acceptance # noqa: E402 @@ -127,11 +128,4 @@ def test_failures_retain_the_scratch_directory(): if __name__ == "__main__": - test_import_does_not_start_a_server() - test_child_env_removes_case_and_provider_settings() - test_event_stream_is_not_consumed_by_response_capture() - test_cleanup_only_deletes_created_ids() - test_cleanup_continues_after_a_delete_failure() - test_stop_owned_process_terminates_and_waits() - test_failures_retain_the_scratch_directory() - print("test_acceptance_safety: ok") + _helpers.run_tests(globals()) diff --git a/tests/test_assist.py b/tests/test_assist.py index 75337d2..1ed59b2 100644 --- a/tests/test_assist.py +++ b/tests/test_assist.py @@ -528,8 +528,4 @@ def test_first_click_of_fresh_link_sets_session_cookie_over_http(): if __name__ == "__main__": - for name, fn in sorted(globals().items()): - if name.startswith("test_"): - fn() - print("ok", name) - print("PASS") + _helpers.run_tests(globals()) diff --git a/tests/test_auth_attempts.py b/tests/test_auth_attempts.py index 8b5a5fb..b2a83bc 100644 --- a/tests/test_auth_attempts.py +++ b/tests/test_auth_attempts.py @@ -9,7 +9,7 @@ import _helpers -_HOME = _helpers.isolated_home() +_helpers.isolated_home() import auth_attempts # noqa: E402 import handoffs # noqa: E402 @@ -619,15 +619,4 @@ def test_upsert_preserves_credential_auth_profile(): if __name__ == "__main__": - try: - for name, fn in sorted(globals().items()): - if name.startswith("test_"): - fn() - print("ok", name) - print("PASS") - finally: - try: - store.db.close() - except Exception: - pass - shutil.rmtree(_HOME, ignore_errors=True) + _helpers.run_tests(globals()) diff --git a/tests/test_auth_mcp.py b/tests/test_auth_mcp.py index 4066910..addaad7 100644 --- a/tests/test_auth_mcp.py +++ b/tests/test_auth_mcp.py @@ -7,6 +7,8 @@ import sys from unittest import mock +import _helpers + ROOT = os.path.join(os.path.dirname(__file__), "..") sys.path.insert(0, os.path.join(ROOT, "mcp")) @@ -231,7 +233,4 @@ def test_auth_attempt_wait_caps_max_wait(): if __name__ == "__main__": - for name, fn in sorted(globals().items()): - if name.startswith("test_"): - fn() - print("ok", name) + _helpers.run_tests(globals()) diff --git a/tests/test_auth_wait.py b/tests/test_auth_wait.py index 4eac5ec..3f93031 100644 --- a/tests/test_auth_wait.py +++ b/tests/test_auth_wait.py @@ -2,12 +2,11 @@ """Auth attempt long-poll wait (cursor + event wake). Run: .venv/bin/python tests/test_auth_wait.py""" import asyncio -import shutil import unittest.mock as mock import _helpers -_HOME = _helpers.isolated_home() +_helpers.isolated_home() import auth_attempts # noqa: E402 import events # noqa: E402 @@ -382,15 +381,4 @@ def json_dumps(obj): if __name__ == "__main__": - try: - for name, fn in sorted(globals().items()): - if name.startswith("test_"): - fn() - print("ok", name) - print("PASS") - finally: - try: - store.db.close() - except Exception: - pass - shutil.rmtree(_HOME, ignore_errors=True) + _helpers.run_tests(globals()) diff --git a/tests/test_blockers.py b/tests/test_blockers.py index 58cc929..0164493 100644 --- a/tests/test_blockers.py +++ b/tests/test_blockers.py @@ -192,8 +192,4 @@ def desk(row, *a, **k): if __name__ == "__main__": - for name, fn in sorted(globals().copy().items()): - if name.startswith("test_") and callable(fn): - fn() - print("ok", name) - print("PASS") + _helpers.run_tests(globals()) diff --git a/tests/test_browse.py b/tests/test_browse.py index 842575c..f5f0bf7 100644 --- a/tests/test_browse.py +++ b/tests/test_browse.py @@ -708,8 +708,4 @@ def test_teach_tick_504_still_raises(): if __name__ == "__main__": - for name, fn in sorted(globals().items()): - if name.startswith("test_"): - fn() - print("ok", name) - print("PASS") + _helpers.run_tests(globals()) diff --git a/tests/test_captcha.py b/tests/test_captcha.py index 410ef6c..c30deaf 100644 --- a/tests/test_captcha.py +++ b/tests/test_captcha.py @@ -1075,8 +1075,4 @@ def test_login_success_ungated_reports_success(): if __name__ == "__main__": - for name, fn in sorted(globals().items()): - if name.startswith("test_"): - fn() - print("ok", name) - print("PASS") + _helpers.run_tests(globals()) diff --git a/tests/test_deskd.py b/tests/test_deskd.py index e77ea4b..b732e74 100644 --- a/tests/test_deskd.py +++ b/tests/test_deskd.py @@ -14,6 +14,8 @@ import types import unittest.mock as mock +import _helpers + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "image")) os.environ.setdefault("DESK_TOKEN", "test") sys.modules.setdefault("websocket", types.ModuleType("websocket")) @@ -933,8 +935,4 @@ def test_auth_submit_challenge_refused_code_is_not_ok_and_keeps_the_login_held() assert deskd.state["login"] is None and deskd.state["in_login"] is False if __name__ == "__main__": - for name, fn in sorted(globals().items()): - if name.startswith("test_"): - fn() - print("ok", name) - print("PASS") + _helpers.run_tests(globals()) diff --git a/tests/test_dockerd.py b/tests/test_dockerd.py index b7f4182..d459dec 100644 --- a/tests/test_dockerd.py +++ b/tests/test_dockerd.py @@ -241,16 +241,4 @@ def test_managed_containers_is_one_listing_keyed_by_name(): if __name__ == "__main__": - test_host_mode_dials_loopback_and_publishes_ports() - test_container_limits_cover_swap_and_pids() - test_compose_mode_uses_container_dns_and_no_host_ports() - test_deskclient_accepts_sqlite_row() - test_deskclient_url_follows_the_network() - test_deskclient_reuses_one_session_per_thread() - test_vnc_url_hidden_when_computers_are_on_the_compose_network() - test_create_clears_a_name_stuck_in_removal_and_retries_once() - test_destroy_names_a_volume_it_could_not_remove() - test_new_volumes_carry_the_cased_label() - test_cached_client_reconnects_only_when_the_daemon_went_away() - test_managed_containers_is_one_listing_keyed_by_name() - print("test_dockerd: ok") + _helpers.run_tests(globals()) diff --git a/tests/test_gates.py b/tests/test_gates.py index 439eacd..21e1895 100644 --- a/tests/test_gates.py +++ b/tests/test_gates.py @@ -233,8 +233,4 @@ async def connect(*args, **kwargs): if __name__ == "__main__": - for name, fn in sorted(globals().items()): - if name.startswith("test_"): - fn() - print("ok", name) - print("PASS") + _helpers.run_tests(globals()) diff --git a/tests/test_handoffs.py b/tests/test_handoffs.py index 9cb6468..2a4c041 100644 --- a/tests/test_handoffs.py +++ b/tests/test_handoffs.py @@ -579,8 +579,4 @@ def test_expire_stale_loses_race_to_a_finished_answer(): if __name__ == "__main__": - for name, fn in sorted(globals().items()): - if name.startswith("test_"): - fn() - print("ok", name) - print("PASS") + _helpers.run_tests(globals()) diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index b2f3433..51cdb8c 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -485,8 +485,4 @@ def test_create_rejects_nonsense_sizing(): if __name__ == "__main__": - for name, fn in sorted(globals().items()): - if name.startswith("test_"): - fn() - print("ok", name) - print("PASS") + _helpers.run_tests(globals()) diff --git a/tests/test_links.py b/tests/test_links.py index 2fe29b9..1bc7661 100644 --- a/tests/test_links.py +++ b/tests/test_links.py @@ -216,8 +216,4 @@ def test_prune_expired_links_keeps_live(): if __name__ == "__main__": - for name, fn in sorted(globals().items()): - if name.startswith("test_"): - fn() - print("ok", name) - print("PASS") + _helpers.run_tests(globals()) diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py index 44d55ac..a88a4c0 100644 --- a/tests/test_mcp_http.py +++ b/tests/test_mcp_http.py @@ -8,6 +8,8 @@ import sys import types +import _helpers + ROOT = os.path.join(os.path.dirname(__file__), "..") sys.path.insert(0, os.path.join(ROOT, "mcp")) @@ -165,7 +167,4 @@ def test_no_credential_write_tool(): if __name__ == "__main__": - for name, fn in sorted(globals().items()): - if name.startswith("test_"): - fn() - print("ok", name) + _helpers.run_tests(globals()) diff --git a/tests/test_navigate.py b/tests/test_navigate.py index 8c05874..a10e7b2 100644 --- a/tests/test_navigate.py +++ b/tests/test_navigate.py @@ -177,8 +177,4 @@ def test_page_text_eval_failure_is_omitted(): if __name__ == "__main__": - for name, fn in sorted(globals().items()): - if name.startswith("test_"): - fn() - print("ok", name) - print("PASS") + _helpers.run_tests(globals()) diff --git a/tests/test_notify.py b/tests/test_notify.py index 6ba343e..5aff59e 100644 --- a/tests/test_notify.py +++ b/tests/test_notify.py @@ -207,8 +207,4 @@ def notify(self, h, name): if __name__ == "__main__": - for name, fn in list(globals().items()): - if name.startswith("test_") and callable(fn): - fn() - print("ok", name) - print("all passed") + _helpers.run_tests(globals()) diff --git a/tests/test_runs.py b/tests/test_runs.py index b75688b..aa51704 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -277,8 +277,4 @@ def test_the_handoff_list_does_not_ship_screenshots(): if __name__ == "__main__": - for name, fn in sorted(globals().items()): - if name.startswith("test_"): - fn() - print("ok", name) - print("PASS") + _helpers.run_tests(globals()) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 57b1455..533b925 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -673,8 +673,4 @@ def test_run_brain_cmd_wins_over_url(): if __name__ == "__main__": - for name, fn in sorted(globals().items()): - if name.startswith("test_"): - fn() - print("ok", name) - print("PASS") + _helpers.run_tests(globals()) diff --git a/tests/test_session_keeper.py b/tests/test_session_keeper.py index 3ac2eb3..db0933e 100644 --- a/tests/test_session_keeper.py +++ b/tests/test_session_keeper.py @@ -353,8 +353,4 @@ def test_tick_forgets_computers_that_no_longer_have_probes(): if __name__ == "__main__": - for name, fn in sorted(globals().items()): - if name.startswith("test_"): - fn() - print("ok", name) - print("PASS") + _helpers.run_tests(globals()) diff --git a/tests/test_skills.py b/tests/test_skills.py index 1d79c54..ab5b26f 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -6,6 +6,8 @@ import sys from unittest import mock +import _helpers + ROOT = os.path.join(os.path.dirname(__file__), "..") sys.path.insert(0, os.path.join(ROOT, "mcp")) @@ -113,8 +115,4 @@ def test_tool_registered(): if __name__ == "__main__": - for name, fn in sorted(globals().items()): - if name.startswith("test_"): - fn() - print("ok", name) - print("PASS") + _helpers.run_tests(globals()) diff --git a/tests/test_store.py b/tests/test_store.py index 3957a70..8e5c518 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -1,13 +1,12 @@ # SPDX-License-Identifier: MIT """Concurrent reads from the shared vault connection. No Docker required.""" from concurrent.futures import ThreadPoolExecutor -import shutil import threading import unittest.mock as mock import _helpers -_HOME = _helpers.isolated_home() +_helpers.isolated_home() from store import store # noqa: E402 @@ -63,11 +62,4 @@ def test_prune_terminal_auth_attempts_keeps_active_and_recent_rows(): if __name__ == "__main__": - try: - test_concurrent_reads_keep_their_rows_intact() - test_active_attempt_queries_use_an_index() - test_prune_terminal_auth_attempts_keeps_active_and_recent_rows() - print("test_store: ok") - finally: - store.db.close() - shutil.rmtree(_HOME) + _helpers.run_tests(globals()) diff --git a/tests/test_telemetry.py b/tests/test_telemetry.py index 1ae7bac..1a06219 100644 --- a/tests/test_telemetry.py +++ b/tests/test_telemetry.py @@ -116,8 +116,4 @@ def test_deleted_computers_are_not_counted(): if __name__ == "__main__": - for name, fn in sorted(globals().items()): - if name.startswith("test_"): - fn() - print("ok", name) - print("PASS") + _helpers.run_tests(globals()) diff --git a/tests/test_token.py b/tests/test_token.py index e0e7234..e492d25 100644 --- a/tests/test_token.py +++ b/tests/test_token.py @@ -61,8 +61,4 @@ def test_awake_touches_on_success_only(): if __name__ == "__main__": - test_open_when_unset() - test_requires_matching_bearer() - test_blank_token_is_open() - test_awake_touches_on_success_only() - print("test_token: ok") + _helpers.run_tests(globals()) From 78fd11381835354de46f414b5bc32e963a93c355 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:49:48 +0000 Subject: [PATCH 107/126] Share the test fixtures that several files copied. test_assist and test_links each defined the same running-computer seed and /v1/desk/check caller, test_auth_attempts and test_lifecycle the same ApiError assertion, and test_auth_mcp, test_mcp_http and test_skills three near-identical ways to re-import case_mcp under a clean environment. They live in tests/_helpers.py now. The case_mcp loader clears the union of the settings the three copies cleared. --- tests/_helpers.py | 42 +++++++++++++++++++++++++++++++++++++ tests/test_assist.py | 25 +++++----------------- tests/test_auth_attempts.py | 21 ++++++------------- tests/test_auth_mcp.py | 37 ++++++++++---------------------- tests/test_lifecycle.py | 23 +++++++------------- tests/test_links.py | 24 ++++----------------- tests/test_mcp_http.py | 33 ++++++++--------------------- tests/test_skills.py | 27 +++++++----------------- 8 files changed, 91 insertions(+), 141 deletions(-) diff --git a/tests/_helpers.py b/tests/_helpers.py index 4d5b0c0..7f8d3ac 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: MIT """Shared by the tests/test_*.py scripts, each of which runs as its own process.""" import atexit +import importlib import os import shutil import sys @@ -24,6 +25,47 @@ def isolated_home(): return home +def raises(fn, code): + """Call fn and return the ApiError it must raise, asserting its code.""" + from errors import ApiError + try: + fn() + except ApiError as e: + assert e.code == code, (e.code, e.message) + return e + assert False, f"expected ApiError {code}" + + +def running_computer(cid="c_1"): + from store import store + store.q("DELETE FROM computers WHERE id=?", (cid,)) + store.insert_computer(cid, "ava", "case-desk:0.1", 1, 2048, "vol", "tok") + store.set_state(cid, "running") + + +def desk_check_ep(uri, cookie): + """Call the /desk forward-auth check as the reverse proxy would.""" + from starlette.requests import Request + import cased + scope = {"type": "http", "headers": [ + (b"x-forwarded-uri", uri.encode()), + (b"cookie", cookie.encode()), + ], "method": "GET", "path": "/v1/desk/check", "query_string": b""} + return cased.desk_check_ep(Request(scope)) + + +def load_case_mcp(**env): + """Re-import case_mcp with `env` as the only settings it reads at import.""" + mcp = os.path.join(ROOT, "mcp") + if mcp not in sys.path: + sys.path.insert(0, mcp) + for k in ("CASE_MCP_HTTP", "CASE_MCP_PORT", "CASE_MCP_BIND", "CASE_MCP_SCHEDULES", + "CASE_ALLOWED_HOSTS", "CASE_PUBLIC_HOST"): + os.environ.pop(k, None) + os.environ.update(env) + return importlib.reload(importlib.import_module("case_mcp")) + + def run_tests(namespace): """Run every test_* function in `namespace` (a script's globals()) in name order. diff --git a/tests/test_assist.py b/tests/test_assist.py index 1ed59b2..4a96662 100644 --- a/tests/test_assist.py +++ b/tests/test_assist.py @@ -32,21 +32,6 @@ def _cleanup(): handoffs.LOGIN_CTX.clear() -def _running_computer(cid="c_1"): - store.q("DELETE FROM computers WHERE id=?", (cid,)) - store.insert_computer(cid, "ava", "case-desk:0.1", 1, 2048, "vol", "tok") - store.set_state(cid, "running") - - -def _desk_check_ep(uri, cookie): - from starlette.requests import Request - scope = {"type": "http", "headers": [ - (b"x-forwarded-uri", uri.encode()), - (b"cookie", cookie.encode()), - ], "method": "GET", "path": "/v1/desk/check", "query_string": b""} - return cased.desk_check_ep(Request(scope)) - - def _hash(raw): return hashlib.sha256(raw.encode()).hexdigest() @@ -166,9 +151,9 @@ def test_desk_check_accepts_assist_cookie_for_bound_computer(): _pending("h_cap", "captcha") raw, _ = assist.mint_assist_token("h_cap") session, _ = assist.exchange(raw) - _running_computer("c_1") + _helpers.running_computer("c_1") assert links.desk_check("/desk/vnc.html", f"case_assist={session}") == (None, None) - assert _desk_check_ep("/desk/vnc.html", f"case_assist={session}").status_code == 200 + assert _helpers.desk_check_ep("/desk/vnc.html", f"case_assist={session}").status_code == 200 def test_assist_cookie_does_not_unlock_fill_or_console(): @@ -194,7 +179,7 @@ def test_otp_submit_via_assist_completes_without_login_ctx(): # desk access dies once handoff is terminal assert assist.valid_session(session) is None assert links.desk_check("/desk/", f"case_assist={session}") == (None, None) - assert _desk_check_ep("/desk/", f"case_assist={session}").status_code == 401 + assert _helpers.desk_check_ep("/desk/", f"case_assist={session}").status_code == 401 assert store.get_handoff("h_otp")["answer"] is None @@ -329,9 +314,9 @@ def test_attempt_scoped_session_follows_current_handoff(): assert view["continuation"] == "submit_value" assert "submit_value" in view["allowed_actions"] # desk still unlocks for the computer - _running_computer("c_1") + _helpers.running_computer("c_1") assert links.desk_check("/desk/", f"case_assist={session}") == (None, None) - assert _desk_check_ep("/desk/", f"case_assist={session}").status_code == 200 + assert _helpers.desk_check_ep("/desk/", f"case_assist={session}").status_code == 200 def test_proving_state_has_no_actions(): diff --git a/tests/test_auth_attempts.py b/tests/test_auth_attempts.py index b2a83bc..f1bc6cc 100644 --- a/tests/test_auth_attempts.py +++ b/tests/test_auth_attempts.py @@ -29,15 +29,6 @@ def _cleanup(): handoffs.LOGIN_CTX.clear() -def _raises(fn, code): - try: - fn() - except ApiError as e: - assert e.code == code, (e.code, e.message) - return e - assert False, f"expected ApiError {code}" - - def _obs(**kwargs): base = { "href": "https://example.com/login", @@ -118,7 +109,7 @@ def test_one_active_attempt_per_computer(): auth_attempts.start_attempt("c_1", "github", "https://example.com/login") assert store.active_attempt_exists("c_1") assert store.get_active_auth_attempt("c_1") is not None - _raises(lambda: auth_attempts.start_attempt( + _helpers.raises(lambda: auth_attempts.start_attempt( "c_1", "github", "https://example.com/login", idempotency_key="other"), "auth_in_progress") # different computer is fine @@ -133,7 +124,7 @@ def test_cas_revision_conflict(): assert n == 1 assert store.get_auth_attempt(a["id"])["revision"] == 1 # stale revision on cancel - _raises(lambda: auth_attempts.cancel_attempt(a["id"], expected_revision=0), + _helpers.raises(lambda: auth_attempts.cancel_attempt(a["id"], expected_revision=0), "revision_conflict") # store-level CAS n = store.cas_auth_attempt_status(a["id"], "advancing", "proving", 0) @@ -177,7 +168,7 @@ def test_raise_challenge_while_proving_creates_no_handoff(): a = auth_attempts.start_attempt("c_1", "github", "https://example.com/login") store.cas_auth_attempt_status(a["id"], "created", "proving", 0) with mock.patch("handoffs.create_handoff") as create: - _raises(lambda: auth_attempts.raise_challenge(a["id"], "otp", "code?"), + _helpers.raises(lambda: auth_attempts.raise_challenge(a["id"], "otp", "code?"), "illegal_transition") create.assert_not_called() @@ -195,7 +186,7 @@ def create_then_cancel(*args, **kw): with mock.patch("lifecycle.get_computer", return_value=COMP), \ mock.patch("deskclient.screenshot_b64", return_value=None), \ mock.patch("handoffs.create_handoff", side_effect=create_then_cancel): - _raises(lambda: auth_attempts.raise_challenge(a["id"], "otp", "code?"), + _helpers.raises(lambda: auth_attempts.raise_challenge(a["id"], "otp", "code?"), "revision_conflict") rows = store.all("SELECT id, status FROM handoffs") assert [r["status"] for r in rows] == ["failed"], [dict(r) for r in rows] @@ -228,9 +219,9 @@ def test_claim_challenge_cas(): assert claimed["revision"] == 1 assert "answer" not in claimed # stale / wrong status - _raises(lambda: auth_attempts.claim_challenge("h_claim", expected_revision=0), + _helpers.raises(lambda: auth_attempts.claim_challenge("h_claim", expected_revision=0), "revision_conflict") - _raises(lambda: auth_attempts.claim_challenge("h_claim", expected_revision=1), + _helpers.raises(lambda: auth_attempts.claim_challenge("h_claim", expected_revision=1), "revision_conflict") diff --git a/tests/test_auth_mcp.py b/tests/test_auth_mcp.py index addaad7..97a46f2 100644 --- a/tests/test_auth_mcp.py +++ b/tests/test_auth_mcp.py @@ -2,25 +2,10 @@ """MCP durable-auth handles: computer_login + auth_attempt_get/wait. Call-shape tests with mocked HTTP — no Docker, no network. Run: .venv/bin/python tests/test_auth_mcp.py""" -import importlib -import os -import sys from unittest import mock import _helpers -ROOT = os.path.join(os.path.dirname(__file__), "..") -sys.path.insert(0, os.path.join(ROOT, "mcp")) - - -def _load(schedules=False): - for k in ("CASE_MCP_HTTP", "CASE_MCP_PORT", "CASE_MCP_SCHEDULES"): - os.environ.pop(k, None) - if schedules: - os.environ["CASE_MCP_SCHEDULES"] = "1" - mod = importlib.import_module("case_mcp") - return importlib.reload(mod) - def _ok(payload): r = mock.Mock() @@ -32,7 +17,7 @@ def _ok(payload): def test_auth_tool_surface(): - m = _load() + m = _helpers.load_case_mcp() names = list(m.mcp._tool_manager._tools) assert "auth_attempt_wait" in names, names assert "computer_login" in names @@ -41,16 +26,16 @@ def test_auth_tool_surface(): def test_schedule_tools_archived_by_default(): - m = _load() + m = _helpers.load_case_mcp() names = list(m.mcp._tool_manager._tools) assert not [n for n in names if n.startswith("schedule_")], names - m = _load(schedules=True) + m = _helpers.load_case_mcp(CASE_MCP_SCHEDULES="1") names = list(m.mcp._tool_manager._tools) assert "schedule_create" in names and "schedule_runs" in names, names def test_computer_login_passes_idempotency_and_proof_spec(): - m = _load() + m = _helpers.load_case_mcp() with mock.patch.object(m, "call", return_value=_ok({ "status": "success", "attempt_id": "a_1", "revision": 1, })) as call: @@ -74,7 +59,7 @@ def test_computer_login_passes_idempotency_and_proof_spec(): def test_computer_login_omits_optional_when_unset(): - m = _load() + m = _helpers.load_case_mcp() with mock.patch.object(m, "call", return_value=_ok({ "status": "handoff_pending", "handoff_id": "h_1", "attempt_id": "a_2", })) as call: @@ -85,7 +70,7 @@ def test_computer_login_omits_optional_when_unset(): def test_file_get_returns_readable_text_and_flags_binary(): - m = _load() + m = _helpers.load_case_mcp() txt = mock.Mock(status_code=200, content="hello ☃".encode("utf-8"), text="") with mock.patch.object(m, "call", return_value=txt): out = m.computer_file_get("c_1", "/home/agent/a.txt") @@ -98,7 +83,7 @@ def test_file_get_returns_readable_text_and_flags_binary(): def test_handoff_request_kind_in_body(): - m = _load() + m = _helpers.load_case_mcp() with mock.patch.object(m, "call", return_value=_ok({"id": "h_d"})) as call: m.handoff_request("c_1", "Scan the QR", kind="device") assert call.call_args.kwargs["json"] == { @@ -107,7 +92,7 @@ def test_handoff_request_kind_in_body(): def test_auth_attempt_wait_terminal_return(): - m = _load() + m = _helpers.load_case_mcp() terminal = { "changed": True, "wait_status": "terminal", @@ -135,7 +120,7 @@ def test_auth_attempt_wait_terminal_return(): def test_auth_attempt_wait_chains_intermediate_challenges(): - m = _load() + m = _helpers.load_case_mcp() mid = { "changed": True, "wait_status": "changed", @@ -176,7 +161,7 @@ def test_auth_attempt_wait_chains_intermediate_challenges(): def test_auth_attempt_wait_bounded_timeout_no_login_retry(): - m = _load() + m = _helpers.load_case_mcp() timed = { "changed": False, "wait_status": "timeout", @@ -208,7 +193,7 @@ def test_auth_attempt_wait_bounded_timeout_no_login_retry(): def test_auth_attempt_wait_caps_max_wait(): - m = _load() + m = _helpers.load_case_mcp() timed = { "changed": False, "wait_status": "timeout", diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index 51cdb8c..d2a50fa 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -27,15 +27,6 @@ def test_illegal_edges_rejected(): assert not can_transition(a, b), (a, b) -def _raises(fn, code): - try: - fn() - except ApiError as e: - assert e.code == code, e.code - return - assert False, f"expected ApiError {code}" - - def test_creating_computer_is_not_actionable(): # a computer mid-provision: ensure_running must give coherent guidance (not "wake it"), # and do_sleep must reject BEFORE stopping the half-built container (M2). @@ -43,8 +34,8 @@ def test_creating_computer_is_not_actionable(): store.delete_computer(cid) store.insert_computer(cid, "t", "img", 1, 512, "vol", "tok") # state=creating try: - _raises(lambda: ensure_running(cid, wake=True), "provisioning") - _raises(lambda: do_sleep(cid), "illegal_transition") # raises before any docker call + _helpers.raises(lambda: ensure_running(cid, wake=True), "provisioning") + _helpers.raises(lambda: do_sleep(cid), "illegal_transition") # raises before any docker call finally: store.delete_computer(cid) @@ -64,7 +55,7 @@ def test_wake_respects_max_running(): store.insert_computer(cid, "asleep", "img", 1, 512, "vol-a", "tok-a") store.set_state(cid, "asleep") from lifecycle import do_wake - _raises(lambda: do_wake(cid), "too_many_running") # before any docker call + _helpers.raises(lambda: do_wake(cid), "too_many_running") # before any docker call finally: lifecycle.MAX_RUNNING = old store.delete_computer(cid) @@ -122,7 +113,7 @@ def test_a_second_wake_does_not_take_over_a_waking_row(): try: with mock.patch.object(dockerd, "container_up", return_value=False), \ mock.patch.object(dockerd, "start_container") as start: - _raises(lambda: lifecycle.do_wake(cid), "waking") + _helpers.raises(lambda: lifecycle.do_wake(cid), "waking") start.assert_not_called() finally: store.delete_computer(cid) @@ -139,7 +130,7 @@ def test_sleep_blocked_when_auth_attempt_active(): "a_unittest_pin", cid, "github", "https://example.com/login", status="awaiting_human") try: - _raises(lambda: do_sleep(cid), "auth_in_progress") # before any docker call + _helpers.raises(lambda: do_sleep(cid), "auth_in_progress") # before any docker call assert store.get_computer(cid)["state"] == "running" finally: store.q("DELETE FROM auth_attempts WHERE computer_id=?", (cid,)) @@ -162,7 +153,7 @@ def test_wake_respects_ram_budget(): store.set_state(cid, "asleep") assert store.active_ram_mb() == 3072 from lifecycle import do_wake - _raises(lambda: do_wake(cid), "not_enough_ram") # 3072 + 2048 > 4096 + _helpers.raises(lambda: do_wake(cid), "not_enough_ram") # 3072 + 2048 > 4096 lifecycle.MAX_RAM_MB = 8192 # budget raised: admitted lifecycle.admit(2048) finally: @@ -477,7 +468,7 @@ def test_create_rejects_nonsense_sizing(): import cased for body in ({"ram_mb": 0}, {"ram_mb": "banana"}, {"cpus": 0}, {"cpus": 999}, {"ram_mb": 10 ** 9}): - _raises(lambda b=body: cased.create_computer(b), "bad_request") + _helpers.raises(lambda b=body: cased.create_computer(b), "bad_request") # blank/absent means "the default", not "invalid" assert cased._num(None, 2048, 512, 65536, "ram_mb") == 2048 assert cased._num("", 1, 0.25, 32, "cpus") == 1 diff --git a/tests/test_links.py b/tests/test_links.py index 1bc7661..89ba45a 100644 --- a/tests/test_links.py +++ b/tests/test_links.py @@ -12,22 +12,6 @@ def _cleanup(): store.q("DELETE FROM links") -def _running_computer(cid): - store.q("DELETE FROM computers WHERE id=?", (cid,)) - store.insert_computer(cid, "ava", "case-desk:0.1", 1, 2048, "vol", "tok") - store.set_state(cid, "running") - - -def _desk_check_ep(uri, cookie): - from starlette.requests import Request - import cased - scope = {"type": "http", "headers": [ - (b"x-forwarded-uri", uri.encode()), - (b"cookie", cookie.encode()), - ], "method": "GET", "path": "/v1/desk/check", "query_string": b""} - return cased.desk_check_ep(Request(scope)) - - def test_mint_fill_returns_fill_path_and_stores_row(): _cleanup() out = links.mint("c_1", "fill") @@ -152,10 +136,10 @@ def test_assist_cookie_opens_desk_but_not_fill(): continuation="verify_page") raw, _ = assist.mint_assist_token("h_link_assist") session, _ = assist.exchange(raw) - _running_computer("c_assist") + _helpers.running_computer("c_assist") assert links.desk_check("/desk/vnc.html", f"case_assist={session}") == (None, None) - resp = _desk_check_ep("/desk/vnc.html", f"case_assist={session}") + resp = _helpers.desk_check_ep("/desk/vnc.html", f"case_assist={session}") assert resp.status_code == 200, resp.status_code # negatives: assist session is not a fill/vnc link token @@ -165,11 +149,11 @@ def test_assist_cookie_opens_desk_but_not_fill(): fill = links.mint("c_assist", "fill")["token"] assert links.desk_check(f"/x?token={fill}", "")[0] is None # assist cookie still authorizes desk via desk_check_ep even when fill token is in query - assert _desk_check_ep(f"/x?token={fill}", f"case_assist={session}").status_code == 200 + assert _helpers.desk_check_ep(f"/x?token={fill}", f"case_assist={session}").status_code == 200 store.set_handoff_status("h_link_assist", "completed") assert links.desk_check("/desk/", f"case_assist={session}") == (None, None) - assert _desk_check_ep("/desk/", f"case_assist={session}").status_code == 401 + assert _helpers.desk_check_ep("/desk/", f"case_assist={session}").status_code == 401 store.delete_handoff("h_link_assist") store.q("DELETE FROM assist_tokens WHERE handoff_id=?", ("h_link_assist",)) diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py index a88a4c0..9ef5c80 100644 --- a/tests/test_mcp_http.py +++ b/tests/test_mcp_http.py @@ -3,32 +3,17 @@ 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 import _helpers -ROOT = os.path.join(os.path.dirname(__file__), "..") -sys.path.insert(0, os.path.join(ROOT, "mcp")) - - -def _load(**env): - for k in ("CASE_MCP_HTTP", "CASE_MCP_PORT", "CASE_MCP_BIND", - "CASE_ALLOWED_HOSTS", "CASE_PUBLIC_HOST"): - os.environ.pop(k, None) - os.environ.update(env) - mod = importlib.import_module("case_mcp") - return importlib.reload(mod) - def test_stdio_is_the_default(): - assert _load().HTTP is False # unset env → every existing flow untouched + assert _helpers.load_case_mcp().HTTP is False # unset env → every existing flow untouched def test_http_mode_defaults_to_loopback(): - m = _load(CASE_MCP_HTTP="1", CASE_MCP_PORT="8899") + m = _helpers.load_case_mcp(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 assert m.mcp.settings.port == 8899 @@ -36,12 +21,12 @@ def test_http_mode_defaults_to_loopback(): def test_http_mode_honours_bind_env(): - m = _load(CASE_MCP_HTTP="1", CASE_MCP_BIND="0.0.0.0") + m = _helpers.load_case_mcp(CASE_MCP_HTTP="1", CASE_MCP_BIND="0.0.0.0") assert m.mcp.settings.host == "0.0.0.0" def test_http_app_serves_mcp_path(): - m = _load(CASE_MCP_HTTP="1") + m = _helpers.load_case_mcp(CASE_MCP_HTTP="1") assert m.mcp.settings.port == 8788 # the port compose publishes paths = [getattr(r, "path", None) for r in m.mcp.streamable_http_app().routes] assert "/mcp" in paths, paths @@ -59,7 +44,7 @@ def json(self): def _failed_call(body, status_code=500): """call() against a >=400 response; returns (message, chained exception).""" - m = _load() + m = _helpers.load_case_mcp() m.requests = types.SimpleNamespace(request=lambda *a, **kw: _Resp(status_code, body)) try: m.call("GET", "/computers") @@ -87,7 +72,7 @@ def test_call_falls_back_to_the_status_unchained(): def _mcp_statuses(requests_, **env): """POST a tools/call per (Host, Origin) to one HTTP app; returns the statuses.""" from starlette.testclient import TestClient - m = _load(CASE_MCP_HTTP="1", CASE_MCP_BIND="0.0.0.0", **env) + m = _helpers.load_case_mcp(CASE_MCP_HTTP="1", CASE_MCP_BIND="0.0.0.0", **env) m.requests = types.SimpleNamespace(request=lambda *a, **kw: _Resp(200, {"computers": []})) out = [] with TestClient(m.mcp.streamable_http_app()) as c: @@ -120,7 +105,7 @@ def test_slow_tool_leaves_health_answering(): import threading import time from starlette.testclient import TestClient - m = _load(CASE_MCP_HTTP="1") + m = _helpers.load_case_mcp(CASE_MCP_HTTP="1") started, release = threading.Event(), threading.Event() def slow(*a, **kw): @@ -144,7 +129,7 @@ def slow(*a, **kw): def test_optional_params_accept_explicit_null(): # clients send "x": null for an unset optional; `x: int = None` rejected that import asyncio - m = _load() + m = _helpers.load_case_mcp() for name, tool in m.mcp._tool_manager._tools.items(): for arg, prop in tool.parameters["properties"].items(): if "default" in prop and prop["default"] is None: @@ -159,7 +144,7 @@ def test_optional_params_accept_explicit_null(): def test_no_credential_write_tool(): # security invariant: secrets enter via `case cred add` only, never a tool call - m = _load() + m = _helpers.load_case_mcp() names = list(m.mcp._tool_manager._tools) writes = ("add", "create", "set", "put", "update", "save", "store", "write", "delete", "remove") diff --git a/tests/test_skills.py b/tests/test_skills.py index ab5b26f..4152114 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -1,23 +1,10 @@ # SPDX-License-Identifier: MIT """case_skill — name validation, secret screening, and call shapes. Pure: HTTP mocked. Run: .venv/bin/python tests/test_skills.py""" -import importlib -import os -import sys from unittest import mock import _helpers -ROOT = os.path.join(os.path.dirname(__file__), "..") -sys.path.insert(0, os.path.join(ROOT, "mcp")) - - -def _load(): - for k in ("CASE_MCP_HTTP", "CASE_MCP_PORT", "CASE_MCP_SCHEDULES"): - os.environ.pop(k, None) - mod = importlib.import_module("case_mcp") - return importlib.reload(mod) - def _ok(payload=None, content=b""): r = mock.Mock() @@ -46,7 +33,7 @@ def _ok(payload=None, content=b""): def test_skill_name_rules(): - m = _load() + m = _helpers.load_case_mcp() assert m.skill_name_ok("coupa-ap-aging") assert m.skill_name_ok("x2") assert not m.skill_name_ok("") @@ -57,7 +44,7 @@ def test_skill_name_rules(): def test_secret_screen_catches_secret_shapes(): - m = _load() + m = _helpers.load_case_mcp() assert m.skill_content_risky("password: hunter2secret") assert m.skill_content_risky("API_KEY=sk-abcdef123456") assert m.skill_content_risky("otp: 123456") @@ -69,7 +56,7 @@ def test_secret_screen_catches_secret_shapes(): def test_save_rejects_secrets_and_bad_names(): - m = _load() + m = _helpers.load_case_mcp() with mock.patch.object(m, "call") as call: for bad in (("Bad Name", SKILL), ("ok-name", "token: abc123XYZplenty")): try: @@ -81,7 +68,7 @@ def test_save_rejects_secrets_and_bad_names(): def test_save_writes_file_then_logbook(): - m = _load() + m = _helpers.load_case_mcp() with mock.patch.object(m, "call", return_value=_ok({})) as call: out = m.case_skill("c_1", "save", name="coupa-ap-aging", content=SKILL) assert out["saved"].endswith("/skills/coupa-ap-aging/SKILL.md") @@ -93,7 +80,7 @@ def test_save_writes_file_then_logbook(): def test_list_returns_index_text(): - m = _load() + m = _helpers.load_case_mcp() with mock.patch.object(m, "call", return_value=_ok( {"stdout": "## /home/agent/skills/coupa-ap-aging/SKILL.md\nname: coupa-ap-aging\n"})) as call: out = m.case_skill("c_1", "list") @@ -103,14 +90,14 @@ def test_list_returns_index_text(): def test_read_returns_utf8(): - m = _load() + m = _helpers.load_case_mcp() with mock.patch.object(m, "call", return_value=_ok(content=SKILL.encode())): out = m.case_skill("c_1", "read", name="coupa-ap-aging") assert out["content"].startswith("---\nname: coupa-ap-aging") def test_tool_registered(): - m = _load() + m = _helpers.load_case_mcp() assert "case_skill" in list(m.mcp._tool_manager._tools) From e2d319c747535b687a550aa53baf0a968e605c08 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:53:05 +0000 Subject: [PATCH 108/126] Name the interval at which an auth wait re-reads the attempt. wait_attempt re-reads SQLite every 15 seconds in case it missed a publish, but the interval was a literal, so the test for that re-read had to sit out a real five-second wait. It is WAIT_REREAD_S now, with the same value, and the test shortens it. --- control-plane/auth_attempts.py | 3 ++- tests/test_auth_wait.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/control-plane/auth_attempts.py b/control-plane/auth_attempts.py index 3f8d3f8..edc351d 100644 --- a/control-plane/auth_attempts.py +++ b/control-plane/auth_attempts.py @@ -25,6 +25,7 @@ # and the MCP wait budget. WAIT_TIMEOUT_MAX_S = 270 WAIT_TIMEOUT_DEFAULT_S = 30 +WAIT_REREAD_S = 15.0 # Optional: fn(computer_row, computer_id, credential_name) -> {"status": "success"|"failed"}|None _CAPTCHA_AUTO = None @@ -392,7 +393,7 @@ def _matches(type_, data): return _wait_payload(pub, changed=False, wait_status="timeout") try: type_, data = await asyncio.wait_for( - q.get(), timeout=min(remaining, 15.0)) + q.get(), timeout=min(remaining, WAIT_REREAD_S)) except asyncio.TimeoutError: # Periodic re-read covers missed publishes (no LOOP / race). pub = attempt_public(_require(attempt_id)) diff --git a/tests/test_auth_wait.py b/tests/test_auth_wait.py index 3f93031..8020491 100644 --- a/tests/test_auth_wait.py +++ b/tests/test_auth_wait.py @@ -268,7 +268,8 @@ async def cancel_soon(): await task return out - out = _run(go()) + with mock.patch.object(auth_attempts, "WAIT_REREAD_S", 0.1): + out = _run(go()) assert out["changed"] is True assert out["attempt"]["status"] == "cancelled" From d436f8daf1e36eb1893b7809c570351f4146fbe8 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:53:05 +0000 Subject: [PATCH 109/126] Stop the captcha, navigate and browse tests from sleeping for real. test_login_success_ungated_reports_success ran the real late-challenge poll, which spent its whole 12-second deadline dialling 127.0.0.1:1; it is mocked now, as the gate beside it already was. The navigate and browse poll loops slept on the wall clock between faked evals, so their tests now give those modules a clock that moves on when slept. --- tests/_helpers.py | 16 ++++++++++++++++ tests/test_browse.py | 2 ++ tests/test_captcha.py | 1 + tests/test_navigate.py | 2 ++ 4 files changed, 21 insertions(+) diff --git a/tests/_helpers.py b/tests/_helpers.py index 7f8d3ac..d875e09 100644 --- a/tests/_helpers.py +++ b/tests/_helpers.py @@ -6,6 +6,7 @@ import shutil import sys import tempfile +import time import traceback ROOT = os.path.join(os.path.dirname(__file__), "..") @@ -66,6 +67,21 @@ def load_case_mcp(**env): return importlib.reload(importlib.import_module("case_mcp")) +class FakeClock: + """Stands in for a module's `time`: sleep() moves the clock on instead of waiting.""" + + def __init__(self): + self.now = time.time() + + def time(self): + return self.now + + monotonic = time + + def sleep(self, seconds): + self.now += seconds + + def run_tests(namespace): """Run every test_* function in `namespace` (a script's globals()) in name order. diff --git a/tests/test_browse.py b/tests/test_browse.py index f5f0bf7..0cde616 100644 --- a/tests/test_browse.py +++ b/tests/test_browse.py @@ -10,6 +10,8 @@ import browse # noqa: E402 from errors import ApiError # noqa: E402 +browse.time = _helpers.FakeClock() + ROW = {"desk_port": 1, "desk_token": "t"} ELS = [ {"tag": "a", "type": "", "name": "Home", "value": "", "href": "/"}, diff --git a/tests/test_captcha.py b/tests/test_captcha.py index c30deaf..9c7cc60 100644 --- a/tests/test_captcha.py +++ b/tests/test_captcha.py @@ -1061,6 +1061,7 @@ def test_login_success_ungated_reports_success(): mock.patch("cased.desk_json", return_value={"status": "success"}), \ mock.patch("cased.store.touch"), \ mock.patch("login_flow._post_login_gate", return_value=None), \ + mock.patch("login_flow._post_login_challenge", return_value=None), \ mock.patch("auth_attempts.check_proof", return_value=True), \ mock.patch("cased.store.record_credential_result") as rec, \ mock.patch("cased.events.emit"): diff --git a/tests/test_navigate.py b/tests/test_navigate.py index a10e7b2..25593d4 100644 --- a/tests/test_navigate.py +++ b/tests/test_navigate.py @@ -8,6 +8,8 @@ import deskclient # noqa: E402 from errors import ApiError # noqa: E402 +deskclient.time = _helpers.FakeClock() + ROW = {"desk_port": 1, "desk_token": "t"} OK = {"ok": True, "value": None} From 13bbfe39e73b129b88cc772a8e9801a619eb52e5 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:54:34 +0000 Subject: [PATCH 110/126] Drop dead code from the captcha tests and check an unverified login event. test_captcha imported store at the top and again inside a helper, and nineteen tests imported cased or login_flow without using them; one side effect had an if whose body was pass, behind a condition padded with an "if False" branch. test_missing_proof_spec_ends_unverified patched events.emit and never looked at it, so it now checks that the unverified outcome is announced as login_completed. The continuation lines of the shared raises helper line up with its call again. --- tests/test_auth_attempts.py | 12 +++++++----- tests/test_captcha.py | 24 ------------------------ tests/test_lifecycle.py | 2 +- 3 files changed, 8 insertions(+), 30 deletions(-) diff --git a/tests/test_auth_attempts.py b/tests/test_auth_attempts.py index f1bc6cc..15ae34e 100644 --- a/tests/test_auth_attempts.py +++ b/tests/test_auth_attempts.py @@ -125,7 +125,7 @@ def test_cas_revision_conflict(): assert store.get_auth_attempt(a["id"])["revision"] == 1 # stale revision on cancel _helpers.raises(lambda: auth_attempts.cancel_attempt(a["id"], expected_revision=0), - "revision_conflict") + "revision_conflict") # store-level CAS n = store.cas_auth_attempt_status(a["id"], "advancing", "proving", 0) assert n == 0 @@ -169,7 +169,7 @@ def test_raise_challenge_while_proving_creates_no_handoff(): store.cas_auth_attempt_status(a["id"], "created", "proving", 0) with mock.patch("handoffs.create_handoff") as create: _helpers.raises(lambda: auth_attempts.raise_challenge(a["id"], "otp", "code?"), - "illegal_transition") + "illegal_transition") create.assert_not_called() @@ -187,7 +187,7 @@ def create_then_cancel(*args, **kw): mock.patch("deskclient.screenshot_b64", return_value=None), \ mock.patch("handoffs.create_handoff", side_effect=create_then_cancel): _helpers.raises(lambda: auth_attempts.raise_challenge(a["id"], "otp", "code?"), - "revision_conflict") + "revision_conflict") rows = store.all("SELECT id, status FROM handoffs") assert [r["status"] for r in rows] == ["failed"], [dict(r) for r in rows] assert not handoffs.LOGIN_CTX, handoffs.LOGIN_CTX @@ -220,9 +220,9 @@ def test_claim_challenge_cas(): assert "answer" not in claimed # stale / wrong status _helpers.raises(lambda: auth_attempts.claim_challenge("h_claim", expected_revision=0), - "revision_conflict") + "revision_conflict") _helpers.raises(lambda: auth_attempts.claim_challenge("h_claim", expected_revision=1), - "revision_conflict") + "revision_conflict") def test_missing_proof_spec_ends_unverified(): @@ -241,6 +241,8 @@ def test_missing_proof_spec_ends_unverified(): out = auth_attempts.advance_attempt(a["id"]) assert out["status"] == "unverified", out rec.assert_called_with("c_1", "github", "unverified") + emit.assert_any_call("login_completed", {"computer_id": "c_1", "credential": "github", + "status": "unverified", "attempt_id": a["id"]}) # never authenticated assert store.get_auth_attempt(a["id"])["status"] == "unverified" diff --git a/tests/test_captcha.py b/tests/test_captcha.py index 9c7cc60..9cdb8f9 100644 --- a/tests/test_captcha.py +++ b/tests/test_captcha.py @@ -463,7 +463,6 @@ def _detect_ok(): def test_try_captcha_auto_solve_none_falls_through(): """solve_if_capable returns None → helper None so login creates handoff.""" import login_flow - import cased row = {"id": "c_1", "desk_port": 1, "desk_token": "t"} @@ -484,7 +483,6 @@ def test_try_captcha_auto_solve_none_falls_through(): def test_try_captcha_auto_still_present_reports_and_falls_through(): """Verify fails (captcha phrases) → report + None; NEVER resume.""" import login_flow - import cased row = {"id": "c_1", "desk_port": 1, "desk_token": "t"} @@ -493,10 +491,6 @@ def eval_side_effect(row, expr, timeout_s=15): return _detect_ok() if expr == "location.href": return {"ok": True, "value": "https://x"} - if "setRecaptchaFields" in expr or "TOKEN_PLACEHOLDER" in expr or "tok" in expr \ - or "recaptcha_form" in expr or "family" in (expr[:80] if False else "") \ - or "INJECT" in expr: - pass if expr == captcha.SETTLE_JS or "readyState" in expr: return {"ok": True, "value": {"href": "https://x", "ready": "complete"}} if expr == captcha.VERIFY_JS: @@ -561,7 +555,6 @@ def eval_side_effect(row, expr, timeout_s=15): def test_try_captcha_auto_otp_phrase_no_resume(): """OTP/verification-code page after inject → fail, no resume.""" import login_flow - import cased row = {"id": "c_1", "desk_port": 1, "desk_token": "t"} @@ -593,7 +586,6 @@ def eval_side_effect(row, expr, timeout_s=15): def test_try_captcha_auto_password_still_present_no_resume(): """Visible password field after inject → fail, no resume.""" import login_flow - import cased row = {"id": "c_1", "desk_port": 1, "desk_token": "t"} @@ -623,7 +615,6 @@ def eval_side_effect(row, expr, timeout_s=15): def test_try_captcha_auto_no_callback_skips_without_resume(): import login_flow - import cased row = {"id": "c_1", "desk_port": 1, "desk_token": "t"} with mock.patch("login_flow.captcha.enabled", return_value=True), \ @@ -642,7 +633,6 @@ def test_try_captcha_auto_no_callback_skips_without_resume(): def test_try_captcha_auto_verified_success_resumes_only_then(): import login_flow - import cased row = {"id": "c_1", "desk_port": 1, "desk_token": "t"} calls = [] @@ -686,7 +676,6 @@ def eval_side_effect(row, expr, timeout_s=15): def test_try_captcha_auto_resume_failed_returns_failed_not_none(): """Verify ok but resume failed → return failed status (no dead handoff).""" import login_flow - import cased row = {"id": "c_1", "desk_port": 1, "desk_token": "t"} @@ -751,7 +740,6 @@ def eval_side_effect(row, expr, timeout_s=15): def _seed_login_computer(cid="c_1"): """Durable login needs a store computer (raise_challenge → get_computer).""" - from store import store store.q("DELETE FROM auth_attempts WHERE computer_id=?", (cid,)) store.q("DELETE FROM handoffs WHERE computer_id=?", (cid,)) store.delete_computer(cid) @@ -761,7 +749,6 @@ def _seed_login_computer(cid="c_1"): def test_login_challenge_without_dbc_still_creates_handoff(): """Integration-ish: login path with captcha challenge and DBC off → handoff.""" - import login_flow import cased _seed_login_computer() @@ -792,7 +779,6 @@ def test_login_challenge_without_dbc_still_creates_handoff(): def test_login_creates_handoff_when_auto_returns_none(): """Bad solve → helper None → login still creates handoff.""" - import login_flow import cased _seed_login_computer() @@ -840,7 +826,6 @@ def test_gate_js_covers_checkpoint_url_and_iframes(): def test_post_login_gate_returns_none_when_not_gated(): """Clean feed page after login → gate path must not interfere.""" import login_flow - import cased row = {"id": "c_1", "name": "ava", "desk_port": 1, "desk_token": "t"} with mock.patch("login_flow.captcha.enabled", return_value=True), \ @@ -857,7 +842,6 @@ def test_post_login_gate_returns_none_when_not_gated(): def test_post_login_gate_runs_auto_without_resume(): """Gate open → auto-solve invoked with resume=False (deskd holds no login).""" import login_flow - import cased row = {"id": "c_1", "name": "ava", "desk_port": 1, "desk_token": "t"} with mock.patch("login_flow.captcha.enabled", return_value=True), \ @@ -879,7 +863,6 @@ def test_post_login_gate_failure_creates_handoff_without_login_ctx(): """CRITICAL: the fallback handoff must carry no login_credential — deskd already cleared state["login"], so answering one would 409 on /login/resume.""" import login_flow - import cased row = {"id": "c_1", "name": "ava", "desk_port": 1, "desk_token": "t"} with mock.patch("login_flow.captcha.enabled", return_value=True), \ @@ -904,7 +887,6 @@ def test_post_login_gate_dbc_off_still_creates_typed_captcha_handoff(): """Human fallback: gate open + DBC unavailable → verify_page captcha handoff, never silent success.""" import login_flow - import cased row = {"id": "c_1", "name": "ava", "desk_port": 1, "desk_token": "t"} with mock.patch("login_flow.captcha.enabled", return_value=False), \ @@ -928,7 +910,6 @@ def test_post_login_gate_dbc_off_still_creates_typed_captcha_handoff(): def test_post_login_gate_handoff_survives_screenshot_failure(): import login_flow - import cased row = {"id": "c_1", "name": "ava", "desk_port": 1, "desk_token": "t"} with mock.patch("login_flow.captcha.enabled", return_value=True), \ @@ -947,7 +928,6 @@ def test_post_login_gate_handoff_survives_screenshot_failure(): def test_gate_info_survives_eval_failure(): """Unreadable tab → {} → not gated → normal success path (no false handoff).""" import login_flow - import cased row = {"id": "c_1", "desk_port": 1, "desk_token": "t"} with mock.patch("login_flow.eval_value", return_value=None): @@ -957,7 +937,6 @@ def test_gate_info_survives_eval_failure(): def test_auto_no_resume_requires_gate_closed(): """resume=False path: verify clean but gate still open → fail, report, no success.""" import login_flow - import cased row = {"id": "c_1", "desk_port": 1, "desk_token": "t"} @@ -991,7 +970,6 @@ def eval_side_effect(row, expr, timeout_s=15): def test_auto_no_resume_success_never_calls_resume(): import login_flow - import cased row = {"id": "c_1", "desk_port": 1, "desk_token": "t"} @@ -1027,7 +1005,6 @@ def eval_side_effect(row, expr, timeout_s=15): def test_login_success_checks_gate(): """deskd success + open gate → login() returns the gate path's result.""" - import login_flow import cased _seed_login_computer() @@ -1050,7 +1027,6 @@ def test_login_success_checks_gate(): def test_login_success_ungated_reports_success(): """deskd success + closed gate + positive proof_spec → authenticated.""" - import login_flow import cased _seed_login_computer() diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index d2a50fa..62d79d0 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -153,7 +153,7 @@ def test_wake_respects_ram_budget(): store.set_state(cid, "asleep") assert store.active_ram_mb() == 3072 from lifecycle import do_wake - _helpers.raises(lambda: do_wake(cid), "not_enough_ram") # 3072 + 2048 > 4096 + _helpers.raises(lambda: do_wake(cid), "not_enough_ram") # 3072 + 2048 > 4096 lifecycle.MAX_RAM_MB = 8192 # budget raised: admitted lifecycle.admit(2048) finally: From 3696339841372a144735189c0797da4e1d8c7b74 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:55:02 +0000 Subject: [PATCH 111/126] Lint the Python tree in CI. Nothing caught an unused import or a name redefined before use, and test_captcha had collected twenty-one of them. The test job now runs ruff's pyflakes and syntax-error checks over control-plane, mcp, image and tests, with ruff pinned in requirements-dev.txt. --- .github/workflows/ci.yml | 3 +++ requirements-dev.txt | 1 + 2 files changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c44ca8c..fa1e11b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,9 @@ jobs: python -m venv .venv .venv/bin/pip install -r requirements-dev.txt + - name: Lint + run: .venv/bin/ruff check --select F,E9 control-plane mcp image tests + - name: Unit tests (no Docker) run: | # Everything except the acceptance suite, which needs a real desktop diff --git a/requirements-dev.txt b/requirements-dev.txt index ef02e79..364120c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +1,6 @@ -r requirements.txt pytest==9.1.1 +ruff==0.16.8 # fastapi.testclient needs it; it arrives transitively via mcp, so pin it here rather # than let the suite break the day mcp drops the dependency. httpx==0.28.1 From b1fc275098a655c97da4dd19c6b76256492e688e Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:56:04 +0000 Subject: [PATCH 112/126] Say in CONTRIBUTING that unit tests run one file per process. Each test file points CASE_HOME at its own temporary vault when it is imported, but config reads CASE_HOME once, so a single pytest process across the files ends up with one shared vault and tests that expect an empty one fail. The local test commands also run the lint CI runs. --- CONTRIBUTING.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8807b00..b4cb6d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,10 +33,14 @@ for f in tests/test_*.py; do case "$f" in tests/test_acceptance.py) continue ;; esac .venv/bin/python "$f" || exit 1 done +.venv/bin/ruff check --select F,E9 control-plane mcp image tests npm --prefix web ci npm --prefix web test ``` +Unit tests run one file per process, as CI does: each file sets up its own vault +when it is imported, so a single pytest run across the files shares one and fails. + ## Acceptance tests (Docker) ```bash From a4dd1bfa6bf7e0c708c980495078cec5b288403b Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:19:02 +0000 Subject: [PATCH 113/126] Say in the README that an approval takes only approve or deny. Phone replies such as "yes" used to approve; they are now refused and the handoff keeps waiting, which the Replies and handoffs section did not say. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 92a3cb4..1754b6e 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,8 @@ to the same topic. It marks its own posts so it does not read them as new tasks. When one handoff is waiting, your next message answers it. If several are waiting, prefix the answer with its handoff ID, such as `h_ab12 483920`. +An approval takes only `approve` or `deny`; any other reply is refused and the +handoff keeps waiting. With no handoff waiting, a message steers the current Phone task or starts a new task on the first computer returned by cased. Create a computer before sending your first task. From 45d3c2dc38551e98d307baddbc9270e9ae6d803e Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:19:02 +0000 Subject: [PATCH 114/126] Describe the injection gate's check on routes that were already running. An /exec, /action or /eval that started before an injection now gets the 423 too, and a second login is refused while one is injecting. SECURITY.md only described requests that arrive during the injection. --- SECURITY.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index ec6a068..dbfb619 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -8,9 +8,11 @@ Case holds logins. These are promises, with code you can read. - **deskd returns 423 while a credential is being injected.** Not just screenshots: `/exec`, `/action`, `/file` (read and write), `/eval`, `/auth/observe` and the capture reads all refuse until the injection finishes, - and network capture drops anything in flight. The password field is cleared - (`CLEAR_PASS`) before the gate reopens, so the first screenshot after a login - sees an empty box. + and network capture drops anything in flight. An `/exec`, `/action` or `/eval` + that was already running when the injection started returns 423 instead of + its result, and a second login is refused (409) rather than overlapping. + The password field is cleared (`CLEAR_PASS`) before the gate reopens, so the + first screenshot after a login sees an empty box. - **That gate is "we do not hand it over", not "cannot obtain".** `computer_exec` runs bash in the same container as Chromium, and Chromium's CDP port is on that container's loopback. An agent that goes looking can reach what the From d4140dc4f2084e801a13c6ba6c44f57fbc6eb603 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:40:27 +0000 Subject: [PATCH 115/126] Audit cased's and deskd's pins in separate runs. pip-audit resolves every -r file into one environment, and the two sets pin different fastapi versions, so the combined run could never resolve. --- .github/workflows/audit.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index ba548c2..960251c 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -35,7 +35,9 @@ jobs: run: | python -m venv .venv .venv/bin/pip install pip-audit==2.10.1 - .venv/bin/pip-audit -r requirements-dev.txt -r image/requirements.txt + # separate runs: cased and deskd pin different fastapi, so one resolve fails + .venv/bin/pip-audit -r requirements-dev.txt + .venv/bin/pip-audit -r image/requirements.txt - name: Drive run: npm --prefix web audit --omit=dev From 1d38fdc1bf155a40391c8bbd83ca4cb2710d7c6f Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:41:47 +0000 Subject: [PATCH 116/126] Pin shellcheck for CI and spell out bin/case's colima stop as an if. The runner's own shellcheck is older than the one contributors install and flags `A && B || true` (SC2015), so the same script passed locally and failed in CI. shellcheck now comes from requirements-dev.txt like ruff, and the down branch says what it meant: stop colima on macOS, ignore failure. --- .github/workflows/ci.yml | 2 +- bin/case | 2 +- requirements-dev.txt | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa1e11b..dd9781e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ jobs: npm --prefix web test - name: Shell scripts - run: shellcheck -e SC1090 image/start.sh bin/case # SC1090: ~/.case/env is the user's + run: .venv/bin/shellcheck -e SC1090 image/start.sh bin/case # SC1090: ~/.case/env is the user's - name: Compose file run: docker compose config -q diff --git a/bin/case b/bin/case index 5a8f4ff..2eec82b 100755 --- a/bin/case +++ b/bin/case @@ -43,7 +43,7 @@ case "${1:-help}" in systemctl stop cased; exit 0 # else systemd just restarts it in 3s fi pkill -f '[c]ased.py' 2>/dev/null || true - [ "$(uname)" = Darwin ] && colima stop || true ;; + if [ "$(uname)" = Darwin ]; then colima stop || true; fi ;; new) c -X POST "$BASE/computers" -H 'Content-Type: application/json' \ -d "$(python3 -c 'import json,sys;print(json.dumps({"name":sys.argv[1]}))' "${2:-}")" | pp ;; ls) c "$BASE/computers" | pp ;; diff --git a/requirements-dev.txt b/requirements-dev.txt index 364120c..250f36c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,6 +1,7 @@ -r requirements.txt pytest==9.1.1 ruff==0.16.8 +shellcheck-py==0.11.0.1 # fastapi.testclient needs it; it arrives transitively via mcp, so pin it here rather # than let the suite break the day mcp drops the dependency. httpx==0.28.1 From 40f70031417f7b5128a3be8907648ddc299c4b30 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:49:40 +0000 Subject: [PATCH 117/126] Refuse a resume or challenge submit while another injection is typing. Only /login checked for an injection already in flight, so a resume and a challenge submit could type into the same tab at once, and a resume cleared the held login before it knew it could run. Both now answer 409 injection_running first; cased treats that as a soft failure and leaves the handoff pending for a retry. --- image/deskd.py | 12 ++++++++---- tests/test_deskd.py | 17 ++++++++++++++++- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/image/deskd.py b/image/deskd.py index 346201c..c5652fd 100644 --- a/image/deskd.py +++ b/image/deskd.py @@ -836,11 +836,14 @@ def login_resume(b: dict = Body(...)): if "value" not in b: return err(400, "bad_request", "body needs 'value'") value = str(b["value"]) - ctx = state["login"] + if not state["login"]: + return err(409, "no_pending_login", "no login is waiting on a handoff") + if not inject_begin(alone=True): + return err(409, "injection_running", "another credential injection is running") + ctx, state["login"] = state["login"], None if not ctx: + inject_end() return err(409, "no_pending_login", "no login is waiting on a handoff") - state["login"] = None - inject_begin() try: tab = Tab() try: @@ -900,8 +903,9 @@ def auth_submit_challenge(b: dict = Body(...)): if str(kind).lower() in ("otp", "code") and ( not isinstance(b.get("domains"), list) or not b["domains"]): return err(400, "bad_request", "body needs 'domains' for otp/code") + if not inject_begin(alone=True): + return err(409, "injection_running", "another credential injection is running") state["in_login"] = True - inject_begin() try: tab = Tab() try: diff --git a/tests/test_deskd.py b/tests/test_deskd.py index b732e74..183bb9b 100644 --- a/tests/test_deskd.py +++ b/tests/test_deskd.py @@ -760,7 +760,8 @@ def apply(tab, kind, value=None): resume.start() try: assert started.wait(5) - assert deskd.auth_submit_challenge({"kind": "approval"}) == {"ok": True} + # every injecting route refuses to type alongside another + assert deskd.auth_submit_challenge({"kind": "approval"}).status_code == 409 r = _client().post("/exec", headers=H, json={"command": "echo leaked"}) assert r.status_code == 423, r.text again = deskd.login({"credential": {"name": "x", "domains": ["site.com"]}, @@ -772,6 +773,20 @@ def apply(tab, kind, value=None): assert deskd.state["injecting"] is False and deskd.state["injections"] == 0 +def test_resume_refused_during_an_injection_keeps_the_held_login(): + held = {"kind": "otp", "cred_name": "x", "domains": ["site.com"], "at": 0} + deskd.state["login"] = held + assert deskd.inject_begin() + try: + r = deskd.login_resume({"value": "123456"}) + assert r.status_code == 409 + assert deskd.state["login"] is held + finally: + deskd.inject_end() + deskd.state["login"] = None + assert deskd.state["injections"] == 0 + + class SlowEvalTab(RecordingTab): """Runtime.evaluate whose promise is still pending while `during` runs.""" From 504cd943eeac78954da6f946e6549a61401a1985 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:51:26 +0000 Subject: [PATCH 118/126] Send a message typed mid-turn to the thread it was typed into. Typing into a new task or another thread while a turn ran steered the running turn, invisibly, and queued prompts were always sent back to the thread that ran. A message now steers only the turn on screen; typed anywhere else it waits in the queue with its thread, or with a new task, and goes there when the turn ends. --- web/web-ui/index.html | 26 +++++++++++++++++--------- web/web-ui/test_nav.mjs | 15 ++++++++++++++- web/web-ui/test_serve.mjs | 7 +++---- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/web/web-ui/index.html b/web/web-ui/index.html index 09366ce..17566b3 100644 --- a/web/web-ui/index.html +++ b/web/web-ui/index.html @@ -1451,19 +1451,26 @@

SCHEDULES

q.value='';q.style.height='auto'; pending.length=0;paintAline(); if(chatCtl){ - if(files.length){promptQ.push({text:t,files});paintQ();return;} + if(files.length){promptQ.push({text:t,files,tid:activeTid});paintQ();return;} steerPrompt(t);return; } sendPrompt(t,files); }); +/* A message typed mid-turn steers the running turn only while that turn is on + screen. Typed into another thread or a new task, it waits in the queue for + that thread; queue items without one belong to the turn that ran. */ +function steerTarget(run,active){return run&&run!=='__pending'&&run===active?run:'';} +function queuedThread(item,ran){ + return item&&typeof item==='object'&&typeof item.tid==='string'&&item.tid!=='__pending'?item.tid:ran; +} /* 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=runTid&&runTid!=='__pending'?runTid:''; - if(!tid){promptQ.push(t);paintQ();return;} + const tid=steerTarget(runTid,activeTid); + if(!tid){promptQ.push({text:t,files:[],tid:activeTid});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;} + if(!r.ok){promptQ.push({text:t,files:[],tid});paintQ();return;} if(activeTid!==tid)return; // on screen is another thread; reopening this one shows it const art=document.createElement('article'); art.className='turn you'; @@ -1471,7 +1478,7 @@

SCHEDULES

art.querySelector('p').textContent=t; inner.appendChild(art); stick(true); - }catch{promptQ.push(t);paintQ();} + }catch{promptQ.push({text:t,files:[],tid});paintQ();} } async function sendPrompt(t,files){ if(t&&typeof t==='object'&&!Array.isArray(t)){files=t.files||[];t=t.text||'';} @@ -1635,12 +1642,13 @@

SCHEDULES

const ran=runTid!=='__pending'?runTid:''; runTid=''; refresh(); // state may have changed (wake on first tool); threads re-sync too - // Queued prompts were typed into the turn that just ended: they continue its - // thread, even if another one is on screen now. + // A queued prompt goes to the thread it was typed into, whatever is on screen now. if(promptQ.length){ const n=promptQ.shift();paintQ(); - if(ran&&ran!==activeTid)openThread(ran).then(()=>{if(chatCtl){promptQ.unshift(n);paintQ();}else sendPrompt(n);}); - else sendPrompt(n); + const to=queuedThread(n,ran); + if(to===activeTid)sendPrompt(n); + else if(!to){newTask();sendPrompt(n);} + else openThread(to).then(()=>{if(chatCtl){promptQ.unshift(n);paintQ();}else sendPrompt(n);}); } } } diff --git a/web/web-ui/test_nav.mjs b/web/web-ui/test_nav.mjs index bc8edb3..944ad2d 100644 --- a/web/web-ui/test_nav.mjs +++ b/web/web-ui/test_nav.mjs @@ -38,6 +38,8 @@ fs.writeFileSync(mod, ` ${grab('threadRowHtml')} ${grab('myThreads')} ${grab('navHtml')} + ${grab('steerTarget')} + ${grab('queuedThread')} export const set = (s) => { apiUp = s.apiUp ?? true; comps = s.comps || []; @@ -45,7 +47,7 @@ fs.writeFileSync(mod, ` comp = s.comp || null; pickLost = !!s.pickLost; }; - export { navHtml, myThreads }; + export { navHtml, myThreads, steerTarget, queuedThread }; `); const nav = await import('file://' + mod); @@ -87,6 +89,17 @@ assert(!html.includes('theirs'), 'hides another computer\'s threads'); assert(html.includes('legacy'), 'adopts pre-single-seat threads that have no agent'); assert(!html.includes('data-id='), 'sidebar lists no computers, only threads'); +// A message typed mid-turn steers only the turn on screen; typed anywhere else it +// is queued for the thread it was typed into ('' is a new task). +assert(nav.steerTarget('t1', 't1') === 't1', 'typing into the running thread steers it'); +assert(nav.steerTarget('t1', '') === '', 'typing into a new task does not steer the running turn'); +assert(nav.steerTarget('t1', 't2') === '', 'typing into another thread does not steer the running turn'); +assert(nav.steerTarget('__pending', '__pending') === '', 'a thread not yet born cannot be steered'); +assert(nav.queuedThread({ text: 'x', tid: '' }, 't1') === '', 'a prompt typed into a new task starts one'); +assert(nav.queuedThread({ text: 'x', tid: 't2' }, 't1') === 't2', 'a prompt typed into another thread goes there'); +assert(nav.queuedThread({ text: 'x', tid: '__pending' }, 't1') === 't1', 'typed while the thread was being born: the one that ran'); +assert(nav.queuedThread('x', 't1') === 't1', 'a queue item with no thread belongs to the turn that ran'); + if (failed) { console.error(`\n${failed} failed`); process.exit(1); diff --git a/web/web-ui/test_serve.mjs b/web/web-ui/test_serve.mjs index 8c1808a..798208b 100644 --- a/web/web-ui/test_serve.mjs +++ b/web/web-ui/test_serve.mjs @@ -513,11 +513,10 @@ assert.equal(pageFile('/deploy.html'), '/deploy.html'); assert.match(html, /if\(textFrame\)\{cancelAnimationFrame\(textFrame\);drawText\(\);\}\n caret\.remove\(\);/, 'the last frame is drawn before the caret goes'); assert.match(html, /if\(nav!==navDrawn\)/, 'refresh repaints the sidebar only when it changed'); - // switching threads mid-turn: a slow reopen never paints over a newer pick, and - // what is typed still steers the running turn, not the thread on screen + // switching threads mid-turn: a slow reopen never paints over a newer pick + // (where typed messages go is covered in test_nav.mjs) assert.match(html, /if\(gen!==threadGen\)return;/); - assert.match(html, /const tid=runTid&&runTid!=='__pending'\?runTid:'';/); - assert.ok(!/const tid=activeTid/.test(html)); + assert.match(html, /const tid=steerTarget\(runTid,activeTid\);/); assert.match(html, /ev\.type==='round'\)\{mark=/, 'the UI marks where each provider round began'); assert.match(html, /ev\.type==='round_reset'&&mark/, 'and drops a failed round\'s partial output on replay'); assert.match(caseToolsSrc, /emit\(\{ type: 'round_reset' \}\);\n result = await withRateRetry\(\(\) => round\(rest\)/, From a3d3b1249c521cc5d994650db8439f1d98b37a56 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:53:10 +0000 Subject: [PATCH 119/126] Let go of an /exec's pipes once it returns, even if a background job holds them. The readers blocked until EOF, so every `cmd &` left two threads, two pipe descriptors and up to 2 x CAP of buffer alive for as long as the job ran. They now poll and stop when the call's grace period ends, which closes the pipes as the call did before it stopped waiting for them. --- image/deskd.py | 21 ++++++++++++++++----- tests/test_deskd.py | 3 +++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/image/deskd.py b/image/deskd.py index c5652fd..4635c16 100644 --- a/image/deskd.py +++ b/image/deskd.py @@ -11,6 +11,7 @@ import json import os import re +import select import signal import struct import subprocess @@ -234,9 +235,16 @@ def home_path(path): return p if p.startswith(HOME + "/") else None -def _slurp(f, buf): - # drains until EOF even past CAP, so a writer never blocks on a full pipe - for chunk in iter(lambda: f.read1(65536), b""): +def _slurp(f, buf, stop): + # drains past CAP so a writer never blocks on a full pipe, and polls so /exec can + # let go of a pipe a background job still holds instead of waiting out the job + fd = f.fileno() + while not stop.is_set(): + if not select.select([fd], [], [], 0.1)[0]: + continue + chunk = os.read(fd, 65536) + if not chunk: + break if len(buf) <= CAP: buf.extend(chunk) f.close() @@ -261,8 +269,8 @@ def exec_(b: dict = Body(...)): start_new_session=True) except (FileNotFoundError, NotADirectoryError, PermissionError): return err(400, "bad_cwd", f"no such directory: {cwd}") - out, errb = bytearray(), bytearray() - readers = [threading.Thread(target=_slurp, args=io, daemon=True) + out, errb, stop = bytearray(), bytearray(), threading.Event() + readers = [threading.Thread(target=_slurp, args=(*io, stop), daemon=True) for io in ((p.stdout, out), (p.stderr, errb))] for t in readers: t.start() @@ -280,6 +288,9 @@ def exec_(b: dict = Body(...)): grace = time.time() + 0.5 for t in readers: t.join(max(0, grace - time.time())) + stop.set() + for t in readers: + t.join() out, errb = bytes(out), bytes(errb) if code is None: code, errb = 124, errb + b"\n[deskd] command timed out" diff --git a/tests/test_deskd.py b/tests/test_deskd.py index 183bb9b..d04c859 100644 --- a/tests/test_deskd.py +++ b/tests/test_deskd.py @@ -609,6 +609,9 @@ def test_exec_returns_when_bash_exits_despite_a_background_job(): try: assert out["exit_code"] == 0 and out["stdout"] == "started\n", out assert took < 5, took + # nor are its pipe readers left blocked on the job for as long as it runs + assert not [t for t in threading.enumerate() + if getattr(t, "_target", None) is deskd._slurp] finally: subprocess.run(["pkill", "-f", "sleep 30.17"]) From 804e80df9c354380251f627adc84df54d0b90fd3 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:54:37 +0000 Subject: [PATCH 120/126] Keep the scheduler lock off a computer's final sleep. The last run out held the scheduler's single lock while Docker stopped its box, so a slow stop delayed every other computer's schedules. A per-computer gate now orders a starting run's wake after that sleep, and the shared lock only guards the holder counts. --- control-plane/scheduler.py | 22 ++++++++++++++------ tests/test_scheduler.py | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/control-plane/scheduler.py b/control-plane/scheduler.py index 5420917..79748ac 100644 --- a/control-plane/scheduler.py +++ b/control-plane/scheduler.py @@ -38,6 +38,9 @@ # computer_id -> [runs using it, whether one of them woke it]. Two schedules can share # a box, and the one that woke it must not sleep it under the other: the last one out does. _HOLDERS = {} +# computer_id -> lock ordering a run's wake after the last run's sleep of that box, so +# a run starting mid-sleep waits and wakes it; _LOCK itself is never held over Docker. +_GATES = {} def _wall_exists(t, hh, mm): @@ -232,11 +235,13 @@ def run_schedule(sid): woke_for_run = False with _LOCK: _HOLDERS.setdefault(cid, [0, False])[0] += 1 + gate = _GATES.setdefault(cid, threading.Lock()) try: if bad: raise bad - was_asleep = get_computer(cid)["state"] == "asleep" - do_wake(cid) + with gate: + was_asleep = get_computer(cid)["state"] == "asleep" + do_wake(cid) woke_for_run = was_asleep code, summary = run_brain(cid, s["prompt"], name=s["name"]) status = "ok" if code == 0 else "fail" @@ -257,14 +262,19 @@ def run_schedule(sid): summary = f"{type(e).__name__}: {e}" log.exception("schedule %s run failed", sid) finally: - with _LOCK: # held through the sleep: a run starting now waits, then wakes + with _LOCK: held = _HOLDERS[cid] held[0] -= 1 held[1] = held[1] or woke_for_run - if not held[0]: - del _HOLDERS[cid] + last = not held[0] + if last: + with gate: + with _LOCK: # a run that joined since takes over the sleep + last = _HOLDERS.get(cid) is held and not held[0] + if last: + del _HOLDERS[cid] try: - if held[1] and not store.active_attempt_exists(cid): + if last and held[1] and not store.active_attempt_exists(cid): do_sleep(cid) except Exception: log.exception("sleep after schedule %s", sid) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 533b925..8478dbe 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -456,6 +456,47 @@ def run_a(): assert scheduler._HOLDERS == {} +def test_a_slow_sleep_does_not_hold_up_another_computer(): + # The last run out used to hold the scheduler's one lock while Docker stopped its + # box, so every other schedule waited on that stop. + import threading + import unittest.mock as mock + import scheduler + store.q("DELETE FROM schedules") + store.q("DELETE FROM runs") + state = {"c_x": "asleep", "c_y": "running"} + store.insert_schedule("sch_X", "c_x", "sch_X", "p", "interval", "3600", 0, + "2020-01-01T00:00:00Z") + store.insert_schedule("sch_Y", "c_y", "sch_Y", "p", "interval", "3600", 0, + "2020-01-01T00:00:00Z") + sleeping, release, y_done = threading.Event(), threading.Event(), threading.Event() + + def slow_sleep(cid): + sleeping.set() + release.wait(5) + state[cid] = "asleep" + + with mock.patch.object(scheduler, "get_computer", lambda cid: {"id": cid, "state": state[cid]}), \ + mock.patch.object(scheduler, "do_wake", lambda cid: state.update({cid: "running"})), \ + mock.patch.object(scheduler, "do_sleep", slow_sleep), \ + mock.patch.object(scheduler, "run_brain", lambda cid, p, name="": (0, "ok")), \ + mock.patch.object(scheduler, "capture_run_artifacts", lambda *a, **k: None), \ + mock.patch.object(scheduler.notifier, "push"), \ + mock.patch.object(scheduler, "emit"): + tx = threading.Thread(target=scheduler.run_schedule, args=("sch_X",)) + tx.start() + try: + assert sleeping.wait(5) + ty = threading.Thread(target=lambda: (scheduler.run_schedule("sch_Y"), y_done.set())) + ty.start() + assert y_done.wait(2), "a run on another computer waited on c_x's sleep" + finally: + release.set() + tx.join(5) + assert state == {"c_x": "asleep", "c_y": "running"}, state + assert scheduler._HOLDERS == {} + + def test_ram_tight_box_is_a_skip_too(): import scheduler from errors import ApiError From fe85d337be5701243b1c2d98e71a2c3e7acbf171 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:01:03 +0000 Subject: [PATCH 121/126] Send a queued prompt only once its own thread is on screen. A prompt queued for another thread was sent when openThread resolved, even if a different thread had been picked while it loaded, so it went to the new pick. drainQ now sends only into the queued thread and openThread drains once that thread has loaded; picked away, the prompt stays queued. Prompts queued while their thread was being born take its real id when it arrives. --- web/web-ui/index.html | 22 ++++++++++++++-------- web/web-ui/test_nav.mjs | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/web/web-ui/index.html b/web/web-ui/index.html index 17566b3..a44f405 100644 --- a/web/web-ui/index.html +++ b/web/web-ui/index.html @@ -929,6 +929,7 @@

SCHEDULES

inner.appendChild(art); } log.scrollTop=log.scrollHeight; + if(promptQ.length&&queuedThread(promptQ[0],'')===tid)drainQ(tid); }catch{ if(gen===threadGen)inner.innerHTML='
cannot reach the drive server
'; } @@ -1463,6 +1464,17 @@

SCHEDULES

function queuedThread(item,ran){ return item&&typeof item==='object'&&typeof item.tid==='string'&&item.tid!=='__pending'?item.tid:ran; } +/* Send the next queued prompt to the thread it was typed into, never to whatever is + on screen. A thread that has to load first drains from openThread once it is up; + pick another meanwhile and the prompt stays queued until its thread is opened. */ +function drainQ(ran){ + if(chatCtl||!promptQ.length)return; + const to=queuedThread(promptQ[0],ran); + if(to&&to!==activeTid){openThread(to);return;} + const n=promptQ.shift();paintQ(); + if(!to&&activeTid)newTask(); + sendPrompt(n); +} /* 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){ @@ -1598,6 +1610,7 @@

SCHEDULES

} if(activeTid==='__pending')activeTid=ev.thread_id; if(runTid==='__pending')runTid=ev.thread_id; + for(const p of promptQ)if(p&&p.tid==='__pending')p.tid=ev.thread_id; } else if(ev.type==='claim'&&ev.thread_id){claimThread(ev.thread_id,ev.agent);} else if(ev.type==='round'){mark={raw,think:thinkEl.textContent,tools:toolsEl.children.length};} @@ -1642,14 +1655,7 @@

SCHEDULES

const ran=runTid!=='__pending'?runTid:''; runTid=''; refresh(); // state may have changed (wake on first tool); threads re-sync too - // A queued prompt goes to the thread it was typed into, whatever is on screen now. - if(promptQ.length){ - const n=promptQ.shift();paintQ(); - const to=queuedThread(n,ran); - if(to===activeTid)sendPrompt(n); - else if(!to){newTask();sendPrompt(n);} - else openThread(to).then(()=>{if(chatCtl){promptQ.unshift(n);paintQ();}else sendPrompt(n);}); - } + drainQ(ran); } } diff --git a/web/web-ui/test_nav.mjs b/web/web-ui/test_nav.mjs index 944ad2d..3bff6b9 100644 --- a/web/web-ui/test_nav.mjs +++ b/web/web-ui/test_nav.mjs @@ -40,6 +40,15 @@ fs.writeFileSync(mod, ` ${grab('navHtml')} ${grab('steerTarget')} ${grab('queuedThread')} + // drainQ against a fake page: opening a thread is recorded, not loaded + let chatCtl = null; + const promptQ = [], calls = []; + const paintQ = () => {}; + const newTask = () => { calls.push('new'); activeTid = ''; }; + const sendPrompt = (n) => calls.push('send ' + n.text + ' to ' + (activeTid || 'new')); + const openThread = (t) => { calls.push('open ' + t); activeTid = t; }; + ${grab('drainQ')} + export const fake = { promptQ, calls, drainQ, view: (t) => { activeTid = t; } }; export const set = (s) => { apiUp = s.apiUp ?? true; comps = s.comps || []; @@ -100,6 +109,23 @@ assert(nav.queuedThread({ text: 'x', tid: 't2' }, 't1') === 't2', 'a prompt type assert(nav.queuedThread({ text: 'x', tid: '__pending' }, 't1') === 't1', 'typed while the thread was being born: the one that ran'); assert(nav.queuedThread('x', 't1') === 't1', 'a queue item with no thread belongs to the turn that ran'); +// A queued prompt is sent only into the thread it was typed into: if another thread +// is picked while that one loads, it waits instead of landing in the new pick. +const { fake } = nav; +fake.view('t1'); +fake.promptQ.push({ text: 'hi', files: [], tid: 't2' }); +fake.drainQ('t1'); +assert(fake.calls.join() === 'open t2', 'a prompt for another thread opens it first'); +fake.view('t3'); // clicked away while t2 loaded +assert(fake.promptQ.length === 1, 'and does not send while that thread is not on screen'); +fake.view('t2'); // t2 finished loading: openThread drains +fake.drainQ('t2'); +assert(fake.calls.join() === 'open t2,send hi to t2', 'it goes to t2 once t2 is up'); +fake.calls.length = 0; +fake.promptQ.push({ text: 'fresh', files: [], tid: '' }); +fake.drainQ('t2'); +assert(fake.calls.join() === 'new,send fresh to new', 'a prompt typed into a new task starts one'); + if (failed) { console.error(`\n${failed} failed`); process.exit(1); From f7493a04aa786a821e8d05d3d899c786f0667015 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:08:27 +0000 Subject: [PATCH 122/126] Hold a prompt for a thread until that thread has finished loading. openThread marks a thread active before its fetch returns, so a queued prompt, or one typed in that moment, was sent into a view the load then replaced, hiding the prompt and its reply. drainQ now waits while the thread is still loading, a prompt typed into a loading thread is queued, and openThread sends it once the thread is up. --- web/web-ui/index.html | 10 +++++++--- web/web-ui/test_nav.mjs | 16 +++++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/web/web-ui/index.html b/web/web-ui/index.html index a44f405..115a174 100644 --- a/web/web-ui/index.html +++ b/web/web-ui/index.html @@ -778,6 +778,7 @@

SCHEDULES

let comps=[]; let threads=[]; let activeTid=''; +let loadingTid=''; // activeTid while openThread is still fetching it let runTid=''; // thread of the turn in flight; activeTid is only the one on screen let threadGen=0; const EMPTY_HTML='
DRIVE

Tell it what to do. Watch if you want.

“open x.com and tell me what\'s on the home feed”
'; @@ -865,7 +866,7 @@

SCHEDULES

} function newTask(){ threadGen++; - activeTid=''; + activeTid=loadingTid=''; showEmpty(); paint(); $('q').focus(); @@ -900,12 +901,13 @@

SCHEDULES

async function openThread(tid){ if(tid===activeTid)return; const gen=++threadGen; - activeTid=tid; + activeTid=loadingTid=tid; paint(); try{ const r=await fetch('/api/threads?id='+encodeURIComponent(tid)); const j=await r.json(); if(gen!==threadGen)return; // clicked on to another thread meanwhile + loadingTid=''; if(!r.ok){inner.innerHTML='
'+esc(j.error||'thread gone')+'
';return;} // Deliberately does NOT follow j.agent: moving you to another computer // behind a click means another set of logins. Deploy owns the seat. @@ -931,7 +933,7 @@

SCHEDULES

log.scrollTop=log.scrollHeight; if(promptQ.length&&queuedThread(promptQ[0],'')===tid)drainQ(tid); }catch{ - if(gen===threadGen)inner.innerHTML='
cannot reach the drive server
'; + if(gen===threadGen){loadingTid='';inner.innerHTML='
cannot reach the drive server
';} } } @@ -1455,6 +1457,7 @@

SCHEDULES

if(files.length){promptQ.push({text:t,files,tid:activeTid});paintQ();return;} steerPrompt(t);return; } + if(loadingTid&&loadingTid===activeTid){promptQ.push({text:t,files,tid:activeTid});paintQ();return;} sendPrompt(t,files); }); /* A message typed mid-turn steers the running turn only while that turn is on @@ -1471,6 +1474,7 @@

SCHEDULES

if(chatCtl||!promptQ.length)return; const to=queuedThread(promptQ[0],ran); if(to&&to!==activeTid){openThread(to);return;} + if(to&&loadingTid===to)return; // sent into a view the load is about to replace const n=promptQ.shift();paintQ(); if(!to&&activeTid)newTask(); sendPrompt(n); diff --git a/web/web-ui/test_nav.mjs b/web/web-ui/test_nav.mjs index 3bff6b9..5fc679e 100644 --- a/web/web-ui/test_nav.mjs +++ b/web/web-ui/test_nav.mjs @@ -41,14 +41,15 @@ fs.writeFileSync(mod, ` ${grab('steerTarget')} ${grab('queuedThread')} // drainQ against a fake page: opening a thread is recorded, not loaded - let chatCtl = null; + let chatCtl = null, loadingTid = ''; const promptQ = [], calls = []; const paintQ = () => {}; const newTask = () => { calls.push('new'); activeTid = ''; }; const sendPrompt = (n) => calls.push('send ' + n.text + ' to ' + (activeTid || 'new')); - const openThread = (t) => { calls.push('open ' + t); activeTid = t; }; + const openThread = (t) => { calls.push('open ' + t); activeTid = loadingTid = t; }; ${grab('drainQ')} - export const fake = { promptQ, calls, drainQ, view: (t) => { activeTid = t; } }; + export const fake = { promptQ, calls, drainQ, view: (t) => { activeTid = t; loadingTid = ''; }, + loading: (t) => { activeTid = loadingTid = t; } }; export const set = (s) => { apiUp = s.apiUp ?? true; comps = s.comps || []; @@ -122,6 +123,15 @@ fake.view('t2'); // t2 finished loading: openT fake.drainQ('t2'); assert(fake.calls.join() === 'open t2,send hi to t2', 'it goes to t2 once t2 is up'); fake.calls.length = 0; +fake.loading('t5'); // a turn ends while t5 is still loading +fake.promptQ.push({ text: 'later', files: [], tid: 't5' }); +fake.drainQ('t4'); +assert(fake.calls.length === 0 && fake.promptQ.length === 1, + 'a prompt for a thread still loading waits for the load'); +fake.view('t5'); +fake.drainQ('t5'); +assert(fake.calls.join() === 'send later to t5', 'and is sent once it has loaded'); +fake.calls.length = 0; fake.promptQ.push({ text: 'fresh', files: [], tid: '' }); fake.drainQ('t2'); assert(fake.calls.join() === 'new,send fresh to new', 'a prompt typed into a new task starts one'); From e69986fe28f5f7da097913891634ac407147c4ee Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:13:09 +0000 Subject: [PATCH 123/126] Leave a thread that failed to load unopened, so it can be retried. A failed load kept the thread active with nothing shown: clicking it again did nothing, and a queued prompt could later be sent into history that never appeared. A failed load now leaves no thread open, so a click or the queue retries it and the prompt waits for a load that succeeds. --- web/web-ui/index.html | 5 +++-- web/web-ui/test_nav.mjs | 25 ++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/web/web-ui/index.html b/web/web-ui/index.html index 115a174..2030373 100644 --- a/web/web-ui/index.html +++ b/web/web-ui/index.html @@ -908,7 +908,8 @@

SCHEDULES

const j=await r.json(); if(gen!==threadGen)return; // clicked on to another thread meanwhile loadingTid=''; - if(!r.ok){inner.innerHTML='
'+esc(j.error||'thread gone')+'
';return;} + // a thread that never loaded is not open: clicking it (or a queued prompt) retries + if(!r.ok){activeTid='';paint();inner.innerHTML='
'+esc(j.error||'thread gone')+'
';return;} // Deliberately does NOT follow j.agent: moving you to another computer // behind a click means another set of logins. Deploy owns the seat. inner.innerHTML=''; @@ -933,7 +934,7 @@

SCHEDULES

log.scrollTop=log.scrollHeight; if(promptQ.length&&queuedThread(promptQ[0],'')===tid)drainQ(tid); }catch{ - if(gen===threadGen){loadingTid='';inner.innerHTML='
cannot reach the drive server
';} + if(gen===threadGen){activeTid=loadingTid='';paint();inner.innerHTML='
cannot reach the drive server
';} } } diff --git a/web/web-ui/test_nav.mjs b/web/web-ui/test_nav.mjs index 5fc679e..cf6e9f4 100644 --- a/web/web-ui/test_nav.mjs +++ b/web/web-ui/test_nav.mjs @@ -48,8 +48,15 @@ fs.writeFileSync(mod, ` const sendPrompt = (n) => calls.push('send ' + n.text + ' to ' + (activeTid || 'new')); const openThread = (t) => { calls.push('open ' + t); activeTid = loadingTid = t; }; ${grab('drainQ')} + // the real openThread, against a fetch that fails + let threadGen = 0, fetchFails = false; + const inner = { innerHTML: '', appendChild() {} }, log = {}, md = (x) => x, paint = () => {}; + const fetch = async () => { if (fetchFails) throw new Error('down'); return { ok: false, json: async () => ({ error: 'gone' }) }; }; + async ${grab('openThread').replace('function openThread(', 'function realOpenThread(')} export const fake = { promptQ, calls, drainQ, view: (t) => { activeTid = t; loadingTid = ''; }, - loading: (t) => { activeTid = loadingTid = t; } }; + loading: (t) => { activeTid = loadingTid = t; }, + open: realOpenThread, state: () => ({ activeTid, loadingTid }), + down: (v) => { fetchFails = v; } }; export const set = (s) => { apiUp = s.apiUp ?? true; comps = s.comps || []; @@ -132,6 +139,22 @@ fake.view('t5'); fake.drainQ('t5'); assert(fake.calls.join() === 'send later to t5', 'and is sent once it has loaded'); fake.calls.length = 0; +fake.promptQ.length = 0; +// A thread that fails to load is left unopened, so clicking it or draining retries. +for (const down of [false, true]) { + fake.view('t1'); + fake.down(down); + await fake.open('t6'); + const st = fake.state(); + assert(st.activeTid === '' && st.loadingTid === '', + (down ? 'an unreachable server' : 'a thread gone') + ' leaves no thread open to send into'); +} +fake.promptQ.push({ text: 'retry', files: [], tid: 't6' }); +fake.drainQ(''); +assert(fake.calls.join() === 'open t6' && fake.promptQ.length === 1, + 'a queued prompt for it retries the load instead of sending'); +fake.calls.length = 0; +fake.promptQ.length = 0; fake.promptQ.push({ text: 'fresh', files: [], tid: '' }); fake.drainQ('t2'); assert(fake.calls.join() === 'new,send fresh to new', 'a prompt typed into a new task starts one'); From 47a64f784b31710e2cfc82a2af445cc1ea23aadf Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:18:52 +0000 Subject: [PATCH 124/126] Start a new task for prompts queued to a thread that is gone. A queued prompt for a deleted thread stayed at the head of the queue: each drain retried the thread and stopped there, stranding the prompts behind it. When a thread fails to load because it is gone, or is deleted, its queued prompts start a new task instead and the queue moves on; a server that cannot be reached still leaves them queued for a retry. --- web/web-ui/index.html | 12 +++++++++++- web/web-ui/test_nav.mjs | 13 +++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/web/web-ui/index.html b/web/web-ui/index.html index 2030373..a61860b 100644 --- a/web/web-ui/index.html +++ b/web/web-ui/index.html @@ -884,6 +884,7 @@

SCHEDULES

if(row)row.classList.add('gone'); fetch('/api/threads?id='+encodeURIComponent(tid),{method:'DELETE'}).catch(()=>{}); threads=threads.filter(t=>t.id!==tid); + orphanQueued(tid); const finish=()=>{ if(activeTid===tid){activeTid='';showEmpty();} paint(); @@ -909,7 +910,11 @@

SCHEDULES

if(gen!==threadGen)return; // clicked on to another thread meanwhile loadingTid=''; // a thread that never loaded is not open: clicking it (or a queued prompt) retries - if(!r.ok){activeTid='';paint();inner.innerHTML='
'+esc(j.error||'thread gone')+'
';return;} + if(!r.ok){ + activeTid='';paint();inner.innerHTML='
'+esc(j.error||'thread gone')+'
'; + orphanQueued(tid);drainQ(''); + return; + } // Deliberately does NOT follow j.agent: moving you to another computer // behind a click means another set of logins. Deploy owns the seat. inner.innerHTML=''; @@ -1480,6 +1485,11 @@

SCHEDULES

if(!to&&activeTid)newTask(); sendPrompt(n); } +/* Prompts queued for a thread that is gone start a new task instead, so they are + neither lost nor left blocking the prompts queued behind them. */ +function orphanQueued(tid){ + for(const p of promptQ)if(p&&typeof p==='object'&&p.tid===tid)p.tid=''; +} /* 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){ diff --git a/web/web-ui/test_nav.mjs b/web/web-ui/test_nav.mjs index cf6e9f4..01961c6 100644 --- a/web/web-ui/test_nav.mjs +++ b/web/web-ui/test_nav.mjs @@ -48,6 +48,7 @@ fs.writeFileSync(mod, ` const sendPrompt = (n) => calls.push('send ' + n.text + ' to ' + (activeTid || 'new')); const openThread = (t) => { calls.push('open ' + t); activeTid = loadingTid = t; }; ${grab('drainQ')} + ${grab('orphanQueued')} // the real openThread, against a fetch that fails let threadGen = 0, fetchFails = false; const inner = { innerHTML: '', appendChild() {} }, log = {}, md = (x) => x, paint = () => {}; @@ -155,6 +156,18 @@ assert(fake.calls.join() === 'open t6' && fake.promptQ.length === 1, 'a queued prompt for it retries the load instead of sending'); fake.calls.length = 0; fake.promptQ.length = 0; +// A thread that is gone for good hands its queued prompts to a new task, and the +// prompts behind them are no longer stuck. +fake.view('t1'); +fake.down(false); +fake.promptQ.push({ text: 'orphan', files: [], tid: 't7' }, { text: 'next', files: [], tid: 't1' }); +await fake.open('t7'); +assert(fake.calls.join() === 'send orphan to new', + 'a prompt for a deleted thread starts a new task instead of blocking the queue'); +assert(fake.promptQ.length === 1 && fake.promptQ[0].text === 'next', 'and the rest stay queued in order'); +fake.calls.length = 0; +fake.promptQ.length = 0; +fake.view('t2'); fake.promptQ.push({ text: 'fresh', files: [], tid: '' }); fake.drainQ('t2'); assert(fake.calls.join() === 'new,send fresh to new', 'a prompt typed into a new task starts one'); From b2ee1305e9707eccbd6b8603853150a75a27f1e6 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:24:53 +0000 Subject: [PATCH 125/126] Hand queued prompts to a new task only when their thread is really gone. Any failed load orphaned the thread's queued prompts, so a 401 after a token change or a 5xx moved them to a new task that was just as likely to fail, and they were lost. Only a 404 does that now; any other failure leaves them queued for their own thread. --- web/web-ui/index.html | 2 +- web/web-ui/test_nav.mjs | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/web/web-ui/index.html b/web/web-ui/index.html index a61860b..d8fc565 100644 --- a/web/web-ui/index.html +++ b/web/web-ui/index.html @@ -912,7 +912,7 @@

SCHEDULES

// a thread that never loaded is not open: clicking it (or a queued prompt) retries if(!r.ok){ activeTid='';paint();inner.innerHTML='
'+esc(j.error||'thread gone')+'
'; - orphanQueued(tid);drainQ(''); + if(r.status===404){orphanQueued(tid);drainQ('');} // gone; a 401 or 5xx is retried return; } // Deliberately does NOT follow j.agent: moving you to another computer diff --git a/web/web-ui/test_nav.mjs b/web/web-ui/test_nav.mjs index 01961c6..4e563d9 100644 --- a/web/web-ui/test_nav.mjs +++ b/web/web-ui/test_nav.mjs @@ -50,14 +50,14 @@ fs.writeFileSync(mod, ` ${grab('drainQ')} ${grab('orphanQueued')} // the real openThread, against a fetch that fails - let threadGen = 0, fetchFails = false; + let threadGen = 0, fetchFails = false, failStatus = 404; const inner = { innerHTML: '', appendChild() {} }, log = {}, md = (x) => x, paint = () => {}; - const fetch = async () => { if (fetchFails) throw new Error('down'); return { ok: false, json: async () => ({ error: 'gone' }) }; }; + const fetch = async () => { if (fetchFails) throw new Error('down'); return { ok: false, status: failStatus, json: async () => ({ error: 'gone' }) }; }; async ${grab('openThread').replace('function openThread(', 'function realOpenThread(')} export const fake = { promptQ, calls, drainQ, view: (t) => { activeTid = t; loadingTid = ''; }, loading: (t) => { activeTid = loadingTid = t; }, open: realOpenThread, state: () => ({ activeTid, loadingTid }), - down: (v) => { fetchFails = v; } }; + down: (v) => { fetchFails = v; }, status: (v) => { failStatus = v; } }; export const set = (s) => { apiUp = s.apiUp ?? true; comps = s.comps || []; @@ -167,6 +167,15 @@ assert(fake.calls.join() === 'send orphan to new', assert(fake.promptQ.length === 1 && fake.promptQ[0].text === 'next', 'and the rest stay queued in order'); fake.calls.length = 0; fake.promptQ.length = 0; +fake.view('t1'); +fake.status(401); // the thread exists; this tab just lost its token +fake.promptQ.push({ text: 'keep', files: [], tid: 't8' }); +await fake.open('t8'); +assert(fake.calls.length === 0 && fake.promptQ[0].tid === 't8', + 'an unauthorized load keeps the prompt queued for its own thread'); +fake.status(404); +fake.calls.length = 0; +fake.promptQ.length = 0; fake.view('t2'); fake.promptQ.push({ text: 'fresh', files: [], tid: '' }); fake.drainQ('t2'); From a8cddb2ebb9f17c256a8e8f870de1c51d48e12c9 Mon Sep 17 00:00:00 2001 From: ishu86 <112744711+ishu86@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:31:01 +0000 Subject: [PATCH 126/126] Hold a prompt whose thread failed to load instead of blocking the queue. After a 401 or a server error the prompt stayed at the head of the queue, nothing retried it, and every prompt behind it waited too. It is now held: drains skip it, the rest of the queue moves on, and it goes out when its thread next loads. Nothing reopens the thread on a timer, since that would switch the view unasked; the chip says it is waiting. Drains also take a prompt for the thread already on screen first, so they never pull the view away from it. --- web/web-ui/index.html | 32 ++++++++++++++++++++++++-------- web/web-ui/test_nav.mjs | 21 +++++++++++++++++---- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/web/web-ui/index.html b/web/web-ui/index.html index d8fc565..7d0fe14 100644 --- a/web/web-ui/index.html +++ b/web/web-ui/index.html @@ -912,7 +912,8 @@

SCHEDULES

// a thread that never loaded is not open: clicking it (or a queued prompt) retries if(!r.ok){ activeTid='';paint();inner.innerHTML='
'+esc(j.error||'thread gone')+'
'; - if(r.status===404){orphanQueued(tid);drainQ('');} // gone; a 401 or 5xx is retried + if(r.status===404)orphanQueued(tid);else holdQueued(tid,true); // gone, or retried on the next open + drainQ(''); return; } // Deliberately does NOT follow j.agent: moving you to another computer @@ -937,9 +938,13 @@

SCHEDULES

inner.appendChild(art); } log.scrollTop=log.scrollHeight; - if(promptQ.length&&queuedThread(promptQ[0],'')===tid)drainQ(tid); + holdQueued(tid,false); + if(promptQ.some(p=>queuedThread(p,'')===tid))drainQ(tid); }catch{ - if(gen===threadGen){activeTid=loadingTid='';paint();inner.innerHTML='
cannot reach the drive server
';} + if(gen===threadGen){ + activeTid=loadingTid='';paint();inner.innerHTML='
cannot reach the drive server
'; + holdQueued(tid,true);drainQ(''); + } } } @@ -1428,6 +1433,7 @@

SCHEDULES

chip.className='qchip'; chip.innerHTML=''; chip.querySelector('.qtxt').textContent=item.text||((item.files[0]&&item.files[0].name)||'queued'); + if(p&&p.held)chip.title='Waits for its task to open'; chip.querySelector('.qx').addEventListener('click',()=>{promptQ.splice(i,1);paintQ();}); ql.appendChild(chip); }); @@ -1475,20 +1481,30 @@

SCHEDULES

} /* Send the next queued prompt to the thread it was typed into, never to whatever is on screen. A thread that has to load first drains from openThread once it is up; - pick another meanwhile and the prompt stays queued until its thread is opened. */ + pick another meanwhile and the prompt stays queued until its thread is opened. + Prompts for the thread already on screen go first, so draining never pulls the + view away from it. A prompt whose thread failed to load is held, not retried on + a timer (that would switch the view unasked): the rest of the queue moves on and + it goes out when its thread next loads. */ function drainQ(ran){ - if(chatCtl||!promptQ.length)return; - const to=queuedThread(promptQ[0],ran); + if(chatCtl)return; + const live=promptQ.filter(p=>!(p&&p.held)); + if(!live.length)return; + const n=live.find(p=>activeTid&&queuedThread(p,ran)===activeTid)||live[0]; + const to=queuedThread(n,ran); if(to&&to!==activeTid){openThread(to);return;} if(to&&loadingTid===to)return; // sent into a view the load is about to replace - const n=promptQ.shift();paintQ(); + promptQ.splice(promptQ.indexOf(n),1);paintQ(); if(!to&&activeTid)newTask(); sendPrompt(n); } /* Prompts queued for a thread that is gone start a new task instead, so they are neither lost nor left blocking the prompts queued behind them. */ function orphanQueued(tid){ - for(const p of promptQ)if(p&&typeof p==='object'&&p.tid===tid)p.tid=''; + for(const p of promptQ)if(p&&typeof p==='object'&&p.tid===tid){p.tid='';delete p.held;} +} +function holdQueued(tid,held){ + for(const p of promptQ)if(p&&typeof p==='object'&&p.tid===tid){if(held)p.held=true;else delete p.held;} } /* 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. */ diff --git a/web/web-ui/test_nav.mjs b/web/web-ui/test_nav.mjs index 4e563d9..96dee42 100644 --- a/web/web-ui/test_nav.mjs +++ b/web/web-ui/test_nav.mjs @@ -49,10 +49,15 @@ fs.writeFileSync(mod, ` const openThread = (t) => { calls.push('open ' + t); activeTid = loadingTid = t; }; ${grab('drainQ')} ${grab('orphanQueued')} + ${grab('holdQueued')} // the real openThread, against a fetch that fails let threadGen = 0, fetchFails = false, failStatus = 404; const inner = { innerHTML: '', appendChild() {} }, log = {}, md = (x) => x, paint = () => {}; - const fetch = async () => { if (fetchFails) throw new Error('down'); return { ok: false, status: failStatus, json: async () => ({ error: 'gone' }) }; }; + const fetch = async () => { + if (fetchFails) throw new Error('down'); + if (failStatus === 200) return { ok: true, status: 200, json: async () => ({ turns: [] }) }; + return { ok: false, status: failStatus, json: async () => ({ error: 'gone' }) }; + }; async ${grab('openThread').replace('function openThread(', 'function realOpenThread(')} export const fake = { promptQ, calls, drainQ, view: (t) => { activeTid = t; loadingTid = ''; }, loading: (t) => { activeTid = loadingTid = t; }, @@ -169,10 +174,18 @@ fake.calls.length = 0; fake.promptQ.length = 0; fake.view('t1'); fake.status(401); // the thread exists; this tab just lost its token -fake.promptQ.push({ text: 'keep', files: [], tid: 't8' }); +fake.promptQ.push({ text: 'keep', files: [], tid: 't8' }, { text: 'behind', files: [], tid: '' }); +await fake.open('t8'); +assert(fake.promptQ.length === 1 && fake.promptQ[0].tid === 't8' && fake.promptQ[0].held, + 'an unauthorized load holds the prompt for its own thread'); +assert(fake.calls.join() === 'send behind to new', 'and the prompts behind it are not stuck'); +fake.calls.length = 0; +fake.drainQ(''); +assert(fake.calls.length === 0, 'a held prompt does not keep retrying its thread on every drain'); +fake.status(200); // access is back and the user opens t8 await fake.open('t8'); -assert(fake.calls.length === 0 && fake.promptQ[0].tid === 't8', - 'an unauthorized load keeps the prompt queued for its own thread'); +assert(fake.calls.join() === 'send keep to t8' && fake.promptQ.length === 0, + 'it goes out once its thread loads'); fake.status(404); fake.calls.length = 0; fake.promptQ.length = 0;