Bind-mount to an empty host dir was shadowing the image's pre-installed /nix tree at runtime — `nix --version` returned "sh: nix: not found" inside the live container even though the binary was baked into the image at build time. Docker auto-populates a fresh named volume from the image's content on first mount. So the named-volume version preserves the install AND persists across container recreations. Volume name `crafting-table-nix`. Lives at the docker default volume path on Lucy. Backups/migration-out: `docker run --rm -v crafting-table-nix:/src -v /tmp:/dst alpine tar cf /dst/nix.tar /src`. |
||
|---|---|---|
| crafting_table | ||
| examples | ||
| mcp | ||
| tests | ||
| .env.example | ||
| .gitignore | ||
| compose.yml | ||
| Dockerfile | ||
| LICENSE | ||
| pyproject.toml | ||
| README.md | ||
| requirements.txt | ||
| smoke.sh | ||
crafting-table
Polyglot dev/build/audit container — the build farm for the Sulkta ecosystem.
What this is
A single Docker container with every toolchain we work with, fronted by a FastAPI HTTP API + async job runner. Used as a reliable place to compile / test / audit any Sulkta repo regardless of where the caller is — agents, Claude sessions, ad-hoc curl, scheduled cron.
Eventual surface (v0.1 full): HTTP API + MCP server + project registry + job runner + structured findings + email digest + autonomous patch loop through clawdforge.
Spec: Sulkta-Coop/openclaw-workspace/memory/spec-crafting-table.md (LAN-only).
Status — v0.1 complete (10 of 10)
- Step 1: Dockerfile + per-language smoke
- Step 2: SQLite ledger + project registry
- Step 3: HTTP API skeleton (FastAPI, port 8810)
- Step 4: Job runner core (asyncio worker pool, git worktree, subprocess)
- Step 5: Per-language parsers (Rust / Python / Go / TS first)
- Step 6: Findings extraction + storage
- Step 7: MCP server (stdio JSON-RPC, 8 tools) — see mcp/README.md
- Step 8: Email digest scheduler
- Step 9: Autonomous patch loop (clawdforge integration → unified diff → worktree apply → verify recipe → push branch → Gitea PR)
- Step 10: Production recipes — clawdforge, cauldron, tradecraft (see examples/recipes/)
Toolchains in v0.1
| Lang | Versions / extras |
|---|---|
| Python | 3.11 (Debian default) + uv, pipx, pip-audit, ruff, mypy, pytest, semgrep |
| Node | 22.11.0 LTS + npm, pnpm, tsx, eslint, typescript |
| Bun | latest (rolling) |
| Go | 1.22.10 + govulncheck, staticcheck |
| Rust | stable (rustup) + clippy, rustfmt, cargo-audit, cargo-deny |
| Ruby | 3.1 (Debian default) + bundler, bundler-audit, rubocop |
| PHP | 8.2 (Debian default) + composer, phpstan, phpunit |
| JDK | 17 (default) + 21 (Temurin, alongside via JAVA_HOME_21) |
| Maven | 3.x (Debian) |
| Gradle | 8.10 |
| .NET | 8.0 SDK |
| Swift | 5.9.2 (Ubuntu 22.04 tarball — works on Debian bookworm) |
| Kotlin | 1.9.25 (compiler) |
| C/C++ | clang + lld + cmake + ninja + valgrind |
| Bash | bash + shellcheck + bats + shfmt |
| Generic | git, jq, yq, ripgrep, fd, gh-cli, curl, wget |
HTTP API surface
LAN-only. Every request needs Authorization: Bearer <token>. The default
LAN allowlist is 10/8, 172.16/12, 192.168/16, 127/8, ::1/128;
override via CRAFTING_LAN_CIDRS.
| Method | Path | Who | What |
|---|---|---|---|
| GET | /healthz |
LAN | Liveness + runner stats |
| POST | /admin/tokens |
admin | Mint a new bearer |
| GET | /admin/tokens |
admin | List tokens |
| DELETE | /admin/tokens/{name} |
admin | Revoke a token |
| POST | /projects |
any | Register (becomes owner) |
| GET | /projects |
any | List own (or all if admin) |
| GET | /projects/{name} |
owner | Detail (404 if not visible) |
| PUT | /projects/{name} |
owner | Update |
| DELETE | /projects/{name} |
owner | Remove (cascades jobs+findings) |
| POST | /projects/{name}/jobs |
owner | Enqueue a recipe run |
| GET | /jobs?project=&status=&limit= |
any | List own (or all if admin) |
| GET | /jobs/{id} |
owner | State + last 200 log lines |
| GET | /jobs/{id}/log |
owner | Full log (file stream) |
| GET | /jobs/{id}/findings |
owner | Structured findings (see Findings) |
| POST | /jobs/{id}/patches |
owner | Trigger an auto-patch attempt (wave 3) |
| GET | /patches?project=&status=&limit= |
any | List own patch attempts |
| GET | /patches/{id} |
owner | Patch attempt detail |
Cross-token access returns 404, not 403 — same existence-leak guard as clawdforge sessions.
Quickstart
After build + first boot, the admin bearer is written to
/data/admin-bearer.txt (chmod 600 inside the container — readable from
the bind-mounted appdata path on the host).
ADMIN=$(cat /mnt/user/appdata/crafting-table/data/admin-bearer.txt)
# Mint a project-scoped token
TOKEN=$(curl -s http://192.168.0.5:8810/admin/tokens \
-H "Authorization: Bearer $ADMIN" \
-H "content-type: application/json" \
-d '{"name":"clawdforge","is_admin":false,"ip_cidrs":[]}' | jq -r .bearer)
# Register the project
curl -s http://192.168.0.5:8810/projects \
-H "Authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{
"name": "clawdforge",
"git_url": "http://192.168.0.5:3001/Sulkta-Coop/clawdforge.git",
"default_branch": "main",
"languages": ["python", "rust"],
"subprojects": [
{"path": "clients/python", "language": "python",
"test": "pytest tests/", "lint": "ruff check .", "audit": "pip-audit",
"timeout_secs": 600},
{"path": "clients/rust", "language": "rust",
"build": "cargo build --release", "test": "cargo test --all",
"audit": "cargo audit", "timeout_secs": 1800}
]
}'
# Kick off a test job
JOB=$(curl -s http://192.168.0.5:8810/projects/clawdforge/jobs \
-H "Authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{"recipe":"test","subproject":"clients/python"}' | jq -r .job_id)
# Poll status
watch -n2 "curl -s http://192.168.0.5:8810/jobs/$JOB \
-H 'Authorization: Bearer $TOKEN' | jq '.job.status, .log_tail[-5:]'"
# Stream the full log
curl http://192.168.0.5:8810/jobs/$JOB/log \
-H "Authorization: Bearer $TOKEN"
Build + smoke
docker network inspect sulkta >/dev/null 2>&1 || docker network create sulkta
docker compose build
# Run the per-toolchain hello-world smoke
docker compose run --rm crafting-table /usr/local/bin/smoke.sh
# expect: "=== ALL TOOLCHAINS GREEN ==="
# Bring up the API
docker compose up -d
curl http://192.168.0.5:8810/healthz
Image notes
- Base:
debian:bookworm-slim. Swift uses the upstream Ubuntu 22.04 tarball which links against bookworm's libicu/libstdc++ baseline. - Runs as non-root user
crafter(uid 1000) with passwordless sudo. The API server runs ascrafter. Recipe commands run ascraftertoo — never elevated to root. - Volume mount points (production):
/data— SQLite ledger, admin bearer file, per-job logs/workspace— bare clones + per-job worktrees/caches— cargo / maven / gradle / npm / pip / bun caches
- Network: external
sulktabridge (same one clawdforge + cauldron use). Create withdocker network create sulktaif missing. - Image size baseline is large (8-15 GB expected). Per spec: that's fine.
Architecture notes
Recipe security
Recipe commands run via /bin/sh -c so any shell metachar works. This is
by design — admins set them. Recipes like cargo build && cargo test
work as expected; ; rm -rf / would too if an admin set it. The container
sandbox + crafter user are the safety net, not the recipe parser.
Workspace strategy
/workspace/<project>/.cache/— bare clone of the upstream/workspace/<project>/<job_id>/— git worktree pointing at the requested branch+sha, removed after the job ends- Periodic gc (1h timer) prunes worktrees older than 24h and runs
git gc --prune=nowon bare clones quiet for >7d
Recipe immutability
Every job snapshots the project's recipe at run-time
(recipe_snapshot_json). Editing a project's recipe doesn't retcon the
view of in-flight or already-finished jobs.
Process group + timeout
Recipe subprocesses spawn in their own process group via
start_new_session=True. On timeout we os.killpg(pgid, SIGTERM) and grace
for 10s before escalating to SIGKILL. Without process-group kill,
multi-process recipes (cargo build spawning rustc, etc.) could leave
orphans that hold the stdout pipe open.
Restart resilience
Jobs marked running but stranded by a process crash are NOT auto-resumed.
On startup the runner sweeps them to failed with exit_code=-1 and
appends a synthetic log line [crafting-table] runner restart, job orphaned. Callers can re-enqueue if they want.
SQLite + WAL
Single host, single process — SQLite is plenty. WAL mode (PRAGMA journal_mode=WAL + synchronous=NORMAL) gives many readers + one writer
without lock contention. The runner is the only mutator of jobs/
findings; HTTP workers mostly read.
Layout
.
├── Dockerfile # monolith image: toolchains + Python app
├── compose.yml # build + run wiring
├── smoke.sh # per-language hello-world test
├── crafting_table/
│ ├── server.py # FastAPI app + endpoints
│ ├── db.py # SQLite + migrations
│ ├── auth.py # bearer + IP allowlist
│ ├── runner.py # async job pool + subprocess exec
│ ├── workspace.py # bare clone + worktree materialization + gc
│ ├── models.py # Pydantic schemas
│ ├── digest.py # email digest scheduler
│ ├── patcher.py # autonomous patch loop (clawdforge → diff → verify → PR)
│ ├── parsers/ # per-language Finding extractors
│ └── config.py # env-driven config
├── tests/ # pytest suite (143 tests)
├── mcp/ # crafting-table-mcp — MCP stdio bridge (separate pip install)
├── examples/
│ ├── recipes/ # production recipes — clawdforge, cauldron, tradecraft
│ └── register-all.sh # bulk-register helper
├── pyproject.toml
├── requirements.txt
└── .env.example
Tests
python3 -m venv .venv && source .venv/bin/activate
pip install -e '.[test]'
pytest tests/
Findings
After every job, the runner reads the captured log and hands it to a
per-language parser. The parser turns native tool output (clippy JSON,
ruff JSON, govulncheck NDJSON, eslint JSON, tsc human errors, etc.) into
structured rows in the findings table.
Parsers in v0.1
| Language | Recipes parsed | Tool output expected |
|---|---|---|
| rust | audit, lint, test, build | cargo audit --json, cargo clippy --message-format=json, cargo test (human) |
| python | audit, lint, test, build | pip-audit -f json, ruff --output-format=json, mypy --output=json, pytest --tb=line |
| go | audit, lint, build, test | govulncheck -json, go vet -json |
| typescript | lint, build, test, audit | eslint -f json, tsc --noEmit (stderr) |
| javascript | (alias of typescript) | same |
| other | (any) | falls back to GenericParser — emits one recipe_fail row when exit_code != 0, else nothing |
Resolution order in the registry: exact language match (parsers
self-gate on recipe via Parser.matches), then GenericParser. Adding a
new language is a single file in crafting_table/parsers/ plus an entry
in PARSERS.
Finding kinds
lint— clippy / ruff / mypy / eslint / tsc / go vet diagnostic.cve— vulnerability from cargo-audit / pip-audit / govulncheck. Carriescode= advisory id (RUSTSEC-..., GO-..., PYSEC-...) and asuggested_fixof the form "bump to " when patched versions are known.test_fail— failed test name extracted fromcargo test/pytestoutput.recipe_fail— fallback when no language-specific parser fired and the recipe exited non-zero.code=exit_<n>, message names the recipe.
Fingerprints + dedup
Every finding row carries a 16-char fingerprint hash over
kind|file|line|code (NOT the message — tool wording drifts). The same
lint reappearing across nightly runs produces the same fingerprint, so a
later wave can dedup digest output and surface only "new since last run."
Consuming findings
GET /jobs/{job_id}/findings
→ {"ok": true, "findings": [
{"id": 1, "job_id": "...", "kind": "lint", "severity": "warn",
"file": "src/app.py", "line": 3, "code": "F401",
"message": "...", "suggested_fix": "...", "fingerprint": "...",
"raw_json": "{...}", "created_at": ...},
...
]}
Authorization is project-token-scoped (same model as /jobs/{id}). The
matching job row's findings_count mirrors the array length so
callers can decide whether to fetch the detail.
Digest
Daily 06:00 PT email digest. One message per project per day; aggregates the last 24h of jobs per recipient and sends via SMTP relay (Lucy postfix).
Set the SMTP block in .env to enable — leaving CRAFTING_SMTP_HOST unset
keeps the scheduler off and logs digest disabled — CRAFTING_SMTP_HOST not set
at startup. The /digests and /admin/digest/run-now endpoints still work
in dry-run mode regardless.
CRAFTING_SMTP_HOST=postfix.sulkta.com
CRAFTING_SMTP_PORT=587
CRAFTING_SMTP_USER=crafting-table@sulkta.com
CRAFTING_SMTP_PASS=...
CRAFTING_SMTP_FROM=crafting-table@sulkta.com
CRAFTING_SMTP_TLS=1
Each project's notify.email + notify.on fields control delivery:
notify.on event |
When it fires |
|---|---|
audit_pass |
a passing audit job |
audit_fail |
a failing audit job |
test_fail |
a failing test job |
lint_warn |
a lint job with warning-severity findings |
cve_found |
any job whose findings include a cve |
patch_drafted |
(wave 3 / step 9) auto-patch was drafted |
nightly_summary |
catch-all — show ALL jobs in the project's section |
Empty notify.on defaults to ["audit_fail", "cve_found", "patch_drafted"].
Empty notify.email silently excludes the project.
Manual trigger from the LAN admin token:
# Render today's digest without sending
curl -sH "Authorization: Bearer $ADMIN" \
-X POST http://192.168.0.5:8810/admin/digest/run-now \
-d '{"dry_run": true}' | jq .
# Render an arbitrary date as JSON
curl -sH "Authorization: Bearer $ADMIN" \
http://192.168.0.5:8810/digests/2026-04-29 | jq .
Idempotency: digest_runs table holds UNIQUE(date, project_name), so the
06:00 loop is safe to re-fire on the same day — only the first call sends.
Autonomous patch loop
Wave 3 wires crafting-table into clawdforge so a project with
notify.auto_patch=true gets an automatic patch attempt on every
actionable finding (lint with file/line; cve with a known fix). Lifecycle:
- Runner finishes a job + parsers populate findings.
- Post-job hook fires: pulls the highest-severity actionable finding, reads ±20 lines of context from the worktree.
- Patcher opens a clawdforge session (
POST /sessions), sends one turn with the finding + source context + project metadata, expects{"diff": ..., "explanation": ..., "confidence": ...}back. - Diff applied to a fresh worktree on
crafting-table/auto/<job_id>-<finding_id>. Apply failure → statusapply_failed. - Recipe re-runs against the patched worktree (the verify step).
Fail →
verify_failed. - Pass → commit + push + open Gitea PR. Status
pr_opened. - clawdforge session always closed.
Configuration (env vars):
CRAFTING_CLAWDFORGE_URL=http://192.168.0.5:8800
CRAFTING_CLAWDFORGE_TOKEN=cf_...
CRAFTING_GITEA_URL=http://192.168.0.5:3001
CRAFTING_GITEA_TOKEN=<gitea PAT>
CRAFTING_PATCHER_MAX_ATTEMPTS=3
CRAFTING_PATCHER_BRANCH_PREFIX=crafting-table/auto/
If any of the four required vars is missing, the patcher stays disabled
and POST /jobs/{id}/patches returns 503. The runner hook silently no-ops
in that case so existing job flow is unaffected.
Verification cost matters. The verify step re-runs the failing recipe
on the patched worktree — for projects with multi-minute builds this
DOUBLES the latency. Set notify.auto_patch=true only for projects where
the audit/test recipe is <5min, OR accept the latency. v0.2 candidate:
"fast verify" mode that re-runs only the specific lint that fired.
patch_attempts table holds every attempt with UNIQUE(finding_id, attempt_number);
the loop early-exits at max_attempts_per_finding (default 3). No
auto-merge; PRs land for human review.
Manual trigger:
curl -sH "Authorization: Bearer $TOKEN" \
-X POST http://192.168.0.5:8810/jobs/$JOB/patches \
-d '{"finding_id": 42}' | jq .
# → {"ok": true, "attempt": {"status": "pr_opened", "pr_url": "...", ...}}
First production recipes
Three recipes ship in examples/recipes/:
| Recipe | Subprojects | Schedule (audit) | auto_patch |
|---|---|---|---|
clawdforge |
14 (one per SDK + root) | nightly 02:00 | true |
cauldron |
1 (Flask app, .) |
nightly 02:00 | true |
tradecraft |
1 (.) |
nightly 02:00 | false (manual review) |
Each ships with a placeholder REPLACE_WITH_GITEA_TOKEN in git_url;
examples/register-all.sh substitutes $GITEA_TOKEN at register time so
no real token ever lands in the repo.
Smoke procedure (post-deploy):
1. docker compose up -d
2. TOKEN=$(cat /mnt/user/appdata/crafting-table/data/admin-bearer.txt)
3. CRAFTING_TABLE_TOKEN=$TOKEN GITEA_TOKEN=<your-pat> bash examples/register-all.sh
4. curl -H "Authorization: Bearer $TOKEN" http://192.168.0.5:8810/projects \
→ expect 3 projects (clawdforge, cauldron, tradecraft)
5. curl -X POST -H "Authorization: Bearer $TOKEN" \
http://192.168.0.5:8810/projects/clawdforge/jobs \
-d '{"recipe":"test","subproject":"clients/python"}'
→ expect job_id
6. Poll GET /jobs/{job_id} until status terminal → expect succeeded
Per-recipe smoke status (today, pre-deploy):
clawdforge— 14 subprojects;clients/python&clients/typescript&clients/go&clients/rustknown clean from existing CI; ruby / php / kotlin / java / csharp / swift compile-cleanly today but toolchain availability inside the crafting-table image is what step 1 smoke verified. Bash subproject'stest/run.shmay not exist (manual check needed post-deploy).cauldron— single Flask subproject; pip-audit & pytest known to run cleanly from the cauldron repo's own CI history.tradecraft— single subproject; auto_patch is off by design (production app, manual PR review only).
MCP bridge
The mcp/ subdirectory ships a self-contained crafting-table-mcp Python
package that exposes the HTTP API to MCP-aware clients (Claude Desktop, Claude
Code, Cursor, Zed, custom agents). See mcp/README.md for the
tool surface, installation, and configuration.
Quickstart:
pip install -e mcp
export CRAFTING_TABLE_BASE_URL=http://192.168.0.5:8810
export CRAFTING_TABLE_TOKEN=ct_...
crafting-table-mcp # stdio JSON-RPC server
License
MIT