diff --git a/.env.example b/.env.example
index 2ea283d..ffa9799 100644
--- a/.env.example
+++ b/.env.example
@@ -47,10 +47,16 @@ CASE_LOCAL=1
# Legacy: separate reply topic for handoff answers only. Must not equal TOPIC.
# CASE_NTFY_ANSWER_TOPIC=
-# Public hostname when a reverse proxy fronts the API (adds /assist links to
-# notifications so a human can help from their phone). Unset on a laptop.
+# Public hostname served over HTTPS by a reverse proxy in front of the API.
+# Adds Assist links and signed approval buttons to ntfy notifications. Leave
+# 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
+# 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=
+
# Unlock the schedule_* MCP tools (recurring agent runs). Off by default to
# keep the tool surface small.
# CASE_MCP_SCHEDULES=1
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c86d3ba..7835313 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -8,16 +8,19 @@ on:
branches: [main]
pull_request:
+permissions:
+ contents: read
+
jobs:
test:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- - uses: actions/setup-python@v5
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: "3.12"
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "22"
@@ -42,12 +45,7 @@ jobs:
- name: Drive UI tests
run: |
npm --prefix web ci --omit=dev # serve.mjs imports openai at module load
- node web/web-ui/test_serve.mjs
- node web/web-ui/test_ntfy.mjs
- node web/web-ui/test_phone.mjs
- node web/web-ui/test_telegram.mjs
- node web/web-ui/test_nav.mjs
- node web/web-ui/test_deploy.mjs
+ npm --prefix web test
# Case is dual-licensed and sold commercially, which is only possible if the
# project can sublicense every contribution. Deliberately no third-party CLA
@@ -58,7 +56,7 @@ jobs:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Contributor has signed the CLA
env:
AUTHOR: ${{ github.event.pull_request.user.login }}
@@ -91,7 +89,7 @@ jobs:
spdx:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- name: Every source file declares its license
run: |
missing=0
diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml
index e753f79..850024f 100644
--- a/.github/workflows/publish-image.yml
+++ b/.github/workflows/publish-image.yml
@@ -33,9 +33,9 @@ jobs:
contents: read
packages: write
steps:
- - uses: actions/checkout@v4
- - uses: docker/setup-buildx-action@v3
- - uses: docker/login-action@v3
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
+ - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -43,7 +43,7 @@ jobs:
- name: Build and push by digest
id: build
- uses: docker/build-push-action@v6
+ uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
with:
context: ./image
platforms: ${{ matrix.platform }}
@@ -58,7 +58,7 @@ jobs:
mkdir -p /tmp/digests
# filename is the bare hex; the merge job puts the sha256: prefix back
touch "/tmp/digests/${DIGEST#sha256:}"
- - uses: actions/upload-artifact@v4
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: digest-${{ strategy.job-index }}
path: /tmp/digests/*
@@ -71,13 +71,13 @@ jobs:
contents: read
packages: write
steps:
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
- - uses: docker/setup-buildx-action@v3
- - uses: docker/login-action@v3
+ - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
+ - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -85,7 +85,7 @@ jobs:
- name: Tags
id: meta
- uses: docker/metadata-action@v5
+ uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: ${{ env.IMAGE }}
tags: |
diff --git a/.gitignore b/.gitignore
index 284f9b8..8c11f8d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,6 +23,9 @@ __pycache__/
# Local git worktrees
.worktrees/
+# Working notes — not part of the published repo
+docs/
+
# Agent tooling state
.claude/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2e29b1b..8807b00 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -24,30 +24,42 @@ New files take the `SPDX-License-Identifier` of their directory (see
## Tests (no Docker)
+Use Python 3.12 and Node 22, matching CI.
+
+```bash
+python3 -m venv .venv
+.venv/bin/pip install -r requirements-dev.txt
+for f in tests/test_*.py; do
+ case "$f" in tests/test_acceptance.py) continue ;; esac
+ .venv/bin/python "$f" || exit 1
+done
+npm --prefix web ci
+npm --prefix web test
+```
+
+## Acceptance tests (Docker)
+
```bash
-python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
-.venv/bin/python tests/test_lifecycle.py
-.venv/bin/python tests/test_dockerd.py
-.venv/bin/python tests/test_token.py
-.venv/bin/python tests/test_deskd.py
-.venv/bin/python tests/test_browse.py
-node web/web-ui/test_serve.mjs
-node web/web-ui/test_phone.mjs
-node web/web-ui/test_ntfy.mjs
-node web/web-ui/test_telegram.mjs
-node web/web-ui/test_nav.mjs
-node web/web-ui/test_deploy.mjs
+docker build -t case-desk:acceptance image
+CASE_ACCEPTANCE_IMAGE=case-desk:acceptance .venv/bin/python -m pytest -q tests/test_acceptance.py
```
-Acceptance tests (`tests/test_acceptance.py`) need Docker and a running cased.
+The suite starts its own cased on a random loopback port, with a temporary vault
+and token. It removes only computers created by that run. Failed runs retain
+logs and screenshots in the printed scratch directory. `CASE_KEEP=1` also keeps
+the primary test computer and its volume for inspection.
+
+A7 needs ntfy and a phone. A8 is skipped unless `CASE_A8=1` is set,
+because it restarts the Docker VM and interrupts every container using it.
+Run that check only on a dedicated test machine.
## Layout
-- `control-plane/` — REST API (composition root: `cased.py`)
-- `image/` — desktop container
-- `mcp/case_mcp.py` — MCP wrapper
-- `web/web-ui/` — Drive UI
-- `compose.yaml` — self-host stack
+- `control-plane/`: REST API (composition root: `cased.py`)
+- `image/`: desktop container
+- `mcp/case_mcp.py`: MCP wrapper
+- `web/web-ui/`: Drive UI
+- `compose.yaml`: self-host stack
Be decent to people: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
diff --git a/Dockerfile.ui b/Dockerfile.ui
index 7774f1c..1a715f6 100644
--- a/Dockerfile.ui
+++ b/Dockerfile.ui
@@ -1,4 +1,4 @@
-# Drive UI. Proxies cased + noVNC on the compose network.
+# Drive UI. Proxies the API and live desktop through cased.
FROM node:22-slim
WORKDIR /app
COPY web/package.json web/package-lock.json ./
diff --git a/README.md b/README.md
index 63e871b..12628a7 100644
--- a/README.md
+++ b/README.md
@@ -1,293 +1,432 @@
# Case: persistent computers for AI agents
-Case gives AI agents a durable Linux desktop (Chromium, files, logins) on any machine
-with Docker. Sleep, wake, or reboot: the identity stays on the volume. You bring the
-brain (Claude, Cursor, Codex, or the Drive UI with your provider key).
+Case gives AI agents a persistent Linux desktop with Chromium, files, and saved
+logins. Run it with Docker and connect your agent over MCP, or use Drive, the web
+interface. Files and saved logins stay with the computer across sleep, wake, and
+restart.

-What the agent gets, over MCP:
-
-- **A real desktop**: navigate, snapshot numbered clickable elements, click/fill
- by ref, hover menus, upload files under `/home/agent`, marked screenshots,
- exec, files, network capture — no coordinate guessing. Navigate and click
- return the first 2000 characters of page text.
-- **Vault logins**: the human saves a credential once (encrypted, via a one-time
- link); the machine types it into the site's own login page. The agent and the
- API never see the password.
-- **Human handoff**: 2FA codes, captchas and approvals pause the run and reach a
- human — in Drive, on their phone over Telegram (Approve / Deny buttons, reply
- with the code), or via a one-shot Assist link (ntfy).
-- **Skills**: the agent saves a completed task as a SKILL.md on the computer and
- follows it next run. Procedural memory that survives reboots.
-- **Schedules**: recurring headless runs on the computer's own identity.
+**[Managed Case](https://case.computer):** We run the computers for you, including
+DNS, HTTPS, and managed images.
-```
-Drive UI (4174) ──┐
-agent / MCP (8788)┼─→ cased (8787: REST, vault, lifecycle) ─→ deskd (in-container:
-bin/case ─────────┘ display, input, Chromium)
-```
+**Self-hosted:** Run the open source version on your own machine or server.
+Start with Docker below.
## Quick start
-Needs Docker 20.10+ with Compose v2.
-
-1. Clone the repo.
-
-```bash
-git clone https://github.com/case-computers/case.git && cd case
-```
-
-2. Start the stack.
+You need Git and Docker 20.10+ with Compose v2. Start Docker, then run:
```bash
-docker compose up --build
+git clone https://github.com/case-computers/case.git
+cd case
+docker compose up --build -d
```
-3. Open http://127.0.0.1:4174/deploy in your browser to create a computer.
+The first build may take a few minutes. Case runs in the background once it starts.
-4. Default MCP URL (compose): http://127.0.0.1:8788/mcp
+Open the [computers page](http://127.0.0.1:4174/deploy), click **+ New computer**,
+enter a name, and press Enter. Wait for the computer to show **AWAKE**.
- Point Claude Code at it (use `127.0.0.1`, not `localhost`: the SDK 421s on a
- Host mismatch).
+This setup is for local use. For remote access, follow the
+[server hosting instructions](#server-hosting) and read [SECURITY.md](SECURITY.md).
-```bash
-claude mcp add --transport http case http://127.0.0.1:8788/mcp
-```
+## Run your first task
-## Faster start
+### Use Drive in your browser
-Skip the desktop image build (Debian + Xfce + Chromium, a few minutes) by
-pulling a published image:
+Drive needs an OpenAI or Anthropic API key to run tasks.
-```bash
-docker pull ghcr.io/case-computers/case-desk:latest
-echo "CASE_IMAGE=ghcr.io/case-computers/case-desk:latest" >> .env
-docker compose up
-```
+1. Click **DRIVE** next to your computer.
+2. Click **KEY**, choose your provider, enter its API key, and click **SAVE**.
+3. Send a task, such as: `Open example.com and save a summary to /home/agent/example.txt.`
-If that pull 404s, no image has been published yet. Use `docker compose up --build`.
+Watch the desktop as the agent works, then open **FILES** to read the summary.
+The file stays on the computer when you sleep and wake it. Use **CREDENTIALS**
+to save logins for that computer.
-## Optional
+### Connect an existing agent
-### API-only mode
+Case supports MCP (Model Context Protocol), which lets an agent use the desktop
+as a set of tools. The local MCP address is `http://127.0.0.1:8788/mcp`.
-Run the control plane and MCP without the Drive UI.
+For Claude Code:
```bash
-docker compose up cased mcp --build
+claude mcp add --transport http case http://127.0.0.1:8788/mcp
```
-### Cursor config
+Then ask the agent to list your Case computers and run a task on the one you
+created. This uses your agent's model connection; you do not need to add a key
+in Drive. Cursor:
```json
{ "mcpServers": { "case": { "type": "http", "url": "http://127.0.0.1:8788/mcp" } } }
```
-`case-mcp.json` at the repo root is the stdio MCP config the scheduler passes
-to Claude (`--mcp-config`). Compose users should use the HTTP URL above.
+## Features
+
+- **A real desktop:** navigate pages, click and fill elements, upload files,
+ take screenshots, run commands, and inspect network activity.
+- **Vault logins:** save a credential once through a one-time link or Drive.
+ Case types it into the site's login page without returning the password to
+ the agent. See the [security model](SECURITY.md) for the limits of this protection.
+- **Human handoff:** 2FA codes, captchas, and approvals pause the run for a human
+ to help through Drive, Telegram, or an Assist link.
+- **Skills:** the agent saves a completed task as a `SKILL.md` on the computer
+ and follows it next time. The file survives restarts.
+- **Schedules:** recurring runs use the computer's saved identity.
+- **Phone chat:** send tasks and answer handoffs through Telegram or ntfy.
+ See [phone setup](#phone-chat).
-### Laptop without Compose
+## Optional setup
-Run the control plane on the host (needs a venv with `requirements.txt`), then
-Drive locally:
+The defaults are enough to try Case locally. Optional settings are listed in
+[.env.example](.env.example). For Compose, put them in `.env` next to
+`compose.yaml`, then run `docker compose up -d` to apply changes. Edit an existing
+`.env` rather than replacing it.
+
+
+
+Stop and start again
+
+From the repository directory, stop Case with:
```bash
-bin/case up
-CASE_LOCAL=1 CASE_URL=http://127.0.0.1:8787 node web/web-ui/serve.mjs
+docker compose down
```
-Drive stores thread screenshots under `~/.case/drive/shots` and chat
-attachments under `~/.case/drive/inbox` (Compose: the `ui-data` volume via
-`CASE_HOME=/data`). Those files persist after a thread is deleted — remove
-the directory or volume if you need them gone. `CASE_TURN_TOKENS` (default 2M)
-caps one turn's cumulative input tokens. Mid-turn messages go to
-`/api/chat/steer`. Attach files from the plus menu; they stay on the Drive
-host and are never copied onto the computer.
+Your computers' files and saved logins remain on their Docker volumes. To start
+Case again:
-### More knobs
+```bash
+docker compose up -d
+```
-Phone chat (Telegram or ntfy), CAPTCHA auto-solve, scheduled runs: all
-optional, all documented in [.env.example](.env.example).
+Open the [computers page](http://127.0.0.1:4174/deploy) and click **WAKE** next
+to the computer you want to use.
-### Phone chat (optional)
+
-Drive can take tasks from your phone. Off by default. Nothing gets exposed:
-Drive dials out and posts replies back. Phone messages run through the same
-brain and `threads.json` as the laptop UI, in a thread named `Phone`. Both
-channels need a box-side key, since there is no browser to hold one:
+
+
+Phone chat: Telegram and ntfy
-```
+Send tasks and answer handoffs through Telegram or ntfy. Both are optional.
+Drive connects out to the service, so phone chat works without exposing a local
+port. Your host and Docker must stay running to receive messages and run tasks.
+
+Phone tasks use a shared thread named `Phone`. They need a provider key on the
+server because there is no browser tab to supply one. If you do not have a `.env`
+file yet, copy `.env.example` to `.env` next to `compose.yaml`. Add these settings
+to that file, replacing the placeholder with your key:
+
+```dotenv
CASE_DRIVE_PROVIDER=openai # or anthropic
-CASE_DRIVE_API_KEY=
+CASE_DRIVE_API_KEY=
```
-A pending handoff (2FA code, approval) consumes the next phone message. With
-several open, prefix the answer with the handoff id: `h_ab12 483920`.
-`approve`, `deny`, `done`, or a bare code with nothing waiting gets back
-"Nothing waiting." Text sent while a Phone turn is running steers that turn;
-otherwise it starts a task on the box's first computer.
+You can also set `CASE_DRIVE_MODEL`. Choose either service below to finish setup.
-This is a live channel, not a queue. Telegram holds messages for a Drive that
-is down and reports the ones older than ten minutes back as skipped; ntfy
-drops them, so send again.
+### Telegram
-#### Telegram
+1. Open [@BotFather](https://t.me/BotFather) in Telegram, send `/newbot`, and
+ follow the prompts to create a bot. Copy its token. Use `/setjoingroups` to
+ disable adding the bot to groups.
+2. Add the token to `.env`:
-1. In Telegram, open [@BotFather](https://t.me/BotFather), send `/newbot`,
- pick any name, and copy the token it gives you. Keep the bot private:
- `/setjoingroups` → Disable.
-2. Put the token in `.env` and start the UI:
+ ```dotenv
+ CASE_TELEGRAM_TOKEN=
+ ```
-```
-CASE_TELEGRAM_TOKEN=123456:ABC…
-```
+3. Start or update Drive from the repository directory:
+
+ ```bash
+ docker compose up -d ui
+ ```
+
+4. Send `/start` to your bot. It replies with your chat ID. Add that ID to `.env`:
+
+ ```dotenv
+ CASE_TELEGRAM_CHAT_ID=
+ ```
+
+5. Run `docker compose up -d ui` again to apply the setting, then send a task
+ such as `What is on the screen?`.
+
+Only the configured chat can drive the computer. The bot shows a typing indicator
+while it works, then sends the result or error. Long replies arrive in separate
+messages. Approval handoffs have **Approve** and **Deny** buttons; for a code
+handoff, reply to its prompt with the code. Pending handoffs are sent again when
+Drive reconnects after a restart.
+
+### ntfy
+
+[ntfy](https://ntfy.sh) delivers messages through named topics. Anyone who knows
+an unprotected topic's name can read and write to it. Generate a random topic
+name and treat it like a password:
```bash
-docker compose up -d ui
+openssl rand -hex 32
```
-3. Send `/start` to your bot. It answers with your chat id and the line to
- add. Add it to `.env` and restart the UI:
+1. Install the [ntfy app](https://docs.ntfy.sh/subscribe/phone/) and subscribe
+ to the generated topic. If you use your own ntfy server, point the app at it.
+2. Add these settings to `.env`, replacing the topic placeholder:
-```
-CASE_TELEGRAM_CHAT_ID=123456789
-```
+ ```dotenv
+ CASE_NTFY_CHAT=1
+ CASE_NTFY_URL=https://ntfy.sh
+ CASE_NTFY_TOPIC=
+ CASE_NTFY_TOKEN=
+ ```
+
+ Change `CASE_NTFY_URL` if you use another server. For a protected topic, set
+ `CASE_NTFY_TOKEN` to an access token that can publish and subscribe. See
+ ntfy's [authentication instructions](https://docs.ntfy.sh/publish/#authentication).
+
+3. Apply the settings to Drive and cased:
+
+ ```bash
+ docker compose up -d
+ ```
+
+4. Send a task using one of these methods. Replace `` with your topic
+ name, and use your server's URL if you host ntfy yourself:
+
+ - Android: use the message bar in the topic view. Enable **Show message bar**
+ in settings if it is hidden.
+ - iOS: create a Shortcut with **Ask for Input**, then **Get Contents of URL**.
+ Use `https://ntfy.sh/`, method POST, and the input as the request body.
+ Add the Shortcut to your home screen or run it with Siri.
+ - Terminal: run `curl -d "check my mail" "https://ntfy.sh/"`.
+
+For a protected topic, include an `Authorization: Bearer ` header when
+sending from a Shortcut or curl. Drive posts `Working`, then the result or error,
+to the same topic. It marks its own posts so it does not read them as new tasks.
+
+### Replies and handoffs
+
+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`.
+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.
+
+`approve`, `deny`, `done`, or a bare code with nothing waiting gets a
+"Nothing waiting" reply. Telegram skips ordinary messages more than ten minutes
+old when Drive reconnects and tells you which ones it skipped. ntfy does not
+replay messages sent while Drive was stopped. Send the task again if it was missed.
+
+ntfy handoff notifications can include an Assist link and signed approval buttons.
+These require `CASE_PUBLIC_HOST` and an HTTPS reverse proxy in front of cased.
+They are omitted without a public hostname. See [server hosting](#server-hosting)
+to enable them; phone chat itself does not require this setup.
+
+
+
+
+
+Computer size and memory
+
+Choose a computer's size under **+ New computer**, then **SIZE**. The default is
+2 GB of RAM and 1 CPU. Case keeps that choice when it recreates the container.
+
+| Setting | What it controls | Compose default |
+| --- | --- | --- |
+| `CASE_MAX_RUNNING` | Maximum number of awake computers | 4 |
+| `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
+for one 2 GB computer within the default budget. To give Colima more memory when
+starting it:
```bash
-docker compose up -d ui
+colima start --cpu 4 --memory 8
```
-4. Send a task: `what is on the screen?`. The bot shows "typing" while it
- works and posts the result (or the error), split at Telegram's message
- limit.
+If creating or waking a computer exceeds a limit, Case returns `409`. Sleep
+another computer or adjust the limits before retrying. Asleep computers use
+disk space only; Case does not limit their volume size.
-Only your chat can drive the box; every other chat is ignored. Approval
-handoffs arrive with Approve / Deny buttons; code handoffs arrive as a prompt
-you reply to. Restarting the UI never loses a pending handoff: it is sent
-again on reconnect.
+
-#### ntfy
+
+
+Server hosting and remote access
-[ntfy](https://ntfy.sh) is a pub-sub service. The public server has no
-accounts: a topic is just a name, and anyone who knows the name can post and
-read. The topic name is your only credential, so mint a long random one and
-treat it like a password:
+Keep ports 4174, 8787, and 8788 bound to loopback. For remote access, put an HTTPS
+reverse proxy in front of Case. Generate a token:
```bash
openssl rand -hex 32
```
-1. Install the ntfy app (Play Store / App Store) and subscribe to that topic.
- Self-hosting ntfy instead? Point the app and `CASE_NTFY_URL` at your
- server; `CASE_NTFY_TOKEN` carries the bearer token if your server uses
- ntfy access control.
-2. Configure the box (`.env`) and restart the UI container:
+Set `CASE_TOKEN` to that value in `.env`. Add your proxy hostname to
+`CASE_ALLOWED_HOSTS`, using commas for multiple hostnames. Run
+`docker compose up -d` to apply the settings.
+
+`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.
+
+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
+repeating it in `CASE_ALLOWED_HOSTS`. These links carry their own access tokens;
+treat them as secrets.
+Case does not configure DNS or HTTPS for you. Read the
+[self-hosting trust model](SECURITY.md#self-host-trust-model) before providing
+remote access.
+
+
+
+
+Run the API and MCP without Drive
+
+```bash
+docker compose up cased mcp --build -d
```
-CASE_NTFY_CHAT=1
-CASE_NTFY_URL=https://ntfy.sh # or your ntfy server
-CASE_NTFY_TOPIC=
-CASE_NTFY_TOKEN= # self-hosted ntfy auth only
+
+An MCP agent or the CLI can create and use computers in this mode. The MCP
+address remains `http://127.0.0.1:8788/mcp`.
+
+
+
+
+Install without Compose
+
+This runs cased and Drive as host processes. Desktops still run in Docker.
+Install Python 3.12, Node 22, and Docker first. On macOS, `bin/case up` uses
+Colima; start it before building the image.
+
+Use either this setup or Compose at a time. Both use port 8787, but their vaults
+are separate: this setup uses `~/.case`, while Compose uses a Docker volume.
+Computers created through one setup do not appear in the other.
+
+With Docker running, install the dependencies and build the desktop image:
+
+```bash
+python3 -m venv .venv
+.venv/bin/pip install -r requirements.txt
+npm --prefix web ci
+docker build -t case-desk:0.1 image
```
+Start cased, then Drive:
+
```bash
-docker compose up -d ui
+bin/case up
+CASE_LOCAL=1 CASE_URL=http://127.0.0.1:8787 node web/web-ui/serve.mjs
```
-3. Send a message.
- - Android: the ntfy app has a message bar at the bottom of the topic view
- (Settings > Show message bar if it's hidden).
- - iOS: the app only receives. Make a Shortcut: Ask for Input, then Get
- Contents of URL with method POST, the input as the request body, and
- `https://ntfy.sh/` as the URL. Add it to the home screen or run
- it with Siri.
- - Any machine: `curl -d "check my mail" ntfy.sh/`. Useful to test
- the bridge before involving the phone.
+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`.
-Drive posts `Working`, then the final text or the error, back to the same
-topic. Its own posts are tagged so it never reads them back as instructions.
+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
+access to cased. The scheduler uses this file with Claude's `--mcp-config`.
-### Token hardening (optional)
+
-Copy `.env.example` to `.env`, generate a token, and set `CASE_TOKEN` before
-exposing ports off loopback.
+
+Use a published desktop image
+
+If a desktop image has been published, you can pull it instead of building it:
```bash
-cp .env.example .env
-openssl rand -hex 32
+docker pull ghcr.io/case-computers/case-desk:latest
```
-### Stop everything
+After a successful pull, set this value in `.env`:
-```bash
-docker compose down
+```dotenv
+CASE_IMAGE=ghcr.io/case-computers/case-desk:latest
```
-## Details
+Then run `docker compose up -d`. If the image is unavailable, use the source
+build in the [quick start](#quick-start).
-### Separate database warning
+
-`bin/case up` runs the control plane on the host with its database in `~/.case`.
-Compose uses a Docker volume instead. Same engine, same desktops, two separate
-databases: computers you create one way are not listed by the other, and both
-want port 8787, so run one at a time.
+
+
+Storage and backups
-### RAM budget
+Each computer has a Docker volume mounted at `/home/agent`. It holds files,
+Chromium's profile, saved logins, and skills. It survives sleep, wake, and
+container recreation. Deleting a computer through Case deletes this volume too.
+Changes outside `/home/agent`, such as installed system packages, do not survive
+container recreation.
-Pick each computer's size when you create it (`+ New computer` → SIZE, default 2 GB and
-1 CPU). The sizing sticks to the computer and is reapplied every time its container is
-rebuilt, so a box you made big stays big.
+cased stores the credential database and encryption key in `~/.case` for a host
+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.
-Two limits keep a host from being oversold:
+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.
-- `CASE_MAX_RUNNING`: how many computers may be awake at once (compose default `4`).
-- `CASE_MAX_RAM_MB`: how much memory those awake computers may hold in total.
- Unset means 75% of what the Docker engine reports, so it is usually right without
- being set. A create or wake that would exceed it returns `409`, which is the polite
- version of the OOM killer.
+
-Asleep computers cost disk only, and disk is not capped: Docker's local volume driver
-has no size limit, so Case does not pretend to offer one.
+
+
+How Case works
-On macOS the number that matters is the VM's RAM, not the Mac's. Colima defaults to
-4 GB, which is one 2 GB computer plus headroom. `colima start --cpu 4 --memory 8` if you
-want more.
+Drive (`web/web-ui/`) and MCP agents (`mcp/`) send requests to cased
+(`control-plane/`), which manages computers, the vault, and handoffs. The CLI
+(`bin/case`) uses the same REST API. Each desktop runs deskd inside the Docker
+image built from `image/`.
-A laptop is fine to try it. A small always-on Linux box is where it belongs: a computer
-that is asleep because your Mac shut the lid is not a computer an agent can be employed
-on.
+Desktop tools cover navigation, numbered element snapshots, clicking and
+filling by reference, hovering, uploads, screenshots, commands, files, and
+network capture. Uploads select files already under `/home/agent`. Navigate and
+click responses include the first 2000 characters of page text.
-### On a server
+Compose puts desktops on `case-desks` with no published host ports. cased joins
+that network and the application network. It relays the live desktop view and
+adds the desktop's token. Desktops can reach their peers and cased, so service
+tokens remain necessary. See [SECURITY.md](SECURITY.md) for the trust model.
-Do not publish 4174/8787/8788 off loopback. Set `CASE_TOKEN` in `.env` for Drive
-and the REST API (`http://:4174/?token=…`). `CASE_TOKEN` does **not** lock
-`:8788`: that door is loopback-only in compose; publish it only behind your own
-reverse proxy (TLS + bearer).
-HTTPS and DNS are not shipped here.
+`CASE_TURN_TOKENS` defaults to 2 million and caps cumulative input tokens for a
+Drive turn. Messages sent during a turn use `/api/chat/steer`. Client development
+details are in the [Drive README](web/web-ui/README.md).
-## What you get
+
-- `image/`: the desktop (`case-desk`)
-- `control-plane/`: REST API, vault, sleep/wake, handoff
-- `mcp/` + `bin/case`: drive it from an agent or the CLI
-- Drive UI: chat, live desk, files, credentials, teach-a-task
-- Deployer (`/deploy`): create, sleep, wake, delete computers
+## Troubleshooting
-The hosted fleet (DNS, HTTPS, managed images) is a separate product. This repo is
-the box you can run yourself.
+- If Docker cannot connect to its daemon, start Docker and check `docker info`.
+- If a port is already in use, check whether another Case installation is running.
+ Stop that installation before starting this one.
+- If Drive does not open, check `docker compose ps` and
+ `docker compose logs cased ui` for startup errors.
+- If creating or waking a computer fails because of memory or capacity, check
+ [computer size and memory](#computer-size-and-memory) and Docker's available RAM.
-## Tests
+
+
+Move an existing desktop to the new network
-No Docker:
+A desktop created before the network split keeps its original network until
+its container is recreated. Waking it alone does not change the network.
-```bash
-python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
-for t in tests/test_*.py; do [ "$t" = tests/test_acceptance.py ] || .venv/bin/python "$t"; done
-(cd web && npm ci && npm test && node web-ui/test_nav.mjs && node web-ui/test_deploy.mjs)
-```
+Sleep the computer, remove its container with `docker rm case-`, then wake
+it again. Replace `` with the computer ID. Keep its home volume: that is
+where its files, browser profile, and saved logins live.
+
+
+
+## Contributing and security
-Acceptance tests need a running stack (`tests/test_acceptance.py`).
+See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and tests, and
+[SECURITY.md](SECURITY.md) for the trust model and vulnerability reporting.
## License
diff --git a/SECURITY.md b/SECURITY.md
index f471310..c10006d 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,21 +2,81 @@
Case holds logins. These are promises, with code you can read.
-- **Secrets never appear in API responses, logs, or exec output.** Login injects
- via CDP `Input.insertText` (not keystrokes, not clipboard). See
- `image/deskd.py`.
-- **Screenshots return 423** while credentials are being injected.
-- **Login only fires** when the page host matches the credential's `domains`.
+- **The password is typed into the page, never handed to the agent.** Login
+ injects via CDP `Input.insertText` (not keystrokes, not clipboard); the secret
+ stays out of API responses, logs, and exec output. See `image/deskd.py`.
+- **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.
+- **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
+ browser holds. The 423 closes the paths Case itself offers; it is not a
+ sandbox boundary.
+- **Login checks the live page before inserting credentials.** The host must
+ match the credential's `domains`, and the scheme must be HTTPS. HTTP is allowed
+ only on `localhost`, `127.0.0.1`, and `::1`. Password and verification-code
+ entry repeat that check after redirects and between login steps.
- **MCP has no credential-write tool.** Secrets enter via the Drive UI, `/fill`,
or `bin/case cred add`.
+- **File API uploads are capped at 8 MiB.** cased and deskd count bytes as the
+ body arrives, including requests without Content-Length. File reads enforce
+ the same limit.
- **`computer_upload` only assigns files already on the computer.** The path
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 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.
- **cased binds loopback by default.** Compose publishes `127.0.0.1:8787` and
`127.0.0.1:4174`. Set `CASE_TOKEN` before exposing those ports.
-- **Audit log** (`~/.case/audit/.jsonl`): one line per API call; request
+- **MCP HTTP has no built-in bearer check.** Compose publishes port 8788 on
+ loopback. Its `CASE_TOKEN` authenticates calls to cased, not requests from MCP
+ clients. Keep that port local or put an authenticating reverse proxy in front.
+- **Desktops sit on their own Docker network (`case-desks`).** Compose joins
+ only cased to both networks, so Drive and the MCP server have no route to a
+ desktop. Desktops share `case-desks` with each other, so a desktop can still
+ address its neighbours; what stops it there is the desk token, not the
+ network — deskd and websockify both answer a neighbour with 401, and x11vnc
+ listens on loopback only. Desktop containers publish no host ports under
+ compose.
+- **The live desk is behind the desk token.** With `CASE_DOCKER_NETWORK` set,
+ websockify runs under basic auth `agent:$DESK_TOKEN` (`image/start.sh`).
+ Drive's `/live//…` is a proxy to cased `/v1/computers//live/…`, and
+ cased adds that header; the WebSocket upgrade checks `CASE_TOKEN` itself,
+ because Starlette's HTTP middleware never sees a websocket scope. Without
+ `CASE_TOKEN`, that upgrade checks Host and Origin instead. Host mode
+ (`bin/case up`) sets no network, so websockify is open there on the desktop's
+ loopback-published port.
+- **`/fill`, `/assist` and `/answer` are doors opened by a token in the URL.**
+ A human on a phone has no bearer header, so these three skip `CASE_TOKEN` and
+ carry their own key: `/fill` and `/assist` links expire and are single-use,
+ `/answer` is an HMAC over the handoff id. Anyone holding the URL is the human.
+ Treat the links like one-time passwords.
+- **Audit log** (`~/.case/audit/.jsonl`): one line per API call with the
+ caller's address, the query string, method, path, status and duration; request
bodies that can carry secrets are redacted; response bodies are never logged.
+ The three token doors log as `/fill/[token]` and friends, so the log records
+ that a door was used without storing the key.
+- **ntfy handoff notifications carry a full-desktop PNG.** When the computer is
+ awake, the screenshot rides along as the message attachment. Everyone
+ subscribed to the topic sees whatever was on screen, which is the argument for
+ a random topic name. Signed approval buttons require `CASE_PUBLIC_HOST` and
+ an HTTPS reverse proxy; they are omitted without it.
+- **DeathByCaptcha gets scheme, host and path only.** The page URL is stripped
+ of query and fragment before the solve request leaves
+ (`control-plane/login_flow.py`), so the session tokens and continuation URLs a
+ login page carries in its query string stay on the box. Sitekey, that URL and
+ a configured proxy are all that go out.
- **Drive screenshots and chat attachments persist on disk** under
`~/.case/drive/shots` and `~/.case/drive/inbox` (Compose: `ui-data` via
`CASE_HOME=/data`). They are content-addressed and kept until you delete the
@@ -30,22 +90,36 @@ Case holds logins. These are promises, with code you can read.
When you run Case yourself, these assumptions matter:
-(a) **No CASE_TOKEN means open API on the bind address.** Anything that can reach
-cased's bind address (including desktop containers on the compose network) can
-drive the REST API. Set `CASE_TOKEN` if the host is shared or ports are not
+(a) **No CASE_TOKEN means an open API on the bind address.** Anything that can
+reach cased's or Drive's bind address can drive them. The Host/Origin check only
+rejects callers that address the box under a name it does not know; it is not
+authentication. Set `CASE_TOKEN` if the host is shared or ports are not
loopback-only.
-(b) **`~/.case/` is secret-equivalent.** The Fernet encryption key lives beside the
+(b) **A desktop can still reach cased.** Docker bridges are bidirectional: cased
+joins `case-desks` to dial the desktops, so a desktop can dial cased back on
+`:8787`. The Host check does not help, because a container writes its own
+`Host:` header. This does not require a compromise: anything running inside a
+desktop by design has that reach, including whatever the agent starts through
+`/exec` and any gateway installed into the box. The separate network keeps Drive
+and MCP out of a desktop's reach; it does not put cased out of reach, and it
+does not separate desktops from each other. `CASE_TOKEN` is the thing that stops
+a compromised desktop from driving the API, and each desktop's `DESK_TOKEN` is
+what stops it from driving its neighbours. This is a known limit, not a closed
+hole.
+
+(c) **`~/.case/` is secret-equivalent.** The Fernet encryption key lives beside the
SQLite database. Treat the whole directory like a password manager export: back it
up accordingly, restrict filesystem permissions, and do not copy it to untrusted
storage.
-(c) **Desktops keep passwordless sudo by design.** The container is the agent's
+(d) **Desktops keep passwordless sudo by design.** The container is the agent's
sandbox; `no-new-privileges` is deliberately not set because passwordless sudo
-requires setuid. Do not run untrusted code inside a desktop you also use for
+requires setuid. Chromium also starts with `--no-sandbox`; the container is its
+isolation boundary. Do not run untrusted code inside a desktop you also use for
personal browsing.
-(d) **ntfy topics and Telegram bot tokens are bearer secrets.** Anyone who knows
+(e) **ntfy topics and Telegram bot tokens are bearer secrets.** Anyone who knows
a topic name can post or subscribe; anyone who holds the bot token can read and
send as the bot. Treat both like passwords; use random topic names, and revoke
the token in @BotFather if it leaks.
diff --git a/bin/case b/bin/case
index 1c44dcd..6e7e5cf 100755
--- a/bin/case
+++ b/bin/case
@@ -44,7 +44,7 @@ case "${1:-help}" in
pkill -f '[c]ased.py' 2>/dev/null || true
[ "$(uname)" = Darwin ] && colima stop || true ;;
new) c -X POST "$BASE/computers" -H 'Content-Type: application/json' \
- -d "{\"name\":\"${2:-}\"}" | pp ;;
+ -d "$(python3 -c 'import json,sys;print(json.dumps({"name":sys.argv[1]}))' "${2:-}")" | pp ;;
ls) c "$BASE/computers" | pp ;;
sleep) c -X POST "$BASE/computers/$2/sleep" | pp ;;
wake) c -X POST "$BASE/computers/$2/wake" | pp ;;
@@ -63,13 +63,14 @@ for c in json.load(sys.stdin)["computers"]: print(" " + c["id"] + " " + c["nam
[ -n "$NAME" ] || read -r -p "credential name (e.g. github): " NAME
[ -n "$USERN" ] || read -r -p "username / email: " USERN
[ -n "$DOMAINS" ] || read -r -p "domains (comma-separated, e.g. github.com): " DOMAINS
- [ -n "$TOTP" ] || read -r -p "TOTP seed (enter to skip): " TOTP
+ [ -n "$TOTP" ] || { read -r -s -p "TOTP seed (enter to skip): " TOTP; echo; }
read -r -s -p "secret for $NAME: " SECRET; echo
- python3 - "$BASE" "$CID" "$NAME" "$USERN" "$DOMAINS" "$TOTP" "$SECRET" <<'EOF'
+ # secrets ride the environment, not argv: /proc//cmdline is world-readable
+ SECRET="$SECRET" TOTP="$TOTP" python3 - "$BASE" "$CID" "$NAME" "$USERN" "$DOMAINS" <<'EOF'
import json, os, sys, urllib.request
-base, cid, name, user, domains, totp, secret = sys.argv[1:8]
-body = {"name": name, "username": user, "secret": secret, "domains": domains.split(",")}
-if totp: body["totp_seed"] = totp
+base, cid, name, user, domains = sys.argv[1:6]
+body = {"name": name, "username": user, "secret": os.environ["SECRET"], "domains": domains.split(",")}
+if os.environ.get("TOTP"): body["totp_seed"] = os.environ["TOTP"]
headers = {"Content-Type": "application/json"}
tok = (os.environ.get("CASE_TOKEN") or "").strip()
if tok:
diff --git a/compose.yaml b/compose.yaml
index 084986e..09e7e47 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -8,12 +8,15 @@
#
# API/MCP only (no Drive): docker compose up cased mcp --build
# Optional share token: CASE_TOKEN=… in .env (required if you publish ports off localhost).
+# Desktops sit on their own network (case-desks); only cased can reach them.
name: case
networks:
case:
name: case
+ desks: # desktops live here; only cased joins both
+ name: case-desks
volumes:
case-home:
@@ -34,7 +37,7 @@ services:
image: case-control:0.1
environment:
CASE_HOME: /data
- CASE_DOCKER_NETWORK: case
+ CASE_DOCKER_NETWORK: case-desks
CASE_BIND: "0.0.0.0"
CASE_IMAGE: ${CASE_IMAGE:-case-desk:0.1}
CASE_TOKEN: ${CASE_TOKEN:-}
@@ -52,6 +55,7 @@ services:
CASE_NTFY_ANSWER_TOPIC: ${CASE_NTFY_ANSWER_TOPIC:-}
CASE_NTFY_TOKEN: ${CASE_NTFY_TOKEN:-}
CASE_PUBLIC_HOST: ${CASE_PUBLIC_HOST:-}
+ CASE_ALLOWED_HOSTS: ${CASE_ALLOWED_HOSTS:-}
# Optional CAPTCHA auto-solve (see .env.example)
CASE_DBC_AUTHTOKEN: ${CASE_DBC_AUTHTOKEN:-}
CASE_DBC_USERNAME: ${CASE_DBC_USERNAME:-}
@@ -61,7 +65,7 @@ services:
- case-home:/data
ports:
- "127.0.0.1:8787:8787"
- networks: [case]
+ networks: [case, desks]
depends_on:
desk-image:
condition: service_completed_successfully
@@ -104,7 +108,6 @@ services:
image: case-ui:0.1
environment:
CASE_URL: http://cased:8787/v1
- CASE_DOCKER_NETWORK: case
CASE_BIND: "0.0.0.0"
CASE_TOKEN: ${CASE_TOKEN:-}
CASE_THREADS: /data/threads.json
@@ -120,6 +123,8 @@ services:
CASE_DRIVE_MODEL: ${CASE_DRIVE_MODEL:-}
CASE_TELEGRAM_TOKEN: ${CASE_TELEGRAM_TOKEN:-}
CASE_TELEGRAM_CHAT_ID: ${CASE_TELEGRAM_CHAT_ID:-}
+ CASE_PUBLIC_HOST: ${CASE_PUBLIC_HOST:-}
+ CASE_ALLOWED_HOSTS: ${CASE_ALLOWED_HOSTS:-}
volumes:
- ui-data:/data
ports:
diff --git a/control-plane/assist.py b/control-plane/assist.py
index 88d3847..ef07600 100644
--- a/control-plane/assist.py
+++ b/control-plane/assist.py
@@ -143,38 +143,6 @@ def session_cookie_header(session_raw, max_age=None):
"Secure; HttpOnly; SameSite=Lax")
-def resolve(raw_token, cookie_header=""):
- """Auth for /assist/{token}: exchange if needed, else accept matching session cookie.
-
- Returns (handoff_row, set_session_raw|None). The handoff is the *current*
- challenge when attempt-scoped. Raises ApiError(410) when nothing works.
- """
- cookies = _cookies(cookie_header)
- sess = cookies.get(COOKIE, "")
- th = _hash(raw_token)
- row = store.get_assist_by_token_hash(th)
-
- # Fresh exchange token → burn and issue session.
- if row and row["burned_at"] is None and row["expires_at"] > now():
- session, handoff = exchange(raw_token)
- view = session_view(session)
- if view:
- _bound, current, attempt = view
- return current or handoff, session
- return handoff, session
-
- # Burned exchange (or unknown token): session cookie must match this binding.
- if sess:
- view = session_view(sess)
- if view:
- bound, current, attempt = view
- if row and row["handoff_id"] != bound["id"]:
- raise ApiError(410, "gone", "assist link does not match this session")
- return current or bound, None
-
- raise ApiError(410, "gone", "assist link invalid or expired")
-
-
def resolve_view(raw_token, cookie_header=""):
"""Auth + attempt-scoped view. Returns (view_dict, set_session_raw|None).
diff --git a/control-plane/auth_attempts.py b/control-plane/auth_attempts.py
index 84308ed..faec6ef 100644
--- a/control-plane/auth_attempts.py
+++ b/control-plane/auth_attempts.py
@@ -81,9 +81,6 @@ def parse_proof_spec(raw):
return None
-_parse_proof_spec = parse_proof_spec
-
-
def attempt_public(row):
"""Public AuthAttempt, never secrets, OTP answers, or raw proof_spec."""
if row is None:
@@ -228,7 +225,7 @@ def start_attempt(computer_id, credential_name, target_url, proof_spec=None,
while another attempt is active → 409 auth_in_progress.
"""
# Drop unknown/empty predicates so a typo never looks "configured".
- proof_spec = _parse_proof_spec(proof_spec)
+ proof_spec = parse_proof_spec(proof_spec)
if idempotency_key:
existing = store.get_auth_attempt_by_idempotency(computer_id, idempotency_key)
if existing:
@@ -508,9 +505,6 @@ def observation_looks_logged_out(observation):
return bool(signals)
-_observation_looks_logged_out = observation_looks_logged_out
-
-
def check_proof(computer, proof_spec, observation=None):
"""Evaluate proof_spec against the live tab / last observation. Never logs secrets.
@@ -563,9 +557,6 @@ def check_proof(computer, proof_spec, observation=None):
return True
-_check_proof = check_proof
-
-
def prove_attempt(attempt_id, expected_revision=None, observation=None):
"""Prove step: missing/false proof_spec → unverified; verified → authenticated.
@@ -589,7 +580,7 @@ def prove_attempt(attempt_id, expected_revision=None, observation=None):
row = store.get_auth_attempt(attempt_id)
rev = int(row["revision"] or 0)
- proof_spec = _parse_proof_spec(row["proof_spec"])
+ proof_spec = parse_proof_spec(row["proof_spec"])
from events import emit
from lifecycle import get_computer
@@ -606,7 +597,7 @@ def prove_attempt(attempt_id, expected_revision=None, observation=None):
return pub
computer = get_computer(row["computer_id"])
- ok = _check_proof(computer, proof_spec, observation=observation)
+ ok = check_proof(computer, proof_spec, observation=observation)
if ok:
pub = _cas_or_conflict(attempt_id, "proving", "authenticated", rev)
store.record_credential_result(row["computer_id"], row["credential"], "success")
@@ -671,7 +662,8 @@ def advance_attempt(attempt_id, expected_revision=None, observation=None, _depth
if material and material.get("totp_seed"):
try:
code = _totp(material["totp_seed"])
- out = auth_submit_challenge(computer, "otp", value=code)
+ out = auth_submit_challenge(computer, "otp", value=code,
+ domains=material.get("domains") or [])
if isinstance(out, dict) and out.get("ok"):
return advance_attempt(attempt_id, observation=None, _depth=_depth + 1)
except Exception:
@@ -708,7 +700,7 @@ def advance_attempt(attempt_id, expected_revision=None, observation=None, _depth
attempt_id, handoff_kind, prompt, domain=domain, expected_revision=rev)
# No challenge left → prove (missing proof_spec → unverified).
- if _observation_looks_logged_out(observation) and _parse_proof_spec(row["proof_spec"]):
+ if observation_looks_logged_out(observation) and parse_proof_spec(row["proof_spec"]):
# Password form still up with a configured proof, treat as failed login.
return fail_attempt(attempt_id, reason="still_on_login_form",
expected_revision=rev)
diff --git a/control-plane/captcha.py b/control-plane/captcha.py
index a56bd38..1cd3679 100644
--- a/control-plane/captcha.py
+++ b/control-plane/captcha.py
@@ -7,8 +7,9 @@
Captcha is the only vendor this release, and only for declared capabilities.
Off unless CASE_DBC_* credentials are set. Only sitekey/publickey + pageurl
-(+ configured proxy) leave the box; tokens, DBC passwords, screenshots, and
-page text are never logged.
+(+ configured proxy) leave the box, and the caller strips the pageurl's query
+and fragment first, so session tokens in the URL stay here; tokens, DBC
+passwords, screenshots, and page text are never logged.
Capabilities: recaptcha_v2 (DBC type 4), recaptcha_enterprise (type 25,
requires proxy), arkose (type 6). Unsupported / terminal DBC answers
diff --git a/control-plane/cased.py b/control-plane/cased.py
index fedd502..7f4e49b 100644
--- a/control-plane/cased.py
+++ b/control-plane/cased.py
@@ -8,6 +8,7 @@
dockerd, store, events.
"""
import asyncio
+import base64
from contextlib import asynccontextmanager, contextmanager
import hmac
import html
@@ -16,12 +17,14 @@
import threading
import time
from datetime import datetime, timedelta, timezone
-from urllib.parse import parse_qs
+from urllib.parse import parse_qs, unquote, urlsplit
+import requests
import uvicorn
-from fastapi import Body, FastAPI, Query, Request
+from fastapi import Body, FastAPI, Query, Request, WebSocket
from fastapi.exceptions import RequestValidationError
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
+from websockets.asyncio.client import connect as ws_connect
import auth_attempts
import captcha
@@ -91,7 +94,7 @@ async def validation_error(_, e):
@app.exception_handler(Exception)
async def internal_error(_, e):
log.exception("internal error")
- return JSONResponse({"error": {"code": "internal", "message": f"{type(e).__name__}: {e}"}},
+ return JSONResponse({"error": {"code": "internal", "message": "internal error"}},
status_code=500)
@@ -108,36 +111,52 @@ def bearer_ok(authorization):
auth = authorization or ""
if not auth.lower().startswith("bearer "):
return False
- got = auth[7:].strip()
- if len(got) != len(want):
- return False
- return hmac.compare_digest(got, want)
+ return hmac.compare_digest(auth[7:].strip(), want)
-@app.middleware("http")
-async def token_guard(request: Request, call_next):
- # /health stays open so compose can probe us without circulating the token.
- if request.url.path == "/health":
- return await call_next(request)
- if not bearer_ok(request.headers.get("authorization")):
- return JSONResponse(
- {"error": {"code": "unauthorized", "message": "unauthorized"}},
- status_code=401)
- return await call_next(request)
+PUBLIC_PREFIXES = ("/fill/", "/assist/", "/answer/") # token-in-URL doors, no bearer
+
+
+def allowed_hosts():
+ """Names a browser may address us as; anything else is a rebinding page."""
+ hosts = {"127.0.0.1", "localhost", "[::1]", "cased"}
+ hosts.update(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.add(pub)
+ return hosts
+
+
+def _host_of(value):
+ v = (value or "").strip().lower()
+ return v[:v.find("]") + 1] if v.startswith("[") else v.split(":")[0]
+
+
+def browser_ok(request):
+ """Host must be ours; a present Origin must be ours too (CSRF)."""
+ if _host_of(request.headers.get("host")) not in allowed_hosts():
+ return False
+ origin = request.headers.get("origin")
+ return not origin or _host_of(origin.split("//", 1)[-1]) in allowed_hosts()
# ---------- audit log ----------
# One JSONL line per API call, ~/.case/audit/.jsonl. Answers "what did the
# agent do on this machine" without agents self-logging transcripts. Sessions are
-# whatever the client sends as X-Case-Session (the MCP server sends one per process).
+# whatever the client sends as X-Case-Session (the MCP server sends one per process),
+# alongside the caller's address and the query string.
# Security invariant: response bodies are NEVER logged (screenshots, file contents),
# and request bodies that can carry secrets are redacted, secrets never hit disk.
# Redacted routes: /credentials (password/TOTP), /answer (OTP codes relayed by the
# human), /files (uploaded file contents may hold tokens), /fill (the human
# credential form posts the password itself).
+# Registered BEFORE token_guard: Starlette runs the last-registered middleware
+# outermost, so an unauthorized call is rejected without reaching the log.
def _redacted(path):
return ("/credentials" in path or path.endswith("/answer")
+ or path.startswith("/answer/")
or path.endswith("/files") or path.startswith("/fill/")
or path.endswith("/fill") # agent form-fill bodies carry user data
or path.startswith("/assist/"))
@@ -145,35 +164,62 @@ def _redacted(path):
@app.middleware("http")
async def audit_mw(request: Request, call_next):
- body = b"" if request.method in ("GET", "DELETE") else await request.body()
+ path = request.url.path
+ big = int(request.headers.get("content-length") or 0) > 64 * 1024
+ skip_body = request.method in ("GET", "DELETE") or _redacted(path) or big
+ body = b"" if skip_body else await request.body()
t0 = time.time()
resp = await call_next(request)
- path = request.url.path
- if path != "/health" and not path.endswith("/events"): # skip noise + SSE streams
+ # skip noise + SSE streams; a desk open is ~25 asset GETs through the live relay
+ if path != "/health" and not path.endswith("/events") and "/live/" not in path:
req = "[redacted]" if _redacted(path) else body[:2000].decode("utf-8", "replace")
- # a fill/assist token is a live capability, the log records that the door
- # was used, never the key itself
+ # a fill/assist/answer token is a live capability, the log records that the
+ # door was used, never the key itself
if path.startswith("/fill/"):
logged_path = "/fill/[token]"
elif path.startswith("/assist/"):
logged_path = "/assist/[token]" + (
"/submit" if path.endswith("/submit") else
"/done" if path.endswith("/done") else "")
+ elif path.startswith("/answer/"):
+ logged_path = "/answer/[token]"
else:
logged_path = path
line = {"ts": now(), "session": request.headers.get("x-case-session", "-"),
- "method": request.method, "path": logged_path, "status": resp.status_code,
- "ms": int((time.time() - t0) * 1000), "req": req}
+ "client": request.client.host if request.client else "-",
+ "method": request.method, "path": logged_path, "query": request.url.query,
+ "status": resp.status_code, "ms": int((time.time() - t0) * 1000),
+ "req": "[large]" if big else req}
await asyncio.to_thread(_audit_append, line)
return resp
def _audit_append(line):
- os.makedirs(AUDIT_DIR, exist_ok=True)
- with open(os.path.join(AUDIT_DIR, time.strftime("%Y-%m-%d") + ".jsonl"), "a") as f:
+ os.makedirs(AUDIT_DIR, mode=0o700, exist_ok=True)
+ p = os.path.join(AUDIT_DIR, time.strftime("%Y-%m-%d") + ".jsonl")
+ 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")
+@app.middleware("http")
+async def token_guard(request: Request, call_next):
+ path = request.url.path
+ # /health stays open so compose can probe us without circulating the token.
+ if path == "/health":
+ return await call_next(request)
+ if path.startswith(PUBLIC_PREFIXES) or not case_token():
+ if not browser_ok(request):
+ return JSONResponse(
+ {"error": {"code": "bad_host", "message": "unexpected Host or Origin"}},
+ status_code=403)
+ return await call_next(request)
+ if not bearer_ok(request.headers.get("authorization")):
+ return JSONResponse(
+ {"error": {"code": "unauthorized", "message": "unauthorized"}},
+ status_code=401)
+ return await call_next(request)
+
+
def unlink_run_artifacts(paths):
"""Delete run PNGs only when they resolve inside RUNS_DIR. Missing files are fine."""
root = os.path.realpath(RUNS_DIR)
@@ -286,7 +332,11 @@ def wake_computer(cid: str):
@app.get("/health")
-def health():
+def health(request: Request):
+ # Open door (compose probes it without the token), so an unauthenticated caller
+ # 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())
try:
dockerd.dc().ping()
@@ -433,9 +483,22 @@ def tabs_(cid: str, body: dict = Body(default={}), wake: bool = False):
target_id=body.get("target_id"), url=body.get("url"))
+FILE_MAX = 8 * 1024 * 1024
+
+
@app.put("/v1/computers/{cid}/files", status_code=201)
async def file_put(cid: str, path: str, request: Request, wake: bool = False):
- data = await request.body()
+ if int(request.headers.get("content-length") or 0) > FILE_MAX:
+ raise ApiError(413, "too_large", "file over 8MB")
+ data = bytearray()
+ async for chunk in request.stream():
+ if len(data) + len(chunk) > FILE_MAX:
+ raise ApiError(413, "too_large", "file over 8MB")
+ data.extend(chunk)
+ return await asyncio.to_thread(_file_put, cid, path, bytes(data), wake)
+
+
+def _file_put(cid, path, data, wake):
with awake(cid, wake) as row:
return desk_json(row, "PUT", "/file", params={"path": path}, data=data, timeout=120)
@@ -617,7 +680,7 @@ def _assist_set_cookie(set_sess):
@app.get("/assist/static/assist.js")
def assist_static_js():
- """Same-origin poll script, CSP script-src 'self' (no inline)."""
+ """Same-origin poll script, served as a file so the page carries no inline JS."""
return Response(assist.ASSIST_JS, media_type="application/javascript",
headers={"Cache-Control": "no-store", "Referrer-Policy": "no-referrer"})
@@ -648,26 +711,36 @@ def assist_state(token: str, request: Request):
return JSONResponse(assist.state_payload(view), headers=headers)
-@app.post("/assist/{token}/open")
-async def assist_open(token: str, request: Request):
- """Navigate remote Chromium to a pastable HTTPS URL (allowlisted hosts only)."""
+async def _assist_form(token, request):
+ """The preamble every assist POST shares: CSRF, session cookie, a still-live
+ view, and the urlencoded body. Returns (sess, view, form, expected_revision),
+ 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"})
sess = _assist_cookie(request)
if not sess:
- return HTMLResponse(assist.GONE_HTML, status_code=410,
- headers={"Cache-Control": "no-store"})
+ return gone
try:
- assist.resolve_view(token, request.headers.get("cookie", ""))
+ view, _ = assist.resolve_view(token, request.headers.get("cookie", ""))
except ApiError:
- return HTMLResponse(assist.GONE_HTML, status_code=410,
- headers={"Cache-Control": "no-store"})
+ return gone
form = assist.parse_form(await request.body())
- url = (form.get("url") or "").strip()
rev = form.get("expected_revision")
- expected = int(rev) if rev not in (None, "") else None
+ return sess, view, form, int(rev) if rev not in (None, "") else None
+
+
+@app.post("/assist/{token}/open")
+async def assist_open(token: str, request: Request):
+ """Navigate remote Chromium to a pastable HTTPS URL (allowlisted hosts only)."""
+ got = await _assist_form(token, request)
+ if isinstance(got, HTMLResponse):
+ return got
+ sess, _, form, expected = got
try:
- assist.open_with_session(sess, url, expected_revision=expected)
+ await asyncio.to_thread(assist.open_with_session, sess,
+ (form.get("url") or "").strip(), expected_revision=expected)
except ApiError as e:
if e.status == 410:
return HTMLResponse(assist.GONE_HTML, status_code=410,
@@ -682,27 +755,20 @@ async def assist_open(token: str, request: Request):
@app.post("/assist/{token}/submit")
async def assist_submit(token: str, request: Request):
"""OTP / submit_value, auth is the case_assist session cookie."""
- if not assist.check_same_origin(request):
- raise ApiError(403, "csrf", "missing or mismatched Origin")
- sess = _assist_cookie(request)
- if not sess:
- return HTMLResponse(assist.GONE_HTML, status_code=410)
- try:
- view, _ = assist.resolve_view(token, request.headers.get("cookie", ""))
- except ApiError:
- return HTMLResponse(assist.GONE_HTML, status_code=410)
+ got = await _assist_form(token, request)
+ if isinstance(got, HTMLResponse):
+ 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"})
- form = assist.parse_form(await request.body())
value = (form.get("value") or "").strip()
if not value:
raise ApiError(400, "bad_request", "value is required")
- rev = form.get("expected_revision")
- expected = int(rev) if rev not in (None, "") else None
try:
- row = assist.submit_with_session(sess, value, expected_revision=expected)
+ row = await asyncio.to_thread(assist.submit_with_session, sess, value,
+ expected_revision=expected)
except ApiError as e:
if e.status == 410:
return HTMLResponse(assist.GONE_HTML, status_code=410)
@@ -721,20 +787,13 @@ async def assist_submit(token: str, request: Request):
@app.post("/assist/{token}/done")
async def assist_done(token: str, request: Request):
"""CAPTCHA / verify_page, human cleared the live desk challenge."""
- if not assist.check_same_origin(request):
- raise ApiError(403, "csrf", "missing or mismatched Origin")
- sess = _assist_cookie(request)
- if not sess:
- return HTMLResponse(assist.GONE_HTML, status_code=410)
- try:
- assist.resolve_view(token, request.headers.get("cookie", ""))
- except ApiError:
- return HTMLResponse(assist.GONE_HTML, status_code=410)
- form = assist.parse_form(await request.body())
- rev = form.get("expected_revision")
- expected = int(rev) if rev not in (None, "") else None
+ got = await _assist_form(token, request)
+ if isinstance(got, HTMLResponse):
+ return got
+ sess, _, _, expected = got
try:
- row = assist.done_with_session(sess, expected_revision=expected)
+ row = await asyncio.to_thread(assist.done_with_session, sess,
+ expected_revision=expected)
except ApiError as e:
if e.status == 410:
return HTMLResponse(assist.GONE_HTML, status_code=410)
@@ -787,6 +846,68 @@ def desk_check_ep(request: Request):
"Cache-Control": "no-store"})
+# ---------- live view (noVNC, relayed) ----------
+
+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()
+ return dockerd.desk_base(row["id"], row["vnc_port"]), {"Authorization": f"Basic {auth}"}
+
+
+def live_path_ok(path):
+ return ".." not in path and ".." not in unquote(path) # Starlette decoded once already
+
+
+@app.get("/v1/computers/{cid}/live/{path:path}")
+def live_http(cid: str, path: str, request: Request):
+ """noVNC's static files, relayed so Drive never needs the desks network."""
+ if not live_path_ok(path):
+ raise ApiError(400, "bad_request", "bad path")
+ base, headers = live_upstream(lifecycle.ensure_running(cid, False))
+ q = request.url.query
+ r = requests.get(f"{base}/{path}" + (f"?{q}" if q else ""), headers=headers, timeout=15)
+ return Response(r.content, status_code=r.status_code, media_type=r.headers.get("content-type"))
+
+
+@app.websocket("/v1/computers/{cid}/live/websockify")
+async def live_ws(ws: WebSocket, cid: str):
+ # HTTP middleware does not see WebSocket handshakes.
+ if not bearer_ok(ws.headers.get("authorization")) or (not case_token() and not browser_ok(ws)):
+ await ws.close(code=1008)
+ return
+ try:
+ base, headers = live_upstream(lifecycle.ensure_running(cid, False))
+ except ApiError:
+ await ws.close(code=1011)
+ return
+ subs = [p.strip() for p in ws.headers.get("sec-websocket-protocol", "").split(",") if p.strip()]
+ # max_size=None: a full-screen framebuffer update is larger than the 1 MiB default.
+ 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)
+
+ async def to_desk():
+ try:
+ while True:
+ m = await ws.receive()
+ if m["type"] != "websocket.receive":
+ break
+ await up.send(m["bytes"] if m.get("bytes") is not None else m["text"])
+ finally:
+ await up.close()
+
+ pump = asyncio.create_task(to_desk())
+ try:
+ async for msg in up:
+ await ws.send_bytes(msg if isinstance(msg, bytes) else msg.encode())
+ finally:
+ pump.cancel()
+ try:
+ await ws.close()
+ except RuntimeError:
+ pass # peer already closed
+
+
@app.get("/v1/auth-attempts/{aid}")
def get_auth_attempt(aid: str):
return auth_attempts.get_attempt(aid)
@@ -819,8 +940,12 @@ def login(cid: str, body: dict = Body(...), wake: bool = False):
material = store.credential_material(cid, name)
if not material:
raise ApiError(404, "not_found", f"no credential {name!r}")
- if not body.get("url"):
- raise ApiError(400, "bad_request", "missing 'url'")
+ url = str(body.get("url", ""))
+ # Credentials must not cross a network in the clear. The desktop's own loopback
+ # never leaves the box, so a local test site over http is still allowed.
+ if not (url.startswith("https://") or (url.startswith("http://")
+ and urlsplit(url).hostname in ("localhost", "127.0.0.1", "::1"))):
+ raise ApiError(400, "bad_request", "url must be https, or http to loopback")
proof_spec = login_flow._credential_proof_spec(cid, name, body.get("proof_spec"))
attempt = auth_attempts.start_attempt(
@@ -878,6 +1003,13 @@ def answer_handoff_ep(hid: str, body: dict = Body(...)):
return handoffs.handoff_json(handoffs.answer_handoff(hid, str(body["value"])))
+@app.post("/answer/{hid}/{token}")
+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"))
+
+
# ---------- schedules ----------
@app.post("/v1/computers/{cid}/schedules", status_code=201)
@@ -969,7 +1101,9 @@ def sweeper():
prune_old_audit_files()
store.prune_terminal_handoffs(cutoff)
scheduler.fire_due_schedules(_spawn)
- session_keeper.tick() # preflight persistent session health
+ # 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")
diff --git a/control-plane/deskclient.py b/control-plane/deskclient.py
index 13106c1..76152a4 100644
--- a/control-plane/deskclient.py
+++ b/control-plane/deskclient.py
@@ -13,6 +13,7 @@
import requests
+from config import log
from dockerd import desk_base
from errors import ApiError
@@ -34,7 +35,9 @@ def _raise(r):
e = r.json()["error"]
raise ApiError(r.status_code, e["code"], e["message"])
except (ValueError, KeyError):
- raise ApiError(r.status_code, "desk_error", r.text[:300])
+ # deskd's raw body can echo a page or a credential, keep it in the log only
+ log.warning("deskd %s: %s", r.status_code, r.text[:300])
+ raise ApiError(r.status_code, "desk_error", f"deskd returned {r.status_code}")
def desk_json(row, method, path, timeout=35, **kw):
@@ -221,11 +224,13 @@ def observe_auth(row):
return desk_json(row, "POST", "/auth/observe", timeout=35)
-def auth_submit_challenge(row, kind, value=None):
+def auth_submit_challenge(row, kind, value=None, domains=None):
"""POST /auth/submit_challenge, otp/code fill+enter, or approval settle."""
body = {"kind": kind}
if value is not None:
body["value"] = value
+ if domains is not None:
+ body["domains"] = domains
return desk_json(row, "POST", "/auth/submit_challenge", json=body, timeout=90)
diff --git a/control-plane/dockerd.py b/control-plane/dockerd.py
index a2a2c38..617887e 100644
--- a/control-plane/dockerd.py
+++ b/control-plane/dockerd.py
@@ -13,6 +13,8 @@
from config import IMAGE, VNC_PORT
from errors import ApiError
+NotFound = docker.errors.NotFound # lifecycle catches this without importing docker-py
+
DESK_INTERNAL_PORT = 8000
VNC_INTERNAL_PORT = 6080
@@ -72,9 +74,13 @@ def container_run_kwargs(cid, cpus, ram_mb, volume, token):
# DESK_DEBUG=1 on cased mirrors chromium/websockify logs
# to every desktop's docker logs
"DESK_DEBUG": (os.environ.get("DESK_DEBUG") or "").strip(),
- "DESK_RESOLUTION": (os.environ.get("DESK_RESOLUTION") or "").strip()},
+ "DESK_RESOLUTION": (os.environ.get("DESK_RESOLUTION") or "").strip(),
+ # start.sh puts websockify behind basic auth when it is set
+ "CASE_DOCKER_NETWORK": docker_network()},
volumes={volume: {"bind": "/home/agent", "mode": "rw"}},
- mem_limit=f"{int(ram_mb)}m", nano_cpus=int(cpus * 1e9), shm_size="1g",
+ # memswap_limit == mem_limit: without it the desktop swaps past its RAM cap
+ mem_limit=f"{int(ram_mb)}m", memswap_limit=f"{int(ram_mb)}m", pids_limit=512,
+ nano_cpus=int(cpus * 1e9), shm_size="1g",
labels={"managed-by": "cased"})
net = docker_network()
if net:
diff --git a/control-plane/handoffs.py b/control-plane/handoffs.py
index 999023d..1be3c1f 100644
--- a/control-plane/handoffs.py
+++ b/control-plane/handoffs.py
@@ -17,6 +17,8 @@
writes go through store.transition_handoff (CAS + revision bump). Legacy
`answered` is treated as completed on read for one release.
"""
+import hmac
+import json
import os
import assist
@@ -139,6 +141,9 @@ def create_handoff(computer_row, kind, prompt, screenshot=None, login_credential
assist_url = f"https://{host}/assist/{raw_token}" if host else ""
if not host:
log.warning("CASE_PUBLIC_HOST unset — notification carries no assist link")
+ answer_url = ""
+ if kind == "approval" and host:
+ answer_url = f"https://{host}/answer/{hid}/{store.sign('answer:' + hid)}"
notifier.notify({
"id": hid,
"computer_id": computer_row["id"],
@@ -147,6 +152,7 @@ def create_handoff(computer_row, kind, prompt, screenshot=None, login_credential
"screenshot": screenshot,
"domain": domain,
"assist_url": assist_url,
+ "answer_url": answer_url,
"expires_at": expires_at,
}, computer_row["name"])
return handoff_json(row)
@@ -174,6 +180,7 @@ def _attempt_id_of(row):
def expire_stale():
cutoff = (datetime.now(timezone.utc) - HANDOFF_TTL).strftime("%Y-%m-%dT%H:%M:%SZ")
+ import auth_attempts # cycle: auth_attempts → handoffs on raise_challenge
for h in store.stale_pending_handoffs(cutoff):
if store.transition_handoff(h["id"], "expired") is None:
continue # another writer (answer/resume) won the race; not ours to expire
@@ -182,7 +189,6 @@ def expire_stale():
if aid:
# Attempt owns credential outcome, do not double-write from LOGIN_CTX.
try:
- import auth_attempts # cycle: auth_attempts → handoffs on raise_challenge
auth_attempts.fail_attempt(aid, reason="handoff_expired")
except Exception as e:
log.warning("expire_stale fail_attempt %s: %s", aid, e)
@@ -192,6 +198,13 @@ def expire_stale():
store.record_credential_result(ctx["computer_id"], ctx["credential"], "failed")
emit("login_completed", {"computer_id": ctx["computer_id"],
"credential": ctx["credential"], "status": "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):
+ try:
+ auth_attempts.fail_attempt(a["id"], reason="stale")
+ except Exception as e:
+ log.warning("expire_stale attempt %s: %s", a["id"], e)
# Lists never carry the screenshot. A pending 2FA handoff holds a full-display PNG as
@@ -355,7 +368,10 @@ def submit_handoff_value(hid, value):
computer = get_computer(row["computer_id"])
submitted = False
try:
- out = auth_submit_challenge(computer, row["kind"], value=value)
+ 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)
@@ -446,6 +462,17 @@ def answer_handoff(hid, value):
f"handoff continuation {cont!r} cannot be answered this way")
+def answer_token_ok(hid, token):
+ return hmac.compare_digest(store.sign("answer:" + hid), token or "")
+
+
+def answer_by_token(hid, token, value):
+ """Public ntfy-button door: the token is the only credential, so a bad one is a 404."""
+ if not answer_token_ok(hid, token):
+ raise ApiError(404, "not_found", "no such handoff")
+ return answer_handoff(hid, value)
+
+
def on_ntfy_answer(hid, value):
if hid is None:
pending = store.pending_handoff_ids()
diff --git a/control-plane/lifecycle.py b/control-plane/lifecycle.py
index f48f523..2b214f7 100644
--- a/control-plane/lifecycle.py
+++ b/control-plane/lifecycle.py
@@ -143,7 +143,7 @@ def provision(name=None, cpus=1, ram_mb=2048):
except Exception as e:
dockerd.destroy_infra(cid, volume)
store.delete_computer(cid)
- raise ApiError(500, "create_failed", f"{type(e).__name__}: {e}")
+ raise ApiError(500, "create_failed", f"create failed: {type(e).__name__}")
if not _try_set(cid, "running"):
# raced a DELETE while provisioning, tear down the infra we just built
dockerd.destroy_infra(cid, volume)
@@ -166,6 +166,7 @@ def destroy(cid):
row = get_computer(cid)
dockerd.destroy_infra(cid, row["volume"])
store.delete_credentials(cid)
+ store.delete_schedules_for(cid)
# deletion is terminal and the infra is already gone, force it, so a concurrent
# wake that moved the row to 'waking' can't leave it un-deleted.
_force_state(cid, row["state"], "deleted")
diff --git a/control-plane/links.py b/control-plane/links.py
index a662ede..1372e9a 100644
--- a/control-plane/links.py
+++ b/control-plane/links.py
@@ -81,16 +81,6 @@ def _is_ip_literal(host):
return False
-def _blocked_ip(host):
- """True when host is an IP that must never be opened (private/loopback/…)."""
- try:
- ip = ipaddress.ip_address(host)
- except ValueError:
- return False
- return bool(ip.is_private or ip.is_loopback or ip.is_link_local
- or ip.is_reserved or ip.is_multicast or ip.is_unspecified)
-
-
def validate_assist_open_url(url):
"""Return hostname if `url` is a public HTTPS URL safe for Assist open_url.
@@ -112,7 +102,7 @@ def validate_assist_open_url(url):
host = host.lower().rstrip(".")
if host == "localhost" or host.endswith(".localhost") or host.endswith(".local"):
raise ApiError(400, "bad_request", "url host not allowed")
- if _is_ip_literal(host) or _blocked_ip(host):
+ if _is_ip_literal(host):
raise ApiError(400, "bad_request", "url host not allowed")
if not HOSTNAME.match(host):
raise ApiError(400, "bad_request", "url host not allowed")
diff --git a/control-plane/login_flow.py b/control-plane/login_flow.py
index 149f8e5..888c8d1 100644
--- a/control-plane/login_flow.py
+++ b/control-plane/login_flow.py
@@ -5,6 +5,7 @@
can move into lifespan without import-time side effects.
"""
import time
+from urllib.parse import urlsplit
import auth_attempts
import captcha
@@ -202,10 +203,14 @@ def _try_captcha_auto(row, cid, name, resume=True, record=True):
# Re-assert page URL from the live tab (detect can race a soft nav).
try:
live_href = eval_value(row, "location.href", timeout_s=5)
- if isinstance(live_href, str) and live_href.startswith("http"):
+ if isinstance(live_href, str) and live_href.startswith("https://"):
pageurl = live_href
except Exception:
pass
+ # The solver is a third party: it needs scheme+host+path, never the session
+ # tokens and continuation URLs a login page carries in its query string.
+ if isinstance(pageurl, str):
+ pageurl = urlsplit(pageurl)._replace(query="", fragment="").geturl()
solved = captcha.solve_if_capable(family, pageurl, key, enterprise=enterprise)
if not solved:
log.info("captcha_auto=fail reason=solve")
diff --git a/control-plane/notify.py b/control-plane/notify.py
index 38bc8be..08dc41c 100644
--- a/control-plane/notify.py
+++ b/control-plane/notify.py
@@ -16,8 +16,6 @@
import requests
-from config import API_BASE
-
log = logging.getLogger("cased.notify")
OUTBOUND_TAG = "case-outbound"
@@ -39,11 +37,10 @@ def _tags(ev):
class Ntfy:
- def __init__(self, url, topic, answer_topic, api_base):
+ def __init__(self, url, topic, answer_topic):
self.url = url.rstrip("/")
self.topic = topic
self.answer_topic = answer_topic
- self.api_base = api_base
if not topic:
log.warning("CASE_NTFY_TOPIC unset — handoff notifications disabled")
if topic and answer_topic and topic == answer_topic:
@@ -63,17 +60,20 @@ def _send(self, h, computer_name):
tags = [OUTBOUND_TAG]
if h.get("id"):
tags.append(h["id"])
+ prompt = " ".join((h.get("prompt") or "").split()) # header values can't hold newlines
headers = {
**_auth_headers(),
"X-Title": ascii_(f"[Case] {h['kind']} — {computer_name}"),
"X-Tags": ",".join(tags),
- "X-Message": ascii_(h["prompt"])[:800],
+ "X-Message": ascii_(prompt)[:800],
}
- if h["kind"] == "approval":
- a = f"{self.api_base}/handoffs/{h['id']}/answer"
+ if h.get("assist_url"):
+ headers["X-Click"] = h["assist_url"]
+ if h.get("answer_url"):
+ a = h["answer_url"]
headers["X-Actions"] = (
- f"http, Approve, {a}, method=POST, body={{\"value\":\"approve\"}}; "
- f"http, Deny, {a}, method=POST, body={{\"value\":\"deny\"}}")
+ f"http, Approve, {a}, method=POST, headers.Content-Type=application/json, body={{\"value\":\"approve\"}}; "
+ f"http, Deny, {a}, method=POST, headers.Content-Type=application/json, body={{\"value\":\"deny\"}}")
body = b""
if h.get("screenshot"):
headers["X-Filename"] = "screen.png"
@@ -107,7 +107,7 @@ def _listen(self, on_answer):
while True:
try:
r = requests.get(f"{self.url}/{self.answer_topic}/sse", stream=True,
- headers=headers, timeout=(10, None))
+ headers=headers, timeout=(10, 90))
for line in r.iter_lines():
if not line or not line.startswith(b"data: "):
continue
@@ -126,15 +126,15 @@ def _listen(self, on_answer):
on_answer(None, msg)
except Exception as e:
log.warning("answer via ntfy rejected: %s", e)
- except Exception:
- pass
+ except Exception as e:
+ log.warning("ntfy listen: %s", e)
time.sleep(5)
def build_notifier():
return Ntfy(os.environ.get("CASE_NTFY_URL", "https://ntfy.sh"),
os.environ.get("CASE_NTFY_TOPIC"),
- os.environ.get("CASE_NTFY_ANSWER_TOPIC"), API_BASE)
+ os.environ.get("CASE_NTFY_ANSWER_TOPIC"))
notifier = build_notifier()
diff --git a/control-plane/scheduler.py b/control-plane/scheduler.py
index 4982a7a..01a0a24 100644
--- a/control-plane/scheduler.py
+++ b/control-plane/scheduler.py
@@ -35,14 +35,16 @@ def compute_next(kind, spec, jitter_s):
"""Next fire time as UTC ISO. Lexicographic order == chronological (zero-padded, Z)."""
j = random.randint(0, int(jitter_s or 0))
if kind == "interval":
+ if int(spec) < 60:
+ raise ApiError(400, "bad_request", "interval must be at least 60 seconds")
nxt = datetime.now(timezone.utc) + timedelta(seconds=int(spec) + j)
elif kind == "daily":
- local = datetime.now().astimezone()
+ local = datetime.now()
hh, mm = (int(x) for x in str(spec).split(":"))
t = local.replace(hour=hh, minute=mm, second=0, microsecond=0)
if t <= local:
t += timedelta(days=1)
- nxt = (t + timedelta(seconds=j)).astimezone(timezone.utc)
+ nxt = (t + timedelta(seconds=j)).astimezone(timezone.utc) # naive→aware picks that date's offset
else:
raise ApiError(400, "bad_kind", "kind must be 'interval' or 'daily'")
return nxt.strftime("%Y-%m-%dT%H:%M:%SZ")
diff --git a/control-plane/session_keeper.py b/control-plane/session_keeper.py
index 88c5cb2..0e03199 100644
--- a/control-plane/session_keeper.py
+++ b/control-plane/session_keeper.py
@@ -13,6 +13,7 @@
Unhealthy → record `failed`; optionally open an AuthAttempt for human recovery later.
"""
import os
+import threading
import time
from datetime import datetime, timezone
@@ -33,6 +34,7 @@
# computer_id → monotonic timestamp of last completed probe pass for that box
_last_probe_at = {}
+_TICK = threading.Lock()
def _has_probe_profile(row):
@@ -175,6 +177,15 @@ def _probe_one_awake(computer_id, name):
def tick():
"""Sweeper entry: probe due credentials, batched per computer, with busy/cadence guards."""
+ if not _TICK.acquire(blocking=False): # a pass can outlast the sweep interval
+ return
+ try:
+ _tick()
+ finally:
+ _TICK.release()
+
+
+def _tick():
try:
creds = [c for c in store.list_all_credentials() if _has_probe_profile(c)]
except Exception:
@@ -184,6 +195,9 @@ def tick():
by_cid = {}
for c in creds:
by_cid.setdefault(c["computer_id"], []).append(c)
+ for cid in list(_last_probe_at): # destroyed computers must not leak the cadence map
+ if cid not in by_cid:
+ _last_probe_at.pop(cid)
for cid, group in by_cid.items():
if not _due(cid):
diff --git a/control-plane/store.py b/control-plane/store.py
index f32d6e0..302beec 100644
--- a/control-plane/store.py
+++ b/control-plane/store.py
@@ -7,6 +7,9 @@
module — the vault boundary never leaks plaintext to callers except through
credential_material(), which login uses.
"""
+import base64
+import hashlib
+import hmac
import json
import os
import sqlite3
@@ -90,15 +93,22 @@
class Store:
def __init__(self, home=None):
home = home or CASE_HOME
- os.makedirs(home, exist_ok=True)
+ # the vault key and every stored secret live here; a pre-existing 0755
+ # ~/.case (or a permissive umask) is the whole disclosure
+ os.makedirs(home, mode=0o700, exist_ok=True)
+ os.chmod(home, 0o700)
key_path = os.path.join(home, "key")
if not os.path.exists(key_path):
fd = os.open(key_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
os.write(fd, Fernet.generate_key())
os.close(fd)
with open(key_path, "rb") as f:
- self.fernet = Fernet(f.read())
- self.db = sqlite3.connect(os.path.join(home, "case.db"), check_same_thread=False)
+ key = f.read()
+ self.fernet = Fernet(key)
+ self._key = base64.urlsafe_b64decode(key)
+ db_path = os.path.join(home, "case.db")
+ self.db = sqlite3.connect(db_path, check_same_thread=False)
+ os.chmod(db_path, 0o600) # sqlite mirrors this onto -wal/-shm
self.db.row_factory = sqlite3.Row
self.db.execute("PRAGMA journal_mode=WAL")
self.db.executescript(SCHEMA)
@@ -161,10 +171,13 @@ def q(self, sql, args=()):
return cur
def one(self, sql, args=()):
- return self.q(sql, args).fetchone()
+ # A shared connection needs the lock until the cursor has been read.
+ with self.lock:
+ return self.db.execute(sql, args).fetchone()
def all(self, sql, args=()):
- return self.q(sql, args).fetchall()
+ with self.lock:
+ return self.db.execute(sql, args).fetchall()
# ---- vault ----
def enc(self, s):
@@ -176,6 +189,10 @@ def dec(self, b):
this — credential_material() is the sanctioned plaintext exit."""
return self.fernet.decrypt(b).decode() if b is not None else None
+ def sign(self, text):
+ """Stable HMAC for URL capabilities (ntfy answer buttons); keyed by the vault key."""
+ return hmac.new(self._key, text.encode(), hashlib.sha256).hexdigest()
+
# ---- computers ----
def get_computer(self, cid):
return self.one("SELECT * FROM computers WHERE id=?", (cid,))
@@ -434,6 +451,11 @@ def get_active_auth_attempt(self, computer_id):
"ORDER BY created_at DESC LIMIT 1",
(computer_id, *self.AUTH_ATTEMPT_ACTIVE))
+ 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))
+
def get_auth_attempt_by_idempotency(self, computer_id, idempotency_key):
if not idempotency_key:
return None
@@ -535,6 +557,9 @@ def list_schedules(self, cid):
def delete_schedule(self, sid):
return self.q("DELETE FROM schedules WHERE id=?", (sid,)).rowcount
+ def delete_schedules_for(self, cid):
+ return self.q("DELETE FROM schedules WHERE computer_id=?", (cid,)).rowcount
+
def set_schedule_next(self, sid, next_run_at):
self.q("UPDATE schedules SET next_run_at=? WHERE id=?", (next_run_at, sid))
diff --git a/image/Dockerfile b/image/Dockerfile
index 64cd5c0..948cb6b 100644
--- a/image/Dockerfile
+++ b/image/Dockerfile
@@ -1,5 +1,5 @@
# case-desk — persistent agent desktop. Debian slim + xfwm4 + Chromium + noVNC + deskd.
-FROM debian:bookworm-slim
+FROM debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171
# No desktop environment: xfwm4 alone gives windows titlebars/focus/maximize,
# which is all the agent and the noVNC viewer need.
@@ -36,12 +36,20 @@ RUN printf 'export GOOGLE_API_KEY="no"\nexport GOOGLE_DEFAULT_CLIENT_ID="no"\nex
> /etc/chromium/policies/managed/case.json
ARG NOVNC_VERSION=1.5.0
-RUN curl -fsSL "https://github.com/novnc/noVNC/archive/refs/tags/v${NOVNC_VERSION}.tar.gz" \
- | tar xz -C /usr/share && mv "/usr/share/noVNC-${NOVNC_VERSION}" /usr/share/novnc
+ARG NOVNC_SHA256=6a73e41f98388a5348b7902f54b02d177cb73b7e5eb0a7a0dcf688cc2c79b42a
+RUN curl -fsSL -o /tmp/novnc.tgz "https://github.com/novnc/noVNC/archive/refs/tags/v${NOVNC_VERSION}.tar.gz" \
+ && 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.
RUN python3 -m venv /opt/deskd \
- && /opt/deskd/bin/pip install --no-cache-dir fastapi uvicorn websocket-client requests \
- && /opt/deskd/bin/pip install --no-cache-dir --no-deps websockify
+ && /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 --no-deps websockify==0.13.0
# Passwordless sudo: the container IS the agent's sandbox — self-serve apt
# installs. Note system installs die on container recreate; only the home
@@ -82,4 +90,7 @@ ENV DISPLAY=:0
ENV LANG=C.UTF-8 LC_ALL=C.UTF-8
EXPOSE 8000 6080
+HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
+ CMD curl -sf -H "Authorization: Bearer $DESK_TOKEN" http://127.0.0.1:8000/health || exit 1
+
CMD ["/usr/local/bin/start.sh"]
diff --git a/image/deskd.py b/image/deskd.py
index 819424b..4c30879 100644
--- a/image/deskd.py
+++ b/image/deskd.py
@@ -45,6 +45,11 @@ def err(status, code, message):
return JSONResponse({"error": {"code": code, "message": message}}, status_code=status)
+def injecting():
+ if state["injecting"]:
+ return err(423, "credential_injection", "blocked during credential injection")
+
+
@app.middleware("http")
async def auth(request: Request, call_next):
got = request.headers.get("authorization") or ""
@@ -115,8 +120,8 @@ def health():
@app.get("/screenshot")
def screenshot():
- if state["injecting"]:
- return err(423, "credential_injection", "screenshots blocked during credential injection")
+ if (r := injecting()):
+ return r
return Response(grab(), media_type="image/png")
@@ -152,9 +157,11 @@ def do_action(a):
xdo("click", "--repeat", str(min(abs(dy), 50)), "--delay", "40", "5" if dy > 0 else "4")
elif t == "type":
text = str(a["text"])
+ if len(text) > 10000:
+ raise ValueError("text over 10000 chars")
# a killed xdotool leaves text half-typed and the caller retrying the
# whole thing — scale the timeout so long texts can't hit it
- xdo("type", "--delay", "15", "--", text, timeout=15 + len(text) // 10)
+ xdo("type", "--delay", "15", "--", text, timeout=min(15 + len(text) // 10, 300))
elif t == "key":
xdo("key", "--", str(a["keys"]))
elif t == "wait":
@@ -165,6 +172,8 @@ def do_action(a):
@app.post("/action")
def action(a: dict = Body(...)):
+ if (r := injecting()):
+ return r
try:
do_action(a)
except KeyError as e:
@@ -174,16 +183,27 @@ def action(a: dict = Body(...)):
out = {"ok": True}
if a.get("screenshot"):
time.sleep(min(int(a.get("delay_ms", 300)), 5000) / 1000)
- if state["injecting"]:
- return err(423, "credential_injection", "screenshots blocked during credential injection")
+ if (r := injecting()):
+ return r
out["screenshot_png_b64"] = base64.b64encode(grab()).decode()
return out
# ---------- exec & files ----------
+HOME = "/home/agent"
+FILE_MAX = 8 * 1024 * 1024
+
+
+def home_path(path):
+ p = os.path.realpath(path or "")
+ return p if p.startswith(HOME + "/") else None
+
+
@app.post("/exec")
def exec_(b: dict = Body(...)):
+ if (r := injecting()):
+ return r
if "command" not in b:
return err(400, "bad_request", "command required")
timeout = min(int(b.get("timeout_s", 30)), 600)
@@ -195,7 +215,7 @@ def exec_(b: dict = Body(...)):
except subprocess.TimeoutExpired as e:
code, out = 124, e.stdout or b""
errb = (e.stderr or b"") + b"\n[deskd] command timed out"
- except NotADirectoryError:
+ except (FileNotFoundError, NotADirectoryError, PermissionError):
return err(400, "bad_cwd", f"no such directory: {cwd}")
truncated = len(out) > CAP or len(errb) > CAP
return {"exit_code": code, "stdout": out[:CAP].decode(errors="replace"),
@@ -204,20 +224,41 @@ def exec_(b: dict = Body(...)):
@app.put("/file")
async def file_put(request: Request, path: str):
- data = await request.body()
- d = os.path.dirname(path)
- if d:
- os.makedirs(d, exist_ok=True)
- with open(path, "wb") as f:
- f.write(data)
- return JSONResponse({"path": path, "bytes": len(data)}, status_code=201)
+ if (r := injecting()):
+ return r
+ p = home_path(path)
+ if not p:
+ return err(400, "bad_path", f"path must be under {HOME}/")
+ if int(request.headers.get("content-length") or 0) > FILE_MAX:
+ return err(413, "too_large", f"file over {FILE_MAX} bytes")
+ data = bytearray()
+ async for chunk in request.stream():
+ if len(data) + len(chunk) > FILE_MAX:
+ return err(413, "too_large", f"file over {FILE_MAX} bytes")
+ data.extend(chunk)
+ if (r := injecting()):
+ return r
+ try:
+ os.makedirs(os.path.dirname(p), exist_ok=True)
+ with open(p, "wb") as f:
+ f.write(data)
+ except OSError as e:
+ return err(400, "bad_path", str(e))
+ return JSONResponse({"path": p, "bytes": len(data)}, status_code=201)
@app.get("/file")
def file_get(path: str):
- if not os.path.isfile(path):
- return err(404, "not_found", path)
- with open(path, "rb") as f:
+ if (r := injecting()):
+ return r
+ p = home_path(path)
+ if not p:
+ return err(400, "bad_path", f"path must be under {HOME}/")
+ if not os.path.isfile(p):
+ return err(404, "not_found", p)
+ if os.path.getsize(p) > FILE_MAX:
+ return err(413, "too_large", f"file over {FILE_MAX} bytes")
+ with open(p, "rb") as f:
return Response(f.read(), media_type="application/octet-stream")
@@ -325,8 +366,8 @@ def press_enter(tab):
@app.post("/eval")
def eval_(b: dict = Body(...)):
- if state["injecting"]:
- return err(423, "credential_injection", "eval blocked during credential injection")
+ if (r := injecting()):
+ return r
if "expression" not in b:
return err(400, "bad_request", "body needs 'expression'")
timeout = min(int(b.get("timeout_s", 20)), 120)
@@ -377,21 +418,12 @@ 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) : '')"
+CLEAR_PASS = "[...document.querySelectorAll('input[type=\"password\"]')].forEach(p=>{p.value=''})"
# Generic auth observation — no website names. The JS only collects raw material
# (fields, frame markers, page text); challenge_signals are computed in Python
# (challenge_signals_from_text) so the phrase map lives in exactly one place.
-OBSERVE_AUTH_JS = r"""
-(() => {
- const vis = e => {
- if (!e || e.disabled || e.offsetParent === null) return false;
- const st = getComputedStyle(e);
- if (st.display === 'none' || st.visibility === 'hidden' || Number(st.opacity) === 0) return false;
- const r = e.getBoundingClientRect();
- return r.width > 0 && r.height > 0;
- };
- const userSel = 'input[autocomplete="username"],input[type="email"],input[name*="user" i],input[name*="email" i],input[name*="login" i],input[id*="user" i],input[id*="email" i],input[id*="login" i],input[type="text"]';
- const codeSel = 'input[autocomplete="one-time-code"],input[name*="otp" i],input[name*="code" i],input[id*="otp" i],input[id*="code" i],input[type="tel"],input[type="number"],input[type="text"]';
+_OBSERVE_BODY = r"""
const u = [...document.querySelectorAll(userSel)].find(vis);
const p = [...document.querySelectorAll('input[type="password"]')].find(vis);
const c = [...document.querySelectorAll(codeSel)].find(vis);
@@ -421,6 +453,10 @@ def eval_(b: dict = Body(...)):
})()
"""
+OBSERVE_AUTH_JS = ("(() => {" + VIS
+ + f"const userSel='{USER_SEL}';const codeSel='{CODE_SEL}';"
+ + _OBSERVE_BODY)
+
# Watchdog RE_BLOCK scans arbitrary page text (DMs, feeds, drafts). Bare "2fa" / "captcha"
# false-positive on outreach copy ("asking about 2fa and captcha flows") and spam handoff
# emails. Keep challenge phrasing only. Login classify still uses RE_OTP / RE_CAPTCHA.
@@ -529,8 +565,24 @@ def domain_ok(host, domains):
return any(host == d.lower() or host.endswith("." + d.lower()) for d in domains)
-def challenge(tab, cred, kind, prompt):
- state["login"] = {"kind": kind, "cred_name": cred["name"], "at": time.time()}
+def credential_origin_error(tab, cred):
+ href = tab.js("location.href") or ""
+ parsed = urlparse(href)
+ host = parsed.hostname
+ if not domain_ok(host, cred.get("domains") or []):
+ return f"page origin {host!r} not in credential domains"
+ if parsed.scheme == "https":
+ return None
+ if parsed.scheme == "http" and host in ("localhost", "127.0.0.1", "::1"):
+ return None
+ return f"page origin scheme {parsed.scheme!r} on host {host!r} is not HTTPS"
+
+
+def challenge(cred, kind, prompt):
+ state["login"] = {
+ "kind": kind, "cred_name": cred["name"],
+ "domains": cred.get("domains") or [], "at": time.time(),
+ }
try:
shot = base64.b64encode(grab()).decode()
except Exception:
@@ -554,12 +606,14 @@ def classify(tab, cred):
# by matching concrete path segments only — never the full opaque query blob.
path = (urlparse(href).path or "").lower()
if any(seg in path for seg in ("/codeentry", "/checkpoint", "/two_factor", "/two-factor")):
- return challenge(tab, cred, "otp", f"{host}: verification code entry")
+ return challenge(cred, "otp", f"{host}: verification code entry")
if RE_CAPTCHA.search(blob):
- return challenge(tab, cred, "captcha", f"{host}: {snippet(blob, RE_CAPTCHA)}")
+ return challenge(cred, "captcha", f"{host}: {snippet(blob, RE_CAPTCHA)}")
if RE_OTP.search(blob):
if cred.get("totp_seed"):
+ if reason := credential_origin_error(tab, cred):
+ return {"status": "failed", "reason": reason}
fill(tab, FOCUS_CODE, totp(cred["totp_seed"]))
press_enter(tab)
settle(tab)
@@ -569,9 +623,9 @@ def classify(tab, cred):
return {"status": "success", "totp_used": True}
# SMS OTP / other code challenge -> human (or Twilio, decided by cased)
kind = "otp"
- return challenge(tab, cred, kind, f"{host}: {snippet(blob, RE_OTP)}")
+ return challenge(cred, kind, f"{host}: {snippet(blob, RE_OTP)}")
if RE_APPROVAL.search(blob):
- return challenge(tab, cred, "approval", f"{host}: {snippet(blob, RE_APPROVAL)}")
+ return challenge(cred, "approval", f"{host}: {snippet(blob, RE_APPROVAL)}")
if fields.get("pass") and RE_FAIL.search(text):
return {"status": "failed", "reason": snippet(text, RE_FAIL)}
if fields.get("pass"):
@@ -599,14 +653,20 @@ def fill_login_form(tab, cred):
if not fields.get("user") and not fields.get("pass"):
return "no login fields found"
if fields.get("user"):
+ if reason := credential_origin_error(tab, cred):
+ return reason
fill(tab, FOCUS_USER, cred["username"])
if fields.get("pass"):
+ if reason := credential_origin_error(tab, cred):
+ return reason
fill(tab, FOCUS_PASS, cred["secret"])
press_enter(tab)
else: # two-step (username first)
press_enter(tab)
settle(tab)
if (tab.js(HAS_FIELDS) or {}).get("pass"):
+ if reason := credential_origin_error(tab, cred):
+ return reason
fill(tab, FOCUS_PASS, cred["secret"])
press_enter(tab)
elif not advanced_past_identifier(tab):
@@ -648,16 +708,19 @@ def login(b: dict = Body(...)):
tab = Tab()
try:
navigate(tab, url)
- host = urlparse(tab.js("location.href") or url).hostname
- if not domain_ok(host, cred.get("domains") or []):
+ if reason := credential_origin_error(tab, cred):
return err(400, "domain_mismatch",
- f"page origin {host!r} not in credential domains")
+ reason)
reason = fill_login_form(tab, cred)
if reason:
return {"status": "failed", "reason": reason}
wait_post_submit(tab)
return classify(tab, cred)
finally:
+ try:
+ tab.js(CLEAR_PASS) # the DOM keeps the typed secret if the page did not navigate
+ except Exception:
+ pass
tab.close()
except Exception as e:
return {"status": "failed", "reason": f"login error: {type(e).__name__}: {e}"}
@@ -678,7 +741,11 @@ def login_resume(b: dict = Body(...)):
try:
tab = Tab()
try:
- if ctx["kind"] == "approval" or value.lower() in ("approve", "deny"):
+ is_approval = ctx["kind"] == "approval" or value.lower() in ("approve", "deny")
+ if not is_approval:
+ if reason := credential_origin_error(tab, {"domains": ctx.get("domains") or []}):
+ return {"status": "failed", "reason": reason}
+ if is_approval:
reason = apply_challenge_action(tab, "approval", value)
if reason:
return {"status": "failed", "reason": reason}
@@ -695,6 +762,10 @@ def login_resume(b: dict = Body(...)):
return {"status": "failed", "reason": "challenge still present"}
return {"status": "success"}
finally:
+ try:
+ tab.js(CLEAR_PASS) # the DOM keeps the typed secret if the page did not navigate
+ except Exception:
+ pass
tab.close()
except Exception as e:
return {"status": "failed", "reason": f"resume error: {type(e).__name__}: {e}"}
@@ -707,8 +778,8 @@ def login_resume(b: dict = Body(...)):
@app.post("/auth/observe")
def auth_observe():
- if state["injecting"]:
- return err(423, "credential_injection", "observe blocked during credential injection")
+ if (r := injecting()):
+ return r
try:
tab = Tab()
try:
@@ -728,11 +799,17 @@ def auth_submit_challenge(b: dict = Body(...)):
return err(400, "bad_request", "body needs 'kind'")
kind = b["kind"]
value = b.get("value")
+ 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")
state["in_login"] = True
state["injecting"] = True
try:
tab = Tab()
try:
+ if str(kind).lower() in ("otp", "code"):
+ if reason := credential_origin_error(tab, {"domains": b["domains"]}):
+ return {"ok": False, "reason": reason}
reason = apply_challenge_action(tab, kind, value)
if reason == "missing challenge value":
return err(400, "bad_request", "body needs 'value' for otp/code")
@@ -754,9 +831,8 @@ def auth_submit_challenge(b: dict = Body(...)):
def auth_navigate_verification(b: dict = Body(...)):
if "url" not in b:
return err(400, "bad_request", "body needs 'url'")
- if state["injecting"]:
- return err(423, "credential_injection",
- "navigate_verification blocked during credential injection")
+ if (r := injecting()):
+ return r
url = b["url"]
domains = b.get("domains")
host = urlparse(url).hostname
@@ -899,6 +975,8 @@ def _stop_capture():
@app.post("/capture/start")
def capture_start(b: dict = Body(...)):
+ # ponytail: no regex-backtracking guard — CPython has no regex timeout, and a
+ # caller who can reach this route already has /exec on the box.
pattern = b.get("pattern")
if not pattern:
return err(400, "bad_request", "body needs 'pattern'")
@@ -920,8 +998,8 @@ def capture_start(b: dict = Body(...)):
@app.get("/capture")
def capture_get():
- if state["injecting"]:
- return err(423, "credential_injection", "capture blocked during credential injection")
+ if (r := injecting()):
+ return r
cap = state["capture"]
if not cap:
return {"items": [], "running": False, "error": None}
@@ -932,8 +1010,8 @@ def capture_get():
@app.delete("/capture")
def capture_delete():
- if state["injecting"]:
- return err(423, "credential_injection", "capture blocked during credential injection")
+ if (r := injecting()):
+ return r
cap = _stop_capture()
return {"items": _drain(cap["buf"]) if cap else [], "running": False,
"error": cap["error"] if cap else None}
diff --git a/image/start.sh b/image/start.sh
index b51bdd6..accb9ab 100644
--- a/image/start.sh
+++ b/image/start.sh
@@ -47,11 +47,17 @@ Xvfb :0 -screen 0 "$RES" -nolisten tcp -fbdir /dev/shm &
XVFB_PID=$!
for _ in $(seq 1 100); do [ -e /tmp/.X11-unix/X0 ] && break; sleep 0.1; done
+[ -e /tmp/.X11-unix/X0 ] || { echo "[start] Xvfb did not come up" >&2; exit 1; }
-x11vnc -display :0 -forever -shared -nopw -quiet -bg
+x11vnc -display :0 -localhost -forever -shared -nopw -quiet -bg
+# On a shared network the only client is cased, which holds DESK_TOKEN. Host mode
+# stays open: 6080 is loopback-only there and the /desk proxy door cannot add the header.
+# -localhost on x11vnc matters too — without it a peer skips websockify and speaks RFB to 5900.
+auth=()
+[ -n "$CASE_DOCKER_NETWORK" ] && auth=(--auth-plugin BasicHTTPAuth --auth-source "agent:$DESK_TOKEN")
# log to file, not compose stdout: its per-connection "Plain non-SSL (ws://)"
# lines read like TLS errors to people skimming `docker compose logs`
-/opt/deskd/bin/websockify --web /usr/share/novnc 6080 localhost:5900 >>/tmp/websockify.log 2>&1 &
+/opt/deskd/bin/websockify "${auth[@]}" --web /usr/share/novnc 6080 localhost:5900 >>/tmp/websockify.log 2>&1 &
# DESK_DEBUG=1 (docker run -e, or on cased to cover every desktop): mirror the
# in-container logs to docker logs
[ "$DESK_DEBUG" = "1" ] && tail -n +1 -F /tmp/chromium.log /tmp/websockify.log 2>/dev/null &
diff --git a/mcp/case_mcp.py b/mcp/case_mcp.py
index 6676eef..5256ffa 100644
--- a/mcp/case_mcp.py
+++ b/mcp/case_mcp.py
@@ -54,7 +54,12 @@ def call(method, path, **kw):
r = requests.request(method, BASE + path, timeout=kw.pop("timeout", 150),
headers=_headers(), **kw)
if r.status_code >= 400:
- raise RuntimeError(r.text[:500])
+ try:
+ e = r.json()["error"]
+ msg = f"{e['code']}: {e['message']}"
+ except (ValueError, KeyError, TypeError):
+ msg = f"cased returned {r.status_code}"
+ raise RuntimeError(msg)
return r
@@ -355,14 +360,15 @@ def computer_login(computer_id: str, credential: str, url: str,
@mcp.tool()
def computer_file_put(computer_id: str, path: str, content_b64: str) -> dict:
- """Write a file on the computer (content is base64)."""
+ """Write a file on the computer (content is base64). Paths must be under /home/agent/."""
return call("PUT", f"/computers/{computer_id}/files",
params={"path": path, "wake": "true"}, data=base64.b64decode(content_b64)).json()
@mcp.tool()
def computer_file_get(computer_id: str, path: str) -> dict:
- """Read a file from the computer. Text files come back readable:
+ """Read a file from the computer. Paths must be under /home/agent/.
+ Text files come back readable:
{encoding:"utf8", content, bytes}. Binary files come back as
{encoding:"base64", content, bytes} — do not try to read base64 yourself;
process binary files on the computer with computer_exec instead
@@ -399,8 +405,8 @@ def skill_name_ok(name):
def skill_content_risky(content):
- """True when content carries something secret-shaped (key: value secrets, or a
- 40+ char unbroken token). Vault names like 'credential: coupa' pass."""
+ """True when content is obviously secret-shaped (key: value secrets, or a 40+ char
+ unbroken token) — a lint, not a guarantee. Vault names like 'credential: coupa' pass."""
m = _SKILL_RISKY_RE.search(content or "")
return m.group(0)[:60] if m else None
@@ -438,7 +444,7 @@ def case_skill(computer_id: str, action: str, name: str = "", content: str = "")
- End with a "Done means" section: how to verify the task actually succeeded.
- Logins: ONE step — computer_login(credential=) + auth_attempt_wait.
NEVER write usernames, passwords, OTP codes, cookies or tokens into a skill;
- save rejects secret-shaped content.
+ save refuses obviously secret-shaped content (a lint, not a guarantee).
- On later runs where reality diverged: finish the task, then update the file
and append a dated line to a `## Drift log` section — heal loudly.
A new skill is a draft until a later run succeeds by following it."""
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 1441c92..ef02e79 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,2 +1,5 @@
-r requirements.txt
pytest==9.1.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 1bddc0a..218b5f8 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,7 +2,8 @@
fastapi==0.139.0
uvicorn==0.50.2
docker==7.1.0
-cryptography==49.0.0
+cryptography==50.0.0
requests==2.34.2
mcp==1.28.1
-Pillow==12.2.0
+Pillow==12.3.0
+websockets==15.0.1
diff --git a/tests/test_acceptance.py b/tests/test_acceptance.py
index f2e6b96..03bd0b5 100644
--- a/tests/test_acceptance.py
+++ b/tests/test_acceptance.py
@@ -1,10 +1,8 @@
# SPDX-License-Identifier: MIT
-"""Acceptance tests A1–A10. Run order matters — pytest runs top-down.
+"""Acceptance tests A1-A10 against a temporary cased process.
-Requires: cased running on 127.0.0.1:8787 (logs at ~/.case/cased.log), image built.
-A7 is manual (phone). A8 is gated behind CASE_A8=1 (restarts the Docker VM).
-A9 runs via Claude Code separately. Set CASE_KEEP=1 to keep the test computer around
-(A1 reaps any previous accept-1 first, so at most one ever survives).
+The suite starts its own loopback API and temporary vault. A7 is manual. A8 is
+gated because it restarts the Docker VM. CASE_KEEP=1 retains this run's fixture.
"""
import base64
import contextlib
@@ -14,15 +12,25 @@
import json
import os
import secrets
+import shutil
+import socket
import struct
import subprocess
+import sys
+import tempfile
import threading
import time
import pytest
import requests
-BASE = "http://127.0.0.1:8787/v1"
+ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+BASE = None
+ROOT = None
+CASE_HOME = None
+SCRATCH_DIR = None
+CASE_TOKEN = None
+BOX = f"acceptance-fixture-{secrets.token_hex(8)}"
SITE_USER = "agent@example.com"
SITE_PASS = "s3cr3t-" + secrets.token_hex(8) # unique per run so log-grep is meaningful
# Durable auth requires a positive proof_spec for status=success (else unverified).
@@ -30,41 +38,163 @@
"expression": "!!document.body && /You are signed in/.test(document.body.innerText)",
}
TOTP_SEED = base64.b32encode(secrets.token_bytes(10)).decode()
-CASE_HOME = os.environ.get("CASE_HOME", os.path.expanduser("~/.case"))
-
CAPTURED = [] # every JSON/text API response body, for the A5 vault grep
_computer = {}
+_created_ids = set()
+
+
+def free_loopback_port():
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.bind(("127.0.0.1", 0))
+ return sock.getsockname()[1]
+
+
+def child_env(parent, case_home, port, token, image):
+ """Build a child environment that cannot use a caller's Case settings."""
+ env = dict(parent)
+ for key in list(env):
+ upper = key.upper()
+ if upper.startswith((
+ "CASE_", "DESK_", "ANTHROPIC_", "OPENAI_", "GOOGLE_", "GEMINI_",
+ "COHERE_", "MISTRAL_", "XAI_", "DEEPSEEK_", "GROQ_", "TOGETHER_",
+ "FIREWORKS_", "PERPLEXITY_", "DASHSCOPE_", "AWS_", "AZURE_", "NTFY_",
+ )):
+ env.pop(key)
+ env.update({
+ "CASE_HOME": case_home,
+ "CASE_BIND": "127.0.0.1",
+ "CASE_PORT": str(port),
+ "CASE_TOKEN": token,
+ "CASE_IMAGE": image,
+ })
+ return env
+
+
+def cleanup_owned(api_call, ids, keep_id=None):
+ """Delete every owned ID and return any failures."""
+ failures = []
+ for computer_id in ids:
+ if computer_id == keep_id:
+ continue
+ try:
+ response = api_call("DELETE", f"/computers/{computer_id}")
+ if response.status_code not in (204, 404):
+ failures.append(f"{computer_id}: {response.status_code} {response.text}")
+ except Exception as error:
+ failures.append(f"{computer_id}: {error}")
+ return failures
+
+
+def stop_owned_process(proc):
+ """Stop the cased process started by this fixture."""
+ failures = []
+ try:
+ if proc.poll() is None:
+ proc.terminate()
+ try:
+ proc.wait(timeout=20)
+ except subprocess.TimeoutExpired:
+ proc.kill()
+ proc.wait(timeout=10)
+ except Exception as error:
+ failures.append(str(error))
+ return failures
+
+
+def retain_scratch(keep_id, setup_failed, test_failed, cleanup_failures, stop_failures):
+ """Keep logs when the fixture or its cleanup failed."""
+ return bool(keep_id or setup_failed or test_failed or cleanup_failures or stop_failures)
+
+
+@pytest.fixture(scope="module", autouse=True)
+def acceptance_server(request):
+ """Run cased with a fresh local vault for this module."""
+ global BASE, ROOT, CASE_HOME, SCRATCH_DIR, CASE_TOKEN
+ failures_before = request.session.testsfailed
+ setup_failed = False
+ SCRATCH_DIR = tempfile.mkdtemp(prefix="case-acceptance-")
+ CASE_HOME = os.path.join(SCRATCH_DIR, "vault")
+ os.makedirs(CASE_HOME)
+ CASE_TOKEN = secrets.token_urlsafe(32)
+ port = free_loopback_port()
+ ROOT = f"http://127.0.0.1:{port}"
+ BASE = ROOT + "/v1"
+ image = os.environ.get("CASE_ACCEPTANCE_IMAGE", "case-desk:0.1")
+ env = child_env(os.environ, CASE_HOME, port, CASE_TOKEN, image)
+ log_path = os.path.join(CASE_HOME, "cased.log")
+ with open(log_path, "w") as log_file:
+ proc = subprocess.Popen(
+ [sys.executable, os.path.join(ROOT_DIR, "control-plane", "cased.py")],
+ cwd=ROOT_DIR, env=env, stdout=log_file, stderr=subprocess.STDOUT,
+ )
+ try:
+ deadline = time.monotonic() + 30
+ while time.monotonic() < deadline:
+ if proc.poll() is not None:
+ raise AssertionError(f"owned cased exited early; see {log_path}")
+ try:
+ if requests.get(ROOT + "/health", timeout=1).status_code == 200:
+ break
+ except requests.RequestException:
+ pass
+ time.sleep(0.2)
+ else:
+ raise AssertionError(f"owned cased did not become healthy; see {log_path}")
+ assert requests.get(BASE + "/computers", timeout=2).status_code == 401
+ response = api("GET", "/computers")
+ assert response.status_code == 200, response.text
+ assert response.json()["computers"] == [], "temporary vault was not empty"
+ assert api("GET", "/handoffs").json()["handoffs"] == []
+ yield
+ except BaseException:
+ setup_failed = True
+ raise
+ finally:
+ keep_id = _computer.get("id") if os.environ.get("CASE_KEEP") == "1" else None
+ cleanup_failures = cleanup_owned(api, tuple(_created_ids), keep_id)
+ stop_failures = stop_owned_process(proc)
+ test_failed = request.session.testsfailed > failures_before
+ retain = retain_scratch(keep_id, setup_failed, test_failed, cleanup_failures, stop_failures)
+ if keep_id:
+ print(f"retained acceptance computer {keep_id} in {SCRATCH_DIR}")
+ elif retain:
+ print(f"acceptance scratch retained at {SCRATCH_DIR}")
+ else:
+ shutil.rmtree(SCRATCH_DIR, ignore_errors=True)
+ BASE = ROOT = CASE_HOME = SCRATCH_DIR = CASE_TOKEN = None
+ _computer.clear()
+ _created_ids.clear()
+ CAPTURED.clear()
+ if cleanup_failures or stop_failures:
+ raise AssertionError("acceptance teardown failed: " + "; ".join(
+ cleanup_failures + stop_failures))
def api(method, path, timeout=180, **kw):
- r = requests.request(method, BASE + path, timeout=timeout, **kw)
- if "json" in r.headers.get("content-type", "") or "text" in r.headers.get("content-type", ""):
+ headers = dict(kw.pop("headers", {}))
+ headers["Authorization"] = f"Bearer {CASE_TOKEN}"
+ r = requests.request(method, BASE + path, timeout=timeout, headers=headers, **kw)
+ if not kw.get("stream") and any(t in r.headers.get("content-type", "") for t in ("json", "text")):
CAPTURED.append(r.text)
return r
def cid():
- if not _computer: # standalone run (e.g. CASE_A8=1): reuse the kept accept-1
- for c in api("GET", "/computers").json()["computers"]:
- if c["name"] == "accept-1":
- _computer.update(c)
- assert _computer, "A1 must run first (or a kept accept-1 must exist)"
+ assert _computer, "A1 must run first"
return _computer["id"]
-@contextlib.contextmanager
-def spare_slot():
- """Free the one desktop slot so a test can create a second computer.
+def create_computer(name):
+ r = api("POST", "/computers", json={"name": name})
+ assert r.status_code == 201, r.text
+ computer = r.json()
+ _created_ids.add(computer["id"])
+ return computer
- A box behind a reverse proxy runs CASE_MAX_RUNNING=1 *and* pins the noVNC host
- port (CASE_VNC_PORT=6080, so the /desk door has a fixed upstream). Together those
- mean a second *running* computer cannot exist there at all: create fails with
- "Bind for 127.0.0.1:6080 failed: port is already allocated". A Mac has the headroom
- and never notices, which is why these tests passed there and only there.
- Waking accept-1 again is not optional — every later test calls cid() and expects a
- live desktop behind it.
- """
+@contextlib.contextmanager
+def spare_slot():
+ """Sleep the fixture while a test creates another computer."""
api("POST", f"/computers/{cid()}/sleep")
try:
yield
@@ -100,7 +230,7 @@ def totp(seed, at):
def save_shot(name):
r = api("GET", f"/computers/{cid()}/screenshot")
if r.status_code == 200:
- out = os.path.join(os.path.dirname(__file__), "shots")
+ out = os.path.join(SCRATCH_DIR, "shots")
os.makedirs(out, exist_ok=True)
with open(os.path.join(out, name), "wb") as f:
f.write(r.content)
@@ -109,26 +239,9 @@ def save_shot(name):
# ---------- A1 boot ----------
-def reap_stale():
- """Delete leftover accept-1 boxes before minting a fresh one.
-
- CASE_KEEP=1 (the documented way to run this suite) skips test_zz_cleanup, so
- without this every run left another accept-1 behind — they pile up in the DB
- and in Drive, and each one holds five test credentials. Reaping here rather
- than at teardown keeps CASE_KEEP's whole point (one box survives to poke at)
- while capping the count at one. Only ever touches the name this file creates.
- """
- for c in api("GET", "/computers").json()["computers"]:
- if c["name"] == "accept-1":
- api("DELETE", f"/computers/{c['id']}", json={"name": c["name"]})
-
-
def test_a1_boot():
- reap_stale()
t0 = time.time()
- r = api("POST", "/computers", json={"name": "accept-1"})
- assert r.status_code == 201, r.text
- c = r.json()
+ c = create_computer(BOX)
assert c["state"] == "running"
assert time.time() - t0 <= 60
_computer.update(c)
@@ -136,7 +249,10 @@ def test_a1_boot():
shot = save_shot("a1_desktop.png")
assert shot.status_code == 200
assert png_dims(shot.content) == (1280, 800)
- assert len(shot.content) > 30_000, "screenshot suspiciously small — likely a black screen"
+ # A painted xfce desktop measures 22k-25k here; black is 3k, a blank X root 5k,
+ # near-black with noise 9k. 30k sat above every real screenshot on arm64, so it
+ # could never pass. 15k keeps the black-screen catch with margin either side.
+ assert len(shot.content) > 15_000, "screenshot suspiciously small, likely a black screen"
# ---------- A2 exec ----------
@@ -188,11 +304,11 @@ def start_site():
r = api("PUT", f"/computers/{cid()}/files", params={"path": "/home/agent/site_server.py"}, data=src)
assert r.status_code == 201
# separate exec: pkill -f must not share a command line with the plain string it hunts.
- # Wait until the old listener is gone — a 1s sleep alone races on cx23 under load.
+ # Wait until the old listener is gone. A 1s sleep races on cx23 under load.
exec_("pkill -f '[s]ite_server' || true; "
"for i in 1 2 3 4 5 6 7 8; do pgrep -f '[s]ite_server' >/dev/null || break; sleep 1; done; "
"true")
- # Bind is 127.0.0.1 (not localhost) — curl the same. Retry ready-check; unbuffered
+ # Bind is 127.0.0.1, not localhost. Curl the same. Retry ready-check; unbuffered
# so a bind failure lands in /tmp/site.log instead of a silent empty file.
out = exec_(
f"SITE_USER='{SITE_USER}' SITE_PASS='{SITE_PASS}' PYTHONUNBUFFERED=1 "
@@ -227,7 +343,7 @@ def test_a5_vault_hygiene():
def hammer():
while not stop.is_set():
- r = requests.get(f"{BASE}/computers/{cid()}/screenshot", timeout=10)
+ r = api("GET", f"/computers/{cid()}/screenshot", timeout=10)
if r.status_code == 423:
hits["n423"] += 1
time.sleep(0.1)
@@ -270,11 +386,8 @@ def hammer():
# ---------- human doors (fill + desk) ----------
-ROOT = BASE.rsplit("/v1", 1)[0] # /fill lives outside /v1 — straight at cased
-
-
def test_fill_link():
- """A minted fill link writes a credential through the browser-form door —
+ """A minted fill link writes a credential through the browser-form door.
single-use, never through MCP, never plaintext in the audit log (A5 family)."""
r = api("POST", f"/computers/{cid()}/links", json={"kind": "fill"})
assert r.status_code == 201, r.text
@@ -283,7 +396,7 @@ def test_fill_link():
assert "name=secret type=password" in requests.get(f"{ROOT}/fill/{tok}", timeout=10).text
- # a pasted URL is what humans actually type — it must land as a bare host, or the
+ # A pasted URL must land as a bare host, or the
# login never matches and the credential name (with a /) cannot even be deleted
r = requests.post(f"{ROOT}/fill/{tok}", timeout=10,
data={"domains": "https://Fill-Test.example.com/inbox",
@@ -302,7 +415,7 @@ def test_fill_link():
data={"domains": "x.com", "username": "u", "secret": "p"})
assert r.status_code == 410, r.text
- # neither the password (body) nor the token (path — it is a live capability)
+ # Neither the password (body) nor the token (path, a live capability)
# may reach the audit log
blob = "".join(open(p, errors="replace").read()
for p in glob.glob(os.path.join(CASE_HOME, "audit", "*.jsonl")))
@@ -316,14 +429,12 @@ def test_fill_link():
def test_fill_form_escapes_an_agent_chosen_name():
"""The computer name comes from the agent (computer_create). The credential page
- must never let it become script — that would let the agent read the password the
+ must never let it become script. That would let the agent read the password the
human types, on the one page whose whole promise is that it cannot."""
- # This only needs the name to reach the DB and come back out through the form —
+ # This only needs the name to reach the DB and come back out through the form.
# never two live desktops.
with spare_slot():
- r = api("POST", "/computers", json={"name": ""})
- assert r.status_code == 201, r.text
- evil = r.json()["id"]
+ evil = create_computer("")["id"]
try:
tok = api("POST", f"/computers/{evil}/links", json={"kind": "fill"}).json()["token"]
page = requests.get(f"{ROOT}/fill/{tok}", timeout=10).text
@@ -340,31 +451,29 @@ def test_desk_check():
# 302, not 200: forward_auth hands a non-2xx auth response back to the browser,
# which is the only way the Set-Cookie reaches a human. Token leaves the URL.
- r = requests.get(f"{BASE}/desk/check", timeout=10, allow_redirects=False,
- headers={"X-Forwarded-Uri": f"/desk/vnc.html?token={tok}&autoconnect=1"})
+ r = api("GET", "/desk/check", timeout=10, allow_redirects=False,
+ headers={"X-Forwarded-Uri": f"/desk/vnc.html?token={tok}&autoconnect=1"})
assert r.status_code == 302 and f"case_desk={tok}" in r.headers.get("set-cookie", ""), r.headers
assert r.headers["Location"] == "/desk/vnc.html?autoconnect=1", r.headers
- r = requests.get(f"{BASE}/desk/check", timeout=10, headers={"Cookie": f"case_desk={tok}"})
+ r = api("GET", "/desk/check", timeout=10, headers={"Cookie": f"case_desk={tok}"})
assert r.status_code == 200 and "set-cookie" not in {k.lower() for k in r.headers}
- r = requests.get(f"{BASE}/desk/check", timeout=10,
- headers={"X-Forwarded-Uri": "/desk/vnc.html?token=nope"})
+ r = api("GET", "/desk/check", timeout=10,
+ headers={"X-Forwarded-Uri": "/desk/vnc.html?token=nope"})
assert r.status_code == 401
# a token is not enough: it must name the computer that is actually behind the
# door, or the human meets whichever desktop happens to be awake
# desk-bind only ever has to exist and be asleep, so it never needs the slot at the
- # same time as accept-1 — but creating it does, because create starts the container.
+ # same time as the fixture, but creating it does because create starts the container.
with spare_slot():
- r = api("POST", "/computers", json={"name": "desk-bind"})
- assert r.status_code == 201, r.text
- other = r.json()["id"]
+ other = create_computer("desk-bind")["id"]
try:
api("POST", f"/computers/{other}/sleep")
t2 = api("POST", f"/computers/{other}/links", json={"kind": "vnc"}).json()["token"]
- r = requests.get(f"{BASE}/desk/check", timeout=10, allow_redirects=False,
- headers={"X-Forwarded-Uri": f"/desk/vnc.html?token={t2}"})
+ r = api("GET", "/desk/check", timeout=10, allow_redirects=False,
+ headers={"X-Forwarded-Uri": f"/desk/vnc.html?token={t2}"})
assert r.status_code == 409 and "asleep" in r.text, (r.status_code, r.text[:200])
finally:
api("DELETE", f"/computers/{other}")
@@ -404,8 +513,8 @@ def test_eval():
def hammer():
while not stop.is_set():
- rr = requests.post(f"{BASE}/computers/{cid()}/eval",
- json={"expression": "1"}, timeout=10)
+ rr = api("POST", f"/computers/{cid()}/eval",
+ json={"expression": "1"}, timeout=10)
if rr.status_code == 423:
hits["n423"] += 1
time.sleep(0.05)
@@ -472,14 +581,14 @@ def test_a6_totp():
assert codes[-1] in valid, f"entered code {codes[-1]} not a valid TOTP for the seed"
-# ---------- A7 handoff loop — API half (phone half is manual) ----------
+# ---------- A7 handoff loop, API half (phone half is manual) ----------
def test_a7_handoff_api_loop():
events = []
stop = threading.Event()
def listen():
- with requests.get(f"{BASE}/events", stream=True, timeout=(5, 60)) as r:
+ with api("GET", "/events", stream=True, timeout=(5, 60)) as r:
for line in r.iter_lines():
if stop.is_set():
return
@@ -542,18 +651,18 @@ def test_a10_fleet():
ids = [cid()]
try:
for i in range(2, 7):
- r = api("POST", "/computers", json={"name": f"fleet-{i}"})
- assert r.status_code == 201, f"fleet-{i}: {r.text}"
- ids.append(r.json()["id"])
+ ids.append(create_computer(f"fleet-{i}")["id"])
for i in ids:
out = api("POST", f"/computers/{i}/exec", json={"command": "echo hi"}).json()
assert out["stdout"].strip() == "hi"
for i in ids:
r = api("POST", f"/computers/{i}/sleep")
assert r.json()["state"] == "asleep"
- ps = subprocess.run(["docker", "ps", "-q", "--filter", "label=managed-by=cased"],
- capture_output=True, text=True)
- assert ps.stdout.strip() == "", "containers still running after fleet sleep"
+ for computer_id in ids:
+ ps = subprocess.run(["docker", "inspect", "--format", "{{.State.Running}}",
+ f"case-{computer_id}"], capture_output=True, text=True)
+ assert ps.returncode == 0 and ps.stdout.strip() == "false", \
+ f"fixture container {computer_id} still running: {ps.stderr}"
finally:
for i in ids[1:]:
api("DELETE", f"/computers/{i}")
diff --git a/tests/test_acceptance_safety.py b/tests/test_acceptance_safety.py
new file mode 100644
index 0000000..e35b292
--- /dev/null
+++ b/tests/test_acceptance_safety.py
@@ -0,0 +1,136 @@
+# SPDX-License-Identifier: MIT
+"""No-Docker checks for the acceptance harness.
+
+Run: .venv/bin/python tests/test_acceptance_safety.py
+"""
+import os
+import sys
+from unittest import mock
+
+sys.path.insert(0, os.path.dirname(__file__))
+import test_acceptance as acceptance # noqa: E402
+
+
+def test_import_does_not_start_a_server():
+ assert acceptance.BASE is None
+ assert acceptance.ROOT is None
+
+
+def test_child_env_removes_case_and_provider_settings():
+ parent = {
+ "CASE_HOME": "/real/vault",
+ "CASE_TOKEN": "real-token",
+ "CASE_DOCKER_NETWORK": "case-desks",
+ "DESK_DEBUG": "1",
+ "OPENAI_API_KEY": "provider-key",
+ "ANTHROPIC_API_KEY": "provider-key",
+ "CASE_NTFY_TOKEN": "notify-token",
+ "DOCKER_HOST": "unix:///tmp/docker.sock",
+ "DOCKER_CERT_PATH": "/tmp/docker-certs",
+ "PATH": "/bin",
+ }
+ env = acceptance.child_env(parent, "/tmp/test-vault", 43123, "test-token", "test-image")
+ assert env["CASE_HOME"] == "/tmp/test-vault"
+ assert env["CASE_PORT"] == "43123"
+ assert env["CASE_TOKEN"] == "test-token"
+ assert env["CASE_IMAGE"] == "test-image"
+ assert env["CASE_BIND"] == "127.0.0.1"
+ assert "CASE_DOCKER_NETWORK" not in env
+ assert "DESK_DEBUG" not in env
+ assert "OPENAI_API_KEY" not in env
+ assert "ANTHROPIC_API_KEY" not in env
+ assert "CASE_NTFY_TOKEN" not in env
+ assert env["DOCKER_HOST"] == "unix:///tmp/docker.sock"
+ assert env["DOCKER_CERT_PATH"] == "/tmp/docker-certs"
+
+
+def test_event_stream_is_not_consumed_by_response_capture():
+ class Response:
+ headers = {"content-type": "text/event-stream"}
+
+ @property
+ def text(self):
+ raise AssertionError("stream consumed before the listener could read it")
+
+ response = Response()
+ with mock.patch.object(acceptance, "BASE", "http://127.0.0.1:43123/v1"), \
+ mock.patch.object(acceptance, "CASE_TOKEN", "test-token"), \
+ mock.patch.object(acceptance.requests, "request", return_value=response) as request:
+ assert acceptance.api("GET", "/events", stream=True) is response
+ assert request.call_args.kwargs["headers"]["Authorization"] == "Bearer test-token"
+ assert request.call_args.kwargs["stream"] is True
+
+
+def test_cleanup_only_deletes_created_ids():
+ calls = []
+
+ class Response:
+ status_code = 204
+ text = ""
+
+ def fake_api(method, path):
+ calls.append((method, path))
+ return Response()
+
+ acceptance.cleanup_owned(fake_api, ("c_owned_a", "c_owned_b"), keep_id="c_owned_b")
+ assert calls == [("DELETE", "/computers/c_owned_a")]
+
+
+def test_cleanup_continues_after_a_delete_failure():
+ calls = []
+
+ class Response:
+ status_code = 204
+ text = ""
+
+ def fake_api(method, path):
+ calls.append((method, path))
+ if path.endswith("c_owned_a"):
+ raise RuntimeError("connection lost")
+ return Response()
+
+ failures = acceptance.cleanup_owned(fake_api, ("c_owned_a", "c_owned_b"))
+ assert calls == [
+ ("DELETE", "/computers/c_owned_a"),
+ ("DELETE", "/computers/c_owned_b"),
+ ]
+ assert failures == ["c_owned_a: connection lost"]
+
+
+def test_stop_owned_process_terminates_and_waits():
+ class Process:
+ def __init__(self):
+ self.calls = []
+
+ def poll(self):
+ self.calls.append("poll")
+ return None
+
+ def terminate(self):
+ self.calls.append("terminate")
+
+ def wait(self, timeout):
+ self.calls.append(("wait", timeout))
+
+ proc = Process()
+ assert acceptance.stop_owned_process(proc) == []
+ assert proc.calls == ["poll", "terminate", ("wait", 20)]
+
+
+def test_failures_retain_the_scratch_directory():
+ assert acceptance.retain_scratch(None, True, False, [], []) is True
+ assert acceptance.retain_scratch(None, False, True, [], []) is True
+ assert acceptance.retain_scratch(None, False, False, ["delete failed"], []) is True
+ assert acceptance.retain_scratch(None, False, False, [], ["stop failed"]) is True
+ assert acceptance.retain_scratch(None, False, False, [], []) is False
+
+
+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")
diff --git a/tests/test_assist.py b/tests/test_assist.py
index 0feaf27..1c10f9a 100644
--- a/tests/test_assist.py
+++ b/tests/test_assist.py
@@ -226,17 +226,17 @@ def test_resolve_get_exchanges_then_replay_needs_cookie():
_cleanup()
_pending("h_otp", "otp")
raw, _ = assist.mint_assist_token("h_otp")
- handoff, set_sess = assist.resolve(raw, cookie_header="")
- assert handoff["id"] == "h_otp" and set_sess
+ view, set_sess = assist.resolve_view(raw, cookie_header="")
+ assert view["handoff"]["id"] == "h_otp" and set_sess
# burned exchange alone fails
try:
- assist.resolve(raw, cookie_header="")
+ assist.resolve_view(raw, cookie_header="")
assert False, "burned exchange without cookie must fail"
except ApiError as e:
assert e.status == 410
# same URL + session cookie still opens the page
- handoff2, set2 = assist.resolve(raw, cookie_header=f"case_assist={set_sess}")
- assert handoff2["id"] == "h_otp" and set2 is None
+ view2, set2 = assist.resolve_view(raw, cookie_header=f"case_assist={set_sess}")
+ assert view2["handoff"]["id"] == "h_otp" and set2 is None
# ---- Wave 3: dynamic phases, state shape, open_url policy, attempt scope ----
@@ -384,12 +384,12 @@ def test_repeat_submit_on_terminal_attempt_renders_status_not_expired():
import cased
from fastapi.testclient import TestClient
- client = TestClient(cased.app, raise_server_exceptions=False)
+ client = TestClient(cased.app, base_url="http://127.0.0.1", raise_server_exceptions=False)
response = client.post(
f"/assist/{raw}/submit",
data={"value": "123456", "expected_revision": "1"},
cookies={assist.COOKIE: session},
- headers={"Origin": "http://testserver"})
+ headers={"Origin": "http://127.0.0.1"})
assert response.status_code == 200, response.text
assert "Signed in" in response.text
assert "Link expired" not in response.text
@@ -492,7 +492,7 @@ def test_first_click_of_fresh_link_sets_session_cookie_over_http():
raw, _ = assist.mint_assist_token("h_otp")
from fastapi.testclient import TestClient
- client = TestClient(cased.app, raise_server_exceptions=False)
+ client = TestClient(cased.app, base_url="http://127.0.0.1", raise_server_exceptions=False)
r = client.get(f"/assist/{raw}")
assert r.status_code == 200, r.text
set_cookie = r.headers.get("set-cookie") or ""
@@ -505,7 +505,7 @@ def test_first_click_of_fresh_link_sets_session_cookie_over_http():
assert r2.status_code == 200, r2.text
assert assist.COOKIE + "=" not in (r2.headers.get("set-cookie") or "")
# Burned token, no cookie → 410.
- bare = TestClient(cased.app, raise_server_exceptions=False)
+ bare = TestClient(cased.app, base_url="http://127.0.0.1", raise_server_exceptions=False)
assert bare.get(f"/assist/{raw}").status_code == 410
diff --git a/tests/test_auth_attempts.py b/tests/test_auth_attempts.py
index 0b5d7aa..5e5c8a6 100644
--- a/tests/test_auth_attempts.py
+++ b/tests/test_auth_attempts.py
@@ -314,6 +314,35 @@ def test_challenge_completion_does_not_record_success_until_prove():
adv.assert_called_once_with(a["id"])
+def test_totp_submit_carries_credential_domains():
+ _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")
+ with mock.patch("lifecycle.get_computer", return_value=COMP), \
+ mock.patch("deskclient.auth_submit_challenge", return_value={"ok": False}) as submit, \
+ mock.patch("deskclient.screenshot_b64", return_value=None):
+ auth_attempts.advance_attempt(a["id"], observation=_obs(
+ challenge_signals=["otp"], visible_fields={"code": True}))
+ assert submit.call_count == 1
+ assert submit.call_args.kwargs["domains"] == ["example.com"]
+ assert len(submit.call_args.kwargs["value"]) == 6
+
+
+def test_handoff_submit_carries_credential_domains():
+ _cleanup()
+ store.upsert_credential("c_1", "cred", "u", "secret", None, None, ["example.com"])
+ a = auth_attempts.start_attempt("c_1", "cred", "https://example.com/login")
+ store.insert_handoff("h_domains", "c_1", "otp", "enter code", None, "cred",
+ continuation="submit_value", attempt_id=a["id"])
+ with mock.patch.object(handoffs, "get_computer", return_value=COMP), \
+ mock.patch.object(handoffs, "auth_submit_challenge", return_value={"ok": False}) as submit:
+ row = handoffs.submit_handoff_value("h_domains", "123456")
+ submit.assert_called_once_with(COMP, "otp", value="123456", domains=["example.com"])
+ assert row["status"] == "pending"
+ assert store.get_handoff("h_domains")["answer"] is None
+
+
def test_bad_code_stays_pending_same_challenge():
_cleanup()
a = auth_attempts.start_attempt(
@@ -387,7 +416,7 @@ def test_malformed_proof_spec_never_authenticates():
"c_1", "github", "https://example.com/login",
proof_spec=bad, idempotency_key=f"bad-{i}")
assert a["proof_level"] == "heuristic", (bad, a)
- assert auth_attempts._check_proof(
+ assert auth_attempts.check_proof(
COMP, bad, observation={"href": "https://evil.invalid/"}) is False
with mock.patch("lifecycle.get_computer", return_value=COMP), \
mock.patch("deskclient.observe_auth",
diff --git a/tests/test_captcha.py b/tests/test_captcha.py
index 5315871..f27992f 100644
--- a/tests/test_captcha.py
+++ b/tests/test_captcha.py
@@ -20,6 +20,7 @@
os.environ.pop(_k, None)
import captcha # noqa: E402
+from store import store # noqa: E402
# ---- enabled() ----
@@ -1027,7 +1028,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("auth_attempts._check_proof", return_value=True), \
+ mock.patch("auth_attempts.check_proof", return_value=True), \
mock.patch("cased.store.record_credential_result") as rec, \
mock.patch("cased.events.emit"):
out = cased.login("c_1", {
diff --git a/tests/test_deskd.py b/tests/test_deskd.py
index 277f160..703e442 100644
--- a/tests/test_deskd.py
+++ b/tests/test_deskd.py
@@ -24,10 +24,12 @@ def test_vis_rejects_opacity_zero_and_zero_size():
assert "opacity" in deskd.VIS
assert "getBoundingClientRect" in deskd.VIS
assert "visibility" in deskd.VIS
- # Observe uses its own vis helper; keep the same opacity/rect guards there.
+ # Observe is built from VIS and the shared selectors, not a retyped copy.
assert "opacity" in deskd.OBSERVE_AUTH_JS
assert "getBoundingClientRect" in deskd.OBSERVE_AUTH_JS
assert "visibility" in deskd.OBSERVE_AUTH_JS
+ assert deskd.USER_SEL in deskd.OBSERVE_AUTH_JS
+ assert deskd.CODE_SEL in deskd.OBSERVE_AUTH_JS
def test_focus_helpers_select_before_insert():
@@ -277,7 +279,8 @@ def cmd(self, *a, **k):
def _run_fill_login_form(tab):
- cred = {"username": "ava", "secret": "s3cret", "name": "x"}
+ cred = {"username": "ava", "secret": "s3cret", "name": "x",
+ "domains": [deskd.urlparse(tab._href).hostname]}
with mock.patch.object(deskd, "fill") as fill, \
mock.patch.object(deskd, "press_enter") as press, \
mock.patch.object(deskd, "settle"):
@@ -322,6 +325,90 @@ def test_fill_login_form_fails_when_identifier_step_never_moved():
assert reason == "password field never appeared", reason
+class OneStepTab:
+ def __init__(self, href):
+ self.href = href
+
+ def js(self, expr):
+ if expr == deskd.HAS_FIELDS:
+ return {"user": True, "pass": True}
+ if expr == "location.href":
+ return self.href
+ return None
+
+ def cmd(self, *a, **k):
+ return {}
+
+
+def test_fill_login_form_allows_https_and_loopback_origins():
+ for href, domain in (("https://site.com/login", "site.com"),
+ ("http://localhost/login", "localhost"),
+ ("http://127.0.0.1/login", "127.0.0.1"),
+ ("http://[::1]/login", "::1")):
+ tab = OneStepTab(href)
+ cred = {"username": "ava", "secret": "s3cret", "domains": [domain]}
+ with mock.patch.object(deskd, "fill") as fill, \
+ mock.patch.object(deskd, "press_enter"), \
+ mock.patch.object(deskd, "settle"):
+ assert deskd.fill_login_form(tab, cred) is None
+ assert fill.call_count == 2
+
+
+def test_fill_login_form_rechecks_foreign_https_origin_before_second_step_password():
+ tab = TwoStepNoPasswordTab(href="https://site.com/login", user_after=False)
+ cred = {"username": "ava", "secret": "s3cret", "domains": ["site.com"]}
+ original_js = tab.js
+
+ def js(expr):
+ if expr == deskd.HAS_FIELDS and tab.submitted:
+ return {"user": False, "pass": True}
+ if expr == "location.href" and tab.submitted:
+ return "https://evil.example/login?token=topsecret"
+ return original_js(expr)
+
+ tab.js = js
+ with mock.patch.object(deskd, "fill") as fill, \
+ mock.patch.object(deskd, "settle"), \
+ mock.patch.object(deskd, "press_enter") as press:
+ press.side_effect = lambda *_a, **_k: setattr(tab, "submitted", True)
+ reason = deskd.fill_login_form(tab, cred)
+ assert reason == "page origin 'evil.example' not in credential domains"
+ fill.assert_called_once_with(tab, deskd.FOCUS_USER, "ava")
+
+
+def test_fill_login_form_rechecks_same_domain_http_before_second_step_password():
+ tab = TwoStepNoPasswordTab(href="https://site.com/login", user_after=False)
+ cred = {"username": "ava", "secret": "s3cret", "domains": ["site.com"]}
+ original_js = tab.js
+
+ def js(expr):
+ if expr == deskd.HAS_FIELDS and tab.submitted:
+ return {"user": False, "pass": True}
+ if expr == "location.href" and tab.submitted:
+ return "http://site.com/login?token=topsecret"
+ return original_js(expr)
+
+ tab.js = js
+ with mock.patch.object(deskd, "fill") as fill, \
+ mock.patch.object(deskd, "settle"), \
+ mock.patch.object(deskd, "press_enter") as press:
+ press.side_effect = lambda *_a, **_k: setattr(tab, "submitted", True)
+ reason = deskd.fill_login_form(tab, cred)
+ assert reason == "page origin scheme 'http' on host 'site.com' is not HTTPS"
+ assert "topsecret" not in reason
+ fill.assert_called_once_with(tab, deskd.FOCUS_USER, "ava")
+
+
+def test_classify_rechecks_origin_before_totp_fill():
+ cred = {"name": "x", "domains": ["site.com"], "totp_seed": "GEZDGNBVGY3TQOJQ"}
+ tab = FakeTab(text="Enter the verification code", href="http://site.com/login")
+ with mock.patch.object(deskd, "fill") as fill:
+ result = deskd.classify(tab, cred)
+ assert result == {"status": "failed",
+ "reason": "page origin scheme 'http' on host 'site.com' is not HTTPS"}
+ fill.assert_not_called()
+
+
def test_finalize_auth_observation_caps_page_state_and_signals():
raw = {
"href": "https://example.com/login",
@@ -455,6 +542,210 @@ def test_capture_step_getResponseBody_error_is_visible():
assert buf[0]["error"] == "No data found for resource" and "body" not in buf[0]
+# ---- the injection gate and /file scoping, over the real ASGI app ----
+
+import tempfile # noqa: E402
+
+from fastapi.testclient import TestClient # noqa: E402
+
+H = {"Authorization": "Bearer test"}
+
+
+def _client():
+ return TestClient(deskd.app, raise_server_exceptions=False)
+
+
+def test_no_bearer_is_401():
+ assert _client().get("/health").status_code == 401
+ assert _client().get("/health", headers=H).status_code == 200
+
+
+def test_injection_gates_screenshot_exec_and_file():
+ deskd.state["injecting"] = True
+ try:
+ c = _client()
+ for r in (c.get("/screenshot", headers=H),
+ c.post("/exec", headers=H, json={"command": "id"}),
+ c.get("/file", headers=H, params={"path": "/home/agent/x"})):
+ assert r.status_code == 423
+ assert r.json()["error"]["code"] == "credential_injection"
+ finally:
+ deskd.state["injecting"] = False
+
+
+def test_file_get_rejects_paths_outside_home():
+ c = _client()
+ assert c.get("/file", headers=H, params={"path": "/etc/passwd"}).status_code == 400
+ esc = c.get("/file", headers=H, params={"path": "/home/agent/../../etc/passwd"})
+ assert esc.status_code == 400 # realpath resolves .. before the check
+ assert esc.json()["error"]["code"] == "bad_path"
+
+
+def test_file_put_get_roundtrip_under_home():
+ with tempfile.TemporaryDirectory() as td:
+ home = os.path.realpath(td)
+ with mock.patch.object(deskd, "HOME", home):
+ c = _client()
+ p = f"{home}/sub/note.txt"
+ put = c.put("/file", headers=H, params={"path": p}, content=b"hello")
+ assert put.status_code == 201 and put.json()["bytes"] == 5
+ got = c.get("/file", headers=H, params={"path": p})
+ assert got.status_code == 200 and got.content == b"hello"
+
+
+def test_file_put_rejects_oversized_content_length():
+ with tempfile.TemporaryDirectory() as td:
+ home = os.path.realpath(td)
+ with mock.patch.object(deskd, "HOME", home):
+ r = _client().put("/file", headers={**H, "content-length": "99999999"},
+ params={"path": f"{home}/big"}, content=b"x")
+ assert r.status_code == 413
+ assert not os.path.exists(f"{home}/big") # rejected before any write
+
+
+def test_file_put_streams_within_limit_without_content_length():
+ with tempfile.TemporaryDirectory() as td:
+ home = os.path.realpath(td)
+ with mock.patch.object(deskd, "HOME", home):
+ chunks = iter((b"hello", b" world"))
+ r = _client().put("/file", headers=H, params={"path": f"{home}/chunked"},
+ content=chunks)
+ assert "content-length" not in r.request.headers
+ assert r.status_code == 201 and r.json()["bytes"] == 11
+ with open(f"{home}/chunked", "rb") as f:
+ assert f.read() == b"hello world"
+
+
+def test_file_put_rejects_oversized_stream_without_content_length():
+ with tempfile.TemporaryDirectory() as td:
+ home = os.path.realpath(td)
+ with mock.patch.object(deskd, "HOME", home):
+ chunks = iter((b"x" * deskd.FILE_MAX, b"y"))
+ r = _client().put("/file", headers=H, params={"path": f"{home}/big"},
+ content=chunks)
+ assert "content-length" not in r.request.headers
+ assert r.status_code == 413
+ assert not os.path.exists(f"{home}/big")
+
+
+# ---- login clears the typed password before the injection gate drops ----
+
+class RecordingTab:
+ """Records every js() with the gate state at that moment."""
+
+ def __init__(self):
+ self.js_calls = []
+
+ def js(self, expr):
+ self.js_calls.append((expr, deskd.state["injecting"]))
+ if expr == "location.href":
+ return "https://site.com/login"
+ return None
+
+ def cmd(self, *a, **k):
+ return {}
+
+ def close(self):
+ pass
+
+
+def test_login_clears_password_field_while_still_gated():
+ # A failed submit leaves the secret in the DOM; clearing it after injecting
+ # goes False would expose it to /screenshot and /eval in between.
+ tab = RecordingTab()
+ with mock.patch.object(deskd, "Tab", lambda: tab), \
+ mock.patch.object(deskd, "navigate"), \
+ mock.patch.object(deskd, "domain_ok", return_value=True), \
+ mock.patch.object(deskd, "fill_login_form", return_value=None), \
+ mock.patch.object(deskd, "wait_post_submit"), \
+ mock.patch.object(deskd, "classify", return_value={"status": "success"}):
+ out = deskd.login({"credential": {"name": "x", "domains": ["site.com"]},
+ "url": "https://site.com/login"})
+ assert out == {"status": "success"}, out
+ assert (deskd.CLEAR_PASS, True) in tab.js_calls, tab.js_calls
+ assert deskd.state["injecting"] is False
+
+
+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
+ with mock.patch.object(deskd, "Tab", lambda: tab), \
+ mock.patch.object(deskd, "navigate"), \
+ mock.patch.object(deskd, "fill_login_form") as fill_form:
+ out = deskd.login({"credential": {"name": "x", "domains": ["site.com"]},
+ "url": "https://site.com/login"})
+ assert out.status_code == 400
+ assert out.body == b'{"error":{"code":"domain_mismatch","message":"page origin scheme \'http\' on host \'site.com\' is not HTTPS"}}'
+ fill_form.assert_not_called()
+
+
+def test_login_resume_rechecks_origin_before_otp_fill():
+ tab = RecordingTab()
+ tab.js = lambda expr: "http://site.com/login" if expr == "location.href" else 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") as apply:
+ out = deskd.login_resume({"value": "123456"})
+ assert out == {"status": "failed",
+ "reason": "page origin scheme 'http' on host 'site.com' is not HTTPS"}
+ apply.assert_not_called()
+
+
+def test_login_resume_rechecks_nonapproval_code_path_origin():
+ tab = RecordingTab()
+ tab.js = lambda expr: "https://evil.example/login?token=topsecret" if expr == "location.href" else None
+ deskd.state["login"] = {"kind": "captcha", "cred_name": "x", "domains": ["site.com"], "at": 0}
+ with mock.patch.object(deskd, "Tab", lambda: tab), \
+ mock.patch.object(deskd, "apply_challenge_action") as apply:
+ out = deskd.login_resume({"value": "123456"})
+ assert out == {"status": "failed", "reason": "page origin 'evil.example' not in credential domains"}
+ assert "topsecret" not in out["reason"]
+ apply.assert_not_called()
+
+
+class ChallengeTab:
+ def __init__(self, href):
+ self.href = href
+
+ def js(self, expr):
+ if expr == "location.href":
+ return self.href
+ return None
+
+ def close(self):
+ pass
+
+
+def test_auth_submit_challenge_requires_domains_for_code():
+ with mock.patch.object(deskd, "Tab") as tab:
+ out = deskd.auth_submit_challenge({"kind": "otp", "value": "123456"})
+ assert out.status_code == 400
+ assert out.body == b'{"error":{"code":"bad_request","message":"body needs \'domains\' for otp/code"}}'
+ tab.assert_not_called()
+
+
+def test_auth_submit_challenge_allows_https_and_loopback_code_origins():
+ for href, domain in (("https://site.com/challenge", "site.com"),
+ ("http://localhost/challenge", "localhost"),
+ ("http://127.0.0.1/challenge", "127.0.0.1"),
+ ("http://[::1]/challenge", "::1")):
+ with mock.patch.object(deskd, "Tab", lambda: ChallengeTab(href)), \
+ mock.patch.object(deskd, "apply_challenge_action", return_value=None) as apply:
+ out = deskd.auth_submit_challenge(
+ {"kind": "otp", "value": "123456", "domains": [domain]})
+ assert out == {"ok": True}
+ apply.assert_called_once()
+
+
+def test_auth_submit_challenge_rejects_foreign_https_without_query_secret():
+ with mock.patch.object(deskd, "Tab", lambda: ChallengeTab("https://evil.example/code?token=topsecret")), \
+ mock.patch.object(deskd, "apply_challenge_action") as apply:
+ out = deskd.auth_submit_challenge(
+ {"kind": "code", "value": "123456", "domains": ["site.com"]})
+ assert out == {"ok": False, "reason": "page origin 'evil.example' not in credential domains"}
+ assert "topsecret" not in out["reason"]
+ apply.assert_not_called()
+
if __name__ == "__main__":
for name, fn in sorted(globals().items()):
if name.startswith("test_"):
diff --git a/tests/test_dockerd.py b/tests/test_dockerd.py
index 9de2397..36528c6 100644
--- a/tests/test_dockerd.py
+++ b/tests/test_dockerd.py
@@ -27,6 +27,14 @@ def test_host_mode_dials_loopback_and_publishes_ports():
assert kw["name"] == "case-c_ab"
+def test_container_limits_cover_swap_and_pids():
+ # mem_limit alone lets a desktop swap past its budget, and a fork bomb in the
+ # browser takes the host's pid table with it.
+ kw = dockerd.container_run_kwargs("c_ab", 1, 2048, "vol", "tok")
+ assert kw["pids_limit"] == 512
+ assert kw["memswap_limit"] == kw["mem_limit"] == "2048m"
+
+
def test_compose_mode_uses_container_dns_and_no_host_ports():
_net("case")
try:
@@ -143,6 +151,7 @@ def remove_container(self, name, force=False):
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()
diff --git a/tests/test_gates.py b/tests/test_gates.py
new file mode 100644
index 0000000..65887f4
--- /dev/null
+++ b/tests/test_gates.py
@@ -0,0 +1,202 @@
+# SPDX-License-Identifier: MIT
+"""The doors into cased: Host/Origin, the token-in-URL routes, middleware order.
+Run: .venv/bin/python tests/test_gates.py"""
+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"
+from fastapi.testclient import TestClient # noqa: E402
+
+import cased # noqa: E402
+
+
+def _client():
+ # base_url, not the TestClient default: "testserver" is not a name a browser
+ # could reach us by, so browser_ok rejects it exactly like a rebinding host.
+ return TestClient(cased.app, base_url="http://127.0.0.1", raise_server_exceptions=False)
+
+
+def _tokened(fn):
+ os.environ["CASE_TOKEN"] = "share-me"
+ try:
+ fn()
+ finally:
+ os.environ.pop("CASE_TOKEN", None)
+
+
+def test_untokened_box_still_checks_the_host():
+ # DNS rebinding: evil.example resolves to 127.0.0.1, the browser sends its own
+ # Host, and every same-origin rule the page relies on is satisfied.
+ os.environ.pop("CASE_TOKEN", None)
+ c = _client()
+ assert c.get("/v1/computers", headers={"Host": "evil.example"}).status_code == 403
+ assert c.get("/v1/computers").status_code == 200
+
+
+def test_untokened_box_rejects_a_foreign_origin():
+ os.environ.pop("CASE_TOKEN", None)
+ c = _client()
+ r = c.post("/v1/computers", json={}, headers={"Origin": "https://evil.example"})
+ assert r.status_code == 403, r.text
+ assert r.json()["error"]["code"] == "bad_host"
+
+
+def test_token_guards_the_api_but_not_the_human_doors():
+ def check():
+ c = _client()
+ assert c.get("/v1/computers").status_code == 401
+ assert c.get("/fill/nope").status_code != 401 # token is in the URL
+ assert c.get("/assist/nope").status_code == 410
+ _tokened(check)
+
+
+def test_health_says_only_ok_without_the_bearer():
+ def check():
+ c = _client()
+ assert set(c.get("/health").json()) == {"ok"}
+ assert "computers" in c.get("/health", headers={"Authorization": "Bearer share-me"}).json()
+ _tokened(check)
+
+
+def test_unauthorized_calls_leave_no_audit_line():
+ # audit_mw is registered before token_guard so the guard runs outermost; the
+ # other order logs (and keeps) whatever an unauthenticated caller sends.
+ def check():
+ shutil.rmtree(cased.AUDIT_DIR, ignore_errors=True)
+ c = _client()
+ assert c.get("/v1/computers").status_code == 401
+ assert glob.glob(os.path.join(cased.AUDIT_DIR, "*.jsonl")) == []
+ assert c.get("/v1/computers", headers={"Authorization": "Bearer share-me"}
+ ).status_code == 200
+ assert glob.glob(os.path.join(cased.AUDIT_DIR, "*.jsonl")) # still auditing
+ _tokened(check)
+
+
+def test_oversized_upload_is_refused_before_the_desktop_wakes():
+ # A declared oversize body is rejected before the desktop is touched.
+ os.environ.pop("CASE_TOKEN", None)
+ r = _client().put("/v1/computers/c_x/files?path=/x", content=b"",
+ headers={"Content-Length": "9999999"})
+ assert r.status_code == 413, r.text
+
+
+def test_streamed_upload_counts_bytes_without_content_length():
+ os.environ.pop("CASE_TOKEN", None)
+ with mock.patch.object(cased, "_file_put", return_value={"bytes": cased.FILE_MAX}) as put:
+ content = iter([b"x" * (1024 * 1024)] * 9)
+ r = _client().put("/v1/computers/c_x/files?path=/home/agent/x", content=content)
+ assert "content-length" not in r.request.headers
+ assert r.status_code == 413, r.text
+ put.assert_not_called()
+
+ content = iter([b"x" * (1024 * 1024)] * 8)
+ r = _client().put("/v1/computers/c_x/files?path=/home/agent/x", content=content)
+ assert r.status_code == 201, r.text
+ assert len(put.call_args.args[2]) == cased.FILE_MAX
+
+
+def test_login_url_must_be_https():
+ # login posts the vault's plaintext to the desktop; over http:// the target site
+ # sees it on the wire. Loopback is the exception: it never leaves the desktop,
+ # and the acceptance suite logs into a test site it runs there.
+ from unittest import mock
+ os.environ.pop("CASE_TOKEN", None)
+ with mock.patch.object(cased.lifecycle, "ensure_running", return_value={"id": "c_x"}), \
+ mock.patch.object(cased.store, "credential_material", return_value={"name": "a"}):
+ c = _client()
+ bad = c.post("/v1/computers/c_x/login", json={"credential": "a", "url": "http://x"})
+ assert bad.status_code == 400, bad.text
+ assert "https" in bad.json()["error"]["message"]
+ for ok in ("http://localhost:8088/plain", "http://127.0.0.1:8088/plain"):
+ r = c.post("/v1/computers/c_x/login", json={"credential": "a", "url": ok})
+ assert r.status_code != 400, (ok, r.text)
+
+
+def test_live_relay_needs_the_bearer_on_both_halves():
+ # HTTP middleware never sees a websocket scope, so the socket has to check the
+ # token itself or the desktop is one upgrade away from anyone.
+ from starlette.websockets import WebSocketDisconnect
+
+ def check():
+ c = _client()
+ assert c.get("/v1/computers/c_x/live/vnc.html").status_code == 401
+ try:
+ with c.websocket_connect("ws://127.0.0.1/v1/computers/c_x/live/websockify"):
+ assert False, "socket accepted without a bearer"
+ except WebSocketDisconnect as e:
+ assert e.code == 1008, e.code
+ _tokened(check)
+
+
+def test_live_upstream_dials_the_desk_with_websockify_basic_auth():
+ import base64
+ os.environ.pop("CASE_DOCKER_NETWORK", None)
+ base, headers = cased.live_upstream({"id": "c_x", "vnc_port": 32771, "desk_token": "t0k"})
+ assert base == "http://127.0.0.1:32771"
+ assert headers == {"Authorization": "Basic " + base64.b64encode(b"agent:t0k").decode()}
+ # the relay forwards the tail of the URL verbatim, so traversal has to die here
+ assert cased.live_path_ok("vnc.html")
+ assert not cased.live_path_ok("../../etc/passwd")
+ assert not cased.live_path_ok("%2e%2e/x")
+
+
+def test_live_socket_rejects_foreign_browsers_before_dialing():
+ from starlette.websockets import WebSocketDisconnect
+ os.environ.pop("CASE_TOKEN", None)
+ for headers in ({"Host": "evil.example"}, {"Origin": "https://evil.example"},
+ {"Origin": "null"}):
+ with mock.patch.object(cased.lifecycle, "ensure_running") as ensure:
+ try:
+ with _client().websocket_connect("ws://127.0.0.1/v1/computers/c_x/live/websockify",
+ headers=headers):
+ assert False, "foreign browser accepted"
+ except WebSocketDisconnect as e:
+ assert e.code == 1008, e.code
+ ensure.assert_not_called()
+
+
+def test_live_socket_relays_for_allowed_browsers_and_bearers():
+ import asyncio
+ from contextlib import asynccontextmanager
+
+ class Upstream:
+ subprotocol = None
+
+ def __init__(self):
+ self.closed = asyncio.Event()
+
+ async def close(self):
+ self.closed.set()
+
+ async def __aiter__(self):
+ yield b"RFB 003.008\n"
+ await self.closed.wait()
+
+ @asynccontextmanager
+ async def connect(*args, **kwargs):
+ yield Upstream()
+
+ row = {"id": "c_x", "vnc_port": 32771, "desk_token": "test"}
+ cases = [("", {}), ("", {"Origin": "http://localhost:4174"}),
+ ("share-me", {"Authorization": "Bearer share-me", "Origin": "https://client.example"})]
+ for token, headers in cases:
+ with mock.patch.dict(os.environ, {"CASE_TOKEN": token, "CASE_DOCKER_NETWORK": ""}), \
+ mock.patch.object(cased.lifecycle, "ensure_running", return_value=row), \
+ mock.patch.object(cased, "ws_connect", side_effect=connect):
+ with _client().websocket_connect("ws://127.0.0.1/v1/computers/c_x/live/websockify",
+ headers=headers) as ws:
+ assert ws.receive_bytes() == b"RFB 003.008\n"
+
+
+if __name__ == "__main__":
+ for name, fn in sorted(globals().items()):
+ if name.startswith("test_"):
+ fn()
+ print("ok", name)
+ print("PASS")
diff --git a/tests/test_handoffs.py b/tests/test_handoffs.py
index bead0ca..3936223 100644
--- a/tests/test_handoffs.py
+++ b/tests/test_handoffs.py
@@ -37,6 +37,7 @@ def _mk(*a, **kw):
the returned shape, not on persistence, and must not leave state for the next run."""
h = handoffs.create_handoff(*a, **kw)
store.delete_handoff(h["id"])
+ store.q("DELETE FROM assist_tokens")
handoffs.LOGIN_CTX.pop(h["id"], None)
return h
@@ -49,6 +50,39 @@ def _persist(hid, kind, prompt, login_credential=None, domain=None, **kw):
return store.get_handoff(hid)
+def test_only_approvals_carry_a_signed_answer_url():
+ _cleanup()
+ seen = []
+ handoffs.notifier = type("N", (), {"notify": lambda self, h, name: seen.append(h)})()
+ try:
+ with mock.patch.dict(os.environ, {"CASE_PUBLIC_HOST": "case.example.com"}):
+ _mk(ROW, "approval", "ok?")
+ _mk(ROW, "question", "who?")
+ hid = seen[0]["id"]
+ assert seen[0]["answer_url"] == f"https://case.example.com/answer/{hid}/{store.sign('answer:' + hid)}"
+ assert seen[1]["answer_url"] == ""
+ with mock.patch.dict(os.environ, {"CASE_PUBLIC_HOST": ""}):
+ _mk(ROW, "approval", "ok?")
+ assert seen[2]["answer_url"] == ""
+ finally:
+ handoffs.notifier = type("N", (), {"notify": lambda self, h, name: None})()
+ _cleanup()
+
+
+def test_expire_stale_fails_abandoned_auth_attempts():
+ import auth_attempts
+ _cleanup()
+ store.q("DELETE FROM auth_attempts WHERE computer_id='c_stale'")
+ try:
+ a = auth_attempts.start_attempt("c_stale", "github", "https://example.com/login")
+ with mock.patch.object(store, "stale_active_auth_attempts",
+ return_value=[{"id": a["id"]}]):
+ handoffs.expire_stale()
+ assert auth_attempts.get_attempt(a["id"])["status"] == "failed"
+ finally:
+ store.q("DELETE FROM auth_attempts WHERE computer_id='c_stale'")
+
+
def test_rebuild_login_ctx_recovers_pending_login_handoff():
# a login handoff persisted before a (simulated) restart, with the in-memory map wiped
_cleanup()
diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py
index 06a011e..c97aa60 100644
--- a/tests/test_lifecycle.py
+++ b/tests/test_lifecycle.py
@@ -5,7 +5,9 @@
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane"))
-os.environ.setdefault("CASE_HOME", "/tmp/case-lifecycle-test")
+# 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"
from errors import ApiError # noqa: E402
from lifecycle import can_transition, do_sleep, ensure_running # noqa: E402
from store import store # noqa: E402
@@ -155,10 +157,74 @@ def test_sleep_all_parks_awake_computers_and_survives_a_failure():
store.delete_computer(stuck)
+def test_wake_rebuilds_a_container_docker_no_longer_has():
+ # `docker rm` on a sleeping desktop: start_container 404s and the wake has to
+ # recreate around the volume. dockerd.NotFound did not exist, so this path
+ # raised AttributeError instead.
+ from unittest import mock
+ import deskclient
+ import dockerd
+ import lifecycle
+ cid = "c_unittest_wake_gone"
+ store.delete_computer(cid)
+ store.insert_computer(cid, "gone", "img", 1, 512, "vol-g", "tok-g")
+ store.set_state(cid, "asleep")
+ try:
+ with mock.patch.object(dockerd, "start_container", side_effect=dockerd.NotFound("x")), \
+ mock.patch.object(dockerd, "create_container") as create, \
+ mock.patch.object(dockerd, "get_container"), \
+ mock.patch.object(dockerd, "container_ports", return_value=(1, 2)), \
+ mock.patch.object(dockerd, "container_up", return_value=True), \
+ mock.patch.object(deskclient, "wait_desk"):
+ lifecycle.do_wake(cid)
+ create.assert_called_once_with(cid, 1.0, 512, "vol-g", "tok-g")
+ assert store.get_computer(cid)["state"] == "running"
+ finally:
+ store.delete_computer(cid)
+
+
+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"
+ shutil.rmtree(home, ignore_errors=True)
+ os.makedirs(home, mode=0o755) # the permissive dir an upgrade inherits
+ try:
+ Store(home)
+ assert os.stat(home).st_mode & 0o777 == 0o700
+ assert os.stat(os.path.join(home, "case.db")).st_mode & 0o777 == 0o600
+ finally:
+ shutil.rmtree(home, ignore_errors=True)
+
+
+def test_destroy_takes_the_schedules_with_it():
+ # An orphaned schedule keeps firing against a deleted computer, and every run
+ # fails on a row that is no longer there.
+ import lifecycle
+ cid, sid = "c_unittest_destroy_sched", "s_unittest_destroy_sched"
+ store.delete_computer(cid)
+ store.delete_schedule(sid)
+ real = lifecycle.dockerd.destroy_infra
+ lifecycle.dockerd.destroy_infra = lambda cid, volume: None
+ try:
+ store.insert_computer(cid, "doomed", "img", 1, 512, "vol-d", "tok-d")
+ store.set_state(cid, "running")
+ store.insert_schedule(sid, cid, "nightly", "do a thing", "cron", "0 3 * * *", 0, None)
+ lifecycle.destroy(cid)
+ assert store.get_schedule(sid) is None
+ finally:
+ lifecycle.dockerd.destroy_infra = real
+ store.delete_schedule(sid)
+ store.delete_computer(cid)
+
+
def test_health_exposes_awake_cap():
import cased
+ from types import SimpleNamespace
from config import MAX_RUNNING
- h = cased.health()
+ h = cased.health(SimpleNamespace(headers={})) # untokened box: every caller is trusted
assert h["ok"] is True
assert h["max_running"] == MAX_RUNNING
assert "running" in h
diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py
index 351b98d..05dbb9b 100644
--- a/tests/test_mcp_http.py
+++ b/tests/test_mcp_http.py
@@ -1,10 +1,12 @@
# SPDX-License-Identifier: MIT
-"""The remote door: case_mcp's HTTP mode must stay loopback-only and stateless,
-and stdio must stay the default. No Docker, no network.
+"""The remote door: case_mcp's HTTP mode defaults to loopback (compose overrides the
+bind and publishes 127.0.0.1) and stays stateless, and stdio must stay the default.
+No Docker, no network.
Run: .venv/bin/python tests/test_mcp_http.py"""
import importlib
import os
import sys
+import types
ROOT = os.path.join(os.path.dirname(__file__), "..")
sys.path.insert(0, os.path.join(ROOT, "mcp"))
@@ -22,7 +24,7 @@ def test_stdio_is_the_default():
assert _load().HTTP is False # unset env → every existing flow untouched
-def test_http_mode_binds_loopback_only():
+def test_http_mode_defaults_to_loopback():
m = _load(CASE_MCP_HTTP="1", CASE_MCP_PORT="8899")
assert m.HTTP is True
assert m.mcp.settings.host == "127.0.0.1" # default; compose overrides CASE_MCP_BIND
@@ -42,6 +44,38 @@ def test_http_app_serves_mcp_path():
assert "/mcp" in paths, paths
+class _Resp:
+ def __init__(self, status_code, body):
+ self.status_code, self._body = status_code, body
+
+ def json(self):
+ if isinstance(self._body, Exception):
+ raise self._body
+ return self._body
+
+
+def _failed_call(body, status_code=500):
+ """call() against a >=400 response; returns (message, chained exception)."""
+ m = _load()
+ m.requests = types.SimpleNamespace(request=lambda *a, **kw: _Resp(status_code, body))
+ try:
+ m.call("GET", "/computers")
+ except RuntimeError as e:
+ return str(e), e.__context__
+ assert False, "call() must raise on a >=400 response"
+
+
+def test_call_reports_the_cased_error():
+ msg, _ = _failed_call({"error": {"code": "not_found", "message": "no such computer"}})
+ assert msg == "not_found: no such computer", msg
+
+
+def test_call_falls_back_to_the_status_unchained():
+ msg, chained = _failed_call(ValueError("not json"), 502)
+ assert msg == "cased returned 502", msg
+ assert chained is None, chained # a chained decode error buries the status
+
+
def test_no_credential_write_tool():
# security invariant: secrets enter via `case cred add` only, never a tool call
m = _load()
diff --git a/tests/test_notify.py b/tests/test_notify.py
index 415b62a..70e008b 100644
--- a/tests/test_notify.py
+++ b/tests/test_notify.py
@@ -36,7 +36,7 @@ def test_no_topic_means_warned_noop_not_a_crash():
def test_ntfy_notify_posts_to_the_topic():
- ntfy = notify.Ntfy("https://ntfy.sh", "topic-x", None, "http://127.0.0.1:8787/v1")
+ ntfy = notify.Ntfy("https://ntfy.sh", "topic-x", None)
done = threading.Event()
posted = {}
@@ -57,7 +57,7 @@ def fake_post(url, **kw):
def test_ntfy_notify_sends_bearer_token():
os.environ["CASE_NTFY_TOKEN"] = "secret-tok"
- ntfy = notify.Ntfy("https://ntfy.sh", "topic-x", None, "http://127.0.0.1:8787/v1")
+ ntfy = notify.Ntfy("https://ntfy.sh", "topic-x", None)
done = threading.Event()
posted = {}
@@ -78,14 +78,14 @@ def fake_post(url, **kw):
def test_same_topic_does_not_start_answer_listen():
- ntfy = notify.Ntfy("https://ntfy.sh", "same", "same", "http://127.0.0.1:8787/v1")
+ ntfy = notify.Ntfy("https://ntfy.sh", "same", "same")
with mock.patch.object(notify.threading, "Thread") as th:
ntfy.listen(lambda *a: None)
th.assert_not_called()
def test_push_marks_outbound():
- ntfy = notify.Ntfy("https://ntfy.sh", "topic-x", None, "http://127.0.0.1:8787/v1")
+ ntfy = notify.Ntfy("https://ntfy.sh", "topic-x", None)
done = threading.Event()
posted = {}
@@ -100,6 +100,63 @@ def fake_post(url, **kw):
assert posted["headers"].get("X-Tags") == "case-outbound"
+def _post_once(payload, name="box"):
+ ntfy = notify.Ntfy("https://ntfy.sh", "topic-x", None)
+ done = threading.Event()
+ posted = {}
+
+ def fake_post(url, **kw):
+ posted.update(kw.get("headers") or {})
+ done.set()
+ return mock.Mock(status_code=200)
+
+ with mock.patch.object(notify.requests, "post", side_effect=fake_post):
+ ntfy.notify(payload, name)
+ assert done.wait(2), "ntfy thread did not run"
+ return posted
+
+
+def test_multiline_prompt_is_flattened_into_the_header():
+ h = _post_once({"id": "h_1", "kind": "question", "screenshot": None,
+ "prompt": "line one\nline two\r\n line three"})
+ assert h["X-Message"] == "line one line two line three"
+
+
+def test_assist_url_becomes_the_click_action():
+ h = _post_once({"id": "h_1", "kind": "question", "prompt": "hi", "screenshot": None,
+ "assist_url": "https://acme.example/assist/tok"})
+ assert h["X-Click"] == "https://acme.example/assist/tok"
+ assert "X-Actions" not in h
+
+
+def test_approval_buttons_use_the_signed_answer_url():
+ h = _post_once({"id": "h_1", "kind": "approval", "prompt": "ok?", "screenshot": None,
+ "answer_url": "https://case.example.com/answer/h_1/sig"})
+ assert h["X-Actions"].count("https://case.example.com/answer/h_1/sig") == 2
+ assert h["X-Actions"].count("headers.Content-Type=application/json") == 2
+ assert "approve" in h["X-Actions"] and "deny" in h["X-Actions"]
+
+
+def test_no_answer_url_means_no_buttons():
+ h = _post_once({"id": "h_1", "kind": "approval", "prompt": "ok?", "screenshot": None})
+ assert "X-Actions" not in h
+
+
+def test_answer_token_ok_only_for_the_matching_signature():
+ import handoffs
+ from store import store
+ with mock.patch.object(store, "sign", lambda text: "sig:" + text):
+ assert handoffs.answer_token_ok("h_1", "sig:answer:h_1")
+ assert not handoffs.answer_token_ok("h_1", "sig:answer:h_2")
+ assert not handoffs.answer_token_ok("h_1", "")
+ assert not handoffs.answer_token_ok("h_1", None)
+ try:
+ handoffs.answer_by_token("h_1", "nope", "approve")
+ assert False, "bad token must not reach the handoff"
+ except handoffs.ApiError as e:
+ assert e.status == 404, e
+
+
def test_create_handoff_mints_assist_and_passes_url_to_notifier():
"""Integration: create_handoff → mint → notify payload carries assist_url."""
os.environ["CASE_PUBLIC_HOST"] = "acme.case.example"
diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py
index 82d7f5b..00abcb7 100644
--- a/tests/test_scheduler.py
+++ b/tests/test_scheduler.py
@@ -47,11 +47,21 @@ def test_daily_fires_at_requested_local_time():
def test_jitter_stays_bounded():
base = datetime.now(timezone.utc)
for _ in range(20):
- nxt = _dt(compute_next("interval", "0", 600))
+ nxt = _dt(compute_next("interval", "60", 600))
delta = (nxt - base).total_seconds()
# -1: compute_next stores whole seconds, so truncation can land up to
# 0.999s before `base` when the jitter draw is 0.
- assert -1 <= delta <= 600 + 5, delta
+ assert 60 - 1 <= delta <= 60 + 600 + 5, delta
+
+
+def test_sub_minute_interval_is_refused():
+ from errors import ApiError
+ for spec in ("0", "1", "59"):
+ try:
+ compute_next("interval", spec, 600)
+ assert False, spec
+ except ApiError as e:
+ assert e.status == 400 and "60 seconds" in e.message, (spec, e.message)
def test_bad_schedule_spec_raises_bad_request():
diff --git a/tests/test_session_keeper.py b/tests/test_session_keeper.py
index 5e0b40c..a879858 100644
--- a/tests/test_session_keeper.py
+++ b/tests/test_session_keeper.py
@@ -227,6 +227,33 @@ def test_tick_respects_cadence_and_skips_recent_live_session():
assert wakes == [], wakes
+def test_tick_is_not_reentrant():
+ session_keeper._TICK.acquire()
+ try:
+ with mock.patch.object(session_keeper, "_tick") as inner:
+ session_keeper.tick()
+ inner.assert_not_called()
+ finally:
+ session_keeper._TICK.release()
+ with mock.patch.object(session_keeper, "_tick") as inner:
+ session_keeper.tick() # lock released again
+ inner.assert_called_once()
+
+
+def test_tick_forgets_computers_that_no_longer_have_probes():
+ _cleanup()
+ _reset_keeper_clock()
+ session_keeper._last_probe_at["c_gone"] = 1.0
+ cid = _computer(state="asleep")
+ _cred(cid, proof_spec={"url_contains": "/a"})
+ with mock.patch.object(session_keeper, "do_wake"), \
+ mock.patch.object(session_keeper, "do_sleep"), \
+ mock.patch.object(session_keeper, "_probe_one_awake", return_value="ok"):
+ session_keeper.tick()
+ assert "c_gone" not in session_keeper._last_probe_at
+ assert cid in session_keeper._last_probe_at
+
+
if __name__ == "__main__":
for name, fn in sorted(globals().items()):
if name.startswith("test_"):
diff --git a/tests/test_store.py b/tests/test_store.py
new file mode 100644
index 0000000..bfabcf4
--- /dev/null
+++ b/tests/test_store.py
@@ -0,0 +1,44 @@
+# 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
+
+_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"))
+from store import store # noqa: E402
+
+
+def test_concurrent_reads_keep_their_rows_intact():
+ for i in range(2):
+ cid = f"c_read{i}"
+ store.insert_computer(cid, cid, "test", 1, 2048, "test-volume", "test-token")
+ store.insert_auth_attempt(f"a_read{i}", cid, "test", "https://example.com")
+ expected = {f"c_read{i}": dict(store.get_computer(f"c_read{i}")) for i in range(2)}
+ ready = threading.Barrier(16)
+
+ def read(worker):
+ ready.wait()
+ cid = f"c_read{worker % 2}"
+ for _ in range(250):
+ assert dict(store.get_computer(cid)) == expected[cid]
+ rows = store.all("SELECT * FROM computers ORDER BY id")
+ assert [dict(r) for r in rows] == list(expected.values())
+ attempts = store.stale_active_auth_attempts("9999-01-01T00:00:00Z")
+ assert sorted(a["id"] for a in attempts) == ["a_read0", "a_read1"]
+
+ with ThreadPoolExecutor(max_workers=16) as pool:
+ list(pool.map(read, range(16)))
+
+
+if __name__ == "__main__":
+ try:
+ test_concurrent_reads_keep_their_rows_intact()
+ print("test_store: ok")
+ finally:
+ store.db.close()
+ shutil.rmtree(_HOME)
diff --git a/tests/test_token.py b/tests/test_token.py
index 1f37b38..3e472a9 100644
--- a/tests/test_token.py
+++ b/tests/test_token.py
@@ -5,7 +5,9 @@
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "control-plane"))
-os.environ.setdefault("CASE_HOME", "/tmp/case-token-test")
+# 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 cased # noqa: E402
@@ -25,7 +27,7 @@ def test_requires_matching_bearer():
assert cased.bearer_ok("Bearer share-me") is True
assert cased.bearer_ok("bearer share-me") is True
assert cased.bearer_ok("Bearer share-me ") is True
- assert cased.bearer_ok("Bearer share-meX") is False # length mismatch, no throw
+ assert cased.bearer_ok("Bearer share-meX") is False
finally:
os.environ.pop("CASE_TOKEN", None)
diff --git a/web/package.json b/web/package.json
index 967637b..4840ec4 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_phone.mjs && node web-ui/test_ntfy.mjs && node web-ui/test_telegram.mjs"
+ "test": "node web-ui/test_serve.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"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.117.1",
diff --git a/web/web-ui/README.md b/web/web-ui/README.md
index 124bdc1..7d327f4 100644
--- a/web/web-ui/README.md
+++ b/web/web-ui/README.md
@@ -15,8 +15,9 @@ 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
-`http://cased:8787/v1` and `CASE_DOCKER_NETWORK=case` so `/live` proxies noVNC
-at `case-:6080` on the compose network.
+`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.
**Files view** uses `computer_exec` `find` (`/api/fs`) and cased `GET /files`
(`/api/file`).
diff --git a/web/web-ui/case-tools.mjs b/web/web-ui/case-tools.mjs
index 4656093..6f09a26 100644
--- a/web/web-ui/case-tools.mjs
+++ b/web/web-ui/case-tools.mjs
@@ -315,7 +315,7 @@ export function histToAnthropicMessages(items, { media = false } = {}) {
pendingResults = [];
};
for (const it of items || []) {
- if (it.shot) continue;
+ if (it.shot && !media) continue;
if (it.role === 'user' && it.content != null && !it.type) {
flushAssistant();
flushResults();
@@ -512,11 +512,10 @@ export async function anthropicToolLoop({
ok: !!toolResult.ok,
detail: clipJson(toolResult.error || toolResult.result || toolResult, 400),
});
- results.push({
- type: 'tool_result',
- tool_use_id: call.call_id || call.id,
- content: clipJson(toolResult),
- });
+ const { image_b64, ...rest } = toolResult;
+ const content = [{ type: 'text', text: clipJson(rest) }];
+ if (image_b64) content.push({ type: 'image', source: { type: 'base64', media_type: 'image/png', data: image_b64 } });
+ results.push({ type: 'tool_result', tool_use_id: call.call_id || call.id, content });
}
messages.push({ role: 'user', content: results });
}
diff --git a/web/web-ui/deploy.html b/web/web-ui/deploy.html
index f0748de..6e9fec0 100644
--- a/web/web-ui/deploy.html
+++ b/web/web-ui/deploy.html
@@ -7,9 +7,6 @@
Deploy · case
-
-
-