LAN-only HTTP service that runs claude -p subprocess on behalf of Sulkta apps. Bearer token + IP allowlist gated.
HIGH:
- H1: __debugInfo() redacts token on Client + AppToken; #[\SensitiveParameter]
on Client constructor's $token param so PHP scrubs it from stack traces.
MEDIUM:
- M1: uploadStream(StreamInterface, filename, ttl) overload so callers
handling form uploads have a non-path entry point. README warning above
the API table on uploadFile path-trust.
- M2: RunRequest now rejects empty-string model/system in the constructor
(callers should pass null/omit rather than '' to use defaults).
- M3: new MalformedResponseException extends ForgeException for
"transport succeeded, body unparseable as expected JSON object". Decoupled
from ApiException so callers can distinguish "server told me no" from
"server replied 200 with garbage". README + ApiException docstring updated.
- M4: non-UTF-8 / malformed JSON now flows through M3's new exception.
- M5: ApiException error-message extraction falls back to json_encode
(capped at 200 chars) when the error field is an object/array, so
callers don't get empty messages on {"error":{"code":...,"msg":...}}.
LOW:
- L2: revokeToken now requires server response ok === true, raises
MalformedResponseException on missing/false ok rather than silently
returning true.
- L5: README WordPress snippet uses bare Client (matches the use line above).
- L7: 29 new tests — token redaction (3), uploadStream (2), empty
model/system (2), MalformedResponseException across 7 scenarios incl.
non-UTF-8, ApiException object-error formatting + 200-char cap, revoke
ok=true requirement + ok=false + empty-name, RunRequest timeout bounds
(3) + non-string/empty files entries (2), uploadFile unreadable-path
+ 4xx + 5xx, healthz 500, Authorization header asserted on every
endpoint.
README polish: TLS verify=false caveat under "Custom HTTP client".
Audit memo: memory/clawdforge-audits/php-1cff9b8.md
|
||
|---|---|---|
| clawdforge | ||
| clients | ||
| .env.example | ||
| .gitignore | ||
| compose.yml | ||
| Dockerfile | ||
| LICENSE | ||
| README.md | ||
| requirements.txt | ||
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
{
"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:
{
"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:
- IP allowlist — global CIDR list in
ALLOW_CIDRSenv. Loopback always allowed. Per-app allowlist optional on top (mint withip_cidrs: [...]). - Bearer token —
Authorization: Bearer cf_<...>for/runand/files,Authorization: Bearer <ADMIN_BOOTSTRAP_TOKEN>for/admin/*.
Tokens are SHA-256 hashed in SQLite. The plaintext is shown ONCE at create time.
Deploy
- SSH to Lucy:
ssh lucy mkdir -p /mnt/user/appdata/clawdforge/{data,claude-config,claude-alt-config}- Drop
.envat/mnt/cache/appdata/secrets/clawdforge.env(chmod 600, root:root) — see.env.example - Clone the repo to
/opt/stacks/clawdforge(Lucy uses Gitea reverse-tunnel pattern) cd /opt/stacks/clawdforge && docker compose up -d --build- Auth Claude CLI (one-time, persists on volume):
Walk through the device-auth flow. Credentials persist atdocker exec -it clawdforge claude /login/root/.claude/inside the container, mapped to/mnt/user/appdata/clawdforge/claude-config/on host. - Smoke:
Should reportcurl http://192.168.0.5:8800/healthzclaude_present: true+ a version string. - Mint a token for the first consumer:
Save the returnedcurl -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"]}'tokeninto the consumer's env.
Client snippet (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 PythonanthropicSDK). - Default model is
sonnet; per-request override viamodelfield. - Per-run working directory is staged under
RUNS_DIRand torn down on exit, soclaudecan't pollute the container's working tree. - File uploads are scoped to the uploading app — token A can't reference token B's files.