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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## v1.8.6 — Config Repair, Gateway Mode & Node.js Update

### Bug Fixes

- **Config Corruption Fix (#83, #88)** — Provider model entries were written as bare strings instead of objects (`{ id: "model-name" }`), causing OpenClaw config validation to reject the file with "expected object, received string". Fixed both the Node.js script path and the direct file I/O fallback in `ProviderConfigService`. Existing corrupted configs are now auto-repaired on gateway init
- **Gateway Start Failure (#93, #90)** — The gateway blocked with "set gateway.mode=local (current: unset)". Now `gateway.mode=local` is set automatically in openclaw.json during provider config saves, gateway config writes, bionic bypass installation, and on startup repair
- **Config Auto-Repair on Init (#88)** — Added `_repairConfigFile()` that runs on every `GatewayService.init()` to fix corrupted model entries and missing `gateway.mode`, preventing the crash-restart loop (5 restarts → stopped)
- **Bionic Bypass Installation Robustness (#94)** — Added retry logic with parent directory creation if the initial `mkdirs()` fails silently on some devices
- **Pre-seed Config on Setup**`installBionicBypass()` now creates a default `openclaw.json` with `gateway.mode=local` during initial setup, so the gateway works immediately after installation
- **Setup Re-prompt After Node Upgrade (#97)** — Expanded auto-repair on splash screen to reinstall Node.js and OpenClaw when their binaries are missing but rootfs is intact, instead of forcing a full re-setup

### Enhancements

- **Node.js Updated to 22.14.0** — Upgraded from 22.13.1 to latest 22.x LTS for better stability and compatibility (#87)
- **npm Package Synced to 1.8.6** — Updated package.json version, refreshed dependencies, bumped engine to Node >= 22
- **Removed Outdated Model** — Dropped `claude-3-5-sonnet-20241022` from Anthropic provider defaults

---

## v1.8.4 — Serial, Log Timestamps & ADB Backup

### New Features
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -824,7 +824,15 @@ class BootstrapManager(

fun installBionicBypass() {
val bypassDir = File("$rootfsDir/root/.openclaw")
bypassDir.mkdirs()
if (!bypassDir.exists()) {
bypassDir.mkdirs()
}
// Verify directory was created — some devices fail silently (#94)
if (!bypassDir.exists()) {
// Retry with parent creation
bypassDir.parentFile?.mkdirs()
bypassDir.mkdir()
}

// 1. CWD fix — proot's getcwd() syscall returns ENOSYS on Android 10+.
// process.cwd() is called by Node's CJS module resolver and npm.
Expand Down Expand Up @@ -1239,6 +1247,61 @@ require('/root/.openclaw/proot-compat.js');
if (!existing.contains("bionic-bypass")) {
bashrc.appendText("\n# OpenClaw Bionic Bypass\n$exportLine\n")
}

// Pre-seed openclaw.json with gateway.mode=local so the gateway
// doesn't reject startup with "set gateway.mode=local" (#93, #90).
val configFile = File(bypassDir, "openclaw.json")
if (!configFile.exists()) {
configFile.writeText("""
{
"gateway": {
"mode": "local"
}
}
""".trimIndent())
} else {
// Repair existing config: ensure gateway.mode and fix model entries (#83, #88)
try {
val content = configFile.readText()
val json = org.json.JSONObject(content)
var modified = false
if (!json.has("gateway")) {
json.put("gateway", org.json.JSONObject().put("mode", "local"))
modified = true
} else {
val gw = json.getJSONObject("gateway")
if (!gw.has("mode")) {
gw.put("mode", "local")
modified = true
}
}
// Fix model entries: strings → objects with id field (#83, #88)
if (json.has("models")) {
val models = json.optJSONObject("models")
val providers = models?.optJSONObject("providers")
if (providers != null) {
val keys = providers.keys()
while (keys.hasNext()) {
val key = keys.next()
val prov = providers.optJSONObject(key)
val arr = prov?.optJSONArray("models")
if (arr != null) {
for (i in 0 until arr.length()) {
val item = arr.get(i)
if (item is String) {
arr.put(i, org.json.JSONObject().put("id", item))
modified = true
}
}
}
}
}
}
if (modified) {
configFile.writeText(json.toString(2))
}
} catch (_: Exception) {}
}
Comment on lines +1264 to +1304
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Malformed config can remain unrepaired due to swallowed parse errors.

At Line 1266, if JSON parsing fails, the empty catch at Line 1303 leaves openclaw.json untouched. That can preserve corruption and still miss gateway.mode, which conflicts with the repair-on-startup goal.

🔧 Proposed fix
-            try {
-                val content = configFile.readText()
-                val json = org.json.JSONObject(content)
+            try {
+                val content = configFile.readText().trim()
+                val json = try {
+                    if (content.isEmpty()) org.json.JSONObject() else org.json.JSONObject(content)
+                } catch (_: Exception) {
+                    org.json.JSONObject()
+                }
                 var modified = false
-                if (!json.has("gateway")) {
-                    json.put("gateway", org.json.JSONObject().put("mode", "local"))
-                    modified = true
-                } else {
-                    val gw = json.getJSONObject("gateway")
-                    if (!gw.has("mode")) {
-                        gw.put("mode", "local")
-                        modified = true
-                    }
+                val gw = json.optJSONObject("gateway") ?: org.json.JSONObject().also {
+                    json.put("gateway", it)
+                    modified = true
+                }
+                if (!gw.has("mode")) {
+                    gw.put("mode", "local")
+                    modified = true
                 }
                 // Fix model entries: strings → objects with id field (`#83`, `#88`)
                 if (json.has("models")) {
@@
                 if (modified) {
                     configFile.writeText(json.toString(2))
                 }
-            } catch (_: Exception) {}
+            } catch (_: Exception) {
+                // Last-resort recovery for unreadable/unparseable configs
+                configFile.writeText(
+                    org.json.JSONObject()
+                        .put("gateway", org.json.JSONObject().put("mode", "local"))
+                        .toString(2)
+                )
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@flutter_app/android/app/src/main/kotlin/com/nxg/openclawproot/BootstrapManager.kt`
around lines 1264 - 1304, The catch block currently swallows parse errors so a
malformed openclaw.json never gets repaired; update the try/catch around
configFile.readText() and JSON parsing to catch the Exception as e, log the
error (e.g., using Android Log.e or your existing logger) and then attempt a
repair: back up the original configFile contents (e.g., write to configFile.name
+ ".bak"), create a new JSONObject or fallback object that ensures "gateway"
with "mode":"local" (and preserve/attempt to fix "models" entries if possible),
set modified = true and write the repaired JSON via
configFile.writeText(json.toString(2)); ensure any further IO exceptions are
logged rather than swallowed. Reference symbols: configFile, readText(),
writeText(), modified, "gateway", "models".

}

/**
Expand Down
4 changes: 2 additions & 2 deletions flutter_app/lib/constants.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
class AppConstants {
static const String appName = 'OpenClaw';
static const String version = '1.8.5';
static const String version = '1.8.6';
static const String packageName = 'com.nxg.openclawproot';

/// Matches ANSI escape sequences (e.g. color codes in terminal output).
Expand Down Expand Up @@ -33,7 +33,7 @@ class AppConstants {

// Node.js binary tarball — downloaded directly by Flutter, extracted by Java.
// Bypasses curl/gpg/NodeSource which fail inside proot.
static const String nodeVersion = '22.13.1';
static const String nodeVersion = '22.14.0';
static const String nodeBaseUrl =
'https://nodejs.org/dist/v$nodeVersion/node-v$nodeVersion-linux-';

Expand Down
1 change: 0 additions & 1 deletion flutter_app/lib/models/ai_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ class AiProvider {
'claude-sonnet-4-20250514',
'claude-opus-4-20250514',
'claude-haiku-4-20250506',
'claude-3-5-sonnet-20241022',
],
apiKeyHint: 'sk-ant-...',
);
Expand Down
45 changes: 40 additions & 5 deletions flutter_app/lib/screens/splash_screen.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../constants.dart';
Expand Down Expand Up @@ -114,8 +115,8 @@ class _SplashScreenState extends State<SplashScreen>
setupComplete = false;
}

// Auto-repair: if only bionic-bypass is missing, regenerate it
// instead of forcing full re-setup (#70, #73).
// Auto-repair: if the rootfs and bash exist but other components are
// missing, try to repair them instead of forcing full re-setup (#70, #73, #97).
if (!setupComplete) {
try {
final status = await NativeBridge.getBootstrapStatus();
Expand All @@ -125,9 +126,43 @@ class _SplashScreenState extends State<SplashScreen>
final openclawOk = status['openclawInstalled'] == true;
final bypassOk = status['bypassInstalled'] == true;

if (rootfsOk && bashOk && nodeOk && openclawOk && !bypassOk) {
setState(() => _status = 'Repairing bionic bypass...');
await NativeBridge.installBionicBypass();
// Core rootfs must exist — can't repair without it
if (rootfsOk && bashOk) {
// Regenerate bionic bypass if missing
if (!bypassOk) {
setState(() => _status = 'Repairing bionic bypass...');
await NativeBridge.installBionicBypass();
}
Comment on lines +131 to +135
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent error handling: bionic bypass reinstall lacks isolated try/catch.

Node.js and OpenClaw reinstalls each have their own try/catch blocks, allowing subsequent repairs to proceed if one fails. However, installBionicBypass() is not wrapped, so if it throws, the outer catch (line 168) triggers and skips Node.js/OpenClaw repairs entirely.

Proposed fix
            // Regenerate bionic bypass if missing
            if (!bypassOk) {
              setState(() => _status = 'Repairing bionic bypass...');
+             try {
                await NativeBridge.installBionicBypass();
+             } catch (_) {}
            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Regenerate bionic bypass if missing
if (!bypassOk) {
setState(() => _status = 'Repairing bionic bypass...');
await NativeBridge.installBionicBypass();
}
// Regenerate bionic bypass if missing
if (!bypassOk) {
setState(() => _status = 'Repairing bionic bypass...');
try {
await NativeBridge.installBionicBypass();
} catch (_) {}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flutter_app/lib/screens/splash_screen.dart` around lines 131 - 135, The call
to NativeBridge.installBionicBypass() is not isolated in its own try/catch, so
if it throws the outer catch prevents subsequent repairs (Node/OpenClaw) from
running; wrap the installBionicBypass() invocation in a dedicated try/catch,
setState before/after as currently done using _status = 'Repairing bionic
bypass...', and on error log or handle the error locally so the method returns
and allows the following reinstall blocks (the existing Node/OpenClaw try/catch
sections) to execute; reference NativeBridge.installBionicBypass and the _status
state update when making the change.


// Reinstall node if binary is missing (#97)
if (!nodeOk) {
setState(() => _status = 'Reinstalling Node.js...');
try {
final arch = await NativeBridge.getArch();
final nodeTarUrl = AppConstants.getNodeTarballUrl(arch);
final filesDir = await NativeBridge.getFilesDir();
final nodeTarPath = '$filesDir/tmp/nodejs.tar.xz';
final dio = Dio();
await dio.download(nodeTarUrl, nodeTarPath);
await NativeBridge.extractNodeTarball(nodeTarPath);
} catch (_) {}
Comment on lines +144 to +148
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing parent directory creation before download.

The $filesDir/tmp/ directory may not exist, causing dio.download() to fail with a FileSystemException. The error is silently swallowed, leaving the user stuck with no indication of why the repair failed.

Additionally, Dio() is instantiated without a timeout, so the download could hang indefinitely on slow or stalled connections.

Proposed fix
              try {
                final arch = await NativeBridge.getArch();
                final nodeTarUrl = AppConstants.getNodeTarballUrl(arch);
                final filesDir = await NativeBridge.getFilesDir();
                final nodeTarPath = '$filesDir/tmp/nodejs.tar.xz';
-               final dio = Dio();
+               await Directory('$filesDir/tmp').create(recursive: true);
+               final dio = Dio(BaseOptions(
+                 connectTimeout: const Duration(seconds: 30),
+                 receiveTimeout: const Duration(minutes: 10),
+               ));
                await dio.download(nodeTarUrl, nodeTarPath);
                await NativeBridge.extractNodeTarball(nodeTarPath);
              } catch (_) {}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
final nodeTarPath = '$filesDir/tmp/nodejs.tar.xz';
final dio = Dio();
await dio.download(nodeTarUrl, nodeTarPath);
await NativeBridge.extractNodeTarball(nodeTarPath);
} catch (_) {}
final nodeTarPath = '$filesDir/tmp/nodejs.tar.xz';
await Directory('$filesDir/tmp').create(recursive: true);
final dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(minutes: 10),
));
await dio.download(nodeTarUrl, nodeTarPath);
await NativeBridge.extractNodeTarball(nodeTarPath);
} catch (_) {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flutter_app/lib/screens/splash_screen.dart` around lines 144 - 148, Create
the missing parent directory before attempting the download and make the
download robust: ensure the directory for nodeTarPath is created (use
Directory('<filesDir>/tmp').create(recursive: true) or similar) before calling
dio.download, instantiate Dio with timeouts (use BaseOptions with
connect/receive/send timeouts) instead of plain Dio(), and stop silently
swallowing errors in the try/catch around
dio.download/NativeBridge.extractNodeTarball by catching the exception (catch
(e, st)) and logging or rethrowing so failures are visible; reference the
nodeTarPath variable, the Dio() instantiation, dio.download call,
NativeBridge.extractNodeTarball invocation, and the empty catch (_) {} to locate
the changes.

}

// Reinstall openclaw if package.json is missing (#97)
if (!openclawOk && nodeOk) {
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: nodeOk is captured before the Node reinstall, so OpenClaw reinstall is skipped when Node was missing. Refresh nodeOk (or the bootstrap status) after the Node repair before checking !openclawOk && nodeOk.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At flutter_app/lib/screens/splash_screen.dart, line 152:

<comment>`nodeOk` is captured before the Node reinstall, so OpenClaw reinstall is skipped when Node was missing. Refresh `nodeOk` (or the bootstrap status) after the Node repair before checking `!openclawOk && nodeOk`.</comment>

<file context>
@@ -125,9 +126,43 @@ class _SplashScreenState extends State<SplashScreen>
+            }
+
+            // Reinstall openclaw if package.json is missing (#97)
+            if (!openclawOk && nodeOk) {
+              setState(() => _status = 'Reinstalling OpenClaw...');
+              try {
</file context>
Fix with Cubic

setState(() => _status = 'Reinstalling OpenClaw...');
try {
const wrapper = '/root/.openclaw/node-wrapper.js';
const nodeRun = 'node $wrapper';
const npmCli = '/usr/local/lib/node_modules/npm/bin/npm-cli.js';
await NativeBridge.runInProot(
'$nodeRun $npmCli install -g openclaw',
timeout: 1800,
);
await NativeBridge.createBinWrappers('openclaw');
} catch (_) {}
}
Comment on lines +151 to +164
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Logic bug: OpenClaw reinstall is skipped when both Node.js and OpenClaw are missing.

The condition !openclawOk && nodeOk uses the original nodeOk value from the initial status check (line 125). If Node.js was missing and just reinstalled in lines 138-149, nodeOk remains false, so this block is always skipped in that scenario.

Trace:

  1. Initial: nodeOk=false, openclawOk=false
  2. Node.js reinstalled successfully
  3. Condition: !openclawOk && nodeOktrue && falsefalse
  4. OpenClaw reinstall skipped
Proposed fix: Re-fetch status after Node.js reinstall
            // Reinstall node if binary is missing (`#97`)
            if (!nodeOk) {
              setState(() => _status = 'Reinstalling Node.js...');
              try {
                final arch = await NativeBridge.getArch();
                final nodeTarUrl = AppConstants.getNodeTarballUrl(arch);
                final filesDir = await NativeBridge.getFilesDir();
                final nodeTarPath = '$filesDir/tmp/nodejs.tar.xz';
-               final dio = Dio();
+               await Directory('$filesDir/tmp').create(recursive: true);
+               final dio = Dio(BaseOptions(
+                 connectTimeout: const Duration(seconds: 30),
+                 receiveTimeout: const Duration(minutes: 10),
+               ));
                await dio.download(nodeTarUrl, nodeTarPath);
                await NativeBridge.extractNodeTarball(nodeTarPath);
+               // Update nodeOk after successful reinstall
+               final updatedStatus = await NativeBridge.getBootstrapStatus();
+               nodeOk = updatedStatus['nodeInstalled'] == true;
              } catch (_) {}
            }

            // Reinstall openclaw if package.json is missing (`#97`)
            if (!openclawOk && nodeOk) {

Alternatively, a simpler fix if re-fetching status is expensive:

Alternative: Track reinstall success with a local flag
            // Reinstall node if binary is missing (`#97`)
+           var nodeAvailable = nodeOk;
            if (!nodeOk) {
              setState(() => _status = 'Reinstalling Node.js...');
              try {
                // ... download and extract ...
                await NativeBridge.extractNodeTarball(nodeTarPath);
+               nodeAvailable = true;
              } catch (_) {}
            }

            // Reinstall openclaw if package.json is missing (`#97`)
-           if (!openclawOk && nodeOk) {
+           if (!openclawOk && nodeAvailable) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flutter_app/lib/screens/splash_screen.dart` around lines 151 - 164, The
reinstall block for OpenClaw incorrectly uses the original nodeOk value so
OpenClaw is skipped when Node.js was just reinstalled; fix by updating the
condition after attempting Node.js installation—either re-fetch the Node.js
status using the same check that sets nodeOk (so nodeOk reflects the current
system) or set a local flag (e.g., nodeReinstalled = true) when
NativeBridge.runInProot succeeds and change the OpenClaw condition to if
(!openclawOk && (nodeOk || nodeReinstalled)); keep the existing calls to
NativeBridge.runInProot and NativeBridge.createBinWrappers for the OpenClaw
reinstall.


setupComplete = await NativeBridge.isBootstrapComplete();
}
} catch (_) {}
Expand Down
92 changes: 92 additions & 0 deletions flutter_app/lib/services/gateway_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ class GatewayService {
}
} catch (_) {}

// Repair corrupted config before gateway start (#88).
// This fixes the "Invalid input: expected object, received string" crash loop.
await _repairConfigFile();

final alreadyRunning = await NativeBridge.isGatewayRunning();
if (alreadyRunning) {
// Write allowCommands config so the next gateway restart picks it up,
Expand Down Expand Up @@ -137,9 +141,18 @@ const p = "/root/.openclaw/openclaw.json";
let c = {};
try { c = JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
if (!c.gateway) c.gateway = {};
if (!c.gateway.mode) c.gateway.mode = "local";
if (!c.gateway.nodes) c.gateway.nodes = {};
c.gateway.nodes.denyCommands = [];
c.gateway.nodes.allowCommands = $allowJson;
// Fix config corruption: models entries must be objects, not strings (#83, #88)
if (c.models && c.models.providers) {
for (const [pid, prov] of Object.entries(c.models.providers)) {
if (prov && Array.isArray(prov.models)) {
prov.models = prov.models.map(m => typeof m === "string" ? { id: m } : m);
}
}
}
fs.writeFileSync(p, JSON.stringify(c, null, 2));
''';
var prootOk = false;
Expand Down Expand Up @@ -167,10 +180,14 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2));
}
config.putIfAbsent('gateway', () => <String, dynamic>{});
final gw = config['gateway'] as Map<String, dynamic>;
// Ensure gateway.mode=local so the gateway starts without --allow-unconfigured (#93, #90)
gw.putIfAbsent('mode', () => 'local');
Comment on lines +183 to +184
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Treat null/empty gateway.mode as corrupted too.

Both Dart repair paths only patch a missing key. {"gateway":{"mode":null}} or {"gateway":{"mode":""}} survives init/start on the direct-write path even though the gateway still needs "local", so imported or manually edited configs can still fail.

Suggested fix
-        gw.putIfAbsent('mode', () => 'local');
+        final mode = gw['mode'];
+        if (mode is! String || mode.isEmpty) {
+          gw['mode'] = 'local';
+        }
...
-      if (!gw.containsKey('mode')) {
-        gw['mode'] = 'local';
+      final mode = gw['mode'];
+      if (mode is! String || mode.isEmpty) {
+        gw['mode'] = 'local';
         modified = true;
       }

Also applies to: 219-223

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flutter_app/lib/services/gateway_service.dart` around lines 183 - 184, The
code only uses gw.putIfAbsent('mode', () => 'local') which leaves gateway.mode
as null or empty string; update the repair/init logic to treat null or empty
values as corrupted by checking if gw['mode'] == null || (gw['mode'] is String
&& (gw['mode'] as String).isEmpty) and then set gw['mode'] = 'local'; apply the
same fix to the other repair block that currently uses putIfAbsent (the second
gateway-mode patch) so both direct-write and import paths enforce "local" when
mode is missing, null, or empty.

gw.putIfAbsent('nodes', () => <String, dynamic>{});
final nodes = gw['nodes'] as Map<String, dynamic>;
nodes['denyCommands'] = <String>[];
nodes['allowCommands'] = allowCommands;
// Fix config corruption: models entries must be objects, not strings (#83, #88)
_repairModelEntries(config);
configFile.parent.createSync(recursive: true);
configFile.writeAsStringSync(
const JsonEncoder.withIndent(' ').convert(config),
Expand All @@ -179,6 +196,81 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2));
}
}

/// Repair openclaw.json on disk — fixes corrupted model entries and ensures
/// gateway.mode=local is set. Called on init() before any gateway start (#88).
Future<void> _repairConfigFile() async {
try {
final filesDir = await NativeBridge.getFilesDir();
final configFile = File('$filesDir/rootfs/ubuntu/root/.openclaw/openclaw.json');
if (!configFile.existsSync()) return;
final content = configFile.readAsStringSync();
if (content.isEmpty) return;

Map<String, dynamic> config;
try {
config = Map<String, dynamic>.from(jsonDecode(content) as Map);
} catch (_) {
return; // Unparseable — _writeNodeAllowConfig will recreate it
}
Comment on lines +206 to +214
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make the init-time repair recover from invalid JSON atomically.

_repairConfigFile() currently returns on empty/unparseable content, but this same method also rewrites openclaw.json in place. If the app is killed during that new startup write, the next init will keep bailing out on the truncated file and the config never self-heals unless some later write path happens to run.

Suggested fix
-      final content = configFile.readAsStringSync();
-      if (content.isEmpty) return;
-
-      Map<String, dynamic> config;
-      try {
-        config = Map<String, dynamic>.from(jsonDecode(content) as Map);
-      } catch (_) {
-        return; // Unparseable — _writeNodeAllowConfig will recreate it
-      }
-
-      bool modified = false;
+      final content = configFile.readAsStringSync();
+      bool modified = false;
+
+      Map<String, dynamic> config;
+      if (content.isEmpty) {
+        config = <String, dynamic>{};
+        modified = true;
+      } else {
+        try {
+          config = Map<String, dynamic>.from(jsonDecode(content) as Map);
+        } catch (_) {
+          config = <String, dynamic>{};
+          modified = true;
+        }
+      }
...
-      if (modified) {
-        configFile.writeAsStringSync(
-          const JsonEncoder.withIndent('  ').convert(config),
-        );
-      }
+      if (modified) {
+        final tmpFile = File('${configFile.path}.tmp');
+        tmpFile.writeAsStringSync(
+          const JsonEncoder.withIndent('  ').convert(config),
+        );
+        tmpFile.renameSync(configFile.path);
+      }

Also applies to: 247-250

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@flutter_app/lib/services/gateway_service.dart` around lines 206 - 214,
_repairConfigFile() returns early on empty or unparseable JSON but then relies
on an in-place rewrite that can be interrupted; change the repair logic in
_repairConfigFile() (and the similar block at lines ~247-250) to perform the
recovery atomically by writing the rebuilt JSON to a temporary file (e.g., same
directory, unique suffix) and then renaming/moving it over openclaw.json in a
single atomic replace operation; ensure the code proceeds to create the
temp-and-rename even when jsonDecode fails (do not return early), and reuse the
existing helper _writeNodeAllowConfig() or a new helper to perform the safe
temp-write + rename to avoid leaving a truncated file if the process is killed
mid-write.


bool modified = false;

// Ensure gateway.mode=local (#93, #90)
config.putIfAbsent('gateway', () => <String, dynamic>{});
final gw = config['gateway'] as Map<String, dynamic>;
if (!gw.containsKey('mode')) {
gw['mode'] = 'local';
modified = true;
}

// Fix model entries: strings → objects (#83, #88)
final models = config['models'] as Map<String, dynamic>?;
if (models != null) {
final providers = models['providers'] as Map<String, dynamic>?;
if (providers != null) {
for (final entry in providers.values) {
if (entry is Map<String, dynamic>) {
final modelsList = entry['models'];
if (modelsList is List) {
for (int i = 0; i < modelsList.length; i++) {
if (modelsList[i] is String) {
modelsList[i] = {'id': modelsList[i]};
modified = true;
}
}
}
}
}
}
}

if (modified) {
configFile.writeAsStringSync(
const JsonEncoder.withIndent(' ').convert(config),
);
}
} catch (_) {}
}

/// Fix corrupted model entries: convert bare strings to {id: string} objects (#83, #88).
static void _repairModelEntries(Map<String, dynamic> config) {
final models = config['models'] as Map<String, dynamic>?;
if (models == null) return;
final providers = models['providers'] as Map<String, dynamic>?;
if (providers == null) return;
for (final entry in providers.values) {
if (entry is Map<String, dynamic>) {
final modelsList = entry['models'];
if (modelsList is List) {
entry['models'] = modelsList.map((m) {
if (m is String) return {'id': m};
return m;
}).toList();
}
}
}
}

/// Read the actual gateway auth token from openclaw.json config file (#74, #82).
/// This is the source of truth — more reliable than regex-scraping stdout.
Future<String?> _readTokenFromConfig() async {
Expand Down
28 changes: 19 additions & 9 deletions flutter_app/lib/services/provider_config_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,26 +61,32 @@ class ProviderConfigService {
required String apiKey,
required String model,
}) async {
final providerJson = jsonEncode({
'apiKey': apiKey,
'baseUrl': provider.baseUrl,
'models': [model],
});
final modelJson = jsonEncode(model);
final providerIdJson = jsonEncode(provider.id);
final apiKeyJson = jsonEncode(apiKey);
final baseUrlJson = jsonEncode(provider.baseUrl);
final modelJson = jsonEncode(model);

// Build the provider object with the model as an object containing `id`,
// not a bare string. OpenClaw expects: models: [{ id: "model-name" }].
// Writing a bare string causes config validation failure (#83, #88).
final script = '''
const fs = require("fs");
const p = "$_configPath";
let c = {};
try { c = JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
if (!c.models) c.models = {};
if (!c.models.providers) c.models.providers = {};
c.models.providers[$providerIdJson] = $providerJson;
c.models.providers[$providerIdJson] = {
apiKey: $apiKeyJson,
baseUrl: $baseUrlJson,
models: [{ id: $modelJson }]
};
if (!c.agents) c.agents = {};
if (!c.agents.defaults) c.agents.defaults = {};
if (!c.agents.defaults.model) c.agents.defaults.model = {};
c.agents.defaults.model.primary = $modelJson;
if (!c.gateway) c.gateway = {};
if (!c.gateway.mode) c.gateway.mode = "local";
fs.mkdirSync(require("path").dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(c, null, 2));
''';
Expand Down Expand Up @@ -117,13 +123,13 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2));
// Start fresh
}

// Merge provider entry
// Merge provider entry — models must be objects with `id`, not bare strings (#83, #88).
config['models'] ??= <String, dynamic>{};
(config['models'] as Map<String, dynamic>)['providers'] ??= <String, dynamic>{};
((config['models'] as Map<String, dynamic>)['providers'] as Map<String, dynamic>)[providerId] = {
'apiKey': apiKey,
'baseUrl': baseUrl,
'models': [model],
'models': [{'id': model}],
};

// Set active model
Expand All @@ -132,6 +138,10 @@ fs.writeFileSync(p, JSON.stringify(c, null, 2));
((config['agents'] as Map<String, dynamic>)['defaults'] as Map<String, dynamic>)['model'] ??= <String, dynamic>{};
(((config['agents'] as Map<String, dynamic>)['defaults'] as Map<String, dynamic>)['model'] as Map<String, dynamic>)['primary'] = model;

// Ensure gateway.mode is set (#93, #90)
config['gateway'] ??= <String, dynamic>{};
(config['gateway'] as Map<String, dynamic>)['mode'] ??= 'local';

const encoder = JsonEncoder.withIndent(' ');
await NativeBridge.writeRootfsFile(_configPath, encoder.convert(config));
}
Expand Down
2 changes: 1 addition & 1 deletion flutter_app/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: openclaw
description: OpenClaw AI Gateway for Android - standalone, no Termux required.
publish_to: 'none'
version: 1.8.5+16
version: 1.8.6+17

environment:
sdk: '>=3.2.0 <4.0.0'
Expand Down
Loading
Loading