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/production-smoke.yml b/.github/workflows/production-smoke.yml new file mode 100644 index 0000000..1cd9e7b --- /dev/null +++ b/.github/workflows/production-smoke.yml @@ -0,0 +1,77 @@ +name: Production continuity verification + +on: + workflow_dispatch: + inputs: + metered_mode: + description: Optional single paid canary for this run + required: true + default: none + type: choice + options: + - none + - agent + - diagnose + schedule: + - cron: "17 4 * * *" + +permissions: + contents: read + +concurrency: + group: production-continuity + cancel-in-progress: false + +jobs: + verify-production: + name: clawfix.dev contract + runs-on: ubuntu-latest + timeout-minutes: 5 + env: + CLAWFIX_API_TOKEN: ${{ secrets.CLAWFIX_API_TOKEN }} + CLAWFIX_CANARY_TOKEN: ${{ secrets.CLAWFIX_CANARY_TOKEN }} + CLAWFIX_SCHEDULED_CANARY: ${{ vars.CLAWFIX_SCHEDULED_CANARY }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node 24 + uses: actions/setup-node@v6 + with: + node-version: "24" + package-manager-cache: false + + - name: Verify public production contract + shell: bash + run: | + set -euo pipefail + mode="none" + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + mode="${{ inputs.metered_mode }}" + elif [ -n "${CLAWFIX_SCHEDULED_CANARY:-}" ]; then + # Scheduled paid traffic is opt-in. No repository variable means free checks only. + mode="$CLAWFIX_SCHEDULED_CANARY" + fi + + case "$mode" in + none) canary_args=() ;; + agent) canary_args=(--agent-canary) ;; + diagnose) canary_args=(--diagnose-canary) ;; + *) echo "::error ::invalid canary mode: $mode"; exit 2 ;; + esac + + version="$(node -p "require('./package.json').version")" + node scripts/verify-production.mjs \ + --base-url https://clawfix.dev \ + --expected-version "$version" \ + "${canary_args[@]}" \ + 2>&1 | tee production-verification.json + + - name: Retain verification evidence + if: always() + uses: actions/upload-artifact@v4 + with: + name: production-verification-${{ github.run_id }} + path: production-verification.json + if-no-files-found: warn + retention-days: 30 diff --git a/.github/workflows/release-tui.yml b/.github/workflows/release-tui.yml index 7e308fc..77e9908 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,48 @@ concurrency: cancel-in-progress: false jobs: + test-tui: + name: TUI tests and typecheck + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Verify release identity + shell: bash + run: | + set -euo pipefail + root_version="$(node -p "require('./package.json').version")" + cli_version="$(node -p "require('./cli/package.json').version")" + test "$root_version" = "$cli_version" + if [ "$GITHUB_REF_TYPE" = "tag" ]; then + [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + test "$GITHUB_REF_NAME" = "v$root_version" + test "$(git rev-list -n 1 "$GITHUB_REF_NAME")" = "$GITHUB_SHA" + fi + + - 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 +115,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 +152,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: @@ -132,6 +176,18 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest steps: + - name: Checkout exact release source + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: ${{ github.sha }} + + - name: Setup Node 24 + uses: actions/setup-node@v6 + with: + node-version: "24" + package-manager-cache: false + - name: Download all TUI artifacts uses: actions/download-artifact@v4 with: @@ -139,10 +195,31 @@ jobs: pattern: clawfix-tui-* merge-multiple: false - - name: Package target tarballs + - name: Revalidate remote tag before packaging + run: | + set -euo pipefail + remote_sha="$(git ls-remote --tags origin "refs/tags/$GITHUB_REF_NAME^{}" | cut -f1)" + if [ -z "$remote_sha" ]; then + remote_sha="$(git ls-remote --tags origin "refs/tags/$GITHUB_REF_NAME" | cut -f1)" + fi + test -n "$remote_sha" + test "$remote_sha" = "$GITHUB_SHA" + + - name: Package and smoke final target tarballs run: | set -euo pipefail - mkdir -p release-dist + mkdir -p release-dist release-smoke + assert_archive_mode() { + local tarball="$1" + local member="$2" + local listing mode + listing="$(tar -tvzf "$tarball" "$member")" + read -r mode _ <<< "$listing" + if [ "$mode" != "-rwxr-xr-x" ]; then + echo "::error ::archive member $member in $tarball has mode $mode, expected -rwxr-xr-x" + exit 1 + fi + } # download-artifact layout with merge-multiple=false: # release-tui/clawfix-tui-/{launcher,.bin,assets-/,...} shopt -s nullglob @@ -158,7 +235,49 @@ jobs: ls -la "$dir" || true exit 1 fi - tar -C "$dir" -czf "release-dist/clawfix-tui-${target}.tar.gz" . + + # actions/upload-artifact intentionally normalizes regular files to 0644. Restore + # executable modes after download so the release archive contains runnable files. + chmod 0755 "$launcher" "$binary" + + tarball="release-dist/clawfix-tui-${target}.tar.gz" + tar -C "$dir" -czf "$tarball" . + assert_archive_mode "$tarball" "./clawfix-tui-$target" + assert_archive_mode "$tarball" "./clawfix-tui-$target.bin" + + # Release evidence starts after packaging. Extract into a fresh directory and smoke + # only those bytes; build-tree checks cannot prove the public download is runnable. + smoke_dir="release-smoke/$target" + rm -rf "$smoke_dir" + mkdir -p "$smoke_dir" + tar -xzf "$tarball" -C "$smoke_dir" + smoke_launcher="$smoke_dir/clawfix-tui-$target" + smoke_binary="$smoke_dir/clawfix-tui-$target.bin" + test -x "$smoke_launcher" + test -x "$smoke_binary" + test "$(stat -c '%a' "$smoke_launcher")" = "755" + test "$(stat -c '%a' "$smoke_binary")" = "755" + test -d "$smoke_dir/assets-$target" + node scripts/verify-tui-artifact.mjs "$smoke_binary" --target "$target" + + if [ "$target" = "linux-x64-musl" ]; then + docker run --rm \ + -v "$PWD:/work" \ + -w /work \ + node:24-alpine \ + sh -lc 'set -eu + apk add --no-cache python3 >/dev/null + smoke_launcher=/work/release-smoke/linux-x64-musl/clawfix-tui-linux-x64-musl + smoke_binary=/work/release-smoke/linux-x64-musl/clawfix-tui-linux-x64-musl.bin + test -x "$smoke_launcher" + test -x "$smoke_binary" + node scripts/smoke-tui-binary.mjs "$smoke_launcher" + CLAWFIX_TUI_REQUIRE_PTY=1 node scripts/smoke-tui-interactive.mjs "$smoke_launcher"' + else + node scripts/smoke-tui-binary.mjs "$smoke_launcher" + CLAWFIX_TUI_REQUIRE_PTY=1 node scripts/smoke-tui-interactive.mjs "$smoke_launcher" + fi + echo "packed clawfix-tui-${target}.tar.gz" packed=$((packed + 1)) done @@ -174,9 +293,38 @@ 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: Revalidate remote tag before upload + run: | + set -euo pipefail + remote_sha="$(git ls-remote --tags origin "refs/tags/$GITHUB_REF_NAME^{}" | cut -f1)" + if [ -z "$remote_sha" ]; then + remote_sha="$(git ls-remote --tags origin "refs/tags/$GITHUB_REF_NAME" | cut -f1)" + fi + test -n "$remote_sha" + test "$remote_sha" = "$GITHUB_SHA" + - name: Upload to GitHub Release uses: softprops/action-gh-release@v2 with: + tag_name: ${{ github.ref_name }} files: | release-dist/*.tar.gz release-dist/TUI-SHA256SUMS diff --git a/CHANGELOG.md b/CHANGELOG.md index e11ace0..d4cbc00 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-08-02 + +- 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..2cbe633 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ ClawFix scans locally, redacts recognized secrets, and matches failures against deterministic rules. Optional AI analysis can explain unmatched problems when it is configured on the selected server. Model output never becomes executable shell. -[Quick start](#quick-start) · [How it works](#how-it-works) · [Security](#security--transparency) · [Self-hosting](#self-hosting) · [Contributing](#contributing) +[Quick start](#quick-start) · [How it works](#how-it-works) · [Security](#security--transparency) · [Versioned capability contract](docs/capabilities/v0.12.0.md) · [Self-hosting](#self-hosting) · [Contributing](#contributing) ## Quick Start @@ -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 @@ -51,10 +51,10 @@ bash clawfix.sh ClawFix is an open-source operator tool with active releases and public maintenance records. -Verified on July 22, 2026: +Maintenance evidence: -- The npm CLI recorded [1,175 downloads since February 22](https://api.npmjs.org/downloads/point/2026-02-22:2026-07-22/clawfix), including [247 downloads in the previous 30-day period](https://api.npmjs.org/downloads/point/2026-06-22:2026-07-21/clawfix). -- The hosted service reported [192 completed diagnoses](https://clawfix.dev/api/stats). +- On July 22, 2026, the npm CLI recorded [1,175 downloads since February 22](https://api.npmjs.org/downloads/point/2026-02-22:2026-07-22/clawfix), including [247 downloads in the previous 30-day period](https://api.npmjs.org/downloads/point/2026-06-22:2026-07-21/clawfix). +- On August 1, 2026, the hosted service reported [207 completed diagnoses](https://clawfix.dev/api/stats). - Current pull-request and `main` CI runs tests on Node.js 22 and 24, remediation proofs, ShellCheck-backed repair validation, a production dependency audit, npm package inspection, and a container smoke test. The release workflow repeats the Node.js 24 test, repair, audit, and package gates before publishing. Maintenance records are public in the [changelog](CHANGELOG.md), [releases](https://github.com/arcabotai/clawfix/releases), [issues](https://github.com/arcabotai/clawfix/issues), and [pull requests](https://github.com/arcabotai/clawfix/pulls). @@ -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/REVIEW.md b/REVIEW.md index 1294734..07419f8 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -670,7 +670,7 @@ OpenClaw's own supported commands and both verified against a real OpenClaw 2026 | 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` | sets `gateway.auth.mode` to `token`, then `doctor --fix --generate-gateway-token` | reads the mode back; never reads the token itself | medium | +| `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: diff --git a/SCRIPT_HASH b/SCRIPT_HASH index 7b61c63..66cc481 100644 --- a/SCRIPT_HASH +++ b/SCRIPT_HASH @@ -1 +1 @@ -c8f29823b1534fda1a26c4d95abaa9f17b5998c18077a3f5c1918d59317f37d8 +35c948b5cd599a538242c632513d554d30709acece2a17dbb9fb361a093c3751 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/adapters/openclaw.js b/cli/adapters/openclaw.js index 0a868cb..22dec75 100644 --- a/cli/adapters/openclaw.js +++ b/cli/adapters/openclaw.js @@ -490,11 +490,50 @@ export function createOpenClawAdapter({ * * Repairs verify against this rather than parsing openclaw.json: it is the value OpenClaw * resolves, and it keeps repair evidence to a single key instead of a whole config blob. - * Returns '' when the key is unset or the call fails — callers must not read that as false. + * An empty value can be a successful "unset" result. Callers must use `ok` to distinguish + * that from an invocation failure. */ async configGet(key, options = {}) { - if (typeof key !== 'string' || !/^[A-Za-z0-9_.-]{1,128}$/.test(key)) return ''; - return processText(await invoke(['config', 'get', key], options)); + if (typeof key !== 'string' || !/^[A-Za-z0-9_.-]{1,128}$/.test(key)) { + return Object.freeze({ + ok: false, + value: '', + status: null, + errorSummary: 'invalid config key', + }); + } + const result = await invoke(['config', 'get', key], options); + const ok = result.status === 0 + && result.errorCode == null + && result.errorSummary == null + && result.signal == null + && !result.timedOut + && !result.aborted + && !result.outputLimitExceeded + && !result.stdoutTruncated + && !result.stderrTruncated; + return Object.freeze({ + ok, + value: ok ? String(result.stdout || '').trim() : '', + status: result.status, + errorSummary: ok + ? null + : result.errorSummary || `openclaw config get exited with status ${result.status}`, + }); + }, + + /** + * Check whether a config key has a non-empty value without returning that value to callers. + * This is the only repair-facing primitive allowed for secret-bearing config keys. + */ + async configHasValue(key, options = {}) { + const read = await this.configGet(key, options); + return Object.freeze({ + ok: read.ok, + present: read.ok ? read.value.trim().length > 0 : false, + status: read.status, + errorSummary: read.errorSummary, + }); }, /** Set one config key. Values are passed as literal argv, never through a shell. */ @@ -504,6 +543,14 @@ export function createOpenClawAdapter({ } return invoke(['config', 'set', key, String(value)], options); }, + + /** Remove one config key through OpenClaw itself. */ + async configUnset(key, options = {}) { + if (typeof key !== 'string' || !/^[A-Za-z0-9_.-]{1,128}$/.test(key)) { + return Object.freeze({ status: 1, errorSummary: 'invalid config key' }); + } + return invoke(['config', 'unset', key], options); + }, /** * PIDs that plausibly belong to a running gateway *server*. * 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..979dc9d 100644 --- a/cli/core/repair-catalog.js +++ b/cli/core/repair-catalog.js @@ -12,6 +12,34 @@ // which itself only ever spawns argv arrays (shell: false). ctx.wait is an injectable delay hook // so tests can drive apply -> verify without real timers. +import { applyFailureReason } from './repair-engine.js'; + +/** + * Preserve the process adapter's complete terminal verdict without retaining command output or + * raw Error objects in repair/session state. The repair engine must see every failure marker; + * projecting only `status` lets a status-zero timeout, abort, signal, or truncated result pass. + */ +function terminalResult(result, details = {}) { + const source = result && typeof result === 'object' && !Array.isArray(result) ? result : {}; + const flag = (field) => (Object.hasOwn(source, field) ? source[field] : false); + const projected = { + status: source.status, + signal: source.signal ?? null, + timedOut: flag('timedOut'), + aborted: flag('aborted'), + stdoutTruncated: flag('stdoutTruncated'), + stderrTruncated: flag('stderrTruncated'), + outputLimitExceeded: flag('outputLimitExceeded'), + errorCode: source.errorCode ?? null, + errorSummary: source.errorSummary ?? null, + error: source.error == null ? null : true, + }; + for (const field of ['partial', 'partiallyApplied']) { + if (Object.hasOwn(source, field)) projected[field] = source[field]; + } + return Object.freeze({ ...projected, ...details }); +} + /** * Is the gateway actually up? * @@ -78,12 +106,7 @@ const gatewayNotRunning = Object.freeze({ async apply(ctx) { const { openclaw } = ctx; const result = await openclaw.invoke(['gateway', 'restart'], { timeoutMs: 60_000 }); - return Object.freeze({ - status: result.status, - timedOut: result.timedOut, - errorSummary: result.errorSummary, - stdout: result.stdout, - }); + return terminalResult(result); }, async verify(ctx) { @@ -108,6 +131,11 @@ function configFlag(value) { return null; } +function configValue(read) { + if (!read || read.ok !== true || typeof read.value !== 'string') return null; + return read.value; +} + /** * A repair that flips one boolean OpenClaw config key to `target`. * @@ -128,10 +156,15 @@ function configToggleRepair({ id, key, target, title, description, blockedReason risk, async preflight(ctx) { - const current = await ctx.openclaw.configGet(key, { timeoutMs: 10_000 }); + const read = await ctx.openclaw.configGet(key, { timeoutMs: 10_000 }); + const current = configValue(read); const flag = configFlag(current); if (flag === null) { - return Object.freeze({ ok: false, reason: 'config_state_unknown', evidence: { key, current } }); + return Object.freeze({ + ok: false, + reason: 'config_state_unknown', + evidence: { key, current: current ?? '', errorSummary: read?.errorSummary ?? null }, + }); } if (flag === target) { return Object.freeze({ ok: false, reason: blockedReason, evidence: { key, current } }); @@ -152,25 +185,43 @@ function configToggleRepair({ id, key, target, title, description, blockedReason async apply(ctx) { const result = await ctx.openclaw.configSet(key, targetText, { timeoutMs: 30_000 }); - return Object.freeze({ - status: result.status, - timedOut: result.timedOut, - errorSummary: result.errorSummary, + const failure = applyFailureReason(result); + return terminalResult(result, { + changed: failure === null ? true : 'unknown', + changes: failure === null + ? Object.freeze([Object.freeze({ type: 'config', key, before: previousText, after: targetText })]) + : Object.freeze([]), }); }, async verify(ctx) { - const current = await ctx.openclaw.configGet(key, { timeoutMs: 10_000 }); - return Object.freeze({ ok: configFlag(current) === target, evidence: { key, current } }); + const read = await ctx.openclaw.configGet(key, { timeoutMs: 10_000 }); + const current = configValue(read); + return Object.freeze({ + ok: configFlag(current) === target, + evidence: { key, current: current ?? '', errorSummary: read?.errorSummary ?? null }, + }); }, async rollback(ctx) { const result = await ctx.openclaw.configSet(key, previousText, { timeoutMs: 30_000 }); + if (result.status !== 0) { + return Object.freeze({ + rolledBack: false, + note: `Could not restore ${key}; check \`openclaw config get ${key}\`.`, + }); + } + const read = await ctx.openclaw.configGet(key, { timeoutMs: 10_000 }); + const current = configValue(read); + const restored = configFlag(current) === !target; return Object.freeze({ - rolledBack: result.status === 0, - note: result.status === 0 + rolledBack: restored, + note: restored ? `Restored ${key} to ${previousText}.` - : `Could not restore ${key}; check \`openclaw config get ${key}\`.`, + : read?.ok === true + ? `Rollback command completed but ${key} was not restored to ${previousText}.` + : `Rollback command completed but ${key} could not be read back: ` + + `${read?.errorSummary ?? 'unknown read failure'}.`, }); }, }); @@ -213,64 +264,193 @@ const gatewayLoopbackNoAuth = Object.freeze({ id: 'gateway-loopback-no-auth', title: 'Require a token on the gateway', description: - 'The gateway accepts unauthenticated connections. This switches auth to token mode and has ' - + 'OpenClaw generate one. Clients will need that token to connect afterwards.', + 'The gateway accepts unauthenticated connections. This switches auth to token mode, reuses ' + + 'an existing token or has OpenClaw generate one when missing, and verifies token presence ' + + 'without recording the token. Clients will need that token to connect afterwards.', // Medium, not low: existing clients stop working until they carry the new token. risk: 'medium', async preflight(ctx) { - const mode = await ctx.openclaw.configGet('gateway.auth.mode', { timeoutMs: 10_000 }); - const current = String(mode || '').trim().toLowerCase(); + const read = await ctx.openclaw.configGet('gateway.auth.mode', { timeoutMs: 10_000 }); + const mode = configValue(read); + const current = String(mode ?? '').trim().toLowerCase(); + if (mode === null || current === '') { + return Object.freeze({ + ok: false, + reason: 'config_state_unknown', + evidence: { mode: current, errorSummary: read?.errorSummary ?? null }, + }); + } if (current === 'token' || current === 'password' || current === 'trusted-proxy') { return Object.freeze({ ok: false, reason: 'gateway_auth_already_enabled', evidence: { mode: current } }); } - return Object.freeze({ ok: true, evidence: { mode: current || '(unset)' } }); + if (current !== 'none') { + return Object.freeze({ ok: false, reason: 'config_state_unknown', evidence: { mode: current } }); + } + return Object.freeze({ ok: true, evidence: { mode: current } }); }, async preview() { return Object.freeze({ steps: Object.freeze([ + 'Check whether gateway.auth.token is present without recording its value.', 'Set gateway.auth.mode to token through the OpenClaw CLI (argv, no shell).', - 'Run `openclaw doctor --fix --generate-gateway-token` so OpenClaw generates the token.', - 'Read gateway.auth.mode back to confirm token auth is active.', + 'If no token exists, run `openclaw doctor --fix --generate-gateway-token` so OpenClaw generates one.', + 'Read gateway.auth.mode and token presence back to confirm token auth is usable.', 'Restart the gateway yourself for it to take effect; existing clients need the new token.', ]), - summary: 'openclaw config set gateway.auth.mode token + doctor --generate-gateway-token', + summary: 'verify/generate gateway token + openclaw config set gateway.auth.mode token', }); }, async apply(ctx) { + const changes = []; + const tokenBefore = await ctx.openclaw.configHasValue( + 'gateway.auth.token', + { timeoutMs: 10_000 }, + ); + if (!tokenBefore.ok) { + return terminalResult(tokenBefore, { + stage: 'read-token-state', + changed: false, + changes: Object.freeze(changes), + errorSummary: tokenBefore.errorSummary || 'could not determine gateway token presence', + }); + } 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 }); + if (applyFailureReason(set) !== null) { + return terminalResult(set, { + stage: 'set-mode', + changed: 'unknown', + changes: Object.freeze(changes), + tokenPreviouslyPresent: tokenBefore.present, + tokenMayHaveChanged: false, + }); } - const generated = await ctx.openclaw.invoke( - ['doctor', '--fix', '--generate-gateway-token'], - { timeoutMs: 120_000 }, - ); - return Object.freeze({ - status: generated.status, + changes.push(Object.freeze({ + type: 'config', + key: 'gateway.auth.mode', + before: 'none', + after: 'token', + })); + if (tokenBefore.present) { + return terminalResult(set, { + stage: 'set-mode', + changed: true, + changes: Object.freeze(changes), + tokenPreviouslyPresent: true, + tokenMayHaveChanged: false, + }); + } + let generated; + try { + generated = await ctx.openclaw.invoke( + ['doctor', '--fix', '--generate-gateway-token'], + { timeoutMs: 120_000 }, + ); + } catch (error) { + return terminalResult(null, { + stage: 'generate-token', + changed: true, + changes: Object.freeze(changes), + tokenPreviouslyPresent: false, + tokenMayHaveChanged: true, + errorSummary: error.message, + error: true, + }); + } + return terminalResult(generated, { stage: 'generate-token', - timedOut: generated.timedOut, - errorSummary: generated.errorSummary, + changed: true, + changes: Object.freeze(changes), + tokenPreviouslyPresent: false, + tokenMayHaveChanged: true, }); }, + // Presence is the strongest safe local assertion: a client authentication round trip would + // require handling token material, so end-to-end token usability remains an external OpenClaw + // semantic rather than evidence retained by ClawFix. async verify(ctx) { - // Evidence is the mode only — never read the token itself into a repair record. - const mode = String(await ctx.openclaw.configGet('gateway.auth.mode', { timeoutMs: 10_000 })).trim(); - return Object.freeze({ ok: mode.toLowerCase() === 'token', evidence: { mode } }); + const [modeRead, tokenState] = await Promise.all([ + ctx.openclaw.configGet('gateway.auth.mode', { timeoutMs: 10_000 }), + ctx.openclaw.configHasValue('gateway.auth.token', { timeoutMs: 10_000 }), + ]); + const mode = String(configValue(modeRead) ?? '').trim(); + return Object.freeze({ + ok: modeRead?.ok === true + && mode.toLowerCase() === 'token' + && tokenState.ok === true + && tokenState.present === true, + evidence: { + mode, + tokenPresent: tokenState.ok === true ? tokenState.present : null, + errorSummary: modeRead?.errorSummary ?? tokenState.errorSummary ?? null, + }, + }); }, async rollback(ctx, { applyResult } = {}) { - if (applyResult?.stage === 'set-mode') { + if (!applyResult?.changed) { return Object.freeze({ rolledBack: false, note: 'Auth mode was never changed.' }); } + const setMode = await ctx.openclaw.configSet( + 'gateway.auth.mode', + 'none', + { timeoutMs: 30_000 }, + ); + if (setMode.status !== 0) { + return Object.freeze({ + rolledBack: false, + note: 'Could not restore gateway.auth.mode; inspect it before restarting the gateway.', + }); + } + const modeRead = await ctx.openclaw.configGet( + 'gateway.auth.mode', + { timeoutMs: 10_000 }, + ); + const mode = String(configValue(modeRead) ?? '').trim().toLowerCase(); + if (modeRead?.ok !== true || mode !== 'none') { + return Object.freeze({ + rolledBack: false, + note: modeRead?.ok === true + ? `Rollback command completed but gateway.auth.mode is ${mode || '(empty)'}, not none.` + : 'Rollback command completed but gateway.auth.mode could not be read back: ' + + `${modeRead?.errorSummary ?? 'unknown read failure'}.`, + }); + } + + if (applyResult.tokenPreviouslyPresent === false && applyResult.tokenMayHaveChanged) { + const unset = await ctx.openclaw.configUnset( + 'gateway.auth.token', + { timeoutMs: 30_000 }, + ); + if (unset.status !== 0) { + return Object.freeze({ + rolledBack: false, + note: 'Restored gateway.auth.mode to none, but could not remove the token this repair may have generated.', + }); + } + const tokenState = await ctx.openclaw.configHasValue( + 'gateway.auth.token', + { timeoutMs: 10_000 }, + ); + if (!tokenState.ok || tokenState.present) { + return Object.freeze({ + rolledBack: false, + note: tokenState.ok + ? 'Restored gateway.auth.mode to none, but the generated token is still present.' + : 'Restored gateway.auth.mode to none, but token absence could not be verified: ' + + `${tokenState.errorSummary ?? 'unknown read failure'}.`, + }); + } + } + return Object.freeze({ - rolledBack: false, - note: 'Gateway auth was switched to token mode. To undo it deliberately, run ' - + '`openclaw config set gateway.auth.mode none` — that returns the gateway to accepting ' - + 'unauthenticated connections.', + rolledBack: true, + note: applyResult.tokenPreviouslyPresent === false && applyResult.tokenMayHaveChanged + ? 'Restored gateway.auth.mode to none and verified the generated token was removed.' + : 'Restored gateway.auth.mode to its previous value, none.', }); }, }); diff --git a/cli/core/repair-engine.js b/cli/core/repair-engine.js index 01e00c8..82bf23b 100644 --- a/cli/core/repair-engine.js +++ b/cli/core/repair-engine.js @@ -22,14 +22,64 @@ function defaultRandomToken() { } /** Rollback is best-effort cleanup — a throw here must never mask the apply/verify outcome. */ -async function safeRollback(entry, ctx, applyResult) { +async function safeRollback(entry, ctx, applyResult, preflight) { try { - return await entry.rollback(ctx, { applyResult }); + return await entry.rollback(ctx, { applyResult, preflight }); } catch (error) { return Object.freeze({ rolledBack: false, note: `rollback failed: ${error.message}` }); } } +function safeResultText(value, fallback) { + try { + const text = String(value) + .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ') + .trim(); + return text ? text.slice(0, 200) : fallback; + } catch { + return fallback; + } +} + +export function applyFailureReason(result) { + if (!result || typeof result !== 'object' || Array.isArray(result)) { + return 'adapter returned no structured result'; + } + + const terminalFlags = [ + ['timedOut', 'command timed out'], + ['aborted', 'command was aborted'], + ['outputLimitExceeded', 'command output limit was exceeded'], + ['stdoutTruncated', 'command stdout was truncated'], + ['stderrTruncated', 'command stderr was truncated'], + ['partial', 'command reported a partial apply'], + ['partiallyApplied', 'command reported a partial apply'], + ]; + for (const [field, message] of terminalFlags) { + if (Object.hasOwn(result, field) && typeof result[field] !== 'boolean') { + return `adapter returned invalid ${field} metadata`; + } + if (result[field] === true) return message; + } + + if (result.signal != null) { + return `command terminated by signal ${safeResultText(result.signal, 'unknown')}`; + } + if (result.error != null) { + return `adapter error: ${safeResultText(result.error?.message || result.error, 'unknown error')}`; + } + if (result.errorCode != null) { + return `adapter error code ${safeResultText(result.errorCode, 'unknown')}`; + } + const errorSummary = result.errorSummary == null ? '' : safeResultText(result.errorSummary, ''); + if (errorSummary) { + return errorSummary; + } + if (result.changed === 'unknown') return 'adapter could not determine whether it changed state'; + if (result.status !== 0) return `status ${safeResultText(result.status, 'unknown')}`; + return null; +} + function stableFingerprintInput(finding, revision) { return JSON.stringify({ revision, @@ -160,7 +210,53 @@ export function createRepairEngine({ catalog = {}, now = () => Date.now(), rando try { applyResult = await entry.apply(ctx); } catch (error) { - return Object.freeze({ status: 'error', error: error.message, plan, preview }); + applyResult = Object.freeze({ + status: null, + changed: 'unknown', + changes: Object.freeze([]), + errorSummary: error.message, + }); + const rollback = await safeRollback(entry, ctx, applyResult, preflight); + return Object.freeze({ + status: 'error', + error: `apply failed: ${error.message}`, + plan, + preview, + applyResult, + rollback, + }); + } + + const applyFailure = applyFailureReason(applyResult); + if (applyFailure) { + // A failed/ambiguous process result may still have changed state. Roll back every returned + // failure rather than trusting an optional `changed` flag supplied by the failing adapter. + const rollback = await safeRollback(entry, ctx, applyResult, preflight); + return Object.freeze({ + status: 'error', + error: `apply failed: ${applyFailure}`, + plan, + preview, + applyResult, + rollback, + }); + } + + // 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 @@ -170,7 +266,7 @@ export function createRepairEngine({ catalog = {}, now = () => Date.now(), rando try { verify = await entry.verify(ctx); } catch (error) { - const rollback = await safeRollback(entry, ctx, applyResult); + const rollback = await safeRollback(entry, ctx, applyResult, preflight); return Object.freeze({ status: 'verify_failed', plan, @@ -182,7 +278,7 @@ export function createRepairEngine({ catalog = {}, now = () => Date.now(), rando } if (!verify.ok) { - const rollback = await safeRollback(entry, ctx, applyResult); + const rollback = await safeRollback(entry, ctx, applyResult, preflight); return Object.freeze({ status: 'verify_failed', plan, preview, applyResult, verify, rollback }); } diff --git a/cli/interfaces/plain.js b/cli/interfaces/plain.js index 87cc840..4da8c5a 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 }; @@ -494,14 +497,16 @@ async function applyBuiltinFix(issue, builtinFix, rl, scanFn) { console.log(` ${c.green('✅')} ${change}`); } - // Restart if needed + // Restart if needed. A failed restart is a failed verification, never a soft success. + let restartVerified = true; if (builtinFix.needsRestart) { process.stdout.write(` ${c.blue('🔄')} Restarting gateway...`); - const ok = tryGatewayRestart(); - console.log(ok ? ` ${c.green('✅')}` : ` ${c.yellow('⚠️ may need manual restart')}`); + restartVerified = tryGatewayRestart(); + console.log(restartVerified ? ` ${c.green('✅')}` : ` ${c.yellow('⚠️ restart failed')}`); } - // Re-scan to verify + // Re-scan to verify. Legacy fixes are never reported as applied without this evidence. + let status = 'unverified'; if (scanFn) { process.stdout.write(` ${c.blue('🔍')} Re-scanning...`); const scanResult = await scanFn(); @@ -509,18 +514,26 @@ async function applyBuiltinFix(issue, builtinFix, rl, scanFn) { const allAfter = mergeIssues(scanResult.issues, scanResult.serverIssues); const stillPresent = allAfter.some(candidate => candidate.id === issue.id); - if (stillPresent) { - console.log(` ${c.yellow('⚠️ issue may persist until gateway fully restarts')}`); + if (stillPresent || !restartVerified) { + status = 'verify_failed'; + console.log(` ${c.yellow('⚠️ verification failed')}`); } else { + status = 'applied'; console.log(` ${c.green('✅ Issue resolved!')}`); } } else { console.log(` ${c.dim('skipped')}`); } + } else { + console.log(` ${c.yellow('⚠️')} Verification unavailable; repair is not marked applied.`); } console.log(''); - return { applied: true }; + return { + status, + applied: status === 'applied', + backupPath, + }; } catch (err) { console.log(` ${c.red('❌')} Error: ${err.message}`); @@ -528,106 +541,10 @@ async function applyBuiltinFix(issue, builtinFix, rl, scanFn) { console.log(` ${c.dim(`Rollback available: cp ${backupPath} ${CONFIG_PATH}`)}`); } console.log(''); - return { error: err.message }; + return { status: 'error', applied: false, error: err.message, backupPath }; } } -/** - * 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 +877,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 +1030,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 +1195,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 +1227,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 +1248,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/build.ts b/cli/tui/scripts/build.ts index 8523b0f..c6e19be 100644 --- a/cli/tui/scripts/build.ts +++ b/cli/tui/scripts/build.ts @@ -203,13 +203,13 @@ export function stageOtuiAssetRoot(spec: TargetSpec, assetRoot: string): void { } function writeUnixLauncher(launcherPath: string, binName: string, assetDirName: string) { - const script = `#!/usr/bin/env bash + const script = `#!/bin/sh # ClawFix TUI launcher — sets OTUI_ASSET_ROOT for bun --compile OpenTUI assets. -set -euo pipefail +set -eu HERE="$(cd "$(dirname "$0")" && pwd)" export OTUI_ASSET_ROOT="\${OTUI_ASSET_ROOT:-$HERE/${assetDirName}}" export OTUI_TREE_SITTER_WORKER_PATH="\${OTUI_TREE_SITTER_WORKER_PATH:-$OTUI_ASSET_ROOT/@opentui/core/parser.worker.js}" -if [[ ! -d "$OTUI_ASSET_ROOT" ]]; then +if [ ! -d "$OTUI_ASSET_ROOT" ]; then echo "clawfix-tui: missing assets at $OTUI_ASSET_ROOT" >&2 exit 1 fi 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/docs/capabilities/evidence.json b/docs/capabilities/evidence.json new file mode 100644 index 0000000..3731745 --- /dev/null +++ b/docs/capabilities/evidence.json @@ -0,0 +1,46 @@ +{ + "asOf": "2026-08-02", + "release": { + "tag": "v0.12.0", + "state": "published", + "publishedAt": "2026-08-02T05:24:40Z", + "source": "https://github.com/arcabotai/clawfix/releases/tag/v0.12.0", + "assets": [ + "clawfix-tui-linux-x64-baseline.tar.gz", + "clawfix-tui-linux-x64.tar.gz", + "clawfix-tui-linux-x64-musl.tar.gz", + "TUI-SHA256SUMS" + ] + }, + "platforms": { + "portableCli": [ + "macOS", + "Linux", + "WSL" + ], + "standaloneTui": [ + "Linux x64", + "Linux x64 baseline", + "Linux x64 musl" + ] + }, + "validation": { + "supportedOpenClawVersions": [], + "supportStatement": "No broad OpenClaw compatibility range has been validated or asserted.", + "testedOpenClawVersions": [ + { + "version": "2026.6.11", + "scope": "Real-machine diagnostic and guarded-repair scenarios in the Blaxel lab.", + "source": "REVIEW.md" + } + ] + }, + "outcomes": { + "source": "https://clawfix.dev/api/stats", + "observedAt": "2026-08-01", + "totalDiagnoses": 207, + "recordedSuccesses": 5, + "unknown": 202, + "interpretation": "Unknown outcomes are missing follow-up, not failures. This snapshot does not establish a repair success rate." + } +} diff --git a/docs/capabilities/v0.11.2.json b/docs/capabilities/v0.11.2.json new file mode 100644 index 0000000..998dfee --- /dev/null +++ b/docs/capabilities/v0.11.2.json @@ -0,0 +1,175 @@ +{ + "schemaVersion": 1, + "generatedFrom": { + "packageMetadata": [ + "package.json", + "cli/package.json" + ], + "detectorCatalog": "src/known-issues.js", + "repairCatalog": "cli/core/repair-catalog.js", + "evidenceSnapshot": "docs/capabilities/evidence.json" + }, + "evidenceAsOf": "2026-07-27", + "release": { + "version": "0.11.2", + "tag": "v0.11.2", + "publishedAt": "2026-07-24T06:22:17Z", + "package": "clawfix", + "nodeEngine": ">=22.0.0", + "releaseMetadataSource": "https://api.github.com/repos/arcabotai/clawfix/releases/tags/v0.11.2", + "releaseAssets": [ + "clawfix-tui-linux-x64-baseline.tar.gz", + "clawfix-tui-linux-x64.tar.gz", + "TUI-SHA256SUMS" + ] + }, + "interfaces": { + "installer": { + "preparation": [ + "Download https://clawfix.dev/install to install-clawfix.sh.", + "Inspect the script and compare its SHA-256 with https://clawfix.dev/install/sha256.", + "Run bash install-clawfix.sh." + ], + "run": "clawfix", + "delivers": "Portable plain readline CLI installed under ~/.clawfix with a ~/.local/bin/clawfix launcher.", + "platforms": [ + "macOS", + "Linux", + "WSL" + ] + }, + "npx": { + "run": "npx clawfix@0.11.2", + "delivers": "Portable plain readline CLI.", + "platforms": [ + "macOS", + "Linux", + "WSL" + ] + }, + "standaloneTui": { + "bundledWithNpm": false, + "delivers": "Separate chat-first OpenTUI release asset.", + "platforms": [ + "Linux x64", + "Linux x64 baseline" + ], + "assets": [ + "clawfix-tui-linux-x64-baseline.tar.gz", + "clawfix-tui-linux-x64.tar.gz" + ] + } + }, + "detectors": { + "deterministicCount": 49, + "ids": [ + "auto-update-enabled-warning", + "auto-update-restart-loop", + "browser-port-binding", + "browser-relay-handshake-spam", + "browser-relay-not-listening", + "browser-relay-outdated", + "browser-relay-wrong-port", + "codex-service-tier-not-fast", + "codex-session-store-permission", + "codex-shell-home-mismatch", + "config-reload-sigterm-cascade", + "context-overflow", + "discord-allowlist-empty", + "duplicate-plugin", + "filevault-blocks-reboot", + "gateway-extended-downtime", + "gateway-not-listening", + "gateway-not-running", + "gateway-watchdog-missing", + "gateway-zombie", + "ggml-metal-crash", + "heartbeat-no-model-override", + "high-token-usage", + "invalid-gh-token-override", + "large-workspace-files", + "launchd-corrupted-state", + "macos-app-metadata-upgrade", + "matrix-sync-timeout-spam", + "mem0-graph-free", + "missing-agents-md", + "native-codex-timeout-boundary", + "no-compaction-config", + "no-context-pruning", + "no-hybrid-search", + "no-memory-files", + "no-memory-flush", + "no-soul", + "openclaw-node-engine-mismatch", + "orphan-tool-calls", + "oversized-error-log", + "pi-backed-openai-codex-route", + "plaintext-secrets-in-config", + "port-conflict", + "provider-prefix-unregistered", + "session-transcript-not-indexed", + "stale-bundled-plugin-load-paths", + "stale-plist-env-secrets", + "stale-self-paired-node", + "state-dir-migration" + ] + }, + "executableRepairs": { + "count": 5, + "items": [ + { + "id": "auto-update-enabled-warning", + "title": "Disable OpenClaw auto-update", + "risk": "low" + }, + { + "id": "gateway-loopback-no-auth", + "title": "Require a token on the gateway", + "risk": "medium" + }, + { + "id": "gateway-not-running", + "title": "Restart the OpenClaw gateway", + "risk": "low" + }, + { + "id": "no-hybrid-search", + "title": "Enable hybrid memory search", + "risk": "low" + }, + { + "id": "no-memory-flush", + "title": "Enable memory flush on compaction", + "risk": "low" + } + ], + "safetyBoundary": "Repairs are deterministic catalog entries with preview, apply, verify, and rollback contracts." + }, + "aiBoundary": { + "optional": true, + "serverControlled": true, + "purpose": "Explain unmatched diagnostic evidence and provide advisory analysis.", + "modelOutputCanBecomeExecutableShell": false, + "novelAiFindingsAutomaticallyRepairable": false + }, + "compatibility": { + "supportedOpenClawVersions": [], + "supportStatement": "No broad OpenClaw compatibility range has been validated or asserted.", + "testedOpenClawVersions": [ + { + "version": "2026.6.11", + "scope": "Real-machine diagnostic and guarded-repair scenarios in the Blaxel lab.", + "source": "REVIEW.md" + } + ] + }, + "measuredOutcomes": { + "source": "https://clawfix.dev/api/stats", + "observedAt": "2026-07-27", + "totalDiagnoses": 205, + "recordedSuccesses": 5, + "unknown": 200, + "interpretation": "Unknown outcomes are missing follow-up, not failures. This snapshot does not establish a repair success rate." + }, + "claimBoundary": "ClawFix diagnoses OpenClaw in one command and can apply only the supported repairs listed in this contract." +} diff --git a/docs/capabilities/v0.11.2.md b/docs/capabilities/v0.11.2.md new file mode 100644 index 0000000..630a25f --- /dev/null +++ b/docs/capabilities/v0.11.2.md @@ -0,0 +1,74 @@ +# ClawFix 0.11.2 capability contract + +Evidence snapshot: **2026-07-27**. This file is generated; edit `evidence.json` or shipped source, then run `npm run capabilities:generate`. + +## Product boundary + +ClawFix diagnoses OpenClaw in one command and can apply only the supported repairs listed in this contract. + +- Deterministic detectors: **49** +- Executable reviewed repairs: **5** +- Node.js: `>=22.0.0` + +## Interfaces delivered + +### Installer + +- Download https://clawfix.dev/install to install-clawfix.sh. +- Inspect the script and compare its SHA-256 with https://clawfix.dev/install/sha256. +- Run bash install-clawfix.sh. + +Run `clawfix`. It delivers: Portable plain readline CLI installed under ~/.clawfix with a ~/.local/bin/clawfix launcher. + +### npm + +Run `npx clawfix@0.11.2`. It delivers: Portable plain readline CLI. + +### Standalone TUI + +The TUI is **not bundled with npm**. ClawFix 0.11.2 publishes these separate assets: + +- `clawfix-tui-linux-x64-baseline.tar.gz` +- `clawfix-tui-linux-x64.tar.gz` + +Portable CLI platforms documented for this release: macOS, Linux, WSL. +Standalone TUI platforms evidenced by the release assets: Linux x64, Linux x64 baseline. + +## Executable repair catalog + +| ID | Action | Risk | +|---|---|---| +| `auto-update-enabled-warning` | Disable OpenClaw auto-update | low | +| `gateway-loopback-no-auth` | Require a token on the gateway | medium | +| `gateway-not-running` | Restart the OpenClaw gateway | low | +| `no-hybrid-search` | Enable hybrid memory search | low | +| `no-memory-flush` | Enable memory flush on compaction | low | + +Repairs are deterministic catalog entries with preview, apply, verify, and rollback contracts. + +## AI boundary + +AI analysis is optional and controlled by the selected server. It may explain unmatched evidence, but model output never becomes executable shell and a novel AI finding is not automatically repairable. + +## OpenClaw compatibility evidence + +No broad OpenClaw compatibility range has been validated or asserted. + +- OpenClaw 2026.6.11: Real-machine diagnostic and guarded-repair scenarios in the Blaxel lab. ([source](../../REVIEW.md)) + +## Measured outcomes + +The [public stats snapshot](https://clawfix.dev/api/stats) recorded: + +- Total diagnoses: **205** +- Recorded successes: **5** +- Unknown outcomes: **200** + +Unknown outcomes are missing follow-up, not failures. This snapshot does not establish a repair success rate. + +## Sources + +- Release metadata: https://api.github.com/repos/arcabotai/clawfix/releases/tags/v0.11.2 +- Detector catalog: [`src/known-issues.js`](../../src/known-issues.js) +- Repair catalog: [`cli/core/repair-catalog.js`](../../cli/core/repair-catalog.js) +- Machine-readable contract: [`v0.11.2.json`](./v0.11.2.json) diff --git a/docs/capabilities/v0.12.0.json b/docs/capabilities/v0.12.0.json new file mode 100644 index 0000000..fa01558 --- /dev/null +++ b/docs/capabilities/v0.12.0.json @@ -0,0 +1,179 @@ +{ + "schemaVersion": 1, + "generatedFrom": { + "packageMetadata": [ + "package.json", + "cli/package.json" + ], + "detectorCatalog": "src/known-issues.js", + "repairCatalog": "cli/core/repair-catalog.js", + "evidenceSnapshot": "docs/capabilities/evidence.json" + }, + "evidenceAsOf": "2026-08-02", + "release": { + "version": "0.12.0", + "tag": "v0.12.0", + "state": "published", + "publishedAt": "2026-08-02T05:24:40Z", + "package": "clawfix", + "nodeEngine": ">=22.0.0", + "releaseMetadataSource": "https://github.com/arcabotai/clawfix/releases/tag/v0.12.0", + "releaseAssets": [ + "clawfix-tui-linux-x64-baseline.tar.gz", + "clawfix-tui-linux-x64.tar.gz", + "clawfix-tui-linux-x64-musl.tar.gz", + "TUI-SHA256SUMS" + ] + }, + "interfaces": { + "installer": { + "preparation": [ + "Download https://clawfix.dev/install to install-clawfix.sh.", + "Inspect the script and compare its SHA-256 with https://clawfix.dev/install/sha256.", + "Run bash install-clawfix.sh." + ], + "run": "clawfix", + "delivers": "Portable plain readline CLI installed under ~/.clawfix with a ~/.local/bin/clawfix launcher.", + "platforms": [ + "macOS", + "Linux", + "WSL" + ] + }, + "npx": { + "run": "npx clawfix@0.12.0", + "delivers": "Portable plain readline CLI.", + "platforms": [ + "macOS", + "Linux", + "WSL" + ] + }, + "standaloneTui": { + "bundledWithNpm": false, + "delivers": "Separate chat-first OpenTUI release asset.", + "platforms": [ + "Linux x64", + "Linux x64 baseline", + "Linux x64 musl" + ], + "assets": [ + "clawfix-tui-linux-x64-baseline.tar.gz", + "clawfix-tui-linux-x64.tar.gz", + "clawfix-tui-linux-x64-musl.tar.gz" + ] + } + }, + "detectors": { + "deterministicCount": 49, + "ids": [ + "auto-update-enabled-warning", + "auto-update-restart-loop", + "browser-port-binding", + "browser-relay-handshake-spam", + "browser-relay-not-listening", + "browser-relay-outdated", + "browser-relay-wrong-port", + "codex-service-tier-not-fast", + "codex-session-store-permission", + "codex-shell-home-mismatch", + "config-reload-sigterm-cascade", + "context-overflow", + "discord-allowlist-empty", + "duplicate-plugin", + "filevault-blocks-reboot", + "gateway-extended-downtime", + "gateway-not-listening", + "gateway-not-running", + "gateway-watchdog-missing", + "gateway-zombie", + "ggml-metal-crash", + "heartbeat-no-model-override", + "high-token-usage", + "invalid-gh-token-override", + "large-workspace-files", + "launchd-corrupted-state", + "macos-app-metadata-upgrade", + "matrix-sync-timeout-spam", + "mem0-graph-free", + "missing-agents-md", + "native-codex-timeout-boundary", + "no-compaction-config", + "no-context-pruning", + "no-hybrid-search", + "no-memory-files", + "no-memory-flush", + "no-soul", + "openclaw-node-engine-mismatch", + "orphan-tool-calls", + "oversized-error-log", + "pi-backed-openai-codex-route", + "plaintext-secrets-in-config", + "port-conflict", + "provider-prefix-unregistered", + "session-transcript-not-indexed", + "stale-bundled-plugin-load-paths", + "stale-plist-env-secrets", + "stale-self-paired-node", + "state-dir-migration" + ] + }, + "executableRepairs": { + "count": 5, + "items": [ + { + "id": "auto-update-enabled-warning", + "title": "Disable OpenClaw auto-update", + "risk": "low" + }, + { + "id": "gateway-loopback-no-auth", + "title": "Require a token on the gateway", + "risk": "medium" + }, + { + "id": "gateway-not-running", + "title": "Restart the OpenClaw gateway", + "risk": "low" + }, + { + "id": "no-hybrid-search", + "title": "Enable hybrid memory search", + "risk": "low" + }, + { + "id": "no-memory-flush", + "title": "Enable memory flush on compaction", + "risk": "low" + } + ], + "safetyBoundary": "Repairs are deterministic catalog entries with preview, apply, verify, and rollback contracts." + }, + "aiBoundary": { + "optional": true, + "serverControlled": true, + "purpose": "Explain unmatched diagnostic evidence and provide advisory analysis.", + "modelOutputCanBecomeExecutableShell": false, + "novelAiFindingsAutomaticallyRepairable": false + }, + "compatibility": { + "supportedOpenClawVersions": [], + "supportStatement": "No broad OpenClaw compatibility range has been validated or asserted.", + "testedOpenClawVersions": [ + { + "version": "2026.6.11", + "scope": "Real-machine diagnostic and guarded-repair scenarios in the Blaxel lab.", + "source": "REVIEW.md" + } + ] + }, + "measuredOutcomes": { + "source": "https://clawfix.dev/api/stats", + "observedAt": "2026-08-01", + "totalDiagnoses": 207, + "recordedSuccesses": 5, + "unknown": 202, + "interpretation": "Unknown outcomes are missing follow-up, not failures. This snapshot does not establish a repair success rate." + }, + "claimBoundary": "ClawFix diagnoses OpenClaw in one command and can apply only the supported repairs listed in this contract." +} diff --git a/docs/capabilities/v0.12.0.md b/docs/capabilities/v0.12.0.md new file mode 100644 index 0000000..f3dd005 --- /dev/null +++ b/docs/capabilities/v0.12.0.md @@ -0,0 +1,75 @@ +# ClawFix 0.12.0 release capability contract + +Evidence snapshot: **2026-08-02**. This file is generated; edit `evidence.json` or shipped source, then run `npm run capabilities:generate`. + +## Product boundary + +ClawFix diagnoses OpenClaw in one command and can apply only the supported repairs listed in this contract. + +- Deterministic detectors: **49** +- Executable reviewed repairs: **5** +- Node.js: `>=22.0.0` + +## Interfaces delivered + +### Installer + +- Download https://clawfix.dev/install to install-clawfix.sh. +- Inspect the script and compare its SHA-256 with https://clawfix.dev/install/sha256. +- Run bash install-clawfix.sh. + +Run `clawfix`. It delivers: Portable plain readline CLI installed under ~/.clawfix with a ~/.local/bin/clawfix launcher. + +### npm + +Run `npx clawfix@0.12.0`. It delivers: Portable plain readline CLI. + +### Standalone TUI + +The TUI is **not bundled with npm**. ClawFix 0.12.0 publishes these separate assets: + +- `clawfix-tui-linux-x64-baseline.tar.gz` +- `clawfix-tui-linux-x64.tar.gz` +- `clawfix-tui-linux-x64-musl.tar.gz` + +Portable CLI platforms documented for this release: macOS, Linux, WSL. +Standalone TUI platforms evidenced by the release assets: Linux x64, Linux x64 baseline, Linux x64 musl. + +## Executable repair catalog + +| ID | Action | Risk | +|---|---|---| +| `auto-update-enabled-warning` | Disable OpenClaw auto-update | low | +| `gateway-loopback-no-auth` | Require a token on the gateway | medium | +| `gateway-not-running` | Restart the OpenClaw gateway | low | +| `no-hybrid-search` | Enable hybrid memory search | low | +| `no-memory-flush` | Enable memory flush on compaction | low | + +Repairs are deterministic catalog entries with preview, apply, verify, and rollback contracts. + +## AI boundary + +AI analysis is optional and controlled by the selected server. It may explain unmatched evidence, but model output never becomes executable shell and a novel AI finding is not automatically repairable. + +## OpenClaw compatibility evidence + +No broad OpenClaw compatibility range has been validated or asserted. + +- OpenClaw 2026.6.11: Real-machine diagnostic and guarded-repair scenarios in the Blaxel lab. ([source](../../REVIEW.md)) + +## Measured outcomes + +The [public stats snapshot](https://clawfix.dev/api/stats) recorded: + +- Total diagnoses: **207** +- Recorded successes: **5** +- Unknown outcomes: **202** + +Unknown outcomes are missing follow-up, not failures. This snapshot does not establish a repair success rate. + +## Sources + +- Release metadata: https://github.com/arcabotai/clawfix/releases/tag/v0.12.0 +- Detector catalog: [`src/known-issues.js`](../../src/known-issues.js) +- Repair catalog: [`cli/core/repair-catalog.js`](../../cli/core/repair-catalog.js) +- Machine-readable contract: [`v0.12.0.json`](./v0.12.0.json) 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..5699e51 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", @@ -19,6 +19,8 @@ "start": "node --env-file-if-exists=.env src/server.js", "dev": "node --env-file-if-exists=.env --watch src/server.js", "test": "node scripts/run-node-tests.mjs", + "capabilities:generate": "node scripts/generate-capability-contract.mjs", + "capabilities:check": "node scripts/generate-capability-contract.mjs --check", "hash:script": "node scripts/update-script-hash.mjs", "test:tui": "bun test --cwd cli/tui", "prove:remediation": "node scripts/prove-remediation.mjs .", @@ -32,7 +34,8 @@ "build:tui": "node scripts/build-tui-release.mjs", "verify:tui": "node scripts/verify-tui-artifact.mjs", "smoke:tui": "node scripts/smoke-tui-binary.mjs", - "smoke:tui:interactive": "node scripts/smoke-tui-interactive.mjs" + "smoke:tui:interactive": "node scripts/smoke-tui-interactive.mjs", + "verify:production": "node scripts/verify-production.mjs" }, "dependencies": { "express": "^5.1.0", diff --git a/scripts/generate-capability-contract.mjs b/scripts/generate-capability-contract.mjs new file mode 100755 index 0000000..4bbda9c --- /dev/null +++ b/scripts/generate-capability-contract.mjs @@ -0,0 +1,252 @@ +#!/usr/bin/env node + +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { repairCatalog } from '../cli/core/repair-catalog.js'; +import { KNOWN_ISSUES } from '../src/known-issues.js'; + +const ROOT = new URL('../', import.meta.url); +const OUTPUT_DIR = new URL('docs/capabilities/', ROOT); + +async function readJson(relativePath) { + return JSON.parse(await readFile(new URL(relativePath, ROOT), 'utf8')); +} + +function assertUnique(label, values) { + const unique = new Set(values); + if (unique.size !== values.length) { + throw new Error(`${label} contains duplicate ids`); + } +} + +export async function buildCapabilityContract() { + const [rootPackage, cliPackage, evidence] = await Promise.all([ + readJson('package.json'), + readJson('cli/package.json'), + readJson('docs/capabilities/evidence.json'), + ]); + + if (rootPackage.version !== cliPackage.version) { + throw new Error( + `root version ${rootPackage.version} does not match CLI version ${cliPackage.version}`, + ); + } + if (evidence.release.tag !== `v${cliPackage.version}`) { + throw new Error( + `evidence tag ${evidence.release.tag} does not match CLI version ${cliPackage.version}`, + ); + } + if (evidence.outcomes.recordedSuccesses + evidence.outcomes.unknown + !== evidence.outcomes.totalDiagnoses) { + throw new Error('outcome counts do not add up to totalDiagnoses'); + } + + const detectorIds = KNOWN_ISSUES.map(issue => issue.id).sort(); + const repairs = Object.values(repairCatalog) + .map(entry => ({ + id: entry.id, + title: entry.title, + risk: entry.risk, + })) + .sort((left, right) => left.id.localeCompare(right.id)); + + assertUnique('detector catalog', detectorIds); + assertUnique('repair catalog', repairs.map(repair => repair.id)); + + const version = cliPackage.version; + return { + schemaVersion: 1, + generatedFrom: { + packageMetadata: ['package.json', 'cli/package.json'], + detectorCatalog: 'src/known-issues.js', + repairCatalog: 'cli/core/repair-catalog.js', + evidenceSnapshot: 'docs/capabilities/evidence.json', + }, + evidenceAsOf: evidence.asOf, + release: { + version, + tag: evidence.release.tag, + state: evidence.release.state || 'published', + publishedAt: evidence.release.publishedAt, + package: cliPackage.name, + nodeEngine: cliPackage.engines.node, + releaseMetadataSource: evidence.release.source, + releaseAssets: evidence.release.assets, + }, + interfaces: { + installer: { + preparation: [ + 'Download https://clawfix.dev/install to install-clawfix.sh.', + 'Inspect the script and compare its SHA-256 with https://clawfix.dev/install/sha256.', + 'Run bash install-clawfix.sh.', + ], + run: 'clawfix', + delivers: 'Portable plain readline CLI installed under ~/.clawfix with a ~/.local/bin/clawfix launcher.', + platforms: evidence.platforms.portableCli, + }, + npx: { + run: `npx ${cliPackage.name}@${version}`, + delivers: 'Portable plain readline CLI.', + platforms: evidence.platforms.portableCli, + }, + standaloneTui: { + bundledWithNpm: false, + delivers: 'Separate chat-first OpenTUI release asset.', + platforms: evidence.platforms.standaloneTui, + assets: evidence.release.assets.filter(asset => asset.endsWith('.tar.gz')), + }, + }, + detectors: { + deterministicCount: detectorIds.length, + ids: detectorIds, + }, + executableRepairs: { + count: repairs.length, + items: repairs, + safetyBoundary: 'Repairs are deterministic catalog entries with preview, apply, verify, and rollback contracts.', + }, + aiBoundary: { + optional: true, + serverControlled: true, + purpose: 'Explain unmatched diagnostic evidence and provide advisory analysis.', + modelOutputCanBecomeExecutableShell: false, + novelAiFindingsAutomaticallyRepairable: false, + }, + compatibility: evidence.validation, + measuredOutcomes: evidence.outcomes, + claimBoundary: 'ClawFix diagnoses OpenClaw in one command and can apply only the supported repairs listed in this contract.', + }; +} + +export function serializeCapabilityContract(contract) { + return `${JSON.stringify(contract, null, 2)}\n`; +} + +function markdownList(values) { + return values.map(value => `- ${value}`).join('\n'); +} + +export function renderCapabilityMarkdown(contract) { + const repairRows = contract.executableRepairs.items + .map(repair => `| \`${repair.id}\` | ${repair.title} | ${repair.risk} |`) + .join('\n'); + const testedVersions = contract.compatibility.testedOpenClawVersions + .map(item => `- OpenClaw ${item.version}: ${item.scope} ([source](../../${item.source}))`) + .join('\n'); + const releaseLabel = contract.release.state === 'published' ? 'release' : 'release candidate'; + const assetVerb = contract.release.state === 'published' + ? 'publishes these separate assets' + : 'is expected to publish these separate assets'; + + return `# ClawFix ${contract.release.version} ${releaseLabel} capability contract + +Evidence snapshot: **${contract.evidenceAsOf}**. This file is generated; edit \`evidence.json\` or shipped source, then run \`npm run capabilities:generate\`. + +## Product boundary + +${contract.claimBoundary} + +- Deterministic detectors: **${contract.detectors.deterministicCount}** +- Executable reviewed repairs: **${contract.executableRepairs.count}** +- Node.js: \`${contract.release.nodeEngine}\` + +## Interfaces delivered + +### Installer + +${markdownList(contract.interfaces.installer.preparation)} + +Run \`${contract.interfaces.installer.run}\`. It delivers: ${contract.interfaces.installer.delivers} + +### npm + +Run \`${contract.interfaces.npx.run}\`. It delivers: ${contract.interfaces.npx.delivers} + +### Standalone TUI + +The TUI is **not bundled with npm**. ClawFix ${contract.release.version} ${assetVerb}: + +${markdownList(contract.interfaces.standaloneTui.assets.map(asset => `\`${asset}\``))} + +Portable CLI platforms documented for this release: ${contract.interfaces.npx.platforms.join(', ')}. +Standalone TUI platforms evidenced by the release assets: ${contract.interfaces.standaloneTui.platforms.join(', ')}. + +## Executable repair catalog + +| ID | Action | Risk | +|---|---|---| +${repairRows} + +${contract.executableRepairs.safetyBoundary} + +## AI boundary + +AI analysis is optional and controlled by the selected server. It may explain unmatched evidence, but model output never becomes executable shell and a novel AI finding is not automatically repairable. + +## OpenClaw compatibility evidence + +${contract.compatibility.supportStatement} + +${testedVersions || '- No real-machine OpenClaw version evidence is recorded.'} + +## Measured outcomes + +The [public stats snapshot](${contract.measuredOutcomes.source}) recorded: + +- Total diagnoses: **${contract.measuredOutcomes.totalDiagnoses}** +- Recorded successes: **${contract.measuredOutcomes.recordedSuccesses}** +- Unknown outcomes: **${contract.measuredOutcomes.unknown}** + +${contract.measuredOutcomes.interpretation} + +## Sources + +- Release metadata: ${contract.release.releaseMetadataSource} +- Detector catalog: [\`src/known-issues.js\`](../../src/known-issues.js) +- Repair catalog: [\`cli/core/repair-catalog.js\`](../../cli/core/repair-catalog.js) +- Machine-readable contract: [\`v${contract.release.version}.json\`](./v${contract.release.version}.json) +`; +} + +export async function generateCapabilityContract({ check = false } = {}) { + const contract = await buildCapabilityContract(); + const outputs = [ + { + url: new URL(`v${contract.release.version}.json`, OUTPUT_DIR), + content: serializeCapabilityContract(contract), + }, + { + url: new URL(`v${contract.release.version}.md`, OUTPUT_DIR), + content: renderCapabilityMarkdown(contract), + }, + ]; + + if (check) { + for (const output of outputs) { + const actual = await readFile(output.url, 'utf8').catch(() => ''); + if (actual !== output.content) { + throw new Error( + `${fileURLToPath(output.url)} is stale; run npm run capabilities:generate`, + ); + } + } + return contract; + } + + await mkdir(OUTPUT_DIR, { recursive: true }); + await Promise.all(outputs.map(output => writeFile(output.url, output.content, 'utf8'))); + return contract; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + generateCapabilityContract({ check: process.argv.includes('--check') }) + .then(contract => { + const verb = process.argv.includes('--check') ? 'Verified' : 'Generated'; + console.log(`${verb} capability contract v${contract.release.version}`); + }) + .catch(error => { + console.error(`Capability contract generation failed: ${error.message}`); + process.exitCode = 1; + }); +} 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..5bf6b23 100644 --- a/scripts/smoke-tui-interactive.mjs +++ b/scripts/smoke-tui-interactive.mjs @@ -18,10 +18,13 @@ import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' const DRIVER = String.raw` -import os, pty, subprocess, sys, threading, time +import fcntl, os, pty, re, struct, subprocess, sys, termios, threading, time binary = sys.argv[1] master, slave = pty.openpty() +# Some CI and nested-container PTYs start at 0x0. OpenTUI can exit cleanly without ever +# rendering in that state, which is a harness false positive rather than product evidence. +fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 30, 100, 0, 0)) proc = subprocess.Popen([binary, "--fake-session"], stdin=slave, stdout=slave, stderr=slave, env={**os.environ, "TERM": "xterm-256color", "CLAWFIX_TUI_SMOKE": "1"}) os.close(slave) @@ -64,7 +67,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 +112,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/scripts/verify-production.mjs b/scripts/verify-production.mjs new file mode 100644 index 0000000..263d52d --- /dev/null +++ b/scripts/verify-production.mjs @@ -0,0 +1,374 @@ +#!/usr/bin/env node + +import { createHash, randomUUID } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const DEFAULT_BASE_URL = 'https://clawfix.dev'; +const DEFAULT_TIMEOUT_MS = 15_000; +const MAX_RESPONSE_BYTES = 1_000_000; +const PACKAGE_VERSION = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8'), +).version; + +function invariant(value, message) { + if (!value) throw new Error(message); +} + +function safeMessage(error) { + const message = error instanceof Error ? error.message : String(error); + return message.replace(/[\r\n]+/g, ' ').slice(0, 500); +} + +function normalizeBaseUrl(value) { + let parsed; + try { + parsed = new URL(value); + } catch { + throw new Error('base URL is invalid'); + } + invariant(!parsed.username && !parsed.password, 'base URL must not contain credentials'); + invariant(!parsed.search && !parsed.hash, 'base URL must not contain a query or fragment'); + invariant(parsed.pathname === '/', 'base URL must not contain a path'); + + const hostname = parsed.hostname.replace(/^\[|\]$/g, ''); + const isLoopback = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; + invariant( + parsed.protocol === 'https:' || (parsed.protocol === 'http:' && isLoopback), + 'remote base URL must use HTTPS', + ); + return parsed.origin; +} + +async function readBoundedText(response, label) { + const advertisedLength = Number(response.headers?.get?.('content-length')); + if (Number.isFinite(advertisedLength) && advertisedLength > MAX_RESPONSE_BYTES) { + throw new Error(`${label} body exceeded ${MAX_RESPONSE_BYTES} bytes`); + } + + if (!response.body || typeof response.body.getReader !== 'function') { + invariant(typeof response.text === 'function', `${label} returned an invalid response`); + const text = await response.text(); + invariant( + Buffer.byteLength(text, 'utf8') <= MAX_RESPONSE_BYTES, + `${label} body exceeded ${MAX_RESPONSE_BYTES} bytes`, + ); + return text; + } + + const reader = response.body.getReader(); + const chunks = []; + let totalBytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = Buffer.from(value); + totalBytes += chunk.length; + if (totalBytes > MAX_RESPONSE_BYTES) { + try { + await reader.cancel(); + } catch { + // The size failure remains authoritative. + } + throw new Error(`${label} body exceeded ${MAX_RESPONSE_BYTES} bytes`); + } + chunks.push(chunk); + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks, totalBytes).toString('utf8'); +} + +async function requestText(fetchImpl, url, options, { label, timeoutMs }) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + let response; + try { + response = await fetchImpl(url, { + ...options, + redirect: 'error', + signal: controller.signal, + }); + } catch (error) { + if (controller.signal.aborted) { + throw new Error(`${label} timed out after ${timeoutMs}ms`); + } + throw new Error(`${label} request failed: ${safeMessage(error)}`); + } + invariant(response && typeof response === 'object', `${label} returned an invalid response`); + invariant(response.ok, `${label} returned HTTP ${response.status}`); + const text = await readBoundedText(response, label); + return { response, text }; + } finally { + clearTimeout(timer); + } +} + +function parseJson(text, label) { + try { + return JSON.parse(text); + } catch { + throw new Error(`${label} returned invalid JSON`); + } +} + +export function parseSseEvents(raw) { + const events = []; + const normalized = String(raw).replace(/\r\n/g, '\n'); + invariant(normalized.length === 0 || /\n\n+$/.test(normalized), 'unterminated SSE frame'); + const frames = normalized.split(/\n\n+/); + for (const frame of frames) { + if (!frame.trim()) continue; + let event = 'message'; + const data = []; + for (const line of frame.split('\n')) { + if (line.startsWith('event:')) event = line.slice(6).trim(); + else if (line.startsWith('data:')) data.push(line.slice(5).trimStart()); + } + if (data.length === 0) continue; + const payload = data.join('\n'); + try { + events.push({ event, data: JSON.parse(payload) }); + } catch { + throw new Error(`invalid SSE JSON for ${event}`); + } + } + return events; +} + +export function parseVerifierArgs(argv) { + const parsed = { + baseUrl: DEFAULT_BASE_URL, + expectedVersion: PACKAGE_VERSION, + meteredMode: 'none', + timeoutMs: DEFAULT_TIMEOUT_MS, + }; + let agent = false; + let diagnose = false; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--base-url') parsed.baseUrl = argv[++index]; + else if (arg === '--expected-version') parsed.expectedVersion = argv[++index]; + else if (arg === '--timeout-ms') parsed.timeoutMs = Number.parseInt(argv[++index], 10); + else if (arg === '--agent-canary') agent = true; + else if (arg === '--diagnose-canary') diagnose = true; + else if (arg === '--help' || arg === '-h') { + throw new Error( + 'usage: verify-production [--base-url URL] [--expected-version X.Y.Z] ' + + '[--timeout-ms N] [--agent-canary | --diagnose-canary]', + ); + } else { + throw new Error(`unknown argument: ${arg}`); + } + } + + invariant(typeof parsed.baseUrl === 'string' && parsed.baseUrl.length > 0, '--base-url requires a value'); + invariant( + typeof parsed.expectedVersion === 'string' && /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(parsed.expectedVersion), + '--expected-version requires a semantic version', + ); + invariant(Number.isSafeInteger(parsed.timeoutMs) && parsed.timeoutMs > 0, '--timeout-ms must be a positive integer'); + invariant(!(agent && diagnose), '--agent-canary and --diagnose-canary are mutually exclusive'); + if (agent) parsed.meteredMode = 'agent'; + if (diagnose) parsed.meteredMode = 'diagnose'; + return parsed; +} + +async function verifyFreeSurface({ baseUrl, expectedVersion, fetchImpl, timeoutMs }) { + const requestOptions = { label: '', timeoutMs }; + const [rootResult, healthResult, statsResult, installerResult, installerHashResult] = await Promise.all([ + requestText(fetchImpl, `${baseUrl}/`, { headers: { accept: 'text/html' } }, { + ...requestOptions, + label: 'root', + }), + requestText(fetchImpl, `${baseUrl}/api/health`, { headers: { accept: 'application/json' } }, { + ...requestOptions, + label: 'health', + }), + requestText(fetchImpl, `${baseUrl}/api/stats`, { headers: { accept: 'application/json' } }, { + ...requestOptions, + label: 'stats', + }), + requestText(fetchImpl, `${baseUrl}/install`, { headers: { accept: 'text/plain' } }, { + ...requestOptions, + label: 'installer', + }), + requestText(fetchImpl, `${baseUrl}/install/sha256`, { headers: { accept: 'application/json' } }, { + ...requestOptions, + label: 'installer hash', + }), + ]); + + invariant(/ClawFix/i.test(rootResult.text), 'root did not contain the ClawFix product marker'); + + const health = parseJson(healthResult.text, 'health'); + invariant(health?.status === 'ok', `health status was ${String(health?.status)}`); + invariant(Number.isFinite(Date.parse(health?.timestamp)), 'health timestamp was invalid'); + + const stats = parseJson(statsResult.text, 'stats'); + invariant( + stats?.version === expectedVersion, + `expected version ${expectedVersion}, received ${String(stats?.version)}`, + ); + invariant(Number.isFinite(Number(stats?.totalDiagnoses)), 'stats totalDiagnoses was invalid'); + + const hashDocument = parseJson(installerHashResult.text, 'installer hash'); + const advertisedHash = hashDocument?.sha256; + invariant(typeof advertisedHash === 'string' && /^[a-f0-9]{64}$/i.test(advertisedHash), 'installer hash was invalid'); + const calculatedHash = createHash('sha256').update(installerResult.text).digest('hex'); + invariant(calculatedHash === advertisedHash, 'installer SHA-256 mismatch'); + const headerHash = installerResult.response.headers?.get?.('x-script-sha256'); + invariant(headerHash === advertisedHash, 'installer response header SHA-256 mismatch'); + + return [ + { name: 'root', ok: true }, + { name: 'health', ok: true, status: health.status }, + { name: 'stats', ok: true, version: stats.version }, + { name: 'installer', ok: true, sha256: calculatedHash }, + ]; +} + +async function verifyAgentCanary({ baseUrl, fetchImpl, timeoutMs, apiToken }) { + const conversationId = randomUUID(); + const headers = { accept: 'text/event-stream', 'content-type': 'application/json' }; + if (apiToken) headers.authorization = `Bearer ${apiToken}`; + const result = await requestText(fetchImpl, `${baseUrl}/api/v2/agent/messages`, { + method: 'POST', + headers, + body: JSON.stringify({ + conversationId, + message: 'ClawFix continuity canary. Reply with one short sentence and do not propose a repair.', + availableRepairs: [], + }), + }, { label: 'agent canary', timeoutMs }); + invariant( + result.response.headers?.get?.('content-type')?.includes('text/event-stream'), + 'agent canary did not return SSE', + ); + const events = parseSseEvents(result.text); + invariant(!events.some((event) => event.event === 'agent.error'), 'agent canary emitted agent.error'); + const metaEvents = events.filter((event) => event.event === 'agent.meta'); + invariant(metaEvents.length === 1, 'agent canary must emit exactly one agent.meta event'); + const meta = metaEvents[0]; + invariant(events[0] === meta, 'agent.meta must be the first event'); + const deltas = events.filter((event) => event.event === 'assistant.delta'); + const doneEvents = events.filter((event) => event.event === 'agent.done'); + invariant(doneEvents.length === 1, 'agent canary must emit exactly one agent.done event'); + const done = doneEvents[0]; + invariant(events.at(-1) === done, 'agent.done must be the final event'); + invariant(meta?.data?.conversationId === conversationId, 'agent canary metadata did not match the conversation'); + const assistantText = deltas + .map((event) => typeof event.data?.text === 'string' ? event.data.text : '') + .join(''); + invariant(assistantText.trim().length > 0, 'agent canary returned no assistant text'); + invariant(done?.data?.conversationId === conversationId, 'agent canary did not complete'); + return { name: 'agent-canary', ok: true, sseEvents: events.length }; +} + +async function verifyDiagnosisCanary({ baseUrl, fetchImpl, timeoutMs, apiToken, canaryToken }) { + invariant(canaryToken, 'CLAWFIX_CANARY_TOKEN is required for --diagnose-canary'); + const headers = { + accept: 'application/json', + 'content-type': 'application/json', + 'x-clawfix-canary': canaryToken, + }; + if (apiToken) headers.authorization = `Bearer ${apiToken}`; + const result = await requestText(fetchImpl, `${baseUrl}/api/diagnose`, { + method: 'POST', + headers, + body: JSON.stringify({ + system: { os: 'clawfix-continuity-canary', arch: 'x64' }, + openclaw: { version: 'canary', processExists: true, portListening: true }, + service: { manager: 'synthetic', state: 'running', exitCode: 0 }, + logs: { errors: '', sigtermCount: 0, errLogSizeMB: 0 }, + _localIssues: [], + }), + }, { label: 'diagnosis canary', timeoutMs }); + const diagnostic = parseJson(result.text, 'diagnosis canary'); + invariant(typeof diagnostic?.fixId === 'string' && diagnostic.fixId.length >= 10, 'diagnosis canary returned no fix ID'); + invariant(typeof diagnostic?.analysis === 'string' && diagnostic.analysis.length > 0, 'diagnosis canary returned no analysis'); + invariant( + diagnostic.canary === true && diagnostic.persisted === true, + 'diagnosis canary classification or persistence was not acknowledged', + ); + return { name: 'diagnosis-canary', ok: true, fixIdPresent: true }; +} + +export async function runProductionVerification({ + baseUrl = DEFAULT_BASE_URL, + expectedVersion = PACKAGE_VERSION, + meteredMode = 'none', + timeoutMs = DEFAULT_TIMEOUT_MS, + apiToken = '', + canaryToken = '', + fetchImpl = globalThis.fetch, +} = {}) { + invariant(typeof fetchImpl === 'function', 'fetch is unavailable'); + invariant(['none', 'agent', 'diagnose'].includes(meteredMode), `invalid metered mode: ${meteredMode}`); + if (meteredMode === 'diagnose') { + invariant(canaryToken, 'CLAWFIX_CANARY_TOKEN is required for --diagnose-canary'); + } + + const normalizedBaseUrl = normalizeBaseUrl(baseUrl); + const startedAt = new Date().toISOString(); + const checks = await verifyFreeSurface({ + baseUrl: normalizedBaseUrl, + expectedVersion, + fetchImpl, + timeoutMs, + }); + + if (meteredMode === 'agent') { + checks.push(await verifyAgentCanary({ + baseUrl: normalizedBaseUrl, + fetchImpl, + timeoutMs, + apiToken, + })); + } else if (meteredMode === 'diagnose') { + checks.push(await verifyDiagnosisCanary({ + baseUrl: normalizedBaseUrl, + fetchImpl, + timeoutMs, + apiToken, + canaryToken, + })); + } + + return { + ok: true, + baseUrl: normalizedBaseUrl, + expectedVersion, + meteredMode, + startedAt, + finishedAt: new Date().toISOString(), + checks, + }; +} + +async function main() { + try { + const options = parseVerifierArgs(process.argv.slice(2)); + const report = await runProductionVerification({ + ...options, + apiToken: process.env.CLAWFIX_API_TOKEN || '', + canaryToken: process.env.CLAWFIX_CANARY_TOKEN || '', + }); + console.log(JSON.stringify(report, null, 2)); + } catch (error) { + console.error(JSON.stringify({ + ok: false, + error: safeMessage(error), + finishedAt: new Date().toISOString(), + }, null, 2)); + process.exitCode = 1; + } +} + +const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ''; +if (import.meta.url === invokedPath) await main(); 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..5e427c7 100644 --- a/src/db.js +++ b/src/db.js @@ -4,6 +4,25 @@ const { Pool } = pg; let pool = null; +const PUBLIC_DIAGNOSIS_FILTER = "source IS DISTINCT FROM 'canary'"; + +export function shouldCountDiagnosisInPublicMetrics(source) { + return source !== 'canary'; +} + +/** Public dashboard queries over diagnoses. Canary rows stay available for operations only. */ +export function getPublicStatsQueries() { + return Object.freeze({ + total: `SELECT COUNT(*) as count FROM diagnoses WHERE ${PUBLIC_DIAGNOSIS_FILTER}`, + today: `SELECT COUNT(*) as count FROM diagnoses WHERE ${PUBLIC_DIAGNOSIS_FILTER} AND created_at > NOW() - INTERVAL '24 hours'`, + versions: `SELECT openclaw_version, COUNT(*) as count FROM diagnoses WHERE ${PUBLIC_DIAGNOSIS_FILTER} AND openclaw_version IS NOT NULL GROUP BY openclaw_version ORDER BY count DESC LIMIT 5`, + outcomes: `SELECT outcome, COUNT(*) as count FROM diagnoses WHERE ${PUBLIC_DIAGNOSIS_FILTER} GROUP BY outcome`, + serviceManagers: `SELECT service_manager, COUNT(*) as count FROM diagnoses WHERE ${PUBLIC_DIAGNOSIS_FILTER} AND service_manager IS NOT NULL GROUP BY service_manager ORDER BY count DESC`, + sigterms: `SELECT COUNT(*) as count FROM diagnoses WHERE ${PUBLIC_DIAGNOSIS_FILTER} AND (sigterm_count > 0 OR service_state = 'sigterm')`, + zombies: `SELECT COUNT(*) as count FROM diagnoses WHERE ${PUBLIC_DIAGNOSIS_FILTER} AND (service_state = 'crashed' OR service_state = 'failed')`, + }); +} + export function getPool() { if (!pool && process.env.DATABASE_URL) { pool = new Pool({ @@ -19,6 +38,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 +172,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); @@ -118,7 +200,7 @@ export async function initDB() { */ export async function storeDiagnosis(result, source = 'cli') { const db = getPool(); - if (!db) return; + if (!db) return false; try { await db.query(` @@ -150,7 +232,7 @@ export async function storeDiagnosis(result, source = 'cli') { ]); // Update pattern detection counts - if (result.knownIssues) { + if (shouldCountDiagnosisInPublicMetrics(source) && result.knownIssues) { for (const issue of result.knownIssues) { await db.query(` INSERT INTO patterns (id, title, severity, times_detected, last_seen) @@ -161,8 +243,10 @@ export async function storeDiagnosis(result, source = 'cli') { `, [issue.id, issue.title, issue.severity]); } } + return true; } catch (err) { console.error('Store diagnosis failed:', err.message); + return false; } } @@ -186,8 +270,8 @@ export async function storeFeedback(fixId, success, issuesRemaining, comment) { // Update pattern success rates if (success) { - const diag = await db.query('SELECT issues_pattern FROM diagnoses WHERE id = $1', [fixId]); - if (diag.rows[0]) { + const diag = await db.query('SELECT issues_pattern, source FROM diagnoses WHERE id = $1', [fixId]); + if (diag.rows[0] && shouldCountDiagnosisInPublicMetrics(diag.rows[0].source)) { const patterns = diag.rows[0].issues_pattern || []; for (const patternId of patterns) { await db.query(` @@ -207,8 +291,7 @@ export async function storeFeedback(fixId, success, issuesRemaining, comment) { /** * Retrieve a diagnosis by fix ID (for results page persistence) */ -export async function getDiagnosis(fixId) { - const db = getPool(); +export async function getDiagnosis(fixId, db = getPool()) { if (!db) return null; try { @@ -235,6 +318,7 @@ export async function getDiagnosis(fixId) { return { fixId: row.id, + _source: row.source || 'unknown', timestamp: row.created_at.toISOString(), issuesFound: row.issues_count, knownIssues, @@ -264,15 +348,16 @@ export async function getStats() { if (!db) return null; try { + const queries = getPublicStatsQueries(); const [total, today, topIssues, versions, outcomes, serviceManagers, sigterms, zombies] = await Promise.all([ - db.query('SELECT COUNT(*) as count FROM diagnoses'), - db.query("SELECT COUNT(*) as count FROM diagnoses WHERE created_at > NOW() - INTERVAL '24 hours'"), + db.query(queries.total), + db.query(queries.today), db.query('SELECT id, title, severity, times_detected, success_rate FROM patterns ORDER BY times_detected DESC LIMIT 10'), - db.query('SELECT openclaw_version, COUNT(*) as count FROM diagnoses WHERE openclaw_version IS NOT NULL GROUP BY openclaw_version ORDER BY count DESC LIMIT 5'), - db.query("SELECT outcome, COUNT(*) as count FROM diagnoses GROUP BY outcome"), - db.query("SELECT service_manager, COUNT(*) as count FROM diagnoses WHERE service_manager IS NOT NULL GROUP BY service_manager ORDER BY count DESC"), - db.query("SELECT COUNT(*) as count FROM diagnoses WHERE sigterm_count > 0 OR service_state = 'sigterm'"), - db.query("SELECT COUNT(*) as count FROM diagnoses WHERE service_state = 'crashed' OR service_state = 'failed'"), + db.query(queries.versions), + db.query(queries.outcomes), + db.query(queries.serviceManagers), + db.query(queries.sigterms), + db.query(queries.zombies), ]); return { 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 + + + - - + +