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 diff --git a/.env.example b/.env.example index 2dcd3e7..4f0156f 100644 --- a/.env.example +++ b/.env.example @@ -14,13 +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 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. +# 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 @@ -30,6 +30,9 @@ CASE_LOCAL=1 # 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_LOCAL=1 # 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_LOCAL=1 # 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,3 +86,12 @@ CASE_LOCAL=1 # 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 +# line turns them off. +# CASE_TELEMETRY=0 +# DO_NOT_TRACK=1 diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..960251c --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,43 @@ +# 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 + - image/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 + # 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7835313..dd9781e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,24 +11,36 @@ 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: | 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 @@ -47,6 +59,30 @@ jobs: npm --prefix web ci --omit=dev # serve.mjs imports openai at module load npm --prefix web test + - name: Shell scripts + 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 + + # 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 @@ -55,6 +91,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 +125,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' 'web/*.html' '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 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 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 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 diff --git a/README.md b/README.md index ede43ec..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. @@ -242,7 +244,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 @@ -277,7 +279,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 @@ -326,13 +330,20 @@ 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 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 @@ -375,11 +386,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/SECURITY.md b/SECURITY.md index c10006d..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 @@ -29,11 +31,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/bin/case b/bin/case index 6e7e5cf..2eec82b 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 @@ -42,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/compose.yaml b/compose.yaml index 683a98b..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,11 @@ 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:-} volumes: - /var/run/docker.sock:/var/run/docker.sock - case-home:/data @@ -87,6 +95,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"] @@ -114,7 +124,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/control-plane/assist.py b/control-plane/assist.py index ef07600..d0c0cf6 100644 --- a/control-plane/assist.py +++ b/control-plane/assist.py @@ -14,31 +14,18 @@ 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): 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") - - 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.""" @@ -59,10 +46,11 @@ 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: + 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") @@ -93,13 +81,14 @@ 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 LIVE_STATUSES and not attempt: + if bound["status"] in HANDOFF_LIVE and not attempt: return bound, bound, None if attempt: @@ -107,16 +96,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 +116,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 @@ -149,8 +138,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 @@ -170,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.""" - if attempt and attempt["status"] in _ATTEMPT_TERMINAL: + 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 LIVE_STATUSES: + 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": @@ -196,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 { @@ -209,7 +194,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 +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 LIVE_STATUSES 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 @@ -277,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): @@ -291,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): @@ -329,7 +314,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 +451,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", @@ -483,6 +468,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/auth_attempts.py b/control-plane/auth_attempts.py index faec6ef..edc351d 100644 --- a/control-plane/auth_attempts.py +++ b/control-plane/auth_attempts.py @@ -18,20 +18,23 @@ 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 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 +# 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") @@ -97,6 +100,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"), } @@ -117,7 +121,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": @@ -126,6 +131,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: @@ -147,19 +162,36 @@ 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") 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") _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 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 +205,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 +222,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,25 +290,22 @@ 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", 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 ("pending", "validating"): - 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 _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): @@ -291,7 +316,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 @@ -300,24 +324,23 @@ 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: - 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 ("pending", "validating"): - store.transition_handoff(hid, "completed", answer=None) - return pub async def wait_attempt(attempt_id, after_revision=0, after_handoff_id=None, @@ -370,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)) @@ -386,13 +409,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") @@ -417,15 +440,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 ("pending", "validating"): - # 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 @@ -438,7 +463,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, @@ -447,30 +472,25 @@ 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): - """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 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) - 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, - }) + pub = _cas_or_conflict(attempt_id, row["status"], "failed", rev, fail_reason=reason) + record_login(row["computer_id"], row["credential"], "failed", attempt_id=attempt_id) return pub @@ -536,9 +556,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 @@ -563,7 +583,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) @@ -581,42 +601,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 @@ -631,13 +625,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) @@ -646,22 +640,27 @@ 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) # 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/control-plane/browse.py b/control-plane/browse.py index ebb012c..7493a1a 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){ @@ -214,6 +218,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 +241,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 +257,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): @@ -387,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): @@ -424,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]; @@ -512,6 +521,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/control-plane/captcha.py b/control-plane/captcha.py index 1cd3679..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 @@ -386,20 +386,20 @@ 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 -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": @@ -420,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} @@ -512,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 @@ -541,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: diff --git a/control-plane/cased.py b/control-plane/cased.py index f21c60e..abb09e9 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 @@ -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 ---------- @@ -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") @@ -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() @@ -341,9 +359,9 @@ 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.dc().ping() + dockerd.ping() docker_ok = True except Exception: docker_ok = False @@ -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")), @@ -602,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(...)): @@ -664,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): @@ -682,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.""" @@ -694,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)) @@ -721,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 @@ -747,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") @@ -764,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") @@ -775,17 +792,20 @@ 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 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." 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") @@ -800,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": @@ -810,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") @@ -821,7 +841,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 @@ -836,6 +856,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 @@ -852,6 +873,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() @@ -880,7 +904,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 @@ -889,6 +914,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: @@ -901,11 +934,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: @@ -959,7 +994,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"]) @@ -967,7 +1002,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. @@ -1011,7 +1046,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 ---------- @@ -1042,7 +1078,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) ---------- @@ -1053,7 +1092,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"])} @@ -1088,51 +1127,76 @@ 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) + store.prune_terminal_auth_attempts(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(): 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/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/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/control-plane/dockerd.py b/control-plane/dockerd.py index 617887e..e9733bb 100644 --- a/control-plane/dockerd.py +++ b/control-plane/dockerd.py @@ -9,8 +9,9 @@ import time import docker +import requests -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 @@ -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): @@ -150,15 +170,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: + 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() - except Exception: + _docker(lambda c: c.volumes.get(volume)).remove() + 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) + _docker(lambda c: c.volumes.create(name=volume, labels={"managed-by": "cased"})) 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: diff --git a/control-plane/handoffs.py b/control-plane/handoffs.py index 1be3c1f..62f3bc9 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 @@ -54,8 +54,9 @@ def _public_host(): VERIFY_DONE_VALUES = frozenset({"done", "approve", "i'm done", "im done", "i am done"}) -TERMINAL_STATUSES = frozenset({"completed", "answered", "failed", "expired"}) - +# 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") def continuation_for(kind, continuation=None): if continuation: @@ -99,7 +100,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"): @@ -195,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): @@ -237,7 +236,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") @@ -263,13 +262,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): @@ -291,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 @@ -302,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) @@ -325,15 +319,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( @@ -341,8 +333,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. @@ -357,6 +348,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) @@ -366,19 +361,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) @@ -396,14 +391,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/lifecycle.py b/control-plane/lifecycle.py index 2b214f7..9023aaf 100644 --- a/control-plane/lifecycle.py +++ b/control-plane/lifecycle.py @@ -8,8 +8,12 @@ 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 concurrent.futures import ThreadPoolExecutor +from contextlib import contextmanager from config import DESK_H, DESK_W, IMAGE, MAX_RAM_MB, MAX_RUNNING, log from errors import ApiError @@ -29,6 +33,43 @@ "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 +# 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 +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 +94,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}) @@ -124,12 +165,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}" - store.insert_computer(cid, name, IMAGE, cpus, ram_mb, volume, token) + with _in_flight(cid, serialize=False): + 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, cpus, ram_mb, volume, token): try: dockerd.create_volume(volume) container = dockerd.create_container(cid, cpus, ram_mb, volume, token) @@ -162,6 +209,7 @@ def _try_set(cid, to): return False +@_serialized def destroy(cid): row = get_computer(cid) dockerd.destroy_infra(cid, row["volume"]) @@ -172,6 +220,7 @@ def destroy(cid): _force_state(cid, row["state"], "deleted") +@_serialized def do_sleep(cid): row = get_computer(cid) if row["state"] == "asleep": @@ -194,20 +243,25 @@ 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 +@_serialized def do_wake(cid): row = get_computer(cid) if row["state"] == "running" and dockerd.container_up(cid): @@ -215,10 +269,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) @@ -235,12 +292,23 @@ 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"): - # 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,25 +325,33 @@ 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] + 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") + _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) 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/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: diff --git a/control-plane/login_flow.py b/control-plane/login_flow.py index 888c8d1..b91fcd7 100644 --- a/control-plane/login_flow.py +++ b/control-plane/login_flow.py @@ -9,12 +9,12 @@ import auth_attempts import captcha -import events 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 store +from store import HANDOFF_LIVE, store from util import row_get @@ -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) @@ -226,20 +227,23 @@ 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) 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. @@ -249,26 +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} - # 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: - 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: @@ -286,7 +281,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/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/control-plane/scheduler.py b/control-plane/scheduler.py index 506e7f9..79748ac 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 @@ -34,6 +35,12 @@ 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 = {} +# 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): @@ -149,13 +156,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)" @@ -203,16 +216,32 @@ 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 + with _LOCK: + _HOLDERS.setdefault(cid, [0, False])[0] += 1 + gate = _GATES.setdefault(cid, threading.Lock()) try: - was_asleep = get_computer(cid)["state"] == "asleep" - do_wake(cid) + if bad: + raise bad + 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" @@ -233,11 +262,22 @@ 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 = _HOLDERS[cid] + held[0] -= 1 + held[1] = held[1] or woke_for_run + 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 last and 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 "" @@ -294,6 +334,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/control-plane/session_keeper.py b/control-plane/session_keeper.py index 0e03199..7030f6a 100644 --- a/control-plane/session_keeper.py +++ b/control-plane/session_keeper.py @@ -3,14 +3,14 @@ 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). 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,13 +24,14 @@ ) from config import log from lifecycle import do_sleep, do_wake, get_computer +from notify import notifier 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"))) +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 = {} @@ -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): @@ -138,6 +131,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) @@ -148,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 @@ -159,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) @@ -170,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 @@ -209,11 +217,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 +232,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/control-plane/store.py b/control-plane/store.py index b6cab73..33791fc 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, @@ -53,14 +66,16 @@ 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) 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_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 ( @@ -74,6 +89,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 @@ -133,32 +149,14 @@ def __init__(self, home=None): ("credentials", "proof_spec", "TEXT"), ("credentials", "verification_hosts", "TEXT"), ("schedules", "tz", "TEXT"), + ("auth_attempts", "fail_reason", "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})")] 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() @@ -205,6 +203,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'") @@ -346,7 +347,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): @@ -355,7 +356,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): @@ -380,7 +381,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): @@ -388,9 +389,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.""" @@ -407,7 +405,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) @@ -444,19 +442,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(self.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)) + 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(self.AUTH_ATTEMPT_ACTIVE)) - return self.all(f"SELECT * FROM auth_attempts WHERE status IN ({qs}) AND updated_at < ?", - (*self.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: @@ -465,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( @@ -618,7 +617,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"), @@ -629,9 +628,13 @@ 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 < ?", + 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_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): diff --git a/image/Dockerfile b/image/Dockerfile index 948cb6b..b2f980d 100644 --- a/image/Dockerfile +++ b/image/Dockerfile @@ -41,14 +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 \ - 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 \ + && /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 @@ -62,20 +59,22 @@ 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 +# 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 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 diff --git a/image/deskd.py b/image/deskd.py index 4c30879..4635c16 100644 --- a/image/deskd.py +++ b/image/deskd.py @@ -11,6 +11,8 @@ import json import os import re +import select +import signal import struct import subprocess import threading @@ -34,6 +36,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 +49,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 +204,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 +216,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 @@ -200,23 +235,67 @@ def home_path(path): return p if p.startswith(HOME + "/") else None +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() + + @app.post("/exec") def exec_(b: dict = Body(...)): + gen = state["inject_gen"] if (r := injecting()): return r if "command" not in b: return err(400, "bad_request", "command required") - timeout = min(int(b.get("timeout_s", 30)), 600) + 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: - 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, 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() + 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())) + 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" + 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} @@ -229,8 +308,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: @@ -264,16 +346,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): @@ -298,7 +385,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": @@ -366,11 +455,15 @@ 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: 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: @@ -381,6 +474,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") @@ -418,6 +513,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 @@ -617,8 +713,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) @@ -633,6 +728,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. @@ -683,8 +789,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 @@ -700,10 +809,13 @@ 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") state["login"] = None state["in_login"] = True - state["injecting"] = True try: tab = Tab() try: @@ -725,19 +837,24 @@ 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 @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 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 - state["injecting"] = True try: tab = Tab() try: @@ -753,13 +870,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: @@ -770,7 +882,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 @@ -802,8 +914,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 - state["injecting"] = True try: tab = Tab() try: @@ -815,16 +928,20 @@ 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() except Exception as e: return {"ok": False, "reason": f"submit_challenge error: {type(e).__name__}: {e}"} finally: - state["injecting"] = False - state["in_login"] = False + inject_end() + if not state["login"]: + state["in_login"] = False @app.post("/auth/navigate_verification") @@ -928,12 +1045,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(): @@ -1080,8 +1192,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 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 diff --git a/image/start.sh b/image/start.sh index accb9ab..3b59d6f 100644 --- a/image/start.sh +++ b/image/start.sh @@ -3,10 +3,31 @@ # 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 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). @@ -109,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" diff --git a/mcp/case_mcp.py b/mcp/case_mcp.py index f646712..324dc99 100644 --- a/mcp/case_mcp.py +++ b/mcp/case_mcp.py @@ -11,13 +11,16 @@ 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 BASE = os.environ.get("CASE_URL", "http://127.0.0.1:8787/v1") HTTP = os.environ.get("CASE_MCP_HTTP") == "1" @@ -25,9 +28,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: @@ -41,12 +65,21 @@ async def _health(request: Request) -> PlainTextResponse: return PlainTextResponse("ok") -def _headers(**extra): +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(): h = {"X-Case-Session": SESSION} tok = (os.environ.get("CASE_TOKEN") or "").strip() if tok: h["Authorization"] = "Bearer " + tok - h.update(extra) return h @@ -63,7 +96,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. @@ -73,7 +106,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 — @@ -82,7 +115,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. @@ -93,12 +126,12 @@ def computer_screenshot(computer_id: str, marks: bool = False) -> Image: return Image(data=r.content, format="png") -@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, +@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, + 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; @@ -123,15 +156,18 @@ def computer_action(computer_id: str, type: str, x: int = None, y: int = None, 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').""" + """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() -@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 @@ -149,7 +185,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 @@ -165,7 +201,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 @@ -185,9 +221,9 @@ def computer_snapshot(computer_id: str) -> dict: return call("GET", f"/computers/{computer_id}/page", params={"wake": "true"}).json() -@mcp.tool() -def computer_click_element(computer_id: str, ref: int, name: str = None, - text: str = None, screenshot: bool = False) -> dict: +@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 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 @@ -211,8 +247,8 @@ def computer_click_element(computer_id: str, ref: int, name: str = None, params={"wake": "true"}, json=body, timeout=60).json() -@mcp.tool() -def computer_hover(computer_id: str, ref: int, name: str = None) -> dict: +@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.""" body = {"ref": ref} @@ -222,8 +258,8 @@ def computer_hover(computer_id: str, ref: int, name: str = None) -> dict: params={"wake": "true"}, json=body, timeout=40).json() -@mcp.tool() -def computer_upload(computer_id: str, ref: int, path: str, name: str = None) -> dict: +@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 this tool — write the file with computer_file_put or computer_exec first.""" @@ -234,7 +270,7 @@ def computer_upload(computer_id: str, ref: int, path: str, name: str = None) -> 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 @@ -253,8 +289,8 @@ def computer_fill(computer_id: str, fields: list, submit: bool = False) -> dict: params={"wake": "true"}, json=body, timeout=60).json() -@mcp.tool() -def computer_wait_for(computer_id: str, selector: str = None, text: str = None, +@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: """Block in this one call until the page is ready — instead of polling with @@ -279,9 +315,9 @@ def computer_wait_for(computer_id: str, selector: str = None, text: str = None, params={"wake": "true"}, json=body, timeout=timeout_s + 30).json() -@mcp.tool() -def computer_tabs(computer_id: str, action: str = "list", target_id: str = None, - url: str = None) -> dict: +@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 [{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 @@ -297,7 +333,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 @@ -309,7 +345,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 @@ -321,9 +357,9 @@ 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, 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", @@ -347,7 +383,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: @@ -358,14 +394,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: @@ -411,7 +447,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. @@ -482,7 +518,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. @@ -490,8 +526,8 @@ def computer_sleep(computer_id: str) -> dict: return call("POST", f"/computers/{computer_id}/sleep").json() -@mcp.tool() -def auth_attempt_wait(attempt_id: str, since_revision: int = None, +@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. This is also the inspection tool: for a non-blocking snapshot of where a login @@ -545,20 +581,15 @@ def auth_attempt_wait(attempt_id: str, since_revision: int = 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} -@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; @@ -572,7 +603,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 | @@ -584,7 +615,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. @@ -599,7 +630,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: @@ -614,26 +645,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/requirements-dev.txt b/requirements-dev.txt index ef02e79..250f36c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,5 +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 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 diff --git a/tests/_helpers.py b/tests/_helpers.py new file mode 100644 index 0000000..d875e09 --- /dev/null +++ b/tests/_helpers.py @@ -0,0 +1,105 @@ +# 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 +import tempfile +import time +import traceback + +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 + + +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")) + + +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. + + 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.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..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 @@ -35,6 +36,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 @@ -126,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 1c10f9a..4a96662 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 @@ -35,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() @@ -169,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(): @@ -197,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 @@ -293,6 +275,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") @@ -311,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(): @@ -510,8 +513,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 5e5c8a6..15ae34e 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 + +_helpers.isolated_home() import auth_attempts # noqa: E402 import handoffs # noqa: E402 @@ -32,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", @@ -121,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 @@ -136,8 +124,8 @@ 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), - "revision_conflict") + _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) assert n == 0 @@ -158,6 +146,69 @@ 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_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: + _helpers.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): + _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] + 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( @@ -168,10 +219,10 @@ 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), - "revision_conflict") - _raises(lambda: auth_attempts.claim_challenge("h_claim", expected_revision=1), - "revision_conflict") + _helpers.raises(lambda: auth_attempts.claim_challenge("h_claim", expected_revision=0), + "revision_conflict") + _helpers.raises(lambda: auth_attempts.claim_challenge("h_claim", expected_revision=1), + "revision_conflict") def test_missing_proof_spec_ends_unverified(): @@ -190,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" @@ -210,6 +263,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() @@ -314,6 +375,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", @@ -329,6 +414,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"]) @@ -372,6 +475,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") @@ -407,6 +532,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( @@ -453,15 +612,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..97a46f2 100644 --- a/tests/test_auth_mcp.py +++ b/tests/test_auth_mcp.py @@ -2,22 +2,9 @@ """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 -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) +import _helpers def _ok(payload): @@ -30,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 @@ -39,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: @@ -72,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: @@ -83,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") @@ -96,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"] == { @@ -105,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", @@ -133,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", @@ -174,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", @@ -206,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", @@ -231,7 +218,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 27960fb..8020491 100644 --- a/tests/test_auth_wait.py +++ b/tests/test_auth_wait.py @@ -2,15 +2,11 @@ """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 + +_helpers.isolated_home() import auth_attempts # noqa: E402 import events # noqa: E402 @@ -272,16 +268,17 @@ 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" -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 +331,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") @@ -361,15 +382,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 7b1163d..0164493 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 @@ -35,6 +34,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"} @@ -122,9 +136,60 @@ 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): - fn() - print("ok", name) - print("PASS") + _helpers.run_tests(globals()) diff --git a/tests/test_browse.py b/tests/test_browse.py index 67e572f..0cde616 100644 --- a/tests/test_browse.py +++ b/tests/test_browse.py @@ -2,15 +2,16 @@ """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 +browse.time = _helpers.FakeClock() + ROW = {"desk_port": 1, "desk_token": "t"} ELS = [ {"tag": "a", "type": "", "name": "Home", "value": "", "href": "/"}, @@ -246,6 +247,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(): @@ -421,6 +434,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}}) @@ -500,6 +527,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"},' @@ -671,8 +710,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 f27992f..9cdb8f9 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", @@ -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"} @@ -716,9 +705,41 @@ 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 store.q("DELETE FROM auth_attempts WHERE computer_id=?", (cid,)) store.q("DELETE FROM handoffs WHERE computer_id=?", (cid,)) store.delete_computer(cid) @@ -728,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() @@ -759,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() @@ -807,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), \ @@ -824,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), \ @@ -846,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), \ @@ -871,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), \ @@ -895,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), \ @@ -914,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): @@ -924,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"} @@ -958,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"} @@ -994,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() @@ -1017,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() @@ -1028,6 +1037,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"): @@ -1042,8 +1052,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 703e442..d04c859 100644 --- a/tests/test_deskd.py +++ b/tests/test_deskd.py @@ -7,10 +7,15 @@ """ import base64 import os +import subprocess import sys +import threading +import time 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")) @@ -187,6 +192,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(): @@ -573,6 +592,67 @@ 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 + # 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"]) + + +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_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 @@ -666,6 +746,75 @@ 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) + # 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"]}, + "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 + + +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.""" + + 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 @@ -703,6 +852,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 @@ -746,9 +930,27 @@ 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_"): - fn() - print("ok", name) - print("PASS") + _helpers.run_tests(globals()) diff --git a/tests/test_dockerd.py b/tests/test_dockerd.py index 36528c6..d459dec 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 @@ -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} @@ -137,9 +150,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: @@ -149,12 +160,85 @@ 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})() + 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})() + 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"}) + + +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() - test_compose_mode_uses_container_dns_and_no_host_ports() - test_deskclient_accepts_sqlite_row() - 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() - print("test_dockerd: ok") + _helpers.run_tests(globals()) diff --git a/tests/test_gates.py b/tests/test_gates.py index 65887f4..21e1895 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 @@ -118,6 +116,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. @@ -193,10 +222,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()): - 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 3936223..2a4c041 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 @@ -195,7 +193,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 @@ -349,6 +347,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") @@ -362,6 +378,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: @@ -407,7 +464,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" @@ -522,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 c97aa60..62d79d0 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 @@ -29,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). @@ -45,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) @@ -66,13 +55,70 @@ 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) 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: + _helpers.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" @@ -84,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,)) @@ -107,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: @@ -183,12 +229,195 @@ 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, "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), \ + 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_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" + _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, "managed_containers", return_value={}): + 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, "managed_containers", return_value={}): + 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. 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: @@ -239,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 @@ -247,8 +476,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 5653014..89ba45a 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 @@ -17,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") @@ -157,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 @@ -170,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",)) @@ -221,8 +200,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 05dbb9b..9ef5c80 100644 --- a/tests/test_mcp_http.py +++ b/tests/test_mcp_http.py @@ -3,29 +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 -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"): - os.environ.pop(k, None) - os.environ.update(env) - mod = importlib.import_module("case_mcp") - return importlib.reload(mod) +import _helpers 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 @@ -33,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 @@ -56,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") @@ -76,15 +64,92 @@ 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 per (Host, Origin) to one HTTP app; returns the statuses.""" + from starlette.testclient import TestClient + 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: + for host, origin in requests_: + h = {"Host": host, **ACCEPT} + if origin: + h["Origin"] = origin + out.append(c.post("/mcp", headers=h, json=LIST_CALL).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_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 = _helpers.load_case_mcp(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 + 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: + 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() + m = _helpers.load_case_mcp() 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__": - 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 7cd903d..25593d4 100644 --- a/tests/test_navigate.py +++ b/tests/test_navigate.py @@ -2,14 +2,14 @@ """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 +deskclient.time = _helpers.FakeClock() + ROW = {"desk_port": 1, "desk_token": "t"} OK = {"ok": True, "value": None} @@ -179,8 +179,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 70e008b..5aff59e 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) @@ -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"}) @@ -202,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 e67913b..aa51704 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: @@ -85,6 +83,34 @@ 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_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 @@ -251,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 fcd9d95..8478dbe 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 @@ -246,6 +244,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. @@ -385,6 +408,95 @@ 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_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 @@ -421,6 +533,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 @@ -557,8 +714,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 a879858..db0933e 100644 --- a/tests/test_session_keeper.py +++ b/tests/test_session_keeper.py @@ -3,14 +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 @@ -116,7 +114,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" @@ -124,6 +122,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() @@ -149,6 +168,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() @@ -227,6 +301,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: @@ -255,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..4152114 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -1,20 +1,9 @@ # 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 -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) +import _helpers def _ok(payload=None, content=b""): @@ -44,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("") @@ -55,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") @@ -67,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: @@ -79,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") @@ -91,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") @@ -101,20 +90,16 @@ 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) 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 bfabcf4..8e5c518 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -1,15 +1,12 @@ # 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 + +_helpers.isolated_home() from store import store # noqa: E402 @@ -35,10 +32,34 @@ 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() - 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 7d6a3c4..1a06219 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) @@ -105,9 +102,18 @@ 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_"): - fn() - print("ok", name) - print("PASS") + _helpers.run_tests(globals()) diff --git a/tests/test_token.py b/tests/test_token.py index 3e472a9..e492d25 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 @@ -63,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()) 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..7ba1e7a 100644 --- a/web/web-ui/README.md +++ b/web/web-ui/README.md @@ -13,16 +13,19 @@ 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. +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`). 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. diff --git a/web/web-ui/case-tools.mjs b/web/web-ui/case-tools.mjs index c5a8a15..8607728 100644 --- a/web/web-ui/case-tools.mjs +++ b/web/web-ui/case-tools.mjs @@ -14,11 +14,11 @@ 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 } }, ]; -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`; } @@ -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 }); @@ -274,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) => { @@ -367,9 +375,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 @@ -382,19 +395,30 @@ 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) { 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); } -/** 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 - * a failed round is safe. `signal` cancels the backoff sleep on STOP. */ +/** 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; const err = new Error(signal?.reason ? String(signal.reason) : 'stopped by user'); @@ -422,15 +446,20 @@ 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(); } 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'; + 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: `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); } } @@ -440,7 +469,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 = { @@ -455,8 +484,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; @@ -487,24 +519,27 @@ 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); } 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; + emit({ type: 'round_reset' }); result = await withRateRetry(() => round(rest), emit, 5, signal); } const { message, traces, textDelta } = result; const u = message.usage || {}; - spend.in += (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) - + (u.cache_creation_input_tokens || 0); - spend.cached += u.cache_read_input_tokens || 0; + 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) { @@ -526,10 +561,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/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 00c8e46..7d0fe14 100644 --- a/web/web-ui/index.html +++ b/web/web-ui/index.html @@ -1,4 +1,5 @@ + @@ -624,7 +625,7 @@
-
+
LOADING

Add credential

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

SCHEDULES

@@ -770,12 +771,16 @@

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 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”
'; function showEmpty(){ $('loginner').innerHTML=EMPTY_HTML; @@ -790,8 +795,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(); @@ -830,10 +840,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; @@ -853,7 +865,8 @@

SCHEDULES

paint(); } function newTask(){ - activeTid=''; + threadGen++; + activeTid=loadingTid=''; showEmpty(); paint(); $('q').focus(); @@ -871,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(); @@ -887,12 +901,21 @@

SCHEDULES

$('newAgent').addEventListener('click',()=>newTask()); async function openThread(tid){ if(tid===activeTid)return; - activeTid=tid; + const gen=++threadGen; + activeTid=loadingTid=tid; paint(); try{ const r=await fetch('/api/threads?id='+encodeURIComponent(tid)); const j=await r.json(); - if(!r.ok){inner.innerHTML='
'+esc(j.error||'thread gone')+'
';return;} + 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')+'
'; + 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 // behind a click means another set of logins. Deploy owns the seat. inner.innerHTML=''; @@ -904,7 +927,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{ @@ -915,8 +938,13 @@

SCHEDULES

inner.appendChild(art); } log.scrollTop=log.scrollHeight; + holdQueued(tid,false); + if(promptQ.some(p=>queuedThread(p,'')===tid))drainQ(tid); }catch{ - inner.innerHTML='
cannot reach the drive server
'; + if(gen===threadGen){ + activeTid=loadingTid='';paint();inner.innerHTML='
cannot reach the drive server
'; + holdQueued(tid,true);drainQ(''); + } } } @@ -1029,7 +1057,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 +1107,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 +1122,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 +1161,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 +1176,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 +1194,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,9 +1311,9 @@

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;} + if(!r.ok){$('schedList').innerHTML='
'+esc(apiErr(j)||'could not load')+'
';return;} paintSchedList(Array.isArray(j)?j:[]); }catch{$('schedList').innerHTML='
cased unreachable
';} } @@ -1310,7 +1338,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 +1346,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 +1384,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=''; @@ -1405,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); }); @@ -1437,26 +1466,62 @@

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; } + 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 + 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; +} +/* 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. + 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)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 + 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='';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. */ async function steerPrompt(t){ - const tid=activeTid&&activeTid!=='__pending'?activeTid:''; - 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'; art.innerHTML='
YOU

'; 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||'';} @@ -1475,6 +1540,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

'; @@ -1492,8 +1558,12 @@

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 + 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=>{ @@ -1570,8 +1640,15 @@

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; + 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};} + 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);} @@ -1603,10 +1680,13 @@

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:''; + runTid=''; refresh(); // state may have changed (wake on first tool); threads re-sync too - if(promptQ.length){const n=promptQ.shift();paintQ();sendPrompt(n);} + drainQ(ran); } } @@ -1655,7 +1735,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/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/serve.mjs b/web/web-ui/serve.mjs index 203610f..48e5272 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'; @@ -20,7 +19,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, 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'; @@ -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) { @@ -84,6 +76,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 || ''; @@ -179,6 +177,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 }); @@ -196,10 +198,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); } @@ -211,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, @@ -223,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 }); } } @@ -257,8 +263,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 }); @@ -278,16 +284,16 @@ 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 }); + { 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, { @@ -308,9 +314,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') { @@ -380,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 } }, @@ -534,6 +539,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 }; @@ -554,6 +563,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. @@ -612,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), @@ -626,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') { @@ -640,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; @@ -701,7 +722,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]; @@ -721,11 +753,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); } } @@ -749,7 +781,7 @@ export function histCloseOpenCalls(items, { keepReasoning = false } = {}) { } return out; } -export function shotsDir(home = HOME) { +function shotsDir(home = HOME) { return path.join(home, 'drive', 'shots'); } @@ -778,33 +810,39 @@ 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; +const ATTACH_MAX_N = 4; -export function inboxDir(home = HOME) { +function inboxDir(home = HOME) { return path.join(home, 'drive', 'inbox'); } @@ -817,7 +855,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'; } @@ -901,7 +939,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 = []; @@ -910,7 +948,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 }; }); } @@ -944,16 +986,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. */ @@ -987,7 +1019,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 = { @@ -1044,6 +1076,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, @@ -1056,7 +1090,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]); @@ -1078,16 +1113,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) { @@ -1097,12 +1133,14 @@ 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); 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 @@ -1113,7 +1151,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 @@ -1130,11 +1168,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; @@ -1193,11 +1231,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) { @@ -1297,10 +1331,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 = 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 || []); @@ -1337,7 +1371,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; @@ -1728,6 +1762,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}`); @@ -1738,8 +1779,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 — @@ -1757,7 +1798,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(); @@ -1772,7 +1813,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, @@ -1780,32 +1821,33 @@ 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 { - 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 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 || {}); } - 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,8 +1866,10 @@ 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`); + 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_http.mjs b/web/web-ui/test_http.mjs index c9eefd2..42d08cf 100644 --- a/web/web-ui/test_http.mjs +++ b/web/web-ui/test_http.mjs @@ -2,11 +2,27 @@ // 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 net from 'node:net'; +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'); +// 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)); @@ -21,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); })); @@ -65,5 +86,109 @@ 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?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(); + } +} + +// 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. +{ + 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'); diff --git a/web/web-ui/test_nav.mjs b/web/web-ui/test_nav.mjs index bc8edb3..96dee42 100644 --- a/web/web-ui/test_nav.mjs +++ b/web/web-ui/test_nav.mjs @@ -38,6 +38,31 @@ fs.writeFileSync(mod, ` ${grab('threadRowHtml')} ${grab('myThreads')} ${grab('navHtml')} + ${grab('steerTarget')} + ${grab('queuedThread')} + // drainQ against a fake page: opening a thread is recorded, not loaded + 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 = 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'); + 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; }, + open: realOpenThread, state: () => ({ activeTid, loadingTid }), + down: (v) => { fetchFails = v; }, status: (v) => { failStatus = v; } }; export const set = (s) => { apiUp = s.apiUp ?? true; comps = s.comps || []; @@ -45,7 +70,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 +112,88 @@ 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'); + +// 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.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.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; +// 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('t1'); +fake.status(401); // the thread exists; this tab just lost its token +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.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; +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'); + if (failed) { console.error(`\n${failed} failed`); process.exit(1); diff --git a/web/web-ui/test_ntfy.mjs b/web/web-ui/test_ntfy.mjs index 926e8e3..2d26c34 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, @@ -147,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); diff --git a/web/web-ui/test_rate_retry.mjs b/web/web-ui/test_rate_retry.mjs index 2bb4730..0260c65 100644 --- a/web/web-ui/test_rate_retry.mjs +++ b/web/web-ui/test_rate_retry.mjs @@ -3,7 +3,9 @@ // 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 { isRateLimited, rateWaitS, withRateRetry } from './case-tools.mjs'; +import Anthropic from '@anthropic-ai/sdk'; +import OpenAI from 'openai'; +import { isRateLimited, isRetryable, rateWaitS, withRateRetry } from './case-tools.mjs'; // --- what counts as "too fast" ------------------------------------------- assert.ok(isRateLimited({ status: 429 })); @@ -14,12 +16,28 @@ 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 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); @@ -47,9 +65,25 @@ 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 +{ + 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.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 calls = 0; @@ -70,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'); @@ -82,9 +116,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 5e4ba3d..798208b 100644 --- a/web/web-ui/test_serve.mjs +++ b/web/web-ui/test_serve.mjs @@ -4,16 +4,21 @@ 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'; -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, + 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'); +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, 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/); assert.match(html, /ANTHROPIC KEY/); @@ -26,6 +31,10 @@ 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]}`); +} 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 @@ -39,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' }] }]), @@ -219,11 +241,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'); @@ -321,6 +338,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); } @@ -434,6 +455,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-')); @@ -463,7 +487,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'); @@ -484,6 +508,19 @@ 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 + // (where typed messages go is covered in test_nav.mjs) + assert.match(html, /if\(gen!==threadGen\)return;/); + 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\)/, + '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'), @@ -540,6 +577,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); @@ -582,6 +627,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' }], @@ -636,4 +691,115 @@ 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(); + } +} + +// 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'); 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' }),