clawdforge/README.md
Kayos 44a8fe743f v0.1 — clawdforge service scaffold
LAN-only HTTP service that runs claude -p subprocess on behalf of Sulkta apps.
Bearer token + IP allowlist gated. SQLite-backed token registry + run audit log.

- POST /run               run a prompt, return parsed result
- POST /files             upload a file, get a file_token to attach to /run
- POST /admin/tokens      mint per-app tokens (admin-bootstrap-token gated)
- GET  /admin/tokens      list, DELETE /admin/tokens/<name>  revoke
- GET  /healthz           liveness + claude --version smoke

Container = node:22 + npm-installed @anthropic-ai/claude-code + uvicorn/FastAPI
wrapper. Persistent volumes for /data (sqlite + run staging) and /root/.claude
(subscription auth — survives container rebuilds; auth via 'docker exec -it
clawdforge claude /login' once). Compose binds 192.168.0.5:8800 only — no
public proxy.

First consumer = cauldron (about to land).
2026-04-28 16:46:44 -07:00

124 lines
4.1 KiB
Markdown

# clawdforge
LAN-only HTTP service that runs `claude -p` subprocess calls on behalf of Sulkta apps.
One container holds the Claude Code subscription auth; multiple apps consume via bearer
tokens + IP allowlist.
## Why
- **Auth in one place** — only this container needs to be `claude /login`'d, not every app
- **Smaller app images** — apps stay tiny Python/Go containers, no node/npm/claude-cli
- **Audit log** — every prompt + response chars + duration in one SQLite db
- **Reusable bone** — petalparse, cauldron, johnny5 all consume the same surface
## Surface
```
GET /healthz liveness + claude --version smoke
POST /run run a prompt, return parsed result
POST /files upload a file, get a file_token to pass to /run
POST /admin/tokens mint a per-app token (admin)
GET /admin/tokens list app tokens (admin)
DELETE /admin/tokens/<name> revoke a token (admin)
```
### `POST /run`
```json
{
"prompt": "Sterilize this ingredient line: 'about 2 cups of cooked white rice'",
"model": "sonnet",
"system": "You are a precise recipe parser. Always reply with valid JSON.",
"files": ["ff_..."],
"timeout_secs": 60
}
```
Returns:
```json
{
"ok": true,
"result": { "qty": 2, "unit": "cup", "food": "rice", "note": "cooked, white", "approx": true },
"duration_ms": 4321,
"stop_reason": "end_turn"
}
```
`result` is the inner `{"type":"result","result":"..."}` from `claude -p --output-format json`,
auto-stripped of code fences and JSON-parsed if possible. If the inner is not valid JSON, it's
returned as a string.
### `POST /files`
multipart/form-data, field `file`, optional `ttl_secs` (60..86400, default 3600).
Returns `{"file_token": "ff_...", "ttl_secs": 3600, "size": 12345}`.
Use that token in subsequent `/run` requests to attach the file via `claude -p --files`.
## Auth
Two layers:
1. **IP allowlist** — global CIDR list in `ALLOW_CIDRS` env. Loopback always allowed.
Per-app allowlist optional on top (mint with `ip_cidrs: [...]`).
2. **Bearer token**`Authorization: Bearer cf_<...>` for `/run` and `/files`,
`Authorization: Bearer <ADMIN_BOOTSTRAP_TOKEN>` for `/admin/*`.
Tokens are SHA-256 hashed in SQLite. The plaintext is shown ONCE at create time.
## Deploy
1. SSH to Lucy: `ssh lucy`
2. `mkdir -p /mnt/user/appdata/clawdforge/{data,claude-config,claude-alt-config}`
3. Drop `.env` at `/mnt/cache/appdata/secrets/clawdforge.env` (chmod 600, root:root) — see `.env.example`
4. Clone the repo to `/opt/stacks/clawdforge` (Lucy uses Gitea reverse-tunnel pattern)
5. `cd /opt/stacks/clawdforge && docker compose up -d --build`
6. **Auth Claude CLI** (one-time, persists on volume):
```
docker exec -it clawdforge claude /login
```
Walk through the device-auth flow. Credentials persist at `/root/.claude/` inside the
container, mapped to `/mnt/user/appdata/clawdforge/claude-config/` on host.
7. Smoke:
```
curl http://192.168.0.5:8800/healthz
```
Should report `claude_present: true` + a version string.
8. Mint a token for the first consumer:
```
curl -sS -X POST http://192.168.0.5:8800/admin/tokens \
-H "Authorization: Bearer $ADMIN_BOOTSTRAP_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"cauldron","ip_cidrs":["172.24.0.0/16"]}'
```
Save the returned `token` into the consumer's env.
## Client snippet (Python)
```python
import os, requests
CF = "http://192.168.0.5:8800"
TOKEN = os.environ["CLAWDFORGE_TOKEN"]
r = requests.post(
f"{CF}/run",
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"prompt": 'Reply with JSON: {"hello": "world"}',
"model": "sonnet",
"timeout_secs": 30,
},
timeout=60,
)
r.raise_for_status()
print(r.json()["result"]) # {'hello': 'world'}
```
## Notes
- The CLI is `@anthropic-ai/claude-code` (not the Python `anthropic` SDK).
- Default model is `sonnet`; per-request override via `model` field.
- Per-run working directory is staged under `RUNS_DIR` and torn down on exit, so
`claude` can't pollute the container's working tree.
- File uploads are scoped to the uploading app — token A can't reference token B's files.