From 146861d2864e3031c247ab9c7add9ad0d427f70c Mon Sep 17 00:00:00 2001 From: Cad from Arca Date: Mon, 27 Jul 2026 13:07:30 +0000 Subject: [PATCH 01/21] release: prepare ClawFix 0.12.0 --- .github/workflows/ci.yml | 24 ++++ .github/workflows/release-tui.yml | 59 +++++++- CHANGELOG.md | 30 ++++ README.md | 23 +-- cli/README.md | 2 +- cli/bin/clawfix.js | 11 +- cli/bin/security.js | 4 +- cli/core/repair-catalog.js | 16 +++ cli/core/repair-engine.js | 17 +++ cli/interfaces/plain.js | 134 ++---------------- cli/package.json | 4 +- cli/tui/scripts/frames.tsx | 16 +-- cli/tui/src/live-session.ts | 2 +- cli/tui/src/remote-analyzer.ts | 73 ++++++++-- cli/tui/src/session-bridge.ts | 6 + cli/tui/test/new-layout.test.tsx | 12 +- cli/tui/test/remote-analyzer-boundary.test.ts | 122 ++++++++++++++++ cli/tui/test/responsive.test.tsx | 2 +- package-lock.json | 4 +- package.json | 4 +- scripts/install.sh | 2 +- scripts/smoke-tui-interactive.mjs | 13 +- src/ai.js | 18 ++- src/conversation-store.js | 25 ++++ src/db.js | 63 ++++++++ src/landing.js | 58 ++++---- src/routes/agent-v2.js | 38 +++-- src/routes/chat.js | 32 +++-- src/routes/results.js | 6 +- src/routes/script.js | 2 +- src/routes/webhooks.js | 15 +- test/agent-conversation-store.test.js | 13 +- test/ai-abort.test.js | 36 +++++ test/chat-conversation-store.test.js | 46 ++++++ test/cli-mode-contracts.test.js | 8 +- test/repair-catalog.test.js | 17 +++ test/repair-engine.test.js | 30 ++++ test/security-regressions.test.js | 21 +++ test/tooling-regressions.test.js | 50 ++++++- test/tui-release-scripts.test.js | 55 ++++++- test/webhook-idempotency.test.js | 37 +++++ 41 files changed, 892 insertions(+), 258 deletions(-) create mode 100644 cli/tui/test/remote-analyzer-boundary.test.ts create mode 100644 src/conversation-store.js create mode 100644 test/ai-abort.test.js create mode 100644 test/chat-conversation-store.test.js create mode 100644 test/webhook-idempotency.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0768d5..69ff716 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,6 +66,30 @@ jobs: npm pack ./cli --dry-run --json --cache /tmp/clawfix-npm-cache > "$manifest" node scripts/verify-cli-package.mjs "$manifest" + tui: + name: TUI tests and typecheck + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.2.21 + + - name: Install TUI dependencies + working-directory: cli/tui + run: bun install --frozen-lockfile + + - name: Test TUI + working-directory: cli/tui + run: bun test + + - name: Typecheck TUI + working-directory: cli/tui + run: bunx tsc --noEmit + container: name: Production container runs-on: ubuntu-latest diff --git a/.github/workflows/release-tui.yml b/.github/workflows/release-tui.yml index 7e308fc..e069025 100644 --- a/.github/workflows/release-tui.yml +++ b/.github/workflows/release-tui.yml @@ -5,11 +5,6 @@ name: Release TUI binaries on: workflow_dispatch: - inputs: - targets: - description: Comma-separated TUI targets - required: false - default: linux-x64,linux-x64-baseline push: tags: - "v*" @@ -24,8 +19,33 @@ concurrency: cancel-in-progress: false jobs: + test-tui: + name: TUI tests and typecheck + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.2.21 + + - name: Install TUI dependencies + working-directory: cli/tui + run: bun install --frozen-lockfile + + - name: Test TUI + working-directory: cli/tui + run: bun test + + - name: Typecheck TUI + working-directory: cli/tui + run: bunx tsc --noEmit + build-tui: name: tui-${{ matrix.target }} + needs: test-tui runs-on: ${{ matrix.runner }} strategy: fail-fast: false @@ -80,7 +100,7 @@ jobs: windows-x64) pkg="@opentui/core-windows-x64" ;; *) echo "unknown target"; exit 1 ;; esac - bun add --optional "${pkg}@0.4.5" || true + bun add --optional --no-save "${pkg}@0.4.5" - name: Build standalone binary env: @@ -117,6 +137,15 @@ jobs: # a session nobody can type into. CLAWFIX_TUI_REQUIRE_PTY=1 node scripts/smoke-tui-interactive.mjs "$launcher" + - name: Smoke musl binary on Alpine + if: matrix.target == 'linux-x64-musl' + run: | + docker run --rm \ + -v "$PWD:/work" \ + -w /work \ + node:24-alpine \ + sh -lc 'apk add --no-cache python3 >/dev/null && launcher=/work/dist/tui/clawfix-tui-linux-x64-musl && test -x "$launcher" && node scripts/smoke-tui-binary.mjs "$launcher" && CLAWFIX_TUI_REQUIRE_PTY=1 node scripts/smoke-tui-interactive.mjs "$launcher"' + - name: Upload artifact uses: actions/upload-artifact@v4 with: @@ -174,6 +203,24 @@ jobs: test -s TUI-SHA256SUMS ) + - name: Attest TUI release tarballs + uses: actions/attest-build-provenance@v3 + with: + subject-path: release-dist/*.tar.gz + + - name: Wait for CLI workflow to create the GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + for attempt in $(seq 1 60); do + if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + exit 0 + fi + sleep 10 + done + echo "::error ::CLI release was not created within 10 minutes" + exit 1 + - name: Upload to GitHub Release uses: softprops/action-gh-release@v2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index e11ace0..ebe5dde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,36 @@ ClawFix follows semantic versioning for the published npm CLI. GitHub releases a ## Unreleased +## 0.12.0 - 2026-07-27 + +- Expanded the guarded repair catalog from 1 to 5 entries: `gateway-not-running` (needs systemd/launchd), plus four config toggles applied and verified through OpenClaw's own CLI — `auto-update-enabled-warning`, `gateway-loopback-no-auth`, `no-hybrid-search`, and `no-memory-flush`. Every toggle shares one `configToggleRepair` contract with a read-back verification and a rollback path. +- Fixed `checkGatewayRunning()` reporting a repair as applied when nothing was actually listening on the gateway port; the listening port is now the sole verdict, never process-status prose or PIDs. +- Closed two unauthenticated webhook holes: the Resend/Svix inbound-email relay and the Lemon Squeezy payment webhook both previously skipped signature verification when unconfigured, or verified against the re-serialized body instead of the raw bytes. Both now fail closed on a missing secret, missing raw body, or bad signature, with constant-time comparison. +- Removed the payment surface entirely (`/api/checkout`, the payment page, the Lemon Squeezy webhook handler, and the unused verifier) after finding a configured store could charge a customer without recording the payment anywhere. `clawfix.dev` has no paid tier and no payment path. +- Added a Linux musl (Alpine) target to the standalone OpenTUI binary build and release matrix; ClawFix now runs on Alpine-based OpenClaw containers. +- Fixed the remote-repair flow silently dropping server-proposed repairs; the TUI now resolves `repair.proposed` events against a local finding before opening the approval dialog, and never renders a server-authored plan as if it were local. +- Added a TUI quit path (Ctrl+D, or Ctrl+C while idle); previously only `SIGTERM`/`SIGKILL` could end a session. +- Fixed the TUI sidebar renumbering findings by severity while `fix <#>`/`explain <#>` indexed the unsorted list, so a number shown in the sidebar could resolve to the wrong finding. +- Fixed the TUI status line printing the scan revision twice, which pushed the finding count off the end of the line. +- Findings are no longer titled with raw machine text (a timed-out probe surfaced as `[critical] timeout`; a parser exception surfaced its stack-trace fragment as the headline). Both now carry an actionable headline with the original text preserved as detail. +- Verified 5/5 real break-fix scenarios end-to-end against a live OpenClaw install: gateway killed, port conflict, corrupted config, loopback auth disabled, and auto-update left on all detect correctly; the repair pipeline reports `applied` or `verify_failed` truthfully against a rescan in every case. +- The port conflict ClawFix detects (a squatting process holding the gateway port) is deliberately not an automatic repair — every version of it ends in killing a process ClawFix does not own. Left as diagnosis and guidance only. +- Fixed `gateway-loopback-no-auth` reporting `applied` when `openclaw config set gateway.auth.mode token` succeeded but the follow-up `doctor --fix --generate-gateway-token` failed. The repair now restores the previous auth mode after token-generation failure, and the engine treats any nonzero or timed-out apply result as `verify_failed` before a later state check can create fake success. +- Reviewed open issue #8 (four detector candidates from superseded PR #2: persisted `__OPENCLAW_REDACTED__` placeholders, incomplete global npm installs, config written by a newer OpenClaw than the installed CLI, and structured update availability). The historical incident evidence is real, but the old patch predates the current diagnostics core and provides no current-main synthetic contracts. Per the issue's acceptance criteria, these remain separate focused follow-ups rather than being copied into 0.12.0 without fresh positive/negative fixtures. +- Added a second consent check at the TUI network boundary. A direct adapter caller can no longer upload a diagnostic or chat message with `consentGranted: false` even if a future UI refactor bypasses the privacy dialog. +- Fixed the standalone TUI ignoring the documented `CLAWFIX_API` custom-server variable and `CLAWFIX_API_TOKEN`; protected self-hosted servers now receive the bearer header consistently with the portable CLI. +- Expanded text redaction to cover generic bearer credentials, complete `Cookie:` headers, and Slack `xox*` tokens before diagnostics, errors, or chat content cross the network boundary. +- Bounded both agent-v2 and legacy `/api/chat` conversation stores by last activity and hard caps on every response path, including AI-disabled fallback and provider failures. Active long-lived chats no longer expire merely because their original creation time crossed the TTL. +- Connected client disconnects to upstream AI cancellation so abandoned requests stop provider work and release the shared concurrency slot instead of running to the provider timeout. +- Escaped results-page error text before assigning HTML, closing a DOM-injection sink. +- Fixed the portable interactive CLI omitting catalog-only repairs from its authorization set and replaced gateway-specific success/failure copy with repair-accurate outcomes. +- Added mandatory TUI tests and typechecking to CI and release builds, made native dependency installation fail closed, added real Alpine PTY smoke tests for the musl artifact, and attached GitHub build provenance attestations to TUI tarballs. +- Redacted free-text TUI messages in both the exact consent preview and final request, then bounded remote SSE time, bytes, incomplete-frame buffering, and assistant text so a hostile or wedged peer cannot keep the UI busy or grow memory indefinitely. +- Strengthened the release PTY smoke to require the literal typed probe in the composer and exit status zero; periodic redraws and crash-on-exit binaries now fail the gate. +- Disabled unsafe `fix-all` batch execution and changed remaining legacy repair prompts to default no. Every executable repair now needs its own current-state plan, explicit approval, verification, and rollback outcome. +- Added durable Resend `svix-id` idempotency when PostgreSQL is configured, a bounded single-process fallback when it is not, and retryable 503 behavior after failed forwarding. +- Corrected public repair, package-boundary, privacy, retention, and artifact-integrity language; the site no longer promises universal backups, temporary database retention, model-authored coverage, or reproducible binaries it cannot prove. + ## 0.11.2 - 2026-07-24 - Shipped the full post-0.10.0 mainline as one end-to-end release: installer, hosted service, npm CLI, and OpenTUI standalone assets. diff --git a/README.md b/README.md index 057c3b7..e3b0172 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,8 @@ clawfix ### Alternative: npx ```bash -npx clawfix@0.11.2 -npx clawfix@0.11.2 --dry-run +npx clawfix@0.12.0 +npx clawfix@0.12.0 --dry-run ``` ### Legacy diagnostic script @@ -63,7 +63,7 @@ Maintenance records are public in the [changelog](CHANGELOG.md), [releases](http 1. **Run one command** — The diagnostic script scans your OpenClaw config, logs, plugins, ports, and listener ownership 2. **Evidence is correlated** — Native config validation, status, Doctor, security audit, and 49 deterministic patterns run first. Optional AI analysis can explain unmatched problems when available -3. **Review & apply** — You get a commented fix script. Nothing runs without your approval +3. **Review & apply** — Catalog repairs show a reviewed plan, require explicit approval, then report verification and rollback. Legacy hosted findings provide reviewable script guidance Failures and warnings are counted as issues. Performance and quality tuning is shown separately as optional optimization advice. @@ -139,13 +139,12 @@ curl --fail --show-error --silent https://clawfix.dev/fix/sha256 ### Design Decisions - **Consent by default**: The CLI asks before upload unless you explicitly use `--yes`, `-y`, or `CLAWFIX_AUTO=1` -- **Fix scripts are not auto-executed**: They're saved to `/tmp` for your review -- **No model-authored shell**: AI output is advisory only; executable repairs come from reviewed deterministic snippets -- **Repair validation**: Combined deterministic scripts must pass `bash -n`; hosted builds also run ShellCheck and fail closed on validator errors +- **Explicit repair approval**: Catalog repairs show a reviewed plan and default to no; legacy hosted scripts stay reviewable and do not auto-run +- **No model-authored shell**: AI output is advisory only; executable repairs come from reviewed deterministic code +- **Repair validation**: Catalog repairs verify the postcondition and report rollback; hosted scripts must pass `bash -n`, and hosted builds also run ShellCheck - **Feedback is opt-in**: Repair scripts only report outcomes when run with `CLAWFIX_SEND_FEEDBACK=1` -- **Auto-backup**: Every fix script backs up `openclaw.json` before modifying +- **Bounded rollback**: Config-changing catalog repairs capture prior values and attempt rollback; legacy fix scripts back up `openclaw.json` before modifying it - **Open source**: [The CLI, server, and diagnostic script](https://github.com/arcabotai/clawfix) are public under the MIT license -- **npx over curl**: We recommend `npx clawfix` as the primary method because the source is auditable on [npm](https://www.npmjs.com/package/clawfix) and GitHub ### CLI Options @@ -225,8 +224,12 @@ The scenario suite restores changed configuration and processes in a `finally` b |----------|--------|-------------| | `/` | GET | Landing page | | `/fix` | GET | Diagnostic bash script | -| `/fix/sha256` | GET | Script hash for verification | -| `/api/diagnose` | POST | Submit diagnostic data | +| `/fix/sha256` | GET | Diagnostic script hash for verification | +| `/install` | GET | Pinned download-and-verify installer | +| `/install/sha256` | GET | Installer hash for verification | +| `/api/diagnose` | POST | Submit consented diagnostic data | +| `/api/chat` | POST | Legacy consented remote chat stream | +| `/api/v2/agent/messages` | POST | Bounded agent-v2 SSE conversation | | `/api/fix/:fixId` | GET | Retrieve fix results | | `/api/stats` | GET | Service statistics | | `/api/feedback/:fixId` | POST | Report if fix worked | diff --git a/cli/README.md b/cli/README.md index ada80d8..bca9c39 100644 --- a/cli/README.md +++ b/cli/README.md @@ -21,7 +21,7 @@ clawfix Or with npx: ```bash -npx clawfix@0.11.2 +npx clawfix@0.12.0 ``` ## What it does diff --git a/cli/bin/clawfix.js b/cli/bin/clawfix.js index a9b2c82..9ed7f1d 100755 --- a/cli/bin/clawfix.js +++ b/cli/bin/clawfix.js @@ -4,10 +4,10 @@ * ClawFix CLI entrypoint — mode dispatch only. * https://clawfix.dev * - * Usage: npx clawfix (OpenTUI session; plain fallback without Bun) - * npx clawfix --plain (classic interactive session) + * Usage: npx clawfix (portable plain session from npm) + * npx clawfix --plain (portable plain session) * npx clawfix --scan (one-shot scan) - * npx clawfix --tui (force OpenTUI; requires Bun) + * npx clawfix --tui (source checkout only; standalone release binary is separate) */ import { existsSync, readFileSync } from 'node:fs'; @@ -25,7 +25,7 @@ const VERSION = (() => { try { return JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; } catch { - return '0.11.2'; + return '0.12.0'; } })(); @@ -64,8 +64,7 @@ Environment: CLAWFIX_AUTO=1 Same as --yes Interactive Commands: - fix <#> Fix issue (shows plan → confirm → apply → verify) - fix-all Fix all auto-fixable issues at once + fix <#> Fix one issue (shows plan → confirm → apply → verify) scan Re-run diagnostics issues Show detected issues help Show help diff --git a/cli/bin/security.js b/cli/bin/security.js index a819920..6073abd 100644 --- a/cli/bin/security.js +++ b/cli/bin/security.js @@ -24,9 +24,11 @@ export function redactText(value, { home = homedir() } = {}) { let text = String(value ?? ''); text = text .replace(/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/gi, REDACTED) + .replace(/((?:Set-)?Cookie\s*:\s*)[^\r\n]*/gi, `$1${REDACTED}`) .replace(/(Authorization\s*:\s*Bearer\s+)[^\s,;]+/gi, `$1${REDACTED}`) + .replace(/\b(Bearer\s+)[A-Za-z0-9._~+/-]{8,}\b/gi, `$1${REDACTED}`) .replace(/\b((?:https?|postgres(?:ql)?|mysql|mongodb(?:\+srv)?):\/\/)([^\s/@:]+):([^\s/@]+)@/gi, `$1${REDACTED}:${REDACTED}@`) - .replace(/\b(?:sk(?:-or-v\d+)?[-_]|xai[-_]|gh[pousr]_|npm_|m0[-_]|ntn_)[A-Za-z0-9._-]{8,}\b/gi, REDACTED) + .replace(/\b(?:sk(?:-or-v\d+)?[-_]|xai[-_]|gh[pousr]_|npm_|m0[-_]|ntn_|xox[baprs]-)[A-Za-z0-9._-]{8,}\b/gi, REDACTED) .replace(/\bAIza[A-Za-z0-9_-]{20,}\b/g, REDACTED) .replace(/((?:^|[\s;])(?:export\s+)?[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|AUTH)[A-Z0-9_]*\s*=\s*)(?:(['"])[\s\S]*?\2|[^\s;]+)/gim, `$1${REDACTED}`) .replace(/((?:api[_-]?key|access[_-]?token|cookie|credential|jwt|password|secret|token)\s*[=:]\s*)(?:(['"])[\s\S]*?\2|[^\s,;]+)/gi, `$1${REDACTED}`); diff --git a/cli/core/repair-catalog.js b/cli/core/repair-catalog.js index 4475ac1..9d8382b 100644 --- a/cli/core/repair-catalog.js +++ b/cli/core/repair-catalog.js @@ -240,6 +240,8 @@ const gatewayLoopbackNoAuth = Object.freeze({ }, async apply(ctx) { + const previousModeRaw = await ctx.openclaw.configGet('gateway.auth.mode', { timeoutMs: 10_000 }); + const previousMode = String(previousModeRaw || '').trim() || 'none'; const set = await ctx.openclaw.configSet('gateway.auth.mode', 'token', { timeoutMs: 30_000 }); if (set.status !== 0) { return Object.freeze({ status: set.status, stage: 'set-mode', errorSummary: set.errorSummary }); @@ -248,11 +250,25 @@ const gatewayLoopbackNoAuth = Object.freeze({ ['doctor', '--fix', '--generate-gateway-token'], { timeoutMs: 120_000 }, ); + let restoredMode = null; + if (generated.status !== 0 || generated.timedOut) { + const restored = await ctx.openclaw.configSet( + 'gateway.auth.mode', + previousMode, + { timeoutMs: 30_000 }, + ); + restoredMode = Object.freeze({ + attempted: true, + status: restored.status, + mode: previousMode, + }); + } return Object.freeze({ status: generated.status, stage: 'generate-token', timedOut: generated.timedOut, errorSummary: generated.errorSummary, + restoredMode, }); }, diff --git a/cli/core/repair-engine.js b/cli/core/repair-engine.js index 01e00c8..b45d2ca 100644 --- a/cli/core/repair-engine.js +++ b/cli/core/repair-engine.js @@ -163,6 +163,23 @@ export function createRepairEngine({ catalog = {}, now = () => Date.now(), rando return Object.freeze({ status: 'error', error: error.message, plan, preview }); } + // An adapter-level failure is never evidence of a successful repair, even when a partial + // write makes the later state check look like the target state. This matters for multi-stage + // repairs such as gateway auth: setting auth.mode can succeed while token generation fails. + if (applyResult?.timedOut === true || ( + Number.isInteger(applyResult?.status) && applyResult.status !== 0 + )) { + const rollback = await safeRollback(entry, ctx, applyResult); + return Object.freeze({ + status: 'verify_failed', + plan, + preview, + applyResult, + verify: Object.freeze({ ok: false, error: 'repair apply step failed' }), + rollback, + }); + } + // Past this point the repair has run. Every remaining failure must still be reported as a // structured outcome that carries applyResult — throwing here would lose the one fact the // caller most needs, which is that the system was already changed. diff --git a/cli/interfaces/plain.js b/cli/interfaces/plain.js index 87cc840..4d1f61e 100755 --- a/cli/interfaces/plain.js +++ b/cli/interfaces/plain.js @@ -419,9 +419,12 @@ async function applyCatalogRepair(issue, rl, session) { }); if (result.status === 'applied') { - console.log(` ${c.green('✅')} Gateway restarted and verified.`); + console.log(` ${c.green('✅')} Repair applied and verified.`); } else if (result.status === 'verify_failed') { - console.log(` ${c.yellow('⚠️')} Restart ran, but the gateway is still unavailable.`); + const rollback = result.rollback?.rolledBack + ? ' The partial change was rolled back.' + : ''; + console.log(` ${c.yellow('⚠️')} Repair ran, but runtime verification failed.${rollback}`); } else if (result.status === 'blocked') { console.log(` ${c.dim('ℹ️')} Repair no longer needed: ${result.reason}`); } else if (result.status === 'rejected') { @@ -459,10 +462,10 @@ async function applyBuiltinFix(issue, builtinFix, rl, scanFn) { console.log(''); const answer = await new Promise(resolve => { - rl.question(` ${c.yellow('Apply?')} [Y/n] `, resolve); + rl.question(` ${c.yellow('Apply?')} [y/N] `, resolve); }); - if (answer.trim() && !/^y(es)?$/i.test(answer.trim())) { + if (!/^y(es)?$/i.test(answer.trim())) { console.log(c.dim(' Cancelled.')); console.log(''); return { cancelled: true }; @@ -532,102 +535,6 @@ async function applyBuiltinFix(issue, builtinFix, rl, scanFn) { } } -/** - * Apply all fixable issues at once with single backup and single restart - */ -async function applyAllFixes(issues, serverIssues, rl, scanFn) { - const allIssues = mergeIssues(issues, serverIssues); - const fixable = allIssues.filter(i => BUILTIN_FIXES[i.repairId] && !BUILTIN_FIXES[i.repairId].informational); - - if (fixable.length === 0) { - console.log(c.dim(' No auto-fixable issues found.')); - return null; - } - - console.log(''); - console.log(c.bold(` Fix plan (${fixable.length} issues):`)); - for (const issue of fixable) { - const fix = BUILTIN_FIXES[issue.repairId]; - const risk = fix.risk === 'low' ? c.green('low') : c.yellow(fix.risk); - console.log(` ${c.blue('🔧')} [${risk}] ${issue.title || issue.text}`); - console.log(` ${c.dim(fix.description)}`); - } - - const skipped = allIssues.filter(i => BUILTIN_FIXES[i.repairId]?.informational); - if (skipped.length) { - console.log(''); - for (const issue of skipped) { - console.log(` ${c.dim(`ℹ️ [SKIP] ${issue.title || issue.text} — informational`)}`); - } - } - - const noFix = allIssues.filter(i => !BUILTIN_FIXES[i.repairId] && !i.fix); - if (noFix.length) { - console.log(''); - for (const issue of noFix) { - console.log(` ${c.dim(`❓ [MANUAL] ${issue.title || issue.text} — ask AI for help`)}`); - } - } - - console.log(''); - const answer = await new Promise(resolve => { - rl.question(` ${c.yellow(`Apply ${fixable.length} fix(es)?`)} [Y/n] `, resolve); - }); - - if (answer.trim() && !/^y(es)?$/i.test(answer.trim())) { - console.log(c.dim(' Cancelled.')); - console.log(''); - return null; - } - - // Single backup - const backupPath = await backupConfig(); - console.log(` ${c.green('✅')} Config backed up → ${c.dim(backupPath.split('/').pop())}`); - - // Read config once - let config = await readConfig(); - let needsRestart = false; - let applied = 0; - - for (const issue of fixable) { - const fix = BUILTIN_FIXES[issue.repairId]; - try { - const result = await fix.apply(config); - for (const change of result.changes) { - console.log(` ${c.green('✅')} ${change}`); - } - if (fix.needsRestart) needsRestart = true; - applied++; - } catch (err) { - console.log(` ${c.red('❌')} ${issue.title || issue.text}: ${err.message}`); - } - } - - // Write config once - await safeWriteConfig(config); - console.log(` ${c.green('✅')} Config saved`); - - // Restart once - if (needsRestart) { - process.stdout.write(` ${c.blue('🔄')} Restarting gateway...`); - const ok = tryGatewayRestart(); - console.log(ok ? ` ${c.green('✅')}` : ` ${c.yellow('⚠️ may need manual restart')}`); - } - - // Re-scan - if (scanFn) { - process.stdout.write(` ${c.blue('🔍')} Re-scanning...`); - await scanFn(); - console.log(` ${c.green('done')}`); - } - - console.log(''); - console.log(c.green(` ✅ ${applied}/${fixable.length} fix(es) applied.`)); - if (backupPath) console.log(c.dim(` Rollback: cp ${backupPath} ${CONFIG_PATH}`)); - console.log(''); - return { applied, total: fixable.length }; -} - // ============================================================ // Diagnostic core compatibility bridge // ============================================================ @@ -960,7 +867,7 @@ async function runInteractiveMode() { }, repairEngine, normalizeFindings, - knownRepairIds: Object.keys(BUILTIN_FIXES), + knownRepairIds: [...new Set([...Object.keys(BUILTIN_FIXES), ...Object.keys(repairCatalog)])], makeRevisionId: randomUUID, onEvent: event => { if (!sessionQuiet && event.type.startsWith('scan.')) { @@ -1113,22 +1020,10 @@ async function runInteractiveMode() { return; } - // fix-all — apply all auto-fixable issues at once + // Batch repairs deliberately stay disabled: each repair needs its own current-state plan, + // explicit approval, verification, and rollback outcome. if (/^fix[\s-]?all$/i.test(input)) { - const scanFn = async () => { - const result = await scanSession({ quiet: true }); - if (!result.error) { - syncSessionState(result); - // Preserve the startup consent decision for post-fix rescans. - if (sendConsent) { - try { await uploadDiagnostic(); } catch {} - } - return { issues, serverIssues }; - } - return null; - }; - - await applyAllFixes(issues, serverIssues, rl, scanFn); + console.log(c.yellow(' Batch repair is disabled. Apply one numbered repair at a time with fix <#>.')); rl.prompt(); return; } @@ -1290,7 +1185,7 @@ function renderStatus(summary, issues, serverIssues) { renderIssues(issues, serverIssues); console.log(c.cyan('━'.repeat(48))); - console.log(c.dim(' fix <#> | fix-all | scan | help | exit — or just type to chat')); + console.log(c.dim(' fix <#> | scan | help | exit — or just type to chat')); console.log(''); } @@ -1322,8 +1217,7 @@ function renderIssues(issues, serverIssues) { function renderHelp() { console.log(''); console.log(c.bold('Commands:')); - console.log(` ${c.cyan('fix <#>')} Fix issue # (shows plan → confirm → apply → verify)`); - console.log(` ${c.cyan('fix-all')} Fix all auto-fixable issues at once`); + console.log(` ${c.cyan('fix <#>')} Fix one issue (shows plan → confirm → apply → verify)`); console.log(` ${c.cyan('scan')} Re-run diagnostics`); console.log(` ${c.cyan('issues')} Show detected issues`); console.log(` ${c.cyan('status')} Show system status`); @@ -1344,7 +1238,7 @@ function mergeIssues(localIssues, serverIssues) { return dedupeFindingsForDisplay(normalizeFindings({ localIssues, serverFindings: serverIssues, - knownRepairIds: Object.keys(BUILTIN_FIXES), + knownRepairIds: [...new Set([...Object.keys(BUILTIN_FIXES), ...Object.keys(repairCatalog)])], })); } diff --git a/cli/package.json b/cli/package.json index a463fd8..03d0ee7 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,7 +1,7 @@ { "name": "clawfix", - "version": "0.11.2", - "description": "AI-powered diagnostic and repair for OpenClaw installations", + "version": "0.12.0", + "description": "Deterministic-first OpenClaw diagnostics and guarded repairs with optional AI analysis", "bin": { "clawfix": "bin/clawfix.js" }, diff --git a/cli/tui/scripts/frames.tsx b/cli/tui/scripts/frames.tsx index e7cc3ad..48956f0 100644 --- a/cli/tui/scripts/frames.tsx +++ b/cli/tui/scripts/frames.tsx @@ -31,9 +31,9 @@ const SIZES = [ const plan: RepairPlanView = { planId: "plan-1", scanFingerprint: "fp", - repairIds: ["gateway-restart"], - risk: "medium", - summary: "Restart the OpenClaw gateway service", + repairIds: ["gateway-not-running"], + risk: "low", + summary: "Restart the OpenClaw gateway", effects: [{ kind: "service", summary: "systemctl --user restart openclaw-gateway" }], previewText: "~/.openclaw/openclaw.json (model)", unifiedDiff: null, @@ -43,8 +43,8 @@ const plan: RepairPlanView = { } const findings = [ - { id: "f1", title: "Gateway service is not running", severity: "critical", repairable: true, repairId: "gateway-restart" }, - { id: "f2", title: "Port 18789 is not listening", severity: "critical", repairable: true, repairId: "gateway-restart" }, + { id: "f1", title: "Gateway service is not running", severity: "critical", repairable: true, repairId: "gateway-not-running" }, + { id: "f2", title: "Port 18789 is not listening", severity: "critical", repairable: true, repairId: "gateway-not-running" }, { id: "f3", title: "Config file present but node version untested", severity: "warning", repairable: false, repairId: null }, { id: "f4", title: "Memory files missing (MEMORY.md)", severity: "warning", repairable: false, repairId: null }, { id: "f5", title: "Disk usage above 80% on data volume", severity: "optimization", repairable: false, repairId: null }, @@ -57,11 +57,11 @@ function state(partial: Partial): TuiSessionView { } const chatItems: TranscriptItem[] = [ - { kind: "finding", id: "fc-f1", findingId: "f1", title: "Gateway service is not running", severity: "critical", repairable: true, repairId: "gateway-restart", evidence: "systemctl --user status openclaw-gateway → inactive (dead)" }, - { kind: "finding", id: "fc-f2", findingId: "f2", title: "Port 18789 is not listening", severity: "critical", repairable: true, repairId: "gateway-restart", evidence: null }, + { kind: "finding", id: "fc-f1", findingId: "f1", title: "Gateway service is not running", severity: "critical", repairable: true, repairId: "gateway-not-running", evidence: "systemctl --user status openclaw-gateway → inactive (dead)" }, + { kind: "finding", id: "fc-f2", findingId: "f2", title: "Port 18789 is not listening", severity: "critical", repairable: true, repairId: "gateway-not-running", evidence: null }, { kind: "finding", id: "fc-f3", findingId: "f3", title: "Config file present but node version untested", severity: "warning", repairable: false, repairId: null, evidence: null }, { kind: "message", id: "m1", role: "user", text: "why is my gateway not running" }, - { kind: "message", id: "m2", role: "assistant", text: "Your gateway service is registered but currently stopped. The most common cause after a reboot is the systemd user service failing to start because Node is not on its PATH.\n\nI can restart it with the reviewed gateway-restart repair. Want me to?" }, + { kind: "message", id: "m2", role: "assistant", text: "Your gateway service is registered but currently stopped. The most common cause after a reboot is the systemd user service failing to start because Node is not on its PATH.\n\nI can restart it with the reviewed gateway-not-running repair. Want me to?" }, { kind: "repair", id: "r1", plan, rationale: "Gateway is down and port 18789 is closed.", status: "proposed" }, ] diff --git a/cli/tui/src/live-session.ts b/cli/tui/src/live-session.ts index ca7485f..2f198aa 100644 --- a/cli/tui/src/live-session.ts +++ b/cli/tui/src/live-session.ts @@ -104,7 +104,7 @@ export function createLiveSession(options: LiveSessionOptions): SessionBridge { offlineAnalyzer: createOfflineAnalyzer({ session }) as any, remoteAnalyzer: createRemoteAnalyzer({ session }) as any, preferRemote: true, - remoteBaseUrl: process.env.CLAWFIX_API_URL || "https://clawfix.dev", + remoteBaseUrl: process.env.CLAWFIX_API || "https://clawfix.dev", repairContext: { openclaw: openClawAdapter, wait: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), diff --git a/cli/tui/src/remote-analyzer.ts b/cli/tui/src/remote-analyzer.ts index a01fbc3..11af837 100644 --- a/cli/tui/src/remote-analyzer.ts +++ b/cli/tui/src/remote-analyzer.ts @@ -14,9 +14,13 @@ */ import { randomUUID } from "node:crypto" -import { projectLocalIssuesForUpload, redactOutbound } from "../../bin/security.js" +import { projectLocalIssuesForUpload, redactOutbound, redactText } from "../../bin/security.js" const MAX_REPAIRS = 32 +const DEFAULT_TIMEOUT_MS = 90_000 +const MAX_SSE_BYTES = 1_000_000 +const MAX_SSE_BUFFER_BYTES = 256_000 +const MAX_ASSISTANT_CHARS = 64_000 const CONV_RE = /^[A-Za-z0-9_-]{8,128}$/ export interface RemoteAnalyzerOptions { @@ -29,10 +33,11 @@ export interface RemoteAnalyzerOptions { } readonly baseUrl?: string readonly fetchImpl?: typeof fetch + readonly timeoutMs?: number } function normalizeBaseUrl(raw?: string): string { - const value = (raw || process.env.CLAWFIX_API_URL || "https://clawfix.dev").trim() + const value = (raw || process.env.CLAWFIX_API || "https://clawfix.dev").trim() try { return new URL(value).origin } catch { @@ -40,17 +45,34 @@ function normalizeBaseUrl(raw?: string): string { } } +function combineSignals(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { + const timeout = AbortSignal.timeout(timeoutMs) + if (!signal) return timeout + return AbortSignal.any([signal, timeout]) +} + +function safeMessage(value: unknown): string { + return redactText(String(value || "")).slice(0, 4000) +} + /** Parse `event: X\ndata: {...}\n\n` frames from an SSE byte stream. */ async function* readSseEvents(body: ReadableStream, signal?: AbortSignal) { const reader = body.getReader() const decoder = new TextDecoder() let buffer = "" + let totalBytes = 0 + let assistantChars = 0 try { for (;;) { if (signal?.aborted) return const { done, value } = await reader.read() if (done) break + totalBytes += value.byteLength + if (totalBytes > MAX_SSE_BYTES) throw new Error("ClawFix SSE response exceeded the byte limit") buffer += decoder.decode(value, { stream: true }) + if (buffer.length > MAX_SSE_BUFFER_BYTES) { + throw new Error("ClawFix SSE frame exceeded the buffer limit") + } let idx: number while ((idx = buffer.indexOf("\n\n")) !== -1) { const frame = buffer.slice(0, idx) @@ -63,8 +85,16 @@ async function* readSseEvents(body: ReadableStream, signal?: AbortSi } if (!data) continue try { - yield { type: event, ...JSON.parse(data) } - } catch { + const parsed = JSON.parse(data) + if (event === "assistant.delta") { + assistantChars += String(parsed?.text || parsed?.delta || "").length + if (assistantChars > MAX_ASSISTANT_CHARS) { + throw new Error("ClawFix assistant response exceeded the character limit") + } + } + yield { type: event, ...parsed } + } catch (error) { + if (error instanceof Error && error.message.startsWith("ClawFix ")) throw error // ignore malformed frames } } @@ -77,8 +107,17 @@ async function* readSseEvents(body: ReadableStream, signal?: AbortSi export function createRemoteAnalyzer(options: RemoteAnalyzerOptions) { const baseUrl = normalizeBaseUrl(options.baseUrl) const fetchImpl = options.fetchImpl ?? fetch + const requestedTimeout = Number(options.timeoutMs) + const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0 + ? requestedTimeout + : DEFAULT_TIMEOUT_MS const conversationId = randomUUID() - const headers = Object.freeze({ "Content-Type": "application/json" }) + const headers = Object.freeze({ + "Content-Type": "application/json", + ...(process.env.CLAWFIX_API_TOKEN + ? { Authorization: `Bearer ${process.env.CLAWFIX_API_TOKEN}` } + : {}), + }) let diagnosticId: string | null = null async function ensureDiagnosticId(signal?: AbortSignal): Promise { @@ -101,7 +140,8 @@ export function createRemoteAnalyzer(options: RemoteAnalyzerOptions) { const id = typeof data?.fixId === "string" && CONV_RE.test(data.fixId) ? data.fixId : null diagnosticId = id return id - } catch { + } catch (error) { + if (signal?.aborted) throw error return null } } @@ -145,14 +185,22 @@ export function createRemoteAnalyzer(options: RemoteAnalyzerOptions) { ? { ">> first, POST /api/diagnose": "the redacted diagnostic below is uploaded and returns the diagnosticId used here" } : {}), conversationId, - message: String(message || "").slice(0, 4000), + message: safeMessage(message), ...(diagnosticId ? { diagnosticId } : {}), availableRepairs: availableRepairs(), }, }) }, async *send(input: { readonly message: string; readonly consentGranted: boolean; readonly signal?: AbortSignal }) { - const signal = input.signal + if (input.consentGranted !== true) { + yield { + type: "agent.error", + error: "Remote analysis requires explicit consent.", + fatal: true, + } + return + } + const signal = combineSignals(input.signal, timeoutMs) let diagId: string | null = null try { diagId = await ensureDiagnosticId(signal) @@ -161,7 +209,7 @@ export function createRemoteAnalyzer(options: RemoteAnalyzerOptions) { headers, body: JSON.stringify({ conversationId, - message: input.message.slice(0, 4000), + message: safeMessage(input.message), ...(diagId ? { diagnosticId: diagId } : {}), availableRepairs: availableRepairs(), }), @@ -175,10 +223,13 @@ export function createRemoteAnalyzer(options: RemoteAnalyzerOptions) { yield event } } catch (error: any) { - if (signal?.aborted) return + if (input.signal?.aborted) return + const detail = signal.aborted + ? "request timed out" + : String(error?.message || error).slice(0, 200) yield { type: "agent.error", - error: `ClawFix service unreachable (${String(error?.message || error).slice(0, 200)})`, + error: `ClawFix service unreachable (${detail})`, fatal: true, } } diff --git a/cli/tui/src/session-bridge.ts b/cli/tui/src/session-bridge.ts index b84983f..39ac945 100644 --- a/cli/tui/src/session-bridge.ts +++ b/cli/tui/src/session-bridge.ts @@ -11,6 +11,8 @@ import { } from "./lib/models" import { sanitizeDisplayText } from "./lib/paste" +const MAX_REMOTE_ASSISTANT_CHARS = 64_000 + export interface TuiFinding { readonly id: string readonly title: string @@ -535,6 +537,10 @@ export function createSessionBridge(options: { const type = event?.type || event?.event if (type === "assistant.delta") { const delta = sanitizeDisplayText(String(event.text || event.delta || ""), 4000) + if (assistant.length + delta.length > MAX_REMOTE_ASSISTANT_CHARS) { + abortActive?.abort() + throw new Error("Remote assistant response exceeded the display limit") + } assistant += delta // Replace streaming card extras = extras.filter((i) => i.id !== assistantId) diff --git a/cli/tui/test/new-layout.test.tsx b/cli/tui/test/new-layout.test.tsx index cf092e1..3fa4270 100644 --- a/cli/tui/test/new-layout.test.tsx +++ b/cli/tui/test/new-layout.test.tsx @@ -14,9 +14,9 @@ afterEach(() => { const plan: RepairPlanView = { planId: "plan-1", scanFingerprint: "fp", - repairIds: ["gateway-restart"], - risk: "medium", - summary: "Restart the OpenClaw gateway service", + repairIds: ["gateway-not-running"], + risk: "low", + summary: "Restart the OpenClaw gateway", effects: [{ kind: "service", summary: "systemctl --user restart openclaw-gateway" }], previewText: "~/.openclaw/openclaw.json (model)", unifiedDiff: null, @@ -26,8 +26,8 @@ const plan: RepairPlanView = { } const findings = [ - { id: "f1", title: "Gateway service is not running", severity: "critical", repairable: true, repairId: "gateway-restart" }, - { id: "f2", title: "Port 18789 is not listening", severity: "critical", repairable: true, repairId: "gateway-restart" }, + { id: "f1", title: "Gateway service is not running", severity: "critical", repairable: true, repairId: "gateway-not-running" }, + { id: "f2", title: "Port 18789 is not listening", severity: "critical", repairable: true, repairId: "gateway-not-running" }, ] function state(partial: Partial): TuiSessionView { @@ -64,7 +64,7 @@ describe("new layout", () => { expect(frame).toContain("First paragraph.") expect(frame).toContain("Second paragraph.") expect(frame).toContain("Repair proposal · proposed") - expect(frame).toContain("Restart the OpenClaw gateway service") + expect(frame).toContain("Restart the OpenClaw gateway") }) test("approval dialog keeps action buttons visible on small terminals", async () => { diff --git a/cli/tui/test/remote-analyzer-boundary.test.ts b/cli/tui/test/remote-analyzer-boundary.test.ts new file mode 100644 index 0000000..6ce2102 --- /dev/null +++ b/cli/tui/test/remote-analyzer-boundary.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, test } from "bun:test" + +import { createRemoteAnalyzer } from "../src/remote-analyzer" + +const originalApi = process.env.CLAWFIX_API +const originalToken = process.env.CLAWFIX_API_TOKEN + +afterEach(() => { + if (originalApi == null) delete process.env.CLAWFIX_API + else process.env.CLAWFIX_API = originalApi + if (originalToken == null) delete process.env.CLAWFIX_API_TOKEN + else process.env.CLAWFIX_API_TOKEN = originalToken +}) + +function fakeSession() { + return { + getState() { + return { + diagnostic: { system: { os: "linux" } }, + issues: [], + findings: [], + } + }, + } +} + +describe("TUI remote analyzer network boundary", () => { + test("refuses a direct send without explicit consent and performs no fetch", async () => { + const requests: unknown[] = [] + const analyzer = createRemoteAnalyzer({ + session: fakeSession() as any, + baseUrl: "https://example.test", + fetchImpl: (async (...args: unknown[]) => { + requests.push(args) + throw new Error("must not fetch") + }) as any, + }) + + const events: any[] = [] + for await (const event of analyzer.send({ message: "help", consentGranted: false })) { + events.push(event) + } + + expect(requests).toHaveLength(0) + expect(events).toEqual([{ + type: "agent.error", + error: "Remote analysis requires explicit consent.", + fatal: true, + }]) + }) + + test("uses the documented CLAWFIX_API and CLAWFIX_API_TOKEN variables", async () => { + process.env.CLAWFIX_API = "https://self-hosted.example/path" + process.env.CLAWFIX_API_TOKEN = "test-token" + const requests: Array<{ url: string; init: RequestInit }> = [] + const analyzer = createRemoteAnalyzer({ + session: { getState: () => ({ diagnostic: null, issues: [], findings: [] }) } as any, + fetchImpl: (async (url: string, init: RequestInit) => { + requests.push({ url, init }) + return new Response("event: agent.done\ndata: {}\n\n", { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + }) as any, + }) + + expect(analyzer.baseUrl).toBe("https://self-hosted.example") + expect(analyzer.endpointUrl).toBe("https://self-hosted.example/api/v2/agent/messages") + const secretMessage = "token=opaque-message-secret Authorization: Bearer abcdefghijklmnop" + const preview = analyzer.describeOutbound(secretMessage) + expect(JSON.stringify(preview)).not.toContain("opaque-message-secret") + expect(JSON.stringify(preview)).not.toContain("abcdefghijklmnop") + + const events = [] + for await (const event of analyzer.send({ message: secretMessage, consentGranted: true })) { + events.push(event) + } + expect(requests).toHaveLength(1) + expect(requests[0]!.url).toBe(analyzer.endpointUrl) + expect(String(requests[0]!.init.body)).not.toContain("opaque-message-secret") + expect(String(requests[0]!.init.body)).not.toContain("abcdefghijklmnop") + expect(requests[0]!.init.headers).toEqual({ + "Content-Type": "application/json", + Authorization: "Bearer test-token", + }) + expect(events).toEqual([{ type: "agent.done" }]) + }) + + test("times out a server that never responds", async () => { + const analyzer = createRemoteAnalyzer({ + session: { getState: () => ({ diagnostic: null, issues: [], findings: [] }) } as any, + baseUrl: "https://example.test", + timeoutMs: 20, + fetchImpl: (async (_url: string, init: RequestInit) => new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }) + })) as any, + }) + + const events: any[] = [] + for await (const event of analyzer.send({ message: "help", consentGranted: true })) events.push(event) + expect(events).toHaveLength(1) + expect(events[0]?.type).toBe("agent.error") + expect(events[0]?.error).toContain("request timed out") + }) + + test("rejects an oversized incomplete SSE frame", async () => { + const analyzer = createRemoteAnalyzer({ + session: { getState: () => ({ diagnostic: null, issues: [], findings: [] }) } as any, + baseUrl: "https://example.test", + fetchImpl: (async () => new Response(`event: assistant.delta\ndata: ${"x".repeat(300_000)}`, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + })) as any, + }) + + const events: any[] = [] + for await (const event of analyzer.send({ message: "help", consentGranted: true })) events.push(event) + expect(events).toHaveLength(1) + expect(events[0]?.type).toBe("agent.error") + expect(events[0]?.error).toContain("buffer limit") + }) +}) diff --git a/cli/tui/test/responsive.test.tsx b/cli/tui/test/responsive.test.tsx index 8527b29..2a6ea61 100644 --- a/cli/tui/test/responsive.test.tsx +++ b/cli/tui/test/responsive.test.tsx @@ -52,7 +52,7 @@ describe("responsive frames", () => { const withFindings = Object.freeze({ ...createFakeSession(), findings: [ - { id: "f1", title: "Gateway service is not running", severity: "critical", repairable: true, repairId: "gateway-restart" }, + { id: "f1", title: "Gateway service is not running", severity: "critical", repairable: true, repairId: "gateway-not-running" }, ], revision: "a1b2c3d", }) diff --git a/package-lock.json b/package-lock.json index 2664c4d..45d9f62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "clawfix", - "version": "0.11.2", + "version": "0.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "clawfix", - "version": "0.11.2", + "version": "0.12.0", "license": "MIT", "dependencies": { "express": "^5.1.0", diff --git a/package.json b/package.json index c6af663..77a55ec 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "clawfix", - "version": "0.11.2", - "description": "AI-powered OpenClaw diagnostic and repair service", + "version": "0.12.0", + "description": "Deterministic-first OpenClaw diagnostics and guarded repairs with optional AI analysis", "private": true, "license": "MIT", "homepage": "https://clawfix.dev", diff --git a/scripts/install.sh b/scripts/install.sh index b6e0060..836b8f1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -30,7 +30,7 @@ set -euo pipefail -VERSION="${CLAWFIX_VERSION:-0.11.2}" +VERSION="${CLAWFIX_VERSION:-0.12.0}" PREFIX="${CLAWFIX_PREFIX:-${HOME}/.clawfix}" BIN_DIR="${CLAWFIX_BIN_DIR:-${HOME}/.local/bin}" REGISTRY="${CLAWFIX_REGISTRY:-https://registry.npmjs.org}" diff --git a/scripts/smoke-tui-interactive.mjs b/scripts/smoke-tui-interactive.mjs index 87123a4..b69bf45 100644 --- a/scripts/smoke-tui-interactive.mjs +++ b/scripts/smoke-tui-interactive.mjs @@ -18,7 +18,7 @@ import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' const DRIVER = String.raw` -import os, pty, subprocess, sys, threading, time +import os, pty, re, subprocess, sys, threading, time binary = sys.argv[1] master, slave = pty.openpty() @@ -64,7 +64,9 @@ if exited is None: proc.kill() print("RENDERED:", b"ClawFix" in rendered) -print("ACCEPTS_INPUT:", len(echoed) > 0) +ansi = re.compile(rb'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') +plain_echoed = ansi.sub(b"", echoed) +print("ACCEPTS_INPUT:", b"ZZTOP" in plain_echoed) print("EXIT_CODE:", "none" if exited is None else exited) ` @@ -107,8 +109,11 @@ if (!acceptsInput) { + `createSolidTransformPlugin() to Bun.build.\n--- driver output ---\n${output}`, ) } -if (exitCode === 'none') { - fail(`binary did not exit on Ctrl+D — there is no way out of the session\n--- driver output ---\n${output}`) +if (exitCode !== '0') { + const reason = exitCode === 'none' + ? 'binary did not exit on Ctrl+D — there is no way out of the session' + : `binary exited with nonzero status ${exitCode}` + fail(`${reason}\n--- driver output ---\n${output}`) } console.log(JSON.stringify({ ok: true, mode: 'pty', rendered, acceptsInput, exitCode }, null, 2)) diff --git a/src/ai.js b/src/ai.js index 02295ac..b842954 100644 --- a/src/ai.js +++ b/src/ai.js @@ -54,6 +54,21 @@ export function getAIConfig(env = process.env) { }; } +function combineWithTimeout(signal, timeoutMs) { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + if (!signal) return timeoutSignal; + if (typeof AbortSignal.any === 'function') return AbortSignal.any([signal, timeoutSignal]); + + const controller = new AbortController(); + const abort = () => controller.abort(); + if (signal.aborted || timeoutSignal.aborted) abort(); + else { + signal.addEventListener('abort', abort, { once: true }); + timeoutSignal.addEventListener('abort', abort, { once: true }); + } + return controller.signal; +} + export async function requestAI({ messages, stream = false, @@ -62,6 +77,7 @@ export async function requestAI({ toolChoice, config = getAIConfig(), fetchImpl = fetch, + signal, }) { if (!config.apiKey) { throw new Error('AI is not configured'); @@ -100,7 +116,7 @@ export async function requestAI({ method: 'POST', headers, body: JSON.stringify(body), - signal: AbortSignal.timeout(config.timeoutMs), + signal: combineWithTimeout(signal, config.timeoutMs), }); if (!response.ok) { diff --git a/src/conversation-store.js b/src/conversation-store.js new file mode 100644 index 0000000..591097a --- /dev/null +++ b/src/conversation-store.js @@ -0,0 +1,25 @@ +export const LEGACY_CHAT_CONVERSATION_TTL_MS = 30 * 60 * 1000; +export const LEGACY_CHAT_MAX_CONVERSATIONS = 500; + +export function pruneLegacyConversations( + store, + { + now = Date.now(), + ttlMs = LEGACY_CHAT_CONVERSATION_TTL_MS, + maxEntries = LEGACY_CHAT_MAX_CONVERSATIONS, + } = {}, +) { + for (const [key, conversation] of store) { + const touchedAt = Number(conversation?.lastSeenAt || conversation?.createdAt || 0); + if (!Number.isFinite(touchedAt) || now - touchedAt > ttlMs) store.delete(key); + } + if (store.size <= maxEntries) return; + const overflow = [...store.entries()] + .sort((a, b) => { + const aTouched = Number(a[1]?.lastSeenAt || a[1]?.createdAt || 0); + const bTouched = Number(b[1]?.lastSeenAt || b[1]?.createdAt || 0); + return aTouched - bTouched; + }) + .slice(0, store.size - maxEntries); + for (const [key] of overflow) store.delete(key); +} diff --git a/src/db.js b/src/db.js index a9d0934..ce4c84e 100644 --- a/src/db.js +++ b/src/db.js @@ -19,6 +19,60 @@ export function getPool() { return pool; } +const memoryWebhookDeliveries = new Map(); +const WEBHOOK_MEMORY_TTL_MS = 10 * 60 * 1000; +const WEBHOOK_MEMORY_MAX = 1000; + +function validWebhookKey(value) { + return typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,160}$/.test(value); +} + +export async function claimWebhookDelivery(provider, eventId, { db = getPool(), now = Date.now() } = {}) { + if (!validWebhookKey(provider) || !validWebhookKey(eventId)) return false; + if (db) { + try { + const result = await db.query(` + INSERT INTO webhook_deliveries (provider, event_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + RETURNING event_id + `, [provider, eventId]); + void db.query("DELETE FROM webhook_deliveries WHERE created_at < NOW() - INTERVAL '7 days'") + .catch(err => console.warn('Webhook idempotency cleanup failed:', err.message)); + return result.rowCount === 1; + } catch (err) { + console.error('Webhook idempotency claim failed:', err.message); + return false; + } + } + + for (const [key, createdAt] of memoryWebhookDeliveries) { + if (now - createdAt > WEBHOOK_MEMORY_TTL_MS) memoryWebhookDeliveries.delete(key); + } + const key = `${provider}:${eventId}`; + if (memoryWebhookDeliveries.has(key)) return false; + memoryWebhookDeliveries.set(key, now); + while (memoryWebhookDeliveries.size > WEBHOOK_MEMORY_MAX) { + const oldest = memoryWebhookDeliveries.keys().next(); + if (oldest.done) break; + memoryWebhookDeliveries.delete(oldest.value); + } + return true; +} + +export async function releaseWebhookDelivery(provider, eventId, { db = getPool() } = {}) { + if (!validWebhookKey(provider) || !validWebhookKey(eventId)) return; + if (db) { + try { + await db.query('DELETE FROM webhook_deliveries WHERE provider = $1 AND event_id = $2', [provider, eventId]); + } catch (err) { + console.error('Webhook idempotency release failed:', err.message); + } + return; + } + memoryWebhookDeliveries.delete(`${provider}:${eventId}`); +} + /** * Initialize database schema */ @@ -99,6 +153,15 @@ export async function initDB() { comment TEXT ); + CREATE TABLE IF NOT EXISTS webhook_deliveries ( + provider TEXT NOT NULL, + event_id TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (provider, event_id) + ); + + CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_created ON webhook_deliveries(created_at); + CREATE INDEX IF NOT EXISTS idx_diagnoses_created ON diagnoses(created_at DESC); CREATE INDEX IF NOT EXISTS idx_diagnoses_host ON diagnoses(host_hash); CREATE INDEX IF NOT EXISTS idx_diagnoses_version ON diagnoses(openclaw_version); diff --git a/src/landing.js b/src/landing.js index cad7804..6b592dc 100644 --- a/src/landing.js +++ b/src/landing.js @@ -8,9 +8,9 @@ landingRouter.get('/', (req, res) => { return res.json({ name: 'ClawFix', tagline: 'OpenClaw diagnostics and guarded repairs', - version: '0.11.2', + version: '0.12.0', install: 'curl --fail --show-error --silent --location https://clawfix.dev/install --output install.sh && bash install.sh', - fix: 'npx clawfix@0.11.2', + fix: 'npx clawfix@0.12.0', }); } @@ -23,15 +23,15 @@ const LANDING_HTML = ` - ClawFix 0.11.2 — OpenClaw Diagnostics & Repair - - - + ClawFix 0.12.0 — OpenClaw Diagnostics & Repair + + + - - + +