29 KiB
DESIGN.md — Caret repo-enforcer (openclaw Gitea-slice replacement)
Status: draft for sign-off Author: Caret Approver: Rooh Phase: 1 (architecture lock) Scope reminder: replaces only the Gitea-facing slice of openclaw — webhook ingress, event router, script fan-out, repo policy enforcement, and an opt-in judgment wake-up. Everything else (155 projects, workspace memory, sub-agent orchestration, delivery system) stays owned by openclaw.
Purpose
A single-file Bun HTTP listener that receives Gitea webhooks, verifies them with HMAC, runs deterministic policy scripts, and optionally wakes a Claude session for judgment cases — replacing openclaw's gitea-transform.js pipeline with no LLM in the hot path.
Target architecture
Gitea (sol/*)
│ POST + X-Gitea-Signature (HMAC-SHA256 of raw body)
│ X-Gitea-Event, X-Gitea-Delivery
▼
nginx (slack.solio.tech)
│ TLS termination
│ path /hooks/caret/gitea → forwards raw body, no JSON parsing
▼
caret-repo-enforcer (Bun, single file, host process under systemd-style supervisor)
listens 127.0.0.1:18790
┌──────────────────────────────────────────────────────────────────┐
│ 1. read raw body bytes │
│ 2. HMAC verify (timing-safe) → 403 on mismatch │
│ 3. dedup by X-Gitea-Delivery (24h LRU on disk) │
│ 4. parse JSON │
│ 5. event router ──► route table (event → script[]) │
│ 6. fan-out: spawn scripts in /host/root/.caret/tools/ as children│
│ 7. capture stdout/stderr/exit, attach to log entry │
│ 8. on script error or "judgment" exit code → enqueue wake-up │
│ 9. structured JSON log line → /host/root/.caret/log/ │
│ 10. respond 200 with {ok, runId, scripts:[...]} │
└──────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
scripts (bash/node) judgment wake-up tg-stream alert
/host/root/.caret/tools/ via Channels plugin on error paths
- post-repo-audit.sh POST to local (existing)
- audit-repo-policies.sh channel endpoint ─────────────► Rooh's Telegram
- secret-scan.sh spawns Claude session
- audit-webhooks.sh with payload as prompt
│
▼
Gitea API (curl, sol token) — apply fixes, add collaborators, commit policy files
│
▼
Result visible to Rooh:
- log line greppable across tg-stream + repo-enforcer logs
- tg-stream Telegram message on error or judgment cases
- the actual repo state in Gitea
Every hop is synchronous from request to 200 response except the wake-up (fire-and-forget) and tg-stream (fire-and-forget). The 200 carries the run ID so any future replay can be correlated.
Components
1. HTTP listener — caret-repo-enforcer
What: Single Bun file. HTTP server on 127.0.0.1:18790. Receives Gitea webhooks via nginx, runs the entire pipeline, returns 200 with a run ID.
Where on disk: /host/root/.caret/repo-enforcer/server.ts (single file, ~600-800 lines, same shape as tg-stream).
Runtime: Bun (latest stable). No Node, no npm install — Bun's stdlib covers HTTP, crypto, fs, child_process. No package.json needed beyond a stub for type hints.
Process model: host process supervised by a tiny shell wrapper (run.sh) under tmux for parity with tg-stream. Rationale: docker container would add image-build overhead, volume mounting for /host/root/.caret/, and a network hop for nothing. tg-stream already proves the host-process pattern works for a long-lived listener; reuse the same shape for operational simplicity. If Rooh prefers containerization for isolation, the single-file shape ports trivially — we just add a Dockerfile later.
Dependencies: Bun stdlib only. No openclaw imports. No plugin SDK. The Gitea API is called by the scripts via curl, not from the listener.
Responsibilities:
- Bind 127.0.0.1:18790 (nginx is the only thing that can reach it)
- Read raw request body before any parsing
- HMAC-SHA256 verify against
GITEA_WEBHOOK_SECRET - Bearer token check (defense in depth, in case nginx is misconfigured)
- Dedup by
X-Gitea-Deliveryheader against on-disk LRU - Route the event to scripts via a static table
- Spawn scripts as child processes with bounded timeouts
- Emit one structured JSON log line per request, regardless of outcome
- Emit a tg-stream alert on error paths
- Expose
/healthzfor liveness
2. Event router (inside the listener)
What: A static routeTable map: (event, action) → [scriptPath, ...]. Replaces gitea-transform.js's switch statement.
Where: Inline in server.ts. Not a separate file because routes are dense and rarely change; one file beats two.
Routes (initial):
| Gitea event | Action filter | Scripts (in order) | Mode |
|---|---|---|---|
repository |
created |
post-repo-audit.sh, audit-repo-policies.sh --fix |
sequential |
push |
ref = refs/heads/main or master |
secret-scan.sh, audit-repo-policies.sh --check |
parallel |
push |
other refs | (skip, log "ignored: non-main") | n/a |
issues |
opened with [IMPLEMENT] in title |
enqueue-judgment.sh implement |
sequential |
issue_comment |
created by Rooh, body matches approval words |
enqueue-judgment.sh approval |
sequential |
| (other) | any | (skip, log "ignored: not routed") | n/a |
Rationale for keeping routing in-listener vs a config file: the routes are part of the security perimeter and they need to be reviewed in code, not in JSON. Editing server.ts triggers the listener restart that re-applies the lock — same as openclaw's transform versioning. Config-file routing invites the "I changed the JSON and broke prod silently" failure mode.
3. Script fan-out — /host/root/.caret/tools/
What: Ported scripts from sol/gitea-webhooks/tools/, with openclaw-specific paths stripped. Each script is standalone bash or node, takes the JSON payload on stdin or via env vars, exits 0 (success), 1 (deterministic failure → tg-stream alert), or 42 (judgment escalation → wake-up).
Where on disk: /host/root/.caret/tools/. One file per script. Each file has a header comment naming its trigger and exit-code contract. License preserved from openclaw.
Initial set (Phase 2 ports):
post-repo-audit.sh— add Rooh as admin collaborator, ensure HMAC webhook exists with correct secret. Idempotent.audit-repo-policies.sh— ensures Makefile, .editorconfig, .gitignore, README baseline.--fixcommits missing files;--checkonly reports.secret-scan.sh— finds private keys and high-entropy strings on the diff for the push. Honors.secret-scan-allowlist.audit-webhooks.sh— verifies all sol/* repos have a Caret webhook with the right secret. Called by cron, not by webhook events.enqueue-judgment.sh— writes a judgment request file to/host/root/.caret/judgment-inbox/and POSTs to the Channels plugin endpoint.
Path conventions: every script reads its config from env vars set by the listener. No hard-coded /root/.openclaw/ paths. Workspace root is /host/root/.caret/. All scripts must be re-runnable on the same payload with no side effects beyond the first run (idempotency contract is in the header comment of each script).
Timeouts: the listener wraps each script in a 30-second timeout (matches openclaw's precomputeSpawnParams). Hitting the timeout is a deterministic failure (exit 124) that triggers a tg-stream alert and a log entry but does NOT escalate to judgment — repeated timeouts are an infrastructure bug, not a judgment call.
4. Structured log
What: Append-only JSONL at /host/root/.caret/log/repo-enforcer.log. One line per request. Format intentionally identical to tg-stream's log so a single grep covers both pipelines.
Format:
{"ts":"2026-04-05T12:34:56.789Z","svc":"repo-enforcer","runId":"r_abc123","level":"info",
"event":"push","action":null,"repo":"sol/foo","delivery":"d_xyz","hmac":"ok",
"scripts":[{"name":"secret-scan.sh","exit":0,"durMs":421,"stderr":""},
{"name":"audit-repo-policies.sh","exit":0,"durMs":1102}],
"outcome":"ok","msg":"push processed"}
Error lines use level:"error" and include errPath and errMsg. Every dropped event (dedup hit, route miss, ignored ref) gets a line at level:"debug" so there are no silent drops — addresses the openclaw gap in PLAN.md risk #3.
Rotation: line-count rotation at 50,000 lines, keep 10 generations, gzip the rest. Same policy as tg-stream. Implemented inline in the listener (no logrotate dependency) so the listener owns its own log lifecycle.
Failure mode: if log write fails (disk full), the listener still returns 200 to Gitea (Gitea is not the right place to retry log failures) but emits a tg-stream alert via the alternate path and increments a logWriteFailures counter on /healthz.
5. Wake-up channel — judgment escalation
What: A native Claude Code Channels plugin at /host/root/.caret/channels/gitea-judgment/. Receives a POST with a payload, starts a Claude session with that payload as the initial prompt, lets the session run and report back via tg-stream when done.
Why Channels and not CronCreate: the trigger is event-driven, not time-driven, so cron is the wrong primitive. Channels plugins are explicitly designed for "external event → Claude session" — exactly this use case. They're a native primitive, no openclaw dependency, and the plugin is a few-dozen-line manifest plus a handler script. Considered alternative: call openclaw's /hooks/{hookPath}/agent endpoint. Rejected because (a) it reintroduces the openclaw dependency we're explicitly removing, (b) openclaw's gateway is currently degraded (Research 03 §critical), and (c) the channels plugin gives us a clean ownership boundary.
Trigger conditions (from PLAN.md J3.2):
- A deterministic script exits 42 ("judgment requested").
- A script exits non-zero AND its header marks it
escalate-on-error: true(e.g. policy enforcement failed mid-run on a repo we don't recognize). - An event matches an explicit opt-in marker —
[IMPLEMENT]issue title, Rooh approval-word comment.
Cost hygiene: judgment is never fired on a normal push, normal repo create, normal policy check. The default deterministic path applies to every event. Token spend on judgment is bounded by (a) only firing on errors or explicit opt-ins, (b) the channels plugin enforcing a per-hour rate limit (5 sessions/hour) configurable in the manifest, (c) tg-stream alerting Rooh every time a judgment session fires so spend is visible.
6. Secrets storage
What lives where:
GITEA_WEBHOOK_SECRET— HMAC secret, 32 random bytes, hex-encoded. Stored at/host/root/.caret/secrets/gitea-webhook-secret(mode 0600, owned by the listener user). Loaded into the listener at boot. The same secret is registered on every sol/* repo's webhook config (set bypost-repo-audit.shon repo create, and by the periodicaudit-webhooks.shcron).GITEA_API_TOKEN— sol account token used by scripts to call the Gitea API. At/host/root/.caret/secrets/gitea-api-token(mode 0600). Loaded into env when scripts spawn.CARET_BEARER_TOKEN— bearer token nginx injects on every forwarded request. Defense-in-depth in case the localhost ACL is bypassed. At/host/root/.caret/secrets/bearer-token(mode 0600).TG_STREAM_TOKEN— already exists; reused, not duplicated.
Rotation procedure:
- Generate new secret:
openssl rand -hex 32 > /host/root/.caret/secrets/gitea-webhook-secret.new - Run
tools/audit-webhooks.sh --rotate-to /host/root/.caret/secrets/gitea-webhook-secret.new— this updates every sol/* webhook in Gitea to the new secret AND keeps the old secret as a fallback in the listener for 60 seconds (dual-accept window). - Atomic rename:
mv .new /host/root/.caret/secrets/gitea-webhook-secret. - Send the listener SIGHUP — it reloads secrets and drops the old one after the dual-accept window.
- Audit script logs the rotation to repo-enforcer.log with a special
rotationevent.
Blast radius of leak: a leaked webhook secret lets an attacker forge events. Mitigation: leaked secrets get rotated by the procedure above (one shell command), and the listener's dedup cache prevents replay of real events as forgeries.
7. Observability
Health check: GET /healthz on the listener returns:
{"ok":true,"uptimeSec":12345,"lastEventTs":"2026-04-05T...","dedupCacheSize":423,
"logWriteFailures":0,"hmacFailures24h":0,"scriptFailures24h":2,"version":"0.1.0"}
Used by an external watchdog (a 2-line cron from Rooh's existing setup or tg-stream) that pings every 5 minutes and alerts on non-200 or ok:false.
Metrics: lightweight, no Prometheus. Counters in memory, exposed on /healthz. Persisted to /host/root/.caret/repo-enforcer/state.json on shutdown so they survive restart.
Heartbeat: every 60 seconds the listener writes {"ts":...,"svc":"repo-enforcer","beat":true} to the log. Greppable proof of life. If 5 minutes pass without a heartbeat line, the watchdog fires a tg-stream alert.
Error alerts via tg-stream: four alert classes:
- HMAC failure burst (>3 in 1 minute) — possible attack
- Script timeout — possible infra problem
- Script exit non-zero (no escalate-on-error) — possible policy bug
- Listener crash / restart — caught by the supervisor wrapper, posts to tg-stream before re-exec
All alerts are rate-limited at 1/minute per class to avoid Telegram spam.
8. Gitea API access
Scripts use curl with the sol token. The listener never calls the Gitea API directly — that's a script concern, keeping the listener's surface area small. Token scope blocker (PLAN.md): sol token lacks read:admin. audit-webhooks.sh can list/manage per-repo webhooks (which is enough for Phase 2/3) but cannot manage system-level webhooks. Rooh needs to either elevate the token or accept that we use per-repo registration, not system-level. Recommendation: stick with per-repo registration — it's what openclaw already uses, avoids token elevation, and the audit script will catch any sol/* repo missing a hook within 6 hours.
Data flows
Flow 1 — Rooh creates a new repo
- Rooh runs
gitea repo create sol/foo(or clicks the UI). - Gitea fires
repositoryevent withaction: "created"to all matching webhooks. In Phase 4: both openclaw's endpoint and Caret's endpoint receive the event. In Phase 5+: only Caret's. - Caret's nginx forwards POST to
127.0.0.1:18790/hooks/giteawith raw body intact and bearer header injected. - Listener reads raw body, computes HMAC-SHA256, compares with
X-Gitea-Signature(timing-safe). Pass. - Listener checks bearer header. Pass.
- Listener checks
X-Gitea-Deliveryagainst dedup cache. Miss → record. - Listener parses JSON, looks up route:
repository/created→[post-repo-audit.sh, audit-repo-policies.sh --fix]. - Spawns
post-repo-audit.shwith payload on stdin and env (CARET_REPO=sol/foo,GITEA_API_TOKEN=...). Script: adds Rooh as admin via Gitea API (idempotent — checks current state first), ensures the Caret webhook exists with the current HMAC secret. Exits 0. Duration ~800ms. - Spawns
audit-repo-policies.sh --fix. Script clones repo to a tmp dir, checks for required files, commits any missing ones with the sol bot user, pushes. Exits 0. Duration ~3-4s. - Listener writes one log line:
{"event":"repository","action":"created","repo":"sol/foo","scripts":[{post-repo-audit:0,807ms},{audit-repo-policies:0,3421ms}],"outcome":"ok"}. - Listener returns 200 with run ID. Total time: ~4-5 seconds.
- Rooh sees the new repo with policies applied within ~5 seconds. No tg-stream alert (success path is silent by design — alerts are for failures only).
Flow 2 — Policy enforcement fails mid-run (Gitea API 500)
- Push event arrives. Listener verifies HMAC, dedups, routes to
[secret-scan.sh, audit-repo-policies.sh --check]. secret-scan.shruns in parallel, exits 0.audit-repo-policies.sh --checkcalls Gitea API to fetch the policy template. Gitea returns 500.- Script retries 3 times with backoff (1s, 2s, 4s). All fail.
- Script exits 1 with stderr =
"gitea api 500 fetching policy template after 3 retries". - Listener sees exit 1, looks up the script's
escalate-on-errorflag. Foraudit-repo-policies.shthis is false (Gitea 500 is infra, not judgment). So: log line atlevel:"error", fire tg-stream alert, return 200 to Gitea. - Log line:
{"level":"error","event":"push","repo":"sol/foo","scripts":[{secret-scan:0},{audit-repo-policies:1,"stderr":"gitea api 500..."}],"outcome":"script_error","errPath":"audit-repo-policies.sh"}. - tg-stream sends Rooh:
[repo-enforcer] sol/foo policy check failed: gitea api 500 fetching policy template after 3 retries (runId r_abc123). - Rooh sees the alert immediately. Greps log by runId for full context. Fixes Gitea or waits it out. The next push will retry; idempotency means no double-application.
Flow 3 — Push to main triggers secret-scan, finds a leaked key
- Push event arrives. Verify, dedup, route →
[secret-scan.sh, audit-repo-policies.sh --check]. secret-scan.shclones the diff range, scans for private keys (PEM headers, AWS-pattern keys, high-entropy base64 strings), checks.secret-scan-allowlist. Finds a real, non-allowlisted private key.- Script exits 42 (judgment escalation) with stdout containing the finding details (path, line number, key fingerprint — never the actual key bytes).
- Listener sees exit 42, calls
enqueue-judgment.sh secret-scanwith the script output as input. enqueue-judgment.shwrites the case to/host/root/.caret/judgment-inbox/r_xyz.jsonand POSTs to the Channels plugin endpoint athttp://127.0.0.1:18791/channels/gitea-judgment/wake.- Channels plugin receives POST, starts a Claude session with prompt: "A secret-scan found this in sol/foo: . Decide: revoke and rotate, or false positive needing allowlist update. Report via tg-stream."
- Listener writes log line at
level:"warn":{"outcome":"escalated","escalation":"secret-scan","judgmentRunId":"r_xyz"}and returns 200. - tg-stream alerts Rooh immediately:
[repo-enforcer] WARNING — secret found in sol/foo by secret-scan, judgment session r_xyz started. - Claude session investigates, takes action (revoke key via Gitea API, post in the issue, ping Rooh on Telegram for confirmation). Reports completion via tg-stream.
- The deterministic path is the safety net: even if the judgment session fails to start, the alert in step 8 already woke Rooh.
Storage
| Store | Path | Format | Purpose | Rotation/cleanup | Corruption mode |
|---|---|---|---|---|---|
| Structured log | /host/root/.caret/log/repo-enforcer.log |
JSONL | Audit trail of every event | 50k lines, keep 10 gz'd | New file started; old file flagged via tg-stream |
| Dedup cache | /host/root/.caret/repo-enforcer/dedup.json |
JSON {deliveryId:ts} |
Replay protection | Entries >24h pruned on read | File deleted and recreated empty (worst case: 24h replay window) |
| Secrets | /host/root/.caret/secrets/* |
raw text, mode 0600 | HMAC, API, bearer tokens | Manual rotation only | Listener refuses to start without all secrets present |
| State counters | /host/root/.caret/repo-enforcer/state.json |
JSON | Counters surviving restart | Truncated on rotation | Reset to zero; not load-bearing |
| Judgment inbox | /host/root/.caret/judgment-inbox/ |
JSON files | Pending judgment cases | Removed by channels plugin after ack | Files re-tried on plugin restart |
| Lock dir | /host/root/.caret/repo-enforcer/locks/ |
empty files | Per-repo lock for concurrent events | Locks >5min auto-released | Stale locks deleted on next event |
Per-repo locks are file-based, named {owner}-{repo}.lock. Acquired before script fan-out, released after. TTL 5 minutes (much shorter than openclaw's 2h because we're not holding for an LLM session, only for scripts). This prevents two near-simultaneous pushes to the same repo from racing in audit-repo-policies.sh --fix and creating conflicting commits.
Observability
Already covered in Components §7. Summary of what proves health:
/healthzreturns 200 andok:true— listener up- Heartbeat log line every 60s — main loop alive
lastEventTsrecent (or watchdog accepts no events as long as heartbeat is fresh)hmacFailures24hlow — no attack noisescriptFailures24hlow and Rooh has acknowledged any spikes- tg-stream silent (no news = good news on the deterministic path)
Security contract
HMAC verification: every request must include X-Gitea-Signature matching HMAC-SHA256(rawBody, secret). Implementation reads raw bytes via Bun's req.arrayBuffer() BEFORE any JSON parsing. Comparison is crypto.timingSafeEqual over equal-length buffers. Failure: return 403, no body, log at level:"warn" with hmac:"fail" and the source IP. Three failures from the same IP within 60s triggers a tg-stream alert.
Bearer token: nginx injects Authorization: Bearer ${CARET_BEARER_TOKEN}. The listener checks it after HMAC. Failure → 401. This is a defense-in-depth layer; HMAC alone is sufficient cryptographically.
ACL: listener binds 127.0.0.1 only. Nginx is the only thing that can reach it. Anything else hitting 18790 directly never gets there.
Rotation: see Components §6.
Failed-HMAC POST: returns 403, logs the incident, dedup cache is NOT updated (so a real retry of the same delivery still works), and an alert fires after burst threshold. The body is discarded — never written to disk in any form.
Failure modes and recovery
| Failure | Detection | Recovery |
|---|---|---|
| Listener crash | Watchdog misses heartbeat for 5min | Supervisor wrapper auto-restarts; tg-stream alert |
| Disk full | Log write fails | Alert via tg-stream alt path; listener stays up; /healthz shows logWriteFailures>0 |
| Gitea unreachable | Script exits with retry-exhausted error | tg-stream alert; next webhook event retries naturally |
| Telegram unreachable (tg-stream down) | tg-stream POST fails | Listener logs the failed alert and continues; alert is dropped (no alert queue — Rooh greps the log) |
| Policy script failure (deterministic) | Exit 1 from script | Log + tg-stream alert; no escalation |
| Policy script timeout (>30s) | SIGTERM from listener | Exit 124; logged as timeout; alert fires |
| HMAC mismatch | Signature compare fails | 403 returned; alert on burst |
| Replay attack | Same delivery ID seen twice | Dedup cache hit; second one logged as dedup_hit and skipped |
| Duplicate event (Gitea retry) | Same as replay | Same — dedup cache covers both |
| Concurrent events same repo | Lock contention | Second event waits up to 5s for lock, then runs; if lock held >5s, second event is queued (file in locks/queue/) and runs after release |
| Channels plugin down | enqueue-judgment.sh POST fails |
Judgment file stays in judgment-inbox/, channels plugin replays on restart; alert fires immediately so Rooh has the info either way |
Parallel-run coexistence with openclaw (Phase 4)
During Phase 4, every sol/* repo has TWO webhooks: openclaw's existing one (no HMAC) and Caret's new one (with HMAC). Both fire on every event.
Idempotency guarantees that prevent double work:
post-repo-audit.shis idempotent. Adding a collaborator that already exists is a no-op. Ensuring a webhook that already exists is a no-op. So if openclaw added Rooh first, Caret's run is a silent no-op. And vice versa.audit-repo-policies.sh --fixchecks file existence before committing. If openclaw already committed the Makefile, Caret sees it and skips. The git commit is content-addressed, so even racing commits resolve to the same content.secret-scan.shis read-only — scanning twice produces the same result.- The dedup cache is per-pipeline. Caret's cache only protects Caret. We don't try to coordinate with openclaw's dedup.
- Locks are per-pipeline. Caret's lock dir is its own; openclaw can't see it. Race between pipelines is resolved by git-level conflict (one wins, the other rebases). In practice this won't happen because the policy fix is fast and idempotent.
Reconciliation rules: the Phase 4 acceptance criterion is "72 hours of dual-run with zero unreconciled divergences." A divergence is any case where Caret's log shows a different outcome than openclaw's. Reconciliation = Caret reads both pipelines' logs nightly, diffs them, alerts on disagreements.
One thing the dual-run does NOT cover: judgment escalations. In Phase 4, judgment escalations from Caret will fire even though openclaw might also be handling the case its own way. Acceptable — judgment cases are rare (errors and explicit opt-ins) and Rooh will see both notifications and can manually deconflict. After Phase 5 cut-over, only Caret's judgment fires.
Rollback procedure
Single command: bash /host/root/.caret/repo-enforcer/rollback.sh
What it does:
- Stops the Caret listener (
tmux kill-session -t caret-repo-enforcer) - Removes Caret's webhooks from every sol/* repo (loops via Gitea API using sol token)
- Verifies openclaw's gateway container is up (
docker ps | grep openclaw-openclaw-gateway-1) - If openclaw container is down, starts it (
docker start openclaw-openclaw-gateway-1) - Sends tg-stream confirmation:
[rollback] Caret repo-enforcer stopped, openclaw restored, N webhooks removed
Time to rollback: ~30 seconds (mostly the loop over sol/* repos to deregister webhooks).
State lost: none. Caret's logs stay on disk for forensics. The dedup cache is irrelevant after rollback. Openclaw's pipeline is unchanged because we never touched it. The only thing that's "gone" is the Caret webhooks on Gitea, and audit-webhooks.sh can re-add them if we resume.
Tested before cut-over: Phase 4 includes a rollback drill. We run rollback once on the test repos before widening to all of sol/*.
Dependencies on openclaw
Hard dependencies (the migration cannot remove these in Phase 1-5 scope):
- None for the deterministic path. Every script in
/host/root/.caret/tools/runs against Gitea directly with the sol token. No openclaw imports, no plugin SDK, no session storage. - None for the listener. Bun + stdlib + nginx. Nothing openclaw.
- None for the judgment path if Channels plugin is the chosen mechanism. Channels is a Claude Code native primitive, not an openclaw thing.
Soft dependencies (things openclaw still owns that we don't replicate):
- The 155 projects in
/root/.openclaw/workspace/projects/. Untouched. - The workspace memory system. Untouched.
- Sub-agent orchestration (Manager/Worker spawn). Untouched.
- The delivery system to Mattermost/Slack/etc. We use tg-stream instead.
- The 28-item heartbeat checklist on the main agent. Untouched.
Dependencies needing Rooh's confirmation:
- Gitea token scope. Sol token currently lacks
read:admin. Recommendation: stay per-repo. Alternative: Rooh elevates the token, we get system-level webhooks. (Phase 2 blocker only if Rooh wants system-level.) - nginx routing. We need a new path
/hooks/caret/giteaonslack.solio.techforwarding to127.0.0.1:18790with raw body intact. Recommendation: Rooh adds the nginx config in Phase 2. Alternative: Caret listener takes its own port directly via a separate hostname. - Channels plugin discovery path. Need to confirm
/host/root/.caret/channels/is where Claude Code looks for plugins, or whether we need a different path.
Deliberate non-goals
This design does NOT:
- Replace openclaw's gateway, plugin SDK, session storage, delivery system, tool policy resolution, RBAC, or control UI.
- Replace openclaw's heartbeat scheduler or cron service. Our cron needs (
audit-webhooks.shperiodic) are handled by a single 6h trigger registered separately. - Replicate openclaw's Manager/Worker/Spawner hierarchy.
- Manage 155 projects' workspaces.
- Provide a web UI.
/healthzreturns JSON; that's the UI. - Replace
gitea-worker,coder-agent,god-agent, or any other named openclaw agent. - Implement RBAC scopes. The bearer token + HMAC are flat — anyone holding the secret can fire any event. This is acceptable because the secret is held by Gitea only.
- Try to be model-agnostic, multi-tenant, or generally reusable. It's a Caret-owned, sol-namespace, single-purpose tool.
If Rooh wants any of these, that's a scope expansion that should bump the design back to draft.