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. ![Case demo](.github/demo.gif) -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 - - -