Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/release-tui.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ jobs:
test -x "$launcher"
# Proves compile + OTUI_ASSET_ROOT launcher; no wasm path crash.
node scripts/smoke-tui-binary.mjs "$launcher"
# Proves the binary is actually usable: renders, accepts typed input, quits on Ctrl+D.
# A compile without the Solid JSX transform passes every check above and still ships
# a session nobody can type into.
CLAWFIX_TUI_REQUIRE_PTY=1 node scripts/smoke-tui-interactive.mjs "$launcher"

- name: Upload artifact
uses: actions/upload-artifact@v4
Expand Down
542 changes: 542 additions & 0 deletions REVIEW.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion SCRIPT_HASH
Original file line number Diff line number Diff line change
@@ -1 +1 @@
4470a867f26e2c3e729c0a03c109270252e11e2973089984731e2c503b78d96b
c8f29823b1534fda1a26c4d95abaa9f17b5998c18077a3f5c1918d59317f37d8
55 changes: 53 additions & 2 deletions cli/adapters/openclaw.js
Original file line number Diff line number Diff line change
Expand Up @@ -484,8 +484,59 @@ export function createOpenClawAdapter({
npmVersion(options = {}) {
return successfulText('npm', ['--version'], options);
},
gatewayProcesses(options = {}) {
return successfulText('pgrep', ['-f', 'openclaw.*gateway'], options);
/**
* PIDs that plausibly belong to a running gateway *server*.
*
* The old pattern was `pgrep -f 'openclaw.*gateway'`, which was wrong in both directions on
* a real install: a gateway started by `openclaw gateway run` re-execs with the bare argv
* `openclaw`, so it never matched, while ClawFix's own `openclaw gateway status` probe —
* which runs concurrently with this call — always did. So this reported "running" whenever
* ClawFix looked, and never because a gateway was actually up.
*
* Transient CLI invocations are excluded by verb, and this process is excluded outright.
* Treat the result as supporting evidence only: `gatewayListening()` is the real signal.
*/
async gatewayProcesses(options = {}) {
const listing = await successfulText('pgrep', ['-af', 'openclaw'], options);
const self = String(process.pid);
const pids = [];
for (const line of String(listing || '').split('\n')) {
const match = /^(\d+)\s+(.*)$/.exec(line.trim());
if (!match) continue;
const [, pid, command] = match;
if (pid === self) continue;
// Sub-commands ClawFix itself runs, plus anything merely mentioning openclaw.
if (/\b(gateway\s+(status|restart|stop|logs|probe|health|diagnostics|stability|call|discover|install)|doctor|config|--version)\b/.test(command)) continue;
if (/\b(pgrep|grep|tail|less|cat|sh -c|bash -c|node .*clawfix)\b/.test(command)) continue;
pids.push(pid);
}
return pids.join('\n');
},

/**
* Whether something is accepting connections on the gateway port.
*
* This is what "the gateway is running" means operationally, and it is the same evidence
* the diagnostics core already reports as `portListening`.
*/
async gatewayListening(port = 18789, options = {}) {
if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
const probes = [
['ss', ['-ltn']],
['netstat', ['-ltn']],
['lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN']],
];
for (const [executable, argv] of probes) {
const text = await successfulText(executable, argv, options);
if (!text) continue;
if (executable === 'lsof') return text.trim().length > 0;
// Match ":<port>" at the end of a local address column only.
if (new RegExp(`[\\s:\\[\\]][:.]?${port}\\s`).test(`${text}\n`) || new RegExp(`:${port}\\b`).test(text)) {
return true;
}
return false;
}
return false;
},
serviceManagerState,
readFileTail,
Expand Down
39 changes: 30 additions & 9 deletions cli/bin/clawfix.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* npx clawfix --tui (force OpenTUI; requires Bun)
*/

import { readFileSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { execSync, spawn } from 'node:child_process';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
Expand Down Expand Up @@ -41,9 +41,10 @@ function printHelp() {
Usage: npx clawfix [options]

Modes:
(default) OpenTUI session UI (falls back to plain session without Bun)
(default) Interactive session. This package ships the plain session; the OpenTUI
chat UI is a separate standalone binary (see below)
--plain Classic interactive readline session: scan, review, fix, optional chat
--tui Force the OpenTUI session UI (requires Bun)
--tui Force the OpenTUI session UI (source checkout + Bun only)
--scan One-shot scan (legacy mode)
--no-interactive Same as --scan

Expand Down Expand Up @@ -79,18 +80,39 @@ Security:
• ClawFix discloses OpenRouter and asks before the first upload (unless --yes)
• Source code: https://github.com/arcabotai/clawfix

OpenTUI chat session:
Not bundled in this npm package. Download the standalone binary from
https://github.com/arcabotai/clawfix/releases/latest

Examples:
npx clawfix # OpenTUI session (default; plain fallback without Bun)
npx clawfix # interactive plain session
npx clawfix --plain # Classic interactive session
npx clawfix --scan # One-shot scan + repair guidance
npx clawfix --dry-run # See what data would be collected
npx clawfix --yes --scan # Auto-send for CI/scripting
`);
}

const TUI_RELEASES_URL = 'https://github.com/arcabotai/clawfix/releases/latest';

async function runOpenTuiMode({ quiet = false } = {}) {
const cliDir = dirname(fileURLToPath(import.meta.url));
const tuiEntry = join(cliDir, '../tui/src/main.tsx');
const tuiDir = join(cliDir, '../tui');

// The npm package ships the portable CLI only — the OpenTUI session is distributed as a
// standalone binary. Detect that here instead of spawning Bun against a missing directory,
// which surfaces as `spawn <bun> ENOENT` and wrongly blames a Bun install that is present.
if (!existsSync(tuiEntry)) {
if (quiet) return false;
console.error(c.red('The OpenTUI session is not bundled in the clawfix npm package.'));
console.error(c.dim(`Download the standalone binary: ${TUI_RELEASES_URL}`));
console.error(c.dim('Or run it from a source checkout: cd cli/tui && bun install && bun run src/main.tsx'));
console.error(c.dim('This session works today with: npx clawfix --plain'));
process.exitCode = 2;
return false;
}

let bunPath = '';
try {
bunPath = execSync('command -v bun', { encoding: 'utf8' }).trim();
Expand All @@ -106,7 +128,7 @@ async function runOpenTuiMode({ quiet = false } = {}) {
const child = spawn(bunPath, [tuiEntry], {
stdio: 'inherit',
env: process.env,
cwd: join(cliDir, '../tui'),
cwd: tuiDir,
});
child.on('error', error => resolve({ error }));
child.on('exit', code => resolve({ code: code ?? 1 }));
Expand All @@ -119,10 +141,9 @@ async function runOpenTuiMode({ quiet = false } = {}) {
}
return false;
}
if (result.code !== 0) {
if (!quiet) process.exitCode = result.code;
return false;
}
// The TUI ran. A non-zero exit is its own outcome to report — it must not be mistaken for
// "never launched", which would drop the user into a second, full plain session.
if (result.code !== 0) process.exitCode = result.code;
return true;
}

Expand Down
5 changes: 3 additions & 2 deletions cli/core/findings.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// ClawFix Task 5: normalize local diagnostic issues, native OpenClaw findings, server findings,
// and future AI repair proposals into one frozen Finding contract with stable, explicit identity.
// ClawFix Task 5: normalize local diagnostic issues (including native OpenClaw checks, which
// diagnostics.js folds into localIssues carrying a nativeCheckId), server findings, and future
// AI repair proposals into one frozen Finding contract with stable, explicit identity.
//
// The rule this module exists to enforce: a repair may only be authorized from an *explicit
// reviewed mapping* keyed by a stable local `knownIssueId` or native `checkId` — never from a
Expand Down
37 changes: 33 additions & 4 deletions cli/core/repair-catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,43 @@
// 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.

/**
* Is the gateway actually up?
*
* The answer is the listening port. Two weaker signals were previously treated as proof and
* both were wrong on a real install:
*
* - `pgrep -f 'openclaw.*gateway'` matched ClawFix's own `openclaw gateway status` probe (run
* concurrently with it, right here) and never matched a real gateway, whose argv is just
* `openclaw`. verify() therefore returned ok with nothing listening on the port, which is
* how a repair that did nothing could be reported as applied.
* - the `/running.*pid|state active/i` test against status prose never fired on the real
* `openclaw gateway status` output at all.
*
* Status text and PIDs are still collected, as evidence for the caller — never as the verdict.
*/
async function checkGatewayRunning(ctx) {
const { openclaw } = ctx;
const [statusText, pid] = await Promise.all([
const port = Number.isInteger(ctx.gatewayPort) ? ctx.gatewayPort : 18789;
const [statusText, pid, listening] = await Promise.all([
openclaw.gatewayStatusText({ timeoutMs: 5000 }),
openclaw.gatewayProcesses({ timeoutMs: 5000 }),
typeof openclaw.gatewayProcesses === 'function'
? openclaw.gatewayProcesses({ timeoutMs: 5000 })
: Promise.resolve(''),
typeof openclaw.gatewayListening === 'function'
? openclaw.gatewayListening(port, { timeoutMs: 5000 })
: Promise.resolve(null),
]);
const running = Boolean(pid) || /running.*pid|state active/i.test(statusText || '');
return Object.freeze({ running, statusText: statusText || '', pid: pid || '' });

// Without a port probe, fall back to filtered PIDs rather than claiming knowledge.
const running = listening === null ? Boolean(pid) : Boolean(listening);
return Object.freeze({
running,
listening: listening === null ? null : Boolean(listening),
port,
statusText: statusText || '',
pid: pid || '',
});
}

const gatewayNotRunning = Object.freeze({
Expand Down
62 changes: 58 additions & 4 deletions cli/core/repair-engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,22 @@

import { randomUUID, createHash } from 'node:crypto';

const REFUSED_RISKS = new Set(['high', 'critical']);
const MAX_RETAINED_PLANS = 256;

function defaultRandomToken() {
return randomUUID();
}

/** Rollback is best-effort cleanup — a throw here must never mask the apply/verify outcome. */
async function safeRollback(entry, ctx, applyResult) {
try {
return await entry.rollback(ctx, { applyResult });
} catch (error) {
return Object.freeze({ rolledBack: false, note: `rollback failed: ${error.message}` });
}
}

function stableFingerprintInput(finding, revision) {
return JSON.stringify({
revision,
Expand Down Expand Up @@ -57,6 +69,15 @@ export function createRepairEngine({ catalog = {}, now = () => Date.now(), rando
throw new Error(`no catalog entry for repair "${finding.repairId}"`);
}

// Consumed plans are dead weight; keep only enough history to keep rejecting replays of
// recent tokens rather than growing for the life of the process.
if (records.size > MAX_RETAINED_PLANS) {
for (const [id, record] of records) {
if (records.size <= MAX_RETAINED_PLANS) break;
if (record.consumed) records.delete(id);
}
}

const planId = randomUUID();
const fingerprint = computeFingerprint(finding, revision);
const approvalToken = randomToken();
Expand Down Expand Up @@ -112,12 +133,28 @@ export function createRepairEngine({ catalog = {}, now = () => Date.now(), rando

const entry = catalog[plan.repairId];

const preflight = await entry.preflight(ctx);
// High/critical repairs are never executed automatically. This belongs here rather than in
// any one UI: the engine is the only layer every caller has to go through.
if (REFUSED_RISKS.has(String(entry.risk ?? plan.risk).toLowerCase())) {
return Object.freeze({ status: 'blocked', reason: 'risk_refused', plan });
}

let preflight;
try {
preflight = await entry.preflight(ctx);
} catch (error) {
return Object.freeze({ status: 'error', error: `preflight failed: ${error.message}`, plan });
}
if (!preflight.ok) {
return Object.freeze({ status: 'blocked', reason: preflight.reason, plan, preflight });
}

const preview = await entry.preview(ctx);
let preview = null;
try {
preview = await entry.preview(ctx);
} catch (error) {
return Object.freeze({ status: 'error', error: `preview failed: ${error.message}`, plan });
}

let applyResult;
try {
Expand All @@ -126,9 +163,26 @@ export function createRepairEngine({ catalog = {}, now = () => Date.now(), rando
return Object.freeze({ status: 'error', error: error.message, plan, preview });
}

const verify = await entry.verify(ctx);
// Past this point the repair has run. Every remaining failure must still be reported as a
// structured outcome that carries applyResult — throwing here would lose the one fact the
// caller most needs, which is that the system was already changed.
let verify;
try {
verify = await entry.verify(ctx);
} catch (error) {
const rollback = await safeRollback(entry, ctx, applyResult);
return Object.freeze({
status: 'verify_failed',
plan,
preview,
applyResult,
verify: Object.freeze({ ok: false, error: error.message }),
rollback,
});
}

if (!verify.ok) {
const rollback = await entry.rollback(ctx, { applyResult });
const rollback = await safeRollback(entry, ctx, applyResult);
return Object.freeze({ status: 'verify_failed', plan, preview, applyResult, verify, rollback });
}

Expand Down
22 changes: 20 additions & 2 deletions cli/core/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export function createSessionController({
let scanError = null;
let transcript = freezeList();
let activeScan = null;
let messageCounter = 0;

function getState() {
return Object.freeze({
Expand Down Expand Up @@ -132,9 +133,10 @@ export function createSessionController({
} else {
diagnostic = result?.diagnostic ?? null;
issues = freezeList(result?.issues);
// Native OpenClaw checks arrive folded into localIssues with a nativeCheckId — there is
// no separate nativeChecks input, and passing one silently did nothing.
findings = freezeList(normalizeFindings({
localIssues: issues,
nativeChecks: result?.nativeChecks,
serverFindings: result?.serverFindings,
aiFindings: result?.aiFindings,
knownRepairIds,
Expand All @@ -157,6 +159,13 @@ export function createSessionController({
activeScan = null;
revision = nextRevision;
scanning = false;
// A thrown scan commits the new revision, so the previous revision's results must not
// survive alongside it — otherwise stale findings stay repairable under a revision whose
// scan never produced them. Mirrors the result.error branch above.
diagnostic = null;
issues = freezeList();
findings = freezeList();
summary = null;
scanError = asError(error);
emitSession('session.scan.committed', {
revision,
Expand All @@ -174,7 +183,16 @@ export function createSessionController({
function appendMessage(role, text) {
if (!SESSION_ROLES.has(role)) throw new TypeError('role must be user, assistant, or system');
if (typeof text !== 'string' || text.trim().length === 0) throw new TypeError('text must be non-empty');
const message = Object.freeze({ type: 'session.message', role, text, at: Date.now() });
// A stable id per message: consumers key rendered rows by it, and without one every
// projection minted fresh keys for the whole transcript on each update.
messageCounter += 1;
const message = Object.freeze({
type: 'session.message',
id: `msg-${messageCounter}`,
role,
text,
at: Date.now(),
});
transcript = freezeList([...transcript, message]);
onEvent(message);
return message;
Expand Down
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"author": "Arca <arca@arcabot.ai> (https://arcabot.ai)",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
"node": ">=22.0.0"
},
"files": [
"bin/",
Expand Down
Loading