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
13 changes: 10 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,19 @@ CASE_LOCAL=1
# DESK_RESOLUTION=1280x800x24

# 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
# The box key below is only for phone chat (Telegram or ntfy).
# CASE_DRIVE_PROVIDER=openai
# CASE_DRIVE_API_KEY=
# CASE_DRIVE_MODEL=

# Phone chat over Telegram: make a bot with @BotFather, paste its token, start
# the ui, send /start to the bot and it replies with the chat id to put here.
# CASE_TELEGRAM_TOKEN=
# CASE_TELEGRAM_CHAT_ID=

# Phone chat over ntfy. Topic is a password:
# openssl rand -hex 32
# CASE_NTFY_CHAT=1
# CASE_NTFY_URL=https://ntfy.sh
# CASE_NTFY_TOPIC=
# CASE_NTFY_TOKEN=
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ jobs:
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

Expand Down
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
.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
```
Expand Down
88 changes: 65 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ What the agent gets, over MCP:
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, or on their phone via a one-shot Assist link (ntfy).
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.
Expand Down Expand Up @@ -106,19 +107,72 @@ host and are never copied onto the computer.

### More knobs

Phone notifications for 2FA/approvals (ntfy), CAPTCHA auto-solve, scheduled
runs: all optional, all documented in [.env.example](.env.example).
Phone chat (Telegram or 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`.
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:

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:
```
CASE_DRIVE_PROVIDER=openai # or anthropic
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.

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

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:

```
CASE_TELEGRAM_TOKEN=123456:ABC…
```

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

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:

```
CASE_TELEGRAM_CHAT_ID=123456789
```

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

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.

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

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

```bash
openssl rand -hex 32
Expand All @@ -135,8 +189,6 @@ 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
Expand All @@ -156,16 +208,6 @@ docker compose up -d ui
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 Down Expand Up @@ -242,7 +284,7 @@ No Docker:
```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 && node web-ui/test_serve.mjs && node web-ui/test_nav.mjs && node web-ui/test_deploy.mjs)
(cd web && npm ci && npm test && node web-ui/test_nav.mjs && node web-ui/test_deploy.mjs)
```

Acceptance tests need a running stack (`tests/test_acceptance.py`).
Expand Down
9 changes: 6 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,12 @@ 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
personal browsing.

(d) **ntfy topics are bearer secrets.** Anyone who knows a topic name can post or
subscribe. Treat topic names like passwords; use random names and rotate if leaked.
(d) **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.

Report vulnerabilities privately via GitHub Security Advisories:
https://github.com/case-computers/case/security/advisories/new
Do not file public issues that include tokens, ntfy topics, or vault contents.
Do not file public issues that include tokens, ntfy topics, bot tokens, or vault
contents.
2 changes: 2 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ services:
CASE_DRIVE_PROVIDER: ${CASE_DRIVE_PROVIDER:-}
CASE_DRIVE_API_KEY: ${CASE_DRIVE_API_KEY:-}
CASE_DRIVE_MODEL: ${CASE_DRIVE_MODEL:-}
CASE_TELEGRAM_TOKEN: ${CASE_TELEGRAM_TOKEN:-}
CASE_TELEGRAM_CHAT_ID: ${CASE_TELEGRAM_CHAT_ID:-}
volumes:
- ui-data:/data
ports:
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 && node web-ui/test_ntfy.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"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.117.1",
Expand Down
30 changes: 0 additions & 30 deletions web/web-ui/ntfy.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
// SPDX-License-Identifier: MIT
export const OUTBOUND_TAG = 'case-outbound';
export const PHONE_THREAD_ID = 't_phone';
const HANDOFF_RE = /^(h_\w+)\s+(.+)$/s;
const RESERVED_RE = /^(approve|deny|done|i'm done|im done|i am done|\d+)$/i;

export function ntfyConfig(env = process.env) {
const chat = ['1', 'true'].includes(String(env.CASE_NTFY_CHAT || '').trim().toLowerCase());
Expand Down Expand Up @@ -50,33 +47,6 @@ export function parseSseData(chunk, carry = '') {
return { events, rest };
}

export function parseHandoffReply(text) {
const m = HANDOFF_RE.exec(String(text || '').trim());
if (m) return { hid: m[1], value: m[2].trim() };
return { hid: null, value: String(text || '').trim() };
}

export function routePhone({ text, pendingIds = [], busy = false }) {
const raw = String(text || '').trim();
if (!raw) return { type: 'ignore' };
const parsed = parseHandoffReply(raw);
if (parsed.hid) {
if (!pendingIds.includes(parsed.hid)) {
return { type: 'error', error: `no pending handoff ${parsed.hid}` };
}
return { type: 'handoff', hid: parsed.hid, value: parsed.value };
}
if (pendingIds.length === 1) {
return { type: 'handoff', hid: pendingIds[0], value: parsed.value };
}
if (pendingIds.length > 1) {
return { type: 'error', error: `${pendingIds.length} pending handoffs; prefix with handoff id` };
}
if (RESERVED_RE.test(parsed.value)) return { type: 'error', error: 'Nothing waiting.' };
if (busy) return { type: 'steer', text: parsed.value };
return { type: 'task', text: parsed.value };
}

export function clipNtfy(s, n = 3500) {
const t = String(s || '');
return t.length <= n ? t : `${t.slice(0, n)}\n…open Drive for the rest`;
Expand Down
35 changes: 35 additions & 0 deletions web/web-ui/phone.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: MIT
/**
* Phone chat routing shared by every transport (ntfy, Telegram). A transport
* turns its wire format into text; this decides what the text means.
*/
export const PHONE_THREAD_ID = 't_phone';
const HANDOFF_RE = /^(h_\w+)\s+(.+)$/s;
const RESERVED_RE = /^(approve|deny|done|i'm done|im done|i am done|\d+)$/i;

export function parseHandoffReply(text) {
const m = HANDOFF_RE.exec(String(text || '').trim());
if (m) return { hid: m[1], value: m[2].trim() };
return { hid: null, value: String(text || '').trim() };
}

export function routePhone({ text, pendingIds = [], busy = false }) {
const raw = String(text || '').trim();
if (!raw) return { type: 'ignore' };
const parsed = parseHandoffReply(raw);
if (parsed.hid) {
if (!pendingIds.includes(parsed.hid)) {
return { type: 'error', error: `no pending handoff ${parsed.hid}` };
}
return { type: 'handoff', hid: parsed.hid, value: parsed.value };
}
if (pendingIds.length === 1) {
return { type: 'handoff', hid: pendingIds[0], value: parsed.value };
}
if (pendingIds.length > 1) {
return { type: 'error', error: `${pendingIds.length} pending handoffs; prefix with handoff id` };
}
if (RESERVED_RE.test(parsed.value)) return { type: 'error', error: 'Nothing waiting.' };
if (busy) return { type: 'steer', text: parsed.value };
return { type: 'task', text: parsed.value };
}
Loading
Loading