Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,17 @@ CASE_LOCAL=1
# Desktop resolution for new/woken computers (WxH or WxHxDEPTH).
# DESK_RESOLUTION=1280x800x24

# OpenAI key stays in the Drive page (x-openai-key). Do not put it here.

# Optional phone notifications for handoffs (2FA codes, approvals) via ntfy
# (https://ntfy.sh or self-hosted). Topic names are bearer secrets — pick long
# random ones. ANSWER_TOPIC lets you reply from the phone. Unset = disabled
# (handoffs still show up in Drive and the API).
# Browser Drive still takes the key per request (x-openai-key / x-anthropic-key).
# Box key is only for phone ntfy chat (CASE_NTFY_CHAT=1). Topic is a password:
# openssl rand -hex 32
# CASE_NTFY_CHAT=1
# CASE_DRIVE_PROVIDER=openai
# CASE_DRIVE_API_KEY=
# CASE_DRIVE_MODEL=
# CASE_NTFY_URL=https://ntfy.sh
# CASE_NTFY_TOPIC=
# CASE_NTFY_TOKEN=
# 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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ jobs:
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_nav.mjs
node web/web-ui/test_deploy.mjs

Expand Down
2 changes: 1 addition & 1 deletion LICENSE.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ license picker does not split by directory. The table above is authoritative.

## Trademark

**Case** and the Case logo are trademarks of Daemon Labs. Neither the AGPL nor
**Case** and the Case logo are trademarks of Case. Neither the AGPL nor
the MIT license grants any right to use them, and this file grants none either.

You may state accurately that your software is built on, derived from, or
Expand Down
2 changes: 1 addition & 1 deletion LICENSES/MIT.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Copyright (c) 2026 Case contributors
Copyright (c) 2026 Case

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
Expand Down
2 changes: 1 addition & 1 deletion NOTICE
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
Case
Copyright (c) 2026 Case contributors
Copyright (c) 2026 Case

This product includes software developed by third parties. Case does not
relicense any of it; each component remains under its own terms. This file
Expand Down
61 changes: 59 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,63 @@ host and are never copied onto the computer.
Phone notifications for 2FA/approvals (ntfy), CAPTCHA auto-solve, scheduled
runs: all optional, all documented in [.env.example](.env.example).

### Phone chat (optional)

Drive can take tasks from your phone through [ntfy](https://ntfy.sh). Off by
default. Nothing gets exposed: Drive dials out to the ntfy server and posts
replies back. Phone messages run through the same brain and `threads.json` as
the laptop UI, in a thread named `Phone`.

ntfy 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:

```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:

```
CASE_NTFY_CHAT=1
CASE_NTFY_URL=https://ntfy.sh # or your ntfy server
CASE_NTFY_TOPIC=<the value from openssl>
CASE_NTFY_TOKEN= # self-hosted ntfy auth only
CASE_DRIVE_PROVIDER=openai # or anthropic
CASE_DRIVE_API_KEY=
```

```bash
docker compose up -d ui
```

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/<topic>` as the URL. Add it to the home screen or run
it with Siri.
- Any machine: `curl -d "check my mail" ntfy.sh/<topic>`. Useful to test
the bridge before involving the phone.

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.

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.

This is a live channel, not a queue. If Drive was down when you sent
something, send it again. The API key sits in the box env for this feature;
the laptop Drive page still uses the key you paste in the page.

### Token hardening (optional)

Copy `.env.example` to `.env`, generate a token, and set `CASE_TOKEN` before
Expand All @@ -130,8 +187,8 @@ docker compose down
### 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, different
bookkeeping: computers you create one way are not listed by the other, and both
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.

### RAM budget
Expand Down
8 changes: 8 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ services:
CASE_NTFY_URL: ${CASE_NTFY_URL:-https://ntfy.sh}
CASE_NTFY_TOPIC: ${CASE_NTFY_TOPIC:-}
CASE_NTFY_ANSWER_TOPIC: ${CASE_NTFY_ANSWER_TOPIC:-}
CASE_NTFY_TOKEN: ${CASE_NTFY_TOKEN:-}
CASE_PUBLIC_HOST: ${CASE_PUBLIC_HOST:-}
# Optional CAPTCHA auto-solve (see .env.example)
CASE_DBC_AUTHTOKEN: ${CASE_DBC_AUTHTOKEN:-}
Expand Down Expand Up @@ -110,6 +111,13 @@ services:
CASE_HOME: /data
CASE_LOCAL: "1"
PORT: "4174"
CASE_NTFY_URL: ${CASE_NTFY_URL:-https://ntfy.sh}
CASE_NTFY_TOPIC: ${CASE_NTFY_TOPIC:-}
CASE_NTFY_TOKEN: ${CASE_NTFY_TOKEN:-}
CASE_NTFY_CHAT: ${CASE_NTFY_CHAT:-}
CASE_DRIVE_PROVIDER: ${CASE_DRIVE_PROVIDER:-}
CASE_DRIVE_API_KEY: ${CASE_DRIVE_API_KEY:-}
CASE_DRIVE_MODEL: ${CASE_DRIVE_MODEL:-}
volumes:
- ui-data:/data
ports:
Expand Down
39 changes: 34 additions & 5 deletions control-plane/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@
from config import API_BASE

log = logging.getLogger("cased.notify")
OUTBOUND_TAG = "case-outbound"


def _ntfy_token():
return (os.environ.get("CASE_NTFY_TOKEN") or "").strip()


def _auth_headers():
token = _ntfy_token()
return {"Authorization": f"Bearer {token}"} if token else {}


def _tags(ev):
raw = ev.get("tags") or []
if isinstance(raw, str):
return [x.strip() for x in raw.split(",") if x.strip()]
return [str(x) for x in raw]


class Ntfy:
Expand All @@ -29,6 +46,11 @@ def __init__(self, url, topic, answer_topic, api_base):
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:
log.warning("CASE_NTFY_ANSWER_TOPIC equals CASE_NTFY_TOPIC — answer listen disabled")

def _same_topic(self):
return bool(self.topic and self.answer_topic and self.topic == self.answer_topic)

def notify(self, handoff, computer_name):
if not self.topic:
Expand All @@ -38,9 +60,13 @@ def notify(self, handoff, computer_name):
def _send(self, h, computer_name):
try:
ascii_ = lambda s: (s or "").encode("ascii", "replace").decode()
tags = [OUTBOUND_TAG]
if h.get("id"):
tags.append(h["id"])
headers = {
**_auth_headers(),
"X-Title": ascii_(f"[Case] {h['kind']} — {computer_name}"),
"X-Tags": h["id"],
"X-Tags": ",".join(tags),
"X-Message": ascii_(h["prompt"])[:800],
}
if h["kind"] == "approval":
Expand All @@ -64,29 +90,32 @@ def _p():
try:
requests.post(f"{self.url}/{self.topic}",
data=(text or "").encode("ascii", "replace")[:1000],
headers={"X-Title": "Case run"}, timeout=15)
headers={**_auth_headers(), "X-Title": "Case run",
"X-Tags": OUTBOUND_TAG}, timeout=15)
except Exception as e:
log.warning("ntfy push failed: %s", e)
threading.Thread(target=_p, daemon=True).start()

def listen(self, on_answer):
"""Subscribe to the answer topic (SSE); messages are '{handoff_id} {value}' or a bare value."""
if not self.answer_topic:
if not self.answer_topic or self._same_topic():
return
threading.Thread(target=self._listen, args=(on_answer,), daemon=True).start()

def _listen(self, on_answer):
headers = _auth_headers()
while True:
try:
r = requests.get(f"{self.url}/{self.answer_topic}/sse", stream=True, timeout=(10, None))
r = requests.get(f"{self.url}/{self.answer_topic}/sse", stream=True,
headers=headers, timeout=(10, None))
for line in r.iter_lines():
if not line or not line.startswith(b"data: "):
continue
try:
ev = json.loads(line[6:])
except ValueError:
continue
if ev.get("event") != "message":
if ev.get("event") != "message" or OUTBOUND_TAG in _tags(ev):
continue
msg = (ev.get("message") or "").strip()
m = re.match(r"^(h_\w+)\s+(.+)$", msg, re.S)
Expand Down
46 changes: 46 additions & 0 deletions tests/test_notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,52 @@ def fake_post(url, **kw):
assert done.wait(2), "ntfy thread did not run"
assert posted["url"] == "https://ntfy.sh/topic-x"
assert "h_1" in posted["headers"].get("X-Tags", "")
assert "case-outbound" in posted["headers"].get("X-Tags", "")


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")
done = threading.Event()
posted = {}

def fake_post(url, **kw):
posted["headers"] = kw.get("headers")
done.set()
return mock.Mock(status_code=200)

try:
with mock.patch.object(notify.requests, "post", side_effect=fake_post):
ntfy.notify({"id": "h_1", "kind": "question", "prompt": "hi",
"screenshot": None}, "box")
assert done.wait(2), "ntfy thread did not run"
assert posted["headers"]["Authorization"] == "Bearer secret-tok"
assert "case-outbound" in posted["headers"]["X-Tags"]
finally:
os.environ.pop("CASE_NTFY_TOKEN", None)


def test_same_topic_does_not_start_answer_listen():
ntfy = notify.Ntfy("https://ntfy.sh", "same", "same", "http://127.0.0.1:8787/v1")
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")
done = threading.Event()
posted = {}

def fake_post(url, **kw):
posted["headers"] = kw.get("headers")
done.set()
return mock.Mock(status_code=200)

with mock.patch.object(notify.requests, "post", side_effect=fake_post):
ntfy.push("run finished ok")
assert done.wait(2), "ntfy thread did not run"
assert posted["headers"].get("X-Tags") == "case-outbound"


def test_create_handoff_mints_assist_and_passes_url_to_notifier():
Expand Down
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"start": "node web-ui/serve.mjs",
"test": "node web-ui/test_serve.mjs"
"test": "node web-ui/test_serve.mjs && node web-ui/test_ntfy.mjs"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.117.1",
Expand Down
7 changes: 7 additions & 0 deletions web/web-ui/case-tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,13 @@ export function chatAuth(headers = {}) {
return { provider: '', key: '' };
}

export function envDriveAuth(env = process.env) {
const key = String(env.CASE_DRIVE_API_KEY || '').trim();
const provider = String(env.CASE_DRIVE_PROVIDER || '').trim().toLowerCase();
if (!key || (provider !== 'openai' && provider !== 'anthropic')) return { provider: '', key: '' };
return { provider, key };
}

export function resolveChatModel(requested, provider) {
if (provider === 'anthropic') return ANTHROPIC_MODELS[requested] || 'claude-sonnet-4-6';
return MODELS[requested] || 'gpt-5.6-terra';
Expand Down
Loading
Loading