Status: implementation pass complete on branch
review/upgrade-pass(local only, never pushed). All 20 planned items are done. Gates: 365 node tests, 87 TUI tests, tsc clean, 80 frames, binary built and smoked. See "Implementation outcome" at the end for what was fixed, what was verified, and the one item that is only partially fixed.Two further blockers were found and fixed after the first pass: B-23 (the standalone binary accepted no keyboard input) and B-24 (the guarded repair reported success without the gateway running), the latter found only by running against a real OpenClaw install.
Reviewed: CLI core, TUI, web service, install/release flow, website copy. Method: source review + live server, live SSE, published-tarball inspection, frame captures, a compiled standalone binary, and targeted repros. Every finding below has reproducible evidence.
The working directory was empty at session start; /root/clawfix did not exist. This review is of
origin/main @ 6d51d7e fetched from GitHub, so git ls-files --others shows nothing — any
uncommitted local work on your machine was not reviewed. Host is macOS/arm64, Bun 1.3.6,
Node 22.22.3 (not the pinned Bun 1.2.21).
| Gate | Result |
|---|---|
npm test |
349/350 pass, 1 fail — test/install-script.test.js:70, macOS-only (L-16) |
cd cli/tui && bun test |
59/60 pass, 1 fail — test/app.test.tsx:27, macOS-only (L-16) |
cd cli/tui && bunx tsc --noEmit |
clean (exit 0) |
| Frame sweep 60x20 → 140x40 | rendered, no regression at swept sizes — but the sweep misses the broken range (M-9) |
scripts/build-tui-release.mjs + binary smoke |
binary builds and renders real UI (see below); repo smoke script is a no-op gate (M-11) |
Binary smoke, rendered output (not exit code) from clawfix-tui-darwin-arm64.bin:
ClawFix v...
Tell me what is going wrong
Local only · Enter send · Shift+Enter newline · Ctrl+P help · Ctrl+C cancel
OpenClaw diagnostics and guarded repairs
Remote AI analysis always asks before anything leaves this machine.
It rendered — and then had to be killed with SIGTERM, because of B-2.
src/routes/agent-v2.js:163 emits exactly:
sse.send('repair.proposed', { repairId: ..., rationale: ... });cli/tui/src/session-bridge.ts:468 handles it with planFromRaw(event.plan || event), and
planFromRaw returns null unless the object carries planId or id
(cli/tui/src/session-bridge.ts:137-138). The server never sends either. if (plan) is false, so
no repair card, no approval dialog, no error — the user only sees the assistant prose.
Repro (real server event shape, real bridge):
dialog.type : none
repair cards : 0
session.proposeRepair: 0 call(s)
RESULT: NO approval dialog. Remote repair proposal was silently dropped.
This also violates invariant 2. The client is supposed to resolve a server repair ID locally;
session.proposeRepair() is never called on this path. Worse, the code as written would render a
server-authored plan — server-supplied risk, summary, and up to 20 000 chars of
unifiedDiff (session-bridge.ts:164-178) — inside an approval dialog the user reads as local.
Execution still fails closed (repair-engine.js:86-88 rejects an unknown planId), so the impact
is UI deception plus a dead feature, not code execution.
Fix: in the repair.proposed branch, look up a local finding whose repairId === event.repairId,
call session.proposeRepair(finding.id), and build the RepairPlanView from the returned local
plan. Surface an explicit error card when no local finding matches. Never let planFromRaw
consume a server payload.
This is exactly the flow named in your known-open-items as having no e2e coverage — that is why it shipped broken.
cli/tui/src/main.tsx:106-107 sets exitOnCtrlC: false and exitSignals: []. cli/tui/src/app.tsx:128-130
binds Ctrl+C to cancelScan() only. cli/tui/src/keymap.tsx documents no quit key — Ctrl+C is
labelled "Cancel active scan/remote request". Because stdin is in raw mode, Ctrl+C arrives as a key
event, so the SIGINT listener in topLevelExit (main.tsx:65-69) never fires.
Evidence: the compiled binary ran past a 120 s timeout, and a follow-up run survived 12 s of
repeated 0x03 — STILL RUNNING after 12s of Ctrl+C — no quit path. Only SIGTERM/SIGKILL ended it.
Fix: add an explicit quit binding (Ctrl+D, or q when the composer is empty, or double Ctrl+C
within ~1 s) that calls renderer.destroy(), and document it in keymap.tsx + KEY_HINTS.
cli/package.json files allowlists bin/ adapters/ core/ interfaces/ README.md LICENSE — no
tui/. cli/bin/clawfix.js:93 resolves ../tui/src/main.tsx and spawns Bun with
cwd: join(cliDir, '../tui') (clawfix.js:109), a directory that does not exist once installed.
Evidence — published tarball contents:
$ npm pack clawfix@0.11.2 && tar -tzf clawfix-0.11.2.tgz | grep -c tui
0
21 files, zero TUI files. scripts/install.sh installs that same registry tarball
(install.sh:205-233), so both documented install paths lack the TUI.
Worse, the failure message blames the wrong thing. On a machine with Bun on PATH:
$ node package/bin/clawfix.js --tui
Fatal error: spawn /Users/felirami/.bun/bin/bun ENOENT
Bun is installed; the missing thing is the cwd. Users will chase a Bun install that is already there.
Fix: decide and align. Either add tui/ to files (and accept the install-size/Bun-dependency
cost), or keep it out and (a) make runOpenTuiMode detect a missing entry file before spawning and
say "the OpenTUI session is not included in the npm package — download the standalone binary from
", and (b) reword the site so the TUI is presented as a separate binary download.
/fix/sha256 tells users to compare against a GitHub file that does not match. Live:
$ curl -s localhost:3111/fix/sha256
{"sha256":"c8f29823b1534fda1a26c4d95abaa9f17b5998c18077a3f5c1918d59317f37d8",
"note":"...GitHub reference: https://github.com/arcabotai/clawfix/blob/main/SCRIPT_HASH"}
$ cat SCRIPT_HASH
4470a867f26e2c3e729c0a03c109270252e11e2973089984731e2c503b78d96b
SCRIPT_HASH was last touched in 6aa3908; the served script (src/routes/script.js:22-593) has
changed across several releases since. A user who performs the verification honestly gets a
mismatch, and the only lesson available is "ignore the mismatch" — which is worse than no check.
(/install/sha256 is fine: it hashes scripts/install.sh at runtime and points at the source file.
Verified matching: 21816fab….)
Fix: regenerate SCRIPT_HASH in CI on every change to src/routes/script.js, and add a test
asserting sha256(DIAGNOSTIC_SCRIPT) === readFileSync('SCRIPT_HASH'). Until then, point the note at
/fix/sha256 itself rather than a stale repo file.
cli/tui/src/remote-analyzer.ts:84-107 uploads the entire redacted diagnostic to /api/diagnose
inside send(), before the agent call. The privacy dialog the user approves
(session-bridge.ts:351-373) previews only the agent-v2 body, with diagnosticId: null and a
hardcoded conversationId: "pending-session", and risk: "medium" for every repair
(session-bridge.ts:359). The disclosure text says fields are included "when present on a linked
diagnostic" — it never says this action creates that linked diagnostic by uploading it.
Consent is correctly gated (free text does open the dialog first; session-bridge.ts:588-597), and
redactOutbound is applied — the defect is that "Inspect payload" shows something other than what
is sent.
Fix: show both requests in the disclosure — the /api/diagnose upload and the
/api/v2/agent/messages body — and build the preview from the same availableRepairs() the client
actually sends.
cli/core/session.js:152-167. The result.error branch (126-131) correctly clears diagnostic,
issues, findings, summary. The catch branch does not: it sets revision = nextRevision and
scanError, then emits session.scan.committed with findingsCount: findings.length — the previous
scan's findings, now labelled with the new revision.
So after a failed rescan the UI shows old findings as current, and those findings remain
repairable and can be proposed against a revision whose scan never produced them.
Fix: clear the same four fields in the catch branch before emitting.
cli/tui/src/live-session.ts:115 does void bridge.scan(). bridge.scan() re-throws whatever
session.scan() throws (session-bridge.ts:540-550), and session.scan() re-throws on the catch
path (session.js:166). main.tsx:60 maps unhandledRejection to rejectFatal, which kills the
renderer and prints ClawFix TUI failed: …. A first-scan exception — e.g. plainSummary() at
live-session.ts:42-51 dereferencing summary.gateway when summary is undefined — takes the app
down at startup instead of showing a scan error.
Fix: void bridge.scan().catch(() => {}) and let the committed scanError render.
cli/tui/src/app.tsx:164-168 switches to the short hint at width <= 64. The long hint is 62 chars;
with aiLabel it needs 75 ("Local only") to 89 ("Remote (pending consent)") columns, against a
content width of width - 2. Measured:
width 64 -> 1 line width 65..90 -> WRAPS to 2 lines width 92 -> 1 line
The status line is not pushed off screen (the composer column grows), but a transcript row is eaten
and the footer looks broken across the most common 80-column terminal band.
Fix: don't guess a threshold — compute `${aiLabel()} · ${LONG_HINTS}`.length <= dims().width - 2
and fall back to the short hint otherwise. A constant bump to <= 91 also works but re-breaks the
moment a hint or label changes.
cli/tui/scripts/frames.tsx:17-23 samples 140/120/100/80/60 — it never samples 61–79 or 81–99, which
is exactly where M-8 lives. Every state comes from createFakeSession(), so aiMode is always
"local" (the shortest label, the least likely to wrap). And the privacy frame
(frames.tsx:135-146) hand-writes a disclosure — endpointUrl: ".../api/analyze", provider
"ClawFix hosted AI (deepseek v4 flash)" — that does not match buildDisclosureView
(lib/disclosure.ts:50-58), which produces /api/v2/agent/messages and
ClawFix service → OpenRouter → selected model. The UX gate is reviewing copy that ships nowhere.
Fix: add 66/72/88 widths, sweep aiMode variants, and build the privacy frame from the real
buildDisclosureView().
session-bridge.ts:190 uses typeof entry.id === "string" ? entry.id : nextId("msg"), but session
messages are { type, role, text, at } (session.js:177) — no id, ever. So every publish()
re-keys every message, defeating Solid's keyed reconciliation during token streaming, and
idCounter grows without bound.
Fix: assign a stable id in appendMessage() and use it.
scripts/smoke-tui-binary.mjs:57-61 falls back to smokePlain when node-pty is absent. It is
absent (not in package.json), and the CI step (.github/workflows/release-tui.yml:101-107) never
installs it. smokePlain asserts bytes-on-stdout and an exit code, not rendered UI. My run:
{ "ok": true, "mode": "plain", "full": false, "exitCode": 0, "stdoutBytes": 5048 }Note the interaction with B-2: the PTY path writes \x03 and waits for exit (:92-104), so if
node-pty were installed the gate would fail with "timed out waiting for TUI startup". The gate is
green only because it is degrading.
Fix: add node-pty as a dev dependency, fail loudly instead of degrading, and assert rendered
strings ("ClawFix", the composer placeholder) rather than byte counts.
src/routes/agent-v2.js:23,72-78 stores up to 12 messages per conversation, keyed by a
client-supplied conversationId, with createdAt recorded and never read. Nothing evicts. On a
long-lived Railway instance any client can grow server memory with fresh conversation IDs, rate
limits notwithstanding (30/min/IP still allows ~43k conversations/day per IP).
Fix: TTL sweep on createdAt plus a hard cap with LRU eviction.
session.js:137 passes nativeChecks: result?.nativeChecks into normalizeFindings, which does not
accept it (findings.js:129-134). Native checks only survive because diagnostics.js folds them into
localIssues with a nativeCheckId. The header comment on findings.js:1-8 claims it normalizes
"native OpenClaw findings", implying a path that isn't there.
Fix: delete the argument, or implement the branch and cover it with a test.
session-bridge.ts:690-701 refuses high/critical plans. repair-engine.js:84-135 does not check
plan.risk at all, and the plain interface has no equivalent guard. Today the catalog holds exactly
one low-risk entry (repair-catalog.js:74-76), so this is latent — but the invariant is enforced at
the UI layer, which is the wrong layer.
Fix: reject high/critical inside applyPlan and return {status:'blocked', reason:'risk_refused'}.
repair-engine.js:115-135 wraps entry.apply in try/catch but not preflight, verify, or
rollback. A throwing verify rejects the promise after the repair has already been applied, so the
caller reports a generic failure and loses the applyResult — the one case where truthful reporting
matters most.
Fix: wrap each stage; on a verify throw return {status:'verify_failed', verifyError, applyResult}.
- L-16 — Two tests are Linux-only, so the suite is not green on macOS.
test/install-script.test.js:95uses GNUtar --transform; BSD tar rejects it (tar: Option --transform=... is not supported), the helper ignores the failure, and the test dies later onENOENT … clawfix-0.10.0.tgz.cli/tui/test/app.test.tsx:27,39hardcodes@opentui/core-linux-x64, absent on darwin. Fix: use-s ,^,package/,on BSD ortar -Cwith a pre-madepackage/dir; select the native package fromprocess.platform/arch. - L-17 — Landing tense conflict.
src/landing.js:564"Chat-first TUI is now the default session." vs:572"The chat-first TUI ships in the next release." The "New on main" pill (:504-507) and the note are correctly labelled — only the H2 overclaims. Fix: "Chat-first TUI lands as the default session." - L-18 — Node engine drift.
cli/package.jsonengines.node >= 18, root>= 22, site says "Node.js 22+" (landing.js:519). The CLI uses--env-file-if-existsand Node 22 APIs elsewhere. Fix: raise the CLI floor to 22. - L-19 —
runOpenTuiModeconflates "launched" with "exited 0" (cli/bin/clawfix.js:122-126). A TUI that starts, runs, and exits non-zero returnsfalse, so the default path drops the user into a second, full plain session. Fix: track "did the child start" separately from its exit code. - L-20 —
statusLine()can split a surrogate pair (app.tsx:179,full.slice(0, budget - 1)), and measures 🦞 by.lengthrather than display width. - L-21 —
recordsMap inrepair-engine.js:43is never pruned; consumed plans live for the process lifetime. - L-22 — Disclosure defaults are duplicated in
cli/core/privacy.js:15-29andcli/tui/src/lib/disclosure.ts:5-19. They agree today; the frames harness (M-9) shows how quietly they drift. Fix: single source, imported by the TUI.
Worth stating explicitly, since these are the load-bearing claims:
- agent-v2 contract holds under live testing. Banned field →
{"error":"Field \"shell\" is not allowed"}; 7-charconversationId→ rejected; 33 repairs →too many availableRepairs; valid request streamsagent.meta → assistant.delta × N → agent.done.propose_repairis a closed enum built from client-supplied IDs (contract.js:122-150) and re-validated against the allowlist (contract.js:156-189). - Consent gating for free text is correct. Deterministic commands bypass
(
session-bridge.ts:583); everything else opens the dialog with focus defaulting tostay-local(:368), andrunRemotere-checks consent (:434-437). - Offline analyzer is deterministic — unmatched input returns "Unknown local command. Type help for
the deterministic command list." (
offline-analyzer.js:106-108), never a generated answer. - Approval defaults to Cancel (
session-bridge.ts:381) and success copy is gated onstatus === "applied"(:718);rejected/blocked/verify_failedall produce failure cards (:731-751). The bridge callsapplyRepair({planId, approvalToken, findingId, ctx})— noapproveRepairfallback anywhere in the tree. - Plan binding is sound: single-use token consumed before any further check
(
repair-engine.js:91-111), revision + fingerprint checked,verifyreads runtime evidence. - AI abuse gate is fail-closed.
isPaidAIEnabled(security.js:76-79) plus a timing-safe bearer check increateAIRequestGuard(security.js:105-108); concurrency + daily budget + per-IP limits. - Site claims that check out:
npx clawfix@0.11.2resolves (npmlatest= 0.11.2); npm attestations exist (dist.attestations, SLSA provenance v1); "21-file allowlisted package" is exactly right; "49 known issue detectors" matchesKNOWN_ISSUES.length === 49; TUI binaries +TUI-SHA256SUMSare attached to the v0.11.2 release; install copy is download → verify → bash throughout, with nocurl | bashanywhere (enforced byinstall-script.test.js:62).
Ordered by value-at-risk. Nothing here is started — awaiting your go.
| # | Item | Effort | Risk if done | Risk if skipped |
|---|---|---|---|---|
| 1 | B-1 resolve repair.proposed locally via session.proposeRepair |
M | Low — new code path, fully unit-testable | The product's headline flow does not work |
| 2 | B-2 add a quit binding + document it | S | Low | Shipped binary traps users |
| 3 | H-6 clear findings on the scan catch path | S | Low | Stale findings repairable under a new revision |
| 4 | M-7 .catch() the autoscan |
S | Very low | Startup scan error kills the TUI |
| 5 | H-4 generate SCRIPT_HASH in CI + equality test |
S | Low | Documented integrity check fails for honest users |
| 6 | H-5 disclose the /api/diagnose upload in the privacy dialog |
M | Low, copy + preview only | Consent UI understates the upload |
| 7 | H-3 decide TUI packaging; fix the misleading Bun error either way | M | Medium — shipping tui/ changes package size and adds a Bun dependency; your call |
Site promises a TUI that npx cannot run |
| 8 | M-14/M-15 enforce risk refusal and wrap stages in repair-engine |
S | Low | Safety invariant enforced at the UI layer only |
| 9 | M-8 measure the hint line instead of thresholding | S | Low | Footer wraps across the 65–91 column band |
| 10 | M-12 TTL + cap on conversations |
S | Low | Unbounded server memory |
| 11 | L-16 make the two tests platform-agnostic | S | Low | Suite cannot go green on macOS |
| # | Item | Effort | Risk |
|---|---|---|---|
| 12 | M-11 add node-pty, fail instead of degrading, assert rendered strings |
S | Low — will surface B-2 immediately, which is the point |
| 13 | M-9 widen the frame sweep; drive the privacy frame from buildDisclosureView |
S | Low |
| 14 | New: scripted PTY e2e — scan → consent → remote chat (stub SSE) → repair proposal → approval → apply → verify | L | Medium (fixture-heavy) — but this is the gap that let B-1 ship |
| 15 | M-10 stable message ids | S | Low |
| 16 | L-17/L-18 landing tense, Node engine floor | S | Very low |
| 17 | L-19 separate "started" from "exit code" in mode dispatch | S | Low |
| 18 | L-20/L-21 grapheme-safe truncation; prune consumed plans | S | Very low |
| # | Item | Effort | Risk |
|---|---|---|---|
| 19 | M-13 the dead nativeChecks argument (session.js:137) and the misleading findings.js header claim |
S | Very low |
| 20 | L-22 the duplicated disclosure constants in lib/disclosure.ts; import from cli/core/privacy.js |
S | Low — only if TUI packaging (item 7) keeps cli/core reachable |
Items 1–4 together: they are the two blockers plus the two one-line correctness fixes, they touch
session-bridge.ts, session.js, app.tsx, live-session.ts only, and each is covered by a test I
can add in the same commit. Item 14 (the PTY e2e) should follow immediately so B-1 cannot regress.
Open decision for you (item 7): ship tui/ inside the npm package, or keep the TUI
binary-only and reword the site? I recommend binary-only — shipping the TUI would add a hard Bun
dependency to an npx-first tool — but it means editing the hero copy, so it's your call.
Branch review/upgrade-pass, 9 commits, local only — nothing pushed, no Railway, no npm publish.
| Gate | Before | After |
|---|---|---|
npm test |
349/350 | 365/365 |
cd cli/tui && bun test |
59/60 | 87/87 |
bunx tsc --noEmit |
clean | clean |
| Frame sweep | 40 frames, 5 widths | 80 frames, 8 widths, zero footer wraps |
| Build + smoke | passed without asserting anything | passes, asserting rendered UI |
| # | Finding | Evidence |
|---|---|---|
| B-1 | Remote repair proposals dropped | e2e test drives the real flow; confirmed to fail against the pre-fix bridge |
| H-3 | TUI absent from npm package | --tui on a staged published layout now names the standalone binary; test asserts it |
| H-4 | Stale SCRIPT_HASH |
regenerated; guard test fails on drift (verified by corrupting it) |
| H-5 | Consent dialog understated the upload | preview built from the real request; /api/diagnose disclosed |
| H-6 | Stale findings after a thrown scan | test asserts the cleared state and that the repair can no longer be proposed |
| M-7 | Autoscan rejection killed the TUI | .catch() on the autoscan |
| M-8 | Footer wrapped at widths 65–91 | 303 renders across 3 aiModes × widths 40–140: single-row footer everywhere |
| M-9 | Frame sweep could not see M-8 | widths 66/72/88 and consent-pending/remote states added; real disclosure rendered |
| M-10 | Unstable transcript keys | ids assigned in appendMessage |
| M-11 | Smoke gate proved nothing | asserts rendered UI; regression test feeds it a non-rendering binary |
| M-12 | Unbounded conversation store | TTL + cap with oldest-first eviction, unit tested |
| M-13 | Dead nativeChecks argument |
removed; comment corrected |
| M-14 | Risk refusal only in the TUI | enforced in applyPlan; nothing runs, not even preflight |
| M-15 | Stage exceptions escaped applyPlan |
each stage returns a structured outcome; applyResult preserved |
| L-16 | Suite unrunnable on macOS | both tests fixed; suite green on macOS |
| L-17..L-22 | Copy, engines, mode dispatch, truncation, plan retention, disclosure drift | all addressed; parity test added |
B-2 — TUI quit path. Fixed and verified from source: Ctrl+D, and Ctrl+C when idle, exit in
~0.1s with exit code 0 and the terminal restored (ICANON/ECHO back on). Ctrl+C still cancels
while work is in flight. The decision is a pure resolveGlobalKeyAction(), unit tested, because
bun test swallows raw Ctrl+C.
It does not fix the standalone binary, for the reason below.
Discovered while verifying B-2. The bun --compile build renders the UI correctly but never
receives a key. Driving both builds through a PTY with the pty buffer drained, typing ZZTOP:
compiled binary : typed text echoed into UI: False bytes rendered after typing: 0
from source : typed text echoed into UI: True bytes rendered after typing: 132
So in the shipped binary the composer cannot be typed into, no dialog can be answered, no repair can be approved, and — the symptom that led here — it cannot be quit. It renders a session that cannot be used. Reproduced consistently across rebuilds; unaffected by the B-2 fix, which is why that fix lands only for the source path.
Not fixed: the cause is below ClawFix, in how OpenTUI's input layer behaves under bun --compile
(cli/tui/bunfig.toml preloads @opentui/solid/preload, which a compiled binary cannot resolve
— the most likely culprit, unconfirmed). Fixing it means changing how the binary is built, which
is outside the approved plan.
Suggested next step: treat this as the top priority. Reproduce with a minimal OpenTUI
bun --compile app to isolate it from ClawFix, then either embed the preload plugin at build
time or raise it upstream. Until then the GitHub release binaries render but do not work, and
the site should not present them as a usable way to run ClawFix.
Note this was invisible to every existing gate: verify-tui-artifact.mjs checks the file, and the
smoke script only proved the process started — even after this pass hardened it to assert rendered
output, rendering is exactly what still works. Only driving real keys through a PTY catches it.
An earlier round of this investigation reached wrong conclusions — that renderer.destroy()
blocks, that process.exit() does not fire — because the PTY harness was not draining the master,
so writes to fd 1 blocked on a full buffer. Once the harness drained continuously, the source
build exited cleanly and those conclusions dissolved. The findings above come from the corrected
harness.
Everything below ran on a provisioned Linux sandbox with a real OpenClaw install, not a fixture.
| Check | Result |
|---|---|
| Node suite on Linux | 378 tests, 376 pass, 0 fail (2 platform-skipped) |
clawfix --json against real OpenClaw |
found the binary, version, config, and 7 real issues including native openclaw doctor and security-audit findings |
| Hostname privacy | hostHash 8 chars; real hostname, home path and /root absent from the diagnostic |
| Redaction of a real secret | a real 48-char gateway.auth.token appears 0 times; the section renders as "auth": "***REDACTED***" |
| Guarded repair, full flow | plan built locally, wrong token → invalid_token, replay → token_reused, outcome truthful against a rescan |
| TUI on Alpine/musl | cannot run — see the limitation below |
checkGatewayRunning() decided liveness from pgrep -f 'openclaw.*gateway'. On a real install
that pattern is wrong in both directions:
- a gateway started by
openclaw gateway runre-execs with the bare argvopenclaw, so it never matched a real gateway; - ClawFix's own
openclaw gateway statusprobe — launched concurrently with the pgrep in the samePromise.all— always matched.
Reproduced with nothing listening on 18789:
verify() -> {"ok":true,"evidence":{"running":true,"pid":"6911\n6926"}}
$ ss -ltn | grep 18789 # nothing listening
verify.ok === true makes applyPlan return applied, and the UI then prints "Repair applied
… Verification passed against local detectors." A repair that changed nothing would be reported
as a success. The mirror image also held: preflight returned gateway_already_running while
the gateway was down, so the only repair ClawFix ships could never be applied.
Fixed by making the listening port the verdict — the same evidence the diagnostics core already
reports as portListening. Status prose and PIDs remain evidence, never the verdict, and the PID
probe now excludes ClawFix's own sub-commands and the current process.
Verified against a real gateway, both states:
gateway down -> verify {"ok":false,"listening":false,"pid":""} preflight {"ok":true}
gateway up -> verify {"ok":true,"listening":true,"pid":"8855"} preflight {"ok":false,"reason":"gateway_already_running"}
A full repair run with the service unavailable now reports verify_failed, and a rescan agrees
with the reported outcome.
@opentui/core resolves its native library as @opentui/core-linux-x64 with no musl detection.
On Alpine the glibc libopentui.so fails to load (ld-linux-x86-64.so.2 missing), gcompat does
not help, and installing @opentui/core-linux-x64-musl does not either — the loader asks for the
glibc package by name and fails with Cannot find module '@opentui/core-linux-x64'. All 23 TUI
render tests fail there for this one reason.
The CLI core is unaffected: the full node suite and the plain interface work on musl, which is
what npx clawfix uses. But an operator running OpenClaw in an Alpine container cannot use the
TUI from source or from the release binary. Worth deciding whether to ship a musl target or to
state the requirement on the site; it is an upstream OpenTUI limitation either way.
The route checked only that svix-id, svix-timestamp and svix-signature were present —
never that the signature was valid — and skipped even that when no secret was configured. It
sends mail from arca@arcabot.ai to a real inbox, so anyone who knew the URL could forge an
email.received event and choose the from, subject and body.
Verified against the running server before and after: a forged delivery with junk svix-*
headers now returns 401 (Rejected Resend webhook: not_configured), a correctly signed
delivery returns 200, and a forged signature against a configured secret returns 401.
Signatures are now verified as Svix specifies — HMAC-SHA256 over id.timestamp.rawBody keyed by
the base64 secret, constant-time compare, and a timestamp tolerance that bounds replay of a
captured delivery.
Two related issues in the same path: email_id came from the webhook body and was interpolated
into a Resend API path called with the full-access key (now constrained to an opaque id and
encoded), and inbound from/to/subject went unescaped into the forwarded HTML.
It hashed JSON.stringify(req.body) — the parsed object re-serialized, not the bytes Lemon
Squeezy signed. Key order, spacing and unicode escaping all differ, so a genuine signature could
never match; the comparison was also !== rather than constant-time, and verification was
skipped entirely when no secret was set, accepting any POST as a payment notification.
The raw body is now captured in the json parser for /webhook routes and both endpoints fail
closed on an unset secret, a missing raw body, or a bad signature.
@opentui/core resolves its native library by a hardcoded package name per platform with no
musl detection, so on Alpine it asks for @opentui/core-linux-x64 and fails to load. The new
linux-x64-musl target compiles with bun-linux-x64-musl and stages the musl library under
the asset key the runtime actually asks for.
Verified on a real Alpine 3.21 machine, driving the compiled binary through a PTY:
{ "ok": true, "mode": "pty", "rendered": true, "acceptsInput": true, "exitCode": "0" }
Added to the release matrix, so Alpine-based OpenClaw containers get a working binary.
Payments are received but never recorded — the payment surface was removed entirely. Original finding: src/routes/payment.js handles order_created
with // TODO: Mark fix as paid in database. With Lemon Squeezy configured a user can be charged
$2 and nothing in the system records it or unlocks anything. The checkout path is otherwise
sound. This needs a product decision — record and gate on payment, or remove the paid path until
it is real — so it is flagged rather than guessed at.
/api/checkout created real Lemon Squeezy sessions while order_created was
// TODO: Mark fix as paid in database, so a configured store could charge $2 and record
nothing. Removed the checkout endpoint, the payment page, the payment webhook and the unused
Lemon Squeezy verifier. Svix verification stays — the Resend inbound-email webhook uses it.
security-regressions now asserts those routes stay 404.
Each scenario resets the box to a valid config, breaks one real thing, and scans.
| # | Broken | ClawFix reported | Detected |
|---|---|---|---|
| 1 | Gateway process killed | [critical] Gateway is not running |
yes |
| 2 | Port 18789 held by an unrelated process | [critical] Port conflict detected + Gateway port 18789 is occupied by python3 (PID 726), but OpenClaw cannot reach it |
yes |
| 3 | openclaw.json corrupted |
[high] JSON5 parse failed: SyntaxError: JSON5: invalid character 'i' at 1:8 |
yes |
| 4 | Gateway auth set to none on loopback |
[critical] Gateway auth missing on loopback + [medium] Gateway HTTP APIs are reachable without auth |
yes |
| 5 | update.auto.enabled = true |
[medium] Auto-update enabled (risk of restart loops) |
yes |
Detection: 5/5. Scenario 2 is notably good — it names the squatting process and PID.
The guarded-repair pipeline ran end to end against the real install: plan built locally,
invalid_token rejected, single-use token enforced (token_reused), apply executed the real
openclaw gateway restart, verify re-checked the port, and the outcome was reported as
verify_failed — with a rescan agreeing the gateway was still down.
That is the correct behaviour, and it is the B-24 fix working: before this pass the same run
would have claimed applied.
But it is not a fix, and the reason matters:
ClawFix ships exactly one executable repair, and it cannot work in a container.
gateway-not-running invokes openclaw gateway restart, which needs systemd or launchd. On
this Alpine host OpenClaw answered "Gateway service disabled … systemd user services are
unavailable", so the repair could not succeed no matter how correct the plumbing is. Nothing in
the catalog can repair scenarios 2–5 either: the four other real problems ClawFix detects have
no repair at all.
So today ClawFix is a strong diagnostic with one repair that only applies on service-managed hosts. The site and README should say that plainly, and the catalog is the obvious place to invest next — port-conflict and config-invalid are both detected precisely enough to repair.
- A finding surfaced as
[critical] timeoutwith no other context — a native collector timing out is being presented to the user as a critical OpenClaw problem (cli/core/diagnostics.js,text: finding.title || finding.message). - Raw parser output is used as a user-facing title:
JSON5 parse failed: SyntaxError: JSON5: invalid character 'i' at 1:8. Accurate, but it is a stack-trace fragment where a sentence belongs.
The catalog held one repair that needed a service manager. Two more were added, both using OpenClaw's own supported commands and both verified against a real OpenClaw 2026.6.11 install:
| Repair | Does | Verified by | Risk |
|---|---|---|---|
auto-update-enabled-warning |
openclaw config set update.auto.enabled false |
reads the key back through config get |
low |
gateway-loopback-no-auth |
reuses or generates a token, then sets token mode | verifies mode and token presence without recording token material; client usability remains an external OpenClaw semantic | medium |
Repairable findings went from 1 to 3. On a real install, broken deliberately:
auto-update: before "true" → applied, verify {"ok":true,"current":"false"} → finding gone
gateway auth: before "none" → applied, verify {"ok":true,"mode":"token"} → finding gone
gateway-loopback-no-auth is deliberately medium risk, not low: existing clients stop working
until they carry the new token, and the preview says so.
Verification reads one config key through OpenClaw rather than parsing openclaw.json, so repair
evidence stays narrow — and the auth repair never pulls the token into a repair record.
The compiled musl binary was driven through a PTY with a terminal emulator attached, on a real broken install. Both of these were invisible to unit tests and frame captures.
The sidebar's numbers did not match the numbers fix <#> accepts. The sidebar sorted findings
by severity and renumbered its own view 1–4, while fix <#> and explain <#> index the unsorted
findings list. Reading "3. Auto-update enabled" and typing fix 3 reached an advisory finding
and answered "This finding has no reviewed automatic repair." The sidebar now carries each
finding's real position — criticals still lead, but the numbers are the ones the commands take.
The status line printed the revision twice, which pushed the finding count off the end:
before: 🦞 ClawFix v0.11.2 · revision f3e9479b-… · Revision f3e9479b-…
after: 🦞 ClawFix v0.11.2 · Revision f3e9479b-b52c-456e-8d0f-f5f25ba3b2e4 · 6 findings · AI consent required
The bridge's status already begins with the revision; the extra prefix was redundant.
Sidebar listed "4. Gateway auth missing on loopback" → typed fix 4 → approval dialog showed
Risk: medium · gateway-loopback-no-auth with the composer locked and focus defaulting to
Cancel → approved → openclaw config get gateway.auth.mode returned token.
That is the whole chain — detect, propose, review, approve, apply, verify — fixing a real problem on a real OpenClaw install through the shipped interface.
Three more config repairs, all built on openclaw config set with the read-back as verification,
all sharing one configToggleRepair contract so the guard rails cannot drift apart:
| Repair | Key | Verified on a real install |
|---|---|---|
auto-update-enabled-warning |
update.auto.enabled → false |
applied, finding gone |
gateway-loopback-no-auth |
gateway.auth.mode → token |
applied, finding gone |
no-hybrid-search |
agents.defaults.memorySearch.query.hybrid.enabled → true |
applied, current:"true", finding gone |
no-memory-flush |
agents.defaults.compaction.memoryFlush.enabled → true |
applied, current:"true", finding gone |
Four of the five now work without a service manager, so a containerised OpenClaw is no longer a
host where ClawFix can only look and shrug. gateway-not-running still needs systemd or launchd —
that is OpenClaw's restart path, not something ClawFix can route around.
Each toggle refuses an unreadable key (config_state_unknown) rather than treating "cannot read"
as "false", and rollback restores the previous value.
The obvious next repair is the port conflict — ClawFix already knows the squatting PID. It was not
added, because every version of it ends in killing a process ClawFix does not own. A repair that
terminates an unrelated service is not a guarded repair, whatever the preview says. If this is
wanted later, the safe shape is: refuse unless the listener is itself a stale OpenClaw process,
and even then prefer OpenClaw's own --force restart over a kill.
[critical] timeout came from nativeStatus.gateway.error being used directly as an issue title.
A probe that gave up was presented as a critical OpenClaw fault named "timeout". The same pattern
turned a validator message into the title JSON5 parse failed: SyntaxError: JSON5: invalid character 'i' at 1:8.
Both now carry an actionable headline with the upstream text as detail:
[high] OpenClaw config schema validation failed
detail: JSON5 parse failed: SyntaxError: JSON5: invalid character 'i' at 1:8
Fixing the title broke the doctor-findings dedup, which had been matching on the raw message — so the same problem briefly appeared twice, once well-titled and once raw. Caught on the real machine, not in tests: the dedup now compares the detail as well as the headline.